@cohortapp/agent-sdk 2.15.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.env.example +5 -2
  2. package/docs/guides/front-door-session.md +16 -5
  3. package/docs/guides/poller-daemon-setup.md +53 -2
  4. package/lib/assurance/plan-note.mjs +251 -0
  5. package/lib/assurance/plan-note.test.mjs +234 -0
  6. package/lib/assurance/room-budget.mjs +497 -0
  7. package/lib/assurance/room-budget.test.mjs +486 -0
  8. package/lib/assurance/tier.mjs +166 -0
  9. package/lib/assurance/tier.test.mjs +174 -0
  10. package/lib/comms/receipts.mjs +17 -1
  11. package/lib/context/budget.mjs +327 -0
  12. package/lib/context/budget.test.mjs +252 -0
  13. package/lib/context/history-scope.mjs +138 -0
  14. package/lib/context/history-scope.test.mjs +79 -0
  15. package/lib/model-router/economics.mjs +9 -0
  16. package/lib/model-router/resolve.mjs +6 -0
  17. package/lib/org/inbound/facts.mjs +4 -2
  18. package/lib/org/inbound/hydrate.mjs +555 -51
  19. package/lib/org/inbound/hydrate.test.mjs +456 -1
  20. package/package.json +3 -1
  21. package/plugins/maestro-skills/skills/inbound-triage.md +52 -24
  22. package/plugins/maestro-skills/skills/main-session.md +6 -4
  23. package/scripts/daemon/agent-daemon.mjs +35 -7
  24. package/scripts/daemon/agent-daemon.test.mjs +23 -6
  25. package/scripts/daemon/assurance-e2e.test.mjs +75 -19
  26. package/scripts/daemon/assurance.mjs +663 -159
  27. package/scripts/daemon/assurance.test.mjs +820 -140
  28. package/scripts/daemon/context-compiler.mjs +52 -21
  29. package/scripts/daemon/context-compiler.test.mjs +106 -0
  30. package/scripts/daemon/deliver.mjs +7 -4
  31. package/scripts/daemon/dispatcher-session-continuity.test.mjs +365 -0
  32. package/scripts/daemon/dispatcher.mjs +210 -9
  33. package/scripts/daemon/lib/session-router.mjs +310 -42
  34. package/scripts/daemon/lib/session-router.test.mjs +260 -1
  35. package/scripts/daemon/prompt-builder.mjs +160 -16
  36. package/scripts/daemon/prompt-builder.test.mjs +287 -7
  37. package/scripts/daemon/responder-history.test.mjs +37 -1
  38. package/scripts/daemon/responder.mjs +79 -72
