better-dsh 0.2.3-c → 0.2.3-e

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 (25) hide show
  1. package/docs/50_test-reports/2026-09-11-control-prompt-into-eval-description/345/256/236/346/265/213/346/212/245/345/221/212.md +222 -0
  2. package/docs/50_test-reports/2026-09-12-fs-scheme-resolution-/345/256/236/346/265/213/346/212/245/345/221/212.md +81 -0
  3. package/docs/50_test-reports/2026-09-12-url-schemes-grammar-matrix/344/270/216catalog-centralize-/345/256/236/346/265/213/346/212/245/345/221/212.md +234 -0
  4. package/docs/50_test-reports/2026-09-12-url-schemes-recallable-context-design/351/252/214/350/257/201/346/212/245/345/221/212.md +160 -0
  5. package/docs/50_test-reports/2026-09-12-url-schemes-recallable-context-/345/256/236/346/265/213/345/211/247/346/234/254.md +44 -0
  6. package/docs/50_test-reports/2026-09-12-url-schemes-recallable-context-/345/256/236/346/265/213/346/212/245/345/221/212.md +89 -0
  7. package/docs/50_test-reports/2026-09-12-url-schemes-/345/205/255scheme/345/206/222/347/203/237/344/270/216/350/276/271/347/225/214/345/256/236/346/265/213/346/212/245/345/221/212.md +198 -0
  8. package/docs/50_test-reports/2026-09-13-hashline-off/344/270/213scheme/345/217/257/350/276/276/346/200/247/345/267/245/345/205/267/351/235/242/344/270/215/345/257/271/347/247/260-/345/256/236/346/265/213/346/212/245/345/221/212.md +246 -0
  9. package/docs/50_test-reports/2026-09-13-preact-ui-shell/345/256/236/346/265/213/346/212/245/345/221/212.md +50 -0
  10. package/docs/50_test-reports/v0.2.3c-mobile-wave/345/256/236/346/265/213/346/212/245/345/221/212.md +47 -0
  11. package/eval-description.md +33 -0
  12. package/lib/client/index.js +1 -1
  13. package/lib/fs-aware/sandbox-plugin.d.ts +71 -0
  14. package/lib/fs-aware/sandbox-plugin.js +249 -0
  15. package/lib/index.d.ts +11 -18
  16. package/lib/index.js +555 -757
  17. package/lib/py-sdk-Chvy92MB.js +178 -0
  18. package/lib/py-sdk.d.ts +19 -2
  19. package/lib/py-sdk.js +2 -2
  20. package/lib/wrap-DC8O3SYz.js +721 -0
  21. package/package.json +7 -2
  22. package/url-schemes-instruction.md +22 -0
  23. package/control-prompt.md +0 -37
  24. package/lib/py-sdk-BCaOGYz7.d.ts +0 -125
  25. package/lib/py-sdk-CbgYiX8O.js +0 -691
