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,940 @@
|
|
|
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 {
|
|
7
|
+
buildSessionContext,
|
|
8
|
+
formatContextForInjection,
|
|
9
|
+
estimateTokens,
|
|
10
|
+
parseFacts,
|
|
11
|
+
computeBlendedScore,
|
|
12
|
+
} from "./inject.js";
|
|
13
|
+
|
|
14
|
+
let db: MemDatabase;
|
|
15
|
+
let tmpDir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmpDir = mkdtempSync(join(tmpdir(), "candengo-mem-inject-test-"));
|
|
19
|
+
db = new MemDatabase(join(tmpDir, "test.db"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
db.close();
|
|
24
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// --- estimateTokens ---
|
|
28
|
+
|
|
29
|
+
describe("estimateTokens", () => {
|
|
30
|
+
test("empty string returns 0", () => {
|
|
31
|
+
expect(estimateTokens("")).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("null/undefined returns 0", () => {
|
|
35
|
+
expect(estimateTokens(null as any)).toBe(0);
|
|
36
|
+
expect(estimateTokens(undefined as any)).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("short string estimates correctly", () => {
|
|
40
|
+
// "hello" = 5 chars → ceil(5/4) = 2 tokens
|
|
41
|
+
expect(estimateTokens("hello")).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("longer text estimates within expected range", () => {
|
|
45
|
+
const text = "This is a longer piece of text that should be around 15 tokens";
|
|
46
|
+
const estimate = estimateTokens(text);
|
|
47
|
+
// 62 chars → ceil(62/4) = 16
|
|
48
|
+
expect(estimate).toBeGreaterThan(10);
|
|
49
|
+
expect(estimate).toBeLessThan(25);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// --- parseFacts ---
|
|
54
|
+
|
|
55
|
+
describe("parseFacts", () => {
|
|
56
|
+
test("parses valid JSON array", () => {
|
|
57
|
+
const facts = parseFacts('["fact one", "fact two"]');
|
|
58
|
+
expect(facts).toEqual(["fact one", "fact two"]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns empty for null/empty", () => {
|
|
62
|
+
expect(parseFacts("")).toEqual([]);
|
|
63
|
+
expect(parseFacts(null as any)).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("handles malformed JSON gracefully", () => {
|
|
67
|
+
const facts = parseFacts("not valid json");
|
|
68
|
+
expect(facts).toEqual(["not valid json"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("filters out non-string entries", () => {
|
|
72
|
+
const facts = parseFacts('[123, "valid", null, "also valid"]');
|
|
73
|
+
expect(facts).toEqual(["valid", "also valid"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("filters out empty strings", () => {
|
|
77
|
+
const facts = parseFacts('["good", "", "also good"]');
|
|
78
|
+
expect(facts).toEqual(["good", "also good"]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// --- buildSessionContext ---
|
|
83
|
+
|
|
84
|
+
describe("buildSessionContext", () => {
|
|
85
|
+
test("returns empty for unknown project", () => {
|
|
86
|
+
const ctx = buildSessionContext(db, "/tmp/nonexistent");
|
|
87
|
+
expect(ctx).not.toBeNull();
|
|
88
|
+
expect(ctx!.observations).toEqual([]);
|
|
89
|
+
expect(ctx!.total_active).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("returns pinned observations first", () => {
|
|
93
|
+
const project = db.upsertProject({
|
|
94
|
+
canonical_id: "local/testproject",
|
|
95
|
+
name: "testproject",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
db.insertObservation({
|
|
99
|
+
project_id: project.id,
|
|
100
|
+
type: "change",
|
|
101
|
+
title: "Regular change",
|
|
102
|
+
quality: 0.5,
|
|
103
|
+
user_id: "david",
|
|
104
|
+
device_id: "laptop",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const pinned = db.insertObservation({
|
|
108
|
+
project_id: project.id,
|
|
109
|
+
type: "decision",
|
|
110
|
+
title: "Use PostgreSQL for all data",
|
|
111
|
+
quality: 0.9,
|
|
112
|
+
lifecycle: "pinned",
|
|
113
|
+
user_id: "david",
|
|
114
|
+
device_id: "laptop",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const ctx = buildSessionContext(db, "/tmp/testproject");
|
|
118
|
+
expect(ctx).not.toBeNull();
|
|
119
|
+
expect(ctx!.observations.length).toBe(2);
|
|
120
|
+
expect(ctx!.observations[0]!.id).toBe(pinned.id);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("filters out low-quality observations", () => {
|
|
124
|
+
const project = db.upsertProject({
|
|
125
|
+
canonical_id: "local/testproject",
|
|
126
|
+
name: "testproject",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
db.insertObservation({
|
|
130
|
+
project_id: project.id,
|
|
131
|
+
type: "change",
|
|
132
|
+
title: "Minor tweak",
|
|
133
|
+
quality: 0.1,
|
|
134
|
+
user_id: "david",
|
|
135
|
+
device_id: "laptop",
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
db.insertObservation({
|
|
139
|
+
project_id: project.id,
|
|
140
|
+
type: "bugfix",
|
|
141
|
+
title: "Fix critical auth bug",
|
|
142
|
+
quality: 0.8,
|
|
143
|
+
user_id: "david",
|
|
144
|
+
device_id: "laptop",
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const ctx = buildSessionContext(db, "/tmp/testproject");
|
|
148
|
+
expect(ctx!.observations.length).toBe(1);
|
|
149
|
+
expect(ctx!.observations[0]!.title).toBe("Fix critical auth bug");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("respects legacy maxCount limit", () => {
|
|
153
|
+
const project = db.upsertProject({
|
|
154
|
+
canonical_id: "local/testproject",
|
|
155
|
+
name: "testproject",
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
for (let i = 0; i < 20; i++) {
|
|
159
|
+
db.insertObservation({
|
|
160
|
+
project_id: project.id,
|
|
161
|
+
type: "bugfix",
|
|
162
|
+
title: `Fix ${i}`,
|
|
163
|
+
quality: 0.5,
|
|
164
|
+
user_id: "david",
|
|
165
|
+
device_id: "laptop",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Legacy number argument
|
|
170
|
+
const ctx = buildSessionContext(db, "/tmp/testproject", 5);
|
|
171
|
+
expect(ctx!.observations.length).toBeLessThanOrEqual(5);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("respects maxCount via options object", () => {
|
|
175
|
+
const project = db.upsertProject({
|
|
176
|
+
canonical_id: "local/testproject",
|
|
177
|
+
name: "testproject",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
for (let i = 0; i < 20; i++) {
|
|
181
|
+
db.insertObservation({
|
|
182
|
+
project_id: project.id,
|
|
183
|
+
type: "bugfix",
|
|
184
|
+
title: `Fix ${i}`,
|
|
185
|
+
quality: 0.5,
|
|
186
|
+
user_id: "david",
|
|
187
|
+
device_id: "laptop",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const ctx = buildSessionContext(db, "/tmp/testproject", { maxCount: 5 });
|
|
192
|
+
expect(ctx!.observations.length).toBeLessThanOrEqual(5);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("deduplicates pinned and recent", () => {
|
|
196
|
+
const project = db.upsertProject({
|
|
197
|
+
canonical_id: "local/testproject",
|
|
198
|
+
name: "testproject",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
db.insertObservation({
|
|
202
|
+
project_id: project.id,
|
|
203
|
+
type: "decision",
|
|
204
|
+
title: "Architecture decision",
|
|
205
|
+
quality: 0.9,
|
|
206
|
+
lifecycle: "pinned",
|
|
207
|
+
user_id: "david",
|
|
208
|
+
device_id: "laptop",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const ctx = buildSessionContext(db, "/tmp/testproject");
|
|
212
|
+
const titles = ctx!.observations.map((o) => o.title);
|
|
213
|
+
const unique = new Set(titles);
|
|
214
|
+
expect(titles.length).toBe(unique.size);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("includes total_active count", () => {
|
|
218
|
+
const project = db.upsertProject({
|
|
219
|
+
canonical_id: "local/testproject",
|
|
220
|
+
name: "testproject",
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
for (let i = 0; i < 10; i++) {
|
|
224
|
+
db.insertObservation({
|
|
225
|
+
project_id: project.id,
|
|
226
|
+
type: "bugfix",
|
|
227
|
+
title: `Fix ${i}`,
|
|
228
|
+
quality: 0.5,
|
|
229
|
+
user_id: "david",
|
|
230
|
+
device_id: "laptop",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Low quality — still counted in total_active
|
|
235
|
+
db.insertObservation({
|
|
236
|
+
project_id: project.id,
|
|
237
|
+
type: "change",
|
|
238
|
+
title: "Minor",
|
|
239
|
+
quality: 0.1,
|
|
240
|
+
user_id: "david",
|
|
241
|
+
device_id: "laptop",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const ctx = buildSessionContext(db, "/tmp/testproject");
|
|
245
|
+
// total_active counts all active/aging/pinned regardless of quality
|
|
246
|
+
expect(ctx!.total_active).toBe(11);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("includes facts in context observations", () => {
|
|
250
|
+
const project = db.upsertProject({
|
|
251
|
+
canonical_id: "local/testproject",
|
|
252
|
+
name: "testproject",
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
db.insertObservation({
|
|
256
|
+
project_id: project.id,
|
|
257
|
+
type: "decision",
|
|
258
|
+
title: "Use SQLite",
|
|
259
|
+
facts: '["SQLite is fast", "Works offline"]',
|
|
260
|
+
quality: 0.8,
|
|
261
|
+
user_id: "david",
|
|
262
|
+
device_id: "laptop",
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const ctx = buildSessionContext(db, "/tmp/testproject");
|
|
266
|
+
expect(ctx!.observations[0]!.facts).toBe(
|
|
267
|
+
'["SQLite is fast", "Works offline"]'
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// --- Token budget ---
|
|
273
|
+
|
|
274
|
+
describe("token budget", () => {
|
|
275
|
+
test("respects token budget — stops adding when exhausted", () => {
|
|
276
|
+
const project = db.upsertProject({
|
|
277
|
+
canonical_id: "local/testproject",
|
|
278
|
+
name: "testproject",
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Insert 30 observations with long narratives
|
|
282
|
+
for (let i = 0; i < 30; i++) {
|
|
283
|
+
db.insertObservation({
|
|
284
|
+
project_id: project.id,
|
|
285
|
+
type: "discovery",
|
|
286
|
+
title: `Discovery about component ${i} and its various interactions`,
|
|
287
|
+
narrative: `This is a detailed narrative about discovery ${i}. `.repeat(
|
|
288
|
+
5
|
|
289
|
+
),
|
|
290
|
+
quality: 0.5,
|
|
291
|
+
user_id: "david",
|
|
292
|
+
device_id: "laptop",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const ctx = buildSessionContext(db, "/tmp/testproject", {
|
|
297
|
+
tokenBudget: 200,
|
|
298
|
+
});
|
|
299
|
+
// With a very tight budget, should include fewer than 30
|
|
300
|
+
expect(ctx!.observations.length).toBeLessThan(30);
|
|
301
|
+
expect(ctx!.observations.length).toBeGreaterThan(0);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("always includes pinned even if they consume most budget", () => {
|
|
305
|
+
const project = db.upsertProject({
|
|
306
|
+
canonical_id: "local/testproject",
|
|
307
|
+
name: "testproject",
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// A pinned observation with long content
|
|
311
|
+
db.insertObservation({
|
|
312
|
+
project_id: project.id,
|
|
313
|
+
type: "decision",
|
|
314
|
+
title: "Critical architecture decision that must always be shown",
|
|
315
|
+
narrative: "Very important details. ".repeat(20),
|
|
316
|
+
quality: 0.9,
|
|
317
|
+
lifecycle: "pinned",
|
|
318
|
+
user_id: "david",
|
|
319
|
+
device_id: "laptop",
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// A regular observation
|
|
323
|
+
db.insertObservation({
|
|
324
|
+
project_id: project.id,
|
|
325
|
+
type: "bugfix",
|
|
326
|
+
title: "Minor fix",
|
|
327
|
+
quality: 0.5,
|
|
328
|
+
user_id: "david",
|
|
329
|
+
device_id: "laptop",
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const ctx = buildSessionContext(db, "/tmp/testproject", {
|
|
333
|
+
tokenBudget: 50,
|
|
334
|
+
});
|
|
335
|
+
// Pinned should always be included
|
|
336
|
+
expect(
|
|
337
|
+
ctx!.observations.some(
|
|
338
|
+
(o) => o.title === "Critical architecture decision that must always be shown"
|
|
339
|
+
)
|
|
340
|
+
).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("default budget is 800 tokens", () => {
|
|
344
|
+
const project = db.upsertProject({
|
|
345
|
+
canonical_id: "local/testproject",
|
|
346
|
+
name: "testproject",
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Create observations with realistic content that will hit budget
|
|
350
|
+
for (let i = 0; i < 50; i++) {
|
|
351
|
+
db.insertObservation({
|
|
352
|
+
project_id: project.id,
|
|
353
|
+
type: "bugfix",
|
|
354
|
+
title: `Fix critical authentication issue in component ${i} affecting all users`,
|
|
355
|
+
narrative: `Discovered that the authentication token was not being refreshed correctly in component ${i}. The root cause was a race condition in the token refresh logic. Fixed by adding proper mutex locking around the refresh operation.`,
|
|
356
|
+
facts: JSON.stringify([
|
|
357
|
+
`Component ${i} had a race condition`,
|
|
358
|
+
`Token refresh was failing silently`,
|
|
359
|
+
`Added mutex locking to fix`,
|
|
360
|
+
]),
|
|
361
|
+
quality: 0.5,
|
|
362
|
+
user_id: "david",
|
|
363
|
+
device_id: "laptop",
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Default options (no maxCount, no explicit tokenBudget)
|
|
368
|
+
const ctx = buildSessionContext(db, "/tmp/testproject");
|
|
369
|
+
// Should include some but not all 50
|
|
370
|
+
expect(ctx!.observations.length).toBeLessThan(50);
|
|
371
|
+
expect(ctx!.observations.length).toBeGreaterThan(0);
|
|
372
|
+
|
|
373
|
+
// Verify the formatted output is roughly within budget
|
|
374
|
+
const formatted = formatContextForInjection(ctx!);
|
|
375
|
+
const tokens = estimateTokens(formatted);
|
|
376
|
+
// Should be within budget range (800 + some margin for header/footer)
|
|
377
|
+
expect(tokens).toBeLessThan(1200);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// --- formatContextForInjection (tiered + facts-first) ---
|
|
382
|
+
|
|
383
|
+
describe("formatContextForInjection", () => {
|
|
384
|
+
test("formats empty context", () => {
|
|
385
|
+
const text = formatContextForInjection({
|
|
386
|
+
project_name: "myproject",
|
|
387
|
+
canonical_id: "local/myproject",
|
|
388
|
+
observations: [],
|
|
389
|
+
session_count: 0,
|
|
390
|
+
total_active: 0,
|
|
391
|
+
});
|
|
392
|
+
expect(text).toContain("myproject");
|
|
393
|
+
expect(text).toContain("no prior observations");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("top-tier observations show facts as bullet points", () => {
|
|
397
|
+
const text = formatContextForInjection({
|
|
398
|
+
project_name: "myproject",
|
|
399
|
+
canonical_id: "local/myproject",
|
|
400
|
+
observations: [
|
|
401
|
+
{
|
|
402
|
+
id: 1,
|
|
403
|
+
type: "decision",
|
|
404
|
+
title: "Use SQLite",
|
|
405
|
+
narrative: "We chose SQLite for local storage",
|
|
406
|
+
facts: '["SQLite is fast", "Works offline", "No server needed"]',
|
|
407
|
+
quality: 0.9,
|
|
408
|
+
created_at: "2026-03-10T10:00:00Z",
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
session_count: 1,
|
|
412
|
+
total_active: 1,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Should show facts as bullets, not narrative
|
|
416
|
+
expect(text).toContain(" - SQLite is fast");
|
|
417
|
+
expect(text).toContain(" - Works offline");
|
|
418
|
+
expect(text).toContain(" - No server needed");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("falls back to narrative when no facts", () => {
|
|
422
|
+
const text = formatContextForInjection({
|
|
423
|
+
project_name: "myproject",
|
|
424
|
+
canonical_id: "local/myproject",
|
|
425
|
+
observations: [
|
|
426
|
+
{
|
|
427
|
+
id: 1,
|
|
428
|
+
type: "bugfix",
|
|
429
|
+
title: "Fix auth",
|
|
430
|
+
narrative: "Token was expiring during long requests",
|
|
431
|
+
facts: null,
|
|
432
|
+
quality: 0.8,
|
|
433
|
+
created_at: "2026-03-10T10:00:00Z",
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
session_count: 1,
|
|
437
|
+
total_active: 1,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
expect(text).toContain("Token was expiring");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("lower-tier observations show title only (no detail)", () => {
|
|
444
|
+
const observations = [];
|
|
445
|
+
for (let i = 0; i < 6; i++) {
|
|
446
|
+
observations.push({
|
|
447
|
+
id: i + 1,
|
|
448
|
+
type: "bugfix",
|
|
449
|
+
title: `Fix number ${i}`,
|
|
450
|
+
narrative: `Detailed narrative for fix ${i} that should not appear for lower tier`,
|
|
451
|
+
facts: `["Fact for fix ${i}"]`,
|
|
452
|
+
quality: 0.5,
|
|
453
|
+
created_at: "2026-03-10T10:00:00Z",
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const text = formatContextForInjection({
|
|
458
|
+
project_name: "myproject",
|
|
459
|
+
canonical_id: "local/myproject",
|
|
460
|
+
observations,
|
|
461
|
+
session_count: 6,
|
|
462
|
+
total_active: 10,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// Observations 0-2 (top tier) should have detail
|
|
466
|
+
expect(text).toContain("Fact for fix 0");
|
|
467
|
+
expect(text).toContain("Fact for fix 1");
|
|
468
|
+
expect(text).toContain("Fact for fix 2");
|
|
469
|
+
|
|
470
|
+
// Observations 3-5 (lower tier) should NOT have detail
|
|
471
|
+
expect(text).not.toContain("Fact for fix 3");
|
|
472
|
+
expect(text).not.toContain("Fact for fix 4");
|
|
473
|
+
expect(text).not.toContain("Detailed narrative for fix 3");
|
|
474
|
+
|
|
475
|
+
// But their titles should be present
|
|
476
|
+
expect(text).toContain("Fix number 3");
|
|
477
|
+
expect(text).toContain("Fix number 4");
|
|
478
|
+
expect(text).toContain("Fix number 5");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("shows footer with remaining count", () => {
|
|
482
|
+
const text = formatContextForInjection({
|
|
483
|
+
project_name: "myproject",
|
|
484
|
+
canonical_id: "local/myproject",
|
|
485
|
+
observations: [
|
|
486
|
+
{
|
|
487
|
+
id: 1,
|
|
488
|
+
type: "bugfix",
|
|
489
|
+
title: "Fix something",
|
|
490
|
+
narrative: null,
|
|
491
|
+
facts: null,
|
|
492
|
+
quality: 0.5,
|
|
493
|
+
created_at: "2026-03-10T10:00:00Z",
|
|
494
|
+
},
|
|
495
|
+
],
|
|
496
|
+
session_count: 1,
|
|
497
|
+
total_active: 15,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
expect(text).toContain("14 more observation(s) available via search");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("no footer when all observations shown", () => {
|
|
504
|
+
const text = formatContextForInjection({
|
|
505
|
+
project_name: "myproject",
|
|
506
|
+
canonical_id: "local/myproject",
|
|
507
|
+
observations: [
|
|
508
|
+
{
|
|
509
|
+
id: 1,
|
|
510
|
+
type: "bugfix",
|
|
511
|
+
title: "Fix",
|
|
512
|
+
narrative: null,
|
|
513
|
+
facts: null,
|
|
514
|
+
quality: 0.5,
|
|
515
|
+
created_at: "2026-03-10T10:00:00Z",
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
session_count: 1,
|
|
519
|
+
total_active: 1,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
expect(text).not.toContain("more observation(s) available");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("with ≤3 observations all get detailed format", () => {
|
|
526
|
+
const observations = [
|
|
527
|
+
{
|
|
528
|
+
id: 1,
|
|
529
|
+
type: "decision",
|
|
530
|
+
title: "Decision one",
|
|
531
|
+
narrative: null,
|
|
532
|
+
facts: '["Fact A"]',
|
|
533
|
+
quality: 0.9,
|
|
534
|
+
created_at: "2026-03-10T10:00:00Z",
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
id: 2,
|
|
538
|
+
type: "bugfix",
|
|
539
|
+
title: "Bugfix two",
|
|
540
|
+
narrative: "Fixed a bug",
|
|
541
|
+
facts: null,
|
|
542
|
+
quality: 0.7,
|
|
543
|
+
created_at: "2026-03-10T10:00:00Z",
|
|
544
|
+
},
|
|
545
|
+
];
|
|
546
|
+
|
|
547
|
+
const text = formatContextForInjection({
|
|
548
|
+
project_name: "myproject",
|
|
549
|
+
canonical_id: "local/myproject",
|
|
550
|
+
observations,
|
|
551
|
+
session_count: 2,
|
|
552
|
+
total_active: 2,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
expect(text).toContain("Fact A");
|
|
556
|
+
expect(text).toContain("Fixed a bug");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("handles malformed facts JSON gracefully", () => {
|
|
560
|
+
const text = formatContextForInjection({
|
|
561
|
+
project_name: "myproject",
|
|
562
|
+
canonical_id: "local/myproject",
|
|
563
|
+
observations: [
|
|
564
|
+
{
|
|
565
|
+
id: 1,
|
|
566
|
+
type: "bugfix",
|
|
567
|
+
title: "Fix",
|
|
568
|
+
narrative: null,
|
|
569
|
+
facts: "not valid json but still useful",
|
|
570
|
+
quality: 0.5,
|
|
571
|
+
created_at: "2026-03-10T10:00:00Z",
|
|
572
|
+
},
|
|
573
|
+
],
|
|
574
|
+
session_count: 1,
|
|
575
|
+
total_active: 1,
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// Should treat as single fact, not crash
|
|
579
|
+
expect(text).toContain("not valid json but still useful");
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("caps facts at 4 per observation", () => {
|
|
583
|
+
const manyFacts = JSON.stringify([
|
|
584
|
+
"Fact 1",
|
|
585
|
+
"Fact 2",
|
|
586
|
+
"Fact 3",
|
|
587
|
+
"Fact 4",
|
|
588
|
+
"Fact 5 should not appear",
|
|
589
|
+
"Fact 6 should not appear",
|
|
590
|
+
]);
|
|
591
|
+
const text = formatContextForInjection({
|
|
592
|
+
project_name: "myproject",
|
|
593
|
+
canonical_id: "local/myproject",
|
|
594
|
+
observations: [
|
|
595
|
+
{
|
|
596
|
+
id: 1,
|
|
597
|
+
type: "decision",
|
|
598
|
+
title: "Decision",
|
|
599
|
+
narrative: null,
|
|
600
|
+
facts: manyFacts,
|
|
601
|
+
quality: 0.9,
|
|
602
|
+
created_at: "2026-03-10T10:00:00Z",
|
|
603
|
+
},
|
|
604
|
+
],
|
|
605
|
+
session_count: 1,
|
|
606
|
+
total_active: 1,
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
expect(text).toContain("Fact 1");
|
|
610
|
+
expect(text).toContain("Fact 4");
|
|
611
|
+
expect(text).not.toContain("Fact 5");
|
|
612
|
+
expect(text).not.toContain("Fact 6");
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("truncates long narratives in fallback", () => {
|
|
616
|
+
const longNarrative = "x".repeat(200);
|
|
617
|
+
const text = formatContextForInjection({
|
|
618
|
+
project_name: "test",
|
|
619
|
+
canonical_id: "local/test",
|
|
620
|
+
observations: [
|
|
621
|
+
{
|
|
622
|
+
id: 1,
|
|
623
|
+
type: "bugfix",
|
|
624
|
+
title: "Fix",
|
|
625
|
+
narrative: longNarrative,
|
|
626
|
+
facts: null,
|
|
627
|
+
quality: 0.5,
|
|
628
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
629
|
+
},
|
|
630
|
+
],
|
|
631
|
+
session_count: 1,
|
|
632
|
+
total_active: 1,
|
|
633
|
+
});
|
|
634
|
+
// Narrative truncated to 120 chars
|
|
635
|
+
expect(text).toContain("...");
|
|
636
|
+
expect(text.length).toBeLessThan(longNarrative.length + 200);
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// --- computeBlendedScore ---
|
|
641
|
+
|
|
642
|
+
describe("computeBlendedScore", () => {
|
|
643
|
+
const NOW = Math.floor(Date.now() / 1000);
|
|
644
|
+
const ONE_DAY = 86400;
|
|
645
|
+
|
|
646
|
+
test("recent medium-quality beats old high-quality", () => {
|
|
647
|
+
// 2 days old, q=0.5
|
|
648
|
+
const recentScore = computeBlendedScore(0.5, NOW - 2 * ONE_DAY, NOW);
|
|
649
|
+
// 25 days old, q=0.7
|
|
650
|
+
const oldScore = computeBlendedScore(0.7, NOW - 25 * ONE_DAY, NOW);
|
|
651
|
+
expect(recentScore).toBeGreaterThan(oldScore);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("very old observations get near-zero recency boost", () => {
|
|
655
|
+
// 60 days old — well past the 30-day window
|
|
656
|
+
const score = computeBlendedScore(0.5, NOW - 60 * ONE_DAY, NOW);
|
|
657
|
+
// Should be roughly quality * 0.6 = 0.3 (no recency contribution)
|
|
658
|
+
expect(score).toBeCloseTo(0.3, 1);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test("brand new observation gets full recency boost", () => {
|
|
662
|
+
const score = computeBlendedScore(0.5, NOW, NOW);
|
|
663
|
+
// quality * 0.6 + 1.0 * 0.4 = 0.3 + 0.4 = 0.7
|
|
664
|
+
expect(score).toBeCloseTo(0.7, 1);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("same-age observations sort by quality", () => {
|
|
668
|
+
const age = NOW - 5 * ONE_DAY;
|
|
669
|
+
const highQ = computeBlendedScore(0.9, age, NOW);
|
|
670
|
+
const lowQ = computeBlendedScore(0.3, age, NOW);
|
|
671
|
+
expect(highQ).toBeGreaterThan(lowQ);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("score is always between 0 and 1", () => {
|
|
675
|
+
// Extreme cases
|
|
676
|
+
expect(computeBlendedScore(0, NOW - 100 * ONE_DAY, NOW)).toBeGreaterThanOrEqual(0);
|
|
677
|
+
expect(computeBlendedScore(1, NOW, NOW)).toBeLessThanOrEqual(1);
|
|
678
|
+
expect(computeBlendedScore(0.5, NOW + ONE_DAY, NOW)).toBeLessThanOrEqual(1); // future timestamp
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test("future timestamps clamp recency to 1", () => {
|
|
682
|
+
// Created "in the future" — shouldn't produce score > 1
|
|
683
|
+
const score = computeBlendedScore(1.0, NOW + 10 * ONE_DAY, NOW);
|
|
684
|
+
expect(score).toBeLessThanOrEqual(1.0);
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// --- Blended scoring integration ---
|
|
689
|
+
|
|
690
|
+
describe("blended scoring in buildSessionContext", () => {
|
|
691
|
+
test("recent medium-quality observation appears before old high-quality", () => {
|
|
692
|
+
const project = db.upsertProject({
|
|
693
|
+
canonical_id: "local/testproject",
|
|
694
|
+
name: "testproject",
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
const nowEpoch = Math.floor(Date.now() / 1000);
|
|
698
|
+
|
|
699
|
+
// Insert old high-quality observation (25 days ago)
|
|
700
|
+
// We need to directly manipulate created_at_epoch
|
|
701
|
+
db.db
|
|
702
|
+
.query(
|
|
703
|
+
`INSERT INTO observations (project_id, type, title, quality, lifecycle,
|
|
704
|
+
user_id, device_id, created_at, created_at_epoch)
|
|
705
|
+
VALUES (?, 'decision', 'Old important decision', 0.8, 'active',
|
|
706
|
+
'david', 'laptop', datetime('now'), ?)`
|
|
707
|
+
)
|
|
708
|
+
.run(project.id, nowEpoch - 25 * 86400);
|
|
709
|
+
|
|
710
|
+
// Insert recent medium-quality observation (1 day ago)
|
|
711
|
+
db.db
|
|
712
|
+
.query(
|
|
713
|
+
`INSERT INTO observations (project_id, type, title, quality, lifecycle,
|
|
714
|
+
user_id, device_id, created_at, created_at_epoch)
|
|
715
|
+
VALUES (?, 'discovery', 'Recent discovery', 0.5, 'active',
|
|
716
|
+
'david', 'laptop', datetime('now'), ?)`
|
|
717
|
+
)
|
|
718
|
+
.run(project.id, nowEpoch - 1 * 86400);
|
|
719
|
+
|
|
720
|
+
// Also add to FTS
|
|
721
|
+
db.db.query(
|
|
722
|
+
`INSERT INTO observations_fts (rowid, title, narrative, facts, concepts)
|
|
723
|
+
SELECT id, title, narrative, facts, concepts FROM observations WHERE project_id = ?`
|
|
724
|
+
).run(project.id);
|
|
725
|
+
|
|
726
|
+
const ctx = buildSessionContext(db, "/tmp/testproject", { tokenBudget: 800 });
|
|
727
|
+
expect(ctx!.observations.length).toBe(2);
|
|
728
|
+
|
|
729
|
+
// Recent medium-quality should come first due to blended scoring
|
|
730
|
+
expect(ctx!.observations[0]!.title).toBe("Recent discovery");
|
|
731
|
+
expect(ctx!.observations[1]!.title).toBe("Old important decision");
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// --- Supersession ---
|
|
736
|
+
|
|
737
|
+
describe("supersession", () => {
|
|
738
|
+
test("superseded observation excluded from session context", () => {
|
|
739
|
+
const project = db.upsertProject({
|
|
740
|
+
canonical_id: "local/testproject",
|
|
741
|
+
name: "testproject",
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
const old = db.insertObservation({
|
|
745
|
+
project_id: project.id,
|
|
746
|
+
type: "decision",
|
|
747
|
+
title: "Use Express for API",
|
|
748
|
+
quality: 0.8,
|
|
749
|
+
user_id: "david",
|
|
750
|
+
device_id: "laptop",
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
const replacement = db.insertObservation({
|
|
754
|
+
project_id: project.id,
|
|
755
|
+
type: "decision",
|
|
756
|
+
title: "Migrated from Express to Hono",
|
|
757
|
+
quality: 0.9,
|
|
758
|
+
user_id: "david",
|
|
759
|
+
device_id: "laptop",
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// Supersede the old one
|
|
763
|
+
const result = db.supersedeObservation(old.id, replacement.id);
|
|
764
|
+
expect(result).toBe(true);
|
|
765
|
+
|
|
766
|
+
const ctx = buildSessionContext(db, "/tmp/testproject");
|
|
767
|
+
const titles = ctx!.observations.map((o) => o.title);
|
|
768
|
+
expect(titles).toContain("Migrated from Express to Hono");
|
|
769
|
+
expect(titles).not.toContain("Use Express for API");
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test("superseded observation excluded from total_active count", () => {
|
|
773
|
+
const project = db.upsertProject({
|
|
774
|
+
canonical_id: "local/testproject",
|
|
775
|
+
name: "testproject",
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const old = db.insertObservation({
|
|
779
|
+
project_id: project.id,
|
|
780
|
+
type: "decision",
|
|
781
|
+
title: "Old decision",
|
|
782
|
+
quality: 0.8,
|
|
783
|
+
user_id: "david",
|
|
784
|
+
device_id: "laptop",
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
const replacement = db.insertObservation({
|
|
788
|
+
project_id: project.id,
|
|
789
|
+
type: "decision",
|
|
790
|
+
title: "New decision",
|
|
791
|
+
quality: 0.9,
|
|
792
|
+
user_id: "david",
|
|
793
|
+
device_id: "laptop",
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
db.supersedeObservation(old.id, replacement.id);
|
|
797
|
+
|
|
798
|
+
const ctx = buildSessionContext(db, "/tmp/testproject");
|
|
799
|
+
// Only the replacement should count
|
|
800
|
+
expect(ctx!.total_active).toBe(1);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("supersedeObservation archives the old one", () => {
|
|
804
|
+
const project = db.upsertProject({
|
|
805
|
+
canonical_id: "local/testproject",
|
|
806
|
+
name: "testproject",
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
const old = db.insertObservation({
|
|
810
|
+
project_id: project.id,
|
|
811
|
+
type: "discovery",
|
|
812
|
+
title: "Old discovery",
|
|
813
|
+
quality: 0.7,
|
|
814
|
+
user_id: "david",
|
|
815
|
+
device_id: "laptop",
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
const newer = db.insertObservation({
|
|
819
|
+
project_id: project.id,
|
|
820
|
+
type: "discovery",
|
|
821
|
+
title: "Updated discovery",
|
|
822
|
+
quality: 0.8,
|
|
823
|
+
user_id: "david",
|
|
824
|
+
device_id: "laptop",
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
db.supersedeObservation(old.id, newer.id);
|
|
828
|
+
|
|
829
|
+
const archived = db.getObservationById(old.id)!;
|
|
830
|
+
expect(archived.lifecycle).toBe("archived");
|
|
831
|
+
expect(archived.superseded_by).toBe(newer.id);
|
|
832
|
+
expect(archived.archived_at_epoch).not.toBeNull();
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
test("supersession chains resolve to the current head", () => {
|
|
836
|
+
const project = db.upsertProject({
|
|
837
|
+
canonical_id: "local/testproject",
|
|
838
|
+
name: "testproject",
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
const obs1 = db.insertObservation({
|
|
842
|
+
project_id: project.id,
|
|
843
|
+
type: "decision",
|
|
844
|
+
title: "First",
|
|
845
|
+
quality: 0.5,
|
|
846
|
+
user_id: "david",
|
|
847
|
+
device_id: "laptop",
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
const obs2 = db.insertObservation({
|
|
851
|
+
project_id: project.id,
|
|
852
|
+
type: "decision",
|
|
853
|
+
title: "Second",
|
|
854
|
+
quality: 0.6,
|
|
855
|
+
user_id: "david",
|
|
856
|
+
device_id: "laptop",
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
const obs3 = db.insertObservation({
|
|
860
|
+
project_id: project.id,
|
|
861
|
+
type: "decision",
|
|
862
|
+
title: "Third",
|
|
863
|
+
quality: 0.7,
|
|
864
|
+
user_id: "david",
|
|
865
|
+
device_id: "laptop",
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// First supersession: #1 superseded by #2
|
|
869
|
+
expect(db.supersedeObservation(obs1.id, obs2.id)).toBe(true);
|
|
870
|
+
|
|
871
|
+
// Chain supersession: asking to supersede #1 with #3
|
|
872
|
+
// Should resolve to head (#2) and supersede that instead
|
|
873
|
+
expect(db.supersedeObservation(obs1.id, obs3.id)).toBe(true);
|
|
874
|
+
|
|
875
|
+
// #1 is still superseded by #2 (unchanged)
|
|
876
|
+
const check1 = db.getObservationById(obs1.id)!;
|
|
877
|
+
expect(check1.superseded_by).toBe(obs2.id);
|
|
878
|
+
|
|
879
|
+
// #2 is now superseded by #3 (the chain resolved)
|
|
880
|
+
const check2 = db.getObservationById(obs2.id)!;
|
|
881
|
+
expect(check2.superseded_by).toBe(obs3.id);
|
|
882
|
+
expect(check2.lifecycle).toBe("archived");
|
|
883
|
+
|
|
884
|
+
// #3 is the current head (not superseded)
|
|
885
|
+
const check3 = db.getObservationById(obs3.id)!;
|
|
886
|
+
expect(check3.superseded_by).toBeNull();
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("cannot self-supersede", () => {
|
|
890
|
+
const project = db.upsertProject({
|
|
891
|
+
canonical_id: "local/testproject",
|
|
892
|
+
name: "testproject",
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
const obs = db.insertObservation({
|
|
896
|
+
project_id: project.id,
|
|
897
|
+
type: "decision",
|
|
898
|
+
title: "Self",
|
|
899
|
+
quality: 0.5,
|
|
900
|
+
user_id: "david",
|
|
901
|
+
device_id: "laptop",
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
expect(db.supersedeObservation(obs.id, obs.id)).toBe(false);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
test("returns false for nonexistent observation IDs", () => {
|
|
908
|
+
expect(db.supersedeObservation(999, 888)).toBe(false);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test("isSuperseded returns correct state", () => {
|
|
912
|
+
const project = db.upsertProject({
|
|
913
|
+
canonical_id: "local/testproject",
|
|
914
|
+
name: "testproject",
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
const old = db.insertObservation({
|
|
918
|
+
project_id: project.id,
|
|
919
|
+
type: "decision",
|
|
920
|
+
title: "Old",
|
|
921
|
+
quality: 0.5,
|
|
922
|
+
user_id: "david",
|
|
923
|
+
device_id: "laptop",
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
const newer = db.insertObservation({
|
|
927
|
+
project_id: project.id,
|
|
928
|
+
type: "decision",
|
|
929
|
+
title: "New",
|
|
930
|
+
quality: 0.7,
|
|
931
|
+
user_id: "david",
|
|
932
|
+
device_id: "laptop",
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
expect(db.isSuperseded(old.id)).toBe(false);
|
|
936
|
+
db.supersedeObservation(old.id, newer.id);
|
|
937
|
+
expect(db.isSuperseded(old.id)).toBe(true);
|
|
938
|
+
expect(db.isSuperseded(newer.id)).toBe(false);
|
|
939
|
+
});
|
|
940
|
+
});
|