@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
@@ -11,7 +11,17 @@ import { promises as fsp } from "fs";
11
11
  import { tmpdir } from "os";
12
12
  import { join } from "path";
13
13
 
14
- import { routingKey, createRouter } from "./session-router.mjs";
14
+ import {
15
+ routingKey,
16
+ createRouter,
17
+ createRouterSync,
18
+ decideRoute,
19
+ routerItemFromDaemonItem,
20
+ claimSession,
21
+ releaseSession,
22
+ isSessionInFlight,
23
+ _resetInFlightForTests,
24
+ } from "./session-router.mjs";
15
25
 
16
26
  function tmpRegistryPath(suffix = "") {
17
27
  const name = `session-router-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${suffix}.json`;
@@ -293,3 +303,252 @@ test("registry round-trip — second router instance reads persisted state", asy
293
303
  assert.equal(dec.decision, "RESUME");
294
304
  assert.equal(dec.resumeId, "cli-A");
295
305
  });
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // 4. Conversation-shaped services (design §3 R10 / §5.6).
309
+ //
310
+ // `cohort` fell through to the throw, so every turn in the agent's own
311
+ // workspace was keyed by nothing and spawned cold. telegram / whatsapp /
312
+ // orgmail were in the same position.
313
+ // ---------------------------------------------------------------------------
314
+
315
+ test("routingKey — cohort channel with no thread is keyed by the room", () => {
316
+ assert.equal(routingKey({ source: "cohort", channel: "chan-abc" }), "cohort:chan-abc");
317
+ });
318
+
319
+ test("routingKey — cohort thread narrows the key, so two threads never share a session", () => {
320
+ const a = routingKey({ source: "cohort", channel: "chan-abc", thread_root_id: "msg-1" });
321
+ const b = routingKey({ source: "cohort", channel: "chan-abc", thread_root_id: "msg-2" });
322
+ assert.equal(a, "cohort:chan-abc:msg-1");
323
+ assert.notEqual(a, b);
324
+ });
325
+
326
+ test("routingKey — telegram / whatsapp / orgmail share the shape", () => {
327
+ assert.equal(routingKey({ source: "telegram", channel: "12345" }), "telegram:12345");
328
+ assert.equal(routingKey({ source: "whatsapp", channel: "4471@s.whatsapp.net" }), "whatsapp:4471@s.whatsapp.net");
329
+ assert.equal(routingKey({ source: "orgmail", channel: "mb-1", thread_id: "t-9" }), "orgmail:mb-1:t-9");
330
+ });
331
+
332
+ test("routingKey — a cohort item with no channel throws rather than colliding on a blank key", () => {
333
+ assert.throws(() => routingKey({ source: "cohort" }), /missing channel/);
334
+ });
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // 5. routerItemFromDaemonItem — ONE translation, both callers.
338
+ // ---------------------------------------------------------------------------
339
+
340
+ test("routerItemFromDaemonItem — a cohort item keys on channel_id, never the label", () => {
341
+ const out = routerItemFromDaemonItem({
342
+ service: "cohort",
343
+ channel: "task/Ship the thing", // the DISPLAY label
344
+ channel_id: "chan-real",
345
+ thread_id: "root-7",
346
+ });
347
+ assert.deepEqual(out, { source: "cohort", channel: "chan-real", thread_root_id: "root-7" });
348
+ assert.equal(routingKey(out), "cohort:chan-real:root-7");
349
+ });
350
+
351
+ test("routerItemFromDaemonItem — a cohort item with only a label has no session to continue", () => {
352
+ assert.equal(routerItemFromDaemonItem({ service: "cohort", channel: "doc/Pricing memo" }), null);
353
+ });
354
+
355
+ test("routerItemFromDaemonItem — slack / gmail / calendar are unchanged", () => {
356
+ assert.deepEqual(routerItemFromDaemonItem({ service: "slack", channel: "C1", thread_id: "1.2", ts: "3.4" }),
357
+ { source: "slack", channel: "C1", thread_ts: "1.2", ts: "3.4" });
358
+ assert.deepEqual(routerItemFromDaemonItem({ service: "gmail", thread_id: "t1" }), { source: "gmail", thread_id: "t1" });
359
+ assert.deepEqual(routerItemFromDaemonItem({ service: "calendar", event_id: "e1" }), { source: "calendar", event_id: "e1" });
360
+ });
361
+
362
+ test("routerItemFromDaemonItem — an unknown service is not keyed", () => {
363
+ assert.equal(routerItemFromDaemonItem({ service: "carrier-pigeon", channel_id: "x" }), null);
364
+ assert.equal(routerItemFromDaemonItem(null), null);
365
+ });
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // 6. decideRoute — the pure rule both IO shells share.
369
+ // ---------------------------------------------------------------------------
370
+
371
+ test("decideRoute — the memo §4.4 table", () => {
372
+ const live = { claude_session_id: "S1", last_used_at: 1000, status: "live", last_exit_code: 0 };
373
+ assert.deepEqual(decideRoute(undefined, { now: 1000, ttlSeconds: 30 }), { decision: "EPHEMERAL", resumeId: null });
374
+ assert.deepEqual(decideRoute(live, { now: 1000, ttlSeconds: 30 }), { decision: "RESUME", resumeId: "S1" });
375
+ assert.equal(decideRoute(live, { now: 1000 + 31_000, ttlSeconds: 30 }).decision, "EPHEMERAL_REPLACE");
376
+ assert.equal(decideRoute({ ...live, status: "killed" }, { now: 1000, ttlSeconds: 30 }).decision, "EPHEMERAL_REPLACE");
377
+ assert.equal(decideRoute({ ...live, last_exit_code: 1 }, { now: 1000, ttlSeconds: 30 }).decision, "EPHEMERAL_REPLACE");
378
+ });
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // 7. createRouterSync — the dispatcher's face of the SAME registry.
382
+ // ---------------------------------------------------------------------------
383
+
384
+ test("createRouterSync — a cold key is EPHEMERAL; a touched key then RESUMEs", async () => {
385
+ const path = tmpRegistryPath("-sync");
386
+ try {
387
+ const r = createRouterSync({ registryPath: path });
388
+ assert.equal(r.route("cohort:c1").decision, "EPHEMERAL");
389
+ r.touch("cohort:c1", { claudeSessionId: "S-live", daemonSessionId: "s-1", model: "sonnet" });
390
+ const again = r.route("cohort:c1");
391
+ assert.equal(again.decision, "RESUME");
392
+ assert.equal(again.resumeId, "S-live");
393
+ } finally {
394
+ await safeUnlink(path);
395
+ }
396
+ });
397
+
398
+ test("createRouterSync — the SYNC and ASYNC routers read the same file", async () => {
399
+ const path = tmpRegistryPath("-shared");
400
+ try {
401
+ const sync = createRouterSync({ registryPath: path });
402
+ sync.touch("cohort:c2", { claudeSessionId: "S-shared" });
403
+ const async_ = await createRouter({ registryPath: path });
404
+ const d = async_.route("cohort:c2");
405
+ assert.equal(d.decision, "RESUME");
406
+ assert.equal(d.resumeId, "S-shared", "one registry, or the dispatcher and the responder key two sessions per room");
407
+ } finally {
408
+ await safeUnlink(path);
409
+ }
410
+ });
411
+
412
+ test("createRouterSync — a non-zero exit stops the next turn resuming into a broken session", async () => {
413
+ const path = tmpRegistryPath("-exit");
414
+ try {
415
+ const r = createRouterSync({ registryPath: path });
416
+ r.touch("cohort:c3", { claudeSessionId: "S-bad" });
417
+ r.recordExit("cohort:c3", 1);
418
+ assert.equal(r.route("cohort:c3").decision, "EPHEMERAL_REPLACE");
419
+ } finally {
420
+ await safeUnlink(path);
421
+ }
422
+ });
423
+
424
+ test("createRouterSync — an unreadable registry routes cold rather than throwing", () => {
425
+ const r = createRouterSync({ registryPath: join(tmpdir(), "definitely", "not", "a", "path", "registry.json") });
426
+ assert.equal(r.route("cohort:c4").decision, "EPHEMERAL");
427
+ assert.equal(r.recordExit("cohort:c4", 0), false);
428
+ });
429
+
430
+ test("createRouterSync — a corrupt registry routes cold rather than throwing", async () => {
431
+ const path = tmpRegistryPath("-corrupt");
432
+ try {
433
+ await fsp.writeFile(path, "{ this is not json", "utf-8");
434
+ const r = createRouterSync({ registryPath: path });
435
+ assert.equal(r.route("cohort:c5").decision, "EPHEMERAL");
436
+ } finally {
437
+ await safeUnlink(path);
438
+ }
439
+ });
440
+
441
+ test("createRouterSync — the LRU cap is enforced", async () => {
442
+ const path = tmpRegistryPath("-lru");
443
+ try {
444
+ const r = createRouterSync({ registryPath: path, maxLiveSessions: 2 });
445
+ r.touch("cohort:a", { claudeSessionId: "A" });
446
+ r.touch("cohort:b", { claudeSessionId: "B" });
447
+ r.touch("cohort:c", { claudeSessionId: "C" });
448
+ assert.equal(r.route("cohort:a").decision, "EPHEMERAL", "oldest evicted");
449
+ assert.equal(r.route("cohort:c").decision, "RESUME");
450
+ } finally {
451
+ await safeUnlink(path);
452
+ }
453
+ });
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // TWO SHELLS, ONE FILE
457
+ //
458
+ // The dispatcher writes this registry through createRouterSync and the
459
+ // responder through createRouter. The async shell used to read the file once
460
+ // and keep the object for the daemon's life, so its `touch()` wrote a stale
461
+ // snapshot back — and the LRU sweep at the end of `touch()` deletes every key
462
+ // absent from that snapshot, which means one ordinary quick reply erased the
463
+ // dispatcher's live rows. The converse was worse than a lost row: the cached
464
+ // shell could answer RESUME for a session the other shell had already recorded
465
+ // as killed, handing a 60-second --print reply the transcript of a long
466
+ // agentic session.
467
+ // ---------------------------------------------------------------------------
468
+
469
+ test("an async touch() does not delete rows the SYNC shell wrote", async (t) => {
470
+ const path = tmpRegistryPath("-two-shells");
471
+ t.after(() => safeUnlink(path));
472
+
473
+ const async_ = await createRouter({ registryPath: path });
474
+ const sync = createRouterSync({ registryPath: path });
475
+
476
+ await async_.touch("slack:D1", { claudeSessionId: "S-QUICK" });
477
+ sync.touch("cohort:C-eng", { claudeSessionId: "S-LONG" });
478
+
479
+ // One perfectly ordinary quick reply on the other key.
480
+ await async_.touch("slack:D1", { claudeSessionId: "S-QUICK" });
481
+
482
+ const onDisk = JSON.parse(await fsp.readFile(path, "utf-8"));
483
+ assert.deepEqual(
484
+ Object.keys(onDisk.sessions).sort(),
485
+ ["cohort:C-eng", "slack:D1"],
486
+ "the async shell must merge onto what is on disk, not overwrite it with a snapshot",
487
+ );
488
+ assert.equal(sync.route("cohort:C-eng").decision, "RESUME", "§5.6 continuity survives an interleaved quick reply");
489
+ });
490
+
491
+ test("an async route() sees an exit the SYNC shell recorded — no resume into a killed session", async (t) => {
492
+ const path = tmpRegistryPath("-two-shells-exit");
493
+ t.after(() => safeUnlink(path));
494
+
495
+ const async_ = await createRouter({ registryPath: path });
496
+ const sync = createRouterSync({ registryPath: path });
497
+
498
+ sync.touch("cohort:C-eng", { claudeSessionId: "S-LONG" });
499
+ assert.equal(async_.route("cohort:C-eng").decision, "RESUME", "a live row resumes from either shell");
500
+
501
+ sync.recordExit("cohort:C-eng", 137);
502
+ const after = async_.route("cohort:C-eng");
503
+ assert.equal(after.decision, "EPHEMERAL_REPLACE", "a killed session must not be resumed by the other shell");
504
+ assert.equal(after.resumeId, null);
505
+ });
506
+
507
+ test("an async recordExit() is visible to the sync shell, and vice versa", async (t) => {
508
+ const path = tmpRegistryPath("-two-shells-sym");
509
+ t.after(() => safeUnlink(path));
510
+
511
+ const async_ = await createRouter({ registryPath: path });
512
+ const sync = createRouterSync({ registryPath: path });
513
+
514
+ sync.touch("cohort:C-x", { claudeSessionId: "S-1" });
515
+ await async_.recordExit("cohort:C-x", 1);
516
+ assert.equal(sync.route("cohort:C-x").decision, "EPHEMERAL_REPLACE");
517
+ });
518
+
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // the in-flight lease — one CLI process per session id
522
+ // ---------------------------------------------------------------------------
523
+
524
+ test("claimSession is exclusive, and releaseSession is idempotent", () => {
525
+ _resetInFlightForTests();
526
+ assert.equal(claimSession("cohort:C-eng"), true);
527
+ assert.equal(claimSession("cohort:C-eng"), false, "a second claim on a live key is refused");
528
+ assert.equal(isSessionInFlight("cohort:C-eng"), true);
529
+ releaseSession("cohort:C-eng");
530
+ releaseSession("cohort:C-eng");
531
+ assert.equal(isSessionInFlight("cohort:C-eng"), false);
532
+ assert.equal(claimSession("cohort:C-eng"), true);
533
+ _resetInFlightForTests();
534
+ assert.equal(claimSession(""), false, "an empty key is not a key");
535
+ });
536
+
537
+ test("neither shell resumes a key that already has a process on it", async (t) => {
538
+ const path = tmpRegistryPath("-inflight");
539
+ t.after(() => { _resetInFlightForTests(); return safeUnlink(path); });
540
+ _resetInFlightForTests();
541
+
542
+ const sync = createRouterSync({ registryPath: path });
543
+ const async_ = await createRouter({ registryPath: path });
544
+ sync.touch("cohort:C-busy", { claudeSessionId: "S-LIVE" });
545
+
546
+ assert.equal(sync.route("cohort:C-busy").decision, "RESUME", "idle: the row is resumable");
547
+ claimSession("cohort:C-busy");
548
+ assert.deepEqual(sync.route("cohort:C-busy"), { decision: "EPHEMERAL", resumeId: null });
549
+ assert.deepEqual(async_.route("cohort:C-busy"), { decision: "EPHEMERAL", resumeId: null },
550
+ "a 60-second quick reply must not be handed a running agentic session's transcript");
551
+
552
+ releaseSession("cohort:C-busy");
553
+ assert.equal(sync.route("cohort:C-busy").decision, "RESUME", "continuity returns when the room goes quiet");
554
+ });
@@ -13,6 +13,7 @@ import { isEnabled as orgEnabled } from "../../lib/org/client.mjs";
13
13
  import { recall as orgRecall } from "../../lib/org/knowledge.mjs";
