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.
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/dist/cli/batch-errors.d.ts +24 -0
- package/dist/cli/batch-errors.js +236 -0
- package/dist/cli/commands-schema.d.ts +3 -0
- package/dist/cli/commands-schema.js +80 -0
- package/dist/cli/commands.d.ts +133 -0
- package/dist/cli/commands.js +628 -0
- package/dist/cli/format.d.ts +9 -0
- package/dist/cli/format.js +158 -0
- package/dist/cli/help-agent.d.ts +6 -0
- package/dist/cli/help-agent.js +158 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +304 -0
- package/dist/cli/resolve-vault.d.ts +13 -0
- package/dist/cli/resolve-vault.js +124 -0
- package/dist/cli/schema-introspect.d.ts +70 -0
- package/dist/cli/schema-introspect.js +372 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +3 -0
- package/dist/contracts.d.ts +26 -0
- package/dist/contracts.js +87 -0
- package/dist/db.d.ts +68 -0
- package/dist/db.js +663 -0
- package/dist/digest.d.ts +29 -0
- package/dist/digest.js +94 -0
- package/dist/export-okf.d.ts +12 -0
- package/dist/export-okf.js +176 -0
- package/dist/git.d.ts +12 -0
- package/dist/git.js +355 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +909 -0
- package/dist/knowledge-index.d.ts +3 -0
- package/dist/knowledge-index.js +135 -0
- package/dist/merge.d.ts +1 -0
- package/dist/merge.js +176 -0
- package/dist/migrate-knowledge-v2.d.ts +11 -0
- package/dist/migrate-knowledge-v2.js +158 -0
- package/dist/migrate-next-actions.d.ts +6 -0
- package/dist/migrate-next-actions.js +32 -0
- package/dist/migrate-types.d.ts +11 -0
- package/dist/migrate-types.js +142 -0
- package/dist/parser.d.ts +18 -0
- package/dist/parser.js +68 -0
- package/dist/query.d.ts +24 -0
- package/dist/query.js +167 -0
- package/dist/schema.d.ts +9 -0
- package/dist/schema.js +59 -0
- package/dist/sections.d.ts +54 -0
- package/dist/sections.js +297 -0
- package/dist/templates.d.ts +8 -0
- package/dist/templates.js +39 -0
- package/dist/types.d.ts +321 -0
- package/dist/types.js +11 -0
- package/dist/vault.d.ts +12 -0
- package/dist/vault.js +45 -0
- package/package.json +67 -0
- 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
|
+
}
|
package/dist/digest.d.ts
ADDED
|
@@ -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[];
|