@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.
- package/dist/api/client.d.ts +2 -1
- package/dist/api/client.js +3 -0
- package/dist/api/dto/inbox.d.ts +1 -1
- package/dist/api/dto/nodes.d.ts +16 -0
- package/dist/builtin-memory/insights/capture.md +1 -1
- package/dist/clients/attach/ansi-cells.d.ts +13 -4
- package/dist/clients/attach/ansi-cells.js +16 -10
- package/dist/clients/attach/overlays/help.js +6 -0
- package/dist/clients/attach/render/transcript-search.d.ts +28 -0
- package/dist/clients/attach/render/transcript-search.js +153 -0
- package/dist/clients/attach/render/viewport.d.ts +10 -1
- package/dist/clients/attach/render/viewport.js +27 -2
- package/dist/clients/attach/session/frame.d.ts +2 -1
- package/dist/clients/attach/session/frame.js +9 -0
- package/dist/clients/attach/session/keys.d.ts +5 -0
- package/dist/clients/attach/session/keys.js +3 -0
- package/dist/clients/attach/session/layout.d.ts +2 -2
- package/dist/clients/attach/session/layout.js +3 -2
- package/dist/clients/attach/session/transcript-search.d.ts +20 -0
- package/dist/clients/attach/session/transcript-search.js +151 -0
- package/dist/clients/attach/viewer.js +712 -710
- package/dist/commands/memory/delete.js +14 -3
- package/dist/commands/memory/edit.d.ts +1 -0
- package/dist/commands/memory/edit.js +166 -0
- package/dist/commands/memory/history.d.ts +1 -0
- package/dist/commands/memory/history.js +179 -0
- package/dist/commands/memory/list.js +1 -1
- package/dist/commands/memory/read.js +2 -2
- package/dist/commands/memory/shared.d.ts +27 -0
- package/dist/commands/memory/shared.js +69 -0
- package/dist/commands/memory/write.js +54 -61
- package/dist/commands/memory.js +6 -3
- package/dist/core/__tests__/seam/dormancy-release.test.js +2 -0
- package/dist/core/human/scan.d.ts +1 -3
- package/dist/core/human/scan.js +3 -3
- package/dist/core/human/types.d.ts +1 -0
- package/dist/core/keybindings/attach-control.d.ts +3 -0
- package/dist/core/keybindings/attach-control.js +1 -0
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +6 -0
- package/dist/core/memory/history.d.ts +54 -0
- package/dist/core/memory/history.js +202 -0
- package/dist/core/memory-resolver.d.ts +21 -0
- package/dist/core/memory-resolver.js +47 -16
- package/dist/core/preview-registry.js +2 -0
- package/dist/core/runtime/node-read.d.ts +11 -0
- package/dist/core/runtime/node-read.js +107 -41
- package/dist/daemon/api/handlers/inbox.js +1 -0
- package/dist/daemon/api/handlers/nodes.js +34 -1
- package/package.json +1 -1
- 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` —
|
|
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(
|
|
293
|
+
if (!(e.isFile() && e.name.endsWith(ext)))
|
|
280
294
|
continue;
|
|
281
|
-
const base = e.name.slice(0, -
|
|
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;
|
|
@@ -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
|
|
139
|
-
*
|
|
140
|
-
|
|
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-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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;
|
|
@@ -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 },
|