@vellumai/assistant 0.10.8-dev.202607141535.d82d641 → 0.10.8-dev.202607141817.ebca77a

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/AGENTS.md CHANGED
@@ -73,6 +73,14 @@ Some routes are IPC-only (defined in `src/ipc/routes/`, not in the shared array)
73
73
 
74
74
  The module-level dependency-injection pattern (`registerFooDeps()`) used by some IPC routes is a known antipattern. New IPC-only routes should avoid it.
75
75
 
76
+ ## Telemetry wire contract
77
+
78
+ `src/telemetry/telemetry-wire.generated.ts` is generated from the platform's telemetry ingest serializers and auto-synced here on platform merges (the platform's `sync-telemetry-wire.yaml` workflow). **Never edit it by hand** — contract changes belong in `vellum-assistant-platform` at `django/app/assistant/self_hosted_local/serializers.py`.
79
+
80
+ `src/telemetry/types.ts` is the override layer on top of it: simple events flow through `WireEventMap` without restating fields — they use the generated types directly, so their construction sites get excess-property/missing-field errors when the contract moves. Events where the daemon's type is intentionally richer live in `Overrides`, each pinned to the wire type by compile-time guards covering both drift directions: `_*Narrows` (daemon values stay wire-assignable — catches wire-side tightening) and `_*KeysExist` (the daemon emits no field the wire no longer declares — catches platform-side field removals/renames, which structural subtyping would otherwise let through silently). Daemon-only events live in `Extensions`. A red guard or a failing `types.test.ts` on a sync PR means the platform contract moved — reconcile the override to the new wire shape, don't loosen the guard.
81
+
82
+ Pre-flush validation (`src/telemetry/telemetry-wire-validation.ts`) checks outgoing events against the wire schemas and logs any the server would silently drop; it is observability only and never blocks or mutates the batch.
83
+
76
84
  ## Code comments
77
85
 
78
86
  When writing or updating comments, **do not reference code that has been removed.** Comments should describe the current state of the codebase, not narrate its history. Avoid phrases like "no longer does X", "previously used Y", or "was removed in PR Z" — future readers should not need to understand past implementations to understand the current code.
package/knip.json CHANGED
@@ -9,10 +9,7 @@
9
9
  "src/config/bundled-skills/**/tools/**/*.ts!"
10
10
  ],
11
11
  "project": ["src/**/*.ts!", "src/**/*.tsx!", "scripts/**/*.ts"],
12
- "ignore": [
13
- "src/config/preloaded-apps/**",
14
- "src/telemetry/telemetry-wire.generated.ts"
15
- ],
12
+ "ignore": ["src/config/preloaded-apps/**"],
16
13
  "ignoreDependencies": [
17
14
  "@microsoft/api-extractor",
18
15
  "@vellumai/ces-client",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.8-dev.202607141535.d82d641",
3
+ "version": "0.10.8-dev.202607141817.ebca77a",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,7 +23,7 @@ describe("oauth provider profiles (DB-seeded)", () => {
23
23
  );
24
24
  });
25
25
 
