@vellumai/assistant 0.4.49 → 0.4.50

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 (239) hide show
  1. package/ARCHITECTURE.md +24 -33
  2. package/README.md +3 -3
  3. package/docs/architecture/memory.md +180 -119
  4. package/package.json +2 -2
  5. package/src/__tests__/agent-loop.test.ts +3 -1
  6. package/src/__tests__/anthropic-provider.test.ts +114 -23
  7. package/src/__tests__/approval-cascade.test.ts +1 -15
  8. package/src/__tests__/approval-routes-http.test.ts +2 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +0 -23
  10. package/src/__tests__/canonical-guardian-store.test.ts +95 -0
  11. package/src/__tests__/checker.test.ts +13 -0
  12. package/src/__tests__/config-schema.test.ts +1 -68
  13. package/src/__tests__/context-memory-e2e.test.ts +11 -100
  14. package/src/__tests__/conversation-routes-guardian-reply.test.ts +8 -0
  15. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  16. package/src/__tests__/credential-security-e2e.test.ts +1 -0
  17. package/src/__tests__/credential-vault-unit.test.ts +4 -0
  18. package/src/__tests__/credential-vault.test.ts +13 -1
  19. package/src/__tests__/cu-unified-flow.test.ts +532 -0
  20. package/src/__tests__/date-context.test.ts +93 -77
  21. package/src/__tests__/deterministic-verification-control-plane.test.ts +64 -0
  22. package/src/__tests__/guardian-routing-invariants.test.ts +93 -0
  23. package/src/__tests__/history-repair.test.ts +245 -0
  24. package/src/__tests__/host-cu-proxy.test.ts +165 -3
  25. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  26. package/src/__tests__/invite-redemption-service.test.ts +65 -1
  27. package/src/__tests__/keychain-broker-client.test.ts +4 -4
  28. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +56 -18
  29. package/src/__tests__/memory-lifecycle-e2e.test.ts +244 -387
  30. package/src/__tests__/memory-recall-quality.test.ts +244 -407
  31. package/src/__tests__/memory-regressions.experimental.test.ts +126 -101
  32. package/src/__tests__/memory-regressions.test.ts +477 -2841
  33. package/src/__tests__/memory-retrieval.benchmark.test.ts +33 -150
  34. package/src/__tests__/memory-upsert-concurrency.test.ts +5 -244
  35. package/src/__tests__/mime-builder.test.ts +28 -0
  36. package/src/__tests__/native-web-search.test.ts +1 -0
  37. package/src/__tests__/oauth-cli.test.ts +572 -5
  38. package/src/__tests__/oauth-store.test.ts +120 -6
  39. package/src/__tests__/qdrant-collection-migration.test.ts +53 -8
  40. package/src/__tests__/registry.test.ts +0 -1
  41. package/src/__tests__/relay-server.test.ts +46 -1
  42. package/src/__tests__/schedule-tools.test.ts +32 -0
  43. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  44. package/src/__tests__/secret-onetime-send.test.ts +1 -0
  45. package/src/__tests__/secure-keys.test.ts +7 -2
  46. package/src/__tests__/send-endpoint-busy.test.ts +3 -0
  47. package/src/__tests__/session-abort-tool-results.test.ts +1 -14
  48. package/src/__tests__/session-agent-loop-overflow.test.ts +1583 -0
  49. package/src/__tests__/session-agent-loop.test.ts +19 -15
  50. package/src/__tests__/session-confirmation-signals.test.ts +1 -15
  51. package/src/__tests__/session-error.test.ts +124 -2
  52. package/src/__tests__/session-history-web-search.test.ts +918 -0
  53. package/src/__tests__/session-pre-run-repair.test.ts +1 -14
  54. package/src/__tests__/session-provider-retry-repair.test.ts +25 -28
  55. package/src/__tests__/session-queue.test.ts +37 -27
  56. package/src/__tests__/session-runtime-assembly.test.ts +54 -0
  57. package/src/__tests__/session-slash-known.test.ts +1 -15
  58. package/src/__tests__/session-slash-queue.test.ts +1 -15
  59. package/src/__tests__/session-slash-unknown.test.ts +1 -15
  60. package/src/__tests__/session-workspace-cache-state.test.ts +3 -33
  61. package/src/__tests__/session-workspace-injection.test.ts +3 -37
  62. package/src/__tests__/session-workspace-tool-tracking.test.ts +3 -37
  63. package/src/__tests__/skills-install-extract.test.ts +93 -0
  64. package/src/__tests__/skillssh-registry.test.ts +451 -0
  65. package/src/__tests__/trust-store.test.ts +15 -0
  66. package/src/__tests__/voice-invite-redemption.test.ts +32 -1
  67. package/src/agent/ax-tree-compaction.test.ts +51 -0
  68. package/src/agent/loop.ts +39 -12
  69. package/src/approvals/AGENTS.md +1 -1
  70. package/src/approvals/guardian-request-resolvers.ts +14 -2
  71. package/src/bundler/compiler-tools.ts +66 -2
  72. package/src/calls/call-domain.ts +132 -0
  73. package/src/calls/call-store.ts +6 -0
  74. package/src/calls/relay-server.ts +43 -5
  75. package/src/calls/relay-setup-router.ts +17 -1
  76. package/src/calls/twilio-config.ts +1 -1
  77. package/src/calls/types.ts +3 -1
  78. package/src/cli/commands/doctor.ts +4 -3
  79. package/src/cli/commands/mcp.ts +46 -59
  80. package/src/cli/commands/memory.ts +16 -165
  81. package/src/cli/commands/oauth/apps.ts +31 -2
  82. package/src/cli/commands/oauth/connections.ts +431 -97
  83. package/src/cli/commands/oauth/providers.ts +15 -1
  84. package/src/cli/commands/sessions.ts +5 -2
  85. package/src/cli/commands/skills.ts +173 -1
  86. package/src/cli/http-client.ts +0 -20
  87. package/src/cli/main-screen.tsx +2 -2
  88. package/src/cli/program.ts +5 -6
  89. package/src/cli.ts +4 -10
  90. package/src/config/bundled-skills/computer-use/TOOLS.json +1 -1
  91. package/src/config/bundled-skills/computer-use/tools/computer-use-observe.ts +12 -0
  92. package/src/config/bundled-tool-registry.ts +2 -5
  93. package/src/config/schema.ts +1 -12
  94. package/src/config/schemas/memory-lifecycle.ts +0 -9
  95. package/src/config/schemas/memory-processing.ts +0 -180
  96. package/src/config/schemas/memory-retrieval.ts +32 -104
  97. package/src/config/schemas/memory.ts +0 -10
  98. package/src/config/types.ts +0 -4
  99. package/src/context/window-manager.ts +4 -1
  100. package/src/daemon/config-watcher.ts +61 -3
  101. package/src/daemon/daemon-control.ts +1 -1
  102. package/src/daemon/date-context.ts +114 -31
  103. package/src/daemon/handlers/sessions.ts +18 -13
  104. package/src/daemon/handlers/skills.ts +20 -1
  105. package/src/daemon/history-repair.ts +72 -8
  106. package/src/daemon/host-cu-proxy.ts +55 -26
  107. package/src/daemon/lifecycle.ts +31 -3
  108. package/src/daemon/mcp-reload-service.ts +2 -2
  109. package/src/daemon/message-types/computer-use.ts +1 -12
  110. package/src/daemon/message-types/memory.ts +4 -16
  111. package/src/daemon/message-types/messages.ts +1 -0
  112. package/src/daemon/message-types/sessions.ts +4 -0
  113. package/src/daemon/server.ts +12 -1
  114. package/src/daemon/session-agent-loop-handlers.ts +38 -0
  115. package/src/daemon/session-agent-loop.ts +334 -48
  116. package/src/daemon/session-error.ts +89 -6
  117. package/src/daemon/session-history.ts +17 -7
  118. package/src/daemon/session-media-retry.ts +6 -2
  119. package/src/daemon/session-memory.ts +69 -149
  120. package/src/daemon/session-process.ts +10 -1
  121. package/src/daemon/session-runtime-assembly.ts +49 -19
  122. package/src/daemon/session-surfaces.ts +4 -1
  123. package/src/daemon/session-tool-setup.ts +7 -1
  124. package/src/daemon/session.ts +12 -2
  125. package/src/instrument.ts +61 -1
  126. package/src/memory/admin.ts +2 -191
  127. package/src/memory/canonical-guardian-store.ts +38 -2
  128. package/src/memory/conversation-crud.ts +0 -33
  129. package/src/memory/conversation-queries.ts +22 -3
  130. package/src/memory/db-init.ts +28 -0
  131. package/src/memory/embedding-backend.ts +84 -8
  132. package/src/memory/embedding-types.ts +9 -1
  133. package/src/memory/indexer.ts +7 -46
  134. package/src/memory/items-extractor.ts +274 -76
  135. package/src/memory/job-handlers/backfill.ts +2 -127
  136. package/src/memory/job-handlers/cleanup.ts +2 -16
  137. package/src/memory/job-handlers/extraction.ts +2 -138
  138. package/src/memory/job-handlers/index-maintenance.ts +1 -6
  139. package/src/memory/job-handlers/summarization.ts +3 -148
  140. package/src/memory/job-utils.ts +21 -59
  141. package/src/memory/jobs-store.ts +1 -159
  142. package/src/memory/jobs-worker.ts +9 -52
  143. package/src/memory/migrations/104-core-indexes.ts +3 -3
  144. package/src/memory/migrations/149-oauth-tables.ts +2 -0
  145. package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +98 -0
  146. package/src/memory/migrations/151-oauth-providers-ping-url.ts +11 -0
  147. package/src/memory/migrations/152-memory-item-supersession.ts +44 -0
  148. package/src/memory/migrations/153-drop-entity-tables.ts +15 -0
  149. package/src/memory/migrations/154-drop-fts.ts +20 -0
  150. package/src/memory/migrations/155-drop-conflicts.ts +7 -0
  151. package/src/memory/migrations/156-call-session-invite-metadata.ts +24 -0
  152. package/src/memory/migrations/index.ts +7 -0
  153. package/src/memory/qdrant-client.ts +148 -51
  154. package/src/memory/raw-query.ts +1 -1
  155. package/src/memory/retriever.test.ts +294 -273
  156. package/src/memory/retriever.ts +421 -645
  157. package/src/memory/schema/calls.ts +2 -0
  158. package/src/memory/schema/memory-core.ts +3 -48
  159. package/src/memory/schema/oauth.ts +2 -0
  160. package/src/memory/search/formatting.ts +263 -176
  161. package/src/memory/search/lexical.ts +1 -254
  162. package/src/memory/search/ranking.ts +0 -455
  163. package/src/memory/search/semantic.ts +100 -14
  164. package/src/memory/search/staleness.ts +47 -0
  165. package/src/memory/search/tier-classifier.ts +21 -0
  166. package/src/memory/search/types.ts +15 -77
  167. package/src/memory/task-memory-cleanup.ts +4 -6
  168. package/src/messaging/providers/gmail/mime-builder.ts +17 -7
  169. package/src/oauth/byo-connection.test.ts +8 -1
  170. package/src/oauth/oauth-store.ts +113 -27
  171. package/src/oauth/seed-providers.ts +6 -0
  172. package/src/oauth/token-persistence.ts +11 -3
  173. package/src/permissions/defaults.ts +1 -0
  174. package/src/permissions/trust-store.ts +23 -1
  175. package/src/playbooks/playbook-compiler.ts +1 -1
  176. package/src/prompts/system-prompt.ts +18 -2
  177. package/src/providers/anthropic/client.ts +56 -126
  178. package/src/providers/types.ts +7 -1
  179. package/src/runtime/AGENTS.md +9 -0
  180. package/src/runtime/auth/route-policy.ts +6 -3
  181. package/src/runtime/guardian-reply-router.ts +24 -22
  182. package/src/runtime/http-server.ts +2 -2
  183. package/src/runtime/invite-redemption-service.ts +19 -1
  184. package/src/runtime/invite-service.ts +25 -0
  185. package/src/runtime/pending-interactions.ts +2 -2
  186. package/src/runtime/routes/brain-graph-routes.ts +10 -90
  187. package/src/runtime/routes/conversation-routes.ts +9 -1
  188. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -12
  189. package/src/runtime/routes/memory-item-routes.test.ts +754 -0
  190. package/src/runtime/routes/memory-item-routes.ts +503 -0
  191. package/src/runtime/routes/session-management-routes.ts +3 -3
  192. package/src/runtime/routes/settings-routes.ts +2 -2
  193. package/src/runtime/routes/trust-rules-routes.ts +14 -0
  194. package/src/runtime/routes/workspace-routes.ts +2 -1
  195. package/src/security/keychain-broker-client.ts +17 -4
  196. package/src/security/secure-keys.ts +25 -3
  197. package/src/security/token-manager.ts +36 -36
  198. package/src/skills/catalog-install.ts +74 -18
  199. package/src/skills/skillssh-registry.ts +503 -0
  200. package/src/tools/assets/search.ts +5 -1
  201. package/src/tools/computer-use/definitions.ts +0 -10
  202. package/src/tools/computer-use/registry.ts +1 -1
  203. package/src/tools/credentials/vault.ts +1 -3
  204. package/src/tools/memory/definitions.ts +4 -13
  205. package/src/tools/memory/handlers.test.ts +83 -103
  206. package/src/tools/memory/handlers.ts +50 -85
  207. package/src/tools/schedule/create.ts +8 -1
  208. package/src/tools/schedule/update.ts +8 -1
  209. package/src/tools/skills/load.ts +25 -2
  210. package/src/__tests__/clarification-resolver.test.ts +0 -193
  211. package/src/__tests__/conflict-intent-tokenization.test.ts +0 -160
  212. package/src/__tests__/conflict-policy.test.ts +0 -269
  213. package/src/__tests__/conflict-store.test.ts +0 -372
  214. package/src/__tests__/contradiction-checker.test.ts +0 -361
  215. package/src/__tests__/entity-extractor.test.ts +0 -211
  216. package/src/__tests__/entity-search.test.ts +0 -1117
  217. package/src/__tests__/profile-compiler.test.ts +0 -392
  218. package/src/__tests__/session-conflict-gate.test.ts +0 -1228
  219. package/src/__tests__/session-profile-injection.test.ts +0 -557
  220. package/src/config/bundled-skills/knowledge-graph/SKILL.md +0 -25
  221. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +0 -66
  222. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +0 -211
  223. package/src/daemon/session-conflict-gate.ts +0 -167
  224. package/src/daemon/session-dynamic-profile.ts +0 -77
  225. package/src/memory/clarification-resolver.ts +0 -417
  226. package/src/memory/conflict-intent.ts +0 -205
  227. package/src/memory/conflict-policy.ts +0 -127
  228. package/src/memory/conflict-store.ts +0 -410
  229. package/src/memory/contradiction-checker.ts +0 -508
  230. package/src/memory/entity-extractor.ts +0 -535
  231. package/src/memory/format-recall.ts +0 -47
  232. package/src/memory/fts-reconciler.ts +0 -165
  233. package/src/memory/job-handlers/conflict.ts +0 -200
  234. package/src/memory/profile-compiler.ts +0 -195
  235. package/src/memory/recall-cache.ts +0 -117
  236. package/src/memory/search/entity.ts +0 -535
  237. package/src/memory/search/query-expansion.test.ts +0 -70
  238. package/src/memory/search/query-expansion.ts +0 -118
  239. package/src/runtime/routes/mcp-routes.ts +0 -20
