hafez 1.3.0 → 1.4.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/dist/db.js CHANGED
@@ -4,14 +4,22 @@ 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';
9
10
  // Bump this when indexing logic changes to force a full rebuild.
10
11
  // v5: wiki link harvesting (mention relations), parent link indexing, relation filter on queries.
11
12
  // v6: description column on items.
12
13
  // v7: resource column on items (entities only).
14
+ // v8: per-kind structural section binding (documentFor) changes how brief,
15
+ // next-action, and session-log columns are derived from bodies.
16
+ // v9: Document binds the kind-level section union (no type/subtype
17
+ // narrowing), changing section boundaries body columns derive from.
18
+ // v10: re-indexing an externally changed file is delete-then-insert instead of
19
+ // INSERT OR REPLACE. Indexes written by v9 and earlier may hold ghost FTS
20
+ // rows (REPLACE never fired the delete trigger) and must be rebuilt, not reused.
13
21
  // Existing instances will fully rebuild the index on first access after upgrade.
14
- export const SCHEMA_VERSION = '7';
22
+ export const SCHEMA_VERSION = '10';
15
23
  const SCHEMA = `
16
24
  CREATE TABLE IF NOT EXISTS items (
17
25
  slug TEXT PRIMARY KEY,
@@ -86,13 +94,74 @@ const STOPWORDS = new Set([
86
94
  function hashContent(content) {
87
95
  return createHash('sha256').update(content).digest('hex');
88
96
  }
97
+ // --- Query input validation ---
98
+ //
99
+ // Every enum-ish query opt is checked before it can influence (or fail to
100
+ // influence) SQL. The mappers below only forward values they recognise, so an
101
+ // unchecked typo used to be *dropped*: `--filter actvie` ran unfiltered and
102
+ // exited 0. Validating here covers the CLI and library callers at once.
103
+ const SORT_COL_MAP = {
104
+ name: 'i.name',
105
+ created: 'i.created',
106
+ last_touched: 'i.last_touched',
107
+ staleness: 'i.staleness_days',
108
+ reinforcement_count: 'i.reinforcement_count',
109
+ last_reinforced: 'i.last_reinforced',
110
+ };
111
+ const KNOWLEDGE_ONLY_SORTS = new Set(['reinforcement_count', 'last_reinforced']);
112
+ const SORT_FIELDS = Object.keys(SORT_COL_MAP);
113
+ const SORT_ORDERS = ['asc', 'desc'];
114
+ const QUERY_FILTERS = [...ENTITY_STATUSES, 'all', 'stale', 'capture'];
115
+ function assertEnum(value, valid, label) {
116
+ if (value === undefined || value === null)
117
+ return;
118
+ if (typeof value !== 'string' || !valid.includes(value)) {
119
+ throw new HafezError('VALIDATION_FAILED', `Invalid ${label}: "${String(value)}". Valid values: ${valid.join(', ')}.`);
120
+ }
121
+ }
122
+ function assertSortOrder(value) {
123
+ if (value === undefined || value === null)
124
+ return;
125
+ if (typeof value !== 'string' || !SORT_ORDERS.includes(value.toLowerCase())) {
126
+ throw new HafezError('VALIDATION_FAILED', `Invalid sort_order: "${String(value)}". Valid values: asc, desc.`);
127
+ }
128
+ }
129
+ function assertCount(value, label) {
130
+ if (value === undefined || value === null)
131
+ return;
132
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
133
+ throw new HafezError('VALIDATION_FAILED', `Invalid ${label}: "${String(value)}". Must be a non-negative integer.`);
134
+ }
135
+ }
136
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
137
+ function validateTimeOpts(opts) {
138
+ const fields = ['since', 'before', 'createdSince', 'createdBefore'];
139
+ for (const f of fields) {
140
+ if (opts?.[f] !== undefined && !ISO_DATE.test(opts[f])) {
141
+ throw new HafezError('VALIDATION_FAILED', `Invalid date format for ${f}: "${opts[f]}". Must be YYYY-MM-DD.`);
142
+ }
143
+ }
144
+ }
145
+ function validateListOpts(opts) {
146
+ assertEnum(opts?.sort_by, SORT_FIELDS, 'sort_by');
147
+ assertSortOrder(opts?.sort_order);
148
+ assertCount(opts?.limit, 'limit');
149
+ assertCount(opts?.offset, 'offset');
150
+ }
89
151
  // --- Shared query functions (used by both write and readonly paths) ---
90
152
  function sanitizeFtsQuery(raw) {
91
- const cleaned = raw.replace(/[^\w\s]/g, ' ');
153
+ // Unicode-aware: keep letters/digits/underscore in ANY script, strip
154
+ // punctuation. `[^\w\s]` was ASCII-only, so it erased whole non-Latin
155
+ // queries (日本語 → '') and the vault's non-Latin notes were unfindable.
156
+ // For ASCII input this class is identical to the old one.
157
+ const cleaned = raw.replace(/[^\p{L}\p{N}_\s]/gu, ' ');
92
158
  const tokens = cleaned.split(/\s+/).filter(t => t.length > 0);
93
159
  const filtered = tokens.filter(t => !STOPWORDS.has(t.toLowerCase()));
160
+ // All-stopword queries fall back to the raw tokens — which is how bare
161
+ // AND/OR/NOT reach fts5. Quoting makes every term a literal string, never
162
+ // an operator, and a trailing `*` still gives prefix matching.
94
163
  const terms = filtered.length === 0 ? tokens : filtered;
95
- return { fts: terms.map(t => `${t}*`).join(' OR '), terms };
164
+ return { fts: terms.map(t => `"${t.replace(/"/g, '""')}"*`).join(' OR '), terms };
96
165
  }
