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.
Files changed (82) hide show
  1. package/.mcp.json +9 -0
  2. package/AUTH-DESIGN.md +436 -0
  3. package/BRIEF.md +197 -0
  4. package/CLAUDE.md +44 -0
  5. package/COMPETITIVE.md +174 -0
  6. package/CONTEXT-OPTIMIZATION.md +305 -0
  7. package/INFRASTRUCTURE.md +252 -0
  8. package/LICENSE +105 -0
  9. package/MARKET.md +230 -0
  10. package/PLAN.md +278 -0
  11. package/README.md +121 -0
  12. package/SENTINEL.md +293 -0
  13. package/SERVER-API-PLAN.md +553 -0
  14. package/SPEC.md +843 -0
  15. package/SWOT.md +148 -0
  16. package/SYNC-ARCHITECTURE.md +294 -0
  17. package/VIBE-CODER-STRATEGY.md +250 -0
  18. package/bun.lock +375 -0
  19. package/hooks/post-tool-use.ts +144 -0
  20. package/hooks/session-start.ts +64 -0
  21. package/hooks/stop.ts +131 -0
  22. package/mem-page.html +1305 -0
  23. package/package.json +30 -0
  24. package/src/capture/dedup.test.ts +103 -0
  25. package/src/capture/dedup.ts +76 -0
  26. package/src/capture/extractor.test.ts +245 -0
  27. package/src/capture/extractor.ts +330 -0
  28. package/src/capture/quality.test.ts +168 -0
  29. package/src/capture/quality.ts +104 -0
  30. package/src/capture/retrospective.test.ts +115 -0
  31. package/src/capture/retrospective.ts +121 -0
  32. package/src/capture/scanner.test.ts +131 -0
  33. package/src/capture/scanner.ts +100 -0
  34. package/src/capture/scrubber.test.ts +144 -0
  35. package/src/capture/scrubber.ts +181 -0
  36. package/src/cli.ts +517 -0
  37. package/src/config.ts +238 -0
  38. package/src/context/inject.test.ts +940 -0
  39. package/src/context/inject.ts +382 -0
  40. package/src/embeddings/backfill.ts +50 -0
  41. package/src/embeddings/embedder.test.ts +76 -0
  42. package/src/embeddings/embedder.ts +139 -0
  43. package/src/lifecycle/aging.test.ts +103 -0
  44. package/src/lifecycle/aging.ts +36 -0
  45. package/src/lifecycle/compaction.test.ts +264 -0
  46. package/src/lifecycle/compaction.ts +190 -0
  47. package/src/lifecycle/purge.test.ts +100 -0
  48. package/src/lifecycle/purge.ts +37 -0
  49. package/src/lifecycle/scheduler.test.ts +120 -0
  50. package/src/lifecycle/scheduler.ts +101 -0
  51. package/src/provisioning/browser-auth.ts +172 -0
  52. package/src/provisioning/provision.test.ts +198 -0
  53. package/src/provisioning/provision.ts +94 -0
  54. package/src/register.test.ts +167 -0
  55. package/src/register.ts +178 -0
  56. package/src/server.ts +436 -0
  57. package/src/storage/migrations.test.ts +244 -0
  58. package/src/storage/migrations.ts +261 -0
  59. package/src/storage/outbox.test.ts +229 -0
  60. package/src/storage/outbox.ts +131 -0
  61. package/src/storage/projects.test.ts +137 -0
  62. package/src/storage/projects.ts +184 -0
  63. package/src/storage/sqlite.test.ts +798 -0
  64. package/src/storage/sqlite.ts +934 -0
  65. package/src/storage/vec.test.ts +198 -0
  66. package/src/sync/auth.test.ts +76 -0
  67. package/src/sync/auth.ts +68 -0
  68. package/src/sync/client.ts +183 -0
  69. package/src/sync/engine.test.ts +94 -0
  70. package/src/sync/engine.ts +127 -0
  71. package/src/sync/pull.test.ts +279 -0
  72. package/src/sync/pull.ts +170 -0
  73. package/src/sync/push.test.ts +117 -0
  74. package/src/sync/push.ts +230 -0
  75. package/src/tools/get.ts +34 -0
  76. package/src/tools/pin.ts +47 -0
  77. package/src/tools/save.test.ts +301 -0
  78. package/src/tools/save.ts +231 -0
  79. package/src/tools/search.test.ts +69 -0
  80. package/src/tools/search.ts +181 -0
  81. package/src/tools/timeline.ts +64 -0
  82. package/tsconfig.json +22 -0
