@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.
- package/.env.example +5 -2
- package/docs/guides/front-door-session.md +16 -5
- package/docs/guides/poller-daemon-setup.md +53 -2
- package/lib/assurance/plan-note.mjs +251 -0
- package/lib/assurance/plan-note.test.mjs +234 -0
- package/lib/assurance/room-budget.mjs +497 -0
- package/lib/assurance/room-budget.test.mjs +486 -0
- package/lib/assurance/tier.mjs +166 -0
- package/lib/assurance/tier.test.mjs +174 -0
- package/lib/comms/receipts.mjs +17 -1
- package/lib/context/budget.mjs +327 -0
- package/lib/context/budget.test.mjs +252 -0
- package/lib/context/history-scope.mjs +138 -0
- package/lib/context/history-scope.test.mjs +79 -0
- package/lib/model-router/economics.mjs +9 -0
- package/lib/model-router/resolve.mjs +6 -0
- package/lib/org/inbound/facts.mjs +4 -2
- package/lib/org/inbound/hydrate.mjs +555 -51
- package/lib/org/inbound/hydrate.test.mjs +456 -1
- package/package.json +3 -1
- package/plugins/maestro-skills/skills/inbound-triage.md +52 -24
- package/plugins/maestro-skills/skills/main-session.md +6 -4
- package/scripts/daemon/agent-daemon.mjs +35 -7
- package/scripts/daemon/agent-daemon.test.mjs +23 -6
- package/scripts/daemon/assurance-e2e.test.mjs +75 -19
- package/scripts/daemon/assurance.mjs +663 -159
- package/scripts/daemon/assurance.test.mjs +820 -140
- package/scripts/daemon/context-compiler.mjs +52 -21
- package/scripts/daemon/context-compiler.test.mjs +106 -0
- package/scripts/daemon/deliver.mjs +7 -4
- package/scripts/daemon/dispatcher-session-continuity.test.mjs +365 -0
- package/scripts/daemon/dispatcher.mjs +210 -9
- package/scripts/daemon/lib/session-router.mjs +310 -42
- package/scripts/daemon/lib/session-router.test.mjs +260 -1
- package/scripts/daemon/prompt-builder.mjs +160 -16
- package/scripts/daemon/prompt-builder.test.mjs +287 -7
- package/scripts/daemon/responder-history.test.mjs +37 -1
- 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 {
|
|
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
|
-
*
|
|
607
|
-
*
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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("");
|