97
166
  function queryItemsFn(db, opts) {
98
167
  const conditions = [];
@@ -102,7 +171,11 @@ function queryItemsFn(db, opts) {
102
171
  params.kind = opts.kind;
103
172
  }
104
173
  if (opts.status) {
105
- conditions.push('i.status = @status');
174
+ // Status is an entity attribute — knowledge rows are indexed with a NULL
175
+ // status. In a kind-unset (unified) query the status filter narrows the
176
+ // entity side only; applying it to every row is what made `query --kind
177
+ // all` (filter defaults to active) return entities and nothing else.
178
+ conditions.push(opts.kind === 'entity' ? 'i.status = @status' : "(i.kind = 'knowledge' OR i.status = @status)");
106
179
  params.status = opts.status;
107
180
  }
108
181
  if (opts.type) {
@@ -175,24 +248,21 @@ function queryItemsFn(db, opts) {
175
248
  }
176
249
  }
177
250
  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
- };
251
+ // sort_order / limit / offset are the three opts that reach SQL as text
252
+ // rather than bound params — check them at the interpolation site, so a
253
+ // caller that skips the read surface can't hand raw SQL to prepare().
254
+ assertSortOrder(opts.sort_order);
255
+ assertCount(opts.limit, 'limit');
256
+ assertCount(opts.offset, 'offset');
187
257
  let sortCol = 'i.last_touched';
188
258
  if (opts.sort_by) {
189
- const isKnowledgeOnly = knowledgeOnlySorts.has(opts.sort_by);
259
+ const isKnowledgeOnly = KNOWLEDGE_ONLY_SORTS.has(opts.sort_by);
190
260
  const isEntityQuery = opts.kind === 'entity';
191
261
  if (isKnowledgeOnly && isEntityQuery) {
192
262
  sortCol = 'i.last_touched';
193
263
  }
194
- else if (sortColMap[opts.sort_by]) {
195
- sortCol = sortColMap[opts.sort_by];
264
+ else if (SORT_COL_MAP[opts.sort_by]) {
265
+ sortCol = SORT_COL_MAP[opts.sort_by];
196
266
  }
197
267
  }
198
268
  let sortOrder;
@@ -237,6 +307,18 @@ function queryItemsFn(db, opts) {
237
307
  }
238
308
  return { items: rows, total };
239
309
  }
