@cohortapp/agent-sdk 2.15.0 → 2.17.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.
Files changed (38) hide show
  1. package/.env.example +5 -2
  2. package/docs/guides/front-door-session.md +16 -5
  3. package/docs/guides/poller-daemon-setup.md +53 -2
  4. package/lib/assurance/plan-note.mjs +251 -0
  5. package/lib/assurance/plan-note.test.mjs +234 -0
  6. package/lib/assurance/room-budget.mjs +497 -0
  7. package/lib/assurance/room-budget.test.mjs +486 -0
  8. package/lib/assurance/tier.mjs +166 -0
  9. package/lib/assurance/tier.test.mjs +174 -0
  10. package/lib/comms/receipts.mjs +17 -1
  11. package/lib/context/budget.mjs +327 -0
  12. package/lib/context/budget.test.mjs +252 -0
  13. package/lib/context/history-scope.mjs +138 -0
  14. package/lib/context/history-scope.test.mjs +79 -0
  15. package/lib/model-router/economics.mjs +9 -0
  16. package/lib/model-router/resolve.mjs +6 -0
  17. package/lib/org/inbound/facts.mjs +4 -2
  18. package/lib/org/inbound/hydrate.mjs +555 -51
  19. package/lib/org/inbound/hydrate.test.mjs +456 -1
  20. package/package.json +3 -1
  21. package/plugins/maestro-skills/skills/inbound-triage.md +52 -24
  22. package/plugins/maestro-skills/skills/main-session.md +6 -4
  23. package/scripts/daemon/agent-daemon.mjs +35 -7
  24. package/scripts/daemon/agent-daemon.test.mjs +23 -6
  25. package/scripts/daemon/assurance-e2e.test.mjs +75 -19
  26. package/scripts/daemon/assurance.mjs +663 -159
  27. package/scripts/daemon/assurance.test.mjs +820 -140
  28. package/scripts/daemon/context-compiler.mjs +52 -21
  29. package/scripts/daemon/context-compiler.test.mjs +106 -0
  30. package/scripts/daemon/deliver.mjs +7 -4
  31. package/scripts/daemon/dispatcher-session-continuity.test.mjs +365 -0
  32. package/scripts/daemon/dispatcher.mjs +210 -9
  33. package/scripts/daemon/lib/session-router.mjs +310 -42
  34. package/scripts/daemon/lib/session-router.test.mjs +260 -1
  35. package/scripts/daemon/prompt-builder.mjs +160 -16
  36. package/scripts/daemon/prompt-builder.test.mjs +287 -7
  37. package/scripts/daemon/responder-history.test.mjs +37 -1
  38. package/scripts/daemon/responder.mjs +79 -72
@@ -12,12 +12,38 @@ import yaml from "js-yaml";
12
12
  const ORG_AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-pb-org-"));
13
13
  mkdirSync(join(ORG_AGENT_DIR, "config"), { recursive: true });
14
14
  process.env.AGENT_DIR = ORG_AGENT_DIR;
15
- const { buildPrompt, buildInboxContext, _resetOrgConfigCache } = await import("./prompt-builder.mjs");
15
+ const { buildPrompt, buildInboxContext, _resetOrgConfigCache, contextCompilerEnabled } = await import("./prompt-builder.mjs");
16
+ const { _resetOrgCfgCache } = await import("./context-compiler.mjs");
17
+
18
+ /**
19
+ * Run `fn` with the compiled-context path OFF.
20
+ *
21
+ * The three org-knowledge tests below assert the LEGACY `--- ORG SHARED
22
+ * KNOWLEDGE ---` block, which `buildPrompt` emits only on that path. The flag
23
+ * now defaults ON (design §3 R11), so the legacy assertions have to say which
24
+ * path they are about instead of relying on a default that has moved. The
25
+ * compiled path's own org injection is pinned separately, below.
26
+ */
27
+ async function withLegacyContext(fn) {
28
+ const prior = process.env.DAEMON_CONTEXT_COMPILER;
29
+ process.env.DAEMON_CONTEXT_COMPILER = "0";
30
+ try {
31
+ return await fn();
32
+ } finally {
33
+ if (prior === undefined) delete process.env.DAEMON_CONTEXT_COMPILER;
34
+ else process.env.DAEMON_CONTEXT_COMPILER = prior;
35
+ }
36
+ }
16
37
 
17
38
  /** Write config/org.yaml into the temp agent dir and drop the cached read. */
