@north-light/crouter 0.3.252 → 0.3.254

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/api/dto/config.d.ts +2 -0
  2. package/dist/builtin-memory/04-base-worker-exploring.md +13 -0
  3. package/dist/builtin-memory/04-base-worker.md +1 -4
  4. package/dist/builtin-memory/05-kinds/explore/00-base.md +2 -2
  5. package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +4 -2
  6. package/dist/builtin-memory/explore/exploration-doc.md +27 -0
  7. package/dist/clients/attach/__tests__/group-activity.test.js +81 -0
  8. package/dist/clients/attach/render/group-activity.d.ts +9 -1
  9. package/dist/clients/attach/render/group-activity.js +75 -111
  10. package/dist/clients/attach/render/group-recap.js +24 -9
  11. package/dist/clients/attach/viewer.js +508 -509
  12. package/dist/commands/memory/delete.js +2 -0
  13. package/dist/commands/node/lifecycle.js +21 -6
  14. package/dist/core/__tests__/daemon-boot.test.js +5 -5
  15. package/dist/core/__tests__/preview-mirror-cap.test.js +16 -0
  16. package/dist/core/exclusive-lock.js +13 -2
  17. package/dist/core/io.d.ts +11 -2
  18. package/dist/core/io.js +10 -11
  19. package/dist/core/runtime/revive.js +6 -1
  20. package/dist/core/substrate/surface-match.js +7 -0
  21. package/dist/daemon/__tests__/autostart-storm.test.d.ts +1 -0
  22. package/dist/daemon/__tests__/autostart-storm.test.js +166 -0
  23. package/dist/daemon/__tests__/integration/migration-startup.test.js +4 -7
  24. package/dist/daemon/api/handlers/nodes.js +124 -4
  25. package/dist/daemon/api/map.d.ts +1 -1
  26. package/dist/daemon/api/map.js +3 -2
  27. package/dist/daemon/crtrd-cli.js +15 -1
  28. package/dist/daemon/crtrd.js +14 -1
  29. package/dist/daemon/manage.d.ts +24 -3
  30. package/dist/daemon/manage.js +98 -12
  31. package/dist/daemon/startup-policy.d.ts +5 -0
  32. package/dist/daemon/startup-policy.js +5 -0
  33. package/dist/migrations/activation.d.ts +9 -0
  34. package/dist/migrations/activation.js +14 -1
  35. package/dist/pi-extensions/canvas-bash-valve.js +4 -0
  36. package/dist/pi-extensions/canvas-preview-result.js +32 -7
  37. package/dist/shared/generated-context.js +1 -1
  38. package/package.json +1 -1
  39. package/runtime.lock.json +2 -2
@@ -16,4 +16,6 @@ export interface NodeConfigPatch {
16
16
  lifecycle?: LifecycleDTO;
17
17
  /** Rename the node (and, when it has a live viewer window, that window). */
18
18
  name?: string;
19
+ /** Repair-only replacement launch directory; exclusive of every other field. */
20
+ cwd?: string;
19
21
  }
@@ -0,0 +1,13 @@
1
+ ---
2
+ kind: preference
3
+ when-and-why-to-read: When a node runs in base mode on any kind but explore, this preference should be read so orientation noise lands in a scout's window and comes back as a clean map instead of silting the context this node's real work runs in.
4
+ gate: {mode: base, not: {kind: explore}}
5
+ rationale: >-
6
+ Split from 04-base-worker: gated {mode: base} alone, this section told an explore base node to spawn an explore scout for its own assignment — circular, and in tension with kinds/explore/base's narrower promote-only rule. The section itself exists because the kernel's "understand before you delegate" line is orchestrator-gated, so base workers had no counterweight to mapping unfamiliar code in their own window: they spent their context on read-only exploration and yielded before the real work. The operative mechanism is context, not model-tier economics (Silas, 2026-08-30): exploration residue — dead ends, half-relevant files — degrades the window it lands in, so the scout's job is to absorb that noise and return a distilled map every later node, the spawner included, loads clean. The body avoids "weigh/consider/decide" verbs deliberately: an instruction to perform a cognitive act gets narrated ("I considered a scout and…"), so the always-consider behavior is carried structurally — spawn is the unmarked default, skip is gated behind an exception test models apply silently.
7
+ surfaces:
8
+ - on: boot
9
+ at: content
10
+ ---
11
+
12
+ ## Exploring
13
+ Fresh work on a surface you do not yet understand — a codebase, a system, a domain — starts with an `explore` node to chart it (`crtr node -h`), with `--mode orchestrate` when the surface is large or there are multiple questions. Charting it yourself bloats your context window; a scout spends its own window and hands back a distilled map or answer, and it will do a more thorough job. Skip the scout only when you already know the surface or it is small enough to read directly — waiting on one for a two-file change costs more than it saves.
@@ -5,7 +5,7 @@ gate: {mode: base}
5
5
  rationale: >-
