@north-light/crouter 0.3.182 → 0.3.184

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 (108) hide show
  1. package/dist/build-root.js +10 -3
  2. package/dist/builtin-memory/internal/memory-loading.md +3 -1
  3. package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
  4. package/dist/clients/attach/__tests__/titled-editor-paste.test.d.ts +1 -0
  5. package/dist/clients/attach/__tests__/titled-editor-paste.test.js +114 -0
  6. package/dist/clients/attach/__tests__/titled-editor-preview.test.js +4 -1
  7. package/dist/clients/attach/input/titled-editor.d.ts +18 -0
  8. package/dist/clients/attach/input/titled-editor.js +62 -0
  9. package/dist/clients/attach/render/chat-view.d.ts +3 -0
  10. package/dist/clients/attach/render/chat-view.js +15 -6
  11. package/dist/clients/attach/render/context-message.js +2 -2
  12. package/dist/clients/attach/render/group-activity.d.ts +18 -1
  13. package/dist/clients/attach/render/group-activity.js +54 -6
  14. package/dist/clients/attach/render/group-recap.js +13 -2
  15. package/dist/clients/attach/render/{markdown-headings.d.ts → markdown-source.d.ts} +4 -4
  16. package/dist/clients/attach/render/markdown-source.js +165 -0
  17. package/dist/clients/attach/session/bindings.js +5 -2
  18. package/dist/clients/attach/viewer.js +396 -394
  19. package/dist/clients/inbox/review/keys.js +2 -9
  20. package/dist/clients/inbox/tui/keys.js +2 -9
  21. package/dist/commands/cron.js +1 -1
  22. package/dist/commands/memory/delete.js +1 -1
  23. package/dist/commands/memory/find.js +1 -1
  24. package/dist/commands/memory/lint.js +28 -3
  25. package/dist/commands/memory/list.js +1 -1
  26. package/dist/commands/memory/origin.js +1 -1
  27. package/dist/commands/memory/read.js +4 -4
  28. package/dist/commands/memory/write.js +1 -1
  29. package/dist/commands/sys/settings-shell.d.ts +0 -3
  30. package/dist/commands/sys/settings-shell.js +1 -38
  31. package/dist/commands/sys/settings.js +2 -3
  32. package/dist/commands/sys/setup-core.d.ts +2 -17
  33. package/dist/commands/sys/setup-core.js +9 -33
  34. package/dist/commands/sys/setup-wizard.d.ts +1 -3
  35. package/dist/commands/sys/setup-wizard.js +9 -39
  36. package/dist/commands/sys/setup.js +6 -6
  37. package/dist/commands/sys/sync-project-guidance.js +62 -12
  38. package/dist/core/__tests__/fault-classifier.test.js +20 -0
  39. package/dist/core/__tests__/nested-store-discovery.test.d.ts +1 -0
  40. package/dist/core/__tests__/nested-store-discovery.test.js +89 -0
  41. package/dist/core/__tests__/on-read-nested-store.test.d.ts +1 -0
  42. package/dist/core/__tests__/on-read-nested-store.test.js +117 -0
  43. package/dist/core/__tests__/plugin-link-symlinked-scope-root.test.d.ts +1 -0
  44. package/dist/core/__tests__/plugin-link-symlinked-scope-root.test.js +38 -0
  45. package/dist/core/__tests__/serial/tmux-surface.test.js +8 -5
  46. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +34 -1
  47. package/dist/core/canvas/nav-model.d.ts +1 -8
  48. package/dist/core/canvas/nav-model.js +30 -59
  49. package/dist/core/canvas/nav-render.d.ts +2 -2
  50. package/dist/core/canvas/nav-render.js +8 -6
  51. package/dist/core/command.js +5 -3
  52. package/dist/core/fault-classifier.d.ts +1 -1
  53. package/dist/core/fault-classifier.js +7 -0
  54. package/dist/core/fs-utils.js +6 -1
  55. package/dist/core/keybindings/index.d.ts +1 -1
  56. package/dist/core/keybindings/index.js +1 -1
  57. package/dist/core/keybindings/match.d.ts +11 -0
  58. package/dist/core/keybindings/match.js +22 -0
  59. package/dist/core/memory/doc-link-grammar.js +3 -7
  60. package/dist/core/memory/inline-ref-guidance.js +1 -1
  61. package/dist/core/memory-resolver.d.ts +7 -1
  62. package/dist/core/memory-resolver.js +20 -7
  63. package/dist/core/nested-stores.d.ts +12 -0
  64. package/dist/core/nested-stores.js +146 -0
  65. package/dist/core/preview-registry.js +4 -2
  66. package/dist/core/runtime/bearings.d.ts +1 -1
  67. package/dist/core/runtime/bearings.js +2 -2
  68. package/dist/core/runtime/broker/fault-retry.js +23 -4
  69. package/dist/core/runtime/broker-extension-render.d.ts +1 -1
  70. package/dist/core/runtime/broker-extension-render.js +2 -2
  71. package/dist/core/runtime/broker-protocol.d.ts +1 -1
  72. package/dist/core/runtime/deliver-live.js +11 -1
  73. package/dist/core/runtime/fault.js +1 -1
  74. package/dist/core/runtime/tmux-bindings.js +42 -20
  75. package/dist/core/runtime/tool-group-summary.js +2 -6
  76. package/dist/core/substrate/injected-store.d.ts +6 -0
  77. package/dist/core/substrate/injected-store.js +19 -0
  78. package/dist/core/substrate/on-read-node.d.ts +4 -2
  79. package/dist/core/substrate/on-read-node.js +5 -3
  80. package/dist/core/substrate/on-read.d.ts +4 -2
  81. package/dist/core/substrate/on-read.js +27 -5
  82. package/dist/daemon/__tests__/subtree-filter-freshness.test.d.ts +1 -0
  83. package/dist/daemon/__tests__/subtree-filter-freshness.test.js +63 -0
  84. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -1
  85. package/dist/pi-extensions/canvas-context-intro.js +14 -3
  86. package/dist/pi-extensions/canvas-doc-substrate.js +13 -2
  87. package/dist/shared/generated-context.d.ts +4 -0
  88. package/dist/shared/generated-context.js +20 -1
  89. package/dist/shared/tool-groups.d.ts +3 -3
  90. package/dist/shared/tool-groups.js +1 -4
  91. package/package.json +1 -1
  92. package/runtime.lock.json +2 -2
  93. package/scripts/postinstall.mjs +1 -1
  94. package/dist/clients/attach/render/markdown-headings.js +0 -93
  95. package/dist/commands/search/answer.d.ts +0 -1
  96. package/dist/commands/search/answer.js +0 -50
  97. package/dist/commands/search/contents.d.ts +0 -1
  98. package/dist/commands/search/contents.js +0 -96
  99. package/dist/commands/search/exa.d.ts +0 -53
  100. package/dist/commands/search/exa.js +0 -157
  101. package/dist/commands/search/puremd.d.ts +0 -11
  102. package/dist/commands/search/puremd.js +0 -53
  103. package/dist/commands/search/web.d.ts +0 -1
  104. package/dist/commands/search/web.js +0 -65
  105. package/dist/commands/search.d.ts +0 -2
  106. package/dist/commands/search.js +0 -24
  107. package/dist/commands/sys/panels/keys-panel.d.ts +0 -16
  108. package/dist/commands/sys/panels/keys-panel.js +0 -60
