@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.
Files changed (57) hide show
  1. package/Dockerfile +7 -7
  2. package/openapi.yaml +151 -53
  3. package/package.json +5 -4
  4. package/scripts/bundled-plugin-packages.ts +154 -0
  5. package/scripts/generate-bundled-plugin-packages.ts +29 -0
  6. package/scripts/test.ts +15 -13
  7. package/src/__tests__/managed-store.test.ts +121 -0
  8. package/src/__tests__/scaffold-managed-skill-tool.test.ts +88 -0
  9. package/src/calls/__tests__/voice-control-protocol.test.ts +24 -2
  10. package/src/calls/__tests__/voice-session-bridge.test.ts +22 -2
  11. package/src/calls/voice-control-protocol.ts +13 -4
  12. package/src/calls/voice-session-bridge.ts +27 -6
  13. package/src/cli/commands/__tests__/plugins.test.ts +66 -0
  14. package/src/cli/commands/plugins.ts +50 -18
  15. package/src/cli/lib/__tests__/install-from-github.test.ts +67 -0
  16. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +169 -0
  17. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +57 -0
  18. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +14 -0
  19. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +27 -1
  20. package/src/cli/lib/__tests__/plugin-details.test.ts +9 -2
  21. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +20 -0
  22. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +43 -0
  23. package/src/cli/lib/__tests__/search-plugins.test.ts +31 -0
  24. package/src/cli/lib/bundled-plugin-packages.json +4 -0
  25. package/src/cli/lib/bundled-plugin-packages.ts +87 -0
  26. package/src/cli/lib/diff-plugin.ts +1 -1
  27. package/src/cli/lib/inspect-plugin.ts +80 -12
  28. package/src/cli/lib/install-from-github.ts +133 -76
  29. package/src/cli/lib/plugin-catalog-cache.ts +22 -3
  30. package/src/cli/lib/plugin-catalog-local.ts +13 -3
  31. package/src/cli/lib/plugin-catalog-platform.ts +6 -1
  32. package/src/cli/lib/plugin-catalog-resolve.ts +20 -0
  33. package/src/cli/lib/plugin-details.ts +12 -0
  34. package/src/cli/lib/plugin-marketplace.ts +121 -21
  35. package/src/cli/lib/plugin-pin-history.ts +5 -2
  36. package/src/cli/lib/search-plugins.ts +58 -16
  37. package/src/cli/lib/upgrade-plugin.ts +48 -3
  38. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  39. package/src/config/bundled-skills/skill-management/TOOLS.json +7 -7
  40. package/src/daemon/conversation-tool-setup.ts +4 -22
  41. package/src/live-voice/__tests__/live-voice-look-follow-up.test.ts +374 -0
  42. package/src/live-voice/__tests__/live-voice-vad.test.ts +658 -3
  43. package/src/live-voice/__tests__/protocol.test.ts +48 -0
  44. package/src/live-voice/__tests__/session-controls.test.ts +43 -0
  45. package/src/live-voice/live-voice-session.ts +784 -64
  46. package/src/live-voice/protocol.ts +20 -0
  47. package/src/live-voice/session-controls.ts +61 -3
  48. package/src/monitoring/plugin-auto-update.ts +6 -0
  49. package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +15 -0
  50. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
  51. package/src/runtime/routes/__tests__/plugins-routes.test.ts +102 -0
  52. package/src/runtime/routes/plugins-routes.ts +69 -49
  53. package/src/skills/managed-store.ts +153 -31
  54. package/src/tools/skills/find-similar-skills.test.ts +214 -2
  55. package/src/tools/skills/find-similar-skills.ts +62 -9
  56. package/src/tools/skills/resolve-execute-invocation.ts +24 -0
  57. 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
- // Build metadata object matching the format parseFrontmatter expects:
232
- // metadata:
233
- // vellum:
234
- // emoji: "..."
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
- if (input.activationHints && input.activationHints.length > 0) {
247
- vellum["activation-hints"] = input.activationHints;
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
- if (input.avoidWhen && input.avoidWhen.length > 0) {
250
- vellum["avoid-when"] = input.avoidWhen;
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
- // The web Skills UI groups skills into a category sidebar by this value;
253
- // skip it when blank so an empty bucket assignment never lands in frontmatter.
254
- if (input.category?.trim()) {
255
- vellum.category = input.category.trim();
313
+ if (Object.keys(metadata).length > 0) {
314
+ rest.metadata = metadata;
315
+ } else {
316
+ delete rest.metadata;
256
317
  }
257
-
258
- if (Object.keys(vellum).length > 0) {
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
- if (existsSync(skillFilePath) && !params.overwrite) {
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 { basename } from "node:path";
18
- import { describe, expect, mock, test } from "bun:test";
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 { getManagedSkillDir } from "../../skills/managed-store.js";
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 + catalog seams so tests run without Qdrant.
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
- // Join install-meta authorship for managed hits so the caller can tell its
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
+ }