@north-light/crouter 0.3.200 → 0.3.202

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 (51) hide show
  1. package/dist/api/client.d.ts +2 -1
  2. package/dist/api/client.js +3 -0
  3. package/dist/api/dto/inbox.d.ts +1 -1
  4. package/dist/api/dto/nodes.d.ts +16 -0
  5. package/dist/builtin-memory/insights/capture.md +1 -1
  6. package/dist/clients/attach/ansi-cells.d.ts +13 -4
  7. package/dist/clients/attach/ansi-cells.js +16 -10
  8. package/dist/clients/attach/overlays/help.js +6 -0
  9. package/dist/clients/attach/render/transcript-search.d.ts +28 -0
  10. package/dist/clients/attach/render/transcript-search.js +153 -0
  11. package/dist/clients/attach/render/viewport.d.ts +10 -1
  12. package/dist/clients/attach/render/viewport.js +27 -2
  13. package/dist/clients/attach/session/frame.d.ts +2 -1
  14. package/dist/clients/attach/session/frame.js +9 -0
  15. package/dist/clients/attach/session/keys.d.ts +5 -0
  16. package/dist/clients/attach/session/keys.js +3 -0
  17. package/dist/clients/attach/session/layout.d.ts +2 -2
  18. package/dist/clients/attach/session/layout.js +3 -2
  19. package/dist/clients/attach/session/transcript-search.d.ts +20 -0
  20. package/dist/clients/attach/session/transcript-search.js +151 -0
  21. package/dist/clients/attach/viewer.js +712 -710
  22. package/dist/commands/memory/delete.js +14 -3
  23. package/dist/commands/memory/edit.d.ts +1 -0
  24. package/dist/commands/memory/edit.js +166 -0
  25. package/dist/commands/memory/history.d.ts +1 -0
  26. package/dist/commands/memory/history.js +179 -0
  27. package/dist/commands/memory/list.js +1 -1
  28. package/dist/commands/memory/read.js +2 -2
  29. package/dist/commands/memory/shared.d.ts +27 -0
  30. package/dist/commands/memory/shared.js +69 -0
  31. package/dist/commands/memory/write.js +54 -61
  32. package/dist/commands/memory.js +6 -3
  33. package/dist/core/__tests__/seam/dormancy-release.test.js +2 -0
  34. package/dist/core/human/scan.d.ts +1 -3
  35. package/dist/core/human/scan.js +3 -3
  36. package/dist/core/human/types.d.ts +1 -0
  37. package/dist/core/keybindings/attach-control.d.ts +3 -0
  38. package/dist/core/keybindings/attach-control.js +1 -0
  39. package/dist/core/keybindings/catalog.d.ts +2 -2
  40. package/dist/core/keybindings/catalog.js +6 -0
  41. package/dist/core/memory/history.d.ts +54 -0
  42. package/dist/core/memory/history.js +202 -0
  43. package/dist/core/memory-resolver.d.ts +21 -0
  44. package/dist/core/memory-resolver.js +47 -16
  45. package/dist/core/preview-registry.js +2 -0
  46. package/dist/core/runtime/node-read.d.ts +11 -0
  47. package/dist/core/runtime/node-read.js +107 -41
  48. package/dist/daemon/api/handlers/inbox.js +1 -0
  49. package/dist/daemon/api/handlers/nodes.js +34 -1
  50. package/package.json +1 -1
  51. package/runtime.lock.json +2 -2
