@vellumai/assistant 0.5.3 → 0.5.5
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.
- package/Dockerfile +18 -27
- package/docs/architecture/memory.md +105 -0
- package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
- package/package.json +1 -1
- package/src/__tests__/archive-recall.test.ts +560 -0
- package/src/__tests__/conversation-clear-safety.test.ts +259 -0
- package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
- package/src/__tests__/credential-security-invariants.test.ts +2 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
- package/src/__tests__/memory-reducer-job.test.ts +538 -0
- package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
- package/src/__tests__/memory-reducer-types.test.ts +12 -4
- package/src/__tests__/memory-reducer.test.ts +7 -1
- package/src/__tests__/memory-regressions.test.ts +24 -4
- package/src/__tests__/memory-simplified-config.test.ts +4 -4
- package/src/__tests__/openai-whisper.test.ts +93 -0
- package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
- package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
- package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
- package/src/__tests__/volume-security-guard.test.ts +155 -0
- package/src/cli/commands/conversations.ts +18 -0
- package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
- package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
- package/src/config/env-registry.ts +9 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/loader.ts +0 -1
- package/src/config/schemas/memory-simplified.ts +1 -1
- package/src/credential-execution/managed-catalog.ts +5 -15
- package/src/daemon/config-watcher.ts +4 -1
- package/src/daemon/conversation-memory.ts +117 -0
- package/src/daemon/conversation-runtime-assembly.ts +1 -0
- package/src/daemon/daemon-control.ts +7 -0
- package/src/daemon/handlers/conversations.ts +11 -0
- package/src/daemon/lifecycle.ts +51 -2
- package/src/daemon/providers-setup.ts +2 -1
- package/src/hooks/manager.ts +7 -0
- package/src/instrument.ts +33 -1
- package/src/memory/archive-recall.ts +516 -0
- package/src/memory/brief-time.ts +5 -4
- package/src/memory/conversation-crud.ts +210 -0
- package/src/memory/conversation-key-store.ts +33 -4
- package/src/memory/db-init.ts +4 -0
- package/src/memory/embedding-local.ts +11 -5
- package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
- package/src/memory/job-handlers/conversation-starters.ts +24 -30
- package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
- package/src/memory/jobs-store.ts +2 -0
- package/src/memory/jobs-worker.ts +8 -0
- package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
- package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
- package/src/memory/migrations/141-rename-verification-table.ts +8 -0
- package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
- package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
- package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/reducer-scheduler.ts +242 -0
- package/src/memory/reducer-types.ts +9 -2
- package/src/memory/reducer.ts +25 -11
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/slack/adapter.ts +29 -2
- package/src/oauth/connection-resolver.test.ts +22 -18
- package/src/oauth/connection-resolver.ts +92 -7
- package/src/oauth/platform-connection.test.ts +78 -69
- package/src/oauth/platform-connection.ts +12 -19
- package/src/permissions/trust-client.ts +343 -0
- package/src/permissions/trust-store-interface.ts +105 -0
- package/src/permissions/trust-store.ts +523 -36
- package/src/platform/client.test.ts +148 -0
- package/src/platform/client.ts +71 -0
- package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
- package/src/providers/speech-to-text/openai-whisper.ts +68 -0
- package/src/providers/speech-to-text/resolve.ts +9 -0
- package/src/providers/speech-to-text/types.ts +17 -0
- package/src/runtime/auth/route-policy.ts +10 -1
- package/src/runtime/http-server.ts +2 -2
- package/src/runtime/routes/conversation-management-routes.ts +88 -2
- package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
- package/src/runtime/routes/inbound-message-handler.ts +27 -3
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
- package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
- package/src/runtime/routes/log-export-routes.ts +1 -0
- package/src/runtime/routes/secret-routes.ts +5 -1
- package/src/schedule/schedule-store.ts +7 -0
- package/src/schedule/scheduler.ts +6 -2
- package/src/security/ces-credential-client.ts +173 -0
- package/src/security/secure-keys.ts +65 -22
- package/src/signals/bash.ts +3 -0
- package/src/signals/cancel.ts +3 -0
- package/src/signals/confirm.ts +3 -0
- package/src/signals/conversation-undo.ts +3 -0
- package/src/signals/event-stream.ts +7 -0
- package/src/signals/shotgun.ts +3 -0
- package/src/signals/trust-rule.ts +3 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
- package/src/telemetry/usage-telemetry-reporter.ts +22 -20
- package/src/tools/filesystem/edit.ts +6 -1
- package/src/tools/filesystem/read.ts +6 -1
- package/src/tools/filesystem/write.ts +6 -1
- package/src/tools/memory/handlers.ts +129 -1
- package/src/tools/schedule/create.ts +3 -0
- package/src/tools/schedule/list.ts +5 -1
- package/src/tools/schedule/update.ts +6 -0
- package/src/util/device-id.ts +70 -7
- package/src/util/logger.ts +35 -9
- package/src/util/platform.ts +29 -5
- package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
- package/src/workspace/migrations/registry.ts +2 -0
|
@@ -0,0 +1,473 @@
|
|
|
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
|
+
// ── Test directory & platform mocks ───────────────────────────────
|
|
7
|
+
|
|
8
|
+
const testDir = mkdtempSync(join(tmpdir(), "reducer-scheduling-test-"));
|
|
9
|
+
|
|
10
|
+
mock.module("../util/platform.js", () => ({
|
|
11
|
+
getDataDir: () => testDir,
|
|
12
|
+
getRootDir: () => testDir,
|
|
13
|
+
getWorkspaceDir: () => join(testDir, "workspace"),
|
|
14
|
+
getConversationsDir: () => join(testDir, "workspace", "conversations"),
|
|
15
|
+
isMacOS: () => process.platform === "darwin",
|
|
16
|
+
isLinux: () => process.platform === "linux",
|
|
17
|
+
isWindows: () => process.platform === "win32",
|
|
18
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
19
|
+
getDbPath: () => join(testDir, "test.db"),
|
|
20
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
21
|
+
ensureDataDir: () => {},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
mock.module("../util/logger.js", () => ({
|
|
25
|
+
getLogger: () =>
|
|
26
|
+
new Proxy({} as Record<string, unknown>, {
|
|
27
|
+
get: () => () => {},
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// ── Config mock — controllable idleDelayMs ───────────────────────
|
|
32
|
+
|
|
33
|
+
let mockIdleDelayMs = 30_000;
|
|
34
|
+
|
|
35
|
+
mock.module("../config/loader.js", () => ({
|
|
36
|
+
getConfig: () => ({
|
|
37
|
+
memory: {
|
|
38
|
+
simplified: {
|
|
39
|
+
reducer: {
|
|
40
|
+
idleDelayMs: mockIdleDelayMs,
|
|
41
|
+
switchWaitMs: 5_000,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
loadConfig: () => ({
|
|
47
|
+
memory: {
|
|
48
|
+
simplified: {
|
|
49
|
+
reducer: {
|
|
50
|
+
idleDelayMs: mockIdleDelayMs,
|
|
51
|
+
switchWaitMs: 5_000,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
// ── Suppress disk-view side effects ──────────────────────────────
|
|
59
|
+
|
|
60
|
+
mock.module("../memory/conversation-disk-view.js", () => ({
|
|
61
|
+
initConversationDir: () => {},
|
|
62
|
+
removeConversationDir: () => {},
|
|
63
|
+
syncMessageToDisk: () => {},
|
|
64
|
+
updateMetaFile: () => {},
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
// ── Suppress indexer side effects ────────────────────────────────
|
|
68
|
+
|
|
69
|
+
mock.module("../memory/indexer.js", () => ({
|
|
70
|
+
indexMessageNow: async () => {},
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
// ── Suppress attention side effects ──────────────────────────────
|
|
74
|
+
|
|
75
|
+
mock.module("../memory/conversation-attention-store.js", () => ({
|
|
76
|
+
projectAssistantMessage: () => {},
|
|
77
|
+
seedForkedConversationAttention: () => {},
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
// ── Imports (after mocks) ─────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
import {
|
|
83
|
+
markConversationMemoryDirty,
|
|
84
|
+
scheduleReducerJob,
|
|
85
|
+
sweepStaleReducerJobs,
|
|
86
|
+
} from "../memory/conversation-crud.js";
|
|
87
|
+
import { initializeDb, resetDb } from "../memory/db.js";
|
|
88
|
+
import { getSqlite } from "../memory/db-connection.js";
|
|
89
|
+
import { resetTestTables } from "../memory/raw-query.js";
|
|
90
|
+
|
|
91
|
+
initializeDb();
|
|
92
|
+
|
|
93
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
const NOW = 1_700_000_000_000;
|
|
96
|
+
|
|
97
|
+
function insertConversation(
|
|
98
|
+
id: string,
|
|
99
|
+
opts?: {
|
|
100
|
+
dirtyTailMessageId?: string | null;
|
|
101
|
+
createdAt?: number;
|
|
102
|
+
},
|
|
103
|
+
): void {
|
|
104
|
+
const raw = getSqlite();
|
|
105
|
+
raw.run(
|
|
106
|
+
`INSERT INTO conversations (id, title, created_at, updated_at, conversation_type, source, memory_scope_id, is_auto_title,
|
|
107
|
+
memory_dirty_tail_since_message_id)
|
|
108
|
+
VALUES (?, 'Test', ?, ?, 'standard', 'user', 'default', 1, ?)`,
|
|
109
|
+
[
|
|
110
|
+
id,
|
|
111
|
+
opts?.createdAt ?? NOW,
|
|
112
|
+
opts?.createdAt ?? NOW,
|
|
113
|
+
opts?.dirtyTailMessageId ?? null,
|
|
114
|
+
],
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function insertMessage(opts: {
|
|
119
|
+
id: string;
|
|
120
|
+
conversationId: string;
|
|
121
|
+
role?: string;
|
|
122
|
+
content?: string;
|
|
123
|
+
createdAt?: number;
|
|
124
|
+
}): void {
|
|
125
|
+
const raw = getSqlite();
|
|
126
|
+
raw.run(
|
|
127
|
+
`INSERT INTO messages (id, conversation_id, role, content, created_at)
|
|
128
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
129
|
+
[
|
|
130
|
+
opts.id,
|
|
131
|
+
opts.conversationId,
|
|
132
|
+
opts.role ?? "user",
|
|
133
|
+
opts.content ?? "test message",
|
|
134
|
+
opts.createdAt ?? NOW,
|
|
135
|
+
],
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getReducerJobs(
|
|
140
|
+
conversationId: string,
|
|
141
|
+
): Array<Record<string, unknown>> {
|
|
142
|
+
const raw = getSqlite();
|
|
143
|
+
return raw
|
|
144
|
+
.query(
|
|
145
|
+
`SELECT * FROM memory_jobs
|
|
146
|
+
WHERE type = 'reduce_conversation_memory'
|
|
147
|
+
AND json_extract(payload, '$.conversationId') = ?
|
|
148
|
+
ORDER BY created_at ASC`,
|
|
149
|
+
)
|
|
150
|
+
.all(conversationId) as Array<Record<string, unknown>>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function insertReducerJob(
|
|
154
|
+
conversationId: string,
|
|
155
|
+
opts?: { status?: string; runAfter?: number },
|
|
156
|
+
): void {
|
|
157
|
+
const raw = getSqlite();
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
raw.run(
|
|
160
|
+
`INSERT INTO memory_jobs (id, type, payload, status, attempts, deferrals, run_after, created_at, updated_at)
|
|
161
|
+
VALUES (?, 'reduce_conversation_memory', ?, ?, 0, 0, ?, ?, ?)`,
|
|
162
|
+
[
|
|
163
|
+
`job-${conversationId}-${now}`,
|
|
164
|
+
JSON.stringify({ conversationId }),
|
|
165
|
+
opts?.status ?? "pending",
|
|
166
|
+
opts?.runAfter ?? now + 30_000,
|
|
167
|
+
now,
|
|
168
|
+
now,
|
|
169
|
+
],
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Teardown ──────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
afterAll(() => {
|
|
176
|
+
resetDb();
|
|
177
|
+
try {
|
|
178
|
+
rmSync(testDir, { recursive: true });
|
|
179
|
+
} catch {
|
|
180
|
+
/* best effort */
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
beforeEach(() => {
|
|
185
|
+
resetTestTables("messages", "conversations", "memory_jobs");
|
|
186
|
+
mockIdleDelayMs = 30_000;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── Tests ─────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
describe("markConversationMemoryDirty — reducer job scheduling", () => {
|
|
192
|
+
test("creates a pending reducer job on first dirty mark", () => {
|
|
193
|
+
insertConversation("conv-1");
|
|
194
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1" });
|
|
195
|
+
|
|
196
|
+
markConversationMemoryDirty("conv-1", "msg-1");
|
|
197
|
+
|
|
198
|
+
const jobs = getReducerJobs("conv-1");
|
|
199
|
+
expect(jobs).toHaveLength(1);
|
|
200
|
+
expect(jobs[0].status).toBe("pending");
|
|
201
|
+
expect(jobs[0].type).toBe("reduce_conversation_memory");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("schedules reducer job with idleDelayMs offset from now", () => {
|
|
205
|
+
mockIdleDelayMs = 60_000;
|
|
206
|
+
insertConversation("conv-1");
|
|
207
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1" });
|
|
208
|
+
|
|
209
|
+
const before = Date.now();
|
|
210
|
+
markConversationMemoryDirty("conv-1", "msg-1");
|
|
211
|
+
const after = Date.now();
|
|
212
|
+
|
|
213
|
+
const jobs = getReducerJobs("conv-1");
|
|
214
|
+
expect(jobs).toHaveLength(1);
|
|
215
|
+
const runAfter = jobs[0].run_after as number;
|
|
216
|
+
// runAfter should be approximately now + 60_000
|
|
217
|
+
expect(runAfter).toBeGreaterThanOrEqual(before + 60_000);
|
|
218
|
+
expect(runAfter).toBeLessThanOrEqual(after + 60_000);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("deduplicates: second mark does not create a second job", () => {
|
|
222
|
+
insertConversation("conv-1");
|
|
223
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1" });
|
|
224
|
+
insertMessage({
|
|
225
|
+
id: "msg-2",
|
|
226
|
+
conversationId: "conv-1",
|
|
227
|
+
createdAt: NOW + 1000,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
markConversationMemoryDirty("conv-1", "msg-1");
|
|
231
|
+
markConversationMemoryDirty("conv-1", "msg-2");
|
|
232
|
+
|
|
233
|
+
const jobs = getReducerJobs("conv-1");
|
|
234
|
+
expect(jobs).toHaveLength(1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("reschedules: second mark pushes runAfter forward", () => {
|
|
238
|
+
mockIdleDelayMs = 10_000;
|
|
239
|
+
insertConversation("conv-1");
|
|
240
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1" });
|
|
241
|
+
|
|
242
|
+
markConversationMemoryDirty("conv-1", "msg-1");
|
|
243
|
+
const jobs1 = getReducerJobs("conv-1");
|
|
244
|
+
const firstRunAfter = jobs1[0].run_after as number;
|
|
245
|
+
|
|
246
|
+
// Simulate a short delay before the next message
|
|
247
|
+
const pauseMs = 50;
|
|
248
|
+
Bun.sleepSync(pauseMs);
|
|
249
|
+
|
|
250
|
+
insertMessage({
|
|
251
|
+
id: "msg-2",
|
|
252
|
+
conversationId: "conv-1",
|
|
253
|
+
createdAt: NOW + 5000,
|
|
254
|
+
});
|
|
255
|
+
markConversationMemoryDirty("conv-1", "msg-2");
|
|
256
|
+
|
|
257
|
+
const jobs2 = getReducerJobs("conv-1");
|
|
258
|
+
expect(jobs2).toHaveLength(1);
|
|
259
|
+
const secondRunAfter = jobs2[0].run_after as number;
|
|
260
|
+
// The second runAfter should be later than the first
|
|
261
|
+
expect(secondRunAfter).toBeGreaterThan(firstRunAfter);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("creates separate jobs for different conversations", () => {
|
|
265
|
+
insertConversation("conv-1");
|
|
266
|
+
insertConversation("conv-2");
|
|
267
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1" });
|
|
268
|
+
insertMessage({ id: "msg-2", conversationId: "conv-2" });
|
|
269
|
+
|
|
270
|
+
markConversationMemoryDirty("conv-1", "msg-1");
|
|
271
|
+
markConversationMemoryDirty("conv-2", "msg-2");
|
|
272
|
+
|
|
273
|
+
const jobs1 = getReducerJobs("conv-1");
|
|
274
|
+
const jobs2 = getReducerJobs("conv-2");
|
|
275
|
+
expect(jobs1).toHaveLength(1);
|
|
276
|
+
expect(jobs2).toHaveLength(1);
|
|
277
|
+
// They should be different job rows
|
|
278
|
+
expect(jobs1[0].id).not.toBe(jobs2[0].id);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("does not reschedule completed or failed jobs", () => {
|
|
282
|
+
insertConversation("conv-1");
|
|
283
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1" });
|
|
284
|
+
|
|
285
|
+
// Insert a completed job for this conversation
|
|
286
|
+
insertReducerJob("conv-1", { status: "completed" });
|
|
287
|
+
|
|
288
|
+
markConversationMemoryDirty("conv-1", "msg-1");
|
|
289
|
+
|
|
290
|
+
// Should create a new pending job (not reuse the completed one)
|
|
291
|
+
const jobs = getReducerJobs("conv-1");
|
|
292
|
+
const pendingJobs = jobs.filter((j) => j.status === "pending");
|
|
293
|
+
expect(pendingJobs).toHaveLength(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("does not reschedule running jobs", () => {
|
|
297
|
+
insertConversation("conv-1");
|
|
298
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1" });
|
|
299
|
+
|
|
300
|
+
// Insert a running job for this conversation
|
|
301
|
+
insertReducerJob("conv-1", { status: "running" });
|
|
302
|
+
|
|
303
|
+
markConversationMemoryDirty("conv-1", "msg-1");
|
|
304
|
+
|
|
305
|
+
// Should create a new pending job since we only look at pending for rescheduling
|
|
306
|
+
const jobs = getReducerJobs("conv-1");
|
|
307
|
+
const pendingJobs = jobs.filter((j) => j.status === "pending");
|
|
308
|
+
expect(pendingJobs).toHaveLength(1);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("scheduleReducerJob — explicit runAfter override", () => {
|
|
313
|
+
test("accepts a custom runAfter timestamp", () => {
|
|
314
|
+
insertConversation("conv-1");
|
|
315
|
+
|
|
316
|
+
const customRunAfter = NOW + 999_999;
|
|
317
|
+
scheduleReducerJob("conv-1", customRunAfter);
|
|
318
|
+
|
|
319
|
+
const jobs = getReducerJobs("conv-1");
|
|
320
|
+
expect(jobs).toHaveLength(1);
|
|
321
|
+
expect(jobs[0].run_after).toBe(customRunAfter);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("sweepStaleReducerJobs — startup sweep", () => {
|
|
326
|
+
test("enqueues immediate jobs for stale dirty conversations", () => {
|
|
327
|
+
mockIdleDelayMs = 30_000;
|
|
328
|
+
const oldTime = Date.now() - 60_000; // Well past idle delay
|
|
329
|
+
|
|
330
|
+
insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
|
|
331
|
+
insertMessage({
|
|
332
|
+
id: "msg-1",
|
|
333
|
+
conversationId: "conv-1",
|
|
334
|
+
createdAt: oldTime,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const count = sweepStaleReducerJobs();
|
|
338
|
+
|
|
339
|
+
expect(count).toBe(1);
|
|
340
|
+
const jobs = getReducerJobs("conv-1");
|
|
341
|
+
expect(jobs).toHaveLength(1);
|
|
342
|
+
expect(jobs[0].status).toBe("pending");
|
|
343
|
+
// Should be scheduled for immediate execution (runAfter <= now)
|
|
344
|
+
expect(jobs[0].run_after as number).toBeLessThanOrEqual(Date.now());
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("skips conversations that are not dirty", () => {
|
|
348
|
+
const oldTime = Date.now() - 60_000;
|
|
349
|
+
|
|
350
|
+
// Not dirty — no dirtyTailMessageId
|
|
351
|
+
insertConversation("conv-1");
|
|
352
|
+
insertMessage({
|
|
353
|
+
id: "msg-1",
|
|
354
|
+
conversationId: "conv-1",
|
|
355
|
+
createdAt: oldTime,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const count = sweepStaleReducerJobs();
|
|
359
|
+
expect(count).toBe(0);
|
|
360
|
+
expect(getReducerJobs("conv-1")).toHaveLength(0);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("skips dirty conversations whose tail is within the idle window", () => {
|
|
364
|
+
mockIdleDelayMs = 30_000;
|
|
365
|
+
const recentTime = Date.now() - 5_000; // Only 5s ago, within idle delay
|
|
366
|
+
|
|
367
|
+
insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
|
|
368
|
+
insertMessage({
|
|
369
|
+
id: "msg-1",
|
|
370
|
+
conversationId: "conv-1",
|
|
371
|
+
createdAt: recentTime,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const count = sweepStaleReducerJobs();
|
|
375
|
+
expect(count).toBe(0);
|
|
376
|
+
expect(getReducerJobs("conv-1")).toHaveLength(0);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("skips conversations that already have a pending reducer job", () => {
|
|
380
|
+
mockIdleDelayMs = 30_000;
|
|
381
|
+
const oldTime = Date.now() - 60_000;
|
|
382
|
+
|
|
383
|
+
insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
|
|
384
|
+
insertMessage({
|
|
385
|
+
id: "msg-1",
|
|
386
|
+
conversationId: "conv-1",
|
|
387
|
+
createdAt: oldTime,
|
|
388
|
+
});
|
|
389
|
+
insertReducerJob("conv-1", { status: "pending" });
|
|
390
|
+
|
|
391
|
+
const count = sweepStaleReducerJobs();
|
|
392
|
+
expect(count).toBe(0);
|
|
393
|
+
// Only the pre-existing job should be there
|
|
394
|
+
const jobs = getReducerJobs("conv-1");
|
|
395
|
+
expect(jobs).toHaveLength(1);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("skips conversations that have a running reducer job", () => {
|
|
399
|
+
mockIdleDelayMs = 30_000;
|
|
400
|
+
const oldTime = Date.now() - 60_000;
|
|
401
|
+
|
|
402
|
+
insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
|
|
403
|
+
insertMessage({
|
|
404
|
+
id: "msg-1",
|
|
405
|
+
conversationId: "conv-1",
|
|
406
|
+
createdAt: oldTime,
|
|
407
|
+
});
|
|
408
|
+
insertReducerJob("conv-1", { status: "running" });
|
|
409
|
+
|
|
410
|
+
const count = sweepStaleReducerJobs();
|
|
411
|
+
expect(count).toBe(0);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("sweeps multiple stale conversations", () => {
|
|
415
|
+
mockIdleDelayMs = 30_000;
|
|
416
|
+
const oldTime = Date.now() - 60_000;
|
|
417
|
+
|
|
418
|
+
insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
|
|
419
|
+
insertMessage({
|
|
420
|
+
id: "msg-1",
|
|
421
|
+
conversationId: "conv-1",
|
|
422
|
+
createdAt: oldTime,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
insertConversation("conv-2", { dirtyTailMessageId: "msg-2" });
|
|
426
|
+
insertMessage({
|
|
427
|
+
id: "msg-2",
|
|
428
|
+
conversationId: "conv-2",
|
|
429
|
+
createdAt: oldTime - 1000,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const count = sweepStaleReducerJobs();
|
|
433
|
+
expect(count).toBe(2);
|
|
434
|
+
expect(getReducerJobs("conv-1")).toHaveLength(1);
|
|
435
|
+
expect(getReducerJobs("conv-2")).toHaveLength(1);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("only enqueues for stale conversations in a mixed set", () => {
|
|
439
|
+
mockIdleDelayMs = 30_000;
|
|
440
|
+
const oldTime = Date.now() - 60_000;
|
|
441
|
+
const recentTime = Date.now() - 5_000;
|
|
442
|
+
|
|
443
|
+
// Stale dirty conversation
|
|
444
|
+
insertConversation("conv-stale", { dirtyTailMessageId: "msg-1" });
|
|
445
|
+
insertMessage({
|
|
446
|
+
id: "msg-1",
|
|
447
|
+
conversationId: "conv-stale",
|
|
448
|
+
createdAt: oldTime,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Recent dirty conversation (within idle window)
|
|
452
|
+
insertConversation("conv-recent", { dirtyTailMessageId: "msg-2" });
|
|
453
|
+
insertMessage({
|
|
454
|
+
id: "msg-2",
|
|
455
|
+
conversationId: "conv-recent",
|
|
456
|
+
createdAt: recentTime,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Clean conversation
|
|
460
|
+
insertConversation("conv-clean");
|
|
461
|
+
insertMessage({
|
|
462
|
+
id: "msg-3",
|
|
463
|
+
conversationId: "conv-clean",
|
|
464
|
+
createdAt: oldTime,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const count = sweepStaleReducerJobs();
|
|
468
|
+
expect(count).toBe(1);
|
|
469
|
+
expect(getReducerJobs("conv-stale")).toHaveLength(1);
|
|
470
|
+
expect(getReducerJobs("conv-recent")).toHaveLength(0);
|
|
471
|
+
expect(getReducerJobs("conv-clean")).toHaveLength(0);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
@@ -84,9 +84,12 @@ describe("parseReducerOutput — invalid inputs", () => {
|
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
test("returns empty result for object with no recognized arrays", () => {
|
|
87
|
-
expect(parseReducerOutput(toRaw({ foo: "bar" }))).
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
expect(parseReducerOutput(toRaw({ foo: "bar" }))).toEqual({
|
|
88
|
+
timeContexts: [],
|
|
89
|
+
openLoops: [],
|
|
90
|
+
archiveObservations: [],
|
|
91
|
+
archiveEpisodes: [],
|
|
92
|
+
});
|
|
90
93
|
});
|
|
91
94
|
|
|
92
95
|
test("returns empty result when all recognized keys are not arrays", () => {
|
|
@@ -99,7 +102,12 @@ describe("parseReducerOutput — invalid inputs", () => {
|
|
|
99
102
|
archiveEpisodes: true,
|
|
100
103
|
}),
|
|
101
104
|
),
|
|
102
|
-
).
|
|
105
|
+
).toEqual({
|
|
106
|
+
timeContexts: [],
|
|
107
|
+
openLoops: [],
|
|
108
|
+
archiveObservations: [],
|
|
109
|
+
archiveEpisodes: [],
|
|
110
|
+
});
|
|
103
111
|
});
|
|
104
112
|
});
|
|
105
113
|
|
|
@@ -529,7 +529,13 @@ describe("runReducer — error handling", () => {
|
|
|
529
529
|
|
|
530
530
|
const result = await runReducer(makeInput());
|
|
531
531
|
|
|
532
|
-
expect(result).
|
|
532
|
+
expect(result).toEqual({
|
|
533
|
+
timeContexts: [],
|
|
534
|
+
openLoops: [],
|
|
535
|
+
archiveObservations: [],
|
|
536
|
+
archiveEpisodes: [],
|
|
537
|
+
});
|
|
538
|
+
expect(result).not.toBe(EMPTY_REDUCER_RESULT);
|
|
533
539
|
});
|
|
534
540
|
});
|
|
535
541
|
|
|
@@ -540,13 +540,23 @@ describe("Memory regressions", () => {
|
|
|
540
540
|
|
|
541
541
|
test("memory_save sets verificationState to user_confirmed", async () => {
|
|
542
542
|
const { handleMemorySave } = await import("../tools/memory/handlers.js");
|
|
543
|
+
const legacyConfig = {
|
|
544
|
+
...DEFAULT_CONFIG,
|
|
545
|
+
memory: {
|
|
546
|
+
...DEFAULT_CONFIG.memory,
|
|
547
|
+
simplified: {
|
|
548
|
+
...DEFAULT_CONFIG.memory.simplified,
|
|
549
|
+
enabled: false,
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
};
|
|
543
553
|
|
|
544
554
|
const result = await handleMemorySave(
|
|
545
555
|
{
|
|
546
556
|
statement: "User explicitly saved this preference",
|
|
547
557
|
kind: "preference",
|
|
548
558
|
},
|
|
549
|
-
|
|
559
|
+
legacyConfig,
|
|
550
560
|
"conv-verify-save",
|
|
551
561
|
"msg-verify-save",
|
|
552
562
|
);
|
|
@@ -563,13 +573,23 @@ describe("Memory regressions", () => {
|
|
|
563
573
|
|
|
564
574
|
test("memory_save in different scopes creates separate items", async () => {
|
|
565
575
|
const { handleMemorySave } = await import("../tools/memory/handlers.js");
|
|
576
|
+
const legacyConfig = {
|
|
577
|
+
...DEFAULT_CONFIG,
|
|
578
|
+
memory: {
|
|
579
|
+
...DEFAULT_CONFIG.memory,
|
|
580
|
+
simplified: {
|
|
581
|
+
...DEFAULT_CONFIG.memory.simplified,
|
|
582
|
+
enabled: false,
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
};
|
|
566
586
|
|
|
567
587
|
const sharedArgs = { statement: "I prefer dark mode", kind: "preference" };
|
|
568
588
|
|
|
569
589
|
// Save in the default scope
|
|
570
590
|
const r1 = await handleMemorySave(
|
|
571
591
|
sharedArgs,
|
|
572
|
-
|
|
592
|
+
legacyConfig,
|
|
573
593
|
"conv-scope-1",
|
|
574
594
|
"msg-scope-1",
|
|
575
595
|
"default",
|
|
@@ -580,7 +600,7 @@ describe("Memory regressions", () => {
|
|
|
580
600
|
// Save the identical statement in a private scope
|
|
581
601
|
const r2 = await handleMemorySave(
|
|
582
602
|
sharedArgs,
|
|
583
|
-
|
|
603
|
+
legacyConfig,
|
|
584
604
|
"conv-scope-2",
|
|
585
605
|
"msg-scope-2",
|
|
586
606
|
"private-abc",
|
|
@@ -604,7 +624,7 @@ describe("Memory regressions", () => {
|
|
|
604
624
|
// Saving the same statement again in default scope should dedup (not create a third)
|
|
605
625
|
const r3 = await handleMemorySave(
|
|
606
626
|
sharedArgs,
|
|
607
|
-
|
|
627
|
+
legacyConfig,
|
|
608
628
|
"conv-scope-3",
|
|
609
629
|
"msg-scope-3",
|
|
610
630
|
"default",
|
|
@@ -83,7 +83,7 @@ describe("MemorySimplifiedConfigSchema", () => {
|
|
|
83
83
|
test("parses empty object with all defaults", () => {
|
|
84
84
|
const result = MemorySimplifiedConfigSchema.parse({});
|
|
85
85
|
expect(result).toEqual({
|
|
86
|
-
enabled:
|
|
86
|
+
enabled: true,
|
|
87
87
|
brief: { maxTokens: 4000 },
|
|
88
88
|
reducer: { idleDelayMs: 30_000, switchWaitMs: 5_000 },
|
|
89
89
|
archiveRecall: { maxSnippets: 10 },
|
|
@@ -175,7 +175,7 @@ describe("AssistantConfigSchema memory.simplified", () => {
|
|
|
175
175
|
test("empty config exposes memory.simplified with defaults", () => {
|
|
176
176
|
const result = AssistantConfigSchema.parse({});
|
|
177
177
|
expect(result.memory.simplified).toEqual({
|
|
178
|
-
enabled:
|
|
178
|
+
enabled: true,
|
|
179
179
|
brief: { maxTokens: 4000 },
|
|
180
180
|
reducer: { idleDelayMs: 30_000, switchWaitMs: 5_000 },
|
|
181
181
|
archiveRecall: { maxSnippets: 10 },
|
|
@@ -248,7 +248,7 @@ describe("loadConfig with memory.simplified", () => {
|
|
|
248
248
|
writeConfig({});
|
|
249
249
|
const config = loadConfig();
|
|
250
250
|
expect(config.memory.simplified).toEqual({
|
|
251
|
-
enabled:
|
|
251
|
+
enabled: true,
|
|
252
252
|
brief: { maxTokens: 4000 },
|
|
253
253
|
reducer: { idleDelayMs: 30_000, switchWaitMs: 5_000 },
|
|
254
254
|
archiveRecall: { maxSnippets: 10 },
|
|
@@ -258,7 +258,7 @@ describe("loadConfig with memory.simplified", () => {
|
|
|
258
258
|
test("no config file loads cleanly with simplified defaults", () => {
|
|
259
259
|
const config = loadConfig();
|
|
260
260
|
expect(config.memory.simplified).toEqual({
|
|
261
|
-
enabled:
|
|
261
|
+
enabled: true,
|
|
262
262
|
brief: { maxTokens: 4000 },
|
|
263
263
|
reducer: { idleDelayMs: 30_000, switchWaitMs: 5_000 },
|
|
264
264
|
archiveRecall: { maxSnippets: 10 },
|