310
+ /** Every indexed document, in the shape index.md renders from. The vault-root
311
+ * index needs name/status/type/confidence/description/domain and the body (for
312
+ * the derived summary) — all of it already indexed, so regenerating index.md
313
+ * after a mutation costs one query instead of a second full-vault parse. */
314
+ function indexEntriesFn(db) {
315
+ const rows = db.prepare('SELECT slug, kind, name, type, status, confidence, description, body FROM items').all();
316
+ const getDomains = db.prepare("SELECT target FROM links WHERE source = ? AND relation = 'domain'");
317
+ return rows.map(row => ({
318
+ ...row,
319
+ domain: getDomains.all(row.slug).map(d => d.target),
320
+ }));
321
+ }
240
322
  function searchFn(db, query, kind) {
241
323
  const { fts: ftsQuery, terms } = sanitizeFtsQuery(query);
242
324
  if (!ftsQuery)
@@ -337,6 +419,17 @@ function getStatsFn(db) {
337
419
  recently_created,
338
420
  };
339
421
  }
422
+ // Internal test seam — NOT public API (createHafez never exposes it). Fires
423
+ // inside syncIfStale after the scan has read the vault and before the new
424
+ // watermark is recorded, so the mid-scan-write race can be injected
425
+ // deterministically instead of raced against the clock.
426
+ // Recorded sync watermarks sit this far behind the scan-start instant: FS
427
+ // timestamps and Date.now() are read from the same kernel clock but round
428
+ // differently, and a write landing in that rounding gap must stay dirty.
429
+ const WATERMARK_SLACK_MS = 10;
430
+ let afterScanHook = null;
431
+ /** @internal */
432
+ export function __setAfterScanHook(fn) { afterScanHook = fn; }
340
433
  export function createIndex(vaultPath, opts) {
341
434
  const dbPath = join(vaultPath, '.hafez.db');
342
435
  const isReadOnly = opts?.readonly ?? false;
@@ -379,7 +472,7 @@ export function createIndex(vaultPath, opts) {
379
472
  }
380
473
  catch { /* DB may have been temporarily deleted during rebuild */ }
381
474
  }
382
- return {
475
+ return withReadSurface({
383
476
  rebuild: () => { throw new Error('Index is read-only'); },
384
477
  syncIfStale,
385
478
  upsertFromFile: () => { throw new Error('Index is read-only'); },
@@ -387,11 +480,12 @@ export function createIndex(vaultPath, opts) {
387
480
  queryItems: (qopts) => queryItemsFn(db, qopts),
388
481
  search: (q, k) => searchFn(db, q, k),
389
482
  getStats: () => getStatsFn(db),
483
+ indexEntries: () => indexEntriesFn(db),
390
484
  close: () => { try {
391
485
  db.close();
392
486
  }
393
487
  catch { } },
394
- };
488
+ });
395
489
  }
396
490
  // --- Write-capable path ---
397
491
  // The db files are a disposable local cache — keep them out of the user's
@@ -469,8 +563,12 @@ export function createIndex(vaultPath, opts) {
469
563
  db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(SCHEMA_VERSION);
470
564
  }
471
565
  // Prepared statements
