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,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
|
+
});
|