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/README.md +67 -9
- package/dist/apply.d.ts +30 -0
- package/dist/apply.js +582 -0
- package/dist/batch-ops.d.ts +296 -13
- package/dist/batch-ops.js +186 -62
- package/dist/cli/args.d.ts +5 -1
- package/dist/cli/args.js +6 -2
- package/dist/cli/commands.d.ts +24 -0
- package/dist/cli/commands.js +197 -115
- package/dist/cli/help-agent.js +24 -0
- package/dist/cli/index.d.ts +41 -0
- package/dist/cli/index.js +150 -130
- package/dist/cli/upgrade.d.ts +42 -0
- package/dist/cli/upgrade.js +305 -0
- package/dist/contracts.d.ts +49 -6
- package/dist/contracts.js +104 -23
- package/dist/dates.d.ts +10 -0
- package/dist/dates.js +27 -0
- package/dist/db.d.ts +27 -2
- package/dist/db.js +309 -33
- package/dist/document.d.ts +97 -88
- package/dist/document.js +512 -382
- package/dist/export-okf.js +41 -15
- package/dist/git.js +165 -48
- package/dist/index.d.ts +2 -2
- package/dist/index.js +212 -727
- 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 +96 -64
- package/dist/schema.js +4 -4
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +38 -11
- package/dist/vault.d.ts +18 -0
- package/dist/vault.js +50 -4
- package/package.json +1 -1
- package/skill/SKILL.md +51 -110
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -169
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 {
|
|
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';
|
|
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 = '
|
|
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
|
-
|
|
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 =>
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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 =
|
|
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 (
|
|
195
|
-
sortCol =
|
|
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
|
-
|
|
473
|
-
|
|
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
|
|
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 ??
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
+
}
|