@cohortapp/agent-sdk 2.3.1 → 2.4.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 +37 -50
- package/framework-features.json +30 -0
- package/lib/backlog.mjs +136 -0
- package/lib/cadences.mjs +63 -2
- package/lib/cadences.test.mjs +105 -0
- package/lib/capability/inventory.mjs +542 -0
- package/lib/capability/inventory.test.mjs +232 -0
- package/lib/capability/probe.mjs +255 -0
- package/lib/channels/contract.mjs +37 -1
- package/lib/channels/contract.test.mjs +25 -1
- package/lib/channels/inbox-item.mjs +20 -0
- package/lib/claude-bin.mjs +37 -3
- package/lib/claude-bin.test.mjs +42 -8
- package/lib/execution/disposition.mjs +501 -0
- package/lib/execution/disposition.test.mjs +482 -0
- package/lib/execution/drive.mjs +352 -0
- package/lib/execution/drive.test.mjs +270 -0
- package/lib/execution/effects.mjs +340 -0
- package/lib/execution/effects.test.mjs +193 -0
- package/lib/execution/index.mjs +152 -0
- package/lib/execution/intake.mjs +581 -0
- package/lib/execution/intake.test.mjs +343 -0
- package/lib/execution/journal.mjs +374 -0
- package/lib/execution/journal.test.mjs +261 -0
- package/lib/execution/match.mjs +331 -0
- package/lib/execution/match.test.mjs +235 -0
- package/lib/execution/pipeline.mjs +341 -0
- package/lib/execution/pipeline.test.mjs +389 -0
- package/lib/execution/route.mjs +332 -0
- package/lib/execution/route.test.mjs +186 -0
- package/lib/execution/surface-policy.mjs +446 -0
- package/lib/execution/surface-policy.test.mjs +162 -0
- package/lib/goals/admission.mjs +209 -0
- package/lib/goals/admission.test.mjs +139 -0
- package/lib/goals/classify.mjs +206 -0
- package/lib/goals/classify.test.mjs +109 -0
- package/lib/goals/collaborate.mjs +415 -0
- package/lib/goals/collaborate.test.mjs +324 -0
- package/lib/goals/gaps.mjs +111 -0
- package/lib/goals/gaps.test.mjs +284 -0
- package/lib/goals/loop.mjs +537 -0
- package/lib/goals/loop.test.mjs +719 -0
- package/lib/identity/persona.mjs +247 -0
- package/lib/identity/persona.test.mjs +117 -0
- package/lib/kpi.mjs +469 -0
- package/lib/kpi.test.mjs +244 -0
- package/lib/mandate/audit.mjs +168 -0
- package/lib/mandate/audit.test.mjs +195 -0
- package/lib/mandate/cache.mjs +162 -0
- package/lib/mandate/derive.mjs +317 -0
- package/lib/mandate/derive.test.mjs +224 -0
- package/lib/mandate/model.mjs +352 -0
- package/lib/mandate/model.test.mjs +145 -0
- package/lib/mandate/refresh.mjs +187 -0
- package/lib/mandate/refresh.test.mjs +293 -0
- package/lib/mcp/server.test.mjs +4 -4
- package/lib/org/approvals.mjs +14 -2
- package/lib/org/client.mjs +79 -25
- package/lib/org/client.test.mjs +54 -1
- package/lib/org/doctor.mjs +64 -0
- package/lib/org/doctor.test.mjs +31 -2
- package/lib/org/inbound/directedness.mjs +720 -0
- package/lib/org/inbound/directedness.test.mjs +543 -0
- package/lib/org/inbound/facts.mjs +501 -0
- package/lib/org/inbound/facts.test.mjs +375 -0
- package/lib/org/inbound/hydrate.mjs +535 -0
- package/lib/org/inbound/hydrate.test.mjs +326 -0
- package/lib/org/inbound/index.mjs +233 -0
- package/lib/org/inbound/index.test.mjs +324 -0
- package/lib/org/inbound/io.mjs +141 -0
- package/lib/org/inbound/project.mjs +201 -0
- package/lib/org/inbound/project.test.mjs +287 -0
- package/lib/org/inbound/surfaces.mjs +257 -0
- package/lib/org/knowledge.mjs +10 -1
- package/lib/org/knowledge.test.mjs +8 -1
- package/lib/org/leases.mjs +5 -0
- package/lib/org/mesh.mjs +45 -2
- package/lib/org/mesh.test.mjs +55 -0
- package/lib/org/messaging.mjs +180 -15
- package/lib/org/messaging.test.mjs +117 -0
- package/lib/org/param-contract.mjs +694 -0
- package/lib/org/param-contract.test.mjs +451 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +8 -0
- package/lib/org/protocol.test.mjs +5 -1
- package/lib/org/push.mjs +1025 -0
- package/lib/org/push.test.mjs +690 -0
- package/lib/org/tool-surface.mjs +138 -38
- package/lib/org/tool-surface.test.mjs +13 -8
- package/lib/org/typing.mjs +341 -0
- package/lib/org/typing.test.mjs +291 -0
- package/lib/plan/compile.mjs +510 -0
- package/lib/plan/compile.test.mjs +286 -0
- package/lib/plan/emit.mjs +256 -0
- package/lib/plan/emit.test.mjs +246 -0
- package/lib/plan/explain.mjs +226 -0
- package/lib/plan/explain.test.mjs +188 -0
- package/lib/plan/schema.mjs +140 -0
- package/lib/resource-governor.mjs +47 -1
- package/lib/resource-governor.test.mjs +21 -1
- package/lib/setup/enroll-from-cohort.mjs +84 -16
- package/lib/setup/enroll-from-cohort.test.mjs +43 -1
- package/lib/setup/sections/identity.mjs +15 -4
- package/lib/setup/sections/identity.test.mjs +94 -0
- package/lib/setup/sections/inventory.mjs +178 -0
- package/lib/setup/sections/inventory.test.mjs +198 -0
- package/lib/setup/sections/mandate.mjs +392 -0
- package/lib/setup/sections/mandate.test.mjs +373 -0
- package/lib/setup/sections/subagents.mjs +427 -0
- package/lib/setup/sections/subagents.test.mjs +429 -0
- package/lib/setup/sections/verify.mjs +121 -0
- package/lib/setup/sections/verify.test.mjs +175 -0
- package/lib/setup/sot.mjs +2 -0
- package/lib/subagents/cli.mjs +463 -0
- package/lib/subagents/cli.test.mjs +389 -0
- package/lib/subagents/client.mjs +373 -0
- package/lib/subagents/client.test.mjs +309 -0
- package/lib/subagents/gap.mjs +268 -0
- package/lib/subagents/gap.test.mjs +234 -0
- package/lib/subagents/lock.mjs +296 -0
- package/lib/subagents/lock.test.mjs +248 -0
- package/lib/subagents/manifest.mjs +224 -0
- package/lib/subagents/manifest.test.mjs +175 -0
- package/lib/subagents/refs.mjs +274 -0
- package/lib/subagents/refs.test.mjs +204 -0
- package/lib/subagents/resolve.mjs +455 -0
- package/lib/subagents/resolve.test.mjs +422 -0
- package/lib/subagents/schema.mjs +467 -0
- package/lib/subagents/schema.test.mjs +306 -0
- package/package.json +9 -4
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
- package/policies/ai-disclosure.yaml +42 -2
- package/scaffold/CLAUDE.md +16 -2
- package/schedules/triggers/goal-steward.md +79 -0
- package/scripts/ci/conformance-org-api.mjs +792 -0
- package/scripts/ci/conformance-org-api.test.mjs +417 -0
- package/scripts/daemon/agent-daemon.mjs +70 -11
- package/scripts/daemon/cadence-handlers.mjs +187 -5
- package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
- package/scripts/daemon/inbox-deferral.mjs +45 -2
- package/scripts/daemon/inbox-deferral.test.mjs +56 -0
- package/scripts/daemon/inbox-wake.mjs +282 -0
- package/scripts/daemon/inbox-wake.test.mjs +199 -0
- package/scripts/daemon/maestro-daemon.mjs +23 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/responder.mjs +56 -0
- package/scripts/daemon/typing-registry.mjs +55 -2
- package/scripts/daemon/typing-registry.test.mjs +25 -0
- package/scripts/local-triggers/generate-plists.test.mjs +5 -5
- package/scripts/poller/inbox-scan-poller.mjs +26 -1
- package/scripts/poller/inbox-scan-poller.test.mjs +64 -0
- package/scripts/poller/slack-cloud-relay-client.mjs +5 -0
- package/scripts/poller/slack-poller.mjs +32 -0
- package/scripts/poller/slack-socket-mode.mjs +27 -1
- package/scripts/poller/slack-socket-mode.test.mjs +52 -0
- package/scripts/poller/utils.mjs +47 -0
- package/scripts/setup/gen-subagent-manifest.mjs +95 -0
- package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
- package/scripts/setup/generate-plan.mjs +108 -0
- package/scripts/setup/init-capability-manifest.mjs +70 -0
- package/scripts/setup/init-skill-marketplace.mjs +155 -0
- package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
package/lib/org/mesh.mjs
CHANGED
|
@@ -331,6 +331,34 @@ async function beatOnce(ctx) {
|
|
|
331
331
|
logWarn(`presence beat failed (${err && err.message}) — org may be down, continuing standalone`);
|
|
332
332
|
return [];
|
|
333
333
|
}
|
|
334
|
+
|
|
335
|
+
// A REJECTED beat is not a thrown beat. `presenceBeat` RESOLVES with a res
|
|
336
|
+
// FRAME — {ok:false,error:{code,message}} — for 401/403/404/500 alike, so the
|
|
337
|
+
// catch above only ever sees transport failures. Without this, a beat the
|
|
338
|
+
// server refuses is indistinguishable from one it accepted: nothing is logged,
|
|
339
|
+
// extractDirectives yields [], and the agent looks perfectly healthy while it
|
|
340
|
+
// is not in the mesh at all. Observed in the wild: an empty token produced a
|
|
341
|
+
// silent 401 on every beat for the life of the process.
|
|
342
|
+
//
|
|
343
|
+
// WARN ONLY — do NOT return early. An error frame is also how the kill switch
|
|
344
|
+
// arrives for a DEACTIVATED token: the server refuses the beat and rides
|
|
345
|
+
// `directive: "halt"` on the error. Returning here would drop exactly the
|
|
346
|
+
// signal that must never be dropped, so the directive walk below still runs.
|
|
347
|
+
//
|
|
348
|
+
// Logged, not thrown: the loop stays fail-open by design (the org being down
|
|
349
|
+
// must never take the daemon down). Being LOUD is the fix, not dying.
|
|
350
|
+
if (frame && frame.ok === false) {
|
|
351
|
+
const code = (frame.error && frame.error.code) || "UNKNOWN";
|
|
352
|
+
const msg = (frame.error && frame.error.message) || "";
|
|
353
|
+
logWarn(
|
|
354
|
+
`presence beat REJECTED by the org (${code}${msg ? `: ${msg}` : ""}) — ` +
|
|
355
|
+
`this agent is NOT in the mesh and will not receive directives` +
|
|
356
|
+
(code === "UNAUTHORIZED"
|
|
357
|
+
? `. Check COHORT_API_KEY in .env is present and paired`
|
|
358
|
+
: ""),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
334
362
|
const directives = extractDirectives(frame);
|
|
335
363
|
for (const d of directives) {
|
|
336
364
|
try { await handleDirective(d, ctx); } catch { /* each directive is independently fail-open */ }
|
|
@@ -421,13 +449,28 @@ export async function connectOrgMesh(o = {}) {
|
|
|
421
449
|
if (entry && entry.id) {
|
|
422
450
|
// Fetch THIS seat's granted tools (SP5); falls back to workspace-wide when absent.
|
|
423
451
|
ctx.agentMemberId = entry.id;
|
|
424
|
-
|
|
452
|
+
// The rich self-entry is projected onto hq's `.strict()` registerSchema by
|
|
453
|
+
// the wire contract inside client.call (param-contract#toRegisterParams);
|
|
454
|
+
// passing it raw used to 400 on every boot.
|
|
455
|
+
const frame = await client.register(entry, {
|
|
425
456
|
base: conn.base,
|
|
426
457
|
token: conn.token,
|
|
427
458
|
idempotencyKey: `registry.register:${entry.id}`,
|
|
428
459
|
fetchImpl: o.fetchImpl,
|
|
429
460
|
});
|
|
430
|
-
|
|
461
|
+
// NEVER claim success we did not get. `register` fails OPEN (it returns an
|
|
462
|
+
// error frame rather than throwing), and this line used to log "registered
|
|
463
|
+
// …" unconditionally — so a rejected registration looked healthy on every
|
|
464
|
+
// boot while the agent was absent from the directory.
|
|
465
|
+
if (frame && frame.ok) {
|
|
466
|
+
logInfo(`registered ${entry.id} with the org mesh`);
|
|
467
|
+
} else {
|
|
468
|
+
const err = (frame && frame.error) || {};
|
|
469
|
+
logWarn(
|
|
470
|
+
`registration REJECTED for ${entry.id} (${err.code || "unknown"}: ${err.message || "no detail"}) ` +
|
|
471
|
+
`— this agent is NOT in the org directory; presence-beat will retry`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
431
474
|
}
|
|
432
475
|
} catch (err) {
|
|
433
476
|
logWarn(`registration failed (${err && err.message}) — continuing; presence-beat will retry`);
|
package/lib/org/mesh.test.mjs
CHANGED
|
@@ -216,6 +216,61 @@ test("org unreachable: presence beat that rejects never throws (fail-open)", asy
|
|
|
216
216
|
handle.stop();
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
+
test("a REJECTED beat (error frame, not a throw) is loudly warned, not silently ignored", async () => {
|
|
220
|
+
const root = tmpRepo();
|
|
221
|
+
// A refused beat resolves with an error FRAME — it does not throw — so the
|
|
222
|
+
// fail-open catch above never sees it. Before this was handled, an agent with
|
|
223
|
+
// a bad/empty token 401'd on every beat for the life of the process while
|
|
224
|
+
// logging nothing: it was not in the mesh, could not receive `halt`, and
|
|
225
|
+
// looked perfectly healthy.
|
|
226
|
+
const client = fakeClient({
|
|
227
|
+
beatFrames: [{ ok: false, error: { code: "UNAUTHORIZED", message: "bad key" } }],
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const warnings = [];
|
|
231
|
+
const realWarn = console.warn;
|
|
232
|
+
console.warn = (...a) => { warnings.push(a.join(" ")); };
|
|
233
|
+
let directives;
|
|
234
|
+
try {
|
|
235
|
+
const handle = await connectOrgMesh({
|
|
236
|
+
agentRoot: root, cfg: cfgEnabled(), client,
|
|
237
|
+
setInterval: () => ({ unref() {} }),
|
|
238
|
+
});
|
|
239
|
+
directives = await _internals.beatOnce(handle._ctx);
|
|
240
|
+
handle.stop();
|
|
241
|
+
} finally {
|
|
242
|
+
console.warn = realWarn;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
assert.deepEqual(directives, [], "a rejected beat yields no directives");
|
|
246
|
+
const warned = warnings.join("\n");
|
|
247
|
+
assert.match(warned, /REJECTED/, "the rejection must be logged, not swallowed");
|
|
248
|
+
assert.match(warned, /UNAUTHORIZED/, "the error code must reach the operator");
|
|
249
|
+
assert.match(warned, /COHORT_API_KEY/, "an auth failure must name the likely cause");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("an accepted beat logs no rejection warning", async () => {
|
|
253
|
+
const root = tmpRepo();
|
|
254
|
+
const client = fakeClient({ beatFrames: [{ ok: true, result: { directives: [] } }] });
|
|
255
|
+
const warnings = [];
|
|
256
|
+
const realWarn = console.warn;
|
|
257
|
+
console.warn = (...a) => { warnings.push(a.join(" ")); };
|
|
258
|
+
try {
|
|
259
|
+
const handle = await connectOrgMesh({
|
|
260
|
+
agentRoot: root, cfg: cfgEnabled(), client,
|
|
261
|
+
setInterval: () => ({ unref() {} }),
|
|
262
|
+
});
|
|
263
|
+
await _internals.beatOnce(handle._ctx);
|
|
264
|
+
handle.stop();
|
|
265
|
+
} finally {
|
|
266
|
+
console.warn = realWarn;
|
|
267
|
+
}
|
|
268
|
+
assert.ok(
|
|
269
|
+
!warnings.join("\n").includes("REJECTED"),
|
|
270
|
+
"a healthy beat must not cry wolf",
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
219
274
|
test("registration failure at boot is swallowed; the mesh still comes up", async () => {
|
|
220
275
|
const root = tmpRepo();
|
|
221
276
|
const client = fakeClient();
|
package/lib/org/messaging.mjs
CHANGED
|
@@ -195,12 +195,19 @@ export async function sendMessage(params = {}, o = {}) {
|
|
|
195
195
|
|
|
196
196
|
// (3) the wire — idempotent on the client message id.
|
|
197
197
|
const idempotencyId = params.idempotencyId != null ? String(params.idempotencyId) : "";
|
|
198
|
+
// WIRE NAMES MUST MATCH hq's zod schema (server/validation/message.sendMessageBase):
|
|
199
|
+
// channelId, body, threadRootId, idempotencyId
|
|
200
|
+
// This sent `channel`, `threadId` and `id`, so EVERY messaging.send from the
|
|
201
|
+
// SDK was rejected with "channelId: Required" — no agent could post to Cohort
|
|
202
|
+
// at all. The legacy names are kept alongside for any older server that still
|
|
203
|
+
// reads them; the canonical ones are what hq validates.
|
|
198
204
|
const wire = {
|
|
205
|
+
channelId: channel,
|
|
199
206
|
channel,
|
|
200
207
|
body: text,
|
|
201
208
|
...(Array.isArray(params.mentions) && params.mentions.length ? { mentions: params.mentions.map(String) } : {}),
|
|
202
|
-
...(params.threadId != null ? { threadId: String(params.threadId) } : {}),
|
|
203
|
-
...(idempotencyId ? { id: idempotencyId } : {}),
|
|
209
|
+
...(params.threadId != null ? { threadRootId: String(params.threadId), threadId: String(params.threadId) } : {}),
|
|
210
|
+
...(idempotencyId ? { idempotencyId, id: idempotencyId } : {}),
|
|
204
211
|
};
|
|
205
212
|
const frame = await call("messaging.send", wire, {
|
|
206
213
|
base: org.base,
|
|
@@ -237,7 +244,7 @@ export async function reactMessage(params = {}, o = {}) {
|
|
|
237
244
|
const org = resolveOrg(o.cfg);
|
|
238
245
|
if (!org) return errFrame("BAD_REQUEST", "org messaging disabled or unconfigured");
|
|
239
246
|
|
|
240
|
-
const frame = await call("messaging.react", { channel, messageId, emoji }, {
|
|
247
|
+
const frame = await call("messaging.react", { channelId: channel, channel, messageId, emoji }, {
|
|
241
248
|
base: org.base,
|
|
242
249
|
token: org.token,
|
|
243
250
|
idempotencyKey: o.idempotencyKey,
|
|
@@ -265,6 +272,11 @@ export async function fetchHistory(params = {}, o = {}) {
|
|
|
265
272
|
const frame = await call(
|
|
266
273
|
"messaging.history",
|
|
267
274
|
{
|
|
275
|
+
// hq's handler validates `channelId` (zod). The SDK sent `channel`, so
|
|
276
|
+
// messaging.history returned BAD_REQUEST on every call and no caller ever
|
|
277
|
+
// received history. Send both: `channelId` is what the server reads, and
|
|
278
|
+
// `channel` is kept for any older server still accepting the legacy name.
|
|
279
|
+
channelId: channel,
|
|
268
280
|
channel,
|
|
269
281
|
...(params.since != null ? { since: params.since } : {}),
|
|
270
282
|
...(params.cursor != null ? { cursor: params.cursor } : {}),
|
|
@@ -372,10 +384,17 @@ export async function joinCall(params = {}, o = {}) {
|
|
|
372
384
|
* here as `nextCursor`) so a crash never loses or double-delivers events.
|
|
373
385
|
*
|
|
374
386
|
* Mention detection: an event whose `mentions[]` (or `mentioned_agents[]`)
|
|
375
|
-
* includes this agent's id, OR a
|
|
376
|
-
* directed. We do NOT include the
|
|
387
|
+
* includes this agent's id, OR a message in a PRIVATE ROOM this agent belongs
|
|
388
|
+
* to (DM / group DM / huddle), counts as directed. We do NOT include the
|
|
389
|
+
* agent's own outbound echoes (`from` == self).
|
|
390
|
+
*
|
|
391
|
+
* The org event feed is org-wide and redacted, so room membership has to come
|
|
392
|
+
* from the agent itself — we resolve it once per pull via `messaging.channels`
|
|
393
|
+
* (see {@link isDirectedAtAgent} for why this is load-bearing rather than an
|
|
394
|
+
* optimisation). Fail-open: if the roster cannot be read we simply pass
|
|
395
|
+
* `undefined`, and the filter stays conservative rather than guessing.
|
|
377
396
|
*
|
|
378
|
-
* @param {object} o - { cfg, agentId?, cursor?, limit?, fetchImpl? }
|
|
397
|
+
* @param {object} o - { cfg, agentId?, cursor?, limit?, fetchImpl?, myChannelIds? }
|
|
379
398
|
* @returns {Promise<{events:object[], nextCursor:(number|string|null)}>}
|
|
380
399
|
*/
|
|
381
400
|
export async function pullInbound(o = {}) {
|
|
@@ -389,6 +408,21 @@ export async function pullInbound(o = {}) {
|
|
|
389
408
|
const r = await read(path, { base: org.base, token: org.token, fetchImpl: o.fetchImpl });
|
|
390
409
|
if (!r.ok || !r.payload) return { events: [], nextCursor: cursor };
|
|
391
410
|
|
|
411
|
+
// Which rooms am I in? Injectable for tests; otherwise one cheap RPC per pull.
|
|
412
|
+
let myChannelIds = o.myChannelIds;
|
|
413
|
+
if (myChannelIds == null) {
|
|
414
|
+
try {
|
|
415
|
+
const channels = await listChannels({ cfg: o.cfg, fetchImpl: o.fetchImpl });
|
|
416
|
+
if (Array.isArray(channels)) {
|
|
417
|
+
myChannelIds = new Set(
|
|
418
|
+
channels.map((c) => String((c && (c.id || c.channelId)) || "")).filter(Boolean),
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
} catch {
|
|
422
|
+
myChannelIds = undefined; // unknown → conservative filter, never a throw
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
392
426
|
const raw = Array.isArray(r.payload) ? r.payload : (Array.isArray(r.payload.events) ? r.payload.events : []);
|
|
393
427
|
let maxSeq = cursor;
|
|
394
428
|
const events = [];
|
|
@@ -396,20 +430,126 @@ export async function pullInbound(o = {}) {
|
|
|
396
430
|
if (ev && (ev.seq != null) && Number.isFinite(Number(ev.seq))) {
|
|
397
431
|
maxSeq = Math.max(Number(maxSeq) || 0, Number(ev.seq));
|
|
398
432
|
}
|
|
399
|
-
const directed = isDirectedAtAgent(ev, me);
|
|
400
|
-
if (
|
|
401
|
-
|
|
433
|
+
const directed = isDirectedAtAgent(ev, me, { myChannelIds });
|
|
434
|
+
if (directed) {
|
|
435
|
+
events.push(toMessageEvent(ev, me));
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
// MENTION CANDIDATE. The org-wide feed says `mentionCount` — a NUMBER —
|
|
439
|
+
// and deliberately does NOT name who was mentioned: it is readable by every
|
|
440
|
+
// agent holding `org.read`, so listing member ids there would leak the
|
|
441
|
+
// association graph of private rooms (the payload is a REDACTED audit row
|
|
442
|
+
// by design). That redaction is right, and it is also why this filter alone
|
|
443
|
+
// is deaf to an @mention in a space.
|
|
444
|
+
//
|
|
445
|
+
// So resolve it the ACL-safe way: mark it a candidate here and confirm it
|
|
446
|
+
// below against `messaging.history`, which hq gates PER MEMBER. If this
|
|
447
|
+
// agent cannot read that room, it gets nothing back and the candidate is
|
|
448
|
+
// dropped — fail-closed, with no widening of what the shared feed says.
|
|
449
|
+
const p = ev.payload || ev;
|
|
450
|
+
const count = Number(p.mentionCount || 0);
|
|
451
|
+
if (me && count > 0) {
|
|
452
|
+
const cand = toMessageEvent(ev, me);
|
|
453
|
+
cand._mentionCandidate = true;
|
|
454
|
+
events.push(cand);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// HYDRATE THE BODY. The org event feed is redacted by design — it carries
|
|
459
|
+
// {actor, channelId, channelKind, counts, ts} and NO message text — so a
|
|
460
|
+
// projected event arrives with `text: ""`. An empty inbound is worse than no
|
|
461
|
+
// inbound: the classifier sees nothing to classify and the agent would
|
|
462
|
+
// "reply" to a blank message.
|
|
463
|
+
//
|
|
464
|
+
// The body is fetched over `messaging.history`, which is ACL'd server-side to
|
|
465
|
+
// rooms this agent belongs to — so hydration cannot widen what the agent can
|
|
466
|
+
// see, it only re-joins content the agent was always entitled to read. One
|
|
467
|
+
// fetch per affected channel, not per message.
|
|
468
|
+
//
|
|
469
|
+
// Fail-open: a message we cannot hydrate is dropped rather than delivered
|
|
470
|
+
// blank, because a blank inbound produces a nonsense reply in a real room.
|
|
471
|
+
const needBody = events.filter((e) => (!e.text || e._mentionCandidate) && e.kind !== "call");
|
|
472
|
+
if (needBody.length) {
|
|
473
|
+
const byChannel = new Map();
|
|
474
|
+
for (const e of needBody) {
|
|
475
|
+
if (!e.channel_id) continue;
|
|
476
|
+
if (!byChannel.has(e.channel_id)) byChannel.set(e.channel_id, []);
|
|
477
|
+
byChannel.get(e.channel_id).push(e);
|
|
478
|
+
}
|
|
479
|
+
for (const [channelId, pending] of byChannel) {
|
|
480
|
+
try {
|
|
481
|
+
const history = await fetchHistory(
|
|
482
|
+
{ channel: channelId, limit: 50 },
|
|
483
|
+
{ cfg: o.cfg, fetchImpl: o.fetchImpl },
|
|
484
|
+
);
|
|
485
|
+
const byId = new Map(
|
|
486
|
+
(Array.isArray(history) ? history : [])
|
|
487
|
+
.map((m) => [String((m && (m.id || m.messageId)) || ""), m])
|
|
488
|
+
.filter(([id]) => id),
|
|
489
|
+
);
|
|
490
|
+
for (const e of pending) {
|
|
491
|
+
const hit = byId.get(String(e.message_id));
|
|
492
|
+
if (!hit) continue;
|
|
493
|
+
e.text = String(hit.body ?? hit.text ?? "");
|
|
494
|
+
// The ACL'd read is the ONLY place mention identities are trusted.
|
|
495
|
+
e._mentions = Array.isArray(hit.mentions)
|
|
496
|
+
? hit.mentions.map((m) => String((m && (m.memberId || m.id)) ?? m))
|
|
497
|
+
: [];
|
|
498
|
+
if (!e.from.name || e.from.name === e.from.id) {
|
|
499
|
+
e.from.name = String(hit.authorName || hit.author?.displayName || e.from.name || "");
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
} catch {
|
|
503
|
+
/* fail-open: leave these unhydrated; they are dropped just below */
|
|
504
|
+
}
|
|
505
|
+
}
|
|
402
506
|
}
|
|
403
|
-
|
|
507
|
+
|
|
508
|
+
// Confirm the mention candidates against what history actually returned. A
|
|
509
|
+
// candidate survives ONLY if this agent's id is in that message's `mentions`
|
|
510
|
+
// — proven by an ACL'd read, never inferred from the shared feed.
|
|
511
|
+
const confirmed = events.filter((e) => {
|
|
512
|
+
if (!e._mentionCandidate) return true;
|
|
513
|
+
const ok = Array.isArray(e._mentions) && e._mentions.map(String).includes(String(me));
|
|
514
|
+
if (!ok) return false;
|
|
515
|
+
e.reason = "mention";
|
|
516
|
+
return true;
|
|
517
|
+
});
|
|
518
|
+
for (const e of confirmed) { delete e._mentionCandidate; delete e._mentions; }
|
|
519
|
+
|
|
520
|
+
const delivered = confirmed.filter((e) => e.kind === "call" || (e.text && e.text.trim()));
|
|
521
|
+
return { events: delivered, nextCursor: raw.length ? maxSeq : cursor };
|
|
404
522
|
}
|
|
405
523
|
|
|
406
524
|
/**
|
|
407
525
|
* Decide whether an org event is directed at this agent (an inbound it should
|
|
408
526
|
* react to) vs ambient chatter / its own echo. Pure.
|
|
409
|
-
*
|
|
527
|
+
*
|
|
528
|
+
* WHY THIS NEEDS `myChannelIds`. The `/v1/events` feed is ORG-scoped, not
|
|
529
|
+
* member-scoped — an agent sees every messaging event in the org — and the
|
|
530
|
+
* payload is deliberately REDACTED: hq emits only
|
|
531
|
+
* { actor, channelId, channelKind, recipientCount, mentionCount, ts }
|
|
532
|
+
* with no body and, crucially, NO recipient list. That redaction is correct
|
|
533
|
+
* (an org-wide feed must not leak who DMs whom), but it means "is this for me?"
|
|
534
|
+
* cannot be answered from the event alone. The agent's OWN channel membership
|
|
535
|
+
* is the missing half, and the agent already knows it (`messaging.channels`).
|
|
536
|
+
*
|
|
537
|
+
* This bit in production: a human DM'd an agent six times and the agent never
|
|
538
|
+
* saw a single message. The old code looked for a `to` array the payload never
|
|
539
|
+
* carries, then fell back to `p.kind` containing "dm" — but the event's kind is
|
|
540
|
+
* "send" and the DM-ness lives in `channelKind: "DM"`, which nothing inspected.
|
|
541
|
+
* Every human DM was silently dropped.
|
|
542
|
+
*
|
|
543
|
+
* Without `myChannelIds` this stays CONSERVATIVE for DMs (returns false rather
|
|
544
|
+
* than guessing), because the failure mode of guessing yes is an agent barging
|
|
545
|
+
* into other people's private conversations.
|
|
546
|
+
*
|
|
547
|
+
* @param {object} ev
|
|
548
|
+
* @param {string} me agent id
|
|
549
|
+
* @param {{myChannelIds?: Set<string>|string[]}} [opts]
|
|
410
550
|
* @returns {boolean}
|
|
411
551
|
*/
|
|
412
|
-
export function isDirectedAtAgent(ev, me) {
|
|
552
|
+
export function isDirectedAtAgent(ev, me, opts = {}) {
|
|
413
553
|
if (!ev || typeof ev !== "object") return false;
|
|
414
554
|
const family = ev.family || (ev.kind ? String(ev.kind).split(".")[0] : "");
|
|
415
555
|
if (family && family !== "messaging" && family !== "calling") return false;
|
|
@@ -423,21 +563,46 @@ export function isDirectedAtAgent(ev, me) {
|
|
|
423
563
|
.map((m) => String(m));
|
|
424
564
|
if (me && mentions.includes(String(me))) return true;
|
|
425
565
|
|
|
426
|
-
//
|
|
427
|
-
// explicit @mention.
|
|
566
|
+
// An explicit recipient list, when a producer supplies one.
|
|
428
567
|
const to = []
|
|
429
568
|
.concat(Array.isArray(p.to) ? p.to : (p.to != null ? [p.to] : []))
|
|
430
569
|
.concat(Array.isArray(p.invitees) ? p.invitees : [])
|
|
431
570
|
.map((t) => String(t));
|
|
432
571
|
if (me && to.includes(String(me))) return true;
|
|
433
572
|
|
|
573
|
+
// The REAL hq shape: a private room I am a member of. Membership is what makes
|
|
574
|
+
// it mine — in a DM/group-DM/huddle every message is addressed to the room,
|
|
575
|
+
// so no @mention is required.
|
|
576
|
+
const mine = normaliseChannelIds(opts.myChannelIds);
|
|
577
|
+
const channelId = String(p.channelId || p.channel || p.callId || "");
|
|
578
|
+
const channelKind = String(p.channelKind || p.channel_kind || "").toUpperCase();
|
|
579
|
+
const PRIVATE_ROOMS = ["DM", "GROUP_DM", "HUDDLE"];
|
|
580
|
+
if (mine && channelId && mine.has(channelId)) {
|
|
581
|
+
if (PRIVATE_ROOMS.includes(channelKind)) return true;
|
|
582
|
+
// A call event in one of my rooms is an invite I should react to.
|
|
583
|
+
if (family === "calling") return true;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Legacy/other producers that put the room kind in `kind` rather than
|
|
587
|
+
// `channelKind`. Unchanged behaviour for them.
|
|
434
588
|
const kind = String(p.kind || ev.kind || "").toLowerCase();
|
|
435
|
-
if (kind.includes("dm") || kind.includes("invite")
|
|
589
|
+
if (kind.includes("dm") || kind.includes("invite")) {
|
|
590
|
+
return !me || to.includes(String(me)) || mentions.includes(String(me)) || to.length === 0;
|
|
591
|
+
}
|
|
592
|
+
// A bare `calling` event with no room context: only when we cannot scope it.
|
|
593
|
+
if (family === "calling" && !mine) {
|
|
436
594
|
return !me || to.includes(String(me)) || mentions.includes(String(me)) || to.length === 0;
|
|
437
595
|
}
|
|
438
596
|
return false;
|
|
439
597
|
}
|
|
440
598
|
|
|
599
|
+
/** Accept a Set, an array, or nothing; null means "membership unknown". */
|
|
600
|
+
function normaliseChannelIds(v) {
|
|
601
|
+
if (v instanceof Set) return v;
|
|
602
|
+
if (Array.isArray(v)) return new Set(v.map((x) => String(x)));
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
441
606
|
/**
|
|
442
607
|
* Project an org messaging/calling event onto the unified MessageEvent contract
|
|
443
608
|
* (lib/channels/contract.MessageEvent) so eventToInboxItem can serialise it into
|
|
@@ -236,3 +236,120 @@ test("pullInbound: org disabled → empty, cursor preserved (fail-open)", async
|
|
|
236
236
|
assert.deepEqual(events, []);
|
|
237
237
|
assert.equal(nextCursor, 5);
|
|
238
238
|
});
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// The REAL hq event shape — regression for the silent-DM-drop
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
/** Exactly what hq appends for a human DM (redacted: no body, no recipients). */
|
|
245
|
+
function hqDmEvent(overrides = {}) {
|
|
246
|
+
return {
|
|
247
|
+
seq: 9880,
|
|
248
|
+
family: "messaging",
|
|
249
|
+
kind: "send",
|
|
250
|
+
entityId: "cmsnbhmqy004blj016kot2pwi",
|
|
251
|
+
actor: "human-1",
|
|
252
|
+
at: "2026-08-10T14:18:55.978Z",
|
|
253
|
+
payload: {
|
|
254
|
+
ts: "2026-08-10T14:18:55.978Z",
|
|
255
|
+
actor: "human-1",
|
|
256
|
+
threaded: false,
|
|
257
|
+
channelId: "dm-human-agent",
|
|
258
|
+
channelKind: "DM",
|
|
259
|
+
mentionCount: 0,
|
|
260
|
+
recipientCount: 2,
|
|
261
|
+
attachmentCount: 0,
|
|
262
|
+
...overrides,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
test("isDirectedAtAgent: a human DM in MY room is directed (the production silent-drop)", () => {
|
|
268
|
+
// Six real DMs were dropped: the filter wanted a `to` list the redacted
|
|
269
|
+
// payload never carries, and checked `kind` for "dm" when the kind is "send"
|
|
270
|
+
// and the room type lives in `channelKind`.
|
|
271
|
+
const mine = new Set(["dm-human-agent"]);
|
|
272
|
+
assert.equal(isDirectedAtAgent(hqDmEvent(), "agent-self", { myChannelIds: mine }), true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("isDirectedAtAgent: a DM I am NOT in is NOT mine (org feed is org-wide)", () => {
|
|
276
|
+
// /v1/events is org-scoped, so an agent sees other people's DM events too.
|
|
277
|
+
// Reacting to those would be barging into private conversations.
|
|
278
|
+
const mine = new Set(["dm-human-agent"]);
|
|
279
|
+
const someoneElses = hqDmEvent({ channelId: "dm-human-other" });
|
|
280
|
+
assert.equal(isDirectedAtAgent(someoneElses, "agent-self", { myChannelIds: mine }), false);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("isDirectedAtAgent: without membership context it stays CONSERVATIVE on DMs", () => {
|
|
284
|
+
// Guessing "yes" without knowing the roster is the dangerous direction.
|
|
285
|
+
assert.equal(isDirectedAtAgent(hqDmEvent(), "agent-self"), false);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("isDirectedAtAgent: my own echo in my own DM is still ignored", () => {
|
|
289
|
+
const mine = new Set(["dm-human-agent"]);
|
|
290
|
+
const echo = hqDmEvent({ actor: "agent-self" });
|
|
291
|
+
echo.actor = "agent-self";
|
|
292
|
+
assert.equal(isDirectedAtAgent(echo, "agent-self", { myChannelIds: mine }), false);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("isDirectedAtAgent: a PUBLIC channel message in my room is NOT directed on its own", () => {
|
|
296
|
+
// Membership in a public channel is not an invitation to answer everything;
|
|
297
|
+
// that needs an @mention (which the redacted payload cannot yet express —
|
|
298
|
+
// hq emits mentionCount, not the ids).
|
|
299
|
+
const mine = new Set(["general"]);
|
|
300
|
+
const pub = hqDmEvent({ channelId: "general", channelKind: "PUBLIC" });
|
|
301
|
+
assert.equal(isDirectedAtAgent(pub, "agent-self", { myChannelIds: mine }), false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("isDirectedAtAgent: an explicit mentions[] still wins anywhere", () => {
|
|
305
|
+
const mine = new Set(["general"]);
|
|
306
|
+
const pub = hqDmEvent({ channelId: "general", channelKind: "PUBLIC", mentions: ["agent-self"] });
|
|
307
|
+
assert.equal(isDirectedAtAgent(pub, "agent-self", { myChannelIds: mine }), true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("isDirectedAtAgent: a call event in my room is an invite", () => {
|
|
311
|
+
const mine = new Set(["room-1"]);
|
|
312
|
+
const ev = { family: "calling", kind: "start", actor: "human-1",
|
|
313
|
+
payload: { channelId: "room-1", topic: "standup" } };
|
|
314
|
+
assert.equal(isDirectedAtAgent(ev, "agent-self", { myChannelIds: mine }), true);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("isDirectedAtAgent: the SHARED feed never names who was mentioned", () => {
|
|
318
|
+
// hq's chain payload is a REDACTED audit row read by every agent holding
|
|
319
|
+
// `org.read`, so it carries `mentionCount` and deliberately NOT the member
|
|
320
|
+
// ids — listing them would leak the association graph of private rooms. This
|
|
321
|
+
// filter therefore cannot resolve a space @mention on its own, BY DESIGN;
|
|
322
|
+
// pullInbound confirms candidates against the per-member `messaging.history`
|
|
323
|
+
// read instead. Asserted so nobody "fixes" the deafness by widening the feed.
|
|
324
|
+
const shared = {
|
|
325
|
+
family: "messaging", kind: "send", entityId: "msg_1", actor: "human-1",
|
|
326
|
+
payload: { channelId: "space-general", channelKind: "PUBLIC", mentionCount: 1 },
|
|
327
|
+
};
|
|
328
|
+
assert.equal(isDirectedAtAgent(shared, "agent-self"), false);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("isDirectedAtAgent: an explicit mentions[] still wins when a producer supplies one", () => {
|
|
332
|
+
// hq's chain payload used to carry `mentionCount` — a NUMBER — so an agent
|
|
333
|
+
// tailing the feed could see that a mention happened but not that it was the
|
|
334
|
+
// one mentioned. Combined with hq deferring to live daemons, an @mention in a
|
|
335
|
+
// public space reached nobody: the server stayed quiet and the agent was deaf.
|
|
336
|
+
// hq now emits `mentions: [memberId…]`, which this filter already understood.
|
|
337
|
+
const ev = {
|
|
338
|
+
seq: 10500,
|
|
339
|
+
family: "messaging",
|
|
340
|
+
kind: "send",
|
|
341
|
+
entityId: "msg_1",
|
|
342
|
+
actor: "human-1",
|
|
343
|
+
payload: {
|
|
344
|
+
channelId: "space-general",
|
|
345
|
+
channelKind: "PUBLIC",
|
|
346
|
+
mentions: ["agent-self"],
|
|
347
|
+
mentionCount: 1,
|
|
348
|
+
recipientCount: 12,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
// Membership is NOT required for a mention — being named is the invitation.
|
|
352
|
+
assert.equal(isDirectedAtAgent(ev, "agent-self"), true);
|
|
353
|
+
// …and someone else's mention in the same space is still not ours.
|
|
354
|
+
assert.equal(isDirectedAtAgent(ev, "agent-other"), false);
|
|
355
|
+
});
|