@north-light/crouter 0.3.211 → 0.3.212
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/commands/sys/__tests__/migrate.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/migrate.test.js +95 -0
- package/dist/commands/sys/migrate.js +95 -41
- package/dist/core/memory-resolver.d.ts +5 -1
- package/dist/core/memory-resolver.js +45 -14
- package/dist/core/nested-stores.d.ts +5 -0
- package/dist/core/nested-stores.js +31 -6
- package/dist/migrations/001-surfaces-frontmatter.js +12 -33
- package/dist/migrations/convergent.d.ts +2 -1
- package/dist/migrations/convergent.js +1 -1
- package/dist/migrations/types.d.ts +6 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const CLI = join(HERE, '../../../cli.ts');
|
|
10
|
+
const TSX_LOADER = import.meta.resolve('tsx/esm');
|
|
11
|
+
const OLD_DOC = `---
|
|
12
|
+
kind: knowledge
|
|
13
|
+
system-prompt-visibility: content
|
|
14
|
+
file-read-visibility: none
|
|
15
|
+
---
|
|
16
|
+
# Old format
|
|
17
|
+
`;
|
|
18
|
+
const CURRENT_DOC = `---
|
|
19
|
+
kind: knowledge
|
|
20
|
+
---
|
|
21
|
+
# Current format
|
|
22
|
+
`;
|
|
23
|
+
function write(path, content) {
|
|
24
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
25
|
+
writeFileSync(path, content);
|
|
26
|
+
}
|
|
27
|
+
function manifest(name, projects) {
|
|
28
|
+
return JSON.stringify({
|
|
29
|
+
schema_version: 1,
|
|
30
|
+
name,
|
|
31
|
+
projects,
|
|
32
|
+
home: projects[0] ?? null,
|
|
33
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
34
|
+
last_used_at: null,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
test('sys migrate covers profile-aware refs and every descendant project/plugin store', () => {
|
|
38
|
+
const home = mkdtempSync(join(tmpdir(), 'crtr-sys-migrate-'));
|
|
39
|
+
const userRoot = join(home, '.crouter');
|
|
40
|
+
const rootProfile = join(userRoot, 'profiles', 'root-00000000');
|
|
41
|
+
const appletProfile = join(userRoot, 'profiles', 'applet-deadbeef');
|
|
42
|
+
const appletProject = join(home, 'inactive-applet-project');
|
|
43
|
+
const nestedOnlyProject = join(home, 'nested-only-project');
|
|
44
|
+
const deepRoot = join(appletProject, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', '.crouter');
|
|
45
|
+
const pluginRoot = join(deepRoot, 'plugins', 'deep-memory');
|
|
46
|
+
const embeddedPluginRoot = join(nestedOnlyProject, 'embedded', '.crouter', 'plugins', 'embedded-memory');
|
|
47
|
+
const userDoc = join(userRoot, 'memory', 'user-link.md');
|
|
48
|
+
const rootDoc = join(rootProfile, 'memory', 'root.md');
|
|
49
|
+
const appletDoc = join(appletProfile, 'memory', 'identity.md');
|
|
50
|
+
const projectDoc = join(appletProject, '.crouter', 'memory', 'project.md');
|
|
51
|
+
const deepDoc = join(deepRoot, 'memory', 'deep.md');
|
|
52
|
+
const pluginDoc = join(pluginRoot, 'memory', 'plugin.md');
|
|
53
|
+
const embeddedPluginDoc = join(embeddedPluginRoot, 'memory', 'plugin.md');
|
|
54
|
+
try {
|
|
55
|
+
write(join(rootProfile, 'profile.json'), manifest('root', []));
|
|
56
|
+
write(join(appletProfile, 'profile.json'), manifest('applet-inactive', [appletProject, nestedOnlyProject]));
|
|
57
|
+
write(userDoc, `${OLD_DOC}\nSee [[user/scoped]].\n`);
|
|
58
|
+
write(join(userRoot, 'memory', 'scoped', 'INDEX.md'), CURRENT_DOC);
|
|
59
|
+
write(rootDoc, `${OLD_DOC}\nSee [[shared]].\n`);
|
|
60
|
+
write(appletDoc, `${OLD_DOC}\nSee [[shared]].\n`);
|
|
61
|
+
write(join(appletProfile, 'memory', 'shared', 'INDEX.md'), CURRENT_DOC);
|
|
62
|
+
write(projectDoc, OLD_DOC);
|
|
63
|
+
write(join(appletProject, '.crouter', 'memory', 'scoped', 'INDEX.md'), CURRENT_DOC);
|
|
64
|
+
write(deepDoc, OLD_DOC);
|
|
65
|
+
write(join(pluginRoot, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: 'deep-memory', version: '1.0.0' }));
|
|
66
|
+
write(pluginDoc, OLD_DOC);
|
|
67
|
+
write(join(embeddedPluginRoot, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: 'embedded-memory', version: '1.0.0' }));
|
|
68
|
+
write(join(nestedOnlyProject, 'embedded', '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
69
|
+
write(embeddedPluginDoc, OLD_DOC);
|
|
70
|
+
const env = {
|
|
71
|
+
...process.env,
|
|
72
|
+
HOME: home,
|
|
73
|
+
CRTR_PROFILE_ID: 'root-00000000',
|
|
74
|
+
CRTR_NO_AUTO_INIT: '1',
|
|
75
|
+
CRTR_NO_AUTO_UPDATE: '1',
|
|
76
|
+
};
|
|
77
|
+
delete env.CRTR_NODE_ID;
|
|
78
|
+
const result = spawnSync(process.execPath, ['--conditions=crtr-src', '--import', TSX_LOADER, CLI, 'sys', 'migrate', '--json'], { cwd: home, env, encoding: 'utf8' });
|
|
79
|
+
assert.equal(result.status, 0, result.stderr);
|
|
80
|
+
const output = JSON.parse(result.stdout);
|
|
81
|
+
assert.equal(output.stores, 7);
|
|
82
|
+
assert.equal(output.changed, 7);
|
|
83
|
+
for (const path of [userDoc, rootDoc, appletDoc, projectDoc, deepDoc, pluginDoc, embeddedPluginDoc]) {
|
|
84
|
+
const migrated = readFileSync(path, 'utf8');
|
|
85
|
+
assert.doesNotMatch(migrated, /system-prompt-visibility|file-read-visibility/);
|
|
86
|
+
assert.match(migrated, /surfaces:\n - on: boot\n at: content/);
|
|
87
|
+
}
|
|
88
|
+
assert.match(readFileSync(userDoc, 'utf8'), /\[\[user\/scoped\/INDEX\]\]/);
|
|
89
|
+
assert.match(readFileSync(rootDoc, 'utf8'), /\[\[shared\]\]/);
|
|
90
|
+
assert.match(readFileSync(appletDoc, 'utf8'), /\[\[shared\/INDEX\]\]/);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
rmSync(home, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
@@ -2,55 +2,83 @@
|
|
|
2
2
|
// every writable memory store's documents to the current document format.
|
|
3
3
|
// (Journaled canvas-db migrations run in the db's own ordered chain when the
|
|
4
4
|
// daemon opens it; this leaf owns only the on-disk document stores.)
|
|
5
|
-
import { join } from 'node:path';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
6
|
import { defineLeaf } from '../../core/command.js';
|
|
7
7
|
import { pathExists, realpathOrSelf } from '../../core/fs-utils.js';
|
|
8
|
+
import { createMemoryDocSnapshot } from '../../core/memory-resolver.js';
|
|
8
9
|
import { listInstalledPlugins, listInstalledPluginsInRoot } from '../../core/resolver.js';
|
|
9
10
|
import { pluginMemoryDir, projectScopeRoots, scopeMemoryDir } from '../../core/scope.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { descendantStoreRoots } from '../../core/nested-stores.js';
|
|
11
|
+
import { listProfiles, profileMemoryDir } from '../../core/profiles/manifest.js';
|
|
12
|
+
import { descendantStoreRootsExhaustive } from '../../core/nested-stores.js';
|
|
13
13
|
import { memoryDir as nodeMemoryDir } from '../../core/runtime/memory.js';
|
|
14
14
|
import { runConvergentMigrations } from '../../migrations/convergent.js';
|
|
15
|
+
function targetKey(target) {
|
|
16
|
+
return `${realpathOrSelf(target.cwd)}\u0000${target.profileId ?? ''}\u0000${target.nodeId ?? ''}`;
|
|
17
|
+
}
|
|
15
18
|
/** Every writable memory store — the lint corpus minus builtin (package-owned,
|
|
16
|
-
* regenerated on every install, so migrating it would fight the package).
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
for (const plugin of listInstalledPluginsInRoot('project', root)) {
|
|
29
|
-
if (plugin.enabled)
|
|
30
|
-
add(pluginMemoryDir(plugin));
|
|
19
|
+
* regenerated on every install, so migrating it would fight the package).
|
|
20
|
+
* Physical stores dedupe once while retaining every owning target corpus. */
|
|
21
|
+
function migratableStores() {
|
|
22
|
+
const stores = new Map();
|
|
23
|
+
const add = (dir, target) => {
|
|
24
|
+
if (dir === null || dir === '' || !pathExists(dir))
|
|
25
|
+
return;
|
|
26
|
+
const real = realpathOrSelf(dir);
|
|
27
|
+
let store = stores.get(real);
|
|
28
|
+
if (store === undefined) {
|
|
29
|
+
store = { dir: real, targets: [] };
|
|
30
|
+
stores.set(real, store);
|
|
31
31
|
}
|
|
32
|
+
const key = targetKey(target);
|
|
33
|
+
if (!store.targets.some((candidate) => targetKey(candidate) === key))
|
|
34
|
+
store.targets.push(target);
|
|
35
|
+
};
|
|
36
|
+
const addProjectRoots = (roots, profileId) => {
|
|
37
|
+
const addRoot = (projectRoot) => {
|
|
38
|
+
const target = { cwd: dirname(projectRoot), profileId, nodeId: null };
|
|
39
|
+
add(join(projectRoot, 'memory'), target);
|
|
40
|
+
for (const plugin of listInstalledPluginsInRoot('project', projectRoot)) {
|
|
41
|
+
if (plugin.enabled)
|
|
42
|
+
add(pluginMemoryDir(plugin), target);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
for (const root of roots)
|
|
46
|
+
addRoot(root);
|
|
47
|
+
for (const descendant of descendantStoreRootsExhaustive(roots))
|
|
48
|
+
addRoot(descendant);
|
|
49
|
+
};
|
|
50
|
+
const selectedProfileId = process.env['CRTR_PROFILE_ID'] || null;
|
|
51
|
+
addProjectRoots(projectScopeRoots(process.cwd(), null), selectedProfileId);
|
|
52
|
+
const profiles = listProfiles();
|
|
53
|
+
const profileTargets = [];
|
|
54
|
+
for (const { profileId, manifest } of profiles) {
|
|
55
|
+
const profileTarget = {
|
|
56
|
+
cwd: manifest.home ?? manifest.projects[0] ?? process.cwd(),
|
|
57
|
+
profileId,
|
|
58
|
+
nodeId: null,
|
|
59
|
+
};
|
|
60
|
+
profileTargets.push(profileTarget);
|
|
61
|
+
add(profileMemoryDir(profileId), profileTarget);
|
|
62
|
+
const roots = [
|
|
63
|
+
...new Set(manifest.projects.flatMap((project) => [join(project, '.crouter'), ...projectScopeRoots(project, null)])),
|
|
64
|
+
];
|
|
65
|
+
addProjectRoots(roots, profileId);
|
|
32
66
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
67
|
+
const ambientTarget = { cwd: process.cwd(), profileId: selectedProfileId, nodeId: null };
|
|
68
|
+
const globalTargets = [ambientTarget, ...profileTargets];
|
|
69
|
+
const userDir = scopeMemoryDir('user');
|
|
70
|
+
for (const target of globalTargets)
|
|
71
|
+
add(userDir, target);
|
|
36
72
|
for (const plugin of listInstalledPlugins('user')) {
|
|
37
|
-
if (plugin.enabled)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (profileIdOrName) {
|
|
42
|
-
try {
|
|
43
|
-
const { profileId } = loadProfileManifest(profileIdOrName);
|
|
44
|
-
add(profileMemoryDir(profileId));
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
// Unresolvable profile: no profile store to migrate.
|
|
48
|
-
}
|
|
73
|
+
if (!plugin.enabled)
|
|
74
|
+
continue;
|
|
75
|
+
for (const target of globalTargets)
|
|
76
|
+
add(pluginMemoryDir(plugin), target);
|
|
49
77
|
}
|
|
50
78
|
const nodeId = process.env['CRTR_NODE_ID'] ?? '';
|
|
51
79
|
if (nodeId !== '')
|
|
52
|
-
add(nodeMemoryDir(nodeId));
|
|
53
|
-
return [...
|
|
80
|
+
add(nodeMemoryDir(nodeId), { cwd: process.cwd(), profileId: selectedProfileId, nodeId });
|
|
81
|
+
return [...stores.values()];
|
|
54
82
|
}
|
|
55
83
|
export const sysMigrateLeaf = defineLeaf({
|
|
56
84
|
name: 'migrate',
|
|
@@ -71,7 +99,7 @@ export const sysMigrateLeaf = defineLeaf({
|
|
|
71
99
|
],
|
|
72
100
|
outputKind: 'object',
|
|
73
101
|
effects: [
|
|
74
|
-
'Rewrites old-format memory documents in place (atomic per-file writes) across the writable stores: project (plus nested descendant stores and installed plugin stores),
|
|
102
|
+
'Rewrites old-format memory documents in place (atomic per-file writes) across the writable stores: every profile and every profile-owned project (plus nested descendant stores and installed plugin stores), the current project, user, and this node\'s store. Builtin docs are package-owned and never touched.',
|
|
75
103
|
'Under --dry-run: read-only, writes nothing.',
|
|
76
104
|
],
|
|
77
105
|
},
|
|
@@ -79,13 +107,39 @@ export const sysMigrateLeaf = defineLeaf({
|
|
|
79
107
|
const dryRun = input.dryRun === true;
|
|
80
108
|
const applied = [];
|
|
81
109
|
const skipped = [];
|
|
82
|
-
const stores =
|
|
110
|
+
const stores = migratableStores();
|
|
111
|
+
const snapshots = new Map();
|
|
83
112
|
for (const store of stores) {
|
|
84
|
-
const
|
|
113
|
+
const resolvers = store.targets.map((target) => (name) => {
|
|
114
|
+
const key = targetKey(target);
|
|
115
|
+
let snapshot = snapshots.get(key);
|
|
116
|
+
if (snapshot === undefined) {
|
|
117
|
+
snapshot = createMemoryDocSnapshot(target);
|
|
118
|
+
snapshots.set(key, snapshot);
|
|
119
|
+
}
|
|
120
|
+
return snapshot.legacyDirectoryIndex(name);
|
|
121
|
+
});
|
|
122
|
+
const result = runConvergentMigrations(store.dir, {
|
|
123
|
+
dryRun,
|
|
124
|
+
context: {
|
|
125
|
+
legacyDirectoryIndex: (name) => {
|
|
126
|
+
let replacement = null;
|
|
127
|
+
for (const resolve of resolvers) {
|
|
128
|
+
const candidate = resolve(name);
|
|
129
|
+
if (candidate === null)
|
|
130
|
+
return null;
|
|
131
|
+
if (replacement !== null && replacement !== candidate)
|
|
132
|
+
return null;
|
|
133
|
+
replacement = candidate;
|
|
134
|
+
}
|
|
135
|
+
return replacement;
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
});
|
|
85
139
|
for (const row of result.applied)
|
|
86
|
-
applied.push({ store, migration: row.migration, files: row.files });
|
|
140
|
+
applied.push({ store: store.dir, migration: row.migration, files: row.files });
|
|
87
141
|
for (const s of result.skipped)
|
|
88
|
-
skipped.push({ store, relPath: s.relPath, error: s.error });
|
|
142
|
+
skipped.push({ store: store.dir, relPath: s.relPath, error: s.error });
|
|
89
143
|
}
|
|
90
144
|
const changed = applied.reduce((n, row) => n + row.files.length, 0);
|
|
91
145
|
return {
|
|
@@ -132,8 +132,12 @@ export interface MemoryDocSnapshot {
|
|
|
132
132
|
/** Resolve names against that source snapshot; failures are omitted so
|
|
133
133
|
* callers can retain their own per-document error behavior. */
|
|
134
134
|
resolve(names: readonly string[]): Map<string, MemoryDoc>;
|
|
135
|
+
/** Frozen pre-cut resolution used only by the surfaces migration: return the
|
|
136
|
+
* explicit INDEX spelling when a bare directory/plugin ref and that spelling
|
|
137
|
+
* resolve to the same file in this target corpus. */
|
|
138
|
+
legacyDirectoryIndex(name: string): string | null;
|
|
135
139
|
}
|
|
136
|
-
export declare function createMemoryDocSnapshot(): MemoryDocSnapshot;
|
|
140
|
+
export declare function createMemoryDocSnapshot(target?: MemoryTarget): MemoryDocSnapshot;
|
|
137
141
|
/** Resolve several unqualified names over one memory-source snapshot. Failed
|
|
138
142
|
* names are omitted so callers can preserve their per-document error behavior. */
|
|
139
143
|
export declare function resolveMemoryDocs(names: readonly string[]): Map<string, MemoryDoc>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join, relative, sep } from 'node:path';
|
|
1
|
+
import { basename, join, relative, sep } from 'node:path';
|
|
2
2
|
import { readdirSync } from 'node:fs';
|
|
3
3
|
import { CRTR_DIR_NAME } from '../types.js';
|
|
4
4
|
import { pathExists, readText, walkFiles } from './fs-utils.js';
|
|
@@ -343,32 +343,47 @@ export function resolveHistoryLogPath(historyDir, segments) {
|
|
|
343
343
|
* never authored with a numeric prefix); resolution against the physical tree
|
|
344
344
|
* is prefix-blind via `resolveNormalizedPath` so a normalized name finds an
|
|
345
345
|
* `NN-`-pinned physical file/dir. */
|
|
346
|
-
function findMemoryMatchInSource(name, segments, source) {
|
|
346
|
+
function findMemoryMatchInSource(name, segments, source, legacyDirectoryIndex = false) {
|
|
347
347
|
const isLegacySkillDoc = segments.at(-1) === 'SKILL';
|
|
348
348
|
// Native memory dir first inside this source (native-before-plugin
|
|
349
349
|
// precedence), then its enabled plugins.
|
|
350
350
|
const dir = source.memoryDir;
|
|
351
351
|
if (dir) {
|
|
352
|
-
const { filePath } = resolveNormalizedPath(dir, segments);
|
|
352
|
+
const { filePath, dirPath } = resolveNormalizedPath(dir, segments);
|
|
353
353
|
if (!isLegacySkillDoc && filePath !== null)
|
|
354
354
|
return loadMemoryDoc(source.scope, dir, filePath, name);
|
|
355
|
+
if (legacyDirectoryIndex && dirPath !== null) {
|
|
356
|
+
const indexPath = join(dirPath, 'INDEX.md');
|
|
357
|
+
if (pathExists(indexPath))
|
|
358
|
+
return loadMemoryDoc(source.scope, dir, indexPath, name);
|
|
359
|
+
}
|
|
355
360
|
}
|
|
356
361
|
// Plugin memory dir: a `<plugin>/<rest>` name resolves against that enabled
|
|
357
362
|
// plugin's memory/ tree (the `<pluginName>/` mount that listAllMemoryDocs
|
|
358
363
|
// enumerates — `read` must resolve what `list` shows).
|
|
359
364
|
const slash = name.indexOf('/');
|
|
360
|
-
if (slash <= 0)
|
|
365
|
+
if (slash <= 0 && !legacyDirectoryIndex)
|
|
361
366
|
return undefined;
|
|
362
|
-
const pluginName = name.slice(0, slash);
|
|
363
|
-
const rest = name.slice(slash + 1);
|
|
367
|
+
const pluginName = slash > 0 ? name.slice(0, slash) : name;
|
|
368
|
+
const rest = slash > 0 ? name.slice(slash + 1) : '';
|
|
364
369
|
for (const p of source.plugins) {
|
|
365
370
|
if (p.name !== pluginName)
|
|
366
371
|
continue;
|
|
367
372
|
const pdir = pluginMemoryDir(p);
|
|
368
|
-
const restSegments = rest.split('/');
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
373
|
+
const restSegments = rest === '' ? [] : rest.split('/');
|
|
374
|
+
if (restSegments.length > 0) {
|
|
375
|
+
const { filePath } = resolveNormalizedPath(pdir, restSegments);
|
|
376
|
+
if (restSegments.at(-1) !== 'SKILL' && filePath !== null)
|
|
377
|
+
return loadMemoryDoc(source.scope, pdir, filePath, name);
|
|
378
|
+
}
|
|
379
|
+
if (legacyDirectoryIndex) {
|
|
380
|
+
const indexDir = rest === '' ? pdir : resolveNormalizedPath(pdir, restSegments).dirPath;
|
|
381
|
+
if (indexDir !== null) {
|
|
382
|
+
const indexPath = join(indexDir, 'INDEX.md');
|
|
383
|
+
if (pathExists(indexPath))
|
|
384
|
+
return loadMemoryDoc(source.scope, pdir, indexPath, name);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
372
387
|
}
|
|
373
388
|
return undefined;
|
|
374
389
|
}
|
|
@@ -384,8 +399,7 @@ function loadMemorySources(target, scope, includeDescendants = false) {
|
|
|
384
399
|
docs: sourceMemoryDocs(source, true),
|
|
385
400
|
}));
|
|
386
401
|
}
|
|
387
|
-
export function createMemoryDocSnapshot() {
|
|
388
|
-
const target = ambientTarget();
|
|
402
|
+
export function createMemoryDocSnapshot(target = ambientTarget()) {
|
|
389
403
|
const defaultSources = loadMemorySources(target);
|
|
390
404
|
const sourcesByScope = new Map([[undefined, defaultSources]]);
|
|
391
405
|
const sourceFor = (scope) => {
|
|
@@ -412,9 +426,26 @@ export function createMemoryDocSnapshot() {
|
|
|
412
426
|
}
|
|
413
427
|
return resolved;
|
|
414
428
|
},
|
|
429
|
+
legacyDirectoryIndex: (name) => {
|
|
430
|
+
if (name === 'INDEX' || name.endsWith('/INDEX'))
|
|
431
|
+
return null;
|
|
432
|
+
try {
|
|
433
|
+
const parsed = parseSkillQualifier(name);
|
|
434
|
+
const sources = sourceFor(parsed.scope);
|
|
435
|
+
const legacy = resolveMemoryDocFromSources(name, {}, sources, true);
|
|
436
|
+
if (basename(legacy.path) !== 'INDEX.md')
|
|
437
|
+
return null;
|
|
438
|
+
const explicitName = `${name}/INDEX`;
|
|
439
|
+
const explicit = resolveMemoryDocFromSources(explicitName, {}, sources);
|
|
440
|
+
return explicit.path === legacy.path ? explicitName : null;
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
},
|
|
415
446
|
};
|
|
416
447
|
}
|
|
417
|
-
function resolveMemoryDocFromSources(rawName, opts, sources) {
|
|
448
|
+
function resolveMemoryDocFromSources(rawName, opts, sources, legacyDirectoryIndex = false) {
|
|
418
449
|
const parsed = parseSkillQualifier(rawName);
|
|
419
450
|
if (parsed.scope && opts.scope && parsed.scope !== opts.scope) {
|
|
420
451
|
throw usage(`scope conflict: identifier "${rawName}" uses scope "${parsed.scope}" but --scope is "${opts.scope}"`);
|
|
@@ -433,7 +464,7 @@ function resolveMemoryDocFromSources(rawName, opts, sources) {
|
|
|
433
464
|
const identity = docs.find((d) => d.name === name);
|
|
434
465
|
if (identity && kindOk(identity))
|
|
435
466
|
return identity;
|
|
436
|
-
const direct = findMemoryMatchInSource(name, segments, source);
|
|
467
|
+
const direct = findMemoryMatchInSource(name, segments, source, legacyDirectoryIndex);
|
|
437
468
|
if (direct && kindOk(direct))
|
|
438
469
|
return direct;
|
|
439
470
|
}
|
|
@@ -10,3 +10,8 @@
|
|
|
10
10
|
* duplicated dedupe logic.
|
|
11
11
|
*/
|
|
12
12
|
export declare function descendantStoreRoots(ancestorRoots: string[]): string[];
|
|
13
|
+
/** Complete descendant-store discovery for state migrations. Unlike the
|
|
14
|
+
* interactive addressing lane, this filesystem walk has no time or depth
|
|
15
|
+
* budget, crosses embedded repositories, admits plugin-only `.crouter` roots,
|
|
16
|
+
* and does not cache. */
|
|
17
|
+
export declare function descendantStoreRootsExhaustive(ancestorRoots: string[]): string[];
|
|
@@ -113,20 +113,24 @@ function collectGitStores(owner, add, timeoutMs, trip) {
|
|
|
113
113
|
/** Non-git owner: DFS with junk skips, a depth cap, and a stop at any embedded
|
|
114
114
|
* `.git` dir (an embedded repo is its own world — register it explicitly). */
|
|
115
115
|
function collectWalkStores(owner, add, deadline, trip) {
|
|
116
|
+
collectWalkStoresFromOwner(owner, add, { deadline, maxDepth: MAX_DEPTH, trip });
|
|
117
|
+
}
|
|
118
|
+
function collectWalkStoresFromOwner(owner, add, opts = {}) {
|
|
116
119
|
const stack = [{ dir: owner, depth: 0 }];
|
|
117
120
|
while (stack.length > 0) {
|
|
118
|
-
if (Date.now() > deadline) {
|
|
119
|
-
trip();
|
|
121
|
+
if (opts.deadline !== undefined && Date.now() > opts.deadline) {
|
|
122
|
+
opts.trip?.();
|
|
120
123
|
return;
|
|
121
124
|
}
|
|
122
125
|
const { dir, depth } = stack.pop();
|
|
123
126
|
if (dir !== owner) {
|
|
124
|
-
if (pathExists(join(dir, '.git')))
|
|
127
|
+
if (opts.crossEmbeddedRepos !== true && pathExists(join(dir, '.git')))
|
|
125
128
|
continue;
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
const storeRoot = join(dir, CRTR_DIR_NAME);
|
|
130
|
+
if (pathExists(opts.includePluginOnly === true ? storeRoot : join(storeRoot, 'memory')))
|
|
131
|
+
add(storeRoot);
|
|
128
132
|
}
|
|
129
|
-
if (depth >=
|
|
133
|
+
if (opts.maxDepth !== undefined && depth >= opts.maxDepth)
|
|
130
134
|
continue;
|
|
131
135
|
let entries;
|
|
132
136
|
try {
|
|
@@ -144,3 +148,24 @@ function collectWalkStores(owner, add, deadline, trip) {
|
|
|
144
148
|
}
|
|
145
149
|
}
|
|
146
150
|
}
|
|
151
|
+
/** Complete descendant-store discovery for state migrations. Unlike the
|
|
152
|
+
* interactive addressing lane, this filesystem walk has no time or depth
|
|
153
|
+
* budget, crosses embedded repositories, admits plugin-only `.crouter` roots,
|
|
154
|
+
* and does not cache. */
|
|
155
|
+
export function descendantStoreRootsExhaustive(ancestorRoots) {
|
|
156
|
+
const ancestorReal = new Set(ancestorRoots.map((root) => realpathOrSelf(root)));
|
|
157
|
+
const found = [];
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
const add = (storeRoot) => {
|
|
160
|
+
const real = realpathOrSelf(storeRoot);
|
|
161
|
+
if (seen.has(real) || ancestorReal.has(real))
|
|
162
|
+
return;
|
|
163
|
+
seen.add(real);
|
|
164
|
+
found.push(storeRoot);
|
|
165
|
+
};
|
|
166
|
+
const owners = new Set(ancestorRoots.map((root) => realpathOrSelf(dirname(root))));
|
|
167
|
+
for (const owner of owners) {
|
|
168
|
+
collectWalkStoresFromOwner(owner, add, { includePluginOnly: true, crossEmbeddedRepos: true });
|
|
169
|
+
}
|
|
170
|
+
return found;
|
|
171
|
+
}
|
|
@@ -18,11 +18,9 @@
|
|
|
18
18
|
// folded scalars). Each splice is validated by re-parsing: the result must
|
|
19
19
|
// equal the original record minus the dead fields plus `surfaces`, or the
|
|
20
20
|
// migration throws naming the doc.
|
|
21
|
-
import { basename } from 'node:path';
|
|
22
21
|
import { isMap, parse as parseYaml, parseDocument, stringify } from 'yaml';
|
|
23
22
|
import { parseFrontmatterGeneric } from '../core/frontmatter.js';
|
|
24
23
|
import { findDocLinks } from '../core/memory/doc-link-grammar.js';
|
|
25
|
-
import { createMemoryDocSnapshot } from '../core/memory-resolver.js';
|
|
26
24
|
const OLD_FIELDS = ['system-prompt-visibility', 'file-read-visibility', 'applies-to', 'read-when'];
|
|
27
25
|
// ---------------------------------------------------------------------------
|
|
28
26
|
// Frozen old-schema parsing (pre-cut substrate/schema.ts semantics).
|
|
@@ -136,42 +134,22 @@ function planEntries(d, bootRung) {
|
|
|
136
134
|
}
|
|
137
135
|
// ---------------------------------------------------------------------------
|
|
138
136
|
// Rule 5: bare-dir `[[ref]]` → `[[dir/INDEX]]`. A ref is rewritten only when
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// Resolution uses the fs-only memory resolver from this process's ambient
|
|
143
|
-
// target, because refs cross stores (user docs point into plugin dirs).
|
|
137
|
+
// the owning target corpus proves the old bare-directory convenience and the
|
|
138
|
+
// explicit INDEX spelling resolve to the same file. The decision cache belongs
|
|
139
|
+
// to one migration target; profile-local names never leak across stores.
|
|
144
140
|
// ---------------------------------------------------------------------------
|
|
145
|
-
|
|
146
|
-
const refDecisionMemo = new Map();
|
|
147
|
-
function resolveOne(name) {
|
|
148
|
-
corpusMemo ??= createMemoryDocSnapshot();
|
|
149
|
-
return corpusMemo.resolve([name]).get(name);
|
|
150
|
-
}
|
|
151
|
-
function indexRefReplacement(name) {
|
|
152
|
-
const memo = refDecisionMemo.get(name);
|
|
153
|
-
if (memo !== undefined)
|
|
154
|
-
return memo;
|
|
155
|
-
let out = null;
|
|
156
|
-
if (name !== 'INDEX' && !name.endsWith('/INDEX')) {
|
|
157
|
-
const hit = resolveOne(name);
|
|
158
|
-
if (hit !== undefined && basename(hit.path) === 'INDEX.md') {
|
|
159
|
-
const explicit = resolveOne(`${name}/INDEX`);
|
|
160
|
-
if (explicit !== undefined && explicit.path === hit.path)
|
|
161
|
-
out = `${name}/INDEX`;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
refDecisionMemo.set(name, out);
|
|
165
|
-
return out;
|
|
166
|
-
}
|
|
167
|
-
function rewriteBareDirRefs(body) {
|
|
141
|
+
function rewriteBareDirRefs(body, context, decisions) {
|
|
168
142
|
const links = findDocLinks(body);
|
|
169
143
|
if (links.length === 0)
|
|
170
144
|
return body;
|
|
171
145
|
let out = '';
|
|
172
146
|
let pos = 0;
|
|
173
147
|
for (const link of links) {
|
|
174
|
-
|
|
148
|
+
let repl = decisions.get(link.name);
|
|
149
|
+
if (repl === undefined) {
|
|
150
|
+
repl = context?.legacyDirectoryIndex(link.name) ?? null;
|
|
151
|
+
decisions.set(link.name, repl);
|
|
152
|
+
}
|
|
175
153
|
if (repl === null)
|
|
176
154
|
continue;
|
|
177
155
|
out += body.slice(pos, link.start) + `[[${repl}]]`;
|
|
@@ -258,12 +236,13 @@ function rebuildSource(d, newYaml, newBody) {
|
|
|
258
236
|
export const surfacesFrontmatterMigration = {
|
|
259
237
|
lane: 'convergent',
|
|
260
238
|
description: 'surfaces frontmatter: fold visibility/applies-to/read-when routing into surfaces entries; rewrite bare-directory [[refs]] to [[dir/INDEX]]',
|
|
261
|
-
apply(store) {
|
|
239
|
+
apply(store, context) {
|
|
262
240
|
const docs = store.docs.map(classify).filter((d) => d !== null);
|
|
263
241
|
const ceiling = buildCeiling(docs);
|
|
242
|
+
const decisions = new Map();
|
|
264
243
|
const changes = [];
|
|
265
244
|
for (const d of docs) {
|
|
266
|
-
const newBody = rewriteBareDirRefs(d.doc.body);
|
|
245
|
+
const newBody = rewriteBareDirRefs(d.doc.body, context, decisions);
|
|
267
246
|
if (!d.hasOldFields && newBody === d.doc.body)
|
|
268
247
|
continue;
|
|
269
248
|
const newYaml = d.hasOldFields
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { StateMigration, StoreSnapshot } from './types.js';
|
|
1
|
+
import type { ConvergentMigrationContext, StateMigration, StoreSnapshot } from './types.js';
|
|
2
2
|
/** A doc excluded from the snapshot because its frontmatter is not valid YAML.
|
|
3
3
|
* Per-doc throw isolation at the collection layer: the bad file is named and
|
|
4
4
|
* the rest of the store still migrates. */
|
|
@@ -28,4 +28,5 @@ export declare function loadStoreSnapshot(root: string): {
|
|
|
28
28
|
export declare function runConvergentMigrations(root: string, opts?: {
|
|
29
29
|
dryRun?: boolean;
|
|
30
30
|
migrations?: readonly StateMigration[];
|
|
31
|
+
context?: ConvergentMigrationContext;
|
|
31
32
|
}): ConvergentRunResult;
|
|
@@ -57,7 +57,7 @@ export function runConvergentMigrations(root, opts = {}) {
|
|
|
57
57
|
const applied = [];
|
|
58
58
|
for (const migration of migrations) {
|
|
59
59
|
const bySource = new Map(snapshot.docs.map((d) => [d.path, d.source]));
|
|
60
|
-
const changes = migration.apply(snapshot).filter((c) => bySource.get(c.path) !== c.after);
|
|
60
|
+
const changes = migration.apply(snapshot, opts.context).filter((c) => bySource.get(c.path) !== c.after);
|
|
61
61
|
if (changes.length === 0)
|
|
62
62
|
continue;
|
|
63
63
|
for (const change of changes) {
|
|
@@ -7,12 +7,17 @@ export interface JournaledMigration {
|
|
|
7
7
|
* (a re-run after a partial application completes rather than corrupts). */
|
|
8
8
|
apply(db: DatabaseSync, canvasHome: string): void;
|
|
9
9
|
}
|
|
10
|
+
export interface ConvergentMigrationContext {
|
|
11
|
+
/** Frozen pre-cut directory-INDEX resolution for this store's owning memory
|
|
12
|
+
* corpus. Null means rewriting the bare ref would not preserve meaning. */
|
|
13
|
+
legacyDirectoryIndex(name: string): string | null;
|
|
14
|
+
}
|
|
10
15
|
export interface ConvergentMigration {
|
|
11
16
|
lane: 'convergent';
|
|
12
17
|
description: string;
|
|
13
18
|
/** Pure planning: return the full new text of each doc to rewrite. The
|
|
14
19
|
* runner filters no-op changes and performs the writes. */
|
|
15
|
-
apply(store: StoreSnapshot): DocChange[];
|
|
20
|
+
apply(store: StoreSnapshot, context?: ConvergentMigrationContext): DocChange[];
|
|
16
21
|
}
|
|
17
22
|
export type StateMigration = JournaledMigration | ConvergentMigration;
|
|
18
23
|
/** One parsed markdown doc in a store snapshot. */
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.212",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.212",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|