Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
@@ -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
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;
}