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,934 @@
|
|
|
1
|
+
import { Database as BunDatabase } from "bun:sqlite";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { runMigrations } from "./migrations.js";
|
|
4
|
+
|
|
5
|
+
// macOS ships a SQLite build that blocks extensions.
|
|
6
|
+
// Use Homebrew's vanilla SQLite if available (must be set before any Database instantiation).
|
|
7
|
+
const HOMEBREW_SQLITE_PATHS = [
|
|
8
|
+
"/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib", // Apple Silicon
|
|
9
|
+
"/usr/local/opt/sqlite3/lib/libsqlite3.dylib", // Intel
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
let _customSqliteSet = false;
|
|
13
|
+
function ensureCustomSqlite(): void {
|
|
14
|
+
if (_customSqliteSet) return;
|
|
15
|
+
_customSqliteSet = true;
|
|
16
|
+
if (process.platform !== "darwin") return;
|
|
17
|
+
for (const p of HOMEBREW_SQLITE_PATHS) {
|
|
18
|
+
if (existsSync(p)) {
|
|
19
|
+
try {
|
|
20
|
+
BunDatabase.setCustomSQLite(p);
|
|
21
|
+
} catch {
|
|
22
|
+
// Already set or not supported — ignore
|
|
23
|
+
}
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --- Row types ---
|
|
30
|
+
|
|
31
|
+
export interface ProjectRow {
|
|
32
|
+
id: number;
|
|
33
|
+
canonical_id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
local_path: string | null;
|
|
36
|
+
remote_url: string | null;
|
|
37
|
+
first_seen_epoch: number;
|
|
38
|
+
last_active_epoch: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ObservationRow {
|
|
42
|
+
id: number;
|
|
43
|
+
session_id: string | null;
|
|
44
|
+
project_id: number;
|
|
45
|
+
type: string;
|
|
46
|
+
title: string;
|
|
47
|
+
narrative: string | null;
|
|
48
|
+
facts: string | null;
|
|
49
|
+
concepts: string | null;
|
|
50
|
+
files_read: string | null;
|
|
51
|
+
files_modified: string | null;
|
|
52
|
+
quality: number;
|
|
53
|
+
lifecycle: string;
|
|
54
|
+
sensitivity: string;
|
|
55
|
+
user_id: string;
|
|
56
|
+
device_id: string;
|
|
57
|
+
agent: string;
|
|
58
|
+
created_at: string;
|
|
59
|
+
created_at_epoch: number;
|
|
60
|
+
archived_at_epoch: number | null;
|
|
61
|
+
compacted_into: number | null;
|
|
62
|
+
superseded_by: number | null;
|
|
63
|
+
remote_source_id: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SessionRow {
|
|
67
|
+
id: number;
|
|
68
|
+
session_id: string;
|
|
69
|
+
project_id: number | null;
|
|
70
|
+
user_id: string;
|
|
71
|
+
device_id: string;
|
|
72
|
+
agent: string;
|
|
73
|
+
status: string;
|
|
74
|
+
observation_count: number;
|
|
75
|
+
started_at_epoch: number | null;
|
|
76
|
+
completed_at_epoch: number | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface FtsMatchRow {
|
|
80
|
+
id: number;
|
|
81
|
+
rank: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface VecMatchRow {
|
|
85
|
+
observation_id: number;
|
|
86
|
+
distance: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SessionSummaryRow {
|
|
90
|
+
id: number;
|
|
91
|
+
session_id: string;
|
|
92
|
+
project_id: number | null;
|
|
93
|
+
user_id: string;
|
|
94
|
+
request: string | null;
|
|
95
|
+
investigated: string | null;
|
|
96
|
+
learned: string | null;
|
|
97
|
+
completed: string | null;
|
|
98
|
+
next_steps: string | null;
|
|
99
|
+
created_at_epoch: number | null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SecurityFindingRow {
|
|
103
|
+
id: number;
|
|
104
|
+
session_id: string | null;
|
|
105
|
+
project_id: number;
|
|
106
|
+
finding_type: string;
|
|
107
|
+
severity: string;
|
|
108
|
+
pattern_name: string;
|
|
109
|
+
file_path: string | null;
|
|
110
|
+
snippet: string | null;
|
|
111
|
+
tool_name: string | null;
|
|
112
|
+
user_id: string;
|
|
113
|
+
device_id: string;
|
|
114
|
+
created_at_epoch: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Insert types ---
|
|
118
|
+
|
|
119
|
+
export interface InsertObservation {
|
|
120
|
+
session_id?: string | null;
|
|
121
|
+
project_id: number;
|
|
122
|
+
type: string;
|
|
123
|
+
title: string;
|
|
124
|
+
narrative?: string | null;
|
|
125
|
+
facts?: string | null;
|
|
126
|
+
concepts?: string | null;
|
|
127
|
+
files_read?: string | null;
|
|
128
|
+
files_modified?: string | null;
|
|
129
|
+
quality: number;
|
|
130
|
+
lifecycle?: string;
|
|
131
|
+
sensitivity?: string;
|
|
132
|
+
user_id: string;
|
|
133
|
+
device_id: string;
|
|
134
|
+
agent?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface InsertProject {
|
|
138
|
+
canonical_id: string;
|
|
139
|
+
name: string;
|
|
140
|
+
local_path?: string | null;
|
|
141
|
+
remote_url?: string | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface InsertSessionSummary {
|
|
145
|
+
session_id: string;
|
|
146
|
+
project_id: number | null;
|
|
147
|
+
user_id: string;
|
|
148
|
+
request: string | null;
|
|
149
|
+
investigated: string | null;
|
|
150
|
+
learned: string | null;
|
|
151
|
+
completed: string | null;
|
|
152
|
+
next_steps: string | null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface InsertSecurityFinding {
|
|
156
|
+
session_id?: string | null;
|
|
157
|
+
project_id: number;
|
|
158
|
+
finding_type: string;
|
|
159
|
+
severity: string;
|
|
160
|
+
pattern_name: string;
|
|
161
|
+
file_path?: string | null;
|
|
162
|
+
snippet?: string | null;
|
|
163
|
+
tool_name?: string | null;
|
|
164
|
+
user_id: string;
|
|
165
|
+
device_id: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- Database class ---
|
|
169
|
+
|
|
170
|
+
export class MemDatabase {
|
|
171
|
+
readonly db: BunDatabase;
|
|
172
|
+
readonly vecAvailable: boolean;
|
|
173
|
+
|
|
174
|
+
constructor(dbPath: string) {
|
|
175
|
+
ensureCustomSqlite();
|
|
176
|
+
this.db = new BunDatabase(dbPath);
|
|
177
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
178
|
+
this.db.exec("PRAGMA foreign_keys = ON");
|
|
179
|
+
|
|
180
|
+
// Attempt to load sqlite-vec extension before migrations
|
|
181
|
+
this.vecAvailable = this.loadVecExtension();
|
|
182
|
+
|
|
183
|
+
runMigrations(this.db);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private loadVecExtension(): boolean {
|
|
187
|
+
try {
|
|
188
|
+
const sqliteVec = require("sqlite-vec");
|
|
189
|
+
sqliteVec.load(this.db);
|
|
190
|
+
return true;
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
close(): void {
|
|
197
|
+
this.db.close();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- Projects ---
|
|
201
|
+
|
|
202
|
+
upsertProject(project: InsertProject): ProjectRow {
|
|
203
|
+
const now = Math.floor(Date.now() / 1000);
|
|
204
|
+
const existing = this.db
|
|
205
|
+
.query<ProjectRow, [string]>(
|
|
206
|
+
"SELECT * FROM projects WHERE canonical_id = ?"
|
|
207
|
+
)
|
|
208
|
+
.get(project.canonical_id);
|
|
209
|
+
|
|
210
|
+
if (existing) {
|
|
211
|
+
this.db
|
|
212
|
+
.query(
|
|
213
|
+
`UPDATE projects SET
|
|
214
|
+
local_path = COALESCE(?, local_path),
|
|
215
|
+
remote_url = COALESCE(?, remote_url),
|
|
216
|
+
last_active_epoch = ?
|
|
217
|
+
WHERE id = ?`
|
|
218
|
+
)
|
|
219
|
+
.run(
|
|
220
|
+
project.local_path ?? null,
|
|
221
|
+
project.remote_url ?? null,
|
|
222
|
+
now,
|
|
223
|
+
existing.id
|
|
224
|
+
);
|
|
225
|
+
return {
|
|
226
|
+
...existing,
|
|
227
|
+
local_path: project.local_path ?? existing.local_path,
|
|
228
|
+
remote_url: project.remote_url ?? existing.remote_url,
|
|
229
|
+
last_active_epoch: now,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const result = this.db
|
|
234
|
+
.query(
|
|
235
|
+
`INSERT INTO projects (canonical_id, name, local_path, remote_url, first_seen_epoch, last_active_epoch)
|
|
236
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
237
|
+
)
|
|
238
|
+
.run(
|
|
239
|
+
project.canonical_id,
|
|
240
|
+
project.name,
|
|
241
|
+
project.local_path ?? null,
|
|
242
|
+
project.remote_url ?? null,
|
|
243
|
+
now,
|
|
244
|
+
now
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
return this.db
|
|
248
|
+
.query<ProjectRow, [number]>("SELECT * FROM projects WHERE id = ?")
|
|
249
|
+
.get(Number(result.lastInsertRowid))!;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
getProjectByCanonicalId(canonicalId: string): ProjectRow | null {
|
|
253
|
+
return (
|
|
254
|
+
this.db
|
|
255
|
+
.query<ProjectRow, [string]>(
|
|
256
|
+
"SELECT * FROM projects WHERE canonical_id = ?"
|
|
257
|
+
)
|
|
258
|
+
.get(canonicalId) ?? null
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
getProjectById(id: number): ProjectRow | null {
|
|
263
|
+
return (
|
|
264
|
+
this.db
|
|
265
|
+
.query<ProjectRow, [number]>("SELECT * FROM projects WHERE id = ?")
|
|
266
|
+
.get(id) ?? null
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// --- Observations ---
|
|
271
|
+
|
|
272
|
+
insertObservation(obs: InsertObservation): ObservationRow {
|
|
273
|
+
const now = Math.floor(Date.now() / 1000);
|
|
274
|
+
const createdAt = new Date().toISOString();
|
|
275
|
+
|
|
276
|
+
const result = this.db
|
|
277
|
+
.query(
|
|
278
|
+
`INSERT INTO observations (
|
|
279
|
+
session_id, project_id, type, title, narrative, facts, concepts,
|
|
280
|
+
files_read, files_modified, quality, lifecycle, sensitivity,
|
|
281
|
+
user_id, device_id, agent, created_at, created_at_epoch
|
|
282
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
283
|
+
)
|
|
284
|
+
.run(
|
|
285
|
+
obs.session_id ?? null,
|
|
286
|
+
obs.project_id,
|
|
287
|
+
obs.type,
|
|
288
|
+
obs.title,
|
|
289
|
+
obs.narrative ?? null,
|
|
290
|
+
obs.facts ?? null,
|
|
291
|
+
obs.concepts ?? null,
|
|
292
|
+
obs.files_read ?? null,
|
|
293
|
+
obs.files_modified ?? null,
|
|
294
|
+
obs.quality,
|
|
295
|
+
obs.lifecycle ?? "active",
|
|
296
|
+
obs.sensitivity ?? "shared",
|
|
297
|
+
obs.user_id,
|
|
298
|
+
obs.device_id,
|
|
299
|
+
obs.agent ?? "claude-code",
|
|
300
|
+
createdAt,
|
|
301
|
+
now
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const id = Number(result.lastInsertRowid);
|
|
305
|
+
const row = this.getObservationById(id)!;
|
|
306
|
+
|
|
307
|
+
// Maintain FTS5 index (external content mode — manual sync)
|
|
308
|
+
this.ftsInsert(row);
|
|
309
|
+
|
|
310
|
+
// Increment session observation count if applicable
|
|
311
|
+
if (obs.session_id) {
|
|
312
|
+
this.db
|
|
313
|
+
.query(
|
|
314
|
+
"UPDATE sessions SET observation_count = observation_count + 1 WHERE session_id = ?"
|
|
315
|
+
)
|
|
316
|
+
.run(obs.session_id);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return row;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
getObservationById(id: number): ObservationRow | null {
|
|
323
|
+
return (
|
|
324
|
+
this.db
|
|
325
|
+
.query<ObservationRow, [number]>(
|
|
326
|
+
"SELECT * FROM observations WHERE id = ?"
|
|
327
|
+
)
|
|
328
|
+
.get(id) ?? null
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
getObservationsByIds(ids: number[]): ObservationRow[] {
|
|
333
|
+
if (ids.length === 0) return [];
|
|
334
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
335
|
+
return this.db
|
|
336
|
+
.query<ObservationRow, number[]>(
|
|
337
|
+
`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch DESC`
|
|
338
|
+
)
|
|
339
|
+
.all(...ids);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Get recent observations for a project within a time window.
|
|
344
|
+
* Used for deduplication checks.
|
|
345
|
+
*/
|
|
346
|
+
getRecentObservations(
|
|
347
|
+
projectId: number,
|
|
348
|
+
sincEpoch: number,
|
|
349
|
+
limit: number = 50
|
|
350
|
+
): ObservationRow[] {
|
|
351
|
+
return this.db
|
|
352
|
+
.query<ObservationRow, [number, number, number]>(
|
|
353
|
+
`SELECT * FROM observations
|
|
354
|
+
WHERE project_id = ? AND created_at_epoch > ?
|
|
355
|
+
ORDER BY created_at_epoch DESC
|
|
356
|
+
LIMIT ?`
|
|
357
|
+
)
|
|
358
|
+
.all(projectId, sincEpoch, limit);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* FTS5 search scoped to a project. Returns observation IDs with BM25 rank.
|
|
363
|
+
*/
|
|
364
|
+
searchFts(
|
|
365
|
+
query: string,
|
|
366
|
+
projectId: number | null,
|
|
367
|
+
lifecycles: string[] = ["active", "aging", "pinned"],
|
|
368
|
+
limit: number = 20
|
|
369
|
+
): FtsMatchRow[] {
|
|
370
|
+
const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
|
|
371
|
+
|
|
372
|
+
if (projectId !== null) {
|
|
373
|
+
return this.db
|
|
374
|
+
.query<FtsMatchRow, [string, number, ...string[], number]>(
|
|
375
|
+
`SELECT o.id, observations_fts.rank
|
|
376
|
+
FROM observations_fts
|
|
377
|
+
JOIN observations o ON o.id = observations_fts.rowid
|
|
378
|
+
WHERE observations_fts MATCH ?
|
|
379
|
+
AND o.project_id = ?
|
|
380
|
+
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
381
|
+
ORDER BY observations_fts.rank
|
|
382
|
+
LIMIT ?`
|
|
383
|
+
)
|
|
384
|
+
.all(query, projectId, ...lifecycles, limit);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return this.db
|
|
388
|
+
.query<FtsMatchRow, [string, ...string[], number]>(
|
|
389
|
+
`SELECT o.id, observations_fts.rank
|
|
390
|
+
FROM observations_fts
|
|
391
|
+
JOIN observations o ON o.id = observations_fts.rowid
|
|
392
|
+
WHERE observations_fts MATCH ?
|
|
393
|
+
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
394
|
+
ORDER BY observations_fts.rank
|
|
395
|
+
LIMIT ?`
|
|
396
|
+
)
|
|
397
|
+
.all(query, ...lifecycles, limit);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Get chronological observations around an anchor.
|
|
402
|
+
*/
|
|
403
|
+
getTimeline(
|
|
404
|
+
anchorId: number,
|
|
405
|
+
projectId: number | null,
|
|
406
|
+
depthBefore: number = 3,
|
|
407
|
+
depthAfter: number = 3
|
|
408
|
+
): ObservationRow[] {
|
|
409
|
+
const anchor = this.getObservationById(anchorId);
|
|
410
|
+
if (!anchor) return [];
|
|
411
|
+
|
|
412
|
+
const projectFilter = projectId !== null ? "AND project_id = ?" : "";
|
|
413
|
+
const projectParams = projectId !== null ? [projectId] : [];
|
|
414
|
+
|
|
415
|
+
const before = this.db
|
|
416
|
+
.query<ObservationRow, (number | null)[]>(
|
|
417
|
+
`SELECT * FROM observations
|
|
418
|
+
WHERE created_at_epoch < ? ${projectFilter}
|
|
419
|
+
AND lifecycle IN ('active', 'aging', 'pinned')
|
|
420
|
+
ORDER BY created_at_epoch DESC
|
|
421
|
+
LIMIT ?`
|
|
422
|
+
)
|
|
423
|
+
.all(anchor.created_at_epoch, ...projectParams, depthBefore);
|
|
424
|
+
|
|
425
|
+
const after = this.db
|
|
426
|
+
.query<ObservationRow, (number | null)[]>(
|
|
427
|
+
`SELECT * FROM observations
|
|
428
|
+
WHERE created_at_epoch > ? ${projectFilter}
|
|
429
|
+
AND lifecycle IN ('active', 'aging', 'pinned')
|
|
430
|
+
ORDER BY created_at_epoch ASC
|
|
431
|
+
LIMIT ?`
|
|
432
|
+
)
|
|
433
|
+
.all(anchor.created_at_epoch, ...projectParams, depthAfter);
|
|
434
|
+
|
|
435
|
+
return [...before.reverse(), anchor, ...after];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Pin or unpin an observation.
|
|
440
|
+
*/
|
|
441
|
+
pinObservation(id: number, pinned: boolean): boolean {
|
|
442
|
+
const obs = this.getObservationById(id);
|
|
443
|
+
if (!obs) return false;
|
|
444
|
+
|
|
445
|
+
// Only active or aging observations can be pinned.
|
|
446
|
+
// Pinned observations can be unpinned back to active.
|
|
447
|
+
if (pinned) {
|
|
448
|
+
if (obs.lifecycle !== "active" && obs.lifecycle !== "aging") return false;
|
|
449
|
+
this.db
|
|
450
|
+
.query("UPDATE observations SET lifecycle = 'pinned' WHERE id = ?")
|
|
451
|
+
.run(id);
|
|
452
|
+
} else {
|
|
453
|
+
if (obs.lifecycle !== "pinned") return false;
|
|
454
|
+
this.db
|
|
455
|
+
.query("UPDATE observations SET lifecycle = 'active' WHERE id = ?")
|
|
456
|
+
.run(id);
|
|
457
|
+
}
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Count active + aging observations (for quota checks).
|
|
463
|
+
*/
|
|
464
|
+
getActiveObservationCount(userId?: string): number {
|
|
465
|
+
if (userId) {
|
|
466
|
+
const result = this.db
|
|
467
|
+
.query<{ count: number }, [string]>(
|
|
468
|
+
`SELECT COUNT(*) as count FROM observations
|
|
469
|
+
WHERE lifecycle IN ('active', 'aging')
|
|
470
|
+
AND sensitivity != 'secret'
|
|
471
|
+
AND user_id = ?`
|
|
472
|
+
)
|
|
473
|
+
.get(userId);
|
|
474
|
+
return result?.count ?? 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const result = this.db
|
|
478
|
+
.query<{ count: number }, []>(
|
|
479
|
+
`SELECT COUNT(*) as count FROM observations
|
|
480
|
+
WHERE lifecycle IN ('active', 'aging')
|
|
481
|
+
AND sensitivity != 'secret'`
|
|
482
|
+
)
|
|
483
|
+
.get();
|
|
484
|
+
return result?.count ?? 0;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// --- Supersession ---
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Mark an observation as superseded by a newer one.
|
|
491
|
+
* The old observation is archived and excluded from context/search.
|
|
492
|
+
*
|
|
493
|
+
* Supports chains: if oldId is already superseded, resolves to the
|
|
494
|
+
* current chain head and supersedes that instead. Max depth 10.
|
|
495
|
+
*/
|
|
496
|
+
supersedeObservation(oldId: number, newId: number): boolean {
|
|
497
|
+
// Don't allow self-supersession
|
|
498
|
+
if (oldId === newId) return false;
|
|
499
|
+
|
|
500
|
+
const replacement = this.getObservationById(newId);
|
|
501
|
+
if (!replacement) return false;
|
|
502
|
+
|
|
503
|
+
// Resolve to the current chain head (follow superseded_by links)
|
|
504
|
+
let targetId = oldId;
|
|
505
|
+
const visited = new Set<number>();
|
|
506
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
507
|
+
const target = this.getObservationById(targetId);
|
|
508
|
+
if (!target) return false;
|
|
509
|
+
|
|
510
|
+
// If not superseded, this is the head — supersede it
|
|
511
|
+
if (target.superseded_by === null) break;
|
|
512
|
+
|
|
513
|
+
// If the head is already the replacement, nothing to do
|
|
514
|
+
if (target.superseded_by === newId) return true;
|
|
515
|
+
|
|
516
|
+
// Follow the chain
|
|
517
|
+
visited.add(targetId);
|
|
518
|
+
targetId = target.superseded_by;
|
|
519
|
+
|
|
520
|
+
// Cycle detection
|
|
521
|
+
if (visited.has(targetId)) return false;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const target = this.getObservationById(targetId);
|
|
525
|
+
if (!target) return false;
|
|
526
|
+
if (target.superseded_by !== null) return false; // chain too deep
|
|
527
|
+
if (targetId === newId) return false; // would self-supersede after resolution
|
|
528
|
+
|
|
529
|
+
const now = Math.floor(Date.now() / 1000);
|
|
530
|
+
this.db
|
|
531
|
+
.query(
|
|
532
|
+
`UPDATE observations
|
|
533
|
+
SET superseded_by = ?, lifecycle = 'archived', archived_at_epoch = ?
|
|
534
|
+
WHERE id = ?`
|
|
535
|
+
)
|
|
536
|
+
.run(newId, now, targetId);
|
|
537
|
+
|
|
538
|
+
// Remove from search indexes (archived observations shouldn't appear)
|
|
539
|
+
this.ftsDelete(target);
|
|
540
|
+
this.vecDelete(targetId);
|
|
541
|
+
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Check if an observation has been superseded.
|
|
547
|
+
*/
|
|
548
|
+
isSuperseded(id: number): boolean {
|
|
549
|
+
const obs = this.getObservationById(id);
|
|
550
|
+
return obs !== null && obs.superseded_by !== null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// --- Sessions ---
|
|
554
|
+
|
|
555
|
+
upsertSession(
|
|
556
|
+
sessionId: string,
|
|
557
|
+
projectId: number | null,
|
|
558
|
+
userId: string,
|
|
559
|
+
deviceId: string,
|
|
560
|
+
agent: string = "claude-code"
|
|
561
|
+
): SessionRow {
|
|
562
|
+
const existing = this.db
|
|
563
|
+
.query<SessionRow, [string]>(
|
|
564
|
+
"SELECT * FROM sessions WHERE session_id = ?"
|
|
565
|
+
)
|
|
566
|
+
.get(sessionId);
|
|
567
|
+
|
|
568
|
+
if (existing) return existing;
|
|
569
|
+
|
|
570
|
+
const now = Math.floor(Date.now() / 1000);
|
|
571
|
+
this.db
|
|
572
|
+
.query(
|
|
573
|
+
`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
|
|
574
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
575
|
+
)
|
|
576
|
+
.run(sessionId, projectId, userId, deviceId, agent, now);
|
|
577
|
+
|
|
578
|
+
return this.db
|
|
579
|
+
.query<SessionRow, [string]>(
|
|
580
|
+
"SELECT * FROM sessions WHERE session_id = ?"
|
|
581
|
+
)
|
|
582
|
+
.get(sessionId)!;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
completeSession(sessionId: string): void {
|
|
586
|
+
const now = Math.floor(Date.now() / 1000);
|
|
587
|
+
this.db
|
|
588
|
+
.query(
|
|
589
|
+
"UPDATE sessions SET status = 'completed', completed_at_epoch = ? WHERE session_id = ?"
|
|
590
|
+
)
|
|
591
|
+
.run(now, sessionId);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// --- Sync outbox ---
|
|
595
|
+
|
|
596
|
+
addToOutbox(recordType: "observation" | "summary", recordId: number): void {
|
|
597
|
+
const now = Math.floor(Date.now() / 1000);
|
|
598
|
+
this.db
|
|
599
|
+
.query(
|
|
600
|
+
`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
|
|
601
|
+
VALUES (?, ?, ?)`
|
|
602
|
+
)
|
|
603
|
+
.run(recordType, recordId, now);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// --- Sync state ---
|
|
607
|
+
|
|
608
|
+
getSyncState(key: string): string | null {
|
|
609
|
+
const row = this.db
|
|
610
|
+
.query<{ value: string }, [string]>(
|
|
611
|
+
"SELECT value FROM sync_state WHERE key = ?"
|
|
612
|
+
)
|
|
613
|
+
.get(key);
|
|
614
|
+
return row?.value ?? null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
setSyncState(key: string, value: string): void {
|
|
618
|
+
this.db
|
|
619
|
+
.query(
|
|
620
|
+
"INSERT INTO sync_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?"
|
|
621
|
+
)
|
|
622
|
+
.run(key, value, value);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// --- FTS5 maintenance (external content mode) ---
|
|
626
|
+
|
|
627
|
+
private ftsInsert(obs: ObservationRow): void {
|
|
628
|
+
this.db
|
|
629
|
+
.query(
|
|
630
|
+
`INSERT INTO observations_fts (rowid, title, narrative, facts, concepts)
|
|
631
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
632
|
+
)
|
|
633
|
+
.run(obs.id, obs.title, obs.narrative, obs.facts, obs.concepts);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
ftsDelete(obs: ObservationRow): void {
|
|
637
|
+
this.db
|
|
638
|
+
.query(
|
|
639
|
+
`INSERT INTO observations_fts (observations_fts, rowid, title, narrative, facts, concepts)
|
|
640
|
+
VALUES ('delete', ?, ?, ?, ?, ?)`
|
|
641
|
+
)
|
|
642
|
+
.run(obs.id, obs.title, obs.narrative, obs.facts, obs.concepts);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// --- sqlite-vec (local semantic search) ---
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Insert an embedding for an observation.
|
|
649
|
+
*/
|
|
650
|
+
vecInsert(observationId: number, embedding: Float32Array): void {
|
|
651
|
+
if (!this.vecAvailable) return;
|
|
652
|
+
this.db
|
|
653
|
+
.query(
|
|
654
|
+
"INSERT OR REPLACE INTO vec_observations (observation_id, embedding) VALUES (?, ?)"
|
|
655
|
+
)
|
|
656
|
+
.run(observationId, new Uint8Array(embedding.buffer));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Delete an embedding when observation is superseded/archived.
|
|
661
|
+
*/
|
|
662
|
+
vecDelete(observationId: number): void {
|
|
663
|
+
if (!this.vecAvailable) return;
|
|
664
|
+
this.db
|
|
665
|
+
.query("DELETE FROM vec_observations WHERE observation_id = ?")
|
|
666
|
+
.run(observationId);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* KNN search returning observation IDs with distance.
|
|
671
|
+
* Results filtered by project and lifecycle via JOIN.
|
|
672
|
+
*/
|
|
673
|
+
searchVec(
|
|
674
|
+
queryEmbedding: Float32Array,
|
|
675
|
+
projectId: number | null,
|
|
676
|
+
lifecycles: string[] = ["active", "aging", "pinned"],
|
|
677
|
+
limit: number = 20
|
|
678
|
+
): VecMatchRow[] {
|
|
679
|
+
if (!this.vecAvailable) return [];
|
|
680
|
+
|
|
681
|
+
const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
|
|
682
|
+
const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
|
|
683
|
+
|
|
684
|
+
if (projectId !== null) {
|
|
685
|
+
return this.db
|
|
686
|
+
.query<VecMatchRow, any[]>(
|
|
687
|
+
`SELECT v.observation_id, v.distance
|
|
688
|
+
FROM vec_observations v
|
|
689
|
+
JOIN observations o ON o.id = v.observation_id
|
|
690
|
+
WHERE v.embedding MATCH ?
|
|
691
|
+
AND k = ?
|
|
692
|
+
AND o.project_id = ?
|
|
693
|
+
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
694
|
+
AND o.superseded_by IS NULL`
|
|
695
|
+
)
|
|
696
|
+
.all(embeddingBlob, limit, projectId, ...lifecycles);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return this.db
|
|
700
|
+
.query<VecMatchRow, any[]>(
|
|
701
|
+
`SELECT v.observation_id, v.distance
|
|
702
|
+
FROM vec_observations v
|
|
703
|
+
JOIN observations o ON o.id = v.observation_id
|
|
704
|
+
WHERE v.embedding MATCH ?
|
|
705
|
+
AND k = ?
|
|
706
|
+
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
707
|
+
AND o.superseded_by IS NULL`
|
|
708
|
+
)
|
|
709
|
+
.all(embeddingBlob, limit, ...lifecycles);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Count observations without embeddings (for backfill progress).
|
|
714
|
+
*/
|
|
715
|
+
getUnembeddedCount(): number {
|
|
716
|
+
if (!this.vecAvailable) return 0;
|
|
717
|
+
const result = this.db
|
|
718
|
+
.query<{ count: number }, []>(
|
|
719
|
+
`SELECT COUNT(*) as count FROM observations o
|
|
720
|
+
WHERE o.lifecycle IN ('active', 'aging', 'pinned')
|
|
721
|
+
AND o.superseded_by IS NULL
|
|
722
|
+
AND NOT EXISTS (
|
|
723
|
+
SELECT 1 FROM vec_observations v WHERE v.observation_id = o.id
|
|
724
|
+
)`
|
|
725
|
+
)
|
|
726
|
+
.get();
|
|
727
|
+
return result?.count ?? 0;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Get unembedded observations for backfill.
|
|
732
|
+
*/
|
|
733
|
+
getUnembeddedObservations(limit: number = 100): ObservationRow[] {
|
|
734
|
+
if (!this.vecAvailable) return [];
|
|
735
|
+
return this.db
|
|
736
|
+
.query<ObservationRow, [number]>(
|
|
737
|
+
`SELECT o.* FROM observations o
|
|
738
|
+
WHERE o.lifecycle IN ('active', 'aging', 'pinned')
|
|
739
|
+
AND o.superseded_by IS NULL
|
|
740
|
+
AND NOT EXISTS (
|
|
741
|
+
SELECT 1 FROM vec_observations v WHERE v.observation_id = o.id
|
|
742
|
+
)
|
|
743
|
+
ORDER BY o.created_at_epoch DESC
|
|
744
|
+
LIMIT ?`
|
|
745
|
+
)
|
|
746
|
+
.all(limit);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// --- Session summaries ---
|
|
750
|
+
|
|
751
|
+
insertSessionSummary(summary: InsertSessionSummary): SessionSummaryRow {
|
|
752
|
+
const now = Math.floor(Date.now() / 1000);
|
|
753
|
+
const result = this.db
|
|
754
|
+
.query(
|
|
755
|
+
`INSERT INTO session_summaries (session_id, project_id, user_id, request, investigated, learned, completed, next_steps, created_at_epoch)
|
|
756
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
757
|
+
)
|
|
758
|
+
.run(
|
|
759
|
+
summary.session_id,
|
|
760
|
+
summary.project_id,
|
|
761
|
+
summary.user_id,
|
|
762
|
+
summary.request,
|
|
763
|
+
summary.investigated,
|
|
764
|
+
summary.learned,
|
|
765
|
+
summary.completed,
|
|
766
|
+
summary.next_steps,
|
|
767
|
+
now
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
const id = Number(result.lastInsertRowid);
|
|
771
|
+
return this.db
|
|
772
|
+
.query<SessionSummaryRow, [number]>(
|
|
773
|
+
"SELECT * FROM session_summaries WHERE id = ?"
|
|
774
|
+
)
|
|
775
|
+
.get(id)!;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
getSessionSummary(sessionId: string): SessionSummaryRow | null {
|
|
779
|
+
return (
|
|
780
|
+
this.db
|
|
781
|
+
.query<SessionSummaryRow, [string]>(
|
|
782
|
+
"SELECT * FROM session_summaries WHERE session_id = ?"
|
|
783
|
+
)
|
|
784
|
+
.get(sessionId) ?? null
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
getRecentSummaries(projectId: number, limit: number = 5): SessionSummaryRow[] {
|
|
789
|
+
return this.db
|
|
790
|
+
.query<SessionSummaryRow, [number, number]>(
|
|
791
|
+
`SELECT * FROM session_summaries
|
|
792
|
+
WHERE project_id = ?
|
|
793
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
794
|
+
LIMIT ?`
|
|
795
|
+
)
|
|
796
|
+
.all(projectId, limit);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// --- Session metrics ---
|
|
800
|
+
|
|
801
|
+
incrementSessionMetrics(
|
|
802
|
+
sessionId: string,
|
|
803
|
+
increments: { files?: number; searches?: number; toolCalls?: number }
|
|
804
|
+
): void {
|
|
805
|
+
const sets: string[] = [];
|
|
806
|
+
const params: (number | string)[] = [];
|
|
807
|
+
|
|
808
|
+
if (increments.files) {
|
|
809
|
+
sets.push("files_touched_count = files_touched_count + ?");
|
|
810
|
+
params.push(increments.files);
|
|
811
|
+
}
|
|
812
|
+
if (increments.searches) {
|
|
813
|
+
sets.push("searches_performed = searches_performed + ?");
|
|
814
|
+
params.push(increments.searches);
|
|
815
|
+
}
|
|
816
|
+
if (increments.toolCalls) {
|
|
817
|
+
sets.push("tool_calls_count = tool_calls_count + ?");
|
|
818
|
+
params.push(increments.toolCalls);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (sets.length === 0) return;
|
|
822
|
+
|
|
823
|
+
params.push(sessionId);
|
|
824
|
+
this.db
|
|
825
|
+
.query(`UPDATE sessions SET ${sets.join(", ")} WHERE session_id = ?`)
|
|
826
|
+
.run(...params);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
getSessionMetrics(sessionId: string): SessionRow & {
|
|
830
|
+
files_touched_count: number;
|
|
831
|
+
searches_performed: number;
|
|
832
|
+
tool_calls_count: number;
|
|
833
|
+
} | null {
|
|
834
|
+
return (
|
|
835
|
+
this.db
|
|
836
|
+
.query<
|
|
837
|
+
SessionRow & {
|
|
838
|
+
files_touched_count: number;
|
|
839
|
+
searches_performed: number;
|
|
840
|
+
tool_calls_count: number;
|
|
841
|
+
},
|
|
842
|
+
[string]
|
|
843
|
+
>("SELECT * FROM sessions WHERE session_id = ?")
|
|
844
|
+
.get(sessionId) ?? null
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// --- Security findings ---
|
|
849
|
+
|
|
850
|
+
insertSecurityFinding(finding: InsertSecurityFinding): SecurityFindingRow {
|
|
851
|
+
const now = Math.floor(Date.now() / 1000);
|
|
852
|
+
const result = this.db
|
|
853
|
+
.query(
|
|
854
|
+
`INSERT INTO security_findings (session_id, project_id, finding_type, severity, pattern_name, file_path, snippet, tool_name, user_id, device_id, created_at_epoch)
|
|
855
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
856
|
+
)
|
|
857
|
+
.run(
|
|
858
|
+
finding.session_id ?? null,
|
|
859
|
+
finding.project_id,
|
|
860
|
+
finding.finding_type,
|
|
861
|
+
finding.severity,
|
|
862
|
+
finding.pattern_name,
|
|
863
|
+
finding.file_path ?? null,
|
|
864
|
+
finding.snippet ?? null,
|
|
865
|
+
finding.tool_name ?? null,
|
|
866
|
+
finding.user_id,
|
|
867
|
+
finding.device_id,
|
|
868
|
+
now
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
const id = Number(result.lastInsertRowid);
|
|
872
|
+
return this.db
|
|
873
|
+
.query<SecurityFindingRow, [number]>(
|
|
874
|
+
"SELECT * FROM security_findings WHERE id = ?"
|
|
875
|
+
)
|
|
876
|
+
.get(id)!;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
getSecurityFindings(
|
|
880
|
+
projectId: number,
|
|
881
|
+
options: { severity?: string; limit?: number } = {}
|
|
882
|
+
): SecurityFindingRow[] {
|
|
883
|
+
const limit = options.limit ?? 50;
|
|
884
|
+
if (options.severity) {
|
|
885
|
+
return this.db
|
|
886
|
+
.query<SecurityFindingRow, [number, string, number]>(
|
|
887
|
+
`SELECT * FROM security_findings
|
|
888
|
+
WHERE project_id = ? AND severity = ?
|
|
889
|
+
ORDER BY created_at_epoch DESC
|
|
890
|
+
LIMIT ?`
|
|
891
|
+
)
|
|
892
|
+
.all(projectId, options.severity, limit);
|
|
893
|
+
}
|
|
894
|
+
return this.db
|
|
895
|
+
.query<SecurityFindingRow, [number, number]>(
|
|
896
|
+
`SELECT * FROM security_findings
|
|
897
|
+
WHERE project_id = ?
|
|
898
|
+
ORDER BY created_at_epoch DESC
|
|
899
|
+
LIMIT ?`
|
|
900
|
+
)
|
|
901
|
+
.all(projectId, limit);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
getSecurityFindingsCount(projectId: number): Record<string, number> {
|
|
905
|
+
const rows = this.db
|
|
906
|
+
.query<{ severity: string; count: number }, [number]>(
|
|
907
|
+
`SELECT severity, COUNT(*) as count FROM security_findings
|
|
908
|
+
WHERE project_id = ?
|
|
909
|
+
GROUP BY severity`
|
|
910
|
+
)
|
|
911
|
+
.all(projectId);
|
|
912
|
+
|
|
913
|
+
const counts: Record<string, number> = {
|
|
914
|
+
critical: 0,
|
|
915
|
+
high: 0,
|
|
916
|
+
medium: 0,
|
|
917
|
+
low: 0,
|
|
918
|
+
};
|
|
919
|
+
for (const row of rows) {
|
|
920
|
+
counts[row.severity] = row.count;
|
|
921
|
+
}
|
|
922
|
+
return counts;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// --- Observations by session ---
|
|
926
|
+
|
|
927
|
+
getObservationsBySession(sessionId: string): ObservationRow[] {
|
|
928
|
+
return this.db
|
|
929
|
+
.query<ObservationRow, [string]>(
|
|
930
|
+
`SELECT * FROM observations WHERE session_id = ? ORDER BY created_at_epoch ASC`
|
|
931
|
+
)
|
|
932
|
+
.all(sessionId);
|
|
933
|
+
}
|
|
934
|
+
}
|