@@ -0,0 +1,146 @@
1
+ // nested-stores.ts — bounded discovery of `.crouter/memory` stores BELOW the
2
+ // mounted ancestor roots, so `crtr memory list/read/find/lint/delete/origin`
3
+ // can address a nested store's docs by name. Deliberately opt-in and never on
4
+ // the boot/render path: the boot catalog, workspace-open render, on-read
5
+ // resolvedDocs, slash-command snapshot, and persona resolution all stay on the
6
+ // flat ancestor+profile stack (on-read delivery finds nested stores through
7
+ // its own read-file walk instead).
8
+ //
9
+ // Two discovery modes per owner dir, both under one shared 500 ms budget:
10
+ // • Git root — `git ls-files` over tracked+untracked (respecting .gitignore),
11
+ // so junk dirs and ignored trees cost nothing and embedded repos are
12
+ // auto-excluded (ls-files does not descend into submodules).
13
+ // • Non-git root — a DFS with junk-dir skips, a depth cap, and an
14
+ // embedded-repo stop.
15
+ import { readdirSync } from 'node:fs';
16
+ import { dirname, join, sep } from 'node:path';
17
+ import { CRTR_DIR_NAME } from '../types.js';
18
+ import { pathExists, realpathOrSelf } from './fs-utils.js';
19
+ import { gitSync } from './git.js';
20
+ import { warn } from './output.js';
21
+ /** Mirrors on-read.ts's junk set, plus `.venv`. */
22
+ const JUNK_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '.yalc', '.venv']);
23
+ const MAX_DEPTH = 6;
24
+ const TIME_BUDGET_MS = 500;
25
+ /** Cache keyed by the sorted joined root list. A `memory read` of an INDEX
26
+ * resolves every `[[link]]` through the resolver, each of which rebuilds its
27
+ * sources — without this cache that is one git spawn + walk per link. CLI
28
+ * processes are short-lived, so staleness is a non-issue; the daemon never
29
+ * opts in. */
30
+ const cache = new Map();
31
+ /**
32
+ * Every `.crouter` store root strictly BELOW the given ancestor roots' owning
33
+ * dirs, in owner order then discovery order. Takes the `projectScopeRoots()`
34
+ * entries themselves (`.crouter` dir paths) and derives owner dirs internally.
35
+ *
36
+ * Any discovered root whose realpath matches an INPUT root is excluded here,
37
+ * inside the function: a profile can manage both `/a` and `/a/b`, making
38
+ * `/a/b/.crouter` simultaneously an ancestor root and a descendant of `/a` —
39
+ * excluding centrally gives the resolver and lint the identical call with no
40
+ * duplicated dedupe logic.
41
+ */
42
+ export function descendantStoreRoots(ancestorRoots) {
43
+ const key = [...ancestorRoots].sort().join('\n');
44
+ const cached = cache.get(key);
45
+ if (cached !== undefined)
46
+ return cached;
47
+ const ancestorReal = new Set(ancestorRoots.map((root) => realpathOrSelf(root)));
48
+ const owners = [];
49
+ const ownerSeen = new Set();
50
+ for (const root of ancestorRoots) {
51
+ const owner = dirname(root);
52
+ const real = realpathOrSelf(owner);
53
+ if (ownerSeen.has(real))
54
+ continue;
55
+ ownerSeen.add(real);
56
+ owners.push(owner);
57
+ }
58
+ const deadline = Date.now() + TIME_BUDGET_MS;
59
+ let warned = false;
60
+ const tripBudget = (owner) => {
61
+ if (warned)
62
+ return;
63
+ warned = true;
64
+ warn(`memory: nested-store discovery under ${owner} exceeded its ${TIME_BUDGET_MS}ms budget — nested stores may be missing from this listing; register deep stores explicitly (add their dirs to the selected profile's projects)`);
65
+ };
66
+ const found = [];
67
+ const foundSeen = new Set();
68
+ const add = (storeRoot) => {
69
+ const real = realpathOrSelf(storeRoot);
70
+ if (foundSeen.has(real) || ancestorReal.has(real))
71
+ return;
72
+ foundSeen.add(real);
73
+ found.push(storeRoot);
74
+ };
75
+ for (const owner of owners) {
76
+ const remaining = deadline - Date.now();
77
+ if (remaining <= 0) {
78
+ tripBudget(owner);
79
+ break;
80
+ }
81
+ if (pathExists(join(owner, '.git'))) {
82
+ collectGitStores(owner, add, remaining, () => tripBudget(owner));
83
+ }
84
+ else {
85
+ collectWalkStores(owner, add, deadline, () => tripBudget(owner));
86
+ }
87
+ }
88
+ cache.set(key, found);
89
+ return found;
90
+ }
91
+ // Git owner: enumerate via `git ls-files -co --exclude-standard` so
92
+ // .gitignore is respected for free. Git's default pathspec fnmatch lets `*`
93
+ // cross slashes, and the leading `*/` excludes the owner's own root store
94
+ // (nothing precedes its `.crouter`).
95
+ function collectGitStores(owner, add, timeoutMs, trip) {
96
+ const marker = `/${CRTR_DIR_NAME}/memory/`;
97
+ const res = gitSync(['-C', owner, 'ls-files', '-z', '-co', '--exclude-standard', '--', `*/${CRTR_DIR_NAME}/memory/*`], undefined, undefined, { timeoutMs });
98
+ if (res.timedOut === true) {
99
+ trip();
100
+ return;
101
+ }
102
+ if (res.status !== 0)
103
+ return;
104
+ for (const rel of res.stdout.split('\0')) {
105
+ if (rel === '')
106
+ continue;
107
+ const idx = rel.indexOf(marker);
108
+ if (idx <= 0)
109
+ continue;
110
+ add(join(owner, rel.slice(0, idx).split('/').join(sep), CRTR_DIR_NAME));
111
+ }
112
+ }
113
+ /** Non-git owner: DFS with junk skips, a depth cap, and a stop at any embedded
114
+ * `.git` dir (an embedded repo is its own world — register it explicitly). */
115
+ function collectWalkStores(owner, add, deadline, trip) {
116
+ const stack = [{ dir: owner, depth: 0 }];
117
+ while (stack.length > 0) {
118
+ if (Date.now() > deadline) {
119
+ trip();
120
+ return;
121
+ }
122
+ const { dir, depth } = stack.pop();
123
+ if (dir !== owner) {
124
+ if (pathExists(join(dir, '.git')))
125
+ continue;
126
+ if (pathExists(join(dir, CRTR_DIR_NAME, 'memory')))
127
+ add(join(dir, CRTR_DIR_NAME));
128
+ }
129
+ if (depth >= MAX_DEPTH)
130
+ continue;
131
+ let entries;
132
+ try {
133
+ entries = readdirSync(dir, { withFileTypes: true });
134
+ }
135
+ catch {
136
+ continue;
137
+ }
138
+ for (const entry of entries) {
139
+ if (!entry.isDirectory())
140
+ continue;
141
+ if (JUNK_DIRS.has(entry.name) || entry.name === CRTR_DIR_NAME)
142
+ continue;
143
+ stack.push({ dir: join(dir, entry.name), depth: depth + 1 });
144
+ }
145
+ }
146
+ }
@@ -62,9 +62,11 @@ export const TOOL_ICON = {
62
62
  write: String.fromCodePoint(0xf0c7),
63
63
  edit: String.fromCodePoint(0xf044),
64
64
  };