@@ -0,0 +1,202 @@
1
+ // Per-document revision history for the memory substrate: one append-only
2
+ // JSONL sidecar per doc at `<store>/memory/.history/<name>.jsonl`, mirroring
3
+ // the doc tree segment for segment. Every record carries the COMPLETE document
4
+ // text before and after the mutation, so a reader never reconstructs a chain,
5
+ // an interleaved concurrent append stays fully valid, and an out-of-band direct
6
+ // file edit is visible as a record whose `before` differs from the prior
7
+ // record's `after`. Diffs and revision numbers are derived at read time, never
8
+ // stored.
9
+ //
10
+ // The raw file is the consumer contract: an external reader (a product surface
11
+ // reading a store off disk) parses one JSON object per line and computes its
12
+ // own diffs. Nothing here shells out to git.
13
+ import { appendFileSync } from 'node:fs';
14
+ import { dirname, join, relative } from 'node:path';
15
+ import { ensureDir, pathExists, readText } from '../fs-utils.js';
16
+ import { general } from '../errors.js';
17
+ import { parseFrontmatterGeneric } from '../frontmatter.js';
18
+ /** Sidecar tree name under a store's `memory/` dir. Dot-prefixed and holding
19
+ * only `.jsonl`, so every substrate scan (which filters to `*.md`, and in
20
+ * `lint`'s case skips dot-dirs outright) passes it over without an exclusion
21
+ * rule of its own. */
22
+ export const HISTORY_DIR = '.history';
23
+ /** The log path for a document, derived from its PHYSICAL path so the history
24
+ * tree mirrors the doc tree exactly (`memory/area/topic.md` →
25
+ * `memory/.history/area/topic.jsonl`). `memoryRoot` is the store's `memory/`
26
+ * dir the doc lives under. */
27
+ export function historyLogPathFor(memoryRoot, docPath) {
28
+ const rel = relative(memoryRoot, docPath);
29
+ if (rel === '' || rel.startsWith('..')) {
30
+ throw general(`memory document ${docPath} is not inside its store root ${memoryRoot}`);
31
+ }
32
+ return join(memoryRoot, HISTORY_DIR, rel.replace(/\.md$/i, '') + '.jsonl');
33
+ }
34
+ /** Stamp the runtime-owned fields of a record. Field insertion order IS the
35
+ * serialized key order — the on-disk shape consumers read. */
36
+ export function buildHistoryRecord(input) {
37
+ const env = process.env;
38
+ const record = {
39
+ op: input.op,
40
+ at: new Date().toISOString(),
41
+ ...(env['CRTR_NODE_ID'] ? { node: env['CRTR_NODE_ID'] } : {}),
42
+ cwd: env['CRTR_NODE_CWD'] ?? process.cwd(),
43
+ ...(input.rationale !== undefined ? { rationale: input.rationale } : {}),
44
+ ...(input.verbatim === true ? { verbatim: true } : {}),
45
+ before: input.before,
46
+ after: input.after,
47
+ };
48
+ return record;
49
+ }
50
+ /** Append one record. Single write of one line — concurrent appends interleave
51
+ * whole records, and every record stands alone, so no lock is needed. */
52
+ export function appendHistoryRecord(logPath, record) {
53
+ ensureDir(dirname(logPath));
54
+ appendFileSync(logPath, JSON.stringify(record) + '\n', 'utf8');
55
+ }
56
+ /** Every record in a log, oldest first (append order). Revision numbers are
57
+ * 1-based positions in this array. */
58
+ export function readHistoryRecords(logPath) {
59
+ if (!pathExists(logPath))
60
+ return [];
61
+ const lines = readText(logPath).split('\n');
62
+ const records = [];
63
+ for (let i = 0; i < lines.length; i += 1) {
64
+ const line = lines[i];
65
+ if (line.trim() === '')
66
+ continue;
67
+ try {
68
+ records.push(JSON.parse(line));
69
+ }
70
+ catch {
71
+ throw general(`corrupt history record at ${logPath}:${i + 1} — the line is not valid JSON`, {
72
+ next: 'Inspect that line directly; the surrounding records are self-contained and still readable.',
73
+ });
74
+ }
75
+ }
76
+ return records;
77
+ }
78
+ /** What one record changed, derived from its own two texts. `last-updated` is
79
+ * excluded: the runtime stamps it on every edit, so listing it says nothing. */
80
+ export function summarizeChange(before, after) {
81
+ const b = parseFrontmatterGeneric(before);
82
+ const a = parseFrontmatterGeneric(after);
83
+ const bData = b.data ?? {};
84
+ const aData = a.data ?? {};
85
+ const keys = new Set([...Object.keys(bData), ...Object.keys(aData)]);
86
+ keys.delete('last-updated');
87
+ const changed = [...keys]
88
+ .filter((k) => JSON.stringify(bData[k]) !== JSON.stringify(aData[k]))
89
+ .sort((x, y) => x.localeCompare(y));
90
+ return { body_changed: b.body !== a.body, frontmatter_changed: changed };
91
+ }
92
+ /** Unified diff between two document texts, computed at read time. Dependency
93
+ * free (an LCS over lines) — memory docs are lint-capped small, so the
94
+ * quadratic table is never a cost. */
95
+ export function unifiedDiff(before, after, context = 3) {
96
+ const { lines: a, terminated: aTerm } = toLines(before);
97
+ const { lines: b, terminated: bTerm } = toLines(after);
98
+ // Diff on keys, not raw text: an unterminated final line is a DIFFERENT line
99
+ // from the same text terminated, so dropping a trailing newline is a real
100
+ // change rather than an empty diff.
101
+ const ops = diffOps(diffKeys(a, aTerm), diffKeys(b, bTerm));
102
+ // Each changed op claims `context` ops on either side; overlapping claims
103
+ // merge into one hunk.
104
+ const ranges = [];
105
+ for (let i = 0; i < ops.length; i += 1) {
106
+ if (ops[i].tag === ' ')
107
+ continue;
108
+ const from = Math.max(0, i - context);
109
+ const to = Math.min(ops.length, i + context + 1);
110
+ const last = ranges[ranges.length - 1];
111
+ if (last !== undefined && from <= last.to)
112
+ last.to = Math.max(last.to, to);
113
+ else
114
+ ranges.push({ from, to });
115
+ }
116
+ if (ranges.length === 0)
117
+ return '';
118
+ // Line numbers each op sits at, in the before and after texts.
119
+ const aAt = [];
120
+ const bAt = [];
121
+ let aLine = 0;
122
+ let bLine = 0;
123
+ for (const op of ops) {
124
+ aAt.push(aLine);
125
+ bAt.push(bLine);
126
+ if (op.tag !== '+')
127
+ aLine += 1;
128
+ if (op.tag !== '-')
129
+ bLine += 1;
130
+ }
131
+ const out = [];
132
+ for (const { from, to } of ranges) {
133
+ const slice = ops.slice(from, to);
134
+ const aCount = slice.filter((op) => op.tag !== '+').length;
135
+ const bCount = slice.filter((op) => op.tag !== '-').length;
136
+ // A side contributing no lines is `0,0` — an empty file has no line 1.
137
+ out.push(`@@ -${aCount === 0 ? 0 : aAt[from] + 1},${aCount} +${bCount === 0 ? 0 : bAt[from] + 1},${bCount} @@`);
138
+ for (let i = from; i < to; i += 1) {
139
+ const op = ops[i];
140
+ out.push(op.tag + (op.tag === '+' ? b[op.index] : a[op.index]));
141
+ const endsA = op.tag !== '+' && !aTerm && aAt[i] === a.length - 1;
142
+ const endsB = op.tag !== '-' && !bTerm && bAt[i] === b.length - 1;
143
+ if (endsA || endsB)
144
+ out.push(NO_NEWLINE_MARKER);
145
+ }
146
+ }
147
+ return out.join('\n');
148
+ }
149
+ const NO_NEWLINE_MARKER = '\';
150
+ // U+0000 cannot occur in a memory document, so it can never collide with text.
151
+ const NO_NEWLINE_KEY = '\u0000no-newline-at-eof';
152
+ function diffKeys(lines, terminated) {
153
+ if (terminated || lines.length === 0)
154
+ return lines;
155
+ return lines.map((line, i) => (i === lines.length - 1 ? line + NO_NEWLINE_KEY : line));
156
+ }
157
+ /** Split into diffable lines. A final `\n` TERMINATES the last line rather than
158
+ * opening an empty one, so `foo\n` is one line; a text without it is flagged so
159
+ * the diff can mark it, the way a unified patch must. */
160
+ function toLines(text) {
161
+ if (text === '')
162
+ return { lines: [], terminated: true };
163
+ const terminated = text.endsWith('\n');
164
+ const lines = text.split('\n');
165
+ if (terminated)
166
+ lines.pop();
167
+ return { lines, terminated };
168
+ }
169
+ function diffOps(a, b) {
170
+ const n = a.length;
171
+ const m = b.length;
172
+ // lcs[i][j] = length of the longest common subsequence of a[i..] and b[j..].
173
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
174
+ for (let i = n - 1; i >= 0; i -= 1) {
175
+ for (let j = m - 1; j >= 0; j -= 1) {
176
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
177
+ }
178
+ }
179
+ const ops = [];
180
+ let i = 0;
181
+ let j = 0;
182
+ while (i < n && j < m) {
183
+ if (a[i] === b[j]) {
184
+ ops.push({ tag: ' ', index: i });
185
+ i += 1;
186
+ j += 1;
187
+ }
188
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
189
+ ops.push({ tag: '-', index: i });
190
+ i += 1;
191
+ }
192
+ else {
193
+ ops.push({ tag: '+', index: j });
194
+ j += 1;
195
+ }
196
+ }
197
+ for (; i < n; i += 1)
198
+ ops.push({ tag: '-', index: i });
199
+ for (; j < m; j += 1)
200
+ ops.push({ tag: '+', index: j });
201
+ return ops;
202
+ }
@@ -26,6 +26,11 @@ export interface MemoryDoc {
26
26
  scope: MemoryScope;
27
27
  /** Absolute path to the resolved .md file. */
28
28
  path: string;
29
+ /** Absolute path of the `memory/` dir this doc loaded from — the store root
30
+ * its identity is relative to. Sidecar trees that mirror the doc tree (the
31
+ * revision-history log) are derived from `path` relative to this. For a
32
+ * plugin doc it is that plugin's own memory dir. */
33
+ root: string;
29
34
  /** Raw, uncoerced frontmatter record (null when the doc has no frontmatter). */
30
35
  frontmatter: Record<string, unknown> | null;
31
36
  /** Document body, with the frontmatter block stripped. */
@@ -64,6 +69,15 @@ export interface MemoryResolutionOpts {
64
69
  * persona resolution, which stay on the flat ancestor+profile stack. */
65
70
  includeDescendants?: boolean;
66
71
  }
72
+ /** The native (non-plugin) store dirs in resolution precedence — node >
73
+ * project stack > profile > user, builtin excluded as a read-only corpus.
74
+ * Addresses a store directly when the document itself may be absent, which is
75
+ * how a DELETED doc's revision log is still found: history outlives the doc,
76
+ * so its lookup cannot go through document resolution. */
77
+ export declare function nativeMemoryStoresInPrecedence(scope?: MemoryScope, includeDescendants?: boolean): {
78
+ scope: MemoryScope;
79
+ memoryDir: string;
80
+ }[];
67
81
  /** Canonical, unambiguous identifier for a memory document: `<scope>/<name>`. */
68
82
  export declare function memoryDocId(doc: MemoryDoc): string;
69
83
  /** Whose memory view is being resolved: the workspace dir, profile, and node
@@ -105,6 +119,13 @@ export declare function listProjectMemoryDocs(startDir?: string, profileId?: str
105
119
  * native docs are emitted before enabled-plugin docs, so native wins on the
106
120
  * caller's first-wins dedup. */
107
121
  export declare function listAllMemoryDocs(scope?: MemoryScope, quiet?: boolean, includeDescendants?: boolean): MemoryDoc[];
122
+ /** Resolve a document NAME against a `.history` tree, which mirrors the doc
123
+ * tree segment for segment with `.jsonl` in place of `.md`. Reuses the doc
124
+ * resolution rules so a log outlives its doc under the same name the doc had:
125
+ * numeric prefixes stay prefix-blind (`00-topic.md` logged at
126
+ * `.history/00-topic.jsonl` still answers to `topic`) and a bare directory
127
+ * name falls back to its INDEX log. Returns null when nothing resolves. */
128
+ export declare function resolveHistoryLogPath(historyDir: string, segments: string[]): string | null;
108
129
  export interface MemoryDocSnapshot {
109
130
  /** Every document in default scope precedence order, loaded once. */
110
131
  docs: readonly MemoryDoc[];
@@ -11,6 +11,20 @@ import { effectiveDocKind, normalizeDocName, normalizeNameSegment, resolveDocNam
11
11
  import { loadProfileManifest, profileMemoryDir } from './profiles/manifest.js';
12
12
  import { memoryDir as nodeMemoryDir } from './runtime/memory.js';
13
13
  import { descendantStoreRoots } from './nested-stores.js';
14
+ /** The native (non-plugin) store dirs in resolution precedence — node >
15
+ * project stack > profile > user, builtin excluded as a read-only corpus.
16
+ * Addresses a store directly when the document itself may be absent, which is
17
+ * how a DELETED doc's revision log is still found: history outlives the doc,
18
+ * so its lookup cannot go through document resolution. */
19
+ export function nativeMemoryStoresInPrecedence(scope, includeDescendants = false) {
20
+ const out = [];
21
+ for (const source of memorySourcesInPrecedence(ambientTarget(), scope, includeDescendants)) {
22
+ if (source.scope === 'builtin' || source.memoryDir === null)
23
+ continue;
24
+ out.push({ scope: source.scope, memoryDir: source.memoryDir });
25
+ }
26
+ return out;
27
+ }
14
28
  /** Canonical, unambiguous identifier for a memory document: `<scope>/<name>`. */
15
29
  export function memoryDocId(doc) {
16
30
  return `${doc.scope}/${doc.name}`;
@@ -109,9 +123,9 @@ function memorySourcesInPrecedence(target, scope, includeDescendants = false) {
109
123
  }
110
124
  return out;
111
125
  }
112
- function loadMemoryDoc(scope, path, fallbackName) {
126
+ function loadMemoryDoc(scope, root, path, fallbackName) {
113
127
  const { data, body } = parseFrontmatterGeneric(readText(path));
114
- return { name: resolveDocName(data, fallbackName), scope, path, frontmatter: data, body };
128
+ return { name: resolveDocName(data, fallbackName), scope, path, root, frontmatter: data, body };
115
129
  }
116
130
  /** All memory docs in one memory/ dir, scanned recursively for *.md (topical
117
131
  * subdirs supported), sorted by resolver identity (explicit frontmatter name
@@ -157,7 +171,7 @@ function listMemoryDocsInDir(scope, dir, quiet = false) {
157
171
  // `quiet` suppresses the notice for a targeted resolve (a leaf-name read),
158
172
  // where another doc's health is irrelevant noise before the result.
159
173
  try {
160
- docs.push(loadMemoryDoc(scope, path, name));
174
+ docs.push(loadMemoryDoc(scope, dir, path, name));
161
175
  }
162
176
  catch (e) {
163
177
  const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
@@ -211,7 +225,7 @@ export function listPluginMemoryDocs(plugin, scope, quiet = false) {
211
225
  continue;
212
226
  const name = `${plugin.name}/${derived}`;
213
227
  try {
214
- docs.push({ ...loadMemoryDoc(scope, file, name), plugin: plugin.name });
228
+ docs.push({ ...loadMemoryDoc(scope, dir, file, name), plugin: plugin.name });
215
229
  }
216
230
  catch (e) {
217
231
  const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
@@ -250,13 +264,13 @@ export function listProjectMemoryDocs(startDir = process.cwd(), profileId = sele
250
264
  export function listAllMemoryDocs(scope, quiet = false, includeDescendants = false) {
251
265
  return memorySourcesInPrecedence(ambientTarget(), scope, includeDescendants).flatMap((source) => sourceMemoryDocs(source, quiet));
252
266
  }
253
- /** Find the direct child of `dir` — a `.md` file (matched on name minus
267
+ /** Find the direct child of `dir` — an `ext` file (matched on name minus
254
268
  * extension) or a directory — whose NORMALIZED display name equals
255
269
  * `segment`. An exact literal match (no prefix to strip) wins over a
256
270
  * normalized match, so a `spine` dir sitting beside a `01-spine` dir (a
257
271
  * malformed corpus) never masks the literal one; a well-formed corpus has at
258
272
  * most one candidate either way. Returns the absolute child path, or null. */
259
- function matchNormalizedChild(dir, segment, want) {
273
+ function matchNormalizedChild(dir, segment, want, ext = '.md') {
260
274
  let entries;
261
275
  try {
262
276
  entries = readdirSync(dir, { withFileTypes: true });
@@ -276,9 +290,9 @@ function matchNormalizedChild(dir, segment, want) {
276
290
  }
277
291
  }
278
292
  else {
279
- if (!(e.isFile() && e.name.endsWith('.md')))
293
+ if (!(e.isFile() && e.name.endsWith(ext)))
280
294
  continue;
281
- const base = e.name.slice(0, -3);
295
+ const base = e.name.slice(0, -ext.length);
282
296
  if (base === segment)
283
297
  return join(dir, e.name);
284
298
  if (normalizedHit === null && normalizeNameSegment(base) === segment) {
@@ -297,20 +311,37 @@ function matchNormalizedChild(dir, segment, want) {
297
311
  * `00-runtime-base.md` findable as `runtime-base` and `01-spine/00-has-manager`
298
312
  * findable as `spine/has-manager` — the physical path keeps its pins, only
299
313
  * lookup is prefix-blind. */
300
- function resolveNormalizedPath(baseDir, segments) {
314
+ function resolveNormalizedPath(baseDir, segments, ext = '.md') {
301
315
  let curDir = baseDir;
302
316
  for (let i = 0; i < segments.length - 1; i++) {
303
- const next = matchNormalizedChild(curDir, segments[i], 'dir');
317
+ const next = matchNormalizedChild(curDir, segments[i], 'dir', ext);
304
318
  if (!next)
305
319
  return { filePath: null, dirPath: null };
306
320
  curDir = next;
307
321
  }
308
322
  const last = segments[segments.length - 1];
309
323
  return {
310
- filePath: matchNormalizedChild(curDir, last, 'file'),
311
- dirPath: matchNormalizedChild(curDir, last, 'dir'),
324
+ filePath: matchNormalizedChild(curDir, last, 'file', ext),
325
+ dirPath: matchNormalizedChild(curDir, last, 'dir', ext),
312
326
  };
313
327
  }
328
+ /** Resolve a document NAME against a `.history` tree, which mirrors the doc
329
+ * tree segment for segment with `.jsonl` in place of `.md`. Reuses the doc
330
+ * resolution rules so a log outlives its doc under the same name the doc had:
331
+ * numeric prefixes stay prefix-blind (`00-topic.md` logged at
332
+ * `.history/00-topic.jsonl` still answers to `topic`) and a bare directory
333
+ * name falls back to its INDEX log. Returns null when nothing resolves. */
334
+ export function resolveHistoryLogPath(historyDir, segments) {
335
+ const { filePath, dirPath } = resolveNormalizedPath(historyDir, segments, '.jsonl');
336
+ if (filePath)
337
+ return filePath;
338
+ if (dirPath) {
339
+ const index = matchNormalizedChild(dirPath, 'INDEX', 'file', '.jsonl');
340
+ if (index)
341
+ return index;
342
+ }
343
+ return null;
344
+ }
314
345
  /** Direct full-path lookup of memory/<name>.md within ONE source. Returns that
315
346
  * source's single hit, or undefined — a source can produce at most one direct
316
347
  * match (native wins over plugin within the source, and at most one plugin's
@@ -333,11 +364,11 @@ function findMemoryMatchInSource(name, segments, source) {
333
364
  if (dir) {
334
365
  const { filePath, dirPath } = resolveNormalizedPath(dir, segments);
335
366
  if (!isLegacySkillDoc && filePath !== null)
336
- return loadMemoryDoc(source.scope, filePath, name);
367
+ return loadMemoryDoc(source.scope, dir, filePath, name);
337
368
  if (dirPath !== null) {
338
369
  const indexPath = join(dirPath, 'INDEX.md');
339
370
  if (pathExists(indexPath))
340
- return loadMemoryDoc(source.scope, indexPath, name);
371
+ return loadMemoryDoc(source.scope, dir, indexPath, name);
341
372
  }
342
373
  }
343
374
  // Plugin memory dir: a `<plugin>/<rest>` name resolves against that enabled
@@ -356,14 +387,14 @@ function findMemoryMatchInSource(name, segments, source) {
356
387
  if (rest) {
357
388
  const { filePath } = resolveNormalizedPath(pdir, restSegments);
358
389
  if (restSegments.at(-1) !== 'SKILL' && filePath !== null)
359
- return loadMemoryDoc(source.scope, filePath, name);
390
+ return loadMemoryDoc(source.scope, pdir, filePath, name);
360
391
  }
361
392
  // Bare name -> <plugin>/memory/INDEX.md; slashed name -> dir INDEX.
362
393
  const pIndexDir = rest ? resolveNormalizedPath(pdir, restSegments).dirPath : pdir;
363
394
  if (pIndexDir !== null) {
364
395
  const pindex = join(pIndexDir, 'INDEX.md');
365
396
  if (pathExists(pindex))
366
- return loadMemoryDoc(source.scope, pindex, name);
397
+ return loadMemoryDoc(source.scope, pdir, pindex, name);
367
398
  }
368
399
  }
369
400
  return undefined;
@@ -114,6 +114,8 @@ const LEAF_ICON_CP = {
114
114
  'memory read': 0xf02d,
115
115
  'memory find': 0xf002,
116
116
  'memory write': 0xf040,
117
+ 'memory edit': 0xf044,
118
+ 'memory history': 0xf1da,
117
119
  'memory origin': 0xf1bb,
118
120
  'memory lint': 0xf14a,
119
121
  'node new': 0xf067,
@@ -28,9 +28,20 @@ export interface NodeSnapshotRead {
28
28
  source: 'builtin';
29
29
  }>;
30
30
  }
31
+ export interface NodeMessagesPageRead {
32
+ messages: unknown[];
33
+ nextCursor: string | null;
34
+ }
35
+ export interface NodeMessagesPageOptions {
36
+ cursor?: string;
37
+ limit?: number;
38
+ }
31
39
  /** Reconstruct a persisted session without launching its broker. Backs the
32
40
  * machine-readable snapshot endpoint and the human transcript render. */
33
41
  export declare function readNodeSnapshot(nodeId: string): Promise<NodeSnapshotRead>;
42
+ /** A backward-paged, chronological window over the same visible history as a
43
+ * snapshot. `nextCursor` walks toward older messages. */
44
+ export declare function readNodeMessagesPage(nodeId: string, { cursor, limit }: NodeMessagesPageOptions): Promise<NodeMessagesPageRead>;
34
45
  /** Human-readable history only: text messages plus terse tool records, never
35
46
  * private model reasoning. */
36
47
  export declare function transcriptMarkdown(nodeId: string): Promise<string>;
@@ -135,9 +135,10 @@ function readToolGroupSummaries(nodeId) {
135
135
  return {};
136
136
  }
137
137
  }
138
- /** Reconstruct a persisted session without launching its broker. Backs the
139
- * machine-readable snapshot endpoint and the human transcript render. */
140
- export async function readNodeSnapshot(nodeId) {
138
+ /** Reconstruct the visible message history from a stable at-rest session copy.
139
+ * Both snapshot and paged reads use this one path so their histories cannot
140
+ * diverge. */
141
+ async function reconstructVisibleMessages(nodeId) {
141
142
  const node = getNode(nodeId);
142
143
  if (node === null) {
143
144
  throw new InputError({ error: 'not_found', message: `no node: ${nodeId}`, field: 'id', next: 'List nodes with `crtr node inspect list`.' });
@@ -146,57 +147,122 @@ export async function readNodeSnapshot(nodeId) {
146
147
  if (sessionFile === null) {
147
148
  throw new InputError({ error: 'no_session', message: `node has no readable session file: ${nodeId}`, field: 'id', next: 'Choose a node with captured conversation history.' });
148
149
  }
149
- const tempDir = mkdtempSync(join(tmpdir(), 'crtr-node-snapshot-'));
150
+ const tempDir = mkdtempSync(join(tmpdir(), 'crtr-node-session-'));
150
151
  try {
151
152
  const copy = join(tempDir, basename(sessionFile));
152
153
  copyFileSync(sessionFile, copy);
153
154
  const manager = SessionManager.open(copy);
154
155
  const context = manager.buildSessionContext();
155
156
  const messages = visibleMessages(cycleAwareMessages(manager), { boundaryReviewId: node.review_binding?.review_id });
156
- const sessionId = manager.getSessionId();
157
- const sessionName = manager.getSessionName();
158
- const snapshot = {
159
- messages,
160
- stats: statsFromMessages(messages, sessionId, sessionFile),
161
- // pi's `get_state` shape (RpcSessionState), reconstructed offline: the
162
- // live-only fields report their at-rest values (nothing is streaming or
163
- // queued for a node with no broker).
164
- state: {
165
- model: await resolveRecordedModel(context.model),
166
- thinkingLevel: context.thinkingLevel,
167
- isStreaming: false,
168
- isCompacting: false,
169
- steeringMode: 'one-at-a-time',
170
- followUpMode: 'one-at-a-time',
171
- sessionFile,
172
- sessionId,
173
- sessionName,
174
- autoCompactionEnabled: true,
175
- messageCount: messages.length,
176
- pendingMessageCount: 0,
177
- },
178
- // A snapshot reconstructed from the .jsonl alone: display state is broker
179
- // process state (last setStatus/setWidget/setTitle) and is not persisted,
180
- // so an offline read has none.
181
- display: { statuses: {}, widgets: {}, title: undefined },
182
- // The unrun queue lives in the engine's memory; a dormant node has no
183
- // engine, so nothing is waiting to run.
184
- queued: { steering: [], followUp: [] },
185
- // Sidecar summaries survive broker exit so a dormant transcript receives
186
- // the same completed-group data as a live welcome snapshot. Malformed or
187
- // absent sidecars are deliberately ignored: tool calls remain visible.
188
- toolGroupSummaries: readToolGroupSummaries(nodeId),
189
- };
190
157
  return {
191
- nodeId,
192
- snapshot,
193
- commands: BUILTIN_SLASH_COMMANDS.map((c) => ({ ...c, source: 'builtin' })),
158
+ sessionFile,
159
+ messages,
160
+ model: context.model,
161
+ thinkingLevel: context.thinkingLevel,
162
+ sessionId: manager.getSessionId(),
163
+ sessionName: manager.getSessionName(),
194
164
  };
195
165
  }
196
166
  finally {
197
167
  rmSync(tempDir, { recursive: true, force: true });
198
168
  }
199
169
  }
170
+ function invalidCursor() {
171
+ throw new InputError({
172
+ error: 'invalid_cursor',
173
+ message: 'cursor must be a base64url-encoded messages cursor.',
174
+ field: 'cursor',
175
+ next: 'Omit cursor to start from the newest messages.',
176
+ });
177
+ }
178
+ function decodeMessagesCursor(cursor, messagesLength) {
179
+ if (!/^[A-Za-z0-9_-]+$/u.test(cursor))
180
+ invalidCursor();
181
+ let parsed;
182
+ try {
183
+ const json = Buffer.from(cursor, 'base64url').toString('utf8');
184
+ if (Buffer.from(json, 'utf8').toString('base64url') !== cursor)
185
+ invalidCursor();
186
+ parsed = JSON.parse(json);
187
+ }
188
+ catch {
189
+ invalidCursor();
190
+ }
191
+ if (typeof parsed !== 'object'
192
+ || parsed === null
193
+ || parsed.v !== 1
194
+ || !Number.isSafeInteger(parsed.before)) {
195
+ invalidCursor();
196
+ }
197
+ const before = parsed.before;
198
+ if (before < 0 || before > messagesLength)
199
+ invalidCursor();
200
+ return before;
201
+ }
202
+ function encodeMessagesCursor(before) {
203
+ return Buffer.from(JSON.stringify({ v: 1, before }), 'utf8').toString('base64url');
204
+ }
205
+ /** Reconstruct a persisted session without launching its broker. Backs the
206
+ * machine-readable snapshot endpoint and the human transcript render. */
207
+ export async function readNodeSnapshot(nodeId) {
208
+ const read = await reconstructVisibleMessages(nodeId);
209
+ const snapshot = {
210
+ messages: read.messages,
211
+ stats: statsFromMessages(read.messages, read.sessionId, read.sessionFile),
212
+ // pi's `get_state` shape (RpcSessionState), reconstructed offline: the
213
+ // live-only fields report their at-rest values (nothing is streaming or
214
+ // queued for a node with no broker).
215
+ state: {
216
+ model: await resolveRecordedModel(read.model),
217
+ thinkingLevel: read.thinkingLevel,
218
+ isStreaming: false,
219
+ isCompacting: false,
220
+ steeringMode: 'one-at-a-time',
221
+ followUpMode: 'one-at-a-time',
222
+ sessionFile: read.sessionFile,
223
+ sessionId: read.sessionId,
224
+ sessionName: read.sessionName,
225
+ autoCompactionEnabled: true,
226
+ messageCount: read.messages.length,
227
+ pendingMessageCount: 0,
228
+ },
229
+ // A snapshot reconstructed from the .jsonl alone: display state is broker
230
+ // process state (last setStatus/setWidget/setTitle) and is not persisted,
231
+ // so an offline read has none.
232
+ display: { statuses: {}, widgets: {}, title: undefined },
233
+ // The unrun queue lives in the engine's memory; a dormant node has no
234
+ // engine, so nothing is waiting to run.
235
+ queued: { steering: [], followUp: [] },
236
+ // Sidecar summaries survive broker exit so a dormant transcript receives
237
+ // the same completed-group data as a live welcome snapshot. Malformed or
238
+ // absent sidecars are deliberately ignored: tool calls remain visible.
239
+ toolGroupSummaries: readToolGroupSummaries(nodeId),
240
+ };
241
+ return {
242
+ nodeId,
243
+ snapshot,
244
+ commands: BUILTIN_SLASH_COMMANDS.map((c) => ({ ...c, source: 'builtin' })),
245
+ };
246
+ }
247
+ /** A backward-paged, chronological window over the same visible history as a
248
+ * snapshot. `nextCursor` walks toward older messages. */
249
+ export async function readNodeMessagesPage(nodeId, { cursor, limit = 200 }) {
250
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
251
+ throw new InputError({
252
+ error: 'invalid_limit',
253
+ message: 'limit must be an integer from 1 through 500.',
254
+ field: 'limit',
255
+ next: 'Use a limit from 1 through 500.',
256
+ });
257
+ }
258
+ const { messages } = await reconstructVisibleMessages(nodeId);
259
+ const before = cursor === undefined ? messages.length : decodeMessagesCursor(cursor, messages.length);
260
+ const start = Math.max(0, before - limit);
261
+ return {
262
+ messages: messages.slice(start, before),
263
+ nextCursor: start === 0 ? null : encodeMessagesCursor(start),
264
+ };
265
+ }
200
266
  function textFromContent(content) {
201
267
  if (typeof content === 'string')
202
268
  return content;
@@ -187,6 +187,7 @@ function handleList() {
187
187
  slot_kinds: item.slotKinds,
188
188
  inbox: item.inbox,
189
189
  awaits_response: item.awaitsResponse,
190
+ state: item.state,
190
191
  };
191
192
  if (item.subtitle !== '')
192
193
  dto.subtitle = item.subtitle;
@@ -14,7 +14,7 @@ import { childrenOf, subscribersOf, subscriptionsOf, setMessageWait, updateNode
14
14
  import { subtreeIds } from '../../../core/canvas/nav-model.js';
15
15
  import { contextDir, reportsDir } from '../../../core/canvas/paths.js';
16
16
  import { nodeArtifacts } from '../../../core/canvas/history.js';
17
- import { readNodeSession, readNodeSnapshot, transcriptMarkdown } from '../../../core/runtime/node-read.js';
17
+ import { readNodeMessagesPage, readNodeSession, readNodeSnapshot, transcriptMarkdown } from '../../../core/runtime/node-read.js';
18
18
  import { reviveAll } from '../../../core/runtime/revive-all.js';
19
19
  import { promote, requestYield, reshapeNode } from '../../../core/runtime/promote.js';
20
20
  import { recycleNode } from '../../../core/runtime/recycle.js';
@@ -301,6 +301,38 @@ async function handleSnapshot(ctx) {
301
301
  };
302
302
  return { status: 200, body };
303
303
  }
304
+ function parseMessagesQuery(ctx) {
305
+ for (const [field] of ctx.query) {
306
+ if (field !== 'cursor' && field !== 'limit')
307
+ throw usage(`unknown messages query parameter: ${field}`);
308
+ }
309
+ const cursors = ctx.query.getAll('cursor');
310
+ const limits = ctx.query.getAll('limit');
311
+ if (cursors.length > 1)
312
+ throw usage('cursor must appear at most once');
313
+ if (limits.length > 1)
314
+ throw usage('limit must appear at most once');
315
+ if (limits.length === 0)
316
+ return cursors.length === 0 ? {} : { cursor: cursors[0] };
317
+ const rawLimit = limits[0];
318
+ const limit = Number(rawLimit);
319
+ if (!Number.isSafeInteger(limit) || rawLimit === '') {
320
+ throw usage('limit must be an integer from 1 through 500');
321
+ }
322
+ return cursors.length === 0 ? { limit } : { cursor: cursors[0], limit };
323
+ }
324
+ /** Offline, backward-paged read of the cycle-flattened visible history. */
325
+ async function handleMessages(ctx) {
326
+ const id = ctx.params['id'];
327
+ const read = await readNodeMessagesPage(id, parseMessagesQuery(ctx));
328
+ const body = {
329
+ node_id: id,
330
+ messages: read.messages,
331
+ next_cursor: read.nextCursor,
332
+ captured_at: nowIso(),
333
+ };
334
+ return { status: 200, body };
335
+ }
304
336
  /** The faithful export read: raw `.jsonl` bytes + assembled system prompt.
305
337
  * `/snapshot` reconstructs a cycle-flattened `AgentMessage[]` for renderers and
306
338
  * drops what a renderer cannot show; this drops nothing. */
@@ -598,6 +630,7 @@ export const nodeRoutes = [
598
630
  { method: 'POST', pattern: '/v1/nodes/revive-all', handler: () => handleReviveAll() },
599
631
  { method: 'GET', pattern: '/v1/nodes/:id', handler: handleDetail },
600
632
  { method: 'GET', pattern: '/v1/nodes/:id/snapshot', handler: handleSnapshot },
633
+ { method: 'GET', pattern: '/v1/nodes/:id/messages', handler: handleMessages },
601
634
  { method: 'GET', pattern: '/v1/nodes/:id/session', handler: handleSession },
602
635
  { method: 'GET', pattern: '/v1/nodes/:id/transcript', handler: handleTranscript },
603
636
  { method: 'GET', pattern: '/v1/nodes/:id/context', handler: handleContext },