@cohortapp/agent-sdk 2.16.0 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -2
- package/docs/guides/poller-daemon-setup.md +4 -1
- package/lib/context/budget.mjs +327 -0
- package/lib/context/budget.test.mjs +252 -0
- package/lib/context/history-scope.mjs +138 -0
- package/lib/context/history-scope.test.mjs +79 -0
- package/lib/model-router/economics.mjs +9 -0
- package/lib/model-router/resolve.mjs +6 -0
- package/lib/org/inbound/facts.mjs +4 -2
- package/lib/org/inbound/hydrate.mjs +555 -51
- package/lib/org/inbound/hydrate.test.mjs +456 -1
- package/package.json +3 -1
- package/scripts/daemon/context-compiler.mjs +52 -21
- package/scripts/daemon/context-compiler.test.mjs +106 -0
- package/scripts/daemon/dispatcher-session-continuity.test.mjs +365 -0
- package/scripts/daemon/dispatcher.mjs +210 -9
- package/scripts/daemon/lib/session-router.mjs +310 -42
- package/scripts/daemon/lib/session-router.test.mjs +260 -1
- package/scripts/daemon/prompt-builder.mjs +97 -12
- package/scripts/daemon/prompt-builder.test.mjs +219 -7
- package/scripts/daemon/responder-history.test.mjs +37 -1
- package/scripts/daemon/responder.mjs +71 -69
|
@@ -7,19 +7,31 @@
|
|
|
7
7
|
* classify and the agent "replies" to a blank message. This module re-joins the
|
|
8
8
|
* content, and it does so through the agent's OWN entitled reads:
|
|
9
9
|
*
|
|
10
|
-
* | surface | read
|
|
11
|
-
*
|
|
12
|
-
* | dm / mention | messaging.history (already in facts)
|
|
13
|
-
* | thread_reply | messaging.history (already in facts)
|
|
14
|
-
* | call |
|
|
15
|
-
* |
|
|
16
|
-
* |
|
|
17
|
-
* |
|
|
18
|
-
* |
|
|
19
|
-
* |
|
|
20
|
-
* |
|
|
21
|
-
* |
|
|
22
|
-
* |
|
|
10
|
+
* | surface | read | ACL hq applies |
|
|
11
|
+
* |----------------|--------------------------------------------|-----------------------------|
|
|
12
|
+
* | dm / mention | messaging.history (already in facts) | assertChannelAccess |
|
|
13
|
+
* | thread_reply | messaging.history (already in facts) | assertChannelAccess |
|
|
14
|
+
* | call | calling.getDetails / .getTranscript, | participation-gated |
|
|
15
|
+
* | | messaging.history for the room | assertChannelAccess |
|
|
16
|
+
* | task_* | board.taskComments | org-scoped task |
|
|
17
|
+
* | file_comment | file.listComments + file.listPinned | assertChannelVisible |
|
|
18
|
+
* | doc_comment | files.comments + files.docRead | doc ACL (NOT_FOUND if none) |
|
|
19
|
+
* | decision | decision.listComments | org-scoped |
|
|
20
|
+
* | escalation | escalation.list (facts) + board.taskComments| org-scoped |
|
|
21
|
+
* | approval | approval.get (facts) + the item's comments | org-scoped |
|
|
22
|
+
* | handoff | board.taskComments on the origin task | org-scoped task |
|
|
23
|
+
* | calendar | calendar.list (facts) + the room's history | lens floor / channel ACL |
|
|
24
|
+
* | email | email.thread / email.message | OWN MAILBOX ONLY |
|
|
25
|
+
*
|
|
26
|
+
* ── WHY FIVE SURFACES USED TO HAVE NO HISTORY AT ALL ──
|
|
27
|
+
* `call`, `approval`, `escalation`, `calendar` and `handoff` rendered the
|
|
28
|
+
* entity's own fields and stopped (design §3 R5). An approval names an action
|
|
29
|
+
* class and a requester; an escalation names a severity. Neither says what the
|
|
30
|
+
* work IS, so the agent answered the row rather than the situation. Each now
|
|
31
|
+
* reaches for the conversation the entity is anchored to, through the SAME
|
|
32
|
+
* `io.call` seam and the SAME `renderThread` budget as every other surface, and
|
|
33
|
+
* each probe is independently fail-open: a probe that fails leaves that one
|
|
34
|
+
* block absent and the item still hydrates.
|
|
23
35
|
*
|
|
24
36
|
* Because every one of those is the same read the agent could make by hand,
|
|
25
37
|
* hydration can never widen what the agent may see — it only re-joins content
|
|
@@ -128,6 +140,220 @@ export function renderThread(comments, { limit = 8, max = 2000 } = {}) {
|
|
|
128
140
|
return kept.join("\n") || null;
|
|
129
141
|
}
|
|
130
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Event kinds whose TRIGGER is the newest row of the comment thread the
|
|
145
|
+
* hydrator is about to read. hq's own handlers are the source: `board/comment`
|
|
146
|
+
* appends `board.commented`, `board/addTaskComment` appends `item.commented`,
|
|
147
|
+
* `file/addComment` appends `file.commented`, `files/commentAdd` appends
|
|
148
|
+
* `doc.comment`, and both `decision/comment` and `decision/requestAdjustment`
|
|
149
|
+
* write a `DecisionComment` row before appending theirs.
|
|
150
|
+
*
|
|
151
|
+
* Nothing else does. `item.assigned`, `item.moved`, `file.shared` and
|
|
152
|
+
* `decision.signed` are events ABOUT an entity that happens to have a comment
|
|
153
|
+
* thread — for those the newest comment is prior context, not the trigger.
|
|
154
|
+
*/
|
|
155
|
+
export const COMMENT_KINDS = Object.freeze([
|
|
156
|
+
"item.commented",
|
|
157
|
+
"board.commented",
|
|
158
|
+
"file.commented",
|
|
159
|
+
"doc.comment",
|
|
160
|
+
"decision.comment",
|
|
161
|
+
"decision.adjustment_requested",
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
const COMMENT_KIND_SET = new Set(COMMENT_KINDS);
|
|
165
|
+
|
|
166
|
+
/** Is this event kind itself a comment? See {@link COMMENT_KINDS}. */
|
|
167
|
+
export function isCommentKind(kind) {
|
|
168
|
+
return COMMENT_KIND_SET.has(s(kind));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The comments that are CONTEXT for this event, i.e. everything except the one
|
|
173
|
+
* that triggered it.
|
|
174
|
+
*
|
|
175
|
+
* This used to be a flat `comments.slice(0, -1)` on every board, file, doc and
|
|
176
|
+
* decision hydrator. That is right for a comment event and wrong for every
|
|
177
|
+
* other kind: an `item.assigned` or a `decision.signed` has no comment of its
|
|
178
|
+
* own, so slicing the tail threw away the newest — and on a freshly assigned
|
|
179
|
+
* task with exactly one comment, that comment was the whole of the context and
|
|
180
|
+
* the agent saw none of it.
|
|
181
|
+
*
|
|
182
|
+
* Two guards, in order of precision:
|
|
183
|
+
* 1. the payload's own `commentId`, when the row is still on the page. Exact,
|
|
184
|
+
* and correct even when a newer comment landed between the event being
|
|
185
|
+
* appended and this read — the flat slice dropped the WRONG row there.
|
|
186
|
+
* 2. otherwise the event kind ({@link isCommentKind}).
|
|
187
|
+
*
|
|
188
|
+
* @param {object[]} comments the full thread, as read
|
|
189
|
+
* @param {{kind?:string, commentId?:string}} [ev]
|
|
190
|
+
* @returns {object[]} a new array — the caller's is never mutated
|
|
191
|
+
*/
|
|
192
|
+
export function priorComments(comments, ev = {}) {
|
|
193
|
+
const rows = Array.isArray(comments) ? comments.slice() : [];
|
|
194
|
+
const id = s(ev && ev.commentId);
|
|
195
|
+
if (id) {
|
|
196
|
+
const idx = rows.findIndex((c) => s(c && c.id) === id);
|
|
197
|
+
if (idx !== -1) {
|
|
198
|
+
rows.splice(idx, 1);
|
|
199
|
+
return rows;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (isCommentKind(ev && ev.kind)) return rows.slice(0, -1);
|
|
203
|
+
return rows;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* How many of a room's most recent messages a non-messaging surface borrows.
|
|
208
|
+
* A call or a calendar event needs enough of the room to know what is being
|
|
209
|
+
* talked about; it is not the conversation itself, so it gets less than one.
|
|
210
|
+
*/
|
|
211
|
+
const ROOM_HISTORY_TURNS = 8;
|
|
212
|
+
|
|
213
|
+
/** Messages fetched when a room has to be paged for one of those surfaces. */
|
|
214
|
+
const ROOM_HISTORY_LIMIT = 25;
|
|
215
|
+
|
|
216
|
+
/** Transcript lines requested / rendered for a call. */
|
|
217
|
+
const CALL_TRANSCRIPT_LIMIT = 40;
|
|
218
|
+
const CALL_TRANSCRIPT_TURNS = 12;
|
|
219
|
+
|
|
220
|
+
/** Prior occurrences of a recurring event rendered on a calendar surface. */
|
|
221
|
+
const CALENDAR_SERIES_TURNS = 3;
|
|
222
|
+
|
|
223
|
+
/** The artifact body budget. Same clamp `clip` already defaults to. */
|
|
224
|
+
const ARTIFACT_BODY_CHARS = 4000;
|
|
225
|
+
|
|
226
|
+
/** The triggering comment's own budget, alongside an artifact body. */
|
|
227
|
+
const COMMENT_CHARS = 1200;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The doc surface's own ceiling for `hydrated.text`.
|
|
231
|
+
*
|
|
232
|
+
* Every other hydrator returns `clip(...)` at {@link clip}'s 4000 default. A doc
|
|
233
|
+
* carries a body AND the comment being answered, and clipping the two together
|
|
234
|
+
* at 4000 would spend the whole allowance on the document and cut off the
|
|
235
|
+
* question. So the ceiling is stated, and it is the sum of the declared parts
|
|
236
|
+
* plus the heading — not "whatever the parts happen to add up to".
|
|
237
|
+
*/
|
|
238
|
+
const DOC_TEXT_CHARS = 5600;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Pinned-file rows scanned for one comment. `file.listPinned` carries no limit
|
|
242
|
+
* on the wire, so the bound lives here.
|
|
243
|
+
*/
|
|
244
|
+
const PINNED_FILES_MAX = 200;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The most doc HTML that is ever CONVERTED.
|
|
248
|
+
*
|
|
249
|
+
* `files.docRead` returns the whole document; the wire has no limit to ask for
|
|
250
|
+
* and inventing a parameter hq does not document would be a silent no-op. So
|
|
251
|
+
* the raw string is clamped the instant it lands, BEFORE `htmlToText` runs and
|
|
252
|
+
* before the per-pull cache holds it. Measured before this clamp: 8 MB of HTML
|
|
253
|
+
* converted to 7,435,349 characters to retain 4,000. Generous against
|
|
254
|
+
* {@link ARTIFACT_BODY_CHARS} because markup is most of the bytes — the body of
|
|
255
|
+
* a long doc still reaches its full text budget.
|
|
256
|
+
*/
|
|
257
|
+
export const DOC_HTML_MAX_CHARS = 200_000;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The doc HTML this pull is willing to hold and convert. Exported so the bound
|
|
261
|
+
* is testable on its own — the clamp is invisible in the hydrated output (the
|
|
262
|
+
* 4000-char text clip hides it), which is exactly how it went missing.
|
|
263
|
+
*/
|
|
264
|
+
export function clampDocHtml(html) {
|
|
265
|
+
const t = s(html);
|
|
266
|
+
return t.length > DOC_HTML_MAX_CHARS ? t.slice(0, DOC_HTML_MAX_CHARS) : t;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The room's recent conversation, for a surface anchored to a channel.
|
|
271
|
+
*
|
|
272
|
+
* THE ONE RULE `facts.mjs` enforces holds here too: a channel outside the
|
|
273
|
+
* visible roster is NEVER touched. `messaging.channels` returns a private room
|
|
274
|
+
* only to its members, so refusing to page anything outside that set means this
|
|
275
|
+
* cannot become the aperture the directedness probe declined to open. When the
|
|
276
|
+
* roster is unknown (`visibleChannelIds === null`) we also decline — unknown is
|
|
277
|
+
* not permission, and this is the one gate in the file that is NOT fail-open.
|
|
278
|
+
* `facts.mjs:314-321` already enforces exactly that (`if (!visible) { … return; }`);
|
|
279
|
+
* writing the guard as `visible instanceof Set && !visible.has(id)` let the null
|
|
280
|
+
* case through, so an unreadable roster plus a channel id supplied by the event
|
|
281
|
+
* payload paged a room this seat may never have been in. hq's
|
|
282
|
+
* `assertChannelAccess` still refused it, but the local gate the docstring
|
|
283
|
+
* claims has to exist or the claim is the thing that is wrong.
|
|
284
|
+
*
|
|
285
|
+
* Free when the facts pass already paged the channel; one capped
|
|
286
|
+
* `messaging.history` otherwise, memoised for the pull.
|
|
287
|
+
*
|
|
288
|
+
* @returns {Promise<string|null>} a rendered thread block, or null
|
|
289
|
+
*/
|
|
290
|
+
async function roomHistory(channelId, facts, io, cache) {
|
|
291
|
+
const id = s(channelId);
|
|
292
|
+
if (!id || !io) return null;
|
|
293
|
+
const visible = facts && facts.visibleChannelIds;
|
|
294
|
+
if (!(visible instanceof Set) || !visible.has(id)) return null;
|
|
295
|
+
|
|
296
|
+
const page = facts && facts.channelPages instanceof Map ? facts.channelPages.get(id) : null;
|
|
297
|
+
const messages = page && Array.isArray(page.messages) && page.messages.length
|
|
298
|
+
? page.messages
|
|
299
|
+
: await cached(cache, `roomHistory:${id}`, async () => {
|
|
300
|
+
const frame = await io.call("messaging.history", { channelId: id, limit: ROOM_HISTORY_LIMIT });
|
|
301
|
+
return frame && frame.ok === true ? arrayOf(resultOf(frame), "messages") : [];
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
return renderThread(
|
|
305
|
+
(Array.isArray(messages) ? messages : []).map((m) => ({
|
|
306
|
+
authorName: speakerFor(m, facts),
|
|
307
|
+
body: m.body ?? m.text,
|
|
308
|
+
createdAt: m.createdAt,
|
|
309
|
+
})),
|
|
310
|
+
{ limit: ROOM_HISTORY_TURNS },
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* A board item's comment thread. Shared by the board, escalation, approval and
|
|
316
|
+
* handoff hydrators, which all anchor on a task id. Fail-open: an unreadable
|
|
317
|
+
* thread is an absent block, never a failed hydration.
|
|
318
|
+
*/
|
|
319
|
+
async function taskComments(taskId, io, cache) {
|
|
320
|
+
const id = s(taskId);
|
|
321
|
+
if (!id || !io) return [];
|
|
322
|
+
return cached(cache, `taskComments:${id}`, async () => {
|
|
323
|
+
const frame = await io.call("board.taskComments", { taskId: id });
|
|
324
|
+
return frame && frame.ok === true ? arrayOf(resultOf(frame), "comments") : [];
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Join the context blocks a surface gathered, dropping the ones that are empty. */
|
|
329
|
+
function joinContext(blocks) {
|
|
330
|
+
const kept = (Array.isArray(blocks) ? blocks : []).map((b) => s(b).trim()).filter(Boolean);
|
|
331
|
+
return kept.length ? kept.join("\n") : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* HTML → the text a reader would see. `files.docRead` returns the doc's HEAD
|
|
336
|
+
* html; handing raw markup to the model spends the budget on tags and teaches
|
|
337
|
+
* it to answer in markup. Deliberately small: block tags become newlines, the
|
|
338
|
+
* five XML entities are decoded, everything else goes.
|
|
339
|
+
*/
|
|
340
|
+
export function htmlToText(html) {
|
|
341
|
+
return s(html)
|
|
342
|
+
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ")
|
|
343
|
+
.replace(/<\/(p|div|li|h[1-6]|tr|blockquote)>/gi, "\n")
|
|
344
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
345
|
+
.replace(/<[^>]+>/g, "")
|
|
346
|
+
.replace(/ /gi, " ")
|
|
347
|
+
.replace(/</gi, "<")
|
|
348
|
+
.replace(/>/gi, ">")
|
|
349
|
+
.replace(/"/gi, '"')
|
|
350
|
+
.replace(/'/gi, "'")
|
|
351
|
+
.replace(/&/gi, "&")
|
|
352
|
+
.replace(/[ \t]+/g, " ")
|
|
353
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
354
|
+
.trim();
|
|
355
|
+
}
|
|
356
|
+
|
|
131
357
|
/**
|
|
132
358
|
* @typedef {Object} Hydrated
|
|
133
359
|
* @property {boolean} ok
|
|
@@ -164,7 +390,7 @@ export async function hydrate(o = {}) {
|
|
|
164
390
|
case "messaging":
|
|
165
391
|
return hydrateMessage(c, facts);
|
|
166
392
|
case "calling":
|
|
167
|
-
return hydrateCall(c, facts);
|
|
393
|
+
return await hydrateCall(c, facts, io, cache);
|
|
168
394
|
case "board":
|
|
169
395
|
return await hydrateTask(c, verdict, facts, io, cache);
|
|
170
396
|
case "file":
|
|
@@ -174,13 +400,13 @@ export async function hydrate(o = {}) {
|
|
|
174
400
|
case "decision":
|
|
175
401
|
return await hydrateDecision(c, facts, io, cache);
|
|
176
402
|
case "escalation":
|
|
177
|
-
return hydrateEscalation(c, facts);
|
|
403
|
+
return await hydrateEscalation(c, facts, io, cache);
|
|
178
404
|
case "approval":
|
|
179
|
-
return hydrateApproval(c, facts);
|
|
405
|
+
return await hydrateApproval(c, facts, io, cache);
|
|
180
406
|
case "handoff":
|
|
181
|
-
return hydrateHandoff(c, facts);
|
|
407
|
+
return await hydrateHandoff(c, facts, io, cache);
|
|
182
408
|
case "calendar":
|
|
183
|
-
return hydrateCalendar(c, facts);
|
|
409
|
+
return await hydrateCalendar(c, facts, io, cache);
|
|
184
410
|
case "email":
|
|
185
411
|
return await hydrateEmail(c, facts, io, cache);
|
|
186
412
|
default:
|
|
@@ -242,15 +468,73 @@ function hydrateMessage(c, facts) {
|
|
|
242
468
|
};
|
|
243
469
|
}
|
|
244
470
|
|
|
245
|
-
/**
|
|
246
|
-
|
|
471
|
+
/**
|
|
472
|
+
* A call. The invite topic used to be the whole of it — an agent pulled into a
|
|
473
|
+
* huddle knew the room and the word "standup" and nothing else.
|
|
474
|
+
*
|
|
475
|
+
* Three reads, each independently fail-open:
|
|
476
|
+
* - `calling.getDetails` for the roster (who is actually in the room). It is
|
|
477
|
+
* participation-gated, so an invite not yet accepted can come back
|
|
478
|
+
* FORBIDDEN; that is a missing line, not a failed hydration.
|
|
479
|
+
* - `calling.getTranscript` for what has already been said, when the call is
|
|
480
|
+
* under way (an invite to a fresh call simply has no lines).
|
|
481
|
+
* - the room's recent messages, which is where the call was almost always
|
|
482
|
+
* agreed before it was started.
|
|
483
|
+
*/
|
|
484
|
+
async function hydrateCall(c, facts, io, cache) {
|
|
247
485
|
const chan = facts.channels instanceof Map ? facts.channels.get(s(c.ids.channelId)) : null;
|
|
248
486
|
const topic = s(c.ids.topic);
|
|
487
|
+
const callId = s(c.ids.callId);
|
|
488
|
+
|
|
489
|
+
const details = callId && io
|
|
490
|
+
? await cached(cache, `callDetails:${callId}`, async () => {
|
|
491
|
+
const frame = await io.call("calling.getDetails", { callId });
|
|
492
|
+
return frame && frame.ok === true ? resultOf(frame) : null;
|
|
493
|
+
})
|
|
494
|
+
: null;
|
|
495
|
+
|
|
496
|
+
// `limit` is asked for, and then enforced locally anyway: `ui-parity.mjs`
|
|
497
|
+
// documents the wire contract for `calling.getTranscript` as `{ callId }`
|
|
498
|
+
// alone, so a server that ignores the extra key would hand back every line of
|
|
499
|
+
// an hour-long call. The tail is the part that matters, so the tail is kept.
|
|
500
|
+
const transcript = callId && io
|
|
501
|
+
? await cached(cache, `callTranscript:${callId}`, async () => {
|
|
502
|
+
const frame = await io.call("calling.getTranscript", { callId, limit: CALL_TRANSCRIPT_LIMIT });
|
|
503
|
+
const lines = frame && frame.ok === true ? arrayOf(resultOf(frame), "lines") : [];
|
|
504
|
+
return lines.length > CALL_TRANSCRIPT_LIMIT ? lines.slice(-CALL_TRANSCRIPT_LIMIT) : lines;
|
|
505
|
+
})
|
|
506
|
+
: [];
|
|
507
|
+
|
|
508
|
+
const lines = [topic ? `Call invite: ${topic}` : "Call invite"];
|
|
509
|
+
if (details) {
|
|
510
|
+
const roster = arrayOf(details, "participants")
|
|
511
|
+
.filter((p) => p && (p.inCall || p.joinedAt))
|
|
512
|
+
.map((p) => s(p.memberId || p.contactId))
|
|
513
|
+
.filter(Boolean);
|
|
514
|
+
if (roster.length) lines.push(`In the call: ${roster.join(", ")}`);
|
|
515
|
+
else if (Number.isFinite(Number(details.participantCount))) {
|
|
516
|
+
lines.push(`Participants: ${Number(details.participantCount)}`);
|
|
517
|
+
}
|
|
518
|
+
if (details.live === false) lines.push("This call has ended.");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const transcriptBlock = renderThread(
|
|
522
|
+
transcript.map((l) => ({
|
|
523
|
+
authorName: s(l && (l.memberName || l.memberId)) || "someone",
|
|
524
|
+
body: l && l.text,
|
|
525
|
+
createdAt: l && l.at,
|
|
526
|
+
})),
|
|
527
|
+
{ limit: CALL_TRANSCRIPT_TURNS },
|
|
528
|
+
);
|
|
529
|
+
const room = await roomHistory(c.ids.channelId, facts, io, cache);
|
|
530
|
+
|
|
249
531
|
return {
|
|
250
532
|
ok: true,
|
|
251
|
-
text:
|
|
533
|
+
text: clip(lines.join("\n")),
|
|
252
534
|
subjectDetail: topic,
|
|
253
|
-
threadId:
|
|
535
|
+
threadId: callId,
|
|
536
|
+
threadContext: joinContext([room, transcriptBlock ? `Call transcript so far:\n${transcriptBlock}` : null]),
|
|
537
|
+
isReply: Boolean(transcriptBlock),
|
|
254
538
|
from: { id: s(c.actor), name: s(c.actor) },
|
|
255
539
|
channelId: s(c.ids.channelId),
|
|
256
540
|
channelLabel: s((chan && (chan.name || chan.slug)) || c.ids.channelId || "cohort"),
|
|
@@ -261,12 +545,10 @@ function hydrateCall(c, facts) {
|
|
|
261
545
|
async function hydrateTask(c, verdict, facts, io, cache) {
|
|
262
546
|
const taskId = s(c.ids.taskId);
|
|
263
547
|
const task = facts.tasks instanceof Map ? facts.tasks.get(taskId) : null;
|
|
264
|
-
const comments = await
|
|
265
|
-
const frame = await io.call("board.taskComments", { taskId });
|
|
266
|
-
return frame && frame.ok === true ? arrayOf(resultOf(frame), "comments") : [];
|
|
267
|
-
});
|
|
548
|
+
const comments = await taskComments(taskId, io, cache);
|
|
268
549
|
|
|
269
550
|
const title = s(task && task.title) || taskId;
|
|
551
|
+
const prior = priorComments(comments, { kind: c.kind, commentId: c.ids.commentId });
|
|
270
552
|
const latest = comments.length ? comments[comments.length - 1] : null;
|
|
271
553
|
const verb = describeBoardKind(c.kind);
|
|
272
554
|
const lines = [`${verb} — "${title}"`];
|
|
@@ -282,15 +564,29 @@ async function hydrateTask(c, verdict, facts, io, cache) {
|
|
|
282
564
|
text: clip(lines.join("\n")),
|
|
283
565
|
subjectDetail: title,
|
|
284
566
|
threadId: taskId,
|
|
285
|
-
threadContext: renderThread(
|
|
286
|
-
isReply:
|
|
567
|
+
threadContext: renderThread(prior),
|
|
568
|
+
isReply: prior.length > 0,
|
|
287
569
|
from: { id: s(c.actor), name: s((latest && latest.authorName) || c.actor) },
|
|
288
570
|
channelId: s(c.ids.channelId || (task && task.channelId) || ""),
|
|
289
571
|
channelLabel: `task/${title}`,
|
|
290
572
|
};
|
|
291
573
|
}
|
|
292
574
|
|
|
293
|
-
/**
|
|
575
|
+
/**
|
|
576
|
+
* A chat-attached file: its comment thread AND the file itself.
|
|
577
|
+
*
|
|
578
|
+
* FileComment had ZERO agent activity in 14 days (design §2), and this is one
|
|
579
|
+
* half of why: the hydrator read the comments and never the artifact, so the
|
|
580
|
+
* agent was asked to respond to "is this right?" about a thing it had not seen.
|
|
581
|
+
*
|
|
582
|
+
* What "the artifact" means here is bounded by the protocol, deliberately.
|
|
583
|
+
* `file.listPinned` is explicit that "the dataUrl bytes are NOT projected —
|
|
584
|
+
* only ids + metadata", so a chat attachment's BYTES are not on the wire at
|
|
585
|
+
* all and this cannot invent a read that would serve them. What it can join is
|
|
586
|
+
* the file's record (name, type, size) and the message that shared it, which
|
|
587
|
+
* together are what a human sees in the room before opening it. Both are
|
|
588
|
+
* fail-open: no record and no room simply means no extra lines.
|
|
589
|
+
*/
|
|
294
590
|
async function hydrateChatFile(c, facts, io, cache) {
|
|
295
591
|
const fileKey = s(c.ids.fileKey);
|
|
296
592
|
const channelId = s(c.ids.channelId);
|
|
@@ -304,21 +600,87 @@ async function hydrateChatFile(c, facts, io, cache) {
|
|
|
304
600
|
const latest = comments.length ? comments[comments.length - 1] : null;
|
|
305
601
|
if (!latest) return { ok: false, reason: "file_comment_unreadable", text: "" };
|
|
306
602
|
|
|
603
|
+
const record = await pinnedFileRecord(fileKey, channelId, facts, io, cache);
|
|
604
|
+
const prior = priorComments(comments, { kind: c.kind, commentId: c.ids.commentId });
|
|
307
605
|
const chan = facts.channels instanceof Map ? facts.channels.get(channelId) : null;
|
|
606
|
+
const name = s(record && record.name) || fileKey;
|
|
607
|
+
|
|
608
|
+
const lines = [`Comment on file ${name}`];
|
|
609
|
+
if (record) lines.push(`File: ${describeFileRecord(record)}`);
|
|
610
|
+
lines.push(`${s(latest.authorName || latest.authorId)}: ${s(latest.body)}`);
|
|
611
|
+
|
|
308
612
|
return {
|
|
309
613
|
ok: true,
|
|
310
|
-
text: clip(
|
|
311
|
-
subjectDetail:
|
|
614
|
+
text: clip(lines.join("\n")),
|
|
615
|
+
subjectDetail: name,
|
|
312
616
|
threadId: fileKey,
|
|
313
|
-
threadContext: renderThread(
|
|
314
|
-
isReply:
|
|
617
|
+
threadContext: renderThread(prior),
|
|
618
|
+
isReply: prior.length > 0,
|
|
315
619
|
from: { id: s(latest.authorId || c.actor), name: s(latest.authorName || latest.authorId || c.actor) },
|
|
316
620
|
channelId,
|
|
317
621
|
channelLabel: s((chan && (chan.name || chan.slug)) || `file/${fileKey}`),
|
|
318
622
|
};
|
|
319
623
|
}
|
|
320
624
|
|
|
321
|
-
/**
|
|
625
|
+
/**
|
|
626
|
+
* The pinned-file record for an opaque chat `fileKey`, or null.
|
|
627
|
+
*
|
|
628
|
+
* ONE `file.listPinned` per CHANNEL per pull — the read is channel-scoped, so
|
|
629
|
+
* two comments on two files in the same room share it. Gated on channel
|
|
630
|
+
* visibility exactly as {@link roomHistory} is, unknown roster included: an
|
|
631
|
+
* absent roster declines rather than proceeding.
|
|
632
|
+
*
|
|
633
|
+
* `file.listPinned` takes no limit, so the row list is clamped here — a room
|
|
634
|
+
* with hundreds of pinned files must not turn one comment into an unbounded
|
|
635
|
+
* scan. The rows carry names and sizes, not bodies, so the clamp is generous.
|
|
636
|
+
*/
|
|
637
|
+
async function pinnedFileRecord(fileKey, channelId, facts, io, cache) {
|
|
638
|
+
const key = s(fileKey);
|
|
639
|
+
const cid = s(channelId);
|
|
640
|
+
if (!key || !cid || !io) return null;
|
|
641
|
+
const visible = facts && facts.visibleChannelIds;
|
|
642
|
+
if (!(visible instanceof Set) || !visible.has(cid)) return null;
|
|
643
|
+
|
|
644
|
+
const rows = await cached(cache, `pinnedFiles:${cid}`, async () => {
|
|
645
|
+
const frame = await io.call("file.listPinned", { channelId: cid });
|
|
646
|
+
return frame && frame.ok === true
|
|
647
|
+
? arrayOf(resultOf(frame), "files").slice(0, PINNED_FILES_MAX)
|
|
648
|
+
: [];
|
|
649
|
+
});
|
|
650
|
+
return rows.find((r) => r && (s(r.fileKey) === key || s(r.id) === key)) || null;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** `quarterly.pdf · application/pdf · 1.2 MB` — what the room shows about a file. */
|
|
654
|
+
function describeFileRecord(record) {
|
|
655
|
+
const parts = [s(record.name) || "file"];
|
|
656
|
+
if (record.mimeType) parts.push(s(record.mimeType));
|
|
657
|
+
const bytes = Number(record.sizeBytes);
|
|
658
|
+
if (Number.isFinite(bytes) && bytes > 0) parts.push(formatBytes(bytes));
|
|
659
|
+
return parts.join(" · ");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Bytes as a person reads them. */
|
|
663
|
+
function formatBytes(bytes) {
|
|
664
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
665
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
666
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* A workspace doc: its comment thread AND the document.
|
|
671
|
+
*
|
|
672
|
+
* `files.docRead` returns the doc's HEAD html through the SAME `files.read`
|
|
673
|
+
* scope `files.get` already used in the facts pass — it is the read the agent
|
|
674
|
+
* could make by hand, so joining it here widens nothing. The body is converted
|
|
675
|
+
* to text and clipped to {@link ARTIFACT_BODY_CHARS}: a 40-page doc must not
|
|
676
|
+
* spend the whole prompt, and the comment being answered is nearly always about
|
|
677
|
+
* the top of it.
|
|
678
|
+
*
|
|
679
|
+
* Only DOC-kind files are read. A sheet or a deck answers `BAD_REQUEST` from
|
|
680
|
+
* `files.docRead`, so when the facts pass already knows the kind we skip the
|
|
681
|
+
* call rather than spend a round trip on a refusal; when it does not, the
|
|
682
|
+
* refusal is fail-open like every other probe here.
|
|
683
|
+
*/
|
|
322
684
|
async function hydrateDoc(c, facts, io, cache) {
|
|
323
685
|
const fileId = s(c.ids.fileId);
|
|
324
686
|
const comments = await cached(cache, `docComments:${fileId}`, async () => {
|
|
@@ -327,25 +689,49 @@ async function hydrateDoc(c, facts, io, cache) {
|
|
|
327
689
|
});
|
|
328
690
|
const acl = facts.fileAcl instanceof Map ? facts.fileAcl.get(fileId) : null;
|
|
329
691
|
const name = s(c.ids.name || (acl && acl.name) || fileId);
|
|
692
|
+
const prior = priorComments(comments, { kind: c.kind, commentId: c.ids.commentId });
|
|
330
693
|
const latest = comments.length ? comments[comments.length - 1] : null;
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
694
|
+
const body = await docBody(fileId, acl, io, cache);
|
|
695
|
+
|
|
696
|
+
const lines = [describeFilesKind(c.kind, name)];
|
|
697
|
+
if (body) lines.push(`Document:\n${body}`);
|
|
698
|
+
// The comment is clipped on its own, not with the body: one `clip` over the
|
|
699
|
+
// join would spend the whole 4000 on the document and drop the very sentence
|
|
700
|
+
// being answered. The join is then clipped AGAIN at the shared ceiling, which
|
|
701
|
+
// every other hydrator applies — dropping it here made this the one surface
|
|
702
|
+
// that could exceed `clip`'s default, silently, by about a third.
|
|
703
|
+
if (latest) lines.push(`${s(latest.authorName || latest.authorId)}: ${clip(s(latest.body), COMMENT_CHARS)}`);
|
|
335
704
|
|
|
336
705
|
return {
|
|
337
706
|
ok: true,
|
|
338
|
-
text: clip(
|
|
707
|
+
text: clip(lines.join("\n"), DOC_TEXT_CHARS),
|
|
339
708
|
subjectDetail: name,
|
|
340
709
|
threadId: fileId,
|
|
341
|
-
threadContext: renderThread(
|
|
342
|
-
isReply:
|
|
710
|
+
threadContext: renderThread(prior),
|
|
711
|
+
isReply: prior.length > 0,
|
|
343
712
|
from: { id: s((latest && latest.authorId) || c.actor), name: s((latest && latest.authorName) || c.actor) },
|
|
344
713
|
channelId: "",
|
|
345
714
|
channelLabel: `doc/${name}`,
|
|
346
715
|
};
|
|
347
716
|
}
|
|
348
717
|
|
|
718
|
+
/** The doc's text, bounded, or null. Fail-open on every path. */
|
|
719
|
+
async function docBody(fileId, acl, io, cache) {
|
|
720
|
+
const id = s(fileId);
|
|
721
|
+
if (!id || !io) return null;
|
|
722
|
+
const kind = s(acl && acl.kind).toUpperCase();
|
|
723
|
+
if (kind && kind !== "DOC") return null;
|
|
724
|
+
const html = await cached(cache, `docBody:${id}`, async () => {
|
|
725
|
+
const frame = await io.call("files.docRead", { fileId: id });
|
|
726
|
+
const res = frame && frame.ok === true ? resultOf(frame) : null;
|
|
727
|
+
// Clamped HERE, not after conversion: this is what the cache holds and what
|
|
728
|
+
// htmlToText walks. See DOC_HTML_MAX_CHARS.
|
|
729
|
+
return res ? clampDocHtml(res.html) : "";
|
|
730
|
+
});
|
|
731
|
+
const text = htmlToText(html);
|
|
732
|
+
return text ? clip(text, ARTIFACT_BODY_CHARS) : null;
|
|
733
|
+
}
|
|
734
|
+
|
|
349
735
|
/** A decision, its rationale, and its comment thread. */
|
|
350
736
|
async function hydrateDecision(c, facts, io, cache) {
|
|
351
737
|
const decisionId = s(c.ids.decisionId);
|
|
@@ -355,6 +741,7 @@ async function hydrateDecision(c, facts, io, cache) {
|
|
|
355
741
|
return frame && frame.ok === true ? arrayOf(resultOf(frame), "comments") : [];
|
|
356
742
|
});
|
|
357
743
|
const title = s((d && d.title) || c.ids.title || decisionId);
|
|
744
|
+
const prior = priorComments(comments, { kind: c.kind, commentId: c.ids.commentId });
|
|
358
745
|
const latest = comments.length ? comments[comments.length - 1] : null;
|
|
359
746
|
|
|
360
747
|
const lines = [`${describeDecisionKind(c.kind)} — "${title}"`];
|
|
@@ -369,41 +756,67 @@ async function hydrateDecision(c, facts, io, cache) {
|
|
|
369
756
|
text: clip(lines.join("\n")),
|
|
370
757
|
subjectDetail: title,
|
|
371
758
|
threadId: decisionId,
|
|
372
|
-
threadContext: renderThread(
|
|
373
|
-
isReply:
|
|
759
|
+
threadContext: renderThread(prior),
|
|
760
|
+
isReply: prior.length > 0,
|
|
374
761
|
from: { id: s(c.ids.authorId || c.actor), name: s((latest && latest.authorName) || c.actor) },
|
|
375
762
|
channelId: "",
|
|
376
763
|
channelLabel: `decision/${title}`,
|
|
377
764
|
};
|
|
378
765
|
}
|
|
379
766
|
|
|
380
|
-
/**
|
|
381
|
-
|
|
767
|
+
/**
|
|
768
|
+
* An escalation, plus the work it escalated.
|
|
769
|
+
*
|
|
770
|
+
* The row says a severity and a sentence of detail. What it never said is what
|
|
771
|
+
* the task is or what has already been tried on it — which is the whole of what
|
|
772
|
+
* an escalation is asking about. The escalated task's board row (free, already
|
|
773
|
+
* in `facts.tasks`) and its comment thread supply that.
|
|
774
|
+
*/
|
|
775
|
+
async function hydrateEscalation(c, facts, io, cache) {
|
|
382
776
|
const id = s(c.ids.escalationId);
|
|
383
777
|
const e = facts.escalations instanceof Map ? facts.escalations.get(id) : null;
|
|
384
778
|
const title = s((e && e.title) || id);
|
|
779
|
+
const taskId = s(c.ids.taskId || (e && e.taskId));
|
|
780
|
+
const task = taskId && facts.tasks instanceof Map ? facts.tasks.get(taskId) : null;
|
|
781
|
+
|
|
385
782
|
const lines = [`${c.kind === "escalation.resolved" ? "Escalation resolved" : "Escalation raised"} — "${title}"`];
|
|
386
783
|
if (e && e.severity) lines.push(`Severity: ${s(e.severity)}`);
|
|
387
784
|
if (e && e.detail) lines.push(clip(e.detail, 1200));
|
|
388
785
|
if (e && e.waitingOn) lines.push(`Waiting on: ${s(e.waitingOn)}`);
|
|
389
786
|
if (e && e.resolution) lines.push(`Resolution: ${clip(e.resolution, 600)}`);
|
|
787
|
+
if (task) lines.push(describeTaskRow(task, taskId));
|
|
788
|
+
|
|
789
|
+
const comments = taskId ? await taskComments(taskId, io, cache) : [];
|
|
790
|
+
|
|
390
791
|
return {
|
|
391
792
|
ok: true,
|
|
392
793
|
text: clip(lines.join("\n")),
|
|
393
794
|
subjectDetail: title,
|
|
394
795
|
threadId: id,
|
|
796
|
+
threadContext: renderThread(comments),
|
|
797
|
+
isReply: comments.length > 0,
|
|
395
798
|
from: { id: s(c.actor), name: s(c.actor) },
|
|
396
799
|
channelId: s(c.ids.channelId || (e && e.channelId) || ""),
|
|
397
800
|
channelLabel: `escalation/${title}`,
|
|
398
801
|
};
|
|
399
802
|
}
|
|
400
803
|
|
|
804
|
+
/** One line of board row: `Board item "Ship it" — doing · priority 1 · due …`. */
|
|
805
|
+
function describeTaskRow(task, taskId) {
|
|
806
|
+
const bits = [];
|
|
807
|
+
if (task.col) bits.push(s(task.col));
|
|
808
|
+
if (task.priority != null) bits.push(`priority ${s(task.priority)}`);
|
|
809
|
+
if (task.dueAt) bits.push(`due ${s(task.dueAt)}`);
|
|
810
|
+
const head = `Board item "${s(task.title) || s(taskId)}"`;
|
|
811
|
+
return bits.length ? `${head} — ${bits.join(" · ")}` : head;
|
|
812
|
+
}
|
|
813
|
+
|
|
401
814
|
/**
|
|
402
815
|
* A calendar event. The body came back on the ACL'd `calendar.list` read in the
|
|
403
816
|
* facts pass — that read IS the probe, so there is no second request here and
|
|
404
817
|
* nothing is rendered that hq did not already hand this seat.
|
|
405
818
|
*/
|
|
406
|
-
function hydrateCalendar(c, facts) {
|
|
819
|
+
async function hydrateCalendar(c, facts, io, cache) {
|
|
407
820
|
const id = s(c.ids.eventId);
|
|
408
821
|
const e = facts.myEvents instanceof Map ? facts.myEvents.get(id) : null;
|
|
409
822
|
const title = s((e && e.title) || c.ids.title || id);
|
|
@@ -413,17 +826,56 @@ function hydrateCalendar(c, facts) {
|
|
|
413
826
|
if (c.kind === "event.rsvp" && c.ids.rsvp) lines.push(`RSVP: ${s(c.ids.rsvp)}`);
|
|
414
827
|
if (e && e.location) lines.push(`Location: ${s(e.location)}`);
|
|
415
828
|
if (e && e.description) lines.push(clip(e.description, 1200));
|
|
829
|
+
|
|
830
|
+
const channelId = s(c.ids.channelId || (e && e.channelId) || "");
|
|
831
|
+
const room = await roomHistory(channelId, facts, io, cache);
|
|
832
|
+
const series = priorOccurrences(e || { id, title, startsAt }, facts);
|
|
833
|
+
|
|
416
834
|
return {
|
|
417
835
|
ok: true,
|
|
418
836
|
text: clip(lines.join("\n")),
|
|
419
837
|
subjectDetail: title,
|
|
420
838
|
threadId: id,
|
|
839
|
+
threadContext: joinContext([room, series]),
|
|
840
|
+
isReply: Boolean(room || series),
|
|
421
841
|
from: { id: s(c.actor), name: s(c.actor) },
|
|
422
|
-
channelId
|
|
842
|
+
channelId,
|
|
423
843
|
channelLabel: `calendar/${title}`,
|
|
424
844
|
};
|
|
425
845
|
}
|
|
426
846
|
|
|
847
|
+
/**
|
|
848
|
+
* Earlier occurrences of the same recurring meeting, rendered as one block.
|
|
849
|
+
*
|
|
850
|
+
* hq's Meeting DTO carries no series id — recurrence is not modelled on the
|
|
851
|
+
* wire — so the series is identified the way a person identifies it: same
|
|
852
|
+
* title, earlier start. That is a HEURISTIC and is deliberately kept cheap and
|
|
853
|
+
* local: it reads only `facts.myEvents`, which the ACL'd `calendar.list` in the
|
|
854
|
+
* facts pass already produced, so it costs no request and cannot see an event
|
|
855
|
+
* the lens floor withheld. A same-titled one-off is at worst one extra line of
|
|
856
|
+
* true context; it is never a read the agent was not entitled to.
|
|
857
|
+
*/
|
|
858
|
+
function priorOccurrences(event, facts) {
|
|
859
|
+
const all = facts && facts.myEvents instanceof Map ? Array.from(facts.myEvents.values()) : [];
|
|
860
|
+
if (all.length < 2) return null;
|
|
861
|
+
const title = s(event && event.title);
|
|
862
|
+
if (!title) return null;
|
|
863
|
+
const at = Date.parse(s(event && event.startsAt));
|
|
864
|
+
|
|
865
|
+
const earlier = all
|
|
866
|
+
.filter((m) => m && s(m.id) !== s(event && event.id) && s(m.title) === title)
|
|
867
|
+
.filter((m) => {
|
|
868
|
+
const t = Date.parse(s(m.startsAt));
|
|
869
|
+
return Number.isFinite(t) && (!Number.isFinite(at) || t < at);
|
|
870
|
+
})
|
|
871
|
+
.sort((a, b) => Date.parse(s(a.startsAt)) - Date.parse(s(b.startsAt)))
|
|
872
|
+
.slice(-CALENDAR_SERIES_TURNS);
|
|
873
|
+
|
|
874
|
+
if (earlier.length === 0) return null;
|
|
875
|
+
const rows = earlier.map((m) => `${s(m.startsAt)}${m.location ? ` · ${s(m.location)}` : ""}`);
|
|
876
|
+
return `Earlier in this series:\n${rows.join("\n")}`;
|
|
877
|
+
}
|
|
878
|
+
|
|
427
879
|
/** Human phrasing for a `calendar.*` event kind. */
|
|
428
880
|
export function describeCalendarKind(kind) {
|
|
429
881
|
switch (kind) {
|
|
@@ -436,11 +888,23 @@ export function describeCalendarKind(kind) {
|
|
|
436
888
|
}
|
|
437
889
|
}
|
|
438
890
|
|
|
439
|
-
/**
|
|
440
|
-
|
|
891
|
+
/**
|
|
892
|
+
* An approval, plus the thread of whatever it is an approval OF.
|
|
893
|
+
*
|
|
894
|
+
* An action class and a requester is not enough to decide anything: the
|
|
895
|
+
* approver needs the discussion that produced the request. hq's Approval row
|
|
896
|
+
* carries `itemId`, which is a board item most of the time and a decision the
|
|
897
|
+
* rest, and the row does not say which — so the board thread is tried first and
|
|
898
|
+
* the decision thread only when that read REFUSES (an empty board thread is an
|
|
899
|
+
* answer, and must not cost a second probe).
|
|
900
|
+
*/
|
|
901
|
+
async function hydrateApproval(c, facts, io, cache) {
|
|
441
902
|
const id = s(c.ids.approvalId);
|
|
442
903
|
const a = facts.approvals instanceof Map ? facts.approvals.get(id) : null;
|
|
443
904
|
const actionClass = s((a && a.actionClass) || c.ids.actionClass);
|
|
905
|
+
const itemId = s((a && a.itemId) || c.ids.taskId);
|
|
906
|
+
const task = itemId && facts.tasks instanceof Map ? facts.tasks.get(itemId) : null;
|
|
907
|
+
|
|
444
908
|
const lines = [`${describeApprovalKind(c.kind)} — ${actionClass || id}`];
|
|
445
909
|
if (a) {
|
|
446
910
|
lines.push(`Status: ${s(a.status)} · requester ${s(a.requester)}${a.approver ? ` · approver ${s(a.approver)}` : ""}`);
|
|
@@ -451,31 +915,68 @@ function hydrateApproval(c, facts) {
|
|
|
451
915
|
lines.push(`Subject: ${clip(typeof a.subject === "string" ? a.subject : JSON.stringify(a.subject), 600)}`);
|
|
452
916
|
}
|
|
453
917
|
}
|
|
918
|
+
if (task) lines.push(describeTaskRow(task, itemId));
|
|
919
|
+
|
|
920
|
+
const comments = itemId ? await anchoredItemComments(itemId, io, cache) : [];
|
|
921
|
+
|
|
454
922
|
return {
|
|
455
923
|
ok: true,
|
|
456
924
|
text: clip(lines.join("\n")),
|
|
457
925
|
subjectDetail: actionClass || id,
|
|
458
926
|
threadId: id,
|
|
927
|
+
threadContext: renderThread(comments),
|
|
928
|
+
isReply: comments.length > 0,
|
|
459
929
|
from: { id: s((a && a.requester) || c.actor), name: s((a && a.requester) || c.actor) },
|
|
460
930
|
channelId: "",
|
|
461
931
|
channelLabel: `approval/${actionClass || id}`,
|
|
462
932
|
};
|
|
463
933
|
}
|
|
464
934
|
|
|
465
|
-
/**
|
|
466
|
-
|
|
935
|
+
/**
|
|
936
|
+
* The comment thread of an item that is EITHER a board task or a decision.
|
|
937
|
+
* Board first; the decision read happens only when the board read refuses, so
|
|
938
|
+
* the common case costs exactly one probe.
|
|
939
|
+
*/
|
|
940
|
+
async function anchoredItemComments(itemId, io, cache) {
|
|
941
|
+
const id = s(itemId);
|
|
942
|
+
if (!id || !io) return [];
|
|
943
|
+
return cached(cache, `anchoredComments:${id}`, async () => {
|
|
944
|
+
const board = await io.call("board.taskComments", { taskId: id });
|
|
945
|
+
if (board && board.ok === true) return arrayOf(resultOf(board), "comments");
|
|
946
|
+
const decision = await io.call("decision.listComments", { decisionId: id });
|
|
947
|
+
if (decision && decision.ok === true) return arrayOf(resultOf(decision), "comments");
|
|
948
|
+
return [];
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* A handoff, plus the originating task's thread.
|
|
954
|
+
*
|
|
955
|
+
* The payload names both ends and an intent sentence, which tells the receiving
|
|
956
|
+
* agent what it is being asked to take but nothing about the state of the work.
|
|
957
|
+
* The originating task's comments are that state — and they are also where the
|
|
958
|
+
* handoff CHAIN is legible, because hq exposes no handoff-history read: every
|
|
959
|
+
* offer, accept and decline that mattered was narrated onto the item.
|
|
960
|
+
*/
|
|
961
|
+
async function hydrateHandoff(c, facts, io, cache) {
|
|
467
962
|
const taskId = s(c.ids.taskId);
|
|
468
963
|
const task = facts.tasks instanceof Map ? facts.tasks.get(taskId) : null;
|
|
469
964
|
const title = s((task && task.title) || taskId);
|
|
470
965
|
const lines = [`${describeHandoffKind(c.kind)}${title ? ` — "${title}"` : ""}`];
|
|
471
966
|
if (c.ids.intent) lines.push(`Intent: ${clip(c.ids.intent, 800)}`);
|
|
472
967
|
lines.push(`From ${s(c.ids.from)} → to ${s(c.ids.to)}`);
|
|
968
|
+
if (task) lines.push(describeTaskRow(task, taskId));
|
|
473
969
|
if (task && task.detail) lines.push(`Detail: ${clip(task.detail, 600)}`);
|
|
970
|
+
|
|
971
|
+
const comments = taskId ? await taskComments(taskId, io, cache) : [];
|
|
972
|
+
|
|
474
973
|
return {
|
|
475
974
|
ok: true,
|
|
476
975
|
text: clip(lines.join("\n")),
|
|
477
976
|
subjectDetail: title || s(c.ids.handoffId),
|
|
478
977
|
threadId: s(c.ids.runId || c.ids.handoffId),
|
|
978
|
+
threadContext: renderThread(comments),
|
|
979
|
+
isReply: comments.length > 0,
|
|
479
980
|
from: { id: s(c.ids.from || c.actor), name: s(c.ids.from || c.actor) },
|
|
480
981
|
channelId: "",
|
|
481
982
|
channelLabel: `handoff/${title || s(c.ids.handoffId)}`,
|
|
@@ -659,6 +1160,9 @@ export default {
|
|
|
659
1160
|
hydrate,
|
|
660
1161
|
clip,
|
|
661
1162
|
renderThread,
|
|
1163
|
+
priorComments,
|
|
1164
|
+
isCommentKind,
|
|
1165
|
+
htmlToText,
|
|
662
1166
|
describeBoardKind,
|
|
663
1167
|
describeDecisionKind,
|
|
664
1168
|
describeApprovalKind,
|