@vellumai/assistant 0.8.9-staging.2 → 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 (196) hide show
  1. package/docs/activation-funnel-telemetry.md +310 -0
  2. package/openapi.yaml +16 -115
  3. package/package.json +1 -1
  4. package/src/__tests__/activation-early-marking.test.ts +120 -0
  5. package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
  6. package/src/__tests__/anthropic-provider.test.ts +23 -8
  7. package/src/__tests__/approval-cascade.test.ts +1 -1
  8. package/src/__tests__/compaction-direct.test.ts +32 -18
  9. package/src/__tests__/compaction-events.test.ts +2 -2
  10. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  11. package/src/__tests__/context-overflow-reducer.test.ts +5 -5
  12. package/src/__tests__/context-window-manager-compact-retry.test.ts +121 -15
  13. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  14. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
  15. package/src/__tests__/conversation-error.test.ts +15 -1
  16. package/src/__tests__/conversation-history-web-search.test.ts +5 -0
  17. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  18. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
  19. package/src/__tests__/conversation-process-callsite.test.ts +1 -1
  20. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
  21. package/src/__tests__/conversation-queue.test.ts +1 -1
  22. package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
  23. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  24. package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
  25. package/src/__tests__/conversation-speed-override.test.ts +1 -1
  26. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  27. package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
  28. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  29. package/src/__tests__/conversation-undo.test.ts +2 -2
  30. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
  31. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  32. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  33. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  34. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  35. package/src/__tests__/history-repair-hook.test.ts +2 -0
  36. package/src/__tests__/llm-resolver.test.ts +73 -0
  37. package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
  38. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  39. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  40. package/src/__tests__/plugin-external-api.test.ts +68 -0
  41. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  42. package/src/__tests__/published-app-updater.test.ts +138 -0
  43. package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
  44. package/src/__tests__/title-generate-hook.test.ts +2 -0
  45. package/src/__tests__/web-fetch.test.ts +45 -0
  46. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  47. package/src/acp/resolve-agent.test.ts +0 -56
  48. package/src/acp/resolve-agent.ts +10 -38
  49. package/src/agent/loop.ts +13 -27
  50. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  51. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  52. package/src/cli/commands/memory-v3.ts +57 -199
  53. package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
  54. package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
  55. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
  56. package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
  57. package/src/cli/lib/install-from-github.ts +258 -41
  58. package/src/cli/lib/plugin-details.ts +20 -13
  59. package/src/cli/lib/plugin-marketplace.ts +23 -5
  60. package/src/cli/lib/search-plugins.ts +14 -8
  61. package/src/config/acp-defaults.ts +3 -3
  62. package/src/config/acp-schema.ts +1 -7
  63. package/src/config/bundled-skills/acp/SKILL.md +4 -17
  64. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  65. package/src/config/call-site-defaults.ts +0 -1
  66. package/src/config/feature-flag-registry.json +3 -18
  67. package/src/config/llm-resolver.ts +39 -7
  68. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  69. package/src/config/schemas/call-site-catalog.ts +0 -7
  70. package/src/config/schemas/llm.ts +17 -1
  71. package/src/config/schemas/memory-v3.ts +58 -8
  72. package/src/config/seed-inference-profiles.ts +18 -0
  73. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  74. package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
  75. package/src/daemon/conversation-agent-loop.ts +87 -95
  76. package/src/daemon/conversation-error.ts +31 -4
  77. package/src/daemon/conversation-history.ts +1 -1
  78. package/src/daemon/conversation-media-retry.ts +19 -6
  79. package/src/daemon/conversation-messaging.ts +17 -0
  80. package/src/daemon/conversation-process.ts +14 -5
  81. package/src/daemon/conversation-queue-manager.ts +8 -0
  82. package/src/daemon/conversation-runtime-assembly.ts +37 -1
  83. package/src/daemon/conversation-surfaces.ts +141 -3
  84. package/src/daemon/conversation.ts +48 -13
  85. package/src/daemon/external-plugins-bootstrap.ts +8 -3
  86. package/src/daemon/persist-unsendable-image.ts +62 -25
  87. package/src/daemon/process-message.ts +1 -1
  88. package/src/daemon/tool-side-effects.ts +15 -0
  89. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  90. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  91. package/src/memory/activation-session-store.ts +43 -0
  92. package/src/memory/db-init.ts +4 -0
  93. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  94. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  95. package/src/memory/migrations/index.ts +2 -0
  96. package/src/memory/onboarding-events-store.ts +66 -18
  97. package/src/memory/schema/infrastructure.ts +13 -0
  98. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  99. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  100. package/src/memory/v2/consolidation-job.ts +2 -72
  101. package/src/memory/v2/types.ts +5 -0
  102. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  103. package/src/notifications/adapters/telegram.ts +7 -1
  104. package/src/plugin-api/constants.ts +2 -2
  105. package/src/plugin-api/index.ts +2 -2
  106. package/src/plugin-api/types.ts +19 -5
  107. package/src/plugins/defaults/compaction/compact.ts +24 -15
  108. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
  109. package/src/plugins/defaults/compaction/manager-store.ts +1 -1
  110. package/src/{context → plugins/defaults/compaction}/window-manager.ts +68 -12
  111. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
  112. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +19 -66
  113. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  114. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  115. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +139 -129
  116. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +332 -164
  117. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +516 -293
  118. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  119. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +51 -21
  120. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  121. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  122. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  123. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +66 -11
  124. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  125. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +271 -110
  126. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +0 -22
  127. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +26 -62
  128. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  129. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  130. package/src/plugins/defaults/memory-v3-shadow/injector.ts +3 -2
  131. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +415 -181
  132. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +196 -56
  133. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +39 -2
  134. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  135. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +15 -7
  136. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  137. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  138. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  139. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +75 -3
  140. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +111 -78
  141. package/src/plugins/defaults/memory-v3-shadow/types.ts +52 -19
  142. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +4 -1
  143. package/src/plugins/external-api.ts +114 -0
  144. package/src/prompts/system-prompt.ts +61 -10
  145. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
  146. package/src/providers/anthropic/client.ts +9 -10
  147. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  148. package/src/providers/inference/logit-bias.ts +55 -0
  149. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  150. package/src/providers/openai/chat-completions-provider.ts +44 -1
  151. package/src/providers/retry.ts +22 -0
  152. package/src/providers/types.ts +6 -0
  153. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  154. package/src/runtime/routes/acp-routes.test.ts +3 -20
  155. package/src/runtime/routes/app-management-routes.ts +3 -0
  156. package/src/runtime/routes/conversation-routes.ts +2 -0
  157. package/src/runtime/routes/memory-v3-routes.ts +64 -314
  158. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  159. package/src/runtime/routes/stt-routes.ts +45 -12
  160. package/src/runtime/routes/workspace-routes.ts +50 -15
  161. package/src/services/published-app-updater.ts +30 -8
  162. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  163. package/src/telemetry/activation-funnel.ts +167 -0
  164. package/src/telemetry/types.ts +13 -0
  165. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  166. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  167. package/src/tools/acp/list-agents.test.ts +2 -18
  168. package/src/tools/acp/list-agents.ts +3 -15
  169. package/src/tools/acp/spawn.test.ts +0 -10
  170. package/src/tools/browser/browser-execution.ts +12 -2
  171. package/src/tools/network/web-fetch.ts +65 -24
  172. package/src/tools/skills/load.ts +1 -1
  173. package/src/tools/ui-surface/definitions.ts +7 -0
  174. package/src/acp/feature-gate.test.ts +0 -48
  175. package/src/acp/feature-gate.ts +0 -34
  176. package/src/plugins/defaults/memory-v3-shadow/__tests__/assign.test.ts +0 -242
  177. package/src/plugins/defaults/memory-v3-shadow/__tests__/core.test.ts +0 -39
  178. package/src/plugins/defaults/memory-v3-shadow/__tests__/health.test.ts +0 -219
  179. package/src/plugins/defaults/memory-v3-shadow/__tests__/needle.test.ts +0 -107
  180. package/src/plugins/defaults/memory-v3-shadow/__tests__/provider-blocks.test.ts +0 -13
  181. package/src/plugins/defaults/memory-v3-shadow/__tests__/reconcile.test.ts +0 -274
  182. package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +0 -337
  183. package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +0 -470
  184. package/src/plugins/defaults/memory-v3-shadow/__tests__/snapshot.test.ts +0 -168
  185. package/src/plugins/defaults/memory-v3-shadow/__tests__/tree.test.ts +0 -192
  186. package/src/plugins/defaults/memory-v3-shadow/assign.ts +0 -272
  187. package/src/plugins/defaults/memory-v3-shadow/core.ts +0 -26
  188. package/src/plugins/defaults/memory-v3-shadow/health.ts +0 -0
  189. package/src/plugins/defaults/memory-v3-shadow/needle.ts +0 -115
  190. package/src/plugins/defaults/memory-v3-shadow/provider-blocks.ts +0 -26
  191. package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +0 -527
  192. package/src/plugins/defaults/memory-v3-shadow/router.ts +0 -190
  193. package/src/plugins/defaults/memory-v3-shadow/selector.ts +0 -226
  194. package/src/plugins/defaults/memory-v3-shadow/snapshot.ts +0 -209
  195. package/src/plugins/defaults/memory-v3-shadow/tree.ts +0 -174
  196. package/src/util/map-limit.ts +0 -27