@@ -0,0 +1,252 @@
1
+ /**
2
+ * budget.test.mjs — the declared token budget (spec §5.4, SDK half).
3
+ *
4
+ * Three properties are under test throughout:
5
+ * 1. the budget is DECLARED per tier, not discovered by a char clamp;
6
+ * 2. sections are filled in PRIORITY order, so the trigger survives a budget
7
+ * that the backlog cannot, and RENDERED in cache order, which is a
8
+ * different order for a different reason; and
9
+ * 3. every drop is VISIBLE in the rendered text. A silent drop is the bug —
10
+ * the reader cannot tell an absent turn from a turn that never happened.
11
+ *
12
+ * Run: node --test lib/context/budget.test.mjs
13
+ */
14
+
15
+ "use strict";
16
+
17
+ import { test } from "node:test";
18
+ import assert from "node:assert/strict";
19
+
20
+ import {
21
+ TIER_BUDGETS,
22
+ SECTION_ORDER,
23
+ FILL_PRIORITY,
24
+ RENDER_ORDER,
25
+ CHARS_PER_TOKEN,
26
+ budgetFor,
27
+ estimateTokens,
28
+ fitSections,
29
+ } from "./budget.mjs";
30
+
31
+ const turns = (n, text = "x".repeat(40)) => Array.from({ length: n }, (_, i) => `s${i}: ${text}`);
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // the declared budget
35
+ // ---------------------------------------------------------------------------
36
+
37
+ test("each tier declares its own budget, and the table is frozen", () => {
38
+ assert.equal(TIER_BUDGETS.answer, 6000);
39
+ assert.equal(TIER_BUDGETS.work, 12000);
40
+ assert.equal(TIER_BUDGETS.plan, 20000);
41
+ assert.equal(Object.isFrozen(TIER_BUDGETS), true);
42
+ });
43
+
44
+ test("an unknown tier takes the WORK budget rather than an unbounded one", () => {
45
+ assert.equal(budgetFor("plan"), 20000);
46
+ assert.equal(budgetFor("nonsense"), TIER_BUDGETS.work);
47
+ assert.equal(budgetFor(undefined), TIER_BUDGETS.work);
48
+ });
49
+
50
+ test("estimateTokens is the declared chars-per-token ratio, rounded up", () => {
51
+ assert.equal(estimateTokens(""), 0);
52
+ assert.equal(estimateTokens("a".repeat(CHARS_PER_TOKEN)), 1);
53
+ assert.equal(estimateTokens("a".repeat(CHARS_PER_TOKEN + 1)), 2);
54
+ assert.equal(estimateTokens(null), 0);
55
+ });
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // fitting
59
+ // ---------------------------------------------------------------------------
60
+
61
+ test("everything that fits is kept, verbatim, with no drop notes", () => {
62
+ const out = fitSections({
63
+ tier: "plan",
64
+ sections: [
65
+ { name: "trigger", text: "fix the invoice total" },
66
+ { name: "thread", turns: turns(3) },
67
+ ],
68
+ });
69
+ assert.equal(out.drops.length, 0);
70
+ assert.match(out.text, /fix the invoice total/);
71
+ assert.doesNotMatch(out.text, /not shown/);
72
+ assert.ok(out.usedTokens <= out.budgetTokens);
73
+ });
74
+
75
+ test("an overflowing thread keeps the NEWEST turns and says how many it dropped", () => {
76
+ const out = fitSections({
77
+ tier: "answer",
78
+ budgetTokens: 60, // ~240 chars
79
+ sections: [
80
+ { name: "trigger", text: "which one?" },
81
+ { name: "thread", turns: turns(20, "y".repeat(60)) },
82
+ ],
83
+ });
84
+ const thread = out.sections.find((s) => s.name === "thread");
85
+ assert.ok(thread.keptTurns > 0, "at least one turn survives");
86
+ assert.ok(thread.droppedTurns > 0, "the rest were dropped");
87
+ assert.equal(thread.keptTurns + thread.droppedTurns, 20);
88
+ // The newest turn is the one nearest the message being answered.
89
+ assert.match(out.text, /s19:/);
90
+ assert.doesNotMatch(out.text, /s0:/);
91
+ assert.match(out.text, new RegExp(`… ${thread.droppedTurns} earlier turns not shown`));
92
+ });
93
+
94
+ test("the drop note is singular for exactly one dropped turn", () => {
95
+ // Two turns, and room for the newest one plus its drop note but not for both.
96
+ const out = fitSections({
97
+ tier: "answer",
98
+ budgetTokens: 60,
99
+ sections: [{ name: "thread", turns: [`s0: ${"a".repeat(200)}`, `s1: ${"b".repeat(200)}`] }],
100
+ });
101
+ const thread = out.sections.find((s) => s.name === "thread");
102
+ assert.equal(thread.keptTurns, 1);
103
+ assert.equal(thread.droppedTurns, 1);
104
+ assert.match(out.text, /… 1 earlier turn not shown/);
105
+ assert.doesNotMatch(out.text, /earlier turns not shown/);
106
+ });
107
+
108
+ test("sections fill in priority order — the trigger outranks the backlog", () => {
109
+ const out = fitSections({
110
+ tier: "answer",
111
+ budgetTokens: 12,
112
+ sections: [
113
+ { name: "backlog", turns: turns(6, "b".repeat(80)) },
114
+ { name: "trigger", text: "the one question that matters" },
115
+ ],
116
+ });
117
+ assert.match(out.text, /the one question that matters/);
118
+ const backlog = out.sections.find((s) => s.name === "backlog");
119
+ assert.equal(backlog.included, false);
120
+ });
121
+
122
+ test("a section dropped WHOLE is still visible — never silently absent", () => {
123
+ const out = fitSections({
124
+ tier: "answer",
125
+ budgetTokens: 20,
126
+ sections: [
127
+ { name: "trigger", text: "t".repeat(60) },
128
+ { name: "memory", turns: turns(4, "m".repeat(100)) },
129
+ ],
130
+ });
131
+ const memory = out.sections.find((s) => s.name === "memory");
132
+ assert.equal(memory.included, false);
133
+ assert.match(out.text, /… memory not shown/);
134
+ assert.equal(out.drops.some((d) => d.section === "memory"), true);
135
+ });
136
+
137
+ test("a long text section is clipped and says how many characters went", () => {
138
+ const body = "z".repeat(4000);
139
+ const out = fitSections({
140
+ tier: "answer",
141
+ budgetTokens: 100,
142
+ sections: [{ name: "entity", title: "Document", text: body }],
143
+ });
144
+ const entity = out.sections.find((s) => s.name === "entity");
145
+ assert.ok(entity.droppedChars > 0);
146
+ assert.match(out.text, /characters not shown/);
147
+ assert.ok(out.usedTokens <= out.budgetTokens);
148
+ });
149
+
150
+ test("the trigger is never dropped whole — it is the thing being answered", () => {
151
+ const out = fitSections({
152
+ tier: "answer",
153
+ budgetTokens: 5,
154
+ sections: [{ name: "trigger", text: "q".repeat(2000) }],
155
+ });
156
+ const trigger = out.sections.find((s) => s.name === "trigger");
157
+ assert.equal(trigger.included, true);
158
+ assert.ok(trigger.text.length > 0);
159
+ });
160
+
161
+ test("rendered order is CACHE order — stable first, the trigger last", () => {
162
+ // Fill priority and render order are two different questions. The trigger
163
+ // WINS the budget (it is the thing being answered) and RENDERS last (it is
164
+ // the only section that changes on every inbound). A block that opened with
165
+ // the trigger would have a zero-length stable prefix, so WP-4's cache_control
166
+ // breakpoint would sit after volatile content and every cache read would miss
167
+ // silently — design §4, "any byte change before a breakpoint invalidates
168
+ // everything after it".
169
+ const out = fitSections({
170
+ tier: "plan",
171
+ sections: [
172
+ { name: "memory", turns: ["m: one"] },
173
+ { name: "trigger", text: "the ask" },
174
+ { name: "thread", turns: ["t: two"] },
175
+ { name: "entity", text: "the card" },
176
+ ],
177
+ });
178
+ assert.deepEqual(out.sections.map((s) => s.name), ["entity", "memory", "thread", "trigger"]);
179
+ assert.ok(out.text.indexOf("the card") < out.text.indexOf("m: one"));
180
+ assert.ok(out.text.indexOf("m: one") < out.text.indexOf("t: two"));
181
+ assert.ok(out.text.indexOf("t: two") < out.text.indexOf("the ask"));
182
+ assert.ok(out.text.endsWith("the ask"), "the volatile section is the tail, so everything before it can be cached");
183
+ });
184
+
185
+ test("FILL priority still decides who is dropped — the trigger wins the budget", () => {
186
+ // Same four sections, a budget only one of them fits. Render order must not
187
+ // become a fill order by accident: `entity` renders first but `trigger` is
188
+ // what survives.
189
+ const out = fitSections({
190
+ budgetTokens: 8,
191
+ sections: [
192
+ { name: "entity", text: "E".repeat(400) },
193
+ { name: "backlog", text: "B".repeat(400) },
194
+ { name: "trigger", text: "the ask itself" },
195
+ ],
196
+ });
197
+ const byName = Object.fromEntries(out.sections.map((s) => [s.name, s]));
198
+ assert.equal(byName.trigger.included, true, "the trigger is filled first, whatever it renders after");
199
+ assert.equal(byName.trigger.droppedChars, 0, "…and it is filled WHOLE");
200
+ assert.match(out.text, /the ask itself/);
201
+ assert.equal(byName.backlog.included, false, "the last section in fill priority is the first to go");
202
+ assert.ok(byName.entity.droppedChars > 0, "and the higher-priority one is only trimmed");
203
+ assert.equal(out.text.endsWith("the ask itself"), true, "render order is unchanged by who was dropped");
204
+ });
205
+
206
+ test("FILL_PRIORITY and RENDER_ORDER name the same sections, and are not the same array", () => {
207
+ assert.deepEqual([...FILL_PRIORITY].sort(), [...RENDER_ORDER].sort(),
208
+ "a section that can be filled must be renderable, and vice versa");
209
+ assert.notDeepEqual([...FILL_PRIORITY], [...RENDER_ORDER]);
210
+ assert.equal(FILL_PRIORITY[0], "trigger", "the trigger wins the budget");
211
+ assert.equal(RENDER_ORDER[RENDER_ORDER.length - 1], "trigger", "…and renders last, so the prefix is cacheable");
212
+ });
213
+
214
+ test("an unknown section name sorts after every declared one, keeping its order", () => {
215
+ const out = fitSections({
216
+ tier: "plan",
217
+ sections: [
218
+ { name: "weather", text: "w" },
219
+ { name: "trigger", text: "t" },
220
+ { name: "tides", text: "d" },
221
+ ],
222
+ });
223
+ assert.deepEqual(out.sections.map((s) => s.name), ["trigger", "weather", "tides"]);
224
+ assert.equal(SECTION_ORDER.includes("trigger"), true);
225
+ assert.equal(SECTION_ORDER.includes("weather"), false);
226
+ });
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // purity
230
+ // ---------------------------------------------------------------------------
231
+
232
+ test("pure: same input, same output, and the caller's arrays are untouched", () => {
233
+ const input = { tier: "work", sections: [{ name: "thread", turns: turns(50) }] };
234
+ const before = JSON.stringify(input);
235
+ const a = fitSections(input);
236
+ const b = fitSections(input);
237
+ assert.deepEqual(a, b);
238
+ assert.equal(JSON.stringify(input), before, "fitSections mutated its argument");
239
+ });
240
+
241
+ test("no sections is an empty block, not a throw", () => {
242
+ const out = fitSections({ tier: "answer", sections: [] });
243
+ assert.equal(out.text, "");
244
+ assert.equal(out.usedTokens, 0);
245
+ assert.deepEqual(out.drops, []);
246
+ });
247
+
248
+ test("a malformed section is ignored rather than taking the prompt down", () => {
249
+ const out = fitSections({ tier: "answer", sections: [null, { text: "no name" }, { name: "trigger", text: "ok" }] });
250
+ assert.equal(out.sections.length, 1);
251
+ assert.match(out.text, /ok/);
252
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * lib/context/history-scope.mjs — which interaction logs may be read for THIS
3
+ * reply.
4
+ *
5
+ * THE DEFECT THIS EXISTS TO CLOSE
6
+ *
7
+ * `scripts/daemon/responder.mjs#logInteraction` files every exchange under
8
+ * `memory/interactions/<service>/…`, and it files each one TWICE: once under the
9
+ * room (`<channelId>`) and once under the person (`dm-<sender-slug>`). The two
10
+ * readers — `prompt-builder.mjs` and `context-compiler.mjs` — then merged every
11
+ * candidate directory into one timestamp-sorted block headed "Recent
12
+ * conversation history with this sender".
13
+ *
14
+ * While that read was pinned to `…/slack/…` the merge was inert for every other
15
+ * service. Reading the item's own service (design §3 R12) makes it live, and
16
+ * Cohort is the one service where a person's DMs and the public rooms they post
17
+ * in share a namespace. So: Dana DMs the agent something private, later
18
+ * @-mentions the agent in a public channel, and the public room's prompt
19
+ * carries the DM sentence verbatim. That is design §5.5's resource-scope leak —
20
+ * "per-person facts may inform a reply but are NEVER quoted into a shared room"
21
+ * — arriving through the back door of a directory list.
22
+ *
23
+ * THE RULE, stated once so both readers and the writer cannot drift:
24
+ *
25
+ * A per-person directory is readable only when the reply itself is going to
26
+ * that person privately. In a shared room only the room's own log is read.
27
+ * Unknown is not private: an item that does not positively say it is a DM is
28
+ * treated as a room.
29
+ *
30
+ * PURE. Everything arrives on the argument; nothing is read from disk, the
31
+ * clock or the environment.
32
+ *
33
+ * @module lib/context/history-scope
34
+ */
35
+
36
+ "use strict";
37
+
38
+ /** A blank-safe string. */
39
+ function s(v) {
40
+ return v == null ? "" : String(v);
41
+ }
42
+
43
+ /**
44
+ * Is this inbound item a private 1:1 (or small group DM) conversation?
45
+ *
46
+ * The signals, in the order they are trusted:
47
+ * 1. `is_dm` — set from Cohort's `channel_kind` (`lib/org/inbound/project.mjs`)
48
+ * and from Slack's `channel_type` (`scripts/poller/utils.mjs`). A literal
49
+ * `false` is a positive assertion of "room" and ends the question.
50
+ * 2. `channel_type` — Slack's own envelope field: `im` / `mpim`.
51
+ * 3. A Slack channel id beginning `D` — the convention the raw_ref parser in
52
+ * `prompt-builder.mjs` already relies on.
53
+ * 4. A `dm/<name>` channel label, which is what the Telegram and Slack
54
+ * adapters write for a private chat.
55
+ *
56
+ * Anything else is a room. That direction is deliberate and is the opposite of
57
+ * this module's neighbours: everywhere else in hydration an unknown degrades
58
+ * towards MORE context, because the cost is a thinner prompt. Here the cost of
59
+ * guessing wrong is one person's private sentence in a public channel, so
60
+ * unknown buys nothing.
61
+ *
62
+ * @param {object} item inbound item (`{service, is_dm, channel_type, channel_id, channel}`)
63
+ * @returns {boolean}
64
+ */
65
+ export function isPrivateConversation(item) {
66
+ if (!item || typeof item !== "object") return false;
67
+ if (item.is_dm === true) return true;
68
+ if (item.is_dm === false) return false;
69
+
70
+ const type = s(item.channel_type).toLowerCase();
71
+ if (type === "im" || type === "mpim") return true;
72
+ if (type === "channel" || type === "group") return false;
73
+
74
+ // Slack's own id convention. `service` defaults to slack here for the same
75
+ // reason both readers write `String(item.service || "slack")` — an item that
76
+ // predates the per-service split carries no service field, and a `D…` id on
77
+ // such an item is a Slack DM.
78
+ const service = s(item.service) || "slack";
79
+ const channelId = s(item.channel_id || item.channelId);
80
+ if (service === "slack" && /^D[A-Z0-9]+$/.test(channelId)) return true;
81
+
82
+ if (/^dm\//i.test(s(item.channel))) return true;
83
+
84
+ return false;
85
+ }
86
+
87
+ /**
88
+ * The interaction-log directory NAMES this reply may read, relative to
89
+ * `memory/interactions/<service>/`. Callers join them onto the service root.
90
+ *
91
+ * Ordered room-first so a merged read still prefers the room's own record.
92
+ * Slack keeps its legacy bare `<sender-slug>` directory alongside the `dm-`
93
+ * one — historical logs live there and must not go dark — and it is subject to
94
+ * exactly the same gate.
95
+ *
96
+ * @param {object} o
97
+ * @param {string|null} [o.channelId] the room/DM channel id, when known
98
+ * @param {string|null} [o.senderSlug] the sender, slugified
99
+ * @param {boolean} [o.isPrivate] is the reply going to that person privately
100
+ * @param {string} [o.service]
101
+ * @returns {string[]} directory names, possibly empty
102
+ */
103
+ export function historyDirNames({ channelId, senderSlug, isPrivate, service } = {}) {
104
+ const dirs = [];
105
+ const cid = s(channelId);
106
+ const slug = s(senderSlug);
107
+ if (cid) dirs.push(cid);
108
+ if (isPrivate === true && slug) {
109
+ dirs.push(`dm-${slug}`);
110
+ if ((s(service) || "slack") === "slack") dirs.push(slug);
111
+ }
112
+ return dirs;
113
+ }
114
+
115
+ /**
116
+ * The directories `logInteraction` may WRITE this exchange to.
117
+ *
118
+ * The read gate above is the one that stops the leak, but leaving the writer
119
+ * unconditional keeps every per-person directory permanently contaminated with
120
+ * public-room content — so a future reader, or an operator grepping the tree,
121
+ * meets the same defect again. One rule, both ends.
122
+ *
123
+ * @param {object} o
124
+ * @param {string|null} [o.channelId]
125
+ * @param {string|null} [o.senderSlug]
126
+ * @param {boolean} [o.isPrivate]
127
+ * @returns {string[]}
128
+ */
129
+ export function interactionWriteDirNames({ channelId, senderSlug, isPrivate } = {}) {
130
+ const dirs = [];
131
+ const cid = s(channelId);
132
+ const slug = s(senderSlug);
133
+ if (cid) dirs.push(cid);
134
+ if (isPrivate === true && slug) dirs.push(`dm-${slug}`);
135
+ return dirs;
136
+ }
137
+
138
+ export default { isPrivateConversation, historyDirNames, interactionWriteDirNames };
@@ -0,0 +1,79 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ isPrivateConversation,
6
+ historyDirNames,
7
+ interactionWriteDirNames,
8
+ } from "./history-scope.mjs";
9
+
10
+ test("is_dm is the authoritative signal, in both directions", () => {
11
+ assert.equal(isPrivateConversation({ is_dm: true }), true);
12
+ assert.equal(isPrivateConversation({ is_dm: false, channel_type: "im" }), false,
13
+ "an explicit is_dm:false is an assertion of 'room' and outranks a stale channel_type");
14
+ });
15
+
16
+ test("Slack's channel_type and the D-prefixed channel id both read as private", () => {
17
+ assert.equal(isPrivateConversation({ service: "slack", channel_type: "im" }), true);
18
+ assert.equal(isPrivateConversation({ service: "slack", channel_type: "mpim" }), true);
19
+ assert.equal(isPrivateConversation({ service: "slack", channel_id: "D099N1JGKRQ" }), true);
20
+ assert.equal(isPrivateConversation({ service: "slack", channel_id: "C099N1JGKRQ" }), false);
21
+ assert.equal(isPrivateConversation({ service: "slack", channel_type: "channel", channel_id: "D1" }), false);
22
+ });
23
+
24
+ test("a missing service reads as Slack — the historical default both readers use", () => {
25
+ assert.equal(isPrivateConversation({ channel_id: "D001TESTCH" }), true);
26
+ assert.equal(isPrivateConversation({ channel_id: "C001TESTCH" }), false);
27
+ assert.deepEqual(
28
+ historyDirNames({ channelId: "D001TESTCH", senderSlug: "alice-smith", isPrivate: true }),
29
+ ["D001TESTCH", "dm-alice-smith", "alice-smith"],
30
+ "…including its legacy bare-slug directory",
31
+ );
32
+ });
33
+
34
+ test("a dm/<name> label counts, and everything unknown is a ROOM", () => {
35
+ assert.equal(isPrivateConversation({ service: "telegram", channel: "dm/dana" }), true);
36
+ assert.equal(isPrivateConversation({ service: "cohort", channel_id: "c_capital_formation" }), false,
37
+ "unknown is not private — guessing wrong here puts a private sentence in a public room");
38
+ assert.equal(isPrivateConversation(null), false);
39
+ assert.equal(isPrivateConversation("nope"), false);
40
+ });
41
+
42
+ test("a shared room reads ONLY the room's own log — never a per-person directory", () => {
43
+ const dirs = historyDirNames({
44
+ channelId: "c_capital_formation", senderSlug: "dana", isPrivate: false, service: "cohort",
45
+ });
46
+ assert.deepEqual(dirs, ["c_capital_formation"]);
47
+ assert.equal(dirs.some((d) => d.includes("dana")), false);
48
+ });
49
+
50
+ test("a DM reads the room and the person; Slack also keeps its legacy bare slug", () => {
51
+ assert.deepEqual(
52
+ historyDirNames({ channelId: "c_dm", senderSlug: "dana", isPrivate: true, service: "cohort" }),
53
+ ["c_dm", "dm-dana"],
54
+ );
55
+ assert.deepEqual(
56
+ historyDirNames({ channelId: "D1", senderSlug: "dana", isPrivate: true, service: "slack" }),
57
+ ["D1", "dm-dana", "dana"],
58
+ );
59
+ });
60
+
61
+ test("the WRITER is gated by the same rule, so per-person logs stay per-person", () => {
62
+ assert.deepEqual(
63
+ interactionWriteDirNames({ channelId: "c_public", senderSlug: "dana", isPrivate: false }),
64
+ ["c_public"],
65
+ );
66
+ assert.deepEqual(
67
+ interactionWriteDirNames({ channelId: "c_dm", senderSlug: "dana", isPrivate: true }),
68
+ ["c_dm", "dm-dana"],
69
+ );
70
+ assert.deepEqual(
71
+ interactionWriteDirNames({ channelId: null, senderSlug: "dana", isPrivate: true }),
72
+ ["dm-dana"],
73
+ );
74
+ assert.deepEqual(
75
+ interactionWriteDirNames({ channelId: null, senderSlug: "dana", isPrivate: false }),
76
+ [],
77
+ "a room exchange with no room id is not filed under the person as a consolation prize",
78
+ );
79
+ });
@@ -310,6 +310,8 @@ const DEFERRABLE_PREFIXES = Object.freeze([
310
310
  // the live conversational / inbox path.
311
311
  const REALTIME_TASK_CLASSES = Object.freeze(new Set([
312
312
  "session.responder",
313
+ "session.answer",
314
+ "session.plan",
313
315
  "classify.inbox",
314
316
  "huddle",
315
317
  "voice.brief",
@@ -556,6 +558,13 @@ const HAIKU_EXPLORE = Object.freeze({
556
558
  // cheap classes where a subagent would only add overhead.
557
559
  const SPAWN_KNOBS = Object.freeze({
558
560
  "session.responder": { maxTurns: 40, effort: null, agentsJson: HAIKU_EXPLORE },
561
+ // The reply tiers (design §5.6 / §5.1). Until now `dispatcher.mjs` hardcoded
562
+ // `session.responder` for every spawn, so `effort` was ALWAYS null and
563
+ // `maxTurns` always 40 — complexity moved the model and the timeout and
564
+ // nothing else. An answer gets low effort and a short leash; a plan gets high
565
+ // effort, a longer one, and the cheap-subagent map it will actually use.
566
+ "session.answer": { maxTurns: 6, effort: "low", agentsJson: null },
567
+ "session.plan": { maxTurns: 60, effort: "high", agentsJson: HAIKU_EXPLORE },
559
568
  "classify.inbox": { maxTurns: 1, effort: null, agentsJson: null },
560
569
  lookup: { maxTurns: 2, effort: null, agentsJson: null },
561
570
  enrich: { maxTurns: 3, effort: null, agentsJson: null },
@@ -93,6 +93,12 @@ const TASK_CLASS_MAX_TURNS = Object.freeze({
93
93
  "voice.brief": 4,
94
94
  "learning.reflect": 6,
95
95
  "learning.curate": 6,
96
+ // The reply tiers (design §5.6). `session.responder` keeps the historical
97
+ // default; an ANSWER is a question the classifier already judged answerable,
98
+ // so a 40-turn ceiling is 39 turns of rope it does not need, and a PLAN is
99
+ // the rung that genuinely has multi-step work to do.
100
+ "session.answer": 6,
101
+ "session.plan": 60,
96
102
  });
97
103
 
98
104
  // ---------------------------------------------------------------------------
@@ -76,7 +76,7 @@ export const DEFAULT_LIMITS = Object.freeze({
76
76
  * @property {Map<string,Set<string>>} decisionVoices
77
77
  * @property {Map<string,object>|null} escalations
78
78
  * @property {Map<string,object>|null} approvals
79
- * @property {Map<string,object>|null} fileAcl fileId → {ownerId, shared, mentionsMe}
79
+ * @property {Map<string,object>|null} fileAcl fileId → {ownerId, shared, mentionsMe, kind}
80
80
  * @property {Set<string>|null} myEventIds calendar events I own or attend (null == unreadable)
81
81
  * @property {Map<string,object>|null} myEvents eventId → the calendar event DTO
82
82
  * @property {string[]} degraded names of facts that could not be read
@@ -602,7 +602,9 @@ async function resolveFileFacts({ candidates, facts, io, limits, log, degraded }
602
602
  const permission = String(res.permission || "").toLowerCase();
603
603
  const ownerId = String(file.ownerId || file.owner || "") || null;
604
604
  const shared = Boolean(permission && permission !== "none");
605
- const entry = { ownerId, shared, permission, mentionsMe: false, name: file.name || "" };
605
+ // `kind` (DOC | SHEET | DECK | …) rides along so the doc hydrator can skip a
606
+ // `files.docRead` that hq would answer BAD_REQUEST for. One field, no read.
607
+ const entry = { ownerId, shared, permission, mentionsMe: false, name: file.name || "", kind: String(file.kind || "") };
606
608
 
607
609
  if (!shared && !(ownerId && ownerId === facts.me) && facts.myNames.length > 0) {
608
610
  const cf = await io.call("files.comments", { fileId, limit: 20 });