@@ -0,0 +1,103 @@
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 "../storage/sqlite.js";
6
+ import { runAgingJob } from "./aging.js";
7
+
8
+ let db: MemDatabase;
9
+ let tmpDir: string;
10
+ let projectId: number;
11
+
12
+ const DAY = 86400;
13
+ const NOW = Math.floor(Date.now() / 1000);
14
+
15
+ function insertObs(
16
+ overrides: Partial<{
17
+ lifecycle: string;
18
+ created_at_epoch: number;
19
+ title: string;
20
+ }> = {}
21
+ ) {
22
+ const epoch = overrides.created_at_epoch ?? NOW;
23
+ db.db
24
+ .query(
25
+ `INSERT INTO observations (project_id, type, title, quality, lifecycle, sensitivity, user_id, device_id, agent, created_at, created_at_epoch)
26
+ VALUES (?, 'discovery', ?, 0.5, ?, 'shared', 'user1', 'dev1', 'claude-code', datetime('now'), ?)`
27
+ )
28
+ .run(
29
+ projectId,
30
+ overrides.title ?? "Test observation",
31
+ overrides.lifecycle ?? "active",
32
+ epoch
33
+ );
34
+ }
35
+
36
+ beforeEach(() => {
37
+ tmpDir = mkdtempSync(join(tmpdir(), "candengo-aging-test-"));
38
+ db = new MemDatabase(join(tmpDir, "test.db"));
39
+ const project = db.upsertProject({
40
+ canonical_id: "github.com/test/repo",
41
+ name: "repo",
42
+ });
43
+ projectId = project.id;
44
+ });
45
+
46
+ afterEach(() => {
47
+ db.close();
48
+ rmSync(tmpDir, { recursive: true, force: true });
49
+ });
50
+
51
+ describe("runAgingJob", () => {
52
+ test("transitions active observations older than 30 days to aging", () => {
53
+ insertObs({ created_at_epoch: NOW - 31 * DAY });
54
+ const result = runAgingJob(db, NOW);
55
+ expect(result.transitioned).toBe(1);
56
+
57
+ const obs = db.getObservationById(1);
58
+ expect(obs!.lifecycle).toBe("aging");
59
+ });
60
+
61
+ test("does not transition observations newer than 30 days", () => {
62
+ insertObs({ created_at_epoch: NOW - 29 * DAY });
63
+ const result = runAgingJob(db, NOW);
64
+ expect(result.transitioned).toBe(0);
65
+
66
+ const obs = db.getObservationById(1);
67
+ expect(obs!.lifecycle).toBe("active");
68
+ });
69
+
70
+ test("does not affect pinned observations", () => {
71
+ insertObs({ lifecycle: "pinned", created_at_epoch: NOW - 60 * DAY });
72
+ const result = runAgingJob(db, NOW);
73
+ expect(result.transitioned).toBe(0);
74
+
75
+ const obs = db.getObservationById(1);
76
+ expect(obs!.lifecycle).toBe("pinned");
77
+ });
78
+
79
+ test("does not re-process already aging observations", () => {
80
+ insertObs({ lifecycle: "aging", created_at_epoch: NOW - 60 * DAY });
81
+ const result = runAgingJob(db, NOW);
82
+ expect(result.transitioned).toBe(0);
83
+ });
84
+
85
+ test("does not affect archived observations", () => {
86
+ insertObs({ lifecycle: "archived", created_at_epoch: NOW - 60 * DAY });
87
+ const result = runAgingJob(db, NOW);
88
+ expect(result.transitioned).toBe(0);
89
+ });
90
+
91
+ test("returns accurate count for multiple observations", () => {
92
+ insertObs({ created_at_epoch: NOW - 31 * DAY, title: "Old 1" });
93
+ insertObs({ created_at_epoch: NOW - 45 * DAY, title: "Old 2" });
94
+ insertObs({ created_at_epoch: NOW - 10 * DAY, title: "Recent" });
95
+ const result = runAgingJob(db, NOW);
96
+ expect(result.transitioned).toBe(2);
97
+ });
98
+
99
+ test("handles empty database", () => {
100
+ const result = runAgingJob(db, NOW);
101
+ expect(result.transitioned).toBe(0);
102
+ });
103
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Aging job: transition active observations older than 30 days to 'aging'.
3
+ *
4
+ * Runs daily (checked on MCP server startup via scheduler).
5
+ * Aging observations remain in FTS5 and are searchable at 0.7x weight.
6
+ */
7
+
8
+ import type { MemDatabase } from "../storage/sqlite.js";
9
+
10
+ const AGING_THRESHOLD_SECONDS = 30 * 86400; // 30 days
11
+
12
+ export interface AgingResult {
13
+ transitioned: number;
14
+ }
15
+
16
+ /**
17
+ * Move active observations older than 30 days to aging lifecycle.
18
+ * Pinned observations are never aged.
19
+ */
20
+ export function runAgingJob(
21
+ db: MemDatabase,
22
+ nowEpoch?: number
23
+ ): AgingResult {
24
+ const now = nowEpoch ?? Math.floor(Date.now() / 1000);
25
+ const cutoff = now - AGING_THRESHOLD_SECONDS;
26
+
27
+ const result = db.db
28
+ .query(
29
+ `UPDATE observations SET lifecycle = 'aging'
30
+ WHERE lifecycle = 'active'
31
+ AND created_at_epoch < ?`
32
+ )
33
+ .run(cutoff);
34
+
35
+ return { transitioned: result.changes };
36
+ }
@@ -0,0 +1,264 @@
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 "../storage/sqlite.js";
6
+ import { runCompactionJob, generateDigest } from "./compaction.js";
7
+ import type { ObservationRow } from "../storage/sqlite.js";
8
+
9
+ let db: MemDatabase;
10
+ let tmpDir: string;
11
+ let projectId: number;
12
+
13
+ const DAY = 86400;
14
+ const NOW = Math.floor(Date.now() / 1000);
15
+
16
+ function insertObs(
17
+ overrides: Partial<{
18
+ lifecycle: string;
19
+ created_at_epoch: number;
20
+ title: string;
21
+ session_id: string | null;
22
+ type: string;
23
+ facts: string | null;
24
+ concepts: string | null;
25
+ narrative: string | null;
26
+ quality: number;
27
+ }> = {}
28
+ ): number {
29
+ const epoch = overrides.created_at_epoch ?? NOW - 100 * DAY;
30
+ // Use db.insertObservation so FTS5 index is properly maintained
31
+ const obs = db.insertObservation({
32
+ project_id: projectId,
33
+ session_id: overrides.session_id ?? "session-1",
34
+ type: overrides.type ?? "discovery",
35
+ title: overrides.title ?? "Test observation",
36
+ narrative: overrides.narrative ?? null,
37
+ facts: overrides.facts ?? null,
38
+ concepts: overrides.concepts ?? null,
39
+ quality: overrides.quality ?? 0.5,
40
+ lifecycle: overrides.lifecycle ?? "aging",
41
+ sensitivity: "shared",
42
+ user_id: "user1",
43
+ device_id: "dev1",
44
+ agent: "claude-code",
45
+ });
46
+ // Override created_at_epoch to test time-based logic
47
+ db.db
48
+ .query("UPDATE observations SET created_at_epoch = ? WHERE id = ?")
49
+ .run(epoch, obs.id);
50
+ return obs.id;
51
+ }
52
+
53
+ beforeEach(() => {
54
+ tmpDir = mkdtempSync(join(tmpdir(), "candengo-compaction-test-"));
55
+ db = new MemDatabase(join(tmpDir, "test.db"));
56
+ const project = db.upsertProject({
57
+ canonical_id: "github.com/test/repo",
58
+ name: "repo",
59
+ });
60
+ projectId = project.id;
61
+ });
62
+
63
+ afterEach(() => {
64
+ db.close();
65
+ rmSync(tmpDir, { recursive: true, force: true });
66
+ });
67
+
68
+ describe("runCompactionJob", () => {
69
+ test("compacts aging observations older than 90 days", () => {
70
+ insertObs({ title: "Fix auth bug", created_at_epoch: NOW - 100 * DAY });
71
+ insertObs({ title: "Add logging", created_at_epoch: NOW - 95 * DAY });
72
+
73
+ const result = runCompactionJob(db, NOW);
74
+ expect(result.sessionsCompacted).toBe(1);
75
+ expect(result.observationsArchived).toBe(2);
76
+ expect(result.digestsCreated).toBe(1);
77
+ });
78
+
79
+ test("creates digest with correct type and pinned lifecycle", () => {
80
+ insertObs({ title: "Observation 1" });
81
+ insertObs({ title: "Observation 2" });
82
+
83
+ runCompactionJob(db, NOW);
84
+
85
+ // Find the digest
86
+ const digest = db.db
87
+ .query<ObservationRow, [string]>(
88
+ "SELECT * FROM observations WHERE type = ?"
89
+ )
90
+ .get("digest");
91
+
92
+ expect(digest).not.toBeNull();
93
+ expect(digest!.lifecycle).toBe("pinned");
94
+ expect(digest!.type).toBe("digest");
95
+ });
96
+
97
+ test("digest gets max quality from source observations", () => {
98
+ insertObs({ title: "Low quality", quality: 0.3 });
99
+ insertObs({ title: "High quality", quality: 0.9 });
100
+
101
+ runCompactionJob(db, NOW);
102
+
103
+ const digest = db.db
104
+ .query<ObservationRow, [string]>(
105
+ "SELECT * FROM observations WHERE type = ?"
106
+ )
107
+ .get("digest");
108
+
109
+ expect(digest!.quality).toBe(0.9);
110
+ });
111
+
112
+ test("archives source observations and sets compacted_into", () => {
113
+ const id1 = insertObs({ title: "Obs 1" });
114
+ const id2 = insertObs({ title: "Obs 2" });
115
+
116
+ runCompactionJob(db, NOW);
117
+
118
+ const obs1 = db.getObservationById(id1);
119
+ const obs2 = db.getObservationById(id2);
120
+ expect(obs1!.lifecycle).toBe("archived");
121
+ expect(obs2!.lifecycle).toBe("archived");
122
+ expect(obs1!.compacted_into).toBeGreaterThan(0);
123
+ expect(obs1!.compacted_into).toBe(obs2!.compacted_into);
124
+ expect(obs1!.archived_at_epoch).toBe(NOW);
125
+ });
126
+
127
+ test("does not compact pinned observations", () => {
128
+ insertObs({ lifecycle: "pinned", title: "Pinned obs" });
129
+ const result = runCompactionJob(db, NOW);
130
+ expect(result.observationsArchived).toBe(0);
131
+ });
132
+
133
+ test("does not compact observations newer than 90 days", () => {
134
+ insertObs({ created_at_epoch: NOW - 60 * DAY });
135
+ const result = runCompactionJob(db, NOW);
136
+ expect(result.observationsArchived).toBe(0);
137
+ });
138
+
139
+ test("groups by session_id", () => {
140
+ insertObs({ session_id: "session-A", title: "A1" });
141
+ insertObs({ session_id: "session-A", title: "A2" });
142
+ insertObs({ session_id: "session-B", title: "B1" });
143
+
144
+ const result = runCompactionJob(db, NOW);
145
+ expect(result.sessionsCompacted).toBe(2);
146
+ expect(result.digestsCreated).toBe(2);
147
+ expect(result.observationsArchived).toBe(3);
148
+ });
149
+
150
+ test("handles observations with null session_id", () => {
151
+ insertObs({ session_id: null, title: "No session 1" });
152
+ insertObs({ session_id: null, title: "No session 2" });
153
+
154
+ const result = runCompactionJob(db, NOW);
155
+ expect(result.sessionsCompacted).toBe(1);
156
+ expect(result.digestsCreated).toBe(1);
157
+ expect(result.observationsArchived).toBe(2);
158
+ });
159
+
160
+ test("adds digest to sync outbox", () => {
161
+ insertObs({ title: "Test obs" });
162
+ runCompactionJob(db, NOW);
163
+
164
+ const outbox = db.db
165
+ .query<{ record_type: string; record_id: number }, []>(
166
+ "SELECT record_type, record_id FROM sync_outbox WHERE record_type = 'observation'"
167
+ )
168
+ .all();
169
+
170
+ expect(outbox.length).toBeGreaterThan(0);
171
+ });
172
+
173
+ test("handles empty database", () => {
174
+ const result = runCompactionJob(db, NOW);
175
+ expect(result.sessionsCompacted).toBe(0);
176
+ expect(result.observationsArchived).toBe(0);
177
+ expect(result.digestsCreated).toBe(0);
178
+ });
179
+ });
180
+
181
+ describe("generateDigest", () => {
182
+ function makeObs(overrides: Partial<ObservationRow> = {}): ObservationRow {
183
+ return {
184
+ id: 1,
185
+ session_id: "s1",
186
+ project_id: 1,
187
+ type: overrides.type ?? "discovery",
188
+ title: overrides.title ?? "Test",
189
+ narrative: overrides.narrative ?? null,
190
+ facts: overrides.facts ?? null,
191
+ concepts: overrides.concepts ?? null,
192
+ files_read: null,
193
+ files_modified: null,
194
+ quality: overrides.quality ?? 0.5,
195
+ lifecycle: "aging",
196
+ sensitivity: "shared",
197
+ user_id: "user1",
198
+ device_id: "dev1",
199
+ agent: "claude-code",
200
+ created_at: "2026-01-01T00:00:00Z",
201
+ created_at_epoch: NOW,
202
+ archived_at_epoch: null,
203
+ compacted_into: null,
204
+ superseded_by: null,
205
+ remote_source_id: null,
206
+ };
207
+ }
208
+
209
+ test("single observation returns its content directly", () => {
210
+ const obs = makeObs({
211
+ title: "Found a bug",
212
+ narrative: "The auth was broken",
213
+ facts: '["fact1", "fact2"]',
214
+ });
215
+ const digest = generateDigest([obs]);
216
+ expect(digest.title).toBe("Found a bug");
217
+ expect(digest.narrative).toBe("The auth was broken");
218
+ expect(digest.facts).toEqual(["fact1", "fact2"]);
219
+ });
220
+
221
+ test("multiple observations are summarised", () => {
222
+ const obs1 = makeObs({ title: "Fix auth", type: "bugfix" });
223
+ const obs2 = makeObs({ id: 2, title: "Add tests", type: "feature" });
224
+ const digest = generateDigest([obs1, obs2]);
225
+ expect(digest.title).toContain("Fix auth");
226
+ expect(digest.title).toContain("Add tests");
227
+ expect(digest.narrative).toContain("[bugfix]");
228
+ expect(digest.narrative).toContain("[feature]");
229
+ });
230
+
231
+ test("many observations use +N more format", () => {
232
+ const observations = Array.from({ length: 5 }, (_, i) =>
233
+ makeObs({ id: i + 1, title: `Obs ${i + 1}` })
234
+ );
235
+ const digest = generateDigest(observations);
236
+ expect(digest.title).toContain("Obs 1");
237
+ expect(digest.title).toContain("+4 more");
238
+ });
239
+
240
+ test("merges and deduplicates facts", () => {
241
+ const obs1 = makeObs({ facts: '["fact1", "fact2"]' });
242
+ const obs2 = makeObs({ id: 2, facts: '["fact2", "fact3"]' });
243
+ const digest = generateDigest([obs1, obs2]);
244
+ expect(digest.facts).toContain("fact1");
245
+ expect(digest.facts).toContain("fact2");
246
+ expect(digest.facts).toContain("fact3");
247
+ expect(digest.facts.length).toBe(3); // no duplicates
248
+ });
249
+
250
+ test("unions concepts", () => {
251
+ const obs1 = makeObs({ concepts: '["auth", "security"]' });
252
+ const obs2 = makeObs({ id: 2, concepts: '["security", "testing"]' });
253
+ const digest = generateDigest([obs1, obs2]);
254
+ expect(digest.concepts).toContain("auth");
255
+ expect(digest.concepts).toContain("security");
256
+ expect(digest.concepts).toContain("testing");
257
+ expect(digest.concepts.length).toBe(3);
258
+ });
259
+
260
+ test("handles empty input", () => {
261
+ const digest = generateDigest([]);
262
+ expect(digest.title).toBe("Empty digest");
263
+ });
264
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Compaction job: group aging observations >90 days by session,
3
+ * create digest observations, archive originals.
4
+ *
5
+ * Runs weekly (checked on MCP server startup via scheduler).
6
+ *
7
+ * Strategy:
8
+ * 1. Find aging observations older than 90 days
9
+ * 2. Group by (project_id, session_id)
10
+ * 3. Generate a digest for each group
11
+ * 4. Archive source observations (set compacted_into, remove from FTS5)
12
+ * 5. Add digest to sync outbox
13
+ */
14
+
15
+ import type { MemDatabase, ObservationRow } from "../storage/sqlite.js";
16
+
17
+ const COMPACTION_THRESHOLD_SECONDS = 90 * 86400; // 90 days
18
+
19
+ export interface CompactionResult {
20
+ sessionsCompacted: number;
21
+ observationsArchived: number;
22
+ digestsCreated: number;
23
+ }
24
+
25
+ export interface DigestContent {
26
+ title: string;
27
+ narrative: string;
28
+ facts: string[];
29
+ concepts: string[];
30
+ }
31
+
32
+ /**
33
+ * Run the compaction job: group old aging observations, create digests, archive originals.
34
+ */
35
+ export function runCompactionJob(
36
+ db: MemDatabase,
37
+ nowEpoch?: number
38
+ ): CompactionResult {
39
+ const now = nowEpoch ?? Math.floor(Date.now() / 1000);
40
+ const cutoff = now - COMPACTION_THRESHOLD_SECONDS;
41
+
42
+ // Fetch aging observations older than threshold
43
+ const candidates = db.db
44
+ .query<ObservationRow, [number]>(
45
+ `SELECT * FROM observations
46
+ WHERE lifecycle = 'aging'
47
+ AND created_at_epoch < ?
48
+ ORDER BY project_id, session_id, created_at_epoch`
49
+ )
50
+ .all(cutoff);
51
+
52
+ if (candidates.length === 0) {
53
+ return { sessionsCompacted: 0, observationsArchived: 0, digestsCreated: 0 };
54
+ }
55
+
56
+ // Group by (project_id, session_id)
57
+ const groups = new Map<string, ObservationRow[]>();
58
+ for (const obs of candidates) {
59
+ const key = `${obs.project_id}:${obs.session_id ?? "__no_session__"}`;
60
+ const group = groups.get(key);
61
+ if (group) {
62
+ group.push(obs);
63
+ } else {
64
+ groups.set(key, [obs]);
65
+ }
66
+ }
67
+
68
+ let sessionsCompacted = 0;
69
+ let observationsArchived = 0;
70
+ let digestsCreated = 0;
71
+
72
+ for (const [, group] of groups) {
73
+ const first = group[0]!;
74
+ const digest = generateDigest(group);
75
+ const maxQuality = Math.max(...group.map((o) => o.quality));
76
+
77
+ // Insert digest observation
78
+ const digestObs = db.insertObservation({
79
+ session_id: first.session_id,
80
+ project_id: first.project_id,
81
+ type: "digest",
82
+ title: digest.title,
83
+ narrative: digest.narrative,
84
+ facts: digest.facts.length > 0 ? JSON.stringify(digest.facts) : null,
85
+ concepts:
86
+ digest.concepts.length > 0 ? JSON.stringify(digest.concepts) : null,
87
+ quality: maxQuality,
88
+ lifecycle: "pinned", // digests don't age out
89
+ sensitivity: first.sensitivity,
90
+ user_id: first.user_id,
91
+ device_id: first.device_id,
92
+ agent: first.agent,
93
+ });
94
+
95
+ // Add digest to sync outbox
96
+ db.addToOutbox("observation", digestObs.id);
97
+ digestsCreated++;
98
+
99
+ // Archive source observations
100
+ for (const obs of group) {
101
+ db.db
102
+ .query(
103
+ `UPDATE observations
104
+ SET lifecycle = 'archived', compacted_into = ?, archived_at_epoch = ?
105
+ WHERE id = ?`
106
+ )
107
+ .run(digestObs.id, now, obs.id);
108
+
109
+ // Remove from FTS5
110
+ db.ftsDelete(obs);
111
+ observationsArchived++;
112
+ }
113
+
114
+ sessionsCompacted++;
115
+ }
116
+
117
+ return { sessionsCompacted, observationsArchived, digestsCreated };
118
+ }
119
+
120
+ /**
121
+ * Generate digest content from a group of observations.
122
+ * Pure function — no database access.
123
+ */
124
+ export function generateDigest(observations: ObservationRow[]): DigestContent {
125
+ if (observations.length === 0) {
126
+ return { title: "Empty digest", narrative: "", facts: [], concepts: [] };
127
+ }
128
+
129
+ if (observations.length === 1) {
130
+ const obs = observations[0]!;
131
+ return {
132
+ title: obs.title,
133
+ narrative: obs.narrative ?? "",
134
+ facts: parseFacts(obs.facts),
135
+ concepts: parseFacts(obs.concepts),
136
+ };
137
+ }
138
+
139
+ // Title: summarise the group
140
+ const first = observations[0]!;
141
+ const title =
142
+ observations.length <= 3
143
+ ? observations.map((o) => o.title).join("; ")
144
+ : `${first.title} (+${observations.length - 1} more)`;
145
+
146
+ // Narrative: bullet points of all titles with their types
147
+ const bullets = observations.map(
148
+ (o) => `- [${o.type}] ${o.title}`
149
+ );
150
+ const narrative = `Session digest (${observations.length} observations):\n${bullets.join("\n")}`;
151
+
152
+ // Facts: merge and deduplicate from all observations
153
+ const allFacts = new Set<string>();
154
+ for (const obs of observations) {
155
+ for (const fact of parseFacts(obs.facts)) {
156
+ allFacts.add(fact);
157
+ }
158
+ }
159
+
160
+ // Concepts: union of all concepts
161
+ const allConcepts = new Set<string>();
162
+ for (const obs of observations) {
163
+ for (const concept of parseFacts(obs.concepts)) {
164
+ allConcepts.add(concept);
165
+ }
166
+ }
167
+
168
+ return {
169
+ title,
170
+ narrative,
171
+ facts: [...allFacts],
172
+ concepts: [...allConcepts],
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Parse a JSON array string, returning empty array on failure.
178
+ */
179
+ function parseFacts(json: string | null): string[] {
180
+ if (!json) return [];
181
+ try {
182
+ const parsed = JSON.parse(json);
183
+ if (Array.isArray(parsed)) {
184
+ return parsed.filter((f) => typeof f === "string" && f.length > 0);
185
+ }
186
+ } catch {
187
+ // Not valid JSON
188
+ }
189
+ return [];
190
+ }
@@ -0,0 +1,100 @@
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 "../storage/sqlite.js";
6
+ import { runPurgeJob } from "./purge.js";
7
+
8
+ let db: MemDatabase;
9
+ let tmpDir: string;
10
+ let projectId: number;
11
+
12
+ const DAY = 86400;
13
+ const NOW = Math.floor(Date.now() / 1000);
14
+
15
+ function insertObs(
16
+ overrides: Partial<{
17
+ lifecycle: string;
18
+ archived_at_epoch: number | null;
19
+ title: string;
20
+ }> = {}
21
+ ) {
22
+ db.db
23
+ .query(
24
+ `INSERT INTO observations (project_id, type, title, quality, lifecycle, sensitivity, user_id, device_id, agent, created_at, created_at_epoch, archived_at_epoch)
25
+ VALUES (?, 'discovery', ?, 0.5, ?, 'shared', 'user1', 'dev1', 'claude-code', datetime('now'), ?, ?)`
26
+ )
27
+ .run(
28
+ projectId,
29
+ overrides.title ?? "Test observation",
30
+ overrides.lifecycle ?? "archived",
31
+ NOW - 400 * DAY,
32
+ overrides.archived_at_epoch ?? null
33
+ );
34
+ }
35
+
36
+ beforeEach(() => {
37
+ tmpDir = mkdtempSync(join(tmpdir(), "candengo-purge-test-"));
38
+ db = new MemDatabase(join(tmpDir, "test.db"));
39
+ const project = db.upsertProject({
40
+ canonical_id: "github.com/test/repo",
41
+ name: "repo",
42
+ });
43
+ projectId = project.id;
44
+ });
45
+
46
+ afterEach(() => {
47
+ db.close();
48
+ rmSync(tmpDir, { recursive: true, force: true });
49
+ });
50
+
51
+ describe("runPurgeJob", () => {
52
+ test("deletes archived observations older than 12 months", () => {
53
+ insertObs({ archived_at_epoch: NOW - 400 * DAY });
54
+ const result = runPurgeJob(db, NOW);
55
+ expect(result.deleted).toBe(1);
56
+ expect(db.getObservationById(1)).toBeNull();
57
+ });
58
+
59
+ test("does not delete archived observations newer than 12 months", () => {
60
+ insertObs({ archived_at_epoch: NOW - 300 * DAY });
61
+ const result = runPurgeJob(db, NOW);
62
+ expect(result.deleted).toBe(0);
63
+ expect(db.getObservationById(1)).not.toBeNull();
64
+ });
65
+
66
+ test("does not delete pinned observations regardless of age", () => {
67
+ insertObs({
68
+ lifecycle: "pinned",
69
+ archived_at_epoch: NOW - 400 * DAY,
70
+ });
71
+ const result = runPurgeJob(db, NOW);
72
+ expect(result.deleted).toBe(0);
73
+ });
74
+
75
+ test("does not delete active or aging observations", () => {
76
+ insertObs({ lifecycle: "active", archived_at_epoch: null });
77
+ insertObs({ lifecycle: "aging", archived_at_epoch: null, title: "Aging obs" });
78
+ const result = runPurgeJob(db, NOW);
79
+ expect(result.deleted).toBe(0);
80
+ });
81
+
82
+ test("does not delete archived observations without archived_at_epoch", () => {
83
+ insertObs({ archived_at_epoch: null });
84
+ const result = runPurgeJob(db, NOW);
85
+ expect(result.deleted).toBe(0);
86
+ });
87
+
88
+ test("returns accurate count", () => {
89
+ insertObs({ archived_at_epoch: NOW - 400 * DAY, title: "Old 1" });
90
+ insertObs({ archived_at_epoch: NOW - 500 * DAY, title: "Old 2" });
91
+ insertObs({ archived_at_epoch: NOW - 100 * DAY, title: "Recent" });
92
+ const result = runPurgeJob(db, NOW);
93
+ expect(result.deleted).toBe(2);
94
+ });
95
+
96
+ test("handles empty database", () => {
97
+ const result = runPurgeJob(db, NOW);
98
+ expect(result.deleted).toBe(0);
99
+ });
100
+ });