@@ -1,23 +1,10 @@
1
1
  /**
2
- * Memory v3 — synthetic "capabilities" leaf (skills + `assistant` CLI commands).
2
+ * Memory v3 — synthetic capability rows (skills + `assistant` CLI commands).
3
3
  *
4
- * v2 surfaces the assistant's invokable capabilities — installed/catalog skills
5
- * and top-level CLI subcommands — by seeding them as synthetic concept-collection
6
- * rows (`skills/<id>`, `cli-commands/<name>`) into the unified router pool, then
7
- * rendering the router-selected ones under `### Skills You Can Use` / `### CLI
8
- * Commands You Can Use`. They are NOT always-injected — they are router-selected
9
- * per turn, same as concept pages.
10
- *
11
- * v3 reproduces that behavior with one always-on leaf:
12
- * - The leaf is synthesized in code (not a `leaves/*.md` data file) and injected
13
- * ONLY into the live lane tree (see `shadow-plugin.ts`). Because the classifier
14
- * builds its own tree without this injection, concept pages are never routed
15
- * INTO the capabilities leaf.
16
- * - Its members are the synthetic skill/CLI slugs, so the BM25 needle indexes
17
- * them for free (the needle corpus is `tree.byPage`).
18
- * - It is added to the always-on core set, so L1 always opens it and the per-leaf
19
- * L2 selects the relevant subset each turn — the semantic equivalent of v2's
20
- * "always in the pool, selected per turn".
4
+ * The assistant's invokable capabilities — installed/catalog skills and
5
+ * top-level CLI subcommands — are seeded as synthetic concept-collection rows
6
+ * (`skills/<id>`, `cli-commands/<name>`). The section-lane retrieval pipeline
7
+ * surfaces the relevant ones per turn, same as concept pages.
21
8
  *
22
9
  * Page summaries for synthetic slugs already resolve through the existing
23
10
  * `pageSummary` lane (the v2 page index appends skill/CLI rows with a summary),
@@ -33,56 +20,13 @@ import {
33
20
  getSkillCapability,
34
21
  isSkillSlug,
35
22
  } from "../../../memory/v2/skill-store.js";
36
- import type { LeafNode, LeafPath, LeafTree, Slug } from "./types.js";
37
-
38
- /** Path of the always-on synthetic leaf that owns skill + CLI capability rows. */
39
- export const CAPABILITIES_LEAF_PATH: LeafPath = "capabilities";
40
-
41
- /** L1/needle label for the capabilities leaf. */
42
- export const CAPABILITIES_LEAF_DESCRIPTION =
43
- "Tools the assistant can invoke: installed and available skills, and the " +
44
- "top-level `assistant` CLI subcommands — what the assistant can DO and how " +
45
- "to reach for each capability.";
23
+ import type { Slug } from "./types.js";
46
24
 
