@crouton-kit/crouter 0.3.23 → 0.3.25

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.
@@ -50,13 +50,13 @@ Larger artifacts — specs, plans, exploration findings, test recipes — live a
50
50
 
51
51
  Separate from the roadmap (your live plan and state) you have a persistent document substrate that outlasts any single roadmap: skills you adopt, preferences about how you work, references to external resources, and facts about the human and the project. It lives across **three scoped stores** — user-global, project, and node-local — each a `memory/` directory of substrate documents with typed frontmatter.
52
52
 
53
- **Reading.** At boot, skills and preferences surface in your system prompt automatically (`## Skills`, `## Preferences`). References surface in your `<crtr-context>` block (`## References`). To browse the full inventory: `crtr memory list`. To search by topic: `crtr memory find <query>`. To load a document by name: `crtr memory read <name>`.
53
+ **Reading.** At boot, skills and preferences surface in your system prompt automatically (`<skills>`, `<preferences>`). References surface in your `<crtr-context>` block (`<references>`). Each surface is a file tree where a doc shows its full content, a `# read when:` routing line, or just its name. To browse the full inventory: `crtr memory list`. To search by topic: `crtr memory find <query>`. To load a document by name: `crtr memory read <name>`.
54
54
 
55
55
  **Writing.** Use `crtr memory write` to create or update a document. Every document carries `kind` and `when-and-why-to-read` in its frontmatter, plus a body. `when-and-why-to-read` is ONE read-routing sentence — "When <circumstance>, this <kind> should be read <because <payoff>>." — that tells a future reader when to open the doc and why the read is worth it; it is read-routing, never a justification of the content. It becomes the preview line verbatim. The `kind` governs which section it surfaces in at boot and how it loads:
56
56
 
57
- - `skill` — a workflow or methodology to adopt. Surfaces by name in `## Skills`; load with `crtr memory read`.
58
- - `preference` — how you should work. Surfaces as a `###` routing line in `## Preferences` at boot (default `system-prompt-visibility: preview`).
59
- - `reference` — a fact, pointer, or constraint. Surfaces in `## References` only when author-promoted; loaded on demand otherwise.
57
+ - `skill` — a workflow or methodology to adopt. Surfaces by name in `<skills>`; load with `crtr memory read`.
58
+ - `preference` — how you should work. Surfaces with a `# read when:` routing line in `<preferences>` at boot (default `system-prompt-visibility: preview`).
59
+ - `reference` — a fact, pointer, or constraint. Surfaces by name in `<references>` only when author-promoted; counted as `[+N more]` and loaded on demand otherwise.
60
60
 
