@north-light/crouter 0.3.262 → 0.3.263

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 (48) hide show
  1. package/dist/api/dto/health.d.ts +2 -0
  2. package/dist/builtin-memory/internal/memory-loading.md +3 -3
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +4 -3
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +5 -3
  5. package/dist/clients/attach/input/controller.d.ts +4 -0
  6. package/dist/clients/attach/input/controller.js +8 -0
  7. package/dist/clients/attach/session/bindings.d.ts +3 -0
  8. package/dist/clients/attach/session/bindings.js +7 -0
  9. package/dist/clients/attach/session/keys.d.ts +2 -0
  10. package/dist/clients/attach/session/keys.js +3 -3
  11. package/dist/clients/attach/viewer.js +616 -616
  12. package/dist/commands/memory/read.js +15 -38
  13. package/dist/commands/memory/shared.d.ts +1 -1
  14. package/dist/commands/memory/shared.js +1 -1
  15. package/dist/commands/sys/context/admin/docs-panel.d.ts +3 -3
  16. package/dist/commands/sys/context/admin/model.d.ts +7 -2
  17. package/dist/commands/sys/context/admin/model.js +7 -1
  18. package/dist/commands/sys/context/admin/read-view.d.ts +5 -5
  19. package/dist/commands/sys/context/admin/read-view.js +9 -4
  20. package/dist/commands/sys/context/resolve.d.ts +32 -3
  21. package/dist/commands/sys/context/resolve.js +74 -25
  22. package/dist/commands/sys/panels/models-panel.d.ts +28 -5
  23. package/dist/commands/sys/panels/models-panel.js +558 -75
  24. package/dist/core/__tests__/integration/broker-sdk-wiring.test.js +1 -1
  25. package/dist/core/__tests__/model-routes-config.test.d.ts +1 -0
  26. package/dist/core/__tests__/model-routes-config.test.js +142 -0
  27. package/dist/core/__tests__/on-read-nested-store.test.js +8 -0
  28. package/dist/core/__tests__/profile-project-memory-delivery.test.js +264 -1
  29. package/dist/core/__tests__/seam/broker-provider-retry.test.js +2 -2
  30. package/dist/core/canvas/render-source.d.ts +1 -1
  31. package/dist/core/canvas/render-source.js +3 -2
  32. package/dist/core/config.d.ts +19 -2
  33. package/dist/core/config.js +162 -47
  34. package/dist/core/keybindings/catalog.js +9 -7
  35. package/dist/core/model-routes.js +10 -3
  36. package/dist/core/substrate/listings.d.ts +1 -1
  37. package/dist/core/substrate/listings.js +6 -2
  38. package/dist/core/substrate/on-read.d.ts +61 -5
  39. package/dist/core/substrate/on-read.js +129 -22
  40. package/dist/core/substrate/render.d.ts +3 -2
  41. package/dist/core/substrate/render.js +133 -38
  42. package/dist/daemon/api/__tests__/seam/api-server.test.js +34 -0
  43. package/dist/daemon/api/handlers/canvas.js +18 -2
  44. package/dist/daemon/api/handlers/health.js +17 -12
  45. package/dist/pi-extensions/__tests__/pre-command-gate.test.js +2 -2
  46. package/dist/types.d.ts +4 -2
  47. package/package.json +1 -1
  48. package/runtime.lock.json +2 -2
@@ -41,11 +41,13 @@
41
41
  // the plan→text adapter: it owns the `<memory>` envelope, the
42
42
  // `<auto-loaded-context>` wrapper, and the exposure ledger.
43
43
  import { sep } from 'node:path';
44
- import { ambientMemoryTarget, listAllMemoryDocs } from '../memory-resolver.js';
44
+ import { realpathOrSelf } from '../fs-utils.js';
45
+ import { ambientMemoryTarget, listAllMemoryDocs, loadMemoryTargetView, } from '../memory-resolver.js';
45
46
  import { owningRootOf } from './surface-match.js';
46
- import { demoteDocumentTranscriptExposure, documentExposedAtOrAbove, emptyContextExposureState, exposureTarget, registerDocumentExposure, } from './injected-store.js';
47
+ import { demoteDocumentTranscriptExposure, documentExposedAtOrAbove, emptyContextExposureState, exposedAtOrAbove, exposureTarget, registerDocumentExposure, registerExposure, } from './injected-store.js';
47
48
  import { planDelivery } from './plan.js';
48
- import { parseSubstrateDoc, previewLine } from './schema.js';
49
+ import { parseSubstrateDoc, previewLine, rungRank } from './schema.js';
50
+ import { dirDedupKey, docListingDirs, docsByName, isDirName, renderDirListing } from './listings.js';
49
51
  import { cachedEventCorpusInclusive } from './session-cache.js';
50
52
  function attr(s) {
51
53
  return s
@@ -88,24 +90,105 @@ function renderDocEnvelope(doc, rung) {
88
90
  }
89
91
  return `<memory ${attrs} />`;
90
92
  }
