@agentpulselabs/cli-plugin-pulse 0.2.0 → 1.0.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.
Files changed (69) hide show
  1. package/README.md +69 -32
  2. package/package.json +10 -18
  3. package/src/commands/pulse/doctor.js +14 -0
  4. package/src/commands/pulse/hub-write.js +26 -0
  5. package/src/commands/pulse/install.js +14 -0
  6. package/src/commands/pulse/login.js +20 -0
  7. package/src/commands/pulse/mirror.js +27 -0
  8. package/src/commands/pulse/pair.js +38 -0
  9. package/src/commands/pulse/restart.js +14 -0
  10. package/src/commands/pulse/start.js +6 -18
  11. package/src/commands/pulse/status.js +6 -6
  12. package/src/commands/pulse/stop.js +24 -0
  13. package/src/daemon/main.js +36 -0
  14. package/src/lib/BaseCommand.js +23 -23
  15. package/src/lib/agentSessions.js +279 -0
  16. package/src/lib/application.js +656 -0
  17. package/src/lib/backoff.js +8 -0
  18. package/src/lib/budgets.js +85 -0
  19. package/src/lib/cliDefaults.js +20 -0
  20. package/src/lib/cloudAuth.js +189 -0
  21. package/src/lib/contract.sha256 +2 -0
  22. package/src/lib/daemonComposition.js +338 -0
  23. package/src/lib/daemonLock.js +55 -0
  24. package/src/lib/daemonLoop.js +69 -0
  25. package/src/lib/daemonProcess.js +366 -0
  26. package/src/lib/daemonRuntime.js +820 -0
  27. package/src/lib/drivers.js +280 -0
  28. package/src/lib/errors.js +19 -0
  29. package/src/lib/graphWindows.js +149 -0
  30. package/src/lib/http.js +84 -0
  31. package/src/lib/hubActions.js +74 -0
  32. package/src/lib/identity.js +102 -0
  33. package/src/lib/intents.js +590 -0
  34. package/src/lib/ledger.js +535 -0
  35. package/src/lib/lifecycle.js +123 -0
  36. package/src/lib/localHub.js +217 -0
  37. package/src/lib/localHubWriter.js +69 -0
  38. package/src/lib/mirror-contract.json +4299 -0
  39. package/src/lib/mirrorContract.js +513 -0
  40. package/src/lib/mirrorDaemon.js +127 -0
  41. package/src/lib/mirrorService.js +358 -0
  42. package/src/lib/mirrorSource.js +455 -0
  43. package/src/lib/mirrorSync.js +353 -0
  44. package/src/lib/oauthLogin.js +132 -0
  45. package/src/lib/ownerLock.js +99 -0
  46. package/src/lib/pairedConfig.js +40 -0
  47. package/src/lib/pairingLifecycle.js +179 -0
  48. package/src/lib/pathRedaction.js +89 -0
  49. package/src/lib/planContract.js +141 -0
  50. package/src/lib/priorityLane.js +80 -0
  51. package/src/lib/processOwnership.js +252 -0
  52. package/src/lib/processSupervisor.js +376 -0
  53. package/src/lib/redaction.js +128 -0
  54. package/src/lib/rootPolicy.js +372 -0
  55. package/src/lib/rulePacks.js +162 -0
  56. package/src/lib/secretStore.js +169 -0
  57. package/src/lib/selfRestart.js +261 -0
  58. package/src/lib/serviceManager.js +358 -0
  59. package/src/lib/storage.js +131 -0
  60. package/src/lib/subprocess.js +45 -0
  61. package/src/lib/tokenStore.js +97 -0
  62. package/src/lib/transport.js +286 -0
  63. package/src/lib/transportContract.js +37 -0
  64. package/LICENSE +0 -21
  65. package/src/commands/pulse/gate.js +0 -25
  66. package/src/commands/pulse/next.js +0 -14
  67. package/src/commands/pulse/submit.js +0 -21
  68. package/src/commands/pulse/verify.js +0 -21
  69. package/src/runPulse.js +0 -58
