@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,190 +0,0 @@
1
- /**
2
- * Memory v3 — L1 leaf router.
3
- *
4
- * One forced-tool LLM call per turn that picks which leaves of the topic tree
5
- * to open for the next reply. The design mirrors `../v2/router.ts`:
6
- * - resolve the configured provider via `getConfiguredProvider`,
7
- * - call `provider.sendMessage` with a forced `tool_choice`,
8
- * - validate the tool input via Zod,
9
- * - map numbered IDs back to leaf paths.
10
- *
11
- * Cache strategy. The numbered leaf block is the single largest input and is
12
- * STABLE across turns — it changes only when leaves are added/removed/edited.
13
- * We render it first in the user message and tag it with an ephemeral
14
- * `cache_control` breakpoint so the provider can serve it from the prompt
15
- * cache turn after turn. The trailing recent-context / current-message block
16
- * changes every turn, so it carries no breakpoint.
17
- *
18
- * Failure handling. A *model-call* failure is not the same as the model
19
- * choosing to open everything, so the two no longer share an outcome:
20
- * - explicit `ids` → open exactly those leaves,
21
- * - explicit empty array (`ids: []`) → open nothing (deliberate abstention),
22
- * - omitted `ids` → open nothing: the router must name the leaves it wants,
23
- * never the whole tree (~137 leaves would fan out a full L2 pass per turn),
24
- * - infrastructure failure (provider unavailable, a throw that survived the
25
- * provider's own retries, no usable `tool_use`, or a schema mismatch) →
26
- * open nothing after a short re-prompt retry, degrading to the deterministic
27
- * recall lanes (always-on core, the BM25 needle, the carry-forward working
28
- * set) that the orchestrator unions in regardless.
29
- */
30
-
31
- import { z } from "zod";
32
-
33
- import {
34
- extractToolUse,
35
- getConfiguredProvider,
36
- } from "../../../providers/provider-send-message.js";
37
- import type { Message, ToolDefinition } from "../../../providers/types.js";
38
- import { getLogger } from "../../../util/logger.js";
39
- import { retryForResult } from "./llm-retry.js";
40
- import { cachedTextBlock } from "./provider-blocks.js";
41
- import type { LeafPath, LeafTree, MemoryRoutingTurn } from "./types.js";
42
-
43
- const log = getLogger("memory-v3-router");
44
-
45
- /** Tool name forced via `tool_choice`. Shared constant so tests can match it. */
46
- const OPEN_LEAVES_TOOL_NAME = "open_leaves";
47
-
48
- const OpenLeavesSchema = z.object({
49
- // Optional so the field can be absent on the wire, but an omitted `ids` opens
50
- // nothing — the router must name the leaves it wants, never the whole tree.
51
- ids: z.array(z.number().int()).optional(),
52
- });
53
-
54
- const OPEN_LEAVES_TOOL: ToolDefinition = {
55
- name: OPEN_LEAVES_TOOL_NAME,
56
- description:
57
- "Open the leaves whose contents could plausibly bear on the next reply. " +
58
- "Lean toward inclusion — a missed relevant leaf is a worse error than an " +
59
- "unused one. Pass the chosen IDs explicitly; return `[]` only when nothing " +
60
- "in the tree could possibly help.",
61
- input_schema: {
62
- type: "object",
63
- properties: {
64
- ids: {
65
- type: "array",
66
- items: { type: "integer" },
67
- },
68
- },
69
- },
70
- };
71
-
72
- const SYSTEM_PROMPT = `You route a conversation turn to the leaves of a topic tree that should be opened for the next reply.
73
-
74
- Each leaf has a numbered ID, a path, and a description of what it holds. Decide which leaves to open by weighing four signals:
75
-
76
- - Topic — entities, projects, and events named or implied by the turn.
77
- - Register — the affect and mode of the message (e.g. playful, distressed, formal). A register signal is enough to open a leaf even when no entity is named.
78
- - Recent context — the immediately preceding exchange, which resolves references like "this", "that", or "the same thing" to concrete topics.
79
- - Situation — the current date and a live scratchpad of what is salient right now. A date or state cue can make a leaf relevant even when the message never names it (e.g. a person whose anniversary is today, an active thread).
80
-
81
- Include on doubt: open every leaf that could plausibly hold something useful. Missing a relevant leaf is a worse error than opening an unused one. Call \`open_leaves\` with the chosen IDs explicitly; return \`[]\` only when nothing in the tree could possibly help.`;
82
-
83
- /** Leaves sorted deterministically by path so the numbered block is stable. */
84
- function sortedLeaves(tree: LeafTree): LeafPath[] {
85
- return [...tree.leaves.keys()].sort();
86
- }
87
-
88
- /**
89
- * Render the STATIC numbered leaf block from a pre-sorted path list. Identical
90
- * across turns for any given tree, which is what makes the prompt-cache
91
- * breakpoint pay off.
92
- */
93
- function renderLeafBlockFromPaths(tree: LeafTree, paths: LeafPath[]): string {
94
- const lines = paths.map((path, i) => {
95
- const description = tree.leaves.get(path)?.description ?? "";
96
- // Collapse the (possibly multi-line) description to a single line so each
97
- // leaf is exactly one numbered entry.
98
- const oneLine = description.replace(/\s+/g, " ").trim();
99
- return `[${i + 1}] ${path} — ${oneLine}`;
100
- });
101
- return `<leaves>\n${lines.join("\n")}\n</leaves>`;
102
- }
103
-
104
- /**
105
- * Render the static numbered leaf block for a tree. Exported for the test that
106
- * locks the byte-identical cache invariant; `routeL1` renders from its already
107
- * sorted path list to avoid sorting twice.
108
- */
109
- export function renderLeafBlock(tree: LeafTree): string {
110
- return renderLeafBlockFromPaths(tree, sortedLeaves(tree));
111
- }
112
-
113
- /**
114
- * Run the L1 router for one turn. Returns the leaf paths to open — only ever the
115
- * leaves the model names explicitly. An omitted `ids`, an explicit `[]`, or an
116
- * infrastructure failure (after a short re-prompt retry) all open nothing,
117
- * degrading to the deterministic recall lanes the orchestrator unions in.
118
- */
119
- export async function routeL1(
120
- turn: MemoryRoutingTurn,
121
- tree: LeafTree,
122
- ): Promise<LeafPath[]> {
123
- const paths = sortedLeaves(tree);
124
- if (paths.length === 0) return [];
125
-
126
- const provider = await getConfiguredProvider("memoryV3RouteL1");
127
- if (!provider) {
128
- log.warn(
129
- "L1 router provider unavailable; degrading to deterministic lanes",
130
- );
131
- return [];
132
- }
133
-
134
- const userMsg: Message = {
135
- role: "user",
136
- content: [
137
- cachedTextBlock(renderLeafBlockFromPaths(tree, paths)),
138
- {
139
- type: "text",
140
- text:
141
- (turn.situationalContext
142
- ? `<situation>${turn.situationalContext}</situation>\n`
143
- : "") +
144
- `<recent_context>${turn.recentContext}</recent_context>\n` +
145
- `<current_message>${turn.currentMessage}</current_message>`,
146
- },
147
- ],
148
- };
149
-
150
- // One forced-tool call, retried a few times so a transient malformed response
151
- // (no usable tool_use, or tool input that fails the schema) re-prompts before
152
- // we give up. `null` from an attempt means "unusable, retry"; the provider
153
- // layer already backs off transient throws, so this loop adds no delay.
154
- const parsed = await retryForResult(async () => {
155
- const response = await provider.sendMessage([userMsg], {
156
- tools: [OPEN_LEAVES_TOOL],
157
- systemPrompt: SYSTEM_PROMPT,
158
- config: {
159
- callSite: "memoryV3RouteL1" as const,
160
- tool_choice: { type: "tool" as const, name: OPEN_LEAVES_TOOL_NAME },
161
- },
162
- });
163
- const toolBlock = extractToolUse(response);
164
- if (!toolBlock || toolBlock.name !== OPEN_LEAVES_TOOL_NAME) return null;
165
- const result = OpenLeavesSchema.safeParse(toolBlock.input);
166
- return result.success ? result.data : null;
167
- });
168
-
169
- if (parsed === null) {
170
- log.warn(
171
- "L1 router could not obtain a selection after retries; degrading to deterministic lanes",
172
- );
173
- return [];
174
- }
175
-
176
- // An omitted `ids` field means the model named no leaves — open nothing rather
177
- // than the whole tree. Only explicitly listed IDs open leaves.
178
- if (parsed.ids === undefined) return [];
179
-
180
- // Map 1-based IDs back to leaf paths, dropping out-of-range IDs without
181
- // throwing. De-duplicate while preserving model-returned order.
182
- const seen = new Set<number>();
183
- const selected: LeafPath[] = [];
184
- for (const id of parsed.ids) {
185
- if (id < 1 || id > paths.length || seen.has(id)) continue;
186
- seen.add(id);
187
- selected.push(paths[id - 1]);
188
- }
189
- return selected;
190
- }
@@ -1,226 +0,0 @@
1
- /**
2
- * Memory v3 — L2 per-leaf page selector.
3
- *
4
- * After the L1 router (`./router.ts`) decides which leaves to open, the L2
5
- * selector runs ONE forced-tool LLM call PER opened leaf to pick which pages
6
- * inside that leaf are relevant for the next reply. `selectAcrossLeaves` fans
7
- * the per-leaf calls out with bounded concurrency.
8
- *
9
- * Cache strategy. Each leaf's `<pages>` block — the numbered list of its member
10
- * pages with their full summaries — is STABLE for that leaf: it changes only
11
- * when pages are added/removed or a summary is rewritten, never per turn. We
12
- * render it FIRST in the per-leaf user message and tag it with an ephemeral
13
- * `cache_control` breakpoint so the provider serves it from the prompt cache
14
- * turn after turn. The trailing recent-context / current-message block changes
15
- * every turn and carries no breakpoint. This mirrors `./router.ts`.
16
- *
17
- * Failure handling. A deliberate "select everything" and a model-call failure
18
- * are different events with different outcomes:
19
- * - explicit `ids` → select exactly those pages,
20
- * - explicit empty `ids: []` → select nothing (deliberate abstention),
21
- * - omitted `ids` → select ALL members of the leaf (the recall-safe "this
22
- * whole leaf is relevant" signal, e.g. "give me all of X"); bounded to one
23
- * leaf, so unlike the router this stays a select-all,
24
- * - infrastructure failure (provider unavailable, a throw that survived the
25
- * provider's own retries, no usable `tool_use`, or a schema mismatch) →
26
- * select nothing after a short re-prompt retry, degrading to the
27
- * deterministic recall lanes (core, needle, carry-forward working set) the
28
- * orchestrator unions in regardless.
29
- */
30
-
31
- import { z } from "zod";
32
-
33
- import {
34
- extractToolUse,
35
- getConfiguredProvider,
36
- } from "../../../providers/provider-send-message.js";
37
- import type { Message, ToolDefinition } from "../../../providers/types.js";
38
- import { getLogger } from "../../../util/logger.js";
39
- import { mapLimit } from "../../../util/map-limit.js";
40
- import { retryForResult } from "./llm-retry.js";
41
- import { cachedTextBlock } from "./provider-blocks.js";
42
- import { membersOf } from "./tree.js";
43
- import type { LeafPath, LeafTree, MemoryRoutingTurn, Slug } from "./types.js";
44
-
45
- const log = getLogger("memory-v3-selector");
46
-
47
- /** A page selected from an opened leaf, with whether the turn centers on it. */
48
- export interface SelectedPage {
49
- slug: Slug;
50
- pinned: boolean;
51
- }
52
-
53
- /** Tool name forced via `tool_choice`. Shared constant so tests can match it. */
54
- const SELECT_PAGES_TOOL_NAME = "select_pages";
55
-
56
- const SelectPagesSchema = z.object({
57
- // Optional: an omitted `ids` field is the recall-safe "select everything"
58
- // signal, distinct from an explicit empty array (deliberate abstention).
59
- ids: z.array(z.number().int()).optional(),
60
- pinned_ids: z.array(z.number().int()).optional(),
61
- });
62
-
63
- const SELECT_PAGES_TOOL: ToolDefinition = {
64
- name: SELECT_PAGES_TOOL_NAME,
65
- description:
66
- "Select the pages in this leaf whose content the reply would directly " +
67
- "draw on. Be selective — prefer a few precisely-relevant pages over many " +
68
- "loosely-related ones; a leaf opened on a weak signal may yield none. " +
69
- "Pass `pinned_ids` for pages the conversation is centrally about. Omit " +
70
- "`ids` only as a recall-safe fallback when you cannot judge the leaf " +
71
- "(selects every page); return `[]` when pages are present but none are " +
72
- "directly relevant.",
73
- input_schema: {
74
- type: "object",
75
- properties: {
76
- ids: {
77
- type: "array",
78
- items: { type: "integer" },
79
- },
80
- pinned_ids: {
81
- type: "array",
82
- items: { type: "integer" },
83
- },
84
- },
85
- },
86
- };
87
-
88
- const SYSTEM_PROMPT = `This leaf of the topic tree is potentially relevant to the conversation. Select ONLY the pages whose content the reply to THIS message would directly draw on.
89
-
90
- Be selective: exclude pages that are merely topically adjacent, part of the ever-present background, or only loosely related. Most opened leaves should contribute a few precisely-relevant pages, not most of their contents — a leaf opened on a weak signal may yield none.
91
-
92
- A page can also be directly relevant because of the current situation — the date or the live scratchpad — not only the message: keep a page the situation makes pertinent (e.g. a person whose anniversary is today).
93
-
94
- If the conversation is centrally ABOUT a page (rather than only peripherally relevant to it), mark that page as pinned. Call \`select_pages\` with the chosen IDs. Omit \`ids\` only as a recall-safe fallback when you cannot judge the leaf (selects every page); return \`[]\` when the pages are present but none are directly relevant.`;
95
-
96
- /**
97
- * Render the STATIC numbered `<pages>` block for a leaf from its member slugs.
98
- * Identical across turns for a given leaf, which is what makes the per-leaf
99
- * prompt-cache breakpoint pay off. Summaries are rendered in full (no
100
- * truncation) so the selector sees each page's complete description.
101
- */
102
- async function renderPagesBlock(
103
- members: Slug[],
104
- pageSummary: (slug: Slug) => Promise<string>,
105
- ): Promise<string> {
106
- const lines = await Promise.all(
107
- members.map(async (slug, i) => {
108
- const summary = await pageSummary(slug);
109
- return `[${i + 1}] ${slug} — ${summary}`;
110
- }),
111
- );
112
- return `<pages>\n${lines.join("\n")}\n</pages>`;
113
- }
114
-
115
- /**
116
- * Run the L2 selector for a single opened leaf. Returns the pages to inject.
117
- *
118
- * An omitted `ids` selects ALL members (the recall-safe "whole leaf is
119
- * relevant" signal); an explicit `[]` selects none; an infrastructure failure
120
- * (after a short re-prompt retry) selects none, degrading to the deterministic
121
- * recall lanes the orchestrator unions in.
122
- */
123
- export async function selectFromLeaf(
124
- leaf: LeafPath,
125
- turn: MemoryRoutingTurn,
126
- tree: LeafTree,
127
- pageSummary: (slug: Slug) => Promise<string>,
128
- ): Promise<SelectedPage[]> {
129
- const members = membersOf(tree, leaf);
130
- if (members.length === 0) return [];
131
-
132
- const allMembers = (): SelectedPage[] =>
133
- members.map((slug) => ({ slug, pinned: false }));
134
-
135
- const provider = await getConfiguredProvider("memoryV3SelectL2");
136
- if (!provider) {
137
- log.warn(
138
- { leaf },
139
- "L2 selector provider unavailable; degrading to deterministic lanes",
140
- );
141
- return [];
142
- }
143
-
144
- const userMsg: Message = {
145
- role: "user",
146
- content: [
147
- cachedTextBlock(
148
- `<leaf>${leaf}</leaf>\n` +
149
- (await renderPagesBlock(members, pageSummary)),
150
- ),
151
- {
152
- type: "text",
153
- text:
154
- (turn.situationalContext
155
- ? `<situation>${turn.situationalContext}</situation>\n`
156
- : "") +
157
- `<recent_context>${turn.recentContext}</recent_context>\n` +
158
- `<current_message>${turn.currentMessage}</current_message>`,
159
- },
160
- ],
161
- };
162
-
163
- // One forced-tool call, retried a few times so a transient malformed response
164
- // (no usable tool_use, or tool input that fails the schema) re-prompts before
165
- // we give up. `null` from an attempt means "unusable, retry"; the provider
166
- // layer already backs off transient throws, so this loop adds no delay.
167
- const parsed = await retryForResult(async () => {
168
- const response = await provider.sendMessage([userMsg], {
169
- tools: [SELECT_PAGES_TOOL],
170
- systemPrompt: SYSTEM_PROMPT,
171
- config: {
172
- callSite: "memoryV3SelectL2" as const,
173
- tool_choice: { type: "tool" as const, name: SELECT_PAGES_TOOL_NAME },
174
- },
175
- });
176
- const toolBlock = extractToolUse(response);
177
- if (!toolBlock || toolBlock.name !== SELECT_PAGES_TOOL_NAME) return null;
178
- const result = SelectPagesSchema.safeParse(toolBlock.input);
179
- return result.success ? result.data : null;
180
- });
181
-
182
- if (parsed === null) {
183
- log.warn(
184
- { leaf },
185
- "L2 selector could not obtain a selection after retries; degrading to deterministic lanes",
186
- );
187
- return [];
188
- }
189
-
190
- // Omitted `ids` is the recall-safe "this whole leaf is relevant" signal.
191
- // Bounded to one leaf, so it stays a select-all (unlike the L1 router).
192
- if (parsed.ids === undefined) return allMembers();
193
-
194
- const pinned = new Set(parsed.pinned_ids ?? []);
195
-
196
- // Map 1-based IDs back to member slugs, dropping out-of-range IDs without
197
- // throwing. De-duplicate while preserving model-returned order.
198
- const seen = new Set<number>();
199
- const selected: SelectedPage[] = [];
200
- for (const id of parsed.ids) {
201
- if (id < 1 || id > members.length || seen.has(id)) continue;
202
- seen.add(id);
203
- selected.push({ slug: members[id - 1]!, pinned: pinned.has(id) });
204
- }
205
- return selected;
206
- }
207
-
208
- /**
209
- * Run the L2 selector across every opened leaf with bounded concurrency and
210
- * flatten the per-leaf selections.
211
- *
212
- * A page assigned to more than one opened leaf may appear more than once in the
213
- * result; de-duplication across leaves is the orchestrator's job in a later PR.
214
- */
215
- export async function selectAcrossLeaves(
216
- leaves: LeafPath[],
217
- turn: MemoryRoutingTurn,
218
- tree: LeafTree,
219
- pageSummary: (slug: Slug) => Promise<string>,
220
- concurrency = 4,
221
- ): Promise<SelectedPage[]> {
222
- const perLeaf = await mapLimit(leaves, concurrency, (leaf) =>
223
- selectFromLeaf(leaf, turn, tree, pageSummary),
224
- );
225
- return perLeaf.flat();
226
- }
@@ -1,209 +0,0 @@
1
- import { promises as fs } from "node:fs";
2
- import path from "node:path";
3
-
4
- /**
5
- * Snapshot / restore for the memory-v3 data directory.
6
- *
7
- * The v3 data dir (`<workspace>/memory/v3/data/`) holds the tree's durable
8
- * state: `leaves/**\/*.md`, `assignments.json`, and `core.json` (see
9
- * `resolveDataDir` in `tree.ts`). A future tree-gardening reconciler mutates
10
- * these in place; this module lets it take a snapshot first and roll back if a
11
- * reconcile pass produces a bad tree.
12
- *
13
- * Page `leaves:` frontmatter lives outside the data dir (in the v2 page store
14
- * at `<workspace>/memory/concepts/`). The reconciler can capture the prior
15
- * page->leaves mapping as `pageRefs` and hand it to {@link snapshotDataDir};
16
- * it is persisted alongside the data snapshot and returned by
17
- * {@link restoreDataDir} so the caller can revert those external frontmatter
18
- * edits too.
19
- */
20
-
21
- /** Files (relative to the data dir) captured by a snapshot. */
22
- const SNAPSHOT_FILES = ["assignments.json", "core.json"] as const;
23
- /** Directories (relative to the data dir) captured recursively. */
24
- const SNAPSHOT_DIRS = ["leaves"] as const;
25
- /** Filename for the persisted page->leaves diff inside a snapshot. */
26
- const PAGE_REFS_FILE = "page-refs.json";
27
- /** Sibling directory (relative to the data dir) that holds snapshots. */
28
- const SNAPSHOTS_DIRNAME = "v3-snapshots";
29
- /** Maximum number of snapshots to retain; older ones are pruned. */
30
- const MAX_RETAINED_SNAPSHOTS = 5;
31
-
32
- function snapshotsRoot(dataDir: string): string {
33
- return path.join(path.dirname(dataDir), SNAPSHOTS_DIRNAME);
34
- }
35
-
36
- async function copyFileIfExists(src: string, dest: string): Promise<void> {
37
- try {
38
- await fs.mkdir(path.dirname(dest), { recursive: true });
39
- await fs.copyFile(src, dest);
40
- } catch (err) {
41
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
42
- throw err;
43
- }
44
- }
45
-
46
- /** Copy `src` into `dest` recursively. Returns false if `src` doesn't exist. */
47
- async function copyDirIfExists(src: string, dest: string): Promise<boolean> {
48
- let entries: import("node:fs").Dirent[];
49
- try {
50
- entries = await fs.readdir(src, { withFileTypes: true });
51
- } catch (err) {
52
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
53
- throw err;
54
- }
55
- await fs.mkdir(dest, { recursive: true });
56
- for (const entry of entries) {
57
- const from = path.join(src, entry.name);
58
- const to = path.join(dest, entry.name);
59
- if (entry.isDirectory()) {
60
- await copyDirIfExists(from, to);
61
- } else if (entry.isFile()) {
62
- await fs.copyFile(from, to);
63
- }
64
- }
65
- return true;
66
- }
67
-
68
- async function removeIfExists(target: string): Promise<void> {
69
- await fs.rm(target, { recursive: true, force: true });
70
- }
71
-
72
- function serializePageRefs(pageRefs: Map<string, string[]>): string {
73
- return JSON.stringify(Object.fromEntries(pageRefs), null, 2);
74
- }
75
-
76
- function deserializePageRefs(raw: string): Map<string, string[]> {
77
- const obj = JSON.parse(raw) as Record<string, string[]>;
78
- return new Map(Object.entries(obj));
79
- }
80
-
81
- /**
82
- * Copy the v3 data dir's durable state into a timestamped sibling snapshot
83
- * directory and return its path.
84
- *
85
- * @param dataDir Absolute path to the v3 data dir.
86
- * @param opts.pageRefs Optional slug->prior-leaves map describing page
87
- * frontmatter that lives outside the data dir; persisted so a later restore
88
- * can revert it.
89
- * @param opts.label Snapshot directory name. Defaults to `Date.now()`; pass an
90
- * explicit label to keep tests deterministic.
91
- * @returns Absolute path to the created snapshot directory.
92
- */
93
- export async function snapshotDataDir(
94
- dataDir: string,
95
- opts?: { pageRefs?: Map<string, string[]>; label?: string },
96
- ): Promise<string> {
97
- const label = opts?.label ?? String(Date.now());
98
- const snapshotPath = path.join(snapshotsRoot(dataDir), label);
99
- await fs.mkdir(snapshotPath, { recursive: true });
100
-
101
- for (const file of SNAPSHOT_FILES) {
102
- await copyFileIfExists(
103
- path.join(dataDir, file),
104
- path.join(snapshotPath, file),
105
- );
106
- }
107
- for (const dir of SNAPSHOT_DIRS) {
108
- await copyDirIfExists(
109
- path.join(dataDir, dir),
110
- path.join(snapshotPath, dir),
111
- );
112
- }
113
- if (opts?.pageRefs) {
114
- await fs.writeFile(
115
- path.join(snapshotPath, PAGE_REFS_FILE),
116
- serializePageRefs(opts.pageRefs),
117
- );
118
- }
119
-
120
- await pruneSnapshots(dataDir);
121
- return snapshotPath;
122
- }
123
-
124
- /**
125
- * Atomically replace the v3 data dir's durable state from a snapshot.
126
- *
127
- * Each captured file/dir is staged next to its destination and renamed into
128
- * place, so a crash mid-restore never leaves a half-copied tree.
129
- *
130
- * @param snapshotPath Absolute path to a directory created by
131
- * {@link snapshotDataDir}.
132
- * @param dataDir Absolute path to the v3 data dir to overwrite.
133
- * @returns The persisted `pageRefs` map (if the snapshot captured one) so the
134
- * caller can revert external page frontmatter.
135
- */
136
- export async function restoreDataDir(
137
- snapshotPath: string,
138
- dataDir: string,
139
- ): Promise<{ pageRefs?: Map<string, string[]> }> {
140
- await fs.mkdir(dataDir, { recursive: true });
141
-
142
- for (const file of SNAPSHOT_FILES) {
143
- const src = path.join(snapshotPath, file);
144
- const dest = path.join(dataDir, file);
145
- const tmp = `${dest}.restore-tmp`;
146
- try {
147
- await fs.copyFile(src, tmp);
148
- } catch (err) {
149
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
150
- // Snapshot didn't capture this file; drop any stale destination copy.
151
- await removeIfExists(dest);
152
- continue;
153
- }
154
- throw err;
155
- }
156
- await fs.rename(tmp, dest);
157
- }
158
-
159
- for (const dir of SNAPSHOT_DIRS) {
160
- const src = path.join(snapshotPath, dir);
161
- const dest = path.join(dataDir, dir);
162
- const tmp = `${dest}.restore-tmp`;
163
- await removeIfExists(tmp);
164
- const captured = await copyDirIfExists(src, tmp);
165
- if (!captured) {
166
- // Snapshot didn't capture this dir; drop any stale destination copy.
167
- await removeIfExists(dest);
168
- continue;
169
- }
170
- await removeIfExists(dest);
171
- await fs.rename(tmp, dest);
172
- }
173
-
174
- const pageRefsPath = path.join(snapshotPath, PAGE_REFS_FILE);
175
- try {
176
- const raw = await fs.readFile(pageRefsPath, "utf8");
177
- return { pageRefs: deserializePageRefs(raw) };
178
- } catch (err) {
179
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
180
- throw err;
181
- }
182
- }
183
-
184
- /**
185
- * Delete all but the most recent {@link MAX_RETAINED_SNAPSHOTS} snapshot
186
- * directories. Snapshots sort lexicographically by their label; numeric
187
- * timestamps and zero-padded labels both order chronologically.
188
- */
189
- async function pruneSnapshots(dataDir: string): Promise<void> {
190
- const root = snapshotsRoot(dataDir);
191
- let entries: import("node:fs").Dirent[];
192
- try {
193
- entries = await fs.readdir(root, { withFileTypes: true });
194
- } catch (err) {
195
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
196
- throw err;
197
- }
198
- const dirs = entries
199
- .filter((e) => e.isDirectory())
200
- .map((e) => e.name)
201
- .sort();
202
- const stale = dirs.slice(
203
- 0,
204
- Math.max(0, dirs.length - MAX_RETAINED_SNAPSHOTS),
205
- );
206
- for (const name of stale) {
207
- await removeIfExists(path.join(root, name));
208
- }
209
- }