@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.
- package/dist/clients/attach/__tests__/group-activity.test.js +81 -0
- package/dist/clients/attach/render/group-activity.d.ts +9 -1
- package/dist/clients/attach/render/group-activity.js +75 -111
- package/dist/clients/attach/render/group-recap.js +24 -9
- package/dist/clients/attach/viewer.js +508 -509
- package/dist/commands/memory/delete.js +2 -0
- package/dist/core/__tests__/daemon-boot.test.js +5 -5
- package/dist/core/__tests__/preview-mirror-cap.test.js +16 -0
- package/dist/core/exclusive-lock.js +13 -2
- package/dist/core/io.d.ts +11 -2
- package/dist/core/io.js +10 -11
- package/dist/core/substrate/surface-match.js +7 -0
- package/dist/daemon/__tests__/autostart-storm.test.d.ts +1 -0
- package/dist/daemon/__tests__/autostart-storm.test.js +166 -0
- package/dist/daemon/__tests__/integration/migration-startup.test.js +4 -7
- package/dist/daemon/crtrd-cli.js +15 -1
- package/dist/daemon/crtrd.js +14 -1
- package/dist/daemon/manage.d.ts +24 -3
- package/dist/daemon/manage.js +98 -12
- package/dist/daemon/startup-policy.d.ts +5 -0
- package/dist/daemon/startup-policy.js +5 -0
- package/dist/migrations/activation.d.ts +9 -0
- package/dist/migrations/activation.js +14 -1
- package/dist/pi-extensions/canvas-bash-valve.js +4 -0
- package/dist/pi-extensions/canvas-preview-result.js +32 -7
- package/dist/shared/generated-context.d.ts +2 -2
- package/dist/shared/generated-context.js +4 -4
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -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
|
-
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
69
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
}
|