@vellumai/assistant 0.12.2-staging.4 → 0.12.2-staging.6
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/Dockerfile +7 -7
- package/openapi.yaml +151 -53
- package/package.json +5 -4
- package/scripts/bundled-plugin-packages.ts +154 -0
- package/scripts/generate-bundled-plugin-packages.ts +29 -0
- package/scripts/test.ts +15 -13
- package/src/__tests__/managed-store.test.ts +121 -0
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +88 -0
- package/src/calls/__tests__/voice-control-protocol.test.ts +24 -2
- package/src/calls/__tests__/voice-session-bridge.test.ts +22 -2
- package/src/calls/voice-control-protocol.ts +13 -4
- package/src/calls/voice-session-bridge.ts +27 -6
- package/src/cli/commands/__tests__/plugins.test.ts +66 -0
- package/src/cli/commands/plugins.ts +50 -18
- package/src/cli/lib/__tests__/install-from-github.test.ts +67 -0
- package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +169 -0
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +57 -0
- package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +14 -0
- package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +27 -1
- package/src/cli/lib/__tests__/plugin-details.test.ts +9 -2
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +20 -0
- package/src/cli/lib/__tests__/plugins-install-offline.test.ts +43 -0
- package/src/cli/lib/__tests__/search-plugins.test.ts +31 -0
- package/src/cli/lib/bundled-plugin-packages.json +4 -0
- package/src/cli/lib/bundled-plugin-packages.ts +87 -0
- package/src/cli/lib/diff-plugin.ts +1 -1
- package/src/cli/lib/inspect-plugin.ts +80 -12
- package/src/cli/lib/install-from-github.ts +133 -76
- package/src/cli/lib/plugin-catalog-cache.ts +22 -3
- package/src/cli/lib/plugin-catalog-local.ts +13 -3
- package/src/cli/lib/plugin-catalog-platform.ts +6 -1
- package/src/cli/lib/plugin-catalog-resolve.ts +20 -0
- package/src/cli/lib/plugin-details.ts +12 -0
- package/src/cli/lib/plugin-marketplace.ts +121 -21
- package/src/cli/lib/plugin-pin-history.ts +5 -2
- package/src/cli/lib/search-plugins.ts +58 -16
- package/src/cli/lib/upgrade-plugin.ts +48 -3
- package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
- package/src/config/bundled-skills/skill-management/TOOLS.json +7 -7
- package/src/daemon/conversation-tool-setup.ts +4 -22
- package/src/live-voice/__tests__/live-voice-look-follow-up.test.ts +374 -0
- package/src/live-voice/__tests__/live-voice-vad.test.ts +658 -3
- package/src/live-voice/__tests__/protocol.test.ts +48 -0
- package/src/live-voice/__tests__/session-controls.test.ts +43 -0
- package/src/live-voice/live-voice-session.ts +784 -64
- package/src/live-voice/protocol.ts +20 -0
- package/src/live-voice/session-controls.ts +61 -3
- package/src/monitoring/plugin-auto-update.ts +6 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +15 -0
- package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +102 -0
- package/src/runtime/routes/plugins-routes.ts +69 -49
- package/src/skills/managed-store.ts +153 -31
- package/src/tools/skills/find-similar-skills.test.ts +214 -2
- package/src/tools/skills/find-similar-skills.ts +62 -9
- package/src/tools/skills/resolve-execute-invocation.ts +24 -0
- package/src/tools/skills/scaffold-managed.ts +16 -13
|
@@ -14,10 +14,13 @@ import { dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
|
|
|
14
14
|
|
|
15
15
|
import { stringify as stringifyYaml } from "yaml";
|
|
16
16
|
|
|
17
|
+
import { parseFrontmatter } from "../config/skills.js";
|
|
17
18
|
import { deleteSkillCapabilityNode } from "../plugins/defaults/memory/graph/capability-seed.js";
|
|
18
19
|
import { isDeniedBasename } from "../tools/shared/filesystem/path-policy.js";
|
|
19
20
|
import { getLogger } from "../util/logger.js";
|
|
21
|
+
import { isPlainObject } from "../util/object.js";
|
|
20
22
|
import { getWorkspaceDir, getWorkspaceSkillsDir } from "../util/platform.js";
|
|
23
|
+
import { parseFrontmatterFields } from "./frontmatter.js";
|
|
21
24
|
import { writeInstallMeta } from "./install-meta.js";
|
|
22
25
|
|
|
23
26
|
const log = getLogger("managed-store");
|
|
@@ -210,11 +213,48 @@ interface BuildSkillMarkdownInput {
|
|
|
210
213
|
name: string;
|
|
211
214
|
description: string;
|
|
212
215
|
bodyMarkdown: string;
|
|
216
|
+
/**
|
|
217
|
+
* The five fields the scaffold tool owns. `undefined` leaves whatever
|
|
218
|
+
* `preserve` carries for that field (nothing, on a create); an empty value
|
|
219
|
+
* clears it; a value sets it.
|
|
220
|
+
*/
|
|
213
221
|
emoji?: string;
|
|
214
222
|
includes?: string[];
|
|
215
223
|
activationHints?: string[];
|
|
216
224
|
avoidWhen?: string[];
|
|
217
225
|
category?: string;
|
|
226
|
+
/**
|
|
227
|
+
* The skill's existing frontmatter, as parsed from disk, for an overwrite.
|
|
228
|
+
* Every key survives except `name`, `description`, and the five fields
|
|
229
|
+
* above, so a `platforms` gate, a `display-name`, or custom metadata a
|
|
230
|
+
* person added by hand is not lost to a call that never mentions it.
|
|
231
|
+
*/
|
|
232
|
+
preserve?: Record<string, unknown>;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Apply one tool-owned field to the vellum block: an input left `undefined`
|
|
237
|
+
* keeps the preserved value, an input given but empty (`value` undefined)
|
|
238
|
+
* clears it, and a value sets it.
|
|
239
|
+
*/
|
|
240
|
+
function setOrClear(
|
|
241
|
+
target: Record<string, unknown>,
|
|
242
|
+
key: string,
|
|
243
|
+
value: unknown,
|
|
244
|
+
input: unknown,
|
|
245
|
+
): void {
|
|
246
|
+
if (input === undefined) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (value === undefined) {
|
|
250
|
+
delete target[key];
|
|
251
|
+
} else {
|
|
252
|
+
target[key] = value;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function nonEmptyList(list: string[] | undefined): string[] | undefined {
|
|
257
|
+
return list && list.length > 0 ? list : undefined;
|
|
218
258
|
}
|
|
219
259
|
|
|
220
260
|
export function buildSkillMarkdown(input: BuildSkillMarkdownInput): string {
|
|
@@ -228,40 +268,55 @@ export function buildSkillMarkdown(input: BuildSkillMarkdownInput): string {
|
|
|
228
268
|
lines.push(`name: "${esc(input.name)}"`);
|
|
229
269
|
lines.push(`description: "${esc(input.description)}"`);
|
|
230
270
|
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
const vellum: Record<string, unknown> = {};
|
|
236
|
-
if (input.emoji) {
|
|
237
|
-
vellum.emoji = input.emoji;
|
|
238
|
-
}
|
|
239
|
-
if (input.includes && input.includes.length > 0) {
|
|
240
|
-
vellum.includes = input.includes;
|
|
241
|
-
}
|
|
242
|
-
// Kebab-case keys match what parseFrontmatter reads back
|
|
243
|
-
// (config/skills.ts: vellum["activation-hints"] / vellum["avoid-when"]).
|
|
244
|
-
// These flow through stringifyYaml below, which escapes/quotes values, so no
|
|
271
|
+
// Everything but name and description is emitted from one object: the
|
|
272
|
+
// preserved frontmatter with the tool-owned fields applied over it, under
|
|
273
|
+
// `metadata.vellum` where parseFrontmatter reads them back (kebab-case for
|
|
274
|
+
// the two list fields). stringifyYaml quotes and escapes values, so no
|
|
245
275
|
// manual sanitization is needed here.
|
|
246
|
-
|
|
247
|
-
|
|
276
|
+
const rest: Record<string, unknown> = structuredClone(input.preserve ?? {});
|
|
277
|
+
delete rest.name;
|
|
278
|
+
delete rest.description;
|
|
279
|
+
const metadata = isPlainObject(rest.metadata) ? rest.metadata : {};
|
|
280
|
+
const vellum = isPlainObject(metadata.vellum) ? metadata.vellum : {};
|
|
281
|
+
setOrClear(vellum, "emoji", input.emoji?.trim() || undefined, input.emoji);
|
|
282
|
+
// An emoji at the legacy `metadata.emoji` location wins over an absent
|
|
283
|
+
// vellum one on read, so a call that states the emoji retires it.
|
|
284
|
+
if (input.emoji !== undefined) {
|
|
285
|
+
delete metadata.emoji;
|
|
248
286
|
}
|
|
249
|
-
|
|
250
|
-
|
|
287
|
+
setOrClear(vellum, "includes", nonEmptyList(input.includes), input.includes);
|
|
288
|
+
setOrClear(
|
|
289
|
+
vellum,
|
|
290
|
+
"activation-hints",
|
|
291
|
+
nonEmptyList(input.activationHints),
|
|
292
|
+
input.activationHints,
|
|
293
|
+
);
|
|
294
|
+
setOrClear(
|
|
295
|
+
vellum,
|
|
296
|
+
"avoid-when",
|
|
297
|
+
nonEmptyList(input.avoidWhen),
|
|
298
|
+
input.avoidWhen,
|
|
299
|
+
);
|
|
300
|
+
// The web Skills UI buckets skills by this value; a blank one is a clear,
|
|
301
|
+
// never an empty bucket in the file.
|
|
302
|
+
setOrClear(
|
|
303
|
+
vellum,
|
|
304
|
+
"category",
|
|
305
|
+
input.category?.trim() || undefined,
|
|
306
|
+
input.category,
|
|
307
|
+
);
|
|
308
|
+
if (Object.keys(vellum).length > 0) {
|
|
309
|
+
metadata.vellum = vellum;
|
|
310
|
+
} else {
|
|
311
|
+
delete metadata.vellum;
|
|
251
312
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
313
|
+
if (Object.keys(metadata).length > 0) {
|
|
314
|
+
rest.metadata = metadata;
|
|
315
|
+
} else {
|
|
316
|
+
delete rest.metadata;
|
|
256
317
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const metadata = { vellum };
|
|
260
|
-
const yamlBlock = stringifyYaml(metadata, { indent: 2 });
|
|
261
|
-
lines.push("metadata:");
|
|
262
|
-
for (const yamlLine of yamlBlock.trimEnd().split("\n")) {
|
|
263
|
-
lines.push(` ${yamlLine}`);
|
|
264
|
-
}
|
|
318
|
+
if (Object.keys(rest).length > 0) {
|
|
319
|
+
lines.push(stringifyYaml(rest, { indent: 2 }).trimEnd());
|
|
265
320
|
}
|
|
266
321
|
|
|
267
322
|
lines.push("---");
|
|
@@ -348,7 +403,8 @@ export function createManagedSkill(
|
|
|
348
403
|
const skillDir = getManagedSkillDir(params.id);
|
|
349
404
|
const skillFilePath = join(skillDir, "SKILL.md");
|
|
350
405
|
|
|
351
|
-
|
|
406
|
+
const skillExists = existsSync(skillFilePath);
|
|
407
|
+
if (skillExists && !params.overwrite) {
|
|
352
408
|
return {
|
|
353
409
|
created: false,
|
|
354
410
|
path: skillFilePath,
|
|
@@ -356,6 +412,14 @@ export function createManagedSkill(
|
|
|
356
412
|
};
|
|
357
413
|
}
|
|
358
414
|
|
|
415
|
+
// An overwrite replaces the body and patches the frontmatter: a field the
|
|
416
|
+
// call leaves undefined keeps its current value, an explicit empty value
|
|
417
|
+
// clears it, and frontmatter the tool does not own passes through. Callers
|
|
418
|
+
// rarely hold every field (the retrospective sees a skill through a
|
|
419
|
+
// similarity hit; a user edit is "change step 3"), so a field they do not
|
|
420
|
+
// pass is kept rather than dropped.
|
|
421
|
+
const existing = skillExists ? readStoredManagedSkill(params.id) : null;
|
|
422
|
+
|
|
359
423
|
// Resolve and validate every companion path before any write so an invalid
|
|
360
424
|
// path leaves no partial files behind.
|
|
361
425
|
const companionWrites: Array<{ resolvedPath: string; content: string }> = [];
|
|
@@ -411,6 +475,7 @@ export function createManagedSkill(
|
|
|
411
475
|
activationHints: params.activationHints,
|
|
412
476
|
avoidWhen: params.avoidWhen,
|
|
413
477
|
category: params.category,
|
|
478
|
+
preserve: existing?.frontmatter,
|
|
414
479
|
});
|
|
415
480
|
|
|
416
481
|
mkdirSync(skillDir, { recursive: true });
|
|
@@ -450,6 +515,63 @@ export function createManagedSkill(
|
|
|
450
515
|
return { created: true, path: skillFilePath };
|
|
451
516
|
}
|
|
452
517
|
|
|
518
|
+
/**
|
|
519
|
+
* A managed skill as it is on disk. Frontmatter fields come through the
|
|
520
|
+
* catalog's parser so they are exactly what routing and the Skills UI see;
|
|
521
|
+
* `body` is the stored text after the frontmatter, verbatim except for the
|
|
522
|
+
* separator newline the store writes before it and the trailing newline it
|
|
523
|
+
* guarantees. Verbatim matters: the skill loader substitutes `{baseDir}` and
|
|
524
|
+
* `{workspaceDir}` and strips feature-gated sections, and a caller that wrote
|
|
525
|
+
* that output back would bake absolute paths into the skill; and a first line
|
|
526
|
+
* that opens an indented code block must keep its indentation or a copy turns
|
|
527
|
+
* it into prose. `frontmatter` is the whole block as written, for an
|
|
528
|
+
* overwrite to carry keys through that the typed fields do not cover.
|
|
529
|
+
*/
|
|
530
|
+
export interface StoredManagedSkill {
|
|
531
|
+
name: string;
|
|
532
|
+
description: string;
|
|
533
|
+
frontmatter: Record<string, unknown>;
|
|
534
|
+
emoji?: string;
|
|
535
|
+
includes?: string[];
|
|
536
|
+
activationHints?: string[];
|
|
537
|
+
avoidWhen?: string[];
|
|
538
|
+
category?: string;
|
|
539
|
+
body: string;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Read a managed skill from disk. Best-effort: a missing file or frontmatter
|
|
544
|
+
* that does not parse resolves to null, so a caller enriching or patching one
|
|
545
|
+
* skill never fails on a bad one.
|
|
546
|
+
*/
|
|
547
|
+
export function readStoredManagedSkill(
|
|
548
|
+
skillId: string,
|
|
549
|
+
): StoredManagedSkill | null {
|
|
550
|
+
const skillFilePath = join(getManagedSkillDir(skillId), "SKILL.md");
|
|
551
|
+
try {
|
|
552
|
+
const content = readFileSync(skillFilePath, "utf-8");
|
|
553
|
+
const parsed = parseFrontmatter(content, skillFilePath);
|
|
554
|
+
const raw = parseFrontmatterFields(content);
|
|
555
|
+
if (!parsed || !raw) {
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
name: parsed.name,
|
|
560
|
+
description: parsed.description,
|
|
561
|
+
frontmatter: raw.fields,
|
|
562
|
+
emoji: parsed.emoji,
|
|
563
|
+
includes: parsed.includes,
|
|
564
|
+
activationHints: parsed.activationHints,
|
|
565
|
+
avoidWhen: parsed.avoidWhen,
|
|
566
|
+
category: parsed.category,
|
|
567
|
+
body: raw.body.replace(/^(?:\r?\n)+/, "").replace(/(?:\r?\n)+$/, ""),
|
|
568
|
+
};
|
|
569
|
+
} catch (err) {
|
|
570
|
+
log.warn({ err, skillFilePath }, "Could not read managed skill");
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
453
575
|
interface DeleteManagedSkillResult {
|
|
454
576
|
deleted: boolean;
|
|
455
577
|
error?: string;
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
* `readInstallMeta` seam is mocked to key off the skill id (its dir basename).
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
17
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { basename, join } from "node:path";
|
|
19
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
19
20
|
|
|
20
21
|
import type { SkillSource } from "../../config/skills.js";
|
|
22
|
+
import type { StoredManagedSkill } from "../../skills/managed-store.js";
|
|
21
23
|
import type { OwnerInfo } from "../types.js";
|
|
22
24
|
|
|
23
25
|
// Map managed skill id → recorded author, consulted by the mocked
|
|
@@ -44,6 +46,11 @@ function makeContext(enabledPluginSet?: Set<string> | null): ToolContext {
|
|
|
44
46
|
};
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
/** A retrospective-pass tool context: the one caller handed `current`. */
|
|
50
|
+
function makeRetrospectiveContext(): ToolContext {
|
|
51
|
+
return { ...makeContext(), requestOrigin: "memory_retrospective" };
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
const catalog = (
|
|
48
55
|
...skills: {
|
|
49
56
|
id: string;
|
|
@@ -217,6 +224,211 @@ describe("find_similar_skills — enrichment", () => {
|
|
|
217
224
|
});
|
|
218
225
|
});
|
|
219
226
|
|
|
227
|
+
describe("find_similar_skills: current skill for a refinable hit", () => {
|
|
228
|
+
afterEach(() => {
|
|
229
|
+
rmSync(join(process.env.VELLUM_WORKSPACE_DIR!, "skills"), {
|
|
230
|
+
recursive: true,
|
|
231
|
+
force: true,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const refinableCatalog = () =>
|
|
236
|
+
catalog(
|
|
237
|
+
{
|
|
238
|
+
id: "weekly-export",
|
|
239
|
+
name: "Weekly Report Export",
|
|
240
|
+
description: "Export the weekly usage report",
|
|
241
|
+
source: "managed",
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: "user-skill",
|
|
245
|
+
name: "User Skill",
|
|
246
|
+
description: "A person wrote this",
|
|
247
|
+
source: "managed",
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: "clean-disk",
|
|
251
|
+
name: "Clean Disk",
|
|
252
|
+
description: "Free up disk space",
|
|
253
|
+
source: "bundled",
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
const stored: Record<string, StoredManagedSkill> = {
|
|
257
|
+
"weekly-export": {
|
|
258
|
+
name: "Weekly Report Export",
|
|
259
|
+
description: "Export the weekly usage report",
|
|
260
|
+
frontmatter: {},
|
|
261
|
+
emoji: "📊",
|
|
262
|
+
category: "productivity",
|
|
263
|
+
includes: ["csv-basics"],
|
|
264
|
+
activationHints: ["user asks for the weekly report"],
|
|
265
|
+
avoidWhen: ["the report is monthly"],
|
|
266
|
+
body: "1. Open the dashboard.\n2. Export.",
|
|
267
|
+
},
|
|
268
|
+
"user-skill": {
|
|
269
|
+
name: "User Skill",
|
|
270
|
+
description: "A person wrote this",
|
|
271
|
+
frontmatter: {},
|
|
272
|
+
body: "Do the user's thing.",
|
|
273
|
+
},
|
|
274
|
+
"clean-disk": {
|
|
275
|
+
name: "Clean Disk",
|
|
276
|
+
description: "Free up disk space",
|
|
277
|
+
frontmatter: {},
|
|
278
|
+
body: "Delete caches.",
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
const reads: string[] = [];
|
|
282
|
+
const readStoredManagedSkill = (skillId: string) => {
|
|
283
|
+
reads.push(skillId);
|
|
284
|
+
return stored[skillId] ?? null;
|
|
285
|
+
};
|
|
286
|
+
const hits = async () => [
|
|
287
|
+
{ skillId: "weekly-export", score: 0.9 },
|
|
288
|
+
{ skillId: "user-skill", score: 0.8 },
|
|
289
|
+
{ skillId: "clean-disk", score: 0.7 },
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
test("the retrospective gets the skill as it is, in scaffold argument names, only for its own managed hits", async () => {
|
|
293
|
+
installMetaAuthors["weekly-export"] = "assistant";
|
|
294
|
+
installMetaAuthors["user-skill"] = "user";
|
|
295
|
+
|
|
296
|
+
const result = await executeFindSimilarSkills(
|
|
297
|
+
{ goal: "export the weekly report" },
|
|
298
|
+
makeRetrospectiveContext(),
|
|
299
|
+
{
|
|
300
|
+
nearestExistingSkills: hits,
|
|
301
|
+
loadCatalog: refinableCatalog,
|
|
302
|
+
readStoredManagedSkill,
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const { skills } = JSON.parse(result.content);
|
|
307
|
+
expect(skills[0].current).toEqual({
|
|
308
|
+
name: "Weekly Report Export",
|
|
309
|
+
description: "Export the weekly usage report",
|
|
310
|
+
emoji: "📊",
|
|
311
|
+
category: "productivity",
|
|
312
|
+
includes: ["csv-basics"],
|
|
313
|
+
activation_hints: ["user asks for the weekly report"],
|
|
314
|
+
avoid_when: ["the report is monthly"],
|
|
315
|
+
body_markdown: "1. Open the dashboard.\n2. Export.",
|
|
316
|
+
});
|
|
317
|
+
// A person's skill and a bundled skill are not the pass's to refine, so
|
|
318
|
+
// their content is not handed over.
|
|
319
|
+
expect(skills[1]).not.toHaveProperty("current");
|
|
320
|
+
expect(skills[2]).not.toHaveProperty("current");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("absent frontmatter fields are omitted rather than sent as null", async () => {
|
|
324
|
+
installMetaAuthors["user-skill"] = "assistant";
|
|
325
|
+
|
|
326
|
+
const result = await executeFindSimilarSkills(
|
|
327
|
+
{ goal: "the user's thing" },
|
|
328
|
+
makeRetrospectiveContext(),
|
|
329
|
+
{
|
|
330
|
+
nearestExistingSkills: async () => [
|
|
331
|
+
{ skillId: "user-skill", score: 1 },
|
|
332
|
+
],
|
|
333
|
+
loadCatalog: refinableCatalog,
|
|
334
|
+
readStoredManagedSkill,
|
|
335
|
+
},
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const { skills } = JSON.parse(result.content);
|
|
339
|
+
expect(skills[0].current).toEqual({
|
|
340
|
+
name: "User Skill",
|
|
341
|
+
description: "A person wrote this",
|
|
342
|
+
body_markdown: "Do the user's thing.",
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("an interactive caller never receives skill bodies, and no body is read", async () => {
|
|
347
|
+
installMetaAuthors["weekly-export"] = "assistant";
|
|
348
|
+
reads.length = 0;
|
|
349
|
+
|
|
350
|
+
const result = await executeFindSimilarSkills(
|
|
351
|
+
{ goal: "export the weekly report" },
|
|
352
|
+
makeContext(),
|
|
353
|
+
{
|
|
354
|
+
nearestExistingSkills: hits,
|
|
355
|
+
loadCatalog: refinableCatalog,
|
|
356
|
+
readStoredManagedSkill,
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const { skills } = JSON.parse(result.content);
|
|
361
|
+
for (const skill of skills) {
|
|
362
|
+
expect(skill).not.toHaveProperty("current");
|
|
363
|
+
}
|
|
364
|
+
expect(reads).toEqual([]);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("the stored body keeps its leading indentation and drops only the store's separator newlines", async () => {
|
|
368
|
+
// The real reader, against a real SKILL.md: a body opening with an
|
|
369
|
+
// indented code block must come back indented, or a refinement that
|
|
370
|
+
// copies it turns the block into prose.
|
|
371
|
+
const skillsDir = join(process.env.VELLUM_WORKSPACE_DIR!, "skills");
|
|
372
|
+
mkdirSync(join(skillsDir, "indented"), { recursive: true });
|
|
373
|
+
writeFileSync(
|
|
374
|
+
join(skillsDir, "indented", "SKILL.md"),
|
|
375
|
+
[
|
|
376
|
+
"---",
|
|
377
|
+
'name: "Indented"',
|
|
378
|
+
'description: "Opens with a code block"',
|
|
379
|
+
"---",
|
|
380
|
+
"",
|
|
381
|
+
" curl https://example.com/report",
|
|
382
|
+
"",
|
|
383
|
+
"Then read the output.",
|
|
384
|
+
"",
|
|
385
|
+
].join("\n"),
|
|
386
|
+
);
|
|
387
|
+
installMetaAuthors["indented"] = "assistant";
|
|
388
|
+
|
|
389
|
+
const result = await executeFindSimilarSkills(
|
|
390
|
+
{ goal: "fetch the report" },
|
|
391
|
+
makeRetrospectiveContext(),
|
|
392
|
+
{
|
|
393
|
+
nearestExistingSkills: async () => [{ skillId: "indented", score: 1 }],
|
|
394
|
+
loadCatalog: () =>
|
|
395
|
+
catalog({
|
|
396
|
+
id: "indented",
|
|
397
|
+
name: "Indented",
|
|
398
|
+
description: "Opens with a code block",
|
|
399
|
+
source: "managed",
|
|
400
|
+
}),
|
|
401
|
+
},
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const { skills } = JSON.parse(result.content);
|
|
405
|
+
expect(skills[0].current.body_markdown).toBe(
|
|
406
|
+
" curl https://example.com/report\n\nThen read the output.",
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("a skill that cannot be read drops current, not the hit", async () => {
|
|
411
|
+
installMetaAuthors["weekly-export"] = "assistant";
|
|
412
|
+
|
|
413
|
+
const result = await executeFindSimilarSkills(
|
|
414
|
+
{ goal: "export the weekly report" },
|
|
415
|
+
makeRetrospectiveContext(),
|
|
416
|
+
{
|
|
417
|
+
nearestExistingSkills: async () => [
|
|
418
|
+
{ skillId: "weekly-export", score: 0.9 },
|
|
419
|
+
],
|
|
420
|
+
loadCatalog: refinableCatalog,
|
|
421
|
+
readStoredManagedSkill: () => null,
|
|
422
|
+
},
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
const { skills } = JSON.parse(result.content);
|
|
426
|
+
expect(skills).toHaveLength(1);
|
|
427
|
+
expect(skills[0].skill_id).toBe("weekly-export");
|
|
428
|
+
expect(skills[0]).not.toHaveProperty("current");
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
220
432
|
describe("find_similar_skills — per-chat plugin scope", () => {
|
|
221
433
|
const SHORTLIST = [
|
|
222
434
|
{ skillId: "core-skill", score: 0.9 },
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { SkillSource } from "../../config/skills.js";
|
|
2
2
|
import { loadSkillCatalog } from "../../config/skills.js";
|
|
3
|
+
import { MEMORY_RETROSPECTIVE_ORIGIN } from "../../plugins/defaults/memory/memory-retrospective-constants.js";
|
|
3
4
|
import { nearestExistingSkills } from "../../plugins/defaults/memory/v3/candidate-match.js";
|
|
4
5
|
import { readInstallMeta } from "../../skills/install-meta.js";
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getManagedSkillDir,
|
|
8
|
+
readStoredManagedSkill,
|
|
9
|
+
type StoredManagedSkill,
|
|
10
|
+
} from "../../skills/managed-store.js";
|
|
6
11
|
import {
|
|
7
12
|
filterSkillsByPlatform,
|
|
8
13
|
type SkillPlatform,
|
|
@@ -21,6 +26,14 @@ import type { OwnerInfo, ToolContext, ToolExecutionResult } from "../types.js";
|
|
|
21
26
|
* `"user"` = a person wrote it, off-limits). It is undefined for non-managed
|
|
22
27
|
* sources and for managed skills with no recorded author, so the caller can
|
|
23
28
|
* distinguish its OWN managed skills from a user's without re-reading meta.
|
|
29
|
+
*
|
|
30
|
+
* `current` is the skill as stored, present only on a hit the caller may
|
|
31
|
+
* refine (managed, assistant-authored) and only for the retrospective. A
|
|
32
|
+
* refinement is a whole-file overwrite and the pass has no other read path:
|
|
33
|
+
* its `skill_load` grant covers skill-management alone, and loading would
|
|
34
|
+
* stamp `lastUsedAt` and count as usage. Fields are spelled as
|
|
35
|
+
* `scaffold_managed_skill`'s own arguments so the pass carries forward what
|
|
36
|
+
* it is not changing without translating names.
|
|
24
37
|
*/
|
|
25
38
|
interface EnrichedHit {
|
|
26
39
|
skill_id: string;
|
|
@@ -29,6 +42,19 @@ interface EnrichedHit {
|
|
|
29
42
|
source: SkillSource;
|
|
30
43
|
author?: "assistant" | "user";
|
|
31
44
|
score: number;
|
|
45
|
+
current?: CurrentSkill;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The refinable skill's present content, in `scaffold_managed_skill` argument names. */
|
|
49
|
+
interface CurrentSkill {
|
|
50
|
+
name: string;
|
|
51
|
+
description: string;
|
|
52
|
+
emoji?: string;
|
|
53
|
+
category?: string;
|
|
54
|
+
includes?: string[];
|
|
55
|
+
activation_hints?: string[];
|
|
56
|
+
avoid_when?: string[];
|
|
57
|
+
body_markdown: string;
|
|
32
58
|
}
|
|
33
59
|
|
|
34
60
|
/**
|
|
@@ -37,7 +63,8 @@ interface EnrichedHit {
|
|
|
37
63
|
* each joined to its catalog name/description. Exported so bundled-skill
|
|
38
64
|
* executors and tests can call it directly.
|
|
39
65
|
*
|
|
40
|
-
* `deps` injects the shortlist
|
|
66
|
+
* `deps` injects the shortlist, catalog, and stored-skill seams so tests run
|
|
67
|
+
* without Qdrant or a skills directory.
|
|
41
68
|
*/
|
|
42
69
|
export async function executeFindSimilarSkills(
|
|
43
70
|
input: Record<string, unknown>,
|
|
@@ -52,6 +79,7 @@ export async function executeFindSimilarSkills(
|
|
|
52
79
|
owner?: OwnerInfo;
|
|
53
80
|
platforms?: SkillPlatform[];
|
|
54
81
|
}[];
|
|
82
|
+
readStoredManagedSkill?: (skillId: string) => StoredManagedSkill | null;
|
|
55
83
|
} = {},
|
|
56
84
|
): Promise<ToolExecutionResult> {
|
|
57
85
|
const goal = input.goal;
|
|
@@ -79,6 +107,7 @@ export async function executeFindSimilarSkills(
|
|
|
79
107
|
|
|
80
108
|
const findNearest = deps.nearestExistingSkills ?? nearestExistingSkills;
|
|
81
109
|
const loadCatalog = deps.loadCatalog ?? (() => loadSkillCatalog());
|
|
110
|
+
const readStored = deps.readStoredManagedSkill ?? readStoredManagedSkill;
|
|
82
111
|
|
|
83
112
|
const catalog = loadCatalog();
|
|
84
113
|
const byId = new Map(catalog.map((s) => [s.id, s]));
|
|
@@ -114,6 +143,9 @@ export async function executeFindSimilarSkills(
|
|
|
114
143
|
...(context.signal ? { signal: context.signal } : {}),
|
|
115
144
|
});
|
|
116
145
|
|
|
146
|
+
const fromRetrospective =
|
|
147
|
+
context.requestOrigin === MEMORY_RETROSPECTIVE_ORIGIN;
|
|
148
|
+
|
|
117
149
|
const enriched: EnrichedHit[] = [];
|
|
118
150
|
for (const hit of hits) {
|
|
119
151
|
const skill = byId.get(hit.skillId);
|
|
@@ -126,19 +158,40 @@ export async function executeFindSimilarSkills(
|
|
|
126
158
|
if (outOfScope(skill)) {
|
|
127
159
|
continue;
|
|
128
160
|
}
|
|
161
|
+
// Join install-meta authorship for managed hits so the caller can tell its
|
|
162
|
+
// OWN skills (overwritable) from a user's. Best-effort: an absent/failed
|
|
163
|
+
// meta read leaves `author` undefined rather than throwing.
|
|
164
|
+
const author =
|
|
165
|
+
skill.source === "managed"
|
|
166
|
+
? readManagedSkillAuthor(hit.skillId)
|
|
167
|
+
: undefined;
|
|
168
|
+
// Only a refinable hit pays for the disk read, and a failed read drops
|
|
169
|
+
// `current` rather than the hit: the pass can still skip a skill it
|
|
170
|
+
// cannot see, it just cannot rewrite it well. Absent fields serialize
|
|
171
|
+
// away with the result.
|
|
172
|
+
const stored =
|
|
173
|
+
fromRetrospective && author === "assistant"
|
|
174
|
+
? readStored(hit.skillId)
|
|
175
|
+
: null;
|
|
129
176
|
enriched.push({
|
|
130
177
|
skill_id: hit.skillId,
|
|
131
178
|
name: skill.name,
|
|
132
179
|
description: skill.description,
|
|
133
180
|
source: skill.source,
|
|
134
|
-
|
|
135
|
-
// OWN skills (overwritable) from a user's. Best-effort: an absent/failed
|
|
136
|
-
// meta read leaves `author` undefined rather than throwing.
|
|
137
|
-
author:
|
|
138
|
-
skill.source === "managed"
|
|
139
|
-
? readManagedSkillAuthor(hit.skillId)
|
|
140
|
-
: undefined,
|
|
181
|
+
author,
|
|
141
182
|
score: hit.score,
|
|
183
|
+
current: stored
|
|
184
|
+
? {
|
|
185
|
+
name: stored.name,
|
|
186
|
+
description: stored.description,
|
|
187
|
+
emoji: stored.emoji,
|
|
188
|
+
category: stored.category,
|
|
189
|
+
includes: stored.includes,
|
|
190
|
+
activation_hints: stored.activationHints,
|
|
191
|
+
avoid_when: stored.avoidWhen,
|
|
192
|
+
body_markdown: stored.body,
|
|
193
|
+
}
|
|
194
|
+
: undefined,
|
|
142
195
|
});
|
|
143
196
|
}
|
|
144
197
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getTool } from "../registry.js";
|
|
2
|
+
import { resolveToolInvocationAlias } from "../tool-name-aliases.js";
|
|
3
|
+
import {
|
|
4
|
+
recoverSkillExecuteEnvelope,
|
|
5
|
+
resolveSkillExecuteInput,
|
|
6
|
+
} from "./execute.js";
|
|
7
|
+
|
|
8
|
+
export function resolveSkillExecuteInvocation(
|
|
9
|
+
input: Record<string, unknown>,
|
|
10
|
+
allowedToolNames?: ReadonlySet<string>,
|
|
11
|
+
): { name: string; input: Record<string, unknown> } {
|
|
12
|
+
const envelope = recoverSkillExecuteEnvelope(input);
|
|
13
|
+
const rawToolName = typeof envelope.tool === "string" ? envelope.tool : "";
|
|
14
|
+
const innerSchema = rawToolName
|
|
15
|
+
? getTool(rawToolName)?.input_schema
|
|
16
|
+
: undefined;
|
|
17
|
+
const rawToolInput = resolveSkillExecuteInput(envelope, innerSchema);
|
|
18
|
+
|
|
19
|
+
return resolveToolInvocationAlias(
|
|
20
|
+
rawToolName,
|
|
21
|
+
{ ...rawToolInput },
|
|
22
|
+
allowedToolNames,
|
|
23
|
+
);
|
|
24
|
+
}
|