18
39
  function writeOrgConfig(doc) {
19
40
  writeFileSync(join(ORG_AGENT_DIR, "config/org.yaml"), doc == null ? "" : yaml.dump(doc));
20
41
  _resetOrgConfigCache();
42
+ // The compiled-context path caches its OWN read of config/org.yaml. Both
43
+ // caches have to drop, or a test that disables the integration is served the
44
+ // previous test's enabled config and "proves" a network call that the config
45
+ // on disk forbids.
46
+ _resetOrgCfgCache();
21
47
  }
22
48
 
23
49
  /** Stub global fetch to serve a knowledge.search frame, restore on cleanup. */
@@ -85,9 +111,9 @@ test("buildInboxContext fences thread context too", () => {
85
111
  const ITEM = { service: "slack", channel: "C123", channel_id: "C123", channel_type: "channel", sender: "Dana", content: "What is our refund policy?" };
86
112
  const CLASS = { priority: "normal", action: "respond", category: "ops", summary: "refund policy question" };
87
113
 
88
- test("buildPrompt injects ORG SHARED KNOWLEDGE when org.cohort is enabled", async () => {
114
+ test("buildPrompt injects ORG SHARED KNOWLEDGE when org.cohort is enabled (legacy path)", async () => {
89
115
  writeOrgConfig({ org: { cohort: { enabled: true, base: "https://cohort.example", token: "tok" } } });
90
- await withFetch(
116
+ await withLegacyContext(() => withFetch(
91
117
  fetchReturningFacts([
92
118
  { id: "k1", text: "Refunds are issued within 14 days of purchase." },
93
119
  { id: "k2", text: "Enterprise contracts are non-refundable after onboarding." },
@@ -103,7 +129,7 @@ test("buildPrompt injects ORG SHARED KNOWLEDGE when org.cohort is enabled", asyn
103
129
  assert.ok(prompt.includes("--- INCOMING MESSAGE ---"));
104
130
  assert.match(prompt, /Log all actions taken/);
105
131
  }
106
- );
132
+ ));
107
133
  });
108
134
 
109
135
  test("buildPrompt does NOT inject org knowledge when the integration is disabled", async () => {
@@ -121,6 +147,50 @@ test("buildPrompt does NOT inject org knowledge when the integration is disabled
121
147
  );
122
148
  });
123
149
 
150
+ // ---------------------------------------------------------------------------
151
+ // DAEMON_CONTEXT_COMPILER — ONE path ships (design §3 R11)
152
+ //
153
+ // The code read `=== "1"` while .env.example shipped `=1`, so which of two
154
+ // materially different context paths ran depended on whether the operator had
155
+ // copied the example env. These pin the default and the escape hatch.
156
+ // ---------------------------------------------------------------------------
157
+
158
+ test("the compiled-context path is ON by default, matching .env.example", () => {
159
+ assert.equal(contextCompilerEnabled({}), true, "unset must mean on");
160
+ assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: "1" }), true);
161
+ assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: "" }), true);
162
+ });
163
+
164
+ test("the compiled-context path can still be turned off in a hurry", () => {
165
+ for (const v of ["0", "false", "off", "no", "OFF"]) {
166
+ assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: v }), false, v);
167
+ }
168
+ });
169
+
170
+ test("on the DEFAULT path the org's facts still reach the prompt", async () => {
171
+ writeOrgConfig({ org: { cohort: { enabled: true, base: "https://cohort.example", token: "tok" } } });
172
+ await withFetch(
173
+ fetchReturningFacts([{ id: "k1", text: "Refunds are issued within 14 days of purchase." }]),
174
+ async () => {
175
+ const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
176
+ assert.ok(prompt.includes("Refunds are issued within 14 days"), "org recall must survive the default path");
177
+ assert.ok(prompt.includes("--- INCOMING MESSAGE ---"));
178
+ }
179
+ );
180
+ });
181
+
182
+ test("on the DEFAULT path a disabled integration still makes no network call", async () => {
183
+ writeOrgConfig({ org: { cohort: { enabled: false } } });
184
+ let fetched = false;
185
+ await withFetch(
186
+ async () => { fetched = true; return { ok: true, status: 200, json: async () => ({ ok: true, result: { facts: [] } }), headers: { get: () => null } }; },
187
+ async () => {
188
+ await buildPrompt(ITEM, CLASS, { type: "inbox" });
189
+ assert.equal(fetched, false, "disabled integration must not hit the network on either path");
190
+ }
191
+ );
192
+ });
193
+
124
194
  test("buildPrompt is unchanged with NO org config file at all", async () => {
125
195
  writeOrgConfig(null); // empty file → parses to {} → disabled
126
196
  const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
@@ -198,10 +268,10 @@ test("buildPrompt never asks a PRIVATE turn for the OUTCOMES block", async () =>
198
268
  }
199
269
  });
200
270
 
