@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
|
@@ -12,12 +12,38 @@ import yaml from "js-yaml";
|
|
|
12
12
|
const ORG_AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-pb-org-"));
|
|
13
13
|
mkdirSync(join(ORG_AGENT_DIR, "config"), { recursive: true });
|
|
14
14
|
process.env.AGENT_DIR = ORG_AGENT_DIR;
|
|
15
|
-
const { buildPrompt, buildInboxContext, _resetOrgConfigCache } = await import("./prompt-builder.mjs");
|
|
15
|
+
const { buildPrompt, buildInboxContext, _resetOrgConfigCache, contextCompilerEnabled } = await import("./prompt-builder.mjs");
|
|
16
|
+
const { _resetOrgCfgCache } = await import("./context-compiler.mjs");
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run `fn` with the compiled-context path OFF.
|
|
20
|
+
*
|
|
21
|
+
* The three org-knowledge tests below assert the LEGACY `--- ORG SHARED
|
|
22
|
+
* KNOWLEDGE ---` block, which `buildPrompt` emits only on that path. The flag
|
|
23
|
+
* now defaults ON (design §3 R11), so the legacy assertions have to say which
|
|
24
|
+
* path they are about instead of relying on a default that has moved. The
|
|
25
|
+
* compiled path's own org injection is pinned separately, below.
|
|
26
|
+
*/
|
|
27
|
+
async function withLegacyContext(fn) {
|
|
28
|
+
const prior = process.env.DAEMON_CONTEXT_COMPILER;
|
|
29
|
+
process.env.DAEMON_CONTEXT_COMPILER = "0";
|
|
30
|
+
try {
|
|
31
|
+
return await fn();
|
|
32
|
+
} finally {
|
|
33
|
+
if (prior === undefined) delete process.env.DAEMON_CONTEXT_COMPILER;
|
|
34
|
+
else process.env.DAEMON_CONTEXT_COMPILER = prior;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
16
37
|
|
|
17
38
|
/** Write config/org.yaml into the temp agent dir and drop the cached read. */
|
|
18
39
|
function writeOrgConfig(doc) {
|
|
19
40
|
writeFileSync(join(ORG_AGENT_DIR, "config/org.yaml"), doc == null ? "" : yaml.dump(doc));
|
|
20
41
|
_resetOrgConfigCache();
|
|
42
|
+
// The compiled-context path caches its OWN read of config/org.yaml. Both
|
|
43
|
+
// caches have to drop, or a test that disables the integration is served the
|
|
44
|
+
// previous test's enabled config and "proves" a network call that the config
|
|
45
|
+
// on disk forbids.
|
|
46
|
+
_resetOrgCfgCache();
|
|
21
47
|
}
|
|
22
48
|
|
|
23
49
|
/** Stub global fetch to serve a knowledge.search frame, restore on cleanup. */
|
|
@@ -85,9 +111,9 @@ test("buildInboxContext fences thread context too", () => {
|
|
|
85
111
|
const ITEM = { service: "slack", channel: "C123", channel_id: "C123", channel_type: "channel", sender: "Dana", content: "What is our refund policy?" };
|
|
86
112
|
const CLASS = { priority: "normal", action: "respond", category: "ops", summary: "refund policy question" };
|
|
87
113
|
|
|
88
|
-
test("buildPrompt injects ORG SHARED KNOWLEDGE when org.cohort is enabled", async () => {
|
|
114
|
+
test("buildPrompt injects ORG SHARED KNOWLEDGE when org.cohort is enabled (legacy path)", async () => {
|
|
89
115
|
writeOrgConfig({ org: { cohort: { enabled: true, base: "https://cohort.example", token: "tok" } } });
|
|
90
|
-
await withFetch(
|
|
116
|
+
await withLegacyContext(() => withFetch(
|
|
91
117
|
fetchReturningFacts([
|
|
92
118
|
{ id: "k1", text: "Refunds are issued within 14 days of purchase." },
|
|
93
119
|
{ id: "k2", text: "Enterprise contracts are non-refundable after onboarding." },
|
|
@@ -103,7 +129,7 @@ test("buildPrompt injects ORG SHARED KNOWLEDGE when org.cohort is enabled", asyn
|
|
|
103
129
|
assert.ok(prompt.includes("--- INCOMING MESSAGE ---"));
|
|
104
130
|
assert.match(prompt, /Log all actions taken/);
|
|
105
131
|
}
|
|
106
|
-
);
|
|
132
|
+
));
|
|
107
133
|
});
|
|
108
134
|
|
|
109
135
|
test("buildPrompt does NOT inject org knowledge when the integration is disabled", async () => {
|
|
@@ -121,6 +147,50 @@ test("buildPrompt does NOT inject org knowledge when the integration is disabled
|
|
|
121
147
|
);
|
|
122
148
|
});
|
|
123
149
|
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// DAEMON_CONTEXT_COMPILER — ONE path ships (design §3 R11)
|
|
152
|
+
//
|
|
153
|
+
// The code read `=== "1"` while .env.example shipped `=1`, so which of two
|
|
154
|
+
// materially different context paths ran depended on whether the operator had
|
|
155
|
+
// copied the example env. These pin the default and the escape hatch.
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
test("the compiled-context path is ON by default, matching .env.example", () => {
|
|
159
|
+
assert.equal(contextCompilerEnabled({}), true, "unset must mean on");
|
|
160
|
+
assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: "1" }), true);
|
|
161
|
+
assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: "" }), true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("the compiled-context path can still be turned off in a hurry", () => {
|
|
165
|
+
for (const v of ["0", "false", "off", "no", "OFF"]) {
|
|
166
|
+
assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: v }), false, v);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("on the DEFAULT path the org's facts still reach the prompt", async () => {
|
|
171
|
+
writeOrgConfig({ org: { cohort: { enabled: true, base: "https://cohort.example", token: "tok" } } });
|
|
172
|
+
await withFetch(
|
|
173
|
+
fetchReturningFacts([{ id: "k1", text: "Refunds are issued within 14 days of purchase." }]),
|
|
174
|
+
async () => {
|
|
175
|
+
const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
|
|
176
|
+
assert.ok(prompt.includes("Refunds are issued within 14 days"), "org recall must survive the default path");
|
|
177
|
+
assert.ok(prompt.includes("--- INCOMING MESSAGE ---"));
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("on the DEFAULT path a disabled integration still makes no network call", async () => {
|
|
183
|
+
writeOrgConfig({ org: { cohort: { enabled: false } } });
|
|
184
|
+
let fetched = false;
|
|
185
|
+
await withFetch(
|
|
186
|
+
async () => { fetched = true; return { ok: true, status: 200, json: async () => ({ ok: true, result: { facts: [] } }), headers: { get: () => null } }; },
|
|
187
|
+
async () => {
|
|
188
|
+
await buildPrompt(ITEM, CLASS, { type: "inbox" });
|
|
189
|
+
assert.equal(fetched, false, "disabled integration must not hit the network on either path");
|
|
190
|
+
}
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
124
194
|
test("buildPrompt is unchanged with NO org config file at all", async () => {
|
|
125
195
|
writeOrgConfig(null); // empty file → parses to {} → disabled
|
|
126
196
|
const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
|
|
@@ -198,10 +268,10 @@ test("buildPrompt never asks a PRIVATE turn for the OUTCOMES block", async () =>
|
|
|
198
268
|
}
|
|
199
269
|
});
|
|
200
270
|
|
|
201
|
-
test("buildPrompt caps the number of injected org facts (bounded)", async () => {
|
|
271
|
+
test("buildPrompt caps the number of injected org facts (bounded, legacy path)", async () => {
|
|
202
272
|
writeOrgConfig({ org: { cohort: { enabled: true, base: "https://cohort.example", token: "tok" } } });
|
|
203
273
|
const many = Array.from({ length: 50 }, (_, i) => ({ id: `k${i}`, text: `fact number ${i}` }));
|
|
204
|
-
await withFetch(fetchReturningFacts(many), async () => {
|
|
274
|
+
await withLegacyContext(() => withFetch(fetchReturningFacts(many), async () => {
|
|
205
275
|
const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
|
|
206
276
|
const block = prompt.slice(
|
|
207
277
|
prompt.indexOf("--- ORG SHARED KNOWLEDGE ---"),
|
|
@@ -209,7 +279,7 @@ test("buildPrompt caps the number of injected org facts (bounded)", async () =>
|
|
|
209
279
|
);
|
|
210
280
|
const bullets = (block.match(/^- fact number /gm) || []).length;
|
|
211
281
|
assert.ok(bullets > 0 && bullets <= 6, `expected <=6 injected facts, got ${bullets}`);
|
|
212
|
-
});
|
|
282
|
+
}));
|
|
213
283
|
});
|
|
214
284
|
|
|
215
285
|
// ---------------------------------------------------------------------------
|
|
@@ -342,3 +412,145 @@ test("buildPrompt treats an unspecified holdingSent as delivered (back-compat)",
|
|
|
342
412
|
});
|
|
343
413
|
assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
|
|
344
414
|
});
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Interaction history is read per SERVICE (design §3 R12)
|
|
418
|
+
//
|
|
419
|
+
// `responder.mjs#logInteraction` files under `memory/interactions/<service>/…`;
|
|
420
|
+
// this reader was pinned to `…/slack/…`, so a Cohort exchange was written down
|
|
421
|
+
// and then never found.
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
test("the legacy history block finds a Cohort conversation, not just a Slack one", async () => {
|
|
425
|
+
writeOrgConfig(null);
|
|
426
|
+
const dir = join(ORG_AGENT_DIR, "memory", "interactions", "cohort", "chan-cohort-9");
|
|
427
|
+
mkdirSync(dir, { recursive: true });
|
|
428
|
+
writeFileSync(
|
|
429
|
+
join(dir, "2026-04-06.jsonl"),
|
|
430
|
+
JSON.stringify({ ts: "1", from: "Dana", content: "the retainer number is wrong on page two", received_at: "2026-04-06T10:00:00Z" }) + "\n",
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
const item = { service: "cohort", channel: "engineering", channel_id: "chan-cohort-9", sender: "Dana", content: "and now?" };
|
|
434
|
+
const prompt = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
|
|
435
|
+
assert.ok(prompt.includes("the retainer number is wrong on page two"), "Cohort history must reach the prompt");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("the legacy history block does not read another service's tree for the same channel id", async () => {
|
|
439
|
+
writeOrgConfig(null);
|
|
440
|
+
const dir = join(ORG_AGENT_DIR, "memory", "interactions", "whatsapp", "chan-crossed");
|
|
441
|
+
mkdirSync(dir, { recursive: true });
|
|
442
|
+
writeFileSync(
|
|
443
|
+
join(dir, "2026-04-06.jsonl"),
|
|
444
|
+
JSON.stringify({ ts: "1", from: "Dana", content: "WHATSAPP ONLY SENTINEL", received_at: "2026-04-06T10:00:00Z" }) + "\n",
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
const item = { service: "cohort", channel: "engineering", channel_id: "chan-crossed", sender: "Dana", content: "?" };
|
|
448
|
+
const prompt = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
|
|
449
|
+
assert.ok(!prompt.includes("WHATSAPP ONLY SENTINEL"), "one service must not read another's history");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
// Resource scope: a per-person DM never reaches a shared room (design §5.5)
|
|
454
|
+
//
|
|
455
|
+
// `logInteraction` files each exchange under BOTH the room and
|
|
456
|
+
// `dm-<sender-slug>`. Merging every candidate directory into one "history with
|
|
457
|
+
// this sender" block meant that the moment this read stopped being Slack-only,
|
|
458
|
+
// a sentence Dana said in a DM appeared in the prompt built for a PUBLIC
|
|
459
|
+
// channel. Cohort is the service where that bites: one namespace for DMs and
|
|
460
|
+
// rooms. Pinned on BOTH context paths, because both merged.
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
const DM_SECRET = "between us - we are letting Marco go on Friday, do not mention it yet";
|
|
464
|
+
|
|
465
|
+
/** Seed a private DM log and a public-room log for the same person. */
|
|
466
|
+
function seedDmAndRoom(service, channelId) {
|
|
467
|
+
const dmDir = join(ORG_AGENT_DIR, "memory", "interactions", service, "dm-dana");
|
|
468
|
+
mkdirSync(dmDir, { recursive: true });
|
|
469
|
+
writeFileSync(
|
|
470
|
+
join(dmDir, "2026-09-11.jsonl"),
|
|
471
|
+
JSON.stringify({ ts: "1", from: "Dana", content: DM_SECRET, received_at: "2026-09-11T10:00:00Z" }) + "\n",
|
|
472
|
+
);
|
|
473
|
+
const roomDir = join(ORG_AGENT_DIR, "memory", "interactions", service, channelId);
|
|
474
|
+
mkdirSync(roomDir, { recursive: true });
|
|
475
|
+
writeFileSync(
|
|
476
|
+
join(roomDir, "2026-09-11.jsonl"),
|
|
477
|
+
JSON.stringify({ ts: "2", from: "Dana", content: "PUBLIC ROOM SENTINEL", received_at: "2026-09-11T11:00:00Z" }) + "\n",
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
test("a public-channel prompt never carries the sender's DM history (legacy path)", async () => {
|
|
482
|
+
writeOrgConfig(null);
|
|
483
|
+
seedDmAndRoom("cohort", "c-capital-formation");
|
|
484
|
+
const item = {
|
|
485
|
+
service: "cohort", channel: "capital-formation", channel_id: "c-capital-formation",
|
|
486
|
+
is_dm: false, sender: "Dana", content: "what's the runway number?",
|
|
487
|
+
};
|
|
488
|
+
const prompt = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
|
|
489
|
+
assert.ok(!prompt.includes(DM_SECRET), "a per-person DM must never be quoted into a shared room");
|
|
490
|
+
assert.ok(prompt.includes("PUBLIC ROOM SENTINEL"), "the room's own history still reaches the prompt");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("a public-channel prompt never carries the sender's DM history (compiled path)", async () => {
|
|
494
|
+
writeOrgConfig(null);
|
|
495
|
+
seedDmAndRoom("cohort", "c-capital-formation-2");
|
|
496
|
+
const item = {
|
|
497
|
+
service: "cohort", channel: "capital-formation", channel_id: "c-capital-formation-2",
|
|
498
|
+
is_dm: false, sender: "Dana", content: "what's the runway number?",
|
|
499
|
+
};
|
|
500
|
+
const prompt = await buildPrompt(item, CLASS, { type: "inbox" });
|
|
501
|
+
assert.ok(contextCompilerEnabled({}), "this test is about the compiled path, which is the default");
|
|
502
|
+
assert.ok(!prompt.includes(DM_SECRET), "a per-person DM must never be quoted into a shared room");
|
|
503
|
+
assert.ok(prompt.includes("PUBLIC ROOM SENTINEL"), "the room's own history still reaches the prompt");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("the DM itself still reads its own per-person history (both paths)", async () => {
|
|
507
|
+
writeOrgConfig(null);
|
|
508
|
+
seedDmAndRoom("cohort", "c-dm-dana");
|
|
509
|
+
const item = {
|
|
510
|
+
service: "cohort", channel: "dm/dana", channel_id: "c-dm-dana",
|
|
511
|
+
is_dm: true, sender: "Dana", content: "still ok for Friday?",
|
|
512
|
+
};
|
|
513
|
+
const legacy = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
|
|
514
|
+
assert.ok(legacy.includes(DM_SECRET), "a private reply may see the private record");
|
|
515
|
+
const compiled = await buildPrompt(item, CLASS, { type: "inbox" });
|
|
516
|
+
assert.ok(compiled.includes(DM_SECRET), "…on the compiled path too");
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("an item with no privacy signal at all is treated as a ROOM", async () => {
|
|
520
|
+
writeOrgConfig(null);
|
|
521
|
+
seedDmAndRoom("cohort", "c-unknown-kind");
|
|
522
|
+
// No is_dm, no channel_type, a cuid channel id that says nothing either way.
|
|
523
|
+
const item = { service: "cohort", channel: "unknown", channel_id: "c-unknown-kind", sender: "Dana", content: "?" };
|
|
524
|
+
const legacy = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
|
|
525
|
+
assert.ok(!legacy.includes(DM_SECRET), "unknown is not permission");
|
|
526
|
+
const compiled = await buildPrompt(item, CLASS, { type: "inbox" });
|
|
527
|
+
assert.ok(!compiled.includes(DM_SECRET), "unknown is not permission (compiled path)");
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// DAEMON_CONTEXT_COMPILER — the flag that picks between two context paths
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
test("the context-compiler flag reads the words operators actually write", async () => {
|
|
535
|
+
const { contextCompilerSetting } = await import("./prompt-builder.mjs");
|
|
536
|
+
|
|
537
|
+
// Default ON (design §3 R11) — .env.example ships 1, the code used to ship 0.
|
|
538
|
+
assert.equal(contextCompilerEnabled({}), true);
|
|
539
|
+
assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: "" }), true);
|
|
540
|
+
|
|
541
|
+
for (const on of ["1", "true", "on", "yes", "Y", "ENABLED"]) {
|
|
542
|
+
assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: on }), true, `"${on}" means on`);
|
|
543
|
+
}
|
|
544
|
+
// The off words are wider than "0": an operator who writes `disabled` meant
|
|
545
|
+
// off, and the earlier list would have given them the opposite — on the one
|
|
546
|
+
// flag that selects between two materially different context paths.
|
|
547
|
+
for (const off of ["0", "false", "off", "no", "n", "disabled", "disable", "none", " OFF "]) {
|
|
548
|
+
assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: off }), false, `"${off}" means off`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// A word we do not know still resolves ON — but it is reported as unknown, so
|
|
552
|
+
// the caller can say so rather than resolving a typo in silence.
|
|
553
|
+
const odd = contextCompilerSetting({ DAEMON_CONTEXT_COMPILER: "2" });
|
|
554
|
+
assert.deepEqual(odd, { enabled: true, recognised: false, raw: "2" });
|
|
555
|
+
assert.equal(contextCompilerSetting({ DAEMON_CONTEXT_COMPILER: "off" }).recognised, true);
|
|
556
|
+
});
|
|
@@ -22,7 +22,8 @@ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
22
22
|
import { tmpdir } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
|
|
25
|
-
import { loadConversationHistory } from "./responder.mjs";
|
|
25
|
+
import { loadConversationHistory, deriveRouterItem } from "./responder.mjs";
|
|
26
|
+
import { routingKey } from "./lib/session-router.mjs";
|
|
26
27
|
|
|
27
28
|
const cohortItem = {
|
|
28
29
|
sender: "Dana Okafor",
|
|
@@ -183,3 +184,38 @@ test("cohort: server history wins over the local mirror", async () => {
|
|
|
183
184
|
assert.match(text, /authoritative line/, "the server is the source of truth");
|
|
184
185
|
assert.doesNotMatch(text, /outdated mirror line/, "the stale mirror must not be mixed in");
|
|
185
186
|
});
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Session continuity: the responder's router adapter (design §3 R10)
|
|
190
|
+
//
|
|
191
|
+
// `deriveRouterItem` returned null for `cohort`, so every Cohort turn fell back
|
|
192
|
+
// to a fresh pre-minted UUID and the session router — which exists, and which
|
|
193
|
+
// Slack and Gmail have used all along — never saw the one surface the owner
|
|
194
|
+
// actually uses.
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
test("deriveRouterItem keys a Cohort item on its room, so a follow-up can resume", () => {
|
|
198
|
+
const out = deriveRouterItem(cohortItem);
|
|
199
|
+
assert.deepEqual(out, {
|
|
200
|
+
source: "cohort",
|
|
201
|
+
channel: "cmql1802601iihhzpf774vwbk",
|
|
202
|
+
thread_root_id: null,
|
|
203
|
+
});
|
|
204
|
+
assert.equal(routingKey(out), "cohort:cmql1802601iihhzpf774vwbk");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("deriveRouterItem narrows to the thread when there is one", () => {
|
|
208
|
+
const out = deriveRouterItem({ ...cohortItem, thread_id: "root-42" });
|
|
209
|
+
assert.equal(routingKey(out), "cohort:cmql1802601iihhzpf774vwbk:root-42");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("deriveRouterItem refuses the display label as a key", () => {
|
|
213
|
+
// `task/<title>` and `doc/<name>` are labels; two rooms can share one.
|
|
214
|
+
assert.equal(deriveRouterItem({ sender: "Dana", service: "cohort", channel: "task/Ship the thing" }), null);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("deriveRouterItem still keys slack, gmail and calendar exactly as before", () => {
|
|
218
|
+
assert.equal(routingKey(deriveRouterItem({ service: "slack", channel: "D099", thread_id: null })), "slack:D099");
|
|
219
|
+
assert.equal(routingKey(deriveRouterItem({ service: "gmail", thread_id: "t-1" })), "gmail:t-1");
|
|
220
|
+
assert.equal(routingKey(deriveRouterItem({ service: "calendar", event_id: "e-1" })), "calendar:e-1");
|
|
221
|
+
});
|
|
@@ -28,7 +28,13 @@ import { spawn } from "child_process";
|
|
|
28
28
|
import { join } from "path";
|
|
29
29
|
import { randomUUID } from "crypto";
|
|
30
30
|
import { checkRecentlySent, registerSent } from "./session-lock.mjs";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
routingKey as deriveRoutingKey,
|
|
33
|
+
createRouter,
|
|
34
|
+
routerItemFromDaemonItem,
|
|
35
|
+
claimSession,
|
|
36
|
+
releaseSession,
|
|
37
|
+
} from "./lib/session-router.mjs";
|
|
32
38
|
// Permission scoping (security CRITICAL / audit H1). sessionPermissionArgs()
|
|
33
39
|
// preserves the historical "--dangerously-skip-permissions" by default and only
|
|
34
40
|
// scopes tools when the operator opts in (MAESTRO_SCOPED_PERMISSIONS=1), so the
|
|
@@ -71,6 +77,7 @@ const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.ur
|
|
|
71
77
|
const SONNET_MODEL = "claude-sonnet-4-6";
|
|
72
78
|
// Resolve claude against the agent's PATH (not launchd's bare env).
|
|
73
79
|
import { resolveClaudeBin, augmentedPath, daemonClaudeArgs } from "../../lib/claude-bin.mjs";
|
|
80
|
+
import { isPrivateConversation, interactionWriteDirNames } from "../../lib/context/history-scope.mjs";
|
|
74
81
|
const CLAUDE_BIN = resolveClaudeBin();
|
|
75
82
|
const CLAUDE_CLI_TIMEOUT_MS = 60_000;
|
|
76
83
|
const SESSION_REGISTRY_PATH = join(AGENT_REPO_DIR, "state", "daemon", "session-router-registry.json");
|
|
@@ -101,45 +108,15 @@ function getRouter() {
|
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
/**
|
|
104
|
-
* Translate a daemon-shaped item into the source
|
|
105
|
-
*
|
|
111
|
+
* Translate a daemon-shaped item into the `{source, channel, …}` shape
|
|
112
|
+
* `session-router`'s `routingKey()` expects.
|
|
106
113
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* Returns null for items the router can't key (e.g. service we don't yet
|
|
112
|
-
* support, or missing required fields). Caller falls back to a fresh
|
|
113
|
-
* pre-minted UUID + EPHEMERAL semantics.
|
|
114
|
+
* The adapter itself now lives in `lib/session-router.mjs` beside the key
|
|
115
|
+
* function, because `dispatcher.mjs` needs the SAME translation: two copies
|
|
116
|
+
* would let one room be keyed two ways, which is the same thing as having no
|
|
117
|
+
* router. Exported here for the responder's own tests.
|
|
114
118
|
*/
|
|
115
|
-
|
|
116
|
-
if (!item || typeof item !== "object") return null;
|
|
117
|
-
|
|
118
|
-
if (item.service === "slack") {
|
|
119
|
-
const channel = item.channel || item.channel_id;
|
|
120
|
-
if (!channel) return null;
|
|
121
|
-
return {
|
|
122
|
-
source: "slack",
|
|
123
|
-
channel,
|
|
124
|
-
thread_ts: item.thread_id || item.thread_ts || null,
|
|
125
|
-
ts: item.ts || item.timestamp || null,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (item.service === "gmail") {
|
|
130
|
-
const tid = item.thread_id || item.threadId;
|
|
131
|
-
if (!tid) return null;
|
|
132
|
-
return { source: "gmail", thread_id: tid };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (item.service === "calendar") {
|
|
136
|
-
const eid = item.event_id || item.eventId;
|
|
137
|
-
if (!eid) return null;
|
|
138
|
-
return { source: "calendar", event_id: eid };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
119
|
+
export const deriveRouterItem = routerItemFromDaemonItem;
|
|
143
120
|
|
|
144
121
|
// Lazy token access — dotenv loads in daemon main before these are called
|
|
145
122
|
function getSlackToken() {
|
|
@@ -438,10 +415,17 @@ function logInteraction(item, responseText, o = {}) {
|
|
|
438
415
|
? resolveSlackChannel(item)
|
|
439
416
|
: (item.channel_id || item.channel || null);
|
|
440
417
|
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
418
|
+
// The room always; the PERSON only when the exchange was actually private.
|
|
419
|
+
// This used to write both unconditionally, which made `dm-<slug>` a mixed
|
|
420
|
+
// store of private and public-room content — inert only for as long as
|
|
421
|
+
// nothing read it per-service. It is read now, so the gate belongs at both
|
|
422
|
+
// ends (`lib/context/history-scope.mjs`, design §5.5).
|
|
423
|
+
const dirs = interactionWriteDirNames({
|
|
424
|
+
channelId,
|
|
425
|
+
senderSlug,
|
|
426
|
+
isPrivate: isPrivateConversation(item),
|
|
427
|
+
}).map((name) => join(AGENT_REPO_DIR, "memory", "interactions", service, String(name)));
|
|
428
|
+
if (dirs.length === 0) return;
|
|
445
429
|
|
|
446
430
|
const incomingEntry = {
|
|
447
431
|
ts: item.ts || item.timestamp || new Date().toISOString(),
|
|
@@ -738,14 +722,25 @@ ${MESSAGE_CRAFT}`;
|
|
|
738
722
|
let sessionId = null;
|
|
739
723
|
if (routerItem) {
|
|
740
724
|
try {
|
|
741
|
-
|
|
742
|
-
const decision = router.route(
|
|
743
|
-
|
|
744
|
-
|
|
725
|
+
const candidateKey = deriveRoutingKey(routerItem);
|
|
726
|
+
const decision = router.route(candidateKey);
|
|
727
|
+
// One CLI process per key. `claimSession` is the same lease the dispatcher
|
|
728
|
+
// takes, in the same process, so a 60-second quick reply can never be
|
|
729
|
+
// handed the session id of a long agentic run that is still going — and
|
|
730
|
+
// two quick replies from one burst cannot share a transcript either.
|
|
731
|
+
if (claimSession(candidateKey)) {
|
|
732
|
+
key = candidateKey;
|
|
733
|
+
if (decision.decision === "RESUME" && decision.resumeId) {
|
|
734
|
+
sessionId = decision.resumeId;
|
|
735
|
+
} else {
|
|
736
|
+
// EPHEMERAL or EPHEMERAL_REPLACE — pre-mint a fresh UUID. Reusing
|
|
737
|
+
// the same key on next call (with a different sessionId) is fine;
|
|
738
|
+
// touch() will overwrite the registry entry.
|
|
739
|
+
sessionId = randomUUID();
|
|
740
|
+
}
|
|
745
741
|
} else {
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
// touch() will overwrite the registry entry.
|
|
742
|
+
// Already in flight: spawn cold, and stay out of the registry so the
|
|
743
|
+
// running session keeps ownership of the row.
|
|
749
744
|
sessionId = randomUUID();
|
|
750
745
|
}
|
|
751
746
|
} catch (err) {
|
|
@@ -757,30 +752,37 @@ ${MESSAGE_CRAFT}`;
|
|
|
757
752
|
}
|
|
758
753
|
}
|
|
759
754
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
755
|
+
// The claim is held across the spawn AND the registry write that follows it,
|
|
756
|
+
// and released in a `finally`: a throw anywhere in between must not leave the
|
|
757
|
+
// room's key claimed forever, which would silently disable continuity for it.
|
|
758
|
+
try {
|
|
759
|
+
const cliResult = await runClaudeCLI(systemPrompt, userContent, model, {
|
|
760
|
+
sessionId,
|
|
761
|
+
router: key ? router : null,
|
|
762
|
+
routingKey: key,
|
|
763
|
+
});
|
|
765
764
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
765
|
+
const text = (cliResult.text || "").trim();
|
|
766
|
+
if (!text) {
|
|
767
|
+
throw new Error("claude CLI returned empty result text in generateResponse");
|
|
768
|
+
}
|
|
770
769
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
770
|
+
// (b3) On success, touch the registry with the CLI-resolved session_id so
|
|
771
|
+
// the next call routed to this key gets a RESUME decision. Skip touch on
|
|
772
|
+
// is_error responses or when JSON parsing failed (legacy fallback path).
|
|
773
|
+
if (key && cliResult.jsonResult && cliResult.jsonResult.is_error !== true) {
|
|
774
|
+
const claudeSessionId = cliResult.jsonResult.session_id || sessionId;
|
|
775
|
+
try {
|
|
776
|
+
await router.touch(key, { claudeSessionId, model });
|
|
777
|
+
} catch (err) {
|
|
778
|
+
console.warn(`[responder] router.touch failed for ${key}: ${err.message}`);
|
|
779
|
+
}
|
|
780
780
|
}
|
|
781
|
-
}
|
|
782
781
|
|
|
783
|
-
|
|
782
|
+
return text;
|
|
783
|
+
} finally {
|
|
784
|
+
if (key) releaseSession(key);
|
|
785
|
+
}
|
|
784
786
|
}
|
|
785
787
|
|
|
786
788
|
// Default the generator slot to the real CLI-backed implementation. (Declared
|