6
6
  A base security reviewer handed its entire assignment to another base security reviewer, which repeated the move through a 35-node chain in under five minutes. The universal prompt had said to delegate any self-contained work while no base-mode layer told sub-kinds to work hands-on; exact sub-kind gating also meant the reviewer did not inherit its parent kind's base layer.
7
7
 
8
- The scout section exists because the kernel's "understand before you delegate" line is orchestrator-gated, so base workers had no counterweight to mapping unfamiliar code in their own window: they spent a strong-model context on read-only exploration and yielded before the real work. Explore defaults to a light tier, which is the mechanism the section leans on.
8
+ The scout section moved to 04-base-worker-exploring so its gate could exclude kind explore — gated {mode: base} alone, it told an explore base node to spawn an explore scout for its own assignment.
9
9
  surfaces:
10
10
  - on: boot
11
11
  at: content
@@ -13,6 +13,3 @@ surfaces:
13
13
 
14
14
  ## Execution vs promotion
15
15
  You are a base-node, which means you primarily handle tasks yourself. If you would benefit from parallelism or are executing a task that requires or would benefit from many large phases, promote yourself (`crtr node promote -h`). Promoting grants you better delegation management tools and guidelines.
16
-
17
- ## Exploring
18
- When the task sits in code you cannot yet map — you don't know which files it touches or which constraints hold — spawn 1–3 `explore` scout nodes to chart it (`crtr node -h`). A current-state map is a bounded outcome distinct from your assignment. Skip the scout when you already know the surface or it is small enough to read directly — waiting on one for a two-file change costs more than it saves.
@@ -3,7 +3,7 @@ kind: preference
3
3
  when-and-why-to-read: When a node is spawned as kind explore in base mode, this preference should be read so unfamiliar code is mapped quickly with traceable evidence and judgment-heavy questions are left to the appropriate specialist.
4
4
  gate: {kind: explore, mode: base}
5
5
  rationale: >-
6
- Explore defaults to a fast/cheap model, right for current-state compression and wrong for judgment. Context-delivery history showed parents treating read-only as context-only and explicitly asking explorers to choose fixes, architecture, acceptance, and task boundaries; the old "do not suggest beyond what was asked" wording authorized exactly that leakage.
6
+ Explore defaults to a fast/cheap model, right for current-state compression and wrong for judgment. Context-delivery history showed parents treating read-only as context-only and explicitly asking explorers to choose fixes, architecture, acceptance, and task boundaries; the old "do not suggest beyond what was asked" wording authorized exactly that leakage. The deliverable split (inline answer vs explore-<topic>.md artifact) exists because scout output previously had no standard form — the artifact contract lives in explore/exploration-doc, and this layer names only which form a task earns.
7
7
  surfaces:
8
8
  - on: boot
9
9
  at: content
@@ -16,4 +16,4 @@ Keep the result descriptive. Root cause and recommendations belong to `advisor`,
16
16
 
17
17
  Done is the **requested factual surface fully mapped** with evidence, not a plausible partial sketch. Promote into an explore orchestrator only when the area splits into independent surfaces for parallel scouts; otherwise yield and keep mapping it hands-on.
18
18
 
