diff --git a/plugins/platforms/photon/README.md b/plugins/platforms/photon/README.md index e92f46329d2..3742296a6e8 100644 --- a/plugins/platforms/photon/README.md +++ b/plugins/platforms/photon/README.md @@ -152,7 +152,7 @@ All env vars are documented in `plugin.yaml`. The most important: as a synthetic `reaction:added:` event. Removal after a sidecar restart is best-effort — the live reaction handle is lost, so a stale tapback heals when the next reaction replaces it. Group spaces stay - reachable across restarts via spectrum-ts v3's `space.get(id)`. + reachable across restarts via spectrum-ts' `space.get(id)`. - **Message effects, polls** — supported by `spectrum-ts` but not yet exposed; the sidecar is the natural place to add them. @@ -160,19 +160,29 @@ All env vars are documented in `plugin.yaml`. The most important: `spectrum-ts` is pinned to an **exact version** in `sidecar/package.json` (no `^` range) and installed with `npm ci`, because the SDK ships breaking -majors (v2 removed `defineFusorPlatform`; v3 reworked space construction). -A floating range or `npm install spectrum-ts@latest` would let a breaking -release take down fresh setups silently. Upgrades are deliberate: +majors (v2 removed `defineFusorPlatform`; v3 reworked space construction; v5 +split it into `@spectrum-ts/*` packages, with `spectrum-ts` as the umbrella +that re-exports them). A floating range or `npm install spectrum-ts@latest` +would let a breaking release take down fresh setups silently. Upgrades are +deliberate: 1. Read the [SDK release notes](https://github.com/photon-hq/spectrum-ts/releases) for every version between the current pin and the target. 2. Bump the exact pin in `sidecar/package.json`, then run `npm install` inside `sidecar/` to regenerate `package-lock.json`. Commit both. -3. Migrate `sidecar/index.mjs` against the new typings - (`sidecar/node_modules/spectrum-ts/dist/*.d.ts` is the source of truth — - the hosted docs can lag). -4. Run `pytest tests/plugins/platforms/photon/`. -5. Verify end-to-end: `hermes photon status`, a DM and a group roundtrip, +3. Migrate `sidecar/index.mjs` against the new typings. `spectrum-ts` re-exports + `@spectrum-ts/core` (the framework: `Spectrum`, content builders, + `Space`/`Message`) and `@spectrum-ts/imessage` (the provider), so the source + of truth is `sidecar/node_modules/@spectrum-ts/{core,imessage}/dist/*.d.ts` + (the hosted docs can lag). +4. Re-validate `sidecar/patch-spectrum-mixed-attachments.mjs`. It rewrites the + compiled iMessage inbound mappers in `@spectrum-ts/imessage/dist/index.js` + so a bubble with both text and attachments keeps its typed text; the anchors + are tied to that build's output. `npm install` runs it via `postinstall` and + fails loudly if the anchors no longer match — update them to the new output + (`test_spectrum_patch.py` covers the patch). +5. Run `pytest tests/plugins/platforms/photon/`. +6. Verify end-to-end: `hermes photon status`, a DM and a group roundtrip, and an agent reply into a group right after a gateway restart (exercises `space.get` rehydration). diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py index e203d4d1448..89e1c6bc8bc 100644 --- a/plugins/platforms/photon/cli.py +++ b/plugins/platforms/photon/cli.py @@ -376,11 +376,12 @@ def _install_sidecar() -> int: return 1 # spectrum-ts is pinned exactly in package.json/package-lock.json because # the SDK ships breaking majors (v2 removed defineFusorPlatform; v3 - # reworked space construction). Upgrades are deliberate: bump the pin, - # migrate sidecar/index.mjs, re-run the photon tests — never `@latest` - # (see README "Upgrading spectrum-ts"). `npm ci` installs the committed - # lockfile verbatim; fall back to `npm install` when the lockfile is - # missing or drifted (e.g. a dev checkout mid-upgrade). + # reworked space construction; v5 split it into @spectrum-ts/* packages). + # Upgrades are deliberate: bump the pin, migrate sidecar/index.mjs, re-run + # the photon tests — never `@latest` (see README "Upgrading spectrum-ts"). + # `npm ci` installs the committed lockfile verbatim; fall back to + # `npm install` when the lockfile is missing or drifted (e.g. a dev + # checkout mid-upgrade). print(f" $ cd {_SIDECAR_DIR} && {npm} ci") proc = subprocess.run( # noqa: S603 [npm, "ci"], diff --git a/plugins/platforms/photon/sidecar/index.mjs b/plugins/platforms/photon/sidecar/index.mjs index 9dec22ddfa4..c382a2a6a66 100644 --- a/plugins/platforms/photon/sidecar/index.mjs +++ b/plugins/platforms/photon/sidecar/index.mjs @@ -38,7 +38,7 @@ // On SIGINT/SIGTERM the sidecar calls `app.stop()` (3s graceful) before // exiting. Logs go to stderr; Python supervises restart. // -// Requires spectrum-ts 3.x — pinned exactly in package.json because the SDK +// Requires spectrum-ts 7.x — pinned exactly in package.json because the SDK // ships breaking majors; see README "Upgrading spectrum-ts". // // Env vars (required): diff --git a/plugins/platforms/photon/sidecar/package-lock.json b/plugins/platforms/photon/sidecar/package-lock.json index 15c55d55d31..31452c5f840 100644 --- a/plugins/platforms/photon/sidecar/package-lock.json +++ b/plugins/platforms/photon/sidecar/package-lock.json @@ -1,24 +1,24 @@ { "name": "@hermes-agent/photon-sidecar", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hermes-agent/photon-sidecar", - "version": "0.3.0", + "version": "0.4.0", "hasInstallScript": true, "dependencies": { - "spectrum-ts": "3.1.0" + "spectrum-ts": "7.0.0" }, "engines": { "node": ">=18.17" } }, "node_modules/@bufbuild/protobuf": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", - "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.1.tgz", + "integrity": "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==", "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@grpc/grpc-js": { @@ -62,15 +62,6 @@ "url": "https://opencollective.com/js-sdsl" } }, - "node_modules/@msgpack/msgpack": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", - "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", - "license": "ISC", - "engines": { - "node": ">= 18" - } - }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -81,9 +72,9 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.216.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.216.0.tgz", - "integrity": "sha512-KmGTgvxTJ0J01d4mOeX1wMV5NUTNf9HebIuOOGDfIn0a/IrnXIQbOnlylDyl9tkDv4h0DUpdI/GqCdLzfTkUXg==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz", + "integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -93,9 +84,9 @@ } }, "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz", - "integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.8.0.tgz", + "integrity": "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==", "license": "Apache-2.0", "engines": { "node": "^18.19.0 || >=20.6.0" @@ -123,6 +114,7 @@ "version": "0.218.0", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.218.0.tgz", "integrity": "sha512-Qx+4rpVHzgg89dawcWRHyt+XRXeLnhFz/qBtvggmjkcgPUdr+NAB0/u/eIPA8yAeJV0J80Vz43JZCh/XFvZFGw==", + "license": "Apache-2.0", "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", @@ -137,38 +129,11 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/api-logs": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz", - "integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/sdk-logs": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", - "integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==", - "dependencies": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, "node_modules/@opentelemetry/exporter-trace-otlp-http": { "version": "0.218.0", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.218.0.tgz", "integrity": "sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw==", + "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", @@ -183,69 +148,7 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.218.0.tgz", - "integrity": "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-transformer": "0.218.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz", - "integrity": "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==", - "dependencies": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-logs": "0.218.0", - "@opentelemetry/sdk-metrics": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/api-logs": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz", - "integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-logs": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", - "integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==", - "dependencies": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources": { + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/resources": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", @@ -261,40 +164,7 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.216.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.216.0.tgz", - "integrity": "sha512-KB3rcwQuitq0JbbsCcNdqMhRJX3kArAYz/ovb0jGRaBQAIrt2roik3xQXuhYxS37zx0jSkUZcJu1z3Y2UCxbDA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.216.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", - "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", @@ -311,6 +181,204 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.218.0.tgz", + "integrity": "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-transformer": "0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz", + "integrity": "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-logs": "0.218.0", + "@opentelemetry/sdk-metrics": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", + "integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", + "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.41.1", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", @@ -339,9 +407,9 @@ } }, "node_modules/@photon-ai/advanced-imessage": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/@photon-ai/advanced-imessage/-/advanced-imessage-0.11.2.tgz", - "integrity": "sha512-3mjzy1IIBtsCQK6kAB8dbFCK0np7hS256wwW+nqNL8vKz0W5nRhu1iKAwyZxP8Z470dtNX5RjNgcl9I4wZeuTA==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@photon-ai/advanced-imessage/-/advanced-imessage-0.12.0.tgz", + "integrity": "sha512-cqSq/ew48P3S+4xXpQmS/mDgpa+ijlKYKkQ4MExsQEjHfrJ0DpPJGuY5VHgzfTqV9wYVGyA3TNkDfse/ZRDxoA==", "license": "MIT", "dependencies": { "@bufbuild/protobuf": "^2.11.0", @@ -369,18 +437,18 @@ } }, "node_modules/@photon-ai/otel": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@photon-ai/otel/-/otel-0.1.1.tgz", - "integrity": "sha512-t/NVepO5+fHOLWDI+Eht+RC8PTik0wi7HQsKhU4yPqBjY5JncXmoBZLnWFvG+/qJ/pn6w+tveabKG9pykfkqKg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@photon-ai/otel/-/otel-1.0.0.tgz", + "integrity": "sha512-5fACVHN7BtGS+phglVAxun5QLckNUSS4p750oCxR//m9JNVzFQMoLFWfC6DnWBP/W0/+B2yP75+3NMVLodEJEg==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@opentelemetry/api-logs": "^0.216.0", + "@opentelemetry/api-logs": "^0.218.0", "@opentelemetry/context-async-hooks": "^2.7.1", - "@opentelemetry/exporter-logs-otlp-http": "^0.216.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.216.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.218.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", "@opentelemetry/resources": "^2.7.1", - "@opentelemetry/sdk-logs": "^0.216.0", + "@opentelemetry/sdk-logs": "^0.218.0", "@opentelemetry/sdk-trace-base": "^2.7.1" }, "engines": { @@ -442,11 +510,111 @@ } }, "node_modules/@repeaterjs/repeater": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz", - "integrity": "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.1.0.tgz", + "integrity": "sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==", "license": "MIT" }, + "node_modules/@spectrum-ts/core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@spectrum-ts/core/-/core-7.0.0.tgz", + "integrity": "sha512-zZmXHeVmoM8RBgGSlVW46njM/vNM2M35NQOTHu8vzufWp25eOT3ERIry1AX2vbri7X34Le3V9W1lwuQYZ+hufw==", + "license": "MIT", + "dependencies": { + "@photon-ai/otel": "^1.0.0", + "@photon-ai/proto": "^0.2.4", + "@repeaterjs/repeater": "^3.0.6", + "marked": "^18.0.5", + "mime-types": "^3.0.1", + "open-graph-scraper": "^6.11.0", + "vcf": "^2.1.2", + "zod": "^4.2.1" + }, + "peerDependencies": { + "ffmpeg-static": "^5", + "typescript": "^5 || ^6.0.0" + }, + "peerDependenciesMeta": { + "ffmpeg-static": { + "optional": true + } + } + }, + "node_modules/@spectrum-ts/imessage": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@spectrum-ts/imessage/-/imessage-7.0.0.tgz", + "integrity": "sha512-j3btkqaxvq21QZRQUCHcamM/8md4bTxa1TWuACToxju3VUl0dafyLDLk6g1Y27DFn3ivo+8ZyLRNi4MiI7HwiA==", + "license": "MIT", + "dependencies": { + "@photon-ai/advanced-imessage": "^0.12.0", + "@photon-ai/imessage-kit": "^3.0.0", + "@photon-ai/otel": "^1.0.0", + "lru-cache": "^11.0.0", + "marked": "^18.0.5", + "zod": "^4.2.1" + }, + "peerDependencies": { + "@spectrum-ts/core": "^7.0.0", + "typescript": "^5 || ^6.0.0" + } + }, + "node_modules/@spectrum-ts/slack": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@spectrum-ts/slack/-/slack-7.0.0.tgz", + "integrity": "sha512-sDNBUE6oObEuu9qgUVyX47sJ4uB6EbNYlMH09KBjBCYOrnYvuNox2n1I7xnKu3c2zKV3hSQluP7b9+1o3jubTA==", + "license": "MIT", + "dependencies": { + "@photon-ai/slack": "^0.2.0", + "zod": "^4.2.1" + }, + "peerDependencies": { + "@spectrum-ts/core": "^7.0.0", + "typescript": "^5 || ^6.0.0" + } + }, + "node_modules/@spectrum-ts/telegram": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@spectrum-ts/telegram/-/telegram-7.0.0.tgz", + "integrity": "sha512-W+OGwH4S7aOMZ7/KHwsVZsGbs+9MqEUK83g10kJmOpVQxVR7Bp9jkQTBvNJlP++ilJhb5ETow+67ky+fMA1yfA==", + "license": "MIT", + "dependencies": { + "@photon-ai/telegram-ts": "10.0.0", + "marked": "^18.0.5", + "zod": "^4.2.1" + }, + "peerDependencies": { + "@spectrum-ts/core": "^7.0.0", + "typescript": "^5 || ^6.0.0" + } + }, + "node_modules/@spectrum-ts/terminal": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@spectrum-ts/terminal/-/terminal-7.0.0.tgz", + "integrity": "sha512-2fSqdcRogOdk+B+pDVnMj7cdRTlvcFjS6haK/77yjRXhkwx+99hLrTe3skmU/jIVUXTC3Ujy67x0A4R2D3X4Vw==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.1" + }, + "peerDependencies": { + "@spectrum-ts/core": "^7.0.0", + "typescript": "^5 || ^6.0.0" + } + }, + "node_modules/@spectrum-ts/whatsapp-business": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@spectrum-ts/whatsapp-business/-/whatsapp-business-7.0.0.tgz", + "integrity": "sha512-3EPP7bUn9RgURus3J1RCOAyfXSDJ5KehD5mMPfTQysZRyRBM5ePDU6w11p4/DiosLk+5X0hXaA9G9uz1gmNZxQ==", + "license": "MIT", + "dependencies": { + "@photon-ai/whatsapp-business": "^0.1.1", + "mime-types": "^3.0.1", + "zod": "^4.2.1" + }, + "peerDependencies": { + "@spectrum-ts/core": "^7.0.0", + "typescript": "^5 || ^6.0.0" + } + }, "node_modules/abort-controller-x": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/abort-controller-x/-/abort-controller-x-0.5.0.tgz", @@ -477,15 +645,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/async-mutex": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", - "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -507,29 +666,10 @@ "license": "MIT", "optional": true }, - "node_modules/better-grpc": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/better-grpc/-/better-grpc-0.3.2.tgz", - "integrity": "sha512-e+u6C4zHwjE5g7vOvDpFeMe7Nas7FU+xa6FktiheRTcOpEdD5nag+uoIw7L5bPXdxxg995feBAXLwIay/npEqw==", - "license": "MIT", - "dependencies": { - "@msgpack/msgpack": "^3.1.2", - "async-mutex": "^0.5.0", - "it-pushable": "^3.2.3", - "nice-grpc": "^2.1.13", - "zod": "^4.1.12" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "typescript": "^5" - } - }, "node_modules/better-sqlite3": { - "version": "12.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", - "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -604,9 +744,9 @@ } }, "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", "license": "MIT" }, "node_modules/cheerio": { @@ -1008,15 +1148,6 @@ "node": ">=8" } }, - "node_modules/it-pushable": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/it-pushable/-/it-pushable-3.2.4.tgz", - "integrity": "sha512-WSD7Ss4oCRfDZJT4ldLWr0Bom/muY90xxoJ5PQnU3uSKf0kxCOeehqZtiJX1ARqn+ymXGh1bxpDW9bDNHp2ivQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "p-defer": "^4.0.0" - } - }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -1182,18 +1313,6 @@ "node": ">=20.0.0" } }, - "node_modules/p-defer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-4.0.1.tgz", - "integrity": "sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -1275,6 +1394,7 @@ "version": "8.6.1", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.6.1.tgz", "integrity": "sha512-s4qQPr4pU0W95iYnUInh95skjIg+3aM2sakYsw60QYanU+qWRDY2zQxOAQV6zU7ROJpSNDG9B+VSmk4dqdWWSA==", + "license": "BSD-3-Clause", "dependencies": { "long": "^5.3.2" }, @@ -1361,9 +1481,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, "bin": { @@ -1421,38 +1541,17 @@ } }, "node_modules/spectrum-ts": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/spectrum-ts/-/spectrum-ts-3.1.0.tgz", - "integrity": "sha512-Dv5rsXATxGUXFnKf3VPK0VpkMPyVkf4HHUYkti0V2AKhz2m+ut3I1UPNMsvZOsiqmF+5hW8Xvrw+u/I82+XcDA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/spectrum-ts/-/spectrum-ts-7.0.0.tgz", + "integrity": "sha512-YdfU5CEZKQL7feyyQylwbzwG+f7Kk4h7OZxztok5iNdYGY1dTpUvJrTpkkLCwXOewsJR9M2n4os5hmJvUWeO6g==", "license": "MIT", "dependencies": { - "@photon-ai/advanced-imessage": "^0.11.0", - "@photon-ai/imessage-kit": "^3.0.0", - "@photon-ai/otel": "^0.1.1", - "@photon-ai/proto": "^0.2.4", - "@photon-ai/slack": "^0.2.0", - "@photon-ai/telegram-ts": "10.0.0", - "@photon-ai/whatsapp-business": "^0.1.1", - "@repeaterjs/repeater": "^3.0.6", - "better-grpc": "^0.3.2", - "lru-cache": "^11.0.0", - "marked": "^18.0.5", - "mime-types": "^3.0.1", - "nice-grpc": "^2.1.16", - "nice-grpc-common": "^2.0.2", - "open-graph-scraper": "^6.11.0", - "type-fest": "^5.4.1", - "vcf": "^2.1.2", - "zod": "^4.2.1" - }, - "peerDependencies": { - "ffmpeg-static": "^5", - "typescript": "^5 || ^6.0.0" - }, - "peerDependenciesMeta": { - "ffmpeg-static": { - "optional": true - } + "@spectrum-ts/core": "7.0.0", + "@spectrum-ts/imessage": "7.0.0", + "@spectrum-ts/slack": "7.0.0", + "@spectrum-ts/telegram": "7.0.0", + "@spectrum-ts/terminal": "7.0.0", + "@spectrum-ts/whatsapp-business": "7.0.0" } }, "node_modules/string_decoder": { @@ -1501,18 +1600,6 @@ "node": ">=0.10.0" } }, - "node_modules/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -1549,12 +1636,6 @@ "integrity": "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==", "license": "MIT" }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -1568,25 +1649,10 @@ "node": "*" } }, - "node_modules/type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "license": "Apache-2.0", "peer": true, "bin": { @@ -1598,9 +1664,9 @@ } }, "node_modules/undici": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", - "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -1691,9 +1757,9 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "license": "MIT", "dependencies": { "cliui": "^8.0.1", diff --git a/plugins/platforms/photon/sidecar/package.json b/plugins/platforms/photon/sidecar/package.json index 314276f6384..16d844346d0 100644 --- a/plugins/platforms/photon/sidecar/package.json +++ b/plugins/platforms/photon/sidecar/package.json @@ -1,7 +1,7 @@ { "name": "@hermes-agent/photon-sidecar", "private": true, - "version": "0.3.0", + "version": "0.4.0", "description": "Spectrum-ts bridge for the Hermes Agent Photon platform plugin.", "type": "module", "main": "index.mjs", @@ -13,7 +13,7 @@ "node": ">=18.17" }, "dependencies": { - "spectrum-ts": "3.1.0" + "spectrum-ts": "7.0.0" }, "overrides": { "protobufjs": "8.6.1", diff --git a/plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs b/plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs index d4ffca83eea..151043bc9e8 100644 --- a/plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs +++ b/plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs @@ -1,8 +1,19 @@ #!/usr/bin/env node // Patch spectrum-ts' iMessage inbound mapper until upstream preserves mixed -// text + attachment Apple events. The current spectrum-ts mapper returns only +// text + attachment Apple events. The mapper returns only // buildAttachmentMessage(...) whenever attachments are present, which drops -// event.message.content.text before Hermes can see it. +// `message.content.text` before Hermes can see it. We rewrite the two inbound +// mappers — `rebuildFromAppleMessage` (used by `space.getMessage`) and +// `toInboundMessages` (used by the live stream) — so a bubble carrying both +// text and attachment(s) surfaces as a group whose first child is the typed +// text. Paths with no text are rewritten to byte-identical behavior, so only +// mixed text+attachment messages change shape. +// +// Since spectrum-ts 5.x split the SDK into scoped packages, the iMessage mapper +// lives in `@spectrum-ts/imessage/dist/index.js` (it used to be a chunk under +// `spectrum-ts/dist`). The published output is tab-indented and uses +// `const ... = async` declarations; the anchors below match that exactly and +// fail loudly if a future spectrum-ts reshapes the mapper. import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -21,89 +32,99 @@ function replaceOnce(source, from, to, label) { return source.replace(from, to); } -function replaceFirst(source, from, to, label) { - if (!source.includes(from)) { - throw new Error(`expected at least one ${label} match, found 0`); +function replaceExactly(source, from, to, expected, label) { + const count = source.split(from).length - 1; + if (count !== expected) { + throw new Error( + `expected exactly ${expected} ${label} matches, found ${count}` + ); } - return source.replace(from, to); + return source.split(from).join(to); } -function addTextChildSnippet(messageExpr) { - return `if (text2) {\n items.unshift({\n ...base,\n id: formatChildId(0, messageGuidStr),\n content: asText(text2),\n partIndex: 0,\n parentId: messageGuidStr\n });\n }`; +// The text-first child of a mixed text+attachment group, indented `tabs` deep +// (the object's closing brace sits at `tabs`; its properties one level in). +function textChild(tabs) { + const t = "\t".repeat(tabs); + return ( + `{\n${t}\t...base,\n${t}\tid: formatChildId(0, messageGuidStr),` + + `\n${t}\tcontent: asText(text2),\n${t}\tpartIndex: 0,` + + `\n${t}\tparentId: messageGuidStr\n${t}}` + ); } function patchRebuild(source) { + // Capture the bubble text before the attachment branches consume it. The + // existing no-attachment branch keeps its own `const text` declaration, so a + // distinct name avoids a redeclaration. source = replaceOnce( source, - ` const attachments = messageAttachments(message);\n if (attachments.length === 1) {`, - ` const attachments = messageAttachments(message);\n const text2 = message.content.text;\n if (attachments.length === 1) {`, + `\tconst attachments = messageAttachments(message);\n\tif (attachments.length === 1) {`, + `\tconst attachments = messageAttachments(message);\n\tconst text2 = message.content.text;\n\tif (attachments.length === 1) {`, "rebuild text capture" ); + // Single attachment: when text is present, push it to slot 0 and the + // attachment to slot 1, then wrap both in a group. source = replaceOnce( source, - ` return buildAttachmentMessage(client, base, info, messageGuidStr, 0);`, - ` const msg2 = await buildAttachmentMessage(\n client,\n base,\n info,\n text2 ? formatChildId(1, messageGuidStr) : messageGuidStr,\n text2 ? 1 : 0,\n text2 ? messageGuidStr : void 0\n );\n if (text2) {\n const textMsg = {\n ...base,\n id: formatChildId(0, messageGuidStr),\n content: asText(text2),\n partIndex: 0,\n parentId: messageGuidStr\n };\n return {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup([textMsg, msg2])\n };\n }\n return msg2;`, + `\t\treturn buildAttachmentMessage(client, base, info, messageGuidStr, 0);`, + `\t\tconst msg2 = await buildAttachmentMessage(client, base, info, text2 ? formatChildId(1, messageGuidStr) : messageGuidStr, text2 ? 1 : 0, text2 ? messageGuidStr : void 0);\n\t\tif (text2) {\n\t\t\tconst textMsg = ${textChild(3)};\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tid: messageGuidStr,\n\t\t\t\tcontent: asProviderGroup([textMsg, msg2])\n\t\t\t};\n\t\t}\n\t\treturn msg2;`, "rebuild single attachment" ); - source = replaceFirst( + // Multi attachment: prepend the text child to the group's items. + source = replaceOnce( source, - ` formatChildId(i, messageGuidStr),\n i,\n messageGuidStr`, - ` formatChildId(text2 ? i + 1 : i, messageGuidStr),\n text2 ? i + 1 : i,\n messageGuidStr`, - "rebuild multi attachment child index" - ); - source = replaceFirst( - source, - ` return {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup(items)\n };\n }\n if (getBalloonBundleId(message) === URL_BALLOON_BUNDLE_ID) {`, - ` ${addTextChildSnippet("message")}\n return {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup(items)\n };\n }\n if (getBalloonBundleId(message) === URL_BALLOON_BUNDLE_ID) {`, + `\t\treturn {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`, + `\t\tif (text2) {\n\t\t\titems.unshift(${textChild(3)});\n\t\t}\n\t\treturn {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`, "rebuild multi attachment text child" ); - source = replaceFirst( - source, - ` const text2 = message.content.text;\n return {\n ...base,`, - ` return {\n ...base,`, - "rebuild duplicate text declaration" - ); return source; } function patchInbound(source) { source = replaceOnce( source, - ` const attachments = messageAttachments(event.message);\n if (attachments.length === 1) {`, - ` const attachments = messageAttachments(event.message);\n const text2 = event.message.content.text;\n if (attachments.length === 1) {`, + `\tconst attachments = messageAttachments(event.message);\n\tif (attachments.length === 1) {`, + `\tconst attachments = messageAttachments(event.message);\n\tconst text2 = event.message.content.text;\n\tif (attachments.length === 1) {`, "inbound text capture" ); source = replaceOnce( source, - ` messageGuidStr,\n 0\n );\n cacheMessage(cache, msg2);\n return [msg2];`, - ` text2 ? formatChildId(1, messageGuidStr) : messageGuidStr,\n text2 ? 1 : 0,\n text2 ? messageGuidStr : void 0\n );\n if (text2) {\n const textMsg = {\n ...base,\n id: formatChildId(0, messageGuidStr),\n content: asText(text2),\n partIndex: 0,\n parentId: messageGuidStr\n };\n const parent = {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup([textMsg, msg2])\n };\n cacheMessage(cache, parent);\n return [parent];\n }\n cacheMessage(cache, msg2);\n return [msg2];`, + `\t\tconst msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0);\n\t\tcacheMessage(cache, msg);\n\t\treturn [msg];`, + `\t\tconst msg = await buildAttachmentMessage(client, base, info, text2 ? formatChildId(1, messageGuidStr) : messageGuidStr, text2 ? 1 : 0, text2 ? messageGuidStr : void 0);\n\t\tif (text2) {\n\t\t\tconst textMsg = ${textChild(3)};\n\t\t\tconst parent = {\n\t\t\t\t...base,\n\t\t\t\tid: messageGuidStr,\n\t\t\t\tcontent: asProviderGroup([textMsg, msg])\n\t\t\t};\n\t\t\tcacheMessage(cache, parent);\n\t\t\treturn [parent];\n\t\t}\n\t\tcacheMessage(cache, msg);\n\t\treturn [msg];`, "inbound single attachment" ); source = replaceOnce( source, - ` formatChildId(i, messageGuidStr),\n i,\n messageGuidStr`, - ` formatChildId(text2 ? i + 1 : i, messageGuidStr),\n text2 ? i + 1 : i,\n messageGuidStr`, - "inbound multi attachment child index" - ); - source = replaceOnce( - source, - ` const parent = {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup(items)\n };`, - ` ${addTextChildSnippet("event.message")}\n const parent = {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup(items)\n };`, + `\t\tconst parent = {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`, + `\t\tif (text2) {\n\t\t\titems.unshift(${textChild(3)});\n\t\t}\n\t\tconst parent = {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`, "inbound multi attachment text child" ); - source = replaceOnce( - source, - ` const text2 = event.message.content.text;\n const msg = {`, - ` const msg = {`, - "inbound duplicate text declaration" - ); return source; } +// Shift attachment part indices by one when a text child occupies slot 0. The +// push line is byte-identical in both mappers, so patch both occurrences. +function patchChildIndices(source) { + return replaceExactly( + source, + `items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));`, + `items.push(await buildAttachmentMessage(client, base, info, formatChildId(text2 ? i + 1 : i, messageGuidStr), text2 ? i + 1 : i, messageGuidStr));`, + 2, + "multi attachment child index" + ); +} + export function patchSpectrumTs(root = scriptDir()) { - const dist = path.join(root, "node_modules", "spectrum-ts", "dist"); + const dist = path.join( + root, + "node_modules", + "@spectrum-ts", + "imessage", + "dist" + ); if (!fs.existsSync(dist)) { - throw new Error(`spectrum-ts dist not found: ${dist}`); + throw new Error(`@spectrum-ts/imessage dist not found: ${dist}`); } const files = fs.readdirSync(dist) .filter((name) => name.endsWith(".js")) @@ -117,18 +138,20 @@ export function patchSpectrumTs(root = scriptDir()) { // Normalize to LF for matching so the patch works regardless of the // checkout's line-ending style (Windows git autocrlf produces CRLF, // which would otherwise defeat the \n-based search strings). The - // original EOL style is restored on write. + // original EOL style is restored on write. Indentation in the published + // tarball is tabs; the anchors match that directly. const CR = String.fromCharCode(13); const CRLF = CR + "\n"; const usedCRLF = raw.includes(CRLF); const original = usedCRLF ? raw.split(CRLF).join("\n") : raw; - if (!original.includes("var toInboundMessages = async") || - !original.includes("var rebuildFromAppleMessage = async")) { + if (!original.includes("const toInboundMessages = async") || + !original.includes("const rebuildFromAppleMessage = async")) { continue; } let patched = original; patched = patchRebuild(patched); patched = patchInbound(patched); + patched = patchChildIndices(patched); patched = `// ${MARKER}\n${patched}`; if (usedCRLF) { patched = patched.split("\n").join(CRLF); @@ -136,7 +159,7 @@ export function patchSpectrumTs(root = scriptDir()) { fs.writeFileSync(file, patched, "utf8"); return { patched: true, file }; } - throw new Error("could not find spectrum-ts iMessage inbound chunk to patch"); + throw new Error("could not find @spectrum-ts/imessage iMessage inbound chunk to patch"); } const _invokedDirectly = diff --git a/tests/plugins/platforms/photon/test_spectrum_patch.py b/tests/plugins/platforms/photon/test_spectrum_patch.py index 8e875ea788c..89e681dfddd 100644 --- a/tests/plugins/platforms/photon/test_spectrum_patch.py +++ b/tests/plugins/platforms/photon/test_spectrum_patch.py @@ -52,128 +52,127 @@ def test_sidecar_labels_catchup_internal_errors_as_upstream_photon() -> None: assert "PHOTON_ALLOWED_USERS" in index -def test_spectrum_patch_preserves_text_when_single_attachment(tmp_path: Path) -> None: - """The sidecar dependency patch must turn text+one attachment into group content.""" - dist = tmp_path / "node_modules" / "spectrum-ts" / "dist" +def _tabify(src: str) -> str: + """Convert the fixture's two-space indentation to the tab indentation that + spectrum-ts ships in `@spectrum-ts/imessage/dist`, so the patch anchors + (which match tabs) apply exactly as they do against a real install.""" + out = [] + for line in src.split("\n"): + stripped = line.lstrip(" ") + indent = len(line) - len(stripped) + out.append("\t" * (indent // 2) + " " * (indent % 2) + stripped) + return "\n".join(out) + + +# A faithful, *executable* slice of spectrum-ts 7.x's iMessage inbound mapper: +# the two functions the patch rewrites (`rebuildFromAppleMessage` for +# `space.getMessage`, `toInboundMessages` for the live stream), plus stubs of +# the helpers they close over. Mirrors the published shape — tab-indented (via +# `_tabify`), `const ... = async` declarations, single-line builder calls — so +# the anchors exercise the real code path, and exporting the two functions lets +# the test assert runtime behavior rather than only string shape. +_SPECTRUM_IMESSAGE_FIXTURE = """ +const formatChildId = (partIndex, parentGuid) => `p:${partIndex}/${parentGuid}`; +const asText = (text) => ({ type: "text", text }); +const asCustom = (message) => ({ type: "custom" }); +const asProviderGroup = (items) => ({ type: "group", items }); +const messageAttachments = (message) => message.content.attachments ?? []; +const getBalloonBundleId = () => ""; +const URL_BALLOON_BUNDLE_ID = "url-balloon"; +const toRichlinkMessage = (message, base, id) => ({ ...base, id, content: { type: "richlink" } }); +const buildMessageBase = (message, chatGuidHint, timestamp, phone) => ({ direction: "inbound", sender: { id: "s" }, space: { id: "sp", type: "dm", phone }, timestamp }); +const buildAttachmentMessage = async (client, base, info, id, partIndex, parentId) => { + const msg = { ...base, id, content: { type: "attachment", id: info.guid }, partIndex }; + if (parentId !== void 0) msg.parentId = parentId; + return msg; +}; +const cacheMessage = (cache, message) => { cache.set(message.id, message); }; +const rebuildFromAppleMessage = async (client, message, phone, chatGuidHint) => { + const messageGuidStr = message.guid; + const base = buildMessageBase(message, chatGuidHint, message.dateCreated ?? /* @__PURE__ */ new Date(), phone); + const attachments = messageAttachments(message); + if (attachments.length === 1) { + const info = attachments[0]; + if (!info) throw new Error("Unreachable: attachments.length === 1 but no element"); + return buildAttachmentMessage(client, base, info, messageGuidStr, 0); + } + if (attachments.length > 1) { + const items = []; + for (let i = 0; i < attachments.length; i++) { + const info = attachments[i]; + if (!info) continue; + items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr)); + } + return { + ...base, + id: messageGuidStr, + content: asProviderGroup(items) + }; + } + if (getBalloonBundleId(message) === URL_BALLOON_BUNDLE_ID) return toRichlinkMessage(message, base, messageGuidStr); + const text = message.content.text; + return { + ...base, + id: messageGuidStr, + content: text ? asText(text) : asCustom(message) + }; +}; +const toInboundMessages = async (client, cache, event, phone) => { + const base = buildMessageBase(event.message, event.chatGuid, event.occurredAt, phone); + const messageGuidStr = event.message.guid; + if (getBalloonBundleId(event.message) === URL_BALLOON_BUNDLE_ID) { + const msg = toRichlinkMessage(event.message, base, messageGuidStr); + cacheMessage(cache, msg); + return [msg]; + } + const attachments = messageAttachments(event.message); + if (attachments.length === 1) { + const info = attachments[0]; + if (!info) throw new Error("Unreachable: attachments.length === 1 but no element"); + const msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0); + cacheMessage(cache, msg); + return [msg]; + } + if (attachments.length > 1) { + const items = []; + for (let i = 0; i < attachments.length; i++) { + const info = attachments[i]; + if (!info) continue; + items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr)); + } + const parent = { + ...base, + id: messageGuidStr, + content: asProviderGroup(items) + }; + cacheMessage(cache, parent); + return [parent]; + } + const text = event.message.content.text; + const msg = { + ...base, + id: messageGuidStr, + content: text ? asText(text) : asCustom(event.message) + }; + cacheMessage(cache, msg); + return [msg]; +}; +export { rebuildFromAppleMessage, toInboundMessages }; +""" + + +def _write_fixture(tmp_path: Path) -> Path: + dist = tmp_path / "node_modules" / "@spectrum-ts" / "imessage" / "dist" dist.mkdir(parents=True) - chunk = dist / "chunk-test.js" - chunk.write_text( - textwrap.dedent( - """ - var rebuildFromAppleMessage = async (client, message, phone, chatGuidHint) => { - const messageGuidStr = message.guid; - const timestamp = message.dateCreated ?? /* @__PURE__ */ new Date(); - const base = buildMessageBase(message, chatGuidHint, timestamp, phone); - const attachments = messageAttachments(message); - if (attachments.length === 1) { - const info = attachments[0]; - if (!info) { - throw new Error("Unreachable: attachments.length === 1 but no element"); - } - return buildAttachmentMessage(client, base, info, messageGuidStr, 0); - } - if (attachments.length > 1) { - const items = []; - for (let i = 0; i < attachments.length; i++) { - const info = attachments[i]; - if (!info) { - continue; - } - items.push( - await buildAttachmentMessage( - client, - base, - info, - formatChildId(i, messageGuidStr), - i, - messageGuidStr - ) - ); - } - return { - ...base, - id: messageGuidStr, - content: asProviderGroup(items) - }; - } - if (getBalloonBundleId(message) === URL_BALLOON_BUNDLE_ID) { - return toRichlinkMessage(message, base, messageGuidStr); - } - const text2 = message.content.text; - return { - ...base, - id: messageGuidStr, - content: text2 ? asText(text2) : asCustom(message) - }; - }; - var toInboundMessages = async (client, cache, event, phone) => { - const base = buildMessageBase( - event.message, - event.chatGuid, - event.occurredAt, - phone - ); - const messageGuidStr = event.message.guid; - if (getBalloonBundleId(event.message) === URL_BALLOON_BUNDLE_ID) { - const msg2 = toRichlinkMessage(event.message, base, messageGuidStr); - cacheMessage(cache, msg2); - return [msg2]; - } - const attachments = messageAttachments(event.message); - if (attachments.length === 1) { - const info = attachments[0]; - if (!info) { - throw new Error("Unreachable: attachments.length === 1 but no element"); - } - const msg2 = await buildAttachmentMessage( - client, - base, - info, - messageGuidStr, - 0 - ); - cacheMessage(cache, msg2); - return [msg2]; - } - if (attachments.length > 1) { - const items = []; - for (let i = 0; i < attachments.length; i++) { - const info = attachments[i]; - if (!info) { - continue; - } - items.push( - await buildAttachmentMessage( - client, - base, - info, - formatChildId(i, messageGuidStr), - i, - messageGuidStr - ) - ); - } - const parent = { - ...base, - id: messageGuidStr, - content: asProviderGroup(items) - }; - cacheMessage(cache, parent); - return [parent]; - } - const text2 = event.message.content.text; - const msg = { - ...base, - id: messageGuidStr, - content: text2 ? asText(text2) : asCustom(event.message) - }; - cacheMessage(cache, msg); - return [msg]; - }; - """ - ), - encoding="utf-8", - ) + chunk = dist / "index.js" + chunk.write_text(_tabify(_SPECTRUM_IMESSAGE_FIXTURE), encoding="utf-8") + return chunk + + +def test_spectrum_patch_rewrites_the_imessage_mapper(tmp_path: Path) -> None: + """The dependency patch must apply to the 7.x `@spectrum-ts/imessage` chunk + and rewrite both inbound mappers to thread text through attachment bubbles.""" + chunk = _write_fixture(tmp_path) result = subprocess.run( ["node", str(_PATCHER), str(tmp_path)], @@ -182,10 +181,84 @@ def test_spectrum_patch_preserves_text_when_single_attachment(tmp_path: Path) -> capture_output=True, check=False, ) - assert result.returncode == 0, result.stderr + patched = chunk.read_text(encoding="utf-8") assert "Preserve mixed text + attachment iMessage payloads" in patched - assert "content: asProviderGroup([textMsg, msg2])" in patched + # Single-attachment bubbles wrap the text + attachment in a group... + assert "content: asProviderGroup([textMsg, msg2])" in patched # rebuild + assert "content: asProviderGroup([textMsg, msg])" in patched # inbound + # ...multi-attachment bubbles keep the group and shift attachment indices. assert "content: asProviderGroup(items)" in patched assert "formatChildId(text2 ? i + 1 : i, messageGuidStr)" in patched + # The text is captured in both mappers before the attachment branches run. + assert "const text2 = message.content.text;" in patched + assert "const text2 = event.message.content.text;" in patched + + # Re-running is a no-op (idempotent self-heal on every sidecar start). + again = subprocess.run( + ["node", str(_PATCHER), str(tmp_path)], + cwd=Path.cwd(), + text=True, + capture_output=True, + check=False, + ) + assert again.returncode == 0, again.stderr + assert chunk.read_text(encoding="utf-8") == patched + + +def test_spectrum_patch_preserves_text_at_runtime(tmp_path: Path) -> None: + """Execute the patched mappers and assert mixed bubbles become groups whose + first child is the typed text, while text-free bubbles keep their exact + original shape (id/partIndex/parentId) so message identity is unchanged.""" + chunk = _write_fixture(tmp_path) + patch = subprocess.run( + ["node", str(_PATCHER), str(tmp_path)], + cwd=Path.cwd(), + text=True, + capture_output=True, + check=False, + ) + assert patch.returncode == 0, patch.stderr + + harness = textwrap.dedent( + f""" + import {{ rebuildFromAppleMessage, toInboundMessages }} from {str(chunk)!r}; + const assert = (c, m) => {{ if (!c) {{ console.error("FAIL: " + m); process.exit(1); }} }}; + + // Mixed text + single attachment -> group [text@0, attachment@1]. + let r = await rebuildFromAppleMessage(null, {{ guid: "G", content: {{ text: "hello", attachments: [{{ guid: "A0" }}] }} }}, "+1"); + assert(r.content.type === "group" && r.id === "G", "single+text -> group parent id=guid"); + assert(r.content.items.length === 2, "two items"); + assert(r.content.items[0].content.type === "text" && r.content.items[0].content.text === "hello" && r.content.items[0].partIndex === 0 && r.content.items[0].id === "p:0/G", "text child @0"); + assert(r.content.items[1].content.type === "attachment" && r.content.items[1].partIndex === 1 && r.content.items[1].id === "p:1/G" && r.content.items[1].parentId === "G", "attachment child @1"); + + // Single attachment, no text -> unchanged bare attachment. + r = await rebuildFromAppleMessage(null, {{ guid: "G", content: {{ text: "", attachments: [{{ guid: "A0" }}] }} }}, "+1"); + assert(r.content.type === "attachment" && r.id === "G" && r.partIndex === 0 && r.parentId === undefined, "no-text single attachment unchanged"); + + // Multi attachment + text via the live stream -> group [text@0, att@1, att@2]. + let arr = await toInboundMessages(null, new Map(), {{ message: {{ guid: "G2", content: {{ text: "cap", attachments: [{{ guid: "A0" }}, {{ guid: "A1" }}] }} }} }}, "+1"); + assert(arr.length === 1 && arr[0].content.type === "group", "multi+text -> single group"); + let items = arr[0].content.items; + assert(items.length === 3 && items[0].content.type === "text" && items[0].partIndex === 0, "text first @0"); + assert(items[1].partIndex === 1 && items[1].id === "p:1/G2" && items[2].partIndex === 2 && items[2].id === "p:2/G2", "attachments shifted to @1,@2"); + + // Multi attachment, no text -> unchanged (attachments at @0,@1). + arr = await toInboundMessages(null, new Map(), {{ message: {{ guid: "G3", content: {{ attachments: [{{ guid: "A0" }}, {{ guid: "A1" }}] }} }} }}, "+1"); + items = arr[0].content.items; + assert(items.length === 2 && items[0].partIndex === 0 && items[0].id === "p:0/G3" && items[1].partIndex === 1, "no-text multi unchanged"); + + // Text only, no attachments -> plain text (unchanged). + r = await rebuildFromAppleMessage(null, {{ guid: "G4", content: {{ text: "just text", attachments: [] }} }}, "+1"); + assert(r.content.type === "text" && r.content.text === "just text" && r.id === "G4", "text-only unchanged"); + """ + ) + run = subprocess.run( + ["node", "--input-type=module", "-e", harness], + cwd=Path.cwd(), + text=True, + capture_output=True, + check=False, + ) + assert run.returncode == 0, run.stderr