91
- /** One winner per doc, in plan (precedence) order. */
92
93
  function planWinners(plan) {
93
- return plan.docs.filter((r) => r.winner).map((r) => ({ doc: r.doc, rung: r.finalRung }));
94
+ return plan.docs.filter((r) => r.winner).map((r) => ({ doc: r.doc, rung: r.finalRung, source: r.source }));
95
+ }
96
+ /** The listing corpus for one event: every document the plan CONSIDERED, in
97
+ * the plan's own precedence order. Winners alone are not enough — a bootless
98
+ * sibling never wins anything, and a store discovered by the read walk is
99
+ * absent from the ambient corpus entirely, so both would drop out of the
100
+ * directory the delivered doc sits in. */
101
+ function planListingCorpus(plan) {
102
+ return docsByName(plan.docs.map(({ source }) => source));
103
+ }
104
+ function listingBlock(dir, lines) {
105
+ return `<memory-listing dir="${attr(dir)}">\n${lines.join('\n')}\n</memory-listing>`;
106
+ }
107
+ export function deliveryAccounting() {
108
+ return { deliveries: new Map() };
109
+ }
110
+ export function accountDelivery(accounting, doc, rung, cause, source) {
111
+ if (accounting === undefined || rung === 'none')
112
+ return;
113
+ const key = realpathOrSelf(doc.path);
114
+ const held = accounting.deliveries.get(key);
115
+ if (held !== undefined && rungRank(held.rung) >= rungRank(rung))
116
+ return;
117
+ accounting.deliveries.set(key, {
118
+ rung,
119
+ ...(cause === undefined ? {} : { cause }),
120
+ ...(source === undefined ? {} : { source }),
121
+ });
122
+ }
123
+ /** The attachment semantics of a content delivery. Content is read-equivalent:
124
+ * it exposes its own/enclosing directory listings and fires memory-read routes.
125
+ * The exposure ledger makes this finite even when companion routes cycle. */
126
+ export function contentReadAttachments(doc, opts) {
127
+ const { target, byName } = opts;
128
+ const dirs = docListingDirs(byName, doc.name);
129
+ const ownDir = dirs[0] === doc.name;
130
+ const blocks = [];
131
+ let listing;
132
+ for (const [index, dir] of dirs.entries()) {
133
+ // A directory absent from THIS corpus discloses nothing here, so claiming
134
+ // its `dir://` identity would tell the ledger a listing rendered that never
135
+ // did — and silence the channel that would have rendered it.
136
+ if (!isDirName(byName, dir))
137
+ continue;
138
+ const own = ownDir && index === 0;
139
+ if (!own && exposedAtOrAbove(target.state, dirDedupKey(dir), 'preview'))
140
+ continue;
141
+ if (opts.listings === 'tree') {
142
+ // The caller renders these members and registers the directory only for
143
+ // the rows it actually emits.
144
+ opts.dirs?.push(dir);
145
+ continue;
146
+ }
147
+ const explicitOwn = own && opts.explicit === true;
148
+ const lines = renderDirListing(byName, dir, target, !explicitOwn, (listed) => accountDelivery(opts.accounting, listed, 'preview', 'directory listing attached to content delivery', listed));
149
+ // An empty listing is not a listing: registering here would claim a render
150
+ // that produced no line and silence the next channel to reach this dir.
151
+ if (lines.length === 0)
152
+ continue;
153
+ registerExposure(target, dirDedupKey(dir), 'preview');
154
+ if (explicitOwn)
155
+ listing = lines;
156
+ else
157
+ blocks.push(listingBlock(dir, lines));
158
+ }
159
+ blocks.push(...memoryReadDocBlocks(realpathOrSelf(doc.path), doc.name, {
160
+ ...opts,
161
+ deliveryCause: 'memory-read companion of content delivery',
162
+ }));
163
+ return { blocks, ...(listing === undefined ? {} : { listing }) };
94
164
  }
95
165
  /** Render the plan's winners that are not already loaded at their matched rung. */
