@north-light/crouter 0.3.162 → 0.3.164

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/builtin-memory/00-runtime-base.md +1 -0
  2. package/dist/builtin-memory/04-orchestration-kernel.md +1 -0
  3. package/dist/builtin-memory/init.md +38 -0
  4. package/dist/builtin-memory/internal/INDEX.md +1 -0
  5. package/dist/builtin-memory/internal/memory-loading.md +45 -0
  6. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +34 -2
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +14 -3
  10. package/dist/clients/attach/__tests__/crtr-output-coverage.test.js +1 -1
  11. package/dist/clients/attach/__tests__/crtr-output.test.js +14 -9
  12. package/dist/clients/attach/__tests__/edit-diff.test.d.ts +1 -0
  13. package/dist/clients/attach/__tests__/edit-diff.test.js +35 -0
  14. package/dist/clients/attach/__tests__/editor-frame-title.test.d.ts +1 -0
  15. package/dist/clients/attach/__tests__/editor-frame-title.test.js +19 -0
  16. package/dist/clients/attach/render/chat-view.js +54 -38
  17. package/dist/clients/attach/render/crtr-output.d.ts +2 -1
  18. package/dist/clients/attach/render/crtr-output.js +33 -4
  19. package/dist/clients/attach/render/edit-diff.d.ts +21 -6
  20. package/dist/clients/attach/render/edit-diff.js +104 -81
  21. package/dist/clients/attach/render/tool-calls.d.ts +21 -20
  22. package/dist/clients/attach/render/tool-calls.js +89 -43
  23. package/dist/clients/attach/session/connection.d.ts +1 -1
  24. package/dist/clients/attach/session/editor-frame.d.ts +5 -1
  25. package/dist/clients/attach/session/editor-frame.js +21 -15
  26. package/dist/clients/attach/session/identity.d.ts +1 -1
  27. package/dist/clients/attach/viewer.js +628 -624
  28. package/dist/commands/__tests__/api-canvas-source.test.d.ts +1 -0
  29. package/dist/commands/__tests__/api-canvas-source.test.js +37 -0
  30. package/dist/commands/__tests__/human.test.js +2 -0
  31. package/dist/commands/__tests__/search-contents.test.d.ts +1 -0
  32. package/dist/commands/__tests__/search-contents.test.js +52 -0
  33. package/dist/commands/api-client.js +1 -0
  34. package/dist/commands/human/prompts.js +2 -2
  35. package/dist/commands/human/shared.d.ts +1 -0
  36. package/dist/commands/human/shared.js +7 -4
  37. package/dist/commands/memory/lint.d.ts +6 -0
  38. package/dist/commands/memory/lint.js +177 -35
  39. package/dist/commands/memory/read.js +22 -1
  40. package/dist/commands/memory/write.js +18 -4
  41. package/dist/commands/memory.js +1 -1
  42. package/dist/commands/search/contents.js +2 -2
  43. package/dist/commands/sys/__tests__/sync-deps.test.js +12 -21
  44. package/dist/commands/sys/__tests__/sync-import.test.js +30 -29
  45. package/dist/commands/sys/setup-wizard.d.ts +8 -1
  46. package/dist/commands/sys/setup-wizard.js +209 -58
  47. package/dist/commands/sys/sync-deps.js +14 -18
  48. package/dist/commands/sys/sync-project-guidance.js +16 -19
  49. package/dist/core/__tests__/migration.test.js +8 -3
  50. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +7 -10
  51. package/dist/core/__tests__/on-read-dedup-resume.test.js +13 -25
  52. package/dist/core/__tests__/on-read-identity.test.js +8 -15
  53. package/dist/core/__tests__/revive.test.js +2 -2
  54. package/dist/core/__tests__/tmux-surface.test.js +10 -4
  55. package/dist/core/__tests__/worktree.test.js +3 -3
  56. package/dist/core/canvas/extensions.d.ts +1 -1
  57. package/dist/core/canvas/extensions.js +18 -11
  58. package/dist/core/canvas/labels.d.ts +4 -5
  59. package/dist/core/canvas/labels.js +9 -9
  60. package/dist/core/command-manifests/schema.js +18 -5
  61. package/dist/core/command.js +17 -7
  62. package/dist/core/configured-clis/invoker.js +2 -0
  63. package/dist/core/help.d.ts +3 -1
  64. package/dist/core/help.js +5 -2
  65. package/dist/core/keybindings/__tests__/resolve.test.js +5 -2
  66. package/dist/core/keybindings/catalog.d.ts +3 -2
  67. package/dist/core/keybindings/catalog.js +34 -25
  68. package/dist/core/keybindings/index.d.ts +1 -1
  69. package/dist/core/keybindings/resolve.js +8 -0
  70. package/dist/core/keybindings/types.d.ts +6 -1
  71. package/dist/core/memory/doc-link-grammar.d.ts +20 -0
  72. package/dist/core/memory/doc-link-grammar.js +110 -0
  73. package/dist/core/memory-resolver.d.ts +5 -0
  74. package/dist/core/memory-resolver.js +12 -1
  75. package/dist/core/runtime/bearings.d.ts +8 -8
  76. package/dist/core/runtime/bearings.js +20 -16
  77. package/dist/core/runtime/broker.js +3 -13
  78. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  79. package/dist/core/runtime/canvas-extensions.js +2 -0
  80. package/dist/core/runtime/launch.d.ts +1 -1
  81. package/dist/core/runtime/launch.js +1 -1
  82. package/dist/core/runtime/tmux.js +136 -100
  83. package/dist/core/scope.js +4 -5
  84. package/dist/core/substrate/ceiling.d.ts +3 -6
  85. package/dist/core/substrate/ceiling.js +13 -15
  86. package/dist/core/substrate/index.d.ts +2 -3
  87. package/dist/core/substrate/index.js +2 -2
  88. package/dist/core/substrate/on-read.d.ts +4 -13
  89. package/dist/core/substrate/on-read.js +142 -262
  90. package/dist/core/substrate/render.js +4 -4
  91. package/dist/core/substrate/schema.d.ts +3 -2
  92. package/dist/core/substrate/schema.js +5 -5
  93. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.d.ts +1 -0
  94. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.js +96 -0
  95. package/dist/pi-extensions/canvas-doc-substrate.js +7 -8
  96. package/dist/pi-extensions/canvas-tool-guide.d.ts +14 -0
  97. package/dist/pi-extensions/canvas-tool-guide.js +77 -0
  98. package/package.json +2 -2
  99. package/runtime.lock.json +6 -6
  100. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crouter-help.ts +0 -95
