@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,28 +1,42 @@
1
1
  /**
2
2
  * Memory v3 — `memory_v3_maintain` job handler.
3
3
  *
4
- * A flag-gated, best-effort self-maintenance pass over the v3 topic tree and
5
- * its page→leaf assignments. It runs three independent stages, in order:
4
+ * A flag-gated, best-effort self-maintenance pass over the v3 section dense
5
+ * store and the in-memory lanes. It runs three independent stages, in order:
6
6
  *
7
- * 1. **Classify-union** — `assignPages` over the delta: every page that is
8
- * still unassigned (empty `leaves:`) plus every page edited since the last
9
- * successful pass (the high-water mark below), UNIONing freshly classified
10
- * leaves into each page (never dropping existing picks). Re-touching
11
- * already-assigned-but-recently-edited pages lets the classifier enrich the
12
- * assistant's own in-consolidation labels with any leaves it missed.
13
- * 2. **Prune** — drop any frontmatter `leaves:` entry that points at a leaf
14
- * path no longer present in the tree (dangling references left behind when
15
- * a leaf is renamed or removed). Read + rewrite only the affected pages.
16
- * 3. **Needle rebuild** — `invalidateLanes()` so the next turn rebuilds the
17
- * leaf tree and BM25 needle from the freshly-updated assignments.
7
+ * 1. **Section re-embed** — diff the page index by `modifiedAt` against the
8
+ * last successful pass (the high-water mark below), and for every page that
9
+ * is new or edited since then, re-chunk it into sections
10
+ * (`buildSectionIndex`) and refresh its dense points
11
+ * (`deleteSectionsForArticle` + `upsertSections`). This keeps the
12
+ * section-grain Qdrant collection in sync with on-disk page edits so the
13
+ * dense lane retrieves against current content. The high-water mark is
14
+ * advanced only after the pass completes with zero page failures (and is
15
+ * captured before any potential mtime bumps) so a page is not re-embedded
16
+ * forever, yet a page whose embed failed (and whose sections were therefore
17
+ * left deleted) stays above the mark and is retried next pass.
18
+ * 2. **Deleted-page prune** — diff the dense store's stored articles
19
+ * (`listSectionArticles`) against the live page-index slugs and
20
+ * `deleteSectionsForArticle` for any article that is no longer in the
21
+ * index. A deleted page's slug never reaches the re-embed delta selector
22
+ * (it only names live pages), so without this its section points would
23
+ * linger in Qdrant and the dense lane could still surface the deleted page.
24
+ * Synthetic capability rows are in the page index, so they are never pruned.
25
+ * 3. **Lane invalidation** — `invalidateLanes()` so the next turn rebuilds the
26
+ * in-memory section index, needle, and edge graph from the freshly-updated
27
+ * pages.
18
28
  *
19
29
  * Best-effort by construction: each stage is wrapped so a failure in one is
20
- * logged and recorded in the outcome but does NOT abort the others. The job is
21
- * a no-op (returns a disabled outcome) unless `memory-v3-shadow` OR
30
+ * logged and recorded in the outcome but does NOT abort the others. A single
31
+ * page whose embed fails does not abort the rest of the re-embed stage, and a
32
+ * single prune delete that throws does not abort the rest of the prune stage.
33
+ * The job is a no-op (returns a disabled outcome) unless `memory-v3-shadow` OR
22
34
  * `memory-v3-live` is enabled — the same flags that gate the v3 plugin.
23
35
  *
24
- * Dependency-injectable: `deps` lets tests substitute the tree loader,
25
- * `assignPages`, and `invalidateLanes` without process-global module mocks.
36
+ * Dependency-injectable: `deps` lets tests substitute the page-index reader,
37
+ * section builder, dense-store ops (including the prune-stage
38
+ * `listSectionArticles`/`listIndexedSlugs` collaborators), and `invalidateLanes`
39
+ * without process-global module mocks.
26
40
  */
27
41
 
28
42
  import { isAssistantFeatureFlagEnabled } from "../../../config/assistant-feature-flags.js";
@@ -33,186 +47,404 @@ import {
33
47
  } from "../../../memory/checkpoints.js";
34
48
  import type { MemoryJob } from "../../../memory/jobs-store.js";
35
49
  import { getPageIndex } from "../../../memory/v2/page-index.js";
36
- import {
37
- listPages,
38
- readPage,
39
- writePage,
40
- } from "../../../memory/v2/page-store.js";
50
+ import { readPage } from "../../../memory/v2/page-store.js";
41
51
  import { getLogger } from "../../../util/logger.js";
42
52
  import { getWorkspaceDir } from "../../../util/platform.js";