19
- Your deliverable is the complete findings — the current behavior, exact files and line numbers that support it, and the code paths or source-proven gotchas you traced. Your result IS the record whoever sent the task receives, so make it self-contained with concrete `file:line` references rather than pointing to notes kept elsewhere. Stop when the current-state question is answered; leave any requested diagnosis, recommendation, target design, acceptance criteria, or implementation breakdown unperformed.
19
+ Your deliverable takes one of two forms. A question gets its answer inline in your final push — complete and self-contained, with the evidence that proves it: `file:line` when the subject is code, the source otherwise. A mapping task gets an exploration doc — `explore-<topic>.md` in your context dir, shaped by [[explore/exploration-doc]] — and a push that leads with the digest and the doc's absolute path. When the task names an existing `explore-*.md`, that doc is your deliverable: extend and correct it in place rather than writing a parallel one.
@@ -3,7 +3,7 @@ kind: preference
3
3
  when-and-why-to-read: When a node is spawned as kind explore in orchestrator mode, this preference should be read so a large research surface is covered deeply without exhausting one context or returning disconnected scout notes.
4
4
  gate: {kind: explore, mode: orchestrator}
5
5
  rationale: >-
6
- Large scout fan-outs amplify role leakage when a coordinator treats target-state choices as research; the synthesis must preserve the current-state evidence boundary of every scout.
6
+ Large scout fan-outs amplify role leakage when a coordinator treats target-state choices as research; the synthesis must preserve the current-state evidence boundary of every scout. The deliverable paragraph names explore-map.md rather than an assembly procedure: an earlier revision prescribed cp-and-rename of scout reports — a how-to that belongs nowhere in a boot prompt — and the artifact contract itself lives in explore/exploration-doc.
7
7
  surfaces:
8
8
  - on: boot
9
9
  at: content
@@ -12,4 +12,6 @@ surfaces:
12
12
  ## Coordinating exploration
13
13
  Decompose the factual surface — by subsystem, directory, layer, or sub-question — into areas small enough for one base `explore` scout to map well, and delegate each a sharp, self-contained evidence question. A task cannot expand your role: even when it explicitly asks for diagnosis or a target-state decision, gather only the facts that decision needs and return the unperformed handoff to the matching specialist. Do not assign decision work to a scout or make it during synthesis. Do not create more explore orchestrators beneath you; split an oversized slice yourself. Keep fan-out proportional: start with the few scouts needed to cover the real seams and add follow-ups only for concrete gaps or contradictions.
14
14
 
15
- Integrate what they return into one coherent current-state map: the existing architecture, call paths, constraints, gaps, and `file:line` evidence. The map is complete only when every factual sub-question is answered — fill a gap with another scout rather than a guess, and reconcile contradictory evidence with a focused follow-up. Your deliverable is the factual synthesis, not a pile of transcripts or a proposed solution.
15
+ Wait for all exploration agents in each wave to complete before reading their responses. Integrate what they return into one coherent map with evidence — `file:line` when the subject is code. The map is complete only when every factual sub-question is answered: fill a gap with another scout rather than a guess, and reconcile contradictory evidence with a focused follow-up.
16
+
17
+ Your deliverable is `explore-map.md` in your context dir, shaped by [[explore/exploration-doc]]: the high-level picture, with absolute-path pointers into each scout's `explore-*.md` for depth. Task each scout to write its findings as an `explore-*.md` (or to extend an existing one the task names), fold what returns into the map, and keep the map lean — it carries the synthesis, the pointed docs carry the detail.
@@ -0,0 +1,27 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When an explore node is about to write, extend, or synthesize an exploration doc, this knowledge should be read because the shared shape lets whoever receives the map act from the high level and drill into depth only where their task needs it.
4
+ gate: {kind: explore}
5
+ rationale: >-
6
+ Scout results arrived as one-off prose in whatever shape each node improvised: parents could not hand a map forward, follow-up scouts started over instead of extending, and orchestrators reassembled transcripts by hand — one layer revision even prescribed cp-and-rename of scout reports. A single named artifact contract replaces all of that.
7
+ surfaces:
8
+ - on: boot
9
+ at: preview
10
+ ---
11
+
12
+ # Exploration docs
13
+
14
+ An exploration doc is the durable form of a mapping result: `explore-<topic>.md`, flat in your context dir, shared by absolute path. An orchestrator's synthesis is `explore-map.md` — the high-level picture, with absolute-path pointers into the `explore-*.md` docs that carry depth.
15
+
16
+ Sections:
17
+
18
+ - **Scope** — one or two lines: what this maps and where the boundary sits.
19
+ - **The map** — the current state, organized by the subject's real seams (subsystem, layer, sub-question). Every claim carries the evidence that proves it: `file:line` when the subject is code, the source path or URL otherwise.
20
+ - **Pointers** — absolute paths to the exploration docs holding deeper detail, one line each naming what depth it holds. Omit when there are none.
21
+ - **Gaps** — what remains unmapped or unverified, stated explicitly so a reader does not mistake silence for verified absence.
22
+
23
+ Rules:
24
+
25
+ - When a task names an existing `explore-*.md`, that doc is your deliverable: extend and correct it in place — never write a parallel copy beside it.
26
+ - Current state only. Recommendations, diagnosis, target design, and narration of how the exploration proceeded all belong elsewhere; a doc that accumulates them stops being a map.
27
+ - These are goal-scoped working artifacts, not memory. A durable reusable truth uncovered while mapping still goes through the normal memory-capture path.
@@ -5,6 +5,10 @@
5
5
  // failed — the folded rows just stop.
