engrm 0.1.0
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/.mcp.json +9 -0
- package/AUTH-DESIGN.md +436 -0
- package/BRIEF.md +197 -0
- package/CLAUDE.md +44 -0
- package/COMPETITIVE.md +174 -0
- package/CONTEXT-OPTIMIZATION.md +305 -0
- package/INFRASTRUCTURE.md +252 -0
- package/LICENSE +105 -0
- package/MARKET.md +230 -0
- package/PLAN.md +278 -0
- package/README.md +121 -0
- package/SENTINEL.md +293 -0
- package/SERVER-API-PLAN.md +553 -0
- package/SPEC.md +843 -0
- package/SWOT.md +148 -0
- package/SYNC-ARCHITECTURE.md +294 -0
- package/VIBE-CODER-STRATEGY.md +250 -0
- package/bun.lock +375 -0
- package/hooks/post-tool-use.ts +144 -0
- package/hooks/session-start.ts +64 -0
- package/hooks/stop.ts +131 -0
- package/mem-page.html +1305 -0
- package/package.json +30 -0
- package/src/capture/dedup.test.ts +103 -0
- package/src/capture/dedup.ts +76 -0
- package/src/capture/extractor.test.ts +245 -0
- package/src/capture/extractor.ts +330 -0
- package/src/capture/quality.test.ts +168 -0
- package/src/capture/quality.ts +104 -0
- package/src/capture/retrospective.test.ts +115 -0
- package/src/capture/retrospective.ts +121 -0
- package/src/capture/scanner.test.ts +131 -0
- package/src/capture/scanner.ts +100 -0
- package/src/capture/scrubber.test.ts +144 -0
- package/src/capture/scrubber.ts +181 -0
- package/src/cli.ts +517 -0
- package/src/config.ts +238 -0
- package/src/context/inject.test.ts +940 -0
- package/src/context/inject.ts +382 -0
- package/src/embeddings/backfill.ts +50 -0
- package/src/embeddings/embedder.test.ts +76 -0
- package/src/embeddings/embedder.ts +139 -0
- package/src/lifecycle/aging.test.ts +103 -0
- package/src/lifecycle/aging.ts +36 -0
- package/src/lifecycle/compaction.test.ts +264 -0
- package/src/lifecycle/compaction.ts +190 -0
- package/src/lifecycle/purge.test.ts +100 -0
- package/src/lifecycle/purge.ts +37 -0
- package/src/lifecycle/scheduler.test.ts +120 -0
- package/src/lifecycle/scheduler.ts +101 -0
- package/src/provisioning/browser-auth.ts +172 -0
- package/src/provisioning/provision.test.ts +198 -0
- package/src/provisioning/provision.ts +94 -0
- package/src/register.test.ts +167 -0
- package/src/register.ts +178 -0
- package/src/server.ts +436 -0
- package/src/storage/migrations.test.ts +244 -0
- package/src/storage/migrations.ts +261 -0
- package/src/storage/outbox.test.ts +229 -0
- package/src/storage/outbox.ts +131 -0
- package/src/storage/projects.test.ts +137 -0
- package/src/storage/projects.ts +184 -0
- package/src/storage/sqlite.test.ts +798 -0
- package/src/storage/sqlite.ts +934 -0
- package/src/storage/vec.test.ts +198 -0
- package/src/sync/auth.test.ts +76 -0
- package/src/sync/auth.ts +68 -0
- package/src/sync/client.ts +183 -0
- package/src/sync/engine.test.ts +94 -0
- package/src/sync/engine.ts +127 -0
- package/src/sync/pull.test.ts +279 -0
- package/src/sync/pull.ts +170 -0
- package/src/sync/push.test.ts +117 -0
- package/src/sync/push.ts +230 -0
- package/src/tools/get.ts +34 -0
- package/src/tools/pin.ts +47 -0
- package/src/tools/save.test.ts +301 -0
- package/src/tools/save.ts +231 -0
- package/src/tools/search.test.ts +69 -0
- package/src/tools/search.ts +181 -0
- package/src/tools/timeline.ts +64 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { MemDatabase } from "./sqlite.js";
|
|
6
|
+
|
|
7
|
+
let db: MemDatabase;
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = mkdtempSync(join(tmpdir(), "candengo-mem-test-"));
|
|
12
|
+
db = new MemDatabase(join(tmpDir, "test.db"));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
db.close();
|
|
17
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("MemDatabase — projects", () => {
|
|
21
|
+
test("upsertProject creates a new project", () => {
|
|
22
|
+
const project = db.upsertProject({
|
|
23
|
+
canonical_id: "github.com/org/repo",
|
|
24
|
+
name: "repo",
|
|
25
|
+
local_path: "/tmp/repo",
|
|
26
|
+
remote_url: "git@github.com:org/repo.git",
|
|
27
|
+
});
|
|
28
|
+
expect(project.id).toBeGreaterThan(0);
|
|
29
|
+
expect(project.canonical_id).toBe("github.com/org/repo");
|
|
30
|
+
expect(project.name).toBe("repo");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("upsertProject updates existing project", () => {
|
|
34
|
+
const first = db.upsertProject({
|
|
35
|
+
canonical_id: "github.com/org/repo",
|
|
36
|
+
name: "repo",
|
|
37
|
+
local_path: "/old/path",
|
|
38
|
+
});
|
|
39
|
+
const second = db.upsertProject({
|
|
40
|
+
canonical_id: "github.com/org/repo",
|
|
41
|
+
name: "repo",
|
|
42
|
+
local_path: "/new/path",
|
|
43
|
+
});
|
|
44
|
+
expect(second.id).toBe(first.id);
|
|
45
|
+
expect(second.local_path).toBe("/new/path");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("getProjectByCanonicalId returns project", () => {
|
|
49
|
+
db.upsertProject({
|
|
50
|
+
canonical_id: "github.com/org/repo",
|
|
51
|
+
name: "repo",
|
|
52
|
+
});
|
|
53
|
+
const found = db.getProjectByCanonicalId("github.com/org/repo");
|
|
54
|
+
expect(found).not.toBeNull();
|
|
55
|
+
expect(found!.name).toBe("repo");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("getProjectByCanonicalId returns null for missing", () => {
|
|
59
|
+
expect(db.getProjectByCanonicalId("nonexistent")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("getProjectById returns project", () => {
|
|
63
|
+
const project = db.upsertProject({
|
|
64
|
+
canonical_id: "github.com/org/repo",
|
|
65
|
+
name: "repo",
|
|
66
|
+
});
|
|
67
|
+
const found = db.getProjectById(project.id);
|
|
68
|
+
expect(found).not.toBeNull();
|
|
69
|
+
expect(found!.canonical_id).toBe("github.com/org/repo");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("MemDatabase — observations", () => {
|
|
74
|
+
let projectId: number;
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
const project = db.upsertProject({
|
|
78
|
+
canonical_id: "github.com/org/repo",
|
|
79
|
+
name: "repo",
|
|
80
|
+
});
|
|
81
|
+
projectId = project.id;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("insertObservation creates observation", () => {
|
|
85
|
+
const obs = db.insertObservation({
|
|
86
|
+
project_id: projectId,
|
|
87
|
+
type: "bugfix",
|
|
88
|
+
title: "Fix auth bug",
|
|
89
|
+
quality: 0.7,
|
|
90
|
+
user_id: "david",
|
|
91
|
+
device_id: "laptop-abc",
|
|
92
|
+
});
|
|
93
|
+
expect(obs.id).toBeGreaterThan(0);
|
|
94
|
+
expect(obs.type).toBe("bugfix");
|
|
95
|
+
expect(obs.title).toBe("Fix auth bug");
|
|
96
|
+
expect(obs.lifecycle).toBe("active");
|
|
97
|
+
expect(obs.sensitivity).toBe("shared");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("insertObservation with all fields", () => {
|
|
101
|
+
const obs = db.insertObservation({
|
|
102
|
+
session_id: "sess-123",
|
|
103
|
+
project_id: projectId,
|
|
104
|
+
type: "decision",
|
|
105
|
+
title: "Choose PostgreSQL",
|
|
106
|
+
narrative: "We chose PG for its JSON support",
|
|
107
|
+
facts: '["PG supports JSONB", "Better indexing"]',
|
|
108
|
+
concepts: '["database", "postgresql"]',
|
|
109
|
+
files_read: '["docs/db.md"]',
|
|
110
|
+
files_modified: '["src/db.ts"]',
|
|
111
|
+
quality: 0.9,
|
|
112
|
+
lifecycle: "active",
|
|
113
|
+
sensitivity: "personal",
|
|
114
|
+
user_id: "david",
|
|
115
|
+
device_id: "laptop-abc",
|
|
116
|
+
agent: "claude-code",
|
|
117
|
+
});
|
|
118
|
+
expect(obs.narrative).toBe("We chose PG for its JSON support");
|
|
119
|
+
expect(obs.sensitivity).toBe("personal");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("getObservationById returns observation", () => {
|
|
123
|
+
const obs = db.insertObservation({
|
|
124
|
+
project_id: projectId,
|
|
125
|
+
type: "discovery",
|
|
126
|
+
title: "Found memory leak",
|
|
127
|
+
quality: 0.5,
|
|
128
|
+
user_id: "david",
|
|
129
|
+
device_id: "laptop-abc",
|
|
130
|
+
});
|
|
131
|
+
const found = db.getObservationById(obs.id);
|
|
132
|
+
expect(found).not.toBeNull();
|
|
133
|
+
expect(found!.title).toBe("Found memory leak");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("getObservationById returns null for missing", () => {
|
|
137
|
+
expect(db.getObservationById(99999)).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("getObservationsByIds returns multiple", () => {
|
|
141
|
+
const obs1 = db.insertObservation({
|
|
142
|
+
project_id: projectId,
|
|
143
|
+
type: "bugfix",
|
|
144
|
+
title: "Fix 1",
|
|
145
|
+
quality: 0.5,
|
|
146
|
+
user_id: "david",
|
|
147
|
+
device_id: "laptop-abc",
|
|
148
|
+
});
|
|
149
|
+
const obs2 = db.insertObservation({
|
|
150
|
+
project_id: projectId,
|
|
151
|
+
type: "bugfix",
|
|
152
|
+
title: "Fix 2",
|
|
153
|
+
quality: 0.5,
|
|
154
|
+
user_id: "david",
|
|
155
|
+
device_id: "laptop-abc",
|
|
156
|
+
});
|
|
157
|
+
const results = db.getObservationsByIds([obs1.id, obs2.id]);
|
|
158
|
+
expect(results.length).toBe(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("getObservationsByIds returns empty for empty input", () => {
|
|
162
|
+
expect(db.getObservationsByIds([])).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("getRecentObservations returns observations within window", () => {
|
|
166
|
+
db.insertObservation({
|
|
167
|
+
project_id: projectId,
|
|
168
|
+
type: "bugfix",
|
|
169
|
+
title: "Recent fix",
|
|
170
|
+
quality: 0.5,
|
|
171
|
+
user_id: "david",
|
|
172
|
+
device_id: "laptop-abc",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const oneDayAgo = Math.floor(Date.now() / 1000) - 86400;
|
|
176
|
+
const recent = db.getRecentObservations(projectId, oneDayAgo);
|
|
177
|
+
expect(recent.length).toBe(1);
|
|
178
|
+
expect(recent[0]!.title).toBe("Recent fix");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("getActiveObservationCount counts correctly", () => {
|
|
182
|
+
db.insertObservation({
|
|
183
|
+
project_id: projectId,
|
|
184
|
+
type: "bugfix",
|
|
185
|
+
title: "Fix 1",
|
|
186
|
+
quality: 0.5,
|
|
187
|
+
user_id: "david",
|
|
188
|
+
device_id: "laptop-abc",
|
|
189
|
+
});
|
|
190
|
+
db.insertObservation({
|
|
191
|
+
project_id: projectId,
|
|
192
|
+
type: "discovery",
|
|
193
|
+
title: "Find 1",
|
|
194
|
+
quality: 0.5,
|
|
195
|
+
user_id: "david",
|
|
196
|
+
device_id: "laptop-abc",
|
|
197
|
+
});
|
|
198
|
+
expect(db.getActiveObservationCount()).toBe(2);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("getActiveObservationCount filters by user", () => {
|
|
202
|
+
db.insertObservation({
|
|
203
|
+
project_id: projectId,
|
|
204
|
+
type: "bugfix",
|
|
205
|
+
title: "Fix by david",
|
|
206
|
+
quality: 0.5,
|
|
207
|
+
user_id: "david",
|
|
208
|
+
device_id: "laptop-abc",
|
|
209
|
+
});
|
|
210
|
+
db.insertObservation({
|
|
211
|
+
project_id: projectId,
|
|
212
|
+
type: "bugfix",
|
|
213
|
+
title: "Fix by alice",
|
|
214
|
+
quality: 0.5,
|
|
215
|
+
user_id: "alice",
|
|
216
|
+
device_id: "desktop-xyz",
|
|
217
|
+
});
|
|
218
|
+
expect(db.getActiveObservationCount("david")).toBe(1);
|
|
219
|
+
expect(db.getActiveObservationCount("alice")).toBe(1);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("MemDatabase — FTS5 search", () => {
|
|
224
|
+
let projectId: number;
|
|
225
|
+
|
|
226
|
+
beforeEach(() => {
|
|
227
|
+
const project = db.upsertProject({
|
|
228
|
+
canonical_id: "github.com/org/repo",
|
|
229
|
+
name: "repo",
|
|
230
|
+
});
|
|
231
|
+
projectId = project.id;
|
|
232
|
+
|
|
233
|
+
db.insertObservation({
|
|
234
|
+
project_id: projectId,
|
|
235
|
+
type: "bugfix",
|
|
236
|
+
title: "Fix OAuth token refresh",
|
|
237
|
+
narrative: "Token was expiring during long requests",
|
|
238
|
+
quality: 0.8,
|
|
239
|
+
user_id: "david",
|
|
240
|
+
device_id: "laptop-abc",
|
|
241
|
+
});
|
|
242
|
+
db.insertObservation({
|
|
243
|
+
project_id: projectId,
|
|
244
|
+
type: "discovery",
|
|
245
|
+
title: "Database connection pool exhaustion",
|
|
246
|
+
narrative: "Pool was limited to 5 connections, needed 20",
|
|
247
|
+
quality: 0.6,
|
|
248
|
+
user_id: "david",
|
|
249
|
+
device_id: "laptop-abc",
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("searchFts finds by title keyword", () => {
|
|
254
|
+
const results = db.searchFts("OAuth", projectId);
|
|
255
|
+
expect(results.length).toBe(1);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("searchFts finds by narrative keyword", () => {
|
|
259
|
+
const results = db.searchFts("expiring", projectId);
|
|
260
|
+
expect(results.length).toBe(1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("searchFts returns empty for no match", () => {
|
|
264
|
+
const results = db.searchFts("nonexistent", projectId);
|
|
265
|
+
expect(results.length).toBe(0);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("searchFts scoped to project", () => {
|
|
269
|
+
const otherProject = db.upsertProject({
|
|
270
|
+
canonical_id: "github.com/other/repo",
|
|
271
|
+
name: "other",
|
|
272
|
+
});
|
|
273
|
+
db.insertObservation({
|
|
274
|
+
project_id: otherProject.id,
|
|
275
|
+
type: "bugfix",
|
|
276
|
+
title: "Fix OAuth in other project",
|
|
277
|
+
quality: 0.5,
|
|
278
|
+
user_id: "david",
|
|
279
|
+
device_id: "laptop-abc",
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const scoped = db.searchFts("OAuth", projectId);
|
|
283
|
+
expect(scoped.length).toBe(1);
|
|
284
|
+
|
|
285
|
+
const all = db.searchFts("OAuth", null);
|
|
286
|
+
expect(all.length).toBe(2);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("searchFts respects limit", () => {
|
|
290
|
+
const results = db.searchFts("connection OR OAuth", projectId, undefined, 1);
|
|
291
|
+
expect(results.length).toBeLessThanOrEqual(1);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("MemDatabase — pin/unpin", () => {
|
|
296
|
+
let projectId: number;
|
|
297
|
+
|
|
298
|
+
beforeEach(() => {
|
|
299
|
+
const project = db.upsertProject({
|
|
300
|
+
canonical_id: "github.com/org/repo",
|
|
301
|
+
name: "repo",
|
|
302
|
+
});
|
|
303
|
+
projectId = project.id;
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("pin active observation", () => {
|
|
307
|
+
const obs = db.insertObservation({
|
|
308
|
+
project_id: projectId,
|
|
309
|
+
type: "decision",
|
|
310
|
+
title: "Use PostgreSQL",
|
|
311
|
+
quality: 0.8,
|
|
312
|
+
user_id: "david",
|
|
313
|
+
device_id: "laptop-abc",
|
|
314
|
+
});
|
|
315
|
+
expect(db.pinObservation(obs.id, true)).toBe(true);
|
|
316
|
+
const updated = db.getObservationById(obs.id);
|
|
317
|
+
expect(updated!.lifecycle).toBe("pinned");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("unpin pinned observation", () => {
|
|
321
|
+
const obs = db.insertObservation({
|
|
322
|
+
project_id: projectId,
|
|
323
|
+
type: "decision",
|
|
324
|
+
title: "Use PostgreSQL",
|
|
325
|
+
quality: 0.8,
|
|
326
|
+
user_id: "david",
|
|
327
|
+
device_id: "laptop-abc",
|
|
328
|
+
});
|
|
329
|
+
db.pinObservation(obs.id, true);
|
|
330
|
+
expect(db.pinObservation(obs.id, false)).toBe(true);
|
|
331
|
+
const updated = db.getObservationById(obs.id);
|
|
332
|
+
expect(updated!.lifecycle).toBe("active");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("cannot pin archived observation", () => {
|
|
336
|
+
const obs = db.insertObservation({
|
|
337
|
+
project_id: projectId,
|
|
338
|
+
type: "bugfix",
|
|
339
|
+
title: "Old fix",
|
|
340
|
+
quality: 0.5,
|
|
341
|
+
lifecycle: "archived",
|
|
342
|
+
user_id: "david",
|
|
343
|
+
device_id: "laptop-abc",
|
|
344
|
+
});
|
|
345
|
+
expect(db.pinObservation(obs.id, true)).toBe(false);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("cannot unpin non-pinned observation", () => {
|
|
349
|
+
const obs = db.insertObservation({
|
|
350
|
+
project_id: projectId,
|
|
351
|
+
type: "bugfix",
|
|
352
|
+
title: "Fix",
|
|
353
|
+
quality: 0.5,
|
|
354
|
+
user_id: "david",
|
|
355
|
+
device_id: "laptop-abc",
|
|
356
|
+
});
|
|
357
|
+
expect(db.pinObservation(obs.id, false)).toBe(false);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("pin nonexistent observation returns false", () => {
|
|
361
|
+
expect(db.pinObservation(99999, true)).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe("MemDatabase — sessions", () => {
|
|
366
|
+
test("upsertSession creates new session", () => {
|
|
367
|
+
const project = db.upsertProject({
|
|
368
|
+
canonical_id: "github.com/org/repo",
|
|
369
|
+
name: "repo",
|
|
370
|
+
});
|
|
371
|
+
const session = db.upsertSession(
|
|
372
|
+
"sess-001",
|
|
373
|
+
project.id,
|
|
374
|
+
"david",
|
|
375
|
+
"laptop-abc"
|
|
376
|
+
);
|
|
377
|
+
expect(session.session_id).toBe("sess-001");
|
|
378
|
+
expect(session.status).toBe("active");
|
|
379
|
+
expect(session.observation_count).toBe(0);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("upsertSession returns existing session", () => {
|
|
383
|
+
const project = db.upsertProject({
|
|
384
|
+
canonical_id: "github.com/org/repo",
|
|
385
|
+
name: "repo",
|
|
386
|
+
});
|
|
387
|
+
const first = db.upsertSession(
|
|
388
|
+
"sess-001",
|
|
389
|
+
project.id,
|
|
390
|
+
"david",
|
|
391
|
+
"laptop-abc"
|
|
392
|
+
);
|
|
393
|
+
const second = db.upsertSession(
|
|
394
|
+
"sess-001",
|
|
395
|
+
project.id,
|
|
396
|
+
"david",
|
|
397
|
+
"laptop-abc"
|
|
398
|
+
);
|
|
399
|
+
expect(second.id).toBe(first.id);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("observation increments session count", () => {
|
|
403
|
+
const project = db.upsertProject({
|
|
404
|
+
canonical_id: "github.com/org/repo",
|
|
405
|
+
name: "repo",
|
|
406
|
+
});
|
|
407
|
+
db.upsertSession("sess-001", project.id, "david", "laptop-abc");
|
|
408
|
+
|
|
409
|
+
db.insertObservation({
|
|
410
|
+
session_id: "sess-001",
|
|
411
|
+
project_id: project.id,
|
|
412
|
+
type: "bugfix",
|
|
413
|
+
title: "Fix",
|
|
414
|
+
quality: 0.5,
|
|
415
|
+
user_id: "david",
|
|
416
|
+
device_id: "laptop-abc",
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const updated = db.db
|
|
420
|
+
.query<{ observation_count: number }, [string]>(
|
|
421
|
+
"SELECT observation_count FROM sessions WHERE session_id = ?"
|
|
422
|
+
)
|
|
423
|
+
.get("sess-001");
|
|
424
|
+
expect(updated!.observation_count).toBe(1);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("completeSession sets status and timestamp", () => {
|
|
428
|
+
const project = db.upsertProject({
|
|
429
|
+
canonical_id: "github.com/org/repo",
|
|
430
|
+
name: "repo",
|
|
431
|
+
});
|
|
432
|
+
db.upsertSession("sess-001", project.id, "david", "laptop-abc");
|
|
433
|
+
db.completeSession("sess-001");
|
|
434
|
+
|
|
435
|
+
const session = db.db
|
|
436
|
+
.query<{ status: string; completed_at_epoch: number | null }, [string]>(
|
|
437
|
+
"SELECT status, completed_at_epoch FROM sessions WHERE session_id = ?"
|
|
438
|
+
)
|
|
439
|
+
.get("sess-001");
|
|
440
|
+
expect(session!.status).toBe("completed");
|
|
441
|
+
expect(session!.completed_at_epoch).not.toBeNull();
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
describe("MemDatabase — sync outbox", () => {
|
|
446
|
+
test("addToOutbox creates entry", () => {
|
|
447
|
+
const project = db.upsertProject({
|
|
448
|
+
canonical_id: "github.com/org/repo",
|
|
449
|
+
name: "repo",
|
|
450
|
+
});
|
|
451
|
+
const obs = db.insertObservation({
|
|
452
|
+
project_id: project.id,
|
|
453
|
+
type: "bugfix",
|
|
454
|
+
title: "Fix",
|
|
455
|
+
quality: 0.5,
|
|
456
|
+
user_id: "david",
|
|
457
|
+
device_id: "laptop-abc",
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// addToOutbox is called by insertObservation... but let's verify direct call
|
|
461
|
+
db.addToOutbox("observation", obs.id);
|
|
462
|
+
|
|
463
|
+
const entries = db.db
|
|
464
|
+
.query<{ record_type: string; record_id: number; status: string }, []>(
|
|
465
|
+
"SELECT record_type, record_id, status FROM sync_outbox ORDER BY id DESC LIMIT 1"
|
|
466
|
+
)
|
|
467
|
+
.get();
|
|
468
|
+
expect(entries!.record_type).toBe("observation");
|
|
469
|
+
expect(entries!.record_id).toBe(obs.id);
|
|
470
|
+
expect(entries!.status).toBe("pending");
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
describe("MemDatabase — sync state", () => {
|
|
475
|
+
test("getSyncState returns null for missing key", () => {
|
|
476
|
+
expect(db.getSyncState("nonexistent")).toBeNull();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("setSyncState and getSyncState round-trip", () => {
|
|
480
|
+
db.setSyncState("last_sync", "1234567890");
|
|
481
|
+
expect(db.getSyncState("last_sync")).toBe("1234567890");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("setSyncState overwrites existing", () => {
|
|
485
|
+
db.setSyncState("key", "old");
|
|
486
|
+
db.setSyncState("key", "new");
|
|
487
|
+
expect(db.getSyncState("key")).toBe("new");
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe("MemDatabase — timeline", () => {
|
|
492
|
+
let projectId: number;
|
|
493
|
+
|
|
494
|
+
beforeEach(() => {
|
|
495
|
+
const project = db.upsertProject({
|
|
496
|
+
canonical_id: "github.com/org/repo",
|
|
497
|
+
name: "repo",
|
|
498
|
+
});
|
|
499
|
+
projectId = project.id;
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("getTimeline returns context around anchor", () => {
|
|
503
|
+
// Insert 5 observations with distinct epochs
|
|
504
|
+
const ids: number[] = [];
|
|
505
|
+
const baseEpoch = Math.floor(Date.now() / 1000) - 100;
|
|
506
|
+
for (let i = 0; i < 5; i++) {
|
|
507
|
+
const result = db.db
|
|
508
|
+
.query(
|
|
509
|
+
`INSERT INTO observations (project_id, type, title, quality, lifecycle, sensitivity, user_id, device_id, agent, created_at, created_at_epoch)
|
|
510
|
+
VALUES (?, 'bugfix', ?, 0.5, 'active', 'shared', 'david', 'laptop-abc', 'claude-code', ?, ?)`
|
|
511
|
+
)
|
|
512
|
+
.run(projectId, `Fix ${i}`, new Date((baseEpoch + i) * 1000).toISOString(), baseEpoch + i);
|
|
513
|
+
ids.push(Number(result.lastInsertRowid));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const timeline = db.getTimeline(ids[2]!, projectId, 2, 2);
|
|
517
|
+
expect(timeline.length).toBe(5); // 2 before + anchor + 2 after
|
|
518
|
+
const anchorIdx = timeline.findIndex((o) => o.id === ids[2]);
|
|
519
|
+
expect(anchorIdx).toBe(2); // middle position
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("getTimeline returns empty for nonexistent anchor", () => {
|
|
523
|
+
const timeline = db.getTimeline(99999, projectId);
|
|
524
|
+
expect(timeline).toEqual([]);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
describe("MemDatabase — session summaries", () => {
|
|
529
|
+
let projectId: number;
|
|
530
|
+
|
|
531
|
+
beforeEach(() => {
|
|
532
|
+
const project = db.upsertProject({
|
|
533
|
+
canonical_id: "github.com/org/repo",
|
|
534
|
+
name: "repo",
|
|
535
|
+
});
|
|
536
|
+
projectId = project.id;
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("insertSessionSummary creates and returns summary", () => {
|
|
540
|
+
const summary = db.insertSessionSummary({
|
|
541
|
+
session_id: "sess-001",
|
|
542
|
+
project_id: projectId,
|
|
543
|
+
user_id: "david",
|
|
544
|
+
request: "Fix auth bug",
|
|
545
|
+
investigated: "- Checked token flow",
|
|
546
|
+
learned: "- Token refresh was missing",
|
|
547
|
+
completed: "- Added refresh logic",
|
|
548
|
+
next_steps: null,
|
|
549
|
+
});
|
|
550
|
+
expect(summary.id).toBeGreaterThan(0);
|
|
551
|
+
expect(summary.session_id).toBe("sess-001");
|
|
552
|
+
expect(summary.request).toBe("Fix auth bug");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("getSessionSummary retrieves by session_id", () => {
|
|
556
|
+
db.insertSessionSummary({
|
|
557
|
+
session_id: "sess-002",
|
|
558
|
+
project_id: projectId,
|
|
559
|
+
user_id: "david",
|
|
560
|
+
request: "Add feature",
|
|
561
|
+
investigated: null,
|
|
562
|
+
learned: null,
|
|
563
|
+
completed: "- Done",
|
|
564
|
+
next_steps: null,
|
|
565
|
+
});
|
|
566
|
+
const found = db.getSessionSummary("sess-002");
|
|
567
|
+
expect(found).not.toBeNull();
|
|
568
|
+
expect(found!.request).toBe("Add feature");
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("getSessionSummary returns null for missing", () => {
|
|
572
|
+
expect(db.getSessionSummary("nonexistent")).toBeNull();
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("getRecentSummaries returns summaries ordered by most recent", () => {
|
|
576
|
+
db.insertSessionSummary({
|
|
577
|
+
session_id: "sess-a",
|
|
578
|
+
project_id: projectId,
|
|
579
|
+
user_id: "david",
|
|
580
|
+
request: "First",
|
|
581
|
+
investigated: null,
|
|
582
|
+
learned: null,
|
|
583
|
+
completed: null,
|
|
584
|
+
next_steps: null,
|
|
585
|
+
});
|
|
586
|
+
db.insertSessionSummary({
|
|
587
|
+
session_id: "sess-b",
|
|
588
|
+
project_id: projectId,
|
|
589
|
+
user_id: "david",
|
|
590
|
+
request: "Second",
|
|
591
|
+
investigated: null,
|
|
592
|
+
learned: null,
|
|
593
|
+
completed: null,
|
|
594
|
+
next_steps: null,
|
|
595
|
+
});
|
|
596
|
+
const recent = db.getRecentSummaries(projectId, 2);
|
|
597
|
+
expect(recent.length).toBe(2);
|
|
598
|
+
// Both inserted in same epoch; order by id desc (second has higher id)
|
|
599
|
+
expect(recent[0]!.id).toBeGreaterThan(recent[1]!.id);
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe("MemDatabase — session metrics", () => {
|
|
604
|
+
let projectId: number;
|
|
605
|
+
|
|
606
|
+
beforeEach(() => {
|
|
607
|
+
const project = db.upsertProject({
|
|
608
|
+
canonical_id: "github.com/org/repo",
|
|
609
|
+
name: "repo",
|
|
610
|
+
});
|
|
611
|
+
projectId = project.id;
|
|
612
|
+
db.upsertSession("sess-metrics", projectId, "david", "laptop-abc");
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("incrementSessionMetrics increments tool_calls_count", () => {
|
|
616
|
+
db.incrementSessionMetrics("sess-metrics", { toolCalls: 1 });
|
|
617
|
+
db.incrementSessionMetrics("sess-metrics", { toolCalls: 3 });
|
|
618
|
+
const metrics = db.getSessionMetrics("sess-metrics");
|
|
619
|
+
expect(metrics).not.toBeNull();
|
|
620
|
+
expect(metrics!.tool_calls_count).toBe(4);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("incrementSessionMetrics increments files_touched_count", () => {
|
|
624
|
+
db.incrementSessionMetrics("sess-metrics", { files: 2 });
|
|
625
|
+
const metrics = db.getSessionMetrics("sess-metrics");
|
|
626
|
+
expect(metrics!.files_touched_count).toBe(2);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("incrementSessionMetrics increments multiple fields", () => {
|
|
630
|
+
db.incrementSessionMetrics("sess-metrics", { files: 1, toolCalls: 5, searches: 2 });
|
|
631
|
+
const metrics = db.getSessionMetrics("sess-metrics");
|
|
632
|
+
expect(metrics!.files_touched_count).toBe(1);
|
|
633
|
+
expect(metrics!.tool_calls_count).toBe(5);
|
|
634
|
+
expect(metrics!.searches_performed).toBe(2);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("getSessionMetrics returns null for missing session", () => {
|
|
638
|
+
expect(db.getSessionMetrics("nonexistent")).toBeNull();
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
describe("MemDatabase — security findings", () => {
|
|
643
|
+
let projectId: number;
|
|
644
|
+
|
|
645
|
+
beforeEach(() => {
|
|
646
|
+
const project = db.upsertProject({
|
|
647
|
+
canonical_id: "github.com/org/repo",
|
|
648
|
+
name: "repo",
|
|
649
|
+
});
|
|
650
|
+
projectId = project.id;
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test("insertSecurityFinding creates finding", () => {
|
|
654
|
+
const finding = db.insertSecurityFinding({
|
|
655
|
+
session_id: "sess-001",
|
|
656
|
+
project_id: projectId,
|
|
657
|
+
finding_type: "api_key",
|
|
658
|
+
severity: "critical",
|
|
659
|
+
pattern_name: "OpenAI API keys",
|
|
660
|
+
snippet: "...key=[REDACTED_API_KEY]...",
|
|
661
|
+
tool_name: "Edit",
|
|
662
|
+
user_id: "david",
|
|
663
|
+
device_id: "laptop-abc",
|
|
664
|
+
});
|
|
665
|
+
expect(finding.id).toBeGreaterThan(0);
|
|
666
|
+
expect(finding.finding_type).toBe("api_key");
|
|
667
|
+
expect(finding.severity).toBe("critical");
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test("getSecurityFindings returns findings for project", () => {
|
|
671
|
+
db.insertSecurityFinding({
|
|
672
|
+
project_id: projectId,
|
|
673
|
+
finding_type: "api_key",
|
|
674
|
+
severity: "critical",
|
|
675
|
+
pattern_name: "AWS access keys",
|
|
676
|
+
user_id: "david",
|
|
677
|
+
device_id: "laptop-abc",
|
|
678
|
+
});
|
|
679
|
+
db.insertSecurityFinding({
|
|
680
|
+
project_id: projectId,
|
|
681
|
+
finding_type: "password",
|
|
682
|
+
severity: "high",
|
|
683
|
+
pattern_name: "Passwords in config",
|
|
684
|
+
user_id: "david",
|
|
685
|
+
device_id: "laptop-abc",
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
const all = db.getSecurityFindings(projectId);
|
|
689
|
+
expect(all.length).toBe(2);
|
|
690
|
+
|
|
691
|
+
const critical = db.getSecurityFindings(projectId, { severity: "critical" });
|
|
692
|
+
expect(critical.length).toBe(1);
|
|
693
|
+
expect(critical[0]!.finding_type).toBe("api_key");
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("getSecurityFindingsCount returns counts by severity", () => {
|
|
697
|
+
db.insertSecurityFinding({
|
|
698
|
+
project_id: projectId,
|
|
699
|
+
finding_type: "api_key",
|
|
700
|
+
severity: "critical",
|
|
701
|
+
pattern_name: "AWS",
|
|
702
|
+
user_id: "david",
|
|
703
|
+
device_id: "laptop-abc",
|
|
704
|
+
});
|
|
705
|
+
db.insertSecurityFinding({
|
|
706
|
+
project_id: projectId,
|
|
707
|
+
finding_type: "password",
|
|
708
|
+
severity: "high",
|
|
709
|
+
pattern_name: "Passwords",
|
|
710
|
+
user_id: "david",
|
|
711
|
+
device_id: "laptop-abc",
|
|
712
|
+
});
|
|
713
|
+
db.insertSecurityFinding({
|
|
714
|
+
project_id: projectId,
|
|
715
|
+
finding_type: "password",
|
|
716
|
+
severity: "high",
|
|
717
|
+
pattern_name: "Passwords",
|
|
718
|
+
user_id: "david",
|
|
719
|
+
device_id: "laptop-abc",
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
const counts = db.getSecurityFindingsCount(projectId);
|
|
723
|
+
expect(counts["critical"]).toBe(1);
|
|
724
|
+
expect(counts["high"]).toBe(2);
|
|
725
|
+
expect(counts["medium"]).toBe(0);
|
|
726
|
+
expect(counts["low"]).toBe(0);
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
describe("MemDatabase — observations by session", () => {
|
|
731
|
+
test("getObservationsBySession returns session observations", () => {
|
|
732
|
+
const project = db.upsertProject({
|
|
733
|
+
canonical_id: "github.com/org/repo",
|
|
734
|
+
name: "repo",
|
|
735
|
+
});
|
|
736
|
+
db.upsertSession("sess-obs", project.id, "david", "laptop-abc");
|
|
737
|
+
|
|
738
|
+
db.insertObservation({
|
|
739
|
+
session_id: "sess-obs",
|
|
740
|
+
project_id: project.id,
|
|
741
|
+
type: "bugfix",
|
|
742
|
+
title: "Fix 1",
|
|
743
|
+
quality: 0.5,
|
|
744
|
+
user_id: "david",
|
|
745
|
+
device_id: "laptop-abc",
|
|
746
|
+
});
|
|
747
|
+
db.insertObservation({
|
|
748
|
+
session_id: "sess-obs",
|
|
749
|
+
project_id: project.id,
|
|
750
|
+
type: "change",
|
|
751
|
+
title: "Change 1",
|
|
752
|
+
quality: 0.5,
|
|
753
|
+
user_id: "david",
|
|
754
|
+
device_id: "laptop-abc",
|
|
755
|
+
});
|
|
756
|
+
db.insertObservation({
|
|
757
|
+
session_id: "other-sess",
|
|
758
|
+
project_id: project.id,
|
|
759
|
+
type: "discovery",
|
|
760
|
+
title: "Other session",
|
|
761
|
+
quality: 0.5,
|
|
762
|
+
user_id: "david",
|
|
763
|
+
device_id: "laptop-abc",
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const results = db.getObservationsBySession("sess-obs");
|
|
767
|
+
expect(results.length).toBe(2);
|
|
768
|
+
expect(results[0]!.title).toBe("Fix 1");
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
describe("MemDatabase — FTS delete", () => {
|
|
773
|
+
test("ftsDelete removes from search index", () => {
|
|
774
|
+
const project = db.upsertProject({
|
|
775
|
+
canonical_id: "github.com/org/repo",
|
|
776
|
+
name: "repo",
|
|
777
|
+
});
|
|
778
|
+
const obs = db.insertObservation({
|
|
779
|
+
project_id: project.id,
|
|
780
|
+
type: "bugfix",
|
|
781
|
+
title: "Unique searchable term xyzzy",
|
|
782
|
+
quality: 0.5,
|
|
783
|
+
user_id: "david",
|
|
784
|
+
device_id: "laptop-abc",
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// Should find it
|
|
788
|
+
let results = db.searchFts("xyzzy", project.id);
|
|
789
|
+
expect(results.length).toBe(1);
|
|
790
|
+
|
|
791
|
+
// Delete from FTS
|
|
792
|
+
db.ftsDelete(obs);
|
|
793
|
+
|
|
794
|
+
// Should not find it
|
|
795
|
+
results = db.searchFts("xyzzy", project.id);
|
|
796
|
+
expect(results.length).toBe(0);
|
|
797
|
+
});
|
|
798
|
+
});
|