43
- import { assignPages as realAssignPages } from "./assign.js";
53
+ import { capabilityOrDiskBody } from "./capabilities.js";
54
+ import {
55
+ deleteSectionsForArticle as realDeleteSectionsForArticle,
56
+ ensureSectionCollection as realEnsureSectionCollection,
57
+ listSectionArticles as realListSectionArticles,
58
+ upsertSections as realUpsertSections,
59
+ } from "./section-dense-store.js";
60
+ import { buildSectionIndex as realBuildSectionIndex } from "./sections.js";
44
61
  import { invalidateLanes as realInvalidateLanes } from "./shadow-plugin.js";
45
- import { loadLeafTree as realLoadLeafTree, resolveDataDir } from "./tree.js";
46
- import type { LeafPath, LeafTree, Slug } from "./types.js";
62
+ import type { Slug } from "./types.js";
47
63
 
48
64
  const MEMORY_V3_SHADOW = "memory-v3-shadow" as const;
49
65
  const MEMORY_V3_LIVE = "memory-v3-live" as const;
50
66
 
51
67
  /**
52
68
  * Durable checkpoint holding the epoch-ms high-water mark of the last successful
53
- * classify-union pass. Pages whose mtime is past this mark are re-classified
54
- * (enrichment); the mark is advanced only after a pass completes, captured after
55
- * its writes so the pass's own frontmatter rewrites do not re-trigger it.
56
- * Distinct from `memory_v3_maintain_last_run` (the enqueue-cadence checkpoint in
57
- * `jobs-worker.ts`), which advances on every backstop enqueue rather than on an
58
- * actual maintenance run.
69
+ * re-embed pass. Pages whose mtime is past this mark are re-chunked + re-embedded
70
+ * into the section dense store; the mark is advanced only after a pass completes,
71
+ * captured before the pass so a transient embed write does not re-trigger itself.
72
+ *
73
+ * This is a FRESH key, distinct from the tree-era `enriched_through_ms` that the
74
+ * old classify-union maintainer advanced. On upgrade the new key is absent, so
75
+ * `computeChangedPages` sees a high-water of 0 and re-embeds EVERY page on the
76
+ * first run — seeding the otherwise-empty `memory_v3_sections` collection.
77
+ * Reusing the old (already-advanced) key would skip all historical pages and
78
+ * leave dense retrieval blind to existing memory until each page was next edited.
79
+ *
80
+ * Distinct too from `memory_v3_maintain_last_run` (the enqueue-cadence checkpoint
81
+ * in `jobs-worker.ts`), which advances on every backstop enqueue rather than on
82
+ * an actual maintenance run.
59
83
  */
60
- const MAINTAIN_ENRICH_HIGH_WATER_KEY =
61
- "memory_v3_maintain:enriched_through_ms" as const;
84
+ const MAINTAIN_EMBED_HIGH_WATER_KEY =
85
+ "memory_v3_maintain:sections_embedded_through_ms" as const;
62
86
 
63
87
  const log = getLogger("memory-v3-maintain");
64
88
 
89
+ /** Page-index projection the changed-page selector reads. */
90
+ export interface ChangedPageCandidate {
91
+ slug: Slug;
92
+ /** File mtime in epoch ms; 0 for synthetic skill/CLI rows (excluded). */
93
+ modifiedAt: number;
94
+ }
95
+
65
96
  /** Injectable collaborators; defaults wire the real implementations. */
