@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
|
@@ -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
|
-
|
|
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 });
|