65
- /** One glyph per root subtree (`crtr <family> ...`) — the fallback for any
65
+ /** One glyph per root family (`crtr <family> ...`) — the fallback for any
66
66
  * leaf not listed in LEAF_ICON_CP below. Codepoints are Nerd Font
67
- * private-use-area icons chosen per family concept (see command-icons.md). */
67
+ * private-use-area icons chosen per family concept (see command-icons.md).
68
+ * Families a plugin mounts (capture, device, search) are listed here too: this
69
+ * registry is keyed by command path, not by who owns the command. */
68
70
  const FAMILY_ICON_CP = {
69
71
  canvas: 0xf0e8,
70
72
  capture: 0xf03d,
@@ -81,4 +81,4 @@ export declare function buildForkBearings(nodeId: string): string;
81
81
  * AGENTS.md/CLAUDE.md injection stays suppressed (`noContextFiles` in
82
82
  * broker.ts); `crtr sys sync project-guidance` imports that guidance into
83
83
  * the directory's `.crouter/memory/INDEX.md` front door. */
84
- export declare function buildContextBearings(nodeId: string): string;
84
+ export declare function buildContextBearings(nodeId: string, seen?: Set<string>): string;
@@ -404,7 +404,7 @@ export function buildForkBearings(nodeId) {
404
404
  * AGENTS.md/CLAUDE.md injection stays suppressed (`noContextFiles` in
405
405
  * broker.ts); `crtr sys sync project-guidance` imports that guidance into
406
406
  * the directory's `.crouter/memory/INDEX.md` front door. */
407
- export function buildContextBearings(nodeId) {
407
+ export function buildContextBearings(nodeId, seen) {
408
408
  const node = getNode(nodeId);
409
409
  const bearings = ['<crtr-bearings>', buildIdentityAssertion(nodeId)];
410
410
  const map = buildGraphMap(nodeId);
@@ -425,7 +425,7 @@ export function buildContextBearings(nodeId) {
425
425
  // The consultable catalog rides BESIDE the bearings, not inside them.
426
426
  const knowledge = renderKnowledgeBlock(nodeId);
427
427
  // A workspace mount is the initial read of its root (`applies-to: "."`).
428
- const workspaceContext = renderWorkspaceOpenDocs(nodeId);
428
+ const workspaceContext = renderWorkspaceOpenDocs(nodeId, seen);
429
429
  // Hidden applet/situation-origin ambient context (see situational-context.ts)
430
430
  // — a SIBLING block, never nested under <crtr-bearings>, and never routed
431
431
  // through the visible chat/digest path. '' when nothing was ever set.
@@ -53,6 +53,15 @@ export class FaultRetry {
53
53
  generation.stagedWatchdogAbort = false;
54
54
  generation.stagedOverflowFailure = null;
55
55
  const settledSession = generation.session;
56
+ // A failed daemon retry settles here with the episode's fault marker still
57
+ // present (a successful provider round-trip would have cleared it). Carry
58
+ // the episode forward — original anchor, start time, and attempt count — so
59
+ // each retry rewinds to the same fork point instead of stacking recovery
60
+ // prompts, and so backoff/exhaustion actually accumulate.
61
+ const prior = readFault(this.deps.nodeId);
62
+ const episode = this.isActiveAutoFault(prior) && prior.link === 'pi→provider'
63
+ ? { since: prior.since, anchorEntryId: prior.anchorEntryId, attempt: prior.retry.attempt }
64
+ : null;
56
65
  const messages = Array.isArray(agentEnd?.messages) ? agentEnd.messages : [];
57
66
  const last = [...messages].reverse().find((message) => typeof message === 'object' && message !== null && message.role === 'assistant');
58
67
  if (overflowFailure !== null) {
@@ -65,9 +74,11 @@ export class FaultRetry {
65
74
  }
66
75
  if (watchdogAbort && last?.stopReason === 'aborted') {
67
76
  recordFault(this.deps.nodeId, {
68
- link: 'pi→provider', op: 'generation turn', kind: 'connection', retry: { disposition: 'auto', by: 'daemon' },
77
+ link: 'pi→provider', op: 'generation turn', kind: 'connection',
78
+ retry: { disposition: 'auto', by: 'daemon', ...(episode?.attempt !== undefined ? { attempt: episode.attempt } : {}) },
69
79
  message: typeof last.errorMessage === 'string' ? last.errorMessage : 'engine error (no errorMessage recorded)',
70
- anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
80
+ ...(episode !== null ? { since: episode.since } : {}),
81
+ anchorEntryId: episode?.anchorEntryId ?? settledSession.sessionManager.getLeafId?.() ?? undefined,
71
82
  });
72
83
  return;
73
84
  }
@@ -84,9 +95,17 @@ export class FaultRetry {
84
95
  retry: uncompactedOverflow
85
96
  ? { disposition: 'fatal' }
86
97
  : classified.disposition === 'auto'
87
- ? { disposition: 'auto', by: 'daemon', ...(coolingDeadline !== undefined ? { nextAt: new Date(coolingDeadline).toISOString() } : {}) }
98
+ ? {
99
+ disposition: 'auto', by: 'daemon',
100
+ ...(coolingDeadline !== undefined ? { nextAt: new Date(coolingDeadline).toISOString() } : {}),
101
+ ...(episode?.attempt !== undefined ? { attempt: episode.attempt } : {}),
102
+ }
88
103
  : { disposition: classified.disposition },
89
- message: raw, anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
104
+ message: raw,
105
+ ...(!uncompactedOverflow && classified.disposition === 'auto' && episode !== null ? { since: episode.since } : {}),
106
+ anchorEntryId: !uncompactedOverflow && classified.disposition === 'auto' && episode?.anchorEntryId !== undefined
107
+ ? episode.anchorEntryId
108
+ : settledSession.sessionManager.getLeafId?.() ?? undefined,
90
109
  });
91
110
  }
92
111
  schedule() {
@@ -3,7 +3,7 @@ export declare function editorLabelForBrokerNode(node: BrokerExtensionNodeDTO):
3
3
  /** Same environment-only project context rendered at normal boot, without a
4
4
  * canvas import. */
5
5
  export declare function buildProjectContextBlockForBroker(cwd: string): string;
6
- export declare function buildContextBearingsFromState(state: BrokerExtensionStateDTO, projectContext: string, situational?: string): string;
6
+ export declare function buildContextBearingsFromState(state: BrokerExtensionStateDTO, projectContext: string, situational?: string, seen?: Set<string>): string;
7
7
  export declare function buildForkBearingsFromState(state: BrokerExtensionStateDTO, situational?: string): string;
8
8
  export declare function renderPreferencesFromState(state: BrokerExtensionStateDTO): string;
9
9
  /** A canonical report ref has already been validated by crtrd in state. The
@@ -120,7 +120,7 @@ function reviewNote(node) {
120
120
  return '';
121
121
  return `You exist for one human review of \`${review.target_file}\`. The inherited conversation belongs to the origin node, not you — carry only the context needed for this review. The person's visible transcript begins at the boundary marker above; everything earlier is context they cannot see.`;
122
122
  }
123
- export function buildContextBearingsFromState(state, projectContext, situational = '') {
123
+ export function buildContextBearingsFromState(state, projectContext, situational = '', seen) {
124
124
  const { node } = state;
125
125
  const bearings = ['<crtr-bearings>', identity(state)];
126
126
  const map = graphMap(state);
@@ -137,7 +137,7 @@ export function buildContextBearingsFromState(state, projectContext, situational
137
137
  bearings.push('', `Because you persist across refresh cycles, your context directory (${contextDir(node.node_id)}) is also where a future cycle of you resumes the work — keep the working notes and decisions a refreshed you would need there, alongside the docs you share with the nodes you spawn.`);
138
138
  bearings.push('</crtr-bearings>');
139
139
  const knowledge = renderKnowledgeForSubject(state.subject, node.node_id);
140
- const workspace = renderWorkspaceOpenDocsForSubject(state.subject, node.cwd, node.profile_id);
140
+ const workspace = renderWorkspaceOpenDocsForSubject(state.subject, node.cwd, node.profile_id, seen);
141
141
  return [bearings.join('\n'), knowledge, workspace, situational, projectContext].filter((part) => part !== '').join('\n');
142
142
  }
143
143
  export function buildForkBearingsFromState(state, situational = '') {
@@ -47,7 +47,7 @@ export interface BrokerSnapshot {
47
47
  title: string | undefined;
48
48
  };
49
49
  /** Broker-owned summaries keyed by the first tool-call id in each group.
50
- * Structured objects with bullets, nodesSpawned, and filesDeleted.
50
+ * Structured objects with bullets and filesDeleted.
51
51
  * Optional so snapshots from an older live runtime remain readable. */
52
52
  toolGroupSummaries?: Record<string, ToolGroupSummary>;
53
53
  /** The broker-selected normal activity label for the current turn. Optional so
@@ -21,6 +21,7 @@ import { getNode } from '../canvas/index.js';
21
21
  import { oneShotControllerRequest } from './broker-request.js';
22
22
  import { isBrokerLive } from './model-swap.js';
23
23
  import { buildContextBearings, CONTEXT_INTRO_CUSTOM_TYPE } from './bearings.js';
24
+ import { loadInjectedDocs, saveInjectedDocs } from '../substrate/injected-store.js';
24
25
  /** Deliver `message` into `nodeId`'s live session. Resolves without doing
25
26
  * anything when the node has no live broker. Rejects when a live broker cannot
26
27
  * be reached or refuses the frame — the caller decides what that means (for a
@@ -55,9 +56,18 @@ export async function deliverCustomMessageLive(nodeId, message) {
55
56
  * guard matches on, so a LATER revive of this node sees its own bearings
56
57
  * already on the branch and does not inject a second copy. */
57
58
  export async function deliverBearingsLive(nodeId) {
59
+ // This runs in the DAEMON while the spare's pi process is already alive, so
60
+ // the disk file — not any in-memory set — is the dedup medium: load the
61
+ // node's persisted set, grow it through the workspace-open render, and
62
+ // persist it after successful delivery. The pi process's on-read hook merges
63
+ // the on-disk set before each render, so docs delivered here never
64
+ // re-deliver on a later read. Plain load/save (not the process-shared
65
+ // cache): the daemon is long-lived and serves many nodes.
66
+ const seen = loadInjectedDocs(nodeId);
58
67
  await deliverCustomMessageLive(nodeId, {
59
68
  customType: CONTEXT_INTRO_CUSTOM_TYPE,
60
- content: buildContextBearings(nodeId),
69
+ content: buildContextBearings(nodeId, seen),
61
70
  details: { nodeId },
62
71
  });
72
+ saveInjectedDocs(nodeId, seen);
63
73
  }
@@ -19,7 +19,7 @@ function faultPriority(fault) {
19
19
  return 2;
20
20
  return 1;
21
21
  }
22
- const faultLinks = ['pi→provider', 'viewer↔broker', 'relay↔broker', 'viewer↔crtrd', 'cli→exa', 'daemon→node', 'crtr→pi'];
22
+ const faultLinks = ['pi→provider', 'viewer↔broker', 'relay↔broker', 'viewer↔crtrd', 'daemon→node', 'crtr→pi'];
23
23
  const faultKinds = ['rate-limit', 'overloaded', 'connection', 'auth', 'protocol', 'context-overflow', 'other', 'wedged', 'model-not-found'];
24
24
  function parseFault(raw) {
25
25
  try {
@@ -161,23 +161,40 @@ function normalizedStrokeParts(stroke) {
161
161
  return { key: parts.pop(), modifiers: parts };
162
162
  }
163
163
  function tmuxKey(stroke) {
164
+ return tmuxKeyNames(stroke)[0];
165
+ }
166
+ /** Every tmux key name one physical chord can arrive as, most specific first.
167
+ *
168
+ * tmux derives the name from the bytes the terminal sent, and a terminal sends
169
+ * different bytes for the same chord depending on whether extended keys are
170
+ * active — which changes per pane, since the pane's application turns them on.
171
+ * tmux does not normalize across those encodings, so a chord bound under one
172
+ * name is simply dead under the others. Verified against tmux 3.7b by writing
173
+ * raw bytes to an attached client; for Alt+Shift+F:
174
+ *
175
+ * ESC F (legacy meta prefix) -> `M-F`
176
+ * ESC [70;4u / ESC [27;4;70~ (shifted keysym) -> `M-S-F`
177
+ * ESC [102;4u / ESC [27;4;102~ (base keysym) -> `M-S-f`
178
+ *
179
+ * Shift alone never survives as a modifier — every encoding of Shift+G lands
180
+ * on `G` — so only chords that pair Shift with Ctrl or Alt carry aliases. */
181
+ function tmuxKeyNames(stroke) {
164
182
  let { key, modifiers: modifierParts } = normalizedStrokeParts(stroke);
165
183
  const modifiers = new Set(modifierParts);
166
- if (modifiers.has('shift') && key.length === 1 && /[a-z]/.test(key)) {
167
- // tmux names a shifted printable with its uppercase character even when an
168
- // extended-key terminal also preserves Shift as a modifier: Alt+Shift+F is
169
- // `M-S-F`. Shift by itself needs only the uppercase character (`F`).
170
- key = key.toUpperCase();
171
- if (!modifiers.has('ctrl') && !modifiers.has('alt'))
172
- modifiers.delete('shift');
173
- }
174
- key = TMUX_NAMED_KEYS[key] ?? key;
175
- const prefix = [
184
+ const name = (character, shift) => [
176
185
  modifiers.has('ctrl') ? 'C-' : '',
177
186
  modifiers.has('alt') ? 'M-' : '',
178
- modifiers.has('shift') ? 'S-' : '',
187
+ shift ? 'S-' : '',
188
+ TMUX_NAMED_KEYS[character] ?? character,
179
189
  ].join('');
180
- return `${prefix}${key}`;
190
+ if (modifiers.has('shift') && key.length === 1 && /[a-z]/.test(key)) {
191
+ const shifted = key.toUpperCase();
192
+ // Shift-only: tmux always collapses it into the uppercase character.
193
+ if (!modifiers.has('ctrl') && !modifiers.has('alt'))
194
+ return [name(shifted, false)];
195
+ return [name(shifted, true), name(shifted, false), name(key, true)];
196
+ }
197
+ return [name(key, modifiers.has('shift'))];
181
198
  }
182
199
  function menuSelector(stroke) {
183
200
  const { key, modifiers } = normalizedStrokeParts(stroke);
@@ -386,7 +403,7 @@ export function occupiedTmuxRootGestures(gestures) {
386
403
  const parsed = parseTmuxBinding(line);
387
404
  return parsed?.table === ROOT_TABLE ? [parsed.key] : [];
388
405
  }));
389
- return gestures.filter((gesture) => !gesture.includes(' > ') && occupied.has(tmuxKey(gesture)));
406
+ return gestures.filter((gesture) => !gesture.includes(' > ') && tmuxKeyNames(gesture).some((key) => occupied.has(key)));
390
407
  }
391
408
  function isInstalledPair(value) {
392
409
  return typeof value === 'object' && value !== null
@@ -547,9 +564,10 @@ function reconcileTmuxBindings(bindingSnapshot, passthroughSnapshot = []) {
547
564
  for (const gesture of bindings.gestures(bindingId)) {
548
565
  if (gesture.includes(' > '))
549
566
  continue;
550
- const key = tmuxKey(gesture);
551
- if (!desiredWrappers.has(key))
552
- desiredWrappers.set(key, { bindingId, gesture });
567
+ for (const key of tmuxKeyNames(gesture)) {
568
+ if (!desiredWrappers.has(key))
569
+ desiredWrappers.set(key, { bindingId, gesture });
570
+ }
553
571
  }
554
572
  }
555
573
  // Restore every wrapper crouter still owns before the ordinary binding sweep.
@@ -610,7 +628,8 @@ function reconcileTmuxBindings(bindingSnapshot, passthroughSnapshot = []) {
610
628
  const menuActions = resolvedMenuActions(bindings);
611
629
  const menuCommand = buildMenuArgs(' crtr ', menuItems(bindings, menuActions));
612
630
  for (const gesture of bindings.gestures('crtr.tmux.menu.open')) {
613
- install(ROOT_TABLE, tmuxKey(gesture), menuCommand, 'crtr.tmux.menu.open', gesture);
631
+ for (const key of tmuxKeyNames(gesture))
632
+ install(ROOT_TABLE, key, menuCommand, 'crtr.tmux.menu.open', gesture);
614
633
  }
615
634
  for (const definition of BINDING_CATALOG) {
616
635
  if (definition.pair?.field !== 'menu')
@@ -625,12 +644,15 @@ function reconcileTmuxBindings(bindingSnapshot, passthroughSnapshot = []) {
625
644
  if (command === null)
626
645
  throw new Error(`no direct tmux action for paired menu binding ${menuId}`);
627
646
  for (const gesture of bindings.gestures(globalId)) {
628
- install(ROOT_TABLE, tmuxKey(gesture), command.argv, globalId, gesture);
647
+ for (const key of tmuxKeyNames(gesture))
648
+ install(ROOT_TABLE, key, command.argv, globalId, gesture);
629
649
  }
630
650
  }
631
651
  for (const id of ['crtr.tmux.node.next', 'crtr.tmux.node.previous', 'crtr.tmux.inbox.toggle']) {
632
- for (const gesture of bindings.gestures(id))
633
- install(ROOT_TABLE, tmuxKey(gesture), actionCommand(id), id, gesture);
652
+ for (const gesture of bindings.gestures(id)) {
653
+ for (const key of tmuxKeyNames(gesture))
654
+ install(ROOT_TABLE, key, actionCommand(id), id, gesture);
655
+ }
634
656
  }
635
657
  // Wrap only occupied roots. Unoccupied gestures already reach the pane and do
636
658
  // not need crouter ownership. Re-read the installed line as the fingerprint;
@@ -49,20 +49,16 @@ const SUMMARY_SYSTEM_PROMPT = 'Create a structured recap of this coding-agent to
49
49
  'Omit inbox receipt, delivery, and queueing; node-response and report bookkeeping; help lookups; status checks; and summary-generation activity. ' +
50
50
  'A node response may support a substantive outcome, but its arrival or resummarization is never itself an outcome. ' +
51
51
  'If no meaningful outcome remains after those omissions, submit the single bullet "No meaningful changes." ' +
52
- 'Count nodes created, and separately count distinct files removed from disk, only when the supplied tool arguments or results evidence them; otherwise use zero, even when the corresponding mechanics are omitted from bullets. ' +
52
+ 'Count distinct files removed from disk only when the supplied tool arguments or results evidence them; otherwise use zero, even when the corresponding mechanics are omitted from bullets. ' +
53
53
  'A deleted file is one that no longer exists after the segment, never a file that was merely edited or overwritten, and a recursive or wildcard removal of an unknown number of files counts as one. ' +
54
54
  'Do not invent success when a result shows failure. ' +
55
- 'Call submit_summary exactly once with bullets, nodesSpawned, and filesDeleted, then stop.';
55
+ 'Call submit_summary exactly once with bullets and filesDeleted, then stop.';
56
56
  const SUBMIT_SUMMARY_SCHEMA = Type.Object({
57
57
  bullets: Type.Array(Type.String({
58
58
  minLength: 1,
59
59
  maxLength: 120,
60
60
  description: 'A very short, plain-English outcome without bullet markers, identifiers, or operational bookkeeping.',
61
61
  }), { minItems: 1, maxItems: 5 }),
62
- nodesSpawned: Type.Integer({
63
- minimum: 0,
64
- description: 'Count of nodes created during the supplied segment, or zero when none are evidenced.',
65
- }),
66
62
  filesDeleted: Type.Integer({
67
63
  minimum: 0,
68
64
  description: 'Count of distinct files removed from disk during the supplied segment, or zero when none are evidenced.',
@@ -1,3 +1,9 @@
1
+ /** The process-shared dedup set for a node: loaded from disk once, then the
2
+ * SAME Set instance on every call, so all hooks in this pi process grow and
3
+ * consult one set. Daemon-side deliveries (a warm-spare claim) write the disk
4
+ * file from another process; consumers that must see those merge the plain
5
+ * `loadInjectedDocs` result in before rendering. */
6
+ export declare function sharedInjectedDocs(nodeId: string): Set<string>;
1
7
  /** Rehydrate a node's on-read dedup set from disk. Returns an empty set when the
2
8
  * file is absent, unreadable, or malformed (a fresh transcript, or a node that
3
9
  * has not yet surfaced any on-read doc). */
@@ -19,6 +19,24 @@
19
19
  // never a crash — a dedup miss must never break a read or a revive.
20
20
  import { readFileSync, writeFileSync, rmSync } from 'node:fs';
21
21
  import { injectedDocsPath } from '../canvas/paths.js';
22
+ /** Per-process cache behind `sharedInjectedDocs`: one Set instance per node,
23
+ * shared by every extension in the pi process (context-intro's workspace-open
24
+ * render and doc-substrate's on-read hook), so a doc delivered by one hook is
25
+ * already in the set the other dedups against. */
26
+ const cache = new Map();
27
+ /** The process-shared dedup set for a node: loaded from disk once, then the
28
+ * SAME Set instance on every call, so all hooks in this pi process grow and
29
+ * consult one set. Daemon-side deliveries (a warm-spare claim) write the disk
30
+ * file from another process; consumers that must see those merge the plain
31
+ * `loadInjectedDocs` result in before rendering. */
32
+ export function sharedInjectedDocs(nodeId) {
33
+ let seen = cache.get(nodeId);
34
+ if (seen === undefined) {
35
+ seen = loadInjectedDocs(nodeId);
36
+ cache.set(nodeId, seen);
37
+ }
38
+ return seen;
39
+ }
22
40
  /** Rehydrate a node's on-read dedup set from disk. Returns an empty set when the
23
41
  * file is absent, unreadable, or malformed (a fresh transcript, or a node that
24
42
  * has not yet surfaced any on-read doc). */
@@ -46,6 +64,7 @@ export function saveInjectedDocs(nodeId, seen) {
46
64
  /** Drop a node's persisted dedup set. Called by the launch paths that start a
47
65
  * FRESH transcript, so the new conversation surfaces docs from scratch. */
48
66
  export function clearInjectedDocs(nodeId) {
67
+ cache.delete(nodeId);
49
68
  try {
50
69
  rmSync(injectedDocsPath(nodeId), { force: true });
51
70
  }
@@ -3,5 +3,7 @@
3
3
  export declare function renderOnReadDocs(nodeId: string, readFilePath: string, seen?: Set<string>): string;
4
4
  /** Surface workspace-wide docs during first-message assembly, using the node's
5
5
  * recorded cwd and selected profile. `.` is a reserved applies-to target
6
- * meaning “when this project store is mounted by cwd/profile”. */
7
- export declare function renderWorkspaceOpenDocs(nodeId: string): string;
6
+ * meaning “when this project store is mounted by cwd/profile” (and, on the
7
+ * read event, “a file under the store's owning dir was read”). `seen` is the
8
+ * transcript-scoped dedup set shared with the on-read hook. */
9
+ export declare function renderWorkspaceOpenDocs(nodeId: string, seen?: Set<string>): string;
@@ -22,14 +22,16 @@ export function renderOnReadDocs(nodeId, readFilePath, seen = new Set()) {
22
22
  }
23
23
  /** Surface workspace-wide docs during first-message assembly, using the node's
24
24
  * recorded cwd and selected profile. `.` is a reserved applies-to target
25
- * meaning “when this project store is mounted by cwd/profile”. */
26
- export function renderWorkspaceOpenDocs(nodeId) {
25
+ * meaning “when this project store is mounted by cwd/profile” (and, on the
26
+ * read event, “a file under the store's owning dir was read”). `seen` is the
27
+ * transcript-scoped dedup set shared with the on-read hook. */
28
+ export function renderWorkspaceOpenDocs(nodeId, seen = new Set()) {
27
29
  try {
28
30
  const subject = assembleNodeSubject(nodeId);
29
31
  const node = getNode(nodeId);
30
32
  if (subject === null || node === null)
31
33
  return '';
32
- return renderWorkspaceOpenDocsForSubject(subject, node.cwd, node.profile_id ?? null);
34
+ return renderWorkspaceOpenDocsForSubject(subject, node.cwd, node.profile_id ?? null, seen);
33
35
  }
34
36
  catch {
35
37
  return '';
@@ -3,5 +3,7 @@ import type { NodeConfigSubject } from './subject-fields.js';
3
3
  * daemon snapshot subject. */
4
4
  export declare function renderOnReadDocsForSubject(subject: NodeConfigSubject, readFilePath: string, seen?: Set<string>): string;
5
5
  /** Workspace boot docs using the snapshot's cwd/profile instead of a canvas
6
- * lookup. */
7
- export declare function renderWorkspaceOpenDocsForSubject(subject: NodeConfigSubject, cwd: string, profileId: string | null): string;
6
+ * lookup. `seen` is the transcript-scoped dedup set shared with the on-read
7
+ * hook, so a front door delivered here never re-delivers on a later read
8
+ * beneath its store. */
9
+ export declare function renderWorkspaceOpenDocsForSubject(subject: NodeConfigSubject, cwd: string, profileId: string | null, seen?: Set<string>): string;
@@ -12,10 +12,16 @@
12
12
  // visible substrate doc. Project stores encountered between that file and
13
13
  // the filesystem root join the resolved cwd/profile corpus, so a nested
14
14
  // workspace can contribute guidance exactly when a file beneath it is read.
15
+ // On this event the reserved `applies-to: "."` target means "the read file
16
+ // is under the doc's store's owning directory" — a nested store's front
17
+ // door surfaces on the first read beneath it, delivery driven purely by
18
+ // filesystem truth.
15
19
  // • Opening a workspace evaluates the reserved `applies-to: "."` target for
16
20
  // every project store mounted by the cwd and selected profile. This happens
17
21
  // while the first-message bearings are assembled, so workspace-wide context
18
22
  // arrives before the task rather than waiting for an arbitrary first file.
23
+ // Both hooks share one transcript-scoped dedup set, so a doc delivered at
24
+ // workspace open never re-delivers on a later matching read.
19
25
  //
20
26
  // `read-when` remains an additional OR trigger over a read markdown file's own
21
27
  // frontmatter. It does not replace `applies-to`: every non-none file-read rung
@@ -25,7 +31,7 @@
25
31
  // is the whole on-read model, so directory INDEX ceilings remain a boot-catalog
26
32
  // concern and do not cap a deliberately matched document.
27
33
  import { homedir } from 'node:os';
28
- import { basename, dirname, matchesGlob, parse, relative, sep } from 'node:path';
34
+ import { basename, dirname, isAbsolute, matchesGlob, parse, relative, sep } from 'node:path';
29
35
  import { CRTR_DIR_NAME } from '../../types.js';
30
36
  import { pathExists, readText, walkFiles } from '../fs-utils.js';
31
37
  import { parseFrontmatterGeneric } from '../frontmatter.js';
@@ -175,6 +181,18 @@ function globMatches(glob, absReadFile, owningRoot) {
175
181
  }
176
182
  });
177
183
  }
184
+ /** True when the read file sits beneath the doc's store's owning directory —
185
+ * the read-event meaning of the reserved `applies-to: "."` target. Callers
186
+ * additionally guard on `doc.scope === 'project'`: a profile store's path
187
+ * computes an owning root of `~`, and user/builtin/node docs likewise have no
188
+ * project-owning dir, so `.` outside a project store must never match. */
189
+ function underOwningRoot(doc, absReadFile) {
190
+ const root = owningRootOf(doc);
191
+ if (root === null)
192
+ return false;
193
+ const rel = relative(realpathOrSelf(root), absReadFile);
194
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
195
+ }
178
196
  function readFileFrontmatter(absReadFile) {
179
197
  if (!/\.(md|mdx|markdown)$/i.test(absReadFile))
180
198
  return {};
@@ -186,7 +204,9 @@ function readFileFrontmatter(absReadFile) {
186
204
  }
187
205
  }
188
206
  function matchesReadEvent(doc, absReadFile, readFrontmatter) {
189
- const pathMatch = doc.appliesTo?.some((glob) => globMatches(glob, absReadFile, owningRootOf(doc))) === true;
207
+ const pathMatch = doc.appliesTo?.some((glob) => glob.trim() === '.'
208
+ ? doc.scope === 'project' && underOwningRoot(doc, absReadFile)
209
+ : globMatches(glob, absReadFile, owningRootOf(doc))) === true;
190
210
  const frontmatterMatch = doc.readWhen !== undefined &&
191
211
  Object.keys(readFrontmatter).length > 0 &&
192
212
  evalCondition(doc.readWhen, readFrontmatter);
@@ -245,8 +265,10 @@ export function renderOnReadDocsForSubject(subject, readFilePath, seen = new Set
245
265
  return renderCandidates(subject, docs, seen);
246
266
  }
247
267
  /** Workspace boot docs using the snapshot's cwd/profile instead of a canvas
248
- * lookup. */
249
- export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId) {
268
+ * lookup. `seen` is the transcript-scoped dedup set shared with the on-read
269
+ * hook, so a front door delivered here never re-delivers on a later read
270
+ * beneath its store. */
271
+ export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId, seen = new Set()) {
250
272
  let docs;
251
273
  try {
252
274
  docs = listProjectMemoryDocs(cwd, profileId)
@@ -264,5 +286,5 @@ export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId) {
264
286
  const depth = aRoot.split(sep).filter(Boolean).length - bRoot.split(sep).filter(Boolean).length;
265
287
  return depth || aRoot.localeCompare(bRoot) || a.path.localeCompare(b.path);
266
288
  });
267
- return renderCandidates(subject, docs, new Set());
289
+ return renderCandidates(subject, docs, seen);
268
290
  }