66
97
  export interface MaintainJobDeps {
67
- /** Load the v3 leaf tree (page→leaf membership resolved from frontmatter). */
68
- loadTree: () => Promise<LeafTree>;
69
- /** Classify-union pass over pages. */
70
- assignPages: typeof realAssignPages;
71
98
  /**
72
- * The slugs to classify this pass: unassigned pages plus pages edited since
73
- * the last successful pass. See {@link computeClassifyTargets}.
99
+ * The slugs whose sections to re-embed this pass: pages new or edited since
100
+ * the last successful pass. See {@link computeChangedPages}.
101
+ */
102
+ selectChangedPages: () => Promise<Slug[]>;
103
+ /** Re-chunk pages into the flat section index (pure; reads page bodies). */
104
+ buildSectionIndex: typeof realBuildSectionIndex;
105
+ /** Read a page's frontmatter-stripped body for `buildSectionIndex`. */
106
+ readPageBody: (slug: Slug) => Promise<string>;
107
+ /** Clear an article's stale section points before re-upserting. */
108
+ deleteSectionsForArticle: typeof realDeleteSectionsForArticle;
109
+ /** Embed + upsert an article's current sections into the dense store. */
110
+ upsertSections: typeof realUpsertSections;
111
+ /**
112
+ * Persist the high-water mark after a re-embed pass with zero failures. The
113
+ * value is captured before the pass's writes (see the key docstring); the
114
+ * caller skips this when any page failed so failed pages retry next pass.
74
115
  */
75
- selectClassifyTargets: () => Promise<Slug[]>;
116
+ commitEmbedHighWater: (highWaterMs: number) => void;
76
117
  /**
77
- * Persist the enrichment high-water mark after a successful classify pass. The
78
- * value is captured after the pass's writes (see the key docstring).
118
+ * Every distinct `article` slug that currently has section points in the
119
+ * dense store. The prune stage diffs this against the live page-index slugs
120
+ * to find articles whose points linger after the page was deleted.
79
121
  */
80
- commitClassifyHighWater: (highWaterMs: number) => void;
81
- /** Drop the memoized v3 lanes so the next turn rebuilds tree + needle. */
122
+ listSectionArticles: () => Promise<string[]>;
123
+ /**
124
+ * The slugs currently in the page index (the live set the lanes are rebuilt
125
+ * from). The prune stage keeps these and deletes any section article absent
126
+ * from this set. Includes synthetic capability rows so they are never pruned.
127
+ */
128
+ listIndexedSlugs: () => Promise<Slug[]>;
129
+ /** Drop the memoized v3 lanes so the next turn rebuilds them. */
82
130
  invalidateLanes: () => void;
83
- /** Workspace root; pages live under `<workspaceDir>/memory/concepts/`. */
84
- workspaceDir: string;
131
+ /** Active assistant config (for the dense-store/embedding calls). */
132
+ config: AssistantConfig;
133
+ }
134
+
135
+ /**
136
+ * Injectable collaborators for the one-time {@link backfillAllSections} pass.
137
+ * Distinct from {@link MaintainJobDeps}: the backfill embeds EVERY page
138
+ * (including synthetic capability rows the incremental selector excludes), so it
139
+ * uses an all-pages selector, a capability-aware body reader, and an explicit
140
+ * collection-ensure step rather than the change-delta machinery.
141
+ */
142
+ export interface BackfillJobDeps {
143
+ /** All page-index slugs, including synthetic skill/CLI capability rows. */
144
+ selectAllPages: () => Promise<Slug[]>;
145
+ /** Create the section collection if absent before the first upsert. */
146
+ ensureSectionCollection: typeof realEnsureSectionCollection;
147
+ /** Re-chunk pages into the flat section index (pure; reads page bodies). */
148
+ buildSectionIndex: typeof realBuildSectionIndex;
149
+ /** Read a page's body; capability slugs resolve their rendered content. */
150
+ readPageBody: (slug: Slug) => Promise<string>;
151
+ /** Clear an article's stale section points before re-upserting. */
152
+ deleteSectionsForArticle: typeof realDeleteSectionsForArticle;
153
+ /** Embed + upsert an article's current sections into the dense store. */
154
+ upsertSections: typeof realUpsertSections;
155
+ /**
156
+ * Persist the high-water mark after the backfill completes with zero
157
+ * failures. Skipped when any page failed so failed pages retry next pass.
158
+ */
159
+ commitEmbedHighWater: (highWaterMs: number) => void;
160
+ /** Epoch-ms stamped as the new high-water mark; injectable for tests. */
161
+ nowMs: () => number;
162
+ /** Active assistant config (for the dense-store/embedding calls). */
163
+ config: AssistantConfig;
164
+ }
165
+
166
+ /** Counts surfaced by {@link backfillAllSections}. */
167
+ export interface BackfillOutcome {
168
+ /** Pages whose sections were (re-)embedded this pass. */
169
+ articles: number;
170
+ /** Total section points upserted across all articles. */
171
+ sections: number;
172
+ /** Pages whose embed threw (and was contained). */
173
+ failures: number;
85
174
  }
86
175
 
87
176
  /** Per-stage outcome surfaced in the structured log line. */
88
177
  export interface MaintainOutcome {
89
178
  /** True when both v3 flags were off and the job no-opped. */
90
179
  disabled: boolean;
91
- /** Pages newly assigned by the classify-union stage. */
92
- assigned: number;
93
- /** Pages whose dangling refs were pruned. */
180
+ /** Pages whose sections were re-chunked + re-embedded this pass. */
181
+ reembedded: number;
182
+ /** Pages whose re-embed threw (and was contained). */
183
+ reembedFailures: number;
184
+ /**
185
+ * Deleted-page articles whose lingering section points were pruned this pass
186
+ * (present in the dense store but absent from the page index).
187
+ */
94
188
  pruned: number;
95
- /** Dangling leaf references dropped across all pruned pages. */
96
- prunedRefs: number;
97
- /** Whether the needle/tree lanes were invalidated. */
189
+ /** Prune deletes that threw (and were contained). */
190
+ pruneFailures: number;
191
+ /** Whether the in-memory lanes were invalidated. */
98
192
  invalidated: boolean;
99
193
  /** Stages that threw (and were contained). */
100
194
  failures: string[];
101
195
  }
