hafez 1.3.0 → 2.0.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/README.md +83 -32
- package/dist/apply.d.ts +33 -0
- package/dist/apply.js +693 -0
- package/dist/batch-ops.d.ts +372 -26
- package/dist/batch-ops.js +355 -73
- package/dist/cli/args.d.ts +48 -6
- package/dist/cli/args.js +74 -9
- package/dist/cli/batch-errors.js +19 -11
- package/dist/cli/block-hook.d.ts +9 -0
- package/dist/cli/block-hook.js +90 -0
- package/dist/cli/commands-schema.js +2 -0
- package/dist/cli/commands.d.ts +28 -3
- package/dist/cli/commands.js +440 -157
- package/dist/cli/format.d.ts +7 -3
- package/dist/cli/format.js +27 -10
- package/dist/cli/index.d.ts +55 -0
- package/dist/cli/index.js +215 -153
- package/dist/cli/install.d.ts +53 -0
- package/dist/cli/install.js +297 -0
- package/dist/cli/onboard.d.ts +15 -2
- package/dist/cli/onboard.js +265 -90
- package/dist/cli/resolve-vault.d.ts +11 -4
- package/dist/cli/resolve-vault.js +13 -11
- package/dist/cli/schema-introspect.js +4 -3
- package/dist/cli/upgrade.d.ts +51 -0
- package/dist/cli/upgrade.js +351 -0
- package/dist/closest.d.ts +8 -0
- package/dist/closest.js +65 -0
- package/dist/contracts.d.ts +65 -6
- package/dist/contracts.js +148 -23
- package/dist/dates.d.ts +10 -0
- package/dist/dates.js +27 -0
- package/dist/db.d.ts +40 -2
- package/dist/db.js +363 -66
- package/dist/digest.d.ts +6 -4
- package/dist/digest.js +28 -17
- package/dist/document.d.ts +135 -88
- package/dist/document.js +665 -379
- package/dist/export-okf.d.ts +13 -0
- package/dist/export-okf.js +50 -20
- package/dist/git.js +165 -48
- package/dist/harness.d.ts +58 -0
- package/dist/harness.js +50 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +301 -826
- package/dist/knowledge-index.d.ts +23 -1
- package/dist/knowledge-index.js +62 -6
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +98 -64
- package/dist/near-match.d.ts +9 -0
- package/dist/near-match.js +34 -0
- package/dist/schema.js +4 -4
- package/dist/stale.d.ts +41 -0
- package/dist/stale.js +43 -0
- package/dist/stopwords.d.ts +6 -0
- package/dist/stopwords.js +25 -0
- package/dist/surfaces/items.d.ts +366 -0
- package/dist/surfaces/items.js +523 -0
- package/dist/surfaces/surfaces.d.ts +32 -0
- package/dist/surfaces/surfaces.js +230 -0
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +78 -22
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +109 -0
- package/dist/vault.d.ts +18 -0
- package/dist/vault.js +51 -5
- package/package.json +4 -3
- package/skill/hafez/SKILL.md +68 -0
- package/skill/hafez-save/SKILL.md +29 -0
- package/dist/cli/help-agent.d.ts +0 -1
- package/dist/cli/help-agent.js +0 -186
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -169
- package/skill/SKILL.md +0 -196
package/dist/db.js
CHANGED
|
@@ -4,14 +4,27 @@ import { readFileSync, statSync, existsSync, unlinkSync } from 'fs';
|
|
|
4
4
|
import { join, basename } from 'path';
|
|
5
5
|
import { parseFilePath, scanVaultDir, kindDir, INDEXED_KINDS } from './vault.js';
|
|
6
6
|
import { ensureLocalExclude } from './knowledge-index.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { WIKI_LINK_RE, documentForFile } from './document.js';
|
|
8
|
+
import { ENTITY_STATUSES, ENTITY_TYPES, CONFIDENCE_LEVELS, VALID_SUBTYPES, DEFAULT_KNOWLEDGE_SUBTYPE } from './contracts.js';
|
|
9
|
+
import { HafezError } from './types.js';
|
|
10
|
+
import { STOPWORDS } from './stopwords.js';
|
|
11
|
+
import { ENTITY_AGE_DAYS_SQL, ENTITY_STALE_SQL } from './stale.js';
|
|
12
|
+
import { localDay } from './dates.js';
|
|
9
13
|
// Bump this when indexing logic changes to force a full rebuild.
|
|
10
14
|
// v5: wiki link harvesting (mention relations), parent link indexing, relation filter on queries.
|
|
11
15
|
// v6: description column on items.
|
|
12
16
|
// v7: resource column on items (entities only).
|
|
17
|
+
// v8: per-kind structural section binding (documentFor) changes how brief,
|
|
18
|
+
// next-action, and session-log columns are derived from bodies.
|
|
19
|
+
// v9: Document binds the kind-level section union (no type/subtype
|
|
20
|
+
// narrowing), changing section boundaries body columns derive from.
|
|
21
|
+
// v10: re-indexing an externally changed file is delete-then-insert instead of
|
|
22
|
+
// INSERT OR REPLACE. Indexes written by v9 and earlier may hold ghost FTS
|
|
23
|
+
// rows (REPLACE never fired the delete trigger) and must be rebuilt, not reused.
|
|
24
|
+
// v11: next_action and the searchable body store action text with its
|
|
25
|
+
// added-date stamp stripped.
|
|
13
26
|
// Existing instances will fully rebuild the index on first access after upgrade.
|
|
14
|
-
export const SCHEMA_VERSION = '
|
|
27
|
+
export const SCHEMA_VERSION = '11';
|
|
15
28
|
const SCHEMA = `
|
|
16
29
|
CREATE TABLE IF NOT EXISTS items (
|
|
17
30
|
slug TEXT PRIMARY KEY,
|
|
@@ -71,28 +84,77 @@ CREATE TABLE IF NOT EXISTS meta (
|
|
|
71
84
|
value TEXT NOT NULL
|
|
72
85
|
);
|
|
73
86
|
`;
|
|
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
87
|
function hashContent(content) {
|
|
87
88
|
return createHash('sha256').update(content).digest('hex');
|
|
88
89
|
}
|
|
90
|
+
// --- Query input validation ---
|
|
91
|
+
//
|
|
92
|
+
// Every enum-ish query opt is checked before it can influence (or fail to
|
|
93
|
+
// influence) SQL. The mappers below only forward values they recognise, so an
|
|
94
|
+
// unchecked typo used to be *dropped*: `--filter actvie` ran unfiltered and
|
|
95
|
+
// exited 0. Validating here covers the CLI and library callers at once.
|
|
96
|
+
const SORT_COL_MAP = {
|
|
97
|
+
name: 'i.name',
|
|
98
|
+
created: 'i.created',
|
|
99
|
+
last_touched: 'i.last_touched',
|
|
100
|
+
staleness: 'i.staleness_days',
|
|
101
|
+
reinforcement_count: 'i.reinforcement_count',
|
|
102
|
+
last_reinforced: 'i.last_reinforced',
|
|
103
|
+
};
|
|
104
|
+
const KNOWLEDGE_ONLY_SORTS = new Set(['reinforcement_count', 'last_reinforced']);
|
|
105
|
+
const SORT_FIELDS = Object.keys(SORT_COL_MAP);
|
|
106
|
+
const SORT_ORDERS = ['asc', 'desc'];
|
|
107
|
+
const QUERY_FILTERS = [...ENTITY_STATUSES, 'all', 'stale', 'capture'];
|
|
108
|
+
function assertEnum(value, valid, label) {
|
|
109
|
+
if (value === undefined || value === null)
|
|
110
|
+
return;
|
|
111
|
+
if (typeof value !== 'string' || !valid.includes(value)) {
|
|
112
|
+
throw new HafezError('VALIDATION_FAILED', `Invalid ${label}: "${String(value)}". Valid values: ${valid.join(', ')}.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function assertSortOrder(value) {
|
|
116
|
+
if (value === undefined || value === null)
|
|
117
|
+
return;
|
|
118
|
+
if (typeof value !== 'string' || !SORT_ORDERS.includes(value.toLowerCase())) {
|
|
119
|
+
throw new HafezError('VALIDATION_FAILED', `Invalid sort_order: "${String(value)}". Valid values: asc, desc.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function assertCount(value, label) {
|
|
123
|
+
if (value === undefined || value === null)
|
|
124
|
+
return;
|
|
125
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
126
|
+
throw new HafezError('VALIDATION_FAILED', `Invalid ${label}: "${String(value)}". Must be a non-negative integer.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
130
|
+
function validateTimeOpts(opts) {
|
|
131
|
+
const fields = ['since', 'before', 'createdSince', 'createdBefore'];
|
|
132
|
+
for (const f of fields) {
|
|
133
|
+
if (opts?.[f] !== undefined && !ISO_DATE.test(opts[f])) {
|
|
134
|
+
throw new HafezError('VALIDATION_FAILED', `Invalid date format for ${f}: "${opts[f]}". Must be YYYY-MM-DD.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function validateListOpts(opts) {
|
|
139
|
+
assertEnum(opts?.sort_by, SORT_FIELDS, 'sort_by');
|
|
140
|
+
assertSortOrder(opts?.sort_order);
|
|
141
|
+
assertCount(opts?.limit, 'limit');
|
|
142
|
+
assertCount(opts?.offset, 'offset');
|
|
143
|
+
}
|
|
89
144
|
// --- Shared query functions (used by both write and readonly paths) ---
|
|
90
145
|
function sanitizeFtsQuery(raw) {
|
|
91
|
-
|
|
146
|
+
// Unicode-aware: keep letters/digits/underscore in ANY script, strip
|
|
147
|
+
// punctuation. `[^\w\s]` was ASCII-only, so it erased whole non-Latin
|
|
148
|
+
// queries (日本語 → '') and the vault's non-Latin notes were unfindable.
|
|
149
|
+
// For ASCII input this class is identical to the old one.
|
|
150
|
+
const cleaned = raw.replace(/[^\p{L}\p{N}_\s]/gu, ' ');
|
|
92
151
|
const tokens = cleaned.split(/\s+/).filter(t => t.length > 0);
|
|
93
152
|
const filtered = tokens.filter(t => !STOPWORDS.has(t.toLowerCase()));
|
|
153
|
+
// All-stopword queries fall back to the raw tokens — which is how bare
|
|
154
|
+
// AND/OR/NOT reach fts5. Quoting makes every term a literal string, never
|
|
155
|
+
// an operator, and a trailing `*` still gives prefix matching.
|
|
94
156
|
const terms = filtered.length === 0 ? tokens : filtered;
|
|
95
|
-
return { fts: terms.map(t =>
|
|
157
|
+
return { fts: terms.map(t => `"${t.replace(/"/g, '""')}"*`).join(' OR '), terms };
|
|
96
158
|
}
|
|
97
159
|
function queryItemsFn(db, opts) {
|
|
98
160
|
const conditions = [];
|
|
@@ -102,7 +164,11 @@ function queryItemsFn(db, opts) {
|
|
|
102
164
|
params.kind = opts.kind;
|
|
103
165
|
}
|
|
104
166
|
if (opts.status) {
|
|
105
|
-
|
|
167
|
+
// Status is an entity attribute — knowledge rows are indexed with a NULL
|
|
168
|
+
// status. In a kind-unset (unified) query the status filter narrows the
|
|
169
|
+
// entity side only; applying it to every row is what made `query --kind
|
|
170
|
+
// all` (filter defaults to active) return entities and nothing else.
|
|
171
|
+
conditions.push(opts.kind === 'entity' ? 'i.status = @status' : "(i.kind = 'knowledge' OR i.status = @status)");
|
|
106
172
|
params.status = opts.status;
|
|
107
173
|
}
|
|
108
174
|
if (opts.type) {
|
|
@@ -140,13 +206,8 @@ function queryItemsFn(db, opts) {
|
|
|
140
206
|
if (opts.filter === 'stale') {
|
|
141
207
|
conditions.push("i.kind = 'entity'");
|
|
142
208
|
conditions.push("i.status = 'active'");
|
|
143
|
-
conditions.push(`(
|
|
144
|
-
|
|
145
|
-
CASE
|
|
146
|
-
WHEN i.staleness_days IS NOT NULL THEN i.staleness_days
|
|
147
|
-
ELSE 14
|
|
148
|
-
END
|
|
149
|
-
)`);
|
|
209
|
+
conditions.push(`(${ENTITY_STALE_SQL})`);
|
|
210
|
+
params.today = localDay();
|
|
150
211
|
}
|
|
151
212
|
if (opts.filter === 'capture') {
|
|
152
213
|
conditions.push("i.type = 'capture'");
|
|
@@ -175,25 +236,21 @@ function queryItemsFn(db, opts) {
|
|
|
175
236
|
}
|
|
176
237
|
}
|
|
177
238
|
const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
reinforcement_count: 'i.reinforcement_count',
|
|
185
|
-
last_reinforced: 'i.last_reinforced',
|
|
186
|
-
};
|
|
239
|
+
// sort_order / limit / offset are the three opts that reach SQL as text
|
|
240
|
+
// rather than bound params — check them at the interpolation site, so a
|
|
241
|
+
// caller that skips the read surface can't hand raw SQL to prepare().
|
|
242
|
+
assertSortOrder(opts.sort_order);
|
|
243
|
+
assertCount(opts.limit, 'limit');
|
|
244
|
+
assertCount(opts.offset, 'offset');
|
|
187
245
|
let sortCol = 'i.last_touched';
|
|
188
246
|
if (opts.sort_by) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
else if (sortColMap[opts.sort_by]) {
|
|
195
|
-
sortCol = sortColMap[opts.sort_by];
|
|
247
|
+
// Same rule as the unmappable opts, one code path over: a sort field the
|
|
248
|
+
// branch cannot honour is rejected, not quietly swapped for recency.
|
|
249
|
+
if (KNOWLEDGE_ONLY_SORTS.has(opts.sort_by) && opts.kind === 'entity') {
|
|
250
|
+
outOfScope('sort_by', opts.sort_by, 'entity');
|
|
196
251
|
}
|
|
252
|
+
if (SORT_COL_MAP[opts.sort_by])
|
|
253
|
+
sortCol = SORT_COL_MAP[opts.sort_by];
|
|
197
254
|
}
|
|
198
255
|
let sortOrder;
|
|
199
256
|
if (opts.sort_order) {
|
|
@@ -237,6 +294,18 @@ function queryItemsFn(db, opts) {
|
|
|
237
294
|
}
|
|
238
295
|
return { items: rows, total };
|
|
239
296
|
}
|
|
297
|
+
/** Every indexed document, in the shape index.md renders from. The vault-root
|
|
298
|
+
* index needs name/status/type/confidence/description/domain and the body (for
|
|
299
|
+
* the derived summary) — all of it already indexed, so regenerating index.md
|
|
300
|
+
* after a mutation costs one query instead of a second full-vault parse. */
|
|
301
|
+
function indexEntriesFn(db) {
|
|
302
|
+
const rows = db.prepare('SELECT slug, kind, name, type, status, confidence, description, body FROM items').all();
|
|
303
|
+
const getDomains = db.prepare("SELECT target FROM links WHERE source = ? AND relation = 'domain'");
|
|
304
|
+
return rows.map(row => ({
|
|
305
|
+
...row,
|
|
306
|
+
domain: getDomains.all(row.slug).map(d => d.target),
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
240
309
|
function searchFn(db, query, kind) {
|
|
241
310
|
const { fts: ftsQuery, terms } = sanitizeFtsQuery(query);
|
|
242
311
|
if (!ftsQuery)
|
|
@@ -297,16 +366,11 @@ function getStatsFn(db) {
|
|
|
297
366
|
by_type[row.type] = row.cnt;
|
|
298
367
|
}
|
|
299
368
|
const stale = db.prepare(`
|
|
300
|
-
SELECT slug, name, type,
|
|
301
|
-
FROM items
|
|
302
|
-
WHERE kind = 'entity' AND status = 'active'
|
|
303
|
-
AND julianday('now') - julianday(last_touched) >
|
|
304
|
-
CASE
|
|
305
|
-
WHEN staleness_days IS NOT NULL THEN staleness_days
|
|
306
|
-
ELSE 14
|
|
307
|
-
END
|
|
369
|
+
SELECT slug, name, type, ${ENTITY_AGE_DAYS_SQL} as days_since_touched
|
|
370
|
+
FROM items i
|
|
371
|
+
WHERE kind = 'entity' AND status = 'active' AND ${ENTITY_STALE_SQL}
|
|
308
372
|
ORDER BY days_since_touched DESC
|
|
309
|
-
`).all();
|
|
373
|
+
`).all({ today: localDay() });
|
|
310
374
|
const no_next_action = db.prepare(`
|
|
311
375
|
SELECT slug, name, type FROM items
|
|
312
376
|
WHERE kind = 'entity' AND status = 'active' AND next_action_count = 0
|
|
@@ -337,6 +401,17 @@ function getStatsFn(db) {
|
|
|
337
401
|
recently_created,
|
|
338
402
|
};
|
|
339
403
|
}
|
|
404
|
+
// Internal test seam — NOT public API (createHafez never exposes it). Fires
|
|
405
|
+
// inside syncIfStale after the scan has read the vault and before the new
|
|
406
|
+
// watermark is recorded, so the mid-scan-write race can be injected
|
|
407
|
+
// deterministically instead of raced against the clock.
|
|
408
|
+
// Recorded sync watermarks sit this far behind the scan-start instant: FS
|
|
409
|
+
// timestamps and Date.now() are read from the same kernel clock but round
|
|
410
|
+
// differently, and a write landing in that rounding gap must stay dirty.
|
|
411
|
+
const WATERMARK_SLACK_MS = 10;
|
|
412
|
+
let afterScanHook = null;
|
|
413
|
+
/** @internal */
|
|
414
|
+
export function __setAfterScanHook(fn) { afterScanHook = fn; }
|
|
340
415
|
export function createIndex(vaultPath, opts) {
|
|
341
416
|
const dbPath = join(vaultPath, '.hafez.db');
|
|
342
417
|
const isReadOnly = opts?.readonly ?? false;
|
|
@@ -379,7 +454,7 @@ export function createIndex(vaultPath, opts) {
|
|
|
379
454
|
}
|
|
380
455
|
catch { /* DB may have been temporarily deleted during rebuild */ }
|
|
381
456
|
}
|
|
382
|
-
return {
|
|
457
|
+
return withReadSurface({
|
|
383
458
|
rebuild: () => { throw new Error('Index is read-only'); },
|
|
384
459
|
syncIfStale,
|
|
385
460
|
upsertFromFile: () => { throw new Error('Index is read-only'); },
|
|
@@ -387,11 +462,12 @@ export function createIndex(vaultPath, opts) {
|
|
|
387
462
|
queryItems: (qopts) => queryItemsFn(db, qopts),
|
|
388
463
|
search: (q, k) => searchFn(db, q, k),
|
|
389
464
|
getStats: () => getStatsFn(db),
|
|
465
|
+
indexEntries: () => indexEntriesFn(db),
|
|
390
466
|
close: () => { try {
|
|
391
467
|
db.close();
|
|
392
468
|
}
|
|
393
469
|
catch { } },
|
|
394
|
-
};
|
|
470
|
+
});
|
|
395
471
|
}
|
|
396
472
|
// --- Write-capable path ---
|
|
397
473
|
// The db files are a disposable local cache — keep them out of the user's
|
|
@@ -469,8 +545,12 @@ export function createIndex(vaultPath, opts) {
|
|
|
469
545
|
db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(SCHEMA_VERSION);
|
|
470
546
|
}
|
|
471
547
|
// Prepared statements
|
|
472
|
-
|
|
473
|
-
|
|
548
|
+
// Plain INSERT, never INSERT OR REPLACE: REPLACE's implicit delete does not
|
|
549
|
+
// fire the AFTER DELETE trigger (recursive_triggers is off), so the replaced
|
|
550
|
+
// row's terms would linger in the external-content FTS index as ghosts.
|
|
551
|
+
// Callers delete the row first — see indexFile.
|
|
552
|
+
const insertStmt = db.prepare(`
|
|
553
|
+
INSERT INTO items
|
|
474
554
|
(slug, kind, name, type, status, parent, next_action, next_action_count, staleness_days,
|
|
475
555
|
confidence, reinforcement_count, last_reinforced, created, last_touched, body, file_hash, subtype,
|
|
476
556
|
description, resource, brief, session_log_count, last_session_date, last_session_type, last_session_summary)
|
|
@@ -507,11 +587,12 @@ export function createIndex(vaultPath, opts) {
|
|
|
507
587
|
process.stderr.write(`Warning: skipping ${kind}/${slug}.md: missing required field(s): ${missing}\n`);
|
|
508
588
|
return;
|
|
509
589
|
}
|
|
510
|
-
const
|
|
590
|
+
const doc = documentForFile({ kind });
|
|
591
|
+
const bodyActions = doc.getNextActions(body);
|
|
511
592
|
const nextAction = bodyActions.length > 0 ? bodyActions[0] : null;
|
|
512
593
|
const nextActionCount = bodyActions.length;
|
|
513
|
-
const brief = getBrief(body)?.slice(0, 300) ?? null;
|
|
514
|
-
const logs = parseSessionLog(body);
|
|
594
|
+
const brief = doc.getBrief(body)?.slice(0, 300) ?? null;
|
|
595
|
+
const logs = doc.parseSessionLog(body);
|
|
515
596
|
const lastLog = logs[0] ?? null;
|
|
516
597
|
const item = {
|
|
517
598
|
slug,
|
|
@@ -528,9 +609,10 @@ export function createIndex(vaultPath, opts) {
|
|
|
528
609
|
last_reinforced: fm['last-reinforced'] ?? null,
|
|
529
610
|
created: fm.created,
|
|
530
611
|
last_touched: fm['last-touched'] ?? null,
|
|
531
|
-
|
|
612
|
+
// Search sees action text, not the added-date stamp (file format only).
|
|
613
|
+
body: doc.stripNextActionStamps(body),
|
|
532
614
|
file_hash: hash,
|
|
533
|
-
subtype: kind === 'knowledge' ? (fm.subtype ??
|
|
615
|
+
subtype: kind === 'knowledge' ? (fm.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE) : null,
|
|
534
616
|
description: fm.description ?? null,
|
|
535
617
|
resource: kind === 'entity' ? (fm.resource ?? null) : null,
|
|
536
618
|
brief,
|
|
@@ -539,9 +621,12 @@ export function createIndex(vaultPath, opts) {
|
|
|
539
621
|
last_session_type: lastLog?.type ?? null,
|
|
540
622
|
last_session_summary: lastLog?.summary?.slice(0, 300) ?? null,
|
|
541
623
|
};
|
|
542
|
-
//
|
|
624
|
+
// Replace item and rebuild links. Delete-then-insert keeps the FTS index
|
|
625
|
+
// trigger-consistent for externally changed files (hand edit, git pull),
|
|
626
|
+
// the same path hafez's own mutations take via upsertFromFile.
|
|
627
|
+
deleteItemStmt.run(slug);
|
|
543
628
|
deleteLinksStmt.run(slug);
|
|
544
|
-
|
|
629
|
+
insertStmt.run(item);
|
|
545
630
|
// Index related links
|
|
546
631
|
if (fm.related && Array.isArray(fm.related)) {
|
|
547
632
|
for (const target of fm.related)
|
|
@@ -564,7 +649,7 @@ export function createIndex(vaultPath, opts) {
|
|
|
564
649
|
// Harvest [[wiki links]] from body (excluding ## Related section)
|
|
565
650
|
// Strip the Related section to avoid double-counting generated links
|
|
566
651
|
let authoredBody = body;
|
|
567
|
-
const relatedSection = findSection(body, 'Related');
|
|
652
|
+
const relatedSection = doc.findSection(body, 'Related');
|
|
568
653
|
if (relatedSection) {
|
|
569
654
|
authoredBody = body.slice(0, relatedSection.start) + body.slice(relatedSection.end);
|
|
570
655
|
}
|
|
@@ -584,6 +669,8 @@ export function createIndex(vaultPath, opts) {
|
|
|
584
669
|
}
|
|
585
670
|
}
|
|
586
671
|
const rebuildTransaction = db.transaction(() => {
|
|
672
|
+
// Watermark the scan by when it STARTED (see syncIfStale).
|
|
673
|
+
const scanStart = Date.now() - WATERMARK_SLACK_MS;
|
|
587
674
|
db.exec('DELETE FROM items');
|
|
588
675
|
db.exec('DELETE FROM links');
|
|
589
676
|
// Collect all vault slugs for wiki link target validation
|
|
@@ -601,7 +688,7 @@ export function createIndex(vaultPath, opts) {
|
|
|
601
688
|
indexFile(file, 'knowledge', allVaultSlugs);
|
|
602
689
|
}
|
|
603
690
|
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_sync', ?)")
|
|
604
|
-
.run(
|
|
691
|
+
.run(scanStart.toString());
|
|
605
692
|
});
|
|
606
693
|
function rebuild() {
|
|
607
694
|
rebuildTransaction();
|
|
@@ -614,10 +701,19 @@ export function createIndex(vaultPath, opts) {
|
|
|
614
701
|
}
|
|
615
702
|
const lastSyncTime = parseInt(lastSync.value, 10);
|
|
616
703
|
let changed = false;
|
|
704
|
+
// Watermark the scan by when it STARTED, not when it finished. A file
|
|
705
|
+
// written while the scan is in flight — after its read, before the record —
|
|
706
|
+
// would otherwise be stamped as already-indexed and skipped by every later
|
|
707
|
+
// sync, leaving it permanently stale. `>=` covers the same-millisecond
|
|
708
|
+
// case; a re-read of an unchanged file is a cheap hash compare. The slack
|
|
709
|
+
// absorbs FS-vs-Date.now clock rounding at the boundary — a file whose
|
|
710
|
+
// mtime rounds just below the instant the scan started must still read as
|
|
711
|
+
// dirty; the only cost is re-hashing files touched inside the window.
|
|
712
|
+
const scanStart = Date.now() - WATERMARK_SLACK_MS;
|
|
617
713
|
const dirs = INDEXED_KINDS.map(kind => ({ dir: kindDir(kind), kind }));
|
|
618
714
|
for (const { dir, kind } of dirs) {
|
|
619
715
|
for (const file of scanVaultDir(join(vaultPath, dir))) {
|
|
620
|
-
if (statSync(file).mtimeMs
|
|
716
|
+
if (statSync(file).mtimeMs >= lastSyncTime) {
|
|
621
717
|
indexFile(file, kind);
|
|
622
718
|
changed = true;
|
|
623
719
|
}
|
|
@@ -637,9 +733,10 @@ export function createIndex(vaultPath, opts) {
|
|
|
637
733
|
changed = true;
|
|
638
734
|
}
|
|
639
735
|
}
|
|
736
|
+
afterScanHook?.();
|
|
640
737
|
if (changed) {
|
|
641
738
|
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_sync', ?)")
|
|
642
|
-
.run(
|
|
739
|
+
.run(scanStart.toString());
|
|
643
740
|
}
|
|
644
741
|
}
|
|
645
742
|
function upsertFromFile(slug, kind) {
|
|
@@ -659,11 +756,211 @@ export function createIndex(vaultPath, opts) {
|
|
|
659
756
|
const count = db.prepare('SELECT COUNT(*) as c FROM items').get().c;
|
|
660
757
|
if (count === 0)
|
|
661
758
|
rebuild();
|
|
662
|
-
return {
|
|
759
|
+
return withReadSurface({
|
|
663
760
|
rebuild, syncIfStale, upsertFromFile, removeItem,
|
|
664
761
|
queryItems: (qopts) => queryItemsFn(db, qopts),
|
|
665
762
|
search: (q, k) => searchFn(db, q, k),
|
|
666
763
|
getStats: () => getStatsFn(db),
|
|
764
|
+
indexEntries: () => indexEntriesFn(db),
|
|
667
765
|
close: () => db.close(),
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
function withReadSurface(core) {
|
|
769
|
+
const index = {
|
|
770
|
+
...core,
|
|
771
|
+
queryEntities: (opts) => queryEntitiesFn(index, opts),
|
|
772
|
+
queryChildren: (parentSlug) => queryEntitiesFn(index, { parent: parentSlug }),
|
|
773
|
+
queryRelatedTo: (slug, relation) => queryRelatedToFn(index, slug, relation),
|
|
774
|
+
queryKnowledge: (opts) => queryKnowledgeFn(index, opts),
|
|
775
|
+
queryUnified: (opts) => queryUnifiedFn(index, opts),
|
|
668
776
|
};
|
|
777
|
+
return index;
|
|
778
|
+
}
|
|
779
|
+
function toQueryResult(slug, row) {
|
|
780
|
+
return {
|
|
781
|
+
slug,
|
|
782
|
+
name: row.name,
|
|
783
|
+
type: row.type,
|
|
784
|
+
status: row.status,
|
|
785
|
+
description: row.description ?? null,
|
|
786
|
+
resource: row.resource ?? null,
|
|
787
|
+
domain: row.domain ?? [],
|
|
788
|
+
last_touched: row.last_touched,
|
|
789
|
+
next_action: row.next_action ?? null,
|
|
790
|
+
next_action_count: row.next_action_count ?? 0,
|
|
791
|
+
parent: row.parent ?? null,
|
|
792
|
+
related: row.related ?? [],
|
|
793
|
+
staleness_days: row.staleness_days ?? null,
|
|
794
|
+
tags: row.tags ?? [],
|
|
795
|
+
brief: row.brief ?? null,
|
|
796
|
+
session_log_count: row.session_log_count ?? 0,
|
|
797
|
+
last_session_date: row.last_session_date ?? null,
|
|
798
|
+
last_session_type: row.last_session_type ?? null,
|
|
799
|
+
last_session_summary: row.last_session_summary ?? null,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
function toKnowledgeResult(slug, row) {
|
|
803
|
+
return {
|
|
804
|
+
slug,
|
|
805
|
+
name: row.name,
|
|
806
|
+
description: row.description ?? null,
|
|
807
|
+
domain: row.domain ?? [],
|
|
808
|
+
confidence: row.confidence ?? 'observation',
|
|
809
|
+
related: row.related ?? [],
|
|
810
|
+
reinforcement_count: row.reinforcement_count ?? 0,
|
|
811
|
+
last_reinforced: row.last_reinforced ?? null,
|
|
812
|
+
last_touched: row.last_touched ?? null,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
const STATUS_FILTERS = new Set(ENTITY_STATUSES);
|
|
816
|
+
function mapEntityFilterOpts(qopts, opts) {
|
|
817
|
+
if (opts?.filter && STATUS_FILTERS.has(opts.filter))
|
|
818
|
+
qopts.status = opts.filter;
|
|
819
|
+
if (opts?.filter === 'stale')
|
|
820
|
+
qopts.filter = 'stale';
|
|
821
|
+
if (opts?.filter === 'capture')
|
|
822
|
+
qopts.filter = 'capture';
|
|
823
|
+
if (opts?.type)
|
|
824
|
+
qopts.type = opts.type;
|
|
825
|
+
if (opts?.parent)
|
|
826
|
+
qopts.parent = opts.parent;
|
|
827
|
+
if (opts?.domain)
|
|
828
|
+
qopts.domain = opts.domain;
|
|
829
|
+
}
|
|
830
|
+
function mapTimeOpts(qopts, opts) {
|
|
831
|
+
if (opts?.since)
|
|
832
|
+
qopts.since = opts.since;
|
|
833
|
+
if (opts?.before)
|
|
834
|
+
qopts.before = opts.before;
|
|
835
|
+
if (opts?.createdSince)
|
|
836
|
+
qopts.createdSince = opts.createdSince;
|
|
837
|
+
if (opts?.createdBefore)
|
|
838
|
+
qopts.createdBefore = opts.createdBefore;
|
|
839
|
+
}
|
|
840
|
+
function mapListOpts(qopts, opts) {
|
|
841
|
+
if (opts?.tags)
|
|
842
|
+
qopts.tags = opts.tags;
|
|
843
|
+
if (opts?.sort_by)
|
|
844
|
+
qopts.sort_by = opts.sort_by;
|
|
845
|
+
if (opts?.sort_order)
|
|
846
|
+
qopts.sort_order = opts.sort_order;
|
|
847
|
+
if (opts?.limit != null)
|
|
848
|
+
qopts.limit = opts.limit;
|
|
849
|
+
if (opts?.offset != null)
|
|
850
|
+
qopts.offset = opts.offset;
|
|
851
|
+
}
|
|
852
|
+
function mapKnowledgeFilterOpts(qopts, opts) {
|
|
853
|
+
if (opts?.domain)
|
|
854
|
+
qopts.domain = opts.domain;
|
|
855
|
+
if (opts?.confidence)
|
|
856
|
+
qopts.confidence = opts.confidence;
|
|
857
|
+
if (opts?.subtype)
|
|
858
|
+
qopts.subtype = opts.subtype;
|
|
859
|
+
}
|
|
860
|
+
function validateEntityFilterOpts(opts) {
|
|
861
|
+
assertEnum(opts?.filter, QUERY_FILTERS, 'filter');
|
|
862
|
+
assertEnum(opts?.type, ENTITY_TYPES, 'type');
|
|
863
|
+
}
|
|
864
|
+
/** Opts a branch cannot map into SQL. Declared per branch and rejected on
|
|
865
|
+
* sight: the CLI has rejected these since 43124ba, but the rule lived only
|
|
866
|
+
* there, so a library caller got the silent drop the CLI had already closed.
|
|
867
|
+
* Asserted against the CLI's flag matrix in query-surface.test.ts. */
|
|
868
|
+
export const ENTITY_ONLY_OPTS = ['filter', 'type', 'parent'];
|
|
869
|
+
export const KNOWLEDGE_ONLY_OPTS = ['confidence', 'subtype'];
|
|
870
|
+
/** The one sentence both scope rejections speak — opts here, sort fields in
|
|
871
|
+
* queryItemsFn — so a caller sees the same shape whichever one they hit. */
|
|
872
|
+
function outOfScope(label, name, branch) {
|
|
873
|
+
const belongsTo = branch === 'entity' ? 'knowledge notes only' : 'entities only';
|
|
874
|
+
throw new HafezError('VALIDATION_FAILED', `Invalid ${label} for a ${branch} query: "${name}" — ${belongsTo}. Use kind: 'all' to query both.`);
|
|
875
|
+
}
|
|
876
|
+
function rejectUnmappableOpts(opts, reject, branch) {
|
|
877
|
+
if (!opts)
|
|
878
|
+
return;
|
|
879
|
+
// Only a *defined* value is a request — an explicit `undefined` (what a
|
|
880
|
+
// spread of unset CLI flags produces) asks for nothing.
|
|
881
|
+
for (const name of reject)
|
|
882
|
+
if (opts[name] !== undefined)
|
|
883
|
+
outOfScope('opt', name, branch);
|
|
884
|
+
}
|
|
885
|
+
function validateKnowledgeFilterOpts(opts) {
|
|
886
|
+
assertEnum(opts?.confidence, CONFIDENCE_LEVELS, 'confidence');
|
|
887
|
+
assertEnum(opts?.subtype, VALID_SUBTYPES, 'subtype');
|
|
888
|
+
}
|
|
889
|
+
function queryEntitiesFn(index, opts) {
|
|
890
|
+
index.syncIfStale();
|
|
891
|
+
validateEntityFilterOpts(opts);
|
|
892
|
+
// Was validateKnowledgeFilterOpts — it checked the *value* of an opt this
|
|
893
|
+
// branch then discarded, so a valid-looking one returned unfiltered.
|
|
894
|
+
rejectUnmappableOpts(opts, KNOWLEDGE_ONLY_OPTS, 'entity');
|
|
895
|
+
validateTimeOpts(opts);
|
|
896
|
+
validateListOpts(opts);
|
|
897
|
+
const qopts = { kind: 'entity' };
|
|
898
|
+
mapEntityFilterOpts(qopts, opts);
|
|
899
|
+
mapTimeOpts(qopts, opts);
|
|
900
|
+
mapListOpts(qopts, opts);
|
|
901
|
+
const { items, total } = index.queryItems(qopts);
|
|
902
|
+
return { items: items.map(i => toQueryResult(i.slug, i)), total };
|
|
903
|
+
}
|
|
904
|
+
function queryRelatedToFn(index, slug, relation) {
|
|
905
|
+
index.syncIfStale();
|
|
906
|
+
const { items, total } = index.queryItems({ relatedTo: slug, relation });
|
|
907
|
+
return {
|
|
908
|
+
items: items.map(i => i.kind === 'entity'
|
|
909
|
+
? { ...toQueryResult(i.slug, i), kind: 'entity' }
|
|
910
|
+
: { ...toKnowledgeResult(i.slug, i), kind: 'knowledge' }),
|
|
911
|
+
total,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
function queryKnowledgeFn(index, opts) {
|
|
915
|
+
index.syncIfStale();
|
|
916
|
+
validateKnowledgeFilterOpts(opts);
|
|
917
|
+
rejectUnmappableOpts(opts, ENTITY_ONLY_OPTS, 'knowledge');
|
|
918
|
+
validateTimeOpts(opts);
|
|
919
|
+
validateListOpts(opts);
|
|
920
|
+
const qopts = { kind: 'knowledge' };
|
|
921
|
+
mapKnowledgeFilterOpts(qopts, opts);
|
|
922
|
+
mapTimeOpts(qopts, opts);
|
|
923
|
+
mapListOpts(qopts, opts);
|
|
924
|
+
const { items, total } = index.queryItems(qopts);
|
|
925
|
+
return { items: items.map(i => toKnowledgeResult(i.slug, i)), total };
|
|
926
|
+
}
|
|
927
|
+
function queryUnifiedFn(index, opts) {
|
|
928
|
+
index.syncIfStale();
|
|
929
|
+
// The public type is a union discriminated on `kind`, so an out-of-scope opt
|
|
930
|
+
// is a compile error. The runtime still has to see every opt to reject it —
|
|
931
|
+
// an untyped caller (JS, JSON over MCP) reaches here regardless — so widen
|
|
932
|
+
// once, here, and let the checks below speak for both.
|
|
933
|
+
const o = opts;
|
|
934
|
+
assertEnum(o?.kind, ['entity', 'knowledge', 'all'], 'kind');
|
|
935
|
+
validateEntityFilterOpts(o);
|
|
936
|
+
validateKnowledgeFilterOpts(o);
|
|
937
|
+
// Only `kind: 'all'` (or unset) maps both sides. `kind` also narrows this
|
|
938
|
+
// query to one of them, and a narrowed branch maps no more than the
|
|
939
|
+
// dedicated one does: the out-of-scope conditions land on columns the
|
|
940
|
+
// selected kind leaves NULL, so every row is excluded and the caller gets
|
|
941
|
+
// 0 results with exit 0 — the same wrong answer, one function over.
|
|
942
|
+
if (o?.kind === 'entity')
|
|
943
|
+
rejectUnmappableOpts(o, KNOWLEDGE_ONLY_OPTS, 'entity');
|
|
944
|
+
if (o?.kind === 'knowledge')
|
|
945
|
+
rejectUnmappableOpts(o, ENTITY_ONLY_OPTS, 'knowledge');
|
|
946
|
+
validateTimeOpts(o);
|
|
947
|
+
validateListOpts(o);
|
|
948
|
+
const qopts = {};
|
|
949
|
+
if (o?.kind && o.kind !== 'all')
|
|
950
|
+
qopts.kind = o.kind;
|
|
951
|
+
// When kind is 'all' or unset, no kind filter — both entities and knowledge are returned
|
|
952
|
+
mapEntityFilterOpts(qopts, o);
|
|
953
|
+
mapKnowledgeFilterOpts(qopts, o);
|
|
954
|
+
mapTimeOpts(qopts, o);
|
|
955
|
+
mapListOpts(qopts, o);
|
|
956
|
+
const result = index.queryItems(qopts);
|
|
957
|
+
const items = result.items.map(i => {
|
|
958
|
+
if (i.kind === 'entity') {
|
|
959
|
+
return { ...toQueryResult(i.slug, i), kind: 'entity' };
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
return { ...toKnowledgeResult(i.slug, i), kind: 'knowledge' };
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
return { items, total: result.total };
|
|
669
966
|
}
|
package/dist/digest.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type
|
|
2
|
+
import { type BatchOperation } from './types.js';
|
|
3
3
|
export declare const DigestInputSchema: z.ZodObject<{
|
|
4
4
|
entities_touched: z.ZodArray<z.ZodString>;
|
|
5
5
|
decisions: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
6
6
|
narrative: z.ZodString;
|
|
7
7
|
session_date: z.ZodString;
|
|
8
|
-
agent: z.
|
|
8
|
+
agent: z.ZodOptional<z.ZodString>;
|
|
9
9
|
}, z.core.$strip>;
|
|
10
10
|
export type DigestInput = z.infer<typeof DigestInputSchema>;
|
|
11
11
|
/**
|
|
@@ -24,6 +24,8 @@ export declare function parseDigestInput(raw: unknown): {
|
|
|
24
24
|
/**
|
|
25
25
|
* Convert session context into a hafez batch payload.
|
|
26
26
|
* Pure function — no file I/O, no vault access.
|
|
27
|
-
*
|
|
27
|
+
* An unknown slug in entities_touched is a NOT_FOUND naming it (and only the
|
|
28
|
+
* unknown ones), never a session log that points nowhere. A knowledge slug is
|
|
29
|
+
* known: the note links it, but a session log is entity-only.
|
|
28
30
|
*/
|
|
29
|
-
export declare function digest(input: DigestInput,
|
|
31
|
+
export declare function digest(input: DigestInput, entitySlugs: Set<string>, knowledgeSlugs?: Set<string>): BatchOperation[];
|