@agentpulselabs/cli-plugin-pulse 1.1.3 → 1.1.6

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.
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { channelFiles, normRole, parsePosts } from '../hub/lib/channels.mjs';
4
4
  import { isTerminal } from '../hub/lib/closure.mjs';
5
+ import { channelFilesAsync, readPostsAsync } from './channelWalk.js';
5
6
 
6
7
  /**
7
8
  * Pulse 1.1.2: the delivery-discipline rules as checks in the runner, from the Cursor hook and a sweep.
@@ -129,6 +130,9 @@ function readPosts(file) {
129
130
  } catch { return null; } finally { if (fd !== undefined) fs.closeSync(fd); }
130
131
  }
131
132
 
133
+ /** What E3 needs of a channel: its newest post, how many posts it has and its mtime; null when it can't be read. */
134
+ const staleState = (read) => (read ? { last: read.posts[read.posts.length - 1] || null, count: read.posts.length, mtime: read.mtime } : null);
135
+
132
136
  const keyOf = (file) => {
133
137
  const m = file.match(new RegExp(`(?:^|/)(${NAME})/\\.agents/(${NAME})/channel\\.md$`));
134
138
  return m ? `${m[1]}/${m[2]}` : null;
@@ -352,49 +356,79 @@ export function createChannelDiscipline(hub, {
352
356
  }
353
357
 
354
358
  /** E3's owner of a channel's newest post: the session that wrote it, else the agent of that stream and role, else its architect. */
355
- function ownerOf(key, last) {
359
+ function ownerOf(key, last, directory) {
356
360
  const wrote = q.author.get(key, last.role, last.head);
357
361
  if (wrote) return wrote.session;
358
- const dir = registry.directory();
362
+ const dir = directory();
359
363
  const crew = dir.filter((a) => a.streamKey === key && normRole(a.role) === last.role).sort((a, b) => (b.seenAt || 0) - (a.seenAt || 0));
360
364
  if (crew.length) return crew[0].id;
361
365
  const arch = dir.find((a) => a.isArchitect && (a.streams || []).includes(key));
362
366
  return last.role === 'ARCHITECT' && arch ? arch.id : null;
363
367
  }
364
368
 
365
- function architectOf(key, owner) {
366
- const dir = registry.directory();
369
+ function architectOf(key, owner, directory) {
370
+ const dir = directory();
367
371
  const a = owner ? dir.find((d) => d.id === owner) : null;
368
372
  if (a && a.deliveryId) return a.deliveryId;
369
373
  const arch = dir.find((d) => d.isArchitect && (d.streams || []).includes(key));
370
374
  return arch ? arch.deliveryId || arch.id : null;
371
375
  }
372
376
 
377
+ /** E3's view of every channel written in the last STALE_WATCH_MS, read now: `[{ key, file, state }]`. */
378
+ const channelStates = (now) => channelFiles(channelRoots, now - STALE_WATCH_MS).map((c) => ({ key: c.key, file: c.file, state: staleState(readPosts(c.file)) }));
379
+
380
+ /** What `scan` last read of each channel, by its mtime and size: an unchanged file is not read again. */
381
+ const marks = new Map();
382
+
383
+ /**
384
+ * The same view as the sweep's own read, without blocking the event loop or holding a transaction: the walk and the
385
+ * reads are asynchronous, and only channels whose mtime or size moved since the last scan are read and parsed.
386
+ */
387
+ async function scan(now = clock()) {
388
+ const out = [];
389
+ const live = new Set();
390
+ for (const c of await channelFilesAsync(channelRoots, now - STALE_WATCH_MS)) {
391
+ live.add(c.file);
392
+ const mark = marks.get(c.file);
393
+ let state = mark && mark.mtime === c.mtime && mark.size === c.size ? mark.state : undefined;
394
+ if (state === undefined) {
395
+ state = staleState(await readPostsAsync(c.file, CHANNEL_MAX_BYTES));
396
+ if (state) marks.set(c.file, { mtime: c.mtime, size: c.size, state });
397
+ else marks.delete(c.file);
398
+ }
399
+ out.push({ key: c.key, file: c.file, state });
400
+ }
401
+ for (const file of marks.keys()) if (!live.has(file)) marks.delete(file);
402
+ return out;
403
+ }
404
+
373
405
  /**
374
406
  * E3, from the lease holder's sweep: each channel written in the last STALE_WATCH_MS whose newest post is not
375
407
  * terminal. Its age counts from when this runner first saw that post (never before the runner started), not from
376
- * the header, whose zone and date are unknown. Returns the messages it sent.
408
+ * the header, whose zone and date are unknown. `seen` is a `scan` taken before the sweep's transaction; without it
409
+ * the channels are read here. Returns the messages it sent.
377
410
  */
378
- function sweep(now = clock()) {
411
+ function sweep({ now = clock(), seen = null } = {}) {
379
412
  const sent = [];
380
413
  const live = new Set();
381
- for (const c of channelFiles(channelRoots, now - STALE_WATCH_MS)) {
382
- const read = readPosts(c.file);
383
- const last = read && read.posts[read.posts.length - 1];
414
+ let dir = null;
415
+ const directory = () => (dir ??= registry.directory());
416
+ for (const c of seen ?? channelStates(now)) {
417
+ const last = c.state?.last;
384
418
  if (!last) continue;
385
419
  live.add(c.key);
386
420
  if (isTerminal(last)) { q.staleDrop.run(c.key); continue; }
387
- const sig = `${last.role}\0${last.head}\0${read.posts.length}`;
421
+ const sig = `${last.role}\0${last.head}\0${c.state.count}`;
388
422
  let row = q.stale.get(c.key);
389
423
  if (!row || row.sig !== sig) {
390
- q.staleSet.run(c.key, c.file, sig, Math.max(started, Math.min(now, read.mtime)));
424
+ q.staleSet.run(c.key, c.file, sig, Math.max(started, Math.min(now, c.state.mtime)));
391
425
  row = q.stale.get(c.key);
392
426
  }
393
427
  const age = now - row.first_seen;
394
428
  const quiet = `${Math.floor(age / 60e3)} min`;
395
429
  const lastLine = `\`## [${last.role}] … ${last.head.slice(0, 120)}\``;
396
430
  if (age >= staleAskMs && !row.asked_at) {
397
- const owner = ownerOf(c.key, last);
431
+ const owner = ownerOf(c.key, last, directory);
398
432
  q.staleAsked.run(now, owner, c.key);
399
433
  row.owner = owner;
400
434
  if (owner) {
@@ -410,7 +444,7 @@ export function createChannelDiscipline(hub, {
410
444
  if (age >= staleEscalateMs && !row.escalated_at) {
411
445
  q.staleEscalated.run(now, c.key);
412
446
  const body = `\`${c.key}\` has had no post for ${quiet}; its last post ${lastLine} is not terminal.${row.owner ? ` STATUS? went to \`${row.owner}\` with no post since.` : ' Pulse found no owner to ask.'}`;
413
- const architect = architectOf(c.key, row.owner);
447
+ const architect = architectOf(c.key, row.owner, directory);
414
448
  for (const topic of [...(architect && architect !== row.owner ? [`delivery/${architect}`] : []), 'user']) {
415
449
  const env = publish({ from: 'manager', fromName: 'Pulse', topic, type: 'STALE', body, meta: { stale: c.key } });
416
450
  if (env && !env.error) sent.push({ key: c.key, to: topic, type: 'STALE' });
@@ -421,5 +455,5 @@ export function createChannelDiscipline(hub, {
421
455
  return sent;
422
456
  }
423
457
 
424
- return { onHook, sweep };
458
+ return { onHook, scan, sweep };
425
459
  }
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readTaskBrief } from '../hub/lib/brief.mjs';
4
+ import { findAgentQuery } from '../hub/lib/transcripts.mjs';
5
+
6
+ const BRIEF_CACHE_MS = 30e3;
7
+ const BRIEF_CACHE_MAX = 200;
8
+ const briefCache = new Map();
9
+
10
+ /** A task channel's file under one of the roots, or null; a path that leaves its root through a link is refused. */
11
+ export function channelFileFor(key, roots) {
12
+ const m = String(key || '').match(/^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/);
13
+ if (!m || m[1].startsWith('.') || m[2].startsWith('.')) return null;
14
+ for (const root of roots) {
15
+ const file = path.join(root, m[1], '.agents', m[2], 'channel.md');
16
+ try {
17
+ const st = fs.lstatSync(file);
18
+ if (!st.isFile()) continue;
19
+ const realRoot = fs.realpathSync(root);
20
+ if (fs.realpathSync(file) !== path.join(realRoot, m[1], '.agents', m[2], 'channel.md')) continue;
21
+ return file;
22
+ } catch { /* not here */ }
23
+ }
24
+ return null;
25
+ }
26
+
27
+ /** A task's brief: brief.md and the spec, else the longest opening prompt that names the channel (a transcript scan). */
28
+ export function taskBriefFor(key, roots) {
29
+ const hit = briefCache.get(key);
30
+ if (hit && Date.now() - hit.at < BRIEF_CACHE_MS) return hit.body;
31
+ const file = channelFileFor(key, roots);
32
+ if (!file) return null;
33
+ const fromFiles = readTaskBrief(file);
34
+ const thin = !fromFiles || !fromFiles.sections.some((s) => s.body && s.body.trim());
35
+ const body = thin ? (readTaskBrief(file, { query: findAgentQuery(key) }) || fromFiles || { title: null, source: null, sections: [] }) : fromFiles;
36
+ if (briefCache.size > BRIEF_CACHE_MAX) briefCache.clear();
37
+ briefCache.set(key, { at: Date.now(), body });
38
+ return body;
39
+ }
@@ -0,0 +1,42 @@
1
+ import { constants } from 'node:fs';
2
+ import { open, readdir, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { parsePosts } from '../hub/lib/channels.mjs';
5
+
6
+ /**
7
+ * hub/lib/channels.mjs `channelFiles` without blocking the event loop: every roots/<repo>/.agents/<task>/channel.md
8
+ * changed since sinceMs, in the same order, with its size as well, so a caller can tell an unchanged file by its
9
+ * mtime and size without reading it.
10
+ */
11
+ export async function channelFilesAsync(roots, sinceMs) {
12
+ const out = [];
13
+ for (const root of roots) {
14
+ let repos = [];
15
+ try { repos = await readdir(root); } catch { continue; }
16
+ for (const repo of repos) {
17
+ const dir = path.join(root, repo, '.agents');
18
+ let tasks = [];
19
+ try { tasks = await readdir(dir); } catch { continue; }
20
+ const found = await Promise.all(tasks.map(async (task) => {
21
+ const file = path.join(dir, task, 'channel.md');
22
+ try {
23
+ const st = await stat(file);
24
+ return st.isFile() && st.mtimeMs >= sinceMs ? { key: `${repo}/${task}`, task, repo, file, mtime: st.mtimeMs, size: st.size } : null;
25
+ } catch { return null; }
26
+ }));
27
+ out.push(...found.filter(Boolean));
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /** A channel's posts, read without following a symlink: `{ posts, mtime }`, or null when it can't be read or is over maxBytes. */
34
+ export async function readPostsAsync(file, maxBytes) {
35
+ let handle;
36
+ try {
37
+ handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
38
+ const st = await handle.stat();
39
+ if (!st.isFile() || st.size > maxBytes) return null;
40
+ return { posts: parsePosts(await handle.readFile('utf8'), st.mtimeMs), mtime: st.mtimeMs };
41
+ } catch { return null; } finally { await handle?.close(); }
42
+ }
@@ -0,0 +1,92 @@
1
+ // The runner's discovery worker: the hub's lib/collect-worker.mjs, except that once a pass is saved a graph read never
2
+ // waits for the next one. A read answers from the rows the last pass saved and, when its epoch is newer or that pass is
3
+ // older than DISCOVERY_REUSE_MS, asks for a new pass, which runs and writes its rows after the reads that came with it.
4
+ // The runner's first read still waits for the first pass. `{ type: 'discovered' }` tells the facade a pass was saved.
5
+ import path from 'node:path';
6
+ import { parentPort } from 'node:worker_threads';
7
+ import { openHub } from '../hub/core/hub.mjs';
8
+ import { hubHome, runnerDb } from '../hub/home.mjs';
9
+ import { collect } from '../hub/lib/collect.mjs';
10
+ import { projectGraph } from '../hub/projections/graph.mjs';
11
+ import { CANONICAL_HOURS, DISCOVERY_REUSE_MS, persistDiscovery } from '../hub/workers/collector.mjs';
12
+
13
+ /** A pass starts once reads have stopped this long, so a mirror pass's window reads are all answered before it. */
14
+ const SETTLE_MS = 100;
15
+ /** Reads that never stop for SETTLE_MS still let a wanted pass start this soon after it was first wanted. */
16
+ const SETTLE_MAX_MS = 2000;
17
+
18
+ const hub = openHub(path.join(hubHome(), 'state'), runnerDb());
19
+ let saved = null;
20
+ let wanted = null;
21
+ let timer = null;
22
+ let wantedSince = null;
23
+
24
+ const discover = (epoch, aliases) => {
25
+ persistDiscovery(hub, collect({ hours: CANONICAL_HOURS, runs: hub.registry.facts('run'), aliases: aliases || [] }));
26
+ saved = { epoch, at: Date.now() };
27
+ };
28
+ const stale = (epoch) => !saved || epoch == null || saved.epoch !== epoch || Date.now() - saved.at >= DISCOVERY_REUSE_MS;
29
+
30
+ const passSoon = () => {
31
+ clearTimeout(timer);
32
+ wantedSince ??= Date.now();
33
+ timer = setTimeout(() => {
34
+ timer = null;
35
+ wantedSince = null;
36
+ const next = wanted;
37
+ wanted = null;
38
+ if (!next || !stale(next.epoch)) return;
39
+ try {
40
+ discover(next.epoch, next.aliases);
41
+ parentPort.postMessage({ type: 'discovered' });
42
+ } catch (e) {
43
+ console.error(`discovery failed: ${(e && e.stack) || e}`);
44
+ }
45
+ }, Math.min(SETTLE_MS, Math.max(0, wantedSince + SETTLE_MAX_MS - Date.now())));
46
+ };
47
+
48
+ /** Registry reads the windows of one burst all make: read once per burst; the projections copy what they change. */
49
+ const SHARED_READS = ['all', 'get', 'presence', 'presences', 'fact', 'facts', 'directory', 'registrations', 'eventsSince'];
50
+ function burstView() {
51
+ const memo = new Map();
52
+ const registry = Object.create(hub.registry);
53
+ for (const name of SHARED_READS) {
54
+ const read = hub.registry[name];
55
+ registry[name] = (...args) => {
56
+ const key = `${name}\0${JSON.stringify(args)}`;
57
+ if (!memo.has(key)) memo.set(key, read.apply(hub.registry, args));
58
+ return memo.get(key);
59
+ };
60
+ }
61
+ return { ...hub, registry };
62
+ }
63
+
64
+ /**
65
+ * Reads that queued while the worker was busy (a mirror pass reads every window at once) are projected together, at
66
+ * one clock and over one set of registry reads.
67
+ */
68
+ let burst = [];
69
+ function project() {
70
+ const reads = burst;
71
+ burst = [];
72
+ const now = Date.now();
73
+ let view = saved ? burstView() : null;
74
+ for (const { id, hours, managerView, aliases, discoveryEpoch } of reads) {
75
+ try {
76
+ if (!saved) { discover(discoveryEpoch, aliases); view = burstView(); }
77
+ else if (stale(discoveryEpoch)) wanted = { epoch: discoveryEpoch, aliases };
78
+ // As JSON text the body crosses to the main thread as one string, not an object graph it must clone and stringify.
79
+ parentPort.postMessage({ id, text: JSON.stringify(projectGraph(view, { hours, managerView: managerView || {}, now })) });
80
+ } catch (e) {
81
+ parentPort.postMessage({ id, error: String((e && e.stack) || e) });
82
+ }
83
+ }
84
+ // Armed only now: a timer that came due while this burst projected would run before the reads queued behind it.
85
+ if (wanted) passSoon();
86
+ }
87
+
88
+ parentPort.on('message', (read) => {
89
+ burst.push(read);
90
+ if (burst.length === 1) setImmediate(project);
91
+ if (timer) passSoon();
92
+ });
@@ -134,18 +134,32 @@ export function createDeliveryStates(hub, bus, { now: clock = Date.now } = {}) {
134
134
  * waited BOUNCE_GRACE_MS for a recipient nobody can read: its sender gets one UNDELIVERABLE notice per recipient,
135
135
  * from the manager, with an id derived from the message and recipient. A notice is never itself bounced.
136
136
  */
137
- function bounce(now = clock()) {
138
- const rows = db.prepare(`SELECT d.message_id AS id, d.recipient, m.sender, m.type, m.body FROM deliveries d JOIN messages m ON m.id = d.message_id
137
+ const BOUNCE_FROM = `FROM deliveries d JOIN messages m ON m.id = d.message_id
139
138
  WHERE d.state = 'queued' AND m.expires_at > ? AND m.created_at <= ? AND m.created_at > ?
140
139
  AND m.sender <> 'manager' AND m.type <> 'UNDELIVERABLE' AND d.recipient NOT IN ('user', 'manager')
141
140
  AND NOT EXISTS (SELECT 1 FROM pulse_reported r WHERE r.message_id = m.id)
142
- AND NOT EXISTS (SELECT 1 FROM pulse_bounced b WHERE b.message_id = d.message_id AND b.recipient = d.recipient)
143
- ORDER BY m.created_at LIMIT ?`).all(now, now - BOUNCE_GRACE_MS, now - DEFAULT_TTL_MS, BOUNCE_BATCH);
141
+ AND NOT EXISTS (SELECT 1 FROM pulse_bounced b WHERE b.message_id = d.message_id AND b.recipient = d.recipient)`;
142
+ const bounceRows = db.prepare(`SELECT d.message_id AS id, d.recipient, m.sender, m.type, m.body ${BOUNCE_FROM} ORDER BY m.created_at LIMIT ?`);
143
+ const stillBounceable = db.prepare(`SELECT 1 AS found ${BOUNCE_FROM} AND d.message_id = ? AND d.recipient = ?`);
144
+ const bounceWindow = (now) => [now, now - BOUNCE_GRACE_MS, now - DEFAULT_TTL_MS];
145
+
146
+ function bounce(now = clock()) {
144
147
  const sent = [];
148
+ for (const step of bounceSteps({ now, sent })) step();
149
+ return sent;
150
+ }
151
+
152
+ /**
153
+ * The bounce as one step per candidate row, for capture's chunks: the candidates are read up front, and each step
154
+ * checks its row is still queued and not yet bounced or reported in the transaction it runs in. Pushes to `sent`.
155
+ */
156
+ function bounceSteps({ now = clock(), sent = [] } = {}) {
157
+ const rows = bounceRows.all(...bounceWindow(now), BOUNCE_BATCH);
145
158
  let dir = null;
146
- for (const r of rows) {
159
+ return rows.map((r) => () => {
160
+ if (!stillBounceable.get(...bounceWindow(now), r.id, r.recipient)) return;
147
161
  const why = bus.reach(r.recipient, now);
148
- if (why.state !== 'undeliverable') continue;
162
+ if (why.state !== 'undeliverable') return;
149
163
  dir ??= registry.directory(now);
150
164
  const id = edgeId('u', r.id, r.recipient);
151
165
  const reason = why.reason === 'stale' ? `its session ${why.session} has had no activity for ${why.idleMin} min`
@@ -157,8 +171,7 @@ export function createDeliveryStates(hub, bus, { now: clock = Date.now } = {}) {
157
171
  }, dir);
158
172
  q.bounced.run(r.id, r.recipient, env.error ? `error:${env.error}` : id, now);
159
173
  if (!env.error) sent.push({ id: r.id, to: r.recipient, notice: id });
160
- }
161
- return sent;
174
+ });
162
175
  }
163
176
 
164
177
  /**
@@ -216,5 +229,5 @@ export function createDeliveryStates(hub, bus, { now: clock = Date.now } = {}) {
216
229
  return [...open].filter(([, settled]) => settled).map(([id]) => id);
217
230
  }
218
231
 
219
- return { readOnHook, managerFyi, reports, honest, withDelivery, status, senderOf, report, bounce, postedSince, settleNudges, settledNudgeRecipients };
232
+ return { readOnHook, managerFyi, reports, honest, withDelivery, status, senderOf, report, bounce, bounceSteps, postedSince, settleNudges, settledNudgeRecipients };
220
233
  }
@@ -0,0 +1,34 @@
1
+ import { ONLINE_MS } from '../hub/core/ids.mjs';
2
+
3
+ const WATCHED = ['actors', 'presence'];
4
+
5
+ /**
6
+ * `registry.directory(now)` for the runner's connection, built again only when it could come out different: after a
7
+ * write to actors or presence on this connection (temp triggers set a new random token, which a rollback puts back),
8
+ * after a commit on another connection (`PRAGMA data_version`), or once `now` crosses a presence's online edge
9
+ * (`last_seen + ONLINE_MS`), the only way the clock changes it. Callers share the array and must not change it.
10
+ */
11
+ export function cachedDirectory(db, registry) {
12
+ db.prepare('CREATE TEMP TABLE IF NOT EXISTS pulse_directory_token (one INTEGER PRIMARY KEY CHECK (one = 1), token TEXT NOT NULL)').run();
13
+ db.prepare('INSERT OR IGNORE INTO temp.pulse_directory_token (one, token) VALUES (1, hex(randomblob(8)))').run();
14
+ for (const table of WATCHED) {
15
+ for (const op of ['INSERT', 'UPDATE', 'DELETE']) {
16
+ db.prepare(`CREATE TEMP TRIGGER IF NOT EXISTS pulse_directory_${table}_${op.toLowerCase()} AFTER ${op} ON main.${table}
17
+ BEGIN UPDATE temp.pulse_directory_token SET token = hex(randomblob(8)) WHERE one = 1; END`).run();
18
+ }
19
+ }
20
+ const token = db.prepare('SELECT token FROM temp.pulse_directory_token WHERE one = 1');
21
+ const version = db.prepare('PRAGMA data_version');
22
+ const lastEdge = db.prepare('SELECT MAX(last_seen) AS t FROM presence WHERE last_seen <= ?');
23
+ const nextEdge = db.prepare('SELECT MIN(last_seen) AS t FROM presence WHERE last_seen > ?');
24
+ let kept = null;
25
+ return (now = Date.now()) => {
26
+ const key = `${version.get().data_version}:${token.get().token}`;
27
+ if (kept && kept.key === key && now >= kept.from && now < kept.until) return kept.dir;
28
+ const dir = registry.directory(now);
29
+ const before = lastEdge.get(now - ONLINE_MS).t;
30
+ const after = nextEdge.get(now - ONLINE_MS).t;
31
+ kept = { key, dir, from: before == null ? -Infinity : before + ONLINE_MS, until: after == null ? Infinity : after + ONLINE_MS };
32
+ return dir;
33
+ };
34
+ }