@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,219 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
-
3
- import { computeV3Health, renderV3Health } from "../health.js";
4
- import type { LeafNode, LeafPath, LeafTree, Slug } from "../types.js";
5
-
6
- /** Build an in-memory leaf node with the given members. */
7
- function leaf(path: LeafPath, members: Slug[]): LeafNode {
8
- return {
9
- path,
10
- frontmatter: { path, in_core: false },
11
- description: "",
12
- members: [...members],
13
- domain: path.split("/")[0],
14
- };
15
- }
16
-
17
- /**
18
- * Build a {@link LeafTree} from `{ leafPath: members[] }`. `byPage` is the
19
- * inverted membership map, matching what `loadLeafTree` produces.
20
- */
21
- function tree(spec: Record<LeafPath, Slug[]>): LeafTree {
22
- const leaves = new Map<LeafPath, LeafNode>();
23
- const byPage = new Map<Slug, LeafPath[]>();
24
- for (const [path, members] of Object.entries(spec)) {
25
- leaves.set(path, leaf(path, members));
26
- for (const slug of members) {
27
- const existing = byPage.get(slug);
28
- if (existing) existing.push(path);
29
- else byPage.set(slug, [path]);
30
- }
31
- }
32
- return { leaves, byPage };
33
- }
34
-
35
- describe("computeV3Health", () => {
36
- test("flags unassigned slugs (in neither byPage nor a leaf)", () => {
37
- const t = tree({ "domain-a/topic-x": ["page-a"] });
38
- const report = computeV3Health({
39
- tree: t,
40
- allSlugs: ["page-a", "page-b", "page-c"],
41
- });
42
- expect(report.unassigned).toEqual(["page-b", "page-c"]);
43
- });
44
-
45
- test("excludes synthetic capability slugs from unassigned / novel clusters", () => {
46
- const t = tree({ "domain-a/topic-x": ["page-a"] });
47
- const report = computeV3Health({
48
- tree: t,
49
- allSlugs: ["page-a", "page-b", "cli-commands/example", "skills/example"],
50
- });
51
- // Capability slugs are handled by the always-on capabilities leaf (injected
52
- // into the live lane tree, absent here), not the persisted tree — so they must
53
- // not be reported as unassigned or grouped into novel clusters. page-b is the
54
- // one real unassigned concept page.
55
- expect(report.unassigned).toEqual(["page-b"]);
56
- expect(report.novelClusters).toEqual([
57
- { prefix: "page-b", slugs: ["page-b"], count: 1 },
58
- ]);
59
- });
60
-
61
- test("flags dangling page refs pointing at missing leaves", () => {
62
- const t = tree({ "domain-a/topic-x": ["page-a"] });
63
- const report = computeV3Health({
64
- tree: t,
65
- allSlugs: ["page-a", "page-b"],
66
- pageRefs: new Map([
67
- ["page-a", ["domain-a/topic-x"]],
68
- ["page-b", ["domain-a/topic-missing"]],
69
- ]),
70
- });
71
- expect(report.danglingRefs).toEqual([
72
- { source: "page-b", leaf: "domain-a/topic-missing" },
73
- ]);
74
- });
75
-
76
- test("flags dangling core refs", () => {
77
- const t = tree({ "domain-a/topic-x": ["page-a"] });
78
- const report = computeV3Health({
79
- tree: t,
80
- allSlugs: ["page-a"],
81
- core: new Set(["domain-a/topic-x", "domain-z/gone"]),
82
- });
83
- expect(report.danglingRefs).toEqual([
84
- { source: "core", leaf: "domain-z/gone" },
85
- ]);
86
- });
87
-
88
- test("groups unassigned slugs into novel clusters by 2-level prefix", () => {
89
- const t = tree({ "domain-a/topic-x": ["assigned"] });
90
- const report = computeV3Health({
91
- tree: t,
92
- allSlugs: [
93
- "assigned",
94
- "area-a/topic-x/one",
95
- "area-a/topic-x/two",
96
- "area-b/topic-y",
97
- ],
98
- });
99
- expect(report.novelClusters).toEqual([
100
- {
101
- prefix: "area-a/topic-x",
102
- slugs: ["area-a/topic-x/one", "area-a/topic-x/two"],
103
- count: 2,
104
- },
105
- { prefix: "area-b/topic-y", slugs: ["area-b/topic-y"], count: 1 },
106
- ]);
107
- });
108
-
109
- test("flags oversized leaves above 3x the median member count", () => {
110
- // Medians: counts [1,1,1,12] -> sorted median = (1+1)/2 = 1; threshold 3.
111
- const t = tree({
112
- "domain-a/small-1": ["s1"],
113
- "domain-a/small-2": ["s2"],
114
- "domain-a/small-3": ["s3"],
115
- "domain-a/big": Array.from({ length: 12 }, (_, i) => `b${i}`),
116
- });
117
- const report = computeV3Health({ tree: t, allSlugs: [] });
118
- expect(report.oversizedLeaves).toEqual([
119
- { leaf: "domain-a/big", members: 12 },
120
- ]);
121
- });
122
-
123
- test("flags tiny leaves with 0-1 members", () => {
124
- const t = tree({
125
- "domain-a/empty": [],
126
- "domain-a/single": ["s1"],
127
- "domain-a/full": ["a", "b", "c"],
128
- });
129
- const report = computeV3Health({ tree: t, allSlugs: [] });
130
- expect(report.tinyLeaves).toEqual([
131
- { leaf: "domain-a/empty", members: 0 },
132
- { leaf: "domain-a/single", members: 1 },
133
- ]);
134
- });
135
-
136
- test("staleLabels is deferred (always empty)", () => {
137
- const t = tree({ "domain-a/topic-x": ["page-a"] });
138
- const report = computeV3Health({ tree: t, allSlugs: ["page-a"] });
139
- expect(report.staleLabels).toEqual([]);
140
- });
141
-
142
- test("passes lastMaintain through", () => {
143
- const t = tree({ "domain-a/topic-x": ["page-a"] });
144
- const report = computeV3Health({
145
- tree: t,
146
- allSlugs: ["page-a"],
147
- lastMaintain: { at: 12345, ok: true },
148
- });
149
- expect(report.lastMaintain).toEqual({ at: 12345, ok: true });
150
- });
151
-
152
- test("does not mutate inputs", () => {
153
- const t = tree({ "domain-a/topic-x": ["page-a", "page-b"] });
154
- const allSlugs = ["page-a", "page-c"];
155
- computeV3Health({ tree: t, allSlugs });
156
- expect(allSlugs).toEqual(["page-a", "page-c"]);
157
- expect(t.leaves.get("domain-a/topic-x")?.members).toEqual([
158
- "page-a",
159
- "page-b",
160
- ]);
161
- });
162
- });
163
-
164
- describe("renderV3Health", () => {
165
- test("returns empty string when all-green", () => {
166
- // A perfectly balanced tree: every leaf has the median count, no
167
- // unassigned, no dangling, no clusters, lastMaintain ok.
168
- const t = tree({
169
- "domain-a/topic-x": ["page-a", "page-b"],
170
- "domain-a/topic-y": ["page-c", "page-d"],
171
- });
172
- const report = computeV3Health({
173
- tree: t,
174
- allSlugs: ["page-a", "page-b", "page-c", "page-d"],
175
- lastMaintain: { at: 1, ok: true },
176
- });
177
- expect(report.unassigned).toEqual([]);
178
- expect(report.danglingRefs).toEqual([]);
179
- expect(report.novelClusters).toEqual([]);
180
- expect(report.oversizedLeaves).toEqual([]);
181
- expect(report.tinyLeaves).toEqual([]);
182
- expect(renderV3Health(report)).toBe("");
183
- });
184
-
185
- test("returns empty string when report is entirely empty and no maintain", () => {
186
- const report = computeV3Health({
187
- tree: { leaves: new Map(), byPage: new Map() },
188
- allSlugs: [],
189
- });
190
- expect(renderV3Health(report)).toBe("");
191
- });
192
-
193
- test("renders a non-empty block when any signal fires", () => {
194
- const t = tree({ "domain-a/topic-x": ["page-a"] });
195
- const report = computeV3Health({
196
- tree: t,
197
- allSlugs: ["page-a", "page-b"],
198
- });
199
- const rendered = renderV3Health(report);
200
- expect(rendered).not.toBe("");
201
- expect(rendered).toContain("memory-v3 health:");
202
- expect(rendered).toContain("unassigned");
203
- expect(rendered).toContain("page-b");
204
- });
205
-
206
- test("renders a failed last-maintenance even with no other signals", () => {
207
- const t = tree({
208
- "domain-a/topic-x": ["page-a", "page-b"],
209
- "domain-a/topic-y": ["page-c", "page-d"],
210
- });
211
- const report = computeV3Health({
212
- tree: t,
213
- allSlugs: ["page-a", "page-b", "page-c", "page-d"],
214
- lastMaintain: { at: 99, ok: false },
215
- });
216
- const rendered = renderV3Health(report);
217
- expect(rendered).toContain("last maintenance FAILED");
218
- });
219
- });
@@ -1,107 +0,0 @@
1
- import { expect, test } from "bun:test";
2
-
3
- import { buildNeedleIndex } from "../needle.js";
4
- import type { LeafNode, LeafPath, LeafTree, Slug } from "../types.js";
5
-
6
- /**
7
- * Builds a small synthetic LeafTree + summary stub. No real content — every
8
- * label, summary, and slug here is invented for the test.
9
- */
10
- function makeFixture(): {
11
- tree: LeafTree;
12
- summaries: Map<Slug, string>;
13
- } {
14
- const leaves = new Map<LeafPath, LeafNode>();
15
- const byPage = new Map<Slug, LeafPath[]>();
16
-
17
- const leaf = (path: LeafPath, description: string, members: Slug[]): void => {
18
- leaves.set(path, {
19
- path,
20
- frontmatter: { path, in_core: false },
21
- description,
22
- members,
23
- domain: "synthetic",
24
- });
25
- for (const slug of members) {
26
- const list = byPage.get(slug) ?? [];
27
- list.push(path);
28
- byPage.set(slug, list);
29
- }
30
- };
31
-
32
- leaf("animals/aquatic", "creatures that swim in water", ["dolphin-notes"]);
33
- leaf("animals/birds", "winged creatures that fly", ["sparrow-notes"]);
34
- leaf("vehicles/land", "machines with wheels for roads", ["bicycle-notes"]);
35
-
36
- const summaries = new Map<Slug, string>([
37
- ["dolphin-notes", "observations about marine mammals and echolocation"],
38
- ["sparrow-notes", "field journal of garden visitors"],
39
- ["bicycle-notes", "maintenance log for chains and brakes"],
40
- ]);
41
-
42
- return { tree: { leaves, byPage }, summaries };
43
- }
44
-
45
- test("query matches a literal term in a page summary", async () => {
46
- const { tree, summaries } = makeFixture();
47
- const index = await buildNeedleIndex(
48
- tree,
49
- async (slug) => summaries.get(slug) ?? "",
50
- );
51
-
52
- const results = index.query("echolocation", 3);
53
- expect(results[0]).toBe("dolphin-notes");
54
- });
55
-
56
- test("query matches a term that appears only in a leaf label", async () => {
57
- const { tree, summaries } = makeFixture();
58
- const index = await buildNeedleIndex(
59
- tree,
60
- async (slug) => summaries.get(slug) ?? "",
61
- );
62
-
63
- // "wheels" appears only in the vehicles/land leaf description, not in any
64
- // summary or slug — it must still surface bicycle-notes.
65
- const results = index.query("wheels", 3);
66
- expect(results).toContain("bicycle-notes");
67
- });
68
-
69
- test("query matches a term in the slug title segment", async () => {
70
- const { tree, summaries } = makeFixture();
71
- const index = await buildNeedleIndex(
72
- tree,
73
- async (slug) => summaries.get(slug) ?? "",
74
- );
75
-
76
- const results = index.query("sparrow", 3);
77
- expect(results[0]).toBe("sparrow-notes");
78
- });
79
-
80
- test("nonsense query returns no spurious results", async () => {
81
- const { tree, summaries } = makeFixture();
82
- const index = await buildNeedleIndex(
83
- tree,
84
- async (slug) => summaries.get(slug) ?? "",
85
- );
86
-
87
- expect(index.query("xyzzyplugh", 3)).toEqual([]);
88
- });
89
-
90
- test("results are capped at k and ordered by score", async () => {
91
- const { tree, summaries } = makeFixture();
92
- const index = await buildNeedleIndex(
93
- tree,
94
- async (slug) => summaries.get(slug) ?? "",
95
- );
96
-
97
- // "creatures" appears in both animal leaf labels; vehicles should be absent.
98
- const results = index.query("creatures", 1);
99
- expect(results.length).toBe(1);
100
- expect(results[0]).not.toBe("bicycle-notes");
101
- });
102
-
103
- test("empty corpus yields empty results", async () => {
104
- const tree: LeafTree = { leaves: new Map(), byPage: new Map() };
105
- const index = await buildNeedleIndex(tree, async () => "");
106
- expect(index.query("anything", 5)).toEqual([]);
107
- });
@@ -1,13 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
-
3
- import { cachedTextBlock } from "../provider-blocks.js";
4
-
5
- describe("cachedTextBlock", () => {
6
- test("stamps an ephemeral cache_control with a 1h TTL", () => {
7
- const block = cachedTextBlock("stable leaf block");
8
- expect(block).toMatchObject({ type: "text", text: "stable leaf block" });
9
- expect(
10
- (block as unknown as { cache_control?: unknown }).cache_control,
11
- ).toEqual({ type: "ephemeral", ttl: "1h" });
12
- });
13
- });
@@ -1,274 +0,0 @@
1
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
5
-
6
- import { readPage, writePage } from "../../../../memory/v2/page-store.js";
7
- import type { Provider } from "../../../../providers/types.js";
8
- import { type LeafRef, reconcileTree } from "../reconcile.js";
9
-
10
- /**
11
- * A canned provider whose `assign_leaves` tool-use response is computed from
12
- * the user message (the page content) via `idsForMessage`. This lets a test
13
- * deterministically re-home each page onto specific surviving leaves without a
14
- * real LLM. The leaf index in the system prompt is sorted by path; tests pass
15
- * the 1-based id of the target leaf in that sorted order.
16
- */
17
- function stubProvider(
18
- idsForMessage: (message: string, systemPrompt: string) => number[],
19
- ): Provider {
20
- return {
21
- name: "stub",
22
- async sendMessage(messages: unknown, opts: unknown) {
23
- const msg = messages as Array<{
24
- content: Array<{ type: string; text?: string }>;
25
- }>;
26
- const text = msg[0]?.content?.map((c) => c.text ?? "").join("") ?? "";
27
- const systemPrompt =
28
- (opts as { systemPrompt?: string }).systemPrompt ?? "";
29
- const ids = idsForMessage(text, systemPrompt);
30
- return {
31
- id: "msg_stub",
32
- role: "assistant",
33
- model: "stub-model",
34
- stopReason: "tool_use",
35
- content: [
36
- {
37
- type: "tool_use",
38
- id: "tool_1",
39
- name: "assign_leaves",
40
- input: { ids },
41
- },
42
- ],
43
- };
44
- },
45
- } as unknown as Provider;
46
- }
47
-
48
- let workspaceDir: string;
49
- let dataDir: string;
50
-
51
- beforeEach(async () => {
52
- workspaceDir = await mkdtemp(join(tmpdir(), "reconcile-ws-"));
53
- dataDir = await mkdtemp(join(tmpdir(), "reconcile-data-"));
54
- });
55
-
56
- afterEach(async () => {
57
- await rm(workspaceDir, { recursive: true, force: true });
58
- await rm(dataDir, { recursive: true, force: true });
59
- await rm(join(dataDir, "..", "v3-snapshots"), {
60
- recursive: true,
61
- force: true,
62
- }).catch(() => {});
63
- });
64
-
65
- /** Write a leaf `.md` with `{path, in_core, id}` frontmatter. */
66
- async function writeLeaf(
67
- path: string,
68
- opts: { id?: string; inCore?: boolean; body?: string } = {},
69
- ): Promise<void> {
70
- const full = join(dataDir, "leaves", `${path}.md`);
71
- await mkdir(join(full, ".."), { recursive: true });
72
- const lines = [`path: ${path}`, `in_core: ${opts.inCore ?? false}`];
73
- if (opts.id) lines.push(`id: ${opts.id}`);
74
- await writeFile(
75
- full,
76
- `---\n${lines.join("\n")}\n---\n${opts.body ?? `Leaf ${path}.`}\n`,
77
- );
78
- }
79
-
80
- async function writeCore(alwaysOn: string[]): Promise<void> {
81
- await writeFile(join(dataDir, "core.json"), JSON.stringify({ alwaysOn }));
82
- }
83
-
84
- /**
85
- * `loadLeafTree` (used by the re-homing path) reads `assignments.json`
86
- * unconditionally, so seed an empty one. Page→leaf membership comes from page
87
- * frontmatter, not this file.
88
- */
89
- async function seedAssignments(): Promise<void> {
90
- await writeFile(join(dataDir, "assignments.json"), "{}");
91
- }
92
-
93
- async function writePageWithLeaves(
94
- slug: string,
95
- leaves: string[],
96
- ): Promise<void> {
97
- await writePage(workspaceDir, {
98
- slug,
99
- frontmatter: { edges: [], ref_files: [], ref_urls: [], leaves },
100
- body: `Body of ${slug}.`,
101
- });
102
- }
103
-
104
- async function pageLeaves(slug: string): Promise<string[]> {
105
- const page = await readPage(workspaceDir, slug);
106
- return page?.frontmatter.leaves ?? [];
107
- }
108
-
109
- async function coreOf(): Promise<string[]> {
110
- const raw = await readFile(join(dataDir, "core.json"), "utf8");
111
- return (JSON.parse(raw) as { alwaysOn: string[] }).alwaysOn;
112
- }
113
-
114
- describe("reconcileTree", () => {
115
- test("rename preserves assignments (refs rewritten old → new)", async () => {
116
- // Current tree: the leaf moved from domain-a/topic-x → domain-b/topic-x,
117
- // keeping its stable id.
118
- await writeLeaf("domain-b/topic-x", { id: "leaf-x" });
119
- await writeCore(["domain-a/topic-x"]);
120
- await writePageWithLeaves("page-a", ["domain-a/topic-x"]);
121
-
122
- const prev: LeafRef[] = [{ id: "leaf-x", path: "domain-a/topic-x" }];
123
- const result = await reconcileTree({
124
- prevLeaves: prev,
125
- dataDir,
126
- workspaceDir,
127
- });
128
-
129
- expect(await pageLeaves("page-a")).toEqual(["domain-b/topic-x"]);
130
- expect(await coreOf()).toEqual(["domain-b/topic-x"]);
131
- expect(result.renames).toEqual([
132
- {
133
- id: "leaf-x",
134
- oldPath: "domain-a/topic-x",
135
- newPath: "domain-b/topic-x",
136
- },
137
- ]);
138
- expect(result.idToNewPath.get("leaf-x")).toBe("domain-b/topic-x");
139
- });
140
-
141
- test("delete re-homes members with no orphan", async () => {
142
- // leaf-x is deleted; leaf-y survives. page-a was only on leaf-x.
143
- await writeLeaf("domain-a/topic-y", { id: "leaf-y" });
144
- await writeCore([]);
145
- await seedAssignments();
146
- await writePageWithLeaves("page-a", ["domain-a/topic-x"]);
147
-
148
- // Surviving leaf is sorted index 1 (only leaf in tree).
149
- const provider = stubProvider(() => [1]);
150
-
151
- const prev: LeafRef[] = [
152
- { id: "leaf-x", path: "domain-a/topic-x" },
153
- { id: "leaf-y", path: "domain-a/topic-y" },
154
- ];
155
- const result = await reconcileTree({
156
- prevLeaves: prev,
157
- dataDir,
158
- workspaceDir,
159
- provider,
160
- });
161
-
162
- const after = await pageLeaves("page-a");
163
- // No orphan: at least one surviving leaf, and the dead path is gone.
164
- expect(after.length).toBeGreaterThanOrEqual(1);
165
- expect(after).not.toContain("domain-a/topic-x");
166
- expect(after).toContain("domain-a/topic-y");
167
- expect(result.deleted).toEqual(["domain-a/topic-x"]);
168
- });
169
-
170
- test("split distributes members across toPaths", async () => {
171
- // leaf-x splits into leaf-x1 (domain-a/x1) and leaf-x2 (domain-a/x2).
172
- await writeLeaf("domain-a/x1", { id: "leaf-x1" });
173
- await writeLeaf("domain-a/x2", { id: "leaf-x2" });
174
- // A third surviving leaf NOT in the split target set — members must not
175
- // land here.
176
- await writeLeaf("domain-b/other", { id: "leaf-other" });
177
- await writeCore([]);
178
- await seedAssignments();
179
- await writePageWithLeaves("page-a", ["domain-a/topic-x"]);
180
- await writePageWithLeaves("page-b", ["domain-a/topic-x"]);
181
-
182
- // Restricted tree (split.toPaths) sorts as [domain-a/x1, domain-a/x2].
183
- // page-a → x1 (id 1), page-b → x2 (id 2).
184
- const provider = stubProvider((message) =>
185
- message.includes("page-a") ? [1] : [2],
186
- );
187
-
188
- const prev: LeafRef[] = [{ id: "leaf-x", path: "domain-a/topic-x" }];
189
- await reconcileTree({
190
- prevLeaves: prev,
191
- dataDir,
192
- workspaceDir,
193
- provider,
194
- splits: [{ fromId: "leaf-x", toPaths: ["domain-a/x1", "domain-a/x2"] }],
195
- });
196
-
197
- expect(await pageLeaves("page-a")).toEqual(["domain-a/x1"]);
198
- expect(await pageLeaves("page-b")).toEqual(["domain-a/x2"]);
199
- // Neither page landed on the off-target leaf.
200
- expect(await pageLeaves("page-a")).not.toContain("domain-b/other");
201
- expect(await pageLeaves("page-b")).not.toContain("domain-b/other");
202
- });
203
-
204
- test("dangling core entry is pruned", async () => {
205
- await writeLeaf("domain-a/topic-x", { id: "leaf-x" });
206
- // core.json points at a leaf that no longer exists in the tree.
207
- await writeCore(["domain-a/topic-x", "domain-a/gone"]);
208
- await writePageWithLeaves("page-a", ["domain-a/topic-x"]);
209
-
210
- // No prev entry for the gone leaf and no page references it, so reconcile
211
- // succeeds and prunes the stale core entry.
212
- const prev: LeafRef[] = [{ id: "leaf-x", path: "domain-a/topic-x" }];
213
- const result = await reconcileTree({
214
- prevLeaves: prev,
215
- dataDir,
216
- workspaceDir,
217
- });
218
-
219
- expect(await coreOf()).toEqual(["domain-a/topic-x"]);
220
- expect(result.prunedCore).toEqual(["domain-a/gone"]);
221
- });
222
-
223
- test("out-of-band dangling page ref converges (prune pass)", async () => {
224
- // The current tree has only leaf-x. page-a references leaf-x (fine) PLUS a
225
- // residual leaf path that exists in NEITHER prev nor current — an out-of-band
226
- // ref a maintainer left when deleting a leaf file without a `prevLeaves`
227
- // entry. v1 reconcile is a convergence/prune pass: it drops the dangling ref
228
- // (the page keeps its surviving leaf, so no re-home is needed) instead of
229
- // failing closed.
230
- await writeLeaf("domain-a/topic-x", { id: "leaf-x" });
231
- await writeCore(["domain-a/topic-x"]);
232
- await seedAssignments();
233
- await writePageWithLeaves("page-a", [
234
- "domain-a/topic-x",
235
- "domain-a/residual",
236
- ]);
237
-
238
- const prev: LeafRef[] = [{ id: "leaf-x", path: "domain-a/topic-x" }];
239
- const result = await reconcileTree({
240
- prevLeaves: prev,
241
- dataDir,
242
- workspaceDir,
243
- });
244
-
245
- // Converged: the dangling residual ref is gone; the surviving leaf remains.
246
- expect(await pageLeaves("page-a")).toEqual(["domain-a/topic-x"]);
247
- expect(await coreOf()).toEqual(["domain-a/topic-x"]);
248
- // The out-of-band drop is not a reconciler-tracked delete (no prev entry),
249
- // so it is not reported in `deleted`.
250
- expect(result.deleted).toEqual([]);
251
- });
252
-
253
- test("page whose ONLY leaf dangles out-of-band is re-homed, not orphaned", async () => {
254
- // page-a's sole leaf was deleted on disk with no `prevLeaves` entry. The
255
- // convergence pass must re-home it onto a surviving leaf before dropping the
256
- // dead ref, so the page is never left with zero leaves.
257
- await writeLeaf("domain-a/topic-x", { id: "leaf-x" });
258
- await writeCore([]);
259
- await seedAssignments();
260
- // page-a points only at a leaf absent from the tree (out-of-band delete).
261
- await writePageWithLeaves("page-a", ["domain-a/gone"]);
262
-
263
- // Only surviving leaf is sorted index 1 → re-home target.
264
- const provider = stubProvider(() => [1]);
265
-
266
- const prev: LeafRef[] = [{ id: "leaf-x", path: "domain-a/topic-x" }];
267
- await reconcileTree({ prevLeaves: prev, dataDir, workspaceDir, provider });
268
-
269
- const after = await pageLeaves("page-a");
270
- expect(after).not.toContain("domain-a/gone"); // dangling ref dropped
271
- expect(after).toEqual(["domain-a/topic-x"]); // re-homed onto the survivor
272
- expect(after.length).toBeGreaterThanOrEqual(1); // never orphaned
273
- });
274
- });