@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
|
@@ -12,12 +12,38 @@ import yaml from "js-yaml";
|
|
|
12
12
|
const ORG_AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-pb-org-"));
|
|
13
13
|
mkdirSync(join(ORG_AGENT_DIR, "config"), { recursive: true });
|
|
14
14
|
process.env.AGENT_DIR = ORG_AGENT_DIR;
|
|
15
|
-
const { buildPrompt, buildInboxContext, _resetOrgConfigCache } = await import("./prompt-builder.mjs");
|
|
15
|
+
const { buildPrompt, buildInboxContext, _resetOrgConfigCache, contextCompilerEnabled } = await import("./prompt-builder.mjs");
|
|
16
|
+
const { _resetOrgCfgCache } = await import("./context-compiler.mjs");
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run `fn` with the compiled-context path OFF.
|
|
20
|
+
*
|
|
21
|
+
* The three org-knowledge tests below assert the LEGACY `--- ORG SHARED
|
|
22
|
+
* KNOWLEDGE ---` block, which `buildPrompt` emits only on that path. The flag
|
|
23
|
+
* now defaults ON (design §3 R11), so the legacy assertions have to say which
|
|
24
|
+
* path they are about instead of relying on a default that has moved. The
|
|
25
|
+
* compiled path's own org injection is pinned separately, below.
|
|
26
|
+
*/
|
|
27
|
+
async function withLegacyContext(fn) {
|
|
28
|
+
const prior = process.env.DAEMON_CONTEXT_COMPILER;
|
|
29
|
+
process.env.DAEMON_CONTEXT_COMPILER = "0";
|
|
30
|
+
try {
|
|
31
|
+
return await fn();
|
|
32
|
+
} finally {
|
|
33
|
+
if (prior === undefined) delete process.env.DAEMON_CONTEXT_COMPILER;
|
|
34
|
+
else process.env.DAEMON_CONTEXT_COMPILER = prior;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
16
37
|
|
|
17
38
|
/** Write config/org.yaml into the temp agent dir and drop the cached read. */
|
|
18
39
|
function writeOrgConfig(doc) {
|
|
19
40
|
writeFileSync(join(ORG_AGENT_DIR, "config/org.yaml"), doc == null ? "" : yaml.dump(doc));
|
|
20
41
|
_resetOrgConfigCache();
|
|
42
|
+
// The compiled-context path caches its OWN read of config/org.yaml. Both
|
|
43
|
+
// caches have to drop, or a test that disables the integration is served the
|
|
44
|
+
// previous test's enabled config and "proves" a network call that the config
|
|
45
|
+
// on disk forbids.
|
|
46
|
+
_resetOrgCfgCache();
|
|
21
47
|
}
|
|
22
48
|
|
|
23
49
|
/** Stub global fetch to serve a knowledge.search frame, restore on cleanup. */
|
|
@@ -85,9 +111,9 @@ test("buildInboxContext fences thread context too", () => {
|
|
|
85
111
|
const ITEM = { service: "slack", channel: "C123", channel_id: "C123", channel_type: "channel", sender: "Dana", content: "What is our refund policy?" };
|
|
86
112
|
const CLASS = { priority: "normal", action: "respond", category: "ops", summary: "refund policy question" };
|
|
87
113
|
|
|
88
|
-
test("buildPrompt injects ORG SHARED KNOWLEDGE when org.cohort is enabled", async () => {
|
|
114
|
+
test("buildPrompt injects ORG SHARED KNOWLEDGE when org.cohort is enabled (legacy path)", async () => {
|
|
89
115
|
writeOrgConfig({ org: { cohort: { enabled: true, base: "https://cohort.example", token: "tok" } } });
|
|
90
|
-
await withFetch(
|
|
116
|
+
await withLegacyContext(() => withFetch(
|
|
91
117
|
fetchReturningFacts([
|
|
92
118
|
{ id: "k1", text: "Refunds are issued within 14 days of purchase." },
|
|
93
119
|
{ id: "k2", text: "Enterprise contracts are non-refundable after onboarding." },
|
|
@@ -103,7 +129,7 @@ test("buildPrompt injects ORG SHARED KNOWLEDGE when org.cohort is enabled", asyn
|
|
|
103
129
|
assert.ok(prompt.includes("--- INCOMING MESSAGE ---"));
|
|
104
130
|
assert.match(prompt, /Log all actions taken/);
|
|
105
131
|
}
|
|
106
|
-
);
|
|
132
|
+
));
|
|
107
133
|
});
|
|
108
134
|
|
|
109
135
|
test("buildPrompt does NOT inject org knowledge when the integration is disabled", async () => {
|
|
@@ -121,6 +147,50 @@ test("buildPrompt does NOT inject org knowledge when the integration is disabled
|
|
|
121
147
|
);
|
|
122
148
|
});
|
|
123
149
|
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// DAEMON_CONTEXT_COMPILER — ONE path ships (design §3 R11)
|
|
152
|
+
//
|
|
153
|
+
// The code read `=== "1"` while .env.example shipped `=1`, so which of two
|
|
154
|
+
// materially different context paths ran depended on whether the operator had
|
|
155
|
+
// copied the example env. These pin the default and the escape hatch.
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
test("the compiled-context path is ON by default, matching .env.example", () => {
|
|
159
|
+
assert.equal(contextCompilerEnabled({}), true, "unset must mean on");
|
|
160
|
+
assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: "1" }), true);
|
|
161
|
+
assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: "" }), true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("the compiled-context path can still be turned off in a hurry", () => {
|
|
165
|
+
for (const v of ["0", "false", "off", "no", "OFF"]) {
|
|
166
|
+
assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: v }), false, v);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("on the DEFAULT path the org's facts still reach the prompt", async () => {
|
|
171
|
+
writeOrgConfig({ org: { cohort: { enabled: true, base: "https://cohort.example", token: "tok" } } });
|
|
172
|
+
await withFetch(
|
|
173
|
+
fetchReturningFacts([{ id: "k1", text: "Refunds are issued within 14 days of purchase." }]),
|
|
174
|
+
async () => {
|
|
175
|
+
const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
|
|
176
|
+
assert.ok(prompt.includes("Refunds are issued within 14 days"), "org recall must survive the default path");
|
|
177
|
+
assert.ok(prompt.includes("--- INCOMING MESSAGE ---"));
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("on the DEFAULT path a disabled integration still makes no network call", async () => {
|
|
183
|
+
writeOrgConfig({ org: { cohort: { enabled: false } } });
|
|
184
|
+
let fetched = false;
|
|
185
|
+
await withFetch(
|
|
186
|
+
async () => { fetched = true; return { ok: true, status: 200, json: async () => ({ ok: true, result: { facts: [] } }), headers: { get: () => null } }; },
|
|
187
|
+
async () => {
|
|
188
|
+
await buildPrompt(ITEM, CLASS, { type: "inbox" });
|
|
189
|
+
assert.equal(fetched, false, "disabled integration must not hit the network on either path");
|
|
190
|
+
}
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
124
194
|
test("buildPrompt is unchanged with NO org config file at all", async () => {
|
|
125
195
|
writeOrgConfig(null); // empty file → parses to {} → disabled
|
|
126
196
|
const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
|
|
@@ -198,10 +268,10 @@ test("buildPrompt never asks a PRIVATE turn for the OUTCOMES block", async () =>
|
|
|
198
268
|
}
|
|
199
269
|
});
|
|
200
270
|
|
|
201
|
-
test("buildPrompt caps the number of injected org facts (bounded)", async () => {
|
|
271
|
+
test("buildPrompt caps the number of injected org facts (bounded, legacy path)", async () => {
|
|
202
272
|
writeOrgConfig({ org: { cohort: { enabled: true, base: "https://cohort.example", token: "tok" } } });
|
|
203
273
|
const many = Array.from({ length: 50 }, (_, i) => ({ id: `k${i}`, text: `fact number ${i}` }));
|
|
204
|
-
await withFetch(fetchReturningFacts(many), async () => {
|
|
274
|
+
await withLegacyContext(() => withFetch(fetchReturningFacts(many), async () => {
|
|
205
275
|
const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
|
|
206
276
|
const block = prompt.slice(
|
|
207
277
|
prompt.indexOf("--- ORG SHARED KNOWLEDGE ---"),
|
|
@@ -209,7 +279,7 @@ test("buildPrompt caps the number of injected org facts (bounded)", async () =>
|
|
|
209
279
|
);
|
|
210
280
|
const bullets = (block.match(/^- fact number /gm) || []).length;
|
|
211
281
|
assert.ok(bullets > 0 && bullets <= 6, `expected <=6 injected facts, got ${bullets}`);
|
|
212
|
-
});
|
|
282
|
+
}));
|
|
213
283
|
});
|
|
214
284
|
|
|
215
285
|
// ---------------------------------------------------------------------------
|
|
@@ -267,6 +337,74 @@ test("buildPrompt does NOT claim delivery when the acknowledgement failed to sen
|
|
|
267
337
|
assert.ok(prompt.includes("Understood — I'm digging into this now (the noted issues). I want to get this right, so I'll come back to you here with a full answer — usually within 10-20 minutes — and you'll hear from me either way."));
|
|
268
338
|
});
|
|
269
339
|
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// THE THIRD AND FOURTH STATES: an interim whose text this process does not
|
|
342
|
+
// hold, and no contact at all. Both were one block before 2026-09-12, and that
|
|
343
|
+
// block asserted a fact that is false on most dispatches.
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
test("REGRESSION: a retry session is NOT told the sender has heard nothing", async () => {
|
|
347
|
+
// The reachable-today path. An item re-delivered while its debt is open with
|
|
348
|
+
// interimSaid:true makes openAndAcknowledge return {acked:true, ackText:null}:
|
|
349
|
+
// holdingMessage is null, so both holding blocks are skipped, and before this
|
|
350
|
+
// fix the no-contact block rendered — telling a RETRY session that a human who
|
|
351
|
+
// had already received a holding line AND a "Hit a problem — … Retrying now"
|
|
352
|
+
// had heard nothing. On main this path produced no block at all, so the false
|
|
353
|
+
// claim was introduced by the acknowledgement-discipline change itself.
|
|
354
|
+
const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox", interimAlreadySent: true });
|
|
355
|
+
assert.match(prompt, /ALREADY IN CONTACT/);
|
|
356
|
+
assert.match(prompt, /already reached the sender/);
|
|
357
|
+
assert.ok(!/heard nothing/.test(prompt), "must not assert silence at a sender who has been spoken to");
|
|
358
|
+
assert.ok(!/NO CONTACT YET/.test(prompt), "the no-contact block must not render alongside it");
|
|
359
|
+
// It must not invent the text it does not have.
|
|
360
|
+
assert.match(prompt, /do not quote it or guess at it/);
|
|
361
|
+
// And the second reminder, immediately before the action block, agrees.
|
|
362
|
+
assert.match(prompt, /REMINDER: An interim about this item already reached the sender/);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("the no-contact block states a FACT about the past, not a PREDICTION about the future", async () => {
|
|
366
|
+
// What it said before: "your reply is the first thing they will read". That is
|
|
367
|
+
// false on the majority of dispatches — the sweep posts one interim at
|
|
368
|
+
// ACK_AFTER_MS (90 s) while measured p50 session duration is 14.7 min — and a
|
|
369
|
+
// session told it is the first voice in the room, with a holding line landing
|
|
370
|
+
// in front of it, produces exactly the double-contact the delivered-holding
|
|
371
|
+
// block exists to prevent, in the other direction.
|
|
372
|
+
const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
|
|
373
|
+
assert.match(prompt, /NO CONTACT YET/);
|
|
374
|
+
assert.match(prompt, /Nothing has been sent to the sender about this item so far/);
|
|
375
|
+
assert.ok(
|
|
376
|
+
!/first thing they will read/.test(prompt),
|
|
377
|
+
"the prompt may not predict that no interim will land before the reply",
|
|
378
|
+
);
|
|
379
|
+
// It must say the opposite instead: one may yet go out, silently.
|
|
380
|
+
assert.match(prompt, /may post at most ONE short holding line/);
|
|
381
|
+
assert.ok(!/ALREADY IN CONTACT/.test(prompt));
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("a delivered holding message outranks the interim-already-sent flag", async () => {
|
|
385
|
+
// When the text IS in hand, quoting it is strictly better than saying it
|
|
386
|
+
// exists — so the ordering of the branches is pinned rather than incidental.
|
|
387
|
+
const prompt = await buildPrompt(ITEM, CLASS, {
|
|
388
|
+
type: "inbox",
|
|
389
|
+
holdingMessage: "Give me a few minutes on the July reconciliation — I'll have the variances.",
|
|
390
|
+
holdingSent: true,
|
|
391
|
+
interimAlreadySent: true,
|
|
392
|
+
});
|
|
393
|
+
assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
|
|
394
|
+
assert.ok(!/ALREADY IN CONTACT/.test(prompt));
|
|
395
|
+
assert.ok(!/NO CONTACT YET/.test(prompt));
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("neither block reaches a backlog item — there is no sender on the other end", async () => {
|
|
399
|
+
const prompt = await buildPrompt(null, CLASS, {
|
|
400
|
+
type: "backlog",
|
|
401
|
+
queueItem: { id: "q-1", title: "Sweep the stale worktrees", status: "open" },
|
|
402
|
+
interimAlreadySent: true,
|
|
403
|
+
});
|
|
404
|
+
assert.ok(!/ALREADY IN CONTACT/.test(prompt));
|
|
405
|
+
assert.ok(!/NO CONTACT YET/.test(prompt));
|
|
406
|
+
});
|
|
407
|
+
|
|
270
408
|
test("buildPrompt treats an unspecified holdingSent as delivered (back-compat)", async () => {
|
|
271
409
|
const prompt = await buildPrompt(ITEM, CLASS, {
|
|
272
410
|
type: "inbox",
|
|
@@ -274,3 +412,145 @@ test("buildPrompt treats an unspecified holdingSent as delivered (back-compat)",
|
|
|
274
412
|
});
|
|
275
413
|
assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
|
|
276
414
|
});
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Interaction history is read per SERVICE (design §3 R12)
|
|
418
|
+
//
|
|
419
|
+
// `responder.mjs#logInteraction` files under `memory/interactions/<service>/…`;
|
|
420
|
+
// this reader was pinned to `…/slack/…`, so a Cohort exchange was written down
|
|
421
|
+
// and then never found.
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
test("the legacy history block finds a Cohort conversation, not just a Slack one", async () => {
|
|
425
|
+
writeOrgConfig(null);
|
|
426
|
+
const dir = join(ORG_AGENT_DIR, "memory", "interactions", "cohort", "chan-cohort-9");
|
|
427
|
+
mkdirSync(dir, { recursive: true });
|
|
428
|
+
writeFileSync(
|
|
429
|
+
join(dir, "2026-04-06.jsonl"),
|
|
430
|
+
JSON.stringify({ ts: "1", from: "Dana", content: "the retainer number is wrong on page two", received_at: "2026-04-06T10:00:00Z" }) + "\n",
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
const item = { service: "cohort", channel: "engineering", channel_id: "chan-cohort-9", sender: "Dana", content: "and now?" };
|
|
434
|
+
const prompt = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
|
|
435
|
+
assert.ok(prompt.includes("the retainer number is wrong on page two"), "Cohort history must reach the prompt");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("the legacy history block does not read another service's tree for the same channel id", async () => {
|
|
439
|
+
writeOrgConfig(null);
|
|
440
|
+
const dir = join(ORG_AGENT_DIR, "memory", "interactions", "whatsapp", "chan-crossed");
|
|
441
|
+
mkdirSync(dir, { recursive: true });
|
|
442
|
+
writeFileSync(
|
|
443
|
+
join(dir, "2026-04-06.jsonl"),
|
|
444
|
+
JSON.stringify({ ts: "1", from: "Dana", content: "WHATSAPP ONLY SENTINEL", received_at: "2026-04-06T10:00:00Z" }) + "\n",
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
const item = { service: "cohort", channel: "engineering", channel_id: "chan-crossed", sender: "Dana", content: "?" };
|
|
448
|
+
const prompt = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
|
|
449
|
+
assert.ok(!prompt.includes("WHATSAPP ONLY SENTINEL"), "one service must not read another's history");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
// Resource scope: a per-person DM never reaches a shared room (design §5.5)
|
|
454
|
+
//
|
|
455
|
+
// `logInteraction` files each exchange under BOTH the room and
|
|
456
|
+
// `dm-<sender-slug>`. Merging every candidate directory into one "history with
|
|
457
|
+
// this sender" block meant that the moment this read stopped being Slack-only,
|
|
458
|
+
// a sentence Dana said in a DM appeared in the prompt built for a PUBLIC
|
|
459
|
+
// channel. Cohort is the service where that bites: one namespace for DMs and
|
|
460
|
+
// rooms. Pinned on BOTH context paths, because both merged.
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
const DM_SECRET = "between us - we are letting Marco go on Friday, do not mention it yet";
|
|
464
|
+
|
|
465
|
+
/** Seed a private DM log and a public-room log for the same person. */
|
|
466
|
+
function seedDmAndRoom(service, channelId) {
|
|
467
|
+
const dmDir = join(ORG_AGENT_DIR, "memory", "interactions", service, "dm-dana");
|
|
468
|
+
mkdirSync(dmDir, { recursive: true });
|
|
469
|
+
writeFileSync(
|
|
470
|
+
join(dmDir, "2026-09-11.jsonl"),
|
|
471
|
+
JSON.stringify({ ts: "1", from: "Dana", content: DM_SECRET, received_at: "2026-09-11T10:00:00Z" }) + "\n",
|
|
472
|
+
);
|
|
473
|
+
const roomDir = join(ORG_AGENT_DIR, "memory", "interactions", service, channelId);
|
|
474
|
+
mkdirSync(roomDir, { recursive: true });
|
|
475
|
+
writeFileSync(
|
|
476
|
+
join(roomDir, "2026-09-11.jsonl"),
|
|
477
|
+
JSON.stringify({ ts: "2", from: "Dana", content: "PUBLIC ROOM SENTINEL", received_at: "2026-09-11T11:00:00Z" }) + "\n",
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
test("a public-channel prompt never carries the sender's DM history (legacy path)", async () => {
|
|
482
|
+
writeOrgConfig(null);
|
|
483
|
+
seedDmAndRoom("cohort", "c-capital-formation");
|
|
484
|
+
const item = {
|
|
485
|
+
service: "cohort", channel: "capital-formation", channel_id: "c-capital-formation",
|
|
486
|
+
is_dm: false, sender: "Dana", content: "what's the runway number?",
|
|
487
|
+
};
|
|
488
|
+
const prompt = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
|
|
489
|
+
assert.ok(!prompt.includes(DM_SECRET), "a per-person DM must never be quoted into a shared room");
|
|
490
|
+
assert.ok(prompt.includes("PUBLIC ROOM SENTINEL"), "the room's own history still reaches the prompt");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("a public-channel prompt never carries the sender's DM history (compiled path)", async () => {
|
|
494
|
+
writeOrgConfig(null);
|
|
495
|
+
seedDmAndRoom("cohort", "c-capital-formation-2");
|
|
496
|
+
const item = {
|
|
497
|
+
service: "cohort", channel: "capital-formation", channel_id: "c-capital-formation-2",
|
|
498
|
+
is_dm: false, sender: "Dana", content: "what's the runway number?",
|
|
499
|
+
};
|
|
500
|
+
const prompt = await buildPrompt(item, CLASS, { type: "inbox" });
|
|
501
|
+
assert.ok(contextCompilerEnabled({}), "this test is about the compiled path, which is the default");
|
|
502
|
+
assert.ok(!prompt.includes(DM_SECRET), "a per-person DM must never be quoted into a shared room");
|
|
503
|
+
assert.ok(prompt.includes("PUBLIC ROOM SENTINEL"), "the room's own history still reaches the prompt");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("the DM itself still reads its own per-person history (both paths)", async () => {
|
|
507
|
+
writeOrgConfig(null);
|
|
508
|
+
seedDmAndRoom("cohort", "c-dm-dana");
|
|
509
|
+
const item = {
|
|
510
|
+
service: "cohort", channel: "dm/dana", channel_id: "c-dm-dana",
|
|
511
|
+
is_dm: true, sender: "Dana", content: "still ok for Friday?",
|
|
512
|
+
};
|
|
513
|
+
const legacy = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
|
|
514
|
+
assert.ok(legacy.includes(DM_SECRET), "a private reply may see the private record");
|
|
515
|
+
const compiled = await buildPrompt(item, CLASS, { type: "inbox" });
|
|
516
|
+
assert.ok(compiled.includes(DM_SECRET), "…on the compiled path too");
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("an item with no privacy signal at all is treated as a ROOM", async () => {
|
|
520
|
+
writeOrgConfig(null);
|
|
521
|
+
seedDmAndRoom("cohort", "c-unknown-kind");
|
|
522
|
+
// No is_dm, no channel_type, a cuid channel id that says nothing either way.
|
|
523
|
+
const item = { service: "cohort", channel: "unknown", channel_id: "c-unknown-kind", sender: "Dana", content: "?" };
|
|
524
|
+
const legacy = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
|
|
525
|
+
assert.ok(!legacy.includes(DM_SECRET), "unknown is not permission");
|
|
526
|
+
const compiled = await buildPrompt(item, CLASS, { type: "inbox" });
|
|
527
|
+
assert.ok(!compiled.includes(DM_SECRET), "unknown is not permission (compiled path)");
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// DAEMON_CONTEXT_COMPILER — the flag that picks between two context paths
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
test("the context-compiler flag reads the words operators actually write", async () => {
|
|
535
|
+
const { contextCompilerSetting } = await import("./prompt-builder.mjs");
|
|
536
|
+
|
|
537
|
+
// Default ON (design §3 R11) — .env.example ships 1, the code used to ship 0.
|
|
538
|
+
assert.equal(contextCompilerEnabled({}), true);
|
|
539
|
+
assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: "" }), true);
|
|
540
|
+
|
|
541
|
+
for (const on of ["1", "true", "on", "yes", "Y", "ENABLED"]) {
|
|
542
|
+
assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: on }), true, `"${on}" means on`);
|
|
543
|
+
}
|
|
544
|
+
// The off words are wider than "0": an operator who writes `disabled` meant
|
|
545
|
+
// off, and the earlier list would have given them the opposite — on the one
|
|
546
|
+
// flag that selects between two materially different context paths.
|
|
547
|
+
for (const off of ["0", "false", "off", "no", "n", "disabled", "disable", "none", " OFF "]) {
|
|
548
|
+
assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: off }), false, `"${off}" means off`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// A word we do not know still resolves ON — but it is reported as unknown, so
|
|
552
|
+
// the caller can say so rather than resolving a typo in silence.
|
|
553
|
+
const odd = contextCompilerSetting({ DAEMON_CONTEXT_COMPILER: "2" });
|
|
554
|
+
assert.deepEqual(odd, { enabled: true, recognised: false, raw: "2" });
|
|
555
|
+
assert.equal(contextCompilerSetting({ DAEMON_CONTEXT_COMPILER: "off" }).recognised, true);
|
|
556
|
+
});
|
|
@@ -22,7 +22,8 @@ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
22
22
|
import { tmpdir } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
|
|
25
|
-
import { loadConversationHistory } from "./responder.mjs";
|
|
25
|
+
import { loadConversationHistory, deriveRouterItem } from "./responder.mjs";
|
|
26
|
+
import { routingKey } from "./lib/session-router.mjs";
|
|
26
27
|
|
|
27
28
|
const cohortItem = {
|
|
28
29
|
sender: "Dana Okafor",
|
|
@@ -183,3 +184,38 @@ test("cohort: server history wins over the local mirror", async () => {
|
|
|
183
184
|
assert.match(text, /authoritative line/, "the server is the source of truth");
|
|
184
185
|
assert.doesNotMatch(text, /outdated mirror line/, "the stale mirror must not be mixed in");
|
|
185
186
|
});
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Session continuity: the responder's router adapter (design §3 R10)
|
|
190
|
+
//
|
|
191
|
+
// `deriveRouterItem` returned null for `cohort`, so every Cohort turn fell back
|
|
192
|
+
// to a fresh pre-minted UUID and the session router — which exists, and which
|
|
193
|
+
// Slack and Gmail have used all along — never saw the one surface the owner
|
|
194
|
+
// actually uses.
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
test("deriveRouterItem keys a Cohort item on its room, so a follow-up can resume", () => {
|
|
198
|
+
const out = deriveRouterItem(cohortItem);
|
|
199
|
+
assert.deepEqual(out, {
|
|
200
|
+
source: "cohort",
|
|
201
|
+
channel: "cmql1802601iihhzpf774vwbk",
|
|
202
|
+
thread_root_id: null,
|
|
203
|
+
});
|
|
204
|
+
assert.equal(routingKey(out), "cohort:cmql1802601iihhzpf774vwbk");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("deriveRouterItem narrows to the thread when there is one", () => {
|
|
208
|
+
const out = deriveRouterItem({ ...cohortItem, thread_id: "root-42" });
|
|
209
|
+
assert.equal(routingKey(out), "cohort:cmql1802601iihhzpf774vwbk:root-42");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("deriveRouterItem refuses the display label as a key", () => {
|
|
213
|
+
// `task/<title>` and `doc/<name>` are labels; two rooms can share one.
|
|
214
|
+
assert.equal(deriveRouterItem({ sender: "Dana", service: "cohort", channel: "task/Ship the thing" }), null);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("deriveRouterItem still keys slack, gmail and calendar exactly as before", () => {
|
|
218
|
+
assert.equal(routingKey(deriveRouterItem({ service: "slack", channel: "D099", thread_id: null })), "slack:D099");
|
|
219
|
+
assert.equal(routingKey(deriveRouterItem({ service: "gmail", thread_id: "t-1" })), "gmail:t-1");
|
|
220
|
+
assert.equal(routingKey(deriveRouterItem({ service: "calendar", event_id: "e-1" })), "calendar:e-1");
|
|
221
|
+
});
|
|
@@ -28,7 +28,13 @@ import { spawn } from "child_process";
|
|
|
28
28
|
import { join } from "path";
|
|
29
29
|
import { randomUUID } from "crypto";
|
|
30
30
|
import { checkRecentlySent, registerSent } from "./session-lock.mjs";
|
|
31
|
-
import {
|
|
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
|
|
@@ -993,9 +995,14 @@ export async function sendQuickResponse(item, classResult, routed = null) {
|
|
|
993
995
|
* via the cheapest model under a tight (~5s, env-tunable) cap, and on ANY
|
|
994
996
|
* generation failure this function sends NOTHING and says so in the log. The
|
|
995
997
|
* durability story is unchanged — the caller opened the obligation before
|
|
996
|
-
* calling this, the assurance sweep
|
|
997
|
-
*
|
|
998
|
-
*
|
|
998
|
+
* calling this, and the assurance sweep owns whether a line goes out at all.
|
|
999
|
+
*
|
|
1000
|
+
* NOTE (2026-09-12): the daemon no longer calls this from the acknowledgement
|
|
1001
|
+
* path. `assurance.openAndAcknowledge` says NOTHING at open time; the sweep
|
|
1002
|
+
* emits at most one interim per obligation, past ASSURANCE_ACK_AFTER_MS and
|
|
1003
|
+
* inside the per-room budget. The timed progress updates are deleted. The
|
|
1004
|
+
* failure, stale and interrupted notices still guarantee the human is never
|
|
1005
|
+
* left in long-term silence.
|
|
999
1006
|
*
|
|
1000
1007
|
* @returns {{ sent: boolean, holdingText: string|null }} result
|
|
1001
1008
|
*/
|