201
- test("buildPrompt caps the number of injected org facts (bounded)", async () => {
271
+ test("buildPrompt caps the number of injected org facts (bounded, legacy path)", async () => {
202
272
  writeOrgConfig({ org: { cohort: { enabled: true, base: "https://cohort.example", token: "tok" } } });
203
273
  const many = Array.from({ length: 50 }, (_, i) => ({ id: `k${i}`, text: `fact number ${i}` }));
204
- await withFetch(fetchReturningFacts(many), async () => {
274
+ await withLegacyContext(() => withFetch(fetchReturningFacts(many), async () => {
205
275
  const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
206
276
  const block = prompt.slice(
207
277
  prompt.indexOf("--- ORG SHARED KNOWLEDGE ---"),
@@ -209,7 +279,7 @@ test("buildPrompt caps the number of injected org facts (bounded)", async () =>
209
279
  );
210
280
  const bullets = (block.match(/^- fact number /gm) || []).length;
211
281
  assert.ok(bullets > 0 && bullets <= 6, `expected <=6 injected facts, got ${bullets}`);
212
- });
282
+ }));
213
283
  });
214
284
 
215
285
  // ---------------------------------------------------------------------------
@@ -267,6 +337,74 @@ test("buildPrompt does NOT claim delivery when the acknowledgement failed to sen
267
337
  assert.ok(prompt.includes("Understood — I'm digging into this now (the noted issues). I want to get this right, so I'll come back to you here with a full answer — usually within 10-20 minutes — and you'll hear from me either way."));
268
338
  });
269
339
 
340
+ // ---------------------------------------------------------------------------
341
+ // THE THIRD AND FOURTH STATES: an interim whose text this process does not
342
+ // hold, and no contact at all. Both were one block before 2026-09-12, and that
343
+ // block asserted a fact that is false on most dispatches.
344
+ // ---------------------------------------------------------------------------
345
+
346
+ test("REGRESSION: a retry session is NOT told the sender has heard nothing", async () => {
347
+ // The reachable-today path. An item re-delivered while its debt is open with
348
+ // interimSaid:true makes openAndAcknowledge return {acked:true, ackText:null}:
349
+ // holdingMessage is null, so both holding blocks are skipped, and before this
350
+ // fix the no-contact block rendered — telling a RETRY session that a human who
351
+ // had already received a holding line AND a "Hit a problem — … Retrying now"
352
+ // had heard nothing. On main this path produced no block at all, so the false
353
+ // claim was introduced by the acknowledgement-discipline change itself.
354
+ const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox", interimAlreadySent: true });
355
+ assert.match(prompt, /ALREADY IN CONTACT/);
356
+ assert.match(prompt, /already reached the sender/);
357
+ assert.ok(!/heard nothing/.test(prompt), "must not assert silence at a sender who has been spoken to");
358
+ assert.ok(!/NO CONTACT YET/.test(prompt), "the no-contact block must not render alongside it");
359
+ // It must not invent the text it does not have.
360
+ assert.match(prompt, /do not quote it or guess at it/);
361
+ // And the second reminder, immediately before the action block, agrees.
362
+ assert.match(prompt, /REMINDER: An interim about this item already reached the sender/);
363
+ });
364
+
365
+ test("the no-contact block states a FACT about the past, not a PREDICTION about the future", async () => {
366
+ // What it said before: "your reply is the first thing they will read". That is
367
+ // false on the majority of dispatches — the sweep posts one interim at
368
+ // ACK_AFTER_MS (90 s) while measured p50 session duration is 14.7 min — and a
369
+ // session told it is the first voice in the room, with a holding line landing
370
+ // in front of it, produces exactly the double-contact the delivered-holding
371
+ // block exists to prevent, in the other direction.
372
+ const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
373
+ assert.match(prompt, /NO CONTACT YET/);
374
+ assert.match(prompt, /Nothing has been sent to the sender about this item so far/);
375
+ assert.ok(
376
+ !/first thing they will read/.test(prompt),
377
+ "the prompt may not predict that no interim will land before the reply",
378
+ );
379
+ // It must say the opposite instead: one may yet go out, silently.
380
+ assert.match(prompt, /may post at most ONE short holding line/);
381
+ assert.ok(!/ALREADY IN CONTACT/.test(prompt));
382
+ });
383
+
384
+ test("a delivered holding message outranks the interim-already-sent flag", async () => {
385
+ // When the text IS in hand, quoting it is strictly better than saying it
386
+ // exists — so the ordering of the branches is pinned rather than incidental.
387
+ const prompt = await buildPrompt(ITEM, CLASS, {
388
+ type: "inbox",
389
+ holdingMessage: "Give me a few minutes on the July reconciliation — I'll have the variances.",
390
+ holdingSent: true,
391
+ interimAlreadySent: true,
392
+ });
393
+ assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
394
+ assert.ok(!/ALREADY IN CONTACT/.test(prompt));
395
+ assert.ok(!/NO CONTACT YET/.test(prompt));
396
+ });
397
+
398
+ test("neither block reaches a backlog item — there is no sender on the other end", async () => {
399
+ const prompt = await buildPrompt(null, CLASS, {
400
+ type: "backlog",
401
+ queueItem: { id: "q-1", title: "Sweep the stale worktrees", status: "open" },
402
+ interimAlreadySent: true,
403
+ });
404
+ assert.ok(!/ALREADY IN CONTACT/.test(prompt));
405
+ assert.ok(!/NO CONTACT YET/.test(prompt));
406
+ });
407
+
270
408
  test("buildPrompt treats an unspecified holdingSent as delivered (back-compat)", async () => {
271
409
  const prompt = await buildPrompt(ITEM, CLASS, {
272
410
  type: "inbox",
@@ -274,3 +412,145 @@ test("buildPrompt treats an unspecified holdingSent as delivered (back-compat)",
274
412
  });
275
413
  assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
276
414
  });
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Interaction history is read per SERVICE (design §3 R12)
418
+ //
419
+ // `responder.mjs#logInteraction` files under `memory/interactions/<service>/…`;
420
+ // this reader was pinned to `…/slack/…`, so a Cohort exchange was written down
421
+ // and then never found.
422
+ // ---------------------------------------------------------------------------
423
+
424
+ test("the legacy history block finds a Cohort conversation, not just a Slack one", async () => {
425
+ writeOrgConfig(null);
426
+ const dir = join(ORG_AGENT_DIR, "memory", "interactions", "cohort", "chan-cohort-9");
427
+ mkdirSync(dir, { recursive: true });
428
+ writeFileSync(
429
+ join(dir, "2026-04-06.jsonl"),
430
+ JSON.stringify({ ts: "1", from: "Dana", content: "the retainer number is wrong on page two", received_at: "2026-04-06T10:00:00Z" }) + "\n",
431
+ );
432
+
433
+ const item = { service: "cohort", channel: "engineering", channel_id: "chan-cohort-9", sender: "Dana", content: "and now?" };
434
+ const prompt = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
435
+ assert.ok(prompt.includes("the retainer number is wrong on page two"), "Cohort history must reach the prompt");
436
+ });
437
+
438
+ test("the legacy history block does not read another service's tree for the same channel id", async () => {
439
+ writeOrgConfig(null);
440
+ const dir = join(ORG_AGENT_DIR, "memory", "interactions", "whatsapp", "chan-crossed");
441
+ mkdirSync(dir, { recursive: true });
442
+ writeFileSync(
443
+ join(dir, "2026-04-06.jsonl"),
444
+ JSON.stringify({ ts: "1", from: "Dana", content: "WHATSAPP ONLY SENTINEL", received_at: "2026-04-06T10:00:00Z" }) + "\n",
445
+ );
446
+
447
+ const item = { service: "cohort", channel: "engineering", channel_id: "chan-crossed", sender: "Dana", content: "?" };
448
+ const prompt = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
449
+ assert.ok(!prompt.includes("WHATSAPP ONLY SENTINEL"), "one service must not read another's history");
450
+ });
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Resource scope: a per-person DM never reaches a shared room (design §5.5)
454
+ //
455
+ // `logInteraction` files each exchange under BOTH the room and
456
+ // `dm-<sender-slug>`. Merging every candidate directory into one "history with
457
+ // this sender" block meant that the moment this read stopped being Slack-only,
458
+ // a sentence Dana said in a DM appeared in the prompt built for a PUBLIC
459
+ // channel. Cohort is the service where that bites: one namespace for DMs and
460
+ // rooms. Pinned on BOTH context paths, because both merged.
461
+ // ---------------------------------------------------------------------------
462
+
463
+ const DM_SECRET = "between us - we are letting Marco go on Friday, do not mention it yet";
464
+
465
+ /** Seed a private DM log and a public-room log for the same person. */
466
+ function seedDmAndRoom(service, channelId) {
467
+ const dmDir = join(ORG_AGENT_DIR, "memory", "interactions", service, "dm-dana");
468
+ mkdirSync(dmDir, { recursive: true });
469
+ writeFileSync(
470
+ join(dmDir, "2026-09-11.jsonl"),
471
+ JSON.stringify({ ts: "1", from: "Dana", content: DM_SECRET, received_at: "2026-09-11T10:00:00Z" }) + "\n",
472
+ );
473
+ const roomDir = join(ORG_AGENT_DIR, "memory", "interactions", service, channelId);
474
+ mkdirSync(roomDir, { recursive: true });
475
+ writeFileSync(
476
+ join(roomDir, "2026-09-11.jsonl"),
477
+ JSON.stringify({ ts: "2", from: "Dana", content: "PUBLIC ROOM SENTINEL", received_at: "2026-09-11T11:00:00Z" }) + "\n",
478
+ );
479
+ }
480
+
481
+ test("a public-channel prompt never carries the sender's DM history (legacy path)", async () => {
482
+ writeOrgConfig(null);
483
+ seedDmAndRoom("cohort", "c-capital-formation");
484
+ const item = {
485
+ service: "cohort", channel: "capital-formation", channel_id: "c-capital-formation",
486
+ is_dm: false, sender: "Dana", content: "what's the runway number?",
487
+ };
488
+ const prompt = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
489
+ assert.ok(!prompt.includes(DM_SECRET), "a per-person DM must never be quoted into a shared room");
490
+ assert.ok(prompt.includes("PUBLIC ROOM SENTINEL"), "the room's own history still reaches the prompt");
491
+ });
492
+
493
+ test("a public-channel prompt never carries the sender's DM history (compiled path)", async () => {
494
+ writeOrgConfig(null);
495
+ seedDmAndRoom("cohort", "c-capital-formation-2");
496
+ const item = {
497
+ service: "cohort", channel: "capital-formation", channel_id: "c-capital-formation-2",
498
+ is_dm: false, sender: "Dana", content: "what's the runway number?",
499
+ };
500
+ const prompt = await buildPrompt(item, CLASS, { type: "inbox" });
501
+ assert.ok(contextCompilerEnabled({}), "this test is about the compiled path, which is the default");
502
+ assert.ok(!prompt.includes(DM_SECRET), "a per-person DM must never be quoted into a shared room");
503
+ assert.ok(prompt.includes("PUBLIC ROOM SENTINEL"), "the room's own history still reaches the prompt");
504
+ });
505
+
506
+ test("the DM itself still reads its own per-person history (both paths)", async () => {
507
+ writeOrgConfig(null);
508
+ seedDmAndRoom("cohort", "c-dm-dana");
509
+ const item = {
510
+ service: "cohort", channel: "dm/dana", channel_id: "c-dm-dana",
511
+ is_dm: true, sender: "Dana", content: "still ok for Friday?",
512
+ };
513
+ const legacy = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
514
+ assert.ok(legacy.includes(DM_SECRET), "a private reply may see the private record");
515
+ const compiled = await buildPrompt(item, CLASS, { type: "inbox" });
516
+ assert.ok(compiled.includes(DM_SECRET), "…on the compiled path too");
517
+ });
518
+
519
+ test("an item with no privacy signal at all is treated as a ROOM", async () => {
520
+ writeOrgConfig(null);
521
+ seedDmAndRoom("cohort", "c-unknown-kind");
522
+ // No is_dm, no channel_type, a cuid channel id that says nothing either way.
523
+ const item = { service: "cohort", channel: "unknown", channel_id: "c-unknown-kind", sender: "Dana", content: "?" };
524
+ const legacy = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
525
+ assert.ok(!legacy.includes(DM_SECRET), "unknown is not permission");
526
+ const compiled = await buildPrompt(item, CLASS, { type: "inbox" });
527
+ assert.ok(!compiled.includes(DM_SECRET), "unknown is not permission (compiled path)");
528
+ });
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // DAEMON_CONTEXT_COMPILER — the flag that picks between two context paths
532
+ // ---------------------------------------------------------------------------
533
+
534
+ test("the context-compiler flag reads the words operators actually write", async () => {
535
+ const { contextCompilerSetting } = await import("./prompt-builder.mjs");
536
+
537
+ // Default ON (design §3 R11) — .env.example ships 1, the code used to ship 0.
538
+ assert.equal(contextCompilerEnabled({}), true);
539
+ assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: "" }), true);
540
+
541
+ for (const on of ["1", "true", "on", "yes", "Y", "ENABLED"]) {
542
+ assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: on }), true, `"${on}" means on`);
543
+ }
544
+ // The off words are wider than "0": an operator who writes `disabled` meant
545
+ // off, and the earlier list would have given them the opposite — on the one
546
+ // flag that selects between two materially different context paths.
547
+ for (const off of ["0", "false", "off", "no", "n", "disabled", "disable", "none", " OFF "]) {
548
+ assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: off }), false, `"${off}" means off`);
549
+ }
550
+
551
+ // A word we do not know still resolves ON — but it is reported as unknown, so
552
+ // the caller can say so rather than resolving a typo in silence.
553
+ const odd = contextCompilerSetting({ DAEMON_CONTEXT_COMPILER: "2" });
554
+ assert.deepEqual(odd, { enabled: true, recognised: false, raw: "2" });
555
+ assert.equal(contextCompilerSetting({ DAEMON_CONTEXT_COMPILER: "off" }).recognised, true);
556
+ });
@@ -22,7 +22,8 @@ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
22
22
  import { tmpdir } from "node:os";