47
25
  /** True iff the slug is a synthetic skill or CLI-command capability row. */
48
26
  export function isCapabilitySlug(slug: Slug): boolean {
49
27
  return isSkillSlug(slug) || isCliCommandSlug(slug);
50
28
  }
51
29
 
52
- /**
53
- * Inject the synthetic capabilities leaf into a live lane tree: register the leaf
54
- * node with `syntheticSlugs` as members, add the leaf to each member's `byPage`
55
- * entry (UNION — never drops existing leaves), and mark the leaf always-on by
56
- * adding its path to `core`. Mutates `tree` and `core` in place. Idempotent.
57
- *
58
- * Must run BEFORE the needle is built so the synthetic members land in the needle
59
- * corpus (`tree.byPage`).
60
- */
61
- export function injectCapabilitiesLeaf(
62
- tree: LeafTree,
63
- core: Set<LeafPath>,
64
- syntheticSlugs: Slug[],
65
- ): void {
66
- const node: LeafNode = {
67
- path: CAPABILITIES_LEAF_PATH,
68
- frontmatter: { path: CAPABILITIES_LEAF_PATH, in_core: true },
69
- description: CAPABILITIES_LEAF_DESCRIPTION,
70
- members: [...syntheticSlugs],
71
- domain: CAPABILITIES_LEAF_PATH,
72
- };
73
- tree.leaves.set(CAPABILITIES_LEAF_PATH, node);
74
-
75
- for (const slug of syntheticSlugs) {
76
- const existing = tree.byPage.get(slug) ?? [];
77
- if (!existing.includes(CAPABILITIES_LEAF_PATH)) {
78
- tree.byPage.set(slug, [...existing, CAPABILITIES_LEAF_PATH]);
79
- }
80
- }
81
-
82
- // Always-on: L1 always opens it, L2 selects the relevant subset per turn.
83
- core.add(CAPABILITIES_LEAF_PATH);
84
- }
85
-
86
30
  interface CapabilityEntry {
87
31
  id: string;
88
32
  content: string;
@@ -125,3 +69,23 @@ export function renderCapabilityContent(
125
69
  }
126
70
  return null;
127
71
  }
