@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.
- package/dist/build-root.js +10 -3
- package/dist/builtin-memory/internal/memory-loading.md +3 -1
- package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
- package/dist/clients/attach/__tests__/titled-editor-paste.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/titled-editor-paste.test.js +114 -0
- package/dist/clients/attach/__tests__/titled-editor-preview.test.js +4 -1
- package/dist/clients/attach/input/titled-editor.d.ts +18 -0
- package/dist/clients/attach/input/titled-editor.js +62 -0
- package/dist/clients/attach/render/chat-view.d.ts +3 -0
- package/dist/clients/attach/render/chat-view.js +15 -6
- package/dist/clients/attach/render/context-message.js +2 -2
- package/dist/clients/attach/render/group-activity.d.ts +18 -1
- package/dist/clients/attach/render/group-activity.js +54 -6
- package/dist/clients/attach/render/group-recap.js +13 -2
- package/dist/clients/attach/render/{markdown-headings.d.ts → markdown-source.d.ts} +4 -4
- package/dist/clients/attach/render/markdown-source.js +165 -0
- package/dist/clients/attach/session/bindings.js +5 -2
- package/dist/clients/attach/viewer.js +396 -394
- package/dist/clients/inbox/review/keys.js +2 -9
- package/dist/clients/inbox/tui/keys.js +2 -9
- package/dist/commands/cron.js +1 -1
- package/dist/commands/memory/delete.js +1 -1
- package/dist/commands/memory/find.js +1 -1
- package/dist/commands/memory/lint.js +28 -3
- package/dist/commands/memory/list.js +1 -1
- package/dist/commands/memory/origin.js +1 -1
- package/dist/commands/memory/read.js +4 -4
- package/dist/commands/memory/write.js +1 -1
- package/dist/commands/sys/settings-shell.d.ts +0 -3
- package/dist/commands/sys/settings-shell.js +1 -38
- package/dist/commands/sys/settings.js +2 -3
- package/dist/commands/sys/setup-core.d.ts +2 -17
- package/dist/commands/sys/setup-core.js +9 -33
- package/dist/commands/sys/setup-wizard.d.ts +1 -3
- package/dist/commands/sys/setup-wizard.js +9 -39
- package/dist/commands/sys/setup.js +6 -6
- package/dist/commands/sys/sync-project-guidance.js +62 -12
- package/dist/core/__tests__/fault-classifier.test.js +20 -0
- package/dist/core/__tests__/nested-store-discovery.test.d.ts +1 -0
- package/dist/core/__tests__/nested-store-discovery.test.js +89 -0
- package/dist/core/__tests__/on-read-nested-store.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-nested-store.test.js +117 -0
- package/dist/core/__tests__/plugin-link-symlinked-scope-root.test.d.ts +1 -0
- package/dist/core/__tests__/plugin-link-symlinked-scope-root.test.js +38 -0
- package/dist/core/__tests__/serial/tmux-surface.test.js +8 -5
- package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +34 -1
- package/dist/core/canvas/nav-model.d.ts +1 -8
- package/dist/core/canvas/nav-model.js +30 -59
- package/dist/core/canvas/nav-render.d.ts +2 -2
- package/dist/core/canvas/nav-render.js +8 -6
- package/dist/core/command.js +5 -3
- package/dist/core/fault-classifier.d.ts +1 -1
- package/dist/core/fault-classifier.js +7 -0
- package/dist/core/fs-utils.js +6 -1
- package/dist/core/keybindings/index.d.ts +1 -1
- package/dist/core/keybindings/index.js +1 -1
- package/dist/core/keybindings/match.d.ts +11 -0
- package/dist/core/keybindings/match.js +22 -0
- package/dist/core/memory/doc-link-grammar.js +3 -7
- package/dist/core/memory/inline-ref-guidance.js +1 -1
- package/dist/core/memory-resolver.d.ts +7 -1
- package/dist/core/memory-resolver.js +20 -7
- package/dist/core/nested-stores.d.ts +12 -0
- package/dist/core/nested-stores.js +146 -0
- package/dist/core/preview-registry.js +4 -2
- package/dist/core/runtime/bearings.d.ts +1 -1
- package/dist/core/runtime/bearings.js +2 -2
- package/dist/core/runtime/broker/fault-retry.js +23 -4
- package/dist/core/runtime/broker-extension-render.d.ts +1 -1
- package/dist/core/runtime/broker-extension-render.js +2 -2
- package/dist/core/runtime/broker-protocol.d.ts +1 -1
- package/dist/core/runtime/deliver-live.js +11 -1
- package/dist/core/runtime/fault.js +1 -1
- package/dist/core/runtime/tmux-bindings.js +42 -20
- package/dist/core/runtime/tool-group-summary.js +2 -6
- package/dist/core/substrate/injected-store.d.ts +6 -0
- package/dist/core/substrate/injected-store.js +19 -0
- package/dist/core/substrate/on-read-node.d.ts +4 -2
- package/dist/core/substrate/on-read-node.js +5 -3
- package/dist/core/substrate/on-read.d.ts +4 -2
- package/dist/core/substrate/on-read.js +27 -5
- package/dist/daemon/__tests__/subtree-filter-freshness.test.d.ts +1 -0
- package/dist/daemon/__tests__/subtree-filter-freshness.test.js +63 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +1 -1
- package/dist/pi-extensions/canvas-context-intro.js +14 -3
- package/dist/pi-extensions/canvas-doc-substrate.js +13 -2
- package/dist/shared/generated-context.d.ts +4 -0
- package/dist/shared/generated-context.js +20 -1
- package/dist/shared/tool-groups.d.ts +3 -3
- package/dist/shared/tool-groups.js +1 -4
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/scripts/postinstall.mjs +1 -1
- package/dist/clients/attach/render/markdown-headings.js +0 -93
- package/dist/commands/search/answer.d.ts +0 -1
- package/dist/commands/search/answer.js +0 -50
- package/dist/commands/search/contents.d.ts +0 -1
- package/dist/commands/search/contents.js +0 -96
- package/dist/commands/search/exa.d.ts +0 -53
- package/dist/commands/search/exa.js +0 -157
- package/dist/commands/search/puremd.d.ts +0 -11
- package/dist/commands/search/puremd.js +0 -53
- package/dist/commands/search/web.d.ts +0 -1
- package/dist/commands/search/web.js +0 -65
- package/dist/commands/search.d.ts +0 -2
- package/dist/commands/search.js +0 -24
- package/dist/commands/sys/panels/keys-panel.d.ts +0 -16
- 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
|
|
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',
|
|
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
|
-
|
|
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
|
-
? {
|
|
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,
|
|
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
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
187
|
+
shift ? 'S-' : '',
|
|
188
|
+
TMUX_NAMED_KEYS[character] ?? character,
|
|
179
189
|
].join('');
|
|
180
|
-
|
|
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(
|
|
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
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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,
|
|
289
|
+
return renderCandidates(subject, docs, seen);
|
|
268
290
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|