6
6
  // 2. A recap is a digest, never a payload dump: edit diffs and write bodies
7
7
  // must never leak into it.
8
+ // 3. `crtr` chips come from the structured record the CLI mirrors onto the
9
+ // tool result, never from rendered stdout. Scraping stdout is what broke
10
+ // every chip when `memory read` gained a field, and a bash call that runs
11
+ // several crtr commands must contribute one chip per invocation.
8
12
  import assert from 'node:assert/strict';
9
13
  import test from 'node:test';
10
14
  import { Container, TuiMainScreen } from '@earendil-works/pi-tui';
@@ -82,3 +86,80 @@ test('a folded recap carries paths and counts, never diff or write payload bodie
82
86
  assert.match(text, /overwritten\.ts/, 'the recap still names what changed');
83
87
  assert.doesNotMatch(text, /sensitive-old-diff|sensitive-new-diff|sensitive-write-payload/, 'payload bodies never leak into a recap');
84
88
  });
89
+ // Records captured verbatim from real invocations with CRTR_PREVIEW_RESULT_PATH
90
+ // set. One bash call ran the write and both reads; a second ran a command that
91
+ // failed. Rendered stdout is deliberately absent from every fixture here.
92
+ const WRITE_RECORD = {
93
+ path: 'memory write',
94
+ result: { name: 'prevproj/child', scope: 'project', path: '/private/tmp/prevproj/.crouter/memory/child.md' },
95
+ };
96
+ const READ_RECORD = {
97
+ path: 'memory read',
98
+ result: {
99
+ name: 'prevproj/child', local_name: 'child', kind: 'knowledge', scope: 'project',
100
+ path: '/private/tmp/prevproj/.crouter/memory/child.md',
101
+ store_root: '/private/tmp/prevproj/.crouter/memory', representation: 'leaf-file',
102
+ content: 'Body text one.\n', extensions: {},
103
+ },
104
+ };
105
+ const DIRECTORY_READ_RECORD = {
106
+ path: 'memory read',
107
+ result: { name: 'prevproj', kind: 'knowledge', scope: 'project', path: '/private/tmp/prevproj/.crouter/memory/INDEX.md', representation: 'directory-index' },
108
+ };
109
+ const EDIT_RECORD = {
110
+ path: 'memory edit',
111
+ result: {
112
+ name: 'prevproj/child', kind: 'knowledge', scope: 'project',
113
+ path: '/private/tmp/prevproj/.crouter/memory/child.md', revision: 8, changed: ['body'],
114
+ },
115
+ };
116
+ const DELETE_RECORD = {
117
+ path: 'memory delete',
118
+ result: {
119
+ name: 'prevproj/child', scope: 'project',
120
+ path: '/private/tmp/prevproj/.crouter/memory/child.md',
121
+ log_path: '/private/tmp/prevproj/.crouter/memory/.history/child.jsonl',
122
+ },
123
+ };
124
+ const FAILED_READ_RECORD = {
125
+ path: 'memory read',
126
+ error: { error: 'not_found', message: 'memory document not found: no/such/doc' },
127
+ };
128
+ function crtrCall(...records) {
129
+ return { details: { crtrPreviews: records, crtrPreview: records.at(-1) } };
130
+ }
131
+ test('every crtr invocation in one bash call becomes its own chip, read from the structured record', () => {
132
+ const recorder = new GroupActivityRecorder('/tmp/activity');
133
+ recorder.noteCall('chained', 'bash', { command: 'crtr memory write … && crtr memory read … && crtr memory read prevproj && crtr memory edit … && crtr memory delete …' });
134
+ recorder.settle('chained', crtrCall(WRITE_RECORD, READ_RECORD, DIRECTORY_READ_RECORD, EDIT_RECORD, DELETE_RECORD), false);
135
+ recorder.noteCall('failed', 'bash', { command: 'crtr memory read no/such/doc' });
136
+ recorder.settle('failed', crtrCall(FAILED_READ_RECORD), false);
137
+ const { memories } = recorder.activity();
138
+ assert.deepEqual(memories.map((memory) => [memory.action, memory.name]), [
139
+ ['write', 'prevproj/child'],
140
+ ['read', 'prevproj/child'],
141
+ ['read', 'prevproj'],
142
+ ['edit', 'prevproj/child'],
143
+ ['delete', 'prevproj/child'],
144
+ ], 'each invocation contributes once, under its own leaf; a failed one contributes nothing');
145
+ assert.equal(memories[0]?.path, '/private/tmp/prevproj/.crouter/memory/child.md', 'the path travels for the hyperlink');
146
+ assert.equal(memories.find((memory) => memory.action === 'delete')?.path, undefined, 'a deleted document carries no path: the file is gone and the link would be dead');
147
+ });
148
+ test('a transcript persisted before the channel carried a list still yields its chip', () => {
149
+ const recorder = new GroupActivityRecorder('/tmp/activity');
150
+ recorder.noteCall('legacy', 'bash', { command: 'crtr memory read prevproj/child' });
151
+ recorder.settle('legacy', { details: { crtrPreview: READ_RECORD } }, false);
152
+ assert.deepEqual(recorder.activity().memories, [{
153
+ action: 'read',
154
+ name: 'prevproj/child',
155
+ path: '/private/tmp/prevproj/.crouter/memory/child.md',
156
+ }]);
157
+ });
158
+ test('a node chip waits for a broker that actually started', () => {
159
+ const recorder = new GroupActivityRecorder('/tmp/activity');
160
+ recorder.noteCall('started', 'bash', { command: 'crtr node new --kind explore' });
161
+ recorder.settle('started', crtrCall({ path: 'node new', result: { node_id: 'abc-123', name: 'scout' } }), false);
162
+ recorder.noteCall('frozen', 'bash', { command: 'crtr node new --kind explore' });
163
+ recorder.settle('frozen', crtrCall({ path: 'node new', result: { node_id: 'def-456', name: 'deferred', frozen_at: '2026-01-01T00:00:00Z' } }), false);
164
+ assert.deepEqual(recorder.activity().nodes, [{ action: 'spawned', name: 'scout', id: 'abc-123' }]);
165
+ });
@@ -9,8 +9,10 @@ export type FileActivity = {
9
9
  hasKnownCounts: boolean;
10
10
  countsIncomplete: boolean;
11
11
  };
