@vellumai/assistant 0.8.9-staging.3 → 0.8.9-staging.4

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 (90) hide show
  1. package/openapi.yaml +16 -115
  2. package/package.json +1 -1
  3. package/src/__tests__/anthropic-provider.test.ts +23 -8
  4. package/src/__tests__/context-window-manager-compact-retry.test.ts +120 -14
  5. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  6. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  7. package/src/__tests__/llm-resolver.test.ts +73 -0
  8. package/src/__tests__/plugin-external-api.test.ts +68 -0
  9. package/src/__tests__/published-app-updater.test.ts +138 -0
  10. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  11. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  12. package/src/cli/commands/memory-v3.ts +57 -199
  13. package/src/config/call-site-defaults.ts +0 -1
  14. package/src/config/llm-resolver.ts +39 -7
  15. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  16. package/src/config/schemas/call-site-catalog.ts +0 -7
  17. package/src/config/schemas/llm.ts +17 -1
  18. package/src/config/schemas/memory-v3.ts +58 -8
  19. package/src/config/seed-inference-profiles.ts +18 -0
  20. package/src/daemon/conversation-agent-loop.ts +9 -17
  21. package/src/daemon/external-plugins-bootstrap.ts +8 -3
  22. package/src/daemon/tool-side-effects.ts +15 -0
  23. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  24. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  25. package/src/memory/v2/consolidation-job.ts +2 -72
  26. package/src/memory/v2/types.ts +5 -0
  27. package/src/plugins/defaults/compaction/window-manager.ts +56 -0
  28. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +19 -66
  29. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  30. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  31. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +139 -129
  32. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +332 -164
  33. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +516 -293
  34. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  35. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +51 -21
  36. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  37. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  38. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  39. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +66 -11
  40. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  41. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +271 -110
  42. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +0 -22
  43. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +26 -62
  44. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  45. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  46. package/src/plugins/defaults/memory-v3-shadow/injector.ts +3 -2
  47. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +415 -181
  48. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +196 -56
  49. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +39 -2
  50. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  51. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +15 -7
  52. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  53. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  54. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  55. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +75 -3
  56. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +111 -78
  57. package/src/plugins/defaults/memory-v3-shadow/types.ts +52 -19
  58. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +4 -1
  59. package/src/plugins/external-api.ts +114 -0
  60. package/src/providers/anthropic/client.ts +9 -10
  61. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  62. package/src/providers/inference/logit-bias.ts +55 -0
  63. package/src/providers/openai/chat-completions-provider.ts +19 -1
  64. package/src/providers/retry.ts +22 -0
  65. package/src/providers/types.ts +6 -0
  66. package/src/runtime/routes/app-management-routes.ts +3 -0
  67. package/src/runtime/routes/memory-v3-routes.ts +64 -314
  68. package/src/services/published-app-updater.ts +30 -8
  69. package/src/tools/skills/load.ts +1 -1
  70. package/src/plugins/defaults/memory-v3-shadow/__tests__/assign.test.ts +0 -242
  71. package/src/plugins/defaults/memory-v3-shadow/__tests__/core.test.ts +0 -39
  72. package/src/plugins/defaults/memory-v3-shadow/__tests__/health.test.ts +0 -219
  73. package/src/plugins/defaults/memory-v3-shadow/__tests__/needle.test.ts +0 -107
  74. package/src/plugins/defaults/memory-v3-shadow/__tests__/provider-blocks.test.ts +0 -13
  75. package/src/plugins/defaults/memory-v3-shadow/__tests__/reconcile.test.ts +0 -274
  76. package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +0 -337
  77. package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +0 -470
  78. package/src/plugins/defaults/memory-v3-shadow/__tests__/snapshot.test.ts +0 -168
  79. package/src/plugins/defaults/memory-v3-shadow/__tests__/tree.test.ts +0 -192
  80. package/src/plugins/defaults/memory-v3-shadow/assign.ts +0 -272
  81. package/src/plugins/defaults/memory-v3-shadow/core.ts +0 -26
  82. package/src/plugins/defaults/memory-v3-shadow/health.ts +0 -0
  83. package/src/plugins/defaults/memory-v3-shadow/needle.ts +0 -115
  84. package/src/plugins/defaults/memory-v3-shadow/provider-blocks.ts +0 -26
  85. package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +0 -527
  86. package/src/plugins/defaults/memory-v3-shadow/router.ts +0 -190
  87. package/src/plugins/defaults/memory-v3-shadow/selector.ts +0 -226
  88. package/src/plugins/defaults/memory-v3-shadow/snapshot.ts +0 -209
  89. package/src/plugins/defaults/memory-v3-shadow/tree.ts +0 -174
  90. package/src/util/map-limit.ts +0 -27
