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.
Files changed (75) hide show
  1. package/README.md +83 -32
  2. package/dist/apply.d.ts +33 -0
  3. package/dist/apply.js +693 -0
  4. package/dist/batch-ops.d.ts +372 -26
  5. package/dist/batch-ops.js +355 -73
  6. package/dist/cli/args.d.ts +48 -6
  7. package/dist/cli/args.js +74 -9
  8. package/dist/cli/batch-errors.js +19 -11
  9. package/dist/cli/block-hook.d.ts +9 -0
  10. package/dist/cli/block-hook.js +90 -0
  11. package/dist/cli/commands-schema.js +2 -0
  12. package/dist/cli/commands.d.ts +28 -3
  13. package/dist/cli/commands.js +440 -157
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +55 -0
  17. package/dist/cli/index.js +215 -153
  18. package/dist/cli/install.d.ts +53 -0
  19. package/dist/cli/install.js +297 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +265 -90
  22. package/dist/cli/resolve-vault.d.ts +11 -4
  23. package/dist/cli/resolve-vault.js +13 -11
  24. package/dist/cli/schema-introspect.js +4 -3
  25. package/dist/cli/upgrade.d.ts +51 -0
  26. package/dist/cli/upgrade.js +351 -0
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +65 -6
  30. package/dist/contracts.js +148 -23
  31. package/dist/dates.d.ts +10 -0
  32. package/dist/dates.js +27 -0
  33. package/dist/db.d.ts +40 -2
  34. package/dist/db.js +363 -66
  35. package/dist/digest.d.ts +6 -4
  36. package/dist/digest.js +28 -17
  37. package/dist/document.d.ts +135 -88
  38. package/dist/document.js +665 -379
  39. package/dist/export-okf.d.ts +13 -0
  40. package/dist/export-okf.js +50 -20
  41. package/dist/git.js +165 -48
  42. package/dist/harness.d.ts +58 -0
  43. package/dist/harness.js +50 -0
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +301 -826
  46. package/dist/knowledge-index.d.ts +23 -1
  47. package/dist/knowledge-index.js +62 -6
  48. package/dist/merge.d.ts +1 -1
  49. package/dist/merge.js +98 -64
  50. package/dist/near-match.d.ts +9 -0
  51. package/dist/near-match.js +34 -0
  52. package/dist/schema.js +4 -4
  53. package/dist/stale.d.ts +41 -0
  54. package/dist/stale.js +43 -0
  55. package/dist/stopwords.d.ts +6 -0
  56. package/dist/stopwords.js +25 -0
  57. package/dist/surfaces/items.d.ts +366 -0
  58. package/dist/surfaces/items.js +523 -0
  59. package/dist/surfaces/surfaces.d.ts +32 -0
  60. package/dist/surfaces/surfaces.js +230 -0
  61. package/dist/templates.d.ts +6 -0
  62. package/dist/templates.js +27 -27
  63. package/dist/types.d.ts +78 -22
  64. package/dist/validate.d.ts +7 -0
  65. package/dist/validate.js +109 -0
  66. package/dist/vault.d.ts +18 -0
  67. package/dist/vault.js +51 -5
  68. package/package.json +4 -3
  69. package/skill/hafez/SKILL.md +68 -0
  70. package/skill/hafez-save/SKILL.md +29 -0
  71. package/dist/cli/help-agent.d.ts +0 -1
  72. package/dist/cli/help-agent.js +0 -186
  73. package/dist/query.d.ts +0 -24
  74. package/dist/query.js +0 -169
  75. 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 { getNextActions, getBrief, WIKI_LINK_RE, findSection } from './document.js';
8
- import { parseSessionLog } from './document.js';
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 = '7';
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
- const cleaned = raw.replace(/[^\w\s]/g, ' ');
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 => `${t}*`).join(' OR '), terms };
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
- conditions.push('i.status = @status');
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
- julianday('now') - julianday(i.last_touched) >
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
- const knowledgeOnlySorts = new Set(['reinforcement_count', 'last_reinforced']);
179
- const sortColMap = {
180
- name: 'i.name',
181
- created: 'i.created',
182
- last_touched: 'i.last_touched',
183
- staleness: 'i.staleness_days',
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
- const isKnowledgeOnly = knowledgeOnlySorts.has(opts.sort_by);
190
- const isEntityQuery = opts.kind === 'entity';
191
- if (isKnowledgeOnly && isEntityQuery) {
192
- sortCol = 'i.last_touched';
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, CAST(julianday('now') - julianday(last_touched) AS INTEGER) as days_since_touched
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
- const upsertStmt = db.prepare(`
473
- INSERT OR REPLACE INTO items
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 bodyActions = getNextActions(body);
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
- body,
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 ?? 'insight') : null,
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
- // Upsert item and rebuild links
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
- upsertStmt.run(item);
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(Date.now().toString());
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 > lastSyncTime) {
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(Date.now().toString());
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 { BatchOperation } from './types.js';
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.ZodDefault<z.ZodString>;
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
- * Unknown slugs in entities_touched are silently omitted (caller should warn).
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, existingSlugs: Set<string>, now?: Date): BatchOperation[];
31
+ export declare function digest(input: DigestInput, entitySlugs: Set<string>, knowledgeSlugs?: Set<string>): BatchOperation[];