14
14
  import { outcomeSourceShareable } from "./session-outcomes.mjs";
15
15
  import { withParallelism } from "../../lib/prompts/parallelism.mjs";
16
+ import { isPrivateConversation, historyDirNames } from "../../lib/context/history-scope.mjs";
16
17
 
17
18
  const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
18
19
 
@@ -38,6 +39,74 @@ function loadAgent() {
38
39
  // Legacy: Maximum lines of conversation history (used when DAEMON_CONTEXT_COMPILER is off)
39
40
  const MAX_HISTORY_LINES = 30;
40
41
 
42
+ /**
43
+ * Is the compiled-context path on?
44
+ *
45
+ * IT DEFAULTS ON, and that is the fix. The code read `=== "1"` while
46
+ * `.env.example:231` shipped `DAEMON_CONTEXT_COMPILER=1`, so which of two
47
+ * materially different context paths ran depended on whether the operator had
48
+ * copied the example env — an agent set up by hand got the legacy path and an
49
+ * agent set up from the template got the compiled one, with nothing anywhere
50
+ * saying so (design §3 R11).
51
+ *
52
+ * One path ships. The legacy path stays reachable for a seat that has to turn
53
+ * it off in a hurry (`DAEMON_CONTEXT_COMPILER=0`), which is why the flag is not
54
+ * simply deleted.
55
+ *
56
+ * A value that is neither a recognised on nor a recognised off is the dangerous
57
+ * case, because it selects between two materially different context paths and
58
+ * an operator who wrote `disabled` meant OFF. The word lists below are wide
59
+ * enough to cover what people actually write, and {@link contextCompilerSetting}
60
+ * reports the leftovers so the caller can say so out loud instead of resolving
61
+ * a typo in silence.
62
+ *
63
+ * @param {NodeJS.ProcessEnv} [env]
64
+ * @returns {boolean}
65
+ */
66
+ export function contextCompilerEnabled(env = process.env) {
67
+ return contextCompilerSetting(env).enabled;
68
+ }
69
+
70
+ /** Values that mean OFF. Wider than `"0"` because operators write words. */
71
+ const CONTEXT_COMPILER_OFF = Object.freeze(["0", "false", "off", "no", "n", "disabled", "disable", "none"]);
72
+
73
+ /** Values that mean ON. */
74
+ const CONTEXT_COMPILER_ON = Object.freeze(["1", "true", "on", "yes", "y", "enabled", "enable"]);
75
+
76
+ /**
77
+ * The flag, and whether the operator's word was one we know.
78
+ *
79
+ * PURE — the env arrives on the argument.
80
+ *
81
+ * @param {NodeJS.ProcessEnv} [env]
82
+ * @returns {{enabled:boolean, recognised:boolean, raw:string}}
83
+ */
84
+ export function contextCompilerSetting(env = process.env) {
85
+ const raw = String((env && env.DAEMON_CONTEXT_COMPILER) ?? "").trim().toLowerCase();
86
+ if (!raw) return { enabled: true, recognised: true, raw };
87
+ if (CONTEXT_COMPILER_OFF.includes(raw)) return { enabled: false, recognised: true, raw };
88
+ if (CONTEXT_COMPILER_ON.includes(raw)) return { enabled: true, recognised: true, raw };
89
+ return { enabled: true, recognised: false, raw };
90
+ }
91
+
92
+ /** Warn once per process about a DAEMON_CONTEXT_COMPILER value we do not know. */
93
+ let warnedAboutContextCompiler = false;
94
+ function warnIfContextCompilerUnrecognised(env = process.env) {
95
+ if (warnedAboutContextCompiler) return;
96
+ const setting = contextCompilerSetting(env);
97
+ if (setting.recognised) return;
98
+ warnedAboutContextCompiler = true;
99
+ console.warn(
100
+ `[prompt-builder] DAEMON_CONTEXT_COMPILER="${setting.raw}" is not a value this reads. ` +
101
+ `Using the compiled context path. Set it to 0 to use the legacy path.`,
102
+ );
103
+ }
104
+
105
+ /** For tests: allow the one-time warning to fire again. */
106
+ export function _resetContextCompilerWarning() {
107
+ warnedAboutContextCompiler = false;
108
+ }
109
+
41
110
  // Org shared-knowledge injection (org enrollment). Bounded so a large recall can
42
111
  // never blow up the prompt: at most this many facts, each truncated.
43
112
  const ORG_RECALL_MAX_FACTS = 6;
@@ -603,8 +672,22 @@ function today() {
603
672
 
604
673
  /**
605
674
  * Load recent conversation history for a sender/channel from interaction logs.
606
- * Searches memory/interactions/slack/{channel-or-sender}/ for recent JSONL entries.
607
- * Returns the most recent entries as formatted context text.
675
+ *
676
+ * IT READS THE ITEM'S OWN SERVICE. `responder.mjs#logInteraction` files every
677
+ * exchange under `memory/interactions/<service>/…` and has done since the
678
+ * write-half was fixed; this read looked only under `…/slack/…`, so a Cohort,
679
+ * Telegram or WhatsApp conversation was written down and then never found
680
+ * (design §3 R12). The agent had the record and could not see it.
681
+ *
682
+ * Slack keeps its legacy `<sender-slug>` directory alongside the `dm-` one —
683
+ * historical logs live there and must not go dark on this change.
684
+ *
685
+ * AND IT READS ONLY WHAT THIS ROOM MAY SEE. `logInteraction` files every
686
+ * exchange under BOTH the room and `dm-<sender-slug>`; merging all of them into
687
+ * one block put a person's private DM sentence into the prompt built for a
688
+ * public channel the moment this read stopped being Slack-only. The gate is
689
+ * `lib/context/history-scope.mjs`, shared with the writer and with the compiled
690
+ * path so the three cannot drift (design §5.5, resource scope).
608
691
  */
609
692
  function loadConversationHistory(item) {
610
693
  if (!item) return null;
@@ -613,13 +696,14 @@ function loadConversationHistory(item) {
613
696
  // Try multiple paths where interaction logs may live
614
697
  const channelId = extractChannelId(item);
615
698
  const senderSlug = item.sender ? item.sender.replace(/\s+/g, "-").toLowerCase() : null;
699
+ const service = String(item.service || "slack");
616
700
 
617
- const candidateDirs = [];
618
- if (channelId) candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", "slack", channelId));
619
- if (senderSlug) {
620
- candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", "slack", `dm-${senderSlug}`));
621
- candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", "slack", senderSlug));
622
- }
701
+ const candidateDirs = historyDirNames({
702
+ channelId,
703
+ senderSlug,
704
+ isPrivate: isPrivateConversation(item),
705
+ service,
706
+ }).map((name) => join(AGENT_REPO_DIR, "memory", "interactions", service, name));
623
707
 
624
708
  const entries = [];
625
709
 
@@ -681,7 +765,7 @@ function loadConversationHistory(item) {
681
765
  */
682
766
  function extractChannelId(item) {
683
767
  if (!item) return null;
684
- // Direct channel ID field
768
+ // Direct channel ID field — every non-Slack service sets this.
685
769
  if (item.channel_id) return item.channel_id;
686
770
  // From raw_ref format: "slack:D099N1JGKRQ:1775331885.690669"
687
771
  if (item.raw_ref) {
@@ -836,11 +920,18 @@ function buildBacklogContext(queueItem) {
836
920
  *
837
921
  * @param {object} item - The inbox item (sender, content, channel, service, subject, thread_context, etc.)
838
922
  * @param {object} classResult - Classifier output (priority, action, model, summary, category)
839
- * @param {object} options - { type: "inbox" | "backlog", queueItem?: object }
923
+ * @param {object} options - { type, queueItem?, holdingMessage?, holdingSent?, interimAlreadySent? }
924
+ * `holdingMessage` the interim's TEXT, when this process composed it.
925
+ * `holdingSent` false ⇒ it was composed but did not land.
926
+ * `interimAlreadySent` true ⇒ an interim reached the sender but its text is
927
+ * not available here (the re-delivery / retry path,
928
+ * where the debt is already open with interimSaid). It
929
+ * is what stops the no-contact block asserting silence
930
+ * at a session whose human has already been spoken to.
840
931
  * @returns {string} Prompt string ready for claude --print
841
932
  */
842
933
  export async function buildPrompt(item, classResult, options = {}) {
843
- const { type = "inbox", queueItem, holdingMessage, holdingSent } = options;
934
+ const { type = "inbox", queueItem, holdingMessage, holdingSent, interimAlreadySent } = options;
844
935
  // Only `false` — an explicit "the send failed" from the caller — flips the
845
936
  // framing. Callers that pass no flag keep the historical assertion, so this
846
937
  // cannot silently downgrade a genuinely delivered acknowledgement.
@@ -895,16 +986,31 @@ export async function buildPrompt(item, classResult, options = {}) {
895
986
  // 1a. Holding message warning — TOP OF PROMPT so Claude sees it before action instructions.
896
987
  // This is the most critical instruction in the prompt: prevents double-replies.
897
988
  // We repeat it at section 7a as well, immediately before the action block.
989
+ //
990
+ // WHEN EACH BRANCH IS REACHED (2026-09-12). The daemon opens the obligation
991
+ // and says NOTHING, so on the ordinary inbound path `holdingMessage` is null.
992
+ // The first two branches exist for the case where an interim genuinely did go
993
+ // out WITH ITS TEXT IN HAND before the prompt was built: the PLAN tier, whose
994
+ // one message is posted as the first step of the work. Do not delete them as
995
+ // dead; they are the correct rendering of a fact that is about to be common.
996
+ //
997
+ // The third branch is for an interim that went out whose TEXT THIS PROCESS
998
+ // DOES NOT HAVE — a re-delivery of an item whose debt is already open with
999
+ // `interimSaid: true`, which `openAndAcknowledge` answers with
1000
+ // `{acked:true, ackText:null}`. Before it existed, that case fell into FIRST
1001
+ // CONTACT and told a RETRY session that a human who had already received a
1002
+ // holding line AND a "Hit a problem — … Retrying now" had heard nothing.
898
1003
  if (holdingMessage && holdingDelivered) {
899
1004
  parts.push("===== STOP — READ THIS FIRST =====");
900
1005
  parts.push(`A HOLDING MESSAGE has ALREADY been sent to the sender by the daemon. The exact text was:`);
901
1006
  parts.push(` "${holdingMessage}"`);
902
1007
  parts.push("");
1008
+ parts.push("That was the ONE interim this ask gets. The sender will hear nothing else until you deliver something substantive, and nothing in the daemon will fill the gap — the timed progress updates were deleted on 2026-09-12 because a message derived from elapsed minutes carries no information.");
903
1009
  parts.push("Your job is to deliver the FULL substantive response or complete the actual work — NOT to acknowledge again.");
904
1010
  parts.push("- Do NOT start your reply with 'Got it', 'On it', 'Looking into this', 'Will get back to you', 'Thanks for reaching out', or any similar acknowledgment phrase.");
905
1011
  parts.push("- Do NOT echo what the user asked — they already received the holding note confirming receipt.");
906
1012
  parts.push("- Open with the actual answer, the actual draft, or the actual finding. Be direct.");
907
- parts.push("- If after investigation you still cannot deliver a substantive response and need more time, send a SECOND-LEVEL UPDATE (specific blocker, ETA, what you need from the user) — never a generic 'still looking into it'.");
1013
+ parts.push("- If the work runs long, say nothing OR say something with CONTENT in it — a specific blocker, a partial finding, what you need from the user. Never 'still looking into it', never a status line whose only content is how long it has been.");
908
1014
  parts.push("===== END WARNING =====");
909
1015
  parts.push("");
910
1016
  } else if (holdingMessage) {
@@ -926,6 +1032,40 @@ export async function buildPrompt(item, classResult, options = {}) {
926
1032
  parts.push("- If you do NOT, do not let the item evaporate. Record the undeliverable ask and escalate it to the operator — silence plus a closed inbox item is how a request disappears.");
927
1033
  parts.push("===== END WARNING =====");
928
1034
  parts.push("");
1035
+ } else if (interimAlreadySent && type === "inbox" && item) {
1036
+ // AN INTERIM WENT OUT AND THIS PROCESS CANNOT QUOTE IT. See the note above:
1037
+ // this is the re-delivery / retry path. What it must NOT say is "the sender
1038
+ // has heard nothing", which is the specific falsehood the FIRST CONTACT
1039
+ // block would assert here.
1040
+ parts.push("===== ALREADY IN CONTACT =====");
1041
+ parts.push("A short interim about this item has already reached the sender. This process no longer holds its text, so do not quote it or guess at it.");
1042
+ parts.push("That was the ONE interim this ask gets. Nothing else will be sent on your behalf — the timed progress updates were deleted on 2026-09-12 because a message derived from elapsed minutes carries no information.");
1043
+ parts.push("- Deliver the substantive response. Do NOT acknowledge receipt again.");
1044
+ parts.push("- Do not apologise for the delay and do not recap what they asked.");
1045
+ parts.push("- If the work runs long, say nothing OR say something with CONTENT in it — a specific blocker, a partial finding, what you need from the sender.");
1046
+ parts.push("===== END =====");
1047
+ parts.push("");
1048
+ } else if (type === "inbox" && item) {
1049
+ // NOTHING HAS BEEN SENT, AND THAT IS THE DEFAULT. Gated on `inbox` because a
1050
+ // backlog item is work the agent gave itself — there is no sender on the
1051
+ // other end of it and this block would be addressed to nobody.
1052
+ //
1053
+ // WHAT THIS BLOCK MAY AND MAY NOT CLAIM. It is built at dispatch time, t≈0.
1054
+ // It can state a FACT about the past — nothing has gone out yet — and it
1055
+ // must not state a PREDICTION about the future, which is what "your reply is
1056
+ // the first thing they will read" was: measured p50 session duration is
1057
+ // 14.7 min against an ACK_AFTER_MS of 90 s, so for most sessions the sweep
1058
+ // will in fact have posted one line before the reply lands. A prompt that
1059
+ // tells a session it is the first voice in the room, when a holding line
1060
+ // lands in front of it, produces exactly the double-contact the first two
1061
+ // blocks exist to prevent — in the other direction.
1062
+ parts.push("===== NO CONTACT YET =====");
1063
+ parts.push("Nothing has been sent to the sender about this item so far. If this runs long, the daemon may post at most ONE short holding line before your reply — you will not be told if it does, and it is the only one this ask gets.");
1064
+ parts.push("- Open with the answer, the draft or the finding. Do not open by acknowledging receipt, and do not apologise for the delay.");
1065
+ parts.push("- Do not write as though a conversation is already underway — no 'as I mentioned', no 'following up on my earlier note'.");
1066
+ parts.push("- If you cannot deliver something substantive, say the specific thing that is blocking you. Never a bare 'looking into it'.");
1067
+ parts.push("===== END =====");
1068
+ parts.push("");
929
1069
  }
930
1070
 
931
1071
  // 2. Session context
@@ -951,7 +1091,8 @@ export async function buildPrompt(item, classResult, options = {}) {
951
1091
  // (context-compiler.mjs → loadOrgMemory), so skip here to avoid a duplicate
952
1092
  // recall + duplicated context in the same prompt. Backlog items pass their
953
1093
  // queueItem into compileContext (below) so entity coverage is preserved.
954
- if (process.env.DAEMON_CONTEXT_COMPILER !== "1") {
1094
+ warnIfContextCompilerUnrecognised();
1095
+ if (!contextCompilerEnabled()) {
955
1096
  const orgBlock = await buildOrgKnowledgeBlock(item, classResult, queueItem);
956
1097
  if (orgBlock) {
957
1098
  parts.push(orgBlock);
@@ -960,7 +1101,7 @@ export async function buildPrompt(item, classResult, options = {}) {
960
1101
  }
961
1102
 
962
1103
  // 4. Compiled context (profile + history + disclosure boundaries + active sessions + sent messages)
963
- if (process.env.DAEMON_CONTEXT_COMPILER === "1" && (type === "inbox" ? item : true)) {
1104
+ if (contextCompilerEnabled() && (type === "inbox" ? item : true)) {
964
1105
  try {
965
1106
  const { contextBlock } = await compileContext(
966
1107
  item || {},
@@ -987,17 +1128,20 @@ export async function buildPrompt(item, classResult, options = {}) {
987
1128
  // If a holding message was already sent, prepend a second reminder so the
988
1129
  // action block is unambiguous about not re-acknowledging.
989
1130
  if (holdingMessage && holdingDelivered) {
990
- parts.push("REMINDER: A holding message was already sent (see top of prompt). The action below describes WHAT to do — but you must NOT begin your reply with another acknowledgment. Open with substance.");
1131
+ parts.push("REMINDER: A holding message was already sent (see top of prompt), and it was the only one this ask gets. The action below describes WHAT to do — but you must NOT begin your reply with another acknowledgment. Open with substance.");
991
1132
  parts.push("");
992
1133
  } else if (holdingMessage) {
993
1134
  parts.push("REMINDER: The acknowledgement for this item FAILED to send (see top of prompt) — the sender has heard nothing. The action below describes WHAT to do; carry it out knowing this is still first contact, and escalate rather than close the item silently if you cannot reach them.");
994
1135
  parts.push("");
1136
+ } else if (interimAlreadySent) {
1137
+ parts.push("REMINDER: An interim about this item already reached the sender (see top of prompt), and it was the only one this ask gets. The action below describes WHAT to do — but you must NOT begin your reply with another acknowledgment. Open with substance.");
1138
+ parts.push("");
995
1139
  }
996
1140
  parts.push(actionBlock);
997
1141
  parts.push("");
998
1142
 
999
1143
  // 6. User profile lookup — legacy only (when feature flag is off, profile is in compiled context)
1000
- if (process.env.DAEMON_CONTEXT_COMPILER !== "1" && type === "inbox" && item && item.sender) {
1144
+ if (!contextCompilerEnabled() && type === "inbox" && item && item.sender) {
1001
1145
  const profileName = item.sender.replace(/\s+/g, "-").toLowerCase();
1002
1146
  parts.push(`Before responding, read memory/profiles/users/${profileName}.yaml for sender preferences, standing instructions, and relationship context. If the file does not exist, proceed without it.`);
1003
1147
  parts.push("");