@vale-dinap/lucidly 0.0.0-stage → 0.2.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.
@@ -0,0 +1,1034 @@
1
+ const MAX_PATCH_LINES = 2000;
2
+ const zeroUsage = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, responses: 0 });
3
+ import { extractPlan, firstLine, hasDeviationCue, hasIntent, intentTitle, isSubstantivePrompt, isReply, endsWithQuestion, issueSummary, matchStep, sentences, similarity, stripMarkdown, tokens, truncate, } from "./text.js";
4
+ const MAX_CHUNK = 256 * 1024;
5
+ const MAX_CONTEXT_ITEMS = 12;
6
+ /**
7
+ * Folds normalized events into a view model. Pure in-memory logic, no I/O,
8
+ * so the same engine serves live tailing, replays and tests.
9
+ */
10
+ export class Engine {
11
+ nodes = new Map();
12
+ chunks = [];
13
+ run = { status: 'idle', goals: [], eventCount: 0 };
14
+ nodeChunks = new Map();
15
+ dirty = new Set();
16
+ runDirty = false;
17
+ seq = 0;
18
+ /** The main agent's last words asked the user something. */
19
+ agentAsked = false;
20
+ goalCtx = new Map();
21
+ units = new Map();
22
+ calls = new Map();
23
+ scopes = new Map();
24
+ orphans = new Map();
25
+ apply(ev) {
26
+ if (ev.scope && !this.scopes.has(ev.scope)) {
27
+ // Subagent output that arrived before its spawning call: hold it until the call shows up.
28
+ const list = this.orphans.get(ev.scope) ?? [];
29
+ list.push(ev);
30
+ this.orphans.set(ev.scope, list);
31
+ return;
32
+ }
33
+ this.run.eventCount++;
34
+ // Max, not last: subagent transcripts are read after the main one and carry older times.
35
+ if (ev.ts && (!this.run.updatedAt || ev.ts > this.run.updatedAt))
36
+ this.run.updatedAt = ev.ts;
37
+ this.runDirty = true;
38
+ switch (ev.type) {
39
+ case 'run.meta':
40
+ if (ev.title)
41
+ this.run.title = ev.title;
42
+ if (ev.cwd)
43
+ this.run.cwd = ev.cwd;
44
+ if (ev.agent)
45
+ this.run.agent = ev.agent;
46
+ if (ev.sessionId)
47
+ this.run.sessionId = ev.sessionId;
48
+ break;
49
+ case 'user.prompt':
50
+ this.onPrompt(ev.text, ev.ts, ev.scope, ev.raw);
51
+ break;
52
+ case 'agent.text':
53
+ this.onText(ev.text, ev.ts, ev.scope);
54
+ break;
55
+ case 'agent.thinking': {
56
+ const target = ev.scope ? this.scopes.get(ev.scope) : this.focusNode();
57
+ if (target)
58
+ this.addChunk(target, 'thinking', ev.text, ev.ts);
59
+ break;
60
+ }
61
+ case 'plan.set':
62
+ this.onPlanSet(ev);
63
+ break;
64
+ case 'plan.update':
65
+ this.onPlanUpdate(ev);
66
+ break;
67
+ case 'tool.start':
68
+ this.onToolStart(ev);
69
+ break;
70
+ case 'tool.end':
71
+ this.onToolEnd(ev);
72
+ break;
73
+ case 'notice':
74
+ this.onNotice(ev);
75
+ break;
76
+ case 'turn.end':
77
+ if (!ev.scope)
78
+ this.onTurnEnd(ev.ts, ev.interrupted);
79
+ break;
80
+ case 'usage':
81
+ this.onUsage(ev);
82
+ break;
83
+ }
84
+ this.syncRunPointers();
85
+ }
86
+ applyAll(events) {
87
+ for (const e of events)
88
+ this.apply(e);
89
+ }
90
+ runCopy() {
91
+ const u = this.run.usage;
92
+ return {
93
+ ...this.run,
94
+ goals: [...this.run.goals],
95
+ usage: u && { ...u, byModel: structuredClone(u.byModel), perMinute: u.perMinute.map((p) => [p[0], p[1]]) },
96
+ };
97
+ }
98
+ /** Full state. Does not consume pending changes: flush takePatch() first when broadcasting. */
99
+ snapshot() {
100
+ return { run: this.runCopy(), nodes: Object.fromEntries(this.nodes) };
101
+ }
102
+ /** Changes since the last snapshot/patch, or null when nothing changed. */
103
+ takePatch() {
104
+ if (this.dirty.size === 0 && !this.runDirty)
105
+ return null;
106
+ const nodes = {};
107
+ for (const id of this.dirty)
108
+ nodes[id] = this.nodes.get(id) ?? null;
109
+ this.dirty.clear();
110
+ this.runDirty = false;
111
+ return { run: this.runCopy(), nodes };
112
+ }
113
+ /**
114
+ * Raw chunks for a node and everything under it, in arrival order.
115
+ * With `own`, only the node's chunks plus those of its direct notes and user replies
116
+ * (used for goals, whose full subtree can be the entire transcript).
117
+ */
118
+ raw(nodeId, own = false) {
119
+ const ids = [];
120
+ const visit = (id, depth) => {
121
+ ids.push(...(this.nodeChunks.get(id) ?? []));
122
+ for (const c of this.nodes.get(id)?.children ?? []) {
123
+ if (!own)
124
+ visit(c, depth + 1);
125
+ else if (depth === 0) {
126
+ const n = this.nodes.get(c);
127
+ if (n && (n.type === 'note' || n.kind === 'user'))
128
+ visit(c, depth + 1);
129
+ }
130
+ }
131
+ };
132
+ visit(nodeId, 0);
133
+ return ids.sort((a, b) => a - b).map((i) => this.chunks[i]);
134
+ }
135
+ /** Every action of the run in execution order, with where it happened. */
136
+ actions(cap = 4000) {
137
+ const rows = [];
138
+ for (const n of this.nodes.values()) {
139
+ const isAgent = n.type === 'subtask' && n.origin === 'subagent';
140
+ if (!(n.type === 'activity' && n.kind !== 'user') && !isAgent)
141
+ continue;
142
+ const toolChunk = (this.nodeChunks.get(n.id) ?? []).map((i) => this.chunks[i]).find((c) => c.kind === 'tool');
143
+ const text = toolChunk?.text ?? '';
144
+ const input = text.length > cap ? text.slice(0, cap) + '\n…' : text;
145
+ const row = {
146
+ id: n.id,
147
+ kind: n.kind ?? 'other',
148
+ tool: n.tool,
149
+ title: n.title,
150
+ status: n.status,
151
+ retry: n.retry,
152
+ recovered: n.recovered,
153
+ issue: n.issue,
154
+ startedAt: n.startedAt,
155
+ endedAt: n.endedAt,
156
+ input,
157
+ };
158
+ if (n.diff)
159
+ row.diff = n.diff;
160
+ if (n.files?.length && (n.kind === 'edit' || n.kind === 'write'))
161
+ row.files = n.files;
162
+ if (n.kind === 'command' && text) {
163
+ row.command = text.replace(/^(?:\$|PS>)\s?/, '');
164
+ if (n.detail)
165
+ row.short = n.detail.replace(/^\$\s?/, '');
166
+ }
167
+ let p = n.parent ? this.nodes.get(n.parent) : undefined;
168
+ while (p) {
169
+ if (p.type === 'subtask' && p.origin === 'subagent' && !row.subagent) {
170
+ row.subagent = p.title.replace(/^Subagent:\s*/, '');
171
+ row.subagentId = p.id;
172
+ }
173
+ if (p.type === 'step' && !row.stepId) {
174
+ row.stepId = p.id;
175
+ row.step = p.title;
176
+ }
177
+ if (p.type === 'goal') {
178
+ row.goalId = p.id;
179
+ row.goal = p.title;
180
+ }
181
+ p = p.parent ? this.nodes.get(p.parent) : undefined;
182
+ }
183
+ rows.push(row);
184
+ }
185
+ // Creation order is execution order within one transcript, but subagent transcripts are
186
+ // read after the main one when loading a session: order by time, not by arrival.
187
+ // Subagent transcripts are read after the main one: order by time, not by arrival.
188
+ let last = '';
189
+ const keyed = rows.map((r, i) => ({ r, i, t: (last = r.startedAt ?? last) }));
190
+ keyed.sort((x, y) => (x.t < y.t ? -1 : x.t > y.t ? 1 : x.i - y.i));
191
+ return keyed.map((k) => k.r);
192
+ }
193
+ // ───────────────────────────── node helpers
194
+ newNode(type, parent, title, extra = {}) {
195
+ const id = `n${++this.seq}`;
196
+ const node = { id, type, parent, children: [], title, status: 'pending', ...extra };
197
+ this.nodes.set(id, node);
198
+ this.dirty.add(id);
199
+ if (parent) {
200
+ const p = this.nodes.get(parent);
201
+ if (p) {
202
+ p.children.push(id);
203
+ this.dirty.add(parent);
204
+ }
205
+ }
206
+ return node;
207
+ }
208
+ touch(id) {
209
+ const n = this.nodes.get(id);
210
+ if (n)
211
+ this.dirty.add(id);
212
+ return n;
213
+ }
214
+ setStatus(id, status, ts) {
215
+ const n = this.touch(id);
216
+ if (!n || n.status === status)
217
+ return;
218
+ if (status === 'active' && !n.startedAt)
219
+ n.startedAt = ts;
220
+ // Reopened (a follow-up reply, a step picked up again): it ends again later.
221
+ if (status === 'active')
222
+ delete n.endedAt;
223
+ if ((status === 'done' || status === 'failed' || status === 'resolved') && !n.endedAt)
224
+ n.endedAt = ts;
225
+ n.status = status;
226
+ }
227
+ removeNode(id) {
228
+ const n = this.nodes.get(id);
229
+ if (!n)
230
+ return;
231
+ for (const c of [...n.children])
232
+ this.removeNode(c);
233
+ if (n.parent) {
234
+ const p = this.touch(n.parent);
235
+ if (p)
236
+ p.children = p.children.filter((c) => c !== id);
237
+ }
238
+ this.nodes.delete(id);
239
+ this.dirty.add(id);
240
+ for (const g of this.goalCtx.values()) {
241
+ g.plan = g.plan.filter((s) => s !== id);
242
+ if (g.current === id)
243
+ g.current = null;
244
+ if (g.unit === id)
245
+ g.unit = g.current;
246
+ if (g.pending === id)
247
+ g.pending = null;
248
+ }
249
+ }
250
+ addChunk(node, kind, text, ts, label, failed) {
251
+ const id = this.chunks.length;
252
+ const t = text.length > MAX_CHUNK ? text.slice(0, MAX_CHUNK) + `\n… [truncated ${text.length - MAX_CHUNK} chars]` : text;
253
+ this.chunks.push({ id, node, kind, text: t, ts, label, failed });
254
+ const list = this.nodeChunks.get(node) ?? [];
255
+ list.push(id);
256
+ this.nodeChunks.set(node, list);
257
+ return id;
258
+ }
259
+ moveChunk(chunkId, to, kind) {
260
+ const c = this.chunks[chunkId];
261
+ if (!c)
262
+ return;
263
+ const from = this.nodeChunks.get(c.node);
264
+ if (from)
265
+ this.nodeChunks.set(c.node, from.filter((x) => x !== chunkId));
266
+ c.node = to;
267
+ if (kind)
268
+ c.kind = kind;
269
+ const list = this.nodeChunks.get(to) ?? [];
270
+ list.push(chunkId);
271
+ this.nodeChunks.set(to, list);
272
+ }
273
+ /** Adds a response's tokens to the run, its model, and every goal/step/subtask it happened in. */
274
+ /** Per subagent scope: last recorded output, the true final one, and the correction applied. */
275
+ outFix = new Map();
276
+ onUsage(ev) {
277
+ if (ev.scope) {
278
+ const f = this.outFix.get(ev.scope) ?? { last: 0, applied: 0, model: ev.model };
279
+ this.outFix.set(ev.scope, f);
280
+ if (ev.final)
281
+ f.final = ev.output;
282
+ else
283
+ f.last = ev.output;
284
+ if (ev.model && (ev.final || !f.model))
285
+ f.model = ev.model;
286
+ // Without a model to file it under, wait for the subagent's own records.
287
+ const want = f.final === undefined || !f.model ? 0 : Math.max(0, f.final - f.last);
288
+ const d = want - f.applied;
289
+ f.applied = want;
290
+ if (ev.final || d) {
291
+ if (d)
292
+ this.credit({ type: 'usage', scope: ev.scope, model: f.model, input: 0, output: d, cacheRead: 0, cacheWrite: 0 }, false);
293
+ if (ev.final)
294
+ return;
295
+ }
296
+ }
297
+ this.credit(ev, true);
298
+ }
299
+ credit(ev, response) {
300
+ const add = (u) => {
301
+ u.input += ev.input;
302
+ u.output += ev.output;
303
+ u.cacheRead += ev.cacheRead;
304
+ u.cacheWrite += ev.cacheWrite;
305
+ if (response)
306
+ u.responses++;
307
+ };
308
+ const ru = (this.run.usage ??= { ...zeroUsage(), byModel: {}, perMinute: [] });
309
+ add(ru);
310
+ add((ru.byModel[ev.model] ??= zeroUsage()));
311
+ if (!ev.scope && response) {
312
+ ru.context = ev.input + ev.cacheRead + ev.cacheWrite;
313
+ ru.contextModel = ev.model;
314
+ }
315
+ if (ev.ts) {
316
+ if (!ru.lastAt || ev.ts > ru.lastAt)
317
+ ru.lastAt = ev.ts;
318
+ const minute = Math.floor(Date.parse(ev.ts) / 60000);
319
+ const fresh = ev.input + ev.cacheWrite + ev.output;
320
+ if (Number.isFinite(minute)) {
321
+ const slot = ru.perMinute.find((p) => p[0] === minute);
322
+ if (slot)
323
+ slot[1] += fresh;
324
+ else {
325
+ ru.perMinute.push([minute, fresh]);
326
+ ru.perMinute.sort((a, b) => a[0] - b[0]);
327
+ if (ru.perMinute.length > 240)
328
+ ru.perMinute.splice(0, ru.perMinute.length - 240);
329
+ }
330
+ }
331
+ }
332
+ let id = ev.scope ? this.scopes.get(ev.scope) : this.focusNode();
333
+ while (id) {
334
+ const n = this.nodes.get(id);
335
+ if (!n)
336
+ break;
337
+ // The main agent's own responses never count toward a subagent, even while the subagent
338
+ // holds the focus (its actions arrive interleaved with the main agent's when watched live).
339
+ const foreign = !ev.scope && n.type === 'subtask' && n.origin === 'subagent';
340
+ if (!foreign && (n.type === 'goal' || n.type === 'step' || n.type === 'subtask')) {
341
+ add((n.usage ??= zeroUsage()));
342
+ if (!n.models?.includes(ev.model))
343
+ (n.models ??= []).push(ev.model);
344
+ this.dirty.add(n.id);
345
+ }
346
+ id = n.parent ?? undefined;
347
+ }
348
+ }
349
+ unitCtx(id) {
350
+ let u = this.units.get(id);
351
+ if (!u) {
352
+ u = { failStreak: 0 };
353
+ this.units.set(id, u);
354
+ }
355
+ return u;
356
+ }
357
+ currentGoal() {
358
+ return this.run.currentGoal ? this.goalCtx.get(this.run.currentGoal) : undefined;
359
+ }
360
+ ensureGoal(ts) {
361
+ return this.currentGoal() ?? this.newGoal(this.run.title ?? 'Session', ts);
362
+ }
363
+ focusNode() {
364
+ const g = this.currentGoal();
365
+ return g ? (g.unit ?? g.current ?? g.id) : undefined;
366
+ }
367
+ syncRunPointers() {
368
+ const g = this.currentGoal();
369
+ const step = g?.current ?? undefined;
370
+ const unit = g?.unit ?? undefined;
371
+ if (this.run.currentStep !== step || this.run.currentUnit !== unit) {
372
+ this.run.currentStep = step;
373
+ this.run.currentUnit = unit;
374
+ }
375
+ }
376
+ /** Steps (in order) of a goal: planned and inferred. */
377
+ stepsOf(g) {
378
+ return (this.nodes.get(g.id)?.children ?? []).filter((c) => this.nodes.get(c)?.type === 'step');
379
+ }
380
+ // ───────────────────────────── goals
381
+ newGoal(prompt, ts) {
382
+ const title = truncate(stripMarkdown(firstLine(prompt, 300)), 110) || 'Task';
383
+ const n = this.newNode('goal', null, title, { status: 'active', startedAt: ts, stats: { actions: 0, errors: 0 } });
384
+ const g = {
385
+ id: n.id,
386
+ plan: [],
387
+ planSource: 'none',
388
+ planKeys: new Map(),
389
+ tentative: null,
390
+ tentativeChunk: null,
391
+ current: null,
392
+ unit: null,
393
+ pending: null,
394
+ lastTextChunk: null,
395
+ actionsSinceText: 0,
396
+ intentCandidate: null,
397
+ };
398
+ this.goalCtx.set(n.id, g);
399
+ this.run.goals.push(n.id);
400
+ this.run.currentGoal = n.id;
401
+ return g;
402
+ }
403
+ finishGoal(g, ts) {
404
+ if (g.tentative)
405
+ this.dropTentative(g);
406
+ // A text plan is the agent's own announcement, not a tracked checklist: when the task
407
+ // ends normally after work on it, steps never told apart were done along with the others.
408
+ const worked = g.planSource === 'text' && !g.interrupted && g.plan.some((id) => this.nodes.get(id)?.status !== 'pending');
409
+ for (const id of this.stepsOf(g)) {
410
+ const s = this.nodes.get(id);
411
+ if (s.status === 'active')
412
+ this.setStatus(id, 'done', ts);
413
+ else if (worked && s.status === 'pending' && s.origin === 'plan' && g.plan.includes(id)) {
414
+ this.touch(id).untracked = true;
415
+ this.setStatus(id, 'done', ts);
416
+ }
417
+ }
418
+ if (g.unit && this.nodes.get(g.unit)?.type === 'subtask' && this.nodes.get(g.unit)?.status === 'active') {
419
+ this.setStatus(g.unit, 'done', ts);
420
+ }
421
+ this.setStatus(g.id, 'done', ts);
422
+ }
423
+ onPrompt(text, ts, scope, raw) {
424
+ if (scope) {
425
+ const n = this.scopes.get(scope);
426
+ if (n)
427
+ this.addChunk(n, 'prompt', raw ?? text, ts, 'Task given to subagent');
428
+ return;
429
+ }
430
+ // A message sent while the agent is still in its turn is an interjection, not a new task.
431
+ const midTurn = this.run.status === 'working';
432
+ this.run.status = 'working';
433
+ const cur = this.currentGoal();
434
+ // After the turn, a short message continues the task only if it answers the agent
435
+ // ("yes", "go ahead", or anything after a question); "all done, pushed" is a new card.
436
+ if (!cur || (!midTurn && !isReply(text, this.agentAsked))) {
437
+ if (cur)
438
+ this.finishGoal(cur, ts);
439
+ const g = this.newGoal(text, ts);
440
+ this.addChunk(g.id, 'prompt', raw ?? text, ts, 'Prompt');
441
+ return;
442
+ }
443
+ this.setStatus(cur.id, 'active', ts);
444
+ const target = cur.unit ?? cur.current ?? cur.id;
445
+ const n = this.newNode('activity', target, `You: ${truncate(text, 80)}`, { kind: 'user', status: 'done', startedAt: ts });
446
+ this.addChunk(n.id, 'prompt', raw ?? text, ts, 'User reply');
447
+ cur.lastTextChunk = null;
448
+ }
449
+ // ───────────────────────────── narration
450
+ onText(text, ts, scope) {
451
+ if (!text.trim())
452
+ return;
453
+ if (!scope)
454
+ this.agentAsked = endsWithQuestion(text);
455
+ if (scope) {
456
+ const n = this.scopes.get(scope);
457
+ if (n) {
458
+ this.addChunk(n, 'text', text, ts);
459
+ const node = this.touch(n);
460
+ node.detail = intentTitle(text, 120);
461
+ }
462
+ return;
463
+ }
464
+ const g = this.ensureGoal(ts);
465
+ const goal = this.nodes.get(g.id);
466
+ this.setStatus(g.id, 'active', ts);
467
+ this.run.status = 'working';
468
+ // A plan?
469
+ if (g.planSource !== 'tool') {
470
+ const started = g.plan.some((id) => this.nodes.get(id)?.status !== 'pending');
471
+ const plan = extractPlan(text, started);
472
+ if (plan) {
473
+ this.setTextPlan(g, plan, text, ts);
474
+ return;
475
+ }
476
+ }
477
+ if (!goal.intent && g.intentCandidate === null)
478
+ g.intentCandidate = text;
479
+ // Consecutive narration with no action in between: same unit.
480
+ if (g.pending) {
481
+ const p = this.touch(g.pending);
482
+ g.lastTextChunk = this.addChunk(g.pending, 'text', text, ts);
483
+ g.actionsSinceText = 0;
484
+ if (!hasIntent(p.title) && hasIntent(text) && p.origin !== 'plan')
485
+ p.title = intentTitle(text);
486
+ return;
487
+ }
488
+ const unitFailing = g.unit ? this.unitCtx(g.unit).failStreak > 0 : false;
489
+ const hasPlan = g.plan.length > 0 || (g.tentative?.length ?? 0) > 0;
490
+ if (hasPlan && !g.tentative) {
491
+ const infos = g.plan.map((id) => {
492
+ const n = this.nodes.get(id);
493
+ return { title: n.title, done: n.status === 'done' };
494
+ });
495
+ const curIdx = g.current ? g.plan.indexOf(g.current) : -1;
496
+ const idx = g.planSource === 'text' ? matchStep(text, infos, curIdx) : -1;
497
+ if (idx >= 0) {
498
+ const target = g.plan[idx];
499
+ if (target !== g.current)
500
+ this.activateStep(g, target, ts);
501
+ else
502
+ this.closeSubtask(g, ts);
503
+ g.lastTextChunk = this.addChunk(target, 'text', text, ts);
504
+ g.actionsSinceText = 0;
505
+ return;
506
+ }
507
+ if (!g.current) {
508
+ const next = g.plan.find((id) => this.nodes.get(id)?.status !== 'done');
509
+ if (next) {
510
+ this.activateStep(g, next, ts);
511
+ g.lastTextChunk = this.addChunk(next, 'text', text, ts);
512
+ g.actionsSinceText = 0;
513
+ return;
514
+ }
515
+ }
516
+ }
517
+ if (g.current && (unitFailing || (hasPlan && hasDeviationCue(text)))) {
518
+ // Unplanned work arising inside the current step.
519
+ if (!(unitFailing && this.nodes.get(g.unit)?.type === 'subtask')) {
520
+ const sub = this.newNode('subtask', g.current, intentTitle(text), {
521
+ origin: 'unplanned',
522
+ status: 'active',
523
+ startedAt: ts,
524
+ stats: { actions: 0, errors: 0 },
525
+ context: { files: [], commands: [], tools: [] },
526
+ });
527
+ if (g.unit && g.unit !== g.current)
528
+ this.closeSubtask(g, ts);
529
+ g.unit = sub.id;
530
+ g.pending = sub.id;
531
+ }
532
+ g.lastTextChunk = this.addChunk(g.unit, 'text', text, ts);
533
+ g.actionsSinceText = 0;
534
+ return;
535
+ }
536
+ if (hasPlan) {
537
+ // Plan exists, narration is part of the current step.
538
+ this.closeSubtask(g, ts);
539
+ const target = g.current ?? g.id;
540
+ g.lastTextChunk = this.addChunk(target, 'text', text, ts);
541
+ g.actionsSinceText = 0;
542
+ return;
543
+ }
544
+ // No plan: every narration that leads to action becomes a new inferred step.
545
+ const step = this.newNode('step', g.id, intentTitle(text), {
546
+ origin: 'inferred',
547
+ stats: { actions: 0, errors: 0 },
548
+ context: { files: [], commands: [], tools: [] },
549
+ });
550
+ this.activateStep(g, step.id, ts);
551
+ g.pending = step.id;
552
+ g.lastTextChunk = this.addChunk(step.id, 'text', text, ts);
553
+ g.actionsSinceText = 0;
554
+ }
555
+ closeSubtask(g, ts) {
556
+ if (g.unit && g.unit !== g.current) {
557
+ const u = this.nodes.get(g.unit);
558
+ if (u?.type === 'subtask' && u.status === 'active')
559
+ this.setStatus(u.id, 'done', ts);
560
+ }
561
+ g.unit = g.current;
562
+ }
563
+ activateStep(g, id, ts) {
564
+ const prev = g.current;
565
+ this.closeSubtask(g, ts);
566
+ if (prev && prev !== id) {
567
+ const ti = g.plan.indexOf(id);
568
+ if (ti >= 0 && g.planSource !== 'tool') {
569
+ for (let i = 0; i < ti; i++) {
570
+ const s = this.nodes.get(g.plan[i]);
571
+ if (s.status === 'pending' || s.status === 'active')
572
+ this.setStatus(s.id, 'done', ts);
573
+ }
574
+ }
575
+ const p = this.nodes.get(prev);
576
+ if (p && p.status === 'active' && (g.planSource !== 'tool' || !g.plan.includes(prev)))
577
+ this.setStatus(prev, 'done', ts);
578
+ }
579
+ this.setStatus(id, 'active', ts);
580
+ g.current = id;
581
+ g.unit = id;
582
+ g.pending = null;
583
+ }
584
+ // ───────────────────────────── plans
585
+ planStep(g, title, extra = {}) {
586
+ return this.newNode('step', g.id, title, {
587
+ origin: 'plan',
588
+ stats: { actions: 0, errors: 0 },
589
+ context: { files: [], commands: [], tools: [] },
590
+ ...extra,
591
+ });
592
+ }
593
+ setTextPlan(g, plan, text, ts) {
594
+ if (g.tentative)
595
+ this.dropTentative(g);
596
+ const goal = this.nodes.get(g.id);
597
+ if (!goal.intent)
598
+ g.intentCandidate = plan.intro || text;
599
+ g.tentativeChunk = this.addChunk(g.id, 'plan', text, ts, 'Plan');
600
+ g.tentative = plan.items.map((t) => this.planStep(g, t, { tentative: true }).id);
601
+ g.lastTextChunk = g.tentativeChunk;
602
+ g.actionsSinceText = 0;
603
+ if (g.pending) {
604
+ // A narration-only unit right before the plan: fold it away.
605
+ const p = this.nodes.get(g.pending);
606
+ if (p && p.children.length === 0) {
607
+ for (const c of [...(this.nodeChunks.get(p.id) ?? [])])
608
+ this.moveChunk(c, g.id);
609
+ this.removeNode(p.id);
610
+ }
611
+ g.pending = null;
612
+ }
613
+ }
614
+ dropTentative(g) {
615
+ for (const id of g.tentative ?? [])
616
+ this.removeNode(id);
617
+ g.tentative = null;
618
+ g.tentativeChunk = null;
619
+ }
620
+ confirmTentative(g, ts) {
621
+ const ids = g.tentative;
622
+ g.tentative = null;
623
+ g.tentativeChunk = null;
624
+ for (const id of g.plan) {
625
+ const n = this.nodes.get(id);
626
+ if (n && n.status === 'pending' && n.children.length === 0)
627
+ this.removeNode(id);
628
+ else if (n && n.status === 'active')
629
+ this.setStatus(id, 'done', ts);
630
+ }
631
+ for (const id of ids) {
632
+ const n = this.touch(id);
633
+ if (n)
634
+ delete n.tentative;
635
+ }
636
+ g.plan = ids;
637
+ g.planSource = 'text';
638
+ this.activateStep(g, ids[0], ts);
639
+ }
640
+ planKey(item) {
641
+ return item.id ? `id:${item.id}` : `t:${(item.title ?? '').toLowerCase().replace(/\s+/g, ' ').trim()}`;
642
+ }
643
+ adoptToolPlan(g) {
644
+ if (g.tentative)
645
+ this.dropTentative(g);
646
+ if (g.planSource === 'tool')
647
+ return;
648
+ for (const id of g.plan) {
649
+ const n = this.nodes.get(id);
650
+ if (n && n.status === 'pending' && n.children.length === 0)
651
+ this.removeNode(id);
652
+ }
653
+ g.plan = [];
654
+ g.planSource = 'tool';
655
+ }
656
+ applyPlanStatus(g, id, status, ts) {
657
+ if (!status)
658
+ return;
659
+ if (status === 'in_progress') {
660
+ if (g.current !== id) {
661
+ this.closeSubtask(g, ts);
662
+ this.setStatus(id, 'active', ts);
663
+ g.current = id;
664
+ g.unit = id;
665
+ g.pending = null;
666
+ }
667
+ }
668
+ else {
669
+ this.setStatus(id, status === 'completed' ? 'done' : 'pending', ts);
670
+ }
671
+ }
672
+ onPlanSet(ev) {
673
+ const g = this.ensureGoal(ev.ts);
674
+ this.adoptToolPlan(g);
675
+ this.run.status = 'working';
676
+ if (ev.raw)
677
+ this.addChunk(g.id, 'plan', ev.raw, ev.ts, `Plan (${ev.source})`);
678
+ const seen = new Set();
679
+ const ordered = [];
680
+ for (const item of ev.items) {
681
+ const key = this.planKey(item);
682
+ seen.add(key);
683
+ let id = g.planKeys.get(key);
684
+ if (!id || !this.nodes.get(id)) {
685
+ id = this.planStep(g, truncate(stripMarkdown(item.title), 110)).id;
686
+ g.planKeys.set(key, id);
687
+ if (item.id)
688
+ g.planKeys.set(this.planKey({ title: item.title }), id);
689
+ }
690
+ else {
691
+ const n = this.touch(id);
692
+ n.title = truncate(stripMarkdown(item.title), 110);
693
+ }
694
+ ordered.push(id);
695
+ }
696
+ if (ev.replace) {
697
+ for (const [key, id] of g.planKeys) {
698
+ if (seen.has(key) || ordered.includes(id))
699
+ continue;
700
+ const n = this.nodes.get(id);
701
+ if (n && n.status === 'pending' && n.children.length === 0)
702
+ this.removeNode(id);
703
+ g.planKeys.delete(key);
704
+ }
705
+ const kept = g.plan.filter((id) => !ordered.includes(id) && this.nodes.has(id));
706
+ g.plan = [...kept, ...ordered];
707
+ }
708
+ else {
709
+ for (const id of ordered)
710
+ if (!g.plan.includes(id))
711
+ g.plan.push(id);
712
+ }
713
+ ev.items.forEach((item, i) => this.applyPlanStatus(g, ordered[i], item.status, ev.ts));
714
+ }
715
+ onPlanUpdate(ev) {
716
+ const g = this.currentGoal();
717
+ if (!g)
718
+ return;
719
+ const id = g.planKeys.get(this.planKey({ id: ev.id })) ?? (ev.title ? g.planKeys.get(this.planKey({ title: ev.title })) : undefined);
720
+ if (!id || !this.nodes.has(id))
721
+ return;
722
+ if (ev.deleted) {
723
+ const n = this.nodes.get(id);
724
+ if (n.children.length === 0)
725
+ this.removeNode(id);
726
+ return;
727
+ }
728
+ if (ev.title)
729
+ this.touch(id).title = truncate(stripMarkdown(ev.title), 110);
730
+ this.applyPlanStatus(g, id, ev.status, ev.ts);
731
+ }
732
+ // ───────────────────────────── actions
733
+ /**
734
+ * A success marks earlier failures as recovered: those in the same unit, and in the units
735
+ * it sits in (a fix subtask recovers the step's failed test). Not downwards: a step's
736
+ * success says nothing about a subagent's failure.
737
+ */
738
+ recover(unitId) {
739
+ let id = unitId;
740
+ while (id) {
741
+ const n = this.nodes.get(id);
742
+ if (!n || n.type === 'goal')
743
+ break;
744
+ const uc = this.units.get(id);
745
+ for (const a of uc?.pendingFails ?? []) {
746
+ const act = this.nodes.get(a);
747
+ if (!act || act.recovered)
748
+ continue;
749
+ act.recovered = true;
750
+ this.dirty.add(a);
751
+ const owner = act.parent;
752
+ if (owner)
753
+ this.bump(owner, 'recovered');
754
+ }
755
+ if (uc)
756
+ uc.pendingFails = [];
757
+ id = n.parent;
758
+ }
759
+ }
760
+ bump(fromId, field) {
761
+ let id = fromId;
762
+ while (id) {
763
+ const n = this.nodes.get(id);
764
+ if (!n)
765
+ break;
766
+ if (n.stats) {
767
+ n.stats[field] = (n.stats[field] ?? 0) + 1;
768
+ this.dirty.add(id);
769
+ }
770
+ id = n.parent;
771
+ }
772
+ }
773
+ addContext(fromId, ev) {
774
+ let id = fromId;
775
+ while (id) {
776
+ const n = this.nodes.get(id);
777
+ if (!n || n.type === 'goal')
778
+ break;
779
+ if (n.context) {
780
+ const push = (arr, v) => {
781
+ if (!arr.includes(v)) {
782
+ arr.push(v);
783
+ if (arr.length > MAX_CONTEXT_ITEMS)
784
+ arr.shift();
785
+ }
786
+ };
787
+ for (const f of ev.files ?? [])
788
+ push(n.context.files, f);
789
+ if (ev.kind === 'command')
790
+ push(n.context.commands, truncate(ev.command ?? ev.label, 60));
791
+ push(n.context.tools, ev.tool);
792
+ this.dirty.add(id);
793
+ }
794
+ if (n.type === 'step')
795
+ break;
796
+ id = n.parent;
797
+ }
798
+ }
799
+ onToolStart(ev) {
800
+ let unit;
801
+ if (ev.scope) {
802
+ const s = this.scopes.get(ev.scope);
803
+ if (!s)
804
+ return;
805
+ unit = s;
806
+ }
807
+ else {
808
+ const g = this.ensureGoal(ev.ts);
809
+ this.run.status = ev.kind === 'ask' ? 'waiting' : 'working';
810
+ if (g.tentative)
811
+ this.confirmTentative(g, ev.ts);
812
+ if (!g.unit) {
813
+ const next = g.plan.find((id) => this.nodes.get(id)?.status !== 'done');
814
+ if (next)
815
+ this.activateStep(g, next, ev.ts);
816
+ else {
817
+ const step = this.newNode('step', g.id, ev.label, {
818
+ origin: 'inferred',
819
+ stats: { actions: 0, errors: 0 },
820
+ context: { files: [], commands: [], tools: [] },
821
+ });
822
+ this.activateStep(g, step.id, ev.ts);
823
+ }
824
+ }
825
+ unit = g.unit;
826
+ g.pending = null;
827
+ g.actionsSinceText++;
828
+ const goal = this.nodes.get(g.id);
829
+ if (!goal.intent && g.intentCandidate) {
830
+ const intent = intentTitle(g.intentCandidate, 160);
831
+ // "Do this in three steps" says nothing the goal title doesn't.
832
+ if (!/^(?:do|tackle|handle|approach|break) (?:this|it)\b/i.test(intent) && intent.split(/\s+/).length >= 3) {
833
+ goal.intent = intent;
834
+ this.touch(g.id);
835
+ }
836
+ }
837
+ g.intentCandidate = null;
838
+ }
839
+ const uc = this.unitCtx(unit);
840
+ const retry = !!uc.lastFail && uc.lastFail.tool === ev.tool && similarity(tokens(uc.lastFail.label), tokens(ev.label)) >= 0.5;
841
+ const node = ev.spawnsAgent
842
+ ? this.newNode('subtask', unit, ev.label, {
843
+ origin: 'subagent',
844
+ kind: 'agent',
845
+ tool: ev.tool,
846
+ detail: ev.detail,
847
+ status: 'active',
848
+ startedAt: ev.ts,
849
+ stats: { actions: 0, errors: 0 },
850
+ context: { files: [], commands: [], tools: [] },
851
+ })
852
+ : this.newNode('activity', unit, ev.label, {
853
+ kind: ev.kind,
854
+ tool: ev.tool,
855
+ detail: ev.detail,
856
+ files: ev.files,
857
+ status: 'active',
858
+ startedAt: ev.ts,
859
+ });
860
+ if (retry)
861
+ node.retry = true;
862
+ if (ev.spawnsAgent)
863
+ this.scopes.set(ev.callId, node.id);
864
+ this.calls.set(ev.callId, { node: node.id, unit, tool: ev.tool, label: ev.label });
865
+ this.addChunk(node.id, 'tool', ev.raw ?? ev.detail ?? ev.label, ev.ts, ev.label);
866
+ this.bump(unit, 'actions');
867
+ this.addContext(unit, ev);
868
+ const held = ev.spawnsAgent ? this.orphans.get(ev.callId) : undefined;
869
+ if (held) {
870
+ this.orphans.delete(ev.callId);
871
+ for (const h of held)
872
+ this.apply(h);
873
+ }
874
+ }
875
+ onToolEnd(ev) {
876
+ const c = this.calls.get(ev.callId);
877
+ if (!c)
878
+ return;
879
+ const node = this.nodes.get(c.node);
880
+ if (!node)
881
+ return;
882
+ if (ev.background) {
883
+ // Launched, not finished: keep it running, and keep the launch message.
884
+ this.addChunk(c.node, 'result', ev.output ?? '', ev.ts, 'Started in the background', false);
885
+ return;
886
+ }
887
+ // A background call reports once; repeats (a resumed agent stopping again) only add output.
888
+ if (node.status === 'done' || node.status === 'failed') {
889
+ if (ev.output)
890
+ this.addChunk(c.node, 'result', ev.output, ev.ts, 'Update', !ev.ok);
891
+ return;
892
+ }
893
+ if (!ev.scope) {
894
+ const wasAsk = node.kind === 'ask';
895
+ if (wasAsk || this.run.status === 'waiting')
896
+ this.run.status = 'working';
897
+ }
898
+ this.setStatus(c.node, ev.ok ? 'done' : 'failed', ev.ts);
899
+ const rc = this.addChunk(c.node, 'result', ev.output ?? '', ev.ts, ev.ok ? 'Result' : 'Error', !ev.ok);
900
+ if (ev.patch?.length) {
901
+ // Keep the diff bounded: huge rewrites are summarised by their counts.
902
+ let budget = MAX_PATCH_LINES;
903
+ const hunks = [];
904
+ let add = 0;
905
+ let del = 0;
906
+ for (const h of ev.patch) {
907
+ for (const l of h.lines) {
908
+ if (l.startsWith('+'))
909
+ add++;
910
+ else if (l.startsWith('-'))
911
+ del++;
912
+ }
913
+ if (budget > 0)
914
+ hunks.push(h.lines.length > budget ? { ...h, lines: [...h.lines.slice(0, budget), ' …'] } : h);
915
+ budget -= h.lines.length;
916
+ }
917
+ this.chunks[rc].patch = hunks;
918
+ node.diff = { add, del };
919
+ }
920
+ const uc = this.unitCtx(c.unit);
921
+ if (!ev.ok) {
922
+ node.issue = ev.rejected ? 'Rejected by user' : issueSummary(ev.output ?? '');
923
+ uc.failStreak++;
924
+ uc.lastFail = { tool: c.tool, label: c.label };
925
+ (uc.pendingFails ??= []).push(c.node);
926
+ this.bump(c.unit, 'errors');
927
+ if (ev.rejected) {
928
+ this.blocker(c.unit, `Rejected: ${c.label}`, ev.ts, ev.output ?? 'The action was rejected.');
929
+ }
930
+ else if (uc.failStreak >= 2 && !uc.openBlocker) {
931
+ this.blocker(c.unit, node.issue, ev.ts, ev.output ?? '', `${uc.failStreak} failed attempts`);
932
+ }
933
+ else if (uc.openBlocker) {
934
+ const b = this.touch(uc.openBlocker);
935
+ if (b)
936
+ b.detail = `${uc.failStreak} failed attempts`;
937
+ }
938
+ }
939
+ else {
940
+ uc.failStreak = 0;
941
+ this.recover(c.unit);
942
+ if (uc.openBlocker) {
943
+ this.setStatus(uc.openBlocker, 'resolved', ev.ts);
944
+ uc.openBlocker = undefined;
945
+ }
946
+ }
947
+ }
948
+ blocker(parent, title, ts, text, detail) {
949
+ const b = this.newNode('blocker', parent, truncate(title, 120), { status: 'open', startedAt: ts, detail });
950
+ this.addChunk(b.id, 'notice', text, ts, 'Blocker');
951
+ this.unitCtx(parent).openBlocker = b.id;
952
+ }
953
+ onNotice(ev) {
954
+ let target;
955
+ if (ev.scope) {
956
+ const s = this.scopes.get(ev.scope);
957
+ if (!s)
958
+ return;
959
+ target = s;
960
+ }
961
+ else {
962
+ const g = this.ensureGoal(ev.ts);
963
+ target = g.unit ?? g.current ?? g.id;
964
+ }
965
+ if (ev.blocking) {
966
+ this.blocker(target, ev.text, ev.ts, ev.raw ?? ev.text);
967
+ return;
968
+ }
969
+ const n = this.newNode('note', target, truncate(ev.text, 140), { level: ev.level, status: 'done', startedAt: ev.ts });
970
+ this.addChunk(n.id, 'notice', ev.raw ?? ev.text, ev.ts, 'Notice');
971
+ }
972
+ onTurnEnd(ts, interrupted) {
973
+ this.run.status = 'waiting';
974
+ const g = this.currentGoal();
975
+ if (!g)
976
+ return;
977
+ g.interrupted = !!interrupted;
978
+ const goal = this.touch(g.id);
979
+ // The last words after the last action are the outcome, not a step.
980
+ if (g.tentative) {
981
+ const chunk = g.tentativeChunk;
982
+ this.dropTentative(g);
983
+ if (chunk !== null) {
984
+ this.moveChunk(chunk, g.id, 'outcome');
985
+ goal.outcome = outcomeText(this.chunks[chunk].text);
986
+ }
987
+ }
988
+ else if (g.pending) {
989
+ const p = this.nodes.get(g.pending);
990
+ if (p.children.length === 0) {
991
+ const chunkIds = [...(this.nodeChunks.get(p.id) ?? [])];
992
+ const text = chunkIds.map((c) => this.chunks[c].text).join('\n\n');
993
+ for (const c of chunkIds)
994
+ this.moveChunk(c, g.id, this.chunks[c].kind === 'text' ? 'outcome' : undefined);
995
+ const wasCurrent = g.current === p.id;
996
+ const parent = p.parent;
997
+ this.removeNode(p.id);
998
+ if (wasCurrent) {
999
+ const steps = this.stepsOf(g);
1000
+ g.current = steps.length ? steps[steps.length - 1] : null;
1001
+ }
1002
+ g.unit = g.current;
1003
+ if (parent && parent !== g.id && this.nodes.get(parent)?.type === 'step')
1004
+ g.unit = g.current;
1005
+ goal.outcome = outcomeText(text);
1006
+ }
1007
+ g.pending = null;
1008
+ }
1009
+ else if (g.lastTextChunk !== null && g.actionsSinceText === 0) {
1010
+ this.moveChunk(g.lastTextChunk, g.id, 'outcome');
1011
+ goal.outcome = outcomeText(this.chunks[g.lastTextChunk].text);
1012
+ }
1013
+ g.lastTextChunk = null;
1014
+ g.intentCandidate = null;
1015
+ if (interrupted) {
1016
+ const target = g.unit ?? g.current ?? g.id;
1017
+ const n = this.newNode('note', target, 'Interrupted by user', { level: 'warning', status: 'done', startedAt: ts });
1018
+ this.addChunk(n.id, 'notice', 'Interrupted by user', ts, 'Notice');
1019
+ }
1020
+ this.closeSubtask(g, ts);
1021
+ if (g.current && g.planSource !== 'tool') {
1022
+ const isLastPlanned = g.plan.length > 0 && g.plan[g.plan.length - 1] === g.current;
1023
+ if (g.plan.length === 0 || isLastPlanned || !g.plan.includes(g.current))
1024
+ this.setStatus(g.current, 'done', ts);
1025
+ }
1026
+ // The agent finished its turn: the task is done, unless it was cut short. A follow-up
1027
+ // reply reopens it (see onPrompt).
1028
+ this.setStatus(g.id, interrupted ? 'waiting' : 'done', ts);
1029
+ }
1030
+ }
1031
+ function outcomeText(text) {
1032
+ const ss = sentences(text);
1033
+ return truncate(ss.slice(0, 2).join(' ') || stripMarkdown(text), 240);
1034
+ }