@@ -1,75 +1,38 @@
1
- // on-read.ts — the file-read-visibility render for the document substrate.
1
+ // on-read.ts — file-context delivery for the document substrate.
2
2
  //
3
- // When a `read` tool call returns, the canvas-doc-substrate pi extension calls
4
- // renderOnReadDocs() to surface the substrate docs that should appear ALONGSIDE
5
- // the file just read — each at its FILE-READ-VISIBILITY rung (NOT the
6
- // system-prompt rung the boot render uses). Three triggers decide which docs
7
- // surface (design §4/§6; Stream A native rules):
3
+ // A memory with file-read visibility participates only through an explicit
4
+ // `applies-to` path glob (authoring lint requires one). Two events use that one
5
+ // routing mechanism:
8
6
  //
9
- // • POSITIONAL — walk the read file's ancestor dirs; any doc living in an
10
- // ancestor PROJECT `.crouter/memory/` surfaces (a doc surfaces when a file
11
- // beside/under its own project scope dir is read), UNLESS it carries an
12
- // explicit read-trigger (see D5 below). This is the substrate's own
13
- // ancestor walk keyed on `.crouter/memory/` — project guidance that starts
14
- // in CLAUDE.md/AGENTS.md or `.claude/rules` now lives here after
15
- // `crtr sys sync project-guidance` migrates it. The user-global
16
- // `~/.crouter/memory/` store is NOT positional; user docs only fire on
17
- // reads through explicit `applies-to` / `read-when` triggers.
18
- // • applies-to GLOB — any RESOLVED substrate doc (user/project/builtin scope)
19
- // whose `appliesTo` glob matches the read file path surfaces, regardless of
20
- // where the read file sits relative to the doc.
21
- // • read-when FRONTMATTER (Stream A) — any RESOLVED doc whose `readWhen`
22
- // condition matches the READ FILE's own parsed frontmatter (markdown only),
23
- // via `evalCondition` against that YAML rather than the node subject.
7
+ // • A successful `read` tool call evaluates the read file against every
8
+ // visible substrate doc. Project stores encountered between that file and
9
+ // the filesystem root join the resolved cwd/profile corpus, so a nested
10
+ // workspace can contribute guidance exactly when a file beneath it is read.
11
+ // • Opening a workspace evaluates the reserved `applies-to: "."` target for
12
+ // every project store mounted by the cwd and selected profile. This happens
13
+ // while the first-message bearings are assembled, so workspace-wide context
14
+ // arrives before the task rather than waiting for an arbitrary first file.
24
15
  //
25
- // D5 — an EXPLICIT read-trigger (applies-to OR read-when) SUPPRESSES the
26
- // positional default for that doc: an author who declared a trigger meant it, so
27
- // the doc fires only on its trigger, never positionally on every read under its
28
- // scope. The two explicit triggers compose by OR.
16
+ // `read-when` remains an additional OR trigger over a read markdown file's own
17
+ // frontmatter. It does not replace `applies-to`: every non-none file-read rung
18
+ // still declares its path boundary explicitly.
29
19
  //
