opencodekit 0.17.13 → 0.18.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/dist/index.js +4 -6
- package/dist/template/.opencode/dcp.jsonc +81 -81
- package/dist/template/.opencode/memory/memory.db +0 -0
- package/dist/template/.opencode/memory.db +0 -0
- package/dist/template/.opencode/memory.db-shm +0 -0
- package/dist/template/.opencode/memory.db-wal +0 -0
- package/dist/template/.opencode/opencode.json +199 -23
- package/dist/template/.opencode/opencode.json.tui-migration.bak +1380 -0
- package/dist/template/.opencode/package.json +1 -1
- package/dist/template/.opencode/plugin/lib/capture.ts +177 -0
- package/dist/template/.opencode/plugin/lib/context.ts +194 -0
- package/dist/template/.opencode/plugin/lib/curator.ts +234 -0
- package/dist/template/.opencode/plugin/lib/db/maintenance.ts +312 -0
- package/dist/template/.opencode/plugin/lib/db/observations.ts +299 -0
- package/dist/template/.opencode/plugin/lib/db/pipeline.ts +520 -0
- package/dist/template/.opencode/plugin/lib/db/schema.ts +356 -0
- package/dist/template/.opencode/plugin/lib/db/types.ts +211 -0
- package/dist/template/.opencode/plugin/lib/distill.ts +376 -0
- package/dist/template/.opencode/plugin/lib/inject.ts +126 -0
- package/dist/template/.opencode/plugin/lib/memory-admin-tools.ts +188 -0
- package/dist/template/.opencode/plugin/lib/memory-db.ts +54 -936
- package/dist/template/.opencode/plugin/lib/memory-helpers.ts +202 -0
- package/dist/template/.opencode/plugin/lib/memory-hooks.ts +240 -0
- package/dist/template/.opencode/plugin/lib/memory-tools.ts +341 -0
- package/dist/template/.opencode/plugin/memory.ts +56 -60
- package/dist/template/.opencode/plugin/sessions.ts +372 -93
- package/dist/template/.opencode/tui.json +15 -0
- package/package.json +1 -1
- package/dist/template/.opencode/tool/action-queue.ts +0 -313
- package/dist/template/.opencode/tool/memory-admin.ts +0 -445
- package/dist/template/.opencode/tool/memory-get.ts +0 -143
- package/dist/template/.opencode/tool/memory-read.ts +0 -45
- package/dist/template/.opencode/tool/memory-search.ts +0 -264
- package/dist/template/.opencode/tool/memory-timeline.ts +0 -105
- package/dist/template/.opencode/tool/memory-update.ts +0 -63
- package/dist/template/.opencode/tool/observation.ts +0 -357
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Maintenance & Auxiliary Operations
|
|
3
|
+
*
|
|
4
|
+
* Memory files, FTS5 maintenance, archiving, and full maintenance cycle.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { purgeOldTemporalMessages } from "./pipeline.js";
|
|
8
|
+
import { getMemoryDB } from "./schema.js";
|
|
9
|
+
import type {
|
|
10
|
+
ArchiveOptions,
|
|
11
|
+
MaintenanceStats,
|
|
12
|
+
MemoryFileRow,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Memory File Operations
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Store or update a memory file.
|
|
21
|
+
*/
|
|
22
|
+
export function upsertMemoryFile(
|
|
23
|
+
filePath: string,
|
|
24
|
+
content: string,
|
|
25
|
+
mode: "replace" | "append" = "replace",
|
|
26
|
+
): void {
|
|
27
|
+
const db = getMemoryDB();
|
|
28
|
+
const now = new Date();
|
|
29
|
+
|
|
30
|
+
db.run(
|
|
31
|
+
`
|
|
32
|
+
INSERT INTO memory_files (file_path, content, mode, created_at, created_at_epoch)
|
|
33
|
+
VALUES (?, ?, ?, ?, ?)
|
|
34
|
+
ON CONFLICT(file_path) DO UPDATE SET
|
|
35
|
+
content = CASE WHEN excluded.mode = 'append' THEN memory_files.content || '\n\n' || excluded.content ELSE excluded.content END,
|
|
36
|
+
mode = excluded.mode,
|
|
37
|
+
updated_at = ?,
|
|
38
|
+
updated_at_epoch = ?
|
|
39
|
+
`,
|
|
40
|
+
[
|
|
41
|
+
filePath,
|
|
42
|
+
content,
|
|
43
|
+
mode,
|
|
44
|
+
now.toISOString(),
|
|
45
|
+
now.getTime(),
|
|
46
|
+
now.toISOString(),
|
|
47
|
+
now.getTime(),
|
|
48
|
+
],
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get a memory file by path.
|
|
54
|
+
*/
|
|
55
|
+
export function getMemoryFile(filePath: string): MemoryFileRow | null {
|
|
56
|
+
const db = getMemoryDB();
|
|
57
|
+
return db
|
|
58
|
+
.query("SELECT * FROM memory_files WHERE file_path = ?")
|
|
59
|
+
.get(filePath) as MemoryFileRow | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// FTS5 Maintenance
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Optimize FTS5 indexes (run periodically).
|
|
68
|
+
*/
|
|
69
|
+
export function optimizeFTS5(): void {
|
|
70
|
+
const db = getMemoryDB();
|
|
71
|
+
db.run("INSERT INTO observations_fts(observations_fts) VALUES('optimize')");
|
|
72
|
+
try {
|
|
73
|
+
db.run(
|
|
74
|
+
"INSERT INTO distillations_fts(distillations_fts) VALUES('optimize')",
|
|
75
|
+
);
|
|
76
|
+
} catch {
|
|
77
|
+
// distillations_fts may not exist yet
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Rebuild FTS5 indexes from scratch.
|
|
83
|
+
*/
|
|
84
|
+
export function rebuildFTS5(): void {
|
|
85
|
+
const db = getMemoryDB();
|
|
86
|
+
db.run("INSERT INTO observations_fts(observations_fts) VALUES('rebuild')");
|
|
87
|
+
try {
|
|
88
|
+
db.run(
|
|
89
|
+
"INSERT INTO distillations_fts(distillations_fts) VALUES('rebuild')",
|
|
90
|
+
);
|
|
91
|
+
} catch {
|
|
92
|
+
// distillations_fts may not exist yet
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if FTS5 is available and working.
|
|
98
|
+
*/
|
|
99
|
+
export function checkFTS5Available(): boolean {
|
|
100
|
+
try {
|
|
101
|
+
const db = getMemoryDB();
|
|
102
|
+
db.query("SELECT * FROM observations_fts LIMIT 1").get();
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ============================================================================
|
|
110
|
+
// Database Maintenance
|
|
111
|
+
// ============================================================================
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Archive old observations to a separate table.
|
|
115
|
+
*/
|
|
116
|
+
export function archiveOldObservations(options: ArchiveOptions = {}): number {
|
|
117
|
+
const db = getMemoryDB();
|
|
118
|
+
const olderThanDays = options.olderThanDays ?? 90;
|
|
119
|
+
const includeSuperseded = options.includeSuperseded ?? true;
|
|
120
|
+
const dryRun = options.dryRun ?? false;
|
|
121
|
+
|
|
122
|
+
const cutoffEpoch = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
|
|
123
|
+
|
|
124
|
+
// Create archive table if not exists
|
|
125
|
+
db.run(`
|
|
126
|
+
CREATE TABLE IF NOT EXISTS observations_archive (
|
|
127
|
+
id INTEGER PRIMARY KEY,
|
|
128
|
+
type TEXT NOT NULL,
|
|
129
|
+
title TEXT NOT NULL,
|
|
130
|
+
subtitle TEXT,
|
|
131
|
+
facts TEXT,
|
|
132
|
+
narrative TEXT,
|
|
133
|
+
concepts TEXT,
|
|
134
|
+
files_read TEXT,
|
|
135
|
+
files_modified TEXT,
|
|
136
|
+
confidence TEXT,
|
|
137
|
+
bead_id TEXT,
|
|
138
|
+
supersedes INTEGER,
|
|
139
|
+
superseded_by INTEGER,
|
|
140
|
+
valid_until TEXT,
|
|
141
|
+
markdown_file TEXT,
|
|
142
|
+
source TEXT DEFAULT 'manual',
|
|
143
|
+
created_at TEXT NOT NULL,
|
|
144
|
+
created_at_epoch INTEGER NOT NULL,
|
|
145
|
+
updated_at TEXT,
|
|
146
|
+
archived_at TEXT NOT NULL
|
|
147
|
+
)
|
|
148
|
+
`);
|
|
149
|
+
|
|
150
|
+
// Build WHERE clause
|
|
151
|
+
let whereClause = `created_at_epoch < ${cutoffEpoch}`;
|
|
152
|
+
if (includeSuperseded) {
|
|
153
|
+
whereClause = `(${whereClause} OR superseded_by IS NOT NULL)`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Count candidates
|
|
157
|
+
const countResult = db
|
|
158
|
+
.query(`SELECT COUNT(*) as count FROM observations WHERE ${whereClause}`)
|
|
159
|
+
.get() as { count: number };
|
|
160
|
+
|
|
161
|
+
if (dryRun || countResult.count === 0) {
|
|
162
|
+
return countResult.count;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Move to archive
|
|
166
|
+
const now = new Date().toISOString();
|
|
167
|
+
db.run(`
|
|
168
|
+
INSERT INTO observations_archive
|
|
169
|
+
SELECT *, '${now}' as archived_at FROM observations WHERE ${whereClause}
|
|
170
|
+
`);
|
|
171
|
+
|
|
172
|
+
// Delete from main table (triggers will remove from FTS)
|
|
173
|
+
db.run(`DELETE FROM observations WHERE ${whereClause}`);
|
|
174
|
+
|
|
175
|
+
return countResult.count;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Checkpoint WAL file back to main database.
|
|
180
|
+
*/
|
|
181
|
+
export function checkpointWAL(): { walSize: number; checkpointed: boolean } {
|
|
182
|
+
const db = getMemoryDB();
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const result = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get() as {
|
|
186
|
+
busy: number;
|
|
187
|
+
log: number;
|
|
188
|
+
checkpointed: number;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
walSize: result.log,
|
|
193
|
+
checkpointed: result.busy === 0,
|
|
194
|
+
};
|
|
195
|
+
} catch {
|
|
196
|
+
return { walSize: 0, checkpointed: false };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Vacuum database to reclaim space and defragment.
|
|
202
|
+
*/
|
|
203
|
+
export function vacuumDatabase(): boolean {
|
|
204
|
+
const db = getMemoryDB();
|
|
205
|
+
try {
|
|
206
|
+
db.run("VACUUM");
|
|
207
|
+
return true;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get database file sizes.
|
|
215
|
+
*/
|
|
216
|
+
export function getDatabaseSizes(): {
|
|
217
|
+
mainDb: number;
|
|
218
|
+
wal: number;
|
|
219
|
+
shm: number;
|
|
220
|
+
total: number;
|
|
221
|
+
} {
|
|
222
|
+
const db = getMemoryDB();
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const pageCount = db.query("PRAGMA page_count").get() as {
|
|
226
|
+
page_count: number;
|
|
227
|
+
};
|
|
228
|
+
const pageSize = db.query("PRAGMA page_size").get() as {
|
|
229
|
+
page_size: number;
|
|
230
|
+
};
|
|
231
|
+
const mainDb = pageCount.page_count * pageSize.page_size;
|
|
232
|
+
|
|
233
|
+
const walResult = db.query("PRAGMA wal_checkpoint").get() as {
|
|
234
|
+
busy: number;
|
|
235
|
+
log: number;
|
|
236
|
+
checkpointed: number;
|
|
237
|
+
};
|
|
238
|
+
const wal = walResult.log * pageSize.page_size;
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
mainDb,
|
|
242
|
+
wal,
|
|
243
|
+
shm: 32768,
|
|
244
|
+
total: mainDb + wal + 32768,
|
|
245
|
+
};
|
|
246
|
+
} catch {
|
|
247
|
+
return { mainDb: 0, wal: 0, shm: 0, total: 0 };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Get list of markdown files that exist in SQLite (for pruning).
|
|
253
|
+
*/
|
|
254
|
+
export function getMarkdownFilesInSqlite(): string[] {
|
|
255
|
+
const db = getMemoryDB();
|
|
256
|
+
const rows = db
|
|
257
|
+
.query(
|
|
258
|
+
"SELECT markdown_file FROM observations WHERE markdown_file IS NOT NULL",
|
|
259
|
+
)
|
|
260
|
+
.all() as { markdown_file: string }[];
|
|
261
|
+
|
|
262
|
+
return rows.map((r) => r.markdown_file);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Run full maintenance cycle.
|
|
267
|
+
*/
|
|
268
|
+
export function runFullMaintenance(
|
|
269
|
+
options: ArchiveOptions = {},
|
|
270
|
+
): MaintenanceStats {
|
|
271
|
+
const sizesBefore = getDatabaseSizes();
|
|
272
|
+
|
|
273
|
+
// 1. Archive old observations
|
|
274
|
+
const archived = archiveOldObservations(options);
|
|
275
|
+
|
|
276
|
+
// 2. Purge old temporal messages
|
|
277
|
+
let purgedMessages = 0;
|
|
278
|
+
if (!options.dryRun) {
|
|
279
|
+
purgedMessages = purgeOldTemporalMessages();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 3. Optimize FTS5
|
|
283
|
+
if (!options.dryRun) {
|
|
284
|
+
optimizeFTS5();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 4. Checkpoint WAL
|
|
288
|
+
let checkpointed = false;
|
|
289
|
+
if (!options.dryRun) {
|
|
290
|
+
const walResult = checkpointWAL();
|
|
291
|
+
checkpointed = walResult.checkpointed;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 5. Vacuum
|
|
295
|
+
let vacuumed = false;
|
|
296
|
+
if (!options.dryRun) {
|
|
297
|
+
vacuumed = vacuumDatabase();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const sizesAfter = getDatabaseSizes();
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
archived,
|
|
304
|
+
vacuumed,
|
|
305
|
+
checkpointed,
|
|
306
|
+
prunedMarkdown: 0,
|
|
307
|
+
purgedMessages,
|
|
308
|
+
freedBytes: sizesBefore.total - sizesAfter.total,
|
|
309
|
+
dbSizeBefore: sizesBefore.total,
|
|
310
|
+
dbSizeAfter: sizesAfter.total,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observation Operations
|
|
3
|
+
*
|
|
4
|
+
* CRUD, search, timeline, and stats for the observations table.
|
|
5
|
+
* Uses FTS5 with porter stemming for full-text search with BM25 ranking.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getMemoryDB } from "./schema.js";
|
|
9
|
+
import type {
|
|
10
|
+
ObservationInput,
|
|
11
|
+
ObservationRow,
|
|
12
|
+
ObservationType,
|
|
13
|
+
SearchIndexResult,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// CRUD
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Store a new observation in the database.
|
|
22
|
+
*/
|
|
23
|
+
export function storeObservation(input: ObservationInput): number {
|
|
24
|
+
const db = getMemoryDB();
|
|
25
|
+
const now = new Date();
|
|
26
|
+
|
|
27
|
+
const result = db
|
|
28
|
+
.query(
|
|
29
|
+
`
|
|
30
|
+
INSERT INTO observations (
|
|
31
|
+
type, title, subtitle, facts, narrative, concepts,
|
|
32
|
+
files_read, files_modified, confidence, bead_id,
|
|
33
|
+
supersedes, markdown_file, source, created_at, created_at_epoch
|
|
34
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
35
|
+
`,
|
|
36
|
+
)
|
|
37
|
+
.run(
|
|
38
|
+
input.type,
|
|
39
|
+
input.title,
|
|
40
|
+
input.subtitle ?? null,
|
|
41
|
+
input.facts ? JSON.stringify(input.facts) : null,
|
|
42
|
+
input.narrative ?? null,
|
|
43
|
+
input.concepts ? JSON.stringify(input.concepts) : null,
|
|
44
|
+
input.files_read ? JSON.stringify(input.files_read) : null,
|
|
45
|
+
input.files_modified ? JSON.stringify(input.files_modified) : null,
|
|
46
|
+
input.confidence ?? "high",
|
|
47
|
+
input.bead_id ?? null,
|
|
48
|
+
input.supersedes ?? null,
|
|
49
|
+
input.markdown_file ?? null,
|
|
50
|
+
input.source ?? "manual",
|
|
51
|
+
now.toISOString(),
|
|
52
|
+
now.getTime(),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const insertedId = Number(result.lastInsertRowid);
|
|
56
|
+
|
|
57
|
+
// Update supersedes relationship
|
|
58
|
+
if (input.supersedes) {
|
|
59
|
+
db.run("UPDATE observations SET superseded_by = ? WHERE id = ?", [
|
|
60
|
+
insertedId,
|
|
61
|
+
input.supersedes,
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return insertedId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get observation by ID.
|
|
70
|
+
*/
|
|
71
|
+
export function getObservationById(id: number): ObservationRow | null {
|
|
72
|
+
const db = getMemoryDB();
|
|
73
|
+
return db
|
|
74
|
+
.query("SELECT * FROM observations WHERE id = ?")
|
|
75
|
+
.get(id) as ObservationRow | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get multiple observations by IDs.
|
|
80
|
+
*/
|
|
81
|
+
export function getObservationsByIds(ids: number[]): ObservationRow[] {
|
|
82
|
+
if (ids.length === 0) return [];
|
|
83
|
+
|
|
84
|
+
const db = getMemoryDB();
|
|
85
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
86
|
+
return db
|
|
87
|
+
.query(`SELECT * FROM observations WHERE id IN (${placeholders})`)
|
|
88
|
+
.all(...ids) as ObservationRow[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============================================================================
|
|
92
|
+
// Search
|
|
93
|
+
// ============================================================================
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Search observations using FTS5.
|
|
97
|
+
* Returns compact index results for progressive disclosure.
|
|
98
|
+
*/
|
|
99
|
+
export function searchObservationsFTS(
|
|
100
|
+
query: string,
|
|
101
|
+
options: {
|
|
102
|
+
type?: ObservationType;
|
|
103
|
+
concepts?: string[];
|
|
104
|
+
limit?: number;
|
|
105
|
+
} = {},
|
|
106
|
+
): SearchIndexResult[] {
|
|
107
|
+
const db = getMemoryDB();
|
|
108
|
+
const limit = options.limit ?? 10;
|
|
109
|
+
|
|
110
|
+
// Build FTS5 query — porter stemming handles word forms automatically
|
|
111
|
+
const ftsQuery = query
|
|
112
|
+
.replace(/['"]/g, '""')
|
|
113
|
+
.split(/\s+/)
|
|
114
|
+
.filter((term) => term.length > 0)
|
|
115
|
+
.map((term) => `"${term}"*`)
|
|
116
|
+
.join(" OR ");
|
|
117
|
+
|
|
118
|
+
if (!ftsQuery) {
|
|
119
|
+
// Empty query — return recent observations
|
|
120
|
+
return db
|
|
121
|
+
.query(
|
|
122
|
+
`
|
|
123
|
+
SELECT id, type, title,
|
|
124
|
+
substr(COALESCE(narrative, ''), 1, 100) as snippet,
|
|
125
|
+
created_at,
|
|
126
|
+
0 as relevance_score
|
|
127
|
+
FROM observations
|
|
128
|
+
WHERE superseded_by IS NULL
|
|
129
|
+
${options.type ? "AND type = ?" : ""}
|
|
130
|
+
ORDER BY created_at_epoch DESC
|
|
131
|
+
LIMIT ?
|
|
132
|
+
`,
|
|
133
|
+
)
|
|
134
|
+
.all(
|
|
135
|
+
...(options.type ? [options.type, limit] : [limit]),
|
|
136
|
+
) as SearchIndexResult[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
// Use FTS5 with BM25 ranking
|
|
141
|
+
let sql = `
|
|
142
|
+
SELECT o.id, o.type, o.title,
|
|
143
|
+
substr(COALESCE(o.narrative, ''), 1, 100) as snippet,
|
|
144
|
+
o.created_at,
|
|
145
|
+
bm25(observations_fts) as relevance_score
|
|
146
|
+
FROM observations o
|
|
147
|
+
JOIN observations_fts fts ON fts.rowid = o.id
|
|
148
|
+
WHERE observations_fts MATCH ?
|
|
149
|
+
AND o.superseded_by IS NULL
|
|
150
|
+
`;
|
|
151
|
+
|
|
152
|
+
const params: (string | number)[] = [ftsQuery];
|
|
153
|
+
|
|
154
|
+
if (options.type) {
|
|
155
|
+
sql += " AND o.type = ?";
|
|
156
|
+
params.push(options.type);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
sql += " ORDER BY relevance_score LIMIT ?";
|
|
160
|
+
params.push(limit);
|
|
161
|
+
|
|
162
|
+
return db.query(sql).all(...params) as SearchIndexResult[];
|
|
163
|
+
} catch {
|
|
164
|
+
// FTS5 query failed, fallback to LIKE search
|
|
165
|
+
return fallbackLikeSearch(db, query, options.type, limit);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Fallback search using LIKE (for when FTS5 fails).
|
|
171
|
+
*/
|
|
172
|
+
function fallbackLikeSearch(
|
|
173
|
+
db: ReturnType<typeof getMemoryDB>,
|
|
174
|
+
query: string,
|
|
175
|
+
type: ObservationType | undefined,
|
|
176
|
+
limit: number,
|
|
177
|
+
): SearchIndexResult[] {
|
|
178
|
+
const likePattern = `%${query}%`;
|
|
179
|
+
|
|
180
|
+
let sql = `
|
|
181
|
+
SELECT id, type, title,
|
|
182
|
+
substr(COALESCE(narrative, ''), 1, 100) as snippet,
|
|
183
|
+
created_at,
|
|
184
|
+
0 as relevance_score
|
|
185
|
+
FROM observations
|
|
186
|
+
WHERE superseded_by IS NULL
|
|
187
|
+
AND (title LIKE ? OR narrative LIKE ? OR concepts LIKE ?)
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
const params: (string | number)[] = [likePattern, likePattern, likePattern];
|
|
191
|
+
|
|
192
|
+
if (type) {
|
|
193
|
+
sql += " AND type = ?";
|
|
194
|
+
params.push(type);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
sql += " ORDER BY created_at_epoch DESC LIMIT ?";
|
|
198
|
+
params.push(limit);
|
|
199
|
+
|
|
200
|
+
return db.query(sql).all(...params) as SearchIndexResult[];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ============================================================================
|
|
204
|
+
// Timeline & Stats
|
|
205
|
+
// ============================================================================
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get timeline around an anchor observation.
|
|
209
|
+
*/
|
|
210
|
+
export function getTimelineAroundObservation(
|
|
211
|
+
anchorId: number,
|
|
212
|
+
depthBefore = 5,
|
|
213
|
+
depthAfter = 5,
|
|
214
|
+
): {
|
|
215
|
+
anchor: ObservationRow | null;
|
|
216
|
+
before: SearchIndexResult[];
|
|
217
|
+
after: SearchIndexResult[];
|
|
218
|
+
} {
|
|
219
|
+
const db = getMemoryDB();
|
|
220
|
+
|
|
221
|
+
const anchor = getObservationById(anchorId);
|
|
222
|
+
if (!anchor) {
|
|
223
|
+
return { anchor: null, before: [], after: [] };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const before = db
|
|
227
|
+
.query(
|
|
228
|
+
`
|
|
229
|
+
SELECT id, type, title,
|
|
230
|
+
substr(COALESCE(narrative, ''), 1, 100) as snippet,
|
|
231
|
+
created_at,
|
|
232
|
+
0 as relevance_score
|
|
233
|
+
FROM observations
|
|
234
|
+
WHERE created_at_epoch < ?
|
|
235
|
+
AND superseded_by IS NULL
|
|
236
|
+
ORDER BY created_at_epoch DESC
|
|
237
|
+
LIMIT ?
|
|
238
|
+
`,
|
|
239
|
+
)
|
|
240
|
+
.all(anchor.created_at_epoch, depthBefore) as SearchIndexResult[];
|
|
241
|
+
|
|
242
|
+
const after = db
|
|
243
|
+
.query(
|
|
244
|
+
`
|
|
245
|
+
SELECT id, type, title,
|
|
246
|
+
substr(COALESCE(narrative, ''), 1, 100) as snippet,
|
|
247
|
+
created_at,
|
|
248
|
+
0 as relevance_score
|
|
249
|
+
FROM observations
|
|
250
|
+
WHERE created_at_epoch > ?
|
|
251
|
+
AND superseded_by IS NULL
|
|
252
|
+
ORDER BY created_at_epoch ASC
|
|
253
|
+
LIMIT ?
|
|
254
|
+
`,
|
|
255
|
+
)
|
|
256
|
+
.all(anchor.created_at_epoch, depthAfter) as SearchIndexResult[];
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
anchor,
|
|
260
|
+
before: before.reverse(),
|
|
261
|
+
after,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Get most recent observation.
|
|
267
|
+
*/
|
|
268
|
+
export function getMostRecentObservation(): ObservationRow | null {
|
|
269
|
+
const db = getMemoryDB();
|
|
270
|
+
return db
|
|
271
|
+
.query(
|
|
272
|
+
"SELECT * FROM observations WHERE superseded_by IS NULL ORDER BY created_at_epoch DESC LIMIT 1",
|
|
273
|
+
)
|
|
274
|
+
.get() as ObservationRow | null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Get observation count by type.
|
|
279
|
+
*/
|
|
280
|
+
export function getObservationStats(): Record<string, number> {
|
|
281
|
+
const db = getMemoryDB();
|
|
282
|
+
const rows = db
|
|
283
|
+
.query(
|
|
284
|
+
`
|
|
285
|
+
SELECT type, COUNT(*) as count
|
|
286
|
+
FROM observations
|
|
287
|
+
WHERE superseded_by IS NULL
|
|
288
|
+
GROUP BY type
|
|
289
|
+
`,
|
|
290
|
+
)
|
|
291
|
+
.all() as { type: string; count: number }[];
|
|
292
|
+
|
|
293
|
+
const stats: Record<string, number> = { total: 0 };
|
|
294
|
+
for (const row of rows) {
|
|
295
|
+
stats[row.type] = row.count;
|
|
296
|
+
stats.total += row.count;
|
|
297
|
+
}
|
|
298
|
+
return stats;
|
|
299
|
+
}
|