26
- test("google provider row contains bearer injection templates for 3 Google API hosts", () => {
26
+ test("google provider row contains bearer injection templates for 4 Google API hosts", () => {
27
27
  const provider = getProvider("google");
28
28
 
29
29
  expect(provider).toBeDefined();
@@ -36,7 +36,7 @@ describe("oauth provider profiles (DB-seeded)", () => {
36
36
  valuePrefix: string;
37
37
  }>;
38
38
 
39
- expect(templates).toHaveLength(3);
39
+ expect(templates).toHaveLength(4);
40
40
 
41
41
  const byHost = new Map(templates.map((t) => [t.hostPattern, t]));
42
42
 
@@ -44,6 +44,7 @@ describe("oauth provider profiles (DB-seeded)", () => {
44
44
  "gmail.googleapis.com",
45
45
  "www.googleapis.com",
46
46
  "people.googleapis.com",
47
+ "docs.googleapis.com",
47
48
  ]) {
48
49
  const tpl = byHost.get(host);
49
50
  expect(tpl).toBeDefined();
@@ -37,6 +37,7 @@ describe("buildBundledPluginCatalog", () => {
37
37
  category: "productivity",
38
38
  homepage: "https://github.com/JuliusBrussee/caveman",
39
39
  license: "MIT",
40
+ icon: "🦴",
40
41
  source: {
41
42
  kind: "github",
42
43
  repo: "JuliusBrussee/caveman",
@@ -76,19 +76,21 @@
76
76
  "description": "Ultra-compressed communication mode that strips filler words to cut token usage.",
77
77
  "category": "productivity",
78
78
  "homepage": "https://github.com/JuliusBrussee/caveman",
79
- "license": "MIT"
79
+ "license": "MIT",
80
+ "icon": "🦴"
80
81
  },
81
82
  {
82
83
  "name": "coffee-aficionado",
83
84
  "source": {
84
85
  "source": "github",
85
86
  "repo": "vellum-ai/coffee-aficionado",
86
- "ref": "b37a0b5adc2bda5559d3b21abe38e436093c1ff3"
87
+ "ref": "facd5a03979605edc894ea61e9a45f7b6a53214a"
87
88
  },
88
89
  "description": "Find great coffee beans and machines matched to your taste profile, budget, and kitchen. Persistent memory learns your preferences over time.",
89
90
  "category": "lifestyle",
90
91
  "homepage": "https://github.com/vellum-ai/coffee-aficionado",
91
- "license": "MIT"
92
+ "license": "MIT",
93
+ "icon": "☕"
92
94
  },
93
95
  {
94
96
  "name": "cofounder",
@@ -391,4 +393,4 @@
391
393
  "license": "MIT"
392
394
  }
393
395
  ]
394
- }
396
+ }
@@ -311,7 +311,7 @@
311
311
  "scope": "assistant",
312
312
  "key": "memory-concept-graph",
313
313
  "label": "Memory concept graph",
314
- "description": "Gates the Obsidian-style memory concept graph on the assistant identity page and the backend-agnostic /memory-graph + /memory-graph-node routes that feed it. When off, the identity page shows the skills constellation instead. Only produces a graph on memory-v3 backends. Default off.",
314
+ "description": "Gates the Obsidian-style memory concept graph on its own Memory tab (/assistant/memory) and the backend-agnostic /memory-graph + /memory-graph-node routes that feed it. When off, the Memory tab is hidden; the identity page keeps the skills constellation. Only produces a graph on memory-v3 backends. Default off.",
315
315
  "defaultEnabled": false
316
316
  },
317
317
  {
@@ -131,6 +131,12 @@ export const PROVIDER_SEED_DATA: Record<
131
131
  headerName: "Authorization",
132
132
  valuePrefix: "Bearer ",
133
133
  },
134
+ {
135
+ hostPattern: "docs.googleapis.com",
136
+ injectionType: "header",
137
+ headerName: "Authorization",
138
+ valuePrefix: "Bearer ",
139
+ },
134
140
  ],
135
141
  revokeUrl: "https://oauth2.googleapis.com/revoke",
136
142
  revokeBodyTemplate: { token: "{access_token}" },
@@ -4,7 +4,10 @@ import type { PageIndexEntry } from "../../v2/page-index.js";
4
4
  import type { Slug } from "../../v3/types.js";
5
5
  import { assembleMemoryGraph } from "../build-memory-graph.js";
6
6
 
7
- function entry(slug: string, over: Partial<PageIndexEntry> = {}): PageIndexEntry {
7
+ function entry(
8
+ slug: string,
9
+ over: Partial<PageIndexEntry> = {},
10
+ ): PageIndexEntry {
8
11
  return {
9
12
  id: 0,
10
13
  slug,
@@ -40,7 +43,9 @@ describe("assembleMemoryGraph", () => {
40
43
  entry("skills/agent-mail", { modifiedAt: 0 }),
41
44
  entry("send-email", { modifiedAt: 0 }),
42
45
  ],
43
- staticAdjacency: adjacency([["my-concept", "skills/agent-mail", undefined]]),
46
+ staticAdjacency: adjacency([
47
+ ["my-concept", "skills/agent-mail", undefined],
48
+ ]),
44
49
  });
45
50
 
46
51
  const byId = new Map(nodes.map((n) => [n.id, n]));
@@ -127,4 +132,78 @@ describe("assembleMemoryGraph", () => {
127
132
  expect(nodes.some((n) => n.id === edge.target)).toBe(true);
128
133
  }
129
134
  });
135
+
136
+ it("prunes disconnected functionality nodes but keeps connected ones and all concepts", () => {
137
+ const { nodes } = assembleMemoryGraph({
138
+ entries: [
139
+ entry("lonely-concept"), // concept, degree 0 → kept
140
+ entry("linked-concept"), // concept, links to a skill
141
+ entry("skills/connected", { modifiedAt: 0 }), // skill, degree 1 → kept
142
+ entry("skills/orphan", { modifiedAt: 0 }), // skill, degree 0 → pruned
143
+ entry("cli-commands/orphan", { modifiedAt: 0 }), // capability, degree 0 → pruned
144
+ ],
145
+ staticAdjacency: adjacency([
146
+ ["linked-concept", "skills/connected", undefined],
147
+ ]),
148
+ pruneDisconnectedNonConcepts: true,
149
+ });
150
+
151
+ expect(nodes.map((n) => n.id).sort()).toEqual([
152
+ "linked-concept",
153
+ "lonely-concept",
154
+ "skills/connected",
155
+ ]);
156
+ });
157
+
158
+ it("keeps disconnected functionality nodes when pruning is off (default)", () => {
159
+ const { nodes } = assembleMemoryGraph({
160
+ entries: [entry("a"), entry("skills/orphan", { modifiedAt: 0 })],
161
+ staticAdjacency: adjacency([]),
162
+ });
163
+ expect(nodes.map((n) => n.id).sort()).toEqual(["a", "skills/orphan"]);
164
+ });
165
+
166
+ it("re-prunes a functionality node stranded by truncation", () => {
167
+ const { nodes, edges, truncated } = assembleMemoryGraph({
168
+ entries: [
169
+ entry("hub"),
170
+ entry("h1"),
171
+ entry("h2"),
172
+ entry("h3"),
173
+ entry("p"),
174
+ entry("q"),
175
+ entry("skills/s", { modifiedAt: 0 }),
176
+ ],
177
+ // hub has degree 3; skills/s has degree 2 (p, q). The cap keeps the top 2
178
+ // by degree (hub, skills/s) but drops every one of skills/s's neighbors.
179
+ staticAdjacency: adjacency([
180
+ ["hub", "h1", undefined],
181
+ ["hub", "h2", undefined],
182
+ ["hub", "h3", undefined],
183
+ ["skills/s", "p", undefined],
184
+ ["skills/s", "q", undefined],
185
+ ]),
186
+ maxNodes: 2,
187
+ pruneDisconnectedNonConcepts: true,
188
+ });
189
+
190
+ expect(truncated).toBe(true);
191
+ // skills/s survived the cap but lost both neighbors → re-pruned as isolated;
192
+ // the isolated concept hub is kept (concepts always survive).
193
+ expect(nodes.map((n) => n.id)).toEqual(["hub"]);
194
+ expect(edges).toEqual([]);
195
+ });
196
+
197
+ it("treats a real page under a reserved prefix as a concept, not a prunable skill", () => {
198
+ // A non-colliding user page like `skills/my-notes` survives the page index
199
+ // with a real mtime; it must classify as a concept (by modifiedAt, not the
200
+ // slug prefix) and therefore never be pruned as a disconnected skill.
201
+ const { nodes } = assembleMemoryGraph({
202
+ entries: [entry("skills/my-notes", { modifiedAt: 5 })],
203
+ staticAdjacency: adjacency([]),
204
+ pruneDisconnectedNonConcepts: true,
205
+ });
206
+ expect(nodes).toHaveLength(1);
207
+ expect(nodes[0]).toMatchObject({ id: "skills/my-notes", kind: "concept" });
208
+ });
130
209
  });
@@ -22,7 +22,10 @@ import { getWorkspaceDir } from "../paths.js";
22
22
  import { getPageIndex, type PageIndexEntry } from "../v2/page-index.js";
23
23
  import { readPage, renderPageContent } from "../v2/page-store.js";
24
24
  import { isSkillSlug } from "../v2/skill-store.js";
25
- import { isCapabilitySlug } from "../v3/capabilities.js";
25
+ import {
26
+ isCapabilitySlug,
27
+ renderCapabilityContent,
28
+ } from "../v3/capabilities.js";
26
29
  import { buildEdgeGraph } from "../v3/edge.js";
27
30
  import { computeLearnedEdgeGraph } from "../v3/learned-edges.js";
28
31
  import type { Slug } from "../v3/types.js";
@@ -78,24 +81,18 @@ function humanizeSlug(slug: string): string {
78
81
  return words.replace(/\b\w/g, (c) => c.toUpperCase());
79
82
  }
80
83
 
81
- /** Node taxonomy tag used for coloring. Synthetic capability slugs (skills /
82
- * CLI commands) carry `modifiedAt: 0`; real concept pages carry a file mtime. */
84
+ /** Node taxonomy tag used for coloring. Only synthetic rows (`modifiedAt: 0`)
85
+ * are functionality: skills carry the `skills/` prefix, other synthetics (CLI
86
+ * commands) are capabilities. A real on-disk page keeps a file mtime and is a
87
+ * concept even when it happens to sit under a reserved prefix (e.g. a user page
88
+ * `skills/my-notes` with no matching skill survives the page index). */
83
89
  function nodeKind(entry: PageIndexEntry): string {
84
- if (isSkillSlug(entry.slug)) {
85
- return "skill";
86
- }
87
90
  if (entry.modifiedAt <= 0) {
88
- return "capability";
91
+ return isSkillSlug(entry.slug) ? "skill" : "capability";
89
92
  }
90
93
  return "concept";
91
94
  }
92
95
 
93
- /** A real concept page: on-disk (has an mtime) and not a synthetic skill or
94
- * CLI-command capability slug. The graph shows concepts only. */
95
- function isConceptEntry(entry: PageIndexEntry): boolean {
96
- return entry.modifiedAt > 0 && !isCapabilitySlug(entry.slug);
97
- }
98
-
99
96
  export interface AssembleMemoryGraphInput {
100
97
  /** Every article node in the corpus (page-index entries). */
101
98
  entries: readonly PageIndexEntry[];
@@ -105,6 +102,12 @@ export interface AssembleMemoryGraphInput {
105
102
  learnedAdjacency?: Adjacency;
106
103
  /** Node cap; defaults to {@link DEFAULT_MAX_NODES}. */
107
104
  maxNodes?: number;
105
+ /**
106
+ * When set, drop functionality nodes (kind `skill` / `capability`) that ended
107
+ * up with no edges — a skill nobody links to or co-selects is inert clutter.
108
+ * Concept nodes are always kept, even when isolated.
109
+ */
110
+ pruneDisconnectedNonConcepts?: boolean;
108
111
  }
109
112
 
110
113
  /**
@@ -199,6 +202,12 @@ export function assembleMemoryGraph(input: AssembleMemoryGraphInput): {
199
202
  return node;
200
203
  });
201
204
 
205
+ // Prune disconnected functionality nodes (see the option's doc). `weight` is
206
+ // the node's degree, so weight 0 ⇒ no incident edges ⇒ no edge cleanup needed.
207
+ if (input.pruneDisconnectedNonConcepts) {
208
+ nodes = nodes.filter((n) => n.kind === "concept" || (n.weight ?? 0) > 0);
209
+ }
210
+
202
211
  if (nodes.length <= maxNodes) {
203
212
  return { nodes, edges };
204
213
  }
@@ -211,6 +220,20 @@ export function assembleMemoryGraph(input: AssembleMemoryGraphInput): {
211
220
  const keptEdges = edges.filter(
212
221
  (e) => kept.has(e.source) && kept.has(e.target),
213
222
  );
223
+
224
+ // Truncation can strand a functionality node whose only neighbors were
225
+ // dropped. Re-prune against post-truncation degree so the connected-only
226
+ // guarantee holds even in a capped graph. An isolated node touches no kept
227
+ // edge by definition, so no further edge cleanup is needed.
228
+ if (input.pruneDisconnectedNonConcepts) {
229
+ const connected = new Set<string>();
230
+ for (const e of keptEdges) {
231
+ connected.add(e.source);
232
+ connected.add(e.target);
233
+ }
234
+ nodes = nodes.filter((n) => n.kind === "concept" || connected.has(n.id));
235
+ }
236
+
214
237
  return { nodes, edges: keptEdges, truncated: true };
215
238
  }
216
239
 
@@ -228,15 +251,28 @@ export async function getMemoryGraph(
228
251
 
229
252
  const workspaceDir = getWorkspaceDir();
230
253
  const pageIndex = await getPageIndex(workspaceDir);
231
- // Concepts only: exclude synthetic skill / CLI-command slugs so the graph is
232
- // purely the assistant's learned/authored concept pages. Edges to excluded
233
- // slugs drop out downstream because they aren't in the node set.
234
- const conceptEntries = pageIndex.entries.filter(isConceptEntry);
254
+ // Concepts plus functionality (skills / CLI-command capabilities). Feeding the
255
+ // full set to the edge builders is what lets a concept's `[[skills/foo]]` link
256
+ // and skill↔concept co-selections resolve to real edges — buildEdgeGraph and
257
+ // computeLearnedEdgeGraph both drop endpoints outside the set they're given.
258
+ // Functionality nodes that end up disconnected are pruned in assembleMemoryGraph.
259
+ const entries = pageIndex.entries;
260
+
261
+ // Synthetic rows (skills / CLI commands) carry `modifiedAt: 0` and have no
262
+ // on-disk page. Keyed by slug (not prefix) so a real user page that happens
263
+ // to live under a reserved prefix is NOT mistaken for a synthetic one.
264
+ const syntheticSlugs = new Set(
265
+ entries.filter((e) => e.modifiedAt <= 0).map((e) => e.slug),
266
+ );
235
267
 
236
268
  // Raw (frontmatter + body) page reader, matching the v3 lane build. A read
237
269
  // that rejects drops that article's authored/wikilink edges but keeps its
238
- // numeric fallbacks.
270
+ // numeric fallbacks. Synthetic capability rows have no page, so short-circuit
271
+ // their guaranteed-miss read; a real page is read so its links are captured.
239
272
  const pageRaw = async (slug: Slug): Promise<string> => {
273
+ if (syntheticSlugs.has(slug)) {
274
+ return "";
275
+ }
240
276
  const page = await readPage(workspaceDir, slug);
241
277
  if (!page) {
242
278
  throw new Error(`page not found: ${slug}`);
@@ -244,7 +280,7 @@ export async function getMemoryGraph(
244
280
  return renderPageContent(page);
245
281
  };
246
282
 
247
- const staticGraph = await buildEdgeGraph(conceptEntries, pageRaw, {
283
+ const staticGraph = await buildEdgeGraph(entries, pageRaw, {
248
284
  hubDegree: config.memory.v3.edge.hubDegree,
249
285
  });
250
286
 
@@ -265,39 +301,50 @@ export async function getMemoryGraph(
265
301
  maxPerPage: Math.max(learned.maxPerPage, GRAPH_LEARNED_MIN_MAX_PER_PAGE),
266
302
  now: Date.now(),
267
303
  windowMs: LEARNED_EDGES_WINDOW_DAYS * DAY_MS,
268
- knownSlugs: new Set(conceptEntries.map((e) => e.slug)),
304
+ knownSlugs: new Set(entries.map((e) => e.slug)),
269
305
  },
270
306
  );
271
307
 
272
308
  const assembled = assembleMemoryGraph({
273
- entries: conceptEntries,
309
+ entries,
274
310
  staticAdjacency: staticGraph.adjacency,
275
311
  learnedAdjacency: learnedGraph.adjacency,
312
+ pruneDisconnectedNonConcepts: true,
276
313
  });
277
314
 
278
315
  return { backend: BACKEND_MEMORY_V3, supported: true, ...assembled };
279
316
  }
280
317
 
281
318
  /**
282
- * Fetch a single concept node's content (its markdown body) by id. Used when a
283
- * user opens a node in the graph. Concepts only — skill/capability slugs and
284
- * unreadable pages return `{ found: false }`.
319
+ * Fetch a single node's content by id. Used when a user opens a node in the
320
+ * graph. Concept nodes return their page's markdown body; functionality nodes
321
+ * (skills / CLI commands) return the rendered capability statement. Unknown or
322
+ * unreadable ids return `{ found: false }`.
285
323
  */
286
324
  export async function getMemoryGraphNode(
287
325
  config: AssistantConfig,
288
326
  id: string,
289
327
  ): Promise<MemoryGraphNodeDetail> {
290
- if (
291
- !isMemoryConceptGraphEnabled(config) ||
292
- !isMemoryV3Live(config) ||
293
- !id ||
294
- isCapabilitySlug(id)
295
- ) {
328
+ if (!isMemoryConceptGraphEnabled(config) || !isMemoryV3Live(config) || !id) {
296
329
  return { found: false };
297
330
  }
331
+ // Seeded skill/CLI capabilities take precedence over any on-disk page at the
332
+ // same slug: the page index drops a colliding page and lets the synthetic win
333
+ // (v2/page-index.ts), so a `skills/foo` node built as the capability must not
334
+ // surface a stale disk page. renderCapabilityContent returns the rendered
335
+ // statement for a seeded capability, "" for an unseeded reserved-prefix slug
336
+ // (a real user page), and null for a normal concept slug.
337
+ if (isCapabilitySlug(id)) {
338
+ const content = renderCapabilityContent(id);
339
+ if (content) {
340
+ return { found: true, title: humanizeSlug(id), content };
341
+ }
342
+ }
343
+ // A real on-disk page: a concept, or a user page under a reserved prefix that
344
+ // isn't a seeded capability (kept in the index with a real mtime).
298
345
  const page = await readPage(getWorkspaceDir(), id).catch(() => null);
299
- if (!page) {
300
- return { found: false };
346
+ if (page) {
347
+ return { found: true, title: humanizeSlug(id), content: page.body };
301
348
  }
302
- return { found: true, title: humanizeSlug(id), content: page.body };
349
+ return { found: false };
303
350
  }
@@ -204,7 +204,7 @@ describe("VellumManagedRealtimeTranscriber", () => {
204
204
  expect.objectContaining({
205
205
  type: "error",
206
206
  category: "auth",
207
- message: expect.stringContaining("platform connect"),
207
+ message: expect.stringContaining("VELAY_BASE_URL"),
208
208
  }),
209
209
  );
210
210
  });
@@ -54,7 +54,10 @@ describe("resolveSpeechRelayConnection", () => {
54
54
 
55
55
  describe("mapVelayError", () => {
56
56
  test("maps the relay contract's codes onto categories", () => {
57
- expect(mapVelayError({ code: "invalid_key" }).category).toBe("auth");
57
+ expect(mapVelayError({ code: "invalid_key" })).toMatchObject({
58
+ category: "auth",
59
+ message: expect.stringContaining("VELAY_BASE_URL"),
60
+ });
58
61
  expect(
59
62
  mapVelayError({ code: "missing_platform_connection" }),
60
63
  ).toMatchObject({
@@ -99,6 +99,11 @@ export function mapVelayError(error: VelayErrorInfo): {
99
99
  } {
100
100
  switch (error.code) {
101
101
  case "invalid_key":
102
+ return {
103
+ category: "auth",
104
+ message:
105
+ "The Vellum speech relay rejected this assistant's API key — the assistant's platform environment may not match the relay it is dialing (set VELAY_BASE_URL on the gateway to the matching environment, e.g. https://velay-staging.vellum.ai for staging).",
106
+ };
102
107
  case "missing_platform_connection":
103
108
  return {
104
109
  category: "auth",