30
- // D6 — an explicitly-triggered doc also PIERCES the INDEX ceiling: it surfaces at
31
- // its OWN fileReadVisibility, not min(own, ancestor INDEX rungs). The ceiling is
32
- // the mechanism for collapsing a subtree's BOOT + POSITIONAL on-read surface; a
33
- // cross-cutting applies-to/read-when trigger is the author deliberately opting
34
- // ONE doc into surfacing when a specific file is read, so a `none`-capped
35
- // ancestor INDEX (the default for a reference plugin pulled by name) must not
36
- // veto it. Same stance as D5 — the explicit trigger is the more specific signal.
37
- //
38
- // Each candidate runs the substrate pipeline at its fileReadVisibility rung:
39
- // parse → gatePasses(doc, assembleNodeSubject(nodeId)) → render
40
- // (content → body, preview → previewLine, name → bare tag, none → skip).
41
- // The result is the faithful envelope (verdict n1):
42
- // <auto-loaded-context>
43
- // <memory kind="…" name="…" src="…">…body/preview…</memory>
44
- // </auto-loaded-context>
45
- // Returns '' when nothing surfaces.
46
- //
47
- // Pure + defensive: reads disk + the resolver + canvas-db subject assembly; no
48
- // writes, no side effects. Every disk/parse/glob step is wrapped so one bad doc
49
- // can never throw the whole render (the extension is additionally inert on
50
- // error). The CALLER owns the per-session `seen` realpath set (cleared on
51
- // session_start) and threads it in, so a given doc is injected at most once per
52
- // session across repeated reads.
20
+ // Every candidate renders at its own fileReadVisibility rung. Explicit routing
21
+ // is the whole on-read model, so directory INDEX ceilings remain a boot-catalog
22
+ // concern and do not cap a deliberately matched document.
53
23
  import { realpathSync } from 'node:fs';
54
24
  import { homedir } from 'node:os';
55
- import { basename, dirname, join, matchesGlob, parse, relative, sep } from 'node:path';
25
+ import { basename, dirname, matchesGlob, parse, relative, sep } from 'node:path';
56
26
  import { CRTR_DIR_NAME } from '../../types.js';
57
27
  import { pathExists, readText, walkFiles } from '../fs-utils.js';
58
28
  import { parseFrontmatterGeneric } from '../frontmatter.js';
59
29
  import { evalCondition } from '../predicate.js';
60
- import { listAllMemoryDocs } from '../memory-resolver.js';
30
+ import { listAllMemoryDocs, listProjectMemoryDocs } from '../memory-resolver.js';
31
+ import { getNode } from '../canvas/index.js';
61
32
  import { userScopeRoot } from '../scope.js';
62
- import { assembleNodeSubject, buildCeilingIndex, displayName, effectiveRung, gatePasses, normalizeDocName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, resolveDocName, } from './index.js';
33
+ import { assembleNodeSubject, displayName, gatePasses, normalizeDocName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, resolveDocName, } from './index.js';
63
34
  import { cachedSubstrateDocs } from './session-cache.js';
64
- // Ancestor dirs we never look inside for a `.crouter/memory/` store (the read
65
- // file may live under a build/dependency tree; `.crouter` is NOT junk here — it
66
- // is the segment we explicitly join onto each surviving ancestor).
67
35
  const JUNK_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '.yalc']);
