@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
@@ -1,530 +0,0 @@
1
- import { mkdtempSync, rmSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
5
-
6
- const testDir = mkdtempSync(join(tmpdir(), "brief-open-loops-test-"));
7
-
8
- mock.module("../util/platform.js", () => ({
9
- getDataDir: () => testDir,
10
- getRootDir: () => testDir,
11
- isMacOS: () => process.platform === "darwin",
12
- isLinux: () => process.platform === "linux",
13
- isWindows: () => process.platform === "win32",
14
- getPidPath: () => join(testDir, "test.pid"),
15
- getDbPath: () => join(testDir, "test.db"),
16
- getLogPath: () => join(testDir, "test.log"),
17
- ensureDataDir: () => {},
18
- }));
19
-
20
- mock.module("../util/logger.js", () => ({
21
- getLogger: () =>
22
- new Proxy({} as Record<string, unknown>, {
23
- get: () => () => {},
24
- }),
25
- }));
26
-
27
- import { createFollowUp } from "../followups/followup-store.js";
28
- import { compileOpenLoopBrief } from "../memory/brief-open-loops.js";
29
- import { initializeDb, resetDb } from "../memory/db.js";
30
- import { getSqlite } from "../memory/db-connection.js";
31
- import { resetTestTables } from "../memory/raw-query.js";
32
- import { createTask } from "../tasks/task-store.js";
33
-
34
- initializeDb();
35
-
36
- // ── Helpers ──────────────────────────────────────────────────────────
37
-
38
- const SCOPE = "test-scope";
39
- const MS_HOUR = 60 * 60 * 1000;
40
- const MS_DAY = 24 * MS_HOUR;
41
-
42
- /** Get the raw bun:sqlite Database for parameterized inserts. */
43
- function getRawDb(): import("bun:sqlite").Database {
44
- return getSqlite();
45
- }
46
-
47
- function insertOpenLoop(opts: {
48
- id: string;
49
- summary: string;
50
- dueAt?: number | null;
51
- surfacedAt?: number | null;
52
- status?: string;
53
- updatedAt?: number;
54
- createdAt?: number;
55
- }): void {
56
- const raw = getRawDb();
57
- const now = Date.now();
58
- raw.run(
59
- `INSERT INTO open_loops (id, scope_id, summary, status, source, due_at, surfaced_at, created_at, updated_at)
60
- VALUES (?, ?, ?, ?, 'conversation', ?, ?, ?, ?)`,
61
- [
62
- opts.id,
63
- SCOPE,
64
- opts.summary,
65
- opts.status ?? "open",
66
- opts.dueAt ?? null,
67
- opts.surfacedAt ?? null,
68
- opts.createdAt ?? now,
69
- opts.updatedAt ?? now,
70
- ],
71
- );
72
- }
73
-
74
- function insertWorkItem(opts: {
75
- id: string;
76
- taskId: string;
77
- title: string;
78
- status?: string;
79
- priorityTier?: number;
80
- updatedAt?: number;
81
- }): void {
82
- const raw = getRawDb();
83
- const now = Date.now();
84
- raw.run(
85
- `INSERT INTO work_items (id, task_id, title, status, priority_tier, sort_index, created_at, updated_at)
86
- VALUES (?, ?, ?, ?, ?, 0, ?, ?)`,
87
- [
88
- opts.id,
89
- opts.taskId,
90
- opts.title,
91
- opts.status ?? "queued",
92
- opts.priorityTier ?? 1,
93
- now,
94
- opts.updatedAt ?? now,
95
- ],
96
- );
97
- }
98
-
99
- // ── Teardown ─────────────────────────────────────────────────────────
100
-
101
- afterAll(() => {
102
- resetDb();
103
- try {
104
- rmSync(testDir, { recursive: true });
105
- } catch {
106
- /* best effort */
107
- }
108
- });
109
-
110
- beforeEach(() => {
111
- resetTestTables(
112
- "open_loops",
113
- "work_items",
114
- "tasks",
115
- "task_runs",
116
- "followups",
117
- );
118
- });
119
-
120
- // ── Tests ────────────────────────────────────────────────────────────
121
-
122
- describe("compileOpenLoopBrief", () => {
123
- test("returns empty when no data exists", () => {
124
- const result = compileOpenLoopBrief(SCOPE, "msg-1");
125
- expect(result.bullets).toEqual([]);
126
- expect(result.resurfacedLoopId).toBeNull();
127
- });
128
-
129
- // ── Tier ranking ──────────────────────────────────────────────────
130
-
131
- describe("tier ranking", () => {
132
- test("overdue loops are tier 1", () => {
133
- const now = Date.now();
134
- insertOpenLoop({
135
- id: "ol-overdue",
136
- summary: "Overdue task",
137
- dueAt: now - MS_HOUR,
138
- updatedAt: now - MS_DAY * 10,
139
- });
140
-
141
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
142
- expect(result.bullets).toHaveLength(1);
143
- expect(result.bullets[0].tier).toBe(1);
144
- expect(result.bullets[0].summary).toBe("Overdue task");
145
- });
146
-
147
- test("loops due within 24h are tier 2", () => {
148
- const now = Date.now();
149
- insertOpenLoop({
150
- id: "ol-24h",
151
- summary: "Due soon",
152
- dueAt: now + 12 * MS_HOUR,
153
- updatedAt: now - MS_DAY * 10,
154
- });
155
-
156
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
157
- expect(result.bullets).toHaveLength(1);
158
- expect(result.bullets[0].tier).toBe(2);
159
- });
160
-
161
- test("loops due within 7d are tier 3", () => {
162
- const now = Date.now();
163
- insertOpenLoop({
164
- id: "ol-7d",
165
- summary: "Due this week",
166
- dueAt: now + 3 * MS_DAY,
167
- updatedAt: now - MS_DAY * 10,
168
- });
169
-
170
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
171
- expect(result.bullets).toHaveLength(1);
172
- expect(result.bullets[0].tier).toBe(3);
173
- });
174
-
175
- test("high-priority work items are tier 4", () => {
176
- const now = Date.now();
177
- const task = createTask({ title: "t", template: "t" });
178
- insertWorkItem({
179
- id: "wi-high",
180
- taskId: task.id,
181
- title: "High priority item",
182
- priorityTier: 0,
183
- updatedAt: now - MS_DAY * 10,
184
- });
185
-
186
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
187
- const wiBullet = result.bullets.find((b) => b.source === "work_item");
188
- expect(wiBullet).toBeDefined();
189
- expect(wiBullet!.tier).toBe(4);
190
- });
191
-
192
- test("recently touched loops are tier 5", () => {
193
- const now = Date.now();
194
- insertOpenLoop({
195
- id: "ol-recent",
196
- summary: "Just updated",
197
- updatedAt: now - MS_HOUR,
198
- });
199
-
200
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
201
- expect(result.bullets).toHaveLength(1);
202
- expect(result.bullets[0].tier).toBe(5);
203
- });
204
-
205
- test("bullets are sorted by tier ascending", () => {
206
- const now = Date.now();
207
- insertOpenLoop({
208
- id: "ol-tier3",
209
- summary: "Due this week",
210
- dueAt: now + 3 * MS_DAY,
211
- updatedAt: now - MS_DAY * 10,
212
- });
213
- insertOpenLoop({
214
- id: "ol-tier1",
215
- summary: "Overdue",
216
- dueAt: now - MS_HOUR,
217
- updatedAt: now - MS_DAY * 10,
218
- });
219
- insertOpenLoop({
220
- id: "ol-tier5",
221
- summary: "Recent",
222
- updatedAt: now - MS_HOUR,
223
- });
224
-
225
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
226
- const tiers = result.bullets.map((b) => b.tier);
227
- expect(tiers).toEqual([...tiers].sort((a, b) => a - b));
228
- expect(tiers[0]).toBe(1);
229
- });
230
- });
231
-
232
- // ── Deduplication ─────────────────────────────────────────────────
233
-
234
- describe("deduplication", () => {
235
- test("work items with the same summary as an open loop are deduplicated", () => {
236
- const now = Date.now();
237
- const task = createTask({ title: "t", template: "t" });
238
-
239
- insertOpenLoop({
240
- id: "ol-dup",
241
- summary: "Review PR",
242
- dueAt: now + MS_HOUR,
243
- updatedAt: now,
244
- });
245
- insertWorkItem({
246
- id: "wi-dup",
247
- taskId: task.id,
248
- title: "Review PR",
249
- updatedAt: now,
250
- });
251
-
252
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
253
- const summaries = result.bullets.map((b) => b.summary);
254
- // Should only appear once (from the loop)
255
- expect(summaries.filter((s) => s === "Review PR")).toHaveLength(1);
256
- expect(
257
- result.bullets.find((b) => b.summary === "Review PR")!.source,
258
- ).toBe("loop");
259
- });
260
-
261
- test("case-insensitive deduplication", () => {
262
- const now = Date.now();
263
- const task = createTask({ title: "t", template: "t" });
264
-
265
- insertOpenLoop({
266
- id: "ol-case",
267
- summary: "deploy release",
268
- dueAt: now + MS_HOUR,
269
- updatedAt: now,
270
- });
271
- insertWorkItem({
272
- id: "wi-case",
273
- taskId: task.id,
274
- title: "Deploy Release",
275
- updatedAt: now,
276
- });
277
-
278
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
279
- const matching = result.bullets.filter(
280
- (b) => b.summary.toLowerCase() === "deploy release",
281
- );
282
- expect(matching).toHaveLength(1);
283
- });
284
-
285
- test("unique keys are not deduplicated", () => {
286
- const now = Date.now();
287
- const task = createTask({ title: "t", template: "t" });
288
-
289
- insertOpenLoop({
290
- id: "ol-a",
291
- summary: "Fix bug",
292
- dueAt: now + MS_HOUR,
293
- updatedAt: now,
294
- });
295
- insertWorkItem({
296
- id: "wi-b",
297
- taskId: task.id,
298
- title: "Write tests",
299
- updatedAt: now,
300
- });
301
-
302
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
303
- expect(result.bullets).toHaveLength(2);
304
- });
305
- });
306
-
307
- // ── Follow-up integration ────────────────────────────────────────
308
-
309
- describe("follow-ups", () => {
310
- test("overdue follow-ups appear as tier 1", () => {
311
- const now = Date.now();
312
- createFollowUp({
313
- channel: "email",
314
- conversationId: "conv-1",
315
- expectedResponseBy: now - MS_HOUR,
316
- });
317
-
318
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
319
- const fuBullet = result.bullets.find((b) => b.source === "followup");
320
- expect(fuBullet).toBeDefined();
321
- expect(fuBullet!.tier).toBe(1);
322
- });
323
-
324
- test("pending follow-ups due within 24h are tier 2", () => {
325
- const now = Date.now();
326
- createFollowUp({
327
- channel: "slack",
328
- conversationId: "conv-2",
329
- expectedResponseBy: now + 12 * MS_HOUR,
330
- });
331
-
332
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
333
- const fuBullet = result.bullets.find((b) => b.source === "followup");
334
- expect(fuBullet).toBeDefined();
335
- expect(fuBullet!.tier).toBe(2);
336
- });
337
- });
338
-
339
- // ── Deterministic resurfacing ────────────────────────────────────
340
-
341
- describe("deterministic resurfacing", () => {
342
- test("resurfaces one low-salience loop from open_loops", () => {
343
- const now = Date.now();
344
- // Create loops that are old and have no due date → tier 6 (low salience)
345
- insertOpenLoop({
346
- id: "ol-old-1",
347
- summary: "Old loop 1",
348
- updatedAt: now - MS_DAY * 30,
349
- createdAt: now - MS_DAY * 30,
350
- });
351
- insertOpenLoop({
352
- id: "ol-old-2",
353
- summary: "Old loop 2",
354
- updatedAt: now - MS_DAY * 30,
355
- createdAt: now - MS_DAY * 30,
356
- });
357
- insertOpenLoop({
358
- id: "ol-old-3",
359
- summary: "Old loop 3",
360
- updatedAt: now - MS_DAY * 30,
361
- createdAt: now - MS_DAY * 30,
362
- });
363
-
364
- const result = compileOpenLoopBrief(SCOPE, "msg-resurface", now);
365
-
366
- // Exactly one should be resurfaced
367
- expect(result.resurfacedLoopId).not.toBeNull();
368
- expect(result.bullets).toHaveLength(1);
369
- expect(result.bullets[0].tier).toBe(5);
370
- });
371
-
372
- test("resurfacing is deterministic for the same seed", () => {
373
- const now = Date.now();
374
- for (let i = 1; i <= 5; i++) {
375
- insertOpenLoop({
376
- id: `ol-det-${i}`,
377
- summary: `Deterministic loop ${i}`,
378
- updatedAt: now - MS_DAY * 30,
379
- createdAt: now - MS_DAY * 30,
380
- });
381
- }
382
-
383
- const r1 = compileOpenLoopBrief(SCOPE, "msg-det", now);
384
-
385
- // Reset surfacedAt and updatedAt so the second call has same candidates
386
- // (updateLastSurfacedAt also writes updatedAt, which would change tier)
387
- const oldUpdatedAt = now - MS_DAY * 30;
388
- getRawDb().run(
389
- `UPDATE open_loops SET surfaced_at = NULL, updated_at = ?`,
390
- [oldUpdatedAt],
391
- );
392
-
393
- const r2 = compileOpenLoopBrief(SCOPE, "msg-det", now);
394
-
395
- expect(r1.resurfacedLoopId).toBe(r2.resurfacedLoopId);
396
- });
397
-
398
- test("different userMessageId produces different selection", () => {
399
- const now = Date.now();
400
- // Need enough loops that different seeds are likely to pick different ones
401
- for (let i = 1; i <= 20; i++) {
402
- insertOpenLoop({
403
- id: `ol-vary-${i}`,
404
- summary: `Varying loop ${i}`,
405
- updatedAt: now - MS_DAY * 30,
406
- createdAt: now - MS_DAY * 30,
407
- });
408
- }
409
-
410
- const selections = new Set<string | null>();
411
- const oldUpdatedAt = now - MS_DAY * 30;
412
- for (let i = 0; i < 10; i++) {
413
- // Reset surfacedAt and updatedAt between calls so all loops stay low-salience
414
- getRawDb().run(
415
- `UPDATE open_loops SET surfaced_at = NULL, updated_at = ?`,
416
- [oldUpdatedAt],
417
- );
418
- const r = compileOpenLoopBrief(SCOPE, `msg-vary-${i}`, now);
419
- selections.add(r.resurfacedLoopId);
420
- }
421
-
422
- // With 20 candidates and 10 different seeds, we should see at least 2
423
- // different selections (overwhelmingly likely)
424
- expect(selections.size).toBeGreaterThanOrEqual(2);
425
- });
426
-
427
- test("updates surfacedAt on the resurfaced loop", () => {
428
- const now = Date.now();
429
- insertOpenLoop({
430
- id: "ol-surf",
431
- summary: "Will be surfaced",
432
- updatedAt: now - MS_DAY * 30,
433
- createdAt: now - MS_DAY * 30,
434
- });
435
-
436
- const result = compileOpenLoopBrief(SCOPE, "msg-surf", now);
437
- expect(result.resurfacedLoopId).toBe("ol-surf");
438
-
439
- // Verify surfacedAt was written
440
- const surfaced = getRawDb()
441
- .query(`SELECT surfaced_at FROM open_loops WHERE id = 'ol-surf'`)
442
- .get() as { surfaced_at: number } | null;
443
- expect(surfaced).not.toBeNull();
444
- expect(surfaced!.surfaced_at).toBe(now);
445
- });
446
-
447
- test("low-salience work items are not resurfaced", () => {
448
- const now = Date.now();
449
- const task = createTask({ title: "t", template: "t" });
450
-
451
- // Only work items, no loops — should not resurface
452
- insertWorkItem({
453
- id: "wi-old",
454
- taskId: task.id,
455
- title: "Old work item",
456
- updatedAt: now - MS_DAY * 30,
457
- });
458
-
459
- const result = compileOpenLoopBrief(SCOPE, "msg-no-resurface", now);
460
- expect(result.resurfacedLoopId).toBeNull();
461
- // Work item is tier 6, so it should not appear in ranked output
462
- expect(result.bullets).toHaveLength(0);
463
- });
464
- });
465
-
466
- // ── Scope isolation ──────────────────────────────────────────────
467
-
468
- describe("scope isolation", () => {
469
- test("only includes loops from the specified scope", () => {
470
- const now = Date.now();
471
- insertOpenLoop({
472
- id: "ol-scope-a",
473
- summary: "In scope",
474
- dueAt: now + MS_HOUR,
475
- updatedAt: now,
476
- });
477
-
478
- // Insert loop for a different scope directly
479
- getRawDb().run(
480
- `INSERT INTO open_loops (id, scope_id, summary, status, source, due_at, created_at, updated_at)
481
- VALUES ('ol-scope-b', 'other-scope', 'Out of scope', 'open', 'conversation', ?, ?, ?)`,
482
- [now + MS_HOUR, now, now],
483
- );
484
-
485
- const result = compileOpenLoopBrief(SCOPE, "msg-scope", now);
486
- expect(result.bullets).toHaveLength(1);
487
- expect(result.bullets[0].summary).toBe("In scope");
488
- });
489
- });
490
-
491
- // ── Mixed sources ────────────────────────────────────────────────
492
-
493
- describe("mixed sources", () => {
494
- test("merges loops, work items, and follow-ups without duplicates", () => {
495
- const now = Date.now();
496
- const task = createTask({ title: "t", template: "t" });
497
-
498
- insertOpenLoop({
499
- id: "ol-mix",
500
- summary: "Loop item",
501
- dueAt: now - MS_HOUR,
502
- updatedAt: now,
503
- });
504
- insertWorkItem({
505
- id: "wi-mix",
506
- taskId: task.id,
507
- title: "Work item",
508
- priorityTier: 0,
509
- updatedAt: now,
510
- });
511
- createFollowUp({
512
- channel: "email",
513
- conversationId: "conv-mix",
514
- expectedResponseBy: now + 6 * MS_HOUR,
515
- });
516
-
517
- const result = compileOpenLoopBrief(SCOPE, "msg-mix", now);
518
- expect(result.bullets).toHaveLength(3);
519
-
520
- const sources = result.bullets.map((b) => b.source);
521
- expect(sources).toContain("loop");
522
- expect(sources).toContain("work_item");
523
- expect(sources).toContain("followup");
524
-
525
- // All keys are unique
526
- const keys = result.bullets.map((b) => b.key);
527
- expect(new Set(keys).size).toBe(keys.length);
528
- });
529
- });
530
- });