@hyperlynq/synaptic 0.7.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/LICENSE +19 -0
- package/README.md +427 -0
- package/build/scripts/rebuild-index.d.ts +5 -0
- package/build/scripts/rebuild-index.js +33 -0
- package/build/src/cli/init.d.ts +13 -0
- package/build/src/cli/init.js +222 -0
- package/build/src/cli/init.js.map +1 -0
- package/build/src/cli/pre-commit.d.ts +6 -0
- package/build/src/cli/pre-commit.js +159 -0
- package/build/src/cli/pre-commit.js.map +1 -0
- package/build/src/cli.d.ts +2 -0
- package/build/src/cli.js +36 -0
- package/build/src/cli.js.map +1 -0
- package/build/src/hooks/pre-compact.d.ts +6 -0
- package/build/src/hooks/pre-compact.js +64 -0
- package/build/src/hooks/pre-compact.js.map +1 -0
- package/build/src/hooks/session-start.d.ts +13 -0
- package/build/src/hooks/session-start.js +277 -0
- package/build/src/hooks/session-start.js.map +1 -0
- package/build/src/hooks/stop.d.ts +7 -0
- package/build/src/hooks/stop.js +248 -0
- package/build/src/hooks/stop.js.map +1 -0
- package/build/src/index.d.ts +1 -0
- package/build/src/index.js +8 -0
- package/build/src/index.js.map +1 -0
- package/build/src/server.d.ts +6 -0
- package/build/src/server.js +133 -0
- package/build/src/server.js.map +1 -0
- package/build/src/storage/embedder.d.ts +27 -0
- package/build/src/storage/embedder.js +126 -0
- package/build/src/storage/embedder.js.map +1 -0
- package/build/src/storage/git.d.ts +20 -0
- package/build/src/storage/git.js +98 -0
- package/build/src/storage/git.js.map +1 -0
- package/build/src/storage/maintenance.d.ts +9 -0
- package/build/src/storage/maintenance.js +46 -0
- package/build/src/storage/maintenance.js.map +1 -0
- package/build/src/storage/markdown.d.ts +21 -0
- package/build/src/storage/markdown.js +79 -0
- package/build/src/storage/markdown.js.map +1 -0
- package/build/src/storage/paths.d.ts +6 -0
- package/build/src/storage/paths.js +17 -0
- package/build/src/storage/paths.js.map +1 -0
- package/build/src/storage/project.d.ts +2 -0
- package/build/src/storage/project.js +35 -0
- package/build/src/storage/project.js.map +1 -0
- package/build/src/storage/session.d.ts +1 -0
- package/build/src/storage/session.js +17 -0
- package/build/src/storage/session.js.map +1 -0
- package/build/src/storage/sqlite.d.ts +102 -0
- package/build/src/storage/sqlite.js +830 -0
- package/build/src/storage/sqlite.js.map +1 -0
- package/build/src/storage/watcher.d.ts +22 -0
- package/build/src/storage/watcher.js +126 -0
- package/build/src/storage/watcher.js.map +1 -0
- package/build/src/tools/context-archive.d.ts +11 -0
- package/build/src/tools/context-archive.js +13 -0
- package/build/src/tools/context-archive.js.map +1 -0
- package/build/src/tools/context-chain.d.ts +12 -0
- package/build/src/tools/context-chain.js +26 -0
- package/build/src/tools/context-chain.js.map +1 -0
- package/build/src/tools/context-cochanges.d.ts +20 -0
- package/build/src/tools/context-cochanges.js +25 -0
- package/build/src/tools/context-cochanges.js.map +1 -0
- package/build/src/tools/context-delete-rule.d.ts +11 -0
- package/build/src/tools/context-delete-rule.js +12 -0
- package/build/src/tools/context-delete-rule.js.map +1 -0
- package/build/src/tools/context-dna.d.ts +18 -0
- package/build/src/tools/context-dna.js +197 -0
- package/build/src/tools/context-dna.js.map +1 -0
- package/build/src/tools/context-git-index.d.ts +17 -0
- package/build/src/tools/context-git-index.js +59 -0
- package/build/src/tools/context-git-index.js.map +1 -0
- package/build/src/tools/context-list-rules.d.ts +8 -0
- package/build/src/tools/context-list-rules.js +11 -0
- package/build/src/tools/context-list-rules.js.map +1 -0
- package/build/src/tools/context-list.d.ts +26 -0
- package/build/src/tools/context-list.js +42 -0
- package/build/src/tools/context-list.js.map +1 -0
- package/build/src/tools/context-resolve-pattern.d.ts +11 -0
- package/build/src/tools/context-resolve-pattern.js +9 -0
- package/build/src/tools/context-resolve-pattern.js.map +1 -0
- package/build/src/tools/context-save-rule.d.ts +14 -0
- package/build/src/tools/context-save-rule.js +15 -0
- package/build/src/tools/context-save-rule.js.map +1 -0
- package/build/src/tools/context-save.d.ts +26 -0
- package/build/src/tools/context-save.js +68 -0
- package/build/src/tools/context-save.js.map +1 -0
- package/build/src/tools/context-search.d.ts +31 -0
- package/build/src/tools/context-search.js +99 -0
- package/build/src/tools/context-search.js.map +1 -0
- package/build/src/tools/context-session.d.ts +13 -0
- package/build/src/tools/context-session.js +29 -0
- package/build/src/tools/context-session.js.map +1 -0
- package/build/src/tools/context-status.d.ts +13 -0
- package/build/src/tools/context-status.js +15 -0
- package/build/src/tools/context-status.js.map +1 -0
- package/package.json +57 -0
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import * as sqliteVec from "sqlite-vec";
|
|
3
|
+
import { DB_PATH, ensureDirs } from "./paths.js";
|
|
4
|
+
function cosineSimilarity(a, b) {
|
|
5
|
+
let dot = 0, normA = 0, normB = 0;
|
|
6
|
+
for (let i = 0; i < a.length; i++) {
|
|
7
|
+
dot += a[i] * b[i];
|
|
8
|
+
normA += a[i] * a[i];
|
|
9
|
+
normB += b[i] * b[i];
|
|
10
|
+
}
|
|
11
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
12
|
+
}
|
|
13
|
+
export class ContextIndex {
|
|
14
|
+
db;
|
|
15
|
+
static assignTier(type, explicitTier) {
|
|
16
|
+
if (explicitTier)
|
|
17
|
+
return explicitTier;
|
|
18
|
+
switch (type) {
|
|
19
|
+
case "handoff":
|
|
20
|
+
case "progress":
|
|
21
|
+
return "ephemeral";
|
|
22
|
+
case "reference":
|
|
23
|
+
return "longterm";
|
|
24
|
+
default:
|
|
25
|
+
return "working";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
constructor(dbPath = DB_PATH) {
|
|
29
|
+
ensureDirs();
|
|
30
|
+
this.db = new DatabaseSync(dbPath, { allowExtension: true });
|
|
31
|
+
sqliteVec.load(this.db);
|
|
32
|
+
this.init();
|
|
33
|
+
}
|
|
34
|
+
init() {
|
|
35
|
+
this.db.exec("PRAGMA journal_mode=WAL");
|
|
36
|
+
this.db.exec("PRAGMA busy_timeout=5000");
|
|
37
|
+
this.db.exec(`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
date TEXT NOT NULL,
|
|
41
|
+
time TEXT NOT NULL,
|
|
42
|
+
type TEXT NOT NULL,
|
|
43
|
+
tags TEXT NOT NULL,
|
|
44
|
+
content TEXT NOT NULL,
|
|
45
|
+
source_file TEXT NOT NULL
|
|
46
|
+
)
|
|
47
|
+
`);
|
|
48
|
+
this.db.exec(`
|
|
49
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
|
|
50
|
+
content,
|
|
51
|
+
tags,
|
|
52
|
+
type,
|
|
53
|
+
content_rowid='rowid',
|
|
54
|
+
tokenize='porter unicode61'
|
|
55
|
+
)
|
|
56
|
+
`);
|
|
57
|
+
// Triggers to keep FTS in sync
|
|
58
|
+
this.db.exec(`
|
|
59
|
+
CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
|
|
60
|
+
INSERT INTO entries_fts(rowid, content, tags, type)
|
|
61
|
+
VALUES (new.rowid, new.content, new.tags, new.type);
|
|
62
|
+
END
|
|
63
|
+
`);
|
|
64
|
+
this.db.exec(`
|
|
65
|
+
CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
|
|
66
|
+
INSERT INTO entries_fts(entries_fts, rowid, content, tags, type)
|
|
67
|
+
VALUES ('delete', old.rowid, old.content, old.tags, old.type);
|
|
68
|
+
END
|
|
69
|
+
`);
|
|
70
|
+
this.db.exec(`
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_entries_date ON entries(date)
|
|
72
|
+
`);
|
|
73
|
+
this.db.exec(`
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type)
|
|
75
|
+
`);
|
|
76
|
+
this.db.exec(`
|
|
77
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_entries USING vec0(
|
|
78
|
+
embedding FLOAT[384]
|
|
79
|
+
)
|
|
80
|
+
`);
|
|
81
|
+
this.migrate();
|
|
82
|
+
}
|
|
83
|
+
migrate() {
|
|
84
|
+
// Check if tier column already exists
|
|
85
|
+
const columns = this.db.prepare("PRAGMA table_info(entries)").all();
|
|
86
|
+
const hasTier = columns.some((col) => col.name === "tier");
|
|
87
|
+
if (!hasTier) {
|
|
88
|
+
this.db.exec("ALTER TABLE entries ADD COLUMN tier TEXT DEFAULT 'working'");
|
|
89
|
+
this.db.exec("ALTER TABLE entries ADD COLUMN access_count INTEGER DEFAULT 0");
|
|
90
|
+
this.db.exec("ALTER TABLE entries ADD COLUMN last_accessed TEXT");
|
|
91
|
+
this.db.exec("ALTER TABLE entries ADD COLUMN pinned INTEGER DEFAULT 0");
|
|
92
|
+
this.db.exec("ALTER TABLE entries ADD COLUMN archived INTEGER DEFAULT 0");
|
|
93
|
+
// Backfill tiers based on entry type
|
|
94
|
+
this.db.exec("UPDATE entries SET tier = 'ephemeral' WHERE type IN ('handoff', 'progress')");
|
|
95
|
+
this.db.exec("UPDATE entries SET tier = 'longterm' WHERE type = 'reference'");
|
|
96
|
+
}
|
|
97
|
+
// Create patterns table for Phase 3c
|
|
98
|
+
this.db.exec(`
|
|
99
|
+
CREATE TABLE IF NOT EXISTS patterns (
|
|
100
|
+
id TEXT PRIMARY KEY,
|
|
101
|
+
label TEXT NOT NULL,
|
|
102
|
+
entry_ids TEXT NOT NULL,
|
|
103
|
+
occurrence_count INTEGER NOT NULL DEFAULT 0,
|
|
104
|
+
first_seen TEXT NOT NULL,
|
|
105
|
+
last_seen TEXT NOT NULL,
|
|
106
|
+
resolved INTEGER DEFAULT 0
|
|
107
|
+
)
|
|
108
|
+
`);
|
|
109
|
+
// Create indexes for new columns
|
|
110
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_entries_tier ON entries(tier)");
|
|
111
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_entries_archived ON entries(archived)");
|
|
112
|
+
const hasLabel = columns.some((col) => col.name === "label");
|
|
113
|
+
if (!hasLabel) {
|
|
114
|
+
this.db.exec("ALTER TABLE entries ADD COLUMN label TEXT");
|
|
115
|
+
this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_rule_label ON entries(label) WHERE type = 'rule'");
|
|
116
|
+
}
|
|
117
|
+
// v0.5.0 migration: project, session_id, agent_id columns
|
|
118
|
+
const hasProject = columns.some((col) => col.name === "project");
|
|
119
|
+
if (!hasProject) {
|
|
120
|
+
this.db.exec("ALTER TABLE entries ADD COLUMN project TEXT DEFAULT NULL");
|
|
121
|
+
this.db.exec("ALTER TABLE entries ADD COLUMN session_id TEXT DEFAULT NULL");
|
|
122
|
+
this.db.exec("ALTER TABLE entries ADD COLUMN agent_id TEXT DEFAULT NULL");
|
|
123
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_entries_project ON entries(project)");
|
|
124
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_entries_session ON entries(session_id)");
|
|
125
|
+
}
|
|
126
|
+
// v0.5.0: file_pairs table for co-change tracking
|
|
127
|
+
this.db.exec(`
|
|
128
|
+
CREATE TABLE IF NOT EXISTS file_pairs (
|
|
129
|
+
project TEXT NOT NULL,
|
|
130
|
+
file_a TEXT NOT NULL,
|
|
131
|
+
file_b TEXT NOT NULL,
|
|
132
|
+
co_change_count INTEGER DEFAULT 1,
|
|
133
|
+
last_seen TEXT NOT NULL,
|
|
134
|
+
PRIMARY KEY (project, file_a, file_b)
|
|
135
|
+
)
|
|
136
|
+
`);
|
|
137
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_file_pairs_lookup ON file_pairs(project, file_a)");
|
|
138
|
+
}
|
|
139
|
+
insert(entry) {
|
|
140
|
+
const stmt = this.db.prepare(`
|
|
141
|
+
INSERT OR REPLACE INTO entries (id, date, time, type, tags, content, source_file, tier, access_count, last_accessed, pinned, archived, label, project, session_id, agent_id)
|
|
142
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
143
|
+
`);
|
|
144
|
+
stmt.run(entry.id, entry.date, entry.time, entry.type, entry.tags.join(", "), entry.content, entry.sourceFile, entry.tier ?? "working", entry.accessCount ?? 0, entry.lastAccessed ?? null, entry.pinned ? 1 : 0, entry.archived ? 1 : 0, entry.label ?? null, entry.project ?? null, entry.sessionId ?? null, entry.agentId ?? null);
|
|
145
|
+
const row = this.db.prepare("SELECT last_insert_rowid() as rowid").get();
|
|
146
|
+
return row.rowid;
|
|
147
|
+
}
|
|
148
|
+
insertVec(entryRowid, embedding) {
|
|
149
|
+
const stmt = this.db.prepare(`
|
|
150
|
+
INSERT INTO vec_entries(rowid, embedding)
|
|
151
|
+
VALUES (CAST(? AS INTEGER), ?)
|
|
152
|
+
`);
|
|
153
|
+
stmt.run(entryRowid, new Uint8Array(embedding.buffer));
|
|
154
|
+
}
|
|
155
|
+
search(query, opts = {}) {
|
|
156
|
+
const limit = opts.limit ?? 20;
|
|
157
|
+
const conditions = [];
|
|
158
|
+
const params = [];
|
|
159
|
+
conditions.push("entries_fts MATCH ?");
|
|
160
|
+
params.push(query);
|
|
161
|
+
if (opts.type) {
|
|
162
|
+
conditions.push("e.type = ?");
|
|
163
|
+
params.push(opts.type);
|
|
164
|
+
}
|
|
165
|
+
if (opts.days) {
|
|
166
|
+
conditions.push("e.date >= date('now', '-' || ? || ' days')");
|
|
167
|
+
params.push(opts.days);
|
|
168
|
+
}
|
|
169
|
+
if (!opts.includeArchived) {
|
|
170
|
+
conditions.push("e.archived = 0");
|
|
171
|
+
}
|
|
172
|
+
params.push(limit);
|
|
173
|
+
const sql = `
|
|
174
|
+
SELECT e.id, e.date, e.time, e.type, e.tags, e.content, e.source_file,
|
|
175
|
+
e.tier, e.access_count, e.last_accessed, e.pinned, e.archived,
|
|
176
|
+
e.project, e.session_id, e.agent_id,
|
|
177
|
+
rank
|
|
178
|
+
FROM entries_fts
|
|
179
|
+
JOIN entries e ON entries_fts.rowid = e.rowid
|
|
180
|
+
WHERE ${conditions.join(" AND ")}
|
|
181
|
+
ORDER BY rank
|
|
182
|
+
LIMIT ?
|
|
183
|
+
`;
|
|
184
|
+
const stmt = this.db.prepare(sql);
|
|
185
|
+
const rows = stmt.all(...params);
|
|
186
|
+
return rows.map((row) => ({
|
|
187
|
+
id: row.id,
|
|
188
|
+
date: row.date,
|
|
189
|
+
time: row.time,
|
|
190
|
+
type: row.type,
|
|
191
|
+
tags: row.tags.split(", ").filter(Boolean),
|
|
192
|
+
content: row.content,
|
|
193
|
+
sourceFile: row.source_file,
|
|
194
|
+
tier: row.tier,
|
|
195
|
+
accessCount: row.access_count,
|
|
196
|
+
lastAccessed: row.last_accessed,
|
|
197
|
+
pinned: !!row.pinned,
|
|
198
|
+
archived: !!row.archived,
|
|
199
|
+
project: row.project,
|
|
200
|
+
sessionId: row.session_id,
|
|
201
|
+
agentId: row.agent_id,
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
searchVec(embedding, limit) {
|
|
205
|
+
const stmt = this.db.prepare(`
|
|
206
|
+
SELECT rowid, distance
|
|
207
|
+
FROM vec_entries
|
|
208
|
+
WHERE embedding MATCH ?
|
|
209
|
+
ORDER BY distance
|
|
210
|
+
LIMIT ?
|
|
211
|
+
`);
|
|
212
|
+
const rows = stmt.all(new Uint8Array(embedding.buffer), limit);
|
|
213
|
+
return rows.map((r) => ({
|
|
214
|
+
rowid: r.rowid,
|
|
215
|
+
distance: r.distance,
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
getByRowids(rowids) {
|
|
219
|
+
if (rowids.length === 0)
|
|
220
|
+
return [];
|
|
221
|
+
const placeholders = rowids.map(() => "?").join(", ");
|
|
222
|
+
const stmt = this.db.prepare(`
|
|
223
|
+
SELECT rowid, id, date, time, type, tags, content, source_file, tier, access_count, last_accessed, pinned, archived, project, session_id, agent_id
|
|
224
|
+
FROM entries
|
|
225
|
+
WHERE rowid IN (${placeholders})
|
|
226
|
+
`);
|
|
227
|
+
const rows = stmt.all(...rowids);
|
|
228
|
+
return rows.map((row) => ({
|
|
229
|
+
id: row.id,
|
|
230
|
+
date: row.date,
|
|
231
|
+
time: row.time,
|
|
232
|
+
type: row.type,
|
|
233
|
+
tags: row.tags.split(", ").filter(Boolean),
|
|
234
|
+
content: row.content,
|
|
235
|
+
sourceFile: row.source_file,
|
|
236
|
+
tier: row.tier,
|
|
237
|
+
accessCount: row.access_count,
|
|
238
|
+
lastAccessed: row.last_accessed,
|
|
239
|
+
pinned: !!row.pinned,
|
|
240
|
+
archived: !!row.archived,
|
|
241
|
+
project: row.project,
|
|
242
|
+
sessionId: row.session_id,
|
|
243
|
+
agentId: row.agent_id,
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
list(opts = {}) {
|
|
247
|
+
const conditions = [];
|
|
248
|
+
const params = [];
|
|
249
|
+
if (opts.type) {
|
|
250
|
+
conditions.push("type = ?");
|
|
251
|
+
params.push(opts.type);
|
|
252
|
+
}
|
|
253
|
+
if (opts.days) {
|
|
254
|
+
conditions.push("date >= date('now', '-' || ? || ' days')");
|
|
255
|
+
params.push(opts.days);
|
|
256
|
+
}
|
|
257
|
+
if (!opts.includeArchived) {
|
|
258
|
+
conditions.push("archived = 0");
|
|
259
|
+
}
|
|
260
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
261
|
+
const sql = `
|
|
262
|
+
SELECT id, date, time, type, tags, content, source_file, tier, access_count, last_accessed, pinned, archived, project, session_id, agent_id
|
|
263
|
+
FROM entries
|
|
264
|
+
${where}
|
|
265
|
+
ORDER BY date DESC, time DESC
|
|
266
|
+
`;
|
|
267
|
+
const stmt = this.db.prepare(sql);
|
|
268
|
+
const rows = stmt.all(...params);
|
|
269
|
+
return rows.map((row) => ({
|
|
270
|
+
id: row.id,
|
|
271
|
+
date: row.date,
|
|
272
|
+
time: row.time,
|
|
273
|
+
type: row.type,
|
|
274
|
+
tags: row.tags.split(", ").filter(Boolean),
|
|
275
|
+
content: row.content,
|
|
276
|
+
sourceFile: row.source_file,
|
|
277
|
+
tier: row.tier,
|
|
278
|
+
accessCount: row.access_count,
|
|
279
|
+
lastAccessed: row.last_accessed,
|
|
280
|
+
pinned: !!row.pinned,
|
|
281
|
+
archived: !!row.archived,
|
|
282
|
+
project: row.project,
|
|
283
|
+
sessionId: row.session_id,
|
|
284
|
+
agentId: row.agent_id,
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
287
|
+
status() {
|
|
288
|
+
const countRow = this.db.prepare("SELECT COUNT(*) as count FROM entries").get();
|
|
289
|
+
const total = countRow.count;
|
|
290
|
+
let dateRange = null;
|
|
291
|
+
if (total > 0) {
|
|
292
|
+
const rangeRow = this.db.prepare("SELECT MIN(date) as earliest, MAX(date) as latest FROM entries").get();
|
|
293
|
+
dateRange = {
|
|
294
|
+
earliest: rangeRow.earliest,
|
|
295
|
+
latest: rangeRow.latest,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
let dbSizeBytes = 0;
|
|
299
|
+
try {
|
|
300
|
+
const sizeRow = this.db.prepare("SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()").get();
|
|
301
|
+
dbSizeBytes = sizeRow.size;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Ignore if pragma fails
|
|
305
|
+
}
|
|
306
|
+
// Tier distribution (non-archived only)
|
|
307
|
+
const tierRows = this.db.prepare("SELECT tier, COUNT(*) as count FROM entries WHERE archived = 0 GROUP BY tier").all();
|
|
308
|
+
const tierDistribution = {};
|
|
309
|
+
for (const row of tierRows) {
|
|
310
|
+
tierDistribution[row.tier] = row.count;
|
|
311
|
+
}
|
|
312
|
+
// Archived count
|
|
313
|
+
const archivedRow = this.db.prepare("SELECT COUNT(*) as count FROM entries WHERE archived = 1").get();
|
|
314
|
+
// Active patterns count
|
|
315
|
+
const patternRow = this.db.prepare("SELECT COUNT(*) as count FROM patterns WHERE resolved = 0").get();
|
|
316
|
+
return {
|
|
317
|
+
totalEntries: total,
|
|
318
|
+
dateRange,
|
|
319
|
+
dbSizeBytes,
|
|
320
|
+
tierDistribution,
|
|
321
|
+
archivedCount: archivedRow.count,
|
|
322
|
+
activePatterns: patternRow.count,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
clearAll() {
|
|
326
|
+
this.db.exec("DROP TRIGGER IF EXISTS entries_ai");
|
|
327
|
+
this.db.exec("DROP TRIGGER IF EXISTS entries_ad");
|
|
328
|
+
this.db.exec("DELETE FROM entries_fts");
|
|
329
|
+
this.db.exec("DELETE FROM entries");
|
|
330
|
+
this.db.exec("DELETE FROM vec_entries");
|
|
331
|
+
this.db.exec("DELETE FROM patterns");
|
|
332
|
+
this.db.exec("DELETE FROM file_pairs");
|
|
333
|
+
// Recreate triggers
|
|
334
|
+
this.db.exec(`
|
|
335
|
+
CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN
|
|
336
|
+
INSERT INTO entries_fts(rowid, content, tags, type)
|
|
337
|
+
VALUES (new.rowid, new.content, new.tags, new.type);
|
|
338
|
+
END
|
|
339
|
+
`);
|
|
340
|
+
this.db.exec(`
|
|
341
|
+
CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
|
|
342
|
+
INSERT INTO entries_fts(entries_fts, rowid, content, tags, type)
|
|
343
|
+
VALUES ('delete', old.rowid, old.content, old.tags, old.type);
|
|
344
|
+
END
|
|
345
|
+
`);
|
|
346
|
+
}
|
|
347
|
+
getRowidsByIds(ids) {
|
|
348
|
+
if (ids.length === 0)
|
|
349
|
+
return [];
|
|
350
|
+
return ids.map((id) => {
|
|
351
|
+
const row = this.db.prepare("SELECT rowid FROM entries WHERE id = ?").get(id);
|
|
352
|
+
return row?.rowid ?? -1;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
archiveEntries(ids) {
|
|
356
|
+
if (ids.length === 0)
|
|
357
|
+
return 0;
|
|
358
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
359
|
+
const stmt = this.db.prepare(`UPDATE entries SET archived = 1 WHERE id IN (${placeholders}) AND pinned = 0`);
|
|
360
|
+
const result = stmt.run(...ids);
|
|
361
|
+
return Number(result.changes);
|
|
362
|
+
}
|
|
363
|
+
bumpAccess(ids) {
|
|
364
|
+
if (ids.length === 0)
|
|
365
|
+
return;
|
|
366
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
367
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
368
|
+
this.db.prepare(`UPDATE entries SET access_count = access_count + 1, last_accessed = ? WHERE id IN (${placeholders})`).run(now, ...ids);
|
|
369
|
+
}
|
|
370
|
+
/** Increment access_count and set last_accessed for a given entry ID */
|
|
371
|
+
touchEntry(id) {
|
|
372
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
373
|
+
const result = this.db.prepare("UPDATE entries SET access_count = access_count + 1, last_accessed = ? WHERE id = ? AND archived = 0").run(today, id);
|
|
374
|
+
return result.changes > 0;
|
|
375
|
+
}
|
|
376
|
+
hybridSearch(query, embedding, opts = {}) {
|
|
377
|
+
const limit = opts.limit ?? 20;
|
|
378
|
+
// Fetch more candidates than needed for RRF merging
|
|
379
|
+
const candidateLimit = limit * 3;
|
|
380
|
+
// 1. BM25 search
|
|
381
|
+
const bm25Results = this.search(query, {
|
|
382
|
+
type: opts.type,
|
|
383
|
+
days: opts.days,
|
|
384
|
+
limit: candidateLimit,
|
|
385
|
+
includeArchived: opts.includeArchived,
|
|
386
|
+
});
|
|
387
|
+
// 2. Vector search
|
|
388
|
+
const vecResults = this.searchVec(embedding, candidateLimit);
|
|
389
|
+
// 3. RRF merge
|
|
390
|
+
const K = 60;
|
|
391
|
+
const scores = new Map(); // rowid -> rrf score
|
|
392
|
+
// Get rowids for BM25 results
|
|
393
|
+
const bm25Ids = bm25Results.map((e) => e.id);
|
|
394
|
+
const bm25Rowids = this.getRowidsByIds(bm25Ids);
|
|
395
|
+
bm25Rowids.forEach((rowid, rank) => {
|
|
396
|
+
scores.set(rowid, (scores.get(rowid) ?? 0) + 1 / (K + rank + 1));
|
|
397
|
+
});
|
|
398
|
+
vecResults.forEach(({ rowid }, rank) => {
|
|
399
|
+
scores.set(rowid, (scores.get(rowid) ?? 0) + 1 / (K + rank + 1));
|
|
400
|
+
});
|
|
401
|
+
// 4. Temporal decay
|
|
402
|
+
const allRowids = Array.from(scores.keys());
|
|
403
|
+
const entries = this.getByRowids(allRowids);
|
|
404
|
+
const entryMap = new Map();
|
|
405
|
+
// Build rowid -> entry map
|
|
406
|
+
const rowidLookup = this.getRowidsByIds(entries.map((e) => e.id));
|
|
407
|
+
entries.forEach((entry, i) => {
|
|
408
|
+
entryMap.set(rowidLookup[i], entry);
|
|
409
|
+
});
|
|
410
|
+
const today = new Date();
|
|
411
|
+
const currentProject = opts.project ?? null;
|
|
412
|
+
const projectBoost = (entryProject, curProject) => {
|
|
413
|
+
if (!curProject || !entryProject)
|
|
414
|
+
return 1.0;
|
|
415
|
+
return entryProject === curProject ? 1.5 : 1.0;
|
|
416
|
+
};
|
|
417
|
+
const tierWeight = (tier) => {
|
|
418
|
+
switch (tier) {
|
|
419
|
+
case "longterm": return 1.5;
|
|
420
|
+
case "ephemeral": return 0.5;
|
|
421
|
+
default: return 1.0;
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
const confidenceBoost = (accessCount) => {
|
|
425
|
+
if (accessCount === 0)
|
|
426
|
+
return 0.7;
|
|
427
|
+
if (accessCount <= 2)
|
|
428
|
+
return 1.0;
|
|
429
|
+
if (accessCount <= 5)
|
|
430
|
+
return 1.2;
|
|
431
|
+
return 1.4;
|
|
432
|
+
};
|
|
433
|
+
const scored = allRowids.map((rowid) => {
|
|
434
|
+
const entry = entryMap.get(rowid);
|
|
435
|
+
const entryDate = new Date(entry.date);
|
|
436
|
+
const ageDays = (today.getTime() - entryDate.getTime()) / (1000 * 60 * 60 * 24);
|
|
437
|
+
const decay = Math.pow(0.5, ageDays / 30);
|
|
438
|
+
return {
|
|
439
|
+
entry,
|
|
440
|
+
score: (scores.get(rowid) ?? 0) * decay * tierWeight(entry.tier) * confidenceBoost(entry.accessCount ?? 0) * projectBoost(entry.project, currentProject),
|
|
441
|
+
};
|
|
442
|
+
});
|
|
443
|
+
// 5. Filter, sort by score descending, return top N
|
|
444
|
+
const filtered = scored.filter((s) => {
|
|
445
|
+
if (!opts.includeArchived && s.entry.archived)
|
|
446
|
+
return false;
|
|
447
|
+
if (opts.tier && s.entry.tier !== opts.tier)
|
|
448
|
+
return false;
|
|
449
|
+
return true;
|
|
450
|
+
});
|
|
451
|
+
filtered.sort((a, b) => b.score - a.score);
|
|
452
|
+
const result = filtered.slice(0, limit).map((s) => s.entry);
|
|
453
|
+
this.bumpAccess(result.map((e) => e.id));
|
|
454
|
+
return result;
|
|
455
|
+
}
|
|
456
|
+
/** Archive ephemeral entries based on access-aware windows */
|
|
457
|
+
decayEphemeral() {
|
|
458
|
+
// 0 accesses: 3 days, 1-2 accesses: 7 days, 3+ accesses: 14 days
|
|
459
|
+
const stmt = this.db.prepare(`
|
|
460
|
+
UPDATE entries SET archived = 1
|
|
461
|
+
WHERE tier = 'ephemeral' AND pinned = 0 AND archived = 0
|
|
462
|
+
AND (
|
|
463
|
+
(access_count = 0 AND date < date('now', '-3 days'))
|
|
464
|
+
OR (access_count BETWEEN 1 AND 2 AND date < date('now', '-7 days'))
|
|
465
|
+
OR (access_count >= 3 AND date < date('now', '-14 days'))
|
|
466
|
+
)
|
|
467
|
+
`);
|
|
468
|
+
return Number(stmt.run().changes);
|
|
469
|
+
}
|
|
470
|
+
/** Demote working entries based on access-aware idle windows */
|
|
471
|
+
demoteIdle() {
|
|
472
|
+
// 0 accesses: 15 days, 1-2 accesses: 30 days, 3+ accesses: 60 days
|
|
473
|
+
const stmt = this.db.prepare(`
|
|
474
|
+
UPDATE entries SET tier = 'ephemeral'
|
|
475
|
+
WHERE tier = 'working' AND pinned = 0 AND archived = 0
|
|
476
|
+
AND (
|
|
477
|
+
(access_count = 0 AND COALESCE(last_accessed, date) < date('now', '-15 days'))
|
|
478
|
+
OR (access_count BETWEEN 1 AND 2 AND COALESCE(last_accessed, date) < date('now', '-30 days'))
|
|
479
|
+
OR (access_count >= 3 AND COALESCE(last_accessed, date) < date('now', '-60 days'))
|
|
480
|
+
)
|
|
481
|
+
`);
|
|
482
|
+
return Number(stmt.run().changes);
|
|
483
|
+
}
|
|
484
|
+
/** Promote decisions/insights older than 7 days to longterm */
|
|
485
|
+
promoteStable() {
|
|
486
|
+
const stmt = this.db.prepare(`
|
|
487
|
+
UPDATE entries SET tier = 'longterm'
|
|
488
|
+
WHERE tier = 'working' AND archived = 0
|
|
489
|
+
AND type IN ('decision', 'insight')
|
|
490
|
+
AND date < date('now', '-7 days')
|
|
491
|
+
`);
|
|
492
|
+
return Number(stmt.run().changes);
|
|
493
|
+
}
|
|
494
|
+
/** Promote ephemeral entries accessed 3+ times to working */
|
|
495
|
+
promoteFrequent() {
|
|
496
|
+
const stmt = this.db.prepare(`
|
|
497
|
+
UPDATE entries SET tier = 'working'
|
|
498
|
+
WHERE tier = 'ephemeral' AND archived = 0
|
|
499
|
+
AND access_count >= 3
|
|
500
|
+
`);
|
|
501
|
+
return Number(stmt.run().changes);
|
|
502
|
+
}
|
|
503
|
+
getEmbedding(entryId) {
|
|
504
|
+
const rowidRow = this.db.prepare("SELECT rowid FROM entries WHERE id = ?").get(entryId);
|
|
505
|
+
if (!rowidRow)
|
|
506
|
+
return null;
|
|
507
|
+
try {
|
|
508
|
+
const vecRow = this.db.prepare("SELECT embedding FROM vec_entries WHERE rowid = CAST(? AS INTEGER)").get(rowidRow.rowid);
|
|
509
|
+
if (!vecRow)
|
|
510
|
+
return null;
|
|
511
|
+
if (vecRow.embedding instanceof Uint8Array) {
|
|
512
|
+
return new Float32Array(vecRow.embedding.buffer, vecRow.embedding.byteOffset, vecRow.embedding.byteLength / 4);
|
|
513
|
+
}
|
|
514
|
+
return new Float32Array(vecRow.embedding);
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
findConsolidationCandidates(threshold = 0.75) {
|
|
521
|
+
// Get all non-archived issue/decision entries from last 30 days
|
|
522
|
+
const candidates = this.list({ days: 30, includeArchived: false })
|
|
523
|
+
.filter(e => e.type === "issue" || e.type === "decision");
|
|
524
|
+
if (candidates.length < 3)
|
|
525
|
+
return [];
|
|
526
|
+
// Simple greedy clustering by cosine similarity
|
|
527
|
+
const used = new Set();
|
|
528
|
+
const groups = [];
|
|
529
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
530
|
+
if (used.has(candidates[i].id))
|
|
531
|
+
continue;
|
|
532
|
+
const embA = this.getEmbedding(candidates[i].id);
|
|
533
|
+
if (!embA)
|
|
534
|
+
continue;
|
|
535
|
+
const cluster = [candidates[i]];
|
|
536
|
+
for (let j = i + 1; j < candidates.length; j++) {
|
|
537
|
+
if (used.has(candidates[j].id))
|
|
538
|
+
continue;
|
|
539
|
+
const embB = this.getEmbedding(candidates[j].id);
|
|
540
|
+
if (!embB)
|
|
541
|
+
continue;
|
|
542
|
+
if (cosineSimilarity(embA, embB) >= threshold) {
|
|
543
|
+
cluster.push(candidates[j]);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (cluster.length >= 3) {
|
|
547
|
+
cluster.forEach(e => used.add(e.id));
|
|
548
|
+
groups.push({
|
|
549
|
+
label: cluster[0].content.slice(0, 80),
|
|
550
|
+
entries: cluster,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return groups;
|
|
555
|
+
}
|
|
556
|
+
hasEntryWithTag(tag) {
|
|
557
|
+
const row = this.db.prepare("SELECT 1 FROM entries WHERE tags LIKE ? LIMIT 1").get(`%${tag}%`);
|
|
558
|
+
return !!row;
|
|
559
|
+
}
|
|
560
|
+
findByTag(tag) {
|
|
561
|
+
const sql = `
|
|
562
|
+
SELECT id, date, time, type, tags, content, source_file, tier, access_count, last_accessed, pinned, archived, project, session_id, agent_id
|
|
563
|
+
FROM entries
|
|
564
|
+
WHERE tags LIKE ? AND archived = 0
|
|
565
|
+
ORDER BY date ASC, time ASC
|
|
566
|
+
`;
|
|
567
|
+
const rows = this.db.prepare(sql).all(`%${tag}%`);
|
|
568
|
+
return rows.map((row) => ({
|
|
569
|
+
id: row.id,
|
|
570
|
+
date: row.date,
|
|
571
|
+
time: row.time,
|
|
572
|
+
type: row.type,
|
|
573
|
+
tags: row.tags.split(", ").filter(Boolean),
|
|
574
|
+
content: row.content,
|
|
575
|
+
sourceFile: row.source_file,
|
|
576
|
+
tier: row.tier,
|
|
577
|
+
accessCount: row.access_count,
|
|
578
|
+
lastAccessed: row.last_accessed,
|
|
579
|
+
pinned: !!row.pinned,
|
|
580
|
+
archived: !!row.archived,
|
|
581
|
+
project: row.project,
|
|
582
|
+
sessionId: row.session_id,
|
|
583
|
+
agentId: row.agent_id,
|
|
584
|
+
}));
|
|
585
|
+
}
|
|
586
|
+
findSimilarIssues(embedding, days = 30, distanceThreshold = 0.5) {
|
|
587
|
+
// sqlite-vec distance: lower = more similar. ~0.5 distance ≈ ~0.75 cosine similarity for normalized vectors
|
|
588
|
+
const vecResults = this.searchVec(embedding, 20);
|
|
589
|
+
const matchingRowids = vecResults
|
|
590
|
+
.filter(r => r.distance <= distanceThreshold)
|
|
591
|
+
.map(r => r.rowid);
|
|
592
|
+
if (matchingRowids.length === 0)
|
|
593
|
+
return [];
|
|
594
|
+
const entries = this.getByRowids(matchingRowids);
|
|
595
|
+
const cutoff = new Date();
|
|
596
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
597
|
+
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
|
598
|
+
return entries.filter(e => e.type === "issue" && !e.archived && e.date >= cutoffStr);
|
|
599
|
+
}
|
|
600
|
+
createOrUpdatePattern(label, entryIds) {
|
|
601
|
+
// Check if any existing unresolved pattern overlaps with these entries
|
|
602
|
+
const patterns = this.db.prepare("SELECT id, entry_ids, occurrence_count, first_seen FROM patterns WHERE resolved = 0").all();
|
|
603
|
+
const entryIdSet = new Set(entryIds);
|
|
604
|
+
for (const pat of patterns) {
|
|
605
|
+
const existing = JSON.parse(pat.entry_ids);
|
|
606
|
+
const overlap = existing.some(id => entryIdSet.has(id));
|
|
607
|
+
if (overlap) {
|
|
608
|
+
// Merge into existing pattern
|
|
609
|
+
const merged = Array.from(new Set([...existing, ...entryIds]));
|
|
610
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
611
|
+
this.db.prepare(`
|
|
612
|
+
UPDATE patterns SET entry_ids = ?, occurrence_count = ?, last_seen = ?, label = ?
|
|
613
|
+
WHERE id = ?
|
|
614
|
+
`).run(JSON.stringify(merged), merged.length, now, label.slice(0, 80), pat.id);
|
|
615
|
+
return pat.id;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// Create new pattern
|
|
619
|
+
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
620
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
621
|
+
this.db.prepare(`
|
|
622
|
+
INSERT INTO patterns (id, label, entry_ids, occurrence_count, first_seen, last_seen)
|
|
623
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
624
|
+
`).run(id, label.slice(0, 80), JSON.stringify(entryIds), entryIds.length, now, now);
|
|
625
|
+
return id;
|
|
626
|
+
}
|
|
627
|
+
getActivePatterns() {
|
|
628
|
+
const rows = this.db.prepare("SELECT * FROM patterns WHERE resolved = 0 AND occurrence_count >= 3 ORDER BY last_seen DESC").all();
|
|
629
|
+
return rows.map(r => ({
|
|
630
|
+
id: r.id,
|
|
631
|
+
label: r.label,
|
|
632
|
+
entryIds: JSON.parse(r.entry_ids),
|
|
633
|
+
occurrenceCount: r.occurrence_count,
|
|
634
|
+
firstSeen: r.first_seen,
|
|
635
|
+
lastSeen: r.last_seen,
|
|
636
|
+
}));
|
|
637
|
+
}
|
|
638
|
+
getPatternForEntry(entryId) {
|
|
639
|
+
const rows = this.db.prepare("SELECT id, occurrence_count, entry_ids FROM patterns WHERE resolved = 0").all();
|
|
640
|
+
for (const row of rows) {
|
|
641
|
+
const ids = JSON.parse(row.entry_ids);
|
|
642
|
+
if (ids.includes(entryId)) {
|
|
643
|
+
return { id: row.id, occurrenceCount: row.occurrence_count };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
resolvePattern(patternId) {
|
|
649
|
+
const result = this.db.prepare("UPDATE patterns SET resolved = 1 WHERE id = ?").run(patternId);
|
|
650
|
+
return Number(result.changes) > 0;
|
|
651
|
+
}
|
|
652
|
+
saveRule(label, content) {
|
|
653
|
+
const now = new Date();
|
|
654
|
+
const date = now.toISOString().slice(0, 10);
|
|
655
|
+
const time = now.toTimeString().slice(0, 5);
|
|
656
|
+
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
657
|
+
// Upsert: clean up old rule with same label (manually sync FTS to avoid trigger issue)
|
|
658
|
+
const existing = this.db.prepare("SELECT rowid FROM entries WHERE type = 'rule' AND label = ?").get(label);
|
|
659
|
+
if (existing) {
|
|
660
|
+
this.db.prepare("DELETE FROM entries_fts WHERE rowid = ?").run(existing.rowid);
|
|
661
|
+
this.db.exec("DROP TRIGGER IF EXISTS entries_ad");
|
|
662
|
+
this.db.prepare("DELETE FROM entries WHERE rowid = ?").run(existing.rowid);
|
|
663
|
+
this.db.exec(`
|
|
664
|
+
CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
|
|
665
|
+
INSERT INTO entries_fts(entries_fts, rowid, content, tags, type)
|
|
666
|
+
VALUES ('delete', old.rowid, old.content, old.tags, old.type);
|
|
667
|
+
END
|
|
668
|
+
`);
|
|
669
|
+
}
|
|
670
|
+
const stmt = this.db.prepare(`
|
|
671
|
+
INSERT INTO entries (id, date, time, type, tags, content, source_file, tier, access_count, last_accessed, pinned, archived, label)
|
|
672
|
+
VALUES (?, ?, ?, 'rule', '', ?, 'rule', 'longterm', 0, NULL, 1, 0, ?)
|
|
673
|
+
`);
|
|
674
|
+
stmt.run(id, date, time, content, label);
|
|
675
|
+
const row = this.db.prepare("SELECT last_insert_rowid() as rowid").get();
|
|
676
|
+
return row.rowid;
|
|
677
|
+
}
|
|
678
|
+
listRules() {
|
|
679
|
+
const rows = this.db.prepare("SELECT id, date, time, type, tags, content, source_file, tier, access_count, last_accessed, pinned, archived, label FROM entries WHERE type = 'rule' AND archived = 0 ORDER BY date DESC").all();
|
|
680
|
+
return rows.map((row) => ({
|
|
681
|
+
id: row.id,
|
|
682
|
+
date: row.date,
|
|
683
|
+
time: row.time,
|
|
684
|
+
type: row.type,
|
|
685
|
+
tags: row.tags.split(", ").filter(Boolean),
|
|
686
|
+
content: row.content,
|
|
687
|
+
sourceFile: row.source_file,
|
|
688
|
+
tier: row.tier,
|
|
689
|
+
accessCount: row.access_count,
|
|
690
|
+
lastAccessed: row.last_accessed,
|
|
691
|
+
pinned: !!row.pinned,
|
|
692
|
+
archived: !!row.archived,
|
|
693
|
+
label: row.label,
|
|
694
|
+
}));
|
|
695
|
+
}
|
|
696
|
+
deleteRule(label) {
|
|
697
|
+
const existing = this.db.prepare("SELECT rowid FROM entries WHERE type = 'rule' AND label = ?").get(label);
|
|
698
|
+
if (!existing)
|
|
699
|
+
return false;
|
|
700
|
+
this.db.prepare("DELETE FROM entries_fts WHERE rowid = ?").run(existing.rowid);
|
|
701
|
+
this.db.exec("DROP TRIGGER IF EXISTS entries_ad");
|
|
702
|
+
this.db.prepare("DELETE FROM entries WHERE rowid = ?").run(existing.rowid);
|
|
703
|
+
this.db.exec(`
|
|
704
|
+
CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
|
|
705
|
+
INSERT INTO entries_fts(entries_fts, rowid, content, tags, type)
|
|
706
|
+
VALUES ('delete', old.rowid, old.content, old.tags, old.type);
|
|
707
|
+
END
|
|
708
|
+
`);
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
upsertFilePair(project, fileA, fileB, date) {
|
|
712
|
+
// Ensure consistent ordering (a < b) to avoid duplicates
|
|
713
|
+
const [f1, f2] = fileA < fileB ? [fileA, fileB] : [fileB, fileA];
|
|
714
|
+
this.db.prepare(`
|
|
715
|
+
INSERT INTO file_pairs (project, file_a, file_b, co_change_count, last_seen)
|
|
716
|
+
VALUES (?, ?, ?, 1, ?)
|
|
717
|
+
ON CONFLICT(project, file_a, file_b) DO UPDATE SET
|
|
718
|
+
co_change_count = co_change_count + 1,
|
|
719
|
+
last_seen = ?
|
|
720
|
+
`).run(project, f1, f2, date, date);
|
|
721
|
+
}
|
|
722
|
+
getCoChanges(project, file, limit = 10) {
|
|
723
|
+
const rows = this.db.prepare(`
|
|
724
|
+
SELECT
|
|
725
|
+
CASE WHEN file_a = ? THEN file_b ELSE file_a END as paired_file,
|
|
726
|
+
co_change_count,
|
|
727
|
+
last_seen
|
|
728
|
+
FROM file_pairs
|
|
729
|
+
WHERE project = ? AND (file_a = ? OR file_b = ?)
|
|
730
|
+
ORDER BY co_change_count DESC
|
|
731
|
+
LIMIT ?
|
|
732
|
+
`).all(file, project, file, file, limit);
|
|
733
|
+
return rows.map(r => ({
|
|
734
|
+
file: r.paired_file,
|
|
735
|
+
count: r.co_change_count,
|
|
736
|
+
lastSeen: r.last_seen,
|
|
737
|
+
}));
|
|
738
|
+
}
|
|
739
|
+
listBySession(sessionId, opts = {}) {
|
|
740
|
+
const conditions = ["session_id = ?"];
|
|
741
|
+
const params = [sessionId];
|
|
742
|
+
if (opts.type) {
|
|
743
|
+
conditions.push("type = ?");
|
|
744
|
+
params.push(opts.type);
|
|
745
|
+
}
|
|
746
|
+
const sql = `
|
|
747
|
+
SELECT id, date, time, type, tags, content, source_file, tier, access_count,
|
|
748
|
+
last_accessed, pinned, archived, project, session_id, agent_id
|
|
749
|
+
FROM entries
|
|
750
|
+
WHERE ${conditions.join(" AND ")}
|
|
751
|
+
ORDER BY date ASC, time ASC
|
|
752
|
+
`;
|
|
753
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
754
|
+
return rows.map((row) => ({
|
|
755
|
+
id: row.id,
|
|
756
|
+
date: row.date,
|
|
757
|
+
time: row.time,
|
|
758
|
+
type: row.type,
|
|
759
|
+
tags: row.tags.split(", ").filter(Boolean),
|
|
760
|
+
content: row.content,
|
|
761
|
+
sourceFile: row.source_file,
|
|
762
|
+
tier: row.tier,
|
|
763
|
+
accessCount: row.access_count,
|
|
764
|
+
lastAccessed: row.last_accessed,
|
|
765
|
+
pinned: !!row.pinned,
|
|
766
|
+
archived: !!row.archived,
|
|
767
|
+
project: row.project,
|
|
768
|
+
sessionId: row.session_id,
|
|
769
|
+
agentId: row.agent_id,
|
|
770
|
+
}));
|
|
771
|
+
}
|
|
772
|
+
/** Update an entry's content (for consolidation) */
|
|
773
|
+
updateEntryContent(id, newContent) {
|
|
774
|
+
// Fetch current row data for FTS removal
|
|
775
|
+
const row = this.db.prepare("SELECT rowid, content, tags, type FROM entries WHERE id = ?").get(id);
|
|
776
|
+
if (!row)
|
|
777
|
+
return false;
|
|
778
|
+
// Remove old FTS entry using the delete command, then update, then re-insert.
|
|
779
|
+
// We temporarily drop and recreate triggers to avoid interference.
|
|
780
|
+
this.db.exec("DROP TRIGGER IF EXISTS entries_ai");
|
|
781
|
+
this.db.exec("DROP TRIGGER IF EXISTS entries_ad");
|
|
782
|
+
// Delete the old FTS row by rowid
|
|
783
|
+
this.db.prepare("DELETE FROM entries_fts WHERE rowid = ?").run(row.rowid);
|
|
784
|
+
// Update the content in entries table
|
|
785
|
+
const result = this.db.prepare("UPDATE entries SET content = ? WHERE id = ?").run(newContent, id);
|
|
786
|
+
// Re-insert FTS entry with updated content
|
|
787
|
+
if (result.changes > 0) {
|
|
788
|
+
this.db.prepare("INSERT INTO entries_fts(rowid, content, tags, type) VALUES (?, ?, ?, ?)").run(row.rowid, newContent, row.tags, row.type);
|
|
789
|
+
}
|
|
790
|
+
// Recreate triggers
|
|
791
|
+
this.db.exec(`
|
|
792
|
+
CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN
|
|
793
|
+
INSERT INTO entries_fts(rowid, content, tags, type)
|
|
794
|
+
VALUES (new.rowid, new.content, new.tags, new.type);
|
|
795
|
+
END
|
|
796
|
+
`);
|
|
797
|
+
this.db.exec(`
|
|
798
|
+
CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
|
|
799
|
+
INSERT INTO entries_fts(entries_fts, rowid, content, tags, type)
|
|
800
|
+
VALUES ('delete', old.rowid, old.content, old.tags, old.type);
|
|
801
|
+
END
|
|
802
|
+
`);
|
|
803
|
+
return result.changes > 0;
|
|
804
|
+
}
|
|
805
|
+
/** Merge tags from source entries into a target entry */
|
|
806
|
+
mergeTagsInto(targetId, sourceIds) {
|
|
807
|
+
const targetRow = this.db.prepare("SELECT tags FROM entries WHERE id = ?").get(targetId);
|
|
808
|
+
if (!targetRow)
|
|
809
|
+
return;
|
|
810
|
+
const allTags = new Set(targetRow.tags.split(", ").filter(Boolean));
|
|
811
|
+
for (const srcId of sourceIds) {
|
|
812
|
+
const srcRow = this.db.prepare("SELECT tags FROM entries WHERE id = ?").get(srcId);
|
|
813
|
+
if (srcRow) {
|
|
814
|
+
for (const tag of srcRow.tags.split(", ").filter(Boolean)) {
|
|
815
|
+
allTags.add(tag);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
this.db.prepare("UPDATE entries SET tags = ? WHERE id = ?").run([...allTags].join(", "), targetId);
|
|
820
|
+
}
|
|
821
|
+
/** Change tier for an entry */
|
|
822
|
+
changeTier(id, tier) {
|
|
823
|
+
const result = this.db.prepare("UPDATE entries SET tier = ? WHERE id = ?").run(tier, id);
|
|
824
|
+
return result.changes > 0;
|
|
825
|
+
}
|
|
826
|
+
close() {
|
|
827
|
+
this.db.close();
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
//# sourceMappingURL=sqlite.js.map
|