@cohortapp/agent-sdk 2.5.0 → 2.6.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/bin/maestro.mjs +185 -88
- package/bin/maestro.test.mjs +175 -48
- package/docs/runbooks/backup-restore.md +65 -33
- package/framework-features.json +4 -4
- package/lib/backup/policy.mjs +710 -0
- package/lib/backup/policy.test.mjs +305 -0
- package/lib/budget-escalate.mjs +133 -0
- package/lib/budget-escalate.test.mjs +232 -0
- package/lib/budget-guard.envelope.test.mjs +476 -0
- package/lib/budget-guard.mjs +853 -75
- package/lib/budget-guard.test.mjs +91 -42
- package/lib/cadences.mjs +33 -0
- package/lib/channels/orgmail/adapter.mjs +88 -3
- package/lib/channels/orgmail/adapter.test.mjs +137 -0
- package/lib/channels/repeat-suppressor.mjs +198 -0
- package/lib/channels/repeat-suppressor.test.mjs +134 -0
- package/lib/comms/receipts.mjs +297 -0
- package/lib/cost/ledger-row.mjs +333 -0
- package/lib/cost/ledger-row.test.mjs +183 -0
- package/lib/execution/drive.mjs +28 -1
- package/lib/execution/effects.mjs +191 -12
- package/lib/execution/effects.test.mjs +50 -11
- package/lib/goals/admission.mjs +13 -1
- package/lib/goals/admission.test.mjs +26 -1
- package/lib/goals/loop.mjs +13 -0
- package/lib/kpi-sensors.test.mjs +3 -0
- package/lib/mandate/cache.mjs +13 -5
- package/lib/mandate/derive.mjs +146 -21
- package/lib/mandate/derive.test.mjs +50 -6
- package/lib/mandate/model.mjs +32 -4
- package/lib/mandate/refresh.test.mjs +16 -2
- package/lib/mcp/server.test.mjs +12 -3
- package/lib/model-router/economics.mjs +107 -76
- package/lib/model-router/economics.test.mjs +64 -46
- package/lib/model-router/integration-coverage.test.mjs +39 -37
- package/lib/model-router/ledger.mjs +75 -22
- package/lib/model-router/ledger.test.mjs +35 -2
- package/lib/org/client.mjs +14 -0
- package/lib/org/cost-sync.mjs +16 -2
- package/lib/org/doctor.mjs +62 -1
- package/lib/org/doctor.test.mjs +36 -3
- package/lib/org/email-remedy.mjs +49 -0
- package/lib/org/engagement-ledger.mjs +376 -0
- package/lib/org/engagement-ledger.test.mjs +112 -0
- package/lib/org/engagement.mjs +1056 -0
- package/lib/org/engagement.test.mjs +739 -0
- package/lib/org/inbound/hydrate.mjs +107 -15
- package/lib/org/inbound/hydrate.test.mjs +127 -0
- package/lib/org/messaging.mjs +230 -3
- package/lib/org/messaging.test.mjs +110 -1
- package/lib/org/param-contract.mjs +56 -2
- package/lib/org/param-contract.test.mjs +26 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +5 -0
- package/lib/org/protocol.test.mjs +7 -1
- package/lib/org/tool-surface.mjs +506 -10
- package/lib/org/tool-surface.test.mjs +191 -7
- package/lib/org/ui-parity.mjs +333 -6
- package/lib/org/ui-parity.test.mjs +96 -3
- package/lib/org/work-ledger.mjs +241 -0
- package/lib/org/work-ledger.test.mjs +237 -0
- package/lib/plan/adoption-e2e.test.mjs +366 -0
- package/lib/plan/budget-enforcement.test.mjs +400 -0
- package/lib/plan/budget-runtime.mjs +215 -0
- package/lib/plan/compile.mjs +201 -5
- package/lib/plan/compile.test.mjs +19 -5
- package/lib/plan/emit.mjs +8 -0
- package/lib/plan/emit.test.mjs +18 -0
- package/lib/resource-governor.mjs +58 -12
- package/lib/resource-governor.test.mjs +41 -1
- package/lib/security/audit-engine.mjs +45 -8
- package/lib/security/audit-engine.test.mjs +35 -0
- package/lib/setup/enroll-from-cohort.mjs +14 -1
- package/lib/setup/sections/mandate.mjs +48 -7
- package/lib/setup/sections/mandate.test.mjs +17 -2
- package/lib/setup/sections/orgmail.mjs +10 -2
- package/lib/setup/state.mjs +83 -2
- package/lib/telemetry/collect.mjs +360 -20
- package/lib/telemetry/collect.test.mjs +266 -0
- package/package.json +1 -1
- package/scripts/cost/track-claude-usage.mjs +207 -48
- package/scripts/cost/track-claude-usage.test.mjs +148 -0
- package/scripts/daemon/agent-daemon.mjs +315 -17
- package/scripts/daemon/assurance-e2e.test.mjs +421 -0
- package/scripts/daemon/assurance.mjs +944 -0
- package/scripts/daemon/assurance.test.mjs +668 -0
- package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
- package/scripts/daemon/cadence-consumer.mjs +147 -9
- package/scripts/daemon/cadence-consumer.test.mjs +6 -0
- package/scripts/daemon/cadence-handlers.mjs +158 -0
- package/scripts/daemon/cadence-handlers.test.mjs +64 -0
- package/scripts/daemon/deliver.mjs +314 -0
- package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
- package/scripts/daemon/dispatcher.mjs +64 -6
- package/scripts/daemon/responder-cost.test.mjs +68 -0
- package/scripts/daemon/responder.mjs +351 -298
- package/scripts/local-triggers/generate-plists.test.mjs +7 -4
- package/scripts/maintenance/backup-run.mjs +415 -0
- package/scripts/maintenance/backup-to-cloud.sh +16 -116
- package/scripts/org/send-orgmail.mjs +16 -0
- package/scripts/record-receipt.sh +63 -0
- package/scripts/restore-from-backup.sh +14 -3
- package/scripts/restore-from-backup.test.mjs +8 -5
- package/scripts/send-email-threaded.py +47 -0
- package/scripts/send-sms.sh +4 -0
- package/scripts/send-whatsapp.sh +4 -0
- package/scripts/setup/init-backup.mjs +93 -38
- package/scripts/slack-send.sh +12 -0
|
@@ -51,16 +51,81 @@ export function clip(text, max = 4000) {
|
|
|
51
51
|
return `${sp > max * 0.8 ? cut.slice(0, sp) : cut}…`;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* How many of the most recent turns must survive a binding character budget.
|
|
56
|
+
* Without a per-turn cap one long message spends the whole allowance and the
|
|
57
|
+
* turns around it vanish.
|
|
58
|
+
*/
|
|
59
|
+
const MIN_TURNS_VISIBLE = 4;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Who said it. hq denormalises `authorName` onto history rows, and when it is
|
|
63
|
+
* there it wins. When it is NOT — an older server, or any surface that never
|
|
64
|
+
* denormalised one — the fallback is a bare cuid, and a transcript of
|
|
65
|
+
* "cmqh0t9…: fix this / cmqh0tc…: which one?" loses the single distinction that
|
|
66
|
+
* matters most: which turns are MINE. An agent that cannot tell its own words
|
|
67
|
+
* from the sender's re-answers itself and misattributes its own statements back
|
|
68
|
+
* to them. `facts.me` is always known, so that distinction never has to be lost.
|
|
69
|
+
*/
|
|
70
|
+
export function speakerFor(m, facts) {
|
|
71
|
+
const name = s(m && m.authorName);
|
|
72
|
+
if (name) return name;
|
|
73
|
+
const id = s(m && (m.authorId || m.author));
|
|
74
|
+
if (facts && facts.me && id === s(facts.me)) {
|
|
75
|
+
return (Array.isArray(facts.myNames) && facts.myNames[0]) || "me";
|
|
76
|
+
}
|
|
77
|
+
return id || "someone";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Render a comment list into a compact prior-thread context block, oldest first.
|
|
82
|
+
*
|
|
83
|
+
* ORDER IS NOT A DETAIL HERE. The sources disagree: every comment read
|
|
84
|
+
* (`board.taskComments`, `file.listComments`, `files.comments`,
|
|
85
|
+
* `decision.listComments`) returns ASCENDING, but `messaging.history` returns
|
|
86
|
+
* DESCENDING — hq orders `createdAt desc` unless the caller pages forward with
|
|
87
|
+
* an `after` cursor, and the inbound pull never does. So the bare `slice(-limit)`
|
|
88
|
+
* this used to do took the OLDEST rows off a message page and then printed them
|
|
89
|
+
* backwards: the wrong window, in the wrong direction, silently.
|
|
90
|
+
*
|
|
91
|
+
* Sorting on the timestamp makes the tail mean "the most recent turns" for every
|
|
92
|
+
* source. The sort is stable and the comparator declines to order rows with no
|
|
93
|
+
* parseable timestamp, so a source that carries none keeps its arrival order.
|
|
94
|
+
*/
|
|
55
95
|
export function renderThread(comments, { limit = 8, max = 2000 } = {}) {
|
|
56
|
-
const
|
|
96
|
+
const all = (Array.isArray(comments) ? comments : []).slice();
|
|
97
|
+
all.sort((a, b) => {
|
|
98
|
+
const ta = Date.parse(s(a && (a.createdAt || a.at)));
|
|
99
|
+
const tb = Date.parse(s(b && (b.createdAt || b.at)));
|
|
100
|
+
if (!Number.isFinite(ta) || !Number.isFinite(tb)) return 0;
|
|
101
|
+
return ta - tb;
|
|
102
|
+
});
|
|
103
|
+
const rows = all.slice(-limit);
|
|
57
104
|
if (rows.length === 0) return null;
|
|
105
|
+
|
|
106
|
+
// Spend the budget from the NEWEST turn backwards. Clipping the joined string
|
|
107
|
+
// — which is what this did — keeps the oldest turns and drops the newest, the
|
|
108
|
+
// exact inverse of what context is for: the turns nearest the message being
|
|
109
|
+
// answered are the ones that explain it. Caught on the live CEO DM, where one
|
|
110
|
+
// long reply of mine filled the 2000 chars and displaced the four newer turns,
|
|
111
|
+
// including the question under discussion. The per-turn cap is the other half:
|
|
112
|
+
// without it a single long message starves every turn around it.
|
|
113
|
+
const perTurn = Math.max(200, Math.floor(max / Math.min(rows.length, MIN_TURNS_VISIBLE)));
|
|
58
114
|
const lines = rows.map((c) => {
|
|
59
115
|
const who = s(c.authorName || c.authorId || c.author || "someone");
|
|
60
|
-
const body = s(c.body || c.text).replace(/\s+/g, " ").trim();
|
|
116
|
+
const body = clip(s(c.body || c.text).replace(/\s+/g, " ").trim(), perTurn);
|
|
61
117
|
return `${who}: ${body}`;
|
|
62
118
|
});
|
|
63
|
-
|
|
119
|
+
|
|
120
|
+
const kept = [];
|
|
121
|
+
let used = 0;
|
|
122
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
123
|
+
const cost = lines[i].length + (kept.length ? 1 : 0); // +1 for the joining newline
|
|
124
|
+
if (kept.length > 0 && used + cost > max) break;
|
|
125
|
+
kept.unshift(lines[i]);
|
|
126
|
+
used += cost;
|
|
127
|
+
}
|
|
128
|
+
return kept.join("\n") || null;
|
|
64
129
|
}
|
|
65
130
|
|
|
66
131
|
/**
|
|
@@ -146,17 +211,24 @@ function hydrateMessage(c, facts) {
|
|
|
146
211
|
|
|
147
212
|
const chan = facts.channels instanceof Map ? facts.channels.get(s(c.ids.channelId)) : null;
|
|
148
213
|
const root = s(msg.threadRootId || msg.thread_root_id);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
)
|
|
159
|
-
|
|
214
|
+
|
|
215
|
+
// THE CONVERSATION SO FAR. For a thread reply that is the thread; for a
|
|
216
|
+
// TOP-LEVEL message it is the channel itself — which is the whole of a DM.
|
|
217
|
+
// This used to be gated on `root`, so a DM (no thread root, by definition)
|
|
218
|
+
// arrived with no context at all and the agent answered "fix this" without
|
|
219
|
+
// ever seeing what "this" referred to. The page is already in hand from the
|
|
220
|
+
// ACL'd `messaging.history` read `pageChannels` made, so re-joining it here
|
|
221
|
+
// costs no RPC and widens nothing: it is the same read, same aperture.
|
|
222
|
+
const scope = root
|
|
223
|
+
? page.messages.filter((m) => s(m.threadRootId || m.thread_root_id) === root || s(m.id) === root)
|
|
224
|
+
: page.messages;
|
|
225
|
+
const threadContext = renderThread(
|
|
226
|
+
priorTo(scope, msg, c.ids.messageId).map((m) => ({
|
|
227
|
+
authorName: speakerFor(m, facts),
|
|
228
|
+
body: m.body ?? m.text,
|
|
229
|
+
createdAt: m.createdAt,
|
|
230
|
+
})),
|
|
231
|
+
);
|
|
160
232
|
|
|
161
233
|
return {
|
|
162
234
|
ok: true,
|
|
@@ -467,6 +539,26 @@ async function hydrateEmail(c, facts, io, cache) {
|
|
|
467
539
|
// Small helpers
|
|
468
540
|
// ---------------------------------------------------------------------------
|
|
469
541
|
|
|
542
|
+
/**
|
|
543
|
+
* The messages that came BEFORE the trigger — never the ones after it.
|
|
544
|
+
*
|
|
545
|
+
* A history page is a window, not a prefix: when the pull is running behind, or
|
|
546
|
+
* replaying after a crash, the page can hold turns that were said AFTER the
|
|
547
|
+
* message being hydrated. Handing those to the agent as "prior context" would
|
|
548
|
+
* have it answer a question using an answer it has not given yet. When a row
|
|
549
|
+
* carries no parseable timestamp we keep it rather than guess — dropping real
|
|
550
|
+
* conversation is the failure mode being fixed here.
|
|
551
|
+
*/
|
|
552
|
+
function priorTo(messages, trigger, triggerId) {
|
|
553
|
+
const at = Date.parse(s(trigger && trigger.createdAt));
|
|
554
|
+
return (Array.isArray(messages) ? messages : []).filter((m) => {
|
|
555
|
+
if (s(m.id) === s(triggerId)) return false;
|
|
556
|
+
if (!Number.isFinite(at)) return true;
|
|
557
|
+
const t = Date.parse(s(m.createdAt));
|
|
558
|
+
return !Number.isFinite(t) || t <= at;
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
470
562
|
/** Memoise an async read for the lifetime of one pull. */
|
|
471
563
|
async function cached(cache, key, fn) {
|
|
472
564
|
if (cache.has(key)) return cache.get(key);
|
|
@@ -118,6 +118,64 @@ test("a threaded message carries prior-thread context", async () => {
|
|
|
118
118
|
assert.doesNotMatch(h.threadContext, /one more thing/, "the trigger itself is not its own context");
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
// A DM has no thread root — the CHANNEL is the conversation. Before this, the
|
|
122
|
+
// `if (root)` guard meant a top-level message carried no context at all, so the
|
|
123
|
+
// agent answered "fix this" with no idea what "this" referred to. The page is
|
|
124
|
+
// already in hand from the ACL'd `messaging.history` read, so this costs no RPC.
|
|
125
|
+
test("a top-level DM carries the preceding conversation as context", async () => {
|
|
126
|
+
// Newest-first, exactly as hq's messaging.history returns it.
|
|
127
|
+
const msgs = [
|
|
128
|
+
{ id: "m3", authorId: THEM, authorName: "Casey", body: "fix this then", createdAt: "2026-08-11T15:33:54.000Z" },
|
|
129
|
+
{ id: "m2", authorId: ME, authorName: "Alex", body: "16 min pickup on the morning batch", createdAt: "2026-08-11T15:32:00.000Z" },
|
|
130
|
+
{ id: "m1", authorId: THEM, authorName: "Casey", body: "what is the reply latency here", createdAt: "2026-08-11T15:31:00.000Z" },
|
|
131
|
+
];
|
|
132
|
+
const f = facts();
|
|
133
|
+
f.channels.set("C-dm", { id: "C-dm", kind: "DM", name: "dm-h001-a016" });
|
|
134
|
+
f.channelPages.set("C-dm", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
|
|
135
|
+
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m3", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM" } }));
|
|
136
|
+
const io = fakeIo({});
|
|
137
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "dm", reason: "dm" }, facts: f, io });
|
|
138
|
+
|
|
139
|
+
assert.equal(h.ok, true);
|
|
140
|
+
assert.equal(io.calls.length, 0, "the page was already in hand — context must cost no extra RPC");
|
|
141
|
+
assert.ok(h.threadContext, "a DM with prior turns must carry them");
|
|
142
|
+
assert.match(h.threadContext, /Casey: what is the reply latency here/);
|
|
143
|
+
assert.match(h.threadContext, /Alex: 16 min pickup/);
|
|
144
|
+
assert.doesNotMatch(h.threadContext, /fix this then/, "the trigger itself is not its own context");
|
|
145
|
+
assert.ok(
|
|
146
|
+
h.threadContext.indexOf("what is the reply latency") < h.threadContext.indexOf("16 min pickup"),
|
|
147
|
+
"oldest first — the conversation must read forwards",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a first-ever message in a channel has no context, not an empty block", async () => {
|
|
152
|
+
const msgs = [{ id: "m1", authorId: THEM, authorName: "Casey", body: "hello", createdAt: "2026-08-11T15:31:00.000Z" }];
|
|
153
|
+
const f = facts();
|
|
154
|
+
f.channels.set("C-dm", { id: "C-dm", kind: "DM" });
|
|
155
|
+
f.channelPages.set("C-dm", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
|
|
156
|
+
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM" } }));
|
|
157
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "dm", reason: "dm" }, facts: f, io: fakeIo({}) });
|
|
158
|
+
|
|
159
|
+
assert.equal(h.ok, true);
|
|
160
|
+
assert.equal(h.threadContext, null);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("messages AFTER the trigger are never passed off as prior context", async () => {
|
|
164
|
+
const msgs = [
|
|
165
|
+
{ id: "m3", authorId: THEM, authorName: "Casey", body: "later message", createdAt: "2026-08-11T16:00:00.000Z" },
|
|
166
|
+
{ id: "m2", authorId: THEM, authorName: "Casey", body: "the trigger", createdAt: "2026-08-11T15:00:00.000Z" },
|
|
167
|
+
{ id: "m1", authorId: ME, authorName: "Alex", body: "earlier message", createdAt: "2026-08-11T14:00:00.000Z" },
|
|
168
|
+
];
|
|
169
|
+
const f = facts();
|
|
170
|
+
f.channels.set("C-dm", { id: "C-dm", kind: "DM" });
|
|
171
|
+
f.channelPages.set("C-dm", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
|
|
172
|
+
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m2", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM" } }));
|
|
173
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "dm", reason: "dm" }, facts: f, io: fakeIo({}) });
|
|
174
|
+
|
|
175
|
+
assert.match(h.threadContext, /earlier message/);
|
|
176
|
+
assert.doesNotMatch(h.threadContext, /later message/);
|
|
177
|
+
});
|
|
178
|
+
|
|
121
179
|
// ---------------------------------------------------------------------------
|
|
122
180
|
// board
|
|
123
181
|
// ---------------------------------------------------------------------------
|
|
@@ -318,6 +376,75 @@ test("clip and renderThread are bounded", () => {
|
|
|
318
376
|
assert.match(renderThread([{ authorName: "A", body: "hi" }]), /A: hi/);
|
|
319
377
|
});
|
|
320
378
|
|
|
379
|
+
// `messaging.history` returns DESCENDING; every comment source returns ASCENDING.
|
|
380
|
+
// A bare slice(-limit) therefore took the OLDEST rows off a message page and
|
|
381
|
+
// printed them backwards — the wrong window, in the wrong order.
|
|
382
|
+
test("renderThread reads forwards and keeps the most RECENT turns", () => {
|
|
383
|
+
const rows = [];
|
|
384
|
+
for (let i = 12; i >= 1; i -= 1) {
|
|
385
|
+
rows.push({ authorName: "A", body: `turn ${i}`, createdAt: `2026-08-11T00:${String(i).padStart(2, "0")}:00.000Z` });
|
|
386
|
+
}
|
|
387
|
+
const out = renderThread(rows); // limit 8
|
|
388
|
+
|
|
389
|
+
assert.match(out, /turn 12/, "the newest turn is the one that matters most");
|
|
390
|
+
assert.doesNotMatch(out, /turn 1\b/, "the oldest turns fall off the window, not the newest");
|
|
391
|
+
assert.ok(out.indexOf("turn 5") < out.indexOf("turn 12"), "oldest first");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("renderThread leaves undated rows in the order they arrived", () => {
|
|
395
|
+
const out = renderThread([
|
|
396
|
+
{ authorName: "A", body: "first" },
|
|
397
|
+
{ authorName: "B", body: "second" },
|
|
398
|
+
]);
|
|
399
|
+
assert.ok(out.indexOf("first") < out.indexOf("second"));
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// The `limit` window and the `max` CHARACTER budget are two different windows,
|
|
403
|
+
// and only the first was covered: twelve short turns never reach 2000 chars, so
|
|
404
|
+
// the clip never fired and the direction it clips in was never asserted. On the
|
|
405
|
+
// real DM it fired immediately — one long reply filled the budget and the four
|
|
406
|
+
// newest turns, including the question being answered, were the ones discarded.
|
|
407
|
+
test("renderThread keeps the NEWEST turns when the CHARACTER budget binds", () => {
|
|
408
|
+
const out = renderThread(
|
|
409
|
+
[
|
|
410
|
+
{ authorName: "A", body: "x".repeat(1500), createdAt: "2026-08-11T00:01:00.000Z" },
|
|
411
|
+
{ authorName: "B", body: "the thing I actually asked", createdAt: "2026-08-11T00:02:00.000Z" },
|
|
412
|
+
],
|
|
413
|
+
{ max: 300 },
|
|
414
|
+
);
|
|
415
|
+
assert.match(out, /the thing I actually asked/, "the latest turn must survive the budget");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("renderThread does not let one huge turn starve the turns around it", () => {
|
|
419
|
+
const out = renderThread(
|
|
420
|
+
[
|
|
421
|
+
{ authorName: "A", body: "y".repeat(5000), createdAt: "2026-08-11T00:01:00.000Z" },
|
|
422
|
+
{ authorName: "B", body: "second", createdAt: "2026-08-11T00:02:00.000Z" },
|
|
423
|
+
{ authorName: "C", body: "third", createdAt: "2026-08-11T00:03:00.000Z" },
|
|
424
|
+
],
|
|
425
|
+
{ max: 2000 },
|
|
426
|
+
);
|
|
427
|
+
assert.match(out, /B: second/);
|
|
428
|
+
assert.match(out, /C: third/);
|
|
429
|
+
assert.ok(out.length <= 2100, `bounded, got ${out.length}`);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Until hq's `authorName` denormalisation is deployed every row carries a bare
|
|
433
|
+
// cuid, and the agent must still be able to see which turns are its own.
|
|
434
|
+
test("a transcript labels my own turns even when the server sends no names", async () => {
|
|
435
|
+
const msgs = [
|
|
436
|
+
{ id: "m1", authorId: ME, body: "already answered that", createdAt: "2026-08-11T14:00:00.000Z" },
|
|
437
|
+
{ id: "m2", authorId: THEM, body: "and the other thing?", createdAt: "2026-08-11T15:00:00.000Z" },
|
|
438
|
+
];
|
|
439
|
+
const f = facts({ myNames: ["Isla"] });
|
|
440
|
+
f.channels.set("C-dm", { id: "C-dm", kind: "DM" });
|
|
441
|
+
f.channelPages.set("C-dm", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
|
|
442
|
+
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m2", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM" } }));
|
|
443
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "dm", reason: "dm" }, facts: f, io: fakeIo({}) });
|
|
444
|
+
|
|
445
|
+
assert.match(h.threadContext, /Isla: already answered that/, "my own turn is mine, not a cuid");
|
|
446
|
+
});
|
|
447
|
+
|
|
321
448
|
test("event-kind phrasing is stable and total", () => {
|
|
322
449
|
assert.match(describeBoardKind("item.assigned"), /assigned to you/);
|
|
323
450
|
assert.match(describeBoardKind("who.knows"), /Update on your board item/);
|
package/lib/org/messaging.mjs
CHANGED
|
@@ -55,6 +55,7 @@ import { isEnabled, configFromAgent, call, read } from "./client.mjs";
|
|
|
55
55
|
import { errFrame } from "./protocol.mjs";
|
|
56
56
|
import { screenOutbound } from "../comms/send-gate.mjs";
|
|
57
57
|
import { getHookBus } from "../hooks/bus.mjs";
|
|
58
|
+
import { recordOutbound } from "../comms/receipts.mjs";
|
|
58
59
|
|
|
59
60
|
// ---------------------------------------------------------------------------
|
|
60
61
|
// Internal helpers
|
|
@@ -73,6 +74,55 @@ function resolveOrg(cfg) {
|
|
|
73
74
|
return { base: c.base, token: c.token };
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Normalise a mentions list onto the shape hq actually validates.
|
|
79
|
+
*
|
|
80
|
+
* ── WHY THIS EXISTS ──
|
|
81
|
+
* hq's `sendMessageSchemaV1` declares
|
|
82
|
+
* `mentions: z.array(z.object({ memberId: string, offset?: number })).max(50)`
|
|
83
|
+
* (server/validation/message.ts#mentionSchema). This module sent
|
|
84
|
+
* `params.mentions.map(String)` — an array of BARE STRINGS. Zod rejects the
|
|
85
|
+
* whole object, so a send carrying mentions did not merely lose its tags: the
|
|
86
|
+
* ENTIRE `messaging.send` came back `BAD_REQUEST`. Tagging was not "missing"
|
|
87
|
+
* from the SDK, it was a landmine — the one call an agent would make to bring a
|
|
88
|
+
* colleague in was the one call guaranteed to fail.
|
|
89
|
+
*
|
|
90
|
+
* Accepts, in the wild: `"M-1"`, `{memberId:"M-1"}`, `{id:"M-1"}`,
|
|
91
|
+
* `{memberId:"M-1", offset:12}`. Drops empties and de-dupes on memberId, since
|
|
92
|
+
* mentioning the same person twice creates two notification rows for one ask.
|
|
93
|
+
*
|
|
94
|
+
* @param {any} mentions
|
|
95
|
+
* @returns {Array<{memberId:string, offset?:number}>}
|
|
96
|
+
*/
|
|
97
|
+
export function normaliseMentions(mentions) {
|
|
98
|
+
if (!Array.isArray(mentions)) return [];
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const m of mentions) {
|
|
102
|
+
let memberId = "";
|
|
103
|
+
let offset;
|
|
104
|
+
if (typeof m === "string" || typeof m === "number") {
|
|
105
|
+
memberId = String(m).trim();
|
|
106
|
+
} else if (m && typeof m === "object") {
|
|
107
|
+
memberId = String(m.memberId ?? m.id ?? m.member ?? "").trim();
|
|
108
|
+
if (Number.isInteger(m.offset) && m.offset >= 0) offset = m.offset;
|
|
109
|
+
}
|
|
110
|
+
if (!memberId || seen.has(memberId)) continue;
|
|
111
|
+
seen.add(memberId);
|
|
112
|
+
out.push(offset === undefined ? { memberId } : { memberId, offset });
|
|
113
|
+
}
|
|
114
|
+
return out.slice(0, 50);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Pull a channel id out of the several shapes hq's channel.* results use. */
|
|
118
|
+
function channelIdOf(result) {
|
|
119
|
+
if (!result || typeof result !== "object") return null;
|
|
120
|
+
const direct = result.channelId || result.id;
|
|
121
|
+
if (direct) return String(direct);
|
|
122
|
+
const nested = result.channel && (result.channel.id || result.channel.channelId);
|
|
123
|
+
return nested ? String(nested) : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
76
126
|
/** A best-effort agent id for ledger attribution (never throws). */
|
|
77
127
|
function agentIdOf(cfg) {
|
|
78
128
|
const n = (cfg && cfg.org && cfg.org.cohort) || {};
|
|
@@ -201,11 +251,14 @@ export async function sendMessage(params = {}, o = {}) {
|
|
|
201
251
|
// SDK was rejected with "channelId: Required" — no agent could post to Cohort
|
|
202
252
|
// at all. The legacy names are kept alongside for any older server that still
|
|
203
253
|
// reads them; the canonical ones are what hq validates.
|
|
254
|
+
const mentions = normaliseMentions(params.mentions);
|
|
204
255
|
const wire = {
|
|
205
256
|
channelId: channel,
|
|
206
257
|
channel,
|
|
207
258
|
body: text,
|
|
208
|
-
|
|
259
|
+
// `[{memberId}]`, never `["M-1"]` — see normaliseMentions for what the bare
|
|
260
|
+
// string cost.
|
|
261
|
+
...(mentions.length ? { mentions } : {}),
|
|
209
262
|
...(params.threadId != null ? { threadRootId: String(params.threadId), threadId: String(params.threadId) } : {}),
|
|
210
263
|
...(idempotencyId ? { idempotencyId, id: idempotencyId } : {}),
|
|
211
264
|
};
|
|
@@ -216,10 +269,20 @@ export async function sendMessage(params = {}, o = {}) {
|
|
|
216
269
|
fetchImpl: o.fetchImpl,
|
|
217
270
|
});
|
|
218
271
|
|
|
219
|
-
// (4) on success: observe-only afterSend + source="messaging" attribution
|
|
272
|
+
// (4) on success: observe-only afterSend + source="messaging" attribution +
|
|
273
|
+
// a DELIVERY RECEIPT. The receipt is what lets the daemon answer "did a human
|
|
274
|
+
// actually hear from us about this ask" with evidence instead of an exit code.
|
|
220
275
|
if (frame && frame.ok) {
|
|
276
|
+
recordOutbound({
|
|
277
|
+
service: "cohort",
|
|
278
|
+
channel,
|
|
279
|
+
kind: "reply",
|
|
280
|
+
via: "messaging.send",
|
|
281
|
+
chars: text.length,
|
|
282
|
+
agentRoot: o.agentRoot,
|
|
283
|
+
});
|
|
221
284
|
await emitAfterSend(
|
|
222
|
-
{ channel: "cohort", recipient: channel, text, mentions:
|
|
285
|
+
{ channel: "cohort", recipient: channel, text, mentions: mentions.map((m) => m.memberId), result: frame.result, source: "messaging" },
|
|
223
286
|
o
|
|
224
287
|
);
|
|
225
288
|
await attributeMessaging({ cfg: o.cfg, agentRoot: o.agentRoot, action: "messaging.send", channel }, o);
|
|
@@ -309,6 +372,165 @@ export async function listChannels(o = {}) {
|
|
|
309
372
|
return [];
|
|
310
373
|
}
|
|
311
374
|
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// Rooms — open a DM, open a group, create a channel, add someone to one
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
//
|
|
379
|
+
// All four methods have been in the frozen protocol table since it was written
|
|
380
|
+
// (`channel.resolveOrCreateDm` / `.createConversation` / `.create` / `.addMember`,
|
|
381
|
+
// all on `messaging.write`). None of them had a helper here, so an SDK agent
|
|
382
|
+
// could reply where it was spoken to and nowhere else: it could not open a 1:1
|
|
383
|
+
// with someone who had not written to it first, could not put three people in a
|
|
384
|
+
// room, and could not pull a fourth into an existing one. "Bring the right
|
|
385
|
+
// people in" was unreachable not because the org lacked the verb but because
|
|
386
|
+
// this layer never bound it.
|
|
387
|
+
//
|
|
388
|
+
// Each returns `{ok, channelId, created, frame}` rather than a raw frame, since
|
|
389
|
+
// every caller's next move is `sendMessage({channel: channelId, …})` and the id
|
|
390
|
+
// arrives under three different keys depending on the handler.
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Resolve (or create) the deterministic 1:1 DM channel with a member.
|
|
394
|
+
* Idempotent by construction — hq derives the slug from the two member slugs,
|
|
395
|
+
* so a retry reuses the room instead of littering DMs.
|
|
396
|
+
*
|
|
397
|
+
* @param {object} params - { memberId }
|
|
398
|
+
* @param {object} o - { cfg, fetchImpl?, agentRoot? }
|
|
399
|
+
* @returns {Promise<{ok:boolean, channelId:string|null, created:boolean, frame:object}>}
|
|
400
|
+
*/
|
|
401
|
+
export async function openDm(params = {}, o = {}) {
|
|
402
|
+
const memberId = params.memberId != null ? String(params.memberId).trim() : "";
|
|
403
|
+
if (!memberId) {
|
|
404
|
+
const frame = errFrame("BAD_REQUEST", "openDm: missing memberId");
|
|
405
|
+
return { ok: false, channelId: null, created: false, frame };
|
|
406
|
+
}
|
|
407
|
+
const org = resolveOrg(o.cfg);
|
|
408
|
+
if (!org) {
|
|
409
|
+
const frame = errFrame("BAD_REQUEST", "org messaging disabled or unconfigured");
|
|
410
|
+
return { ok: false, channelId: null, created: false, frame };
|
|
411
|
+
}
|
|
412
|
+
const frame = await call("channel.resolveOrCreateDm", { memberId }, {
|
|
413
|
+
base: org.base, token: org.token, fetchImpl: o.fetchImpl,
|
|
414
|
+
});
|
|
415
|
+
const channelId = frame && frame.ok ? channelIdOf(frame.result) : null;
|
|
416
|
+
return {
|
|
417
|
+
ok: !!(frame && frame.ok && channelId),
|
|
418
|
+
channelId,
|
|
419
|
+
created: !!(frame && frame.ok && frame.result && frame.result.created === true),
|
|
420
|
+
frame,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Open a conversation with one or more people (channel.createConversation).
|
|
426
|
+
* hq collapses a single other person onto the deterministic 1:1 DM and mints a
|
|
427
|
+
* fresh GROUP_DM for two or more — so this is the right call for "get these
|
|
428
|
+
* three in a room" without the caller having to branch on the count.
|
|
429
|
+
*
|
|
430
|
+
* @param {object} params - { memberIds:string[], name? }
|
|
431
|
+
* @param {object} o - { cfg, fetchImpl?, idempotencyKey? }
|
|
432
|
+
* @returns {Promise<{ok:boolean, channelId:string|null, created:boolean, frame:object}>}
|
|
433
|
+
*/
|
|
434
|
+
export async function openConversation(params = {}, o = {}) {
|
|
435
|
+
const memberIds = Array.from(
|
|
436
|
+
new Set((Array.isArray(params.memberIds) ? params.memberIds : []).map((m) => String(m || "").trim()).filter(Boolean)),
|
|
437
|
+
);
|
|
438
|
+
if (!memberIds.length) {
|
|
439
|
+
const frame = errFrame("BAD_REQUEST", "openConversation: memberIds is empty");
|
|
440
|
+
return { ok: false, channelId: null, created: false, frame };
|
|
441
|
+
}
|
|
442
|
+
const org = resolveOrg(o.cfg);
|
|
443
|
+
if (!org) {
|
|
444
|
+
const frame = errFrame("BAD_REQUEST", "org messaging disabled or unconfigured");
|
|
445
|
+
return { ok: false, channelId: null, created: false, frame };
|
|
446
|
+
}
|
|
447
|
+
const wire = { memberIds, ...(params.name ? { name: String(params.name).slice(0, 120) } : {}) };
|
|
448
|
+
const frame = await call("channel.createConversation", wire, {
|
|
449
|
+
base: org.base, token: org.token, idempotencyKey: o.idempotencyKey, fetchImpl: o.fetchImpl,
|
|
450
|
+
});
|
|
451
|
+
const channelId = frame && frame.ok ? channelIdOf(frame.result) : null;
|
|
452
|
+
return {
|
|
453
|
+
ok: !!(frame && frame.ok && channelId),
|
|
454
|
+
channelId,
|
|
455
|
+
created: !!(frame && frame.ok && frame.result && frame.result.created !== false),
|
|
456
|
+
frame,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Create a named org channel (channel.create). This is the MOST expensive room
|
|
462
|
+
* an agent can make — it is persistent, it appears in everyone's sidebar, and
|
|
463
|
+
* nobody can un-see it — so callers should reach for `openConversation` first
|
|
464
|
+
* and only create a channel when the work is durable and the audience standing.
|
|
465
|
+
*
|
|
466
|
+
* @param {object} params - { slug, name, kind?, topic?, members? }
|
|
467
|
+
* @param {object} o - { cfg, fetchImpl?, idempotencyKey? }
|
|
468
|
+
* @returns {Promise<{ok:boolean, channelId:string|null, created:boolean, frame:object}>}
|
|
469
|
+
*/
|
|
470
|
+
export async function createChannel(params = {}, o = {}) {
|
|
471
|
+
const slug = params.slug != null ? String(params.slug).trim().toLowerCase() : "";
|
|
472
|
+
const name = params.name != null ? String(params.name).trim() : "";
|
|
473
|
+
if (!slug || !name) {
|
|
474
|
+
const frame = errFrame("BAD_REQUEST", "createChannel: slug and name are required");
|
|
475
|
+
return { ok: false, channelId: null, created: false, frame };
|
|
476
|
+
}
|
|
477
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
|
478
|
+
// hq's `channelSlugSchema` refuses anything else; say so here rather than
|
|
479
|
+
// spending a round trip to be told.
|
|
480
|
+
const frame = errFrame("BAD_REQUEST", `createChannel: slug "${slug}" must be lowercase alphanumerics and hyphens`);
|
|
481
|
+
return { ok: false, channelId: null, created: false, frame };
|
|
482
|
+
}
|
|
483
|
+
const org = resolveOrg(o.cfg);
|
|
484
|
+
if (!org) {
|
|
485
|
+
const frame = errFrame("BAD_REQUEST", "org messaging disabled or unconfigured");
|
|
486
|
+
return { ok: false, channelId: null, created: false, frame };
|
|
487
|
+
}
|
|
488
|
+
const wire = {
|
|
489
|
+
slug,
|
|
490
|
+
name: name.slice(0, 120),
|
|
491
|
+
kind: params.kind ? String(params.kind).toUpperCase() : "PRIVATE",
|
|
492
|
+
...(params.topic ? { topic: String(params.topic).slice(0, 500) } : {}),
|
|
493
|
+
...(Array.isArray(params.members) && params.members.length
|
|
494
|
+
? { members: params.members.map((m) => String(m)).filter(Boolean) }
|
|
495
|
+
: {}),
|
|
496
|
+
};
|
|
497
|
+
const frame = await call("channel.create", wire, {
|
|
498
|
+
base: org.base, token: org.token, idempotencyKey: o.idempotencyKey, fetchImpl: o.fetchImpl,
|
|
499
|
+
});
|
|
500
|
+
const channelId = frame && frame.ok ? channelIdOf(frame.result) : null;
|
|
501
|
+
return { ok: !!(frame && frame.ok && channelId), channelId, created: !!(frame && frame.ok), frame };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Add a member to a channel (channel.addMember). Idempotent — hq returns the
|
|
506
|
+
* existing row with `added:false` rather than erroring, so a re-invite is a
|
|
507
|
+
* no-op. The acting agent must already be in the channel (hq default-denies
|
|
508
|
+
* inviting into a room you are not in).
|
|
509
|
+
*
|
|
510
|
+
* @param {object} params - { channelId, memberId }
|
|
511
|
+
* @param {object} o - { cfg, fetchImpl?, idempotencyKey? }
|
|
512
|
+
* @returns {Promise<{ok:boolean, added:boolean, frame:object}>}
|
|
513
|
+
*/
|
|
514
|
+
export async function addChannelMember(params = {}, o = {}) {
|
|
515
|
+
const channelId = params.channelId != null ? String(params.channelId).trim() : "";
|
|
516
|
+
const memberId = params.memberId != null ? String(params.memberId).trim() : "";
|
|
517
|
+
if (!channelId || !memberId) {
|
|
518
|
+
return { ok: false, added: false, frame: errFrame("BAD_REQUEST", "addChannelMember: channelId and memberId are required") };
|
|
519
|
+
}
|
|
520
|
+
const org = resolveOrg(o.cfg);
|
|
521
|
+
if (!org) {
|
|
522
|
+
return { ok: false, added: false, frame: errFrame("BAD_REQUEST", "org messaging disabled or unconfigured") };
|
|
523
|
+
}
|
|
524
|
+
const frame = await call("channel.addMember", { channelId, memberId }, {
|
|
525
|
+
base: org.base, token: org.token, idempotencyKey: o.idempotencyKey, fetchImpl: o.fetchImpl,
|
|
526
|
+
});
|
|
527
|
+
return {
|
|
528
|
+
ok: !!(frame && frame.ok),
|
|
529
|
+
added: !!(frame && frame.ok && frame.result && frame.result.added !== false),
|
|
530
|
+
frame,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
312
534
|
// ---------------------------------------------------------------------------
|
|
313
535
|
// Calling — start / join
|
|
314
536
|
// ---------------------------------------------------------------------------
|
|
@@ -660,6 +882,11 @@ export default {
|
|
|
660
882
|
reactMessage,
|
|
661
883
|
fetchHistory,
|
|
662
884
|
listChannels,
|
|
885
|
+
openDm,
|
|
886
|
+
openConversation,
|
|
887
|
+
createChannel,
|
|
888
|
+
addChannelMember,
|
|
889
|
+
normaliseMentions,
|
|
663
890
|
initiateCall,
|
|
664
891
|
joinCall,
|
|
665
892
|
pullInbound,
|