@@ -0,0 +1,181 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import { makeMockLogger } from "../../../../__tests__/helpers/mock-logger.js";
4
+ import type { AssistantConfig } from "../../../../config/types.js";
5
+
6
+ mock.module("../../../../util/logger.js", () => ({
7
+ getLogger: () => makeMockLogger(),
8
+ }));
9
+
10
+ mock.module("../../../../memory/qdrant-client.js", () => ({
11
+ resolveQdrantUrl: () => "http://127.0.0.1:6333",
12
+ }));
13
+
14
+ // Stub the shared embedding backend. Records the queries it was asked to embed
15
+ // and returns one deterministic vector so `denseLane` can issue the search.
16
+ const embedState = {
17
+ calls: [] as string[][],
18
+ throws: null as Error | null,
19
+ };
20
+ mock.module("../../../../memory/embedding-backend.js", () => ({
21
+ embedWithBackend: async (_config: unknown, inputs: string[]) => {
22
+ embedState.calls.push(inputs);
23
+ if (embedState.throws) throw embedState.throws;
24
+ return {
25
+ provider: "local",
26
+ model: "test-model",
27
+ vectors: inputs.map(() => [0.1, 0.2, 0.3, 0.4]),
28
+ };
29
+ },
30
+ }));
31
+
32
+ // Mock the @qdrant/js-client-rest client. The mock records the query params and
33
+ // returns whatever section points the test programmed (in descending score
34
+ // order, matching Qdrant's behavior).
35
+ type MockPoint = {
36
+ payload: { article: string; ordinal: number };
37
+ score: number;
38
+ };
39
+
40
+ const state = {
41
+ points: [] as MockPoint[],
42
+ queryThrows: null as Error | null,
43
+ queryCalls: [] as Array<{
44
+ collection: string;
45
+ query: unknown;
46
+ limit: number;
47
+ }>,
48
+ };
49
+
50
+ class MockQdrantClient {
51
+ constructor(_opts: unknown) {}
52
+ async query(name: string, params: { query: unknown; limit: number }) {
53
+ state.queryCalls.push({
54
+ collection: name,
55
+ query: params.query,
56
+ limit: params.limit,
57
+ });
58
+ if (state.queryThrows) throw state.queryThrows;
59
+ return { points: state.points };
60
+ }
61
+ }
62
+
63
+ mock.module("@qdrant/js-client-rest", () => ({
64
+ QdrantClient: MockQdrantClient,
65
+ }));
66
+
67
+ const { denseLane, OVERSAMPLE } = await import("../dense.js");
68
+ const { SECTION_COLLECTION, _resetSectionDenseStoreForTests } =
69
+ await import("../section-dense-store.js");
70
+
71
+ const CONFIG = {
72
+ memory: { qdrant: { vectorSize: 4, onDisk: true } },
73
+ } as unknown as AssistantConfig;
74
+
75
+ function point(article: string, ordinal: number, score: number): MockPoint {
76
+ return { payload: { article, ordinal }, score };
77
+ }
78
+
79
+ function resetState(): void {
80
+ embedState.calls.length = 0;
81
+ embedState.throws = null;
82
+ state.points = [];
83
+ state.queryThrows = null;
84
+ state.queryCalls.length = 0;
85
+ _resetSectionDenseStoreForTests();
86
+ }
87
+
88
+ describe("memory v3 dense lane", () => {
89
+ beforeEach(resetState);
90
+ afterEach(resetState);
91
+
92
+ test("targets the section collection at k * OVERSAMPLE depth", async () => {
93
+ state.points = [point("page-a", 0, 0.9)];
94
+
95
+ await denseLane(CONFIG, "some query", 5);
96
+
97
+ // Embedded the query once.
98
+ expect(embedState.calls).toEqual([["some query"]]);
99
+ // Oversampled the section search so dedupe still yields k articles.
100
+ expect(state.queryCalls).toHaveLength(1);
101
+ expect(state.queryCalls[0]!.collection).toBe(SECTION_COLLECTION);
102
+ expect(state.queryCalls[0]!.limit).toBe(5 * OVERSAMPLE);
103
+ expect(state.queryCalls[0]!.query).toEqual([0.1, 0.2, 0.3, 0.4]);
104
+ });
105
+
106
+ test("dedupes section points to distinct articles with their best section", async () => {
107
+ // page-a's best section is ordinal 2 (highest score); the later page-a
108
+ // section is ignored once the article is already represented.
109
+ state.points = [
110
+ point("page-a", 2, 0.95),
111
+ point("topic-x", 0, 0.9),
112
+ point("page-a", 1, 0.5),
113
+ ];
114
+
115
+ const hits = await denseLane(CONFIG, "query", 5);
116
+
117
+ expect(hits).toEqual([
118
+ { article: "page-a", section: 2 },
119
+ { article: "topic-x", section: 0 },
120
+ ]);
121
+ });
122
+
123
+ test("oversamples then truncates to k distinct articles", async () => {
124
+ state.points = [
125
+ point("page-a", 0, 0.99),
126
+ point("page-b", 0, 0.98),
127
+ point("page-c", 0, 0.97),
128
+ point("page-d", 0, 0.96),
129
+ ];
130
+
131
+ const hits = await denseLane(CONFIG, "query", 2);
132
+
133
+ expect(hits).toEqual([
134
+ { article: "page-a", section: 0 },
135
+ { article: "page-b", section: 0 },
136
+ ]);
137
+ });
138
+
139
+ test("ignores malformed payloads", async () => {
140
+ state.points = [
141
+ {
142
+ payload: {
143
+ article: 123,
144
+ ordinal: 0,
145
+ } as unknown as MockPoint["payload"],
146
+ score: 0.9,
147
+ },
148
+ point("topic-x", 1, 0.8),
149
+ ];
150
+
151
+ const hits = await denseLane(CONFIG, "query", 5);
152
+
153
+ expect(hits).toEqual([{ article: "topic-x", section: 1 }]);
154
+ });
155
+
156
+ test("a thrown Qdrant search degrades to []", async () => {
157
+ state.queryThrows = new Error("qdrant unreachable");
158
+
159
+ const hits = await denseLane(CONFIG, "query", 5);
160
+
161
+ expect(hits).toEqual([]);
162
+ });
163
+
164
+ test("a failed embedding degrades to []", async () => {
165
+ embedState.throws = new Error("embed backend down");
166
+
167
+ const hits = await denseLane(CONFIG, "query", 5);
168
+
169
+ expect(hits).toEqual([]);
170
+ // No Qdrant round-trip when embedding fails.
171
+ expect(state.queryCalls).toHaveLength(0);
172
+ });
173
+
174
+ test("non-positive k short-circuits without a search", async () => {
175
+ const hits = await denseLane(CONFIG, "query", 0);
176
+
177
+ expect(hits).toEqual([]);
178
+ expect(embedState.calls).toEqual([]);
179
+ expect(state.queryCalls).toHaveLength(0);
180
+ });
181
+ });
@@ -0,0 +1,247 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import type { PageIndexEntry } from "../../../../memory/v2/page-index.js";
4
+ import { buildEdgeGraph, edgeExpand } from "../edge.js";
5
+ import type { Slug } from "../types.js";
6
+
7
+ /** Minimal page-index entry factory — only the fields the edge lane reads. */
8
+ function entry(id: number, slug: Slug, edges: number[] = []): PageIndexEntry {
9
+ return { id, slug, summary: "", edges, leaves: [], modifiedAt: 0 };
10
+ }
11
+
12
+ /** Build a raw-page reader from a fixture map; missing slugs read as "". */
13
+ function rawReader(pages: Record<string, string>) {
14
+ return async (slug: Slug): Promise<string> => pages[slug] ?? "";
15
+ }
16
+
17
+ /** Frontmatter helper: wrap a `links:` list around an optional body. */
18
+ function withLinks(links: string[], body = "body"): string {
19
+ const block = links.map((l) => ` - "${l}"`).join("\n");
20
+ return `---\ntitle: x\nlinks:\n${block}\n---\n\n${body}`;
21
+ }
22
+
23
+ describe("buildEdgeGraph — links: frontmatter", () => {
24
+ test("parses target + description split on the first ' — '", async () => {
25
+ const articles = [entry(1, "page-a"), entry(2, "page-b")];
26
+ const graph = await buildEdgeGraph(
27
+ articles,
28
+ rawReader({
29
+ "page-a": withLinks(["page-b — the b page — with an extra dash"]),
30
+ "page-b": "no frontmatter",
31
+ }),
32
+ );
33
+ const out = graph.adjacency.get("page-a")!;
34
+ // Split on the FIRST ' — ' only: description keeps the trailing dash text.
35
+ expect(out.get("page-b")).toBe("the b page — with an extra dash");
36
+ });
37
+
38
+ test("drops unknown/dangling link targets", async () => {
39
+ const articles = [entry(1, "page-a"), entry(2, "page-b")];
40
+ const graph = await buildEdgeGraph(
41
+ articles,
42
+ rawReader({
43
+ "page-a": withLinks([
44
+ "page-b — known target",
45
+ "page-ghost — dangling target not in corpus",
46
+ ]),
47
+ }),
48
+ );
49
+ const out = graph.adjacency.get("page-a")!;
50
+ expect(out.has("page-b")).toBe(true);
51
+ expect(out.has("page-ghost")).toBe(false);
52
+ expect([...out.keys()]).toEqual(["page-b"]);
53
+ });
54
+
55
+ test("a page with no links: falls back to wikilinks and numeric edges", async () => {
56
+ const articles = [
57
+ entry(1, "page-a", [2]), // numeric edge → page-b
58
+ entry(2, "page-b"),
59
+ entry(3, "topic-x"),
60
+ ];
61
+ const graph = await buildEdgeGraph(
62
+ articles,
63
+ rawReader({
64
+ // No links: frontmatter; body has an inline wikilink to topic-x.
65
+ "page-a": "plain body referencing [[topic-x]] inline",
66
+ }),
67
+ );
68
+ const out = graph.adjacency.get("page-a")!;
69
+ expect(out.has("page-b")).toBe(true); // from numeric edge
70
+ expect(out.has("topic-x")).toBe(true); // from wikilink
71
+ // No curated descriptions on wikilink/numeric edges.
72
+ expect(out.get("page-b")).toBeUndefined();
73
+ expect(out.get("topic-x")).toBeUndefined();
74
+ });
75
+ });
76
+
77
+ describe("buildEdgeGraph — three-source union", () => {
78
+ test("links ∪ wikilinks ∪ numeric edges merge into one adjacency map", async () => {
79
+ const articles = [
80
+ entry(1, "page-a", [4]), // numeric edge → topic-y
81
+ entry(2, "page-b"),
82
+ entry(3, "topic-x"),
83
+ entry(4, "topic-y"),
84
+ ];
85
+ const graph = await buildEdgeGraph(
86
+ articles,
87
+ rawReader({
88
+ "page-a": withLinks(
89
+ ["page-b — authored neighbour"],
90
+ "and a [[topic-x]] wikilink in the body",
91
+ ),
92
+ }),
93
+ );
94
+ const out = graph.adjacency.get("page-a")!;
95
+ expect(new Set(out.keys())).toEqual(
96
+ new Set(["page-b", "topic-x", "topic-y"]),
97
+ );
98
+ expect(out.get("page-b")).toBe("authored neighbour"); // from links
99
+ expect(out.get("topic-x")).toBeUndefined(); // from wikilink
100
+ expect(out.get("topic-y")).toBeUndefined(); // from numeric edge
101
+ });
102
+
103
+ test("read failure still keeps numeric-edge fallback", async () => {
104
+ const articles = [entry(1, "page-a", [2]), entry(2, "page-b")];
105
+ const graph = await buildEdgeGraph(articles, async () => {
106
+ throw new Error("read failed");
107
+ });
108
+ const out = graph.adjacency.get("page-a")!;
109
+ expect(out.has("page-b")).toBe(true);
110
+ });
111
+ });
112
+
113
+ describe("buildEdgeGraph — hubs", () => {
114
+ test("marks articles with in-degree > hubDegree as hubs", async () => {
115
+ // hub receives 3 inbound edges; with hubDegree=2 it is a hub.
116
+ const articles = [
117
+ entry(1, "page-a"),
118
+ entry(2, "page-b"),
119
+ entry(3, "page-c"),
120
+ entry(4, "hub"),
121
+ ];
122
+ const graph = await buildEdgeGraph(
123
+ articles,
124
+ rawReader({
125
+ "page-a": withLinks(["hub — to hub"]),
126
+ "page-b": withLinks(["hub — to hub"]),
127
+ "page-c": withLinks(["hub — to hub"]),
128
+ }),
129
+ { hubDegree: 2 },
130
+ );
131
+ expect(graph.hubs.has("hub")).toBe(true);
132
+ expect(graph.hubs.has("page-a")).toBe(false);
133
+ });
134
+ });
135
+
136
+ describe("edgeExpand", () => {
137
+ /** Build a star/chain graph: page-a → b,c,d,e,f,g ; topic-x → b. */
138
+ async function fixtureGraph(hubDegree = 30) {
139
+ const articles = [
140
+ entry(1, "page-a"),
141
+ entry(2, "page-b"),
142
+ entry(3, "page-c"),
143
+ entry(4, "page-d"),
144
+ entry(5, "page-e"),
145
+ entry(6, "page-f"),
146
+ entry(7, "page-g"),
147
+ entry(8, "topic-x"),
148
+ ];
149
+ return buildEdgeGraph(
150
+ articles,
151
+ rawReader({
152
+ "page-a": withLinks([
153
+ "page-b — neighbour b",
154
+ "page-c — neighbour c",
155
+ "page-d — neighbour d",
156
+ "page-e — neighbour e",
157
+ "page-f — neighbour f",
158
+ "page-g — neighbour g",
159
+ ]),
160
+ "topic-x": withLinks(["page-b — also to b"]),
161
+ }),
162
+ { hubDegree },
163
+ );
164
+ }
165
+
166
+ test("carries the curated links description through expansion", async () => {
167
+ const graph = await fixtureGraph();
168
+ const out = edgeExpand(graph, ["page-a"]);
169
+ const b = out.find((n) => n.article === "page-b");
170
+ expect(b?.description).toBe("neighbour b");
171
+ });
172
+
173
+ test("respects perSeed", async () => {
174
+ const graph = await fixtureGraph();
175
+ const out = edgeExpand(graph, ["page-a"], { perSeed: 2 });
176
+ expect(out).toHaveLength(2);
177
+ expect(out.map((n) => n.article)).toEqual(["page-b", "page-c"]);
178
+ });
179
+
180
+ test("respects cap across seeds", async () => {
181
+ const graph = await fixtureGraph();
182
+ // Two seeds each could add many; cap=3 truncates the total.
183
+ const out = edgeExpand(graph, ["page-a", "topic-x"], {
184
+ perSeed: 6,
185
+ cap: 3,
186
+ });
187
+ expect(out).toHaveLength(3);
188
+ });
189
+
190
+ test("respects seedCount (only top-N seeds expanded)", async () => {
191
+ // seedCount=1: only the first seed expands; the second seed's unique
192
+ // neighbour is never reached.
193
+ const articles = [
194
+ entry(1, "seed-1"),
195
+ entry(2, "seed-2"),
196
+ entry(3, "only-via-2"),
197
+ entry(4, "via-1"),
198
+ ];
199
+ const g = await buildEdgeGraph(
200
+ articles,
201
+ rawReader({
202
+ "seed-1": withLinks(["via-1 — x"]),
203
+ "seed-2": withLinks(["only-via-2 — y"]),
204
+ }),
205
+ );
206
+ const out = edgeExpand(g, ["seed-1", "seed-2"], { seedCount: 1 });
207
+ expect(out.map((n) => n.article)).toEqual(["via-1"]);
208
+ expect(out.find((n) => n.article === "only-via-2")).toBeUndefined();
209
+ });
210
+
211
+ test("excludes hub neighbours from expansion", async () => {
212
+ // page-b has in-degree 2 (page-a + topic-x). At the default threshold it
213
+ // is not a hub; lowering hubDegree below 2 makes it one.
214
+ const nonHubGraph = await fixtureGraph();
215
+ expect(nonHubGraph.hubs.has("page-b")).toBe(false);
216
+
217
+ const hubGraph = await fixtureGraph(1); // page-b in-degree 2 > 1 → hub
218
+ expect(hubGraph.hubs.has("page-b")).toBe(true);
219
+ const out = edgeExpand(hubGraph, ["page-a"]);
220
+ expect(out.find((n) => n.article === "page-b")).toBeUndefined();
221
+ // non-hub neighbours still surface
222
+ expect(out.find((n) => n.article === "page-c")).toBeDefined();
223
+ });
224
+
225
+ test("respects the alive predicate", async () => {
226
+ const graph = await fixtureGraph();
227
+ const out = edgeExpand(graph, ["page-a"], {
228
+ alive: (slug) => slug !== "page-c",
229
+ });
230
+ expect(out.find((n) => n.article === "page-c")).toBeUndefined();
231
+ expect(out.find((n) => n.article === "page-b")).toBeDefined();
232
+ });
233
+
234
+ test("does not surface the seeds themselves", async () => {
235
+ const articles = [entry(1, "page-a"), entry(2, "page-b")];
236
+ const graph = await buildEdgeGraph(
237
+ articles,
238
+ rawReader({
239
+ "page-a": withLinks(["page-b — to b"]),
240
+ "page-b": withLinks(["page-a — back to a"]),
241
+ }),
242
+ );
243
+ const out = edgeExpand(graph, ["page-a", "page-b"]);
244
+ // page-a and page-b are both seeds; neither should be surfaced.
245
+ expect(out).toHaveLength(0);
246
+ });
247
+ });