Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.photon-npm-error.log
|
||||
@@ -0,0 +1,50 @@
|
||||
# Photon sidecar
|
||||
|
||||
Small Node helper that bridges Hermes Agent to Photon's Spectrum SDK
|
||||
(`spectrum-ts`). Hermes is Python; Photon has no public HTTP
|
||||
send-message endpoint today; replies therefore go through this sidecar.
|
||||
|
||||
The sidecar:
|
||||
|
||||
- runs `Spectrum({ projectId, projectSecret, providers: [imessage.config()] })`
|
||||
- exposes a loopback-only HTTP control channel for the Python adapter
|
||||
to push send/typing requests (auth via `X-Hermes-Sidecar-Token`)
|
||||
- drains the inbound message stream so `spectrum-ts` keeps its
|
||||
reconnect/heartbeat machinery alive and Hermes can receive inbound messages
|
||||
over the adapter's loopback `GET /inbound` stream
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd plugins/platforms/photon/sidecar
|
||||
npm install
|
||||
```
|
||||
|
||||
The Hermes plugin's `hermes photon setup` command runs `npm install`
|
||||
here automatically.
|
||||
|
||||
## Run standalone
|
||||
|
||||
For debugging:
|
||||
|
||||
```bash
|
||||
PHOTON_PROJECT_ID=... PHOTON_PROJECT_SECRET=... \
|
||||
PHOTON_SIDECAR_PORT=8789 PHOTON_SIDECAR_TOKEN=$(openssl rand -hex 16) \
|
||||
node index.mjs
|
||||
```
|
||||
|
||||
In normal use, the Python adapter supervises this process — start,
|
||||
restart on crash, kill on shutdown — and never asks the user to run
|
||||
it by hand.
|
||||
|
||||
## Why a sidecar at all?
|
||||
|
||||
Photon's Spectrum send path is exposed through the TypeScript SDK's
|
||||
`Space.send(...)` API. Hermes is Python, so replies go through this sidecar
|
||||
until Photon ships a public HTTP send endpoint.
|
||||
|
||||
When Photon ships an HTTP send endpoint, the plan is to retire this
|
||||
sidecar entirely and call it directly from Python. The plugin's
|
||||
outbound code path is already isolated behind small helpers
|
||||
(`_sidecar_send`, `_sidecar_send_richlink`, and `_sidecar_send_attachment` in
|
||||
`adapter.py`) to make that swap localized.
|
||||
File diff suppressed because it is too large
Load Diff
+1459
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@hermes-agent/photon-sidecar",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"description": "Spectrum-ts bridge for the Hermes Agent Photon platform plugin.",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"scripts": {
|
||||
"start": "node index.mjs",
|
||||
"postinstall": "node patch-spectrum-mixed-attachments.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"spectrum-ts": "12.7.0"
|
||||
},
|
||||
"overrides": {
|
||||
"protobufjs": "8.7.1",
|
||||
"@opentelemetry/otlp-transformer": "0.218.0",
|
||||
"@opentelemetry/otlp-exporter-base": "0.218.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.218.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "0.218.0",
|
||||
"@opentelemetry/core": "2.10.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
// Patch spectrum-ts' iMessage inbound mapper until upstream preserves mixed
|
||||
// text + attachment Apple events. The mapper returns only
|
||||
// buildAttachmentMessage(...) whenever attachments are present, which drops
|
||||
// `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";
|
||||
|
||||
const MARKER = "Hermes patch: Preserve mixed text + attachment iMessage payloads";
|
||||
|
||||
function scriptDir() {
|
||||
return path.dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
function replaceOnce(source, from, to, label) {
|
||||
const count = source.split(from).length - 1;
|
||||
if (count !== 1) {
|
||||
throw new Error(`expected exactly one ${label} match, found ${count}`);
|
||||
}
|
||||
return source.replace(from, to);
|
||||
}
|
||||
|
||||
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.split(from).join(to);
|
||||
}
|
||||
|
||||
// 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,
|
||||
`\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,
|
||||
`\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"
|
||||
);
|
||||
// Multi attachment: prepend the text child to the group's items.
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`\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"
|
||||
);
|
||||
return source;
|
||||
}
|
||||
|
||||
function patchInbound(source) {
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`\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,
|
||||
`\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,
|
||||
`\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"
|
||||
);
|
||||
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",
|
||||
"imessage",
|
||||
"dist"
|
||||
);
|
||||
if (!fs.existsSync(dist)) {
|
||||
throw new Error(`@spectrum-ts/imessage dist not found: ${dist}`);
|
||||
}
|
||||
const files = fs.readdirSync(dist)
|
||||
.filter((name) => name.endsWith(".js"))
|
||||
.map((name) => path.join(dist, name));
|
||||
|
||||
for (const file of files) {
|
||||
const raw = fs.readFileSync(file, "utf8");
|
||||
if (raw.includes(MARKER)) {
|
||||
return { patched: false, file, reason: "already patched" };
|
||||
}
|
||||
// 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. 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("const toInboundMessages = async") ||
|
||||
!original.includes("const rebuildFromAppleMessage = async")) {
|
||||
continue;
|
||||
}
|
||||
// spectrum-ts 12.x replaced the attachment-only branches with
|
||||
// `buildUnwrappedContentMessage` + `toOrderedParts`, which already emits a
|
||||
// group containing both text and attachments. There is nothing left for
|
||||
// Hermes to patch; keep the legacy v8 path below for older pinned installs.
|
||||
if (
|
||||
original.includes("const buildUnwrappedContentMessage = async") &&
|
||||
original.includes("const parts = toOrderedParts(message.content.text, attachments);")
|
||||
) {
|
||||
return { patched: false, file, reason: "upstream preserves mixed payloads" };
|
||||
}
|
||||
let patched = original;
|
||||
patched = patchRebuild(patched);
|
||||
patched = patchInbound(patched);
|
||||
patched = patchChildIndices(patched);
|
||||
patched = `// ${MARKER}\n${patched}`;
|
||||
if (usedCRLF) {
|
||||
patched = patched.split("\n").join(CRLF);
|
||||
}
|
||||
fs.writeFileSync(file, patched, "utf8");
|
||||
return { patched: true, file };
|
||||
}
|
||||
throw new Error("could not find @spectrum-ts/imessage iMessage inbound chunk to patch");
|
||||
}
|
||||
|
||||
const _invokedDirectly =
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (_invokedDirectly) {
|
||||
try {
|
||||
const root = process.argv[2] ? path.resolve(process.argv[2]) : scriptDir();
|
||||
const result = patchSpectrumTs(root);
|
||||
const action = result.patched ? "patched" : "ok";
|
||||
console.error(`photon-sidecar: spectrum mixed attachment patch ${action}: ${result.file}`);
|
||||
} catch (err) {
|
||||
console.error(`photon-sidecar: spectrum mixed attachment patch failed: ${err?.stack || err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Outbound /send builder selection for the Photon sidecar.
|
||||
//
|
||||
// spectrumMarkdown() enables data detection (enableDataDetection) in the
|
||||
// underlying iMessage API, which can 500 on messages containing raw URLs.
|
||||
// Plain-text URLs are auto-linked by iMessage anyway, so markdown messages
|
||||
// that contain a URL are routed through the text builder, while URL-free
|
||||
// markdown keeps native markdown rendering.
|
||||
//
|
||||
// This lives in its own module (rather than inline in index.mjs) so tests can
|
||||
// execute the real decision logic under node instead of grepping source —
|
||||
// see tests/plugins/platforms/photon/test_url_send_path.py.
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s)'"<>]+/i;
|
||||
|
||||
/**
|
||||
* Decide which spectrum-ts builder the /send handler should use.
|
||||
*
|
||||
* @param {string} format "markdown" | "text" (already validated by /send)
|
||||
* @param {string} text the outbound message body
|
||||
* @returns {"markdown"|"text"}
|
||||
*/
|
||||
export function chooseSendFormat(format, text) {
|
||||
if (format === "markdown" && !URL_RE.test(String(text))) {
|
||||
return "markdown";
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Pure decision helpers for the zombie-stream (half-open gRPC) watchdog.
|
||||
//
|
||||
// spectrum-ts only reconnects when its inbound async iterator throws or ends.
|
||||
// A half-open ("zombie") socket makes the iterator hang forever — no error,
|
||||
// no end — so inbound silently dies while /healthz still looks fine. The
|
||||
// watchdog in index.mjs tracks the last time the inbound iterator yielded and,
|
||||
// once the stream has been silent past a conservative threshold, drives a
|
||||
// cheap authenticated unary read over the same channel. STRICT semantics:
|
||||
//
|
||||
// - probe resolves, or rejects with a not-found-shaped error for our
|
||||
// synthetic id -> ALIVE (the wire round-tripped)
|
||||
// - probe rejects any other way (UNAVAILABLE, DEADLINE_EXCEEDED, network
|
||||
// down, ...) -> INCONCLUSIVE — never treated as alive, and
|
||||
// never treated as zombie-proof either
|
||||
//
|
||||
// A zombie is only declared when the stream is silent past the threshold AND
|
||||
// a probe proves connectivity (the wire works but the stream is deaf). Silence
|
||||
// alone NEVER degrades the stream: shared lines can be legitimately quiet for
|
||||
// hours. Inconclusive probes NEVER degrade it either: the network may simply
|
||||
// be down, and in that case the iterator will eventually throw and the
|
||||
// existing re-subscribe loop recovers on its own.
|
||||
//
|
||||
// These helpers are pure (no SDK, no timers) so tests can execute them under
|
||||
// node — see tests/plugins/platforms/photon/test_zombie_stream_watchdog.py.
|
||||
|
||||
// gRPC NOT_FOUND is code 5; SDKs also surface it as "not found" / "NotFound"
|
||||
// message text. Anything not clearly not-found is inconclusive.
|
||||
const NOT_FOUND_RE = /not[\s_-]?found/i;
|
||||
|
||||
/**
|
||||
* Classify the rejection of the synthetic-id probe read.
|
||||
*
|
||||
* @param {unknown} err error thrown by `space.getMessage(<synthetic id>)`
|
||||
* @returns {{alive: boolean, inconclusive: boolean, reason: string}}
|
||||
*/
|
||||
export function classifyProbeRejection(err) {
|
||||
const code = err && typeof err === "object" ? err.code : undefined;
|
||||
const message =
|
||||
err && typeof err === "object" && err.message
|
||||
? String(err.message)
|
||||
: String(err);
|
||||
if (code === 5 || code === "notFound" || NOT_FOUND_RE.test(message)) {
|
||||
// Expected: the synthetic id doesn't exist. The unary call completed a
|
||||
// round-trip, so the channel is provably alive.
|
||||
return { alive: true, inconclusive: false, reason: "not-found round-trip" };
|
||||
}
|
||||
// Anything else (UNAVAILABLE, DEADLINE_EXCEEDED, TLS, auth, ...) does NOT
|
||||
// prove liveness — and doesn't prove a zombie either.
|
||||
return { alive: false, inconclusive: true, reason: message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Should the watchdog probe at all this tick?
|
||||
*
|
||||
* @param {number} silentForMs ms since the inbound iterator last yielded
|
||||
* @param {number} thresholdMs silence threshold (<= 0 disables the watchdog)
|
||||
* @param {number} sinceLastProbeMs ms since the previous probe attempt
|
||||
* @param {number} probeCooldownMs min spacing between probe attempts
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldProbe(silentForMs, thresholdMs, sinceLastProbeMs, probeCooldownMs) {
|
||||
if (!(thresholdMs > 0)) return false;
|
||||
if (silentForMs < thresholdMs) return false;
|
||||
return sinceLastProbeMs >= probeCooldownMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final classification: zombie only on silence past threshold + probe-proven
|
||||
* connectivity. Never on silence alone, never on an inconclusive probe.
|
||||
*
|
||||
* @param {number} silentForMs ms since the inbound iterator last yielded
|
||||
* @param {number} thresholdMs silence threshold (<= 0 disables the watchdog)
|
||||
* @param {{alive: boolean}} probeOutcome
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isZombieSuspect(silentForMs, thresholdMs, probeOutcome) {
|
||||
if (!(thresholdMs > 0)) return false;
|
||||
if (silentForMs < thresholdMs) return false;
|
||||
return probeOutcome != null && probeOutcome.alive === true;
|
||||
}
|
||||
Reference in New Issue
Block a user