@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 +8 -0
- package/knip.json +1 -4
- package/package.json +1 -1
- package/src/__tests__/oauth-provider-profiles.test.ts +3 -2
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +1 -0
- package/src/cli/lib/bundled-marketplace.json +6 -4
- package/src/config/feature-flag-registry.json +1 -1
- package/src/oauth/seed-providers.ts +6 -0
- package/src/plugins/defaults/memory/graph-topology/__tests__/build-memory-graph.test.ts +81 -2
- package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +80 -33
- package/src/providers/speech-to-text/__tests__/vellum-managed-realtime.test.ts +1 -1
- package/src/providers/speech-to-text/__tests__/vellum-speech-relay-connection.test.ts +4 -1
- package/src/providers/speech-to-text/vellum-speech-relay-connection.ts +5 -0
- package/src/telemetry/__tests__/telemetry-event-fixtures.ts +260 -0
- package/src/telemetry/telemetry-wire-validation.test.ts +149 -0
- package/src/telemetry/telemetry-wire-validation.ts +125 -0
- package/src/telemetry/types.test.ts +50 -0
- package/src/telemetry/types.ts +171 -91
- package/src/telemetry/usage-telemetry-reporter.ts +5 -0
- package/src/telemetry/watchdog-direct-emit.ts +6 -0
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
|
@@ -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
|
|
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(
|
|
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();
|
|
@@ -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": "
|
|
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
|
|
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(
|
|
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([
|
|
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 {
|
|
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.
|
|
82
|
-
*
|
|
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
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
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(
|
|
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(
|
|
304
|
+
knownSlugs: new Set(entries.map((e) => e.slug)),
|
|
269
305
|
},
|
|
270
306
|
);
|
|
271
307
|
|
|
272
308
|
const assembled = assembleMemoryGraph({
|
|
273
|
-
entries
|
|
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
|
|
283
|
-
*
|
|
284
|
-
*
|
|
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 (
|
|
300
|
-
return { found:
|
|
346
|
+
if (page) {
|
|
347
|
+
return { found: true, title: humanizeSlug(id), content: page.body };
|
|
301
348
|
}
|
|
302
|
-
return { found:
|
|
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("
|
|
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" })
|
|
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",
|