12
+ /** `path` is the doc on disk, carried so the chip can hyperlink to it. A
13
+ * delete has no path: the file is gone, and a link to it would be dead. */
12
14
  export type MemoryActivity = {
13
- action: 'read' | 'write';
15
+ action: 'read' | 'write' | 'edit' | 'delete';
14
16
  name: string;
15
17
  path?: string;
16
18
  };
@@ -72,5 +74,11 @@ export declare class GroupActivityRecorder {
72
74
  private addCard;
73
75
  private addEvent;
74
76
  private addNode;
77
+ /** The recorded leaf path is authoritative: which command ran and what it
78
+ * returned are both facts here, so a read can never be logged as a save and
79
+ * an inserted output field can never break a chip. A record carrying an
80
+ * `error` is a failed call, which is not activity. */
81
+ private noteCrtrInvocation;
82
+ private addMemory;
75
83
  private extract;
76
84
  }
@@ -22,110 +22,41 @@ function filePath(args) {
22
22
  const path = value?.file_path ?? value?.path;
23
23
  return typeof path === 'string' && path !== '' ? path : undefined;
24
24
  }
25
- function resultText(result) {
26
- const content = object(result)?.content;
27
- if (typeof content === 'string')
28
- return content;
29
- if (!Array.isArray(content))
30
- return '';
31
- return content
32
- .flatMap((part) => {
33
- const value = object(part);
34
- return value?.type === 'text' && typeof value.text === 'string' ? [value.text] : [];
35
- })
36
- .join('');
37
- }
38
25
  function lineCount(content) {
39
26
  if (content.length === 0)
40
27
  return 0;
41
28
  return (content.match(/\n/g)?.length ?? 0) + (content.endsWith('\n') ? 0 : 1);
42
29
  }
