@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.1.3
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/README.md +55 -0
- package/package.json +7 -4
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +63 -0
- package/src/commands/pulse/doctor.js +1 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/daemon/main.js +20 -12
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/application.js +82 -34
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/cutover.js +411 -0
- package/src/lib/cutoverRequest.js +485 -0
- package/src/lib/cutoverWiring.js +140 -0
- package/src/lib/daemonLock.js +2 -0
- package/src/lib/daemonLoop.js +5 -2
- package/src/lib/daemonProcess.js +57 -6
- package/src/lib/daemonRuntime.js +8 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/errors.js +14 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/mirrorContract.js +1 -1
- package/src/lib/mirrorService.js +8 -1
- package/src/lib/mirrorSource.js +13 -38
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/pairingLifecycle.js +1 -1
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/serviceManager.js +64 -1
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/tokenStore.js +43 -4
- package/src/lib/transport.js +53 -5
- package/src/lib/transportContract.js +3 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +149 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// agents-hub lib/transcript-feed.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Notices Cursor transcript writes within about a second without fs.watch: a recursive watch over ~/.cursor/projects
|
|
7
|
+
* (hundreds of folders) runs out of file handles and then dies silently. Instead, stat the recently active session
|
|
8
|
+
* files every second, and rescan every session every 30 s so a resumed old session or a new project is picked up.
|
|
9
|
+
*/
|
|
10
|
+
const FAST_MS = 1000;
|
|
11
|
+
const FULL_MS = 30e3;
|
|
12
|
+
const ACTIVE_MS = 6 * 3600e3;
|
|
13
|
+
|
|
14
|
+
const safe = (fn, fallback) => { try { return fn(); } catch { return fallback; } };
|
|
15
|
+
const dirs = (dir) => safe(() => fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name), []);
|
|
16
|
+
const jsonl = (dir) => safe(() => fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')).map((f) => path.join(dir, f)), []);
|
|
17
|
+
const sig = (file) => safe(() => { const s = fs.statSync(file); return { m: s.mtimeMs, z: s.size }; }, null);
|
|
18
|
+
|
|
19
|
+
function sessionFiles(sessionDir) {
|
|
20
|
+
return [...jsonl(sessionDir), ...jsonl(path.join(sessionDir, 'subagents'))];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function watchTranscripts(projectsDir, onChange) {
|
|
24
|
+
const seen = new Map();
|
|
25
|
+
const activeSessions = new Set();
|
|
26
|
+
const activeRoots = new Set();
|
|
27
|
+
|
|
28
|
+
const check = (file, now, notify) => {
|
|
29
|
+
const s = sig(file);
|
|
30
|
+
if (!s) return false;
|
|
31
|
+
const was = seen.get(file);
|
|
32
|
+
seen.set(file, s);
|
|
33
|
+
if (notify && (!was || was.m !== s.m || was.z !== s.z)) onChange(file);
|
|
34
|
+
return now - s.m < ACTIVE_MS;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const full = (notify) => {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
activeSessions.clear();
|
|
40
|
+
activeRoots.clear();
|
|
41
|
+
for (const project of dirs(projectsDir)) {
|
|
42
|
+
const root = path.join(projectsDir, project, 'agent-transcripts');
|
|
43
|
+
const sessions = dirs(root);
|
|
44
|
+
if (!sessions.length) continue;
|
|
45
|
+
const rootRecent = now - (sig(root)?.m || 0) < ACTIVE_MS;
|
|
46
|
+
let any = false;
|
|
47
|
+
for (const id of sessions) {
|
|
48
|
+
const dir = path.join(root, id);
|
|
49
|
+
let live = false;
|
|
50
|
+
for (const f of sessionFiles(dir)) live = check(f, now, notify) || live;
|
|
51
|
+
if (live) { activeSessions.add(dir); any = true; }
|
|
52
|
+
}
|
|
53
|
+
if (any || rootRecent) activeRoots.add(root);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const fast = () => {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
for (const root of activeRoots) {
|
|
60
|
+
for (const id of dirs(root)) {
|
|
61
|
+
const dir = path.join(root, id);
|
|
62
|
+
if (!activeSessions.has(dir) && sessionFiles(dir).some((f) => !seen.has(f))) activeSessions.add(dir);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const dir of activeSessions) for (const f of sessionFiles(dir)) check(f, now, true);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
full(false);
|
|
69
|
+
setInterval(() => safe(fast), FAST_MS).unref();
|
|
70
|
+
setInterval(() => safe(() => full(true)), FULL_MS).unref();
|
|
71
|
+
return { watching: () => ({ sessions: activeSessions.size, projects: activeRoots.size }) };
|
|
72
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// agents-hub lib/transcripts.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// Cursor transcripts (~/.cursor/projects/*/agent-transcripts): who exists, who started whom, what each agent was
|
|
3
|
+
// asked to do, and where a session's conversation stands.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
8
|
+
import { normRole } from './channels.mjs';
|
|
9
|
+
|
|
10
|
+
export const PROJECTS = path.join(os.homedir(), '.cursor', 'projects');
|
|
11
|
+
const HEAD_BYTES = 48 * 1024;
|
|
12
|
+
const TAIL_BYTES = 256 * 1024;
|
|
13
|
+
const REPO = /\/(agentpulse-[a-z0-9-]+|AgentforceVoice[A-Za-z0-9-]*)(?=[`'"\s/)]|$)/g;
|
|
14
|
+
const CHANNEL_REF = /(?:([A-Za-z0-9._-]+)\/)?\.agents\/([A-Za-z0-9._-]+)\/channel\.md/g;
|
|
15
|
+
const BRIEF = /You are (?:the |a |an )?([A-Z][A-Z-]+(?: [A-Z][A-Z-]+)?) for (?:[A-Za-z][A-Za-z ]* )?task ([A-Za-z0-9][A-Za-z0-9 ._-]*?)(?: \(([^)]{3,140})\))?[ ,.]/;
|
|
16
|
+
|
|
17
|
+
const headCache = new Map();
|
|
18
|
+
/** A transcript's first line (its brief). It never changes once written, so it is read once per file. */
|
|
19
|
+
function head(file, st) {
|
|
20
|
+
const hit = headCache.get(file);
|
|
21
|
+
if (hit && hit.birth === st.birthtimeMs && hit.complete) return hit.line;
|
|
22
|
+
const fd = fs.openSync(file, 'r');
|
|
23
|
+
try {
|
|
24
|
+
const buf = Buffer.alloc(HEAD_BYTES);
|
|
25
|
+
const n = fs.readSync(fd, buf, 0, HEAD_BYTES, 0);
|
|
26
|
+
const text = buf.subarray(0, n).toString('utf8');
|
|
27
|
+
const nl = text.indexOf('\n');
|
|
28
|
+
const line = nl >= 0 ? text.slice(0, nl) : text;
|
|
29
|
+
if (headCache.size > 5000) headCache.clear();
|
|
30
|
+
headCache.set(file, { birth: st.birthtimeMs, line, complete: nl >= 0 || n < HEAD_BYTES });
|
|
31
|
+
return line;
|
|
32
|
+
} finally { fs.closeSync(fd); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** "<timestamp>Thursday, Oct 8, 2026, 9:49 AM (UTC+5:30)" → epoch ms (local), or null. */
|
|
36
|
+
export function parseStamp(s) {
|
|
37
|
+
const m = s && s.match(/<timestamp>\w+, ([A-Z][a-z]{2} \d{1,2}, \d{4}), (\d{1,2}:\d{2} [AP]M)/);
|
|
38
|
+
if (!m) return null;
|
|
39
|
+
const t = Date.parse(`${m[1]} ${m[2]}`);
|
|
40
|
+
return Number.isNaN(t) ? null : t;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const QUERY_CHARS = 16000;
|
|
44
|
+
|
|
45
|
+
/** The agent's opening prompt (the Task brief), with tags stripped. */
|
|
46
|
+
export function firstUserQuery(firstLine) {
|
|
47
|
+
if (!firstLine) return null;
|
|
48
|
+
const fromParts = (parts) => {
|
|
49
|
+
const texts = [];
|
|
50
|
+
if (typeof parts === 'string') texts.push(parts);
|
|
51
|
+
else if (Array.isArray(parts)) {
|
|
52
|
+
for (const p of parts) {
|
|
53
|
+
if (typeof p === 'string') texts.push(p);
|
|
54
|
+
else if (p && p.type === 'text' && typeof p.text === 'string') texts.push(p.text);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return texts.join('\n');
|
|
58
|
+
};
|
|
59
|
+
let joined = '';
|
|
60
|
+
try {
|
|
61
|
+
const obj = JSON.parse(firstLine);
|
|
62
|
+
joined = fromParts(obj.message && obj.message.content != null ? obj.message.content : obj.content);
|
|
63
|
+
} catch {
|
|
64
|
+
const m = firstLine.match(/<user_query>\\n([^]*?)(?:<\\\/user_query>|<\/user_query>|$)/);
|
|
65
|
+
if (!m) return null;
|
|
66
|
+
joined = m[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\t/g, '\t');
|
|
67
|
+
}
|
|
68
|
+
const tagged = joined.match(/<user_query>\s*([\s\S]*?)<\/user_query>/);
|
|
69
|
+
const q = (tagged ? tagged[1] : joined).replace(/<timestamp>[\s\S]*?<\/timestamp>\s*/g, '').trim();
|
|
70
|
+
return q ? q.slice(0, QUERY_CHARS) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The first user prompt saved in a transcript file. */
|
|
74
|
+
export function queryFromTranscript(file) {
|
|
75
|
+
let st;
|
|
76
|
+
try { st = fs.statSync(file); } catch { return null; }
|
|
77
|
+
return firstUserQuery(head(file, st));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The longest CODER (else any) opening prompt that names this channel. Used when brief.md and the spec are missing.
|
|
82
|
+
*/
|
|
83
|
+
export function findAgentQuery(channelKey) {
|
|
84
|
+
const parts = String(channelKey || '').split('/');
|
|
85
|
+
if (parts.length !== 2) return null;
|
|
86
|
+
const [repo, task] = parts;
|
|
87
|
+
if (!/^[A-Za-z0-9._-]+$/.test(repo) || !/^[A-Za-z0-9._-]+$/.test(task)) return null;
|
|
88
|
+
let best = null;
|
|
89
|
+
eachTranscriptDir((dir) => {
|
|
90
|
+
const subs = path.join(dir, 'subagents');
|
|
91
|
+
let files = [];
|
|
92
|
+
try { files = fs.readdirSync(subs); } catch { return; }
|
|
93
|
+
for (const s of files) {
|
|
94
|
+
if (!s.endsWith('.jsonl')) continue;
|
|
95
|
+
const file = path.join(subs, s);
|
|
96
|
+
let st;
|
|
97
|
+
try { st = fs.statSync(file); } catch { continue; }
|
|
98
|
+
const line = head(file, st);
|
|
99
|
+
if (!line.includes(`${repo}/.agents/${task}/`) && !(line.includes(`.agents/${task}/`) && line.includes(`/${repo}`))) continue;
|
|
100
|
+
const q = firstUserQuery(line);
|
|
101
|
+
if (!q) continue;
|
|
102
|
+
const score = (/^You are (?:the |a |an )?CODER\b/m.test(q) ? 1e6 : 0) + q.length;
|
|
103
|
+
if (!best || score > best.score) best = { query: q, score };
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
return best ? best.query : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What a transcript's first message says about the agent: role, task, its channel and the repo it works in. */
|
|
110
|
+
export function describe(firstLine) {
|
|
111
|
+
const q = (firstLine.match(/<user_query>\\n([^]{0,700})/) || [])[1] || '';
|
|
112
|
+
const text = q.replace(/\\n/g, ' ').replace(/\\"/g, '"').replace(/[`*#]/g, '').replace(/\s+/g, ' ').trim();
|
|
113
|
+
const role = (text.match(/^You are (?:the |a |an )?([A-Z][A-Z-]+(?: [A-Z][A-Z-]+)?)\b/) || [])[1] || (/^Thoroughness:/i.test(text) ? 'EXPLORER' : null);
|
|
114
|
+
const tm = text.match(/\btask ([A-Za-z0-9][A-Za-z0-9 ._-]*?)(?: \(([^)]{3,140})\)|,|\.|$)/);
|
|
115
|
+
const ch = [...firstLine.matchAll(CHANNEL_REF)][0];
|
|
116
|
+
const wt = firstLine.match(/worktree\s+(?:`([^`]+)`|'([^']+)'|\\?"([^"\\]+)\\?"|(\S+))/);
|
|
117
|
+
const worktree = wt ? path.basename((wt[1] || wt[2] || wt[3] || wt[4]).replace(/[).,]+$/, '')) : null;
|
|
118
|
+
const repos = [...firstLine.matchAll(REPO)].map((m) => m[1]);
|
|
119
|
+
const isRepo = (r) => !!r && /^(agentpulse-|AgentforceVoice)/.test(r);
|
|
120
|
+
const repoHint = [ch && ch[1], worktree, repos[0]].find(isRepo) || null;
|
|
121
|
+
return {
|
|
122
|
+
text, role: role ? normRole(role) : null, task: tm ? tm[1].trim() : null, taskTitle: tm && tm[2] ? tm[2].trim() : null,
|
|
123
|
+
channelTask: ch ? ch[2] : null, repo: repoHint,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The channel key ("<repo>/<task>") a task name refers to: unique by name, else the one in the hinted repo. */
|
|
128
|
+
export function resolveKey(task, repoHint, index) {
|
|
129
|
+
const keys = (task && index.get(task)) || [];
|
|
130
|
+
if (keys.length === 1) return keys[0];
|
|
131
|
+
return keys.find((k) => repoHint && k.startsWith(`${repoHint}/`)) || null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const shortLabel = (d) => (d.role ? d.role.toLowerCase().replace(/(^|\s)\w/g, (c) => c.toUpperCase()) : d.text.slice(0, 42) + (d.text.length > 42 ? '…' : ''));
|
|
135
|
+
|
|
136
|
+
function nodeFrom(id, file, parent, st, index) {
|
|
137
|
+
const first = head(file, st);
|
|
138
|
+
const d = describe(first);
|
|
139
|
+
return {
|
|
140
|
+
id, kind: parent ? 'agent' : 'session', role: d.role, task: d.task, taskTitle: d.taskTitle, repo: d.repo,
|
|
141
|
+
channel: d.channelTask, channelKey: resolveKey(d.channelTask, d.repo, index),
|
|
142
|
+
label: shortLabel(d), title: d.text.slice(0, 200), parent, startedAt: parseStamp(first) || st.birthtimeMs, lastAt: st.mtimeMs, bytes: st.size, file,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function eachTranscriptDir(cb) {
|
|
147
|
+
let projects = [];
|
|
148
|
+
try { projects = fs.readdirSync(PROJECTS); } catch { return; }
|
|
149
|
+
for (const p of projects) {
|
|
150
|
+
const root = path.join(PROJECTS, p, 'agent-transcripts');
|
|
151
|
+
let dirs = [];
|
|
152
|
+
try { dirs = fs.readdirSync(root); } catch { continue; }
|
|
153
|
+
for (const d of dirs) cb(path.join(root, d), d);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const LAUNCH_INDEX_MS = 60e3;
|
|
158
|
+
let launchIndex = { at: 0, map: new Map(), files: new Map() };
|
|
159
|
+
/**
|
|
160
|
+
* Every subagent id and the session that launched it, from all `<session>/subagents/` folders whatever their age. A
|
|
161
|
+
* background subagent also has its own top-level transcript, which must never pass for a session of yours.
|
|
162
|
+
*/
|
|
163
|
+
export function launchedBy() {
|
|
164
|
+
if (Date.now() - launchIndex.at < LAUNCH_INDEX_MS) return launchIndex.map;
|
|
165
|
+
const map = new Map();
|
|
166
|
+
const files = new Map();
|
|
167
|
+
eachTranscriptDir((dir, d) => {
|
|
168
|
+
let subs = [];
|
|
169
|
+
try { subs = fs.readdirSync(path.join(dir, 'subagents')); } catch { return; }
|
|
170
|
+
for (const s of subs) if (s.endsWith('.jsonl') && s.slice(0, -6) !== d) map.set(s.slice(0, -6), d);
|
|
171
|
+
if (subs.length) files.set(d, path.join(dir, `${d}.jsonl`));
|
|
172
|
+
});
|
|
173
|
+
launchIndex = { at: Date.now(), map, files };
|
|
174
|
+
return map;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The top-level session a transcript id belongs to (itself, or the session that launched it), as a session node. */
|
|
178
|
+
export function topSession(id, index) {
|
|
179
|
+
const launched = launchedBy();
|
|
180
|
+
let top = id;
|
|
181
|
+
for (let i = 0; launched.has(top) && i < 8; i++) top = launched.get(top);
|
|
182
|
+
if (top === id && !launched.has(id)) return null;
|
|
183
|
+
const file = launchIndex.files.get(top);
|
|
184
|
+
let st;
|
|
185
|
+
try { st = file && fs.statSync(file); } catch { return null; }
|
|
186
|
+
return st ? nodeFrom(top, file, null, st, index) : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Sessions and their subagents changed since sinceMs. A background subagent's launch stub and its own transcript merge. */
|
|
190
|
+
export function scanTranscripts(sinceMs, index) {
|
|
191
|
+
const nodes = new Map();
|
|
192
|
+
const add = (id, file, parent) => {
|
|
193
|
+
let st;
|
|
194
|
+
try { st = fs.statSync(file); } catch { return; }
|
|
195
|
+
if (st.mtimeMs < sinceMs) return;
|
|
196
|
+
const n = nodeFrom(id, file, parent, st, index);
|
|
197
|
+
const prev = nodes.get(id);
|
|
198
|
+
if (prev) { n.parent = n.parent || prev.parent; n.bytes = Math.max(prev.bytes, n.bytes); n.lastAt = Math.max(prev.lastAt, n.lastAt); }
|
|
199
|
+
nodes.set(id, n);
|
|
200
|
+
};
|
|
201
|
+
/** A folder's own mtime changes only when entries are added; an appended transcript or a new stub is newer. */
|
|
202
|
+
const freshest = (...files) => Math.max(0, ...files.map((f) => { try { return fs.statSync(f).mtimeMs; } catch { return 0; } }));
|
|
203
|
+
eachTranscriptDir((dir, d) => {
|
|
204
|
+
let st;
|
|
205
|
+
try { st = fs.statSync(dir); } catch { return; }
|
|
206
|
+
if (!st.isDirectory()) return;
|
|
207
|
+
const subs = path.join(dir, 'subagents');
|
|
208
|
+
if (Math.max(st.mtimeMs, freshest(path.join(dir, `${d}.jsonl`), subs)) < sinceMs) return;
|
|
209
|
+
add(d, path.join(dir, `${d}.jsonl`), null);
|
|
210
|
+
let files = [];
|
|
211
|
+
try { files = fs.readdirSync(subs); } catch { return; }
|
|
212
|
+
for (const s of files) if (s.endsWith('.jsonl')) add(s.slice(0, -6), path.join(subs, s), d);
|
|
213
|
+
});
|
|
214
|
+
const groups = new Map();
|
|
215
|
+
for (const n of nodes.values()) if (n.role && (n.channelKey || n.channel)) {
|
|
216
|
+
const k = `${n.channelKey || n.channel}|${n.role}`;
|
|
217
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
218
|
+
groups.get(k).push(n);
|
|
219
|
+
}
|
|
220
|
+
for (const g of groups.values()) {
|
|
221
|
+
if (g.length < 2) continue;
|
|
222
|
+
g.sort((a, b) => b.bytes - a.bytes);
|
|
223
|
+
const [keep, ...rest] = g;
|
|
224
|
+
for (const r of rest) { keep.parent = keep.parent || r.parent; keep.startedAt = Math.min(keep.startedAt, r.startedAt); keep.lastAt = Math.max(keep.lastAt, r.lastAt); nodes.delete(r.id); }
|
|
225
|
+
}
|
|
226
|
+
const launched = launchedBy();
|
|
227
|
+
for (const n of [...nodes.values()]) {
|
|
228
|
+
const by = launched.get(n.id);
|
|
229
|
+
if (!by) continue;
|
|
230
|
+
n.kind = 'agent';
|
|
231
|
+
n.launchedBy = by;
|
|
232
|
+
if (!n.parent) n.parent = by;
|
|
233
|
+
if (nodes.has(by) || launched.has(by)) continue;
|
|
234
|
+
const file = launchIndex.files.get(by);
|
|
235
|
+
let st;
|
|
236
|
+
try { st = file && fs.statSync(file); } catch { st = null; }
|
|
237
|
+
if (st) nodes.set(by, nodeFrom(by, file, null, st, index));
|
|
238
|
+
}
|
|
239
|
+
for (const n of nodes.values()) if (n.parent && !nodes.has(n.parent)) n.parent = null;
|
|
240
|
+
return nodes;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Top-level sessions (not briefed agents) changed since sinceMs, for finding orchestrators that went quiet. */
|
|
244
|
+
export function scanSessions(sinceMs, index) {
|
|
245
|
+
const out = [];
|
|
246
|
+
eachTranscriptDir((dir, d) => {
|
|
247
|
+
const file = path.join(dir, `${d}.jsonl`);
|
|
248
|
+
let st;
|
|
249
|
+
try { st = fs.statSync(file); } catch { return; }
|
|
250
|
+
if (st.mtimeMs < sinceMs) return;
|
|
251
|
+
const n = nodeFrom(d, file, null, st, index);
|
|
252
|
+
if (n.kind === 'session') out.push(n);
|
|
253
|
+
});
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const ownCache = new Map();
|
|
258
|
+
const READ_CHUNK = 4 * 1024 * 1024;
|
|
259
|
+
|
|
260
|
+
function ownedRefsFromLine(line, list) {
|
|
261
|
+
if (!line.includes('channel.md') || !line.includes('"tool_use"')) return;
|
|
262
|
+
let msg;
|
|
263
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
264
|
+
for (const b of (msg.message && msg.message.content) || []) {
|
|
265
|
+
if (b.type !== 'tool_use') continue;
|
|
266
|
+
const tool = (b.input && b.input.toolName) || b.name;
|
|
267
|
+
const input = JSON.stringify(b.input || {});
|
|
268
|
+
const owns = tool === 'Task' || tool === 'Write' || (tool === 'Shell' && /cat\s*>>?[^|]*channel\.md|>>\s*"?[^"\s]*channel\.md/.test(input));
|
|
269
|
+
if (!owns) continue;
|
|
270
|
+
const repos = [...input.matchAll(REPO)].map((m) => m[1]);
|
|
271
|
+
const brief = tool === 'Task' ? input.match(BRIEF) : null;
|
|
272
|
+
for (const m of input.matchAll(CHANNEL_REF)) {
|
|
273
|
+
list.push({ task: m[2], repo: m[1] && m[1] !== '.agents' ? m[1] : null, repos,
|
|
274
|
+
brief: brief ? { role: normRole(brief[1]), code: brief[2].trim(), title: brief[3] ? brief[3].trim() : null } : null });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Drop cached scans of transcripts that have not changed since cutoffMs (outside every window the hub looks at). */
|
|
280
|
+
export function pruneOwnCache(cutoffMs) { for (const [k, v] of ownCache) if (v.mtime < cutoffMs) ownCache.delete(k); }
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Channel keys a session owns: named in a subagent brief (Task) or written by it (Write, or a Shell append). Reading a
|
|
284
|
+
* channel does not count. Transcripts only grow, so each is scanned incrementally from where the last scan stopped;
|
|
285
|
+
* the found (task, repos) pairs resolve to keys against the current index.
|
|
286
|
+
*/
|
|
287
|
+
export function channelsOwned(file, index) {
|
|
288
|
+
let refs = ownCache.get(file);
|
|
289
|
+
try {
|
|
290
|
+
const st = fs.statSync(file);
|
|
291
|
+
if (!refs || st.size < refs.offset) refs = { offset: 0, rest: '', list: [], decoder: new StringDecoder('utf8'), mtime: 0 };
|
|
292
|
+
refs.mtime = st.mtimeMs;
|
|
293
|
+
if (st.size > refs.offset) {
|
|
294
|
+
const fd = fs.openSync(file, 'r');
|
|
295
|
+
try {
|
|
296
|
+
while (refs.offset < st.size) {
|
|
297
|
+
const len = Math.min(READ_CHUNK, st.size - refs.offset);
|
|
298
|
+
const buf = Buffer.alloc(len);
|
|
299
|
+
const n = fs.readSync(fd, buf, 0, len, refs.offset);
|
|
300
|
+
if (!n) break;
|
|
301
|
+
refs.offset += n;
|
|
302
|
+
const lines = (refs.rest + refs.decoder.write(buf.subarray(0, n))).split('\n');
|
|
303
|
+
refs.rest = lines.pop();
|
|
304
|
+
for (const line of lines) ownedRefsFromLine(line, refs.list);
|
|
305
|
+
}
|
|
306
|
+
} finally { fs.closeSync(fd); }
|
|
307
|
+
}
|
|
308
|
+
ownCache.set(file, refs);
|
|
309
|
+
} catch { refs = refs || { offset: 0, rest: '', list: [], mtime: 0 }; }
|
|
310
|
+
const keys = new Set();
|
|
311
|
+
const briefs = new Map();
|
|
312
|
+
for (const r of refs.list) {
|
|
313
|
+
const k = resolveKey(r.task, r.repo, index) || r.repos.map((repo) => resolveKey(r.task, repo, index)).find(Boolean);
|
|
314
|
+
if (!k) continue;
|
|
315
|
+
keys.add(k);
|
|
316
|
+
if (r.brief) { if (!briefs.has(k)) briefs.set(k, new Map()); briefs.get(k).set(r.brief.role, r.brief); }
|
|
317
|
+
}
|
|
318
|
+
keys.briefs = briefs;
|
|
319
|
+
return keys;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const tailCache = new Map();
|
|
323
|
+
/** Where a session's conversation stands, from the end of its transcript; re-read only when the file grew. */
|
|
324
|
+
export function sessionTail(file) {
|
|
325
|
+
let size = -1;
|
|
326
|
+
try { size = fs.statSync(file).size; } catch { return { state: 'unknown', lastText: '' }; }
|
|
327
|
+
const hit = tailCache.get(file);
|
|
328
|
+
if (hit && hit.size === size) return hit.tail;
|
|
329
|
+
const tail = readTail(file);
|
|
330
|
+
if (tailCache.size > 2000) tailCache.clear();
|
|
331
|
+
tailCache.set(file, { size, tail });
|
|
332
|
+
return tail;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function readTail(file) {
|
|
336
|
+
let text = '';
|
|
337
|
+
try {
|
|
338
|
+
const st = fs.statSync(file);
|
|
339
|
+
const fd = fs.openSync(file, 'r');
|
|
340
|
+
const len = Math.min(TAIL_BYTES, st.size);
|
|
341
|
+
const buf = Buffer.alloc(len);
|
|
342
|
+
fs.readSync(fd, buf, 0, len, st.size - len);
|
|
343
|
+
fs.closeSync(fd);
|
|
344
|
+
text = buf.toString('utf8');
|
|
345
|
+
} catch { return { state: 'unknown', lastText: '' }; }
|
|
346
|
+
const lines = text.split('\n').filter(Boolean).slice(1).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
347
|
+
const last = lines[lines.length - 1];
|
|
348
|
+
let lastText = '';
|
|
349
|
+
for (let i = lines.length - 1; i >= 0 && !lastText; i--) {
|
|
350
|
+
const m = lines[i];
|
|
351
|
+
if (m.role === 'assistant') lastText = (m.message.content || []).filter((c) => c.type === 'text').map((c) => c.text).join(' ').trim();
|
|
352
|
+
}
|
|
353
|
+
if (!last) return { state: 'unknown', lastText };
|
|
354
|
+
if (last.type === 'turn_ended') return { state: 'turn-ended', lastText };
|
|
355
|
+
if (last.role === 'assistant') {
|
|
356
|
+
const ask = [...(last.message.content || [])].reverse().find((c) => c.type === 'tool_use');
|
|
357
|
+
if (ask && /AskQuestion$/.test(ask.name || (ask.input && ask.input.toolName) || '')) {
|
|
358
|
+
const qs = (ask.input.questions || (ask.input.arguments && ask.input.arguments.questions) || []);
|
|
359
|
+
const q = qs[0] || {};
|
|
360
|
+
return { state: 'asking', lastText, question: { title: ask.input.title || null, prompt: q.prompt || '', options: (q.options || []).map((o) => o.label), more: Math.max(0, qs.length - 1) } };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return { state: 'working', lastText };
|
|
364
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// agents-hub lib/writing.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// How agents write anything a person reads in the hub: channel posts, replies and side-chat answers. The hub renders
|
|
3
|
+
// GitHub-flavoured markdown, so structure shows up as real lists, code blocks and tables.
|
|
4
|
+
|
|
5
|
+
/** One paragraph for prompts and hook messages. */
|
|
6
|
+
export const WRITING_GUIDE = 'Write in GitHub markdown so the hub renders it cleanly: lead with the outcome in one plain sentence; '
|
|
7
|
+
+ 'short paragraphs separated by a blank line; `- ` bullets for three or more items and `1.` for steps; '
|
|
8
|
+
+ 'file paths, commands, ids and short code in `backticks`; multi-line code, logs or output in fenced blocks with a language '
|
|
9
|
+
+ '(```js … ```); a table when comparing items (file | status | note); no HTML.';
|
|
10
|
+
|
|
11
|
+
/** How to ask the user for a decision so the hub raises it to them (Deliveries, Board, desktop notification). */
|
|
12
|
+
export const DECISION_RULE = 'When a decision needs the user, tag them: write `NEEDS USER` in the post header and `@you` in the body, '
|
|
13
|
+
+ 'say what must be decided in one sentence, then list the choices as `(a) …`, `(b) …` with your recommendation and its cost. '
|
|
14
|
+
+ 'Keep working on anything that does not depend on the answer; their reply arrives as a USER DECISION post.';
|
|
15
|
+
|
|
16
|
+
/** Extra rule for task-channel posts, whose header line the hub parses. */
|
|
17
|
+
export const CHANNEL_POST_RULE = 'In a channel post keep the header line exactly `## [ROLE] HH:MM:SS TYPE — summary`; inside the body use '
|
|
18
|
+
+ '`###` headings or **bold** labels, and never start a body line with `## [` (that would begin a new post).';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// agents-hub projections/deliveries.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// Pure read of delivery and agent-row facts. One display title per delivery (cursor_title). A status you set
|
|
3
|
+
// (status_override facts, lib/status.mjs) and a card you moved to a step are applied over the derived ones here, so
|
|
4
|
+
// every reader sees the same.
|
|
5
|
+
import { displayTitle } from '../core/ids.mjs';
|
|
6
|
+
import { withOverride, withSteps, workMoved } from '../lib/status.mjs';
|
|
7
|
+
|
|
8
|
+
export function projectDeliveries(hub) {
|
|
9
|
+
const { registry } = hub;
|
|
10
|
+
const overrides = new Map(registry.facts('status_override').map(({ key, updated_at: _at, ...o }) => [key, o]));
|
|
11
|
+
const deliveries = registry.facts('delivery').filter((d) => {
|
|
12
|
+
const actor = registry.get(d.id);
|
|
13
|
+
return !actor || actor.node_type === 'orchestrator';
|
|
14
|
+
}).map((d) => {
|
|
15
|
+
const actor = registry.get(d.id);
|
|
16
|
+
const name = actor ? displayTitle(actor) : (d.name || d.title);
|
|
17
|
+
const moved = withSteps({ ...d, name, displayTitle: name }, (key) => overrides.get(`stream:${key}`));
|
|
18
|
+
return withOverride(moved, overrides.get(`delivery:${d.id}`), { kind: 'delivery', ...workMoved('delivery', d) });
|
|
19
|
+
});
|
|
20
|
+
const agents = registry.facts('agent-row').filter((a) => {
|
|
21
|
+
const actor = a.isArchitect && registry.get(a.id);
|
|
22
|
+
return !actor || actor.node_type === 'orchestrator';
|
|
23
|
+
}).map((a) => {
|
|
24
|
+
const actor = registry.get(a.id);
|
|
25
|
+
const title = actor ? displayTitle(actor) : (a.persona && a.persona.name) || a.label;
|
|
26
|
+
const delivery = deliveries.find((d) => d.id === a.deliveryId);
|
|
27
|
+
const row = { ...a, displayTitle: title, deliveryTitle: delivery ? delivery.displayTitle || delivery.name : a.deliveryTitle };
|
|
28
|
+
return withOverride(row, overrides.get(`agent:${a.id}`), { kind: 'agent', ...workMoved('agent', a) });
|
|
29
|
+
});
|
|
30
|
+
return { deliveries, agents };
|
|
31
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// agents-hub projections/graph.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// Pure read: actors + presence + messages + discovery facts. Writes nothing.
|
|
3
|
+
import { displayTitle, managerChats } from '../core/ids.mjs';
|
|
4
|
+
import { projectDeliveries } from './deliveries.mjs';
|
|
5
|
+
|
|
6
|
+
const ACTIVE_MS = 10 * 60e3;
|
|
7
|
+
const IDLE_MS = 60 * 60e3;
|
|
8
|
+
|
|
9
|
+
function statusFrom(lastAt, presence, now) {
|
|
10
|
+
if (presence && now - presence.last_seen < 3 * 60e3 && presence.last_event !== 'stop') return 'active';
|
|
11
|
+
if (now - lastAt < ACTIVE_MS) return 'active';
|
|
12
|
+
if (now - lastAt < IDLE_MS) return 'idle';
|
|
13
|
+
return 'done';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function kindOf(a) {
|
|
17
|
+
if (a.node_type === 'user') return 'user';
|
|
18
|
+
if (a.node_type === 'manager') return 'manager';
|
|
19
|
+
if (a.node_type === 'orchestrator') return 'session';
|
|
20
|
+
return 'agent';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Header totals for one window from the canonical pass's basis (lib/collect.mjs totalsBasis): the people seen since
|
|
25
|
+
* `since`, the tokens of those whose own transcript is active since then, and the messages sent since then.
|
|
26
|
+
*/
|
|
27
|
+
function windowTotals(basis, since, now, requests) {
|
|
28
|
+
const people = basis.people.filter(([, seenAt]) => seenAt >= since);
|
|
29
|
+
return {
|
|
30
|
+
sessions: people.filter(([kind]) => kind === 's').length,
|
|
31
|
+
agents: people.filter(([kind]) => kind === 'a').length,
|
|
32
|
+
active: people.filter(([, , lastAt]) => now - lastAt < ACTIVE_MS).length,
|
|
33
|
+
messages: basis.messages.reduce((sum, [at, count]) => sum + (at >= since ? count : 0), 0),
|
|
34
|
+
tokensEst: people.reduce((sum, [, , lastAt, tokens]) => sum + (lastAt >= since ? tokens : 0), 0),
|
|
35
|
+
requests,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function projectGraph(hub, { hours = 24, managerView = {}, now = Date.now() } = {}) {
|
|
40
|
+
const { registry, messaging } = hub;
|
|
41
|
+
const since = now - hours * 3600e3;
|
|
42
|
+
const presences = registry.presences();
|
|
43
|
+
// Delivery and agent rows are canonical (workers/collector.mjs CANONICAL_HOURS); a window only picks the rows
|
|
44
|
+
// active in it. A row without lastAt is kept.
|
|
45
|
+
const inWindow = (row) => !Number.isFinite(row.lastAt) || Math.max(row.lastAt, Number(row.movedAt) || 0) >= since;
|
|
46
|
+
const projected = projectDeliveries(hub);
|
|
47
|
+
const deliveries = projected.deliveries.filter(inWindow).sort((a, b) => (b.lastAt || 0) - (a.lastAt || 0));
|
|
48
|
+
const agents = projected.agents.filter(inWindow);
|
|
49
|
+
const requests = registry.facts('request').filter((r) => (r.at || 0) >= since);
|
|
50
|
+
|
|
51
|
+
const ownChats = managerChats(registry.fact('manager', 'config'));
|
|
52
|
+
const nodes = registry.all().filter((a) => {
|
|
53
|
+
if (ownChats.has(a.id)) return false;
|
|
54
|
+
if (!((a.last_at || 0) >= since || a.id === 'user' || a.id === 'manager' || presences.has(a.id))) return false;
|
|
55
|
+
// Bus addresses like repo/task:ROLE are channel participants, not sessions.
|
|
56
|
+
if (a.node_type === 'orchestrator' && /[/:]/.test(a.id)) return false;
|
|
57
|
+
return true;
|
|
58
|
+
}).map((a) => {
|
|
59
|
+
const p = presences.get(a.id);
|
|
60
|
+
const lastAt = Math.max(a.last_at || 0, p ? p.last_seen : 0, a.id === 'user' ? now : 0);
|
|
61
|
+
const persona = a.persona || (a.persona_name ? {
|
|
62
|
+
name: a.persona_name, first: a.persona_first, username: a.persona_username, seed: a.persona_seed, personality: a.persona,
|
|
63
|
+
} : null);
|
|
64
|
+
const title = displayTitle(a);
|
|
65
|
+
return {
|
|
66
|
+
id: a.id,
|
|
67
|
+
kind: kindOf(a),
|
|
68
|
+
node_type: a.node_type,
|
|
69
|
+
role: a.role,
|
|
70
|
+
label: title,
|
|
71
|
+
title,
|
|
72
|
+
chatTitle: a.cursor_title || null,
|
|
73
|
+
promptTitle: a.prompt_title || null,
|
|
74
|
+
displayTitle: title,
|
|
75
|
+
parent: a.parent_id,
|
|
76
|
+
deliveryId: a.delivery_id || (a.node_type === 'orchestrator' ? a.id : a.parent_id),
|
|
77
|
+
deliveryTitle: a.node_type === 'orchestrator' ? title : null,
|
|
78
|
+
channelKey: a.stream_key,
|
|
79
|
+
channel: a.channel,
|
|
80
|
+
repo: a.repo,
|
|
81
|
+
project: a.repo,
|
|
82
|
+
taskTitle: a.prompt_title,
|
|
83
|
+
startedAt: a.started_at || lastAt,
|
|
84
|
+
lastAt,
|
|
85
|
+
status: a.id === 'manager'
|
|
86
|
+
? (managerView.state === 'working' ? 'active' : managerView.online ? 'idle' : 'done')
|
|
87
|
+
: statusFrom(lastAt, p, now),
|
|
88
|
+
tokensEst: a.tokens_est,
|
|
89
|
+
msgsIn: a.msgs_in, msgsOut: a.msgs_out,
|
|
90
|
+
persona,
|
|
91
|
+
busAddress: a.id,
|
|
92
|
+
busOnline: !!(p && now - (p.last_seen || 0) < 90e3),
|
|
93
|
+
synthetic: !!a.synthetic,
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const byId = new Set(nodes.map((n) => n.id));
|
|
98
|
+
const links = [];
|
|
99
|
+
const linkKeys = new Set();
|
|
100
|
+
for (const n of nodes) {
|
|
101
|
+
if (n.parent && byId.has(n.parent)) {
|
|
102
|
+
links.push({ source: n.parent, target: n.id, kind: 'spawn', count: 1 });
|
|
103
|
+
linkKeys.add(`${n.parent}|${n.id}|spawn`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const events = [];
|
|
107
|
+
for (const message of messaging.messages({ since, limit: 300 })) {
|
|
108
|
+
for (const to of message.to || []) {
|
|
109
|
+
events.push({
|
|
110
|
+
at: message.at, from: message.from, to, type: message.type, text: message.body,
|
|
111
|
+
messageId: message.id, topic: message.topic, bus: true,
|
|
112
|
+
});
|
|
113
|
+
if (!byId.has(message.from) || !byId.has(to) || message.from === to) continue;
|
|
114
|
+
const key = `${message.from}|${to}|bus`;
|
|
115
|
+
if (!linkKeys.has(key)) { links.push({ source: message.from, target: to, kind: 'bus', count: 1 }); linkKeys.add(key); }
|
|
116
|
+
else {
|
|
117
|
+
const link = links.find((l) => `${l.source}|${l.target}|${l.kind}` === key);
|
|
118
|
+
if (link) link.count++;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const channelEvents = registry.eventsSince(since, 400).filter((e) => !e.bus);
|
|
123
|
+
const allEvents = [...channelEvents.map((e) => ({
|
|
124
|
+
at: e.at, from: e.from || e.actor, to: e.to, type: e.type || e.kind, text: e.text, channel: e.channel,
|
|
125
|
+
})), ...events].filter((e) => e && e.at).sort((a, b) => a.at - b.at);
|
|
126
|
+
|
|
127
|
+
const people = nodes.filter((n) => n.kind !== 'manager');
|
|
128
|
+
const basis = registry.fact('totals', 'basis');
|
|
129
|
+
return {
|
|
130
|
+
generatedAt: now,
|
|
131
|
+
hours,
|
|
132
|
+
deliveries,
|
|
133
|
+
agents,
|
|
134
|
+
nodes,
|
|
135
|
+
links,
|
|
136
|
+
events: allEvents.slice(-400),
|
|
137
|
+
requests,
|
|
138
|
+
totals: basis ? windowTotals(basis, since, now, requests.length) : {
|
|
139
|
+
sessions: people.filter((n) => n.kind === 'session').length,
|
|
140
|
+
agents: people.filter((n) => n.kind === 'agent').length,
|
|
141
|
+
active: people.filter((n) => n.status === 'active').length,
|
|
142
|
+
messages: allEvents.length,
|
|
143
|
+
tokensEst: people.reduce((s, n) => s + (n.tokensEst || 0), 0),
|
|
144
|
+
requests: requests.length,
|
|
145
|
+
},
|
|
146
|
+
manager: managerView,
|
|
147
|
+
bus: { messages: messaging.messages({ since, limit: 300 }), registrations: registry.registrations(now) },
|
|
148
|
+
};
|
|
149
|
+
}
|