@@ -0,0 +1,455 @@
1
+ import { createHash } from 'node:crypto';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { GRAPH_HOURS as WINDOW_HOURS, mergeWindows, RowMemo, windowRows } from './graphWindows.js';
5
+ import { HubError } from './errors.js';
6
+ import { MIRROR_CONTRACT, redactSecretSubtree, sanitizeItem, secretKeyReplacement } from './mirrorContract.js';
7
+ import { createPathRedactor } from './pathRedaction.js';
8
+ import { redactSecrets } from './redaction.js';
9
+ import { readRulePacks } from './rulePacks.js';
10
+
11
+ export const MIRROR_SOURCES = Object.freeze(['deliveries', 'graph', 'channels', 'inbox', 'rules', 'windows', 'briefs']);
12
+ /**
13
+ * The window a `recent` read refreshes on every change: the one Pulse opens on. The others (each a whole hub collect,
14
+ * run one at a time in its worker) come from the last `windows` read.
15
+ */
16
+ export const RECENT_HOURS = Object.freeze([24]);
17
+
18
+ const SOURCE_KINDS = Object.freeze({
19
+ recent: ['graph_window'],
20
+ deliveries: ['delivery', 'stream', 'agent'],
21
+ graph: ['edge', 'bus'],
22
+ channels: ['channel', 'message'],
23
+ inbox: ['inbox'],
24
+ rules: ['rule_pack'],
25
+ windows: ['graph_window'],
26
+ briefs: ['brief'],
27
+ });
28
+ const BRIEF_READS_IN_FLIGHT = 4;
29
+ const GRAPH_HOURS = 168;
30
+ const SECRET_SEEN_MAX = 100_000;
31
+ const TASK_KEY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
32
+ const BRIEF = MIRROR_CONTRACT.kinds.brief.properties;
33
+ const longer = (text, max) => typeof text === 'string' && Array.from(text).length > max;
34
+
35
+ /** A redacted brief with `truncated` set when the contract limits will cut any of it, so a cut is never silent. */
36
+ function markTruncated(brief) {
37
+ const { maxItems, items } = BRIEF.sections;
38
+ const sections = Array.isArray(brief.sections) ? brief.sections : [];
39
+ const truncated = sections.length > maxItems || longer(brief.title, BRIEF.title.maxLength)
40
+ || sections.some((s) => longer(s?.heading, items.properties.heading.maxLength) || longer(s?.body, items.properties.body.maxLength));
41
+ return { ...brief, truncated };
42
+ }
43
+
44
+ /** Every task key in the windows, with its latest activity, so a brief is re-read only after its task moved. */
45
+ function taskKeys(windows) {
46
+ const keys = new Map();
47
+ for (const { body } of windows) {
48
+ for (const stream of (Array.isArray(body.deliveries) ? body.deliveries : []).flatMap((d) => (Array.isArray(d.streams) ? d.streams : []))) {
49
+ if (typeof stream.key !== 'string' || !TASK_KEY.test(stream.key)) continue;
50
+ keys.set(stream.key, Math.max(keys.get(stream.key) || 0, Number(stream.lastAt) || 0));
51
+ }
52
+ }
53
+ return keys;
54
+ }
55
+ const MAX_EXTERNAL_ID = 300;
56
+ const ACK_PRECEDENCE = ['expired', 'unacked', 'queued', 'delivered', 'acked'];
57
+ const MAX_MEMBERS = 20;
58
+
59
+ function ackState(statuses) {
60
+ const states = new Set((Array.isArray(statuses) ? statuses : []).map((status) => status?.state));
61
+ return ACK_PRECEDENCE.find((state) => states.has(state)) ?? null;
62
+ }
63
+
64
+ function busThreads(messages) {
65
+ const channels = new Map();
66
+ const items = [];
67
+ for (const message of messages) {
68
+ const topic = String(message.topic || '');
69
+ const pair = topic.startsWith('agent/') ? [String(message.from), topic.slice('agent/'.length)].sort() : null;
70
+ const id = pair ? `bus:${pair.join('|')}` : `bus:${topic}`;
71
+ const channel = channels.get(id) || { id, kind: 'bus', name: null, topic: pair ? null : topic, members: new Set(), lastAt: null };
72
+ for (const member of pair || [message.from, ...(Array.isArray(message.to) ? message.to : [])]) {
73
+ if (typeof member === 'string' && member && channel.members.size < MAX_MEMBERS) channel.members.add(member);
74
+ }
75
+ if (Number.isFinite(message.at) && !(channel.lastAt >= message.at)) channel.lastAt = message.at;
76
+ channels.set(id, channel);
77
+ const statuses = Array.isArray(message.status) ? message.status : [];
78
+ items.push({
79
+ channelId: id,
80
+ data: {
81
+ channelId: id,
82
+ id: message.id,
83
+ at: message.at,
84
+ from: message.from,
85
+ fromId: message.from ?? null,
86
+ fromName: message.fromName ?? null,
87
+ to: Array.isArray(message.to) ? message.to : [],
88
+ topic: topic || null,
89
+ type: message.type ?? null,
90
+ body: String(message.body ?? message.text ?? ''),
91
+ replyTo: message.replyTo ?? null,
92
+ via: statuses.find((status) => status?.via)?.via ?? null,
93
+ needsAck: typeof message.needsAck === 'boolean' ? message.needsAck : null,
94
+ ackState: ackState(statuses),
95
+ },
96
+ });
97
+ }
98
+ return {
99
+ channels: [...channels.values()].map((channel) => ({ ...channel, members: [...channel.members].sort() })),
100
+ messages: items,
101
+ };
102
+ }
103
+
104
+ function externalIdFor(value) {
105
+ if (value.length <= MAX_EXTERNAL_ID) return value;
106
+ return `${value.slice(0, 200)}~${createHash('sha256').update(value).digest('hex').slice(0, 32)}`;
107
+ }
108
+
109
+ function firstTime(...values) {
110
+ return values.find((value) => Number.isFinite(value) && value > 0);
111
+ }
112
+
113
+ function persona(value) {
114
+ if (!value || typeof value !== 'object') return null;
115
+ const out = {};
116
+ for (const key of ['name', 'first', 'username', 'seed']) if (value[key] !== undefined) out[key] = value[key];
117
+ return out;
118
+ }
119
+
120
+ function channelEntries(body) {
121
+ const projects = (body.projects || []).map((entry) => ({
122
+ id: entry.id, kind: 'project', name: entry.name ?? null, project: entry.project ?? null, lastAt: entry.lastAt ?? null,
123
+ }));
124
+ const tasks = (body.tasks || []).map((entry) => ({
125
+ id: entry.id, kind: 'task', name: entry.name ?? null, key: entry.key ?? null, repo: entry.repo ?? null,
126
+ project: entry.project ?? null, title: entry.title ?? null, lastAt: entry.lastAt ?? null,
127
+ }));
128
+ const dms = (body.dms || []).map((entry) => ({ id: `dm:${entry.id}`, kind: 'dm', name: entry.id, lastAt: entry.lastAt ?? null }));
129
+ return [...projects, ...tasks, ...dms].filter((entry) => typeof entry.id === 'string');
130
+ }
131
+
132
+ export class MirrorSource {
133
+ constructor({
134
+ reader,
135
+ workspace,
136
+ home = os.homedir(),
137
+ approvedRoots = [],
138
+ readRules = readRulePacks,
139
+ now = Date.now,
140
+ maxMessagesPerChannel = 200,
141
+ briefBudgetMs = 3_000,
142
+ heldSecrets = () => [],
143
+ }) {
144
+ this.heldSecrets = heldSecrets;
145
+ this.reader = reader;
146
+ this.workspace = workspace;
147
+ this.home = home;
148
+ this.approvedRoots = approvedRoots;
149
+ this.readRules = readRules;
150
+ this.now = now;
151
+ this.maxMessagesPerChannel = maxMessagesPerChannel;
152
+ this.briefBudgetMs = briefBudgetMs;
153
+ this.repoRoots = new Map(approvedRoots.map((root) => [root, path.basename(root)]));
154
+ this.windowCache = new Map();
155
+ this.rowMemo = new RowMemo();
156
+ this.worktreeRoots = new Map();
157
+ this.briefs = new Map();
158
+ this.taskKeys = null;
159
+ }
160
+
161
+ /**
162
+ * Briefs for these task keys: cached ones whose task has not moved, fresh reads for the rest, a few at a time and
163
+ * for at most briefBudgetMs so a slow hub never holds the next windows pass back. A brief not reached keeps its
164
+ * cached answer and is read on a later pass. A 404 is "no brief". True when every brief was answered.
165
+ */
166
+ async #readBriefs(keys, errors, absent, signal) {
167
+ let failed = false;
168
+ const next = new Map();
169
+ const stale = [];
170
+ for (const [key, lastAt] of keys) {
171
+ const cached = this.briefs.get(key);
172
+ if (cached && cached.lastAt === lastAt) next.set(key, cached);
173
+ else stale.push([key, lastAt, cached]);
174
+ }
175
+ const deadline = this.now() + this.briefBudgetMs;
176
+ const readOne = async ([key, lastAt, cached]) => {
177
+ try {
178
+ next.set(key, { lastAt, body: await this.reader.get('/api/task/brief', { key }) });
179
+ } catch (error) {
180
+ if (error.code === 'HUB_LOCAL_HUB_HTTP_404') {
181
+ next.set(key, { lastAt, body: null });
182
+ absent.briefs = (absent.briefs || 0) + 1;
183
+ return;
184
+ }
185
+ failed = true;
186
+ errors.push({ source: 'briefs', code: error.code || 'HUB_MIRROR_READ_FAILED' });
187
+ if (cached) next.set(key, cached);
188
+ }
189
+ };
190
+ let index = 0;
191
+ const lane = async () => {
192
+ while (index < stale.length && this.now() < deadline && !signal?.aborted) await readOne(stale[index++]);
193
+ };
194
+ await Promise.all(Array.from({ length: Math.min(BRIEF_READS_IN_FLIGHT, stale.length) }, lane));
195
+ for (const [key, , cached] of stale.slice(index)) if (cached) next.set(key, cached);
196
+ this.briefs = new Map([...keys.keys()].filter((key) => next.has(key)).map((key) => [key, next.get(key)]));
197
+ return !failed && index === stale.length;
198
+ }
199
+
200
+ #learnRepos(deliveries) {
201
+ for (const stream of deliveries.flatMap((delivery) => delivery.streams || [])) {
202
+ const git = stream.git || {};
203
+ if (typeof git.mainRepo === 'string' && path.isAbsolute(git.mainRepo)) this.repoRoots.set(git.mainRepo, path.basename(git.mainRepo));
204
+ for (const extra of [git.worktreePath, git.channelCheckout]) {
205
+ if (typeof extra === 'string' && path.isAbsolute(extra) && !this.repoRoots.has(extra)) this.worktreeRoots.set(extra, path.basename(extra));
206
+ }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Secrets first, then paths, on every string and key: a secret inside a path is never cut down to a last segment
212
+ * that still holds it, and nothing is projected, truncated or hashed before both have run.
213
+ */
214
+ #redactor(secrets) {
215
+ const repos = [...this.repoRoots, ...this.worktreeRoots].map(([root, name]) => ({ root, name }));
216
+ const paths = createPathRedactor({ repos, workspace: this.workspace, home: this.home });
217
+ const seen = new Map();
218
+ const redactString = (input) => {
219
+ const original = String(input ?? '');
220
+ let text = seen.get(original);
221
+ if (text === undefined) {
222
+ text = paths.redactString(redactSecrets(original, { secrets }).text);
223
+ if (seen.size >= SECRET_SEEN_MAX) seen.clear();
224
+ seen.set(original, text);
225
+ }
226
+ return text;
227
+ };
228
+ const entry = (key, item) => secretKeyReplacement(key, item) ?? redactSecretSubtree(key, item, redactString);
229
+ const redactValue = (value, { keep = [] } = {}) => paths.redactValue(value, { keep, redactString, entry });
230
+ return { redactString, redactValue };
231
+ }
232
+
233
+ async collect({ full = false, sources = new Set(MIRROR_SOURCES), channelLastAt = new Map(), signal } = {}) {
234
+ const wanted = full ? new Set(MIRROR_SOURCES) : sources;
235
+ const checkpoint = () => {
236
+ if (signal?.aborted) throw new HubError('HUB_MIRROR_PREEMPTED', 'A change preempted this mirror pass');
237
+ };
238
+ const raw = [];
239
+ const complete = new Set();
240
+ const errors = [];
241
+ // Optional per-item reads that answered 404: the item does not exist, which never makes a pass incomplete.
242
+ const absent = {};
243
+ const withheld = [];
244
+ const nextChannelLastAt = new Map(channelLastAt);
245
+ const refetchedChannels = [];
246
+ let listedChannels = null;
247
+ const read = async (source, run) => {
248
+ checkpoint();
249
+ try {
250
+ await run();
251
+ for (const kind of SOURCE_KINDS[source]) complete.add(kind);
252
+ } catch (error) {
253
+ if (error.code === 'HUB_MIRROR_PREEMPTED') throw error;
254
+ errors.push({ source, code: error.code || 'HUB_MIRROR_READ_FAILED' });
255
+ }
256
+ };
257
+ const fallback = this.now();
258
+ const pushDeliveries = (body) => {
259
+ const deliveries = Array.isArray(body.deliveries) ? body.deliveries : [];
260
+ this.#learnRepos(deliveries);
261
+ if (!wanted.has('deliveries')) return;
262
+ for (const delivery of deliveries) {
263
+ raw.push({ kind: 'delivery', externalId: delivery.id, updatedAt: firstTime(delivery.updated_at, delivery.lastAt, fallback), data: delivery });
264
+ for (const stream of delivery.streams || []) {
265
+ raw.push({
266
+ kind: 'stream',
267
+ externalId: stream.key,
268
+ updatedAt: firstTime(stream.lastAt, fallback),
269
+ data: { ...stream, deliveryId: delivery.id, branch: stream.git?.branch ?? null, isWorktree: stream.git?.isWorktree ?? null },
270
+ });
271
+ }
272
+ }
273
+ for (const agent of Array.isArray(body.agents) ? body.agents : []) {
274
+ const data = agent.persona === undefined ? agent : { ...agent, persona: persona(agent.persona) };
275
+ raw.push({ kind: 'agent', externalId: agent.id, updatedAt: firstTime(agent.updated_at, agent.lastAt, fallback), data });
276
+ }
277
+ };
278
+ const readDeliveries = async () => {
279
+ await read('deliveries', async () => pushDeliveries(await this.reader.get('/api/deliveries')));
280
+ if (!wanted.has('deliveries')) for (const kind of SOURCE_KINDS.deliveries) complete.delete(kind);
281
+ };
282
+
283
+ // The hub's /api/deliveries is the window it collected last (HUB-PERF-01), so a full pass, which tombstones, takes
284
+ // them from its own 168 h read instead: the widest set, which every narrower answer is inside.
285
+ if (!full && (wanted.has('deliveries') || wanted.has('rules'))) await readDeliveries();
286
+
287
+ // `recent` reads the short windows and takes the others as last read, rows already redacted and checked.
288
+ const windows = [];
289
+ const freshHours = wanted.has('windows')
290
+ ? WINDOW_HOURS
291
+ : wanted.has('recent') ? WINDOW_HOURS.filter((hours) => RECENT_HOURS.includes(hours) || !this.windowCache.has(hours)) : [];
292
+ if (freshHours.length) {
293
+ await read(wanted.has('windows') ? 'windows' : 'recent', async () => {
294
+ const bodies = await Promise.all(freshHours.map((hours) => this.reader.get('/api/graph', { hours })));
295
+ const fresh = new Map(freshHours.map((hours, index) => [hours, bodies[index]]));
296
+ for (const hours of WINDOW_HOURS) {
297
+ const body = fresh.get(hours);
298
+ if (body) this.#learnRepos(Array.isArray(body.deliveries) ? body.deliveries : []);
299
+ windows.push({ hours, body: body ?? this.windowCache.get(hours).body, fresh: Boolean(body) });
300
+ }
301
+ });
302
+ if (windows.length === WINDOW_HOURS.length) this.taskKeys = taskKeys(windows);
303
+ }
304
+
305
+ if (full) {
306
+ const widest = windows.find(({ hours }) => hours === GRAPH_HOURS)?.body;
307
+ if (widest) await read('deliveries', async () => pushDeliveries(widest));
308
+ else {
309
+ await readDeliveries();
310
+ for (const kind of SOURCE_KINDS.deliveries) complete.delete(kind);
311
+ }
312
+ }
313
+
314
+ if (wanted.has('graph')) {
315
+ await read('graph', async () => {
316
+ const body = windows.find(({ hours }) => hours === GRAPH_HOURS)?.body
317
+ ?? await this.reader.get('/api/graph', { hours: GRAPH_HOURS });
318
+ for (const link of Array.isArray(body.links) ? body.links : []) {
319
+ raw.push({
320
+ kind: 'edge',
321
+ externalId: `${link.source}>${link.target}:${link.kind}`,
322
+ updatedAt: firstTime(body.generatedAt, fallback),
323
+ data: link,
324
+ });
325
+ }
326
+ const bus = busThreads(Array.isArray(body.bus?.messages) ? body.bus.messages : []);
327
+ for (const channel of bus.channels) {
328
+ raw.push({ kind: 'channel', externalId: channel.id, updatedAt: firstTime(channel.lastAt, fallback), data: channel });
329
+ }
330
+ for (const message of bus.messages) {
331
+ raw.push({
332
+ kind: 'message',
333
+ externalId: externalIdFor(`${message.channelId}#${message.data.id}`),
334
+ updatedAt: firstTime(message.data.at, fallback),
335
+ data: message.data,
336
+ });
337
+ }
338
+ });
339
+ }
340
+
341
+ if (wanted.has('channels')) {
342
+ await read('channels', async () => {
343
+ const entries = channelEntries(await this.reader.get('/api/chat/channels'));
344
+ for (const entry of entries) {
345
+ raw.push({ kind: 'channel', externalId: entry.id, updatedAt: firstTime(entry.lastAt, fallback), data: entry });
346
+ }
347
+ const listed = new Set(entries.map((entry) => entry.id));
348
+ for (const id of nextChannelLastAt.keys()) if (!listed.has(id)) nextChannelLastAt.delete(id);
349
+ for (const entry of entries) {
350
+ if (channelLastAt.has(entry.id) && channelLastAt.get(entry.id) === entry.lastAt) continue;
351
+ checkpoint();
352
+ let body;
353
+ try {
354
+ body = await this.reader.get('/api/chat/messages', { ch: entry.id });
355
+ } catch (error) {
356
+ // A listed channel the hub has no messages for (a DM whose agent is gone) is empty, not a failed read.
357
+ if (error.code !== 'HUB_LOCAL_HUB_HTTP_404') {
358
+ errors.push({ source: 'messages', code: error.code || 'HUB_MIRROR_READ_FAILED' });
359
+ continue;
360
+ }
361
+ absent.messages = (absent.messages || 0) + 1;
362
+ body = { messages: [] };
363
+ }
364
+ const messages = (Array.isArray(body.messages) ? body.messages : []).slice(-this.maxMessagesPerChannel);
365
+ for (const message of messages) {
366
+ raw.push({
367
+ kind: 'message',
368
+ externalId: externalIdFor(`${entry.id}#${message.id}`),
369
+ updatedAt: firstTime(message.at, fallback),
370
+ data: { ...message, channelId: entry.id, body: String(message.body ?? ''), ackState: message.delivery?.busState ?? null },
371
+ });
372
+ }
373
+ nextChannelLastAt.set(entry.id, entry.lastAt);
374
+ refetchedChannels.push(entry.id);
375
+ }
376
+ listedChannels = entries.map((entry) => entry.id);
377
+ });
378
+ if (listedChannels && refetchedChannels.length !== listedChannels.length) complete.delete('message');
379
+ }
380
+
381
+ if (wanted.has('inbox')) {
382
+ await read('inbox', async () => {
383
+ const body = await this.reader.get('/api/inbox');
384
+ for (const message of Array.isArray(body.messages) ? body.messages : []) {
385
+ raw.push({ kind: 'inbox', externalId: message.id, updatedAt: firstTime(message.at, fallback), data: { ...message, body: String(message.body ?? '') } });
386
+ }
387
+ });
388
+ }
389
+
390
+ // Briefs are read in a pass of their own after a windows pass, so the windows never wait on them; the task keys
391
+ // and their activity come from the last complete windows read.
392
+ let briefsPending = false;
393
+ if (wanted.has('briefs') && this.taskKeys) {
394
+ const failures = errors.length;
395
+ const answered = await this.#readBriefs(this.taskKeys, errors, absent, signal);
396
+ checkpoint();
397
+ if (answered) complete.add('brief');
398
+ else briefsPending = errors.length === failures;
399
+ for (const [key, { lastAt, body }] of this.briefs) {
400
+ if (!body || !Array.isArray(body.sections) || !body.sections.length) continue;
401
+ raw.push({ kind: 'brief', externalId: key, updatedAt: firstTime(lastAt, fallback), data: { ...body, key } });
402
+ }
403
+ }
404
+
405
+ if (wanted.has('rules')) {
406
+ await read('rules', async () => {
407
+ const repos = [...this.repoRoots].map(([root, name]) => ({ root, name }));
408
+ const { packs } = await this.readRules({ workspace: this.workspace, home: this.home, repos });
409
+ for (const pack of packs) raw.push({ kind: 'rule_pack', externalId: pack.id, updatedAt: fallback, data: pack });
410
+ });
411
+ }
412
+
413
+ checkpoint();
414
+ const secrets = [await this.reader.tokenValue().catch(() => null), ...this.heldSecrets()].filter(Boolean);
415
+ const redactor = this.#redactor(secrets);
416
+ const items = new Map();
417
+ for (const entry of raw) {
418
+ if (typeof entry.externalId !== 'string' || !entry.externalId) continue;
419
+ const externalId = externalIdFor(entry.externalId);
420
+ const redacted = redactor.redactValue(entry.data, { keep: entry.kind === 'rule_pack' ? ['file'] : [] });
421
+ const result = sanitizeItem(entry.kind, entry.kind === 'brief' ? markTruncated(redacted) : redacted, { secrets });
422
+ if (result.error) {
423
+ withheld.push({ kind: entry.kind, externalId, error: result.error });
424
+ continue;
425
+ }
426
+ items.set(`${entry.kind}\0${externalId}`, { kind: entry.kind, externalId, updatedAt: entry.updatedAt, data: result.data });
427
+ }
428
+
429
+ // Rows are shared across windows, so a pass that missed a window uploads none of them; the last counts stand.
430
+ const graphDropped = {};
431
+ if (windows.length === WINDOW_HOURS.length) {
432
+ // A row's redacted, checked form depends on the roots the redactor knows and the secrets, and on nothing else.
433
+ this.rowMemo.begin(JSON.stringify([[...this.repoRoots], [...this.worktreeRoots], this.workspace, this.home, secrets]));
434
+ const options = { redact: (value) => redactor.redactValue(value), secrets, memo: this.rowMemo };
435
+ const built = mergeWindows(windows.map(({ hours, body, fresh }) => {
436
+ if (!fresh) return this.windowCache.get(hours).rows;
437
+ const rows = windowRows(hours, body, options);
438
+ this.windowCache.set(hours, { body, rows });
439
+ return rows;
440
+ }));
441
+ Object.assign(graphDropped, built.dropped);
442
+ const updatedAt = firstTime(Math.max(...windows.map(({ body }) => Number(body.generatedAt) || 0)), fallback);
443
+ for (const row of built.items) {
444
+ const externalId = externalIdFor(row.externalId);
445
+ const result = this.rowMemo.take(`item\0${JSON.stringify(row.data)}`, () => sanitizeItem('graph_window', row.data, { secrets }));
446
+ if (result.error) {
447
+ withheld.push({ kind: 'graph_window', externalId, error: result.error });
448
+ continue;
449
+ }
450
+ items.set(`graph_window\0${externalId}`, { kind: 'graph_window', externalId, updatedAt, data: result.data });
451
+ }
452
+ }
453
+ return { items, complete, errors, absent, withheld, graphDropped, channelLastAt: nextChannelLastAt, refetchedChannels, listedChannels, briefsPending };
454
+ }
455
+ }