@cohortapp/agent-sdk 2.3.0 → 2.3.2
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 +55 -51
- package/lib/channels/inbox-item.mjs +20 -0
- package/lib/org/client.mjs +21 -3
- package/lib/org/client.test.mjs +51 -0
- package/lib/org/doctor.mjs +64 -0
- package/lib/org/doctor.test.mjs +31 -2
- package/lib/org/mesh.mjs +28 -0
- package/lib/org/mesh.test.mjs +55 -0
- package/lib/org/messaging.mjs +142 -13
- package/lib/org/messaging.test.mjs +77 -0
- package/package.json +3 -2
- package/scripts/daemon/agent-daemon.mjs +34 -7
- package/scripts/daemon/cadence-handlers.mjs +42 -4
- package/scripts/daemon/maestro-daemon.mjs +23 -0
- package/scripts/daemon/responder.mjs +56 -0
- 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/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,90 @@ 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);
|
|
433
|
+
const directed = isDirectedAtAgent(ev, me, { myChannelIds });
|
|
400
434
|
if (!directed) continue;
|
|
401
435
|
events.push(toMessageEvent(ev, me));
|
|
402
436
|
}
|
|
403
|
-
|
|
437
|
+
|
|
438
|
+
// HYDRATE THE BODY. The org event feed is redacted by design — it carries
|
|
439
|
+
// {actor, channelId, channelKind, counts, ts} and NO message text — so a
|
|
440
|
+
// projected event arrives with `text: ""`. An empty inbound is worse than no
|
|
441
|
+
// inbound: the classifier sees nothing to classify and the agent would
|
|
442
|
+
// "reply" to a blank message.
|
|
443
|
+
//
|
|
444
|
+
// The body is fetched over `messaging.history`, which is ACL'd server-side to
|
|
445
|
+
// rooms this agent belongs to — so hydration cannot widen what the agent can
|
|
446
|
+
// see, it only re-joins content the agent was always entitled to read. One
|
|
447
|
+
// fetch per affected channel, not per message.
|
|
448
|
+
//
|
|
449
|
+
// Fail-open: a message we cannot hydrate is dropped rather than delivered
|
|
450
|
+
// blank, because a blank inbound produces a nonsense reply in a real room.
|
|
451
|
+
const needBody = events.filter((e) => !e.text && e.kind !== "call");
|
|
452
|
+
if (needBody.length) {
|
|
453
|
+
const byChannel = new Map();
|
|
454
|
+
for (const e of needBody) {
|
|
455
|
+
if (!e.channel_id) continue;
|
|
456
|
+
if (!byChannel.has(e.channel_id)) byChannel.set(e.channel_id, []);
|
|
457
|
+
byChannel.get(e.channel_id).push(e);
|
|
458
|
+
}
|
|
459
|
+
for (const [channelId, pending] of byChannel) {
|
|
460
|
+
try {
|
|
461
|
+
const history = await fetchHistory(
|
|
462
|
+
{ channel: channelId, limit: 50 },
|
|
463
|
+
{ cfg: o.cfg, fetchImpl: o.fetchImpl },
|
|
464
|
+
);
|
|
465
|
+
const byId = new Map(
|
|
466
|
+
(Array.isArray(history) ? history : [])
|
|
467
|
+
.map((m) => [String((m && (m.id || m.messageId)) || ""), m])
|
|
468
|
+
.filter(([id]) => id),
|
|
469
|
+
);
|
|
470
|
+
for (const e of pending) {
|
|
471
|
+
const hit = byId.get(String(e.message_id));
|
|
472
|
+
if (!hit) continue;
|
|
473
|
+
e.text = String(hit.body ?? hit.text ?? "");
|
|
474
|
+
if (!e.from.name || e.from.name === e.from.id) {
|
|
475
|
+
e.from.name = String(hit.authorName || hit.author?.displayName || e.from.name || "");
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
} catch {
|
|
479
|
+
/* fail-open: leave these unhydrated; they are dropped just below */
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const delivered = events.filter((e) => e.kind === "call" || (e.text && e.text.trim()));
|
|
485
|
+
return { events: delivered, nextCursor: raw.length ? maxSeq : cursor };
|
|
404
486
|
}
|
|
405
487
|
|
|
406
488
|
/**
|
|
407
489
|
* Decide whether an org event is directed at this agent (an inbound it should
|
|
408
490
|
* react to) vs ambient chatter / its own echo. Pure.
|
|
409
|
-
*
|
|
491
|
+
*
|
|
492
|
+
* WHY THIS NEEDS `myChannelIds`. The `/v1/events` feed is ORG-scoped, not
|
|
493
|
+
* member-scoped — an agent sees every messaging event in the org — and the
|
|
494
|
+
* payload is deliberately REDACTED: hq emits only
|
|
495
|
+
* { actor, channelId, channelKind, recipientCount, mentionCount, ts }
|
|
496
|
+
* with no body and, crucially, NO recipient list. That redaction is correct
|
|
497
|
+
* (an org-wide feed must not leak who DMs whom), but it means "is this for me?"
|
|
498
|
+
* cannot be answered from the event alone. The agent's OWN channel membership
|
|
499
|
+
* is the missing half, and the agent already knows it (`messaging.channels`).
|
|
500
|
+
*
|
|
501
|
+
* This bit in production: a human DM'd an agent six times and the agent never
|
|
502
|
+
* saw a single message. The old code looked for a `to` array the payload never
|
|
503
|
+
* carries, then fell back to `p.kind` containing "dm" — but the event's kind is
|
|
504
|
+
* "send" and the DM-ness lives in `channelKind: "DM"`, which nothing inspected.
|
|
505
|
+
* Every human DM was silently dropped.
|
|
506
|
+
*
|
|
507
|
+
* Without `myChannelIds` this stays CONSERVATIVE for DMs (returns false rather
|
|
508
|
+
* than guessing), because the failure mode of guessing yes is an agent barging
|
|
509
|
+
* into other people's private conversations.
|
|
510
|
+
*
|
|
511
|
+
* @param {object} ev
|
|
512
|
+
* @param {string} me agent id
|
|
513
|
+
* @param {{myChannelIds?: Set<string>|string[]}} [opts]
|
|
410
514
|
* @returns {boolean}
|
|
411
515
|
*/
|
|
412
|
-
export function isDirectedAtAgent(ev, me) {
|
|
516
|
+
export function isDirectedAtAgent(ev, me, opts = {}) {
|
|
413
517
|
if (!ev || typeof ev !== "object") return false;
|
|
414
518
|
const family = ev.family || (ev.kind ? String(ev.kind).split(".")[0] : "");
|
|
415
519
|
if (family && family !== "messaging" && family !== "calling") return false;
|
|
@@ -423,21 +527,46 @@ export function isDirectedAtAgent(ev, me) {
|
|
|
423
527
|
.map((m) => String(m));
|
|
424
528
|
if (me && mentions.includes(String(me))) return true;
|
|
425
529
|
|
|
426
|
-
//
|
|
427
|
-
// explicit @mention.
|
|
530
|
+
// An explicit recipient list, when a producer supplies one.
|
|
428
531
|
const to = []
|
|
429
532
|
.concat(Array.isArray(p.to) ? p.to : (p.to != null ? [p.to] : []))
|
|
430
533
|
.concat(Array.isArray(p.invitees) ? p.invitees : [])
|
|
431
534
|
.map((t) => String(t));
|
|
432
535
|
if (me && to.includes(String(me))) return true;
|
|
433
536
|
|
|
537
|
+
// The REAL hq shape: a private room I am a member of. Membership is what makes
|
|
538
|
+
// it mine — in a DM/group-DM/huddle every message is addressed to the room,
|
|
539
|
+
// so no @mention is required.
|
|
540
|
+
const mine = normaliseChannelIds(opts.myChannelIds);
|
|
541
|
+
const channelId = String(p.channelId || p.channel || p.callId || "");
|
|
542
|
+
const channelKind = String(p.channelKind || p.channel_kind || "").toUpperCase();
|
|
543
|
+
const PRIVATE_ROOMS = ["DM", "GROUP_DM", "HUDDLE"];
|
|
544
|
+
if (mine && channelId && mine.has(channelId)) {
|
|
545
|
+
if (PRIVATE_ROOMS.includes(channelKind)) return true;
|
|
546
|
+
// A call event in one of my rooms is an invite I should react to.
|
|
547
|
+
if (family === "calling") return true;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Legacy/other producers that put the room kind in `kind` rather than
|
|
551
|
+
// `channelKind`. Unchanged behaviour for them.
|
|
434
552
|
const kind = String(p.kind || ev.kind || "").toLowerCase();
|
|
435
|
-
if (kind.includes("dm") || kind.includes("invite")
|
|
553
|
+
if (kind.includes("dm") || kind.includes("invite")) {
|
|
554
|
+
return !me || to.includes(String(me)) || mentions.includes(String(me)) || to.length === 0;
|
|
555
|
+
}
|
|
556
|
+
// A bare `calling` event with no room context: only when we cannot scope it.
|
|
557
|
+
if (family === "calling" && !mine) {
|
|
436
558
|
return !me || to.includes(String(me)) || mentions.includes(String(me)) || to.length === 0;
|
|
437
559
|
}
|
|
438
560
|
return false;
|
|
439
561
|
}
|
|
440
562
|
|
|
563
|
+
/** Accept a Set, an array, or nothing; null means "membership unknown". */
|
|
564
|
+
function normaliseChannelIds(v) {
|
|
565
|
+
if (v instanceof Set) return v;
|
|
566
|
+
if (Array.isArray(v)) return new Set(v.map((x) => String(x)));
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
441
570
|
/**
|
|
442
571
|
* Project an org messaging/calling event onto the unified MessageEvent contract
|
|
443
572
|
* (lib/channels/contract.MessageEvent) so eventToInboxItem can serialise it into
|
|
@@ -236,3 +236,80 @@ 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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.3.
|
|
4
|
-
"description": "Cohort Agent SDK
|
|
3
|
+
"version": "2.3.2",
|
|
4
|
+
"description": "Cohort Agent SDK \u2014 autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
+
"agent-sdk": "./bin/maestro.mjs",
|
|
7
8
|
"maestro": "./bin/maestro.mjs",
|
|
8
9
|
"cohort": "./bin/maestro.mjs",
|
|
9
10
|
"cohort-mcp": "./bin/cohort-mcp.mjs",
|
|
@@ -120,19 +120,46 @@ function logEvent(type, entry) {
|
|
|
120
120
|
// ---------------------------------------------------------------------------
|
|
121
121
|
|
|
122
122
|
async function poll() {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
123
|
+
// COHORT IS THE DEFAULT SUBSTRATE. An agent's messaging, email, calendar,
|
|
124
|
+
// CRM, directory, spaces, boards, books and calls all live on the Cohort
|
|
125
|
+
// server. The third-party adapters below are LEGACY BRIDGES for agents that
|
|
126
|
+
// additionally sit in someone else's Slack or Gmail — they are not what a new
|
|
127
|
+
// agent should be doing by default.
|
|
128
|
+
//
|
|
129
|
+
// They used to be polled unconditionally, which meant every freshly-created
|
|
130
|
+
// agent burned a poll cycle on Slack, two Gmail accounts, Google Calendar and
|
|
131
|
+
// voice on a loop, and filled its log with "SLACK_TOKEN not set" /
|
|
132
|
+
// "App password not set" forever. Worse, the noise made a REAL failure
|
|
133
|
+
// (Cohort inbound not wired at all) invisible in the same log.
|
|
134
|
+
//
|
|
135
|
+
// Each is now gated on evidence that it is actually configured, exactly like
|
|
136
|
+
// the channel-bus platforms below. Nothing is removed — an agent that IS on
|
|
137
|
+
// Slack keeps working the moment its token exists.
|
|
138
|
+
const services = [];
|
|
139
|
+
const legacyBridges = [
|
|
140
|
+
{ name: "slack", fn: pollSlack, on: () => Boolean(process.env.SLACK_TOKEN || process.env.SLACK_USER_TOKEN) },
|
|
141
|
+
{ name: "gmail", fn: pollGmail, on: () => Boolean(process.env.GMAIL_APP_PASSWORD) },
|
|
142
|
+
{ name: "alex-gmail", fn: pollSecondaryGmail, on: () => Boolean(process.env.SECONDARY_GMAIL_APP_PASSWORD) },
|
|
143
|
+
{ name: "calendar", fn: pollCalendar, on: () => _existsSync(join(AGENT_REPO_DIR, "config/google-calendar.yaml")) || Boolean(process.env.GOOGLE_CALENDAR_CREDENTIALS) },
|
|
144
|
+
{ name: "voice", fn: pollVoice, on: () => _existsSync(join(AGENT_REPO_DIR, "config/voice.yaml")) || Boolean(process.env.TWILIO_ACCOUNT_SID) },
|
|
129
145
|
];
|
|
146
|
+
for (const b of legacyBridges) {
|
|
147
|
+
let enabled = false;
|
|
148
|
+
try { enabled = b.on(); } catch { enabled = false; }
|
|
149
|
+
if (enabled) services.push({ name: b.name, fn: b.fn });
|
|
150
|
+
}
|
|
130
151
|
|
|
131
152
|
// WS2: channel-bus platforms (Telegram, Baileys WhatsApp) deliver events as
|
|
132
153
|
// inbox YAML via the daemon's channel loop. They have no API poller, so a
|
|
133
154
|
// generic inbox scanner drains their items into the same pipeline. Gate on
|
|
134
155
|
// the platform's config file so we don't scan dirs for disabled channels.
|
|
135
|
-
|
|
156
|
+
// `cohort` is the SAME shape: the messaging-inbound cadence pulls org
|
|
157
|
+
// messages / @mentions / call-invites directed at this agent and writes them
|
|
158
|
+
// as inbox YAML under state/inbox/cohort/ — but nothing drained that
|
|
159
|
+
// directory, so the items simply accumulated and the agent never answered.
|
|
160
|
+
// The whole SP10 inbound chain existed except this last hop. Gated on org
|
|
161
|
+
// enrolment (config/org.yaml) exactly like the other channel sources.
|
|
162
|
+
for (const [name, gate] of [["telegram", "config/telegram.yaml"], ["whatsapp", "config/whatsapp.yaml"], ["orgmail", "config/orgmail.yaml"], ["cohort", "config/org.yaml"]]) {
|
|
136
163
|
if (_existsSync(join(AGENT_REPO_DIR, gate))) {
|
|
137
164
|
services.push({ name, fn: makeInboxScanPoller(name, { agentRoot: AGENT_REPO_DIR }) });
|
|
138
165
|
}
|
|
@@ -48,6 +48,7 @@ import { fileURLToPath } from "node:url";
|
|
|
48
48
|
import { spawn } from "node:child_process";
|
|
49
49
|
import { recoverStaleClaims } from "../../lib/cadence-bus.mjs";
|
|
50
50
|
import { isQuarterStartMonth } from "../../lib/cadences.mjs";
|
|
51
|
+
import { loadOrgConfig } from "../../lib/org/client.mjs";
|
|
51
52
|
|
|
52
53
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
53
54
|
|
|
@@ -389,13 +390,40 @@ async function guardQuarterlySelfAssessment({ now } = {}) {
|
|
|
389
390
|
|
|
390
391
|
/** Read config/agent.json fail-open (for the org legs). Returns null on any error. */
|
|
391
392
|
function loadAgentCfg(agentRoot) {
|
|
393
|
+
let agent = null;
|
|
392
394
|
try {
|
|
393
395
|
const p = join(agentRoot, "config", "agent.json");
|
|
394
|
-
if (
|
|
395
|
-
return JSON.parse(readFileSync(p, "utf8"));
|
|
396
|
+
if (existsSync(p)) agent = JSON.parse(readFileSync(p, "utf8"));
|
|
396
397
|
} catch {
|
|
397
|
-
|
|
398
|
+
agent = null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ORG ENROLMENT LIVES IN config/org.yaml, NOT config/agent.json.
|
|
402
|
+
//
|
|
403
|
+
// Every org-facing guard below gates on `cfg.org.cohort` and bails out with
|
|
404
|
+
// "org messaging not configured" when it is absent. `config/agent.json` has no
|
|
405
|
+
// `org` key at all — enrolment is written to `config/org.yaml` by
|
|
406
|
+
// `maestro setup` and read by lib/org/client.loadOrgConfig — so all SIX of
|
|
407
|
+
// those guards were dead on every enrolled agent, silently and permanently.
|
|
408
|
+
//
|
|
409
|
+
// Observed in production: a human DM'd an agent repeatedly and the
|
|
410
|
+
// messaging-inbound cadence ran ~every 45s, each time deciding "org messaging
|
|
411
|
+
// not configured" and routing nothing. The agent looked healthy the whole time.
|
|
412
|
+
//
|
|
413
|
+
// This is the SAME bug class loadOrgConfig was introduced to fix ("the daemon
|
|
414
|
+
// reading collective.yaml by mistake") — fixed there, missed here. Merging at
|
|
415
|
+
// this one chokepoint fixes every guard at once rather than per-guard.
|
|
416
|
+
let org = null;
|
|
417
|
+
try {
|
|
418
|
+
org = loadOrgConfig(agentRoot);
|
|
419
|
+
} catch {
|
|
420
|
+
org = null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (org && org.org) {
|
|
424
|
+
return { ...(agent || {}), org: { ...((agent && agent.org) || {}), ...org.org } };
|
|
398
425
|
}
|
|
426
|
+
return agent;
|
|
399
427
|
}
|
|
400
428
|
|
|
401
429
|
/**
|
|
@@ -633,7 +661,17 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
|
|
|
633
661
|
const writeInboxItem = opts.writeImpl || (await import("../poller/utils.mjs")).writeInboxItem;
|
|
634
662
|
|
|
635
663
|
const cursor = readMessagingCursor(agentRoot);
|
|
636
|
-
|
|
664
|
+
// Pass the agent id explicitly. It is what suppresses the agent's OWN
|
|
665
|
+
// outbound echoes — without it `me` is null, every echo-filter branch is
|
|
666
|
+
// skipped, and the agent re-ingests its own replies as fresh inbound (a
|
|
667
|
+
// conversation with itself). config/org.yaml does not carry an agentId, so
|
|
668
|
+
// env is the reliable source on a real machine.
|
|
669
|
+
const agentId =
|
|
670
|
+
opts.agentId ||
|
|
671
|
+
process.env.COHORT_AGENT_ID ||
|
|
672
|
+
(cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.agentId) ||
|
|
673
|
+
undefined;
|
|
674
|
+
const res = await pull({ cfg, agentId, cursor, fetchImpl: opts.fetchImpl });
|
|
637
675
|
const events = (res && Array.isArray(res.events)) ? res.events : [];
|
|
638
676
|
|
|
639
677
|
let routed = 0;
|
|
@@ -33,6 +33,29 @@ const AGENT_DIR = process.env.AGENT_DIR || resolve(__dirname, "../..");
|
|
|
33
33
|
process.env.AGENT_DIR = AGENT_DIR;
|
|
34
34
|
process.env.AGENT_ROOT = AGENT_DIR;
|
|
35
35
|
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// 0. Environment — BEFORE anything reads a credential
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// The core daemon (agent-daemon.mjs, step 4) loads .env itself, but the org
|
|
40
|
+
// mesh connects at step 3b' — EARLIER in this file. Under launchd the process
|
|
41
|
+
// environment is bare, so the mesh was reading COHORT_API_KEY before anything
|
|
42
|
+
// had put it there.
|
|
43
|
+
//
|
|
44
|
+
// That failed SILENTLY and looked healthy: `enabled` comes from config/org.yaml
|
|
45
|
+
// (which should NOT carry the token — it is version-controlled), so the mesh
|
|
46
|
+
// started, logged "connected", and then 401'd on every beat forever. An agent
|
|
47
|
+
// that never beats also never receives the kill switch.
|
|
48
|
+
//
|
|
49
|
+
// Loading .env here, before any org work, is the fix. Idempotent: dotenv does
|
|
50
|
+
// not override variables already set, so a launchd/plist-provided value still
|
|
51
|
+
// wins. Fail-open — dotenv is absent in some CI/unit contexts.
|
|
52
|
+
try {
|
|
53
|
+
const { config } = await import("dotenv");
|
|
54
|
+
config({ path: join(AGENT_DIR, ".env") });
|
|
55
|
+
} catch {
|
|
56
|
+
// No dotenv (CI/dev): rely on the ambient environment.
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
// Never die silently (P0-6): a stray unhandled rejection / uncaught exception is
|
|
37
60
|
// logged + counted, not a silent process death. Rejections keep the daemon
|
|
38
61
|
// running; an uncaught exception logs+flushes then lets launchd restart us.
|
|
@@ -684,6 +684,52 @@ function resolveSlackChannel(item) {
|
|
|
684
684
|
*
|
|
685
685
|
* @returns {{ sent: boolean, text: string, channel: string }} result
|
|
686
686
|
*/
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Send a reply back onto Cohort (the org's own messaging app).
|
|
690
|
+
*
|
|
691
|
+
* The responder dispatched on `item.service` and only ever knew "slack" and
|
|
692
|
+
* "gmail", so a `cohort` item fell through to `{ sent: false }`: the agent
|
|
693
|
+
* COMPOSED a reply and dropped it on the floor, logging "not_sent". Observed in
|
|
694
|
+
* production — an agent quietly did that to a human's DMs three times in a row.
|
|
695
|
+
*
|
|
696
|
+
* Routing is by CHANNEL, not by person: `channel_id` is the room the inbound
|
|
697
|
+
* arrived in, which is also where the answer belongs (a DM room for a DM, the
|
|
698
|
+
* space for a space). Threading is preserved when the inbound was threaded.
|
|
699
|
+
*
|
|
700
|
+
* Goes through lib/org/messaging.sendMessage rather than the raw RPC so the
|
|
701
|
+
* outbound send-gate, the afterSend hook and cost attribution all still run —
|
|
702
|
+
* the same chokepoint every other channel uses.
|
|
703
|
+
*/
|
|
704
|
+
async function sendCohortReply(item, text) {
|
|
705
|
+
try {
|
|
706
|
+
const channel = item.channel_id || item.channel || "";
|
|
707
|
+
if (!channel) return { sent: false, via: null, error: "no channel_id on item" };
|
|
708
|
+
const { sendMessage } = await import("../../lib/org/messaging.mjs");
|
|
709
|
+
const { loadOrgConfig } = await import("../../lib/org/client.mjs");
|
|
710
|
+
const agentRoot = process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd();
|
|
711
|
+
const frame = await sendMessage(
|
|
712
|
+
{
|
|
713
|
+
channel,
|
|
714
|
+
body: text,
|
|
715
|
+
// Stable per (message, kind) so a retry dedupes server-side instead of
|
|
716
|
+
// double-posting into a real room.
|
|
717
|
+
idempotencyId: `reply-${item.message_id || item.id || Date.now()}`,
|
|
718
|
+
...(item.thread_id ? { threadId: item.thread_id } : {}),
|
|
719
|
+
},
|
|
720
|
+
{ cfg: loadOrgConfig(agentRoot), agentRoot },
|
|
721
|
+
);
|
|
722
|
+
if (frame && frame.ok) return { sent: true, via: "cohort", channel };
|
|
723
|
+
return {
|
|
724
|
+
sent: false,
|
|
725
|
+
via: null,
|
|
726
|
+
error: (frame && frame.error && frame.error.message) || "send failed",
|
|
727
|
+
};
|
|
728
|
+
} catch (err) {
|
|
729
|
+
return { sent: false, via: null, error: err && err.message };
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
687
733
|
export async function sendQuickResponse(item, classResult) {
|
|
688
734
|
const startTime = Date.now();
|
|
689
735
|
|
|
@@ -745,6 +791,13 @@ export async function sendQuickResponse(item, classResult) {
|
|
|
745
791
|
emitEvent({ type: EVENT_TYPES.SENT, trace_id: item.trace_id || undefined, attrs: { service: "gmail", via: gmailResult.via } });
|
|
746
792
|
logInteraction(item, text);
|
|
747
793
|
}
|
|
794
|
+
} else if (item.service === "cohort") {
|
|
795
|
+
const orgResult = await sendCohortReply(item, text);
|
|
796
|
+
sendResult = { sent: orgResult.sent, via: orgResult.via, ...(orgResult.channel ? { channel: orgResult.channel } : {}), ...(orgResult.error ? { error: orgResult.error } : {}) };
|
|
797
|
+
if (orgResult.sent) {
|
|
798
|
+
emitEvent({ type: EVENT_TYPES.SENT, trace_id: item.trace_id || undefined, attrs: { service: "cohort", channel: orgResult.channel, via: "quick_reply" } });
|
|
799
|
+
logInteraction(item, text);
|
|
800
|
+
}
|
|
748
801
|
}
|
|
749
802
|
|
|
750
803
|
const duration = Date.now() - startTime;
|
|
@@ -802,6 +855,9 @@ export async function sendHoldingMessage(item, classResult) {
|
|
|
802
855
|
} else if (item.service === "gmail") {
|
|
803
856
|
const gmailResult = await sendGmailResponse(item, text);
|
|
804
857
|
sendResult = { sent: gmailResult.sent, via: gmailResult.via, ...(gmailResult.draft_path ? { draft_path: gmailResult.draft_path } : {}), ...(gmailResult.to ? { to: gmailResult.to } : {}) };
|
|
858
|
+
} else if (item.service === "cohort") {
|
|
859
|
+
const orgResult = await sendCohortReply(item, text);
|
|
860
|
+
sendResult = { sent: orgResult.sent, via: orgResult.via, ...(orgResult.error ? { error: orgResult.error } : {}) };
|
|
805
861
|
}
|
|
806
862
|
|
|
807
863
|
const duration = Date.now() - startTime;
|
|
@@ -57,6 +57,19 @@ export function parseInboxItemYaml(body) {
|
|
|
57
57
|
const m = body.match(new RegExp(`^\\s{2}${key}:\\s*(\\S+)`, "m"));
|
|
58
58
|
return /true/i.test((m || [])[1] || "");
|
|
59
59
|
};
|
|
60
|
+
// TRI-STATE. The confidentiality fields must distinguish "absent" from
|
|
61
|
+
// "false": `is_private: false` is a positive assertion that the source is
|
|
62
|
+
// public, and the daemon's fail-closed outcome gate
|
|
63
|
+
// (scripts/daemon/session-outcomes.mjs) admits on it. Decoding a missing line
|
|
64
|
+
// as `false` — which `bool()` does — would forge that assertion for every
|
|
65
|
+
// item ever written, which is exactly the leak the gate exists to prevent.
|
|
66
|
+
const triBool = (key) => {
|
|
67
|
+
const m = body.match(new RegExp(`^${key}:\\s*(\\S+)`, "m"));
|
|
68
|
+
if (!m) return undefined;
|
|
69
|
+
if (/^true$/i.test(m[1])) return true;
|
|
70
|
+
if (/^false$/i.test(m[1])) return false;
|
|
71
|
+
return undefined;
|
|
72
|
+
};
|
|
60
73
|
|
|
61
74
|
// Block scalar `content: |` — capture the indented block.
|
|
62
75
|
const blockScalar = (key) => {
|
|
@@ -66,7 +79,11 @@ export function parseInboxItemYaml(body) {
|
|
|
66
79
|
return m[1].replace(/^ {2}/gm, "").replace(/\n+$/, "");
|
|
67
80
|
};
|
|
68
81
|
|
|
69
|
-
|
|
82
|
+
const channelType = scalar("channel_type");
|
|
83
|
+
const isPrivate = triBool("is_private");
|
|
84
|
+
const isDm = triBool("is_dm");
|
|
85
|
+
|
|
86
|
+
const item = {
|
|
70
87
|
id: scalar("id"),
|
|
71
88
|
service: scalar("service"),
|
|
72
89
|
channel: scalar("channel"),
|
|
@@ -88,6 +105,14 @@ export function parseInboxItemYaml(body) {
|
|
|
88
105
|
},
|
|
89
106
|
raw_ref: scalar("raw_ref"),
|
|
90
107
|
};
|
|
108
|
+
|
|
109
|
+
// Only attached when the writer actually knew (see triBool) — an item with no
|
|
110
|
+
// privacy signal must stay signal-less so the outcome gate fails closed.
|
|
111
|
+
if (channelType) item.channel_type = channelType;
|
|
112
|
+
if (isPrivate !== undefined) item.is_private = isPrivate;
|
|
113
|
+
if (isDm !== undefined) item.is_dm = isDm;
|
|
114
|
+
|
|
115
|
+
return item;
|
|
91
116
|
}
|
|
92
117
|
|
|
93
118
|
/**
|