@dsh-electron/dsh-plugin-multi-root-workspace 0.1.0 → 0.1.2
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/CHANGELOG.en.md +89 -0
- package/CHANGELOG.md +89 -0
- package/README.en.md +96 -20
- package/README.md +97 -19
- package/cordis.patch.yml +22 -1
- package/lib/client.js +234 -71
- package/lib/client.js.map +1 -1
- package/lib/{command-BSkrYOAf.js → command-DyOWujPo.js} +24 -61
- package/lib/command.js +2 -2
- package/lib/compat-CRdv0vPw.js +272 -0
- package/lib/compat.js +2 -0
- package/lib/{fs-D7UoCb7T.js → fs-D_Sw3o5z.js} +7 -3
- package/lib/fs.js +1 -1
- package/lib/index.js +8 -6
- package/lib/instructions-Cn1Jj5Xi.js +703 -0
- package/lib/instructions.js +2 -0
- package/lib/registry-DySMbjgW.js +793 -0
- package/lib/registry-lease-win32-dy8TQMD9.js +93 -0
- package/lib/registry.js +2 -466
- package/lib/{sandbox-BhuMTDgH.js → sandbox-D8Fw95FM.js} +51 -4
- package/lib/sandbox.js +1 -1
- package/lib/scope.js +1 -1
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/client/panel-client.d.ts +0 -2
- package/lib/types/command.d.ts +17 -0
- package/lib/types/compat/agent-instructions.d.ts +138 -0
- package/lib/types/compat/client-icons.d.ts +40 -0
- package/lib/types/compat/client-session.d.ts +32 -0
- package/lib/types/compat/dsh-version.d.ts +119 -0
- package/lib/types/compat/llm-message.d.ts +72 -0
- package/lib/types/compat/sandbox-confine.d.ts +58 -0
- package/lib/types/compat/tool-result.d.ts +29 -0
- package/lib/types/compat.d.ts +85 -0
- package/lib/types/contract.d.ts +6 -8
- package/lib/types/index.d.ts +19 -5
- package/lib/types/instructions.d.ts +125 -0
- package/lib/types/registry-lease-win32.d.ts +29 -0
- package/lib/types/registry-lease.d.ts +62 -0
- package/lib/types/registry.d.ts +130 -19
- package/lib/types/roots.d.ts +1 -1
- package/lib/types/sandbox.d.ts +22 -2
- package/package.json +60 -13
- package/lib/{roots-DyT2jgEP.js → roots-DasWEBJk.js} +1 -1
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
import { l as isCanonicallyUnder } from "./roots-DasWEBJk.js";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import "@deepseek-ai/cordis";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { canonicalPath } from "@deepseek-ai/dsh-sandbox";
|
|
7
|
+
//#region src/compat/agent-instructions.ts
|
|
8
|
+
/**
|
|
9
|
+
* Compatibility adapter for upstream's instruction discovery and rendering.
|
|
10
|
+
*
|
|
11
|
+
* `@deepseek-ai/dsh-agent-instructions` renamed its renderer between two
|
|
12
|
+
* releases this plugin supports, without changing its signature:
|
|
13
|
+
*
|
|
14
|
+
* ```text
|
|
15
|
+
* 0.1.5-rc.2 renderWorkspaceContext(files, options) RenderedWorkspaceContext
|
|
16
|
+
* 0.1.6-alpha.1 renderAgentInstructions(files, options) RenderedAgentInstructions
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* `discoverBaselineInstructionFiles` kept both its name and its signature.
|
|
20
|
+
*
|
|
21
|
+
* All coupling to that package is concentrated here so the business layer
|
|
22
|
+
* (`src/instructions.ts`) only ever calls {@link renderInstructions} and
|
|
23
|
+
* {@link InstructionsApi.discover}. A future rename is one edit in this file,
|
|
24
|
+
* not a version check sprinkled through the provider.
|
|
25
|
+
*
|
|
26
|
+
* The export is picked by NAME rather than by comparing the release string:
|
|
27
|
+
* a structural probe also copes with upstream keeping both names for a release,
|
|
28
|
+
* or reshaping within one, which a version comparison does not.
|
|
29
|
+
*
|
|
30
|
+
* The package is loaded LAZILY, and its genuine ABSENCE is reported as absence
|
|
31
|
+
* rather than thrown. It is an optional peer: the default DSH bundle mounts it,
|
|
32
|
+
* but a minimal composition legitimately has neither it nor an agent, and in
|
|
33
|
+
* that case this plugin's instruction row must simply contribute nothing. A
|
|
34
|
+
* package that is present but cannot be evaluated is a different answer, and
|
|
35
|
+
* one this adapter reports instead of caching as absence.
|
|
36
|
+
*
|
|
37
|
+
* @module @dsh-electron/dsh-plugin-multi-root-workspace/compat/agent-instructions
|
|
38
|
+
*/
|
|
39
|
+
/** Raised when the package is present but carries neither renderer name. */
|
|
40
|
+
var DshInstructionApiError = class extends Error {
|
|
41
|
+
constructor(message) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "DshInstructionApiError";
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
/** Resolution is attempted once per process; `null` records a confirmed absence. */
|
|
47
|
+
let resolution;
|
|
48
|
+
/**
|
|
49
|
+
* Pick the renderer this release exports.
|
|
50
|
+
* @param module - the loaded upstream module.
|
|
51
|
+
* @returns the renderer.
|
|
52
|
+
* @throws {DshInstructionApiError} when neither name is present.
|
|
53
|
+
*/
|
|
54
|
+
function selectRenderer(module) {
|
|
55
|
+
const render = module.renderAgentInstructions ?? module.renderWorkspaceContext;
|
|
56
|
+
if (render === void 0) throw new DshInstructionApiError("multi-root workspace: @deepseek-ai/dsh-agent-instructions exports neither renderAgentInstructions (0.1.6 and later) nor renderWorkspaceContext (0.1.5); the additional roots' instruction files cannot be rendered. Adapt src/compat/agent-instructions.ts to the installed release.");
|
|
57
|
+
return render;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Adapt one loaded upstream module to {@link InstructionsApi}.
|
|
61
|
+
* @param module - the loaded upstream module.
|
|
62
|
+
* @returns the adapted surface.
|
|
63
|
+
* @throws {DshInstructionApiError} when a function this plugin needs is absent.
|
|
64
|
+
*/
|
|
65
|
+
function adaptInstructionsModule(module) {
|
|
66
|
+
const discover = module.discoverBaselineInstructionFiles;
|
|
67
|
+
if (discover === void 0) throw new DshInstructionApiError("multi-root workspace: @deepseek-ai/dsh-agent-instructions does not export discoverBaselineInstructionFiles; adapt src/compat/agent-instructions.ts to the installed release.");
|
|
68
|
+
return {
|
|
69
|
+
discover: (options) => discover(options),
|
|
70
|
+
render: selectRenderer(module)
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether one package is installed at all, judged WITHOUT evaluating it.
|
|
75
|
+
*
|
|
76
|
+
* This is what separates "the optional peer is absent" from "the optional peer
|
|
77
|
+
* is installed but its evaluation fails" (a missing transitive dependency, a
|
|
78
|
+
* broken build). Only a genuine resolvability failure counts as absence: a
|
|
79
|
+
* resolution that fails for another reason (an `exports` map with no `require`
|
|
80
|
+
* condition, say) leaves the verdict to the `import()` below, which is the
|
|
81
|
+
* authority on whether the module can actually be loaded.
|
|
82
|
+
* @param specifier - the bare package specifier to probe.
|
|
83
|
+
* @returns whether the package resolves from this module.
|
|
84
|
+
*/
|
|
85
|
+
function isPackageInstalled(specifier) {
|
|
86
|
+
try {
|
|
87
|
+
createRequire(import.meta.url).resolve(specifier);
|
|
88
|
+
return true;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const code = error?.code;
|
|
91
|
+
return code !== "MODULE_NOT_FOUND" && code !== "ERR_MODULE_NOT_FOUND";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Load and adapt the upstream instruction surface, once.
|
|
96
|
+
*
|
|
97
|
+
* @returns the adapted surface, or `undefined` when the package is not
|
|
98
|
+
* installed. A package that IS installed but exports neither renderer throws
|
|
99
|
+
* instead: that is a compatibility break to fix, not an optional seam. So
|
|
100
|
+
* does one that is installed but cannot be evaluated at all — a missing
|
|
101
|
+
* transitive dependency, a broken build — because reporting that as absence
|
|
102
|
+
* would drop every additional root's instructions for the rest of the
|
|
103
|
+
* process. See {@link isPackageInstalled} for how the two are told apart.
|
|
104
|
+
*/
|
|
105
|
+
async function instructionsApi() {
|
|
106
|
+
resolution ??= (async () => {
|
|
107
|
+
if (!isPackageInstalled("@deepseek-ai/dsh-agent-instructions")) return null;
|
|
108
|
+
return adaptInstructionsModule(await import("@deepseek-ai/dsh-agent-instructions"));
|
|
109
|
+
})();
|
|
110
|
+
return await resolution ?? void 0;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Render loaded instruction files within a byte budget, through whichever
|
|
114
|
+
* renderer the installed release exports.
|
|
115
|
+
*
|
|
116
|
+
* This is the only rendering entry point the business layer uses.
|
|
117
|
+
* @param files - loaded files, ordered broadest to most specific.
|
|
118
|
+
* @param options - the byte budget.
|
|
119
|
+
* @returns the rendered text and its budget diagnostics.
|
|
120
|
+
* @throws {DshInstructionApiError} when no usable renderer is installed.
|
|
121
|
+
*/
|
|
122
|
+
async function renderInstructions(files, options) {
|
|
123
|
+
const api = await instructionsApi();
|
|
124
|
+
if (api === void 0) throw new DshInstructionApiError("multi-root workspace: @deepseek-ai/dsh-agent-instructions is not installed, so instruction files cannot be rendered. Check for its presence with instructionsApi() before calling renderInstructions().");
|
|
125
|
+
return api.render(files, options);
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/compat/llm-message.ts
|
|
129
|
+
/**
|
|
130
|
+
* This plugin's own producer kind.
|
|
131
|
+
*
|
|
132
|
+
* Session format 4 rejects the shared `kind: 'plugin'` (`format v4 message
|
|
133
|
+
* requires a producer-owned source kind`). The kind is ours, not
|
|
134
|
+
* `agent-instructions`: upstream treats that kind's `changes` array as its
|
|
135
|
+
* own reconciliation authority.
|
|
136
|
+
*/
|
|
137
|
+
const PRODUCER_SOURCE_KIND = "multi-root-workspace";
|
|
138
|
+
/**
|
|
139
|
+
* The first session format whose encoder refuses `kind: 'plugin'`.
|
|
140
|
+
* Earlier formats declare that kind on `MessageSourceMap` and require it.
|
|
141
|
+
*/
|
|
142
|
+
const PRODUCER_OWNED_FORMAT = 4;
|
|
143
|
+
/**
|
|
144
|
+
* Which source kind the installed session format will persist.
|
|
145
|
+
* @param formatVersion - `SESSION_FORMAT_VERSION`, when the session package is installed.
|
|
146
|
+
* @returns `plugin` through format 3, and this plugin's own kind from format 4 on.
|
|
147
|
+
*/
|
|
148
|
+
function instructionSourceKind(formatVersion) {
|
|
149
|
+
return formatVersion !== void 0 && formatVersion >= PRODUCER_OWNED_FORMAT ? PRODUCER_SOURCE_KIND : "plugin";
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The installed session format, when that package is present.
|
|
153
|
+
*
|
|
154
|
+
* A composition that never persists a session can omit the package; format 3
|
|
155
|
+
* is then the spelling that still declares `kind: 'plugin'`. Any other failure
|
|
156
|
+
* is a broken install and must surface.
|
|
157
|
+
* @returns the format version, or `undefined` when the package is not installed.
|
|
158
|
+
*/
|
|
159
|
+
async function installedFormatVersion() {
|
|
160
|
+
try {
|
|
161
|
+
const session = await import("@deepseek-ai/dsh-session");
|
|
162
|
+
return typeof session.SESSION_FORMAT_VERSION === "number" ? session.SESSION_FORMAT_VERSION : void 0;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const code = error.code;
|
|
165
|
+
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") return void 0;
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Build one user-role instruction message.
|
|
171
|
+
*
|
|
172
|
+
* Format 3 and earlier: `{ kind: 'plugin', plugin, form: 'instructions' }`.
|
|
173
|
+
* Format 4 and later: `{ kind: 'multi-root-workspace', plugin, form: 'instructions' }`.
|
|
174
|
+
* @param input - the body and the plugin attribution.
|
|
175
|
+
* @returns the frozen message upstream's helper produced.
|
|
176
|
+
* @throws {Error} when the optional peer is absent: a step that has instruction
|
|
177
|
+
* text to deliver cannot be served without it, and staying silent would drop
|
|
178
|
+
* user-visible context instead of reporting a misconfigured composition.
|
|
179
|
+
*/
|
|
180
|
+
async function createInstructionMessage(input) {
|
|
181
|
+
let module;
|
|
182
|
+
try {
|
|
183
|
+
module = await import("@deepseek-ai/dsh-llm");
|
|
184
|
+
} catch (error) {
|
|
185
|
+
throw new Error(`multi-root workspace: @deepseek-ai/dsh-llm is not installed, so additional roots' instruction files cannot be delivered as messages (${error instanceof Error ? error.message : String(error)}).`);
|
|
186
|
+
}
|
|
187
|
+
const create = module.createUserMessage;
|
|
188
|
+
return create({
|
|
189
|
+
content: [{
|
|
190
|
+
type: "text",
|
|
191
|
+
text: input.text
|
|
192
|
+
}],
|
|
193
|
+
source: instructionSource(input.plugin, await installedFormatVersion())
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/** The source object the installed format will persist. */
|
|
197
|
+
function instructionSource(plugin, formatVersion) {
|
|
198
|
+
const kind = instructionSourceKind(formatVersion);
|
|
199
|
+
if (kind === "plugin") return {
|
|
200
|
+
kind,
|
|
201
|
+
plugin,
|
|
202
|
+
form: "instructions"
|
|
203
|
+
};
|
|
204
|
+
return {
|
|
205
|
+
kind,
|
|
206
|
+
plugin,
|
|
207
|
+
form: "instructions"
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region src/compat/tool-result.ts
|
|
212
|
+
/**
|
|
213
|
+
* Whether a tool-result message reports failure, on every supported release.
|
|
214
|
+
* @param message - the `tool/result` message from the session log.
|
|
215
|
+
* @returns `true` when either spelling says the call failed.
|
|
216
|
+
*/
|
|
217
|
+
function toolResultFailed(message) {
|
|
218
|
+
if (message.isError === true) return true;
|
|
219
|
+
const block = message.content?.[0];
|
|
220
|
+
if (typeof block !== "object" || block === null || !("isError" in block)) return false;
|
|
221
|
+
return block.isError === true;
|
|
222
|
+
}
|
|
223
|
+
//#endregion
|
|
224
|
+
//#region src/instructions.ts
|
|
225
|
+
/**
|
|
226
|
+
* `multi-root-instructions`: the additional roots' own instruction files, made
|
|
227
|
+
* visible to the model.
|
|
228
|
+
*
|
|
229
|
+
* Upstream `agent-instructions` discovers instruction files by walking UP from
|
|
230
|
+
* the session cwd (plus the one user-global file). In a multi-root workspace
|
|
231
|
+
* that walk never reaches an additional root, so `repo-b/AGENTS.md` is
|
|
232
|
+
* invisible to a model that is nevertheless allowed to write `repo-b`. This row
|
|
233
|
+
* closes exactly that gap, and nothing else: the primary root's chain — nested
|
|
234
|
+
* files included — and the user-global file stay upstream's business.
|
|
235
|
+
*
|
|
236
|
+
* Four deliberate choices:
|
|
237
|
+
*
|
|
238
|
+
* 1. **Not a system-prompt contribution.** Instruction text is
|
|
239
|
+
* producer-supplied context, not system authority, so it enters as a
|
|
240
|
+
* user-role message tagged `form: 'instructions'`. Through session format 3
|
|
241
|
+
* the source kind is `plugin`; format 4 retired that shared kind, so the
|
|
242
|
+
* adapter persists this plugin's own (`multi-root-workspace`). It is also
|
|
243
|
+
* the only channel that is logged, and model-visible content must be logged.
|
|
244
|
+
* 2. **Injected from `agent/pre-step`, not a session lifecycle event.**
|
|
245
|
+
* `pre-step` is an awaited waterfall, so discovery, reading and rendering all
|
|
246
|
+
* complete BEFORE the step it feeds — deterministically, on the first step.
|
|
247
|
+
* A synchronous prompt callback could not await them, and an emit-mode
|
|
248
|
+
* lifecycle listener would race the first step. It is also the one hook whose
|
|
249
|
+
* shape is identical across every supported release, which keeps the
|
|
250
|
+
* instruction feature out of the compatibility matrix (ADR-0009).
|
|
251
|
+
* 3. **Discovery is pinned to the root.** `projectRoot` is the additional root,
|
|
252
|
+
* so the walk can never leave it, and every candidate is then required to be
|
|
253
|
+
* canonically INSIDE that root. That is what keeps `$DSH_HOME/AGENTS.md`, the
|
|
254
|
+
* primary root's `AGENTS.md`, and any ancestor's file from being injected a
|
|
255
|
+
* second time by this plugin.
|
|
256
|
+
* 4. **Nested files are driven by the session's own tool activity.** A
|
|
257
|
+
* subdirectory's `AGENTS.md` only matters once the session has worked in that
|
|
258
|
+
* subdirectory, so the directories worth examining are derived from the
|
|
259
|
+
* session's persisted events: `tool/call` and `tool/result` are paired, and a
|
|
260
|
+
* successful `read`/`write`/`edit` makes the path from the root down to the
|
|
261
|
+
* touched file's directory relevant (ADR-0010). A file that disappears is
|
|
262
|
+
* retracted explicitly, because the text it contributed earlier is still in
|
|
263
|
+
* the conversation and silence does not withdraw it.
|
|
264
|
+
*
|
|
265
|
+
* The byte budget is shared: `maxBytes` bounds the WHOLE additional-root
|
|
266
|
+
* snapshot, not each root, because ten roots must not quietly cost ten budgets
|
|
267
|
+
* of context. Roots consume it in scope order. Retractions are exempt, because a
|
|
268
|
+
* truncated withdrawal is worse than a slightly over-budget message.
|
|
269
|
+
*
|
|
270
|
+
* @module @dsh-electron/dsh-plugin-multi-root-workspace/instructions
|
|
271
|
+
*/
|
|
272
|
+
/** The `plugin` attribution every message this row produces carries. */
|
|
273
|
+
const PLUGIN_SOURCE = "@dsh-electron/dsh-plugin-multi-root-workspace";
|
|
274
|
+
/**
|
|
275
|
+
* Total UTF-8 byte budget for the additional-root snapshot, matching the
|
|
276
|
+
* default the upstream `agent-instructions` row is composed with so that one
|
|
277
|
+
* extra workspace root costs at most as much as the primary one does.
|
|
278
|
+
*/
|
|
279
|
+
const DEFAULT_MAX_BYTES = 65536;
|
|
280
|
+
/** Largest instruction file this row will read; larger files are ignored. */
|
|
281
|
+
const DEFAULT_MAX_SOURCE_BYTES = 32768;
|
|
282
|
+
/** Tools whose SUCCESS makes the directories on the path to the file relevant. */
|
|
283
|
+
const FILE_TOUCH_TOOL_NAMES = {
|
|
284
|
+
read: true,
|
|
285
|
+
write: true,
|
|
286
|
+
edit: true
|
|
287
|
+
};
|
|
288
|
+
/** Cap on unanswered `tool/call` records remembered per session. */
|
|
289
|
+
const MAX_PENDING_CALLS = 128;
|
|
290
|
+
/** Cap on touched paths remembered per session; beyond it, touches are dropped. */
|
|
291
|
+
const MAX_TOUCHED_PATHS = 64;
|
|
292
|
+
/** Cap on delivered scopes remembered per session; beyond it, nested scopes stop. */
|
|
293
|
+
const MAX_DELIVERED_SCOPES = 512;
|
|
294
|
+
/** Digest one file's content. Content-addressed, so identical content never re-sends. */
|
|
295
|
+
function digestOf(text) {
|
|
296
|
+
return createHash("sha256").update(text).digest("hex");
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The scope identity of one candidate instruction file.
|
|
300
|
+
*
|
|
301
|
+
* NUL-joined, so two additional roots carrying the same relative
|
|
302
|
+
* `AGENTS.md` can never collide on one key.
|
|
303
|
+
* @param root - the canonical additional root.
|
|
304
|
+
* @param relativeDirectory - the file's directory, relative to the root, `.` for the root itself.
|
|
305
|
+
* @param fileName - the instruction file's name.
|
|
306
|
+
* @returns the scope key.
|
|
307
|
+
*/
|
|
308
|
+
function scopeKey(root, relativeDirectory, fileName) {
|
|
309
|
+
return `${root}\0${relativeDirectory}\0${fileName}`;
|
|
310
|
+
}
|
|
311
|
+
/** A directory's location within its root, spelled `.` for the root itself. */
|
|
312
|
+
function relativeDirectoryOf(root, directory) {
|
|
313
|
+
const suffix = relative(root, directory);
|
|
314
|
+
return suffix.length === 0 ? "." : suffix;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The path one RAW tool call touched.
|
|
318
|
+
*
|
|
319
|
+
* `arguments` is the model-produced JSON string, so the path is only known after
|
|
320
|
+
* parsing it; a call that names no `file_path`, or is not one of the tools whose
|
|
321
|
+
* success makes a directory relevant, touched nothing this row cares about.
|
|
322
|
+
* @param name - the tool name the model called.
|
|
323
|
+
* @param argumentsJson - the tool call's raw arguments, exactly as produced.
|
|
324
|
+
* @returns the trimmed absolute-or-relative path, or `undefined`.
|
|
325
|
+
*/
|
|
326
|
+
function touchedPathOfToolCall(name, argumentsJson) {
|
|
327
|
+
if (FILE_TOUCH_TOOL_NAMES[name] !== true) return void 0;
|
|
328
|
+
let parsed;
|
|
329
|
+
try {
|
|
330
|
+
parsed = JSON.parse(argumentsJson);
|
|
331
|
+
} catch {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
335
|
+
if (!("file_path" in parsed)) return void 0;
|
|
336
|
+
const path = parsed.file_path;
|
|
337
|
+
if (typeof path !== "string") return void 0;
|
|
338
|
+
const trimmed = path.trim();
|
|
339
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* The directory a touched path's instructions would live in.
|
|
343
|
+
*
|
|
344
|
+
* Tool arguments are the model's raw input, so a relative `file_path` is
|
|
345
|
+
* resolved against the session cwd — the base the tool layer itself uses —
|
|
346
|
+
* rather than against this process's cwd. Mac's `realpath` spelling is applied
|
|
347
|
+
* here so the result compares against the canonical roots.
|
|
348
|
+
* @param path - a touched path, absolute or relative to the session cwd.
|
|
349
|
+
* @param session - the session the touch belongs to.
|
|
350
|
+
* @returns the canonical parent directory, or `undefined` when it cannot be named.
|
|
351
|
+
*/
|
|
352
|
+
function parentDirectoryOf(path, session) {
|
|
353
|
+
const cwd = session.header.cwd;
|
|
354
|
+
const absolute = isAbsolute(path) ? path : cwd === void 0 ? void 0 : resolve(cwd, path);
|
|
355
|
+
if (absolute === void 0) return void 0;
|
|
356
|
+
try {
|
|
357
|
+
return canonicalPath(dirname(absolute));
|
|
358
|
+
} catch {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/** Cap warnings are emitted once per process; the cap itself is not user-facing. */
|
|
363
|
+
let scopeCapWarned = false;
|
|
364
|
+
/**
|
|
365
|
+
* Compose the model-facing message body for one batch of changes.
|
|
366
|
+
*
|
|
367
|
+
* All three halves matter. A newly visible root — or a newly touched
|
|
368
|
+
* subdirectory — needs its rules; a root that left needs an explicit
|
|
369
|
+
* revocation; and a file that disappeared needs an explicit withdrawal, because
|
|
370
|
+
* the instructions it contributed are still sitting in the conversation history
|
|
371
|
+
* and would otherwise keep applying forever. Silence is not a retraction.
|
|
372
|
+
* @param rendered - roots whose instruction text is new or changed.
|
|
373
|
+
* @param revoked - canonical roots that left the workspace scope.
|
|
374
|
+
* @param removed - instruction files that are no longer present.
|
|
375
|
+
* @returns the message text, or `undefined` when there is nothing to say.
|
|
376
|
+
*/
|
|
377
|
+
function composeInstructionMessage(rendered, revoked, removed) {
|
|
378
|
+
if (rendered.length === 0 && revoked.length === 0 && removed.length === 0) return void 0;
|
|
379
|
+
const sections = ["Additional workspace root instructions. The directories below are additional roots of this session's workspace; the instruction files found at their top level, and in the subdirectories this session has worked in, follow, and they apply to work in those directories exactly as this workspace's own instructions apply to the session root."];
|
|
380
|
+
for (const entry of rendered) sections.push(`Instructions for additional workspace root ${entry.root}:\n\n${entry.text}`);
|
|
381
|
+
for (const root of revoked) sections.push(`The directory ${root} is no longer an additional root of this session's workspace. Any instructions previously supplied for it in this conversation no longer apply, and it is no longer writable here.`);
|
|
382
|
+
for (const scope of [...removed].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0)) sections.push(`The instruction file ${scope.path} is no longer present in the additional workspace root ${scope.root}, so the instructions it supplied earlier no longer apply.`);
|
|
383
|
+
return sections.join("\n\n");
|
|
384
|
+
}
|
|
385
|
+
/** This row is gated on the compatibility contract like every other provider. */
|
|
386
|
+
const inject = [
|
|
387
|
+
"multiRootCompat",
|
|
388
|
+
"multiRootScope",
|
|
389
|
+
"sandboxPolicy"
|
|
390
|
+
];
|
|
391
|
+
/**
|
|
392
|
+
* Mount the additional-root instruction provider.
|
|
393
|
+
*
|
|
394
|
+
* `fs` is a SOFT dependency, like the scope service's prompt contribution: a
|
|
395
|
+
* composition with no filesystem seam (headless probes, bare test contexts)
|
|
396
|
+
* contributes nothing instead of failing to load. The same is true of the
|
|
397
|
+
* upstream instruction package, which is an optional peer.
|
|
398
|
+
*
|
|
399
|
+
* Touch recording is registered OUTSIDE the `fs` injection, so a composition
|
|
400
|
+
* without a filesystem seam still learns which directories the session worked
|
|
401
|
+
* in; delivery is what needs the seam.
|
|
402
|
+
* @param ctx - the row's context.
|
|
403
|
+
* @param config - the row's configuration.
|
|
404
|
+
*/
|
|
405
|
+
function apply(ctx, config = {}) {
|
|
406
|
+
const maxBytes = config.maxBytes ?? 65536;
|
|
407
|
+
if (maxBytes <= 0 || !Number.isFinite(maxBytes)) return;
|
|
408
|
+
const states = /* @__PURE__ */ new WeakMap();
|
|
409
|
+
const callsBySession = /* @__PURE__ */ new WeakMap();
|
|
410
|
+
const stateOf = (session) => {
|
|
411
|
+
const existing = states.get(session);
|
|
412
|
+
if (existing !== void 0) return existing;
|
|
413
|
+
const created = {
|
|
414
|
+
scopes: /* @__PURE__ */ new Map(),
|
|
415
|
+
touched: /* @__PURE__ */ new Set()
|
|
416
|
+
};
|
|
417
|
+
states.set(session, created);
|
|
418
|
+
return created;
|
|
419
|
+
};
|
|
420
|
+
ctx.on("session/event", (session, event) => {
|
|
421
|
+
const pendingCalls = callsBySession.get(session) ?? /* @__PURE__ */ new Map();
|
|
422
|
+
callsBySession.set(session, pendingCalls);
|
|
423
|
+
if (event.type === "tool/call") {
|
|
424
|
+
if (pendingCalls.size >= MAX_PENDING_CALLS) {
|
|
425
|
+
const oldest = pendingCalls.keys().next();
|
|
426
|
+
if (!oldest.done) pendingCalls.delete(oldest.value);
|
|
427
|
+
}
|
|
428
|
+
pendingCalls.set(event.data.callId, {
|
|
429
|
+
name: event.data.name,
|
|
430
|
+
arguments: event.data.arguments
|
|
431
|
+
});
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (event.type !== "tool/result") return;
|
|
435
|
+
const callId = event.data.message.source.callId;
|
|
436
|
+
const call = pendingCalls.get(callId);
|
|
437
|
+
pendingCalls.delete(callId);
|
|
438
|
+
if (call === void 0) return;
|
|
439
|
+
if (event.data.error !== void 0 || toolResultFailed(event.data.message)) return;
|
|
440
|
+
const path = touchedPathOfToolCall(call.name, call.arguments);
|
|
441
|
+
if (path === void 0) return;
|
|
442
|
+
const state = stateOf(session);
|
|
443
|
+
if (state.touched.size >= MAX_TOUCHED_PATHS) return;
|
|
444
|
+
state.touched.add(path);
|
|
445
|
+
});
|
|
446
|
+
ctx.inject(["fs"], (scope) => {
|
|
447
|
+
scope.on("agent/pre-step", async ({ agent, messages, signal }, next) => {
|
|
448
|
+
const decision = await next();
|
|
449
|
+
if (decision.kind !== "enter") return decision;
|
|
450
|
+
const seeded = await pending(scope, states, agent, config, maxBytes, signal);
|
|
451
|
+
if (seeded === void 0) return decision;
|
|
452
|
+
signal.throwIfAborted();
|
|
453
|
+
const lastClaimed = decision.messages.findLastIndex((message) => messages.includes(message));
|
|
454
|
+
return {
|
|
455
|
+
...decision,
|
|
456
|
+
messages: decision.messages.toSpliced(lastClaimed + 1, 0, seeded)
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Compute the message this step should carry, if any.
|
|
463
|
+
*
|
|
464
|
+
* Returns `undefined` — the steady state — whenever every delivered file is
|
|
465
|
+
* still current, so a long session does not re-send the same text on every step.
|
|
466
|
+
* @param ctx - the injected scope, which owns `fs` and the services.
|
|
467
|
+
* @param states - per-session delivery state.
|
|
468
|
+
* @param agent - the agent whose step this is.
|
|
469
|
+
* @param config - the row's configuration.
|
|
470
|
+
* @param maxBytes - the total budget for all additional roots.
|
|
471
|
+
* @param signal - the turn's cancellation signal.
|
|
472
|
+
* @returns the message to inject, or `undefined`.
|
|
473
|
+
*/
|
|
474
|
+
async function pending(ctx, states, agent, config, maxBytes, signal) {
|
|
475
|
+
const session = agent.session;
|
|
476
|
+
const state = states.get(session) ?? {
|
|
477
|
+
scopes: /* @__PURE__ */ new Map(),
|
|
478
|
+
touched: /* @__PURE__ */ new Set()
|
|
479
|
+
};
|
|
480
|
+
states.set(session, state);
|
|
481
|
+
const evaluatedTouches = new Set(state.touched);
|
|
482
|
+
let retryTouches = false;
|
|
483
|
+
const scope = ctx.multiRootScope.resolve(ctx.sandboxPolicy.resolve({ session }));
|
|
484
|
+
const granted = new Set(scope.additionalRoots);
|
|
485
|
+
const revoked = [...new Set([...state.scopes.values()].map((entry) => entry.root))].filter((root) => !granted.has(root));
|
|
486
|
+
const rendered = [];
|
|
487
|
+
const gone = [];
|
|
488
|
+
const updates = [];
|
|
489
|
+
if (scope.additionalRoots.length > 0) {
|
|
490
|
+
const api = await instructionsApi();
|
|
491
|
+
if (api !== void 0) {
|
|
492
|
+
let remaining = maxBytes;
|
|
493
|
+
for (const root of scope.additionalRoots) {
|
|
494
|
+
signal.throwIfAborted();
|
|
495
|
+
const plan = await planRoot(ctx, api, root, config, state, session, signal);
|
|
496
|
+
gone.push(...plan.gone);
|
|
497
|
+
if (plan.deliveries.length === 0) continue;
|
|
498
|
+
if (remaining <= 0) {
|
|
499
|
+
retryTouches = true;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const { text, omitted, truncated } = api.render(plan.deliveries.map((entry) => entry.file), { maxBytes: remaining });
|
|
503
|
+
const omittedPaths = new Set(omitted.map((file) => file.absolutePath));
|
|
504
|
+
const truncatedPaths = new Set(truncated.map((file) => file.displayPath));
|
|
505
|
+
if (omitted.length > 0 || truncated.length > 0 || text === "") retryTouches = true;
|
|
506
|
+
if (text === "") continue;
|
|
507
|
+
remaining -= Buffer.byteLength(text, "utf8");
|
|
508
|
+
rendered.push({
|
|
509
|
+
root,
|
|
510
|
+
text
|
|
511
|
+
});
|
|
512
|
+
for (const entry of plan.deliveries) {
|
|
513
|
+
if (omittedPaths.has(entry.file.absolutePath)) continue;
|
|
514
|
+
updates.push({
|
|
515
|
+
key: entry.key,
|
|
516
|
+
scope: {
|
|
517
|
+
root,
|
|
518
|
+
path: entry.file.absolutePath,
|
|
519
|
+
digest: truncatedPaths.has(entry.file.displayPath) ? void 0 : digestOf(entry.file.content)
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const text = composeInstructionMessage(rendered, revoked, gone.map((entry) => entry.scope));
|
|
527
|
+
const message = text === void 0 ? void 0 : await createInstructionMessage({
|
|
528
|
+
text,
|
|
529
|
+
plugin: PLUGIN_SOURCE
|
|
530
|
+
});
|
|
531
|
+
signal.throwIfAborted();
|
|
532
|
+
if (!retryTouches) for (const touch of evaluatedTouches) state.touched.delete(touch);
|
|
533
|
+
if (text !== void 0) {
|
|
534
|
+
for (const root of revoked) for (const [key, entry] of state.scopes) if (entry.root === root) state.scopes.delete(key);
|
|
535
|
+
for (const entry of gone) state.scopes.delete(entry.key);
|
|
536
|
+
for (const entry of updates) state.scopes.set(entry.key, entry.scope);
|
|
537
|
+
}
|
|
538
|
+
return message;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Plan one additional root's delivery for this step.
|
|
542
|
+
*
|
|
543
|
+
* Which directories are worth walking is the whole design of phase 2, and the
|
|
544
|
+
* set is exactly three things: the root itself (top-level files, as in phase 1,
|
|
545
|
+
* so a change or a removal there is noticed every step), every directory a
|
|
546
|
+
* nested file was already delivered from (so a change or a removal is noticed
|
|
547
|
+
* without a new touch, like upstream's own reconcile), and the parent directory
|
|
548
|
+
* of every path the session has touched since the last evaluation — plus each
|
|
549
|
+
* of that directory's ancestors up to the root, because discovery reports an
|
|
550
|
+
* intermediate directory's file but only the exact directory's candidates
|
|
551
|
+
* survive the filter below.
|
|
552
|
+
* @param ctx - the row's context, for `fs` and the logger.
|
|
553
|
+
* @param api - the adapted upstream instruction surface.
|
|
554
|
+
* @param root - the canonical additional root.
|
|
555
|
+
* @param config - the row's configuration.
|
|
556
|
+
* @param state - the session's delivery state.
|
|
557
|
+
* @param session - the session, for the cwd a relative touch resolves against.
|
|
558
|
+
* @param signal - the turn's cancellation signal.
|
|
559
|
+
* @returns the files to send and the deliveries to withdraw.
|
|
560
|
+
*/
|
|
561
|
+
async function planRoot(ctx, api, root, config, state, session, signal) {
|
|
562
|
+
const delivered = /* @__PURE__ */ new Map();
|
|
563
|
+
for (const [key, entry] of state.scopes) if (entry.root === root) delivered.set(key, entry);
|
|
564
|
+
const dirs = /* @__PURE__ */ new Set([root]);
|
|
565
|
+
for (const entry of delivered.values()) {
|
|
566
|
+
const dir = dirname(entry.path);
|
|
567
|
+
if (dir !== root) dirs.add(dir);
|
|
568
|
+
}
|
|
569
|
+
for (const touched of state.touched) {
|
|
570
|
+
const parent = parentDirectoryOf(touched, session);
|
|
571
|
+
if (parent === void 0 || !isCanonicallyUnder(parent, root)) continue;
|
|
572
|
+
let dir = parent;
|
|
573
|
+
for (;;) {
|
|
574
|
+
dirs.add(dir);
|
|
575
|
+
if (dir === root) break;
|
|
576
|
+
const up = dirname(dir);
|
|
577
|
+
if (up === dir) break;
|
|
578
|
+
dir = up;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const walks = [];
|
|
582
|
+
for (const dir of dirs) {
|
|
583
|
+
signal.throwIfAborted();
|
|
584
|
+
walks.push({
|
|
585
|
+
relativeDirectory: relativeDirectoryOf(root, dir),
|
|
586
|
+
read: await loadDirectory(ctx.fs, api, root, config, signal, dir)
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
walks.sort((left, right) => left.relativeDirectory.split(sep).length - right.relativeDirectory.split(sep).length || (left.relativeDirectory < right.relativeDirectory ? -1 : left.relativeDirectory > right.relativeDirectory ? 1 : 0));
|
|
590
|
+
const deliveries = [];
|
|
591
|
+
const gone = [];
|
|
592
|
+
for (const walk of walks) {
|
|
593
|
+
if (!walk.read.probed) continue;
|
|
594
|
+
const present = /* @__PURE__ */ new Set();
|
|
595
|
+
for (const candidate of walk.read.candidates) {
|
|
596
|
+
if (relativeDirectoryOf(root, dirname(candidate.absolutePath)) !== walk.relativeDirectory) continue;
|
|
597
|
+
present.add(basename(candidate.absolutePath));
|
|
598
|
+
}
|
|
599
|
+
for (const file of walk.read.files) {
|
|
600
|
+
if (relativeDirectoryOf(root, dirname(file.absolutePath)) !== walk.relativeDirectory) continue;
|
|
601
|
+
const key = scopeKey(root, walk.relativeDirectory, basename(file.absolutePath));
|
|
602
|
+
const previous = delivered.get(key);
|
|
603
|
+
if (previous !== void 0 && previous.digest === digestOf(file.content)) continue;
|
|
604
|
+
if (previous === void 0 && walk.relativeDirectory !== ".") {
|
|
605
|
+
if (state.scopes.size >= MAX_DELIVERED_SCOPES) {
|
|
606
|
+
if (!scopeCapWarned) {
|
|
607
|
+
scopeCapWarned = true;
|
|
608
|
+
ctx.logger.warn("multi-root workspace: additional-root instruction scopes capped at %d", MAX_DELIVERED_SCOPES);
|
|
609
|
+
}
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
deliveries.push({
|
|
614
|
+
key,
|
|
615
|
+
file
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
for (const [key, entry] of delivered) {
|
|
619
|
+
if (relativeDirectoryOf(root, dirname(entry.path)) !== walk.relativeDirectory) continue;
|
|
620
|
+
if (present.has(basename(entry.path))) continue;
|
|
621
|
+
gone.push({
|
|
622
|
+
key,
|
|
623
|
+
scope: {
|
|
624
|
+
root,
|
|
625
|
+
path: entry.path
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
deliveries,
|
|
632
|
+
gone
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Walk from `cwd` up to `root` and read the instruction candidates it reports.
|
|
637
|
+
*
|
|
638
|
+
* Two filters are applied to what the walk reports, and both are load-bearing:
|
|
639
|
+
*
|
|
640
|
+
* - **canonical containment** drops every candidate that is not inside the root
|
|
641
|
+
* itself, which is what excludes the user-global file and any ancestor's;
|
|
642
|
+
* - **the source cap** skips a file too large to be instructions, using `stat`
|
|
643
|
+
* so an oversized file is never read at all.
|
|
644
|
+
*
|
|
645
|
+
* The pre-filter candidates are returned alongside the loaded ones: they are how
|
|
646
|
+
* a caller tells "this file is gone" from "this file could not be read".
|
|
647
|
+
*
|
|
648
|
+
* `displayPath` is rewritten to the absolute path on purpose: discovery
|
|
649
|
+
* displays candidates relative to `projectRoot`, so every root's file would
|
|
650
|
+
* otherwise render as the same ambiguous `AGENTS.md`, and the absolute spelling
|
|
651
|
+
* is also the one the model must use in a tool call against a directory outside
|
|
652
|
+
* the session root.
|
|
653
|
+
* @param fs - the filesystem provider to read through.
|
|
654
|
+
* @param api - the adapted upstream instruction surface.
|
|
655
|
+
* @param root - the canonical additional root the walk stops at.
|
|
656
|
+
* @param config - the row's configuration.
|
|
657
|
+
* @param signal - the turn's cancellation signal.
|
|
658
|
+
* @param cwd - the directory the walk starts from; always inside `root`.
|
|
659
|
+
* @returns the directory read.
|
|
660
|
+
*/
|
|
661
|
+
async function loadDirectory(fs, api, root, config, signal, cwd) {
|
|
662
|
+
const maxSourceBytes = config.maxSourceBytes ?? 32768;
|
|
663
|
+
let candidates;
|
|
664
|
+
try {
|
|
665
|
+
candidates = await api.discover({
|
|
666
|
+
cwd,
|
|
667
|
+
projectRoot: root,
|
|
668
|
+
signal,
|
|
669
|
+
...config.instructionFileCandidates === void 0 ? {} : { instructionFileCandidates: config.instructionFileCandidates },
|
|
670
|
+
...config.localInstructionFileCandidates === void 0 ? {} : { localInstructionFileCandidates: config.localInstructionFileCandidates }
|
|
671
|
+
});
|
|
672
|
+
} catch {
|
|
673
|
+
return {
|
|
674
|
+
candidates: [],
|
|
675
|
+
files: [],
|
|
676
|
+
probed: false
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
const files = [];
|
|
680
|
+
for (const candidate of candidates) {
|
|
681
|
+
if (!isCanonicallyUnder(canonicalPath(candidate.absolutePath), root)) continue;
|
|
682
|
+
try {
|
|
683
|
+
const target = await fs.resolve(candidate.absolutePath);
|
|
684
|
+
const info = await fs.stat(target, signal);
|
|
685
|
+
if (info === void 0 || info.type !== "file") continue;
|
|
686
|
+
if (info.size !== void 0 && info.size > maxSourceBytes) continue;
|
|
687
|
+
files.push({
|
|
688
|
+
...candidate,
|
|
689
|
+
displayPath: candidate.absolutePath,
|
|
690
|
+
content: await fs.readText(target, signal)
|
|
691
|
+
});
|
|
692
|
+
} catch {
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
candidates,
|
|
698
|
+
files,
|
|
699
|
+
probed: true
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
//#endregion
|
|
703
|
+
export { composeInstructionMessage as a, createInstructionMessage as c, apply as i, instructionsApi as l, DEFAULT_MAX_SOURCE_BYTES as n, inject as o, PLUGIN_SOURCE as r, touchedPathOfToolCall as s, DEFAULT_MAX_BYTES as t, renderInstructions as u };
|