23
23
  import { join } from "node:path";
24
24
 
25
- import { loadConversationHistory } from "./responder.mjs";
25
+ import { loadConversationHistory, deriveRouterItem } from "./responder.mjs";
26
+ import { routingKey } from "./lib/session-router.mjs";
26
27
 
27
28
  const cohortItem = {
28
29
  sender: "Dana Okafor",
@@ -183,3 +184,38 @@ test("cohort: server history wins over the local mirror", async () => {
183
184
  assert.match(text, /authoritative line/, "the server is the source of truth");
184
185
  assert.doesNotMatch(text, /outdated mirror line/, "the stale mirror must not be mixed in");
185
186
  });
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Session continuity: the responder's router adapter (design §3 R10)
190
+ //
191
+ // `deriveRouterItem` returned null for `cohort`, so every Cohort turn fell back
192
+ // to a fresh pre-minted UUID and the session router — which exists, and which
193
+ // Slack and Gmail have used all along — never saw the one surface the owner
194
+ // actually uses.
195
+ // ---------------------------------------------------------------------------
196
+
197
+ test("deriveRouterItem keys a Cohort item on its room, so a follow-up can resume", () => {
198
+ const out = deriveRouterItem(cohortItem);
199
+ assert.deepEqual(out, {
200
+ source: "cohort",
201
+ channel: "cmql1802601iihhzpf774vwbk",
202
+ thread_root_id: null,
203
+ });
204
+ assert.equal(routingKey(out), "cohort:cmql1802601iihhzpf774vwbk");
205
+ });
206
+
207
+ test("deriveRouterItem narrows to the thread when there is one", () => {
208
+ const out = deriveRouterItem({ ...cohortItem, thread_id: "root-42" });
209
+ assert.equal(routingKey(out), "cohort:cmql1802601iihhzpf774vwbk:root-42");
210
+ });
211
+
212
+ test("deriveRouterItem refuses the display label as a key", () => {
213
+ // `task/<title>` and `doc/<name>` are labels; two rooms can share one.
214
+ assert.equal(deriveRouterItem({ sender: "Dana", service: "cohort", channel: "task/Ship the thing" }), null);
215
+ });
216
+
217
+ test("deriveRouterItem still keys slack, gmail and calendar exactly as before", () => {
218
+ assert.equal(routingKey(deriveRouterItem({ service: "slack", channel: "D099", thread_id: null })), "slack:D099");
219
+ assert.equal(routingKey(deriveRouterItem({ service: "gmail", thread_id: "t-1" })), "gmail:t-1");
220
+ assert.equal(routingKey(deriveRouterItem({ service: "calendar", event_id: "e-1" })), "calendar:e-1");
221
+ });
@@ -28,7 +28,13 @@ import { spawn } from "child_process";
28
28
  import { join } from "path";
29
29
  import { randomUUID } from "crypto";
30
30
  import { checkRecentlySent, registerSent } from "./session-lock.mjs";
31
- import { routingKey as deriveRoutingKey, createRouter } from "./lib/session-router.mjs";
31
+ import {
32
+ routingKey as deriveRoutingKey,
33
+ createRouter,
34
+ routerItemFromDaemonItem,
35
+ claimSession,
36
+ releaseSession,
37
+ } from "./lib/session-router.mjs";
32
38
  // Permission scoping (security CRITICAL / audit H1). sessionPermissionArgs()
33
39
  // preserves the historical "--dangerously-skip-permissions" by default and only
34
40
  // scopes tools when the operator opts in (MAESTRO_SCOPED_PERMISSIONS=1), so the
@@ -71,6 +77,7 @@ const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.ur
71
77
  const SONNET_MODEL = "claude-sonnet-4-6";
72
78
  // Resolve claude against the agent's PATH (not launchd's bare env).
73
79
  import { resolveClaudeBin, augmentedPath, daemonClaudeArgs } from "../../lib/claude-bin.mjs";
80
+ import { isPrivateConversation, interactionWriteDirNames } from "../../lib/context/history-scope.mjs";
74
81
  const CLAUDE_BIN = resolveClaudeBin();
75
82
  const CLAUDE_CLI_TIMEOUT_MS = 60_000;
76
83
  const SESSION_REGISTRY_PATH = join(AGENT_REPO_DIR, "state", "daemon", "session-router-registry.json");
@@ -101,45 +108,15 @@ function getRouter() {
101
108
  }
102
109
 
103
110
  /**
104
- * Translate a daemon-shaped item into the source/channel/thread_ts shape
105
- * that session-router's routingKey() expects.
111
+ * Translate a daemon-shaped item into the `{source, channel, …}` shape
112
+ * `session-router`'s `routingKey()` expects.
106
113
  *
107
- * Daemon items use {service, channel, thread_id, sender_email, ...}; the
108
- * router's pure key fn was specced against {source, channel, thread_ts,
109
- * thread_id, ...} per memo §4.2. This adapter is the seam between them.
110
- *
111
- * Returns null for items the router can't key (e.g. service we don't yet
112
- * support, or missing required fields). Caller falls back to a fresh
113
- * pre-minted UUID + EPHEMERAL semantics.
114
+ * The adapter itself now lives in `lib/session-router.mjs` beside the key
115
+ * function, because `dispatcher.mjs` needs the SAME translation: two copies
116
+ * would let one room be keyed two ways, which is the same thing as having no
117
+ * router. Exported here for the responder's own tests.
114
118
  */
115
- function deriveRouterItem(item) {
116
- if (!item || typeof item !== "object") return null;
117
-
118
- if (item.service === "slack") {
119
- const channel = item.channel || item.channel_id;
120
- if (!channel) return null;
121
- return {
122
- source: "slack",
123
- channel,
124
- thread_ts: item.thread_id || item.thread_ts || null,
125
- ts: item.ts || item.timestamp || null,
126
- };
127
- }
128
-
129
- if (item.service === "gmail") {
130
- const tid = item.thread_id || item.threadId;
131
- if (!tid) return null;
132
- return { source: "gmail", thread_id: tid };
133
- }
134
-
135
- if (item.service === "calendar") {
136
- const eid = item.event_id || item.eventId;
137
- if (!eid) return null;
138
- return { source: "calendar", event_id: eid };
139
- }
140
-
141
- return null;
142
- }
119
+ export const deriveRouterItem = routerItemFromDaemonItem;
143
120
 
144
121
  // Lazy token access — dotenv loads in daemon main before these are called
145
122
  function getSlackToken() {
@@ -438,10 +415,17 @@ function logInteraction(item, responseText, o = {}) {
438
415
  ? resolveSlackChannel(item)
439
416
  : (item.channel_id || item.channel || null);
440
417
 
441
- // Write to both channel-ID and sender-slug directories
442
- const dirs = [];
443
- if (channelId) dirs.push(join(AGENT_REPO_DIR, "memory", "interactions", service, String(channelId)));
444
- dirs.push(join(AGENT_REPO_DIR, "memory", "interactions", service, `dm-${senderSlug}`));
418
+ // The room always; the PERSON only when the exchange was actually private.
419
+ // This used to write both unconditionally, which made `dm-<slug>` a mixed
420
+ // store of private and public-room content — inert only for as long as
421
+ // nothing read it per-service. It is read now, so the gate belongs at both
422
+ // ends (`lib/context/history-scope.mjs`, design §5.5).
423
+ const dirs = interactionWriteDirNames({
424
+ channelId,
425
+ senderSlug,
426
+ isPrivate: isPrivateConversation(item),
427
+ }).map((name) => join(AGENT_REPO_DIR, "memory", "interactions", service, String(name)));
428
+ if (dirs.length === 0) return;
445
429
 
446
430
  const incomingEntry = {
447
431
  ts: item.ts || item.timestamp || new Date().toISOString(),
@@ -738,14 +722,25 @@ ${MESSAGE_CRAFT}`;
738
722
  let sessionId = null;
739
723
  if (routerItem) {
740
724
  try {
741
- key = deriveRoutingKey(routerItem);
742
- const decision = router.route(key);
743
- if (decision.decision === "RESUME" && decision.resumeId) {
744
- sessionId = decision.resumeId;
725
+ const candidateKey = deriveRoutingKey(routerItem);
726
+ const decision = router.route(candidateKey);
727
+ // One CLI process per key. `claimSession` is the same lease the dispatcher
728
+ // takes, in the same process, so a 60-second quick reply can never be
729
+ // handed the session id of a long agentic run that is still going — and
730
+ // two quick replies from one burst cannot share a transcript either.
731
+ if (claimSession(candidateKey)) {
732
+ key = candidateKey;
733
+ if (decision.decision === "RESUME" && decision.resumeId) {
734
+ sessionId = decision.resumeId;
735
+ } else {
736
+ // EPHEMERAL or EPHEMERAL_REPLACE — pre-mint a fresh UUID. Reusing
737
+ // the same key on next call (with a different sessionId) is fine;
738
+ // touch() will overwrite the registry entry.
739
+ sessionId = randomUUID();
740
+ }
745
741
  } else {
746
- // EPHEMERAL or EPHEMERAL_REPLACE — pre-mint a fresh UUID. Reusing
747
- // the same key on next call (with a different sessionId) is fine;
748
- // touch() will overwrite the registry entry.
742
+ // Already in flight: spawn cold, and stay out of the registry so the
743
+ // running session keeps ownership of the row.
749
744
  sessionId = randomUUID();
750
745
  }
751
746
  } catch (err) {
@@ -757,30 +752,37 @@ ${MESSAGE_CRAFT}`;
757
752
  }
758
753
  }
759
754
 
760
- const cliResult = await runClaudeCLI(systemPrompt, userContent, model, {
761
- sessionId,
762
- router: key ? router : null,
763
- routingKey: key,
764
- });
755
+ // The claim is held across the spawn AND the registry write that follows it,
756
+ // and released in a `finally`: a throw anywhere in between must not leave the
757
+ // room's key claimed forever, which would silently disable continuity for it.
758
+ try {
759
+ const cliResult = await runClaudeCLI(systemPrompt, userContent, model, {
760
+ sessionId,
761
+ router: key ? router : null,
762
+ routingKey: key,
763
+ });
765
764
 
766
- const text = (cliResult.text || "").trim();
767
- if (!text) {
768
- throw new Error("claude CLI returned empty result text in generateResponse");
769
- }
765
+ const text = (cliResult.text || "").trim();
766
+ if (!text) {
767
+ throw new Error("claude CLI returned empty result text in generateResponse");
768
+ }
770
769
 
771
- // (b3) On success, touch the registry with the CLI-resolved session_id so
772
- // the next call routed to this key gets a RESUME decision. Skip touch on
773
- // is_error responses or when JSON parsing failed (legacy fallback path).
774
- if (key && cliResult.jsonResult && cliResult.jsonResult.is_error !== true) {
775
- const claudeSessionId = cliResult.jsonResult.session_id || sessionId;
776
- try {
777
- await router.touch(key, { claudeSessionId, model });
778
- } catch (err) {
779
- console.warn(`[responder] router.touch failed for ${key}: ${err.message}`);
770
+ // (b3) On success, touch the registry with the CLI-resolved session_id so
771
+ // the next call routed to this key gets a RESUME decision. Skip touch on
772
+ // is_error responses or when JSON parsing failed (legacy fallback path).
773
+ if (key && cliResult.jsonResult && cliResult.jsonResult.is_error !== true) {
774
+ const claudeSessionId = cliResult.jsonResult.session_id || sessionId;
775
+ try {
776
+ await router.touch(key, { claudeSessionId, model });
777
+ } catch (err) {
778
+ console.warn(`[responder] router.touch failed for ${key}: ${err.message}`);
779
+ }
780
780
  }
781
- }
782
781
 
783
- return text;
782
+ return text;
783
+ } finally {
784
+ if (key) releaseSession(key);
785
+ }
784
786
  }
785
787
 
786
788
  // Default the generator slot to the real CLI-backed implementation. (Declared
@@ -993,9 +995,14 @@ export async function sendQuickResponse(item, classResult, routed = null) {
993
995
  * via the cheapest model under a tight (~5s, env-tunable) cap, and on ANY
994
996
  * generation failure this function sends NOTHING and says so in the log. The
995
997
  * durability story is unchanged — the caller opened the obligation before
996
- * calling this, the assurance sweep retries generation once within
997
- * ASSURANCE_ACK_GRACE_MS, and the progress/failure notices still guarantee the
998
- * human is never left in long-term silence.
998
+ * calling this, and the assurance sweep owns whether a line goes out at all.
999
+ *
1000
+ * NOTE (2026-09-12): the daemon no longer calls this from the acknowledgement
1001
+ * path. `assurance.openAndAcknowledge` says NOTHING at open time; the sweep
1002
+ * emits at most one interim per obligation, past ASSURANCE_ACK_AFTER_MS and
1003
+ * inside the per-room budget. The timed progress updates are deleted. The
1004
+ * failure, stale and interrupted notices still guarantee the human is never
1005
+ * left in long-term silence.
999
1006
  *
1000
1007
  * @returns {{ sent: boolean, holdingText: string|null }} result
1001
1008
  */