68
- // ---------------------------------------------------------------------------
69
- // Small path helpers (mirror the on-read precedent established by
70
- // frontmatter-rules so the injected envelope keeps the faithful substrate
71
- // shape).
72
- // ---------------------------------------------------------------------------
73
36
  function realpathOrSelf(p) {
74
37
  try {
75
38
  return realpathSync(p);
@@ -78,7 +41,6 @@ function realpathOrSelf(p) {
78
41
  return p;
79
42
  }
80
43
  }
81
- /** Escape a value for an XML-ish attribute in the injected envelope. */
82
44
  function attr(s) {
83
45
  return s
84
46
  .replace(/&/g, '&amp;')
@@ -86,12 +48,11 @@ function attr(s) {
86
48
  .replace(/</g, '&lt;')
87
49
  .replace(/>/g, '&gt;');
88
50
  }
89
- /** Nearest enclosing git repo root for a path (walk up looking for `.git`). */
90
51
  function gitRootOf(p) {
91
52
  let d = p;
92
53
  const root = parse(d).root;
93
54
  while (true) {
94
- if (pathExists(join(d, '.git')))
55
+ if (pathExists(`${d}${sep}.git`))
95
56
  return d;
96
57
  if (d === root)
97
58
  return null;
@@ -101,7 +62,6 @@ function gitRootOf(p) {
101
62
  d = parent;
102
63
  }
103
64
  }
104
- /** Display a path relative to its nearest git repo root, else absolute. */
105
65
  function disp(p) {
106
66
  const root = gitRootOf(p);
107
67
  if (!root)
@@ -110,14 +70,18 @@ function disp(p) {
110
70
  return rel === '' ? '.' : rel;
111
71
  }
112
72
  function isJunkAncestor(dir) {
113
- return dir.split(sep).some((seg) => JUNK_DIRS.has(seg));
73
+ return dir.split(sep).some((segment) => JUNK_DIRS.has(segment));
114
74
  }
115
- /** Load one positionally-discovered `.crouter/memory/` file into a SubstrateDoc,
116
- * or null when it is not a substrate doc / unreadable. `scope` is cosmetic here
117
- * (gate eval keys off the NODE subject, render off name/body/rung). */
118
- function loadPositionalDoc(file, memDir, scope) {
75
+ function owningRootOf(doc) {
76
+ const parts = doc.path.split(sep);
77
+ const idx = parts.lastIndexOf(CRTR_DIR_NAME);
78
+ if (idx <= 0)
79
+ return null;
80
+ return parts.slice(0, idx).join(sep) || sep;
81
+ }
82
+ function loadProjectDoc(file, memoryDir) {
119
83
  try {
120
- const raw = relative(memDir, file)
84
+ const raw = relative(memoryDir, file)
121
85
  .replace(/\.md$/i, '')
122
86
  .split(sep)
123
87
  .join('/');
@@ -128,67 +92,47 @@ function loadPositionalDoc(file, memDir, scope) {
128
92
  const schema = parseSubstrateFrontmatter(data);
129
93
  if (schema === null)
130
94
  return null;
131
- // ONE substrate-identity rule (schema.ts's resolveDocName), shared with the
132
- // resolver's loadMemoryDoc: an explicit frontmatter `name` wins over the
133
- // path-derived fallback, so a positionally-discovered doc surfaces on-read
134
- // under the SAME identity the boot resolver would give it.
135
- const name = resolveDocName(data, fallbackName);
136
- return { ...schema, name, scope, path: file, body };
95
+ return {
96
+ ...schema,
97
+ name: resolveDocName(data, fallbackName),
98
+ scope: 'project',
99
+ path: file,
100
+ body,
101
+ };
137
102
  }
138
103
  catch {
139
104
  return null;
140
105
  }
141
106
  }
142
- function safeWalkMd(dir) {
107
+ function safeWalkMarkdown(dir) {
143
108
  try {
144
- return walkFiles(dir, (n) => n.toLowerCase().endsWith('.md'));
109
+ return walkFiles(dir, (name) => name.toLowerCase().endsWith('.md'));
145
110
  }
146
111
  catch {
147
112
  return [];
148
113
  }
149
114
  }
150
- /** Does the doc declare an EXPLICIT read-trigger? (applies-to glob OR read-when
151
- * frontmatter condition). D5: an explicit trigger SUPPRESSES the positional
152
- * default — such a doc fires only on its declared trigger, never positionally. */
153
- function hasExplicitTrigger(doc) {
154
- return (doc.appliesTo !== undefined && doc.appliesTo.length > 0) || doc.readWhen !== undefined;
155
- }
156
- /** POSITIONAL trigger: every substrate doc in an ancestor PROJECT dir's
157
- * `.crouter/memory/`, walking from the read file up to $HOME (or the
158
- * filesystem root for a read outside $HOME), skipping junk ancestors. The
159
- * user-global `~/.crouter/memory/` store is deliberately skipped: otherwise
160
- * every file read under $HOME would surface unrelated user-wide preferences.
161
- *
162
- * The crouter home (`~/.crouter`, the user scope root) is skipped for the same
163
- * reason it is never a project scope root (scope.ts's `isProjectScopeDir`): it
164
- * is the user scope, not a project ancestor. Without this fence, reading any
165
- * canvas runtime artifact (`~/.crouter/canvas/nodes/.../reports|context/...`)
166
- * walks up THROUGH `~/.crouter` and would inject whatever lives at
167
- * `~/.crouter/.crouter/memory/` — e.g. a differently-scoped node whose cwd
168
- * resolved its project store there — leaking one node's private memory into
169
- * every unrelated node that reads a canvas file. */
170
- function positionalCandidates(absReadFile) {
171
- const out = [];
172
- const seenDocPaths = new Set();
115
+ /** Project docs from every workspace store enclosing the read file. This is a
116
+ * discovery walk only: each returned doc must still match an explicit trigger. */
117
+ function enclosingProjectDocs(absReadFile) {
118
+ const docs = [];
119
+ const seen = new Set();
173
120
  const home = realpathOrSelf(homedir());
174
121
  const userRoot = realpathOrSelf(userScopeRoot());
175
122
  const fsRoot = parse(absReadFile).root;
176
123
  let dir = dirname(absReadFile);
177
- let depth = 0;
178
124
  while (true) {
179
125
  if (dir !== home && dir !== userRoot && !isJunkAncestor(dir)) {
180
- const memDir = join(dir, CRTR_DIR_NAME, 'memory');
181
- if (pathExists(memDir)) {
182
- for (const file of safeWalkMd(memDir)) {
126
+ const memoryDir = `${dir}${sep}${CRTR_DIR_NAME}${sep}memory`;
127
+ if (pathExists(memoryDir)) {
128
+ for (const file of safeWalkMarkdown(memoryDir)) {
183
129
  const real = realpathOrSelf(file);
184
- if (seenDocPaths.has(real))
130
+ if (seen.has(real))
185
131
  continue;
186
- seenDocPaths.add(real);
187
- const doc = loadPositionalDoc(file, memDir, 'project');
188
- // D5: a doc with an explicit read-trigger (applies-to/read-when) does
189
- // NOT fire positionally — it surfaces only via that trigger.
190
- if (doc && !hasExplicitTrigger(doc))
191
- out.push({ doc, realpath: real, order: depth, explicit: false });
132
+ seen.add(real);
133
+ const doc = loadProjectDoc(file, memoryDir);
134
+ if (doc !== null)
135
+ docs.push(doc);
192
136
  }
193
137
  }
194
138
  }
@@ -198,65 +142,42 @@ function positionalCandidates(absReadFile) {
198
142
  if (parent === dir)
199
143
  break;
200
144
  dir = parent;
201
- depth += 1;
202
145
  }
203
- return out;
146
+ return docs;
204
147
  }
205
- /** The user/project scope root that owns a resolved doc: `<root>/.crouter/memory/…`
206
- * → `<root>`. Builtin docs (no `.crouter` segment) return null. Used to test an
207
- * `appliesTo` glob against a read path RELATIVE to the doc's own project root. */
208
- function owningRootOf(doc) {
209
- const parts = doc.path.split(sep);
210
- const idx = parts.lastIndexOf(CRTR_DIR_NAME);
211
- if (idx <= 0)
212
- return null;
213
- return parts.slice(0, idx).join(sep) || sep;
214
- }
215
- function globMatches(glob, absReadFile, owningRoot) {
216
- const targets = [absReadFile, basename(absReadFile)];
217
- if (owningRoot)
218
- targets.push(relative(owningRoot, absReadFile));
219
- return targets.some((t) => {
220
- try {
221
- return matchesGlob(t, glob);
222
- }
223
- catch {
224
- return false; // an invalid glob never matches
225
- }
226
- });
227
- }
228
- /** applies-to GLOB trigger: every RESOLVED substrate doc whose `appliesTo` glob
229
- * matches the read path. `taken` carries the realpaths already claimed by the
230
- * positional pass, so a doc found both ways is not double-counted.
231
- * Uses the per-session cache so the corpus is not re-walked+re-parsed on every
232
- * read tool call (O(reads × corpus) without the cache). */
233
- function appliesToCandidates(absReadFile, taken) {
234
- let docs;
148
+ function resolvedDocs() {
235
149
  try {
236
- docs = cachedSubstrateDocs(listAllMemoryDocs, parseSubstrateDoc);
150
+ return cachedSubstrateDocs(listAllMemoryDocs, parseSubstrateDoc);
237
151
  }
238
152
  catch {
239
153
  return [];
240
154
  }
155
+ }
156
+ function dedupeByPhysicalPath(docs) {
157
+ const seen = new Set();
241
158
  const out = [];
242
159
  for (const doc of docs) {
243
- const globs = doc.appliesTo;
244
- if (!globs || globs.length === 0)
245
- continue;
246
160
  const real = realpathOrSelf(doc.path);
247
- if (taken.has(real))
161
+ if (seen.has(real))
248
162
  continue;
249
- if (!globs.some((g) => globMatches(g, absReadFile, owningRootOf(doc))))
250
- continue;
251
- taken.add(real);
252
- out.push({ doc, realpath: real, order: -1, explicit: true });
163
+ seen.add(real);
164
+ out.push(doc);
253
165
  }
254
166
  return out;
255
167
  }
256
- /** Parse the READ FILE's own YAML frontmatter once (markdown only), the subject
257
- * a `read-when` condition is evaluated against. Non-markdown reads (and any
258
- * unreadable / frontmatter-less file) yield `{}` — no `read-when` rule can then
259
- * match (mirrors pi's frontmatter-rules, which only consider .md/.mdx/.markdown). */
168
+ function globMatches(glob, absReadFile, owningRoot) {
169
+ const targets = [absReadFile, basename(absReadFile)];
170
+ if (owningRoot !== null)
171
+ targets.push(relative(owningRoot, absReadFile));
172
+ return targets.some((target) => {
173
+ try {
174
+ return matchesGlob(target, glob);
175
+ }
176
+ catch {
177
+ return false;
178
+ }
179
+ });
180
+ }
260
181
  function readFileFrontmatter(absReadFile) {
261
182
  if (!/\.(md|mdx|markdown)$/i.test(absReadFile))
262
183
  return {};
@@ -267,41 +188,20 @@ function readFileFrontmatter(absReadFile) {
267
188
  return {};
268
189
  }
269
190
  }
270
- /** read-when FRONTMATTER trigger (Stream A): every RESOLVED substrate doc whose
271
- * `readWhen` condition matches the read file's own frontmatter. `taken` carries
272
- * the realpaths already claimed by earlier passes so a doc is not double-counted.
273
- * Uses the per-session cache (same corpus the glob pass walks). Returns [] when
274
- * the read file has no frontmatter — no condition can match an empty subject. */
275
- function readWhenCandidates(readFileFm, taken) {
276
- if (Object.keys(readFileFm).length === 0)
277
- return [];
278
- let docs;
279
- try {
280
- docs = cachedSubstrateDocs(listAllMemoryDocs, parseSubstrateDoc);
281
- }
282
- catch {
283
- return [];
284
- }
285
- const out = [];
286
- for (const doc of docs) {
287
- if (doc.readWhen === undefined)
288
- continue;
289
- const real = realpathOrSelf(doc.path);
290
- if (taken.has(real))
291
- continue;
292
- if (!evalCondition(doc.readWhen, readFileFm))
293
- continue;
294
- taken.add(real);
295
- out.push({ doc, realpath: real, order: -1, explicit: true });
296
- }
297
- return out;
191
+ function matchesReadEvent(doc, absReadFile, readFrontmatter) {
192
+ const pathMatch = doc.appliesTo?.some((glob) => globMatches(glob, absReadFile, owningRootOf(doc))) === true;
193
+ const frontmatterMatch = doc.readWhen !== undefined &&
194
+ Object.keys(readFrontmatter).length > 0 &&
195
+ evalCondition(doc.readWhen, readFrontmatter);
196
+ return pathMatch || frontmatterMatch;
197
+ }
198
+ function envelopeName(doc) {
199
+ const displayed = displayName(doc.name);
200
+ if (displayed !== '')
201
+ return displayed;
202
+ const root = owningRootOf(doc);
203
+ return root === null ? doc.name : basename(root);
298
204
  }
299
- // ---------------------------------------------------------------------------
300
- // Per-doc envelope render.
301
- // ---------------------------------------------------------------------------
302
- /** One memory as a `<memory …>` element at its fileReadVisibility rung, or null
303
- * when that rung is `none`. `content` → full body; `preview` → the routing
304
- * line; `name` → a bare self-closed tag (the `name=` attribute IS the surface). */
305
205
  function renderDocEnvelope(doc) {
306
206
  const rung = doc.fileReadVisibility;
307
207
  if (rung === 'none')
@@ -311,26 +211,33 @@ function renderDocEnvelope(doc) {
311
211
  body = doc.body.trim();
312
212
  else if (rung === 'preview')
313
213
  body = previewLine(doc);
314
- // 'name' → body stays '' (the tag's name attribute is the whole surface).
315
- const attrs = `kind="${attr(doc.kind)}" name="${attr(doc.name)}" src="${attr(disp(doc.path))}"`;
214
+ const attrs = `kind="${attr(doc.kind)}" name="${attr(envelopeName(doc))}" src="${attr(disp(doc.path))}"`;
316
215
  return body === '' ? `<memory ${attrs} />` : `<memory ${attrs}>\n${body}\n</memory>`;
317
216
  }
318
- // ---------------------------------------------------------------------------
319
- // Public entry point.
320
- // ---------------------------------------------------------------------------
321
- /**
322
- * Render the substrate docs that should surface alongside a just-read file.
323
- *
324
- * @param nodeId the canvas node whose subject gates the docs.
325
- * @param readFilePath the path the `read` tool returned (absolute or not — it
326
- * is resolved to a realpath internally).
327
- * @param seen the CALLER-owned, per-session set of already-injected
328
- * doc realpaths. Docs already present are skipped; newly
329
- * injected docs are added. Pass the same set across reads
330
- * within a session (clear it on session_start) to get the
331
- * once-per-session dedup; omit it for a standalone render.
332
- * @returns the `<auto-loaded-context>` envelope, or '' when nothing surfaces.
333
- */
217
+ function renderCandidates(subject, docs, seen) {
218
+ const rendered = [];
219
+ for (const doc of docs) {
220
+ const real = realpathOrSelf(doc.path);
221
+ if (seen.has(real))
222
+ continue;
223
+ try {
224
+ if (!gatePasses(doc, subject))
225
+ continue;
226
+ const block = renderDocEnvelope(doc);
227
+ if (block === null)
228
+ continue;
229
+ seen.add(real);
230
+ rendered.push(block);
231
+ }
232
+ catch {
233
+ continue;
234
+ }
235
+ }
236
+ return rendered.length === 0
237
+ ? ''
238
+ : `<auto-loaded-context>\n${rendered.join('\n')}\n</auto-loaded-context>`;
239
+ }
240
+ /** Surface docs matched by a successful read tool call. */
334
241
  export function renderOnReadDocs(nodeId, readFilePath, seen = new Set()) {
335
242
  let subject;
336
243
  try {
@@ -342,63 +249,36 @@ export function renderOnReadDocs(nodeId, readFilePath, seen = new Set()) {
342
249
  if (subject === null)
343
250
  return '';
344
251
  const absReadFile = realpathOrSelf(readFilePath);
345
- let candidates;
252
+ const readFrontmatter = readFileFrontmatter(absReadFile);
253
+ const docs = dedupeByPhysicalPath([...enclosingProjectDocs(absReadFile), ...resolvedDocs()])
254
+ .filter((doc) => realpathOrSelf(doc.path) !== absReadFile)
255
+ .filter((doc) => matchesReadEvent(doc, absReadFile, readFrontmatter));
256
+ return renderCandidates(subject, docs, seen);
257
+ }
258
+ /** Surface workspace-wide docs during first-message assembly. `.` is a reserved
259
+ * applies-to target meaning “when this project store is mounted by cwd/profile”. */
260
+ export function renderWorkspaceOpenDocs(nodeId) {
261
+ let subject;
262
+ let docs;
346
263
  try {
347
- const positional = positionalCandidates(absReadFile);
348
- const taken = new Set(positional.map((c) => c.realpath));
349
- const byGlob = appliesToCandidates(absReadFile, taken);
350
- const byReadWhen = readWhenCandidates(readFileFrontmatter(absReadFile), taken);
351
- candidates = [...positional, ...byGlob, ...byReadWhen];
264
+ subject = assembleNodeSubject(nodeId);
265
+ const node = getNode(nodeId);
266
+ if (subject === null || node === null)
267
+ return '';
268
+ docs = listProjectMemoryDocs(node.cwd, node.profile_id ?? null)
269
+ .map(parseSubstrateDoc)
270
+ .filter((doc) => doc !== null);
352
271
  }
353
272
  catch {
354
273
  return '';
355
274
  }
356
- // Outermost-first: the nearest/most-specific doc reads last — closest to the
357
- // file content that follows it (the applies-to set, order -1, trails).
358
- candidates.sort((a, b) => b.order - a.order);
359
- // INDEX ceiling: build the dir → governing-INDEX map over the whole resolved
360
- // corpus PLUS the candidate docs, so a dir's INDEX caps (or `none`-hides) its
361
- // subtree on-read exactly as it does at boot. The candidate's own
362
- // fileReadVisibility and name are overridden by the effective rung / dir entry.
363
- let ceil;
364
- try {
365
- const resolved = cachedSubstrateDocs(listAllMemoryDocs, parseSubstrateDoc);
366
- ceil = buildCeilingIndex([...resolved, ...candidates.map((c) => c.doc)]);
367
- }
368
- catch {
369
- ceil = buildCeilingIndex(candidates.map((c) => c.doc));
370
- }
371
- const rendered = [];
372
- for (const c of candidates) {
373
- // Never re-surface the doc the agent is literally reading.
374
- if (c.realpath === absReadFile)
375
- continue;
376
- // Once-per-session dedup (caller-owned set).
377
- if (seen.has(c.realpath))
378
- continue;
379
- let block;
380
- try {
381
- if (!gatePasses(c.doc, subject))
382
- continue; // gated out for this node
383
- // D6: an explicit applies-to/read-when trigger pierces the INDEX ceiling
384
- // and renders at the doc's own rung; positional docs honor the ceiling.
385
- const rung = c.explicit
386
- ? c.doc.fileReadVisibility
387
- : effectiveRung(c.doc, ceil, 'fileReadVisibility');
388
- const doc = rung === c.doc.fileReadVisibility && displayName(c.doc.name) === c.doc.name
389
- ? c.doc
390
- : { ...c.doc, fileReadVisibility: rung, name: displayName(c.doc.name) };
391
- block = renderDocEnvelope(doc);
392
- }
393
- catch {
394
- continue; // a single bad doc never breaks the read
395
- }
396
- if (block === null)
397
- continue; // fileReadVisibility 'none' — not a read surface
398
- seen.add(c.realpath); // mark injected only once it actually surfaces
399
- rendered.push(block);
400
- }
401
- if (rendered.length === 0)
402
- return '';
403
- return `<auto-loaded-context>\n${rendered.join('\n')}\n</auto-loaded-context>`;
275
+ docs = dedupeByPhysicalPath(docs)
276
+ .filter((doc) => doc.appliesTo?.some((glob) => glob.trim() === '.') === true)
277
+ .sort((a, b) => {
278
+ const aRoot = owningRootOf(a) ?? '';
279
+ const bRoot = owningRootOf(b) ?? '';
280
+ const depth = aRoot.split(sep).filter(Boolean).length - bRoot.split(sep).filter(Boolean).length;
281
+ return depth || aRoot.localeCompare(bRoot) || a.path.localeCompare(b.path);
282
+ });
283
+ return renderCandidates(subject, docs, new Set());
404
284
  }
@@ -59,7 +59,7 @@ import { projectScopeRoots } from '../scope.js';
59
59
  // pull subject.js (canvas-db) transitively, re-tainting every CLI consumer of
60
60
  // the pure render fns. The canvas-db wrappers (renderPreferencesSection /
61
61
  // renderKnowledgeBlock) live in render-node.js instead.
62
- import { buildCeilingIndex, effectiveRung, indexDirOf, isIndexName } from './ceiling.js';
62
+ import { buildCeilingIndex, effectiveSystemPromptRung, indexDirOf, isIndexName } from './ceiling.js';
63
63
  import { gatePasses } from './gate.js';
64
64
  import { normalizeDocName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, resolveDocName, rungRank, } from './schema.js';
65
65
  import { cachedSubstrateDocs } from './session-cache.js';
@@ -105,7 +105,7 @@ function selectWinners(subject, kind) {
105
105
  const ceil = buildCeilingIndex(docs);
106
106
  const eligible = docs
107
107
  .map((d) => {
108
- const rung = effectiveRung(d, ceil, 'systemPromptVisibility');
108
+ const rung = effectiveSystemPromptRung(d, ceil);
109
109
  return rung === d.systemPromptVisibility ? d : { ...d, systemPromptVisibility: rung };
110
110
  })
111
111
  .filter((d) => d.kind === kind)
@@ -224,8 +224,8 @@ function nodeLocalDocs(nodeId, subject) {
224
224
  if (schema === null)
225
225
  continue;
226
226
  // ONE substrate-identity rule (schema.ts's resolveDocName), shared with
227
- // the resolver and the on-read positional loader: explicit frontmatter
228
- // `name` wins over the physical-path-derived fallback.
227
+ // the resolver and explicit file-context loader: frontmatter `name` wins
228
+ // over the physical-path-derived fallback.
229
229
  const name = resolveDocName(data, fallbackName);
230
230
  // node-local is NOT a resolver scope; `scope` is a placeholder never read
231
231
  // by gate eval (keyed off the NODE subject, not the doc) nor by the
@@ -54,8 +54,9 @@ export interface SubstrateSchema {
54
54
  * eligible. An empty `{}` is carried as-is and is inert (never matches) — see
55
55
  * `gatePasses`. */
56
56
  gate?: GatePredicate;
57
- /** Optional glob list narrowing the on-read trigger to matching read files.
58
- * Absent ⇒ positional trigger only. A single glob is normalized to a 1-list. */
57
+ /** Explicit file-context routes. `.` is the project workspace-open target;
58
+ * every other value is a glob matched after an actual file read. A single
59
+ * value is normalized to a 1-list. */
59
60
  appliesTo?: string[];
60
61
  /** Optional condition over the READ FILE's own frontmatter — the on-read
61
62
  * frontmatter trigger (Stream A native rules). Same coercion + predicate
@@ -73,9 +73,9 @@ export function normalizeDocName(name) {
73
73
  // Substrate identity — the ONE explicit-name-then-path-fallback rule. A doc's
74
74
  // resolver identity is its explicit frontmatter `name` when present (normalized),
75
75
  // otherwise the caller-supplied path-derived fallback. Every loader that turns a
76
- // physical .md file into a named doc (the resolver's `listMemoryDocsInDir`, the
77
- // on-read positional loader) calls this ONE helper, so a doc's identity never
78
- // depends on which path loaded it.
76
+ // physical .md file into a named doc (the resolver's `listMemoryDocsInDir` and
77
+ // the explicit file-context loader) calls this ONE helper, so a doc's identity
78
+ // never depends on which path loaded it.
79
79
  // ---------------------------------------------------------------------------
80
80
  /** Resolve a doc's identity from its raw frontmatter record: an explicit
81
81
  * `name` field wins (trimmed + normalized), else `fallbackName` (the
@@ -177,8 +177,8 @@ function parseGate(v) {
177
177
  ? v
178
178
  : undefined;
179
179
  }
180
- /** Normalize `applies-to` to a non-empty glob list, or undefined (positional
181
- * trigger only). Accepts a single string or an array of strings. */
180
+ /** Normalize `applies-to` to a non-empty route list, or undefined when no
181
+ * file-context event is declared. Accepts a string or an array of strings. */
182
182
  function parseAppliesTo(v) {
183
183
  if (typeof v === 'string') {
184
184
  return v.trim() === '' ? undefined : [v];