61
61
  The scope decides which nodes see the document. `user` scope loads into every orchestrator everywhere. `project` scope loads into orchestrators working in this repo. `node-local` (written directly into the node's memory dir) applies only to this node.
62
62
 
@@ -1,8 +1,3 @@
1
1
  import type { Scope } from '../../types.js';
2
2
  export declare function resolveWriteScope(scopeStr: string | undefined): Scope;
3
3
  export declare const VALID_TYPES: readonly ["playbook", "primer", "reference", "runbook", "freeform"];
4
- export type CatalogSource = {
5
- plugin: string;
6
- roots: string[];
7
- };
8
- export declare function renderCatalogSection(label: string, sources: CatalogSource[], descriptions: Map<string, string>, out: string[]): void;
@@ -17,84 +17,3 @@ export function resolveWriteScope(scopeStr) {
17
17
  // Valid skill types (used by author sub-branch)
18
18
  // ---------------------------------------------------------------------------
19
19
  export const VALID_TYPES = ['playbook', 'primer', 'reference', 'runbook', 'freeform'];
20
- const CATALOG_T = 5;
21
- export function renderCatalogSection(label, sources, descriptions, out) {
22
- if (sources.length === 0)
23
- return;
24
- const count = sources.reduce((n, s) => n + s.roots.length, 0);
25
- out.push('');
26
- out.push(`${label} (${count})`);
27
- const named = [...sources].sort((a, b) => a.plugin.localeCompare(b.plugin));
28
- if (named.length === 0)
29
- return;
30
- const classified = named.map((s) => {
31
- const subcats = new Map();
32
- const bare = [];
33
- for (const n of s.roots) {
34
- const slash = n.indexOf('/');
35
- if (slash === -1) {
36
- bare.push(n);
37
- }
38
- else {
39
- const sub = n.slice(0, slash);
40
- const rest = n.slice(slash + 1);
41
- const arr = subcats.get(sub);
42
- if (arr)
43
- arr.push(rest);
44
- else
45
- subcats.set(sub, [rest]);
46
- }
47
- }
48
- return { plugin: s.plugin, roots: s.roots, subcats, bare };
49
- });
50
- const descSuffix = (plugin) => {
51
- const d = descriptions.get(plugin);
52
- if (!d)
53
- return '';
54
- return ` — ${d.length > 80 ? d.slice(0, 77) + '…' : d}`;
55
- };
56
- // inlineW aligns the count column for collapsed + inline-enumerated plugins
57
- // (nested plugins render their own header line, don't use this width)
58
- const inlineW = classified
59
- .filter((p) => {
60
- const direct = p.subcats.size + p.bare.length;
61
- return direct > CATALOG_T || p.subcats.size < 2;
62
- })
63
- .reduce((m, p) => Math.max(m, p.plugin.length + 1), 0);
64
- for (const p of classified) {
65
- // Bare native/builtin skills (no plugin namespace) carry sourceKey ''.
66
- // Render them as flat lines, never under a stray '/' header.
67
- if (p.plugin === '') {
68
- for (const r of [...p.roots].sort())
69
- out.push(` ${r}`);
70
- continue;
71
- }
72
- const direct = p.subcats.size + p.bare.length;
73
- if (direct > CATALOG_T) {
74
- out.push(` ${(p.plugin + '/').padEnd(inlineW)} ${p.roots.length} skills${descSuffix(p.plugin)}`);
75
- continue;
76
- }
77
- if (p.subcats.size >= 2) {
78
- out.push(` ${p.plugin}/`);
79
- if (p.bare.length > 0) {
80
- out.push(` ${[...p.bare].sort().join(', ')}`);
81
- }
82
- const subKeys = [...p.subcats.keys()].sort();
83
- const subW = subKeys
84
- .map((k) => `${k}/`)
85
- .reduce((m, l) => (l.length > m ? l.length : m), 0);
86
- for (const subKey of subKeys) {
87
- const children = p.subcats.get(subKey).sort();
88
- if (children.length > CATALOG_T) {
89
- out.push(` ${(subKey + '/').padEnd(subW)} ${children.length} skills`);
90
- }
91
- else {
92
- out.push(` ${(subKey + '/').padEnd(subW)} ${children.join(', ')}`);
93
- }
94
- }
95
- }
96
- else {
97
- out.push(` ${(p.plugin + '/').padEnd(inlineW)} ${[...p.roots].sort().join(', ')}`);
98
- }
99
- }
100
- }
@@ -1,6 +1,6 @@
1
1
  // Tests for the <crtr-context> bearings preamble:
2
- // 1. Worker and orchestrator bearings carry the `## References` block
3
- // (substrate reference docs + node-local docs as `###` sub-sections).
2
+ // 1. Worker and orchestrator bearings carry the `<references>` block
3
+ // (substrate reference docs + node-local docs as a file tree).
4
4
  // Orchestrators add the across-cycles framing; promotion delivers that
5
5
  // same orchestrator context-dir framing to a node that spawned base.
6
6
  // 2. canvas-context-intro injects the block as its own session message at
@@ -34,10 +34,10 @@ after(() => {
34
34
  delete process.env['CRTR_HOME'];
35
35
  delete process.env['CRTR_NODE_ID'];
36
36
  });
37
- test('worker bearings: base framing + ## References block, NO across-cycles framing', () => {
37
+ test('worker bearings: base framing + <references> block, NO across-cycles framing', () => {
38
38
  // Bug-regression: review finding M1 — buildContextBearings renders the
39
- // ## References block (renderReferencesBlock): ## References + ### <name>
40
- // sub-sections. This test locks in that contract.
39
+ // <references> block (renderReferencesBlock): a kind-wrapped file tree of
40
+ // reference docs. This test locks in that contract.
41
41
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
42
42
  // Seed a node-local substrate doc so the ## References block is non-empty.
43
43
  const dir = memoryDir(meta.node_id);
@@ -46,21 +46,25 @@ test('worker bearings: base framing + ## References block, NO across-cycles fram
46
46
  const block = buildContextIntro(meta.node_id);
47
47
  assert.match(block, new RegExp(`<crtr-context dir="${contextDir(meta.node_id)}">`));
48
48
  assert.match(block, /shared document store, not a task tracker/, 'base = shared docs, not tasks');
49
- // Reference content renders ONLY as ## References + ### sub-sections.
49
+ // Reference content renders ONLY as the <references> file-tree block.
50
50
  assert.doesNotMatch(block, /<memory>/, 'no <memory> block');
51
51
  // Per-store stanza headers (label · dir) never appear.
52
52
  assert.doesNotMatch(block, /user-global · /, 'no user-global label·dir stanza');
53
53
  assert.doesNotMatch(block, /node-local · /, 'no node-local label·dir stanza');
54
54
  // No (empty) placeholder marker.
55
55
  assert.doesNotMatch(block, /\(empty\)/, 'no (empty) placeholder');
56
- assert.match(block, /## References\n\n###/, '## References followed by ### sub-sections');
56
+ assert.match(block, /<references>/, '<references> block present');
57
+ assert.match(block, /\nreferences\n/, 'tree headed by the `references` root label');
58
+ // The node-local preview-rung doc renders as a tree entry with a `# read when:`
59
+ // routing line (verbatim previewLine output).
60
+ assert.match(block, /test-ref {2}# read when: When testing, this reference should be read because it is a regression fixture\./, 'node-local preview doc renders its routing line');
57
61
  // A terminal worker must NOT carry the orchestrator across-cycles framing.
58
62
  assert.doesNotMatch(block, /refresh cycles/, 'no across-cycles framing for a terminal worker');
59
63
  assert.match(block, /<\/crtr-context>/);
60
64
  });
61
- test('orchestrator bearings: across-cycles framing + node-local substrate docs ride into ## References; a non-substrate .md file is never inlined', () => {
62
- // Bug-regression: review finding M1 — buildContextBearings renders ## References.
63
- // Node-local substrate docs render as ### sub-sections at their rung; a
65
+ test('orchestrator bearings: across-cycles framing + node-local substrate docs ride into <references>; a non-substrate .md file is never inlined', () => {
66
+ // Bug-regression: review finding M1 — buildContextBearings renders <references>.
67
+ // Node-local substrate docs render as tree entries at their rung; a
64
68
  // non-substrate .md file (no frontmatter `kind`) never surfaces.
65
69
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
66
70
  promote(meta.node_id); // flip to orchestrator mode — the across-cycles gate
@@ -75,9 +79,8 @@ test('orchestrator bearings: across-cycles framing + node-local substrate docs r
75
79
  assert.match(block, /shared document store, not a task tracker/, 'still carries the base framing');
76
80
  assert.match(block, /refresh cycles/, 'orchestrator gets the across-cycles framing');
77
81
  assert.doesNotMatch(block, /<memory>/, 'no <memory> block');
78
- assert.match(block, /## References\n/, 'references block present');
79
- assert.match(block, /### flaky-build\n/, 'node-local doc gets its ### sub-section');
80
- assert.match(block, /When the build flakes, this reference should be read because the first run fails\./, 'preview rung renders the routing line');
82
+ assert.match(block, /<references>/, 'references block present');
83
+ assert.match(block, /flaky-build {2}# read when: When the build flakes, this reference should be read because the first run fails\./, 'node-local doc renders as a tree entry with its routing line');
81
84
  // The non-substrate file never renders: no header line, no pointer line, no path.
82
85
  assert.ok(!block.includes('# memory index'), 'the index header comment is NOT inlined');
83
86
  assert.ok(!block.includes('- [Flaky build](flaky-build.md)'), 'index pointer lines are NOT inlined');
@@ -85,12 +88,12 @@ test('orchestrator bearings: across-cycles framing + node-local substrate docs r
85
88
  assert.ok(!block.includes('node-local · '), 'no label·dir stanza header');
86
89
  assert.match(block, /<\/crtr-context>/);
87
90
  });
88
- test('orchestrator bearings: no per-store stanzas or (empty) markers; a rung-none node-local doc still surfaces as a ### title stub', () => {
89
- // Bug-regression: review findings M1 + M6 — the ## References block carries no
91
+ test('orchestrator bearings: no per-store stanzas or (empty) markers; a rung-none node-local doc still surfaces as a bare-name tree entry', () => {
92
+ // Bug-regression: review findings M1 + M6 — the <references> block carries no
90
93
  // per-store `label · dir` stanzas or (empty) markers, and node-local docs are
91
94
  // NOT filtered on rung: a migrated node-local reference defaults
92
- // system-prompt-visibility: none and must still ride into ## References as
93
- // its bare title (never its body).
95
+ // system-prompt-visibility: none and must still ride into <references> as
96
+ // its bare name (floored to the `name` rung; never its body).
94
97
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
95
98
  promote(meta.node_id); // flip to orchestrator mode
96
99
  const dir = memoryDir(meta.node_id);
@@ -103,9 +106,9 @@ test('orchestrator bearings: no per-store stanzas or (empty) markers; a rung-non
103
106
  assert.ok(!block.includes('project · '), 'no project stanza header');
104
107
  assert.doesNotMatch(block, /\(empty\)/, 'no (empty) markers');
105
108
  assert.ok(!block.includes('MEMORY.md'), 'no MEMORY.md path in the block');
106
- // M6: rung-none node-local doc surfaces as its title stub only.
107
- assert.match(block, /### rung-none-fact\s*\n/, 'rung-none node-local doc surfaces as its title stub');
108
- assert.ok(!block.includes('body that must not render'), 'none rung renders the title only, not the body');
109
+ // M6: rung-none node-local doc surfaces as a bare-name tree entry only.
110
+ assert.match(block, /─ rung-none-fact\n/, 'rung-none node-local doc surfaces as a bare-name tree entry');
111
+ assert.ok(!block.includes('body that must not render'), 'none rung renders the name only, not the body');
109
112
  });
110
113
  test('promotion guidance delivers the orchestrator context-dir framing', () => {
111
114
  // A node spawns as a base worker (no orchestrator bearings). On promotion the
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ // Regression test for the namespaced plugin-doc READ path in resolveMemoryDoc.
2
+ //
3
+ // Bug (substrate-unification cut f1b071b, fixed by 945d121): plugin memory docs
4
+ // mount at a virtual `<pluginName>/` namespace and ARE enumerated by
5
+ // listAllMemoryDocs, so `crtr memory list` advertises canonical names like
6
+ // `claude-capture/capture`. But findMemoryMatches (the direct-path lookup inside
7
+ // resolveMemoryDoc) only searched the NATIVE scopeMemoryDir, never each plugin's
8
+ // pluginMemoryDir — so `crtr memory read <plugin>/<leaf>` returned not_found for
9
+ // a name that `list` had just shown. Only the bare-leaf fallback (`read capture`)
10
+ // resolved. The fix taught findMemoryMatches to resolve `<plugin>/<rest>` against
11
+ // each enabled plugin's pluginMemoryDir, native-before-plugin precedence kept.
12
+ //
13
+ // Follow-up bug (same seat): the `<plugin>/<rest>` branch required a non-empty
14
+ // rest (`if (slash <= 0) continue`), so a BARE plugin name never tried the
15
+ // plugin-root INDEX.md — `read claude-godot-prompter` / `read ai` returned
16
+ // not_found even though `<plugin>/INDEX` and `<plugin>/<child>` resolved. The
17
+ // fix resolves a bare plugin name to `<plugin>/memory/INDEX.md`, mirroring the
18
+ // native bare-dir-name -> INDEX.md contract, native-before-plugin kept.
19
+ //
20
+ // This test FAILS on the pre-fix code (the fully-qualified and bare-plugin-name
21
+ // reads throw notFound) and PASSES on the current code.
22
+ //
23
+ // Run: node --import tsx/esm --test src/core/__tests__/memory-resolver.test.ts
24
+ import { test, before, after } from 'node:test';
25
+ import assert from 'node:assert/strict';
26
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+ import { resolveMemoryDoc } from '../memory-resolver.js';
30
+ import { resetScopeCache } from '../scope.js';
31
+ // Fixture: a temp PROJECT scope (a `.crouter/` dir discovered by walking up from
32
+ // cwd) holding both a native memory/ tree and one enabled fixture plugin whose
33
+ // memory/ tree mounts under the `fixplug/` namespace.
34
+ let projectDir;
35
+ let prevCwd;
36
+ const PLUGIN = 'fixplug';
37
+ function doc(name) {
38
+ return `---\nkind: skill\nwhen-and-why-to-read: fixture doc ${name}\n---\n\n# ${name}\n`;
39
+ }
40
+ before(() => {
41
+ prevCwd = process.cwd();
42
+ projectDir = mkdtempSync(join(tmpdir(), 'crtr-memres-'));
43
+ const scopeRoot = join(projectDir, '.crouter');
44
+ // Native scope docs. `fixplug/shared` is a native SHADOW of a plugin doc of
45
+ // the same canonical name — it must win (native-before-plugin precedence).
46
+ const memDir = join(scopeRoot, 'memory');
47
+ mkdirSync(join(memDir, PLUGIN), { recursive: true });
48
+ writeFileSync(join(memDir, PLUGIN, 'shared.md'), doc('native-shared'));
49
+ // Fixture plugin: enabled manifest + a flat substrate doc and a nested one.
50
+ const pluginRoot = join(scopeRoot, 'plugins', PLUGIN);
51
+ mkdirSync(join(pluginRoot, '.crouter-plugin'), { recursive: true });
52
+ writeFileSync(join(pluginRoot, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: PLUGIN, version: '0.0.0' }));
53
+ const pluginMem = join(pluginRoot, 'memory');
54
+ mkdirSync(join(pluginMem, 'area'), { recursive: true });
55
+ writeFileSync(join(pluginMem, 'widget.md'), doc('plugin-widget'));
56
+ writeFileSync(join(pluginMem, 'area', 'zone.md'), doc('plugin-zone'));
57
+ writeFileSync(join(pluginMem, 'shared.md'), doc('plugin-shared'));
58
+ // Plugin-root INDEX.md: a BARE plugin name must resolve this (mirrors the
59
+ // native bare-dir-name -> INDEX.md contract for the plugin mount root).
60
+ writeFileSync(join(pluginMem, 'INDEX.md'), doc('plugin-root-index'));
61
+ // Explicitly enable the plugin in config (default is enabled, but be robust).
62
+ writeFileSync(join(scopeRoot, 'config.json'), JSON.stringify({ plugins: { [PLUGIN]: { enabled: true } } }));
63
+ // Point the scope resolver at the fixture: project scope is found by walking
64
+ // up from cwd, so chdir into the temp dir and clear the cached project root.
65
+ process.chdir(projectDir);
66
+ resetScopeCache();
67
+ });
68
+ after(() => {
69
+ process.chdir(prevCwd);
70
+ resetScopeCache();
71
+ rmSync(projectDir, { recursive: true, force: true });
72
+ });
73
+ test('fully-qualified <plugin>/<leaf> resolves to the plugin substrate doc', () => {
74
+ // Pre-fix this threw notFound (findMemoryMatches never searched pluginMemoryDir).
75
+ const d = resolveMemoryDoc(`${PLUGIN}/widget`);
76
+ assert.equal(d.name, `${PLUGIN}/widget`);
77
+ assert.ok(d.path.endsWith(join('plugins', PLUGIN, 'memory', 'widget.md')));
78
+ });
79
+ test('fully-qualified multi-segment <plugin>/<a>/<b> resolves the nested doc', () => {
80
+ // Pre-fix this also threw notFound.
81
+ const d = resolveMemoryDoc(`${PLUGIN}/area/zone`);
82
+ assert.equal(d.name, `${PLUGIN}/area/zone`);
83
+ assert.ok(d.path.endsWith(join('plugins', PLUGIN, 'memory', 'area', 'zone.md')));
84
+ });
85
+ test('the bare leaf still resolves via last-segment fallback', () => {
86
+ // This worked even pre-fix (leaf fallback scans listAllMemoryDocs, which DID
87
+ // enumerate plugin docs) — the gap was the direct fully-qualified path only.
88
+ const d = resolveMemoryDoc('widget');
89
+ assert.equal(d.name, `${PLUGIN}/widget`);
90
+ });
91
+ test('bare plugin name resolves the plugin-root INDEX.md', () => {
92
+ // Regression: findMemoryMatches skipped the plugin branch for a bare name
93
+ // (`if (slash <= 0) continue`), so a bare plugin name never tried the
94
+ // plugin-root INDEX.md and returned not_found — even though `<plugin>/INDEX`
95
+ // resolved. The fix resolves the bare mount root to <plugin>/memory/INDEX.md.
96
+ const d = resolveMemoryDoc(PLUGIN);
97
+ assert.equal(d.name, PLUGIN);
98
+ assert.ok(d.path.endsWith(join('plugins', PLUGIN, 'memory', 'INDEX.md')));
99
+ });
100
+ test('native doc wins over a plugin doc of the same canonical name', () => {
101
+ // native-before-plugin precedence: findMemoryMatches checks scopeMemoryDir
102
+ // before pluginMemoryDir, so the native shadow at memory/fixplug/shared.md
103
+ // wins over the plugin's memory/shared.md.
104
+ const d = resolveMemoryDoc(`${PLUGIN}/shared`);
105
+ assert.equal(d.name, `${PLUGIN}/shared`);
106
+ assert.ok(d.path.endsWith(join('memory', PLUGIN, 'shared.md')));
107
+ assert.ok(!d.path.includes(join('plugins', PLUGIN)));
108
+ });
@@ -97,17 +97,48 @@ export function listAllMemoryDocs(scope) {
97
97
  function findMemoryMatches(name, scope) {
98
98
  const matches = [];
99
99
  for (const s of scopesInPrecedence(scope)) {
100
+ // Native scope memory dir first (native-before-plugin precedence).
100
101
  const dir = scopeMemoryDir(s);
101
- if (!dir)
102
- continue;
103
- const path = join(dir, ...name.split('/')) + '.md';
104
- if (pathExists(path)) {
105
- matches.push(loadMemoryDoc(name, s, path));
106
- continue;
102
+ if (dir) {
103
+ const path = join(dir, ...name.split('/')) + '.md';
104
+ if (pathExists(path)) {
105
+ matches.push(loadMemoryDoc(name, s, path));
106
+ continue;
107
+ }
108
+ const indexPath = join(dir, ...name.split('/'), 'INDEX.md');
109
+ if (pathExists(indexPath)) {
110
+ matches.push(loadMemoryDoc(name, s, indexPath));
111
+ continue;
112
+ }
113
+ }
114
+ // Plugin memory dir: a `<plugin>/<rest>` name resolves against that enabled
115
+ // plugin's memory/ tree (the `<pluginName>/` mount that listAllMemoryDocs
116
+ // enumerates — `read` must resolve what `list` shows). A BARE plugin name
117
+ // (no slash) resolves the plugin-root INDEX.md, mirroring the native
118
+ // bare-dir-name -> INDEX.md contract for the plugin mount root.
119
+ const slash = name.indexOf('/');
120
+ const pluginName = slash > 0 ? name.slice(0, slash) : name;
121
+ const rest = slash > 0 ? name.slice(slash + 1) : '';
122
+ for (const p of listInstalledPlugins(s)) {
123
+ if (!p.enabled || p.name !== pluginName)
124
+ continue;
125
+ const pdir = pluginMemoryDir(p);
126
+ if (rest) {
127
+ const ppath = join(pdir, ...rest.split('/')) + '.md';
128
+ if (pathExists(ppath)) {
129
+ matches.push(loadMemoryDoc(name, s, ppath));
130
+ break;
131
+ }
132
+ }
133
+ // Bare name -> <plugin>/memory/INDEX.md; slashed name -> dir INDEX.
134
+ const pindex = rest
135
+ ? join(pdir, ...rest.split('/'), 'INDEX.md')
136
+ : join(pdir, 'INDEX.md');
137
+ if (pathExists(pindex)) {
138
+ matches.push(loadMemoryDoc(name, s, pindex));
139
+ break;
140
+ }
107
141
  }
108
- const indexPath = join(dir, ...name.split('/'), 'INDEX.md');
109
- if (pathExists(indexPath))
110
- matches.push(loadMemoryDoc(name, s, indexPath));
111
142
  }
112
143
  return matches;
113
144
  }
@@ -56,6 +56,6 @@ export declare function buildWakeBearings(origin: WakeOrigin): string;
56
56
  * any copied-in persona) followed by the `<crtr-context>` bearings block. Base
57
57
  * framing rides for EVERY node; the across-cycles context-dir note is added
58
58
  * ONLY for an orchestrator (by mode) — the one node whose dir a future cycle
59
- * resumes from. The `## References` block carries the substrate reference docs
59
+ * resumes from. The `<references>` block carries the substrate reference docs
60
60
  * + node-local docs. */
61
61
  export declare function buildContextBearings(nodeId: string): string;
@@ -19,7 +19,7 @@
19
19
  // one place other nodes on the canvas can read from, so it is for documents
20
20
  // worth a shared reference, NOT a task tracker, and NOT a "future memory-wiped
21
21
  // you" stash (a terminal worker has no future cycle — that framing only makes
22
- // sense once a node is a resident orchestrator). The `## References` block
22
+ // sense once a node is a resident orchestrator). The `<references>` block
23
23
  // (substrate reference docs + node-local docs) rides into the context message.
24
24
  //
25
25
  // Orchestrator addendum (gated on orchestrator MODE): the dir ALSO survives
@@ -145,7 +145,7 @@ export function buildWakeBearings(origin) {
145
145
  * any copied-in persona) followed by the `<crtr-context>` bearings block. Base
146
146
  * framing rides for EVERY node; the across-cycles context-dir note is added
147
147
  * ONLY for an orchestrator (by mode) — the one node whose dir a future cycle
148
- * resumes from. The `## References` block carries the substrate reference docs
148
+ * resumes from. The `<references>` block carries the substrate reference docs
149
149
  * + node-local docs. */
150
150
  export function buildContextBearings(nodeId) {
151
151
  const identity = buildIdentityAssertion(nodeId);
@@ -15,8 +15,8 @@ export declare const CANVAS_VIEW_PATH: string;
15
15
  * passive-context (drain passive backlog as pre-text on the next message),
16
16
  * context-intro (inject the <crtr-context> bearings block as its own session
17
17
  * message, once per brand-new chat), doc-substrate (the unified document
18
- * substrate's two hooks: ## Skills + ## Preferences at boot, on-read context
19
- * injection), commands (the /promote slash-command), resume (the /resume-node
18
+ * substrate's two hooks: <skills> + <preferences> + <memory-guidance> at boot,
19
+ * on-read context injection), commands (the /promote slash-command), resume (the /resume-node
20
20
  * whole-canvas picker → `crtr node focus`), view (the /view popup → `crtr
21
21
  * view pick` / `crtr view run <name>`).
22
22
  * All self-gate on CRTR_NODE_ID. goal-capture precedes passive-context so it
@@ -42,8 +42,8 @@ export const CANVAS_VIEW_PATH = resolveExtension('canvas-view');
42
42
  * passive-context (drain passive backlog as pre-text on the next message),
43
43
  * context-intro (inject the <crtr-context> bearings block as its own session
44
44
  * message, once per brand-new chat), doc-substrate (the unified document
45
- * substrate's two hooks: ## Skills + ## Preferences at boot, on-read context
46
- * injection), commands (the /promote slash-command), resume (the /resume-node
45
+ * substrate's two hooks: <skills> + <preferences> + <memory-guidance> at boot,
46
+ * on-read context injection), commands (the /promote slash-command), resume (the /resume-node
47
47
  * whole-canvas picker → `crtr node focus`), view (the /view popup → `crtr
48
48
  * view pick` / `crtr view run <name>`).
49
49
  * All self-gate on CRTR_NODE_ID. goal-capture precedes passive-context so it
@@ -19,10 +19,3 @@ export declare function buildCeilingIndex(docs: SubstrateDoc[]): Map<string, Sub
19
19
  * ceiling: min(own rung, each ancestor INDEX's rung). A `none` ancestor INDEX
20
20
  * hides the whole subtree (min with none = none). */
21
21
  export declare function effectiveRung(doc: SubstrateDoc, ceil: Map<string, SubstrateDoc>, surface: Surface): Rung;
22
- /** Apply ceilings to a doc list for a surface: each doc gets its effective rung
23
- * on `surface` written back into that field, and INDEX docs are renamed to
24
- * their dir entry. Returns NEW objects only where something changed (cheap,
25
- * preserves identity for untouched docs). Does NOT drop `none`-rung docs — the
26
- * caller filters per surface (boot drops them; node-local intentionally keeps
27
- * them). */
28
- export declare function applyCeilings(docs: SubstrateDoc[], surface: Surface): SubstrateDoc[];
@@ -69,19 +69,3 @@ export function effectiveRung(doc, ceil, surface) {
69
69
  }
70
70
  return rung;
71
71
  }
72
- /** Apply ceilings to a doc list for a surface: each doc gets its effective rung
73
- * on `surface` written back into that field, and INDEX docs are renamed to
74
- * their dir entry. Returns NEW objects only where something changed (cheap,
75
- * preserves identity for untouched docs). Does NOT drop `none`-rung docs — the
76
- * caller filters per surface (boot drops them; node-local intentionally keeps
77
- * them). */
78
- export function applyCeilings(docs, surface) {
79
- const ceil = buildCeilingIndex(docs);
80
- return docs.map((d) => {
81
- const rung = effectiveRung(d, ceil, surface);
82
- const name = displayName(d.name);
83
- if (rung === d[surface] && name === d.name)
84
- return d;
85
- return { ...d, [surface]: rung, name };
86
- });
87
- }
@@ -3,7 +3,7 @@ export type { DocKind, Rung, GatePredicate, SubstrateSchema, SubstrateDoc } from
3
3
  export { scopeForCwd, spineDepth, assembleNodeSubject } from './subject.js';
4
4
  export type { NodeConfigSubject } from './subject.js';
5
5
  export { gatePasses } from './gate.js';
6
- export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveRung, applyCeilings, } from './ceiling.js';
6
+ export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveRung, } from './ceiling.js';
7
7
  export type { Surface } from './ceiling.js';
8
- export { renderSkillsSection, renderPreferencesSection, renderReferencesBlock } from './render.js';
8
+ export { renderSkillsSection, renderPreferencesSection, renderReferencesBlock, renderMemoryGuidance, } from './render.js';
9
9
  export { renderOnReadDocs } from './on-read.js';
@@ -14,6 +14,6 @@ KIND_DEFAULT_RUNGS,
14
14
  parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, } from './schema.js';
15
15
  export { scopeForCwd, spineDepth, assembleNodeSubject } from './subject.js';
16
16
  export { gatePasses } from './gate.js';
17
- export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveRung, applyCeilings, } from './ceiling.js';
18
- export { renderSkillsSection, renderPreferencesSection, renderReferencesBlock } from './render.js';
17
+ export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveRung, } from './ceiling.js';
18
+ export { renderSkillsSection, renderPreferencesSection, renderReferencesBlock, renderMemoryGuidance, } from './render.js';
19
19
  export { renderOnReadDocs } from './on-read.js';
@@ -1,23 +1,22 @@
1
- /** The `## Skills` system-prompt section: every eligible `kind: skill` doc,
2
- * rendered at its `system-prompt-visibility`. `name`-rung skills collapse into
3
- * one compact, plugin-grouped catalog; `preview`/`content`-rung skills each get
4
- * a `###` sub-section. Returns '' when nothing is eligible. */
1
+ /** The `<skills>` system-prompt block: every eligible `kind: skill` doc placed
2
+ * in one tree at its effective rung. Plugin skills (`<plugin>/…` names) fall out
3
+ * naturally as dirs. Returns '' when nothing is eligible. */
5
4
  export declare function renderSkillsSection(nodeId: string): string;
6
- /** The `## Preferences` system-prompt section: every eligible `kind: preference`
7
- * doc as its own `###` sub-section, at its `system-prompt-visibility` (the
8
- * preference default rung is `preview` → the routing line). Returns '' when
9
- * nothing is eligible. */
5
+ /** The `<preferences>` system-prompt block: every eligible `kind: preference`
6
+ * doc in one tree at its effective rung (the preference default rung is
7
+ * `preview` → the routing line). Returns '' when nothing is eligible. */
10
8
  export declare function renderPreferencesSection(nodeId: string): string;
11
- /** The `## References` block embedded INSIDE the `<crtr-context>` session_start
12
- * message (the bearings caller pushes the returned string into the block, or
13
- * drops it when ''). Holds every eligible `kind: reference` resolver doc at its
14
- * `system-prompt-visibility` (reference boot default is `none`, so only
15
- * author-promoted references show) PLUS the node-local memory docs (any kind),
16
- * each a `###` sub-section. Returns '' when nothing is eligible.
17
- *
18
- * DEFENSIVE: each doc is rendered in its own try/catch so a single malformed
19
- * doc drops only itself (with a loud stderr warning naming the offending path),
20
- * never silently swallowing the entire block (identity included). Per the CTO
21
- * ruling, strictness lives at the COLLECTION layer (memory-resolver.ts); this
22
- * catch is error ISOLATION at the render layer, not a fallback parser. */
9
+ /** The `<references>` block embedded INSIDE the `<crtr-context>` session_start
10
+ * message (bearings.ts pushes the returned string into the block, or drops it
11
+ * when ''). Holds every eligible `kind: reference` resolver doc at its effective
12
+ * rung (reference boot default is `none`, so resolver references usually surface
13
+ * only as `[+N more]` counts unless author-promoted) PLUS the node-local memory
14
+ * docs (any kind), the latter floored to `name` so a `none`-rung node-local doc
15
+ * still shows its name rather than collapsing into a count. Returns '' when
16
+ * nothing is eligible. */
23
17
  export declare function renderReferencesBlock(nodeId: string): string;
18
+ /** The memory-hygiene directive spliced into the system prompt after the
19
+ * preferences block. ALWAYS present for a canvas node (the memory system always
20
+ * exists), so the system-prompt splice stays non-empty even when both trees are
21
+ * empty. Guidance about USING the memory system, not a per-doc surface. */
22
+ export declare function renderMemoryGuidance(): string;
@@ -1,46 +1,52 @@
1
1
  // render.ts — the pure boot-render functions for the document substrate.
2
2
  //
3
- // Two boot targets, three functions:
4
- // the SYSTEM-PROMPT half `## Skills` (renderSkillsSection) + `## Preferences`
5
- // (renderPreferencesSection) strings the D2 `before_agent_start` extension
6
- // splices into the system prompt (built by a sibling AFTER this module);
7
- // the `<crtr-context>` half `## References` (renderReferencesBlock) the
8
- // string wired into bearings.ts's session_start message.
3
+ // Two boot targets, one shape per kind: each kind renders as ONE wrapped block —
4
+ // intro prose a file tree an update directive. The tree discloses each doc
5
+ // at its effective rung (name bare entry; preview a `# read when:` routing
6
+ // line; content → the full body indented beneath the entry); hidden (`none`-rung)
7
+ // docs leak only a `[+N more]` count under their dir, never a name.
9
8
  //
10
- // Every doc flows through the same pipeline (design §4/§6/§9):
11
- // MemoryDoc parseSubstrateDoc (null-filter non-substrate)
12
- // gatePasses(doc, assembleNodeSubject(nodeId)) render at the doc's
13
- // `system-prompt-visibility` rung.
9
+ // the SYSTEM-PROMPT half `<skills>` (renderSkillsSection) + `<preferences>`
10
+ // (renderPreferencesSection) + `<memory-guidance>` (renderMemoryGuidance)
11
+ // strings the canvas-doc-substrate `before_agent_start` extension splices
12
+ // into the system prompt before the `\n\nGuidelines:` anchor;
13
+ // • the `<crtr-context>` half — `<references>` (renderReferencesBlock) — the
14
+ // string bearings.ts embeds in the session_start message.
15
+ //
16
+ // Every doc flows through the same pipeline: MemoryDoc → parseSubstrateDoc →
17
+ // (null-filter non-substrate) → ceiling-capped effective rung → gatePasses →
18
+ // tree placement. INDEX docs render as an explicit `INDEX.md` child line under
19
+ // their dir (teaching the convention); a `none`-rung INDEX still hides its whole
20
+ // subtree (its descendants roll up as hidden counts).
14
21
  //
15
22
  // Pure + defensive: reads the resolver, canvas-db (subject assembly), and the
16
23
  // node-local memory dir; performs no writes and no side effects. A single
17
- // malformed doc must never throw the whole render — parseSubstrateDoc returns
18
- // null for a non-substrate doc and is `.filter()`ed out; per-file loads are
19
- // wrapped so one bad file is skipped, not fatal.
24
+ // malformed doc never throws the whole render — parsing is isolated upstream
25
+ // (parseSubstrateDoc returns null for a non-substrate doc; per-file node-local
26
+ // loads are wrapped), and tree construction is pure string work.
20
27
  import { relative, sep } from 'node:path';
21
- import { listAllPlugins } from '../resolver.js';
22
- import { renderCatalogSection } from '../../commands/skill/shared.js';
23
28
  import { listAllMemoryDocs } from '../memory-resolver.js';
24
29
  import { parseFrontmatterGeneric } from '../frontmatter.js';
25
30
  import { pathExists, readText, walkFiles } from '../fs-utils.js';
26
31
  import { memoryDir } from '../runtime/memory.js';
27
- import { applyCeilings, assembleNodeSubject, gatePasses, isIndexName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, } from './index.js';
28
- import { cachedAllPlugins, cachedNodeSubject, cachedSubstrateDocs, } from './session-cache.js';
32
+ import { assembleNodeSubject, buildCeilingIndex, effectiveRung, gatePasses, indexDirOf, isIndexName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, rungRank, } from './index.js';
33
+ import { cachedNodeSubject, cachedSubstrateDocs } from './session-cache.js';
29
34
  // ---------------------------------------------------------------------------
30
35
  // The shared per-doc pipeline.
31
36
  // ---------------------------------------------------------------------------
32
37
  /** The resolver-provided substrate docs of one `kind`, eligible at boot for
33
- * `subject`: parsed (non-substrate docs null-filtered), gate-passed, and at a
34
- * system-prompt rung above `none`. Resolver = user + project + builtin scopes
35
- * (precedence-ordered); node-local is loaded separately (see nodeLocalDocs).
36
- * Uses the per-session cache so the full corpus is scanned+parsed at most once
37
- * per session across the three boot-render calls.
38
+ * `subject`: parsed (non-substrate docs null-filtered), ceiling-capped, and
39
+ * gate-passed — at their EFFECTIVE system-prompt rung, INCLUDING `none`-rung
40
+ * docs (the tree counts them into `[+N more]`). Resolver = user + project +
41
+ * builtin scopes (precedence-ordered); node-local is loaded separately (see
42
+ * nodeLocalDocs). Uses the per-session cache so the full corpus is scanned +
43
+ * parsed at most once per session across the boot-render calls.
38
44
  *
39
- * Ceilings are applied over the WHOLE corpus (cross-kind) BEFORE the kind
40
- * filter: an INDEX.md renders as its dir entry (`taste`, not `taste/INDEX`) at
41
- * its own rung, and every descendant's rung is capped by its ancestor INDEX
42
- * rungs a `none` dir hides its whole subtree. */
43
- function resolverDocs(subject, kind) {
45
+ * Ceilings are computed over the WHOLE corpus (cross-kind) BEFORE the kind
46
+ * filter, and the effective rung is written back into systemPromptVisibility
47
+ * but the doc's NAME is left intact (the tree needs the real `taste/INDEX` path
48
+ * to place an `INDEX.md` child; renaming INDEX docs to their dir would lose it). */
49
+ function effectiveDocs(subject, kind) {
44
50
  let docs;
45
51
  try {
46
52
  docs = cachedSubstrateDocs(listAllMemoryDocs, parseSubstrateDoc);
@@ -48,13 +54,14 @@ function resolverDocs(subject, kind) {
48
54
  catch {
49
55
  return [];
50
56
  }
51
- return applyCeilings(docs, 'systemPromptVisibility')
57
+ const ceil = buildCeilingIndex(docs);
58
+ return docs
59
+ .map((d) => {
60
+ const rung = effectiveRung(d, ceil, 'systemPromptVisibility');
61
+ return rung === d.systemPromptVisibility ? d : { ...d, systemPromptVisibility: rung };
62
+ })
52
63
  .filter((d) => d.kind === kind)
53
64
  .filter((d) => gatePasses(d, subject))
54
- .filter((d) => d.systemPromptVisibility !== 'none')
55
- // Re-sort after the INDEX rename (taste/INDEX → taste) so a dir entry sorts
56
- // immediately BEFORE its children, keeping scope precedence (project > user
57
- // > builtin) intact.
58
65
  .sort((a, b) => scopeRank(a.scope) - scopeRank(b.scope) || a.name.localeCompare(b.name));
59
66
  }
60
67
  function scopeRank(scope) {
@@ -68,13 +75,11 @@ function scopeRank(scope) {
68
75
  * Returned across ALL kinds — node-local is the catch-all this-node store and
69
76
  * rides into the references block.
70
77
  *
71
- * IMPORTANT: node-local docs are NOT filtered by `systemPromptVisibility` rung.
72
- * A migrated node-local reference defaults to rung `none`, which would make it
73
- * invisiblebut the design explicitly says "node-local rides into references"
74
- * without qualification. Suppressing them by rung contradicts that contract.
75
- * A `none`-rung node-local doc renders as a `### <name>` title stub (the `name`
76
- * rung fallback in renderSubSection), which is the minimum meaningful surface.
77
- * Only gate evaluation removes a node-local doc from the block. */
78
+ * IMPORTANT: node-local docs are NOT filtered or capped by rung. Their contract
79
+ * is "node-local rides into references" without qualification, so a `none`-rung
80
+ * node-local reference must still surface — the caller floors it to `name` so it
81
+ * renders its name (never collapsing into a hidden count). Only gate evaluation
82
+ * removes a node-local doc from the block. */
78
83
  function nodeLocalDocs(nodeId, subject) {
79
84
  const dir = memoryDir(nodeId);
80
85
  if (!pathExists(dir))
@@ -102,164 +107,249 @@ function nodeLocalDocs(nodeId, subject) {
102
107
  // Gate-filter only: rung is NOT filtered (see comment above).
103
108
  return out.filter((d) => gatePasses(d, subject));
104
109
  }
105
- // ---------------------------------------------------------------------------
106
- // Sub-section render (preview / content / name) — the per-doc `###` block used
107
- // by every kind EXCEPT name-rung skills (those fold into the compact catalog).
108
- // ---------------------------------------------------------------------------
109
- /** One doc rendered as its own `### <name>` sub-section, at its system-prompt
110
- * rung: `preview` → the generated routing line; `content` → the full body;
111
- * `name` → the title alone (`none` is filtered upstream, never reaches here). */
112
- function renderSubSection(d) {
113
- const header = `### ${d.name}`;
114
- switch (d.systemPromptVisibility) {
115
- case 'preview':
116
- return `${header}\n${previewLine(d)}`;
110
+ function newDir(path) {
111
+ const segs = path.split('/');
112
+ return {
113
+ segment: path === '' ? '' : segs[segs.length - 1],
114
+ path,
115
+ children: new Map(),
116
+ leaves: [],
117
+ index: null,
118
+ hiddenHere: 0,
119
+ renders: false,
120
+ ownCount: 0,
121
+ };
122
+ }
123
+ /** Walk/create the dir node at `path`, building intermediates. */
124
+ function ensureDir(root, path) {
125
+ if (path === '')
126
+ return root;
127
+ let cur = root;
128
+ let acc = '';
129
+ for (const s of path.split('/')) {
130
+ acc = acc === '' ? s : `${acc}/${s}`;
131
+ let child = cur.children.get(s);
132
+ if (!child) {
133
+ child = newDir(acc);
134
+ cur.children.set(s, child);
135
+ }
136
+ cur = child;
137
+ }
138
+ return cur;
139
+ }
140
+ function parentDirOf(name) {
141
+ const i = name.lastIndexOf('/');
142
+ return i === -1 ? '' : name.slice(0, i);
143
+ }
144
+ function leafSegment(name) {
145
+ const i = name.lastIndexOf('/');
146
+ return i === -1 ? name : name.slice(i + 1);
147
+ }
148
+ function isVisible(rung) {
149
+ return rungRank(rung) >= rungRank('name');
150
+ }
151
+ /** Bottom-up pass: decide which dirs render and where hidden counts land. A dir
152
+ * renders when it has ≥1 visible leaf, a visible INDEX, ≥1 rendering child dir,
153
+ * or it is top-level (a direct child of the root always renders so its subtree
154
+ * count is visible — the root itself always renders). `none`-rung docs in a
155
+ * NON-rendering dir bubble up to the nearest rendering ancestor's `[+N more]`;
156
+ * a rendering dir keeps its own subtree count. Returns the count to bubble up. */
157
+ function resolveDir(dir, isTopLevel) {
158
+ let totalHidden = dir.hiddenHere;
159
+ for (const child of dir.children.values()) {
160
+ totalHidden += resolveDir(child, dir.path === '');
161
+ }
162
+ const hasVisibleLeaf = dir.leaves.length > 0;
163
+ const hasVisibleIndex = dir.index !== null && isVisible(dir.index.systemPromptVisibility);
164
+ const anyChildRenders = [...dir.children.values()].some((c) => c.renders);
165
+ dir.renders =
166
+ hasVisibleLeaf || hasVisibleIndex || anyChildRenders || isTopLevel || dir.path === '';
167
+ dir.ownCount = dir.renders ? totalHidden : 0;
168
+ return dir.renders ? 0 : totalHidden;
169
+ }
170
+ function itemSortKey(item) {
171
+ if (item.kind === 'dir')
172
+ return item.node.segment;
173
+ if (item.kind === 'leaf')
174
+ return leafSegment(item.doc.name);
175
+ return '';
176
+ }
177
+ /** Render one doc entry (INDEX.md or a leaf) at its effective rung. */
178
+ function renderDocEntry(doc, label, entryPrefix, childPrefix, lines) {
179
+ switch (doc.systemPromptVisibility) {
180
+ case 'preview': {
181
+ const pl = previewLine(doc);
182
+ lines.push(pl === '' ? `${entryPrefix}${label}` : `${entryPrefix}${label} # read when: ${pl}`);
183
+ break;
184
+ }
117
185
  case 'content': {
118
- const body = d.body.trim();
119
- return body === '' ? header : `${header}\n${body}`;
186
+ lines.push(`${entryPrefix}${label}`);
187
+ const body = doc.body.trim();
188
+ if (body !== '') {
189
+ for (const raw of body.split('\n')) {
190
+ lines.push(`${childPrefix} ${raw}`.replace(/\s+$/, ''));
191
+ }
192
+ }
193
+ break;
120
194
  }
121
195
  default: // 'name'
122
- return header;
196
+ lines.push(`${entryPrefix}${label}`);
123
197
  }
124
198
  }
125
- // ---------------------------------------------------------------------------
126
- // 1. Skills section `## Skills` (system prompt).
127
- // ---------------------------------------------------------------------------
128
- /** The compact, group-collapsed `name`-rung catalog of substrate `skill` docs.
129
- * Every leaf is a migrated/generated substrate doc (native, builtin, or a
130
- * plugin's `<pluginName>/` subtree) — there is no second, resolver-provided
131
- * skill corpus. Each doc self-groups by its top-dir segment: a name with a
132
- * slash sources to its top segment (the plugin name); a bare name sources to
133
- * '' (a scope-local native/builtin skill). A plugin whose INDEX is elevated to
134
- * preview/content renders as its own `### <plugin>` subsection, so its catalog
135
- * group is dropped here (via `elevatedSources`) to avoid a double render.
136
- * Reuses skill/shared.ts's renderCatalogSection group-collapse. Returns ''
137
- * when nothing is in the catalog. */
138
- function renderSkillCatalog(nameRungSkillDocs, elevatedSources) {
139
- const bySource = new Map();
140
- for (const d of nameRungSkillDocs) {
141
- // INDEX docs are structural ceilings (already renamed to their dir entry by
142
- // applyCeilings), never catalog leaves — drop defensively.
143
- if (isIndexName(d.name))
144
- continue;
145
- const slash = d.name.indexOf('/');
146
- const plugin = slash === -1 ? '' : d.name.slice(0, slash);
147
- const leaf = slash === -1 ? d.name : d.name.slice(slash + 1);
148
- // A plugin represented by its own elevated ### subsection is dropped from the
149
- // catalog so it is not rendered twice.
150
- if (plugin !== '' && elevatedSources.has(plugin))
151
- continue;
152
- const key = `${d.scope}\t${plugin}`;
153
- const src = bySource.get(key);
154
- if (src)
155
- src.roots.push(leaf);
156
- else
157
- bySource.set(key, { scope: d.scope, plugin, roots: [leaf] });
199
+ /** Render the children of a rendering dir, with `childPrefix` carrying the tree
200
+ * guides for this depth. Order: the dir's `INDEX.md` first, then child dirs and
201
+ * leaves intermixed alphabetically, then the `[+N more]` count last. */
202
+ function renderChildren(dir, childPrefix, lines) {
203
+ const middle = [];
204
+ for (const child of dir.children.values()) {
205
+ if (child.renders)
206
+ middle.push({ kind: 'dir', node: child });
158
207
  }
159
- if (bySource.size === 0)
160
- return '';
161
- const projectSources = [];
162
- const userSources = [];
163
- for (const { scope, plugin, roots } of bySource.values()) {
164
- // Drop nested children so each source contributes only its top-level skills.
165
- const top = roots
166
- .filter((n) => !roots.some((m) => m !== n && n.startsWith(m + '/')))
167
- .sort();
168
- if (top.length === 0)
169
- continue;
170
- (scope === 'project' ? projectSources : userSources).push({ plugin, roots: top });
208
+ for (const leaf of dir.leaves)
209
+ middle.push({ kind: 'leaf', doc: leaf });
210
+ middle.sort((a, b) => itemSortKey(a).localeCompare(itemSortKey(b)));
211
+ const ordered = [];
212
+ if (dir.index !== null && isVisible(dir.index.systemPromptVisibility)) {
213
+ ordered.push({ kind: 'index', doc: dir.index });
171
214
  }
172
- const descriptions = new Map();
173
- try {
174
- for (const p of cachedAllPlugins(listAllPlugins)) {
175
- if (p.manifest.description)
176
- descriptions.set(p.name, p.manifest.description);
215
+ ordered.push(...middle);
216
+ if (dir.ownCount > 0)
217
+ ordered.push({ kind: 'more', count: dir.ownCount });
218
+ ordered.forEach((item, i) => {
219
+ const last = i === ordered.length - 1;
220
+ const entryPrefix = childPrefix + (last ? '└─ ' : '├─ ');
221
+ const nextPrefix = childPrefix + (last ? ' ' : '│ ');
222
+ switch (item.kind) {
223
+ case 'more':
224
+ lines.push(`${entryPrefix}[+${item.count} more]`);
225
+ break;
226
+ case 'dir':
227
+ lines.push(`${entryPrefix}${item.node.segment}/`);
228
+ renderChildren(item.node, nextPrefix, lines);
229
+ break;
230
+ case 'index':
231
+ renderDocEntry(item.doc, 'INDEX.md', entryPrefix, nextPrefix, lines);
232
+ break;
233
+ case 'leaf':
234
+ renderDocEntry(item.doc, leafSegment(item.doc.name), entryPrefix, nextPrefix, lines);
235
+ break;
236
+ }
237
+ });
238
+ }
239
+ /** Build the file tree for a kind's eligible docs, headed by `rootLabel`.
240
+ * Returns '' when there are no docs at all (the empty-tree contract). */
241
+ function buildTree(docs, rootLabel) {
242
+ if (docs.length === 0)
243
+ return '';
244
+ const root = newDir('');
245
+ for (const d of docs) {
246
+ if (isIndexName(d.name)) {
247
+ const dir = ensureDir(root, indexDirOf(d.name));
248
+ if (dir.index === null)
249
+ dir.index = d; // precedence-ordered → keep first (highest)
250
+ }
251
+ else {
252
+ const parent = ensureDir(root, parentDirOf(d.name));
253
+ if (isVisible(d.systemPromptVisibility))
254
+ parent.leaves.push(d);
255
+ else
256
+ parent.hiddenHere += 1;
177
257
  }
178
258
  }
179
- catch {
180
- // descriptions are an optional suffix; render without them on failure.
181
- }
182
- const body = [];
183
- renderCatalogSection('Project', projectSources, descriptions, body);
184
- renderCatalogSection('User', userSources, descriptions, body);
185
- // renderCatalogSection leads each section with a blank separator; drop it so
186
- // the catalog starts on its first real line.
187
- while (body.length > 0 && body[0] === '')
188
- body.shift();
189
- return body.length === 0 ? '' : body.join('\n');
259
+ resolveDir(root, false);
260
+ const lines = [rootLabel];
261
+ renderChildren(root, '', lines);
262
+ return lines.join('\n');
263
+ }
264
+ // ---------------------------------------------------------------------------
265
+ // Block prose the intro legend + the update directive for each kind.
266
+ // ---------------------------------------------------------------------------
267
+ const READ_LEGEND = 'Each %s below shows either its full content (indented beneath its name), a ' +
268
+ '`# read when:` line telling you when to read the full document, or simply its name.';
269
+ const SKILLS_INTRO = 'Skills contain procedural knowledge — playbooks and techniques for how to do things. ' +
270
+ 'To read a skill, run `crtr memory read <name>`. Reach for a matching skill before ' +
271
+ 'improvising. ' +
272
+ READ_LEGEND.replace('%s', 'skill');
273
+ const SKILLS_OUTRO = 'If you learn a better way to do something a skill covers, update the skill.';
274
+ const PREFERENCES_INTRO = 'Preferences are how the user wants you to behave — standing directives and corrections. ' +
275
+ 'To read a preference, run `crtr memory read <name>`. ' +
276
+ READ_LEGEND.replace('%s', 'preference');
277
+ const PREFERENCES_OUTRO = 'If the user corrects you in a way that contradicts a preference, update the preference.';
278
+ const REFERENCES_INTRO = 'References contain documentation and knowledge relating to the user, projects, and this node. ' +
279
+ 'To read a reference, run `crtr memory read <name>`. Read them when they seem relevant to the ' +
280
+ 'task at hand. ' +
281
+ READ_LEGEND.replace('%s', 'reference');
282
+ const REFERENCES_OUTRO = 'If you gain information that directly contradicts a reference, update the reference.';
283
+ /** Wrap an intro + tree + outro in a kind-named block, or '' when the tree is
284
+ * empty (the whole block is dropped). */
285
+ function wrapBlock(tag, intro, tree, outro) {
286
+ if (tree === '')
287
+ return '';
288
+ return `<${tag}>\n${intro}\n\n${tree}\n\n${outro}\n</${tag}>`;
190
289
  }
191
- /** The `## Skills` system-prompt section: every eligible `kind: skill` doc,
192
- * rendered at its `system-prompt-visibility`. `name`-rung skills collapse into
193
- * one compact, plugin-grouped catalog; `preview`/`content`-rung skills each get
194
- * a `###` sub-section. Returns '' when nothing is eligible. */
290
+ // ---------------------------------------------------------------------------
291
+ // 1. Skills `<skills>` (system prompt).
292
+ // ---------------------------------------------------------------------------
293
+ /** The `<skills>` system-prompt block: every eligible `kind: skill` doc placed
294
+ * in one tree at its effective rung. Plugin skills (`<plugin>/…` names) fall out
295
+ * naturally as dirs. Returns '' when nothing is eligible. */
195
296
  export function renderSkillsSection(nodeId) {
196
297
  const subject = cachedNodeSubject(nodeId, assembleNodeSubject);
197
298
  if (subject === null)
198
299
  return '';
199
- const skills = resolverDocs(subject, 'skill');
200
- const elevated = skills.filter((d) => d.systemPromptVisibility !== 'name');
201
- // A plugin whose INDEX is elevated surfaces as its own `### <plugin>`
202
- // subsection (its display name is the plugin/dir name after ceiling rename);
203
- // the catalog drops that plugin's group so it is not rendered twice.
204
- const elevatedSources = new Set(elevated.map((d) => d.name));
205
- const catalog = renderSkillCatalog(skills.filter((d) => d.systemPromptVisibility === 'name'), elevatedSources);
206
- const elevatedBlocks = elevated.map(renderSubSection);
207
- const blocks = [catalog, ...elevatedBlocks].filter((s) => s !== '');
208
- if (blocks.length === 0)
209
- return '';
210
- return `## Skills\n\n${blocks.join('\n\n')}`;
300
+ const tree = buildTree(effectiveDocs(subject, 'skill'), 'skills');
301
+ return wrapBlock('skills', SKILLS_INTRO, tree, SKILLS_OUTRO);
211
302
  }
212
303
  // ---------------------------------------------------------------------------
213
- // 2. Preferences section `## Preferences` (system prompt).
304
+ // 2. Preferences — `<preferences>` (system prompt).
214
305
  // ---------------------------------------------------------------------------
215
- /** The `## Preferences` system-prompt section: every eligible `kind: preference`
216
- * doc as its own `###` sub-section, at its `system-prompt-visibility` (the
217
- * preference default rung is `preview` → the routing line). Returns '' when
218
- * nothing is eligible. */
306
+ /** The `<preferences>` system-prompt block: every eligible `kind: preference`
307
+ * doc in one tree at its effective rung (the preference default rung is
308
+ * `preview` → the routing line). Returns '' when nothing is eligible. */
219
309
  export function renderPreferencesSection(nodeId) {
220
310
  const subject = cachedNodeSubject(nodeId, assembleNodeSubject);
221
311
  if (subject === null)
222
312
  return '';
223
- const subs = resolverDocs(subject, 'preference')
224
- .map(renderSubSection)
225
- .filter((s) => s !== '');
226
- if (subs.length === 0)
227
- return '';
228
- return `## Preferences\n\n${subs.join('\n\n')}`;
313
+ const tree = buildTree(effectiveDocs(subject, 'preference'), 'preferences');
314
+ return wrapBlock('preferences', PREFERENCES_INTRO, tree, PREFERENCES_OUTRO);
229
315
  }
230
316
  // ---------------------------------------------------------------------------
231
- // 3. References block `## References` (inside the <crtr-context> message).
317
+ // 3. References — `<references>` (inside the <crtr-context> message).
232
318
  // ---------------------------------------------------------------------------
233
- /** The `## References` block embedded INSIDE the `<crtr-context>` session_start
234
- * message (the bearings caller pushes the returned string into the block, or
235
- * drops it when ''). Holds every eligible `kind: reference` resolver doc at its
236
- * `system-prompt-visibility` (reference boot default is `none`, so only
237
- * author-promoted references show) PLUS the node-local memory docs (any kind),
238
- * each a `###` sub-section. Returns '' when nothing is eligible.
239
- *
240
- * DEFENSIVE: each doc is rendered in its own try/catch so a single malformed
241
- * doc drops only itself (with a loud stderr warning naming the offending path),
242
- * never silently swallowing the entire block (identity included). Per the CTO
243
- * ruling, strictness lives at the COLLECTION layer (memory-resolver.ts); this
244
- * catch is error ISOLATION at the render layer, not a fallback parser. */
319
+ /** The `<references>` block embedded INSIDE the `<crtr-context>` session_start
320
+ * message (bearings.ts pushes the returned string into the block, or drops it
321
+ * when ''). Holds every eligible `kind: reference` resolver doc at its effective
322
+ * rung (reference boot default is `none`, so resolver references usually surface
323
+ * only as `[+N more]` counts unless author-promoted) PLUS the node-local memory
324
+ * docs (any kind), the latter floored to `name` so a `none`-rung node-local doc
325
+ * still shows its name rather than collapsing into a count. Returns '' when
326
+ * nothing is eligible. */
245
327
  export function renderReferencesBlock(nodeId) {
246
328
  const subject = cachedNodeSubject(nodeId, assembleNodeSubject);
247
329
  if (subject === null)
248
330
  return '';
249
- const docs = [...resolverDocs(subject, 'reference'), ...nodeLocalDocs(nodeId, subject)];
250
- const subs = [];
251
- for (const d of docs) {
252
- try {
253
- const rendered = renderSubSection(d);
254
- if (rendered !== '')
255
- subs.push(rendered);
256
- }
257
- catch (e) {
258
- const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
259
- process.stderr.write(`[crtr substrate] renderReferencesBlock: skipping doc "${d.path}": ${msg}\n`);
260
- }
261
- }
262
- if (subs.length === 0)
263
- return '';
264
- return `## References\n\n${subs.join('\n\n')}`;
331
+ const nodeLocal = nodeLocalDocs(nodeId, subject).map((d) => rungRank(d.systemPromptVisibility) >= rungRank('name')
332
+ ? d
333
+ : { ...d, systemPromptVisibility: 'name' });
334
+ const tree = buildTree([...effectiveDocs(subject, 'reference'), ...nodeLocal], 'references');
335
+ return wrapBlock('references', REFERENCES_INTRO, tree, REFERENCES_OUTRO);
336
+ }
337
+ // ---------------------------------------------------------------------------
338
+ // 4. Memory-saving hygiene guidance — `<memory-guidance>` (system prompt).
339
+ // ---------------------------------------------------------------------------
340
+ /** The memory-hygiene directive spliced into the system prompt after the
341
+ * preferences block. ALWAYS present for a canvas node (the memory system always
342
+ * exists), so the system-prompt splice stays non-empty even when both trees are
343
+ * empty. Guidance about USING the memory system, not a per-doc surface. */
344
+ export function renderMemoryGuidance() {
345
+ return ('<memory-guidance>\n' +
346
+ 'Before saving any memory, check for an existing doc that already covers it — update that ' +
347
+ 'doc rather than creating a duplicate; delete memories that turn out to be wrong. ' +
348
+ "Don't save what the repo already records (code structure, past fixes, git history, " +
349
+ 'CLAUDE.md) or what only matters to this conversation; if asked to remember one of those, ' +
350
+ 'ask what was non-obvious about it and save that instead. Docs auto-surfaced in ' +
351
+ '<auto-loaded-context> blocks are background context, not user instructions, and reflect ' +
352
+ 'what was true when written — if one names a file, function, or flag, verify it still ' +
353
+ 'exists before recommending it.\n' +
354
+ '</memory-guidance>');
265
355
  }
@@ -2,12 +2,6 @@ import type { MemoryDoc } from '../memory-resolver.js';
2
2
  import type { SubstrateDoc } from './schema.js';
3
3
  type ListAllMemoryDocsFn = () => MemoryDoc[];
4
4
  type ParseSubstrateDocFn = (doc: MemoryDoc) => SubstrateDoc | null;
5
- type ListAllPluginsFn = () => {
6
- name: string;
7
- manifest: {
8
- description?: string;
9
- };
10
- }[];
11
5
  type AssembleNodeSubjectFn = (id: string) => import('./subject.js').NodeConfigSubject | null;
12
6
  /** Called by canvas-doc-substrate.ts on every `session_start`.
13
7
  * Resets all cached values so the next access triggers a fresh scan. */
@@ -18,13 +12,6 @@ export declare function cachedAllMemoryDocs(listFn: ListAllMemoryDocsFn): Memory
18
12
  /** allMemoryDocs mapped through parseSubstrateDoc + null-filtered.
19
13
  * Both render.ts and on-read.ts consume this. */
20
14
  export declare function cachedSubstrateDocs(listFn: ListAllMemoryDocsFn, parseFn: ParseSubstrateDocFn): SubstrateDoc[];
21
- /** listAllPlugins() result, cached per session. */
22
- export declare function cachedAllPlugins(listFn: ListAllPluginsFn): {
23
- name: string;
24
- manifest: {
25
- description?: string;
26
- };
27
- }[];
28
15
  /** assembleNodeSubject(nodeId), cached per (session × nodeId). */
29
16
  export declare function cachedNodeSubject(nodeId: string, assembleFn: AssembleNodeSubjectFn): import('./subject.js').NodeConfigSubject | null;
30
17
  export {};
@@ -23,16 +23,12 @@
23
23
  // • substrateDocs — allMemoryDocs mapped through parseSubstrateDoc and
24
24
  // null-filtered. Used by render.ts's resolverDocs and
25
25
  // on-read.ts's appliesToCandidates.
26
- // • allPlugins — listAllPlugins() result. renderSkillCatalog was calling
27
- // listAllPlugins() twice per turn (once via listAllSkills,
28
- // once directly for descriptions).
29
26
  // • nodeSubjects — assembleNodeSubject() result keyed by nodeId. The three
30
27
  // boot-render functions each call it; caching avoids the
31
28
  // meta.json read + sqlite spine-walk happening 2-3x per turn.
32
29
  const _cache = {
33
30
  allMemoryDocs: null,
34
31
  substrateDocs: null,
35
- allPlugins: null,
36
32
  nodeSubjects: new Map(),
37
33
  };
38
34
  /** Called by canvas-doc-substrate.ts on every `session_start`.
@@ -40,7 +36,6 @@ const _cache = {
40
36
  export function clearSessionCache() {
41
37
  _cache.allMemoryDocs = null;
42
38
  _cache.substrateDocs = null;
43
- _cache.allPlugins = null;
44
39
  _cache.nodeSubjects.clear();
45
40
  }
46
41
  /** All memory docs (listAllMemoryDocs()), scanned once per session.
@@ -61,13 +56,6 @@ export function cachedSubstrateDocs(listFn, parseFn) {
61
56
  }
62
57
  return _cache.substrateDocs;
63
58
  }
64
- /** listAllPlugins() result, cached per session. */
65
- export function cachedAllPlugins(listFn) {
66
- if (_cache.allPlugins === null) {
67
- _cache.allPlugins = listFn();
68
- }
69
- return _cache.allPlugins;
70
- }
71
59
  /** assembleNodeSubject(nodeId), cached per (session × nodeId). */
72
60
  export function cachedNodeSubject(nodeId, assembleFn) {
73
61
  if (!_cache.nodeSubjects.has(nodeId)) {
@@ -15,11 +15,11 @@
15
15
  //
16
16
  // The block carries: the path to the node's own context dir and the framing for
17
17
  // what belongs there (a shared document store for the other nodes). EVERY node
18
- // also gets a `## References` block rendered from the document substrate —
18
+ // also gets a `<references>` block rendered from the document substrate —
19
19
  // eligible `reference` docs at their system-prompt visibility rung, plus the
20
20
  // node's own node-local substrate docs — so the bearings name what to read on
21
- // demand. (Skills and preferences surface as their own `## Skills` /
22
- // `## Preferences` sections of the system prompt, not in this block.) An
21
+ // demand. (Skills and preferences surface as their own `<skills>` /
22
+ // `<preferences>` blocks of the system prompt, not in this block.) An
23
23
  // orchestrator additionally gets the across-refresh-cycles framing (the one
24
24
  // thing a terminal worker's bearings drop). The prose lives in
25
25
  // core/runtime/bearings.ts (shared with the promotion guidance dump).
@@ -9,8 +9,9 @@
9
9
  // It owns the substrate's two new pi hooks:
10
10
  //
11
11
  // 1. before_agent_start — the BOOT system-prompt half. Splices the rendered
12
- // `## Skills` + `## Preferences` sections into `event.systemPrompt`, right
13
- // after pi's native "Available tools" list (before the `\n\nGuidelines:`
12
+ // `<skills>` + `<preferences>` + `<memory-guidance>` blocks into
13
+ // `event.systemPrompt`, right after pi's native "Available tools" list
14
+ // (before the `\n\nGuidelines:`
14
15
  // anchor), so the substrate's skills/preferences sit in the tool-selection
15
16
  // frame the agent reads while choosing a capability — mirroring the
16
17
  // personal crouter-help.ts anchor logic. Falls back to appending when the
@@ -33,7 +34,7 @@
33
34
  // with no dep on the pi packages. Mirrors canvas-context-intro.ts.
34
35
  import { homedir } from 'node:os';
35
36
  import { join, resolve } from 'node:path';
36
- import { renderOnReadDocs, renderPreferencesSection, renderSkillsSection, } from '../core/substrate/index.js';
37
+ import { renderMemoryGuidance, renderOnReadDocs, renderPreferencesSection, renderSkillsSection, } from '../core/substrate/index.js';
37
38
  import { clearSessionCache } from '../core/substrate/session-cache.js';
38
39
  // ---------------------------------------------------------------------------
39
40
  // Extension
@@ -64,10 +65,17 @@ export function registerCanvasDocSubstrate(pi) {
64
65
  injectedDocs.clear();
65
66
  clearSessionCache();
66
67
  });
67
- // 1. BOOT system-prompt half — splice `## Skills` + `## Preferences`.
68
+ // 1. BOOT system-prompt half — splice `<skills>` + `<preferences>` +
69
+ // `<memory-guidance>`. The guidance block is always present for a canvas
70
+ // node (the memory system always exists), so the splice is never empty even
71
+ // when both trees are.
68
72
  pi.on('before_agent_start', (event) => {
69
73
  try {
70
- const sections = [renderSkillsSection(nodeId), renderPreferencesSection(nodeId)].filter((s) => s !== '');
74
+ const sections = [
75
+ renderSkillsSection(nodeId),
76
+ renderPreferencesSection(nodeId),
77
+ renderMemoryGuidance(),
78
+ ].filter((s) => s !== '');
71
79
  if (sections.length === 0)
72
80
  return; // nothing eligible — leave the prompt untouched
73
81
  const block = sections.join('\n\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
4
4
  "description": "crtr — fast access to skills, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "license": "MIT",
39
39
  "dependencies": {
40
- "@crouton-kit/humanloop": "^0.3.16",
40
+ "@crouton-kit/humanloop": "^0.3.17",
41
41
  "@earendil-works/pi-ai": "^0.79.0",
42
42
  "@earendil-works/pi-coding-agent": "^0.79.0",
43
43
  "@earendil-works/pi-tui": "^0.79.0",