@north-light/crouter 0.3.253 → 0.3.255

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.
@@ -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
  }