@khorsheed/dsh-capability-catalog 0.1.98 → 0.1.99
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.md +13 -0
- package/README.en.md +31 -7
- package/README.i18n.yaml +2 -2
- package/README.md +31 -7
- package/cordis.patch.yml +1 -1
- package/lib/client.js +14792 -904
- package/lib/index.js +748 -263
- package/lib/typert.host.js +614 -295
- package/lib/typert.remote-client.d.ts +27 -4
- package/lib/typert.remote-client.js +599 -288
- package/lib/types/client/CapabilityCatalogCard.js +20 -5
- package/lib/types/client/SkillDetailModal.d.ts +13 -5
- package/lib/types/client/SkillDetailModal.js +9 -3
- package/lib/types/client/locales.d.ts +0 -4
- package/lib/types/client/locales.js +4 -8
- package/lib/types/envHint.d.ts +7 -1
- package/lib/types/envHint.js +52 -18
- package/lib/types/evidence.d.ts +142 -0
- package/lib/types/evidence.js +179 -0
- package/lib/types/frontmatter.d.ts +78 -0
- package/lib/types/frontmatter.js +186 -0
- package/lib/types/import.js +10 -19
- package/lib/types/index.d.ts +8 -3
- package/lib/types/index.js +21 -50
- package/lib/types/mcpStore.d.ts +6 -8
- package/lib/types/mcpStore.js +18 -43
- package/lib/types/mcps.d.ts +1 -6
- package/lib/types/mcps.js +0 -9
- package/lib/types/namespace.d.ts +2 -3
- package/lib/types/namespace.js +2 -3
- package/lib/types/remote.d.ts +11 -2
- package/lib/types/remote.js +4 -1
- package/lib/types/scoped-delivery.d.ts +36 -24
- package/lib/types/scoped-delivery.js +136 -100
- package/lib/types/scoped-edits.js +2 -1
- package/lib/types/shellEnv.d.ts +8 -1
- package/lib/types/shellEnv.js +37 -16
- package/lib/types/skills.d.ts +41 -0
- package/lib/types/skills.js +51 -9
- package/lib/types/tool-face.d.ts +68 -0
- package/lib/types/tool-face.js +82 -0
- package/lib/types/types.d.ts +40 -19
- package/package.json +20 -18
- package/lib/invariant.js +0 -25
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -23
package/lib/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { homedir, tmpdir } from "node:os";
|
|
2
|
-
import { basename, dirname, join, relative } from "node:path";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
3
3
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
4
4
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
5
|
-
import { cp, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { cp, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { execFile } from "node:child_process";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
|
+
import yaml from "js-yaml";
|
|
9
10
|
import { inflateRawSync } from "node:zlib";
|
|
10
11
|
import { existsSync, watch } from "node:fs";
|
|
11
12
|
import { createScope } from "@deepseek-ai/dsh-scope";
|
|
@@ -248,6 +249,10 @@ async function skillBodySha(registry, name, options) {
|
|
|
248
249
|
*
|
|
249
250
|
* `bodySha` costs one registry load per skill, so it is opt-in: the browser
|
|
250
251
|
* card wants a listing, and the capability fingerprint wants the bodies.
|
|
252
|
+
*
|
|
253
|
+
* `report`, when given, is filled with the collection's health (see
|
|
254
|
+
* {@link SkillsCollectionReport}); the Remote verbs never pass one, so their
|
|
255
|
+
* rows — and their digests — are exactly what they were.
|
|
251
256
|
*/
|
|
252
257
|
async function collectSkills$1(registry, workdir, scopes = [void 0], collectOptions = {}) {
|
|
253
258
|
const byName = /* @__PURE__ */ new Map();
|
|
@@ -257,15 +262,19 @@ async function collectSkills$1(registry, workdir, scopes = [void 0], collectOpti
|
|
|
257
262
|
...base,
|
|
258
263
|
scope
|
|
259
264
|
};
|
|
265
|
+
if (collectOptions.report !== void 0) collectOptions.report.attemptedScopes += 1;
|
|
260
266
|
let snapshot;
|
|
261
267
|
try {
|
|
262
268
|
snapshot = await registry.snapshot(options);
|
|
263
269
|
} catch {
|
|
270
|
+
if (collectOptions.report !== void 0) collectOptions.report.failedScopes += 1;
|
|
264
271
|
continue;
|
|
265
272
|
}
|
|
273
|
+
if (snapshot.complete === false && collectOptions.report !== void 0) collectOptions.report.incompleteReads += 1;
|
|
266
274
|
for (const summary of snapshot.skills) {
|
|
267
275
|
if (byName.has(summary.name)) continue;
|
|
268
276
|
const bodySha = collectOptions.bodySha === true ? await skillBodySha(registry, summary.name, options) : void 0;
|
|
277
|
+
if (collectOptions.bodySha === true && bodySha === void 0 && collectOptions.report !== void 0) collectOptions.report.unreadableBodies += 1;
|
|
269
278
|
const row = skillRowFrom(summary, await skillUpdatedAt(summary), bodySha);
|
|
270
279
|
byName.set(row.name, row);
|
|
271
280
|
}
|
|
@@ -311,6 +320,28 @@ function sanitizeBundlePath(path) {
|
|
|
311
320
|
}
|
|
312
321
|
return safe.length === 0 ? void 0 : safe.join("/");
|
|
313
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Resolve an already-sanitized bundle-relative path to its real on-disk
|
|
325
|
+
* target, refusing anything that escapes the bundle. {@link sanitizeBundlePath}
|
|
326
|
+
* has already removed the lexical escapes (absolute forms, drive letters,
|
|
327
|
+
* `..`); this closes what a lexical check cannot:
|
|
328
|
+
*
|
|
329
|
+
* - a symlink INSIDE the bundle whose target lives outside it (the bundle
|
|
330
|
+
* directory is user content), and
|
|
331
|
+
* - a prefix collision — containment is a separator-terminated comparison on
|
|
332
|
+
* realpaths, so `/root/foobar` never contain-matches `/root/foo`.
|
|
333
|
+
*
|
|
334
|
+
* @param bundleDir - the skill's bundle directory (its resource base).
|
|
335
|
+
* @param safe - a path that already passed {@link sanitizeBundlePath}.
|
|
336
|
+
* @returns the resolved absolute target, or undefined when the file is absent
|
|
337
|
+
* or would escape the bundle.
|
|
338
|
+
*/
|
|
339
|
+
async function resolveBundleTarget(bundleDir, safe) {
|
|
340
|
+
const base = await realpath(bundleDir).catch(() => bundleDir);
|
|
341
|
+
const target = await realpath(join(bundleDir, safe)).catch(() => void 0);
|
|
342
|
+
if (target === void 0 || target !== base && !target.startsWith(base + sep)) return void 0;
|
|
343
|
+
return target;
|
|
344
|
+
}
|
|
314
345
|
/** Read one skill-bundle file's text content, on demand. */
|
|
315
346
|
async function readSkillFileContent(registry, name, filePath, workdir, scope = void 0, fallback) {
|
|
316
347
|
const base = workdir === void 0 ? {} : { cwd: workdir };
|
|
@@ -325,10 +356,10 @@ async function readSkillFileContent(registry, name, filePath, workdir, scope = v
|
|
|
325
356
|
if (bundleDir === void 0) return void 0;
|
|
326
357
|
const safe = sanitizeBundlePath(filePath);
|
|
327
358
|
if (safe === void 0) return void 0;
|
|
328
|
-
const
|
|
329
|
-
if (
|
|
359
|
+
const target = await resolveBundleTarget(bundleDir, safe);
|
|
360
|
+
if (target === void 0) return void 0;
|
|
330
361
|
try {
|
|
331
|
-
return { content: (await readFile(
|
|
362
|
+
return { content: (await readFile(target)).toString("utf8") };
|
|
332
363
|
} catch {
|
|
333
364
|
return;
|
|
334
365
|
}
|
|
@@ -370,7 +401,8 @@ async function loadSkillDetail(ctx, registry, name, workdir, scope = void 0, fal
|
|
|
370
401
|
const def = await registry.get(name, lookup) ?? fallback;
|
|
371
402
|
if (def === void 0) return void 0;
|
|
372
403
|
const metadataDecls = decodeCredentialDecls(def.metadata);
|
|
373
|
-
const
|
|
404
|
+
const declaredKeys = new Set(metadataDecls.map((decl) => decl.key));
|
|
405
|
+
const environmentRefs = decodeEnvDecls(def.content).map((decl) => decl.key).filter((key) => !declaredKeys.has(key));
|
|
374
406
|
const { credentials } = resolveServices(ctx);
|
|
375
407
|
let credentialStates = [];
|
|
376
408
|
if (metadataDecls.length > 0 && credentials !== void 0) credentialStates = await Promise.all(metadataDecls.map(async (decl) => {
|
|
@@ -478,6 +510,175 @@ function resolveMcpServerName(name, serverNames) {
|
|
|
478
510
|
if (separator > 0) return rest.slice(0, separator);
|
|
479
511
|
}
|
|
480
512
|
//#endregion
|
|
513
|
+
//#region lib/types/frontmatter.js
|
|
514
|
+
/**
|
|
515
|
+
* SKILL.md frontmatter: ONE real-YAML parse shared by every catalog reader.
|
|
516
|
+
*
|
|
517
|
+
* Before this module, each reader carried its own line-based scan: a regex
|
|
518
|
+
* split plus per-key patterns that matched at any indentation, so a nested
|
|
519
|
+
* same-named key could shadow a top-level one and a `presetScope` under an
|
|
520
|
+
* unrelated key still counted. Everything here is derived from a single
|
|
521
|
+
* js-yaml parse of the block (js-yaml is the repo's YAML convention and a
|
|
522
|
+
* shipped runtime dependency), with the scalar semantics mirrored from the
|
|
523
|
+
* host's skill loader (`skill-filesystem`): booleans accept true/false, 1/0,
|
|
524
|
+
* yes/no, on/off.
|
|
525
|
+
*
|
|
526
|
+
* Two deliberate degradations, both pinned by specs:
|
|
527
|
+
*
|
|
528
|
+
* - A malformed `metadata:` block must not take the skill down. A bad metadata
|
|
529
|
+
* value usually poisons the whole YAML parse, so on a parse failure the
|
|
530
|
+
* block is retried once with the top-level `metadata:` section stripped:
|
|
531
|
+
* the skill delivers minus metadata rather than being rejected.
|
|
532
|
+
* - A flag value outside the accepted variants reads as ABSENT (the caller's
|
|
533
|
+
* default applies) instead of rejecting the skill the way the host loader
|
|
534
|
+
* does — the catalog is a management/delivery surface, not the loader of
|
|
535
|
+
* record.
|
|
536
|
+
* @module @khorsheed/dsh-capability-catalog/frontmatter
|
|
537
|
+
*/
|
|
538
|
+
/** Kebab-case skill / preset id grammar (the registry's own). */
|
|
539
|
+
const KEBAB_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
540
|
+
/** Whether a string is a usable skill / preset id. */
|
|
541
|
+
function isKebabId(value) {
|
|
542
|
+
return KEBAB_ID.test(value);
|
|
543
|
+
}
|
|
544
|
+
/** Frontmatter block, then the body it precedes (CRLF-tolerant). */
|
|
545
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
546
|
+
/** Split a SKILL.md into its frontmatter block and body. */
|
|
547
|
+
function splitFrontmatter(content) {
|
|
548
|
+
const match = FRONTMATTER.exec(content);
|
|
549
|
+
if (match === null) return void 0;
|
|
550
|
+
return {
|
|
551
|
+
front: match[1] ?? "",
|
|
552
|
+
body: match[2] ?? ""
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Parse a SKILL.md's frontmatter block once, with js-yaml.
|
|
557
|
+
* @param content - the whole file text.
|
|
558
|
+
* @returns the parsed fields plus the body, or undefined when there is no
|
|
559
|
+
* frontmatter block or the block is not usable YAML even without its
|
|
560
|
+
* `metadata:` section.
|
|
561
|
+
*/
|
|
562
|
+
function parseSkillFrontmatter(content) {
|
|
563
|
+
const split = splitFrontmatter(content);
|
|
564
|
+
if (split === void 0) return void 0;
|
|
565
|
+
const parsed = parseFrontmatterData(split.front);
|
|
566
|
+
if (parsed === void 0) return void 0;
|
|
567
|
+
const { data, metadataDropped } = parsed;
|
|
568
|
+
const rawMetadata = data["metadata"];
|
|
569
|
+
const metadata = !metadataDropped && typeof rawMetadata === "object" && rawMetadata !== null && !Array.isArray(rawMetadata) ? rawMetadata : void 0;
|
|
570
|
+
const name = stringField(data, "name");
|
|
571
|
+
const description = stringField(data, "description");
|
|
572
|
+
const whenToUse = whenToUseOf(data);
|
|
573
|
+
const presetScope = presetScopeOf(data, metadata);
|
|
574
|
+
return {
|
|
575
|
+
...name === void 0 ? {} : { name },
|
|
576
|
+
...description === void 0 ? {} : { description },
|
|
577
|
+
...whenToUse === void 0 ? {} : { whenToUse },
|
|
578
|
+
modelInvocable: frontmatterBoolean(data, "disable-model-invocation") !== true,
|
|
579
|
+
userInvocable: frontmatterBoolean(data, "user-invocable") !== false,
|
|
580
|
+
...presetScope === void 0 ? {} : { presetScope },
|
|
581
|
+
...metadata === void 0 ? {} : { metadata },
|
|
582
|
+
body: split.body
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* A frontmatter boolean with the host skill loader's accepted variants:
|
|
587
|
+
* booleans, 1/0 (number or string), and yes/no/on/off (case-insensitive —
|
|
588
|
+
* js-yaml 4 leaves those as strings, so the string branch carries them).
|
|
589
|
+
* Absent or anything else reads as undefined: the catalog degrades to the
|
|
590
|
+
* caller's default instead of rejecting the skill the way the host loader
|
|
591
|
+
* does (`frontmatter field "<key>" must be a boolean` → file ignored).
|
|
592
|
+
*/
|
|
593
|
+
function frontmatterBoolean(data, key) {
|
|
594
|
+
if (!Object.hasOwn(data, key)) return void 0;
|
|
595
|
+
const value = data[key];
|
|
596
|
+
if (typeof value === "boolean") return value;
|
|
597
|
+
if (value === 1 || value === "1") return true;
|
|
598
|
+
if (value === 0 || value === "0") return false;
|
|
599
|
+
if (typeof value === "string") switch (value.toLowerCase()) {
|
|
600
|
+
case "true":
|
|
601
|
+
case "yes":
|
|
602
|
+
case "on": return true;
|
|
603
|
+
case "false":
|
|
604
|
+
case "no":
|
|
605
|
+
case "off": return false;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/** A frontmatter string field: non-empty strings only (the host's stringField). */
|
|
609
|
+
function stringField(data, key) {
|
|
610
|
+
const value = data[key];
|
|
611
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
612
|
+
}
|
|
613
|
+
/** The routing guidance, kebab spelling first (the catalog has always accepted both). */
|
|
614
|
+
function whenToUseOf(data) {
|
|
615
|
+
return stringField(data, "when-to-use") ?? stringField(data, "whenToUse");
|
|
616
|
+
}
|
|
617
|
+
/** The declared preset scope; the canonical metadata-nested key wins over the legacy top-level one. */
|
|
618
|
+
function presetScopeOf(data, metadata) {
|
|
619
|
+
const nested = metadata === void 0 ? void 0 : normalizePresetScope(metadata["presetScope"]);
|
|
620
|
+
if (nested !== void 0) return nested;
|
|
621
|
+
return normalizePresetScope(data["presetScope"]);
|
|
622
|
+
}
|
|
623
|
+
/** Normalize a raw presetScope value: arrays filter to kebab ids, a scalar is a one-item list. */
|
|
624
|
+
function normalizePresetScope(value) {
|
|
625
|
+
if (Array.isArray(value)) return presetIdsOf(value.map(String));
|
|
626
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return presetIdsOf([String(value)]);
|
|
627
|
+
}
|
|
628
|
+
/** Normalize YAML scalars onto the preset-id grammar (deduped, order preserved). */
|
|
629
|
+
function presetIdsOf(values) {
|
|
630
|
+
const ids = [];
|
|
631
|
+
for (const value of values) {
|
|
632
|
+
const id = value.trim().replace(/^['"]|['"]$/g, "");
|
|
633
|
+
if (id === "" || !KEBAB_ID.test(id)) continue;
|
|
634
|
+
if (!ids.includes(id)) ids.push(id);
|
|
635
|
+
}
|
|
636
|
+
return ids;
|
|
637
|
+
}
|
|
638
|
+
/** Parse the block as a YAML mapping; on a YAML failure retry once without the
|
|
639
|
+
* top-level `metadata:` section (a malformed metadata value degrades to
|
|
640
|
+
* no-metadata instead of rejecting the file). */
|
|
641
|
+
function parseFrontmatterData(front) {
|
|
642
|
+
try {
|
|
643
|
+
const data = loadYamlMapping(front);
|
|
644
|
+
return data === void 0 ? void 0 : {
|
|
645
|
+
data,
|
|
646
|
+
metadataDropped: false
|
|
647
|
+
};
|
|
648
|
+
} catch {
|
|
649
|
+
const stripped = stripTopLevelBlock(front, "metadata");
|
|
650
|
+
if (stripped === void 0) return void 0;
|
|
651
|
+
try {
|
|
652
|
+
const data = loadYamlMapping(stripped);
|
|
653
|
+
return data === void 0 ? void 0 : {
|
|
654
|
+
data,
|
|
655
|
+
metadataDropped: true
|
|
656
|
+
};
|
|
657
|
+
} catch {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/** js-yaml parse of the block; only a plain object is a usable frontmatter map. */
|
|
663
|
+
function loadYamlMapping(front) {
|
|
664
|
+
const parsed = yaml.load(front);
|
|
665
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
666
|
+
return parsed;
|
|
667
|
+
}
|
|
668
|
+
/** Remove a top-level `key:` line and its indented block; undefined when the key is absent. */
|
|
669
|
+
function stripTopLevelBlock(front, key) {
|
|
670
|
+
const lines = front.split("\n");
|
|
671
|
+
const start = lines.findIndex((line) => new RegExp(`^${key}\\s*:`).test(line));
|
|
672
|
+
if (start < 0) return void 0;
|
|
673
|
+
let end = start + 1;
|
|
674
|
+
while (end < lines.length) {
|
|
675
|
+
const line = lines[end] ?? "";
|
|
676
|
+
if (line.trim() !== "" && !/^\s/.test(line)) break;
|
|
677
|
+
end += 1;
|
|
678
|
+
}
|
|
679
|
+
return [...lines.slice(0, start), ...lines.slice(end)].join("\n");
|
|
680
|
+
}
|
|
681
|
+
//#endregion
|
|
481
682
|
//#region lib/types/zip.js
|
|
482
683
|
/**
|
|
483
684
|
* Minimal, dependency-free ZIP reader: enough to unpack a skill archive
|
|
@@ -569,7 +770,6 @@ function findEocd(buf) {
|
|
|
569
770
|
* @module @khorsheed/dsh-capability-catalog/import
|
|
570
771
|
*/
|
|
571
772
|
const execFileAsync = promisify(execFile);
|
|
572
|
-
const FRONTMATTER$1 = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
|
573
773
|
/** First bytes of a ZIP archive (local file header / empty / spanned). */
|
|
574
774
|
const ZIP_MAGIC_PK = 80;
|
|
575
775
|
const ZIP_MAGIC_K = 75;
|
|
@@ -579,12 +779,10 @@ function managedRoot(root, dshHome) {
|
|
|
579
779
|
}
|
|
580
780
|
/** Extract the kebab-case `name` and `description` from a SKILL.md body. */
|
|
581
781
|
function resolveSkillNameFromContent(content) {
|
|
582
|
-
const
|
|
583
|
-
if (
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
const description = /^description:\s*(.+)$/m.exec(front)?.[1]?.trim();
|
|
587
|
-
if (name === void 0 || description === void 0 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) return void 0;
|
|
782
|
+
const parsed = parseSkillFrontmatter(content);
|
|
783
|
+
if (parsed === void 0) return void 0;
|
|
784
|
+
const { name, description } = parsed;
|
|
785
|
+
if (name === void 0 || description === void 0 || !isKebabId(name)) return void 0;
|
|
588
786
|
return {
|
|
589
787
|
name,
|
|
590
788
|
description
|
|
@@ -596,10 +794,7 @@ function decodeBase64(payload) {
|
|
|
596
794
|
}
|
|
597
795
|
/** Read the frontmatter `disable-model-invocation` flag (default model-invocable true). */
|
|
598
796
|
function inferModelInvocable(content) {
|
|
599
|
-
|
|
600
|
-
if (m === null) return true;
|
|
601
|
-
const front = m[1] ?? "";
|
|
602
|
-
return /^disable-model-invocation:\s*(true|false)$/m.exec(front)?.[1] !== "true";
|
|
797
|
+
return parseSkillFrontmatter(content)?.modelInvocable ?? true;
|
|
603
798
|
}
|
|
604
799
|
/** Whether a filesystem path exists (dir or file). */
|
|
605
800
|
async function pathExists(p) {
|
|
@@ -633,13 +828,11 @@ function sanitizeRelPath(p) {
|
|
|
633
828
|
}
|
|
634
829
|
/** Apply the frontmatter `disable-model-invocation` line to match the request. */
|
|
635
830
|
function finalizeSkillText(content, modelInvocable) {
|
|
636
|
-
const
|
|
637
|
-
if (
|
|
638
|
-
const front = String(m[1] ?? "");
|
|
639
|
-
const body = String(m[2] ?? "");
|
|
831
|
+
const split = splitFrontmatter(content);
|
|
832
|
+
if (split === void 0) return content;
|
|
640
833
|
if (inferModelInvocable(content) === modelInvocable) return content;
|
|
641
|
-
const without = front.split("\n").filter((line) => !line.trim().startsWith("disable-model-invocation:")).join("\n");
|
|
642
|
-
return `---\n${modelInvocable ? without : `${without}${without.endsWith("\n") ? "" : "\n"}disable-model-invocation: true`}\n---\n${body}`;
|
|
834
|
+
const without = split.front.split("\n").filter((line) => !line.trim().startsWith("disable-model-invocation:")).join("\n");
|
|
835
|
+
return `---\n${modelInvocable ? without : `${without}${without.endsWith("\n") ? "" : "\n"}disable-model-invocation: true`}\n---\n${split.body}`;
|
|
643
836
|
}
|
|
644
837
|
/**
|
|
645
838
|
* Add a skill from a base64 SKILL.md text payload into the managed root.
|
|
@@ -1302,7 +1495,17 @@ function projectTools(schemas, mcpServers, appearedAfterApply, toolOrigins = /*
|
|
|
1302
1495
|
*/
|
|
1303
1496
|
async function catalogSnapshot(workdir, registry, toolsSchemas, mcpServers, appearedAfterApply, scopes = [void 0], toolOrigins = /* @__PURE__ */ new Map(), options = {}) {
|
|
1304
1497
|
const fingerprint = options.fingerprint === true;
|
|
1305
|
-
const
|
|
1498
|
+
const skillsReport = {
|
|
1499
|
+
attemptedScopes: 0,
|
|
1500
|
+
failedScopes: 0,
|
|
1501
|
+
incompleteReads: 0,
|
|
1502
|
+
unreadableBodies: 0
|
|
1503
|
+
};
|
|
1504
|
+
const base = await collectSkills$1(registry, workdir, scopes, {
|
|
1505
|
+
bodySha: fingerprint,
|
|
1506
|
+
report: skillsReport
|
|
1507
|
+
});
|
|
1508
|
+
if (options.report !== void 0) options.report.skills = skillsReport;
|
|
1306
1509
|
const tools = projectTools(toolsSchemas, mcpServers, appearedAfterApply, toolOrigins);
|
|
1307
1510
|
const snapshot = {
|
|
1308
1511
|
skills: base.skills,
|
|
@@ -1388,6 +1591,124 @@ async function catalogListDirSkills(dirPath) {
|
|
|
1388
1591
|
return listDirSkills(dirPath);
|
|
1389
1592
|
}
|
|
1390
1593
|
//#endregion
|
|
1594
|
+
//#region lib/types/tool-origin.js
|
|
1595
|
+
/**
|
|
1596
|
+
* Community tool-origin convention: a host-side tag a plugin attaches to a tool
|
|
1597
|
+
* definition BEFORE `ctx.tools.register`, so the catalog can attribute the tool
|
|
1598
|
+
* to `plugin` (and any channel) instead of relying on heuristics.
|
|
1599
|
+
*
|
|
1600
|
+
* The harness `ToolSchema` carries no source/owner field and the system-prompt
|
|
1601
|
+
* assembly projects tools to `{ name, description, parameters }`, so the tag must
|
|
1602
|
+
* live on the retained `ToolDefinition` and be read via `ctx.tools.get(name)`
|
|
1603
|
+
* (the registry keeps the definition by reference, so the symbol survives).
|
|
1604
|
+
* The key is `Symbol.for('dsh.tool.origin')` (global symbol registry) — a plugin
|
|
1605
|
+
* may tag without importing anything, using `def[Symbol.for('dsh.tool.origin')]`.
|
|
1606
|
+
* `setToolOrigin` is the convenience form exported for the catalog's own plugins.
|
|
1607
|
+
* @module @khorsheed/dsh-capability-catalog/tool-origin
|
|
1608
|
+
*/
|
|
1609
|
+
/** The global origin-tag key. `Symbol.for` returns the same symbol everywhere. */
|
|
1610
|
+
const TOOL_ORIGIN = Symbol.for("dsh.tool.origin");
|
|
1611
|
+
/** Convenience: attach an origin tag to a tool definition (before `register`). */
|
|
1612
|
+
function setToolOrigin(definition, origin) {
|
|
1613
|
+
definition[TOOL_ORIGIN] = origin;
|
|
1614
|
+
return definition;
|
|
1615
|
+
}
|
|
1616
|
+
/** Read the origin tag off a tool definition (host-side; never on the model wire). */
|
|
1617
|
+
function toolOrigin(definition) {
|
|
1618
|
+
return definition[TOOL_ORIGIN];
|
|
1619
|
+
}
|
|
1620
|
+
/** Whether an origin's `channel` is one the catalog can honor (plugin/builtin/mcp). */
|
|
1621
|
+
function isValidOrigin(origin) {
|
|
1622
|
+
return origin?.channel === "plugin" || origin?.channel === "builtin" || origin?.channel === "mcp";
|
|
1623
|
+
}
|
|
1624
|
+
//#endregion
|
|
1625
|
+
//#region lib/types/tool-face.js
|
|
1626
|
+
/**
|
|
1627
|
+
* Reading the tools side of the capability face: the visible schemas in a
|
|
1628
|
+
* scope, the author-declared origin tags behind them, and the MCP server
|
|
1629
|
+
* names the `mcp__` prefixes imply.
|
|
1630
|
+
*
|
|
1631
|
+
* Extracted from the service so the Remote verbs and the pure-data evidence
|
|
1632
|
+
* producer (`evidence.ts`) read the tools registry through ONE
|
|
1633
|
+
* implementation — the digest parity between them is by construction, not by
|
|
1634
|
+
* two copies agreeing today. Nothing here catches a `schemas()` failure:
|
|
1635
|
+
* whether a failed read rejects the call (the Remote verbs' choice) or is
|
|
1636
|
+
* recorded as an unavailable section (the evidence producer's choice) is the
|
|
1637
|
+
* caller's policy.
|
|
1638
|
+
* @module @khorsheed/dsh-capability-catalog/tool-face
|
|
1639
|
+
*/
|
|
1640
|
+
/**
|
|
1641
|
+
* The visible tool schemas in a scope, or the global layer when no scope is
|
|
1642
|
+
* given; an empty list when the registry is absent.
|
|
1643
|
+
*/
|
|
1644
|
+
function toolSchemasFacing(tools, scope) {
|
|
1645
|
+
if (tools?.schemas === void 0) return [];
|
|
1646
|
+
return scope === void 0 ? tools.schemas() : tools.schemas(scope);
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* The origin tags behind the given schemas, read via `tools.get(name)` so the
|
|
1650
|
+
* `Symbol.for('dsh.tool.origin')` tag on the retained definition survives
|
|
1651
|
+
* (the system-prompt assembly strips it, but `get()` returns the full
|
|
1652
|
+
* definition). A schema the registry cannot hand back contributes no tag —
|
|
1653
|
+
* the tool falls back to heuristics and is named in `untaggedOrigins`.
|
|
1654
|
+
*/
|
|
1655
|
+
function toolOriginsFacing(tools, schemas, scope, warn) {
|
|
1656
|
+
const out = /* @__PURE__ */ new Map();
|
|
1657
|
+
const untagged = [];
|
|
1658
|
+
if (tools?.get === void 0) return {
|
|
1659
|
+
origins: out,
|
|
1660
|
+
untaggedOrigins: untagged
|
|
1661
|
+
};
|
|
1662
|
+
for (const schema of schemas) {
|
|
1663
|
+
const def = scope === void 0 ? tools.get(schema.name) : tools.get(schema.name, scope);
|
|
1664
|
+
if (def === void 0) {
|
|
1665
|
+
warn(`capability-catalog: tool "${schema.name}" is visible but ctx.tools.get() is undefined in this scope — its origin tag cannot be read; it will be classified by heuristics`);
|
|
1666
|
+
untagged.push(schema.name);
|
|
1667
|
+
continue;
|
|
1668
|
+
}
|
|
1669
|
+
const origin = toolOrigin(def);
|
|
1670
|
+
if (origin !== void 0) {
|
|
1671
|
+
if (!isValidOrigin(origin)) {
|
|
1672
|
+
warn(`capability-catalog: tool "${schema.name}" origin channel "${String(origin.channel)}" is not a known channel (plugin/builtin/mcp) — ignoring the tag, falling back to heuristics`);
|
|
1673
|
+
untagged.push(schema.name);
|
|
1674
|
+
continue;
|
|
1675
|
+
}
|
|
1676
|
+
out.set(schema.name, origin);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
return {
|
|
1680
|
+
origins: out,
|
|
1681
|
+
untaggedOrigins: untagged
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
/** MCP server names from live `mcp__`-prefixed tools (prefix-derived baseline). */
|
|
1685
|
+
function mcpServerNamesFacing(schemas) {
|
|
1686
|
+
const names = [];
|
|
1687
|
+
for (const schema of schemas) {
|
|
1688
|
+
if (!schema.name.startsWith("mcp__")) continue;
|
|
1689
|
+
const rest = schema.name.slice(5);
|
|
1690
|
+
const sep = rest.indexOf("__");
|
|
1691
|
+
const server = sep > 0 ? rest.slice(0, sep) : rest;
|
|
1692
|
+
if (server.length > 0 && !names.includes(server)) names.push(server);
|
|
1693
|
+
}
|
|
1694
|
+
return names;
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Collect the whole tools side of one reading position with ONE `schemas()`
|
|
1698
|
+
* call, so the rows, the origin tags and the MCP names cannot disagree about
|
|
1699
|
+
* which registry state they read.
|
|
1700
|
+
*/
|
|
1701
|
+
function collectToolFace(tools, scope, warn) {
|
|
1702
|
+
const schemas = toolSchemasFacing(tools, scope);
|
|
1703
|
+
const { origins, untaggedOrigins } = toolOriginsFacing(tools, schemas, scope, warn);
|
|
1704
|
+
return {
|
|
1705
|
+
schemas,
|
|
1706
|
+
origins,
|
|
1707
|
+
mcpServerNames: mcpServerNamesFacing(schemas),
|
|
1708
|
+
untaggedOrigins
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
//#endregion
|
|
1391
1712
|
//#region lib/types/preset-scope.js
|
|
1392
1713
|
/**
|
|
1393
1714
|
* Resolving WHICH scope the catalog reads — and the one place where a listing
|
|
@@ -1558,106 +1879,37 @@ const MANAGED_SKILL_RANK = 300;
|
|
|
1558
1879
|
const SCOPED_SKILLS_DIR = join("capability-catalog", "skills");
|
|
1559
1880
|
/** Debounce for filesystem-triggered rescans. */
|
|
1560
1881
|
const RESCAN_DEBOUNCE_MS = 150;
|
|
1561
|
-
/** Frontmatter block, then the body it precedes. */
|
|
1562
|
-
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
1563
|
-
/** Kebab-case skill / preset id grammar (the registry's own). */
|
|
1564
|
-
const KEBAB_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1565
1882
|
/** The managed root for one harness home. */
|
|
1566
1883
|
function scopedSkillsRoot(dshHome) {
|
|
1567
1884
|
return join(dshHome, SCOPED_SKILLS_DIR);
|
|
1568
1885
|
}
|
|
1569
1886
|
/**
|
|
1570
|
-
* Read the `presetScope` list out of a frontmatter block.
|
|
1571
|
-
*
|
|
1572
|
-
* Accepted forms, at any indentation (the canonical one is
|
|
1573
|
-
* `metadata.presetScope`):
|
|
1574
|
-
*
|
|
1575
|
-
* ```yaml
|
|
1576
|
-
* metadata:
|
|
1577
|
-
* presetScope: [dsh-writing, dev]
|
|
1578
|
-
* presetScope:
|
|
1579
|
-
* - dsh-writing
|
|
1580
|
-
* ```
|
|
1581
|
-
* @param front - the frontmatter text without its `---` fences.
|
|
1582
|
-
* @returns the declared ids, or undefined when the key is absent.
|
|
1583
|
-
*/
|
|
1584
|
-
function parsePresetScopeFrontmatter(front) {
|
|
1585
|
-
const lines = front.split("\n");
|
|
1586
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
1587
|
-
const match = /^(\s*)presetScope\s*:\s*(.*)$/.exec(lines[index] ?? "");
|
|
1588
|
-
if (match === null) continue;
|
|
1589
|
-
const indent = (match[1] ?? "").length;
|
|
1590
|
-
const rest = (match[2] ?? "").trim();
|
|
1591
|
-
if (rest.startsWith("[")) return presetIdsOf(rest.slice(1, rest.lastIndexOf("]") === -1 ? void 0 : rest.lastIndexOf("]")).split(","));
|
|
1592
|
-
if (rest !== "") return presetIdsOf([rest]);
|
|
1593
|
-
const block = [];
|
|
1594
|
-
for (let item = index + 1; item < lines.length; item += 1) {
|
|
1595
|
-
const entry = /^(\s*)-\s*(.*?)\s*$/.exec(lines[item] ?? "");
|
|
1596
|
-
if (entry === null) break;
|
|
1597
|
-
if ((entry[1] ?? "").length <= indent) break;
|
|
1598
|
-
block.push(entry[2] ?? "");
|
|
1599
|
-
}
|
|
1600
|
-
return presetIdsOf(block);
|
|
1601
|
-
}
|
|
1602
|
-
}
|
|
1603
|
-
/** Normalize YAML scalars onto the preset-id grammar. */
|
|
1604
|
-
function presetIdsOf(values) {
|
|
1605
|
-
const ids = [];
|
|
1606
|
-
for (const value of values) {
|
|
1607
|
-
const id = value.trim().replace(/^['"]|['"]$/g, "");
|
|
1608
|
-
if (id === "" || !KEBAB_ID.test(id)) continue;
|
|
1609
|
-
if (!ids.includes(id)) ids.push(id);
|
|
1610
|
-
}
|
|
1611
|
-
return ids;
|
|
1612
|
-
}
|
|
1613
|
-
/** Whether a string is a usable skill / preset id. */
|
|
1614
|
-
function isKebabId(value) {
|
|
1615
|
-
return KEBAB_ID.test(value);
|
|
1616
|
-
}
|
|
1617
|
-
/** Split a SKILL.md into its frontmatter block and body. */
|
|
1618
|
-
function splitFrontmatter(content) {
|
|
1619
|
-
const match = FRONTMATTER.exec(content);
|
|
1620
|
-
if (match === null) return void 0;
|
|
1621
|
-
return {
|
|
1622
|
-
front: match[1] ?? "",
|
|
1623
|
-
body: match[2] ?? ""
|
|
1624
|
-
};
|
|
1625
|
-
}
|
|
1626
|
-
/** Read a boolean frontmatter flag (`key: true|false`). */
|
|
1627
|
-
function frontmatterFlag(front, key) {
|
|
1628
|
-
const match = new RegExp(`^\\s*${key}\\s*:\\s*(true|false)\\s*$`, "m").exec(front);
|
|
1629
|
-
if (match === null) return void 0;
|
|
1630
|
-
return match[1] === "true";
|
|
1631
|
-
}
|
|
1632
|
-
/** Read a scalar frontmatter value, unquoting a simple quoted or plain form. */
|
|
1633
|
-
function frontmatterScalar(front, key) {
|
|
1634
|
-
const match = new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, "m").exec(front);
|
|
1635
|
-
if (match === null) return void 0;
|
|
1636
|
-
const value = (match[1] ?? "").trim().replace(/^['"]|['"]$/g, "");
|
|
1637
|
-
return value === "" ? void 0 : value;
|
|
1638
|
-
}
|
|
1639
|
-
/**
|
|
1640
1887
|
* Parse one managed `SKILL.md` into its delivery record.
|
|
1888
|
+
*
|
|
1889
|
+
* The frontmatter is ONE real-YAML parse (see `./frontmatter.ts`): name and
|
|
1890
|
+
* description read top-level only, the invocation flags accept the host
|
|
1891
|
+
* loader's boolean variants, and the scope list comes from the canonical
|
|
1892
|
+
* `metadata.presetScope` (falling back to a legacy top-level key). A malformed
|
|
1893
|
+
* `metadata:` block degrades to no-metadata — the skill still delivers —
|
|
1894
|
+
* rather than rejecting a file the rest of the block could still serve.
|
|
1641
1895
|
* @param content - the file text.
|
|
1642
1896
|
* @param dir - the skill's directory (its resource base).
|
|
1643
1897
|
* @param path - the absolute `SKILL.md` path.
|
|
1644
1898
|
* @returns the record, or undefined when the frontmatter is unusable.
|
|
1645
1899
|
*/
|
|
1646
1900
|
function managedSkillFrom(content, dir, path) {
|
|
1647
|
-
const
|
|
1648
|
-
if (
|
|
1649
|
-
const name =
|
|
1650
|
-
|
|
1651
|
-
if (name === void 0 || description === void 0 || !KEBAB_ID.test(name)) return void 0;
|
|
1652
|
-
const whenToUse = frontmatterScalar(split.front, "when-to-use") ?? frontmatterScalar(split.front, "whenToUse");
|
|
1653
|
-
const presetScope = parsePresetScopeFrontmatter(split.front);
|
|
1901
|
+
const parsed = parseSkillFrontmatter(content);
|
|
1902
|
+
if (parsed === void 0) return void 0;
|
|
1903
|
+
const { name, description } = parsed;
|
|
1904
|
+
if (name === void 0 || description === void 0 || !isKebabId(name)) return void 0;
|
|
1654
1905
|
return {
|
|
1655
1906
|
name,
|
|
1656
1907
|
description,
|
|
1657
|
-
...whenToUse === void 0 ? {} : { whenToUse },
|
|
1658
|
-
modelInvocable:
|
|
1659
|
-
userInvocable:
|
|
1660
|
-
presetScope,
|
|
1908
|
+
...parsed.whenToUse === void 0 ? {} : { whenToUse: parsed.whenToUse },
|
|
1909
|
+
modelInvocable: parsed.modelInvocable,
|
|
1910
|
+
userInvocable: parsed.userInvocable,
|
|
1911
|
+
presetScope: parsed.presetScope,
|
|
1912
|
+
...parsed.metadata === void 0 ? {} : { metadata: parsed.metadata },
|
|
1661
1913
|
dir,
|
|
1662
1914
|
path,
|
|
1663
1915
|
content
|
|
@@ -1682,7 +1934,10 @@ function candidateFor(skill) {
|
|
|
1682
1934
|
rank: 300,
|
|
1683
1935
|
locator: skill.path,
|
|
1684
1936
|
path: skill.path,
|
|
1685
|
-
metadata: {
|
|
1937
|
+
metadata: {
|
|
1938
|
+
...skill.metadata,
|
|
1939
|
+
presetScope: [...skill.presetScope ?? []]
|
|
1940
|
+
},
|
|
1686
1941
|
resourceBase: {
|
|
1687
1942
|
kind: "directory",
|
|
1688
1943
|
path: skill.dir
|
|
@@ -1738,7 +1993,7 @@ async function scanManagedSkills(root) {
|
|
|
1738
1993
|
}
|
|
1739
1994
|
const found = [];
|
|
1740
1995
|
for (const child of children) {
|
|
1741
|
-
if (!child.isDirectory() || !
|
|
1996
|
+
if (!child.isDirectory() || !isKebabId(child.name)) continue;
|
|
1742
1997
|
const dir = join(root, child.name);
|
|
1743
1998
|
const path = join(dir, "SKILL.md");
|
|
1744
1999
|
try {
|
|
@@ -1775,6 +2030,9 @@ var ScopedSkillDelivery = class {
|
|
|
1775
2030
|
failure;
|
|
1776
2031
|
started = false;
|
|
1777
2032
|
disposed = false;
|
|
2033
|
+
/** Reconcile gate: one pass in flight, one queued rerun (see reconcile). */
|
|
2034
|
+
reconcileInFlight;
|
|
2035
|
+
reconcileQueued = false;
|
|
1778
2036
|
/** The probed registry's mount list (undefined until start()'s probe resolves). */
|
|
1779
2037
|
presetMounts;
|
|
1780
2038
|
/**
|
|
@@ -1797,8 +2055,46 @@ var ScopedSkillDelivery = class {
|
|
|
1797
2055
|
});
|
|
1798
2056
|
await this.reconcile();
|
|
1799
2057
|
}
|
|
1800
|
-
/**
|
|
2058
|
+
/**
|
|
2059
|
+
* Re-read policy and files, then converge the delivery registrations.
|
|
2060
|
+
*
|
|
2061
|
+
* Triggers arrive concurrently — the filesystem watcher, the
|
|
2062
|
+
* `agent-preset/selected` event, and the management Remotes — and two
|
|
2063
|
+
* overlapping passes can both decide a preset has no registration and each
|
|
2064
|
+
* register one, leaving two same-named providers serving one preset. The
|
|
2065
|
+
* gate allows ONE pass in flight and coalesces every trigger that arrives
|
|
2066
|
+
* during it into ONE queued rerun (the pass re-reads policy and files from
|
|
2067
|
+
* scratch, so a rerun loses no update). A caller that finds a pass in
|
|
2068
|
+
* flight waits for the chain its flag provoked, so an awaited reconcile
|
|
2069
|
+
* still reflects the write that triggered it.
|
|
2070
|
+
*/
|
|
1801
2071
|
async reconcile() {
|
|
2072
|
+
if (this.disposed) return;
|
|
2073
|
+
if (this.reconcileInFlight !== void 0) {
|
|
2074
|
+
this.reconcileQueued = true;
|
|
2075
|
+
let run = this.reconcileInFlight;
|
|
2076
|
+
while (run !== void 0) {
|
|
2077
|
+
await run;
|
|
2078
|
+
if (this.disposed) return;
|
|
2079
|
+
run = this.reconcileInFlight;
|
|
2080
|
+
}
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
const run = (async () => {
|
|
2084
|
+
try {
|
|
2085
|
+
do {
|
|
2086
|
+
this.reconcileQueued = false;
|
|
2087
|
+
await this.reconcileNow();
|
|
2088
|
+
} while (this.reconcileQueued && !this.disposed);
|
|
2089
|
+
} finally {
|
|
2090
|
+
this.reconcileInFlight = void 0;
|
|
2091
|
+
}
|
|
2092
|
+
})();
|
|
2093
|
+
this.reconcileInFlight = run;
|
|
2094
|
+
return run;
|
|
2095
|
+
}
|
|
2096
|
+
/** One reconcile pass (call only through {@link reconcile}'s gate). */
|
|
2097
|
+
async reconcileNow() {
|
|
1802
2098
|
if (this.disposed) return;
|
|
1803
2099
|
const registry = this.deps.registry();
|
|
1804
2100
|
const roster = this.deps.roster();
|
|
@@ -1985,6 +2281,7 @@ var ScopedSkillDelivery = class {
|
|
|
1985
2281
|
invalidate = control.invalidate;
|
|
1986
2282
|
return this.providerFor(presetId, control);
|
|
1987
2283
|
});
|
|
2284
|
+
((await injectableScope(injected, TOOLS_SERVICE))?.get(TOOLS_SERVICE))?.register?.(this.bundleFileTool(presetId));
|
|
1988
2285
|
this.entries.push({
|
|
1989
2286
|
presetId,
|
|
1990
2287
|
key,
|
|
@@ -2039,6 +2336,62 @@ var ScopedSkillDelivery = class {
|
|
|
2039
2336
|
skillsFor(presetId) {
|
|
2040
2337
|
return this.managed.filter((skill) => !this.conflicts.has(skill.name) && (presetId === GLOBAL_PRESET ? !isScoped(skill) : (skill.presetScope ?? []).includes(presetId)));
|
|
2041
2338
|
}
|
|
2339
|
+
/** The managed skills readable through one preset's reader: its own scoped
|
|
2340
|
+
* delivery plus the unscoped ones every preset sees through the global layer. */
|
|
2341
|
+
readableFor(presetId) {
|
|
2342
|
+
return [...this.skillsFor(presetId), ...this.skillsFor(GLOBAL_PRESET)];
|
|
2343
|
+
}
|
|
2344
|
+
/** The read-only bundle-file reader served with one preset's delivery. */
|
|
2345
|
+
bundleFileTool(presetId) {
|
|
2346
|
+
return setToolOrigin(defineTool({
|
|
2347
|
+
name: READ_SKILL_FILE_TOOL,
|
|
2348
|
+
description: "Read a file from the bundle of a skill delivered to this mode — the reference documents and scripts beside its SKILL.md (a SKILL.md may point at them, e.g. \"see search.md\"). Omit path to list the bundle files.",
|
|
2349
|
+
parameters: {
|
|
2350
|
+
name: {
|
|
2351
|
+
type: "string",
|
|
2352
|
+
required: true,
|
|
2353
|
+
description: "The exact skill name from the available skills list."
|
|
2354
|
+
},
|
|
2355
|
+
path: {
|
|
2356
|
+
type: "string",
|
|
2357
|
+
description: "The bundle-relative file path (e.g. search.md). Omit it to list the bundle files."
|
|
2358
|
+
}
|
|
2359
|
+
},
|
|
2360
|
+
output: {
|
|
2361
|
+
schema: { type: "string" },
|
|
2362
|
+
render: (_args, value) => [{
|
|
2363
|
+
type: "text",
|
|
2364
|
+
text: String(value)
|
|
2365
|
+
}]
|
|
2366
|
+
},
|
|
2367
|
+
execute: async (args) => this.readBundleFile(presetId, args.name, args.path)
|
|
2368
|
+
}), {
|
|
2369
|
+
channel: "plugin",
|
|
2370
|
+
owner: "@khorsheed/dsh-capability-catalog"
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
/** Serve one bundle file (or the bundle's file list) to a delivered skill's caller. */
|
|
2374
|
+
async readBundleFile(presetId, name, filePath) {
|
|
2375
|
+
const skill = this.readableFor(presetId).find((entry) => entry.name === name);
|
|
2376
|
+
if (skill === void 0) return `skill "${name}" is not delivered to this mode by the capability catalog`;
|
|
2377
|
+
if (filePath === void 0 || filePath.trim() === "") {
|
|
2378
|
+
const files = [];
|
|
2379
|
+
await collectSkillFiles(skill.dir, skill.dir, files, 0);
|
|
2380
|
+
return files.length === 0 ? `skill "${name}" has no bundle files` : files.sort().join("\n");
|
|
2381
|
+
}
|
|
2382
|
+
const safe = sanitizeBundlePath(filePath);
|
|
2383
|
+
if (safe === void 0) return `refused: "${filePath}" is not a bundle-relative path`;
|
|
2384
|
+
const target = await resolveBundleTarget(skill.dir, safe);
|
|
2385
|
+
if (target === void 0) return `no such bundle file: ${safe}`;
|
|
2386
|
+
let content;
|
|
2387
|
+
try {
|
|
2388
|
+
content = await readFile(target, "utf8");
|
|
2389
|
+
} catch {
|
|
2390
|
+
return `no such bundle file: ${safe}`;
|
|
2391
|
+
}
|
|
2392
|
+
if (content.length > MAX_BUNDLE_FILE_CHARS) return `${content.slice(0, MAX_BUNDLE_FILE_CHARS)}\n…(truncated at ${MAX_BUNDLE_FILE_CHARS} characters)`;
|
|
2393
|
+
return content;
|
|
2394
|
+
}
|
|
2042
2395
|
/** Invalidate every registration's completed catalogs. */
|
|
2043
2396
|
invalidateAll() {
|
|
2044
2397
|
for (const entry of this.entries) entry.invalidate();
|
|
@@ -2069,6 +2422,12 @@ function describeError(error) {
|
|
|
2069
2422
|
}
|
|
2070
2423
|
/** The registry every delivery registration is made through. */
|
|
2071
2424
|
const SKILLS_SERVICE = "skills";
|
|
2425
|
+
/** The tools registry the bundle-file reader registers through (optional). */
|
|
2426
|
+
const TOOLS_SERVICE = "tools";
|
|
2427
|
+
/** The scoped bundle-file reader's tool name. */
|
|
2428
|
+
const READ_SKILL_FILE_TOOL = "read_skill_file";
|
|
2429
|
+
/** A bundle file's served text is capped so a giant asset cannot flood a turn. */
|
|
2430
|
+
const MAX_BUNDLE_FILE_CHARS = 1e5;
|
|
2072
2431
|
/** How long an injection may take before the registration is abandoned. */
|
|
2073
2432
|
const INJECT_TIMEOUT_MS = 500;
|
|
2074
2433
|
/**
|
|
@@ -2079,9 +2438,10 @@ const INJECT_TIMEOUT_MS = 500;
|
|
|
2079
2438
|
* await, which gives `reconcile()` a settled answer instead of a registration
|
|
2080
2439
|
* that appears at some later tick.
|
|
2081
2440
|
* @param scopeCtx - the context minted for one preset's standing key.
|
|
2441
|
+
* @param service - the service the injected context must reach.
|
|
2082
2442
|
* @returns the injected context, or undefined when it never became available.
|
|
2083
2443
|
*/
|
|
2084
|
-
function injectableScope(scopeCtx) {
|
|
2444
|
+
function injectableScope(scopeCtx, service = SKILLS_SERVICE) {
|
|
2085
2445
|
return new Promise((resolve) => {
|
|
2086
2446
|
let settled = false;
|
|
2087
2447
|
const finish = (value) => {
|
|
@@ -2091,7 +2451,7 @@ function injectableScope(scopeCtx) {
|
|
|
2091
2451
|
resolve(value);
|
|
2092
2452
|
};
|
|
2093
2453
|
const timer = setTimeout(() => finish(void 0), INJECT_TIMEOUT_MS);
|
|
2094
|
-
scopeCtx.inject([
|
|
2454
|
+
scopeCtx.inject([service], (injected) => finish(injected));
|
|
2095
2455
|
});
|
|
2096
2456
|
}
|
|
2097
2457
|
//#endregion
|
|
@@ -2352,6 +2712,13 @@ function messageOf(error) {
|
|
|
2352
2712
|
* model's context; it only becomes visible if the model (or a command) actively
|
|
2353
2713
|
* prints it — a default-hide, not a hard secret boundary.
|
|
2354
2714
|
*
|
|
2715
|
+
* Coverage: the registry snapshot is read at the catalog's preset scope, which
|
|
2716
|
+
* a preset-scoped managed skill never appears in — so the managed root is ALSO
|
|
2717
|
+
* scanned directly (its skills' declared keys join the same set). The injected
|
|
2718
|
+
* variables are instance-wide per execution: preset scope is a delivery policy,
|
|
2719
|
+
* not a secret boundary, and a value leaves the shell env only if a command
|
|
2720
|
+
* prints it.
|
|
2721
|
+
*
|
|
2355
2722
|
* Constraint handling (shell-env/src/index.ts):
|
|
2356
2723
|
* - keys must start `DSH_` and match `/^[A-Z][A-Z0-9_]*$/`; one owner per key;
|
|
2357
2724
|
* reserved built-ins (`DSH_HOME`/`DSH_SHELL`/`DSH_SESSION_ID`) are not ownable.
|
|
@@ -2402,7 +2769,7 @@ const RESERVED_ENV_KEYS = /* @__PURE__ */ new Set([
|
|
|
2402
2769
|
* the impl only while its fiber is active) immediately before calling `register`,
|
|
2403
2770
|
* and self-retries on `INACTIVE_EFFECT` so it self-heals once boot stabilizes.
|
|
2404
2771
|
*/
|
|
2405
|
-
function installSkillEnvInjection(ctx, getScope) {
|
|
2772
|
+
function installSkillEnvInjection(ctx, getScope, getDshHome) {
|
|
2406
2773
|
let disposed = false;
|
|
2407
2774
|
let envCache = /* @__PURE__ */ new Map();
|
|
2408
2775
|
let disposeEnv;
|
|
@@ -2458,15 +2825,8 @@ function installSkillEnvInjection(ctx, getScope) {
|
|
|
2458
2825
|
const occupied = new Set((shellEnv.list?.() ?? []).filter((e) => e.contributor !== "capability-catalog").map((e) => e.key));
|
|
2459
2826
|
const keys = /* @__PURE__ */ new Set();
|
|
2460
2827
|
const values = /* @__PURE__ */ new Map();
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
let def;
|
|
2464
|
-
try {
|
|
2465
|
-
def = await skills.get(row.name, snapshotOptions);
|
|
2466
|
-
} catch {
|
|
2467
|
-
continue;
|
|
2468
|
-
}
|
|
2469
|
-
if (def === void 0) continue;
|
|
2828
|
+
/** Fold one definition's declared env keys into the contributor's set. */
|
|
2829
|
+
const collectDecls = async (def) => {
|
|
2470
2830
|
const decls = mergeCredentialDecls(decodeCredentialDecls(def.metadata), decodeEnvDecls(def.content ?? ""));
|
|
2471
2831
|
for (const decl of decls) {
|
|
2472
2832
|
if (!ENV_KEY_SUFFIX.test(decl.key)) continue;
|
|
@@ -2476,6 +2836,21 @@ function installSkillEnvInjection(ctx, getScope) {
|
|
|
2476
2836
|
const resolved = await credentials.resolve(decl.key).catch(() => void 0);
|
|
2477
2837
|
if (resolved !== void 0 && resolved.value.length > 0) values.set(dshKey, resolved.value);
|
|
2478
2838
|
}
|
|
2839
|
+
};
|
|
2840
|
+
for (const row of snapshot.skills) {
|
|
2841
|
+
if (row.invocation?.modelInvocable === false) continue;
|
|
2842
|
+
let def;
|
|
2843
|
+
try {
|
|
2844
|
+
def = await skills.get(row.name, snapshotOptions);
|
|
2845
|
+
} catch {
|
|
2846
|
+
continue;
|
|
2847
|
+
}
|
|
2848
|
+
if (def === void 0) continue;
|
|
2849
|
+
await collectDecls(def);
|
|
2850
|
+
}
|
|
2851
|
+
for (const skill of await scanManagedSkills(scopedSkillsRoot(getDshHome())).catch(() => [])) {
|
|
2852
|
+
if (!skill.modelInvocable) continue;
|
|
2853
|
+
await collectDecls(definitionFor(skill));
|
|
2479
2854
|
}
|
|
2480
2855
|
if (disposed || gen !== refreshGen) return;
|
|
2481
2856
|
envCache = values;
|
|
@@ -2618,8 +2993,7 @@ var McpStore = class {
|
|
|
2618
2993
|
this.servers.set(server.config.serverName, {
|
|
2619
2994
|
config: server.config,
|
|
2620
2995
|
credentials: existing?.credentials ?? /* @__PURE__ */ new Map(),
|
|
2621
|
-
tools
|
|
2622
|
-
error: void 0
|
|
2996
|
+
tools
|
|
2623
2997
|
});
|
|
2624
2998
|
}
|
|
2625
2999
|
for (const [ref, value] of Object.entries(state.credentials ?? {})) {
|
|
@@ -2674,8 +3048,7 @@ var McpStore = class {
|
|
|
2674
3048
|
this.servers.set(config.serverName, {
|
|
2675
3049
|
config,
|
|
2676
3050
|
credentials: existing?.credentials ?? /* @__PURE__ */ new Map(),
|
|
2677
|
-
tools: existing?.tools ?? []
|
|
2678
|
-
error: existing?.error
|
|
3051
|
+
tools: existing?.tools ?? []
|
|
2679
3052
|
});
|
|
2680
3053
|
this.persist();
|
|
2681
3054
|
}
|
|
@@ -2717,51 +3090,38 @@ var McpStore = class {
|
|
|
2717
3090
|
if (s === void 0) return [];
|
|
2718
3091
|
try {
|
|
2719
3092
|
const { connectMcpServer } = await import("./mcpConnector-C2TbkPBY.js");
|
|
2720
|
-
const connection = await connectMcpServer(await resolveConfigSecrets(s.config,
|
|
2721
|
-
const candidates = [
|
|
2722
|
-
`mcp.${serverName}.env.${ref}`,
|
|
2723
|
-
`mcp.${serverName}.header.${ref}`,
|
|
2724
|
-
`mcp.${serverName}.query.${ref}`
|
|
2725
|
-
];
|
|
2726
|
-
for (const candidate of candidates) {
|
|
2727
|
-
const value = s.credentials.get(candidate);
|
|
2728
|
-
if (value !== void 0) return value;
|
|
2729
|
-
}
|
|
2730
|
-
}));
|
|
3093
|
+
const connection = await connectMcpServer(await resolveConfigSecrets(s.config, this.secretResolver(serverName, s)));
|
|
2731
3094
|
const prevEnabled = new Map(s.tools.map((t) => [t.name, t.enabled]));
|
|
2732
3095
|
s.tools = connection.tools.map((t) => ({
|
|
2733
3096
|
...t,
|
|
2734
3097
|
enabled: prevEnabled.get(t.name) ?? true
|
|
2735
3098
|
}));
|
|
2736
|
-
s.error = void 0;
|
|
2737
3099
|
await connection.close();
|
|
2738
3100
|
this.persist();
|
|
2739
3101
|
return s.tools;
|
|
2740
|
-
} catch
|
|
2741
|
-
s.error = String(error);
|
|
3102
|
+
} catch {
|
|
2742
3103
|
this.persist();
|
|
2743
3104
|
return s.tools;
|
|
2744
3105
|
}
|
|
2745
3106
|
}
|
|
2746
|
-
/** Re-connect + re-discover every enabled server — boot-time refill of the
|
|
2747
|
-
* discovered descriptions/parameters (the persisted block stores only
|
|
2748
|
-
* name+enabled). Preserves per-tool enable flags; per-server errors are
|
|
2749
|
-
* recorded and do not stop the others. */
|
|
2750
|
-
async reconnectAll() {
|
|
2751
|
-
for (const server of this.list()) {
|
|
2752
|
-
if (!server.enabled) continue;
|
|
2753
|
-
await this.discover(server.serverName);
|
|
2754
|
-
}
|
|
2755
|
-
}
|
|
2756
|
-
/** A server's most recent connection/dev error. */
|
|
2757
|
-
errorOf(serverName) {
|
|
2758
|
-
return this.servers.get(serverName)?.error;
|
|
2759
|
-
}
|
|
2760
3107
|
/** Forward one tool call to a server (connect → call → close). Resolves secrets. */
|
|
2761
3108
|
async callTool(serverName, rawName, args) {
|
|
2762
3109
|
const s = this.servers.get(serverName);
|
|
2763
3110
|
if (s === void 0) throw new Error(`unknown MCP server: ${serverName}`);
|
|
2764
|
-
const resolved = await resolveConfigSecrets(s.config,
|
|
3111
|
+
const resolved = await resolveConfigSecrets(s.config, this.secretResolver(serverName, s));
|
|
3112
|
+
const { connectMcpServer } = await import("./mcpConnector-C2TbkPBY.js");
|
|
3113
|
+
const connection = await connectMcpServer(resolved);
|
|
3114
|
+
try {
|
|
3115
|
+
return await connection.call(rawName, args);
|
|
3116
|
+
} finally {
|
|
3117
|
+
await connection.close();
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
/** The `resolveConfigSecrets` callback for one server: a bare secret ref
|
|
3121
|
+
* resolves to the stored credential under the server's env/header/query
|
|
3122
|
+
* namespace (the same namespaces {@link credentialRefsOf} writes). */
|
|
3123
|
+
secretResolver(serverName, s) {
|
|
3124
|
+
return async (ref) => {
|
|
2765
3125
|
const candidates = [
|
|
2766
3126
|
`mcp.${serverName}.env.${ref}`,
|
|
2767
3127
|
`mcp.${serverName}.header.${ref}`,
|
|
@@ -2771,14 +3131,7 @@ var McpStore = class {
|
|
|
2771
3131
|
const value = s.credentials.get(candidate);
|
|
2772
3132
|
if (value !== void 0) return value;
|
|
2773
3133
|
}
|
|
2774
|
-
}
|
|
2775
|
-
const { connectMcpServer } = await import("./mcpConnector-C2TbkPBY.js");
|
|
2776
|
-
const connection = await connectMcpServer(resolved);
|
|
2777
|
-
try {
|
|
2778
|
-
return await connection.call(rawName, args);
|
|
2779
|
-
} finally {
|
|
2780
|
-
await connection.close();
|
|
2781
|
-
}
|
|
3134
|
+
};
|
|
2782
3135
|
}
|
|
2783
3136
|
/** Extract credential reference paths from a config (env/header/url-query).
|
|
2784
3137
|
* Namespaced `mcp.<serverName>.env.<KEY>` so multiple servers don't collide. */
|
|
@@ -2928,7 +3281,8 @@ function desiredMcpTools(servers, toolsByServer) {
|
|
|
2928
3281
|
//#region lib/types/envHint.js
|
|
2929
3282
|
/**
|
|
2930
3283
|
* Skill env hint via `tools/post-execute` — a runtime companion context that
|
|
2931
|
-
* tells the model about any skill's configured credential env mappings
|
|
3284
|
+
* tells the model about any skill's configured credential env mappings, and a
|
|
3285
|
+
* scoped-delivered skill's bundle files.
|
|
2932
3286
|
*
|
|
2933
3287
|
* The catalog injects credentials as trusted per-execution `DSH_<KEY>` env
|
|
2934
3288
|
* vars (see shellEnv.ts), but a skill's own text references the original
|
|
@@ -2937,6 +3291,11 @@ function desiredMcpTools(servers, toolsByServer) {
|
|
|
2937
3291
|
* a generic, per-skill note listing each configured mapping, so the model can
|
|
2938
3292
|
* use the value without us modifying the user's SKILL.md.
|
|
2939
3293
|
*
|
|
3294
|
+
* A second block covers scoped-delivered skills (provider `capability-catalog`):
|
|
3295
|
+
* their SKILL.md may reference bundle files (search.md etc.) in a mode that
|
|
3296
|
+
* mounts no fs tools, so the note lists those files and names the scoped
|
|
3297
|
+
* `read_skill_file` reader — only when the caller's own scope carries it.
|
|
3298
|
+
*
|
|
2940
3299
|
* Generic: it derives the mappings from the loaded skill's own env-decl keys
|
|
2941
3300
|
* (decodeEnvDecls) + the credential store, so any future key / skill is
|
|
2942
3301
|
* covered. It never touches the skill file or the provider's returned content.
|
|
@@ -2952,9 +3311,9 @@ function installSkillEnvHint(ctx, getScope) {
|
|
|
2952
3311
|
if (exec?.name !== "skill") return next();
|
|
2953
3312
|
const skillName = exec.arguments?.name;
|
|
2954
3313
|
if (typeof skillName !== "string" || skillName.length === 0) return next();
|
|
2955
|
-
const scope = await getScope().catch(() => void 0);
|
|
3314
|
+
const scope = exec.agent ?? await getScope().catch(() => void 0);
|
|
2956
3315
|
const { registry, credentials } = resolveServices(ctx);
|
|
2957
|
-
if (registry === void 0
|
|
3316
|
+
if (registry === void 0) return next();
|
|
2958
3317
|
const lookup = scope === void 0 ? {} : { scope };
|
|
2959
3318
|
let def;
|
|
2960
3319
|
try {
|
|
@@ -2963,15 +3322,27 @@ function installSkillEnvHint(ctx, getScope) {
|
|
|
2963
3322
|
return next();
|
|
2964
3323
|
}
|
|
2965
3324
|
if (def === void 0) return next();
|
|
2966
|
-
const
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
3325
|
+
const blocks = [];
|
|
3326
|
+
if (credentials !== void 0) {
|
|
3327
|
+
const decls = mergeCredentialDecls(decodeCredentialDecls(def.metadata), decodeEnvDecls(def.content ?? ""));
|
|
3328
|
+
const configured = [];
|
|
3329
|
+
for (const decl of decls) if ((await credentials.describe(decl.key).catch(() => void 0))?.configured === true) configured.push(decl.key);
|
|
3330
|
+
if (configured.length > 0) blocks.push([
|
|
3331
|
+
`Runtime credential mapping for ${skillName}:`,
|
|
3332
|
+
...configured.map((k) => `- ${k} is configured; in the model shell it is available as ${DSH_PREFIX}${k}. Use it as ${k}="${DSH_PREFIX}${k}" <command>.`),
|
|
3333
|
+
`Do not search dotenv files or ask the user for this credential, and do not print either variable.`
|
|
3334
|
+
].join("\n"));
|
|
3335
|
+
}
|
|
3336
|
+
const bundleDir = def.resourceBase?.kind === "directory" ? def.resourceBase.path : void 0;
|
|
3337
|
+
if (def.provider === "capability-catalog" && bundleDir !== void 0 && exec.agent !== void 0) {
|
|
3338
|
+
if ((ctx.get?.("tools"))?.schemas?.(exec.agent)?.some((schema) => schema.name === "read_skill_file") === true) {
|
|
3339
|
+
const files = [];
|
|
3340
|
+
await collectSkillFiles(bundleDir, bundleDir, files, 0);
|
|
3341
|
+
const rest = files.filter((file) => file !== "SKILL.md").sort();
|
|
3342
|
+
if (rest.length > 0) blocks.push([`Bundle files for ${skillName}: ${rest.join(", ")}`, `Read them with ${READ_SKILL_FILE_TOOL} (name: "${skillName}", path: "<file>") — this mode may have no fs tools.`].join("\n"));
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
if (blocks.length === 0) return next();
|
|
2975
3346
|
return {
|
|
2976
3347
|
kind: "accept",
|
|
2977
3348
|
additionalContexts: [{
|
|
@@ -2979,7 +3350,7 @@ function installSkillEnvHint(ctx, getScope) {
|
|
|
2979
3350
|
role: "user",
|
|
2980
3351
|
content: [{
|
|
2981
3352
|
type: "text",
|
|
2982
|
-
text:
|
|
3353
|
+
text: blocks.join("\n\n")
|
|
2983
3354
|
}],
|
|
2984
3355
|
source: {
|
|
2985
3356
|
kind: "capability-catalog",
|
|
@@ -2992,43 +3363,11 @@ function installSkillEnvHint(ctx, getScope) {
|
|
|
2992
3363
|
});
|
|
2993
3364
|
}
|
|
2994
3365
|
//#endregion
|
|
2995
|
-
//#region lib/types/tool-origin.js
|
|
2996
|
-
/**
|
|
2997
|
-
* Community tool-origin convention: a host-side tag a plugin attaches to a tool
|
|
2998
|
-
* definition BEFORE `ctx.tools.register`, so the catalog can attribute the tool
|
|
2999
|
-
* to `plugin` (and any channel) instead of relying on heuristics.
|
|
3000
|
-
*
|
|
3001
|
-
* The harness `ToolSchema` carries no source/owner field and the system-prompt
|
|
3002
|
-
* assembly projects tools to `{ name, description, parameters }`, so the tag must
|
|
3003
|
-
* live on the retained `ToolDefinition` and be read via `ctx.tools.get(name)`
|
|
3004
|
-
* (the registry keeps the definition by reference, so the symbol survives).
|
|
3005
|
-
* The key is `Symbol.for('dsh.tool.origin')` (global symbol registry) — a plugin
|
|
3006
|
-
* may tag without importing anything, using `def[Symbol.for('dsh.tool.origin')]`.
|
|
3007
|
-
* `setToolOrigin` is the convenience form exported for the catalog's own plugins.
|
|
3008
|
-
* @module @khorsheed/dsh-capability-catalog/tool-origin
|
|
3009
|
-
*/
|
|
3010
|
-
/** The global origin-tag key. `Symbol.for` returns the same symbol everywhere. */
|
|
3011
|
-
const TOOL_ORIGIN = Symbol.for("dsh.tool.origin");
|
|
3012
|
-
/** Convenience: attach an origin tag to a tool definition (before `register`). */
|
|
3013
|
-
function setToolOrigin(definition, origin) {
|
|
3014
|
-
definition[TOOL_ORIGIN] = origin;
|
|
3015
|
-
return definition;
|
|
3016
|
-
}
|
|
3017
|
-
/** Read the origin tag off a tool definition (host-side; never on the model wire). */
|
|
3018
|
-
function toolOrigin(definition) {
|
|
3019
|
-
return definition[TOOL_ORIGIN];
|
|
3020
|
-
}
|
|
3021
|
-
/** Whether an origin's `channel` is one the catalog can honor (plugin/builtin/mcp). */
|
|
3022
|
-
function isValidOrigin(origin) {
|
|
3023
|
-
return origin?.channel === "plugin" || origin?.channel === "builtin" || origin?.channel === "mcp";
|
|
3024
|
-
}
|
|
3025
|
-
//#endregion
|
|
3026
3366
|
//#region lib/types/namespace.js
|
|
3027
3367
|
/**
|
|
3028
3368
|
* The settings namespace owned by the capability-catalog plugin. Plain string
|
|
3029
|
-
* so both the host half (settings registration) and the browser half
|
|
3030
|
-
*
|
|
3031
|
-
* dependencies into the client bundle.
|
|
3369
|
+
* so both the host half (settings registration) and the browser half import
|
|
3370
|
+
* it without dragging host-only dependencies into the client bundle.
|
|
3032
3371
|
*/
|
|
3033
3372
|
/** Settings namespace owned by the capability-catalog plugin. */
|
|
3034
3373
|
const CAPABILITY_CATALOG_NS = "capability-catalog";
|
|
@@ -3144,6 +3483,191 @@ async function readModeFaces(options, readFace) {
|
|
|
3144
3483
|
return faces;
|
|
3145
3484
|
}
|
|
3146
3485
|
//#endregion
|
|
3486
|
+
//#region lib/types/evidence.js
|
|
3487
|
+
/**
|
|
3488
|
+
* The PRODUCER side of capability-composition evidence: pure data, computed
|
|
3489
|
+
* where the composition runs, for a consumer in another process to verify.
|
|
3490
|
+
*
|
|
3491
|
+
* {@link produceCapabilityEvidence} answers "what is the capability face of
|
|
3492
|
+
* THIS composition, and how completely was it measured?" — the composition
|
|
3493
|
+
* digest plus a per-section completeness descriptor, the measurement object
|
|
3494
|
+
* (what claims to have been measured), and the caller's coverage declaration
|
|
3495
|
+
* (what directories/services were read). It reuses this package's own
|
|
3496
|
+
* snapshot and digest algorithms (`catalogSnapshot`, `hashOf` — via
|
|
3497
|
+
* `remote.ts` and `capabilities.ts`), so the digest it reports is the digest
|
|
3498
|
+
* `snapshotFor` would stamp over the same registries: parity is by
|
|
3499
|
+
* construction, and a test pins it.
|
|
3500
|
+
*
|
|
3501
|
+
* Two deliberate absences, both by design:
|
|
3502
|
+
*
|
|
3503
|
+
* - **No standing-scope mount.** The function takes no roster and no cordis
|
|
3504
|
+
* Context, so it CANNOT resolve a preset's standing scope — resolving one
|
|
3505
|
+
* mounts the preset (see `preset-scope.ts`), and a target-side producer
|
|
3506
|
+
* must not pay that side effect to measure. The normal target-side
|
|
3507
|
+
* topology needs no scope at all: the producer's own registries already
|
|
3508
|
+
* ARE the composition's face. A caller holding an already-resolved scope
|
|
3509
|
+
* may pass it as `scope`; resolution and release stay the caller's
|
|
3510
|
+
* business.
|
|
3511
|
+
* - **No service registration.** Nothing is mounted, provided or injected as
|
|
3512
|
+
* a side effect; the function reads the slices it is handed and returns
|
|
3513
|
+
* plain JSON-safe data. It runs in a bare process (node builtins and this
|
|
3514
|
+
* package's existing dependencies only).
|
|
3515
|
+
*
|
|
3516
|
+
* The digest algorithm is owned by THIS package. A consumer MUST NOT
|
|
3517
|
+
* recompute it from the rows, and MUST treat a `partial` or `unavailable`
|
|
3518
|
+
* section as "not verified" — never as complete, and never as "the section
|
|
3519
|
+
* is empty". An empty-but-complete section (scanned, zero found) and an
|
|
3520
|
+
* unmeasured section produce distinguishable descriptors, which is the
|
|
3521
|
+
* point: an empty digest is not a complete one unless every section says so.
|
|
3522
|
+
* @module @khorsheed/dsh-capability-catalog/evidence
|
|
3523
|
+
*/
|
|
3524
|
+
/** The registry that measures as "no skills", for a composition without a skills service. */
|
|
3525
|
+
const EMPTY_REGISTRY = {
|
|
3526
|
+
snapshot: async () => ({
|
|
3527
|
+
skills: [],
|
|
3528
|
+
complete: true
|
|
3529
|
+
}),
|
|
3530
|
+
get: async () => void 0
|
|
3531
|
+
};
|
|
3532
|
+
/**
|
|
3533
|
+
* Wrap the caller's tools slice so a `get()` that throws degrades to an
|
|
3534
|
+
* unreadable origin tag (a partial section) instead of failing the whole
|
|
3535
|
+
* measurement. Member-call semantics are preserved — the slice may be a
|
|
3536
|
+
* traceable proxy whose methods need their receiver.
|
|
3537
|
+
*/
|
|
3538
|
+
function guardToolReads(tools) {
|
|
3539
|
+
if (tools.get === void 0) return tools;
|
|
3540
|
+
const get = tools.get;
|
|
3541
|
+
return {
|
|
3542
|
+
schemas: (scope) => tools.schemas(scope),
|
|
3543
|
+
get: (name, scope) => {
|
|
3544
|
+
try {
|
|
3545
|
+
return get.call(tools, name, scope);
|
|
3546
|
+
} catch {
|
|
3547
|
+
return;
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
};
|
|
3551
|
+
}
|
|
3552
|
+
/** The skills section's descriptor, from the collection's own report. */
|
|
3553
|
+
function skillsCompleteness(skills, report) {
|
|
3554
|
+
if (skills === void 0) return {
|
|
3555
|
+
state: "unavailable",
|
|
3556
|
+
reason: "the skills service is not installed"
|
|
3557
|
+
};
|
|
3558
|
+
if (report === void 0 || report.attemptedScopes === 0) return {
|
|
3559
|
+
state: "unavailable",
|
|
3560
|
+
reason: "no skill scope was read"
|
|
3561
|
+
};
|
|
3562
|
+
if (report.failedScopes === report.attemptedScopes) return {
|
|
3563
|
+
state: "unavailable",
|
|
3564
|
+
reason: `the skill registry read failed for every scope (${report.attemptedScopes} attempted)`
|
|
3565
|
+
};
|
|
3566
|
+
const problems = [];
|
|
3567
|
+
if (report.failedScopes > 0) problems.push(`the skill registry read failed for ${report.failedScopes} of ${report.attemptedScopes} scopes`);
|
|
3568
|
+
if (report.incompleteReads > 0) problems.push("the registry reported incomplete skill discovery");
|
|
3569
|
+
if (report.unreadableBodies > 0) problems.push(`${report.unreadableBodies} skill bodies could not be read`);
|
|
3570
|
+
return problems.length === 0 ? { state: "complete" } : {
|
|
3571
|
+
state: "partial",
|
|
3572
|
+
reason: problems.join("; ")
|
|
3573
|
+
};
|
|
3574
|
+
}
|
|
3575
|
+
/** The tools section's descriptor, from how the tools-side read went. */
|
|
3576
|
+
function toolsCompleteness(tools, readFailed, face) {
|
|
3577
|
+
if (tools === void 0) return {
|
|
3578
|
+
state: "unavailable",
|
|
3579
|
+
reason: "the tools service is not installed"
|
|
3580
|
+
};
|
|
3581
|
+
if (readFailed || face === void 0) return {
|
|
3582
|
+
state: "unavailable",
|
|
3583
|
+
reason: "the tool registry read failed"
|
|
3584
|
+
};
|
|
3585
|
+
if (face.untaggedOrigins.length > 0) return {
|
|
3586
|
+
state: "partial",
|
|
3587
|
+
reason: `origin tags unreadable for ${face.untaggedOrigins.length} visible tools — channel attribution fell back to heuristics`
|
|
3588
|
+
};
|
|
3589
|
+
return { state: "complete" };
|
|
3590
|
+
}
|
|
3591
|
+
/** The MCP section is derived from the tools section, so it inherits its failures. */
|
|
3592
|
+
function mcpCompleteness(tools) {
|
|
3593
|
+
if (tools.state === "unavailable") return {
|
|
3594
|
+
state: "unavailable",
|
|
3595
|
+
reason: "MCP servers are derived from the tools section, which is unavailable"
|
|
3596
|
+
};
|
|
3597
|
+
if (tools.state === "partial") return {
|
|
3598
|
+
state: "partial",
|
|
3599
|
+
reason: "derived from a partially measured tools section"
|
|
3600
|
+
};
|
|
3601
|
+
return { state: "complete" };
|
|
3602
|
+
}
|
|
3603
|
+
/** The channel summary is derived from the skills and tools rows, so it is only as strong as they are. */
|
|
3604
|
+
function channelsCompleteness(skills, tools) {
|
|
3605
|
+
if (skills.state === "unavailable" && tools.state === "unavailable") return {
|
|
3606
|
+
state: "unavailable",
|
|
3607
|
+
reason: "neither the skills nor the tools section could be measured"
|
|
3608
|
+
};
|
|
3609
|
+
if (skills.state !== "complete" || tools.state !== "complete") return {
|
|
3610
|
+
state: "partial",
|
|
3611
|
+
reason: `summarizes sections that are not fully measured (skills: ${skills.state}, tools: ${tools.state})`
|
|
3612
|
+
};
|
|
3613
|
+
return { state: "complete" };
|
|
3614
|
+
}
|
|
3615
|
+
/**
|
|
3616
|
+
* Produce the capability-composition evidence of the composition THIS
|
|
3617
|
+
* process runs, as plain data another process can verify.
|
|
3618
|
+
*
|
|
3619
|
+
* The measurement reuses the package's own algorithms end to end: the tools
|
|
3620
|
+
* side is read by `tool-face.ts` (the same reads the Remote verbs make), the
|
|
3621
|
+
* snapshot is `catalogSnapshot` in fingerprint mode, and the digest is its
|
|
3622
|
+
* stamped `sha` — nothing is re-implemented, so the answer is comparable
|
|
3623
|
+
* with a `snapshotFor` digest taken over the same registries.
|
|
3624
|
+
* @param options - every input, explicit; see {@link ProduceCapabilityEvidenceOptions}.
|
|
3625
|
+
* @returns the evidence bundle — digest, completeness, measurement, coverage.
|
|
3626
|
+
* @throws when `workdir` is given but not absolute, when `presetId` is given
|
|
3627
|
+
* but blank, or when the coverage declaration is missing — the caller
|
|
3628
|
+
* verifies identity and coverage; this function only measures.
|
|
3629
|
+
*/
|
|
3630
|
+
async function produceCapabilityEvidence(options) {
|
|
3631
|
+
if (options.workdir !== void 0 && !isAbsolute(options.workdir)) throw new Error(`produceCapabilityEvidence needs an absolute workdir (caller-verified); got ${JSON.stringify(options.workdir)}`);
|
|
3632
|
+
if (options.presetId !== void 0 && options.presetId.trim() === "") throw new Error(`produceCapabilityEvidence needs a non-empty presetId when one is given; got ${JSON.stringify(options.presetId)}`);
|
|
3633
|
+
if (options.coverage === void 0 || options.coverage === null) throw new Error("produceCapabilityEvidence needs the caller's coverage declaration (what directories/services were measured)");
|
|
3634
|
+
const warn = options.warn ?? (() => {});
|
|
3635
|
+
let toolFace;
|
|
3636
|
+
let toolsReadFailed = false;
|
|
3637
|
+
if (options.tools !== void 0) try {
|
|
3638
|
+
toolFace = collectToolFace(guardToolReads(options.tools), options.scope, warn);
|
|
3639
|
+
} catch {
|
|
3640
|
+
toolsReadFailed = true;
|
|
3641
|
+
}
|
|
3642
|
+
const report = {};
|
|
3643
|
+
const snapshot = await catalogSnapshot(options.workdir, options.skills ?? EMPTY_REGISTRY, toolFace?.schemas ?? [], toolFace?.mcpServerNames ?? [], options.appearedAfterApply ?? /* @__PURE__ */ new Set(), [options.scope], toolFace?.origins ?? /* @__PURE__ */ new Map(), {
|
|
3644
|
+
fingerprint: true,
|
|
3645
|
+
report,
|
|
3646
|
+
...options.presetId !== void 0 ? { preset: options.presetId } : {}
|
|
3647
|
+
});
|
|
3648
|
+
const skills = skillsCompleteness(options.skills, report.skills);
|
|
3649
|
+
const tools = toolsCompleteness(options.tools, toolsReadFailed, toolFace);
|
|
3650
|
+
return {
|
|
3651
|
+
measurement: {
|
|
3652
|
+
kind: "preset-composition",
|
|
3653
|
+
...options.presetId !== void 0 ? { presetId: options.presetId } : {},
|
|
3654
|
+
...options.workdir !== void 0 ? { workdir: options.workdir } : {},
|
|
3655
|
+
...options.revision !== void 0 ? { revision: options.revision } : {}
|
|
3656
|
+
},
|
|
3657
|
+
sha: snapshot.sha,
|
|
3658
|
+
completeness: {
|
|
3659
|
+
skills,
|
|
3660
|
+
tools,
|
|
3661
|
+
mcpServers: mcpCompleteness(tools),
|
|
3662
|
+
channels: channelsCompleteness(skills, tools)
|
|
3663
|
+
},
|
|
3664
|
+
coverage: {
|
|
3665
|
+
...options.coverage.directories !== void 0 ? { directories: [...options.coverage.directories] } : {},
|
|
3666
|
+
...options.coverage.services !== void 0 ? { services: [...options.coverage.services] } : {}
|
|
3667
|
+
}
|
|
3668
|
+
};
|
|
3669
|
+
}
|
|
3670
|
+
//#endregion
|
|
3147
3671
|
//#region lib/types/index.js
|
|
3148
3672
|
/**
|
|
3149
3673
|
* Capability catalog host service: a Typert Remote over the running skill and
|
|
@@ -3227,7 +3751,6 @@ let CapabilityCatalogService = (() => {
|
|
|
3227
3751
|
let _addSkill_decorators;
|
|
3228
3752
|
let _deleteSkill_decorators;
|
|
3229
3753
|
let _pickDirectory_decorators;
|
|
3230
|
-
let _mcpList_decorators;
|
|
3231
3754
|
let _mcpAdd_decorators;
|
|
3232
3755
|
let _mcpRemove_decorators;
|
|
3233
3756
|
let _mcpSetEnabled_decorators;
|
|
@@ -3254,7 +3777,6 @@ let CapabilityCatalogService = (() => {
|
|
|
3254
3777
|
_addSkill_decorators = [Remote("addSkill")];
|
|
3255
3778
|
_deleteSkill_decorators = [Remote("deleteSkill")];
|
|
3256
3779
|
_pickDirectory_decorators = [Remote("pickDirectory")];
|
|
3257
|
-
_mcpList_decorators = [Remote("mcpList")];
|
|
3258
3780
|
_mcpAdd_decorators = [Remote("mcpAdd")];
|
|
3259
3781
|
_mcpRemove_decorators = [Remote("mcpRemove")];
|
|
3260
3782
|
_mcpSetEnabled_decorators = [Remote("mcpSetEnabled")];
|
|
@@ -3438,17 +3960,6 @@ let CapabilityCatalogService = (() => {
|
|
|
3438
3960
|
},
|
|
3439
3961
|
metadata: _metadata
|
|
3440
3962
|
}, null, _instanceExtraInitializers);
|
|
3441
|
-
__esDecorate(this, null, _mcpList_decorators, {
|
|
3442
|
-
kind: "method",
|
|
3443
|
-
name: "mcpList",
|
|
3444
|
-
static: false,
|
|
3445
|
-
private: false,
|
|
3446
|
-
access: {
|
|
3447
|
-
has: (obj) => "mcpList" in obj,
|
|
3448
|
-
get: (obj) => obj.mcpList
|
|
3449
|
-
},
|
|
3450
|
-
metadata: _metadata
|
|
3451
|
-
}, null, _instanceExtraInitializers);
|
|
3452
3963
|
__esDecorate(this, null, _mcpAdd_decorators, {
|
|
3453
3964
|
kind: "method",
|
|
3454
3965
|
name: "mcpAdd",
|
|
@@ -3600,7 +4111,7 @@ let CapabilityCatalogService = (() => {
|
|
|
3600
4111
|
"credentials",
|
|
3601
4112
|
"skills"
|
|
3602
4113
|
], () => {
|
|
3603
|
-
installSkillEnvInjection(ctx, () => this.catalogScope());
|
|
4114
|
+
installSkillEnvInjection(ctx, () => this.catalogScope(), () => this.dshHome());
|
|
3604
4115
|
});
|
|
3605
4116
|
installSkillEnvHint(ctx, () => this.catalogScope());
|
|
3606
4117
|
ctx.inject(["skills"], (skillsCtx) => {
|
|
@@ -3711,7 +4222,7 @@ let CapabilityCatalogService = (() => {
|
|
|
3711
4222
|
/** Record tools that appeared after the apply-time baseline (reads the live
|
|
3712
4223
|
* registry each time, so a change event never sees a stale/undefined ref). */
|
|
3713
4224
|
markNewTools() {
|
|
3714
|
-
const tools = this.
|
|
4225
|
+
const tools = this.toolsRegistry();
|
|
3715
4226
|
const current = new Set(tools?.schemas().map((t) => t.name) ?? []);
|
|
3716
4227
|
for (const name of current) if (!this.baseline.has(name)) this.appearedAfterApply.add(name);
|
|
3717
4228
|
}
|
|
@@ -3909,9 +4420,6 @@ let CapabilityCatalogService = (() => {
|
|
|
3909
4420
|
async pickDirectory() {
|
|
3910
4421
|
return catalogPickDirectory(this.ctx);
|
|
3911
4422
|
}
|
|
3912
|
-
async mcpList() {
|
|
3913
|
-
return this.mcp.list().map(maskConfig);
|
|
3914
|
-
}
|
|
3915
4423
|
async mcpAdd(config) {
|
|
3916
4424
|
if (config.serverName.trim() === "") return false;
|
|
3917
4425
|
this.mcp.add(config);
|
|
@@ -3947,51 +4455,28 @@ let CapabilityCatalogService = (() => {
|
|
|
3947
4455
|
credentials: this.mcp.credentials()
|
|
3948
4456
|
};
|
|
3949
4457
|
}
|
|
4458
|
+
/** The live tools registry, when the composition provides one. */
|
|
4459
|
+
toolsRegistry() {
|
|
4460
|
+
return this.ctx.get?.("tools");
|
|
4461
|
+
}
|
|
3950
4462
|
/** The visible tool schemas in the standing scope (the set the model sees),
|
|
3951
4463
|
* or the global layer when no preset standing key resolves. Skills already
|
|
3952
4464
|
* enumerate through the same scope; tools must use it too so the
|
|
3953
4465
|
* settings reader sees the official/plugin/MCP tools the model actually has. */
|
|
3954
4466
|
toolSchemasIn(scope) {
|
|
3955
|
-
|
|
3956
|
-
if (tools?.schemas === void 0) return [];
|
|
3957
|
-
return scope === void 0 ? tools.schemas() : tools.schemas(scope);
|
|
4467
|
+
return toolSchemasFacing(this.toolsRegistry(), scope);
|
|
3958
4468
|
}
|
|
3959
4469
|
/** tool name → author-declared origin, read via `ctx.tools.get(name)` so the
|
|
3960
4470
|
* `Symbol.for('dsh.tool.origin')` tag on the retained definition survives (the
|
|
3961
4471
|
* system-prompt assembly strips it, but `get()` returns the full definition). */
|
|
3962
4472
|
toolOriginsMap(scope) {
|
|
3963
|
-
const tools = this.
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
const out = /* @__PURE__ */ new Map();
|
|
3967
|
-
for (const schema of schemas) {
|
|
3968
|
-
const def = scope === void 0 ? tools.get(schema.name) : tools.get(schema.name, scope);
|
|
3969
|
-
if (def === void 0) {
|
|
3970
|
-
this.ctx.logger.warn(`capability-catalog: tool "${schema.name}" is visible but ctx.tools.get() is undefined in this scope — its origin tag cannot be read; it will be classified by heuristics`);
|
|
3971
|
-
continue;
|
|
3972
|
-
}
|
|
3973
|
-
const origin = toolOrigin(def);
|
|
3974
|
-
if (origin !== void 0) {
|
|
3975
|
-
if (!isValidOrigin(origin)) {
|
|
3976
|
-
this.ctx.logger.warn(`capability-catalog: tool "${schema.name}" origin channel "${String(origin.channel)}" is not a known channel (plugin/builtin/mcp) — ignoring the tag, falling back to heuristics`);
|
|
3977
|
-
continue;
|
|
3978
|
-
}
|
|
3979
|
-
out.set(schema.name, origin);
|
|
3980
|
-
}
|
|
3981
|
-
}
|
|
3982
|
-
return out;
|
|
4473
|
+
const tools = this.toolsRegistry();
|
|
4474
|
+
const { origins } = toolOriginsFacing(tools, toolSchemasFacing(tools, scope), scope, (message) => this.ctx.logger.warn(message));
|
|
4475
|
+
return origins;
|
|
3983
4476
|
}
|
|
3984
4477
|
/** MCP server names from live `mcp__`-prefixed tools (prefix-derived baseline). */
|
|
3985
4478
|
mcpServerNamesIn(scope) {
|
|
3986
|
-
|
|
3987
|
-
for (const schema of this.toolSchemasIn(scope)) {
|
|
3988
|
-
if (!schema.name.startsWith("mcp__")) continue;
|
|
3989
|
-
const rest = schema.name.slice(5);
|
|
3990
|
-
const sep = rest.indexOf("__");
|
|
3991
|
-
const server = sep > 0 ? rest.slice(0, sep) : rest;
|
|
3992
|
-
if (server.length > 0 && !names.includes(server)) names.push(server);
|
|
3993
|
-
}
|
|
3994
|
-
return names;
|
|
4479
|
+
return mcpServerNamesFacing(this.toolSchemasIn(scope));
|
|
3995
4480
|
}
|
|
3996
4481
|
/**
|
|
3997
4482
|
* Reconcile which discovered MCP tools are registered on `ctx.tools` so the
|
|
@@ -4029,4 +4514,4 @@ let CapabilityCatalogService = (() => {
|
|
|
4029
4514
|
};
|
|
4030
4515
|
})();
|
|
4031
4516
|
//#endregion
|
|
4032
|
-
export { CAPS_TAG_PREFIX, CapabilityCatalogService, CapabilityCatalogService as default, MANAGED_SKILL_RANK, REMOTE_NS, SCOPED_PROVIDER_NAME, ScopedSkillDelivery, TOOL_ORIGIN, adoptManagedSkill, canonicalCapabilities, canonicalJson, capsTag, defaultSkillRoots, detectConflicts, findSkillSource, hashOf, hashSkillBody, isDirectory, managedLocation, managedSkillFrom, modeOptions,
|
|
4517
|
+
export { CAPS_TAG_PREFIX, CapabilityCatalogService, CapabilityCatalogService as default, MANAGED_SKILL_RANK, REMOTE_NS, SCOPED_PROVIDER_NAME, ScopedSkillDelivery, TOOL_ORIGIN, adoptManagedSkill, canonicalCapabilities, canonicalJson, capsTag, defaultSkillRoots, detectConflicts, findSkillSource, hashOf, hashSkillBody, isDirectory, managedLocation, managedSkillFrom, modeOptions, produceCapabilityEvidence, readModeFaces, releaseManagedSkill, resolvePresetScope, resolveSkillNameFromContent, scanManagedSkills, scopedSkillsRoot, setManagedPresetScope, setToolOrigin, toolOrigin, withPresetScope };
|