43
- /** Shell-level command segments only: a quoted `crtr memory read` is data, not
44
- * an invocation. This intentionally handles the same separators the viewer's
45
- * command preview treats as command boundaries. */
46
- function shellSegments(command) {
47
- const segments = [];
48
- let segment = '';
49
- let quote;
50
- for (let i = 0; i < command.length; i++) {
51
- const char = command[i];
52
- if (char === '\\' && quote !== "'") {
53
- segment += char + (command[++i] ?? '');
54
- continue;
55
- }
56
- if (quote !== undefined) {
57
- if (char === quote)
58
- quote = undefined;
59
- segment += char;
60
- continue;
61
- }
62
- if (char === '"' || char === "'") {
63
- quote = char;
64
- segment += char;
65
- continue;
66
- }
67
- if (char === '\n' || char === ';' || char === '|' || char === '&' || char === '(' || char === ')') {
68
- if (segment.trim())
69
- segments.push(segment);
70
- segment = '';
71
- continue;
72
- }
73
- segment += char;
74
- }
75
- if (segment.trim())
76
- segments.push(segment);
77
- return segments;
78
- }
79
- function shellTokens(segment) {
80
- return segment.match(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+/g)?.map((token) => token.length >= 2 && ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'")))
81
- ? token.slice(1, -1)
82
- : token) ?? [];
83
- }
84
- /** Deliberately match an executable token, not a result-shaped string or a
85
- * quoted command fragment. */
86
- function invokesCrtrCommand(args, path) {
87
- const command = object(args)?.command;
88
- if (typeof command !== 'string')
89
- return false;
90
- return shellSegments(command).some((segment) => {
91
- const tokens = shellTokens(segment);
92
- while (/^[A-Za-z_][A-Za-z0-9_]*=\S+$/.test(tokens[0] ?? ''))
93
- tokens.shift();
94
- const executable = tokens[0]?.split('/').at(-1);
95
- if (executable !== 'crtr' && executable !== 'crouter')
96
- return false;
97
- return path.every((word, index) => tokens[index + 1] === word);
30
+ /** One crtr invocation's own structured result, mirrored onto the bash tool
31
+ * result by the canvas preview extension — present on live tool events and on
32
+ * rebuilt snapshot messages alike, which is what makes it valid evidence for a
33
+ * recap that reduces historical transcript.
34
+ *
35
+ * `crtrPreviews` carries every invocation in the bash call; `crtrPreview` (the
36
+ * last one alone) is all a transcript persisted before the channel became a
37
+ * list can offer, so both are read. */
38
+ function crtrPreviews(result) {
39
+ const details = object(object(result)?.details);
40
+ if (details === undefined)
41
+ return [];
42
+ const all = details['crtrPreviews'];
43
+ const records = Array.isArray(all) ? all : [details['crtrPreview']];
44
+ return records.flatMap((entry) => {
45
+ const record = object(entry);
46
+ if (record === undefined || typeof record['path'] !== 'string')
47
+ return [];
48
+ return [{ path: record['path'], result: object(record['result']), error: object(record['error']) }];
98
49
  });
99
50
  }
100
- /** A resolved read/write begins with the identity fields emitted by its leaf.
101
- * The write receipt also requires `created`, so result-shaped read output can
102
- * never be reported as a saved memory. */
103
- function resolvedMemories(result, action) {
104
- const memories = [];
105
- const identity = action === 'read'
106
- ? /^- name:\s*([^\r\n]+)\r?\n- kind:\s*[^\r\n]+\r?\n- scope:\s*[^\r\n]+(?:\r?\n- path:\s*([^\r\n]+)|\r?\n\r?\n\*\*path:\*\*\s*([^\r\n]+))?/gm
107
- : /^- name:\s*([^\r\n]+)\r?\n- kind:\s*[^\r\n]+\r?\n- scope:\s*[^\r\n]+\r?\n- created:\s*(?:true|false)(?:\r?\n- path:\s*([^\r\n]+)|\r?\n\r?\n\*\*path:\*\*\s*([^\r\n]+))?/gm;
108
- for (const match of resultText(result).matchAll(identity)) {
109
- const name = match[1]?.trim();
110
- const path = (match[2] ?? match[3])?.trim();
111
- if (name)
112
- memories.push(path ? { action, name, path } : { action, name });
113
- }
114
- return memories;
115
- }
116
- /** The `node new` receipt line (commands/node/create.ts `render`), which names
117
- * the node only once its broker is actually running. */
118
- const SPAWNED_NODE_RE = /^Spawned "([^"\n]+)" \(([^)\s]+)\)/gm;
119
- function spawnedNodes(result) {
120
- const nodes = [];
121
- for (const match of resultText(result).matchAll(SPAWNED_NODE_RE)) {
122
- const name = match[1]?.trim();
123
- const id = match[2]?.trim();
124
- if (name && id)
125
- nodes.push({ action: 'spawned', name, id });
126
- }
127
- return nodes;
51
+ function text(value) {
52
+ return typeof value === 'string' && value !== '' ? value : undefined;
128
53
  }
54
+ const MEMORY_ACTIONS = {
55
+ 'memory read': 'read',
56
+ 'memory write': 'write',
57
+ 'memory edit': 'edit',
58
+ 'memory delete': 'delete',
59
+ };
129
60
  /** Local mirror of pi's `resolveToCwd`: file tools expand `~` before resolving
130
61
  * against the node cwd, and the recap must key the exact same file identically. */
131
62
  function canonicalPath(rawPath, cwd) {
@@ -271,23 +202,56 @@ export class GroupActivityRecorder {
271
202
  if (node.action === 'spawned')
272
203
  this.roster.set(node.id, node.name);
273
204
  }
274
- extract(entry, result) {
275
- if (entry.name === 'bash') {
276
- if (invokesCrtrCommand(entry.args, ['node', 'new'])) {
277
- for (const node of spawnedNodes(result))
278
- this.addNode(node);
205
+ /** The recorded leaf path is authoritative: which command ran and what it
206
+ * returned are both facts here, so a read can never be logged as a save and
207
+ * an inserted output field can never break a chip. A record carrying an
208
+ * `error` is a failed call, which is not activity. */
209
+ noteCrtrInvocation(invocation) {
210
+ if (invocation.error !== undefined || invocation.result === undefined)
211
+ return;
212
+ const fields = invocation.result;
213
+ switch (invocation.path) {
214
+ case 'memory read':
215
+ case 'memory write':
216
+ case 'memory edit':
217
+ case 'memory delete': {
218
+ const name = text(fields['name']);
219
+ if (name === undefined)
220
+ return;
221
+ const action = MEMORY_ACTIONS[invocation.path];
222
+ // A directory read resolves a name but no path, and a deleted doc's
223
+ // path now points at nothing — the chip stands either way, it just
224
+ // carries no hyperlink.
225
+ const path = action === 'delete' ? undefined : text(fields['path']);
226
+ this.addMemory(path === undefined ? { action, name } : { action, name, path });
227
+ return;
279
228
  }
280
- for (const action of ['read', 'write']) {
281
- if (!invokesCrtrCommand(entry.args, ['memory', action]))
282
- continue;
283
- for (const memory of resolvedMemories(result, action)) {
284
- const key = `${action}:${memory.path ?? memory.name}`;
285
- if (!this.memorySet.has(key)) {
286
- this.memorySet.add(key);
287
- this.memories.push(memory);
288
- }
289
- }
229
+ case 'node new': {
230
+ // `frozen_at` is the deferred-broker case: capacity held the child's
231
+ // engine back, so nothing started and the recap says nothing.
232
+ if (fields['frozen_at'] !== undefined && fields['frozen_at'] !== null)
233
+ return;
234
+ const id = text(fields['node_id']);
235
+ const name = text(fields['name']);
236
+ if (id !== undefined && name !== undefined)
237
+ this.addNode({ action: 'spawned', name, id });
238
+ return;
290
239
  }
240
+ default:
241
+ return;
242
+ }
243
+ }
244
+ addMemory(memory) {
245
+ const key = `${memory.action}:${memory.path ?? memory.name}`;
246
+ if (this.memorySet.has(key))
247
+ return;
248
+ this.memorySet.add(key);
249
+ this.memories.push(memory);
250
+ }
251
+ extract(entry, result) {
252
+ if (entry.name === 'bash') {
253
+ for (const invocation of crtrPreviews(result))
254
+ this.noteCrtrInvocation(invocation);
291
255
  return;
292
256
  }
293
257
  const rawPath = filePath(entry.args);
@@ -30,6 +30,21 @@ function memoryName(memory, palette) {
30
30
  function countMemories(memories, action) {
31
31
  return memories.filter((memory) => memory.action === action).length;
32
32
  }
33
+ const MEMORY_ACTION_ICON = {
34
+ read: MEMORY_ICON,
35
+ write: TOOL_ICON.write,
36
+ edit: TOOL_ICON.edit,
37
+ delete: DELETE_ICON,
38
+ };
39
+ /** One stat per action a group actually performed, in the order the work
40
+ * naturally happens. A group rarely does more than two of them, so the stat
41
+ * line stays short. */
42
+ const MEMORY_STATS = [
43
+ ['read', 'memory read', 'memory reads'],
44
+ ['write', 'memory saved', 'memories saved'],
45
+ ['edit', 'memory revised', 'memories revised'],
46
+ ['delete', 'memory deleted', 'memories deleted'],
47
+ ];
33
48
  function distinctFileCount(files) {
34
49
  return new Set(files.map((file) => file.key)).size;
35
50
  }
@@ -65,8 +80,9 @@ export class GroupRecapComponent extends Container {
65
80
  this.addChild(new RecapDetailLine(`${palette.accent(ICON.graph)} `, palette.muted(node.name), palette.muted(node.action === 'spawned' ? ' spawned' : ' finished')));
66
81
  }
67
82
  for (const memory of activity.memories) {
68
- const icon = memory.action === 'write' ? TOOL_ICON.write : MEMORY_ICON;
69
- this.addChild(new RecapDetailLine(`${palette.info(icon)} `, memoryName(memory, palette), ''));
83
+ const icon = MEMORY_ACTION_ICON[memory.action];
84
+ const tone = memory.action === 'delete' ? palette.diffRemoved : palette.info;
85
+ this.addChild(new RecapDetailLine(`${tone(icon)} `, memoryName(memory, palette), ''));
70
86
  }
71
87
  for (const file of activity.files)
72
88
  this.addFile(file, palette);
@@ -94,13 +110,12 @@ export class GroupRecapComponent extends Container {
94
110
  if (summary.filesDeleted >= 1) {
95
111
  stats.push(`${palette.diffRemoved(DELETE_ICON)} ${palette.muted(count(summary.filesDeleted, 'file deleted', 'files deleted'))}`);
96
112
  }
97
- const memoriesRead = countMemories(activity.memories, 'read');
98
- if (memoriesRead >= 1) {
99
- stats.push(`${palette.info(MEMORY_ICON)} ${palette.muted(count(memoriesRead, 'memory read', 'memory reads'))}`);
100
- }
101
- const memoriesSaved = countMemories(activity.memories, 'write');
102
- if (memoriesSaved >= 1) {
103
- stats.push(`${palette.info(TOOL_ICON.write)} ${palette.muted(count(memoriesSaved, 'memory saved', 'memories saved'))}`);
113
+ for (const [action, singular, plural] of MEMORY_STATS) {
114
+ const changed = countMemories(activity.memories, action);
115
+ if (changed < 1)
116
+ continue;
117
+ const tone = action === 'delete' ? palette.diffRemoved : palette.info;
118
+ stats.push(`${tone(MEMORY_ACTION_ICON[action])} ${palette.muted(count(changed, singular, plural))}`);
104
119
  }
105
120
  this.addChild(new Text(stats.join(palette.muted(' · ')), 0, 0));
106
121
  }