@vellumai/assistant 0.12.2-staging.4 → 0.12.2-staging.5

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.
@@ -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,208 @@ 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
+ emoji: "📊",
261
+ category: "productivity",
262
+ includes: ["csv-basics"],
263
+ activationHints: ["user asks for the weekly report"],
264
+ avoidWhen: ["the report is monthly"],
265
+ body: "1. Open the dashboard.\n2. Export.",
266
+ },
267
+ "user-skill": {
268
+ name: "User Skill",
269
+ description: "A person wrote this",
270
+ body: "Do the user's thing.",
271
+ },
272
+ "clean-disk": {
273
+ name: "Clean Disk",
274
+ description: "Free up disk space",
275
+ body: "Delete caches.",
276
+ },
277
+ };
278
+ const reads: string[] = [];
279
+ const readStoredManagedSkill = (skillId: string) => {
280
+ reads.push(skillId);
281
+ return stored[skillId] ?? null;
282
+ };
283
+ const hits = async () => [
284
+ { skillId: "weekly-export", score: 0.9 },
285
+ { skillId: "user-skill", score: 0.8 },
286
+ { skillId: "clean-disk", score: 0.7 },
287
+ ];
288
+
289
+ test("the retrospective gets the skill as it is, in scaffold argument names, only for its own managed hits", async () => {
290
+ installMetaAuthors["weekly-export"] = "assistant";
291
+ installMetaAuthors["user-skill"] = "user";
292
+
293
+ const result = await executeFindSimilarSkills(
294
+ { goal: "export the weekly report" },
295
+ makeRetrospectiveContext(),
296
+ {
297
+ nearestExistingSkills: hits,
298
+ loadCatalog: refinableCatalog,
299
+ readStoredManagedSkill,
300
+ },
301
+ );
302
+
303
+ const { skills } = JSON.parse(result.content);
304
+ expect(skills[0].current).toEqual({
305
+ name: "Weekly Report Export",
306
+ description: "Export the weekly usage report",
307
+ emoji: "📊",
308
+ category: "productivity",
309
+ includes: ["csv-basics"],
310
+ activation_hints: ["user asks for the weekly report"],
311
+ avoid_when: ["the report is monthly"],
312
+ body_markdown: "1. Open the dashboard.\n2. Export.",
313
+ });
314
+ // A person's skill and a bundled skill are not the pass's to refine, so
315
+ // their content is not handed over.
316
+ expect(skills[1]).not.toHaveProperty("current");
317
+ expect(skills[2]).not.toHaveProperty("current");
318
+ });
319
+
320
+ test("absent frontmatter fields are omitted rather than sent as null", async () => {
321
+ installMetaAuthors["user-skill"] = "assistant";
322
+
323
+ const result = await executeFindSimilarSkills(
324
+ { goal: "the user's thing" },
325
+ makeRetrospectiveContext(),
326
+ {
327
+ nearestExistingSkills: async () => [
328
+ { skillId: "user-skill", score: 1 },
329
+ ],
330
+ loadCatalog: refinableCatalog,
331
+ readStoredManagedSkill,
332
+ },
333
+ );
334
+
335
+ const { skills } = JSON.parse(result.content);
336
+ expect(skills[0].current).toEqual({
337
+ name: "User Skill",
338
+ description: "A person wrote this",
339
+ body_markdown: "Do the user's thing.",
340
+ });
341
+ });
342
+
343
+ test("an interactive caller never receives skill bodies, and no body is read", async () => {
344
+ installMetaAuthors["weekly-export"] = "assistant";
345
+ reads.length = 0;
346
+
347
+ const result = await executeFindSimilarSkills(
348
+ { goal: "export the weekly report" },
349
+ makeContext(),
350
+ {
351
+ nearestExistingSkills: hits,
352
+ loadCatalog: refinableCatalog,
353
+ readStoredManagedSkill,
354
+ },
355
+ );
356
+
357
+ const { skills } = JSON.parse(result.content);
358
+ for (const skill of skills) {
359
+ expect(skill).not.toHaveProperty("current");
360
+ }
361
+ expect(reads).toEqual([]);
362
+ });
363
+
364
+ test("the stored body keeps its leading indentation and drops only the store's separator newlines", async () => {
365
+ // The real reader, against a real SKILL.md: a body opening with an
366
+ // indented code block must come back indented, or a refinement that
367
+ // copies it turns the block into prose.
368
+ const skillsDir = join(process.env.VELLUM_WORKSPACE_DIR!, "skills");
369
+ mkdirSync(join(skillsDir, "indented"), { recursive: true });
370
+ writeFileSync(
371
+ join(skillsDir, "indented", "SKILL.md"),
372
+ [
373
+ "---",
374
+ 'name: "Indented"',
375
+ 'description: "Opens with a code block"',
376
+ "---",
377
+ "",
378
+ " curl https://example.com/report",
379
+ "",
380
+ "Then read the output.",
381
+ "",
382
+ ].join("\n"),
383
+ );
384
+ installMetaAuthors["indented"] = "assistant";
385
+
386
+ const result = await executeFindSimilarSkills(
387
+ { goal: "fetch the report" },
388
+ makeRetrospectiveContext(),
389
+ {
390
+ nearestExistingSkills: async () => [{ skillId: "indented", score: 1 }],
391
+ loadCatalog: () =>
392
+ catalog({
393
+ id: "indented",
394
+ name: "Indented",
395
+ description: "Opens with a code block",
396
+ source: "managed",
397
+ }),
398
+ },
399
+ );
400
+
401
+ const { skills } = JSON.parse(result.content);
402
+ expect(skills[0].current.body_markdown).toBe(
403
+ " curl https://example.com/report\n\nThen read the output.",
404
+ );
405
+ });
406
+
407
+ test("a skill that cannot be read drops current, not the hit", async () => {
408
+ installMetaAuthors["weekly-export"] = "assistant";
409
+
410
+ const result = await executeFindSimilarSkills(
411
+ { goal: "export the weekly report" },
412
+ makeRetrospectiveContext(),
413
+ {
414
+ nearestExistingSkills: async () => [
415
+ { skillId: "weekly-export", score: 0.9 },
416
+ ],
417
+ loadCatalog: refinableCatalog,
418
+ readStoredManagedSkill: () => null,
419
+ },
420
+ );
421
+
422
+ const { skills } = JSON.parse(result.content);
423
+ expect(skills).toHaveLength(1);
424
+ expect(skills[0].skill_id).toBe("weekly-export");
425
+ expect(skills[0]).not.toHaveProperty("current");
426
+ });
427
+ });
428
+
220
429
  describe("find_similar_skills — per-chat plugin scope", () => {
221
430
  const SHORTLIST = [
222
431
  { 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