mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-22 10:32:00 +00:00
* fix(photon): preserve text in mixed iMessage attachments
When an iMessage bubble carried both text and an attachment, spectrum-ts'
inbound mapper returned only buildAttachmentMessage(...), dropping the user's
typed text before Hermes could see it. The Photon adapter then had no 'group'
content path, so the text was lost entirely.
- adapter.py: handle a new 'group' content type that flattens text + attachment
items, preserving the typed text alongside cached media (extracted shared
_normalize_binary_payload helper).
- sidecar: emit 'group' content in normalizeContent, and ship
patch-spectrum-mixed-attachments.mjs which patches spectrum-ts' pinned mapper
(at npm postinstall AND at sidecar startup, so existing installs self-heal).
Windows robustness fixes on top of the original PR:
- The patcher's CLI guard used 'import.meta.url === file://${argv[1]}', which
never matches on Windows (file:/// + drive letter) — it silently no-opped.
Switched to pathToFileURL(argv[1]).href.
- The patcher matched \n-joined strings, so a CRLF checkout (Windows git
autocrlf) defeated every replacement. It now normalizes CRLF->LF for matching
and restores the original EOL style on write.
Co-authored-by: Yuhang Lin <yuhanglin@YuhangdeMac-mini.local>
* chore: map YuhangLin contributor email for attribution (#46513)
---------
Co-authored-by: Yuhang Lin <yuhanglin@YuhangdeMac-mini.local>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
155 lines
7.2 KiB
JavaScript
155 lines
7.2 KiB
JavaScript
#!/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
|
|
// buildAttachmentMessage(...) whenever attachments are present, which drops
|
|
// event.message.content.text before Hermes can see it.
|
|
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 replaceFirst(source, from, to, label) {
|
|
if (!source.includes(from)) {
|
|
throw new Error(`expected at least one ${label} match, found 0`);
|
|
}
|
|
return source.replace(from, 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 }`;
|
|
}
|
|
|
|
function patchRebuild(source) {
|
|
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) {`,
|
|
"rebuild text capture"
|
|
);
|
|
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;`,
|
|
"rebuild single attachment"
|
|
);
|
|
source = replaceFirst(
|
|
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) {`,
|
|
"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) {`,
|
|
"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];`,
|
|
"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 };`,
|
|
"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;
|
|
}
|
|
|
|
export function patchSpectrumTs(root = scriptDir()) {
|
|
const dist = path.join(root, "node_modules", "spectrum-ts", "dist");
|
|
if (!fs.existsSync(dist)) {
|
|
throw new Error(`spectrum-ts 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.
|
|
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")) {
|
|
continue;
|
|
}
|
|
let patched = original;
|
|
patched = patchRebuild(patched);
|
|
patched = patchInbound(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 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);
|
|
}
|
|
}
|