@cohortapp/agent-sdk 2.3.2 → 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/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/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 +58 -22
- package/lib/org/client.test.mjs +3 -1
- 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 +17 -2
- package/lib/org/messaging.mjs +40 -4
- package/lib/org/messaging.test.mjs +40 -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 +8 -3
- 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 +36 -4
- package/scripts/daemon/cadence-handlers.mjs +145 -1
- 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/prompt-builder.mjs +41 -1
- 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/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
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/org/inbound/directedness.mjs — "is this org event for ME, and why?"
|
|
3
|
+
*
|
|
4
|
+
* PURE. No IO, no network, no clock. Two halves, deliberately split so the
|
|
5
|
+
* interesting logic is testable with plain objects:
|
|
6
|
+
*
|
|
7
|
+
* 1. {@link classifyEvent} — a raw `/v1/events` row → a {@link Candidate}:
|
|
8
|
+
* which surface family it belongs to, which ids it names, which member ids
|
|
9
|
+
* the payload named OUTRIGHT (`directTo`), and which relational PROBES
|
|
10
|
+
* would have to hit for it to be mine.
|
|
11
|
+
* 2. {@link resolveDirected} — a Candidate + the agent's own resolved
|
|
12
|
+
* {@link module:lib/org/inbound/facts Facts} → a typed verdict
|
|
13
|
+
* `{ directed, surface, reason }`.
|
|
14
|
+
*
|
|
15
|
+
* ── WHY IT HAS TO BE TWO HALVES ──
|
|
16
|
+
* The org event feed is ORG-WIDE and REDACTED. `/v1/events` is scoped with
|
|
17
|
+
* `scopeWhere(orgId, {seq:{gt:cursor}})` (hq `src/server/rpc/reads.ts`), so an
|
|
18
|
+
* agent receives EVERY family's rows, and hq deliberately strips identity from
|
|
19
|
+
* the payloads: `messaging.send` stores `{actor, channelId, channelKind,
|
|
20
|
+
* recipientCount, mentionCount, attachmentCount, threaded, ts}` — a COUNT of
|
|
21
|
+
* mentions, never the list (hq `src/server/actions/messages.ts`,
|
|
22
|
+
* `src/server/methods/messaging/send.ts`). That redaction is correct — an
|
|
23
|
+
* org-wide feed must not leak who DMs whom — but it means "is this mine?" is
|
|
24
|
+
* STRUCTURALLY unanswerable from the event alone for most families. The missing
|
|
25
|
+
* half is the agent's OWN membership/ownership, which the agent can read for
|
|
26
|
+
* itself over its OWN entitlements (`messaging.channels`, `board.context`,
|
|
27
|
+
* `decision.list`, `approval.get`, …). `facts.mjs` fetches exactly that; this
|
|
28
|
+
* module joins the two.
|
|
29
|
+
*
|
|
30
|
+
* ── THE SAFETY INVARIANT (the one that must never regress) ──
|
|
31
|
+
* An event in a room I am not in must NEVER be directed at me, and must never
|
|
32
|
+
* even cause a probe. Enforced here as a hard gate, not as an emergent property:
|
|
33
|
+
*
|
|
34
|
+
* - PRIVATE ROOMS (`DM` / `GROUP_DM` / `HUDDLE`): directed ONLY when the room
|
|
35
|
+
* id is in `facts.memberChannelIds`. `messaging.channels` returns a private
|
|
36
|
+
* room ONLY to its members (hq `methods/messaging/channels.ts` — the OR is
|
|
37
|
+
* `kind:"PUBLIC"` OR `members.some({memberId})`), so presence in that set IS
|
|
38
|
+
* proof of membership. There is no fallback, no heuristic, no "kind contains
|
|
39
|
+
* dm" guess.
|
|
40
|
+
* - PRIVATE SPACES (`kind:"PRIVATE"`): same rule — membership required.
|
|
41
|
+
* - PUBLIC spaces: an @mention or a thread I am in makes it mine. Mere traffic
|
|
42
|
+
* does not.
|
|
43
|
+
* - Any channel id NOT in `facts.visibleChannelIds` is INVISIBLE: no verdict,
|
|
44
|
+
* no hydration, no history fetch. `facts.mjs` refuses to page a channel that
|
|
45
|
+
* is not in that set, so a private room I am not in is never even probed.
|
|
46
|
+
*
|
|
47
|
+
* When membership is UNKNOWN (the roster read failed) the resolver stays
|
|
48
|
+
* conservative for private rooms and answers `false` — the failure mode of
|
|
49
|
+
* guessing "yes" is an agent barging into other people's private conversation.
|
|
50
|
+
*
|
|
51
|
+
* @module lib/org/inbound/directedness
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
"use strict";
|
|
55
|
+
|
|
56
|
+
import { SURFACES } from "./surfaces.mjs";
|
|
57
|
+
|
|
58
|
+
/** Channel kinds where every message is addressed to the room. */
|
|
59
|
+
export const PRIVATE_ROOM_KINDS = Object.freeze(["DM", "GROUP_DM", "HUDDLE"]);
|
|
60
|
+
|
|
61
|
+
/** Channel kinds that require explicit membership to be readable at all. */
|
|
62
|
+
export const MEMBERSHIP_REQUIRED_KINDS = Object.freeze([
|
|
63
|
+
...PRIVATE_ROOM_KINDS,
|
|
64
|
+
"PRIVATE",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The typed reasons a verdict can carry. A daemon switches on `reason` for
|
|
69
|
+
* triage; it is a closed set so a new value is a deliberate, reviewable change.
|
|
70
|
+
*
|
|
71
|
+
* @typedef {"dm"|"mention"|"thread"|"channel"|"participant"|"assignee"
|
|
72
|
+
* |"reviewer"|"direct"|"requester"|"approver"|"proposer"|"commenter"
|
|
73
|
+
* |"owner"|"shared"|"waiting_on"|"task"|"named"} Reason
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/** Narrow an unknown payload to a readable record (never throws). */
|
|
77
|
+
function obj(v) {
|
|
78
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read a non-empty string field, else undefined. */
|
|
82
|
+
function str(p, ...keys) {
|
|
83
|
+
for (const k of keys) {
|
|
84
|
+
const v = p[k];
|
|
85
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
86
|
+
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Coerce a Set / array / null into a Set, or null for "unknown". */
|
|
92
|
+
export function asSet(v) {
|
|
93
|
+
if (v instanceof Set) return v;
|
|
94
|
+
if (Array.isArray(v)) return new Set(v.map((x) => String(x)));
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Does `set` (possibly null == unknown) contain `id`? Unknown → false. */
|
|
99
|
+
function has(set, id) {
|
|
100
|
+
return !!(set && id && set.has(String(id)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The family/kind coordinates of a raw event, tolerating both the modern shape
|
|
105
|
+
* (`{family, kind}`) and the legacy one (`{kind: "messaging.send"}`).
|
|
106
|
+
* @param {object} ev
|
|
107
|
+
* @returns {{family:string, kind:string}}
|
|
108
|
+
*/
|
|
109
|
+
export function coordsOf(ev) {
|
|
110
|
+
const rawKind = String((ev && ev.kind) || "");
|
|
111
|
+
const family = String((ev && ev.family) || (rawKind.includes(".") ? rawKind.split(".")[0] : ""));
|
|
112
|
+
let kind = rawKind;
|
|
113
|
+
if (!ev?.family && rawKind.includes(".")) kind = rawKind.slice(rawKind.indexOf(".") + 1);
|
|
114
|
+
return { family, kind };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @typedef {Object} Candidate
|
|
119
|
+
* @property {number|string|null} seq the per-org ledger cursor position
|
|
120
|
+
* @property {string} family hq event family (chain coordinate)
|
|
121
|
+
* @property {string} kind hq event kind (chain coordinate)
|
|
122
|
+
* @property {string|null} entityId
|
|
123
|
+
* @property {string} actor the acting principal's member id
|
|
124
|
+
* @property {string} at ISO-8601 append time
|
|
125
|
+
* @property {string} topic coarse bucket (see surfaces.mjs)
|
|
126
|
+
* @property {string[]} surfaces candidate surface names, most specific first
|
|
127
|
+
* @property {Record<string,string|undefined>} ids ids the payload named
|
|
128
|
+
* @property {string[]} directTo member ids the PAYLOAD named outright
|
|
129
|
+
* @property {object} payload the raw (redacted) payload, passed through
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Classify one raw event row into a Candidate, or null for the many families a
|
|
134
|
+
* daemon has no business being woken for (presence beats, cost rollups, registry
|
|
135
|
+
* churn, org admin). The cursor still advances past them — they simply produce
|
|
136
|
+
* no candidate.
|
|
137
|
+
*
|
|
138
|
+
* Read DEFENSIVELY throughout: chain payloads are free-form JSON and historical
|
|
139
|
+
* rows predate several of these fields. A missing id degrades to "no probe"
|
|
140
|
+
* (→ dropped) rather than throwing; a classifier crash on one odd historical row
|
|
141
|
+
* must never kill the pull.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} ev raw `/v1/events` row
|
|
144
|
+
* @returns {Candidate|null}
|
|
145
|
+
*/
|
|
146
|
+
export function classifyEvent(ev) {
|
|
147
|
+
if (!ev || typeof ev !== "object") return null;
|
|
148
|
+
const { family, kind } = coordsOf(ev);
|
|
149
|
+
if (!family) return null;
|
|
150
|
+
// Prefer the nested payload; tolerate producers that inline the fields.
|
|
151
|
+
const p = ev.payload && typeof ev.payload === "object" ? obj(ev.payload) : obj(ev);
|
|
152
|
+
const base = {
|
|
153
|
+
seq: ev.seq ?? null,
|
|
154
|
+
family,
|
|
155
|
+
kind,
|
|
156
|
+
entityId: ev.entity_id ?? ev.entityId ?? null,
|
|
157
|
+
actor: String(p.actor || ev.actor || ""),
|
|
158
|
+
at: ev.at || p.ts || p.at || "",
|
|
159
|
+
payload: p,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// ── messaging: a new message. The BODY never entered the chain; `channelKind`
|
|
163
|
+
// is what distinguishes a DM from a space, and the mention LIST is absent —
|
|
164
|
+
// it has to come from `messaging.history` (facts.channelPages).
|
|
165
|
+
if (family === "messaging" && kind.startsWith("send")) {
|
|
166
|
+
const channelId = str(p, "channelId", "channel");
|
|
167
|
+
if (!channelId) return null;
|
|
168
|
+
return {
|
|
169
|
+
...base,
|
|
170
|
+
topic: "message",
|
|
171
|
+
surfaces: ["dm", "mention", "thread_reply"],
|
|
172
|
+
ids: {
|
|
173
|
+
channelId,
|
|
174
|
+
channelKind: String(p.channelKind || p.channel_kind || "").toUpperCase() || undefined,
|
|
175
|
+
messageId: base.entityId ?? str(p, "messageId", "id"),
|
|
176
|
+
},
|
|
177
|
+
// The redaction leaves two COUNTS behind, and they are load-bearing: a
|
|
178
|
+
// space message with `mentionCount: 0` and `threaded: false` cannot be a
|
|
179
|
+
// mention or a thread reply, so it can be dropped WITHOUT paging the
|
|
180
|
+
// channel at all. That single test is what keeps mention-detection from
|
|
181
|
+
// costing one `messaging.history` per busy public channel per pull.
|
|
182
|
+
hints: {
|
|
183
|
+
mentionCount: Number.isFinite(Number(p.mentionCount)) ? Number(p.mentionCount) : undefined,
|
|
184
|
+
threaded: typeof p.threaded === "boolean" ? p.threaded : undefined,
|
|
185
|
+
attachmentCount: Number.isFinite(Number(p.attachmentCount)) ? Number(p.attachmentCount) : undefined,
|
|
186
|
+
},
|
|
187
|
+
directTo: [],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── calling: an invite/start on a call. Mine when it is in one of my rooms,
|
|
192
|
+
// or when the payload named me as an invitee.
|
|
193
|
+
if (family === "calling") {
|
|
194
|
+
const callId = str(p, "callId") ?? base.entityId ?? undefined;
|
|
195
|
+
if (!callId) return null;
|
|
196
|
+
const invitees = []
|
|
197
|
+
.concat(Array.isArray(p.invitees) ? p.invitees : [])
|
|
198
|
+
.concat(Array.isArray(p.participantIds) ? p.participantIds : [])
|
|
199
|
+
.map(String);
|
|
200
|
+
return {
|
|
201
|
+
...base,
|
|
202
|
+
topic: "call",
|
|
203
|
+
surfaces: ["call"],
|
|
204
|
+
ids: { callId, channelId: str(p, "channelId", "channel"), topic: str(p, "topic") },
|
|
205
|
+
directTo: invitees,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── board: `entityId` is the Task id. Several kinds name the seat outright
|
|
210
|
+
// (`assignee` on assign/claim/complete/heartbeat) — prefer that over a
|
|
211
|
+
// join, since an assignment IMMEDIATELY reassigned would otherwise be
|
|
212
|
+
// missed by a probe against the task's current state.
|
|
213
|
+
if (family === "board") {
|
|
214
|
+
const taskId = base.entityId ?? str(p, "itemId", "taskId");
|
|
215
|
+
if (!taskId) return null;
|
|
216
|
+
const assignee = str(p, "assignee");
|
|
217
|
+
const isAssignment = kind === "item.assigned" || kind === "item.claimed";
|
|
218
|
+
return {
|
|
219
|
+
...base,
|
|
220
|
+
topic: "task",
|
|
221
|
+
surfaces: isAssignment ? ["task_assigned", "task_comment"] : ["task_comment", "task_assigned"],
|
|
222
|
+
ids: { taskId, commentId: str(p, "commentId"), channelId: str(p, "channelId") },
|
|
223
|
+
// `assignee` names the seat only when the event is ABOUT that seat.
|
|
224
|
+
directTo: assignee && isAssignment ? [assignee] : [],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── file (chat attachments): the payload names the room, so channel
|
|
229
|
+
// membership is the join. `fileKey` is the opaque handle the read needs.
|
|
230
|
+
if (family === "file") {
|
|
231
|
+
const fileKey = str(p, "fileKey");
|
|
232
|
+
if (!fileKey) return null;
|
|
233
|
+
return {
|
|
234
|
+
...base,
|
|
235
|
+
topic: "file",
|
|
236
|
+
surfaces: ["file_comment"],
|
|
237
|
+
ids: {
|
|
238
|
+
fileKey,
|
|
239
|
+
channelId: str(p, "channelId"),
|
|
240
|
+
commentId: base.entityId ?? undefined,
|
|
241
|
+
authorId: str(p, "authorId", "pinnedBy", "unpinnedBy"),
|
|
242
|
+
},
|
|
243
|
+
directTo: [],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── files (workspace docs): `entityId` is the File id and the payload carries
|
|
248
|
+
// NOTHING identifying — ownership/shares must be read with `files.get`,
|
|
249
|
+
// which is itself ACL'd, so a doc I cannot see is NOT_FOUND rather than a
|
|
250
|
+
// leak.
|
|
251
|
+
if (family === "files") {
|
|
252
|
+
const fileId = base.entityId ?? str(p, "fileId");
|
|
253
|
+
if (!fileId) return null;
|
|
254
|
+
return {
|
|
255
|
+
...base,
|
|
256
|
+
topic: "file",
|
|
257
|
+
surfaces: ["doc_comment"],
|
|
258
|
+
ids: { fileId, commentId: str(p, "commentId"), name: str(p, "name") },
|
|
259
|
+
directTo: [],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── decision: the payload names the COMMENT author, never the audience. Mine
|
|
264
|
+
// when I proposed it, decided it, or have spoken in its thread.
|
|
265
|
+
if (family === "decision") {
|
|
266
|
+
const decisionId = base.entityId ?? str(p, "decisionId");
|
|
267
|
+
if (!decisionId) return null;
|
|
268
|
+
return {
|
|
269
|
+
...base,
|
|
270
|
+
topic: "decision",
|
|
271
|
+
surfaces: ["decision"],
|
|
272
|
+
ids: {
|
|
273
|
+
decisionId,
|
|
274
|
+
commentId: str(p, "commentId"),
|
|
275
|
+
authorId: str(p, "authorId", "proposedBy", "signedBy"),
|
|
276
|
+
title: str(p, "title"),
|
|
277
|
+
},
|
|
278
|
+
directTo: [],
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── escalation: `taskId` / `channelId` are the join keys; `waitingOn` (read
|
|
283
|
+
// back via escalation.list) can name a seat outright.
|
|
284
|
+
if (family === "escalation") {
|
|
285
|
+
const escalationId = base.entityId ?? str(p, "escalationId");
|
|
286
|
+
if (!escalationId) return null;
|
|
287
|
+
return {
|
|
288
|
+
...base,
|
|
289
|
+
topic: "escalation",
|
|
290
|
+
surfaces: ["escalation"],
|
|
291
|
+
ids: {
|
|
292
|
+
escalationId,
|
|
293
|
+
taskId: str(p, "taskId"),
|
|
294
|
+
channelId: str(p, "channelId"),
|
|
295
|
+
severity: str(p, "severity"),
|
|
296
|
+
},
|
|
297
|
+
directTo: [],
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── approval: `approval.requested` names ONLY the requester (hq
|
|
302
|
+
// `methods/approval/request.ts`) — the approver is on the row, not in the
|
|
303
|
+
// payload, so it takes an `approval.get` probe. `approval.approved` /
|
|
304
|
+
// `.rejected` name both ends.
|
|
305
|
+
if (family === "approval") {
|
|
306
|
+
const approvalId = base.entityId ?? str(p, "approvalId", "id");
|
|
307
|
+
if (!approvalId) return null;
|
|
308
|
+
const directTo = [str(p, "requester"), str(p, "approver")].filter(Boolean);
|
|
309
|
+
return {
|
|
310
|
+
...base,
|
|
311
|
+
topic: "approval",
|
|
312
|
+
surfaces: ["approval"],
|
|
313
|
+
ids: { approvalId, taskId: str(p, "itemId"), actionClass: str(p, "actionClass") },
|
|
314
|
+
directTo,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── handoff: the payload names BOTH ends. Zero-join delivery.
|
|
319
|
+
if (family === "handoff") {
|
|
320
|
+
const directTo = [str(p, "to"), str(p, "from")].filter(Boolean);
|
|
321
|
+
if (directTo.length === 0) return null;
|
|
322
|
+
return {
|
|
323
|
+
...base,
|
|
324
|
+
topic: "handoff",
|
|
325
|
+
surfaces: ["handoff"],
|
|
326
|
+
ids: {
|
|
327
|
+
runId: base.entityId ?? undefined,
|
|
328
|
+
taskId: str(p, "itemId"),
|
|
329
|
+
handoffId: str(p, "handoffId"),
|
|
330
|
+
intent: str(p, "intent"),
|
|
331
|
+
to: str(p, "to"),
|
|
332
|
+
from: str(p, "from"),
|
|
333
|
+
},
|
|
334
|
+
directTo,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── email: the one family whose redacted payload already names its seat
|
|
339
|
+
// (`memberId` = the owning mailbox's member). Unrouted mail
|
|
340
|
+
// (`memberId: null`) belongs to nobody and is dropped.
|
|
341
|
+
if (family === "email" && kind === "received") {
|
|
342
|
+
const memberId = str(p, "memberId");
|
|
343
|
+
if (!memberId) return null;
|
|
344
|
+
if (p.autoSubmitted === true) return null; // bounces / vacation autoreplies
|
|
345
|
+
return {
|
|
346
|
+
...base,
|
|
347
|
+
topic: "email",
|
|
348
|
+
surfaces: ["email"],
|
|
349
|
+
ids: {
|
|
350
|
+
emailMessageId: str(p, "emailMessageId") ?? base.entityId ?? undefined,
|
|
351
|
+
threadId: str(p, "threadId"),
|
|
352
|
+
mailboxId: str(p, "mailboxId"),
|
|
353
|
+
},
|
|
354
|
+
directTo: [memberId],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A verdict with no delivery. */
|
|
362
|
+
function no(reason) {
|
|
363
|
+
return { directed: false, surface: null, reason, why: reason };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** A verdict that delivers. */
|
|
367
|
+
function yes(surface, reason, extra = {}) {
|
|
368
|
+
return { directed: true, surface, reason, why: reason, ...extra };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Join a Candidate against the agent's own facts and return a typed verdict.
|
|
373
|
+
* PURE — every fact it needs is on `facts`, which `facts.mjs` resolved from the
|
|
374
|
+
* agent's own entitled reads.
|
|
375
|
+
*
|
|
376
|
+
* @param {Candidate} cand
|
|
377
|
+
* @param {string} me the agent's member id
|
|
378
|
+
* @param {import("./facts.mjs").Facts} facts
|
|
379
|
+
* @param {{enabled?: Record<string,boolean>}} [opts]
|
|
380
|
+
* @returns {{directed:boolean, surface:string|null, reason:string, why:string}}
|
|
381
|
+
*/
|
|
382
|
+
export function resolveDirected(cand, me, facts = {}, opts = {}) {
|
|
383
|
+
if (!cand) return no("not_classified");
|
|
384
|
+
const enabled = opts.enabled || null;
|
|
385
|
+
const on = (s) => (enabled ? !!enabled[s] : true);
|
|
386
|
+
const meId = me ? String(me) : "";
|
|
387
|
+
|
|
388
|
+
// My OWN echo is never inbound. This is the single most important drop: without
|
|
389
|
+
// it an agent re-ingests its own replies and holds a conversation with itself.
|
|
390
|
+
if (meId && cand.actor && cand.actor === meId) return no("own_echo");
|
|
391
|
+
|
|
392
|
+
// A payload that named me outright needs no join at all.
|
|
393
|
+
if (meId && cand.directTo.map(String).includes(meId)) {
|
|
394
|
+
const surface = cand.surfaces.find(on);
|
|
395
|
+
if (!surface) return no("surface_disabled");
|
|
396
|
+
switch (cand.family) {
|
|
397
|
+
case "approval":
|
|
398
|
+
// `approval.requested` names only the requester; `approval.approved` /
|
|
399
|
+
// `.rejected` name both ends. Report which end I am.
|
|
400
|
+
if (String(cand.payload.approver || "") === meId) return yes(surface, "approver");
|
|
401
|
+
return yes(surface, "requester");
|
|
402
|
+
case "handoff":
|
|
403
|
+
// Offered TO me = work landing on my desk; offered BY me = outcome news.
|
|
404
|
+
return yes(surface, String(cand.ids.to || "") === meId ? "direct" : "requester");
|
|
405
|
+
case "board":
|
|
406
|
+
return yes(surface, "assignee");
|
|
407
|
+
case "calling":
|
|
408
|
+
return yes(surface, "participant");
|
|
409
|
+
default:
|
|
410
|
+
return yes(surface, "direct");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
switch (cand.family) {
|
|
415
|
+
case "messaging":
|
|
416
|
+
return resolveMessaging(cand, meId, facts, on);
|
|
417
|
+
case "calling":
|
|
418
|
+
return resolveCalling(cand, meId, facts, on);
|
|
419
|
+
case "board":
|
|
420
|
+
return resolveBoard(cand, meId, facts, on);
|
|
421
|
+
case "file":
|
|
422
|
+
return resolveChatFile(cand, meId, facts, on);
|
|
423
|
+
case "files":
|
|
424
|
+
return resolveDoc(cand, meId, facts, on);
|
|
425
|
+
case "decision":
|
|
426
|
+
return resolveDecision(cand, meId, facts, on);
|
|
427
|
+
case "escalation":
|
|
428
|
+
return resolveEscalation(cand, meId, facts, on);
|
|
429
|
+
case "approval":
|
|
430
|
+
return resolveApproval(cand, meId, facts, on);
|
|
431
|
+
default:
|
|
432
|
+
return no("no_rule");
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// Per-family resolvers
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Messaging. THE safety-critical one.
|
|
442
|
+
*
|
|
443
|
+
* Order of decision:
|
|
444
|
+
* 1. channel not visible to me → invisible (no verdict, no probe)
|
|
445
|
+
* 2. private room + I am a member → `dm`
|
|
446
|
+
* 3. private room + I am NOT a member → NOT directed (hard stop)
|
|
447
|
+
* 4. private SPACE + I am not a member → NOT directed (hard stop)
|
|
448
|
+
* 5. mentions[] (from history) names me → `mention`
|
|
449
|
+
* 6. thread root I have spoken in / been named in → `thread_reply`
|
|
450
|
+
* 7. otherwise → ambient chatter, not directed
|
|
451
|
+
*/
|
|
452
|
+
function resolveMessaging(cand, me, facts, on) {
|
|
453
|
+
const channelId = cand.ids.channelId;
|
|
454
|
+
const visible = asSet(facts.visibleChannelIds);
|
|
455
|
+
const members = asSet(facts.memberChannelIds);
|
|
456
|
+
|
|
457
|
+
// The channel kind on the event is authoritative for the ROOM CLASS; the
|
|
458
|
+
// roster's kind is authoritative when the event omitted it.
|
|
459
|
+
const chan = facts.channels instanceof Map ? facts.channels.get(String(channelId)) : undefined;
|
|
460
|
+
const channelKind = String(cand.ids.channelKind || (chan && chan.kind) || "").toUpperCase();
|
|
461
|
+
|
|
462
|
+
// (1) A channel that is not in my visible roster is a room I cannot read. It
|
|
463
|
+
// is never mine, and facts.mjs will never have paged it.
|
|
464
|
+
if (visible && channelId && !visible.has(String(channelId))) return no("channel_not_visible");
|
|
465
|
+
|
|
466
|
+
const isPrivateRoom = PRIVATE_ROOM_KINDS.includes(channelKind);
|
|
467
|
+
const needsMembership = MEMBERSHIP_REQUIRED_KINDS.includes(channelKind);
|
|
468
|
+
|
|
469
|
+
// (2)/(3) Private rooms: membership IS the address.
|
|
470
|
+
if (isPrivateRoom) {
|
|
471
|
+
if (!members) return no("membership_unknown");
|
|
472
|
+
if (!has(members, channelId)) return no("not_a_member");
|
|
473
|
+
return on("dm") ? yes("dm", "dm") : no("surface_disabled");
|
|
474
|
+
}
|
|
475
|
+
// (4) A private SPACE I am not in: hard stop before any mention probe.
|
|
476
|
+
if (needsMembership && !has(members, channelId)) {
|
|
477
|
+
return no(members ? "not_a_member" : "membership_unknown");
|
|
478
|
+
}
|
|
479
|
+
// An UNKNOWN channel kind (older row, roster miss) is treated as
|
|
480
|
+
// membership-required — the conservative branch.
|
|
481
|
+
if (!channelKind && !has(members, channelId)) return no("channel_kind_unknown");
|
|
482
|
+
|
|
483
|
+
// Cheap structural drop from the redacted counts, BEFORE any hydration: a
|
|
484
|
+
// space message that mentions nobody and is not threaded cannot be mine.
|
|
485
|
+
if (needsSpaceProbe(cand, facts) === false) return no("no_mentions_not_threaded");
|
|
486
|
+
|
|
487
|
+
const msg = lookupMessage(facts, channelId, cand.ids.messageId);
|
|
488
|
+
|
|
489
|
+
// (5) An explicit @mention of my member id. The mention list is NOT in the
|
|
490
|
+
// chain payload; it comes from `messaging.history`, which hq ACLs to rooms
|
|
491
|
+
// I may read.
|
|
492
|
+
if (msg && Array.isArray(msg.mentions) && me && msg.mentions.map(String).includes(me)) {
|
|
493
|
+
return on("mention") ? yes("mention", "mention") : no("surface_disabled");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// (6) A reply in a thread I am in. "In" = I authored a message under that
|
|
497
|
+
// root, or I was named in one.
|
|
498
|
+
const root = msg && (msg.threadRootId || msg.thread_root_id);
|
|
499
|
+
if (root && facts.myThreadRootIds instanceof Set && facts.myThreadRootIds.has(String(root))) {
|
|
500
|
+
return on("thread_reply") ? yes("thread_reply", "thread") : no("surface_disabled");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// A mention by DISPLAY NAME, when the org has no structured mention row (an
|
|
504
|
+
// agent addressed as "@Isla" in prose). Only ever consulted for a space I can
|
|
505
|
+
// already read, and only when the caller supplied its own names.
|
|
506
|
+
if (msg && matchesMyName(facts, msg.body ?? msg.text)) {
|
|
507
|
+
return on("mention") ? yes("mention", "named") : no("surface_disabled");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (!msg) return no("message_not_hydrated");
|
|
511
|
+
return no("ambient_channel_traffic");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Calls: an invite in a room I belong to, else not mine. */
|
|
515
|
+
function resolveCalling(cand, me, facts, on) {
|
|
516
|
+
if (!on("call")) return no("surface_disabled");
|
|
517
|
+
const members = asSet(facts.memberChannelIds);
|
|
518
|
+
const visible = asSet(facts.visibleChannelIds);
|
|
519
|
+
const channelId = cand.ids.channelId;
|
|
520
|
+
if (channelId) {
|
|
521
|
+
if (visible && !visible.has(String(channelId))) return no("channel_not_visible");
|
|
522
|
+
if (has(members, channelId)) return yes("call", "channel");
|
|
523
|
+
return no("not_a_member");
|
|
524
|
+
}
|
|
525
|
+
// A bare call with no room context: only when the payload named me, which the
|
|
526
|
+
// directTo branch already handled.
|
|
527
|
+
return no("call_without_room");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Board: assignee/reviewer of the item, resolved from `board.context`. */
|
|
531
|
+
function resolveBoard(cand, me, facts, on) {
|
|
532
|
+
const role = facts.taskRole instanceof Map ? facts.taskRole.get(String(cand.ids.taskId)) : undefined;
|
|
533
|
+
if (!role) return no(facts.taskRole ? "not_my_task" : "board_unknown");
|
|
534
|
+
const isAssignment = cand.kind === "item.assigned" || cand.kind === "item.claimed";
|
|
535
|
+
const surface = isAssignment ? "task_assigned" : "task_comment";
|
|
536
|
+
if (!on(surface)) return no("surface_disabled");
|
|
537
|
+
return yes(surface, role);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Chat-attached file: the comment landed in a room, so membership is the join. */
|
|
541
|
+
function resolveChatFile(cand, me, facts, on) {
|
|
542
|
+
if (!on("file_comment")) return no("surface_disabled");
|
|
543
|
+
const channelId = cand.ids.channelId;
|
|
544
|
+
const visible = asSet(facts.visibleChannelIds);
|
|
545
|
+
const members = asSet(facts.memberChannelIds);
|
|
546
|
+
if (!channelId) {
|
|
547
|
+
// No room on the payload: fall back to the task-attachment key, which names
|
|
548
|
+
// a task whose ownership I already know.
|
|
549
|
+
const taskId = taskIdFromFileKey(cand.ids.fileKey);
|
|
550
|
+
const role = taskId && facts.taskRole instanceof Map ? facts.taskRole.get(taskId) : undefined;
|
|
551
|
+
if (role) return yes("file_comment", role);
|
|
552
|
+
return no("file_without_room");
|
|
553
|
+
}
|
|
554
|
+
if (visible && !visible.has(String(channelId))) return no("channel_not_visible");
|
|
555
|
+
if (!has(members, channelId)) return no("not_a_member");
|
|
556
|
+
return yes("file_comment", "channel");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Workspace doc: ownership or an explicit share, read back via `files.get`. */
|
|
560
|
+
function resolveDoc(cand, me, facts, on) {
|
|
561
|
+
if (!on("doc_comment")) return no("surface_disabled");
|
|
562
|
+
const acl = facts.fileAcl instanceof Map ? facts.fileAcl.get(String(cand.ids.fileId)) : undefined;
|
|
563
|
+
if (!acl) return no(facts.fileAcl ? "file_not_visible" : "files_unknown");
|
|
564
|
+
if (acl.ownerId && me && String(acl.ownerId) === me) return yes("doc_comment", "owner");
|
|
565
|
+
if (acl.shared) return yes("doc_comment", "shared");
|
|
566
|
+
if (acl.mentionsMe) return yes("doc_comment", "named");
|
|
567
|
+
return no("not_my_doc");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Decision: proposer, decider, or a voice already in the thread. */
|
|
571
|
+
function resolveDecision(cand, me, facts, on) {
|
|
572
|
+
if (!on("decision")) return no("surface_disabled");
|
|
573
|
+
const d = facts.decisions instanceof Map ? facts.decisions.get(String(cand.ids.decisionId)) : undefined;
|
|
574
|
+
if (d && me) {
|
|
575
|
+
if (String(d.proposedBy || "") === me) return yes("decision", "proposer");
|
|
576
|
+
if (String(d.decidedBy || "") === me) return yes("decision", "direct");
|
|
577
|
+
}
|
|
578
|
+
const voices =
|
|
579
|
+
facts.decisionVoices instanceof Map
|
|
580
|
+
? facts.decisionVoices.get(String(cand.ids.decisionId))
|
|
581
|
+
: undefined;
|
|
582
|
+
if (voices && me && voices.has(me)) return yes("decision", "commenter");
|
|
583
|
+
if (!d && !voices) return no("decision_unknown");
|
|
584
|
+
return no("not_my_decision");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Escalation: on my task, in my room, or explicitly waiting on me. */
|
|
588
|
+
function resolveEscalation(cand, me, facts, on) {
|
|
589
|
+
if (!on("escalation")) return no("surface_disabled");
|
|
590
|
+
const e = facts.escalations instanceof Map ? facts.escalations.get(String(cand.ids.escalationId)) : undefined;
|
|
591
|
+
const waitingOn = e && e.waitingOn ? String(e.waitingOn) : "";
|
|
592
|
+
if (me && waitingOn && waitingOn === me) return yes("escalation", "waiting_on");
|
|
593
|
+
|
|
594
|
+
const taskId = cand.ids.taskId || (e && e.taskId) || "";
|
|
595
|
+
const role = taskId && facts.taskRole instanceof Map ? facts.taskRole.get(String(taskId)) : undefined;
|
|
596
|
+
if (role) return yes("escalation", role);
|
|
597
|
+
|
|
598
|
+
const channelId = cand.ids.channelId || (e && e.channelId) || "";
|
|
599
|
+
const members = asSet(facts.memberChannelIds);
|
|
600
|
+
const visible = asSet(facts.visibleChannelIds);
|
|
601
|
+
if (channelId) {
|
|
602
|
+
if (visible && !visible.has(String(channelId))) return no("channel_not_visible");
|
|
603
|
+
if (has(members, channelId)) return yes("escalation", "channel");
|
|
604
|
+
}
|
|
605
|
+
return no("not_my_escalation");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Approval: the approver seat is on the row, not in the payload — read it back. */
|
|
609
|
+
function resolveApproval(cand, me, facts, on) {
|
|
610
|
+
if (!on("approval")) return no("surface_disabled");
|
|
611
|
+
const a = facts.approvals instanceof Map ? facts.approvals.get(String(cand.ids.approvalId)) : undefined;
|
|
612
|
+
if (!a) return no(facts.approvals ? "approval_not_visible" : "approval_unknown");
|
|
613
|
+
if (me && String(a.approver || "") === me) return yes("approval", "approver");
|
|
614
|
+
if (me && String(a.requester || "") === me) return yes("approval", "requester");
|
|
615
|
+
return no("not_my_approval");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
// Small pure helpers (exported so the tests can pin them)
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Would a SPACE message need its channel paged to decide directedness?
|
|
624
|
+
*
|
|
625
|
+
* `true` — it might mention me or be threaded; page the channel.
|
|
626
|
+
* `false` — the redacted counts PROVE it cannot be mine (0 mentions, not
|
|
627
|
+
* threaded); drop it without a single RPC.
|
|
628
|
+
* `null` — the counts are absent (older row / other producer); page to be safe.
|
|
629
|
+
*
|
|
630
|
+
* Prose-name matching, when the caller enabled it, always needs the body — so a
|
|
631
|
+
* caller with `myNames` gets `null` rather than `false`.
|
|
632
|
+
*
|
|
633
|
+
* @param {Candidate} cand
|
|
634
|
+
* @param {{myNames?: string[]}} [facts]
|
|
635
|
+
* @returns {boolean|null}
|
|
636
|
+
*/
|
|
637
|
+
export function needsSpaceProbe(cand, facts = {}) {
|
|
638
|
+
const h = (cand && cand.hints) || {};
|
|
639
|
+
if (Array.isArray(facts.myNames) && facts.myNames.length > 0) return null;
|
|
640
|
+
if (h.mentionCount === undefined || h.threaded === undefined) return null;
|
|
641
|
+
if (h.mentionCount > 0) return true;
|
|
642
|
+
if (h.threaded) return true;
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Find a hydrated message row in `facts.channelPages`. */
|
|
647
|
+
export function lookupMessage(facts, channelId, messageId) {
|
|
648
|
+
if (!(facts.channelPages instanceof Map) || !channelId || !messageId) return null;
|
|
649
|
+
const page = facts.channelPages.get(String(channelId));
|
|
650
|
+
if (!page || !(page.byId instanceof Map)) return null;
|
|
651
|
+
return page.byId.get(String(messageId)) || null;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Does this prose name me? Used ONLY as a last resort, for surfaces where hq
|
|
656
|
+
* stores no structured mention row (decision/file comment prose). Matches on a
|
|
657
|
+
* word boundary, case-insensitively, with or without a leading `@`.
|
|
658
|
+
*
|
|
659
|
+
* @param {{myNames?: string[]}} facts
|
|
660
|
+
* @param {unknown} text
|
|
661
|
+
* @returns {boolean}
|
|
662
|
+
*/
|
|
663
|
+
export function matchesMyName(facts, text) {
|
|
664
|
+
const names = Array.isArray(facts && facts.myNames) ? facts.myNames : [];
|
|
665
|
+
if (names.length === 0) return false;
|
|
666
|
+
const body = typeof text === "string" ? text : "";
|
|
667
|
+
if (!body) return false;
|
|
668
|
+
const lower = body.toLowerCase();
|
|
669
|
+
for (const raw of names) {
|
|
670
|
+
const n = String(raw || "").trim().toLowerCase();
|
|
671
|
+
if (n.length < 2) continue;
|
|
672
|
+
let from = 0;
|
|
673
|
+
for (;;) {
|
|
674
|
+
const i = lower.indexOf(n, from);
|
|
675
|
+
if (i === -1) break;
|
|
676
|
+
const before = i > 0 ? lower[i - 1] : "";
|
|
677
|
+
const after = lower[i + n.length] || "";
|
|
678
|
+
const boundaryBefore = !before || before === "@" || !/[a-z0-9_]/.test(before);
|
|
679
|
+
const boundaryAfter = !after || !/[a-z0-9_]/.test(after);
|
|
680
|
+
if (boundaryBefore && boundaryAfter) return true;
|
|
681
|
+
from = i + 1;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* hq encodes a chat attachment's surface in the opaque fileKey
|
|
689
|
+
* (`message-attachment:` / `task-attachment:` / `pinned-file:` — see
|
|
690
|
+
* `src/server/llm-responder/comments.ts`). Recover the task id from a
|
|
691
|
+
* task-attachment key so a comment on my task's attachment is still mine when
|
|
692
|
+
* the payload carried no channel.
|
|
693
|
+
*
|
|
694
|
+
* @param {string|undefined} fileKey
|
|
695
|
+
* @returns {string} the task id, or "" when the key is a different surface
|
|
696
|
+
*/
|
|
697
|
+
export function taskIdFromFileKey(fileKey) {
|
|
698
|
+
const k = typeof fileKey === "string" ? fileKey : "";
|
|
699
|
+
const m = /^task-attachment:([^:]+)/.exec(k);
|
|
700
|
+
return m ? m[1] : "";
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** The surface definition for a verdict (or null). */
|
|
704
|
+
export function surfaceDef(name) {
|
|
705
|
+
return (name && SURFACES[name]) || null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export default {
|
|
709
|
+
PRIVATE_ROOM_KINDS,
|
|
710
|
+
MEMBERSHIP_REQUIRED_KINDS,
|
|
711
|
+
classifyEvent,
|
|
712
|
+
resolveDirected,
|
|
713
|
+
coordsOf,
|
|
714
|
+
asSet,
|
|
715
|
+
needsSpaceProbe,
|
|
716
|
+
lookupMessage,
|
|
717
|
+
matchesMyName,
|
|
718
|
+
taskIdFromFileKey,
|
|
719
|
+
surfaceDef,
|
|
720
|
+
};
|