@@ -0,0 +1,754 @@
1
+ /**
2
+ * Tests for memory item CRUD HTTP endpoints.
3
+ *
4
+ * Covers: list with filters, get by ID, create + duplicate rejection,
5
+ * update + fingerprint collision, delete + 404.
6
+ */
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import {
11
+ afterAll,
12
+ beforeAll,
13
+ beforeEach,
14
+ describe,
15
+ expect,
16
+ mock,
17
+ test,
18
+ } from "bun:test";
19
+
20
+ const testDir = mkdtempSync(join(tmpdir(), "memory-item-routes-test-"));
21
+
22
+ mock.module("../../util/platform.js", () => ({
23
+ getDataDir: () => testDir,
24
+ isMacOS: () => process.platform === "darwin",
25
+ isLinux: () => process.platform === "linux",
26
+ isWindows: () => process.platform === "win32",
27
+ getPidPath: () => join(testDir, "test.pid"),
28
+ getDbPath: () => join(testDir, "test.db"),
29
+ getLogPath: () => join(testDir, "test.log"),
30
+ ensureDataDir: () => {},
31
+ }));
32
+
33
+ mock.module("../../util/logger.js", () => ({
34
+ getLogger: () =>
35
+ new Proxy({} as Record<string, unknown>, {
36
+ get: () => () => {},
37
+ }),
38
+ }));
39
+
40
+ // Stub config loader
41
+ mock.module("../../config/loader.js", () => ({
42
+ loadConfig: () => ({}),
43
+ getConfig: () => ({}),
44
+ invalidateConfigCache: () => {},
45
+ }));
46
+
47
+ import { and, eq } from "drizzle-orm";
48
+
49
+ import { getDb, initializeDb, resetDb } from "../../memory/db.js";
50
+ import {
51
+ memoryEmbeddings,
52
+ memoryItems,
53
+ memoryJobs,
54
+ } from "../../memory/schema.js";
55
+ import type { RouteContext } from "../http-router.js";
56
+ import { memoryItemRouteDefinitions } from "./memory-item-routes.js";
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Helpers
60
+ // ---------------------------------------------------------------------------
61
+
62
+ function getHandler(endpoint: string, method: string) {
63
+ const routes = memoryItemRouteDefinitions();
64
+ const route = routes.find(
65
+ (r) => r.endpoint === endpoint && r.method === method,
66
+ );
67
+ if (!route) throw new Error(`No route: ${method} ${endpoint}`);
68
+ return route.handler;
69
+ }
70
+
71
+ function makeCtx(
72
+ searchParams: Record<string, string> = {},
73
+ params: Record<string, string> = {},
74
+ ): RouteContext {
75
+ const url = new URL("http://localhost/v1/memory-items");
76
+ for (const [k, v] of Object.entries(searchParams)) {
77
+ url.searchParams.set(k, v);
78
+ }
79
+ return {
80
+ url,
81
+ req: new Request(url),
82
+ server: {} as ReturnType<typeof Bun.serve>,
83
+ authContext: {} as never,
84
+ params,
85
+ };
86
+ }
87
+
88
+ function makeJsonCtx(
89
+ endpoint: string,
90
+ method: string,
91
+ body: unknown,
92
+ params: Record<string, string> = {},
93
+ ): RouteContext {
94
+ const url = new URL(`http://localhost/v1/${endpoint}`);
95
+ return {
96
+ url,
97
+ req: new Request(url, {
98
+ method,
99
+ headers: { "Content-Type": "application/json" },
100
+ body: JSON.stringify(body),
101
+ }),
102
+ server: {} as ReturnType<typeof Bun.serve>,
103
+ authContext: {} as never,
104
+ params,
105
+ };
106
+ }
107
+
108
+ function insertItem(opts: {
109
+ id: string;
110
+ kind: string;
111
+ subject: string;
112
+ statement: string;
113
+ status?: string;
114
+ importance?: number;
115
+ firstSeenAt?: number;
116
+ lastSeenAt?: number;
117
+ supersedes?: string;
118
+ supersededBy?: string;
119
+ }) {
120
+ const db = getDb();
121
+ const now = Date.now();
122
+ db.insert(memoryItems)
123
+ .values({
124
+ id: opts.id,
125
+ kind: opts.kind,
126
+ subject: opts.subject,
127
+ statement: opts.statement,
128
+ status: opts.status ?? "active",
129
+ confidence: 0.95,
130
+ importance: opts.importance ?? 0.8,
131
+ fingerprint: `fp-${opts.id}`,
132
+ verificationState: "user_confirmed",
133
+ scopeId: "default",
134
+ firstSeenAt: opts.firstSeenAt ?? now,
135
+ lastSeenAt: opts.lastSeenAt ?? now,
136
+ lastUsedAt: null,
137
+ })
138
+ .run();
139
+
140
+ if (opts.supersedes || opts.supersededBy) {
141
+ const set: Record<string, unknown> = {};
142
+ if (opts.supersedes) set.supersedes = opts.supersedes;
143
+ if (opts.supersededBy) set.supersededBy = opts.supersededBy;
144
+ db.update(memoryItems).set(set).where(eq(memoryItems.id, opts.id)).run();
145
+ }
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Suite
150
+ // ---------------------------------------------------------------------------
151
+
152
+ describe("Memory Item Routes", () => {
153
+ beforeAll(() => {
154
+ initializeDb();
155
+ });
156
+
157
+ beforeEach(() => {
158
+ const db = getDb();
159
+ db.run("DELETE FROM memory_embeddings");
160
+ db.run("DELETE FROM memory_item_sources");
161
+ db.run("DELETE FROM memory_items");
162
+ db.run("DELETE FROM memory_jobs");
163
+ });
164
+
165
+ afterAll(() => {
166
+ resetDb();
167
+ rmSync(testDir, { recursive: true, force: true });
168
+ });
169
+
170
+ // =========================================================================
171
+ // GET /v1/memory-items (list)
172
+ // =========================================================================
173
+
174
+ describe("GET /v1/memory-items", () => {
175
+ const handler = getHandler("memory-items", "GET");
176
+
177
+ test("returns empty list when no items", async () => {
178
+ const ctx = makeCtx();
179
+ const res = await handler(ctx);
180
+ expect(res.status).toBe(200);
181
+ const body = (await res.json()) as { items: unknown[]; total: number };
182
+ expect(body.items).toEqual([]);
183
+ expect(body.total).toBe(0);
184
+ });
185
+
186
+ test("returns all active items by default", async () => {
187
+ insertItem({
188
+ id: "i1",
189
+ kind: "preference",
190
+ subject: "s1",
191
+ statement: "st1",
192
+ });
193
+ insertItem({
194
+ id: "i2",
195
+ kind: "identity",
196
+ subject: "s2",
197
+ statement: "st2",
198
+ status: "deleted",
199
+ });
200
+
201
+ const ctx = makeCtx();
202
+ const res = await handler(ctx);
203
+ expect(res.status).toBe(200);
204
+ const body = (await res.json()) as {
205
+ items: Array<{ id: string }>;
206
+ total: number;
207
+ };
208
+ expect(body.total).toBe(1);
209
+ expect(body.items.length).toBe(1);
210
+ expect(body.items[0].id).toBe("i1");
211
+ });
212
+
213
+ test("returns items of all statuses when status=all", async () => {
214
+ insertItem({
215
+ id: "i1",
216
+ kind: "preference",
217
+ subject: "s1",
218
+ statement: "st1",
219
+ status: "active",
220
+ });
221
+ insertItem({
222
+ id: "i2",
223
+ kind: "identity",
224
+ subject: "s2",
225
+ statement: "st2",
226
+ status: "deleted",
227
+ });
228
+
229
+ const ctx = makeCtx({ status: "all" });
230
+ const res = await handler(ctx);
231
+ expect(res.status).toBe(200);
232
+ const body = (await res.json()) as {
233
+ items: Array<{ id: string }>;
234
+ total: number;
235
+ };
236
+ expect(body.total).toBe(2);
237
+ expect(body.items.length).toBe(2);
238
+ const ids = body.items.map((i) => i.id).sort();
239
+ expect(ids).toEqual(["i1", "i2"]);
240
+ });
241
+
242
+ test("filters by kind", async () => {
243
+ insertItem({
244
+ id: "i1",
245
+ kind: "preference",
246
+ subject: "s1",
247
+ statement: "st1",
248
+ });
249
+ insertItem({
250
+ id: "i2",
251
+ kind: "identity",
252
+ subject: "s2",
253
+ statement: "st2",
254
+ });
255
+
256
+ const ctx = makeCtx({ kind: "preference" });
257
+ const res = await handler(ctx);
258
+ const body = (await res.json()) as {
259
+ items: Array<{ id: string }>;
260
+ total: number;
261
+ };
262
+ expect(body.total).toBe(1);
263
+ expect(body.items[0].id).toBe("i1");
264
+ });
265
+
266
+ test("filters by search on subject and statement", async () => {
267
+ insertItem({
268
+ id: "i1",
269
+ kind: "preference",
270
+ subject: "dark mode",
271
+ statement: "User prefers dark mode",
272
+ });
273
+ insertItem({
274
+ id: "i2",
275
+ kind: "identity",
276
+ subject: "name",
277
+ statement: "User name is Alice",
278
+ });
279
+
280
+ const ctx = makeCtx({ search: "dark" });
281
+ const res = await handler(ctx);
282
+ const body = (await res.json()) as {
283
+ items: Array<{ id: string }>;
284
+ total: number;
285
+ };
286
+ expect(body.total).toBe(1);
287
+ expect(body.items[0].id).toBe("i1");
288
+ });
289
+
290
+ test("supports pagination with limit and offset", async () => {
291
+ insertItem({
292
+ id: "i1",
293
+ kind: "preference",
294
+ subject: "s1",
295
+ statement: "st1",
296
+ lastSeenAt: 1000,
297
+ });
298
+ insertItem({
299
+ id: "i2",
300
+ kind: "preference",
301
+ subject: "s2",
302
+ statement: "st2",
303
+ lastSeenAt: 2000,
304
+ });
305
+ insertItem({
306
+ id: "i3",
307
+ kind: "preference",
308
+ subject: "s3",
309
+ statement: "st3",
310
+ lastSeenAt: 3000,
311
+ });
312
+
313
+ const ctx = makeCtx({ limit: "1", offset: "1" });
314
+ const res = await handler(ctx);
315
+ const body = (await res.json()) as {
316
+ items: Array<{ id: string }>;
317
+ total: number;
318
+ };
319
+ expect(body.total).toBe(3);
320
+ expect(body.items.length).toBe(1);
321
+ // Default sort is lastSeenAt desc, so offset 1 should be i2
322
+ expect(body.items[0].id).toBe("i2");
323
+ });
324
+
325
+ test("supports sort by firstSeenAt ascending", async () => {
326
+ insertItem({
327
+ id: "i1",
328
+ kind: "preference",
329
+ subject: "s1",
330
+ statement: "st1",
331
+ firstSeenAt: 3000,
332
+ });
333
+ insertItem({
334
+ id: "i2",
335
+ kind: "preference",
336
+ subject: "s2",
337
+ statement: "st2",
338
+ firstSeenAt: 1000,
339
+ });
340
+
341
+ const ctx = makeCtx({ sort: "firstSeenAt", order: "asc" });
342
+ const res = await handler(ctx);
343
+ const body = (await res.json()) as {
344
+ items: Array<{ id: string }>;
345
+ };
346
+ expect(body.items[0].id).toBe("i2");
347
+ expect(body.items[1].id).toBe("i1");
348
+ });
349
+
350
+ test("rejects invalid kind filter", async () => {
351
+ const ctx = makeCtx({ kind: "bogus" });
352
+ const res = await handler(ctx);
353
+ expect(res.status).toBe(400);
354
+ });
355
+
356
+ test("rejects invalid sort field", async () => {
357
+ const ctx = makeCtx({ sort: "bogus" });
358
+ const res = await handler(ctx);
359
+ expect(res.status).toBe(400);
360
+ });
361
+ });
362
+
363
+ // =========================================================================
364
+ // GET /v1/memory-items/:id
365
+ // =========================================================================
366
+
367
+ describe("GET /v1/memory-items/:id", () => {
368
+ const handler = getHandler("memory-items/:id", "GET");
369
+
370
+ test("returns item by ID", async () => {
371
+ insertItem({
372
+ id: "i1",
373
+ kind: "preference",
374
+ subject: "dark mode",
375
+ statement: "Prefers dark mode",
376
+ });
377
+
378
+ const ctx = makeCtx({}, { id: "i1" });
379
+ const res = await handler(ctx);
380
+ expect(res.status).toBe(200);
381
+ const body = (await res.json()) as {
382
+ item: { id: string; subject: string };
383
+ };
384
+ expect(body.item.id).toBe("i1");
385
+ expect(body.item.subject).toBe("dark mode");
386
+ });
387
+
388
+ test("returns 404 for non-existent item", async () => {
389
+ const ctx = makeCtx({}, { id: "nonexistent" });
390
+ const res = await handler(ctx);
391
+ expect(res.status).toBe(404);
392
+ });
393
+
394
+ test("includes supersedesSubject when supersedes is set", async () => {
395
+ insertItem({
396
+ id: "old",
397
+ kind: "preference",
398
+ subject: "old pref",
399
+ statement: "old",
400
+ });
401
+ insertItem({
402
+ id: "new",
403
+ kind: "preference",
404
+ subject: "new pref",
405
+ statement: "new",
406
+ });
407
+
408
+ // Set supersedes relationship manually
409
+ getDb()
410
+ .update(memoryItems)
411
+ .set({ supersedes: "old" })
412
+ .where(eq(memoryItems.id, "new"))
413
+ .run();
414
+
415
+ const ctx = makeCtx({}, { id: "new" });
416
+ const res = await handler(ctx);
417
+ const body = (await res.json()) as {
418
+ item: { supersedesSubject?: string };
419
+ };
420
+ expect(body.item.supersedesSubject).toBe("old pref");
421
+ });
422
+ });
423
+
424
+ // =========================================================================
425
+ // POST /v1/memory-items
426
+ // =========================================================================
427
+
428
+ describe("POST /v1/memory-items", () => {
429
+ const handler = getHandler("memory-items", "POST");
430
+
431
+ test("creates a new memory item", async () => {
432
+ const ctx = makeJsonCtx("memory-items", "POST", {
433
+ kind: "preference",
434
+ subject: "dark mode",
435
+ statement: "User prefers dark mode",
436
+ });
437
+ const res = await handler(ctx);
438
+ expect(res.status).toBe(201);
439
+ const body = (await res.json()) as {
440
+ item: { id: string; kind: string; subject: string; statement: string };
441
+ };
442
+ expect(body.item.kind).toBe("preference");
443
+ expect(body.item.subject).toBe("dark mode");
444
+ expect(body.item.statement).toBe("User prefers dark mode");
445
+ });
446
+
447
+ test("uses custom importance when provided", async () => {
448
+ const ctx = makeJsonCtx("memory-items", "POST", {
449
+ kind: "preference",
450
+ subject: "importance test",
451
+ statement: "Testing custom importance",
452
+ importance: 0.5,
453
+ });
454
+ const res = await handler(ctx);
455
+ expect(res.status).toBe(201);
456
+ const body = (await res.json()) as {
457
+ item: { importance: number };
458
+ };
459
+ expect(body.item.importance).toBe(0.5);
460
+ });
461
+
462
+ test("rejects duplicate fingerprint", async () => {
463
+ const payload = {
464
+ kind: "preference",
465
+ subject: "dark mode",
466
+ statement: "User prefers dark mode",
467
+ };
468
+ const ctx1 = makeJsonCtx("memory-items", "POST", payload);
469
+ const res1 = await handler(ctx1);
470
+ expect(res1.status).toBe(201);
471
+
472
+ const ctx2 = makeJsonCtx("memory-items", "POST", payload);
473
+ const res2 = await handler(ctx2);
474
+ expect(res2.status).toBe(409);
475
+ });
476
+
477
+ test("rejects invalid kind", async () => {
478
+ const ctx = makeJsonCtx("memory-items", "POST", {
479
+ kind: "bogus",
480
+ subject: "test",
481
+ statement: "test",
482
+ });
483
+ const res = await handler(ctx);
484
+ expect(res.status).toBe(400);
485
+ });
486
+
487
+ test("rejects missing subject", async () => {
488
+ const ctx = makeJsonCtx("memory-items", "POST", {
489
+ kind: "preference",
490
+ statement: "test",
491
+ });
492
+ const res = await handler(ctx);
493
+ expect(res.status).toBe(400);
494
+ });
495
+
496
+ test("rejects missing statement", async () => {
497
+ const ctx = makeJsonCtx("memory-items", "POST", {
498
+ kind: "preference",
499
+ subject: "test",
500
+ });
501
+ const res = await handler(ctx);
502
+ expect(res.status).toBe(400);
503
+ });
504
+
505
+ test("truncates long subject and statement", async () => {
506
+ const longSubject = "a".repeat(200);
507
+ const longStatement = "b".repeat(1000);
508
+ const ctx = makeJsonCtx("memory-items", "POST", {
509
+ kind: "preference",
510
+ subject: longSubject,
511
+ statement: longStatement,
512
+ });
513
+ const res = await handler(ctx);
514
+ expect(res.status).toBe(201);
515
+ const body = (await res.json()) as {
516
+ item: { subject: string; statement: string };
517
+ };
518
+ expect(body.item.subject.length).toBeLessThanOrEqual(80);
519
+ expect(body.item.statement.length).toBeLessThanOrEqual(500);
520
+ });
521
+
522
+ test("enqueues embed job on create", async () => {
523
+ const ctx = makeJsonCtx("memory-items", "POST", {
524
+ kind: "preference",
525
+ subject: "embed test",
526
+ statement: "Should enqueue embed job",
527
+ });
528
+ await handler(ctx);
529
+
530
+ // Verify a memory job was enqueued
531
+ const db = getDb();
532
+ const jobs = db.select().from(memoryJobs).all();
533
+ const embedJobs = jobs.filter(
534
+ (j) => j.type === "embed_item" && j.status === "pending",
535
+ );
536
+ expect(embedJobs.length).toBeGreaterThanOrEqual(1);
537
+ });
538
+ });
539
+
540
+ // =========================================================================
541
+ // PATCH /v1/memory-items/:id
542
+ // =========================================================================
543
+
544
+ describe("PATCH /v1/memory-items/:id", () => {
545
+ const handler = getHandler("memory-items/:id", "PATCH");
546
+
547
+ test("updates subject and statement", async () => {
548
+ insertItem({
549
+ id: "i1",
550
+ kind: "preference",
551
+ subject: "old subject",
552
+ statement: "old statement",
553
+ });
554
+
555
+ const ctx = makeJsonCtx(
556
+ "memory-items/i1",
557
+ "PATCH",
558
+ { subject: "new subject", statement: "new statement" },
559
+ { id: "i1" },
560
+ );
561
+ const res = await handler(ctx);
562
+ expect(res.status).toBe(200);
563
+ const body = (await res.json()) as {
564
+ item: { subject: string; statement: string };
565
+ };
566
+ expect(body.item.subject).toBe("new subject");
567
+ expect(body.item.statement).toBe("new statement");
568
+ });
569
+
570
+ test("returns 404 for non-existent item", async () => {
571
+ const ctx = makeJsonCtx(
572
+ "memory-items/nonexistent",
573
+ "PATCH",
574
+ { subject: "test" },
575
+ { id: "nonexistent" },
576
+ );
577
+ const res = await handler(ctx);
578
+ expect(res.status).toBe(404);
579
+ });
580
+
581
+ test("detects fingerprint collision on update", async () => {
582
+ insertItem({
583
+ id: "i1",
584
+ kind: "preference",
585
+ subject: "first",
586
+ statement: "first statement",
587
+ });
588
+ // Insert a second item using the create handler to get a real fingerprint
589
+ const createHandler = getHandler("memory-items", "POST");
590
+ const createCtx = makeJsonCtx("memory-items", "POST", {
591
+ kind: "preference",
592
+ subject: "second",
593
+ statement: "second statement",
594
+ });
595
+ await createHandler(createCtx);
596
+
597
+ // Now try to update i1 to match the second item's content
598
+ // This should produce the same fingerprint as the second item
599
+ const ctx = makeJsonCtx(
600
+ "memory-items/i1",
601
+ "PATCH",
602
+ { subject: "second", statement: "second statement" },
603
+ { id: "i1" },
604
+ );
605
+ const res = await handler(ctx);
606
+ expect(res.status).toBe(409);
607
+ });
608
+
609
+ test("allows updating kind", async () => {
610
+ insertItem({
611
+ id: "i1",
612
+ kind: "preference",
613
+ subject: "test",
614
+ statement: "test",
615
+ });
616
+
617
+ const ctx = makeJsonCtx(
618
+ "memory-items/i1",
619
+ "PATCH",
620
+ { kind: "identity" },
621
+ { id: "i1" },
622
+ );
623
+ const res = await handler(ctx);
624
+ expect(res.status).toBe(200);
625
+ const body = (await res.json()) as { item: { kind: string } };
626
+ expect(body.item.kind).toBe("identity");
627
+ });
628
+
629
+ test("rejects invalid kind on update", async () => {
630
+ insertItem({
631
+ id: "i1",
632
+ kind: "preference",
633
+ subject: "test",
634
+ statement: "test",
635
+ });
636
+
637
+ const ctx = makeJsonCtx(
638
+ "memory-items/i1",
639
+ "PATCH",
640
+ { kind: "bogus" },
641
+ { id: "i1" },
642
+ );
643
+ const res = await handler(ctx);
644
+ expect(res.status).toBe(400);
645
+ });
646
+
647
+ test("enqueues embed job when statement changes", async () => {
648
+ insertItem({
649
+ id: "i1",
650
+ kind: "preference",
651
+ subject: "test",
652
+ statement: "old statement",
653
+ });
654
+
655
+ // Clear jobs first
656
+ getDb().run("DELETE FROM memory_jobs");
657
+
658
+ const ctx = makeJsonCtx(
659
+ "memory-items/i1",
660
+ "PATCH",
661
+ { statement: "new statement" },
662
+ { id: "i1" },
663
+ );
664
+ await handler(ctx);
665
+
666
+ const db = getDb();
667
+ const jobs = db.select().from(memoryJobs).all();
668
+ const embedJobs = jobs.filter(
669
+ (j) => j.type === "embed_item" && j.status === "pending",
670
+ );
671
+ expect(embedJobs.length).toBe(1);
672
+ });
673
+ });
674
+
675
+ // =========================================================================
676
+ // DELETE /v1/memory-items/:id
677
+ // =========================================================================
678
+
679
+ describe("DELETE /v1/memory-items/:id", () => {
680
+ const handler = getHandler("memory-items/:id", "DELETE");
681
+
682
+ test("deletes item and returns 204", async () => {
683
+ insertItem({
684
+ id: "i1",
685
+ kind: "preference",
686
+ subject: "test",
687
+ statement: "test",
688
+ });
689
+
690
+ const ctx = makeJsonCtx("memory-items/i1", "DELETE", null, { id: "i1" });
691
+ const res = await handler(ctx);
692
+ expect(res.status).toBe(204);
693
+
694
+ // Verify the item is gone
695
+ const db = getDb();
696
+ const item = db
697
+ .select()
698
+ .from(memoryItems)
699
+ .where(eq(memoryItems.id, "i1"))
700
+ .get();
701
+ expect(item).toBeUndefined();
702
+ });
703
+
704
+ test("returns 404 for non-existent item", async () => {
705
+ const ctx = makeJsonCtx("memory-items/nonexistent", "DELETE", null, {
706
+ id: "nonexistent",
707
+ });
708
+ const res = await handler(ctx);
709
+ expect(res.status).toBe(404);
710
+ });
711
+
712
+ test("also deletes associated embeddings", async () => {
713
+ insertItem({
714
+ id: "i1",
715
+ kind: "preference",
716
+ subject: "test",
717
+ statement: "test",
718
+ });
719
+
720
+ // Insert an embedding for this item
721
+ const db = getDb();
722
+ db.insert(memoryEmbeddings)
723
+ .values({
724
+ id: "emb-1",
725
+ targetType: "item",
726
+ targetId: "i1",
727
+ provider: "test",
728
+ model: "test-model",
729
+ dimensions: 384,
730
+ vectorJson: "[]",
731
+ createdAt: Date.now(),
732
+ updatedAt: Date.now(),
733
+ })
734
+ .run();
735
+
736
+ const ctx = makeJsonCtx("memory-items/i1", "DELETE", null, { id: "i1" });
737
+ const res = await handler(ctx);
738
+ expect(res.status).toBe(204);
739
+
740
+ // Verify embedding is also gone
741
+ const emb = db
742
+ .select()
743
+ .from(memoryEmbeddings)
744
+ .where(
745
+ and(
746
+ eq(memoryEmbeddings.targetType, "item"),
747
+ eq(memoryEmbeddings.targetId, "i1"),
748
+ ),
749
+ )
750
+ .get();
751
+ expect(emb).toBeUndefined();
752
+ });
753
+ });
754
+ });