@cohortapp/agent-sdk 2.16.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/poller-daemon-setup.md +4 -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/scripts/daemon/context-compiler.mjs +52 -21
- package/scripts/daemon/context-compiler.test.mjs +106 -0
- 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 +97 -12
- package/scripts/daemon/prompt-builder.test.mjs +219 -7
- package/scripts/daemon/responder-history.test.mjs +37 -1
- package/scripts/daemon/responder.mjs +71 -69
|
@@ -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) {
|
|
@@ -1007,7 +1091,8 @@ export async function buildPrompt(item, classResult, options = {}) {
|
|
|
1007
1091
|
// (context-compiler.mjs → loadOrgMemory), so skip here to avoid a duplicate
|
|
1008
1092
|
// recall + duplicated context in the same prompt. Backlog items pass their
|
|
1009
1093
|
// queueItem into compileContext (below) so entity coverage is preserved.
|
|
1010
|
-
|
|
1094
|
+
warnIfContextCompilerUnrecognised();
|
|
1095
|
+
if (!contextCompilerEnabled()) {
|
|
1011
1096
|
const orgBlock = await buildOrgKnowledgeBlock(item, classResult, queueItem);
|
|
1012
1097
|
if (orgBlock) {
|
|
1013
1098
|
parts.push(orgBlock);
|
|
@@ -1016,7 +1101,7 @@ export async function buildPrompt(item, classResult, options = {}) {
|
|
|
1016
1101
|
}
|
|
1017
1102
|
|
|
1018
1103
|
// 4. Compiled context (profile + history + disclosure boundaries + active sessions + sent messages)
|
|
1019
|
-
if (
|
|
1104
|
+
if (contextCompilerEnabled() && (type === "inbox" ? item : true)) {
|
|
1020
1105
|
try {
|
|
1021
1106
|
const { contextBlock } = await compileContext(
|
|
1022
1107
|
item || {},
|
|
@@ -1056,7 +1141,7 @@ export async function buildPrompt(item, classResult, options = {}) {
|
|
|
1056
1141
|
parts.push("");
|
|
1057
1142
|
|
|
1058
1143
|
// 6. User profile lookup — legacy only (when feature flag is off, profile is in compiled context)
|
|
1059
|
-
if (
|
|
1144
|
+
if (!contextCompilerEnabled() && type === "inbox" && item && item.sender) {
|
|
1060
1145
|
const profileName = item.sender.replace(/\s+/g, "-").toLowerCase();
|
|
1061
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.`);
|
|
1062
1147
|
parts.push("");
|