hafez 1.0.1

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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -0
  3. package/dist/cli/batch-errors.d.ts +24 -0
  4. package/dist/cli/batch-errors.js +236 -0
  5. package/dist/cli/commands-schema.d.ts +3 -0
  6. package/dist/cli/commands-schema.js +80 -0
  7. package/dist/cli/commands.d.ts +133 -0
  8. package/dist/cli/commands.js +628 -0
  9. package/dist/cli/format.d.ts +9 -0
  10. package/dist/cli/format.js +158 -0
  11. package/dist/cli/help-agent.d.ts +6 -0
  12. package/dist/cli/help-agent.js +158 -0
  13. package/dist/cli/index.d.ts +1 -0
  14. package/dist/cli/index.js +304 -0
  15. package/dist/cli/resolve-vault.d.ts +13 -0
  16. package/dist/cli/resolve-vault.js +124 -0
  17. package/dist/cli/schema-introspect.d.ts +70 -0
  18. package/dist/cli/schema-introspect.js +372 -0
  19. package/dist/cli.d.ts +2 -0
  20. package/dist/cli.js +3 -0
  21. package/dist/contracts.d.ts +26 -0
  22. package/dist/contracts.js +87 -0
  23. package/dist/db.d.ts +68 -0
  24. package/dist/db.js +663 -0
  25. package/dist/digest.d.ts +29 -0
  26. package/dist/digest.js +94 -0
  27. package/dist/export-okf.d.ts +12 -0
  28. package/dist/export-okf.js +176 -0
  29. package/dist/git.d.ts +12 -0
  30. package/dist/git.js +355 -0
  31. package/dist/index.d.ts +7 -0
  32. package/dist/index.js +909 -0
  33. package/dist/knowledge-index.d.ts +3 -0
  34. package/dist/knowledge-index.js +135 -0
  35. package/dist/merge.d.ts +1 -0
  36. package/dist/merge.js +176 -0
  37. package/dist/migrate-knowledge-v2.d.ts +11 -0
  38. package/dist/migrate-knowledge-v2.js +158 -0
  39. package/dist/migrate-next-actions.d.ts +6 -0
  40. package/dist/migrate-next-actions.js +32 -0
  41. package/dist/migrate-types.d.ts +11 -0
  42. package/dist/migrate-types.js +142 -0
  43. package/dist/parser.d.ts +18 -0
  44. package/dist/parser.js +68 -0
  45. package/dist/query.d.ts +24 -0
  46. package/dist/query.js +167 -0
  47. package/dist/schema.d.ts +9 -0
  48. package/dist/schema.js +59 -0
  49. package/dist/sections.d.ts +54 -0
  50. package/dist/sections.js +297 -0
  51. package/dist/templates.d.ts +8 -0
  52. package/dist/templates.js +39 -0
  53. package/dist/types.d.ts +321 -0
  54. package/dist/types.js +11 -0
  55. package/dist/vault.d.ts +12 -0
  56. package/dist/vault.js +45 -0
  57. package/package.json +67 -0
  58. package/skill/SKILL.md +190 -0