472
- const upsertStmt = db.prepare(`
473
- INSERT OR REPLACE INTO items
566
+ // Plain INSERT, never INSERT OR REPLACE: REPLACE's implicit delete does not
567
+ // fire the AFTER DELETE trigger (recursive_triggers is off), so the replaced
568
+ // row's terms would linger in the external-content FTS index as ghosts.
569
+ // Callers delete the row first — see indexFile.
570
+ const insertStmt = db.prepare(`
571
+ INSERT INTO items
474
572
  (slug, kind, name, type, status, parent, next_action, next_action_count, staleness_days,
475
573
  confidence, reinforcement_count, last_reinforced, created, last_touched, body, file_hash, subtype,
476
574
  description, resource, brief, session_log_count, last_session_date, last_session_type, last_session_summary)
@@ -507,11 +605,12 @@ export function createIndex(vaultPath, opts) {
507
605
  process.stderr.write(`Warning: skipping ${kind}/${slug}.md: missing required field(s): ${missing}\n`);
508
606
  return;
509
607
  }
510
- const bodyActions = getNextActions(body);
608
+ const doc = documentForFile({ kind });
609
+ const bodyActions = doc.getNextActions(body);
511
610
  const nextAction = bodyActions.length > 0 ? bodyActions[0] : null;
512
611
  const nextActionCount = bodyActions.length;
513
- const brief = getBrief(body)?.slice(0, 300) ?? null;
514
- const logs = parseSessionLog(body);
612
+ const brief = doc.getBrief(body)?.slice(0, 300) ?? null;
613
+ const logs = doc.parseSessionLog(body);
515
614
  const lastLog = logs[0] ?? null;
516
615
  const item = {
517
616
  slug,
@@ -530,7 +629,7 @@ export function createIndex(vaultPath, opts) {
530
629
  last_touched: fm['last-touched'] ?? null,
531
630
  body,
532
631
  file_hash: hash,
533
- subtype: kind === 'knowledge' ? (fm.subtype ?? 'insight') : null,
632
+ subtype: kind === 'knowledge' ? (fm.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE) : null,
534
633
  description: fm.description ?? null,
535
634
  resource: kind === 'entity' ? (fm.resource ?? null) : null,
536
635
  brief,
@@ -539,9 +638,12 @@ export function createIndex(vaultPath, opts) {
539
638
  last_session_type: lastLog?.type ?? null,
540
639
  last_session_summary: lastLog?.summary?.slice(0, 300) ?? null,
541
640
  };
542
- // Upsert item and rebuild links
641
+ // Replace item and rebuild links. Delete-then-insert keeps the FTS index
642
+ // trigger-consistent for externally changed files (hand edit, git pull),
643
+ // the same path hafez's own mutations take via upsertFromFile.
644
+ deleteItemStmt.run(slug);
543
645
  deleteLinksStmt.run(slug);
544
- upsertStmt.run(item);
646
+ insertStmt.run(item);
545
647
  // Index related links
546
648
  if (fm.related && Array.isArray(fm.related)) {
547
649
  for (const target of fm.related)
@@ -564,7 +666,7 @@ export function createIndex(vaultPath, opts) {
564
666
  // Harvest [[wiki links]] from body (excluding ## Related section)
565
667
  // Strip the Related section to avoid double-counting generated links
566
668
  let authoredBody = body;
567
- const relatedSection = findSection(body, 'Related');
669
+ const relatedSection = doc.findSection(body, 'Related');
568
670
  if (relatedSection) {
569
671
  authoredBody = body.slice(0, relatedSection.start) + body.slice(relatedSection.end);
570
672
  }
@@ -584,6 +686,8 @@ export function createIndex(vaultPath, opts) {
584
686
  }
585
687
  }
586
688
  const rebuildTransaction = db.transaction(() => {
689
+ // Watermark the scan by when it STARTED (see syncIfStale).
690
+ const scanStart = Date.now() - WATERMARK_SLACK_MS;
587
691
  db.exec('DELETE FROM items');
588
692
  db.exec('DELETE FROM links');
589
693
  // Collect all vault slugs for wiki link target validation
@@ -601,7 +705,7 @@ export function createIndex(vaultPath, opts) {
601
705
  indexFile(file, 'knowledge', allVaultSlugs);
602
706
  }
603
707
  db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_sync', ?)")
604
- .run(Date.now().toString());
708
+ .run(scanStart.toString());
605
709
  });
606
710
  function rebuild() {
607
711
  rebuildTransaction();
@@ -614,10 +718,19 @@ export function createIndex(vaultPath, opts) {
614
718
  }
615
719
  const lastSyncTime = parseInt(lastSync.value, 10);
616
720
  let changed = false;
721
+ // Watermark the scan by when it STARTED, not when it finished. A file
722
+ // written while the scan is in flight — after its read, before the record —
723
+ // would otherwise be stamped as already-indexed and skipped by every later
724
+ // sync, leaving it permanently stale. `>=` covers the same-millisecond
725
+ // case; a re-read of an unchanged file is a cheap hash compare. The slack
726
+ // absorbs FS-vs-Date.now clock rounding at the boundary — a file whose
727
+ // mtime rounds just below the instant the scan started must still read as
728
+ // dirty; the only cost is re-hashing files touched inside the window.
729
+ const scanStart = Date.now() - WATERMARK_SLACK_MS;
617
730
  const dirs = INDEXED_KINDS.map(kind => ({ dir: kindDir(kind), kind }));
618
731
  for (const { dir, kind } of dirs) {
619
732
  for (const file of scanVaultDir(join(vaultPath, dir))) {
620
- if (statSync(file).mtimeMs > lastSyncTime) {
733
+ if (statSync(file).mtimeMs >= lastSyncTime) {
621
734
  indexFile(file, kind);
622
735
  changed = true;
623
736
  }
@@ -637,9 +750,10 @@ export function createIndex(vaultPath, opts) {
637
750
  changed = true;
638
751
  }
639
752
  }
753
+ afterScanHook?.();
640
754
  if (changed) {
641
755
  db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_sync', ?)")
642
- .run(Date.now().toString());
756
+ .run(scanStart.toString());
643
757
  }
644
758
  }
645
759
  function upsertFromFile(slug, kind) {
@@ -659,11 +773,173 @@ export function createIndex(vaultPath, opts) {
659
773
  const count = db.prepare('SELECT COUNT(*) as c FROM items').get().c;
660
774
  if (count === 0)
661
775
  rebuild();
662
- return {
776
+ return withReadSurface({
663
777
  rebuild, syncIfStale, upsertFromFile, removeItem,
664
778
  queryItems: (qopts) => queryItemsFn(db, qopts),
665
779
  search: (q, k) => searchFn(db, q, k),
666
780
  getStats: () => getStatsFn(db),
781
+ indexEntries: () => indexEntriesFn(db),
667
782
  close: () => db.close(),
783
+ });
784
+ }
785
+ function withReadSurface(core) {
786
+ const index = {
787
+ ...core,
788
+ queryEntities: (opts) => queryEntitiesFn(index, opts),
789
+ queryChildren: (parentSlug) => queryEntitiesFn(index, { parent: parentSlug }),
790
+ queryRelatedTo: (slug, relation) => queryRelatedToFn(index, slug, relation),
791
+ queryKnowledge: (opts) => queryKnowledgeFn(index, opts),
792
+ queryUnified: (opts) => queryUnifiedFn(index, opts),
793
+ };
794
+ return index;
795
+ }
796
+ function toQueryResult(slug, row) {
797
+ return {
798
+ slug,
799
+ name: row.name,
800
+ type: row.type,
801
+ status: row.status,
802
+ description: row.description ?? null,
803
+ resource: row.resource ?? null,
804
+ domain: row.domain ?? [],
805
+ last_touched: row.last_touched,
806
+ next_action: row.next_action ?? null,
807
+ next_action_count: row.next_action_count ?? 0,
808
+ parent: row.parent ?? null,
809
+ related: row.related ?? [],
810
+ staleness_days: row.staleness_days ?? null,
811
+ tags: row.tags ?? [],
812
+ brief: row.brief ?? null,
813
+ session_log_count: row.session_log_count ?? 0,
814
+ last_session_date: row.last_session_date ?? null,
815
+ last_session_type: row.last_session_type ?? null,
816
+ last_session_summary: row.last_session_summary ?? null,
817
+ };
818
+ }
819
+ function toKnowledgeResult(slug, row) {
820
+ return {
821
+ slug,
822
+ name: row.name,
823
+ description: row.description ?? null,
824
+ domain: row.domain ?? [],
825
+ confidence: row.confidence ?? 'observation',
826
+ related: row.related ?? [],
827
+ reinforcement_count: row.reinforcement_count ?? 0,
828
+ last_reinforced: row.last_reinforced ?? null,
829
+ last_touched: row.last_touched ?? null,
830
+ };
831
+ }
832
+ const STATUS_FILTERS = new Set(ENTITY_STATUSES);
833
+ function mapEntityFilterOpts(qopts, opts) {
834
+ if (opts?.filter && STATUS_FILTERS.has(opts.filter))
835
+ qopts.status = opts.filter;
836
+ if (opts?.filter === 'stale')
837
+ qopts.filter = 'stale';
838
+ if (opts?.filter === 'capture')
839
+ qopts.filter = 'capture';
840
+ if (opts?.type)
841
+ qopts.type = opts.type;
842
+ if (opts?.parent)
843
+ qopts.parent = opts.parent;
844
+ if (opts?.domain)
845
+ qopts.domain = opts.domain;
846
+ }
847
+ function mapTimeOpts(qopts, opts) {
848
+ if (opts?.since)
849
+ qopts.since = opts.since;
850
+ if (opts?.before)
851
+ qopts.before = opts.before;
852
+ if (opts?.createdSince)
853
+ qopts.createdSince = opts.createdSince;
854
+ if (opts?.createdBefore)
855
+ qopts.createdBefore = opts.createdBefore;
856
+ }
857
+ function mapListOpts(qopts, opts) {
858
+ if (opts?.tags)
859
+ qopts.tags = opts.tags;
860
+ if (opts?.sort_by)
861
+ qopts.sort_by = opts.sort_by;
862
+ if (opts?.sort_order)
863
+ qopts.sort_order = opts.sort_order;
864
+ if (opts?.limit != null)
865
+ qopts.limit = opts.limit;
866
+ if (opts?.offset != null)
867
+ qopts.offset = opts.offset;
868
+ }
869
+ function mapKnowledgeFilterOpts(qopts, opts) {
870
+ if (opts?.domain)
871
+ qopts.domain = opts.domain;
872
+ if (opts?.confidence)
873
+ qopts.confidence = opts.confidence;
874
+ if (opts?.subtype)
875
+ qopts.subtype = opts.subtype;
876
+ }
877
+ function validateEntityFilterOpts(opts) {
878
+ assertEnum(opts?.filter, QUERY_FILTERS, 'filter');
879
+ assertEnum(opts?.type, ENTITY_TYPES, 'type');
880
+ }
881
+ function validateKnowledgeFilterOpts(opts) {
882
+ assertEnum(opts?.confidence, CONFIDENCE_LEVELS, 'confidence');
883
+ assertEnum(opts?.subtype, VALID_SUBTYPES, 'subtype');
884
+ }
885
+ function queryEntitiesFn(index, opts) {
886
+ index.syncIfStale();
887
+ validateEntityFilterOpts(opts);
888
+ validateKnowledgeFilterOpts(opts);
889
+ validateTimeOpts(opts);
890
+ validateListOpts(opts);
891
+ const qopts = { kind: 'entity' };
892
+ mapEntityFilterOpts(qopts, opts);
893
+ mapTimeOpts(qopts, opts);
894
+ mapListOpts(qopts, opts);
895
+ const { items, total } = index.queryItems(qopts);
896
+ return { items: items.map(i => toQueryResult(i.slug, i)), total };
897
+ }
898
+ function queryRelatedToFn(index, slug, relation) {
899
+ index.syncIfStale();
900
+ const { items, total } = index.queryItems({ relatedTo: slug, relation });
901
+ return {
902
+ items: items.map(i => i.kind === 'entity'
903
+ ? { ...toQueryResult(i.slug, i), kind: 'entity' }
904
+ : { ...toKnowledgeResult(i.slug, i), kind: 'knowledge' }),
905
+ total,
668
906
  };
669
907
  }
908
+ function queryKnowledgeFn(index, opts) {
909
+ index.syncIfStale();
910
+ validateKnowledgeFilterOpts(opts);
911
+ validateTimeOpts(opts);
912
+ validateListOpts(opts);
913
+ const qopts = { kind: 'knowledge' };
914
+ mapKnowledgeFilterOpts(qopts, opts);
915
+ mapTimeOpts(qopts, opts);
916
+ mapListOpts(qopts, opts);
917
+ const { items, total } = index.queryItems(qopts);
918
+ return { items: items.map(i => toKnowledgeResult(i.slug, i)), total };
919
+ }
920
+ function queryUnifiedFn(index, opts) {
921
+ index.syncIfStale();
922
+ assertEnum(opts?.kind, ['entity', 'knowledge', 'all'], 'kind');
923
+ validateEntityFilterOpts(opts);
924
+ validateKnowledgeFilterOpts(opts);
925
+ validateTimeOpts(opts);
926
+ validateListOpts(opts);
927
+ const qopts = {};
928
+ if (opts?.kind && opts.kind !== 'all')
929
+ qopts.kind = opts.kind;
930
+ // When kind is 'all' or unset, no kind filter — both entities and knowledge are returned
931
+ mapEntityFilterOpts(qopts, opts);
932
+ mapKnowledgeFilterOpts(qopts, opts);
933
+ mapTimeOpts(qopts, opts);
934
+ mapListOpts(qopts, opts);
935
+ const result = index.queryItems(qopts);
936
+ const items = result.items.map(i => {
937
+ if (i.kind === 'entity') {
938
+ return { ...toQueryResult(i.slug, i), kind: 'entity' };
939
+ }
940
+ else {
941
+ return { ...toKnowledgeResult(i.slug, i), kind: 'knowledge' };
942
+ }
943
+ });
944
+ return { items, total: result.total };
945
+ }