96
- function renderWinnerBlocks(winners, target) {
166
+ function renderWinnerBlocks(winners, opts) {
167
+ const { target } = opts;
97
168
  const rendered = [];
98
- for (const { doc, rung } of winners) {
169
+ for (const { doc, rung, source } of winners) {
99
170
  if (documentExposedAtOrAbove(target.state, doc.path, doc.body, rung))
100
171
  continue;
101
172
  const block = renderDocEnvelope(doc, rung);
102
173
  if (block === null)
103
174
  continue;
104
175
  registerDocumentExposure(target, doc.path, doc.body, rung);
176
+ accountDelivery(opts.accounting, doc, rung, opts.deliveryCause, source);
177
+ if (rung === 'content') {
178
+ rendered.push(...contentReadAttachments(doc, {
179
+ ...opts,
180
+ deliveryCause: 'memory-read companion of content delivery',
181
+ }).blocks);
182
+ }
105
183
  rendered.push(block);
106
184
  }
107
185
  return rendered;
108
186
  }
187
+ /** The attachment options for one event render: listings inline as blocks,
188
+ * answered from the event corpus ∪ this plan's own winners. */
189
+ function eventAttachmentOptions(subject, target, plan, accounting) {
190
+ return { subject, target, byName: planListingCorpus(plan), listings: 'blocks', accounting };
191
+ }
109
192
  function transientTranscriptTarget() {
110
193
  return exposureTarget(emptyContextExposureState(), 'transcript');
111
194
  }
@@ -114,14 +197,19 @@ function wrapAutoLoaded(blocks) {
114
197
  }
115
198
  /** The corpus-carrying plan for one event. Every renderer here passes the
116
199
  * session-cached corpus so a hook never re-walks the filesystem. */
117
- function eventPlan(subject, event, payload) {
118
- return planDelivery(subject, ambientMemoryTarget(), event, payload, resolvedDocs());
200
+ function eventPlan(subject, event, payload, memoryTarget) {
201
+ // An explicit memory target names a workspace other than this process's, so
202
+ // its corpus is resolved for that target rather than taken from the ambient
203
+ // session cache.
204
+ return memoryTarget === undefined
205
+ ? planDelivery(subject, ambientMemoryTarget(), event, payload, resolvedDocs())
206
+ : planDelivery(subject, memoryTarget, event, payload);
119
207
  }
120
208
  /** Surface docs whose `read` entries match a successfully read file, using
121
209
  * the caller-supplied daemon snapshot subject. */
122
- export function renderOnReadDocsForSubject(subject, readFilePath, target = transientTranscriptTarget()) {
210
+ export function renderOnReadDocsForSubject(subject, readFilePath, target = transientTranscriptTarget(), accounting) {
123
211
  const plan = eventPlan(subject, 'read', { absoluteFile: readFilePath });
124
- return wrapAutoLoaded(renderWinnerBlocks(planWinners(plan), target));
212
+ return wrapAutoLoaded(renderWinnerBlocks(planWinners(plan), eventAttachmentOptions(plan.subject, target, plan, accounting)));
125
213
  }
126
214
  /** Workspace boot docs using the snapshot's cwd/profile instead of a canvas
127
215
  * lookup. The shared exposure target prevents a later read from repeating a
@@ -131,11 +219,18 @@ export function renderOnReadDocsForSubject(subject, readFilePath, target = trans
131
219
  * relationship cap BEFORE filtering, ordering, rendering, and exposure
132
220
  * registration. A later explicit `crtr memory read` can still raise a document
133
221
  * that workspace-open disclosed at a lower rung. */
134
- export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId, target = transientTranscriptTarget()) {
222
+ export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId, target = transientTranscriptTarget(), accounting) {
135
223
  const workspaceTarget = { cwd, profileId, nodeId: null };
136
224
  let winners;
225
+ let byName;
137
226
  try {
138
227
  winners = planWinners(planDelivery(subject, workspaceTarget, 'workspace-open'));
228
+ // Listings and companion routes answer from the workspace this event
229
+ // opened — the ambient cwd/profile of the process rendering it is a
230
+ // different corpus and would disclose another workspace's neighbourhood.
231
+ // The workspace-open plan considers project stores only, so the listing
232
+ // corpus is the target's full view rather than the plan's.
233
+ byName = docsByName(loadMemoryTargetView(workspaceTarget, { quiet: true }).docs);
139
234
  }
140
235
  catch {
141
236
  return '';
@@ -149,7 +244,14 @@ export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId, targe
149
244
  const depth = aRoot.split(sep).filter(Boolean).length - bRoot.split(sep).filter(Boolean).length;
150
245
  return depth || aRoot.localeCompare(bRoot) || a.doc.path.localeCompare(b.doc.path);
151
246
  });
152
- return wrapAutoLoaded(renderWinnerBlocks(winners, target));
247
+ return wrapAutoLoaded(renderWinnerBlocks(winners, {
248
+ subject,
249
+ target,
250
+ byName,
251
+ memoryTarget: workspaceTarget,
252
+ listings: 'blocks',
253
+ accounting,
254
+ }));
153
255
  }
154
256
  /** Inner `<memory>` blocks for the memory-read event: docs whose
155
257
  * `memory-read` entries match the doc a `crtr memory read` just resolved
@@ -157,26 +259,31 @@ export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId, targe
157
259
  * `excludeRealpath` keeps the resolved doc from delivering itself). Returns
158
260
  * unwrapped blocks — the read leaf composes one `<auto-loaded-context>`
159
261
  * envelope from these plus the directory listings. */
160
- export function memoryReadDocBlocks(subject, excludeRealpath, name, target) {
161
- const plan = eventPlan(subject, 'memory-read', {
162
- resolvedCanonicalName: name,
163
- resolvedDocPath: excludeRealpath,
262
+ export function memoryReadDocBlocks(excludeRealpath, name, opts) {
263
+ const plan = eventPlan(opts.subject, 'memory-read', { resolvedCanonicalName: name, resolvedDocPath: excludeRealpath }, opts.memoryTarget);
264
+ // A caller with no corpus of its own (the `sys context resolve` simulation)
265
+ // gets this plan's; every nested companion arrives with its parent's corpus
266
+ // already resolved.
267
+ return renderWinnerBlocks(planWinners(plan), {
268
+ ...opts,
269
+ byName: opts.byName ?? planListingCorpus(plan),
164
270
  });
165
- return renderWinnerBlocks(planWinners(plan), target);
166
271
  }
167
272
  /** Surface docs whose `command` entries match an executed bash command.
168
273
  * The corpus is the resolved cwd/profile set; a command has no file from which
169
274
  * to discover enclosing project stores. */
170
- export function renderOnCommandDocsForSubject(subject, command, target = transientTranscriptTarget()) {
171
- return wrapAutoLoaded(renderWinnerBlocks(planWinners(eventPlan(subject, 'command', { command })), target));
275
+ export function renderOnCommandDocsForSubject(subject, command, target = transientTranscriptTarget(), accounting) {
276
+ const plan = eventPlan(subject, 'command', { command });
277
+ return wrapAutoLoaded(renderWinnerBlocks(planWinners(plan), eventAttachmentOptions(subject, target, plan, accounting)));
172
278
  }
173
279
  /** Surface docs whose `pre-command` entries match a bash command that has NOT
174
280
  * run yet. The exact twin of the post-execution renderer above, and its empty
175
281
  * string is load-bearing: `''` means every matching doc is already exposed at
176
282
  * its matching rung or above, which is the release — the caller lets the
177
283
  * command through. */
178
- export function renderPreCommandDocsForSubject(subject, command, target = transientTranscriptTarget()) {
179
- return wrapAutoLoaded(renderWinnerBlocks(planWinners(eventPlan(subject, 'pre-command', { command })), target));
284
+ export function renderPreCommandDocsForSubject(subject, command, target = transientTranscriptTarget(), accounting) {
285
+ const plan = eventPlan(subject, 'pre-command', { command });
286
+ return wrapAutoLoaded(renderWinnerBlocks(planWinners(plan), eventAttachmentOptions(subject, target, plan, accounting)));
180
287
  }
181
288
  function carriesPreCommandEntry(doc) {
182
289
  return doc.surfaces.some((entry) => entry.on === 'pre-command');
@@ -1,6 +1,7 @@
1
1
  import { type ExposureTarget } from './injected-store.js';
2
2
  import { type SubstrateDoc } from './schema.js';
3
3
  import type { NodeConfigSubject } from './subject-fields.js';
4
+ import { type DeliveryAccounting } from './on-read.js';
4
5
  /** The computed "sub-personas you may spawn" menu for a subject's kind — the
5
6
  * live restoration of the old `personas/resolve.ts` static menu, now derived
6
7
  * at render time from the merged kind registry (`subKindsAvailableTo`) so it
@@ -9,7 +10,7 @@ import type { NodeConfigSubject } from './subject-fields.js';
9
10
  * header). Emitted for BOTH base and orchestrator modes per the CTO's ruling —
10
11
  * mode is irrelevant to which sub-kinds are spawnable. */
11
12
  export declare function buildSubPersonaMenu(kind: string): string;
12
- export declare function renderPreferencesForSubject(subject: NodeConfigSubject | null, target?: ExposureTarget): {
13
+ export declare function renderPreferencesForSubject(subject: NodeConfigSubject | null, target?: ExposureTarget, accounting?: DeliveryAccounting): {
13
14
  block: string;
14
15
  contentDocs: SubstrateDoc[];
15
16
  tree: string;
@@ -26,7 +27,7 @@ export declare function renderPreferencesForSubject(subject: NodeConfigSubject |
26
27
  * renders: floored docs never reach the `content` rung). Procedural guidance and factual
27
28
  * references both live here as `knowledge`. Returns '' when nothing is
28
29
  * eligible. */
29
- export declare function renderKnowledgeForSubject(subject: NodeConfigSubject, nodeId: string, target?: ExposureTarget): string;
30
+ export declare function renderKnowledgeForSubject(subject: NodeConfigSubject, nodeId: string, target?: ExposureTarget, accounting?: DeliveryAccounting): string;
30
31
  /** What a node's configuration change newly exposes, rendered as up to three
31
32
  * blocks in one string: the shipped protocol prose, a `<memory kind="preference">`
32
33
  * block, and a `<memory kind="knowledge">` block — each dropped when empty, ''
@@ -69,9 +69,11 @@ import { projectScopeRoots } from '../scope.js';
69
69
  // pull subject.js (canvas-db) transitively, re-tainting every CLI consumer of
70
70
  // the pure render fns. The canvas-db wrappers (renderPreferencesSection /
71
71
  // renderKnowledgeBlock) live in render-node.js instead.
72
- import { documentExposedAtOrAbove, registerDocumentExposure, } from './injected-store.js';
72
+ import { documentExposedAtOrAbove, emptyContextExposureState, exposureTarget, registerDocumentExposure, registerExposure, } from './injected-store.js';
73
73
  import { bootPartitionOf, planDelivery } from './plan.js';
74
- import { parseSubstrateDoc, previewLine, rungRank, } from './schema.js';
74
+ import { parseSubstrateDoc, previewLine, rungAtLeast, rungRank, } from './schema.js';
75
+ import { accountDelivery, contentReadAttachments, } from './on-read.js';
76
+ import { dirDedupKey, docsByName } from './listings.js';
75
77
  import { cachedEventCorpusInclusive } from './session-cache.js';
76
78
  /** The node's own store (`nodes/<id>/context/memory/`), loaded through the
77
79
  * resolver's descriptor loader like every other store. Returned across ALL
@@ -134,16 +136,6 @@ function bootWinners(plan, partition) {
134
136
  function rendersAtBoot(doc) {
135
137
  return isVisible(doc.bootRung) && (doc.bootRung !== 'content' || doc.body.trim() !== '');
136
138
  }
137
- /** Register every visibly rendered winner at its displayed rung. */
138
- function recordRendered(target, docs) {
139
- if (target === undefined)
140
- return;
141
- for (const d of docs) {
142
- if (!rendersAtBoot(d))
143
- continue;
144
- registerDocumentExposure(target, d.path, d.body, d.bootRung);
145
- }
146
- }
147
139
  // Step 2 — display ordering + grouped render.
148
140
  /** Content-prose ordering rank: general → specific. builtin=0, user=1,
149
141
  * profile=2, project ranks start at 3 and climb by root nearness — the
@@ -204,12 +196,74 @@ function compareTreePosition(a, b) {
204
196
  function isAgentEditable(doc) {
205
197
  return doc.scope !== 'builtin' && doc.plugin === undefined;
206
198
  }
199
+ /** The members of every directory this render's content deliveries disclosed,
200
+ * raised to `preview` so the catalog tree carries the routing lines a
201
+ * `<memory-listing>` would have carried on an explicit read. A member is
202
+ * raised only when the raise is honest: it renders as a row (content bodies
203
+ * render as prose instead), it does not already disclose its line, it is not
204
+ * `unlisted`, its project's profile cap permits preview, and this context does
205
+ * not already hold it at preview or above.
206
+ *
207
+ * The corpus is `listingDocs` — the partition's own winners. A directory
208
+ * holding both knowledge and preference docs therefore discloses only the
209
+ * current partition's members here, and the other partition skips the
210
+ * directory's `dir://` key: under-disclosure, never a false ledger claim.
211
+ *
212
+ * A directory registers its `dir://` identity here and ONLY here — when it
213
+ * actually contributed a row. A directory whose every member was rejected
214
+ * rendered no listing, so the next channel to reach it must still be free to
215
+ * render one. */
216
+ function exposedDirEntries(listingDocs, dirs, target) {
217
+ if (dirs.length === 0)
218
+ return [];
219
+ const byPath = new Map(listingDocs.map((doc) => [doc.path, doc]));
220
+ const tree = buildMemoryTree(listingDocs.map((doc) => doc.source));
221
+ const raised = new Map();
222
+ for (const dir of dirs) {
223
+ const node = tree.node(dir);
224
+ if (node === null)
225
+ continue;
226
+ let rendered = false;
227
+ const members = [
228
+ ...node.childDocuments.map((member) => member.document),
229
+ ...node.childDirectories.flatMap((child) => (child.document === null ? [] : [child.document])),
230
+ ];
231
+ for (const member of members) {
232
+ const doc = byPath.get(member.path);
233
+ if (doc === undefined || raised.has(doc.path))
234
+ continue;
235
+ if (doc.bootRung === 'content' || rungAtLeast(doc.bootRung, 'preview'))
236
+ continue;
237
+ if (doc.unlisted)
238
+ continue;
239
+ if (!rungAtLeast(doc.projectMemory, 'preview'))
240
+ continue;
241
+ if (documentExposedAtOrAbove(target.state, doc.path, doc.body, 'preview'))
242
+ continue;
243
+ raised.set(doc.path, { ...doc, bootRung: 'preview' });
244
+ rendered = true;
245
+ }
246
+ if (rendered)
247
+ registerExposure(target, dirDedupKey(dir), 'preview');
248
+ }
249
+ return [...raised.values()];
250
+ }
207
251
  /** STEP 2 — split deduped `winners` by rung and render each group in its
208
252
  * display order: `content` docs first, as clean prose (general→specific,
209
253
  * then tree position, then filename — no tree chrome); every
210
- * other winner (preview/name/hidden-bootless) folds into ONE catalog tree
211
- * exactly as before. */
212
- function renderGrouped(winners, rootLabel) {
254
+ * other winner (preview/name/hidden-bootless) folds into ONE catalog tree,
255
+ * which additionally carries the members that the content deliveries'
256
+ * directory listings disclosed.
257
+ *
258
+ * This function owns exposure registration for the boot renders: every doc
259
+ * registers at the rung it ACTUALLY rendered at, here, rather than being
260
+ * registered wholesale by the caller beforehand — which would have let a body
261
+ * already delivered as a companion in another partition render again as
262
+ * prose. A caller that passes no `target` renders against a throwaway ledger
263
+ * (the `sys context` diagnostics), so its output matches live boot without
264
+ * recording anything. */
265
+ function renderGrouped(winners, rootLabel, subject, target, listingDocs = winners, accounting) {
266
+ const ledger = target ?? exposureTarget(emptyContextExposureState(), 'transcript');
213
267
  const content = [];
214
268
  const rest = [];
215
269
  for (const d of winners) {
@@ -221,11 +275,52 @@ function renderGrouped(winners, rootLabel) {
221
275
  content.sort((a, b) => scopeGeneralityRank(a) - scopeGeneralityRank(b) ||
222
276
  compareTreePosition(a, b) ||
223
277
  a.name.localeCompare(b.name));
224
- const contentProse = content
278
+ // A body this context already holds at content does not render twice, even
279
+ // when the other delivery was a companion in the other boot partition.
280
+ const fresh = content.filter((d) => !documentExposedAtOrAbove(ledger.state, d.path, d.body, 'content'));
281
+ const contentProse = fresh
225
282
  .map((d) => d.body.trim())
226
283
  .filter((b) => b !== '')
227
284
  .join('\n\n');
228
- return { contentProse, tree: buildTree(rest, rootLabel), contentDocs: content };
285
+ // Register the prose BEFORE its attachments run, so the listings and
286
+ // companion routes dedupe against the bodies this render just delivered.
287
+ for (const d of fresh) {
288
+ if (!rendersAtBoot(d))
289
+ continue;
290
+ registerDocumentExposure(ledger, d.path, d.body, d.bootRung);
291
+ accountDelivery(accounting, d, d.bootRung, undefined, d.source);
292
+ }
293
+ // ONE options object for the whole pass: `dirs` accumulates across every
294
+ // content doc AND every companion it routes, and the shared corpus keeps the
295
+ // listing tree built once.
296
+ const attachments = {
297
+ subject: subject ?? null,
298
+ target: ledger,
299
+ byName: docsByName(listingDocs.map((d) => d.source)),
300
+ listings: 'tree',
301
+ dirs: [],
302
+ accounting,
303
+ };
304
+ const attachmentBlocks = fresh.flatMap((doc) => contentReadAttachments(doc, attachments).blocks);
305
+ const raised = exposedDirEntries(listingDocs, attachments.dirs ?? [], ledger);
306
+ const raisedByPath = new Map(raised.map((d) => [d.path, d]));
307
+ const restPaths = new Set(rest.map((d) => d.path));
308
+ const treeDocs = [
309
+ ...rest.map((d) => raisedByPath.get(d.path) ?? d),
310
+ ...raised.filter((d) => !restPaths.has(d.path)),
311
+ ];
312
+ for (const d of treeDocs) {
313
+ if (!rendersAtBoot(d))
314
+ continue;
315
+ registerDocumentExposure(ledger, d.path, d.body, d.bootRung);
316
+ accountDelivery(accounting, d, d.bootRung, raisedByPath.has(d.path) ? 'directory listing attached to content delivery' : undefined, d.source);
317
+ }
318
+ return {
319
+ contentProse,
320
+ tree: buildTree(treeDocs, rootLabel),
321
+ contentDocs: fresh,
322
+ attachmentBlocks,
323
+ };
229
324
  }
230
325
  function leafSegment(name) {
231
326
  const i = name.lastIndexOf('/');
@@ -456,7 +551,7 @@ export function buildSubPersonaMenu(kind) {
456
551
  * across nodes is preserved. ALWAYS returns a non-empty splice — the guidance
457
552
  * frame is unconditional, so it is never empty even when no preference is
458
553
  * eligible. */
459
- function buildPreferencesBlock(subject, target) {
554
+ function buildPreferencesBlock(subject, target, accounting) {
460
555
  let contentDocs = [];
461
556
  let tree = '';
462
557
  let protocol = '';
@@ -465,8 +560,7 @@ function buildPreferencesBlock(subject, target) {
465
560
  // No nodeId: a node-local doc rides the knowledge partition whatever its
466
561
  // kind, so it never reaches the preference block.
467
562
  const winners = bootWinners(bootPlan(subject, null), 'preference');
468
- recordRendered(target, winners);
469
- const grouped = renderGrouped(winners, 'preferences');
563
+ const grouped = renderGrouped(winners, 'preferences', subject, target, winners, accounting);
470
564
  contentDocs = grouped.contentDocs;
471
565
  tree = grouped.tree;
472
566
  const shipped = contentDocs
@@ -481,7 +575,7 @@ function buildPreferencesBlock(subject, target) {
481
575
  })
482
576
  .filter((b) => b !== '');
483
577
  const subPersonaMenu = buildSubPersonaMenu(subject.kind);
484
- protocol = [...shipped, ...(subPersonaMenu === '' ? [] : [subPersonaMenu])].join('\n\n');
578
+ protocol = [...shipped, ...grouped.attachmentBlocks, ...(subPersonaMenu === '' ? [] : [subPersonaMenu])].join('\n\n');
485
579
  if (editable.length > 0 || tree !== '') {
486
580
  memBody += `\n\n${PREFERENCES_INTRO}`;
487
581
  if (editable.length > 0)
@@ -494,8 +588,8 @@ function buildPreferencesBlock(subject, target) {
494
588
  const memoryBlock = `<memory kind="preference">\n${memBody}\n</memory>`;
495
589
  return { block: protocol === '' ? memoryBlock : `${protocol}\n\n${memoryBlock}`, contentDocs, tree };
496
590
  }
497
- export function renderPreferencesForSubject(subject, target) {
498
- return buildPreferencesBlock(subject, target);
591
+ export function renderPreferencesForSubject(subject, target, accounting) {
592
+ return buildPreferencesBlock(subject, target, accounting);
499
593
  }
500
594
  // 2. Knowledge — `<memory kind="knowledge">` (the first-message bearings).
501
595
  /** The `<memory kind="knowledge">` block embedded in the session_start bearings
@@ -510,15 +604,15 @@ export function renderPreferencesForSubject(subject, target) {
510
604
  * renders: floored docs never reach the `content` rung). Procedural guidance and factual
511
605
  * references both live here as `knowledge`. Returns '' when nothing is
512
606
  * eligible. */
513
- export function renderKnowledgeForSubject(subject, nodeId, target) {
607
+ export function renderKnowledgeForSubject(subject, nodeId, target, accounting) {
514
608
  const winners = bootWinners(bootPlan(subject, nodeId), 'knowledge');
515
- recordRendered(target, winners);
516
- const { contentProse, tree } = renderGrouped(winners, 'knowledge');
517
- if (contentProse === '' && tree === '')
609
+ const { contentProse, tree, attachmentBlocks } = renderGrouped(winners, 'knowledge', subject, target, winners, accounting);
610
+ const deliveredContent = [contentProse, ...attachmentBlocks].filter((block) => block !== '').join('\n\n');
611
+ if (deliveredContent === '' && tree === '')
518
612
  return '';
519
613
  let out = KNOWLEDGE_INTRO;
520
- if (contentProse !== '')
521
- out += `\n\n${contentProse}`;
614
+ if (deliveredContent !== '')
615
+ out += `\n\n${deliveredContent}`;
522
616
  if (tree !== '')
523
617
  out += `\n\n${tree}`;
524
618
  out += `\n\n${KNOWLEDGE_OUTRO}`;
@@ -549,9 +643,7 @@ function undelivered(target, docs) {
549
643
  export function renderConfigChangeDelta(subject, nodeId, target) {
550
644
  const plan = bootPlan(subject, nodeId);
551
645
  const prefs = undelivered(target, bootWinners(plan, 'preference'));
552
- const knowledgeDelta = undelivered(target, bootWinners(plan, 'knowledge'));
553
- recordRendered(target, [...prefs, ...knowledgeDelta]);
554
- const { contentDocs, tree } = renderGrouped(prefs, 'preferences');
646
+ const { contentDocs, tree, attachmentBlocks: preferenceAttachments } = renderGrouped(prefs, 'preferences', subject, target, bootWinners(plan, 'preference'));
555
647
  // Same split as the boot preference render: shipped bodies are protocol prose
556
648
  // (the rules of the node's world), agent-editable bodies are memory the node
557
649
  // can revise, and every non-content preference winner rides the one catalog.
@@ -567,8 +659,8 @@ export function renderConfigChangeDelta(subject, nodeId, target) {
567
659
  })
568
660
  .filter((b) => b !== '');
569
661
  const blocks = [];
570
- if (shipped.length > 0)
571
- blocks.push(`${DELTA_PROTOCOL_INTRO}\n\n${shipped.join('\n\n')}`);
662
+ if (shipped.length > 0 || preferenceAttachments.length > 0)
663
+ blocks.push(`${DELTA_PROTOCOL_INTRO}\n\n${[...shipped, ...preferenceAttachments].join('\n\n')}`);
572
664
  if (editable.length > 0 || tree !== '') {
573
665
  let body = DELTA_PREFERENCES_INTRO;
574
666
  if (editable.length > 0)
@@ -578,11 +670,14 @@ export function renderConfigChangeDelta(subject, nodeId, target) {
578
670
  body += `\n\n${PREFERENCES_OUTRO}`;
579
671
  blocks.push(`<memory kind="preference">\n${body}\n</memory>`);
580
672
  }
581
- const knowledge = renderGrouped(knowledgeDelta, 'knowledge');
582
- if (knowledge.contentProse !== '' || knowledge.tree !== '') {
673
+ // Computed AFTER the preference render: that pass may have delivered a
674
+ // knowledge body as a companion, and this delta must not repeat it.
675
+ const knowledge = renderGrouped(undelivered(target, bootWinners(plan, 'knowledge')), 'knowledge', subject, target, bootWinners(plan, 'knowledge'));
676
+ const knowledgeContent = [knowledge.contentProse, ...knowledge.attachmentBlocks].filter((block) => block !== '').join('\n\n');
677
+ if (knowledgeContent !== '' || knowledge.tree !== '') {
583
678
  let body = DELTA_KNOWLEDGE_INTRO;
584
- if (knowledge.contentProse !== '')
585
- body += `\n\n${knowledge.contentProse}`;
679
+ if (knowledgeContent !== '')
680
+ body += `\n\n${knowledgeContent}`;
586
681
  if (knowledge.tree !== '')
587
682
  body += `\n\n${knowledge.tree}`;
588
683
  body += `\n\n${KNOWLEDGE_OUTRO}`;
@@ -25,6 +25,7 @@
25
25
  // auth.json FILE WRITE only — the plan's sanctioned substitution.
26
26
  import { test, before, after } from 'node:test';
27
27
  import assert from 'node:assert/strict';
28
+ import { spawnSync } from 'node:child_process';
28
29
  import { statSync, existsSync, readFileSync, mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
29
30
  import { tmpdir } from 'node:os';
30
31
  import { join, dirname } from 'node:path';
@@ -33,8 +34,10 @@ import { createHeadlessHarness } from '../../../../core/__tests__/helpers/harnes
33
34
  import { createAttachKit } from '../../../../core/__tests__/helpers/broker-clients.js';
34
35
  import { ApiError, CrtrClient } from '../../../../api/index.js';
35
36
  import { createApiServer } from '../../server.js';
37
+ import { createNode } from '../../../../core/canvas/canvas.js';
36
38
  import { apiSocketPath, inboxPath } from '../../../../core/canvas/paths.js';
37
39
  import { isPidAlive } from '../../../../core/canvas/pid.js';
40
+ import { clearBusy, markBusy } from '../../../../core/runtime/busy.js';
38
41
  let h;
39
42
  let server;
40
43
  let client;
@@ -79,6 +82,37 @@ test('A1: /healthz is ok and the unix socket is bound mode 0600', async () => {
79
82
  const mode = statSync(sockPath).mode & 0o777;
80
83
  assert.equal(mode, 0o600, `socket mode is 0600 (fs-perms-as-auth), got 0o${mode.toString(8)}`);
81
84
  });
85
+ test('status counts only live, busy non-human engine rows', async () => {
86
+ const agent = h.fabricateBrokerNode({ pi_pid: process.pid });
87
+ const deadPid = spawnSync(process.execPath, ['-e', '']).pid;
88
+ assert.ok(deadPid !== undefined, 'dead-pid fixture process started');
89
+ const deadAgent = h.fabricateBrokerNode({ pi_pid: deadPid });
90
+ const bridge = `human-status-${process.pid}`;
91
+ createNode({
92
+ node_id: bridge,
93
+ name: bridge,
94
+ created: new Date().toISOString(),
95
+ cwd: process.cwd(),
96
+ kind: 'human',
97
+ mode: 'base',
98
+ lifecycle: 'terminal',
99
+ status: 'active',
100
+ host_kind: 'broker',
101
+ pi_pid: process.pid,
102
+ });
103
+ const baseline = (await client.status()).busy_node_count;
104
+ markBusy(agent);
105
+ markBusy(deadAgent);
106
+ markBusy(bridge);
107
+ try {
108
+ assert.equal((await client.status()).busy_node_count, baseline + 1);
109
+ }
110
+ finally {
111
+ clearBusy(agent);
112
+ clearBusy(deadAgent);
113
+ clearBusy(bridge);
114
+ }
115
+ });
82
116
  test('attention counts reads only the requested node slice', async () => {
83
117
  const root = h.spawnRoot('attention root');
84
118
  const result = await client.attentionCounts([root, 'unknown-node']);
@@ -266,9 +266,25 @@ function handleHistoryRead(ctx) {
266
266
  }
267
267
  // snapshot \u2014 the enriched browser canvas roster + subscribes_to adjacency.
268
268
  // Lifted from `commands/canvas-snapshot.ts` run() (dashboardRowsAll + enrichRows).
269
- async function handleSnapshot() {
269
+ // `?live=true` selects active/idle rows before the row builder's disk reads.
270
+ function snapshotLiveOnly(ctx) {
271
+ for (const [name] of ctx.query) {
272
+ if (name !== 'live')
273
+ throw usage(`unknown snapshot query parameter: ${name}`);
274
+ }
275
+ const values = ctx.query.getAll('live');
276
+ if (values.length > 1)
277
+ throw usage('live must appear at most once');
278
+ const value = values[0];
279
+ if (value === undefined || value === 'false' || value === '0')
280
+ return false;
281
+ if (value === 'true' || value === '1')
282
+ return true;
283
+ throw usage('live must be true or false');
284
+ }
285
+ async function handleSnapshot(ctx) {
270
286
  const focusedNodeIds = new Set(listFocuses().map((f) => f.node_id));
271
- const rows = await dashboardRowsAllFromSource(localCanvasSource, focusedNodeIds);
287
+ const rows = await dashboardRowsAllFromSource(localCanvasSource, focusedNodeIds, snapshotLiveOnly(ctx));
272
288
  const asks = ticketCountsForNodes(rows.map((r) => r.node_id));
273
289
  await enrichRowsFromSource(localCanvasSource, rows, asks);
274
290
  const nodes = rows.map((row) => {