package/dist/db.js ADDED
@@ -0,0 +1,663 @@
1
+ import Database from 'better-sqlite3';
2
+ import { createHash } from 'crypto';
3
+ import { readdirSync, readFileSync, statSync, existsSync, unlinkSync } from 'fs';
4
+ import { join, basename } from 'path';
5
+ import { parseFilePath } from './vault.js';
6
+ import { ensureLocalExclude } from './knowledge-index.js';
7
+ import { getNextActions, getBrief, WIKI_LINK_RE, findSection } from './sections.js';
8
+ import { parseSessionLog } from './parser.js';
9
+ // Bump this when indexing logic changes to force a full rebuild.
10
+ // v5: wiki link harvesting (mention relations), parent link indexing, relation filter on queries.
11
+ // v6: description column on items.
12
+ // v7: resource column on items (entities only).
13
+ // Existing instances will fully rebuild the index on first access after upgrade.
14
+ export const SCHEMA_VERSION = '7';
15
+ const SCHEMA = `
16
+ CREATE TABLE IF NOT EXISTS items (
17
+ slug TEXT PRIMARY KEY,
18
+ kind TEXT NOT NULL,
19
+ name TEXT NOT NULL,
20
+ type TEXT,
21
+ status TEXT,
22
+ parent TEXT,
23
+ next_action TEXT,
24
+ next_action_count INTEGER DEFAULT 0,
25
+ staleness_days INTEGER,
26
+ confidence TEXT,
27
+ reinforcement_count INTEGER,
28
+ last_reinforced TEXT,
29
+ created TEXT NOT NULL,
30
+ last_touched TEXT,
31
+ body TEXT NOT NULL,
32
+ file_hash TEXT NOT NULL,
33
+ subtype TEXT,
34
+ description TEXT,
35
+ resource TEXT,
36
+ brief TEXT,
37
+ session_log_count INTEGER DEFAULT 0,
38
+ last_session_date TEXT,
39
+ last_session_type TEXT,
40
+ last_session_summary TEXT
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS links (
44
+ source TEXT NOT NULL,
45
+ target TEXT NOT NULL,
46
+ relation TEXT NOT NULL,
47
+ PRIMARY KEY (source, target, relation)
48
+ );
49
+
50
+ CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
51
+ name, body,
52
+ content=items,
53
+ content_rowid=rowid
54
+ );
55
+
56
+ CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN
57
+ INSERT INTO items_fts(rowid, name, body) VALUES (new.rowid, new.name, new.body);
58
+ END;
59
+
60
+ CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN
61
+ INSERT INTO items_fts(items_fts, rowid, name, body) VALUES ('delete', old.rowid, old.name, old.body);
62
+ END;
63
+
64
+ CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN
65
+ INSERT INTO items_fts(items_fts, rowid, name, body) VALUES ('delete', old.rowid, old.name, old.body);
66
+ INSERT INTO items_fts(rowid, name, body) VALUES (new.rowid, new.name, new.body);
67
+ END;
68
+
69
+ CREATE TABLE IF NOT EXISTS meta (
70
+ key TEXT PRIMARY KEY,
71
+ value TEXT NOT NULL
72
+ );
73
+ `;
74
+ const STOPWORDS = new Set([
75
+ 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of',
76
+ 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
77
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
78
+ 'may', 'might', 'can', 'shall', 'not', 'no', 'nor', 'so', 'if', 'then', 'than',
79
+ 'that', 'this', 'these', 'those', 'it', 'its', 'my', 'your', 'his', 'her',
80
+ 'our', 'their', 'what', 'which', 'who', 'whom', 'how', 'when', 'where', 'why',
81
+ 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'some', 'any', 'such',
82
+ 'about', 'into', 'over', 'after', 'before',
83
+ 'work', 'works', 'working', 'use', 'using', 'used', 'get', 'got', 'make', 'made',
84
+ 'need', 'want', 'like', 'just', 'also', 'still', 'already', 'very', 'much',
85
+ ]);
86
+ function hashContent(content) {
87
+ return createHash('sha256').update(content).digest('hex');
88
+ }
89
+ function scanDir(dirPath) {
90
+ try {
91
+ return readdirSync(dirPath).filter(f => f.endsWith('.md')).map(f => join(dirPath, f));
92
+ }
93
+ catch {
94
+ return [];
95
+ }
96
+ }
97
+ // --- Shared query functions (used by both write and readonly paths) ---
98
+ function sanitizeFtsQuery(raw) {
99
+ const cleaned = raw.replace(/[^\w\s]/g, ' ');
100
+ const tokens = cleaned.split(/\s+/).filter(t => t.length > 0);
101
+ const filtered = tokens.filter(t => !STOPWORDS.has(t.toLowerCase()));
102
+ const terms = filtered.length === 0 ? tokens : filtered;
103
+ return { fts: terms.map(t => `${t}*`).join(' OR '), terms };
104
+ }
105
+ function queryItemsFn(db, opts) {
106
+ const conditions = [];
107
+ const params = {};
108
+ if (opts.kind && opts.kind !== 'all') {
109
+ conditions.push('i.kind = @kind');
110
+ params.kind = opts.kind;
111
+ }
112
+ if (opts.status) {
113
+ conditions.push('i.status = @status');
114
+ params.status = opts.status;
115
+ }
116
+ if (opts.type) {
117
+ conditions.push('i.type = @type');
118
+ params.type = opts.type;
119
+ }
120
+ if (opts.subtype) {
121
+ conditions.push('i.subtype = @subtype');
122
+ params.subtype = opts.subtype;
123
+ }
124
+ if (opts.parent) {
125
+ conditions.push('i.parent = @parent');
126
+ params.parent = opts.parent;
127
+ }
128
+ if (opts.confidence) {
129
+ conditions.push('i.confidence = @confidence');
130
+ params.confidence = opts.confidence;
131
+ }
132
+ if (opts.domain) {
133
+ conditions.push("EXISTS (SELECT 1 FROM links l WHERE l.source = i.slug AND l.relation = 'domain' AND l.target = @domain)");
134
+ params.domain = opts.domain;
135
+ }
136
+ if (opts.relatedTo) {
137
+ const effectiveRelation = opts.relation ?? 'related'; // backward compat default
138
+ if (effectiveRelation === 'all') {
139
+ conditions.push("EXISTS (SELECT 1 FROM links l WHERE l.source = i.slug AND l.target = @relatedTo)");
140
+ params.relatedTo = opts.relatedTo;
141
+ }
142
+ else {
143
+ conditions.push("EXISTS (SELECT 1 FROM links l WHERE l.source = i.slug AND l.target = @relatedTo AND l.relation = @relation)");
144
+ params.relatedTo = opts.relatedTo;
145
+ params.relation = effectiveRelation;
146
+ }
147
+ }
148
+ if (opts.filter === 'stale') {
149
+ conditions.push("i.kind = 'entity'");
150
+ conditions.push("i.status = 'active'");
151
+ conditions.push(`(
152
+ julianday('now') - julianday(i.last_touched) >
153
+ CASE
154
+ WHEN i.staleness_days IS NOT NULL THEN i.staleness_days
155
+ ELSE 14
156
+ END
157
+ )`);
158
+ }
159
+ if (opts.filter === 'capture') {
160
+ conditions.push("i.type = 'capture'");
161
+ }
162
+ if (opts.since) {
163
+ conditions.push('i.last_touched >= @since');
164
+ params.since = opts.since;
165
+ }
166
+ if (opts.before) {
167
+ conditions.push('i.last_touched <= @before');
168
+ params.before = opts.before;
169
+ }
170
+ if (opts.createdSince) {
171
+ conditions.push('i.created >= @createdSince');
172
+ params.createdSince = opts.createdSince;
173
+ }
174
+ if (opts.createdBefore) {
175
+ conditions.push('i.created <= @createdBefore');
176
+ params.createdBefore = opts.createdBefore;
177
+ }
178
+ if (opts.tags?.length) {
179
+ for (let i = 0; i < opts.tags.length; i++) {
180
+ const paramKey = `tag${i}`;
181
+ conditions.push(`EXISTS (SELECT 1 FROM links WHERE source = i.slug AND relation = 'tag' AND target = @${paramKey})`);
182
+ params[paramKey] = opts.tags[i];
183
+ }
184
+ }
185
+ const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
186
+ const knowledgeOnlySorts = new Set(['reinforcement_count', 'last_reinforced']);
187
+ const sortColMap = {
188
+ name: 'i.name',
189
+ created: 'i.created',
190
+ last_touched: 'i.last_touched',
191
+ staleness: 'i.staleness_days',
192
+ reinforcement_count: 'i.reinforcement_count',
193
+ last_reinforced: 'i.last_reinforced',
194
+ };
195
+ let sortCol = 'i.last_touched';
196
+ if (opts.sort_by) {
197
+ const isKnowledgeOnly = knowledgeOnlySorts.has(opts.sort_by);
198
+ const isEntityQuery = opts.kind === 'entity';
199
+ if (isKnowledgeOnly && isEntityQuery) {
200
+ sortCol = 'i.last_touched';
201
+ }
202
+ else if (sortColMap[opts.sort_by]) {
203
+ sortCol = sortColMap[opts.sort_by];
204
+ }
205
+ }
206
+ let sortOrder;
207
+ if (opts.sort_order) {
208
+ sortOrder = opts.sort_order.toUpperCase();
209
+ }
210
+ else if (opts.sort_by === 'name' || opts.sort_by === 'staleness') {
211
+ sortOrder = 'ASC';
212
+ }
213
+ else {
214
+ sortOrder = 'DESC';
215
+ }
216
+ const countSql = `SELECT COUNT(*) as cnt FROM items i ${where}`;
217
+ const totalRow = db.prepare(countSql).get(params);
218
+ const total = totalRow.cnt;
219
+ let limitClause = '';
220
+ if (opts.limit != null) {
221
+ limitClause = ` LIMIT ${opts.limit}`;
222
+ if (opts.offset != null) {
223
+ limitClause += ` OFFSET ${opts.offset}`;
224
+ }
225
+ }
226
+ else if (opts.offset != null) {
227
+ limitClause = ` LIMIT -1 OFFSET ${opts.offset}`;
228
+ }
229
+ const sql = `SELECT i.slug, i.kind, i.name, i.type, i.status, i.parent,
230
+ i.next_action, i.next_action_count, i.staleness_days,
231
+ i.confidence, i.reinforcement_count, i.last_reinforced,
232
+ i.created, i.last_touched, i.subtype,
233
+ i.description, i.resource, i.brief, i.session_log_count, i.last_session_date,
234
+ i.last_session_type, i.last_session_summary
235
+ FROM items i ${where}
236
+ ORDER BY ${sortCol} ${sortOrder}${limitClause}`;
237
+ const rows = db.prepare(sql).all(params);
238
+ const getRelated = db.prepare("SELECT target FROM links WHERE source = ? AND relation = 'related'");
239
+ const getDomains = db.prepare("SELECT target FROM links WHERE source = ? AND relation = 'domain'");
240
+ const getTags = db.prepare("SELECT target FROM links WHERE source = ? AND relation = 'tag'");
241
+ for (const row of rows) {
242
+ row.related = getRelated.all(row.slug).map(r => r.target);
243
+ row.domain = getDomains.all(row.slug).map(r => r.target);
244
+ row.tags = getTags.all(row.slug).map(r => r.target);
245
+ }
246
+ return { items: rows, total };
247
+ }
248
+ function searchFn(db, query, kind) {
249
+ const { fts: ftsQuery, terms } = sanitizeFtsQuery(query);
250
+ if (!ftsQuery)
251
+ return [];
252
+ let kindFilter = '';
253
+ const params = { query: ftsQuery };
254
+ if (kind && kind !== 'all') {
255
+ kindFilter = 'AND i.kind = @kind';
256
+ params.kind = kind;
257
+ }
258
+ const nameExact = query.replace(/'/g, "''");
259
+ const nameLike = terms.map(t => `i.name LIKE '%${t.replace(/'/g, "''")}%'`).join(' AND ');
260
+ const nameTermCount = terms.length > 1
261
+ ? '0 - (' + terms.map(t => `(CASE WHEN LOWER(i.name) LIKE '%${t.replace(/'/g, "''").toLowerCase()}%' THEN 1 ELSE 0 END)`).join(' + ') + ')'
262
+ : 'NULL';
263
+ const termCoverageExpr = terms.length > 1
264
+ ? '0 - (' + terms.map(t => {
265
+ const escaped = t.replace(/'/g, "''").toLowerCase();
266
+ return `(CASE WHEN LOWER(i.name) || ' ' || LOWER(i.body) LIKE '%${escaped}%' THEN 1 ELSE 0 END)`;
267
+ }).join(' + ') + ')'
268
+ : 'NULL';
269
+ const tfExpr = terms.length > 0
270
+ ? '0 - (' + terms.map(t => {
271
+ const escaped = t.replace(/'/g, "''").toLowerCase();
272
+ return `(LENGTH(LOWER(i.body)) - LENGTH(REPLACE(LOWER(i.body), '${escaped}', ''))) / MAX(LENGTH('${escaped}'), 1)`;
273
+ }).join(' + ') + ')'
274
+ : 'NULL';
275
+ const sql = `
276
+ SELECT i.slug, i.kind, i.name, i.type, i.status, i.confidence,
277
+ snippet(items_fts, 1, '**', '**', '...', 30) as snippet
278
+ FROM items_fts f
279
+ JOIN items i ON i.rowid = f.rowid
280
+ WHERE items_fts MATCH @query ${kindFilter}
281
+ ORDER BY
282
+ CASE
283
+ WHEN LOWER(i.name) = LOWER('${nameExact}') THEN 0
284
+ WHEN (${nameLike}) THEN 1
285
+ ELSE 2
286
+ END,
287
+ ${nameTermCount},
288
+ ${termCoverageExpr},
289
+ ${tfExpr}
290
+ LIMIT 20
291
+ `;
292
+ return db.prepare(sql).all(params);
293
+ }
294
+ function getStatsFn(db) {
295
+ const counts = { active: 0, paused: 0, done: 0 };
296
+ const statusRows = db.prepare("SELECT status, COUNT(*) as cnt FROM items WHERE kind = 'entity' GROUP BY status").all();
297
+ for (const row of statusRows) {
298
+ if (row.status in counts)
299
+ counts[row.status] = row.cnt;
300
+ }
301
+ const by_type = { capture: 0, entity: 0, project: 0 };
302
+ const typeRows = db.prepare("SELECT type, COUNT(*) as cnt FROM items WHERE kind = 'entity' GROUP BY type").all();
303
+ for (const row of typeRows) {
304
+ if (row.type in by_type)
305
+ by_type[row.type] = row.cnt;
306
+ }
307
+ const stale = db.prepare(`
308
+ SELECT slug, name, type, CAST(julianday('now') - julianday(last_touched) AS INTEGER) as days_since_touched
309
+ FROM items
310
+ WHERE kind = 'entity' AND status = 'active'
311
+ AND julianday('now') - julianday(last_touched) >
312
+ CASE
313
+ WHEN staleness_days IS NOT NULL THEN staleness_days
314
+ ELSE 14
315
+ END
316
+ ORDER BY days_since_touched DESC
317
+ `).all();
318
+ const no_next_action = db.prepare(`
319
+ SELECT slug, name, type FROM items
320
+ WHERE kind = 'entity' AND status = 'active' AND next_action_count = 0
321
+ ORDER BY last_touched DESC
322
+ `).all();
323
+ const recently_touched = db.prepare(`
324
+ SELECT slug, name, last_touched FROM items
325
+ WHERE kind = 'entity'
326
+ ORDER BY last_touched DESC LIMIT 5
327
+ `).all();
328
+ const recently_created = db.prepare(`
329
+ SELECT slug, name, created FROM items
330
+ WHERE kind = 'entity'
331
+ ORDER BY created DESC LIMIT 5
332
+ `).all();
333
+ const knowledgeCount = db.prepare("SELECT COUNT(*) as cnt FROM items WHERE kind = 'knowledge'").get().cnt;
334
+ return {
335
+ counts: counts,
336
+ by_type: by_type,
337
+ knowledge_count: knowledgeCount,
338
+ stale,
339
+ no_next_action,
340
+ recently_touched,
341
+ recently_created,
342
+ };
343
+ }
344
+ export function createIndex(vaultPath, opts) {
345
+ const dbPath = join(vaultPath, '.hafez.db');
346
+ const isReadOnly = opts?.readonly ?? false;
347
+ if (isReadOnly) {
348
+ if (!existsSync(dbPath))
349
+ return null;
350
+ let db = new Database(dbPath, { readonly: true });
351
+ db.pragma('busy_timeout = 5000');
352
+ let dbMtime = statSync(dbPath).mtimeMs;
353
+ // A stale schema is unusable, not merely stale: queries SELECT columns that
354
+ // older schemas lack (e.g. description/resource) and would throw SqliteError.
355
+ // Treat a version mismatch like a missing DB — warn and return null so
356
+ // callers degrade to empty results until a write-capable command rebuilds.
357
+ let storedVersion;
358
+ try {
359
+ const vr = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
360
+ storedVersion = vr?.value;
361
+ }
362
+ catch { /* meta table may not exist */ }
363
+ if (storedVersion !== SCHEMA_VERSION) {
364
+ process.stderr.write(`Warning: .hafez.db schema v${storedVersion ?? 'unknown'} != expected v${SCHEMA_VERSION}. Ignoring index until a write command (e.g. hafez index rebuild) rebuilds it.\n`);
365
+ try {
366
+ db.close();
367
+ }
368
+ catch { }
369
+ return null;
370
+ }
371
+ function syncIfStale() {
372
+ try {
373
+ const currentMtime = statSync(dbPath).mtimeMs;
374
+ if (currentMtime !== dbMtime) {
375
+ try {
376
+ db.close();
377
+ }
378
+ catch { }
379
+ db = new Database(dbPath, { readonly: true });
380
+ db.pragma('busy_timeout = 5000');
381
+ dbMtime = currentMtime;
382
+ }
383
+ }
384
+ catch { /* DB may have been temporarily deleted during rebuild */ }
385
+ }
386
+ return {
387
+ rebuild: () => { throw new Error('Index is read-only'); },
388
+ syncIfStale,
389
+ upsertFromFile: () => { throw new Error('Index is read-only'); },
390
+ removeItem: () => { throw new Error('Index is read-only'); },
391
+ queryItems: (qopts) => queryItemsFn(db, qopts),
392
+ search: (q, k) => searchFn(db, q, k),
393
+ getStats: () => getStatsFn(db),
394
+ close: () => { try {
395
+ db.close();
396
+ }
397
+ catch { } },
398
+ };
399
+ }
400
+ // --- Write-capable path ---
401
+ // The db files are a disposable local cache — keep them out of the user's
402
+ // `git status` without dirtying their vault with a tracked .gitignore.
403
+ ensureLocalExclude(vaultPath);
404
+ let db;
405
+ try {
406
+ db = new Database(dbPath);
407
+ }
408
+ catch {
409
+ // Corrupted DB — delete and recreate
410
+ try {
411
+ unlinkSync(dbPath);
412
+ }
413
+ catch { }
414
+ db = new Database(dbPath);
415
+ }
416
+ db.pragma('journal_mode = WAL');
417
+ db.pragma('busy_timeout = 5000');
418
+ db.pragma('foreign_keys = ON');
419
+ db.exec(SCHEMA);
420
+ // Schema migration: if next_action_count column missing, delete DB and recreate
421
+ try {
422
+ db.prepare('SELECT next_action_count FROM items LIMIT 0').run();
423
+ }
424
+ catch {
425
+ db.close();
426
+ unlinkSync(dbPath);
427
+ db = new Database(dbPath);
428
+ db.pragma('journal_mode = WAL');
429
+ db.pragma('busy_timeout = 5000');
430
+ db.pragma('foreign_keys = ON');
431
+ db.exec(SCHEMA);
432
+ }
433
+ // Schema migration: if domain_entity column exists, delete DB and recreate
434
+ try {
435
+ db.prepare('SELECT domain_entity FROM items LIMIT 0').run();
436
+ // Column exists — old schema, needs rebuild
437
+ db.close();
438
+ unlinkSync(dbPath);
439
+ db = new Database(dbPath);
440
+ db.pragma('journal_mode = WAL');
441
+ db.pragma('busy_timeout = 5000');
442
+ db.pragma('foreign_keys = ON');
443
+ db.exec(SCHEMA);
444
+ }
445
+ catch {
446
+ // Column doesn't exist — correct schema, continue
447
+ }
448
+ // Schema version check — forces full rebuild when indexing logic changes
449
+ const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
450
+ if (!versionRow || versionRow.value !== SCHEMA_VERSION) {
451
+ db.close();
452
+ unlinkSync(dbPath);
453
+ db = new Database(dbPath);
454
+ db.pragma('journal_mode = WAL');
455
+ db.pragma('busy_timeout = 5000');
456
+ db.pragma('foreign_keys = ON');
457
+ db.exec(SCHEMA);
458
+ db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(SCHEMA_VERSION);
459
+ }
460
+ // Prepared statements
461
+ const upsertStmt = db.prepare(`
462
+ INSERT OR REPLACE INTO items
463
+ (slug, kind, name, type, status, parent, next_action, next_action_count, staleness_days,
464
+ confidence, reinforcement_count, last_reinforced, created, last_touched, body, file_hash, subtype,
465
+ description, resource, brief, session_log_count, last_session_date, last_session_type, last_session_summary)
466
+ VALUES
467
+ (@slug, @kind, @name, @type, @status, @parent, @next_action, @next_action_count, @staleness_days,
468
+ @confidence, @reinforcement_count, @last_reinforced, @created, @last_touched, @body, @file_hash, @subtype,
469
+ @description, @resource, @brief, @session_log_count, @last_session_date, @last_session_type, @last_session_summary)
470
+ `);
471
+ const deleteItemStmt = db.prepare('DELETE FROM items WHERE slug = ?');
472
+ const deleteLinksStmt = db.prepare('DELETE FROM links WHERE source = ?');
473
+ const insertLinkStmt = db.prepare('INSERT OR IGNORE INTO links (source, target, relation) VALUES (?, ?, ?)');
474
+ const getHashStmt = db.prepare('SELECT file_hash FROM items WHERE slug = ?');
475
+ function indexFile(filePath, kind, knownSlugs) {
476
+ const content = readFileSync(filePath, 'utf-8');
477
+ const slug = basename(filePath, '.md');
478
+ const hash = hashContent(content);
479
+ // Skip if unchanged
480
+ const existing = getHashStmt.get(slug);
481
+ if (existing && existing.file_hash === hash)
482
+ return;
483
+ let fm, body;
484
+ try {
485
+ const parsed = parseFilePath(filePath);
486
+ fm = parsed.frontmatter;
487
+ body = parsed.body;
488
+ }
489
+ catch (err) {
490
+ process.stderr.write(`Warning: skipping ${kind}/${slug}.md: failed to parse (${err.message})\n`);
491
+ return;
492
+ }
493
+ // Validate required fields — skip malformed files instead of crashing
494
+ if (!fm.name || !fm.created) {
495
+ const missing = [!fm.name && 'name', !fm.created && 'created'].filter(Boolean).join(', ');
496
+ process.stderr.write(`Warning: skipping ${kind}/${slug}.md: missing required field(s): ${missing}\n`);
497
+ return;
498
+ }
499
+ // Parse next actions from body (new), fall back to frontmatter (legacy pre-migration)
500
+ const bodyActions = getNextActions(body);
501
+ const nextAction = bodyActions.length > 0 ? bodyActions[0] : (fm['next-action'] ?? null);
502
+ const nextActionCount = bodyActions.length;
503
+ const brief = getBrief(body)?.slice(0, 300) ?? null;
504
+ const logs = parseSessionLog(body);
505
+ const lastLog = logs[0] ?? null;
506
+ const item = {
507
+ slug,
508
+ kind,
509
+ name: fm.name,
510
+ type: kind === 'entity' ? (fm.type ?? null) : null,
511
+ status: kind === 'entity' ? (fm.status ?? null) : null,
512
+ parent: fm.parent ?? null,
513
+ next_action: nextAction,
514
+ next_action_count: nextActionCount,
515
+ staleness_days: fm['staleness-days'] ?? null,
516
+ confidence: kind === 'knowledge' ? (fm.confidence ?? null) : null,
517
+ reinforcement_count: fm['reinforcement-count'] ?? null,
518
+ last_reinforced: fm['last-reinforced'] ?? null,
519
+ created: fm.created,
520
+ last_touched: fm['last-touched'] ?? null,
521
+ body,
522
+ file_hash: hash,
523
+ subtype: kind === 'knowledge' ? (fm.subtype ?? 'insight') : null,
524
+ description: fm.description ?? null,
525
+ resource: kind === 'entity' ? (fm.resource ?? null) : null,
526
+ brief,
527
+ session_log_count: logs.length,
528
+ last_session_date: lastLog?.date ?? null,
529
+ last_session_type: lastLog?.type ?? null,
530
+ last_session_summary: lastLog?.summary?.slice(0, 300) ?? null,
531
+ };
532
+ // Upsert item and rebuild links
533
+ deleteLinksStmt.run(slug);
534
+ upsertStmt.run(item);
535
+ // Index related links
536
+ if (fm.related && Array.isArray(fm.related)) {
537
+ for (const target of fm.related)
538
+ insertLinkStmt.run(slug, target, 'related');
539
+ }
540
+ // Index tags
541
+ if (fm.tags && Array.isArray(fm.tags)) {
542
+ for (const tag of fm.tags)
543
+ insertLinkStmt.run(slug, tag, 'tag');
544
+ }
545
+ // Index domains for ALL item types (both entity and knowledge)
546
+ if (fm.domain && Array.isArray(fm.domain)) {
547
+ for (const d of fm.domain)
548
+ insertLinkStmt.run(slug, d, 'domain');
549
+ }
550
+ // Index parent link
551
+ if (fm.parent) {
552
+ insertLinkStmt.run(slug, fm.parent, 'parent');
553
+ }
554
+ // Harvest [[wiki links]] from body (excluding ## Related section)
555
+ // Strip the Related section to avoid double-counting generated links
556
+ let authoredBody = body;
557
+ const relatedSection = findSection(body, 'Related');
558
+ if (relatedSection) {
559
+ authoredBody = body.slice(0, relatedSection.start) + body.slice(relatedSection.end);
560
+ }
561
+ let wikiMatch;
562
+ const linkRe = new RegExp(WIKI_LINK_RE.source, 'g');
563
+ while ((wikiMatch = linkRe.exec(authoredBody)) !== null) {
564
+ const targetSlug = wikiMatch[1];
565
+ // Check if target file exists on disk (not in index — target may not be indexed yet)
566
+ const targetEntityPath = join(vaultPath, 'entities', `${targetSlug}.md`);
567
+ const targetKnowledgePath = join(vaultPath, 'knowledge', `${targetSlug}.md`);
568
+ const targetExists = knownSlugs
569
+ ? knownSlugs.has(targetSlug)
570
+ : existsSync(targetEntityPath) || existsSync(targetKnowledgePath);
571
+ if (targetExists) {
572
+ insertLinkStmt.run(slug, targetSlug, 'mention');
573
+ }
574
+ }
575
+ }
576
+ const rebuildTransaction = db.transaction(() => {
577
+ db.exec('DELETE FROM items');
578
+ db.exec('DELETE FROM links');
579
+ // Collect all vault slugs for wiki link target validation
580
+ const allVaultSlugs = new Set();
581
+ for (const file of scanDir(join(vaultPath, 'entities'))) {
582
+ allVaultSlugs.add(basename(file, '.md'));
583
+ }
584
+ for (const file of scanDir(join(vaultPath, 'knowledge'))) {
585
+ allVaultSlugs.add(basename(file, '.md'));
586
+ }
587
+ for (const file of scanDir(join(vaultPath, 'entities'))) {
588
+ indexFile(file, 'entity', allVaultSlugs);
589
+ }
590
+ for (const file of scanDir(join(vaultPath, 'knowledge'))) {
591
+ indexFile(file, 'knowledge', allVaultSlugs);
592
+ }
593
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_sync', ?)")
594
+ .run(Date.now().toString());
595
+ });
596
+ function rebuild() {
597
+ rebuildTransaction();
598
+ }
599
+ function syncIfStale() {
600
+ const lastSync = db.prepare("SELECT value FROM meta WHERE key = 'last_sync'").get();
601
+ if (!lastSync) {
602
+ rebuild();
603
+ return;
604
+ }
605
+ const lastSyncTime = parseInt(lastSync.value, 10);
606
+ let changed = false;
607
+ const dirs = [
608
+ { dir: 'entities', kind: 'entity' },
609
+ { dir: 'knowledge', kind: 'knowledge' },
610
+ ];
611
+ for (const { dir, kind } of dirs) {
612
+ for (const file of scanDir(join(vaultPath, dir))) {
613
+ if (statSync(file).mtimeMs > lastSyncTime) {
614
+ indexFile(file, kind);
615
+ changed = true;
616
+ }
617
+ }
618
+ }
619
+ // Detect deleted files — remove orphaned DB entries
620
+ const dbSlugs = db.prepare('SELECT slug FROM items').all().map(r => r.slug);
621
+ const fileSlugs = new Set();
622
+ for (const { dir } of dirs) {
623
+ for (const file of scanDir(join(vaultPath, dir))) {
624
+ fileSlugs.add(basename(file, '.md'));
625
+ }
626
+ }
627
+ for (const slug of dbSlugs) {
628
+ if (!fileSlugs.has(slug)) {
629
+ removeItem(slug);
630
+ changed = true;
631
+ }
632
+ }
633
+ if (changed) {
634
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_sync', ?)")
635
+ .run(Date.now().toString());
636
+ }
637
+ }
638
+ function upsertFromFile(slug, kind) {
639
+ const dir = kind === 'entity' ? 'entities' : 'knowledge';
640
+ const filePath = join(vaultPath, dir, `${slug}.md`);
641
+ if (existsSync(filePath)) {
642
+ // Force re-index by clearing cached hash
643
+ deleteItemStmt.run(slug);
644
+ deleteLinksStmt.run(slug);
645
+ indexFile(filePath, kind);
646
+ }
647
+ }
648
+ function removeItem(slug) {
649
+ deleteItemStmt.run(slug);
650
+ deleteLinksStmt.run(slug);
651
+ }
652
+ // Auto-rebuild on first creation if empty
653
+ const count = db.prepare('SELECT COUNT(*) as c FROM items').get().c;
654
+ if (count === 0)
655
+ rebuild();
656
+ return {
657
+ rebuild, syncIfStale, upsertFromFile, removeItem,
658
+ queryItems: (qopts) => queryItemsFn(db, qopts),
659
+ search: (q, k) => searchFn(db, q, k),
660
+ getStats: () => getStatsFn(db),
661
+ close: () => db.close(),
662
+ };
663
+ }
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import type { BatchOperation } from './types.js';
3
+ export declare const DigestInputSchema: z.ZodObject<{
4
+ entities_touched: z.ZodArray<z.ZodString>;
5
+ decisions: z.ZodDefault<z.ZodArray<z.ZodString>>;
6
+ narrative: z.ZodString;
7
+ session_date: z.ZodString;
8
+ agent: z.ZodDefault<z.ZodString>;
9
+ }, z.core.$strip>;
10
+ export type DigestInput = z.infer<typeof DigestInputSchema>;
11
+ /**
12
+ * Validate and parse raw digest input from stdin.
13
+ * Returns unknown fields so callers can warn on stderr.
14
+ * Uses strict() parse to detect unknown keys, then normal parse for the clean result.
15
+ */
16
+ export declare function parseDigestInput(raw: unknown): {
17
+ success: true;
18
+ data: DigestInput;
19
+ unknownFields: string[];
20
+ } | {
21
+ success: false;
22
+ error: z.ZodError;
23
+ };
24
+ /**
25
+ * Convert session context into a hafez batch payload.
26
+ * Pure function — no file I/O, no vault access.
27
+ * Unknown slugs in entities_touched are silently omitted (caller should warn).
28
+ */
29
+ export declare function digest(input: DigestInput, existingSlugs: Set<string>, now?: Date): BatchOperation[];