@@ -0,0 +1,721 @@
1
+ import { promises, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ //#region src/url-schemes/catalog.ts
6
+ /**
7
+ * The scheme set, in declaration order.
8
+ *
9
+ * `paths` mirrors each handler's own path parsing, not an idealised grammar:
10
+ * `dvc://` addresses a device by its FIRST segment only (a trailing `/sub` is
11
+ * parsed and then dropped), and `ctx://` addresses collection elements with a
12
+ * `[n]` bracket that lives in the *path*, not in the selector.
13
+ */
14
+ const SCHEME_CATALOGUE = [
15
+ {
16
+ names: ["skill"],
17
+ display: "skill://",
18
+ paths: "skill://<name>[/<file>]",
19
+ purpose: "a registered skill's files",
20
+ example: "skill://<name>/SKILL.md",
21
+ write: "read-only"
22
+ },
23
+ {
24
+ names: ["agent"],
25
+ display: "agent://",
26
+ paths: "agent://[<id>[/transcript]]",
27
+ purpose: "agent roster / a LIVE agent transcript",
28
+ example: "agent://<id>/transcript:1-40",
29
+ write: "read-only"
30
+ },
31
+ {
32
+ names: ["dsh"],
33
+ display: "dsh://",
34
+ paths: "dsh://docs[/<doc>] · dsh://config",
35
+ purpose: "harness docs / live resolved config",
36
+ example: "dsh://config:path/agent-loop.maxParallelToolCalls",
37
+ write: "read-only"
38
+ },
39
+ {
40
+ names: ["ctx"],
41
+ display: "ctx://",
42
+ paths: "ctx://session/<face>",
43
+ purpose: "THIS session's own log; face ∈ transcript, compactions, thinking, system, user_prompts[n], tool_calls[n], agent_responses[n]",
44
+ example: "ctx://session/tool_calls[0]",
45
+ write: "read-only",
46
+ selector: "`:raw` | `:N-M[,N2-M2]` | `:raw:N-M` (composite ≡ `:N-M`); `[n]` is path syntax (0-based ordinal or event seq)"
47
+ },
48
+ {
49
+ names: ["dvc"],
50
+ display: "dvc://",
51
+ paths: "dvc://[<device>]",
52
+ purpose: "device registry; `write dvc://<device>` with JSON args RUNS it",
53
+ example: "dvc://ast_grep",
54
+ write: "json-args"
55
+ },
56
+ {
57
+ names: ["http", "https"],
58
+ display: "http(s)://",
59
+ paths: "http(s)://<host>/<path>",
60
+ purpose: "plain fetch",
61
+ example: "https://example.com",
62
+ write: "read-only",
63
+ selector: "exempt — the whole remainder is the path (`:8443` port, `?x=1` query)"
64
+ }
65
+ ];
66
+ /** Every registered scheme token, sorted — the list error messages report. */
67
+ const SCHEME_NAMES = SCHEME_CATALOGUE.flatMap((doc) => doc.names).sort();
68
+ /** The schemes as display tokens (`skill://`, `http(s)://`, …) — prose + error messages. */
69
+ const SCHEME_DISPLAYS = SCHEME_CATALOGUE.map((doc) => doc.display);
70
+ /** Number of schemes with no write channel (every entry except `dvc://`). */
71
+ const READ_ONLY_SCHEME_COUNT = SCHEME_CATALOGUE.filter((doc) => doc.write === "read-only").length;
72
+
73
+ //#endregion
74
+ //#region src/url-schemes/selector.ts
75
+ /** Structured error thrown by the URL-schema layer. */
76
+ var UrlSchemesError = class extends Error {
77
+ code;
78
+ constructor(code, message) {
79
+ super(message);
80
+ this.name = "UrlSchemesError";
81
+ this.code = code;
82
+ }
83
+ };
84
+ /** Matches `scheme://rest` with a lowercase alphanumeric scheme. */
85
+ const SCHEME_RE = /^([a-z][a-z0-9]*):\/\/(.*)$/;
86
+ /** One line range token: `N`, `N-M`, or open `N-`. */
87
+ const RANGE_RE = /^(\d+)(?:-(\d*))?$/;
88
+ /**
89
+ * Schemes whose URL syntax reserves `:` and `?` (ports, query strings): the
90
+ * whole remainder is the path and NO selector is parsed. The http(s) handler
91
+ * is documented as selector-free for exactly this reason — `:8443` is a port
92
+ * and `?q=` a server query, never a line range or content filter.
93
+ */
94
+ const SELECTOR_EXEMPT = {
95
+ http: true,
96
+ https: true
97
+ };
98
+ /**
99
+ * Parse a `scheme://` URL into `{ scheme, path, selector }`.
100
+ *
101
+ * Throws a structured {@link UrlSchemesError} (code `URL_NO_SCHEME`) when the
102
+ * URL has no `scheme://` prefix. Any lowercase scheme is accepted here; the
103
+ * resolver rejects schemes with no registered handler. Selector-exempt
104
+ * schemes ({@link SELECTOR_EXEMPT}) return the whole remainder as the path.
105
+ */
106
+ function parseUrl(raw) {
107
+ const m = SCHEME_RE.exec(raw);
108
+ if (m === null) throw new UrlSchemesError("URL_NO_SCHEME", `URL "${raw}" has no scheme — expected "scheme://" (${SCHEME_DISPLAYS.join(", ")})`);
109
+ const scheme = m[1];
110
+ const rest = m[2];
111
+ if (SELECTOR_EXEMPT[scheme] === true) return {
112
+ scheme,
113
+ path: rest,
114
+ selector: null
115
+ };
116
+ const colon = rest.indexOf(":");
117
+ const question = rest.indexOf("?");
118
+ const hasColon = colon !== -1;
119
+ const hasQuestion = question !== -1;
120
+ if (!hasColon && !hasQuestion) return {
121
+ scheme,
122
+ path: rest,
123
+ selector: null
124
+ };
125
+ const isQuery = hasQuestion && (!hasColon || question < colon);
126
+ const marker = isQuery ? question : colon;
127
+ const path = rest.slice(0, marker);
128
+ const selPart = rest.slice(marker + 1);
129
+ return {
130
+ scheme,
131
+ path,
132
+ selector: isQuery ? {
133
+ kind: "query",
134
+ q: selPart.startsWith("q=") ? selPart.slice(2) : selPart
135
+ } : parseColonSelector(selPart)
136
+ };
137
+ }
138
+ /** `:raw`, composite `:raw:N-M`, `:path/<subpath>`, or a `:N-M` line range list. */
139
+ function parseColonSelector(selPart) {
140
+ if (selPart === "raw") return { kind: "raw" };
141
+ if (selPart.startsWith("raw:")) return {
142
+ kind: "lines",
143
+ ranges: parseRanges(selPart.slice(4))
144
+ };
145
+ if (selPart.startsWith("path/")) return {
146
+ kind: "path",
147
+ value: selPart.slice(5)
148
+ };
149
+ return {
150
+ kind: "lines",
151
+ ranges: parseRanges(selPart)
152
+ };
153
+ }
154
+ /** `N`, `N-M`, `N-`, comma-separated → `[[start, end], ...]` (1-based). */
155
+ function parseRanges(spec) {
156
+ return spec.split(",").map((part) => {
157
+ const token = part.trim();
158
+ const m = RANGE_RE.exec(token);
159
+ if (m === null) throw new UrlSchemesError("URL_BAD_SELECTOR", `invalid line selector ":${spec}" — expected N, N-M, or N-M,N2-M2`);
160
+ const start = Number(m[1]);
161
+ if (start < 1) throw new UrlSchemesError("URL_BAD_SELECTOR", `line numbers are 1-based — got ${start} in ":${spec}"`);
162
+ const end = m[2] === void 0 ? start : m[2] === "" ? Infinity : Number(m[2]);
163
+ if (end !== Infinity && end < start) throw new UrlSchemesError("URL_BAD_SELECTOR", `line range ${start}-${end} is empty — end must be >= start`);
164
+ return [start, end];
165
+ });
166
+ }
167
+ /**
168
+ * Apply a selector to already-resolved text.
169
+ *
170
+ * `null` and `{ kind: 'raw' }` return the text unchanged. `lines` performs a
171
+ * 1-based inclusive slice (open tail via `Infinity`). `path` navigates a JSON
172
+ * sub-resource by dot-path. `query` navigates JSON by dot-path when the text
173
+ * parses as JSON, and otherwise keeps the lines containing the query string.
174
+ */
175
+ function applySelector(text, sel) {
176
+ if (sel === null) return text;
177
+ switch (sel.kind) {
178
+ case "raw": return text;
179
+ case "lines": return applyLines(text, sel.ranges);
180
+ case "path": return applyPath(text, sel.value);
181
+ case "query": return applyQuery(text, sel.q);
182
+ }
183
+ }
184
+ /** 1-based inclusive line slicing; `Infinity` end = open tail. */
185
+ function applyLines(text, ranges) {
186
+ if (ranges.length === 0) return "";
187
+ const lines = text.split("\n");
188
+ const out = [];
189
+ for (const [startRaw, endRaw] of ranges) {
190
+ const start = Math.max(1, startRaw);
191
+ const end = endRaw === Infinity ? lines.length : Math.min(lines.length, endRaw);
192
+ if (start <= end) out.push(...lines.slice(start - 1, end));
193
+ }
194
+ return out.join("\n");
195
+ }
196
+ /** Navigate a JSON sub-resource by dot-path; non-JSON text passes through. */
197
+ function applyPath(text, value) {
198
+ if (value === "") return text;
199
+ const parsed = tryParseJson(text);
200
+ if (parsed === void 0) return text;
201
+ const node = navigate(parsed, value);
202
+ return node === void 0 ? text : stringifyNode(node);
203
+ }
204
+ /** JSON dot-path query when the text is JSON, else a substring line filter. */
205
+ function applyQuery(text, q) {
206
+ if (q === "") return text;
207
+ const parsed = tryParseJson(text);
208
+ if (parsed !== void 0) {
209
+ const node = navigate(parsed, q);
210
+ return node === void 0 ? text.split("\n").filter((line) => line.includes(q)).join("\n") : stringifyNode(node);
211
+ }
212
+ return text.split("\n").filter((line) => line.includes(q)).join("\n");
213
+ }
214
+ function tryParseJson(text) {
215
+ try {
216
+ return JSON.parse(text);
217
+ } catch {
218
+ return;
219
+ }
220
+ }
221
+ /** Walk a dot-path through a parsed JSON value (numeric segments index arrays). */
222
+ function navigate(node, path) {
223
+ let current = node;
224
+ for (const segment of path.split(".")) {
225
+ if (segment === "") return void 0;
226
+ if (Array.isArray(current)) {
227
+ const idx = Number(segment);
228
+ if (!Number.isInteger(idx) || idx < 0) return void 0;
229
+ current = current[idx];
230
+ } else if (current !== null && typeof current === "object") current = current[segment];
231
+ else return;
232
+ }
233
+ return current;
234
+ }
235
+ /** Render a navigated leaf as text. */
236
+ function stringifyNode(node) {
237
+ if (typeof node === "string") return node;
238
+ try {
239
+ return JSON.stringify(node) ?? "";
240
+ } catch {
241
+ return String(node);
242
+ }
243
+ }
244
+
245
+ //#endregion
246
+ //#region src/url-schemes/handlers/dvc.ts
247
+ /** Module-level device registry: insertion-ordered, shared by every handler instance. */
248
+ const devices = /* @__PURE__ */ new Map();
249
+ /** Register (or replace) the device mounted under `name`. */
250
+ function registerDvcDevice(name, device) {
251
+ devices.set(name, device);
252
+ }
253
+ /** Read-only view of the registered devices, in registration order. */
254
+ function listDvcDevices() {
255
+ return devices;
256
+ }
257
+ /**
258
+ * The first path segment is the device name (`dvc://name`, `dvc://name/sub`).
259
+ * A leading `dvc://` is tolerated so callers may pass either the parsed path
260
+ * (the write tool's contract) or the full URL.
261
+ */
262
+ function deviceNameFromPath(path) {
263
+ const trimmed = (path.startsWith("dvc://") ? path.slice(6) : path).replace(/^\/+/, "");
264
+ const slash = trimmed.indexOf("/");
265
+ return slash === -1 ? trimmed : trimmed.slice(0, slash);
266
+ }
267
+ /** Uniform `unknown`-error rendering for structured error messages. */
268
+ function messageOf(error) {
269
+ return error instanceof Error ? error.message : String(error);
270
+ }
271
+ /**
272
+ * Build the `dvc://` scheme handler over the module-level device registry.
273
+ * Bare `dvc://` yields the roster (`no devices mounted` while empty); a
274
+ * registered `<device>` yields its summary plus a usage hint; an unregistered
275
+ * name keeps the `unknown device` placeholder text.
276
+ */
277
+ function createDvcHandler(_deps = {}) {
278
+ return { async resolve(_env, path) {
279
+ const name = deviceNameFromPath(path);
280
+ if (name === "") {
281
+ if (devices.size === 0) return "no devices mounted";
282
+ return [...devices].map(([n, device$1]) => `${n}\t${device$1.summary}`).join("\n");
283
+ }
284
+ const device = devices.get(name);
285
+ if (device === void 0) return `unknown device: ${name}`;
286
+ return `${device.summary}\nusage: write dvc://${name} with a JSON args object to execute this device`;
287
+ } };
288
+ }
289
+ /**
290
+ * Write dispatch for `dvc://` URLs — called by the write tool's URL branch.
291
+ * `path` addresses the device (parsed path or full `dvc://` URL); `content`
292
+ * must be the JSON args payload.
293
+ *
294
+ * Routing and args failures (`DVC_NO_DEVICE`, `DVC_UNKNOWN_DEVICE`,
295
+ * `DVC_BAD_ARGS`) throw synchronously — the placeholder wave's observable
296
+ * contract — while a device-reported failure rejects the returned promise as
297
+ * `DVC_DEVICE_ERROR` carrying the device name.
298
+ */
299
+ function dispatchDvcWrite(path, content) {
300
+ if (devices.size === 0) throw new UrlSchemesError("DVC_NO_DEVICE", "dvc:// write dispatch: no devices mounted to route the write to");
301
+ const name = deviceNameFromPath(path);
302
+ const device = devices.get(name);
303
+ if (device === void 0) throw new UrlSchemesError("DVC_UNKNOWN_DEVICE", `dvc:// write dispatch: no device named "${name}" (registered: ${[...devices.keys()].sort().join(", ")})`);
304
+ let args;
305
+ try {
306
+ args = JSON.parse(content);
307
+ } catch (error) {
308
+ throw new UrlSchemesError("DVC_BAD_ARGS", `dvc:// write dispatch: device "${name}" requires a JSON args payload (${messageOf(error)})`);
309
+ }
310
+ return Promise.resolve().then(() => device.execute(args)).catch((error) => {
311
+ throw new UrlSchemesError("DVC_DEVICE_ERROR", `dvc:// device "${name}" execute failed: ${messageOf(error)}`);
312
+ });
313
+ }
314
+
315
+ //#endregion
316
+ //#region src/url-schemes/docs-dir.ts
317
+ function resolveDocsDir() {
318
+ let dir = dirname(fileURLToPath(import.meta.url));
319
+ for (;;) {
320
+ const candidate = join(dir, "docs");
321
+ try {
322
+ if (statSync(candidate).isDirectory()) return candidate;
323
+ } catch {}
324
+ const parent = dirname(dir);
325
+ if (parent === dir) return void 0;
326
+ dir = parent;
327
+ }
328
+ }
329
+
330
+ //#endregion
331
+ //#region src/url-schemes/resolver.ts
332
+ /** Scheme→handler registry that resolves `scheme://` URLs end-to-end. */
333
+ var UrlResolver = class {
334
+ handlers = /* @__PURE__ */ new Map();
335
+ /** Register (or replace) the handler for `scheme`. */
336
+ register(scheme, handler) {
337
+ this.handlers.set(scheme, handler);
338
+ }
339
+ /**
340
+ * Resolve a `scheme://` URL to its selected text: parse the URL, dispatch
341
+ * to the registered handler for the scheme, then apply the selector.
342
+ *
343
+ * Throws a structured {@link UrlSchemesError} for a scheme-less URL
344
+ * (`URL_NO_SCHEME`, from {@link parseUrl}) or an unregistered scheme
345
+ * (`URL_UNREGISTERED_SCHEME`).
346
+ */
347
+ async resolve(env, url) {
348
+ const parsed = parseUrl(url);
349
+ const handler = this.handlers.get(parsed.scheme);
350
+ if (handler === void 0) {
351
+ const registered = [...this.handlers.keys()].sort().join(", ");
352
+ throw new UrlSchemesError("URL_UNREGISTERED_SCHEME", `no handler registered for scheme "${parsed.scheme}" (registered: ${registered || "none"})`);
353
+ }
354
+ if (handler.selectorAware === true) return handler.resolve(env, parsed.path, parsed.selector);
355
+ return applySelector(await handler.resolve(env, parsed.path), parsed.selector);
356
+ }
357
+ /**
358
+ * Resolve a `scheme://` URL to its on-disk path when its handler is
359
+ * path-backed: parse the URL, dispatch to the handler's optional
360
+ * `resolvePath`, and return the real disk location. Returns `undefined`
361
+ * for an unregistered scheme, a handler without `resolvePath`, or a path
362
+ * the handler cannot map — callers then fall back to text resolution
363
+ * (whose unregistered-scheme error is the structured generic one).
364
+ * Selectors are NOT applied: they operate on resolved text, not paths.
365
+ */
366
+ async resolvePath(env, url) {
367
+ const parsed = parseUrl(url);
368
+ const handler = this.handlers.get(parsed.scheme);
369
+ if (handler?.resolvePath === void 0) return void 0;
370
+ return await handler.resolvePath(env, parsed.path);
371
+ }
372
+ };
373
+
374
+ //#endregion
375
+ //#region src/url-schemes/handlers/dsh.ts
376
+ /** File extensions considered readable harness documentation. */
377
+ const DOC_EXTENSIONS = {
378
+ ".md": true,
379
+ ".markdown": true,
380
+ ".txt": true
381
+ };
382
+ /**
383
+ * Key-name patterns for the defensive secret denylist, matched against a
384
+ * normalized key (lowercased, separators removed). Complements schema-declared
385
+ * `role('secret')` redaction for fields a schema did not mark secret.
386
+ */
387
+ const SECRET_KEY_PATTERNS = [
388
+ /secret/,
389
+ /password/,
390
+ /passwd/,
391
+ /credential/,
392
+ /token/,
393
+ /apikey/,
394
+ /authorization/,
395
+ /privatekey/,
396
+ /accesskey/
397
+ ];
398
+ /** True when a settings field name names credentials/env/API-key material. */
399
+ function isSecretKey(key) {
400
+ const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
401
+ if (normalized === "env" || normalized === "environment") return true;
402
+ return SECRET_KEY_PATTERNS.some((re) => re.test(normalized));
403
+ }
404
+ /** Deep-copy a settings value, dropping every key the denylist names. */
405
+ function stripSecrets(value) {
406
+ if (Array.isArray(value)) return value.map(stripSecrets);
407
+ if (value !== null && typeof value === "object") {
408
+ const out = {};
409
+ for (const [key, item] of Object.entries(value)) {
410
+ if (isSecretKey(key)) continue;
411
+ out[key] = stripSecrets(item);
412
+ }
413
+ return out;
414
+ }
415
+ return value;
416
+ }
417
+ /** True when `name` has a doc extension (case-insensitive). */
418
+ function isDocFile(name) {
419
+ const dot = name.lastIndexOf(".");
420
+ if (dot === -1) return false;
421
+ return DOC_EXTENSIONS[name.slice(dot).toLowerCase()] === true;
422
+ }
423
+ /** Recursive, sorted list of readable doc paths relative to `dir`. */
424
+ async function listDocs(dir) {
425
+ const out = [];
426
+ const entries = await promises.readdir(dir, { withFileTypes: true });
427
+ entries.sort((a, b) => a.name.localeCompare(b.name));
428
+ for (const entry of entries) {
429
+ if (entry.name.startsWith(".")) continue;
430
+ const abs = join(dir, entry.name);
431
+ if (entry.isDirectory()) for (const child of await listDocs(abs)) out.push(join(entry.name, child));
432
+ else if (isDocFile(entry.name)) out.push(entry.name);
433
+ }
434
+ return out;
435
+ }
436
+ /** Read one `<doc>` path inside `dir`, guarding against path traversal. */
437
+ async function readDoc(dir, docPath) {
438
+ const abs = resolve(dir, docPath);
439
+ const rel = relative(dir, abs);
440
+ if (rel === "" || rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) throw new UrlSchemesError("URL_DOC_NOT_FOUND", `dsh://docs/${docPath}: path escapes the docs directory`);
441
+ let stat;
442
+ try {
443
+ stat = await promises.stat(abs);
444
+ } catch {
445
+ throw new UrlSchemesError("URL_DOC_NOT_FOUND", `dsh://docs/${docPath}: document not found`);
446
+ }
447
+ if (stat.isDirectory()) return JSON.stringify(await listDocs(abs), null, 2);
448
+ if (stat.isFile()) return await promises.readFile(abs, "utf8");
449
+ throw new UrlSchemesError("URL_DOC_NOT_FOUND", `dsh://docs/${docPath}: not a regular file`);
450
+ }
451
+ /** Create the `dsh://` scheme handler, capturing `deps` by closure. */
452
+ function createDshHandler(deps) {
453
+ const { settings, docsDir } = deps;
454
+ let docsDirPromise;
455
+ function findDocsDir() {
456
+ if (docsDirPromise === void 0) docsDirPromise = (async () => {
457
+ const candidates = [];
458
+ if (docsDir !== void 0) candidates.push(docsDir);
459
+ const pkgRoot = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url)))));
460
+ candidates.push(join(pkgRoot, "docs"), join(pkgRoot, "..", "docs"));
461
+ for (const dir of candidates) try {
462
+ if ((await promises.stat(dir)).isDirectory()) return dir;
463
+ } catch {}
464
+ })();
465
+ return docsDirPromise;
466
+ }
467
+ /**
468
+ * Path-backed view: `docs` and `docs/<sub>` map into the real docs tree, so
469
+ * the URL-aware `grep`/`glob` can hand the native tools a disk path instead
470
+ * of materializing the listing text. `config` is content-backed (resolved
471
+ * settings JSON) — never path-backed.
472
+ */
473
+ async function resolvePathDisk(path) {
474
+ if (path !== "docs" && !path.startsWith("docs/")) return void 0;
475
+ const dir = await findDocsDir();
476
+ if (dir === void 0) return void 0;
477
+ const abs = path === "docs" ? dir : join(dir, path.slice(5));
478
+ try {
479
+ await promises.stat(abs);
480
+ return abs;
481
+ } catch {
482
+ return;
483
+ }
484
+ }
485
+ return {
486
+ async resolvePath(_env, path) {
487
+ return resolvePathDisk(path);
488
+ },
489
+ async resolve(_env, path) {
490
+ const [root, ...rest] = path.split("/");
491
+ const restPath = rest.join("/");
492
+ if (root === "docs") {
493
+ const dir = await findDocsDir();
494
+ if (dir === void 0) throw new UrlSchemesError("URL_DOCS_UNAVAILABLE", "dsh://docs: no docs directory found (provide `docsDir` to createDshHandler)");
495
+ if (restPath === "") return JSON.stringify(await listDocs(dir), null, 2);
496
+ return await readDoc(dir, restPath);
497
+ }
498
+ if (root === "config") {
499
+ if (settings === void 0) throw new UrlSchemesError("URL_SETTINGS_UNAVAILABLE", "dsh://config: no ctx.settings service is mounted");
500
+ const descriptors = settings.describe({ redactSecrets: true });
501
+ if (restPath === "") {
502
+ const out = {};
503
+ for (const descriptor of descriptors) out[String(descriptor.ns)] = stripSecrets(descriptor.value);
504
+ return JSON.stringify(out, null, 2);
505
+ }
506
+ const found = descriptors.find((descriptor) => String(descriptor.ns) === restPath);
507
+ if (found === void 0) throw new UrlSchemesError("URL_UNKNOWN_SETTINGS_NAMESPACE", `dsh://config/${restPath}: unknown settings namespace`);
508
+ return JSON.stringify(stripSecrets(found.value), null, 2);
509
+ }
510
+ throw new UrlSchemesError("URL_UNKNOWN_RESOURCE", `dsh://: unknown resource "${root || "(empty)"}" — expected "docs" or "config"`);
511
+ }
512
+ };
513
+ }
514
+
515
+ //#endregion
516
+ //#region src/url-schemes/handlers/http.ts
517
+ /** The schemes one handler instance serves; register it under each of these. */
518
+ const HTTP_SCHEMES = ["http", "https"];
519
+ /** Hard cap on a fetched body: 2 MiB. */
520
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
521
+ /** Abort the GET after this many milliseconds. */
522
+ const TIMEOUT_MS = 2e4;
523
+ /** First line of every resolved http(s) URL: flags plain-fetch semantics. */
524
+ const DISCLAIMER = "[url-fetch] plain-text result of a direct HTTP GET (curl-equivalent). No JS execution or interaction — use browser tools, if any, for that.";
525
+ /** `application/` MIME types treated as text (any `text/*` also is). */
526
+ const APPLICATION_TEXT_TYPES = {
527
+ "application/json": true,
528
+ "application/xml": true,
529
+ "application/yaml": true,
530
+ "application/toml": true,
531
+ "application/xhtml+xml": true,
532
+ "application/javascript": true,
533
+ "application/plain": true
534
+ };
535
+ /** Strict whitelist: `text/*` or a known textual `application/*` MIME. A
536
+ * missing header fails the check — binary decoding is never guessed. */
537
+ function isTextContentType(headerValue) {
538
+ if (headerValue === null) return false;
539
+ const mime = headerValue.split(";", 1)[0].trim().toLowerCase();
540
+ return mime.startsWith("text/") || mime in APPLICATION_TEXT_TYPES;
541
+ }
542
+ /** Best-effort `err.cause` message — undici nests DNS/socket failures there. */
543
+ function causeMessageOf(err) {
544
+ const cause = err?.cause;
545
+ if (cause instanceof Error) return cause.message;
546
+ if (typeof cause === "string" && cause !== "") return cause;
547
+ return null;
548
+ }
549
+ /** Stream the body as UTF-8 text, throwing past the 2 MiB byte cap. */
550
+ async function readBodyCapped(body, href) {
551
+ const reader = body.getReader();
552
+ const decoder = new TextDecoder();
553
+ let received = 0;
554
+ let text = "";
555
+ try {
556
+ for (;;) {
557
+ const { done, value } = await reader.read().catch((err) => {
558
+ if (err instanceof Error && err.name === "AbortError") throw new UrlSchemesError("URL_HTTP_TIMEOUT", `HTTP GET ${href} aborted after the ${TIMEOUT_MS / 1e3} s deadline`);
559
+ throw err;
560
+ });
561
+ if (done) break;
562
+ received += value.byteLength;
563
+ if (received > MAX_BODY_BYTES) throw new UrlSchemesError("URL_HTTP_TOO_LARGE", `body of ${href} exceeded the ${MAX_BODY_BYTES}-byte (2 MiB) text limit after ${received} bytes`);
564
+ text += decoder.decode(value, { stream: true });
565
+ }
566
+ return text + decoder.decode();
567
+ } finally {
568
+ await reader.cancel().catch(() => {});
569
+ }
570
+ }
571
+ /** One capped, whitelisted GET of `url`; returns the decoded body text. */
572
+ async function getUrl(url) {
573
+ const controller = new AbortController();
574
+ let timedOut = false;
575
+ const timer = setTimeout(() => {
576
+ timedOut = true;
577
+ controller.abort();
578
+ }, TIMEOUT_MS);
579
+ try {
580
+ let response;
581
+ try {
582
+ response = await fetch(url, {
583
+ method: "GET",
584
+ redirect: "follow",
585
+ signal: controller.signal
586
+ });
587
+ } catch (err) {
588
+ if (timedOut || err instanceof Error && err.name === "AbortError") throw new UrlSchemesError("URL_HTTP_TIMEOUT", `HTTP GET ${url.href} aborted after the ${TIMEOUT_MS / 1e3} s deadline`);
589
+ const cause = causeMessageOf(err);
590
+ const message = err instanceof Error ? err.message : String(err);
591
+ throw new UrlSchemesError("URL_HTTP_FETCH_FAILED", `HTTP GET ${url.href} failed: ${message}${cause === null ? "" : ` (${cause})`}`);
592
+ }
593
+ if (!response.ok) {
594
+ const statusText = response.statusText === "" ? "" : ` ${response.statusText}`;
595
+ throw new UrlSchemesError("URL_HTTP_STATUS", `HTTP GET ${url.href} returned ${response.status}${statusText}`);
596
+ }
597
+ const declared = response.headers.get("content-length");
598
+ if (declared !== null) {
599
+ const bytes = Number(declared);
600
+ if (Number.isFinite(bytes) && bytes > MAX_BODY_BYTES) throw new UrlSchemesError("URL_HTTP_TOO_LARGE", `body of ${url.href} is ${bytes} bytes, over the ${MAX_BODY_BYTES}-byte (2 MiB) text limit`);
601
+ }
602
+ const contentType = response.headers.get("content-type");
603
+ if (!isTextContentType(contentType)) throw new UrlSchemesError("URL_HTTP_UNSUPPORTED_MEDIA", `content-type of ${url.href} is "${contentType ?? "(none)"}", outside the text whitelist (text/* or application/{json,xml,yaml,toml,xhtml+xml,javascript,plain}) — binary decoding is deliberately not guessed`);
604
+ if (response.body === null) {
605
+ const text = await response.text();
606
+ const bytes = Buffer.byteLength(text, "utf8");
607
+ if (bytes > MAX_BODY_BYTES) throw new UrlSchemesError("URL_HTTP_TOO_LARGE", `body of ${url.href} is ${bytes} bytes, over the ${MAX_BODY_BYTES}-byte (2 MiB) text limit`);
608
+ return text;
609
+ }
610
+ return await readBodyCapped(response.body, url.href);
611
+ } finally {
612
+ clearTimeout(timer);
613
+ }
614
+ }
615
+ /**
616
+ * Build the `http://`/`https://` handler. Register the returned instance
617
+ * under BOTH schemes ({@link HTTP_SCHEMES}); it is stateless and safe to
618
+ * share.
619
+ */
620
+ function createHttpHandler() {
621
+ return { async resolve(env, path) {
622
+ const raw = env.rawUrl ?? `https://${path}`;
623
+ let url;
624
+ try {
625
+ url = new URL(raw);
626
+ } catch (err) {
627
+ throw new UrlSchemesError("URL_INVALID", `invalid http(s) URL "${raw}": ${err instanceof Error ? err.message : String(err)}`);
628
+ }
629
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new UrlSchemesError("URL_INVALID", `not an http(s) URL: "${raw}"`);
630
+ return `${DISCLAIMER}\n\n${await getUrl(url)}`;
631
+ } };
632
+ }
633
+
634
+ //#endregion
635
+ //#region src/fs-aware/wrap.ts
636
+ const WRAPPED = Symbol("dsh-url-schemes.fs-wrap");
637
+ function isSchemePath(path) {
638
+ return typeof path === "string" && /^[a-z][a-z0-9]*:\/\//.test(path);
639
+ }
640
+ function isSessionLayerScheme(path) {
641
+ return path.startsWith("ctx://") || path.startsWith("agent://") || path.startsWith("skill://");
642
+ }
643
+ function sessionLayerError(url) {
644
+ if (url.startsWith("skill://")) return new UrlSchemesError("CTX_SESSION_LAYER", `${url}: session-layer scheme — skill discovery (which roots load, scan depth, layered scope merge) is the host skill provider's business logic and resolves only against a calling agent, which the filesystem layer does not have. Load skills with the native \`skill\` tool; grep/glob with path=skill://… keep working through the tool layer`);
645
+ return new UrlSchemesError("CTX_SESSION_LAYER", `${url}: session-layer scheme — read it through the read tool (the session-layer resolver environment carries the live agent this filesystem layer does not have)`);
646
+ }
647
+ /** Build the FS-layer resolver: file-type schemes only (design D2). */
648
+ function buildFsLayerResolver(services, fsSelf) {
649
+ const resolver = new UrlResolver();
650
+ resolver.register("dsh", createDshHandler({
651
+ settings: services.settings,
652
+ docsDir: resolveDocsDir()
653
+ }));
654
+ resolver.register("dvc", createDvcHandler());
655
+ for (const scheme of HTTP_SCHEMES) resolver.register(scheme, createHttpHandler());
656
+ resolver.register("ctx", { async resolve(_env, path) {
657
+ throw sessionLayerError(`ctx://${path}`);
658
+ } });
659
+ resolver.register("agent", { async resolve(_env, path) {
660
+ throw sessionLayerError(`agent://${path}`);
661
+ } });
662
+ return resolver;
663
+ }
664
+ /**
665
+ * Wrap the mounted `ctx.fs` instance in place. Idempotent via a symbol flag.
666
+ * Returns the resolver used for scheme dereferencing (for diagnostics).
667
+ */
668
+ function wrapFsWithSchemes(fs, services) {
669
+ const holder = fs;
670
+ if (holder[WRAPPED] !== void 0) return holder[WRAPPED];
671
+ const resolver = buildFsLayerResolver(services, fs);
672
+ const envFor = (url) => ({
673
+ fs,
674
+ rawUrl: url
675
+ });
676
+ const bind = (fn) => typeof fn === "function" ? fn.bind(fs) : void 0;
677
+ const origResolve = bind(fs.resolve);
678
+ const origStat = bind(fs.stat);
679
+ const origReadText = bind(fs.readText);
680
+ const origWriteText = bind(fs.writeText);
681
+ const origEditText = bind(fs.editText);
682
+ const requireOrig = (orig, method) => {
683
+ if (orig === void 0) throw new UrlSchemesError("FS_BASE_UNSUPPORTED", `the mounted filesystem service exposes no "${method}" — scheme wrap is active but the base backend cannot serve real paths`);
684
+ return orig;
685
+ };
686
+ fs.resolve = async (path, opts) => {
687
+ if (!isSchemePath(path)) return requireOrig(origResolve, "resolve")(path, opts);
688
+ if (isSessionLayerScheme(path)) throw sessionLayerError(path);
689
+ await resolver.resolve(envFor(path), path);
690
+ return {
691
+ targetKey: path,
692
+ displayPath: path
693
+ };
694
+ };
695
+ fs.stat = async (target, signal) => {
696
+ if (!isSchemePath(target.targetKey)) return requireOrig(origStat, "stat")(target, signal);
697
+ if (isSessionLayerScheme(target.targetKey)) throw sessionLayerError(target.targetKey);
698
+ return {
699
+ type: "file",
700
+ size: (await resolver.resolve(envFor(target.targetKey), target.targetKey)).length
701
+ };
702
+ };
703
+ fs.readText = async (target, signal) => {
704
+ if (!isSchemePath(target.targetKey)) return requireOrig(origReadText, "readText")(target, signal);
705
+ if (isSessionLayerScheme(target.targetKey)) throw sessionLayerError(target.targetKey);
706
+ return resolver.resolve(envFor(target.targetKey), target.targetKey);
707
+ };
708
+ fs.writeText = async (target, ...rest) => {
709
+ if (isSchemePath(target.targetKey)) throw new UrlSchemesError("FS_VIRTUAL_READONLY", `cannot write "${target.targetKey}": scheme resources are read-only at the filesystem layer`);
710
+ return requireOrig(origWriteText, "writeText")(target, ...rest);
711
+ };
712
+ fs.editText = async (target, ...rest) => {
713
+ if (isSchemePath(target.targetKey)) throw new UrlSchemesError("FS_VIRTUAL_READONLY", `cannot edit "${target.targetKey}": scheme resources are read-only at the filesystem layer`);
714
+ return requireOrig(origEditText, "editText")(target, ...rest);
715
+ };
716
+ holder[WRAPPED] = resolver;
717
+ return resolver;
718
+ }
719
+
720
+ //#endregion
721
+ export { createDshHandler as a, createDvcHandler as c, registerDvcDevice as d, UrlSchemesError as f, createHttpHandler as i, dispatchDvcWrite as l, SCHEME_NAMES as m, wrapFsWithSchemes as n, UrlResolver as o, parseUrl as p, HTTP_SCHEMES as r, resolveDocsDir as s, buildFsLayerResolver as t, listDvcDevices as u };