102
196
 
103
197
  /**
104
- * Load the v3 leaf tree the same way the live plugin does: resolve the data
105
- * dir, build the page→leaf membership map from the page index frontmatter, and
106
- * hand both to `loadLeafTree`.
198
+ * Pick the pages the re-embed stage should refresh this pass: every page whose
199
+ * mtime is past `prevHighWaterMs` (new or edited since the last successful run).
200
+ * Synthetic skill/CLI rows (`modifiedAt === 0`) are excluded — they are
201
+ * capability entries with no on-disk page to chunk. On the first run
202
+ * (`prevHighWaterMs` null) every real page is selected so a fresh or
203
+ * freshly-backfilled install seeds the section dense store once.
107
204
  */
108
- async function loadTreeFromWorkspace(workspaceDir: string): Promise<LeafTree> {
109
- const pageIndex = await getPageIndex(workspaceDir);
110
- const pageLeaves = new Map<Slug, LeafPath[]>();
111
- for (const entry of pageIndex.entries) {
112
- pageLeaves.set(entry.slug, entry.leaves);
113
- }
114
- return realLoadLeafTree(resolveDataDir(), pageLeaves);
115
- }
116
-
117
- /** Page-index projection the classify-target selector reads. */
118
- export interface ClassifyCandidate {
119
- slug: Slug;
120
- /** File mtime in epoch ms; 0 for synthetic skill/CLI rows (excluded). */
121
- modifiedAt: number;
122
- /** Leaf assignments from frontmatter; `[]` when unassigned. */
123
- leaves: LeafPath[];
124
- }
125
-
126
- /**
127
- * Pick the pages the classify-union stage should run this pass:
128
- *
129
- * - **Unassigned** pages (empty `leaves:`) — always classified so a page is
130
- * never left permanently unrouted (the pre-enrichment behavior).
131
- * - **Recently edited** pages (mtime past `prevHighWaterMs`) — even when
132
- * already assigned, so the classifier can enrich the assistant's own
133
- * in-consolidation labels with leaves it missed. `assignPages` UNIONs, so a
134
- * page's existing picks are never dropped — only added to.
135
- *
136
- * Synthetic skill/CLI rows (`modifiedAt === 0`) are excluded: they are
137
- * capability entries, not topic-tree pages. On the first run (`prevHighWaterMs`
138
- * null) the recency arm is disabled, so a fresh or freshly-backfilled install
139
- * does a single unassigned-only pass instead of re-classifying the whole corpus.
140
- */
141
- export function computeClassifyTargets(
142
- pages: ReadonlyArray<ClassifyCandidate>,
205
+ export function computeChangedPages(
206
+ pages: ReadonlyArray<ChangedPageCandidate>,
143
207
  prevHighWaterMs: number | null,
144
208
  ): Slug[] {
145
- const targets: Slug[] = [];
209
+ const changed: Slug[] = [];
146
210
  for (const page of pages) {
147
211
  if (page.modifiedAt <= 0) continue; // synthetic capability row
148
- const unassigned = page.leaves.length === 0;
149
- const recentlyEdited =
150
- prevHighWaterMs !== null && page.modifiedAt > prevHighWaterMs;
151
- if (unassigned || recentlyEdited) targets.push(page.slug);
212
+ if (prevHighWaterMs === null || page.modifiedAt > prevHighWaterMs) {
213
+ changed.push(page.slug);
214
+ }
152
215
  }
153
- return targets;
216
+ return changed;
154
217
  }
155
218
 
156
219
  /** Read the persisted high-water mark, treating missing/garbage as first-run. */
157
- function readClassifyHighWater(): number | null {
158
- const raw = getMemoryCheckpoint(MAINTAIN_ENRICH_HIGH_WATER_KEY);
220
+ function readEmbedHighWater(): number | null {
221
+ const raw = getMemoryCheckpoint(MAINTAIN_EMBED_HIGH_WATER_KEY);
159
222
  if (raw === null) return null;
160
223
  const parsed = Number.parseInt(raw, 10);
161
224
  return Number.isNaN(parsed) ? null : parsed;
162
225
  }
163
226
 
164
- /** Default target selector: page index → {@link computeClassifyTargets}. */
165
- async function selectClassifyTargetsFromWorkspace(
227
+ /** Default selector: page index → {@link computeChangedPages}. */
228
+ async function selectChangedPagesFromWorkspace(
166
229
  workspaceDir: string,
167
230
  ): Promise<Slug[]> {
168
231
  const index = await getPageIndex(workspaceDir);
169
- return computeClassifyTargets(index.entries, readClassifyHighWater());
232
+ return computeChangedPages(index.entries, readEmbedHighWater());
170
233
  }
171
234
 
172
- function commitClassifyHighWater(highWaterMs: number): void {
173
- setMemoryCheckpoint(MAINTAIN_ENRICH_HIGH_WATER_KEY, String(highWaterMs));
235
+ function commitEmbedHighWater(highWaterMs: number): void {
236
+ setMemoryCheckpoint(MAINTAIN_EMBED_HIGH_WATER_KEY, String(highWaterMs));
174
237
  }
175
238
 
176
- function defaultDeps(): MaintainJobDeps {
239
+ /** Read a page's frontmatter-stripped body; missing/failed reads degrade to "". */
240
+ async function readPageBodyFromWorkspace(
241
+ workspaceDir: string,
242
+ slug: Slug,
243
+ ): Promise<string> {
244
+ try {
245
+ const page = await readPage(workspaceDir, slug);
246
+ return page?.body ?? "";
247
+ } catch {
248
+ return "";
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Capability-aware page-body reader for the full backfill: synthetic skill/CLI
254
+ * slugs have no on-disk page, so they contribute their rendered capability
255
+ * content (exactly what `page-content.ts` injects for them) while real pages
256
+ * read from disk. Shares the capability-or-disk dispatch with `shadow-plugin.ts`
257
+ * `initLanes`' `pageBody` via {@link capabilityOrDiskBody}.
258
+ */
259
+ async function backfillPageBodyFromWorkspace(
260
+ workspaceDir: string,
261
+ slug: Slug,
262
+ ): Promise<string> {
263
+ return capabilityOrDiskBody(slug, (s) =>
264
+ readPageBodyFromWorkspace(workspaceDir, s),
265
+ );
266
+ }
267
+
268
+ /** All page-index slugs, including synthetic skill/CLI capability rows. */
269
+ async function selectAllPagesFromWorkspace(
270
+ workspaceDir: string,
271
+ ): Promise<Slug[]> {
272
+ const index = await getPageIndex(workspaceDir);
273
+ return index.entries.map((entry) => entry.slug);
274
+ }
275
+
276
+ function defaultDeps(config: AssistantConfig): MaintainJobDeps {
177
277
  const workspaceDir = getWorkspaceDir();
178
278
  return {
179
- loadTree: () => loadTreeFromWorkspace(workspaceDir),
180
- assignPages: realAssignPages,
181
- selectClassifyTargets: () =>
182
- selectClassifyTargetsFromWorkspace(workspaceDir),
183
- commitClassifyHighWater,
279
+ selectChangedPages: () => selectChangedPagesFromWorkspace(workspaceDir),
280
+ buildSectionIndex: realBuildSectionIndex,
281
+ readPageBody: (slug) => readPageBodyFromWorkspace(workspaceDir, slug),
282
+ deleteSectionsForArticle: realDeleteSectionsForArticle,
283
+ upsertSections: realUpsertSections,
284
+ commitEmbedHighWater,
285
+ listSectionArticles: () => realListSectionArticles(config),
286
+ listIndexedSlugs: () => selectAllPagesFromWorkspace(workspaceDir),
184
287
  invalidateLanes: realInvalidateLanes,
185
- workspaceDir,
288
+ config,
289
+ };
290
+ }
291
+
292
+ function defaultBackfillDeps(config: AssistantConfig): BackfillJobDeps {
293
+ const workspaceDir = getWorkspaceDir();
294
+ return {
295
+ selectAllPages: () => selectAllPagesFromWorkspace(workspaceDir),
296
+ ensureSectionCollection: realEnsureSectionCollection,
297
+ buildSectionIndex: realBuildSectionIndex,
298
+ readPageBody: (slug) => backfillPageBodyFromWorkspace(workspaceDir, slug),
299
+ deleteSectionsForArticle: realDeleteSectionsForArticle,
300
+ upsertSections: realUpsertSections,
301
+ commitEmbedHighWater,
302
+ nowMs: () => Date.now(),
303
+ config,
186
304
  };
187
305
  }
188
306
 
189
307
  /**
190
- * Prune dangling `leaves:` references: for every page, drop frontmatter leaf
191
- * paths that are not present in `tree`. Read + rewrite only pages that actually
192
- * change. Returns the number of pages rewritten and total refs dropped.
308
+ * Re-chunk + re-embed each changed page into the section dense store. Each page
309
+ * is refreshed independently: a single page whose delete/build/upsert throws is
310
+ * logged and counted in `reembedFailures` without aborting the rest. Returns the
311
+ * counts of successfully refreshed and failed pages.
193
312
  */
194
- async function prunePages(
195
- tree: LeafTree,
196
- workspaceDir: string,
197
- ): Promise<{ pruned: number; prunedRefs: number }> {
198
- const validLeaves = tree.leaves;
199
- const slugs = await listPages(workspaceDir);
313
+ async function reembedChangedPages(
314
+ slugs: Slug[],
315
+ deps: MaintainJobDeps,
316
+ ): Promise<{ reembedded: number; reembedFailures: number }> {
317
+ let reembedded = 0;
318
+ let reembedFailures = 0;
319
+ for (const slug of slugs) {
320
+ try {
321
+ const index = await deps.buildSectionIndex([slug], deps.readPageBody);
322
+ await deps.deleteSectionsForArticle(deps.config, slug);
323
+ await deps.upsertSections(deps.config, index.sections);
324
+ reembedded += 1;
325
+ } catch (err) {
326
+ reembedFailures += 1;
327
+ log.warn(
328
+ { slug, err: err instanceof Error ? err.message : String(err) },
329
+ "memory-v3 maintain: page re-embed failed (non-fatal)",
330
+ );
331
+ }
332
+ }
333
+ return { reembedded, reembedFailures };
334
+ }
335
+
336
+ /**
337
+ * Prune section points for articles that no longer exist in the page index. A
338
+ * deleted concept page leaves `getPageIndex` (so the needle and edge lanes both
339
+ * drop it on the next rebuild) but its section points linger in Qdrant, where
340
+ * the dense lane could still surface it. The incremental selector
341
+ * ({@link computeChangedPages}) never names a slug that is gone, so a deleted
342
+ * page's stale points are only reachable by reading the collection back and
343
+ * diffing against the live slug set.
344
+ *
345
+ * Each deletion is independent: a single `deleteSectionsForArticle` throw is
346
+ * logged and counted in `pruneFailures` without aborting the rest. Synthetic
347
+ * capability rows are in the page index, so they are never pruned.
348
+ */
349
+ async function pruneDeletedPages(
350
+ deps: MaintainJobDeps,
351
+ ): Promise<{ pruned: number; pruneFailures: number }> {
352
+ const [storedArticles, indexedSlugs] = await Promise.all([
353
+ deps.listSectionArticles(),
354
+ deps.listIndexedSlugs(),
355
+ ]);
356
+ const live = new Set(indexedSlugs);
357
+ const deleted = storedArticles.filter((article) => !live.has(article));
358
+
200
359
  let pruned = 0;
201
- let prunedRefs = 0;
360
+ let pruneFailures = 0;
361
+ for (const article of deleted) {
362
+ try {
363
+ await deps.deleteSectionsForArticle(deps.config, article);
364
+ pruned += 1;
365
+ } catch (err) {
366
+ pruneFailures += 1;
367
+ log.warn(
368
+ { article, err: err instanceof Error ? err.message : String(err) },
369
+ "memory-v3 maintain: deleted-page section prune failed (non-fatal)",
370
+ );
371
+ }
372
+ }
373
+ return { pruned, pruneFailures };
374
+ }
375
+
376
+ /**
377
+ * One-time full backfill of the section dense store. Unlike {@link maintainJob}
378
+ * (which only re-embeds pages changed since the high-water mark and EXCLUDES
379
+ * synthetic capability rows), this embeds EVERY page in the index — including
380
+ * synthetic skill/CLI rows, whose `modifiedAt` is 0 and which the incremental
381
+ * selector therefore never picks. It exists for the operator transition before
382
+ * A/B/cutover: the `memory_v3_sections` collection starts empty, so on an
383
+ * existing install most pages were never embedded into the dense lane.
384
+ *
385
+ * Each article is refreshed independently (delete its stale points, then
386
+ * re-upsert its current sections); a single article whose build/delete/upsert
387
+ * throws is logged and counted in `failures` without aborting the rest. When the
388
+ * pass completes with zero failures the high-water mark is advanced to the
389
+ * timestamp captured BEFORE the pass, so the next incremental maintain run
390
+ * deltas from here instead of re-embedding the whole corpus again. The timestamp
391
+ * is captured up-front (as in `maintainJob`) so an embed write that bumps a
392
+ * page's mtime does not re-trigger it next pass. If any page failed the
393
+ * checkpoint is held (not advanced): a failed page was delete-then-upsert'd and
394
+ * so has no sections, and advancing past its mtime would hide it from the
395
+ * incremental selector forever — holding the mark lets the next pass re-embed it.
396
+ */
397
+ export async function backfillAllSections(
398
+ config: AssistantConfig,
399
+ deps: BackfillJobDeps = defaultBackfillDeps(config),
400
+ ): Promise<BackfillOutcome> {
401
+ // Capture the high-water stamp before any writes (see the function docstring).
402
+ const startedAtMs = deps.nowMs();
403
+
404
+ const slugs = await deps.selectAllPages();
405
+ await deps.ensureSectionCollection(deps.config);
406
+
407
+ let articles = 0;
408
+ let sections = 0;
409
+ let failures = 0;
202
410
  for (const slug of slugs) {
203
- const page = await readPage(workspaceDir, slug);
204
- const leaves = page?.frontmatter.leaves;
205
- if (!page || !leaves || leaves.length === 0) continue;
206
- const kept = leaves.filter((leaf) => validLeaves.has(leaf));
207
- if (kept.length === leaves.length) continue;
208
- prunedRefs += leaves.length - kept.length;
209
- pruned += 1;
210
- await writePage(workspaceDir, {
211
- ...page,
212
- frontmatter: { ...page.frontmatter, leaves: kept },
213
- });
411
+ try {
412
+ const index = await deps.buildSectionIndex([slug], deps.readPageBody);
413
+ await deps.deleteSectionsForArticle(deps.config, slug);
414
+ await deps.upsertSections(deps.config, index.sections);
415
+ articles += 1;
416
+ sections += index.sections.length;
417
+ } catch (err) {
418
+ failures += 1;
419
+ log.warn(
420
+ { slug, err: err instanceof Error ? err.message : String(err) },
421
+ "memory-v3 backfill: page embed failed (non-fatal)",
422
+ );
423
+ }
424
+ }
425
+
426
+ // Advance the maintain high-water mark only when every page embedded cleanly,
427
+ // so the next incremental pass deltas from here rather than re-embedding
428
+ // everything. A failed page was delete-then-upsert'd, so it now has no
429
+ // sections; advancing past its mtime would hide it from `computeChangedPages`
430
+ // forever. Holding the checkpoint keeps failed pages above the mark so the
431
+ // next pass re-embeds them — re-embedding the succeeded pages too is
432
+ // idempotent and cheap.
433
+ if (failures === 0) {
434
+ deps.commitEmbedHighWater(startedAtMs);
435
+ } else {
436
+ log.info(
437
+ { articles, sections, failures },
438
+ "memory-v3 backfill: embed checkpoint held (page failures) — failed pages retry next pass",
439
+ );
214
440
  }
215
- return { pruned, prunedRefs };
441
+
442
+ log.info(
443
+ { articles, sections, failures },
444
+ "memory-v3 section backfill complete",
445
+ );
446
+
447
+ return { articles, sections, failures };
216
448
  }
217
449
 
218
450
  /**
@@ -223,13 +455,14 @@ async function prunePages(
223
455
  export async function maintainJob(
224
456
  _job: MemoryJob,
225
457
  config: AssistantConfig,
226
- deps: MaintainJobDeps = defaultDeps(),
458
+ deps: MaintainJobDeps = defaultDeps(config),
227
459
  ): Promise<MaintainOutcome> {
228
460
  const outcome: MaintainOutcome = {
229
461
  disabled: false,
230
- assigned: 0,
462
+ reembedded: 0,
463
+ reembedFailures: 0,
231
464
  pruned: 0,
232
- prunedRefs: 0,
465
+ pruneFailures: 0,
233
466
  invalidated: false,
234
467
  failures: [],
235
468
  };
@@ -242,59 +475,59 @@ export async function maintainJob(
242
475
  return outcome;
243
476
  }
244
477
 
245
- // The tree is shared input for both the assign and prune stages. If it can't
246
- // load, those two stages are skipped (recorded as a failure), but we still
247
- // invalidate the lanes so a transient load error doesn't wedge the next turn.
248
- let tree: LeafTree | null = null;
478
+ // Stage 1: re-chunk + re-embed pages changed since the last pass. Capture the
479
+ // high-water mark BEFORE the pass so an embed write that bumps a page's mtime
480
+ // does not re-trigger it next pass; advance it only when every page succeeded.
249
481
  try {
250
- tree = await deps.loadTree();
482
+ const startedAtMs = Date.now();
483
+ const changed = await deps.selectChangedPages();
484
+ const { reembedded, reembedFailures } = await reembedChangedPages(
485
+ changed,
486
+ deps,
487
+ );
488
+ outcome.reembedded = reembedded;
489
+ outcome.reembedFailures = reembedFailures;
490
+ // Only advance the high-water mark when nothing failed. A failed page is
491
+ // processed as delete-then-upsert, so a transient embed/Qdrant error leaves
492
+ // its sections deleted; if we advanced past its mtime, `computeChangedPages`
493
+ // would never re-select it (mtime <= high-water) and it would stay missing.
494
+ // Holding the checkpoint keeps every failed page above the mark so it
495
+ // retries next pass — the handful of already-succeeded pages re-embedding
496
+ // again is idempotent and cheap.
497
+ if (reembedFailures === 0) {
498
+ deps.commitEmbedHighWater(startedAtMs);
499
+ } else {
500
+ log.info(
501
+ { reembedded, reembedFailures },
502
+ "memory-v3 maintain: embed checkpoint held (page failures) — failed pages retry next pass",
503
+ );
504
+ }
251
505
  } catch (err) {
252
- outcome.failures.push("load_tree");
506
+ outcome.failures.push("reembed");
253
507
  log.warn(
254
508
  { err: err instanceof Error ? err.message : String(err) },
255
- "memory-v3 maintain: tree load failed (non-fatal)",
509
+ "memory-v3 maintain: section re-embed failed (non-fatal)",
256
510
  );
257
511
  }
258
512
 
259
- if (tree) {
260
- // Stage 1: classify-union over the delta (unassigned ∪ recently-edited).
261
- try {
262
- const targets = await deps.selectClassifyTargets();
263
- const results = await deps.assignPages({
264
- tree,
265
- workspaceDir: deps.workspaceDir,
266
- slugs: targets,
267
- });
268
- outcome.assigned = results.filter(
269
- (r) => !r.failed && r.after.length > r.before.length,
270
- ).length;
271
- // Advance the high-water mark only on success, captured AFTER the writes
272
- // above so pages this pass just rewrote (mtime now bumped) sit at-or-below
273
- // the mark and do not re-trigger themselves next pass.
274
- deps.commitClassifyHighWater(Date.now());
275
- } catch (err) {
276
- outcome.failures.push("assign");
277
- log.warn(
278
- { err: err instanceof Error ? err.message : String(err) },
279
- "memory-v3 maintain: classify-union failed (non-fatal)",
280
- );
281
- }
282
-
283
- // Stage 2: prune dangling refs.
284
- try {
285
- const { pruned, prunedRefs } = await prunePages(tree, deps.workspaceDir);
286
- outcome.pruned = pruned;
287
- outcome.prunedRefs = prunedRefs;
288
- } catch (err) {
289
- outcome.failures.push("prune");
290
- log.warn(
291
- { err: err instanceof Error ? err.message : String(err) },
292
- "memory-v3 maintain: prune failed (non-fatal)",
293
- );
294
- }
513
+ // Stage 2: prune section points for pages deleted from the index. A deleted
514
+ // page's slug never appears in `selectChangedPages` (the delta selector only
515
+ // names live pages), so its lingering points are cleared by diffing the dense
516
+ // store's stored articles against the live page-index slugs. Contained: a
517
+ // failure is logged + recorded in `failures` without aborting invalidation.
518
+ try {
519
+ const { pruned, pruneFailures } = await pruneDeletedPages(deps);
520
+ outcome.pruned = pruned;
521
+ outcome.pruneFailures = pruneFailures;
522
+ } catch (err) {
523
+ outcome.failures.push("prune");
524
+ log.warn(
525
+ { err: err instanceof Error ? err.message : String(err) },
526
+ "memory-v3 maintain: deleted-page prune failed (non-fatal)",
527
+ );
295
528
  }
296
529
 
297
- // Stage 3: rebuild tree + needle on the next turn.
530
+ // Stage 3: rebuild section index + needle + edge graph on the next turn.
298
531
  try {
299
532
  deps.invalidateLanes();
300
533
  outcome.invalidated = true;
@@ -308,9 +541,10 @@ export async function maintainJob(
308
541
 
309
542
  log.info(
310
543
  {
311
- assigned: outcome.assigned,
544
+ reembedded: outcome.reembedded,
545
+ reembedFailures: outcome.reembedFailures,
312
546
  pruned: outcome.pruned,
313
- prunedRefs: outcome.prunedRefs,
547
+ pruneFailures: outcome.pruneFailures,
314
548
  invalidated: outcome.invalidated,
315
549
  failures: outcome.failures,
316
550
  },