@cohortapp/agent-sdk 2.6.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/maestro.mjs CHANGED
@@ -785,6 +785,43 @@ function isPruneExcluded(repoRel) {
785
785
  return PRUNE_EXCLUDE_PREFIXES.some((p) => repoRel.startsWith(p));
786
786
  }
787
787
 
788
+ // Record of what the framework actually delivered to THIS machine, written at
789
+ // the end of every non-dry upgrade. Per-machine state, hence .maestro/.
790
+ //
791
+ // Prune needs to answer "did the framework once ship this file?", and git
792
+ // cannot answer it. A tracked-and-clean file absent from upstream is EITHER a
793
+ // framework file deleted upstream OR a file the operator wrote and committed
794
+ // themselves — the two are byte-for-byte indistinguishable in git status.
795
+ // Getting that wrong destroys operator work: the first cut of this pass
796
+ // proposed deleting an agent's pre-commit secret-scan hook (protecting a live
797
+ // org credential) and its hand-authored workflows, purely because the SDK had
798
+ // never shipped them.
799
+ //
800
+ // So provenance is recorded explicitly. Prune considers a file only if the
801
+ // PREVIOUS manifest lists it — proof this machine received it from the SDK.
802
+ // A machine with no manifest yet prunes nothing and starts clean from the next
803
+ // upgrade; that is the safe direction to fail.
804
+ const SHIPPED_MANIFEST_REL = ".maestro/shipped-manifest.json";
805
+
806
+ function loadShippedManifest(cwd) {
807
+ try {
808
+ const raw = JSON.parse(readFileSync(join(cwd, SHIPPED_MANIFEST_REL), "utf-8"));
809
+ if (Array.isArray(raw?.files)) return new Set(raw.files);
810
+ } catch {
811
+ // Absent or unreadable — caller treats this as "provenance unknown".
812
+ }
813
+ return null;
814
+ }
815
+
816
+ function writeShippedManifest(cwd, files) {
817
+ const dst = join(cwd, SHIPPED_MANIFEST_REL);
818
+ mkdirSync(dirname(dst), { recursive: true });
819
+ writeFileSync(
820
+ dst,
821
+ JSON.stringify({ version: 1, files: [...files].sort() }, null, 2) + "\n",
822
+ );
823
+ }
824
+
788
825
  function sha256File(p) {
789
826
  return createHash("sha256").update(readFileSync(p)).digest("hex");
790
827
  }
@@ -1174,6 +1211,8 @@ Per-file behaviour:
1174
1211
  const ignoredFiles = [];
1175
1212
  const prunedFiles = [];
1176
1213
  const pruneKeptFiles = [];
1214
+ // What upstream ships right now — becomes this machine's provenance record.
1215
+ const shippedNow = new Set();
1177
1216
 