72
+
73
+ /**
74
+ * Resolve a slug's frontmatter-stripped body for the section index: synthetic
75
+ * skill/CLI capability slugs have no on-disk page, so they contribute their
76
+ * rendered capability content (exactly what `page-content.ts` injects for them),
77
+ * while real pages fall through to `readDiskBody`. Shared by `initLanes`'
78
+ * `pageBody` and the full-backfill body reader so the capability-or-disk dispatch
79
+ * lives in one place.
80
+ *
81
+ * `readDiskBody` is injected (rather than imported) so this helper does not pull
82
+ * the page store into `capabilities.ts` — each caller supplies its own cached or
83
+ * direct disk reader.
84
+ */
85
+ export async function capabilityOrDiskBody(
86
+ slug: Slug,
87
+ readDiskBody: (slug: Slug) => Promise<string>,
88
+ ): Promise<string> {
89
+ if (isCapabilitySlug(slug)) return renderCapabilityContent(slug) ?? "";
90
+ return readDiskBody(slug);
91
+ }
@@ -0,0 +1,97 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Memory v3 — dense retrieval lane (section-grain)
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // Read counterpart to `section-dense-store.ts`. Embeds the turn query and runs
6
+ // a single cosine search against the `memory_v3_sections` collection, then
7
+ // dedupes the matched section points down to the top-`k` distinct articles —
8
+ // each carrying its best-scoring section ordinal. This is the dense lane of the
9
+ // section-grain retrieval design: where the v2 dense lane matches whole pages,
10
+ // this one matches the single most relevant section of a long article and hands
11
+ // the orchestrator both the article and which section matched (so the selector
12
+ // can show the matched section as the descriptor).
13
+ //
14
+ // Degrades safely: any embedding or Qdrant failure logs a warning and returns
15
+ // `[]`. The orchestrator unions the other lanes (needle, edge) plus carry-
16
+ // forward regardless, so a dense outage narrows recall but never breaks a turn.
17
+
18
+ import type { AssistantConfig } from "../../../config/types.js";
19
+ import { embedWithBackend } from "../../../memory/embedding-backend.js";
20
+ import { getLogger } from "../../../util/logger.js";
21
+ import {
22
+ getSectionDenseClient,
23
+ SECTION_COLLECTION,
24
+ } from "./section-dense-store.js";
25
+ import type { Slug } from "./types.js";
26
+
27
+ const log = getLogger("memory-v3-dense-lane");
28
+
29
+ /**
30
+ * Multiplier applied to `k` when fetching section points from Qdrant. Several
31
+ * sections can belong to the same article, so we oversample the section hits to
32
+ * leave room for the article-level dedupe to still yield `k` distinct articles.
33
+ */
34
+ export const OVERSAMPLE = 6;
35
+
36
+ /** A single dense-lane hit: an article plus the section ordinal that matched. */
37
+ export interface DenseHit {
38
+ article: Slug;
39
+ section: number;
40
+ }
41
+
42
+ /**
43
+ * Run the dense lane: embed `query`, search the section collection for the top
44
+ * `k * OVERSAMPLE` section points, then dedupe to the top-`k` distinct articles
45
+ * — each with its best-scoring section ordinal. Section points are returned by
46
+ * Qdrant in descending score order, so the first time an article is seen is its
47
+ * best section; subsequent sections of the same article are ignored.
48
+ *
49
+ * Returns `[]` on any embedding or Qdrant failure (logged at warn level).
50
+ */
51
+ export async function denseLane(
52
+ config: AssistantConfig,
53
+ query: string,
54
+ k: number,
55
+ ): Promise<DenseHit[]> {
56
+ if (k <= 0) return [];
57
+
58
+ let points: Array<{ payload?: unknown; score?: number }>;
59
+ try {
60
+ const { vectors } = await embedWithBackend(config, [query]);
61
+ const vector = vectors[0];
62
+ if (!vector || vector.length === 0) return [];
63
+
64
+ const result = await getSectionDenseClient(config).query(
65
+ SECTION_COLLECTION,
66
+ {
67
+ query: vector,
68
+ limit: k * OVERSAMPLE,
69
+ with_payload: true,
70
+ },
71
+ );
72
+ points = result.points;
73
+ } catch (err) {
74
+ log.warn({ err }, "memory v3 dense lane failed; degrading to no hits");
75
+ return [];
76
+ }
77
+
78
+ // Walk hits in score order, keeping the first (best) section per article and
79
+ // stopping once we have `k` distinct articles.
80
+ const seen = new Set<Slug>();
81
+ const hits: DenseHit[] = [];
82
+ for (const point of points) {
83
+ const payload = point.payload as
84
+ | { article?: unknown; ordinal?: unknown }
85
+ | null
86
+ | undefined;
87
+ const article = payload?.article;
88
+ const ordinal = payload?.ordinal;
89
+ if (typeof article !== "string" || typeof ordinal !== "number") continue;
90
+ if (seen.has(article)) continue;
91
+ seen.add(article);
92
+ hits.push({ article, section: ordinal });
93
+ if (hits.length >= k) break;
94
+ }
95
+
96
+ return hits;
97
+ }
@@ -0,0 +1,252 @@
1
+ import type { PageIndexEntry } from "../../../memory/v2/page-index.js";
2
+ import { parseFrontmatterFields } from "../../../skills/frontmatter.js";
3
+ import type { Slug } from "./types.js";
4
+
5
+ /**
6
+ * Edge lane for memory-v3 retrieval: a directed article link-graph used to
7
+ * expand a turn's lexical/dense seeds outward to their first-class neighbours.
8
+ *
9
+ * The graph unions THREE outbound-edge sources per article:
10
+ *
11
+ * (a) The optional `links:` frontmatter — a YAML list of
12
+ * `"<target-slug> — <description>"` strings (split on the first
13
+ * ` — `, space-emdash-space). This is the authored, first-class edge
14
+ * source: the curated description is carried through expansion so the
15
+ * orchestrator can use it directly as a select descriptor.
16
+ * (b) Inline `[[wikilink]]` targets parsed from the body.
17
+ * (c) `PageIndexEntry.edges` numeric ids resolved to slugs — the fallback
18
+ * for pages with no frontmatter `links`.
19
+ *
20
+ * All targets are resolved against the corpus slug set; unknown/dangling
21
+ * targets are dropped. A curated description is stored per (source → target)
22
+ * edge only when the `links` source supplied one; wikilink/numeric edges carry
23
+ * no description (the orchestrator falls back to a section descriptor).
24
+ *
25
+ * The build is pure given a `pageRaw` reader callback (no hard-coded I/O), so
26
+ * it is built once at lane-init and rebuilt by the maintain job, and is
27
+ * trivially unit-testable with a stub reader.
28
+ */
29
+
30
+ /** Default in-degree above which an article is treated as a hub and excluded
31
+ * from expansion (a hub neighbour is too generic to be a useful surface). */
32
+ const DEFAULT_HUB_DEGREE = 30;
33
+
34
+ const DEFAULT_SEED_COUNT = 18;
35
+ const DEFAULT_PER_SEED = 6;
36
+ const DEFAULT_CAP = 45;
37
+
38
+ /** Matches the space-emdash-space separator in a `links:` entry. */
39
+ const LINK_SEPARATOR = " — ";
40
+
41
+ /** Matches `[[target]]` wikilinks; captures the raw target before any `|`
42
+ * display-text or `#section` anchor. */
43
+ const WIKILINK_REGEX = /\[\[([^\]]+)\]\]/g;
44
+
45
+ /**
46
+ * The directed link-graph. `adjacency` maps each source slug to its outbound
47
+ * edges (target slug → curated description, or `undefined` when the edge came
48
+ * from a wikilink/numeric source). `hubs` is the set of high-in-degree slugs
49
+ * excluded from expansion. `slugs` is the resolved corpus slug set.
50
+ */
51
+ export interface EdgeGraph {
52
+ adjacency: Map<Slug, Map<Slug, string | undefined>>;
53
+ hubs: Set<Slug>;
54
+ slugs: Set<Slug>;
55
+ }
56
+
57
+ /** One article surfaced by {@link edgeExpand}, with the curated `links`
58
+ * description of the traversed edge when it carried one (else `undefined`). */
59
+ export interface EdgeNeighbor {
60
+ article: Slug;
61
+ description?: string;
62
+ }
63
+
64
+ export interface EdgeExpandOptions {
65
+ /** Predicate gating which neighbours may be surfaced (e.g. liveness /
66
+ * not-already-selected). Neighbours failing `alive` are skipped. */
67
+ alive?: (slug: Slug) => boolean;
68
+ /** Only the top `seedCount` seeds (in input order) are expanded. */
69
+ seedCount?: number;
70
+ /** Up to `perSeed` neighbours are added per expanded seed. */
71
+ perSeed?: number;
72
+ /** Hard cap on the total number of distinct surfaced articles. */
73
+ cap?: number;
74
+ }
75
+
76
+ interface BuildEdgeGraphOptions {
77
+ /** In-degree above which an article is a hub. Defaults to
78
+ * {@link DEFAULT_HUB_DEGREE}. */
79
+ hubDegree?: number;
80
+ }
81
+
82
+ /**
83
+ * Split one `links:` entry (`"<target-slug> — <description>"`) into its target
84
+ * slug and curated description on the FIRST ` — ` (space-emdash-space).
85
+ * Entries with no separator are bare target slugs and carry no description.
86
+ */
87
+ function parseLinkEntry(entry: string): {
88
+ target: Slug;
89
+ description: string | undefined;
90
+ } {
91
+ const sep = entry.indexOf(LINK_SEPARATOR);
92
+ if (sep === -1) return { target: entry.trim(), description: undefined };
93
+ return {
94
+ target: entry.slice(0, sep).trim(),
95
+ description: entry.slice(sep + LINK_SEPARATOR.length).trim() || undefined,
96
+ };
97
+ }
98
+
99
+ /** Parse inline `[[wikilink]]` targets from a body. Strips `|display` and
100
+ * `#anchor` suffixes; returns trimmed target slugs (possibly with duplicates,
101
+ * deduped by the caller's map insertion). */
102
+ function parseWikilinks(body: string): string[] {
103
+ const targets: string[] = [];
104
+ for (const match of body.matchAll(WIKILINK_REGEX)) {
105
+ let target = match[1];
106
+ const pipe = target.indexOf("|");
107
+ if (pipe !== -1) target = target.slice(0, pipe);
108
+ const hash = target.indexOf("#");
109
+ if (hash !== -1) target = target.slice(0, hash);
110
+ target = target.trim();
111
+ if (target) targets.push(target);
112
+ }
113
+ return targets;
114
+ }
115
+
116
+ /**
117
+ * Build the directed article link-graph from the page-index entries and a raw
118
+ * page reader. See the module docstring for the three unioned edge sources.
119
+ *
120
+ * Pure given `pageRaw`: the build performs no hard-coded I/O. A read that
121
+ * rejects drops that article's authored/wikilink edges but still keeps its
122
+ * numeric `PageIndexEntry.edges` fallback.
123
+ */
124
+ export async function buildEdgeGraph(
125
+ articles: readonly PageIndexEntry[],
126
+ pageRaw: (slug: Slug) => Promise<string>,
127
+ opts: BuildEdgeGraphOptions = {},
128
+ ): Promise<EdgeGraph> {
129
+ const hubDegree = opts.hubDegree ?? DEFAULT_HUB_DEGREE;
130
+
131
+ const slugs = new Set<Slug>(articles.map((a) => a.slug));
132
+ const byId = new Map<number, Slug>(articles.map((a) => [a.id, a.slug]));
133
+
134
+ const raws = await Promise.all(
135
+ articles.map((a) =>
136
+ pageRaw(a.slug).then(
137
+ (text) => text,
138
+ () => null, // read failed — fall back to numeric edges only
139
+ ),
140
+ ),
141
+ );
142
+
143
+ const adjacency = new Map<Slug, Map<Slug, string | undefined>>();
144
+ const inDegree = new Map<Slug, number>();
145
+
146
+ const addEdge = (
147
+ source: Slug,
148
+ target: Slug,
149
+ description: string | undefined,
150
+ ): void => {
151
+ if (target === source) return; // no self-edges
152
+ if (!slugs.has(target)) return; // drop unknown/dangling targets
153
+ let out = adjacency.get(source);
154
+ if (!out) {
155
+ out = new Map();
156
+ adjacency.set(source, out);
157
+ }
158
+ if (!out.has(target)) {
159
+ out.set(target, description);
160
+ inDegree.set(target, (inDegree.get(target) ?? 0) + 1);
161
+ } else if (out.get(target) === undefined && description !== undefined) {
162
+ // A later source (the authored `links`) supplies a description for an
163
+ // edge first seen without one — upgrade it. In-degree already counted.
164
+ out.set(target, description);
165
+ }
166
+ };
167
+
168
+ for (let i = 0; i < articles.length; i++) {
169
+ const article = articles[i];
170
+ const source = article.slug;
171
+ const raw = raws[i];
172
+
173
+ // (a) authored `links:` frontmatter — primary, carries descriptions.
174
+ const parsed = raw !== null ? parseFrontmatterFields(raw) : null;
175
+ const links = parsed?.fields.links;
176
+ if (Array.isArray(links)) {
177
+ for (const entry of links) {
178
+ if (typeof entry !== "string") continue;
179
+ const { target, description } = parseLinkEntry(entry);
180
+ addEdge(source, target, description);
181
+ }
182
+ }
183
+
184
+ // (b) inline `[[wikilink]]` targets from the body. `parsed.body` strips
185
+ // the frontmatter; a page without frontmatter has `parsed === null`, so
186
+ // the whole raw text is the body.
187
+ const body = parsed ? parsed.body : raw;
188
+ if (body !== null) {
189
+ for (const target of parseWikilinks(body)) {
190
+ addEdge(source, target, undefined);
191
+ }
192
+ }
193
+
194
+ // (c) numeric page-index edges resolved to slugs — fallback.
195
+ for (const targetId of article.edges) {
196
+ const target = byId.get(targetId);
197
+ if (target) addEdge(source, target, undefined);
198
+ }
199
+ }
200
+
201
+ const hubs = new Set<Slug>();
202
+ for (const [slug, degree] of inDegree) {
203
+ if (degree > hubDegree) hubs.add(slug);
204
+ }
205
+
206
+ return { adjacency, hubs, slugs };
207
+ }
208
+
209
+ /**
210
+ * Expand `seeds` outward along the graph. For each of the top `seedCount`
211
+ * seeds (in input order), surface up to `perSeed` non-hub, `alive`-passing
212
+ * neighbours, capped at `cap` total distinct articles. Seeds themselves are
213
+ * not surfaced (they are already in the pool). Each surfaced article carries
214
+ * the curated `links` description of the traversed edge when it had one.
215
+ *
216
+ * Deterministic given the input seed order and the graph's insertion order.
217
+ */
218
+ export function edgeExpand(
219
+ graph: EdgeGraph,
220
+ seeds: readonly Slug[],
221
+ opts: EdgeExpandOptions = {},
222
+ ): EdgeNeighbor[] {
223
+ const seedCount = opts.seedCount ?? DEFAULT_SEED_COUNT;
224
+ const perSeed = opts.perSeed ?? DEFAULT_PER_SEED;
225
+ const cap = opts.cap ?? DEFAULT_CAP;
226
+ const alive = opts.alive;
227
+
228
+ const seedSet = new Set<Slug>(seeds);
229
+ const surfaced = new Map<Slug, string | undefined>();
230
+
231
+ for (const seed of seeds.slice(0, seedCount)) {
232
+ if (surfaced.size >= cap) break;
233
+ const out = graph.adjacency.get(seed);
234
+ if (!out) continue;
235
+ let added = 0;
236
+ for (const [target, description] of out) {
237
+ if (added >= perSeed) break;
238
+ if (surfaced.size >= cap) break;
239
+ if (seedSet.has(target)) continue; // already in the pool
240
+ if (surfaced.has(target)) continue; // already surfaced via another seed
241
+ if (graph.hubs.has(target)) continue; // hubs excluded
242
+ if (alive && !alive(target)) continue;
243
+ surfaced.set(target, description);
244
+ added++;
245
+ }
246
+ }
247
+
248
+ return [...surfaced].map(([article, description]) => ({
249
+ article,
250
+ description,
251
+ }));
252
+ }
@@ -22,7 +22,7 @@ import {
22
22
  type Injector,
23
23
  type TurnContext,
24
24
  } from "../../types.js";
25
- import { renderV3PageContent } from "./page-content.js";
25
+ import { renderV3SectionContent } from "./page-content.js";
26
26
  import { renderMemoryBlock } from "./render-injection.js";
27
27
  import {
28
28
  MEMORY_V3_LIVE,
@@ -52,7 +52,8 @@ export const memoryV3Injector: Injector = {
52
52
  // `renderMemoryBlock` returns "" for an empty selection; inject nothing.
53
53
  const text = await renderMemoryBlock(
54
54
  result.finalInjection,
55
- renderV3PageContent,
55
+ result.sectionBySlug,
56
+ renderV3SectionContent,
56
57
  );
57
58
  if (text.length === 0) return null;
58
59
  return {