@vellumai/assistant 0.5.4 → 0.5.6

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 (151) hide show
  1. package/Dockerfile +17 -27
  2. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  3. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/actor-token-service.test.ts +113 -0
  6. package/src/__tests__/config-schema.test.ts +2 -2
  7. package/src/__tests__/context-window-manager.test.ts +78 -0
  8. package/src/__tests__/conversation-title-service.test.ts +30 -1
  9. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  10. package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
  11. package/src/__tests__/memory-regressions.test.ts +8 -30
  12. package/src/__tests__/openai-whisper.test.ts +93 -0
  13. package/src/__tests__/require-fresh-approval.test.ts +4 -0
  14. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  15. package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
  16. package/src/__tests__/tool-executor.test.ts +4 -0
  17. package/src/__tests__/volume-security-guard.test.ts +155 -0
  18. package/src/cli/commands/conversations.ts +0 -18
  19. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  20. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  21. package/src/config/env-registry.ts +9 -0
  22. package/src/config/env.ts +8 -2
  23. package/src/config/feature-flag-registry.json +8 -8
  24. package/src/config/schema.ts +0 -12
  25. package/src/config/schemas/memory.ts +0 -4
  26. package/src/config/schemas/platform.ts +1 -1
  27. package/src/config/schemas/security.ts +4 -0
  28. package/src/context/window-manager.ts +53 -2
  29. package/src/credential-execution/managed-catalog.ts +5 -15
  30. package/src/daemon/conversation-agent-loop.ts +0 -60
  31. package/src/daemon/conversation-memory.ts +0 -117
  32. package/src/daemon/conversation-runtime-assembly.ts +0 -2
  33. package/src/daemon/daemon-control.ts +7 -0
  34. package/src/daemon/handlers/conversations.ts +0 -11
  35. package/src/daemon/lifecycle.ts +10 -47
  36. package/src/daemon/providers-setup.ts +2 -1
  37. package/src/followups/followup-store.ts +5 -2
  38. package/src/hooks/manager.ts +7 -0
  39. package/src/instrument.ts +33 -1
  40. package/src/memory/conversation-crud.ts +0 -236
  41. package/src/memory/conversation-title-service.ts +26 -10
  42. package/src/memory/db-init.ts +5 -13
  43. package/src/memory/embedding-local.ts +11 -5
  44. package/src/memory/indexer.ts +15 -106
  45. package/src/memory/job-handlers/conversation-starters.ts +24 -36
  46. package/src/memory/job-handlers/embedding.ts +0 -79
  47. package/src/memory/job-utils.ts +1 -1
  48. package/src/memory/jobs-store.ts +0 -8
  49. package/src/memory/jobs-worker.ts +0 -20
  50. package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
  51. package/src/memory/migrations/index.ts +1 -3
  52. package/src/memory/qdrant-client.ts +4 -6
  53. package/src/memory/schema/conversations.ts +0 -3
  54. package/src/memory/schema/index.ts +0 -2
  55. package/src/messaging/draft-store.ts +2 -2
  56. package/src/messaging/provider.ts +9 -0
  57. package/src/messaging/providers/slack/adapter.ts +29 -2
  58. package/src/oauth/connection-resolver.test.ts +22 -18
  59. package/src/oauth/connection-resolver.ts +92 -7
  60. package/src/oauth/platform-connection.test.ts +78 -69
  61. package/src/oauth/platform-connection.ts +12 -19
  62. package/src/permissions/defaults.ts +3 -3
  63. package/src/permissions/trust-client.ts +332 -0
  64. package/src/permissions/trust-store-interface.ts +105 -0
  65. package/src/permissions/trust-store.ts +531 -39
  66. package/src/platform/client.test.ts +148 -0
  67. package/src/platform/client.ts +71 -0
  68. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  69. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  70. package/src/providers/speech-to-text/resolve.ts +9 -0
  71. package/src/providers/speech-to-text/types.ts +17 -0
  72. package/src/runtime/auth/route-policy.ts +14 -0
  73. package/src/runtime/auth/token-service.ts +133 -0
  74. package/src/runtime/http-server.ts +4 -2
  75. package/src/runtime/routes/conversation-management-routes.ts +0 -36
  76. package/src/runtime/routes/conversation-query-routes.ts +44 -2
  77. package/src/runtime/routes/conversation-routes.ts +2 -1
  78. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  79. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  80. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  81. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  82. package/src/runtime/routes/log-export-routes.ts +1 -0
  83. package/src/runtime/routes/memory-item-routes.test.ts +221 -3
  84. package/src/runtime/routes/memory-item-routes.ts +124 -2
  85. package/src/runtime/routes/secret-routes.ts +4 -1
  86. package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
  87. package/src/schedule/schedule-store.ts +0 -21
  88. package/src/security/ces-credential-client.ts +173 -0
  89. package/src/security/secure-keys.ts +65 -22
  90. package/src/signals/bash.ts +3 -0
  91. package/src/signals/cancel.ts +3 -0
  92. package/src/signals/confirm.ts +3 -0
  93. package/src/signals/conversation-undo.ts +3 -0
  94. package/src/signals/event-stream.ts +7 -0
  95. package/src/signals/shotgun.ts +3 -0
  96. package/src/signals/trust-rule.ts +3 -0
  97. package/src/skills/inline-command-render.ts +5 -1
  98. package/src/skills/inline-command-runner.ts +30 -2
  99. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  100. package/src/telemetry/usage-telemetry-reporter.ts +21 -19
  101. package/src/tools/memory/handlers.ts +1 -129
  102. package/src/tools/permission-checker.ts +18 -0
  103. package/src/tools/skills/load.ts +9 -2
  104. package/src/util/device-id.ts +70 -7
  105. package/src/util/logger.ts +35 -9
  106. package/src/util/platform.ts +29 -5
  107. package/src/util/xml.ts +8 -0
  108. package/src/workspace/heartbeat-service.ts +5 -24
  109. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  110. package/src/workspace/migrations/registry.ts +2 -0
  111. package/src/__tests__/archive-recall.test.ts +0 -560
  112. package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
  113. package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
  114. package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
  115. package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
  116. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
  117. package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
  118. package/src/__tests__/memory-brief-time.test.ts +0 -285
  119. package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
  120. package/src/__tests__/memory-chunk-archive.test.ts +0 -400
  121. package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
  122. package/src/__tests__/memory-episode-archive.test.ts +0 -370
  123. package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
  124. package/src/__tests__/memory-observation-archive.test.ts +0 -375
  125. package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
  126. package/src/__tests__/memory-reducer-job.test.ts +0 -538
  127. package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
  128. package/src/__tests__/memory-reducer-store.test.ts +0 -728
  129. package/src/__tests__/memory-reducer-types.test.ts +0 -707
  130. package/src/__tests__/memory-reducer.test.ts +0 -704
  131. package/src/__tests__/memory-simplified-config.test.ts +0 -281
  132. package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
  133. package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
  134. package/src/config/schemas/memory-simplified.ts +0 -101
  135. package/src/memory/archive-recall.ts +0 -516
  136. package/src/memory/archive-store.ts +0 -400
  137. package/src/memory/brief-formatting.ts +0 -33
  138. package/src/memory/brief-open-loops.ts +0 -266
  139. package/src/memory/brief-time.ts +0 -162
  140. package/src/memory/brief.ts +0 -75
  141. package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
  142. package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
  143. package/src/memory/migrations/185-memory-brief-state.ts +0 -52
  144. package/src/memory/migrations/186-memory-archive.ts +0 -109
  145. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
  146. package/src/memory/reducer-scheduler.ts +0 -242
  147. package/src/memory/reducer-store.ts +0 -271
  148. package/src/memory/reducer-types.ts +0 -106
  149. package/src/memory/reducer.ts +0 -467
  150. package/src/memory/schema/memory-archive.ts +0 -121
  151. package/src/memory/schema/memory-brief.ts +0 -55
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Workspace migration: Migrate workspace data from /data to /workspace volume.
3
+ *
4
+ * In the old Docker volume layout, workspace data lived at
5
+ * `$BASE_DATA_DIR/.vellum/workspace`. In the new layout, WORKSPACE_DIR points
6
+ * to a dedicated volume (e.g. `/workspace`). On first boot with the new layout,
7
+ * this migration copies existing workspace data from the old location to the
8
+ * new volume so nothing is lost.
9
+ *
10
+ * Idempotent:
11
+ * - Skips if WORKSPACE_DIR is not set (non-Docker or old layout).
12
+ * - Skips if the workspace volume already has data (config.json exists).
13
+ * - Skips if the sentinel file exists (already migrated).
14
+ * - Skips if the old workspace directory doesn't exist or is empty.
15
+ */
16
+
17
+ import { cpSync, existsSync, readdirSync, writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ import {
21
+ getBaseDataDir,
22
+ getWorkspaceDirOverride,
23
+ } from "../../config/env-registry.js";
24
+ import type { WorkspaceMigration } from "./types.js";
25
+
26
+ const SENTINEL_FILENAME = ".workspace-volume-migrated";
27
+
28
+ export const migrateToWorkspaceVolumeMigration: WorkspaceMigration = {
29
+ id: "014-migrate-to-workspace-volume",
30
+ description:
31
+ "Copy workspace data from old /data/.vellum/workspace to new WORKSPACE_DIR volume on first boot",
32
+
33
+ run(workspaceDir: string): void {
34
+ const workspaceDirOverride = getWorkspaceDirOverride();
35
+
36
+ // Only relevant when WORKSPACE_DIR is explicitly set (Docker with separate volume)
37
+ if (!workspaceDirOverride) return;
38
+
39
+ const sentinelPath = join(workspaceDir, SENTINEL_FILENAME);
40
+
41
+ // Already migrated — skip
42
+ if (existsSync(sentinelPath)) return;
43
+
44
+ // If the workspace volume already has data (config.json), assume it's
45
+ // already populated — either by a previous migration or manual setup.
46
+ if (existsSync(join(workspaceDir, "config.json"))) {
47
+ // Write sentinel so we don't re-check on every boot
48
+ writeSentinel(sentinelPath);
49
+ return;
50
+ }
51
+
52
+ // Resolve the old workspace location: $BASE_DATA_DIR/.vellum/workspace
53
+ const baseDataDir = getBaseDataDir();
54
+ if (!baseDataDir) {
55
+ // No BASE_DATA_DIR means there's no old location to migrate from
56
+ writeSentinel(sentinelPath);
57
+ return;
58
+ }
59
+
60
+ const oldWorkspaceDir = join(baseDataDir, ".vellum", "workspace");
61
+
62
+ // If the old workspace doesn't exist or is empty, nothing to migrate
63
+ if (!existsSync(oldWorkspaceDir)) {
64
+ writeSentinel(sentinelPath);
65
+ return;
66
+ }
67
+
68
+ let entries: string[];
69
+ try {
70
+ entries = readdirSync(oldWorkspaceDir);
71
+ } catch {
72
+ // Can't read old workspace — write sentinel and move on
73
+ writeSentinel(sentinelPath);
74
+ return;
75
+ }
76
+
77
+ if (entries.length === 0) {
78
+ writeSentinel(sentinelPath);
79
+ return;
80
+ }
81
+
82
+ // Copy everything from old workspace to new workspace volume.
83
+ // Use cpSync with recursive to handle nested directories.
84
+ // Copy each entry individually rather than the whole directory to avoid
85
+ // overwriting the target directory itself (which may already have
86
+ // sub-directories created by ensureDataDir).
87
+ for (const entry of entries) {
88
+ const src = join(oldWorkspaceDir, entry);
89
+ const dst = join(workspaceDir, entry);
90
+
91
+ // Skip if destination already exists (partial previous run)
92
+ if (existsSync(dst)) continue;
93
+
94
+ try {
95
+ cpSync(src, dst, { recursive: true });
96
+ } catch {
97
+ // Best-effort per entry — continue with remaining items
98
+ }
99
+ }
100
+
101
+ // Mark migration complete
102
+ writeSentinel(sentinelPath);
103
+ },
104
+ };
105
+
106
+ function writeSentinel(sentinelPath: string): void {
107
+ try {
108
+ writeFileSync(sentinelPath, new Date().toISOString() + "\n", "utf-8");
109
+ } catch {
110
+ // Best-effort — if we can't write the sentinel, the migration runner's
111
+ // checkpoint will still prevent re-running the migration function.
112
+ }
113
+ }
@@ -10,6 +10,7 @@ import { appDirRenameMigration } from "./010-app-dir-rename.js";
10
10
  import { backfillInstallationIdMigration } from "./011-backfill-installation-id.js";
11
11
  import { renameConversationDiskViewDirsMigration } from "./012-rename-conversation-disk-view-dirs.js";
12
12
  import { repairConversationDiskViewMigration } from "./013-repair-conversation-disk-view.js";
13
+ import { migrateToWorkspaceVolumeMigration } from "./migrate-to-workspace-volume.js";
13
14
  import type { WorkspaceMigration } from "./types.js";
14
15
 
15
16
  /**
@@ -29,4 +30,5 @@ export const WORKSPACE_MIGRATIONS: WorkspaceMigration[] = [
29
30
  appDirRenameMigration,
30
31
  renameConversationDiskViewDirsMigration,
31
32
  repairConversationDiskViewMigration,
33
+ migrateToWorkspaceVolumeMigration,
32
34
  ];
@@ -1,560 +0,0 @@
1
- /**
2
- * Tests for the archive recall module.
3
- *
4
- * Covers:
5
- * - Explicit artifact recall (past-reference triggers)
6
- * - Analogy/debugging-shaped recall
7
- * - Strong prefetch triggers
8
- * - Empty result omission (no `<supporting_recall>` when nothing found)
9
- * - Keyword extraction
10
- * - Rendering format
11
- */
12
- import { mkdtempSync, rmSync } from "node:fs";
13
- import { tmpdir } from "node:os";
14
- import { join } from "node:path";
15
- import {
16
- afterAll,
17
- beforeAll,
18
- beforeEach,
19
- describe,
20
- expect,
21
- mock,
22
- test,
23
- } from "bun:test";
24
-
25
- const testDir = mkdtempSync(join(tmpdir(), "archive-recall-test-"));
26
- const dbPath = join(testDir, "test.db");
27
-
28
- mock.module("../util/platform.js", () => ({
29
- getDataDir: () => testDir,
30
- isMacOS: () => process.platform === "darwin",
31
- isLinux: () => process.platform === "linux",
32
- isWindows: () => process.platform === "win32",
33
- getPidPath: () => join(testDir, "test.pid"),
34
- getDbPath: () => dbPath,
35
- getLogPath: () => join(testDir, "test.log"),
36
- ensureDataDir: () => {},
37
- }));
38
-
39
- mock.module("../util/logger.js", () => ({
40
- getLogger: () =>
41
- new Proxy({} as Record<string, unknown>, {
42
- get: () => () => {},
43
- }),
44
- }));
45
-
46
- import { v4 as uuid } from "uuid";
47
-
48
- import {
49
- buildArchiveRecall,
50
- classifyRecallTrigger,
51
- extractKeywords,
52
- prefetchArchive,
53
- type RecallBullet,
54
- renderSupportingRecall,
55
- } from "../memory/archive-recall.js";
56
- import {
57
- insertCompactionEpisode,
58
- insertObservation,
59
- } from "../memory/archive-store.js";
60
- import { getDb, initializeDb, resetDb } from "../memory/db.js";
61
- import { conversations, messages } from "../memory/schema.js";
62
-
63
- // ── Helpers ─────────────────────────────────────────────────────────
64
-
65
- function removeTestDbFiles(): void {
66
- rmSync(dbPath, { force: true });
67
- rmSync(`${dbPath}-shm`, { force: true });
68
- rmSync(`${dbPath}-wal`, { force: true });
69
- }
70
-
71
- function createConversation(id: string, title: string | null = null): void {
72
- const db = getDb();
73
- const now = Date.now();
74
- db.insert(conversations)
75
- .values({
76
- id,
77
- title,
78
- createdAt: now,
79
- updatedAt: now,
80
- })
81
- .run();
82
- }
83
-
84
- function createMessage(
85
- id: string,
86
- conversationId: string,
87
- role: string = "user",
88
- content: string = "test message",
89
- ): void {
90
- const db = getDb();
91
- db.insert(messages)
92
- .values({
93
- id,
94
- conversationId,
95
- role,
96
- content,
97
- createdAt: Date.now(),
98
- })
99
- .run();
100
- }
101
-
102
- // ── Test suite ──────────────────────────────────────────────────────
103
-
104
- describe("Archive Recall", () => {
105
- beforeAll(() => {
106
- initializeDb();
107
- });
108
-
109
- beforeEach(() => {
110
- resetDb();
111
- removeTestDbFiles();
112
- initializeDb();
113
- });
114
-
115
- afterAll(() => {
116
- resetDb();
117
- rmSync(testDir, { recursive: true, force: true });
118
- });
119
-
120
- // ─────────────────────────────────────────────────────────────────
121
- // classifyRecallTrigger
122
- // ─────────────────────────────────────────────────────────────────
123
-
124
- describe("classifyRecallTrigger", () => {
125
- test("detects explicit past-reference phrases", () => {
126
- expect(
127
- classifyRecallTrigger("Do you remember the API we discussed?", 0),
128
- ).toBe("explicit_past_reference");
129
- expect(classifyRecallTrigger("We talked about this last time", 0)).toBe(
130
- "explicit_past_reference",
131
- );
132
- expect(
133
- classifyRecallTrigger("As I mentioned earlier, the config is wrong", 0),
134
- ).toBe("explicit_past_reference");
135
- expect(
136
- classifyRecallTrigger("I previously told you about the bug", 0),
137
- ).toBe("explicit_past_reference");
138
- });
139
-
140
- test("detects analogy/debugging-shaped phrases", () => {
141
- expect(
142
- classifyRecallTrigger("This is similar to the issue we had", 0),
143
- ).toBe("analogy_debug");
144
- expect(classifyRecallTrigger("I keep getting this error", 0)).toBe(
145
- "analogy_debug",
146
- );
147
- expect(classifyRecallTrigger("Same problem as yesterday", 0)).toBe(
148
- "analogy_debug",
149
- );
150
- });
151
-
152
- test("detects strong prefetch hits", () => {
153
- expect(
154
- classifyRecallTrigger("How should I configure the database?", 2),
155
- ).toBe("strong_prefetch");
156
- expect(
157
- classifyRecallTrigger("How should I configure the database?", 5),
158
- ).toBe("strong_prefetch");
159
- });
160
-
161
- test("returns none for ordinary turns", () => {
162
- expect(classifyRecallTrigger("What is the capital of France?", 0)).toBe(
163
- "none",
164
- );
165
- expect(
166
- classifyRecallTrigger("Write a function to sort an array", 1),
167
- ).toBe("none");
168
- });
169
-
170
- test("explicit past-reference takes priority over analogy", () => {
171
- // "remember" matches past-reference, "same issue" matches analogy
172
- expect(
173
- classifyRecallTrigger("Do you remember the same issue we had?", 0),
174
- ).toBe("explicit_past_reference");
175
- });
176
- });
177
-
178
- // ─────────────────────────────────────────────────────────────────
179
- // extractKeywords
180
- // ─────────────────────────────────────────────────────────────────
181
-
182
- describe("extractKeywords", () => {
183
- test("extracts meaningful words >= 4 chars", () => {
184
- const kw = extractKeywords("How do I fix the authentication error?");
185
- expect(kw).toContain("authentication");
186
- expect(kw).toContain("error");
187
- // "how", "do", "I", "fix", "the" are too short or stop words
188
- expect(kw).not.toContain("how");
189
- expect(kw).not.toContain("the");
190
- });
191
-
192
- test("removes stop words", () => {
193
- const kw = extractKeywords("I want to make this very much better");
194
- expect(kw).not.toContain("want");
195
- expect(kw).not.toContain("very");
196
- expect(kw).not.toContain("much");
197
- expect(kw).toContain("better");
198
- });
199
-
200
- test("deduplicates keywords", () => {
201
- const kw = extractKeywords("error error error authentication");
202
- expect(kw.filter((w) => w === "error")).toHaveLength(1);
203
- });
204
-
205
- test("returns empty for short/stop-word-only input", () => {
206
- expect(extractKeywords("hi")).toEqual([]);
207
- expect(extractKeywords("the a an")).toEqual([]);
208
- });
209
- });
210
-
211
- // ─────────────────────────────────────────────────────────────────
212
- // renderSupportingRecall
213
- // ─────────────────────────────────────────────────────────────────
214
-
215
- describe("renderSupportingRecall", () => {
216
- test("renders bullets in <supporting_recall> tag", () => {
217
- const bullets: RecallBullet[] = [
218
- {
219
- text: "User prefers REST APIs",
220
- source: "observation",
221
- sourceId: "obs-1",
222
- conversationTitle: "API Discussion",
223
- },
224
- {
225
- text: "Deployed to production last week",
226
- source: "episode",
227
- sourceId: "ep-1",
228
- },
229
- ];
230
-
231
- const result = renderSupportingRecall(bullets);
232
- expect(result).toContain("<supporting_recall>");
233
- expect(result).toContain("</supporting_recall>");
234
- expect(result).toContain(
235
- "- User prefers REST APIs (from: API Discussion)",
236
- );
237
- expect(result).toContain("- Deployed to production last week");
238
- // No provenance for second bullet (no conversationTitle)
239
- expect(result).not.toContain("(from: undefined)");
240
- expect(result).not.toContain("(from: null)");
241
- });
242
-
243
- test("returns empty string for empty bullets", () => {
244
- expect(renderSupportingRecall([])).toBe("");
245
- });
246
- });
247
-
248
- // ─────────────────────────────────────────────────────────────────
249
- // Explicit artifact recall
250
- // ─────────────────────────────────────────────────────────────────
251
-
252
- describe("explicit artifact recall", () => {
253
- test("recalls observations when user references past discussion", () => {
254
- const convId = uuid();
255
- const msgId = uuid();
256
- createConversation(convId, "Authentication Redesign");
257
- createMessage(msgId, convId);
258
-
259
- insertObservation({
260
- conversationId: convId,
261
- messageId: msgId,
262
- role: "user",
263
- content:
264
- "User wants to migrate authentication from JWT to session tokens",
265
- scopeId: "default",
266
- });
267
-
268
- const result = buildArchiveRecall(
269
- "default",
270
- "Do you remember what we discussed about authentication?",
271
- );
272
-
273
- expect(result.trigger).toBe("explicit_past_reference");
274
- expect(result.bullets.length).toBeGreaterThan(0);
275
- expect(result.text).toContain("<supporting_recall>");
276
- expect(result.text).toContain("authentication");
277
- });
278
-
279
- test("recalls episodes when user references past work", () => {
280
- const convId = uuid();
281
- createConversation(convId, "Database Migration Sprint");
282
-
283
- insertCompactionEpisode({
284
- scopeId: "default",
285
- conversationId: convId,
286
- title: "PostgreSQL Migration Planning",
287
- summary:
288
- "Discussed migrating from MySQL to PostgreSQL, decided on a phased approach starting with read replicas",
289
- tokenEstimate: 25,
290
- startAt: Date.now() - 86_400_000,
291
- endAt: Date.now() - 43_200_000,
292
- });
293
-
294
- const result = buildArchiveRecall(
295
- "default",
296
- "What did we talk about regarding the PostgreSQL migration?",
297
- );
298
-
299
- expect(result.trigger).toBe("explicit_past_reference");
300
- expect(result.bullets.length).toBeGreaterThan(0);
301
- expect(result.text).toContain("<supporting_recall>");
302
- expect(result.text).toContain("PostgreSQL");
303
- });
304
- });
305
-
306
- // ─────────────────────────────────────────────────────────────────
307
- // Analogy/debugging-shaped recall
308
- // ─────────────────────────────────────────────────────────────────
309
-
310
- describe("analogy-shaped recall", () => {
311
- test("recalls when user reports a recurring issue", () => {
312
- const convId = uuid();
313
- const msgId = uuid();
314
- createConversation(convId, "Debugging Session");
315
- createMessage(msgId, convId);
316
-
317
- insertObservation({
318
- conversationId: convId,
319
- messageId: msgId,
320
- role: "user",
321
- content:
322
- "Connection timeout error when calling the payment gateway service",
323
- scopeId: "default",
324
- });
325
-
326
- const result = buildArchiveRecall(
327
- "default",
328
- "I keep getting a timeout error with the payment service",
329
- );
330
-
331
- expect(result.trigger).toBe("analogy_debug");
332
- expect(result.bullets.length).toBeGreaterThan(0);
333
- expect(result.text).toContain("<supporting_recall>");
334
- expect(result.text).toContain("timeout");
335
- });
336
-
337
- test("recalls similar past episodes for analogy queries", () => {
338
- const convId = uuid();
339
- createConversation(convId, "Infrastructure Issues");
340
-
341
- insertCompactionEpisode({
342
- scopeId: "default",
343
- conversationId: convId,
344
- title: "Redis Connection Pool Exhaustion",
345
- summary:
346
- "Debugged Redis connection pool exhaustion caused by missing connection.release() calls in the retry handler",
347
- tokenEstimate: 30,
348
- startAt: Date.now() - 172_800_000,
349
- endAt: Date.now() - 86_400_000,
350
- });
351
-
352
- const result = buildArchiveRecall(
353
- "default",
354
- "This is similar to the Redis connection issue we had",
355
- );
356
-
357
- expect(result.trigger).toBe("analogy_debug");
358
- expect(result.bullets.length).toBeGreaterThan(0);
359
- expect(result.text).toContain("Redis");
360
- });
361
- });
362
-
363
- // ─────────────────────────────────────────────────────────────────
364
- // Empty result omission
365
- // ─────────────────────────────────────────────────────────────────
366
-
367
- describe("empty result omission", () => {
368
- test("returns empty text when no archive content exists", () => {
369
- const result = buildArchiveRecall(
370
- "default",
371
- "Do you remember what we discussed about quantum computing?",
372
- );
373
-
374
- expect(result.trigger).toBe("explicit_past_reference");
375
- expect(result.bullets).toHaveLength(0);
376
- expect(result.text).toBe("");
377
- });
378
-
379
- test("returns empty text for ordinary turns with no matches", () => {
380
- const result = buildArchiveRecall(
381
- "default",
382
- "Write a hello world program in Python",
383
- );
384
-
385
- expect(result.trigger).toBe("none");
386
- expect(result.bullets).toHaveLength(0);
387
- expect(result.text).toBe("");
388
- });
389
-
390
- test("does not emit <supporting_recall> when trigger fires but no data matches", () => {
391
- // Seed with unrelated data
392
- const convId = uuid();
393
- const msgId = uuid();
394
- createConversation(convId, "Cooking Tips");
395
- createMessage(msgId, convId);
396
-
397
- insertObservation({
398
- conversationId: convId,
399
- messageId: msgId,
400
- role: "user",
401
- content: "User enjoys Italian cooking with fresh basil",
402
- scopeId: "default",
403
- });
404
-
405
- // Ask about something completely unrelated
406
- const result = buildArchiveRecall(
407
- "default",
408
- "Do you remember what we discussed about Kubernetes deployments?",
409
- );
410
-
411
- expect(result.trigger).toBe("explicit_past_reference");
412
- expect(result.bullets).toHaveLength(0);
413
- expect(result.text).toBe("");
414
- });
415
- });
416
-
417
- // ─────────────────────────────────────────────────────────────────
418
- // Prefetch behavior
419
- // ─────────────────────────────────────────────────────────────────
420
-
421
- describe("prefetch", () => {
422
- test("returns hits from episodes and observations", () => {
423
- const convId = uuid();
424
- const msgId = uuid();
425
- createConversation(convId);
426
- createMessage(msgId, convId);
427
-
428
- insertObservation({
429
- conversationId: convId,
430
- messageId: msgId,
431
- role: "user",
432
- content: "User prefers TypeScript over JavaScript",
433
- scopeId: "default",
434
- });
435
-
436
- insertCompactionEpisode({
437
- scopeId: "default",
438
- conversationId: convId,
439
- title: "TypeScript Configuration",
440
- summary: "Set up strict TypeScript config with path aliases",
441
- tokenEstimate: 15,
442
- startAt: Date.now() - 3600_000,
443
- endAt: Date.now() - 1800_000,
444
- });
445
-
446
- const hits = prefetchArchive("default", "TypeScript configuration setup");
447
- expect(hits.length).toBeGreaterThan(0);
448
- expect(hits.some((h) => h.source === "episode")).toBe(true);
449
- expect(hits.some((h) => h.source === "observation")).toBe(true);
450
- });
451
-
452
- test("returns empty for no matches", () => {
453
- const hits = prefetchArchive("default", "xyzzy nonexistent topic");
454
- expect(hits).toHaveLength(0);
455
- });
456
- });
457
-
458
- // ─────────────────────────────────────────────────────────────────
459
- // Bullet cap and deduplication
460
- // ─────────────────────────────────────────────────────────────────
461
-
462
- describe("bullet cap and dedup", () => {
463
- test("returns at most 3 bullets", () => {
464
- const convId = uuid();
465
- createConversation(convId);
466
-
467
- // Insert 5 distinct observations
468
- for (let i = 0; i < 5; i++) {
469
- const msgId = uuid();
470
- createMessage(msgId, convId);
471
- insertObservation({
472
- conversationId: convId,
473
- messageId: msgId,
474
- role: "user",
475
- content: `Authentication fact number ${i}: uses OAuth2 flow variant ${i}`,
476
- scopeId: "default",
477
- });
478
- }
479
-
480
- const result = buildArchiveRecall(
481
- "default",
482
- "Do you remember what authentication method we use?",
483
- );
484
-
485
- expect(result.trigger).toBe("explicit_past_reference");
486
- expect(result.bullets.length).toBeLessThanOrEqual(3);
487
- });
488
-
489
- test("deduplicates identical content from different sources", () => {
490
- const convId = uuid();
491
- const msgId = uuid();
492
- createConversation(convId);
493
- createMessage(msgId, convId);
494
-
495
- // Insert the same content as both an observation and in an episode
496
- const content = "User prefers dark mode for all development tools";
497
- insertObservation({
498
- conversationId: convId,
499
- messageId: msgId,
500
- role: "user",
501
- content,
502
- scopeId: "default",
503
- });
504
-
505
- insertCompactionEpisode({
506
- scopeId: "default",
507
- conversationId: convId,
508
- title: "Development Preferences",
509
- summary: content,
510
- tokenEstimate: 10,
511
- startAt: Date.now() - 3600_000,
512
- endAt: Date.now() - 1800_000,
513
- });
514
-
515
- const result = buildArchiveRecall(
516
- "default",
517
- "Do you recall my preference for dark mode development tools?",
518
- );
519
-
520
- // Should have bullets but content should not be duplicated
521
- if (result.bullets.length > 1) {
522
- const texts = result.bullets.map((b) => b.text.toLowerCase());
523
- // Each bullet text should be distinct
524
- const uniqueTexts = new Set(texts);
525
- expect(uniqueTexts.size).toBe(texts.length);
526
- }
527
- });
528
- });
529
-
530
- // ─────────────────────────────────────────────────────────────────
531
- // Scope isolation
532
- // ─────────────────────────────────────────────────────────────────
533
-
534
- describe("scope isolation", () => {
535
- test("only returns results from the requested scope", () => {
536
- const convId = uuid();
537
- const msgId = uuid();
538
- createConversation(convId);
539
- createMessage(msgId, convId);
540
-
541
- insertObservation({
542
- conversationId: convId,
543
- messageId: msgId,
544
- role: "user",
545
- content: "Deployment uses Kubernetes with Helm charts",
546
- scopeId: "other-scope",
547
- });
548
-
549
- const result = buildArchiveRecall(
550
- "default",
551
- "Do you remember our Kubernetes deployment setup?",
552
- );
553
-
554
- // Should trigger but find no results in "default" scope
555
- expect(result.trigger).toBe("explicit_past_reference");
556
- expect(result.bullets).toHaveLength(0);
557
- expect(result.text).toBe("");
558
- });
559
- });
560
- });