1178
1217
  for (const { path: relRoot, mode } of UPGRADE_PATHS) {
1179
1218
  const srcRoot = join(MAESTRO_ROOT, relRoot);
@@ -1185,6 +1224,9 @@ Per-file behaviour:
1185
1224
  const relFromRoot = relative(srcRoot, srcFile);
1186
1225
  const dstFile = join(dstRoot, relFromRoot);
1187
1226
  const repoRel = relative(cwd, dstFile).split(sep).join("/");
1227
+ // Recorded regardless of what we then decide to do with the file: the
1228
+ // manifest answers "does the framework ship this?", not "did we write it?"
1229
+ shippedNow.add(repoRel);
1188
1230
 
1189
1231
  const isIgnored = matchesIgnore(repoRel, ignorePatterns);
1190
1232
 
@@ -1287,10 +1329,15 @@ Per-file behaviour:
1287
1329
  //
1288
1330
  // Removals are backed up under .maestro/backup/ exactly like a forced
1289
1331
  // overwrite, so a prune is always reversible.
1332
+ const priorManifest = loadShippedManifest(cwd);
1333
+
1290
1334
  if (flags.noPrune) {
1291
1335
  log("Prune skipped (--no-prune): upstream-deleted files left in place.");
1292
1336
  } else if (!inGit) {
1293
1337
  warn("Prune skipped: not a git repo, so pristine framework files can't be told from yours.");
1338
+ } else if (!priorManifest) {
1339
+ log("Prune skipped: no shipped-manifest yet, so framework files can't be told from your own.");
1340
+ log(`Recording one now (${SHIPPED_MANIFEST_REL}); prune takes effect from the next upgrade.`);
1294
1341
  } else {
1295
1342
  for (const { path: relRoot, mode } of UPGRADE_PATHS) {
1296
1343
  // merge-mode roots hold deliberately-local files — nothing to reconcile.
@@ -1313,6 +1360,15 @@ Per-file behaviour:
1313
1360
  continue;
1314
1361
  }
1315
1362
 
1363
+ // Provenance gate: unless a previous upgrade recorded delivering this
1364
+ // file, it is the operator's until proven otherwise. See the note on
1365
+ // SHIPPED_MANIFEST_REL — this is what stops prune eating hand-authored
1366
+ // scripts, workflows and policies that merely sit under a smart root.
1367
+ if (!priorManifest.has(repoRel)) {
1368
+ if (flags.verbose) console.log(` · ${repoRel} (never shipped by the framework — yours)`);
1369
+ continue;
1370
+ }
1371
+
1316
1372
  if (matchesIgnore(repoRel, ignorePatterns)) {
1317
1373
  counts.ignored++;
1318
1374
  ignoredFiles.push(repoRel);
@@ -1341,6 +1397,18 @@ Per-file behaviour:
1341
1397
  }
1342
1398
  }
1343
1399
 
1400
+ // Record provenance for the next run. Written even when nothing was pruned —
1401
+ // it is what makes the FOLLOWING upgrade able to prune safely. Never written
1402
+ // on a dry run, so --dry-run stays genuinely side-effect-free.
1403
+ if (!flags.dryRun) {
1404
+ try {
1405
+ writeShippedManifest(cwd, shippedNow);
1406
+ } catch (err) {
1407
+ // Non-fatal: a missing manifest costs pruning, not correctness.
1408
+ warn(`Could not write ${SHIPPED_MANIFEST_REL}: ${err.message}`);
1409
+ }
1410
+ }
1411
+
1344
1412
  // Ensure standard runtime directories exist.
1345
1413
  const ensureDirs = [
1346
1414
  "state/handoffs", "state/huddle", "state/indexes", "state/rag",
@@ -1160,7 +1160,7 @@ test("doctor surfaces the new framework systems (org / secrets / diagnostics)",
1160
1160
  * Agent repo carrying `orphans` — paths upstream does not ship. Written before
1161
1161
  * the commit so git sees them as tracked and clean (prune's precondition).
1162
1162
  */
1163
- async function makeAgentWithOrphans(orphans) {
1163
+ async function makeAgentWithOrphans(orphans, opts = {}) {
1164
1164
  const root = await tmpRoot("maestro-orphan");
1165
1165
  mkdirSync(join(root, "config"), { recursive: true });
1166
1166
  writeFileSync(join(root, "CLAUDE.md"), "# agent\n");
@@ -1170,6 +1170,17 @@ async function makeAgentWithOrphans(orphans) {
1170
1170
  mkdirSync(dirname(join(root, rel)), { recursive: true });
1171
1171
  writeFileSync(join(root, rel), body);
1172
1172
  }
1173
+ // Provenance: prune only touches files a PREVIOUS upgrade recorded shipping.
1174
+ // Without this the orphans are indistinguishable from operator-authored work
1175
+ // and prune correctly refuses to act — so every prune test must seed it.
1176
+ if (opts.seedManifest !== false) {
1177
+ mkdirSync(join(root, ".maestro"), { recursive: true });
1178
+ writeFileSync(
1179
+ join(root, ".maestro/shipped-manifest.json"),
1180
+ JSON.stringify({ version: 1, files: Object.keys(orphans).sort() }, null, 2) + "\n",
1181
+ );
1182
+ }
1183
+
1173
1184
  execFileSync("git", ["init", "-q"], { cwd: root });
1174
1185
  execFileSync("git", ["add", "-A"], { cwd: root });
1175
1186
  execFileSync(
@@ -1322,3 +1333,72 @@ test("upgrade prune never removes machine-generated launchd plists", async () =>
1322
1333
  await fsp.rm(root, { recursive: true, force: true });
1323
1334
  }
1324
1335
  });
1336
+
1337
+ test("upgrade prune spares operator-authored files the framework never shipped", async () => {
1338
+ // The hazard that forced the provenance manifest. scripts/ is a smart root,
1339
+ // so an operator's own committed script there is tracked, clean and absent
1340
+ // upstream — every signal git can offer says "orphan". On a real agent
1341
+ // machine this set included the pre-commit hook guarding a live org
1342
+ // credential. Provenance, not cleanliness, is what makes prune safe.
1343
+ const mine = "scripts/pre-commit-secret-scan.sh";
1344
+ const root = await makeAgentWithOrphans({
1345
+ [mine]: "#!/bin/sh\nexit 0\n",
1346
+ [ORPHAN]: "export const gone = 1;\n",
1347
+ });
1348
+ try {
1349
+ // Manifest records ONLY the framework file — the operator's script was
1350
+ // never delivered by the SDK, so it must not be a prune candidate.
1351
+ writeFileSync(
1352
+ join(root, ".maestro/shipped-manifest.json"),
1353
+ JSON.stringify({ version: 1, files: [ORPHAN] }, null, 2) + "\n",
1354
+ );
1355
+ execFileSync("git", ["add", "-A"], { cwd: root });
1356
+ execFileSync(
1357
+ "git",
1358
+ ["-c", "user.email=test@test", "-c", "user.name=test", "commit", "-q", "-m", "manifest"],
1359
+ { cwd: root },
1360
+ );
1361
+
1362
+ const r = runCli(["upgrade"], root);
1363
+ assert.equal(r.status, 0, r.stderr);
1364
+ assert.ok(existsSync(join(root, mine)), "a file the framework never shipped is the operator's");
1365
+ assert.ok(!existsSync(join(root, ORPHAN)), "the genuinely-shipped orphan should still prune");
1366
+ } finally {
1367
+ await fsp.rm(root, { recursive: true, force: true });
1368
+ }
1369
+ });
1370
+
1371
+ test("upgrade prunes nothing on a machine with no shipped-manifest, then records one", async () => {
1372
+ const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" }, { seedManifest: false });
1373
+ try {
1374
+ const first = runCli(["upgrade"], root);
1375
+ assert.equal(first.status, 0, first.stderr);
1376
+ assert.ok(
1377
+ existsSync(join(root, ORPHAN)),
1378
+ "with no provenance record, prune must do nothing rather than guess",
1379
+ );
1380
+ assert.ok(
1381
+ existsSync(join(root, ".maestro/shipped-manifest.json")),
1382
+ "the first run must record provenance so the next one can prune",
1383
+ );
1384
+ const manifest = JSON.parse(readFileSync(join(root, ".maestro/shipped-manifest.json"), "utf-8"));
1385
+ assert.ok(manifest.files.includes("scripts/daemon/responder.mjs"), "manifest lists shipped files");
1386
+ assert.ok(!manifest.files.includes(ORPHAN), "manifest must not list what upstream doesn't ship");
1387
+ } finally {
1388
+ await fsp.rm(root, { recursive: true, force: true });
1389
+ }
1390
+ });
1391
+
1392
+ test("upgrade --dry-run writes no shipped-manifest", async () => {
1393
+ const root = await makeAgentWithOrphans({}, { seedManifest: false });
1394
+ try {
1395
+ const r = runCli(["upgrade", "--dry-run"], root);
1396
+ assert.equal(r.status, 0, r.stderr);
1397
+ assert.ok(
1398
+ !existsSync(join(root, ".maestro/shipped-manifest.json")),
1399
+ "--dry-run must stay side-effect-free, including provenance",
1400
+ );
1401
+ } finally {
1402
+ await fsp.rm(root, { recursive: true, force: true });
1403
+ }
1404
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.6.1",
3
+ "version": "2.7.0",
4
4
  "description": "Cohort Agent SDK \u2014 autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,169 @@
1
+ /**
2
+ * responder.test.mjs — conversation history behind holding messages / quick replies.
3
+ * Run: node --test scripts/daemon/responder.test.mjs
4
+ *
5
+ * `loadConversationHistory` used to build its candidate directories as
6
+ * `memory/interactions/slack/<id>` unconditionally, and its writer
7
+ * (`logInteraction`) early-returns for any service that is not Slack. A Cohort
8
+ * DM therefore had nothing written and nothing to read, so every holding
9
+ * message for Cohort was generated from the current message alone. A bare "?"
10
+ * produced "What's on your mind?" — indistinguishable from amnesia, and the
11
+ * CEO read it as exactly that (2026-08-13, dm-h001-a016).
12
+ *
13
+ * Cohort has an authoritative `messaging.history`. These tests pin that the
14
+ * cohort branch reads it, orders it oldest-first, and NEVER throws — the
15
+ * holding path must degrade to "no history" rather than take the send down.
16
+ */
17
+ "use strict";
18
+
19
+ import { test } from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ import { loadConversationHistory } from "./responder.mjs";
26
+
27
+ const cohortItem = {
28
+ sender: "Dana Okafor",
29
+ service: "cohort",
30
+ channel: "dm-h001-a016",
31
+ channel_id: "cmql1802601iihhzpf774vwbk",
32
+ content: "?",
33
+ };
34
+
35
+ // Newest-first, the way messaging.history actually returns it.
36
+ const HISTORY = [
37
+ { id: "m3", createdAt: "2026-08-13T05:47:51Z", authorName: "Dana Okafor", body: "with that in mind, fix these issues you just noted please" },
38
+ { id: "m2", createdAt: "2026-08-12T13:52:53Z", authorName: "Isla Roselli", body: "Both are merged. #19 went in as 94f227c4." },
39
+ { id: "m1", createdAt: "2026-08-12T13:47:30Z", authorName: "Dana Okafor", body: "why haven't you merged? please go ahead" },
40
+ ];
41
+
42
+ test("cohort: reads messaging.history and renders it oldest-first", async () => {
43
+ const seen = [];
44
+ const text = await loadConversationHistory(cohortItem, {
45
+ fetchHistory: async (params, o) => {
46
+ seen.push({ params, hasCfg: Boolean(o && o.cfg) });
47
+ return HISTORY;
48
+ },
49
+ loadOrgConfig: () => ({ org: { cohort: { enabled: true, base: "https://x", token: "t" } } }),
50
+ });
51
+
52
+ assert.ok(text, "expected a transcript, got null");
53
+ assert.match(text, /Dana Okafor: why haven't you merged/);
54
+ assert.match(text, /Isla Roselli: Both are merged/);
55
+ assert.match(text, /Dana Okafor: with that in mind/);
56
+
57
+ // Oldest-first: the model reads a conversation, not a reversed one.
58
+ assert.ok(
59
+ text.indexOf("why haven't you merged") < text.indexOf("with that in mind"),
60
+ "history must be rendered oldest-first",
61
+ );
62
+
63
+ // The channel it asks for is the item's channel_id, and cfg is ALWAYS passed:
64
+ // fetchHistory() fails open to [] when cfg is omitted, which is silently
65
+ // indistinguishable from an empty channel. That is the bug this file exists for.
66
+ assert.equal(seen.length, 1);
67
+ assert.equal(seen[0].params.channel, "cmql1802601iihhzpf774vwbk");
68
+ assert.equal(seen[0].hasCfg, true, "cfg must be passed or fetchHistory returns [] silently");
69
+ });
70
+
71
+ test("cohort: an empty channel is null, not an empty transcript", async () => {
72
+ const text = await loadConversationHistory(cohortItem, {
73
+ fetchHistory: async () => [],
74
+ loadOrgConfig: () => ({}),
75
+ });
76
+ assert.equal(text, null);
77
+ });
78
+
79
+ test("cohort: a failing history call degrades to null and never throws", async () => {
80
+ const text = await loadConversationHistory(cohortItem, {
81
+ fetchHistory: async () => { throw new Error("cohort unreachable"); },
82
+ loadOrgConfig: () => ({}),
83
+ });
84
+ assert.equal(text, null);
85
+ });
86
+
87
+ test("cohort: the current message is not fed back as its own history", async () => {
88
+ const text = await loadConversationHistory(
89
+ { ...cohortItem, message_id: "m3" },
90
+ {
91
+ fetchHistory: async () => HISTORY,
92
+ loadOrgConfig: () => ({}),
93
+ },
94
+ );
95
+ assert.ok(text);
96
+ assert.doesNotMatch(text, /with that in mind/, "the inbound message itself must be excluded");
97
+ });
98
+
99
+ test("slack: still reads the local interaction mirror", async () => {
100
+ const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
101
+ const dir = join(root, "memory", "interactions", "slack", "D099N1JGKRQ");
102
+ mkdirSync(dir, { recursive: true });
103
+ writeFileSync(
104
+ join(dir, "2026-08-13.jsonl"),
105
+ [
106
+ JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "Dana Okafor", content: "ship it" }),
107
+ JSON.stringify({ ts: "2026-08-13T05:01:00Z", from: "agent", content: "shipped" }),
108
+ ].join("\n"),
109
+ );
110
+
111
+ const text = await loadConversationHistory(
112
+ { sender: "Dana Okafor", service: "slack", channel: "D099N1JGKRQ", content: "?" },
113
+ { agentRepoDir: root },
114
+ );
115
+
116
+ assert.ok(text, "expected the slack mirror to still be read");
117
+ assert.match(text, /Dana Okafor: ship it/);
118
+ assert.match(text, /agent: shipped/);
119
+ });
120
+
121
+ test("cohort: falls back to the local mirror when the server is unreachable", async () => {
122
+ // Isla's original returned null outright for Cohort when messaging.history
123
+ // failed. Falling through to the mirror means a transient API blip degrades
124
+ // to cached context instead of to none — the mirror is a poor source of
125
+ // truth but a perfectly good backstop.
126
+ const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
127
+ const dir = join(root, "memory", "interactions", "cohort", "chan-1");
128
+ mkdirSync(dir, { recursive: true });
129
+ writeFileSync(
130
+ join(dir, "2026-08-13.jsonl"),
131
+ JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "Dana Okafor", content: "cached line" }),
132
+ );
133
+
134
+ const text = await loadConversationHistory(
135
+ { sender: "Dana Okafor", service: "cohort", channel: "chan-1", content: "?" },
136
+ {
137
+ agentRepoDir: root,
138
+ loadOrgConfig: () => ({}),
139
+ fetchHistory: async () => { throw new Error("network down"); },
140
+ },
141
+ );
142
+
143
+ assert.ok(text, "a server failure must not erase locally cached context");
144
+ assert.match(text, /Dana Okafor: cached line/);
145
+ });
146
+
147
+ test("cohort: server history wins over the local mirror", async () => {
148
+ const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
149
+ const dir = join(root, "memory", "interactions", "cohort", "chan-1");
150
+ mkdirSync(dir, { recursive: true });
151
+ writeFileSync(
152
+ join(dir, "2026-08-13.jsonl"),
153
+ JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "stale", content: "outdated mirror line" }),
154
+ );
155
+
156
+ const text = await loadConversationHistory(
157
+ { sender: "Dana Okafor", service: "cohort", channel: "chan-1", content: "?" },
158
+ {
159
+ agentRepoDir: root,
160
+ loadOrgConfig: () => ({}),
161
+ fetchHistory: async () => ([
162
+ { id: "m2", authorName: "Dana Okafor", body: "authoritative line", createdAt: "2026-08-13T06:00:00Z" },
163
+ ]),
164
+ },
165
+ );
166
+
167
+ assert.match(text, /authoritative line/, "the server is the source of truth");
168
+ assert.doesNotMatch(text, /outdated mirror line/, "the stale mirror must not be mixed in");
169
+ });
@@ -523,23 +523,105 @@ function loadUserProfile(sender) {
523
523
  // Load recent conversation history for context
524
524
  // ---------------------------------------------------------------------------
525
525
 
526
+ /** Cohort's message ids as this item carries them, so history can drop itself. */
527
+ function inboundMessageIds(item) {
528
+ const ids = new Set();
529
+ const add = (v) => { if (v) ids.add(String(v)); };
530
+ add(item.message_id);
531
+ // Inbox filenames prefix the service: "cohort-cmsr45u91…" → "cmsr45u91…".
532
+ if (item.id) { add(item.id); add(String(item.id).replace(/^[a-z]+-/, "")); }
533
+ // raw_ref is "cohort:dm:<messageId>:<seq>".
534
+ const m = typeof item.raw_ref === "string" ? item.raw_ref.match(/^cohort:[^:]+:([^:]+)/) : null;
535
+ if (m) add(m[1]);
536
+ return ids;
537
+ }
538
+
539
+ const COHORT_HISTORY_LIMIT = 12;
540
+ const COHORT_HISTORY_CHARS = 400;
541
+
542
+ /**
543
+ * Cohort history straight from the server, via the same `messaging.history` the
544
+ * replies are posted to.
545
+ *
546
+ * The local mirror below is a cache the agent populates itself, so it carries
547
+ * context only if the agent has already been talking AND logging correctly.
548
+ * When the write half broke (logInteraction early-returned for every non-Slack
549
+ * service), the read half silently returned nothing, memory/interactions/ was
550
+ * never even created, and holding messages were composed from the inbound
551
+ * message alone — a bare "?" came back as "What's on your mind?" mid-thread.
552
+ *
553
+ * Asking the authority that stores the message removes that coupling: a fresh
554
+ * agent, or one whose mirror was wiped, still answers with full context.
555
+ *
556
+ * Fails open to null on every path. No amount of history is worth failing a send.
557
+ */
558
+ async function loadCohortHistory(item, deps = {}) {
559
+ const channel = item.channel_id || item.channel || "";
560
+ if (!channel) return null;
561
+
562
+ try {
563
+ const fetchHistory = deps.fetchHistory
564
+ || (await import("../../lib/org/messaging.mjs")).fetchHistory;
565
+ const loadOrgConfig = deps.loadOrgConfig
566
+ || (await import("../../lib/org/client.mjs")).loadOrgConfig;
567
+ const agentRoot = deps.agentRepoDir || process.env.AGENT_ROOT || process.env.AGENT_DIR || AGENT_REPO_DIR;
568
+
569
+ // cfg is mandatory. configFromAgent() reads base+token ONLY from the object
570
+ // handed in — it never loads config/org.yaml — so omitting it returns a
571
+ // fail-open [] that looks exactly like an empty channel.
572
+ const messages = await fetchHistory(
573
+ { channel, limit: COHORT_HISTORY_LIMIT + 1 },
574
+ { cfg: loadOrgConfig(agentRoot), agentRoot },
575
+ );
576
+ if (!Array.isArray(messages) || messages.length === 0) return null;
577
+
578
+ const self = inboundMessageIds(item);
579
+ const rows = messages
580
+ .filter((m) => m && !self.has(String(m.id)))
581
+ .slice(0, COHORT_HISTORY_LIMIT)
582
+ .reverse() // messaging.history is newest-first; a conversation reads the other way
583
+ .map((m) => {
584
+ const from = m.authorName || m.author?.name || m.authorId || "unknown";
585
+ const text = String(m.body ?? m.content ?? "").trim();
586
+ return `[${m.createdAt || ""}] ${from}: ${text.substring(0, COHORT_HISTORY_CHARS)}`;
587
+ });
588
+
589
+ return rows.length > 0 ? rows.join("\n") : null;
590
+ } catch (err) {
591
+ console.warn(`[responder] cohort history unavailable for ${item.sender}: ${err.message}`);
592
+ return null;
593
+ }
594
+ }
595
+
526
596
  /**
527
597
  * The reader half of `logInteraction`. It looked ONLY under
528
598
  * `memory/interactions/slack/…`, so even once a Cohort exchange was written it
529
599
  * would not have been found. Both halves are now keyed on the item's service.
600
+ *
601
+ * Cohort asks the server first (see loadCohortHistory) and falls back to the
602
+ * local mirror, so a transient API failure degrades to cached context rather
603
+ * than to none.
530
604
  */
531
- function loadConversationHistory(item) {
605
+ export async function loadConversationHistory(item, deps = {}) {
532
606
  if (!item || !item.sender || !item.service) return null;
533
607
 
534
608
  const service = String(item.service);
609
+
610
+ if (service === "cohort") {
611
+ const remote = await loadCohortHistory(item, deps);
612
+ if (remote) return remote;
613
+ }
614
+ // Test seam: the mirror's location is a parameter, so a test can point at a
615
+ // tmpdir instead of mutating process.env for the whole worker.
616
+ const repoDir = deps.agentRepoDir || AGENT_REPO_DIR;
535
617
  const senderSlug = item.sender.replace(/\s+/g, "-").toLowerCase();
536
618
  const channelId = service === "slack"
537
619
  ? resolveSlackChannel(item)
538
620
  : (item.channel_id || item.channel || null);
539
621
 
540
622
  const candidateDirs = [];
541
- if (channelId) candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", service, String(channelId)));
542
- candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", service, `dm-${senderSlug}`));
623
+ if (channelId) candidateDirs.push(join(repoDir, "memory", "interactions", service, String(channelId)));
624
+ candidateDirs.push(join(repoDir, "memory", "interactions", service, `dm-${senderSlug}`));
543
625
 
544
626
  const entries = [];
545
627
  for (const dir of candidateDirs) {
@@ -621,7 +703,7 @@ Keep responses focused — 1-4 sentences for simple items, up to a short paragra
621
703
  Match the sender's tone and urgency level.
622
704
  ${profile ? `\nSender profile:\n${profile}` : ""}`;
623
705
 
624
- const conversationHistory = loadConversationHistory(item);
706
+ const conversationHistory = await loadConversationHistory(item);
625
707
 
626
708
  const userContent = [
627
709
  `From: ${item.sender} (${item.sender_privilege || "unknown"})`,