hafez 2.1.0 → 2.3.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 +72 -5
- package/dist/apply.d.ts +14 -1
- package/dist/apply.js +94 -32
- package/dist/batch-ops.d.ts +31 -27
- package/dist/batch-ops.js +83 -16
- package/dist/cli/block-hook.d.ts +6 -0
- package/dist/cli/block-hook.js +27 -12
- package/dist/cli/commands.d.ts +3 -1
- package/dist/cli/commands.js +183 -73
- package/dist/cli/format.d.ts +11 -3
- package/dist/cli/format.js +61 -4
- package/dist/cli/index.js +40 -19
- package/dist/cli/install.d.ts +9 -0
- package/dist/cli/install.js +36 -15
- package/dist/cli/onboard.js +30 -9
- package/dist/cli/upgrade.js +5 -4
- package/dist/contracts.d.ts +9 -0
- package/dist/contracts.js +9 -0
- package/dist/dates.d.ts +7 -0
- package/dist/dates.js +21 -1
- package/dist/db.d.ts +50 -10
- package/dist/db.js +251 -65
- package/dist/digest.js +14 -7
- package/dist/document.d.ts +2 -0
- package/dist/document.js +2 -2
- package/dist/git.d.ts +13 -2
- package/dist/git.js +121 -14
- package/dist/graph/activity.d.ts +5 -0
- package/dist/graph/activity.js +70 -0
- package/dist/graph/client.js +326 -0
- package/dist/graph/copy-fonts.d.ts +1 -0
- package/dist/graph/copy-fonts.js +13 -0
- package/dist/graph/fields.d.ts +8 -0
- package/dist/graph/fields.js +12 -0
- package/dist/graph/fonts/geist-OFL.txt +93 -0
- package/dist/graph/fonts/geist-mono-OFL.txt +93 -0
- package/dist/graph/fonts/geist-mono.woff2 +0 -0
- package/dist/graph/fonts/geist.woff2 +0 -0
- package/dist/graph/fonts/outfit-OFL.txt +93 -0
- package/dist/graph/fonts/outfit.woff2 +0 -0
- package/dist/graph/fonts.d.ts +14 -0
- package/dist/graph/fonts.js +17 -0
- package/dist/graph/live.d.ts +16 -0
- package/dist/graph/live.js +99 -0
- package/dist/graph/open.d.ts +1 -0
- package/dist/graph/open.js +20 -0
- package/dist/graph/server.d.ts +17 -0
- package/dist/graph/server.js +189 -0
- package/dist/health.d.ts +6 -0
- package/dist/health.js +310 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +66 -18
- package/dist/internal.d.ts +21 -0
- package/dist/internal.js +9 -0
- package/dist/knowledge-index.d.ts +1 -1
- package/dist/read-json.d.ts +26 -0
- package/dist/read-json.js +53 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +11 -0
- package/dist/stale.d.ts +2 -2
- package/dist/stale.js +4 -9
- package/dist/surfaces/items.d.ts +100 -1
- package/dist/surfaces/items.js +148 -10
- package/dist/surfaces/surfaces.d.ts +8 -5
- package/dist/surfaces/surfaces.js +34 -17
- package/dist/templates.d.ts +2 -0
- package/dist/templates.js +6 -1
- package/dist/types.d.ts +239 -25
- package/dist/validate.js +44 -56
- package/dist/vault.d.ts +29 -9
- package/dist/vault.js +39 -8
- package/dist/workstream.d.ts +17 -0
- package/dist/workstream.js +78 -0
- package/package.json +10 -4
- package/skill/hafez/SKILL.md +16 -7
package/dist/db.js
CHANGED
|
@@ -2,13 +2,15 @@ import Database from 'better-sqlite3';
|
|
|
2
2
|
import { createHash } from 'crypto';
|
|
3
3
|
import { readFileSync, statSync, existsSync, unlinkSync } from 'fs';
|
|
4
4
|
import { join, basename } from 'path';
|
|
5
|
-
import { parseFilePath, scanVaultDir, kindDir, INDEXED_KINDS } from './vault.js';
|
|
5
|
+
import { parseFilePath, scanVaultDir, kindDir, INDEXED_KINDS, MUTABLE_KINDS } from './vault.js';
|
|
6
6
|
import { ensureLocalExclude } from './knowledge-index.js';
|
|
7
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';
|
|
8
|
+
import { ENTITY_STATUSES, ENTITY_TYPES, CONFIDENCE_LEVELS, VALID_SUBTYPES, DEFAULT_KNOWLEDGE_SUBTYPE, GRAPH_LAYERS, GRAPH_NOTE_RELATIONS, SYNTHETIC_LAYERS } from './contracts.js';
|
|
9
|
+
import { GRAPH_ROW_FIELDS } from './graph/fields.js';
|
|
9
10
|
import { HafezError } from './types.js';
|
|
10
11
|
import { STOPWORDS } from './stopwords.js';
|
|
11
|
-
import {
|
|
12
|
+
import { closestSlugs } from './closest.js';
|
|
13
|
+
import { ENTITY_AGE_DAYS_SQL, ENTITY_STALE_SQL, DEFAULT_STALENESS_DAYS, actionAge } from './stale.js';
|
|
12
14
|
import { localDay } from './dates.js';
|
|
13
15
|
// Bump this when indexing logic changes to force a full rebuild.
|
|
14
16
|
// v5: wiki link harvesting (mention relations), parent link indexing, relation filter on queries.
|
|
@@ -23,8 +25,10 @@ import { localDay } from './dates.js';
|
|
|
23
25
|
// rows (REPLACE never fired the delete trigger) and must be rebuilt, not reused.
|
|
24
26
|
// v11: next_action and the searchable body store action text with its
|
|
25
27
|
// added-date stamp stripped.
|
|
28
|
+
// v12: sessions are indexed (#35); session_date column.
|
|
29
|
+
// v13: action_added column, the open actions' added days (the Graph's stale-action count, #271).
|
|
26
30
|
// Existing instances will fully rebuild the index on first access after upgrade.
|
|
27
|
-
export const SCHEMA_VERSION = '
|
|
31
|
+
export const SCHEMA_VERSION = '13';
|
|
28
32
|
const SCHEMA = `
|
|
29
33
|
CREATE TABLE IF NOT EXISTS items (
|
|
30
34
|
slug TEXT PRIMARY KEY,
|
|
@@ -50,7 +54,9 @@ CREATE TABLE IF NOT EXISTS items (
|
|
|
50
54
|
session_log_count INTEGER DEFAULT 0,
|
|
51
55
|
last_session_date TEXT,
|
|
52
56
|
last_session_type TEXT,
|
|
53
|
-
last_session_summary TEXT
|
|
57
|
+
last_session_summary TEXT,
|
|
58
|
+
session_date TEXT,
|
|
59
|
+
action_added TEXT
|
|
54
60
|
);
|
|
55
61
|
|
|
56
62
|
CREATE TABLE IF NOT EXISTS links (
|
|
@@ -100,8 +106,14 @@ const SORT_COL_MAP = {
|
|
|
100
106
|
staleness: 'i.staleness_days',
|
|
101
107
|
reinforcement_count: 'i.reinforcement_count',
|
|
102
108
|
last_reinforced: 'i.last_reinforced',
|
|
109
|
+
session_date: 'i.session_date',
|
|
110
|
+
};
|
|
111
|
+
/** Sort fields one branch cannot honour: the column is NULL for its rows. */
|
|
112
|
+
const SORT_REJECTS = {
|
|
113
|
+
entity: new Set(['reinforcement_count', 'last_reinforced', 'session_date']),
|
|
114
|
+
knowledge: new Set(['session_date']),
|
|
115
|
+
session: new Set(['reinforcement_count', 'last_reinforced', 'last_touched', 'staleness']),
|
|
103
116
|
};
|
|
104
|
-
const KNOWLEDGE_ONLY_SORTS = new Set(['reinforcement_count', 'last_reinforced']);
|
|
105
117
|
const SORT_FIELDS = Object.keys(SORT_COL_MAP);
|
|
106
118
|
const SORT_ORDERS = ['asc', 'desc'];
|
|
107
119
|
const QUERY_FILTERS = [...ENTITY_STATUSES, 'all', 'stale', 'capture'];
|
|
@@ -163,12 +175,18 @@ function queryItemsFn(db, opts) {
|
|
|
163
175
|
conditions.push('i.kind = @kind');
|
|
164
176
|
params.kind = opts.kind;
|
|
165
177
|
}
|
|
178
|
+
else if (!opts.kind) {
|
|
179
|
+
// Unset is the working set. Sessions are a record of what happened, not
|
|
180
|
+
// a thing being worked on, and each one names every entity it touched:
|
|
181
|
+
// in the default view they would crowd out the note being looked for.
|
|
182
|
+
conditions.push("i.kind != 'session'");
|
|
183
|
+
}
|
|
166
184
|
if (opts.status) {
|
|
167
|
-
// Status is an entity attribute —
|
|
185
|
+
// Status is an entity attribute — other kinds are indexed with a NULL
|
|
168
186
|
// status. In a kind-unset (unified) query the status filter narrows the
|
|
169
187
|
// entity side only; applying it to every row is what made `query --kind
|
|
170
188
|
// all` (filter defaults to active) return entities and nothing else.
|
|
171
|
-
conditions.push(opts.kind === 'entity' ? 'i.status = @status' : "(i.kind
|
|
189
|
+
conditions.push(opts.kind === 'entity' ? 'i.status = @status' : "(i.kind != 'entity' OR i.status = @status)");
|
|
172
190
|
params.status = opts.status;
|
|
173
191
|
}
|
|
174
192
|
if (opts.type) {
|
|
@@ -212,12 +230,14 @@ function queryItemsFn(db, opts) {
|
|
|
212
230
|
if (opts.filter === 'capture') {
|
|
213
231
|
conditions.push("i.type = 'capture'");
|
|
214
232
|
}
|
|
233
|
+
// A session's one day is its session date: the window reads it where an
|
|
234
|
+
// entity or plan has last_touched.
|
|
215
235
|
if (opts.since) {
|
|
216
|
-
conditions.push('i.last_touched >= @since');
|
|
236
|
+
conditions.push('COALESCE(i.last_touched, i.session_date) >= @since');
|
|
217
237
|
params.since = opts.since;
|
|
218
238
|
}
|
|
219
239
|
if (opts.before) {
|
|
220
|
-
conditions.push('i.last_touched <= @before');
|
|
240
|
+
conditions.push('COALESCE(i.last_touched, i.session_date) <= @before');
|
|
221
241
|
params.before = opts.before;
|
|
222
242
|
}
|
|
223
243
|
if (opts.createdSince) {
|
|
@@ -242,12 +262,12 @@ function queryItemsFn(db, opts) {
|
|
|
242
262
|
assertSortOrder(opts.sort_order);
|
|
243
263
|
assertCount(opts.limit, 'limit');
|
|
244
264
|
assertCount(opts.offset, 'offset');
|
|
245
|
-
let sortCol = 'i.last_touched';
|
|
265
|
+
let sortCol = opts.kind === 'session' ? 'i.session_date' : 'i.last_touched';
|
|
246
266
|
if (opts.sort_by) {
|
|
247
267
|
// Same rule as the unmappable opts, one code path over: a sort field the
|
|
248
268
|
// branch cannot honour is rejected, not quietly swapped for recency.
|
|
249
|
-
if (
|
|
250
|
-
outOfScope('sort_by', opts.sort_by,
|
|
269
|
+
if (opts.kind && opts.kind !== 'all' && SORT_REJECTS[opts.kind].has(opts.sort_by)) {
|
|
270
|
+
outOfScope('sort_by', opts.sort_by, opts.kind);
|
|
251
271
|
}
|
|
252
272
|
if (SORT_COL_MAP[opts.sort_by])
|
|
253
273
|
sortCol = SORT_COL_MAP[opts.sort_by];
|
|
@@ -280,7 +300,7 @@ function queryItemsFn(db, opts) {
|
|
|
280
300
|
i.confidence, i.reinforcement_count, i.last_reinforced,
|
|
281
301
|
i.created, i.last_touched, i.subtype,
|
|
282
302
|
i.description, i.resource, i.brief, i.session_log_count, i.last_session_date,
|
|
283
|
-
i.last_session_type, i.last_session_summary
|
|
303
|
+
i.last_session_type, i.last_session_summary, i.session_date
|
|
284
304
|
FROM items i ${where}
|
|
285
305
|
ORDER BY ${sortCol} ${sortOrder}${limitClause}`;
|
|
286
306
|
const rows = db.prepare(sql).all(params);
|
|
@@ -306,13 +326,25 @@ function indexEntriesFn(db) {
|
|
|
306
326
|
domain: getDomains.all(row.slug).map(d => d.target),
|
|
307
327
|
}));
|
|
308
328
|
}
|
|
329
|
+
function graphRowsFn(db) {
|
|
330
|
+
return {
|
|
331
|
+
items: db.prepare(`SELECT slug, kind, name, type, status, subtype, confidence, last_touched, description,
|
|
332
|
+
next_action_count, staleness_days, action_added, (${ENTITY_STALE_SQL}) AS stale
|
|
333
|
+
FROM items i ORDER BY slug`).all({ today: localDay() }),
|
|
334
|
+
links: db.prepare('SELECT source, target, relation FROM links ORDER BY source, target, relation').all(),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
309
337
|
function searchFn(db, query, kind) {
|
|
310
338
|
const { fts: ftsQuery, terms } = sanitizeFtsQuery(query);
|
|
311
339
|
if (!ftsQuery)
|
|
312
340
|
return [];
|
|
313
|
-
|
|
341
|
+
// Same rule as queryItemsFn: unset is the working set, `all` is literal.
|
|
342
|
+
let kindFilter = "AND i.kind != 'session'";
|
|
314
343
|
const params = { query: ftsQuery };
|
|
315
|
-
if (kind
|
|
344
|
+
if (kind === 'all') {
|
|
345
|
+
kindFilter = '';
|
|
346
|
+
}
|
|
347
|
+
else if (kind) {
|
|
316
348
|
kindFilter = 'AND i.kind = @kind';
|
|
317
349
|
params.kind = kind;
|
|
318
350
|
}
|
|
@@ -415,6 +447,7 @@ export function __setAfterScanHook(fn) { afterScanHook = fn; }
|
|
|
415
447
|
export function createIndex(vaultPath, opts) {
|
|
416
448
|
const dbPath = join(vaultPath, '.hafez.db');
|
|
417
449
|
const isReadOnly = opts?.readonly ?? false;
|
|
450
|
+
const sync = opts?.sync ?? 'read';
|
|
418
451
|
if (isReadOnly) {
|
|
419
452
|
if (!existsSync(dbPath))
|
|
420
453
|
return null;
|
|
@@ -463,11 +496,12 @@ export function createIndex(vaultPath, opts) {
|
|
|
463
496
|
search: (q, k) => searchFn(db, q, k),
|
|
464
497
|
getStats: () => getStatsFn(db),
|
|
465
498
|
indexEntries: () => indexEntriesFn(db),
|
|
499
|
+
graphRows: () => graphRowsFn(db),
|
|
466
500
|
close: () => { try {
|
|
467
501
|
db.close();
|
|
468
502
|
}
|
|
469
503
|
catch { } },
|
|
470
|
-
});
|
|
504
|
+
}, sync);
|
|
471
505
|
}
|
|
472
506
|
// --- Write-capable path ---
|
|
473
507
|
// The db files are a disposable local cache — keep them out of the user's
|
|
@@ -553,11 +587,13 @@ export function createIndex(vaultPath, opts) {
|
|
|
553
587
|
INSERT INTO items
|
|
554
588
|
(slug, kind, name, type, status, parent, next_action, next_action_count, staleness_days,
|
|
555
589
|
confidence, reinforcement_count, last_reinforced, created, last_touched, body, file_hash, subtype,
|
|
556
|
-
description, resource, brief, session_log_count, last_session_date, last_session_type, last_session_summary
|
|
590
|
+
description, resource, brief, session_log_count, last_session_date, last_session_type, last_session_summary,
|
|
591
|
+
session_date, action_added)
|
|
557
592
|
VALUES
|
|
558
593
|
(@slug, @kind, @name, @type, @status, @parent, @next_action, @next_action_count, @staleness_days,
|
|
559
594
|
@confidence, @reinforcement_count, @last_reinforced, @created, @last_touched, @body, @file_hash, @subtype,
|
|
560
|
-
@description, @resource, @brief, @session_log_count, @last_session_date, @last_session_type, @last_session_summary
|
|
595
|
+
@description, @resource, @brief, @session_log_count, @last_session_date, @last_session_type, @last_session_summary,
|
|
596
|
+
@session_date, @action_added)
|
|
561
597
|
`);
|
|
562
598
|
const deleteItemStmt = db.prepare('DELETE FROM items WHERE slug = ?');
|
|
563
599
|
const deleteLinksStmt = db.prepare('DELETE FROM links WHERE source = ?');
|
|
@@ -620,6 +656,11 @@ export function createIndex(vaultPath, opts) {
|
|
|
620
656
|
last_session_date: lastLog?.date ?? null,
|
|
621
657
|
last_session_type: lastLog?.type ?? null,
|
|
622
658
|
last_session_summary: lastLog?.summary?.slice(0, 300) ?? null,
|
|
659
|
+
// A session note's one day; the created stamp when digest gave none.
|
|
660
|
+
session_date: kind === 'session' ? (fm['session-date'] ?? fm.created) : null,
|
|
661
|
+
action_added: kind === 'entity'
|
|
662
|
+
? JSON.stringify(doc.getActionItems(body).flatMap(a => a.handle === undefined ? [] : [a.added]))
|
|
663
|
+
: null,
|
|
623
664
|
};
|
|
624
665
|
// Replace item and rebuild links. Delete-then-insert keeps the FTS index
|
|
625
666
|
// trigger-consistent for externally changed files (hand edit, git pull),
|
|
@@ -657,12 +698,12 @@ export function createIndex(vaultPath, opts) {
|
|
|
657
698
|
const linkRe = new RegExp(WIKI_LINK_RE.source, 'g');
|
|
658
699
|
while ((wikiMatch = linkRe.exec(authoredBody)) !== null) {
|
|
659
700
|
const targetSlug = wikiMatch[1];
|
|
660
|
-
// Check if target file exists on disk (not in index — target may not be
|
|
661
|
-
|
|
662
|
-
|
|
701
|
+
// Check if target file exists on disk (not in index — target may not be
|
|
702
|
+
// indexed yet), in every indexed dir — the same set the rebuild's
|
|
703
|
+
// knownSlugs covers, so a mention links the same way on either path.
|
|
663
704
|
const targetExists = knownSlugs
|
|
664
705
|
? knownSlugs.has(targetSlug)
|
|
665
|
-
:
|
|
706
|
+
: INDEXED_KINDS.some(k => existsSync(join(vaultPath, kindDir(k), `${targetSlug}.md`)));
|
|
666
707
|
if (targetExists) {
|
|
667
708
|
insertLinkStmt.run(slug, targetSlug, 'mention');
|
|
668
709
|
}
|
|
@@ -675,17 +716,15 @@ export function createIndex(vaultPath, opts) {
|
|
|
675
716
|
db.exec('DELETE FROM links');
|
|
676
717
|
// Collect all vault slugs for wiki link target validation
|
|
677
718
|
const allVaultSlugs = new Set();
|
|
678
|
-
for (const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
allVaultSlugs.add(basename(file, '.md'));
|
|
683
|
-
}
|
|
684
|
-
for (const file of scanVaultDir(join(vaultPath, kindDir('entity')))) {
|
|
685
|
-
indexFile(file, 'entity', allVaultSlugs);
|
|
719
|
+
for (const kind of INDEXED_KINDS) {
|
|
720
|
+
for (const file of scanVaultDir(join(vaultPath, kindDir(kind)))) {
|
|
721
|
+
allVaultSlugs.add(basename(file, '.md'));
|
|
722
|
+
}
|
|
686
723
|
}
|
|
687
|
-
for (const
|
|
688
|
-
|
|
724
|
+
for (const kind of INDEXED_KINDS) {
|
|
725
|
+
for (const file of scanVaultDir(join(vaultPath, kindDir(kind)))) {
|
|
726
|
+
indexFile(file, kind, allVaultSlugs);
|
|
727
|
+
}
|
|
689
728
|
}
|
|
690
729
|
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_sync', ?)")
|
|
691
730
|
.run(scanStart.toString());
|
|
@@ -762,17 +801,34 @@ export function createIndex(vaultPath, opts) {
|
|
|
762
801
|
search: (q, k) => searchFn(db, q, k),
|
|
763
802
|
getStats: () => getStatsFn(db),
|
|
764
803
|
indexEntries: () => indexEntriesFn(db),
|
|
804
|
+
graphRows: () => graphRowsFn(db),
|
|
765
805
|
close: () => db.close(),
|
|
766
|
-
});
|
|
806
|
+
}, sync);
|
|
767
807
|
}
|
|
768
|
-
function withReadSurface(core) {
|
|
808
|
+
function withReadSurface(core, sync) {
|
|
809
|
+
// Any sync catches the index up, a caller's own included; from then on the
|
|
810
|
+
// index stays current with this process's writes (the Spine indexes them).
|
|
811
|
+
let caughtUp = false;
|
|
812
|
+
const syncIfStale = () => { core.syncIfStale(); caughtUp = true; };
|
|
813
|
+
const syncForRead = () => {
|
|
814
|
+
if (sync === 'read' || (sync === 'once' && !caughtUp))
|
|
815
|
+
syncIfStale();
|
|
816
|
+
};
|
|
817
|
+
const synced = (fn) => (...args) => {
|
|
818
|
+
syncForRead();
|
|
819
|
+
return fn(...args);
|
|
820
|
+
};
|
|
769
821
|
const index = {
|
|
770
822
|
...core,
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
823
|
+
syncIfStale,
|
|
824
|
+
syncForRead,
|
|
825
|
+
queryEntities: synced((opts) => queryEntitiesFn(index, opts)),
|
|
826
|
+
queryChildren: synced((parentSlug) => queryEntitiesFn(index, { parent: parentSlug })),
|
|
827
|
+
queryRelatedTo: synced((slug, relation) => queryRelatedToFn(index, slug, relation)),
|
|
828
|
+
queryKnowledge: synced((opts) => queryKnowledgeFn(index, opts)),
|
|
829
|
+
querySessions: synced((opts) => querySessionsFn(index, opts)),
|
|
830
|
+
queryUnified: synced((opts) => queryUnifiedFn(index, opts)),
|
|
831
|
+
queryGraph: synced((opts) => queryGraphFn(index, opts)),
|
|
776
832
|
};
|
|
777
833
|
return index;
|
|
778
834
|
}
|
|
@@ -812,6 +868,23 @@ function toKnowledgeResult(slug, row) {
|
|
|
812
868
|
last_touched: row.last_touched ?? null,
|
|
813
869
|
};
|
|
814
870
|
}
|
|
871
|
+
function toSessionResult(slug, row) {
|
|
872
|
+
return {
|
|
873
|
+
slug,
|
|
874
|
+
name: row.name,
|
|
875
|
+
session_date: row.session_date,
|
|
876
|
+
created: row.created,
|
|
877
|
+
related: row.related ?? [],
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
/** One row of any kind, shaped for its kind. */
|
|
881
|
+
function toUnifiedResult(i) {
|
|
882
|
+
if (i.kind === 'entity')
|
|
883
|
+
return { ...toQueryResult(i.slug, i), kind: 'entity' };
|
|
884
|
+
if (i.kind === 'session')
|
|
885
|
+
return { ...toSessionResult(i.slug, i), kind: 'session' };
|
|
886
|
+
return { ...toKnowledgeResult(i.slug, i), kind: 'knowledge' };
|
|
887
|
+
}
|
|
815
888
|
const STATUS_FILTERS = new Set(ENTITY_STATUSES);
|
|
816
889
|
function mapEntityFilterOpts(qopts, opts) {
|
|
817
890
|
if (opts?.filter && STATUS_FILTERS.has(opts.filter))
|
|
@@ -867,11 +940,16 @@ function validateEntityFilterOpts(opts) {
|
|
|
867
940
|
* Asserted against the CLI's flag matrix in query-surface.test.ts. */
|
|
868
941
|
export const ENTITY_ONLY_OPTS = ['filter', 'type', 'parent'];
|
|
869
942
|
export const KNOWLEDGE_ONLY_OPTS = ['confidence', 'subtype'];
|
|
943
|
+
/** What a session query cannot map: both sides' opts, and the two every
|
|
944
|
+
* other kind shares — a session note carries no domain and no tags. */
|
|
945
|
+
export const SESSION_REJECTED_OPTS = [...ENTITY_ONLY_OPTS, ...KNOWLEDGE_ONLY_OPTS, 'domain', 'tags'];
|
|
870
946
|
/** The one sentence both scope rejections speak — opts here, sort fields in
|
|
871
947
|
* queryItemsFn — so a caller sees the same shape whichever one they hit. */
|
|
872
948
|
function outOfScope(label, name, branch) {
|
|
873
|
-
const belongsTo = branch === 'entity' ? 'knowledge notes only'
|
|
874
|
-
|
|
949
|
+
const belongsTo = branch === 'entity' ? 'knowledge notes only'
|
|
950
|
+
: branch === 'knowledge' ? 'entities only'
|
|
951
|
+
: 'entities and knowledge notes only';
|
|
952
|
+
throw new HafezError('VALIDATION_FAILED', `Invalid ${label} for a ${branch} query: "${name}" — ${belongsTo}. Use kind: 'all' to query every kind.`);
|
|
875
953
|
}
|
|
876
954
|
function rejectUnmappableOpts(opts, reject, branch) {
|
|
877
955
|
if (!opts)
|
|
@@ -887,7 +965,6 @@ function validateKnowledgeFilterOpts(opts) {
|
|
|
887
965
|
assertEnum(opts?.subtype, VALID_SUBTYPES, 'subtype');
|
|
888
966
|
}
|
|
889
967
|
function queryEntitiesFn(index, opts) {
|
|
890
|
-
index.syncIfStale();
|
|
891
968
|
validateEntityFilterOpts(opts);
|
|
892
969
|
// Was validateKnowledgeFilterOpts — it checked the *value* of an opt this
|
|
893
970
|
// branch then discarded, so a valid-looking one returned unfiltered.
|
|
@@ -901,18 +978,12 @@ function queryEntitiesFn(index, opts) {
|
|
|
901
978
|
const { items, total } = index.queryItems(qopts);
|
|
902
979
|
return { items: items.map(i => toQueryResult(i.slug, i)), total };
|
|
903
980
|
}
|
|
981
|
+
/** Every kind: the sessions that touched an entity are the look back from it. */
|
|
904
982
|
function queryRelatedToFn(index, slug, relation) {
|
|
905
|
-
index.
|
|
906
|
-
|
|
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
|
-
};
|
|
983
|
+
const { items, total } = index.queryItems({ kind: 'all', relatedTo: slug, relation });
|
|
984
|
+
return { items: items.map(toUnifiedResult), total };
|
|
913
985
|
}
|
|
914
986
|
function queryKnowledgeFn(index, opts) {
|
|
915
|
-
index.syncIfStale();
|
|
916
987
|
validateKnowledgeFilterOpts(opts);
|
|
917
988
|
rejectUnmappableOpts(opts, ENTITY_ONLY_OPTS, 'knowledge');
|
|
918
989
|
validateTimeOpts(opts);
|
|
@@ -924,14 +995,23 @@ function queryKnowledgeFn(index, opts) {
|
|
|
924
995
|
const { items, total } = index.queryItems(qopts);
|
|
925
996
|
return { items: items.map(i => toKnowledgeResult(i.slug, i)), total };
|
|
926
997
|
}
|
|
998
|
+
function querySessionsFn(index, opts) {
|
|
999
|
+
rejectUnmappableOpts(opts, SESSION_REJECTED_OPTS, 'session');
|
|
1000
|
+
validateTimeOpts(opts);
|
|
1001
|
+
validateListOpts(opts);
|
|
1002
|
+
const qopts = { kind: 'session' };
|
|
1003
|
+
mapTimeOpts(qopts, opts);
|
|
1004
|
+
mapListOpts(qopts, opts);
|
|
1005
|
+
const { items, total } = index.queryItems(qopts);
|
|
1006
|
+
return { items: items.map(i => toSessionResult(i.slug, i)), total };
|
|
1007
|
+
}
|
|
927
1008
|
function queryUnifiedFn(index, opts) {
|
|
928
|
-
index.syncIfStale();
|
|
929
1009
|
// The public type is a union discriminated on `kind`, so an out-of-scope opt
|
|
930
1010
|
// is a compile error. The runtime still has to see every opt to reject it —
|
|
931
1011
|
// an untyped caller (JS, JSON over MCP) reaches here regardless — so widen
|
|
932
1012
|
// once, here, and let the checks below speak for both.
|
|
933
1013
|
const o = opts;
|
|
934
|
-
assertEnum(o?.kind, ['entity', 'knowledge', 'all'], 'kind');
|
|
1014
|
+
assertEnum(o?.kind, ['entity', 'knowledge', 'session', 'all'], 'kind');
|
|
935
1015
|
validateEntityFilterOpts(o);
|
|
936
1016
|
validateKnowledgeFilterOpts(o);
|
|
937
1017
|
// Only `kind: 'all'` (or unset) maps both sides. `kind` also narrows this
|
|
@@ -943,24 +1023,130 @@ function queryUnifiedFn(index, opts) {
|
|
|
943
1023
|
rejectUnmappableOpts(o, KNOWLEDGE_ONLY_OPTS, 'entity');
|
|
944
1024
|
if (o?.kind === 'knowledge')
|
|
945
1025
|
rejectUnmappableOpts(o, ENTITY_ONLY_OPTS, 'knowledge');
|
|
1026
|
+
if (o?.kind === 'session')
|
|
1027
|
+
rejectUnmappableOpts(o, SESSION_REJECTED_OPTS, 'session');
|
|
946
1028
|
validateTimeOpts(o);
|
|
947
1029
|
validateListOpts(o);
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
qopts.kind = o.kind;
|
|
951
|
-
// When kind is 'all' or unset, no kind filter — both entities and knowledge are returned
|
|
1030
|
+
// Unset is the working set (entity + knowledge); 'all' reaches every kind.
|
|
1031
|
+
const qopts = { kind: o?.kind };
|
|
952
1032
|
mapEntityFilterOpts(qopts, o);
|
|
953
1033
|
mapKnowledgeFilterOpts(qopts, o);
|
|
954
1034
|
mapTimeOpts(qopts, o);
|
|
955
1035
|
mapListOpts(qopts, o);
|
|
956
1036
|
const result = index.queryItems(qopts);
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1037
|
+
return { items: result.items.map(toUnifiedResult), total: result.total };
|
|
1038
|
+
}
|
|
1039
|
+
/** The Graph's node kinds by default: the working set, entities and knowledge. */
|
|
1040
|
+
const GRAPH_KINDS = new Set(MUTABLE_KINDS);
|
|
1041
|
+
const GRAPH_RELATIONS = new Set(GRAPH_NOTE_RELATIONS);
|
|
1042
|
+
/** The opts graph() applies; any other is rejected, never dropped. */
|
|
1043
|
+
const GRAPH_OPTS = ['layers'];
|
|
1044
|
+
/** The layers whose nodes are synthetic: one per distinct value, `<layer>:<value>`. */
|
|
1045
|
+
const SYNTHETIC = new Set(SYNTHETIC_LAYERS);
|
|
1046
|
+
function queryGraphFn(index, opts) {
|
|
1047
|
+
for (const [name, value] of Object.entries(opts ?? {})) {
|
|
1048
|
+
if (value !== undefined && !GRAPH_OPTS.includes(name)) {
|
|
1049
|
+
throw new HafezError('VALIDATION_FAILED', `Invalid opt for a graph query: "${name}"`);
|
|
960
1050
|
}
|
|
961
|
-
|
|
962
|
-
|
|
1051
|
+
}
|
|
1052
|
+
const given = opts?.layers ?? [];
|
|
1053
|
+
if (!Array.isArray(given) || given.some(l => !GRAPH_LAYERS.includes(l))) {
|
|
1054
|
+
throw new HafezError('VALIDATION_FAILED', `Invalid graph layers: ${JSON.stringify(given)}. Valid values: ${GRAPH_LAYERS.join(', ')}.`);
|
|
1055
|
+
}
|
|
1056
|
+
const layers = new Set(given);
|
|
1057
|
+
const { items, links } = index.graphRows();
|
|
1058
|
+
const nodes = [];
|
|
1059
|
+
for (const i of items) {
|
|
1060
|
+
if (!GRAPH_KINDS.has(i.kind) && !(i.kind === 'session' && layers.has('session')))
|
|
1061
|
+
continue;
|
|
1062
|
+
const node = { id: i.slug, kind: i.kind, name: i.name };
|
|
1063
|
+
// The index's columns are plain strings; what it stores there for a
|
|
1064
|
+
// note is the frontmatter's validated enum value.
|
|
1065
|
+
for (const key of GRAPH_ROW_FIELDS)
|
|
1066
|
+
if (i[key] != null)
|
|
1067
|
+
Object.assign(node, { [key]: i[key] });
|
|
1068
|
+
if (i.kind === 'entity')
|
|
1069
|
+
Object.assign(node, entitySignals(i));
|
|
1070
|
+
nodes.push(node);
|
|
1071
|
+
}
|
|
1072
|
+
markUnlinked(nodes, links);
|
|
1073
|
+
// An edge needs both ends on the graph: a missing note, or a session, tag or
|
|
1074
|
+
// domain outside its layer, is no node. Related is one edge per unordered
|
|
1075
|
+
// pair (ADR 0002); a legacy one-way edge is emitted once, as written.
|
|
1076
|
+
const ids = new Set(nodes.map(n => n.id));
|
|
1077
|
+
// A Session's only edges are the `related` it names: the Entities it touched.
|
|
1078
|
+
const sessions = new Set(nodes.filter(n => n.kind === 'session').map(n => n.id));
|
|
1079
|
+
const pairs = new Set();
|
|
1080
|
+
const edges = [];
|
|
1081
|
+
// A tag or domain node exists for each value a node carries; its id cannot
|
|
1082
|
+
// collide with a slug, which never holds `:`.
|
|
1083
|
+
const layerNodes = new Map();
|
|
1084
|
+
for (const l of links) {
|
|
1085
|
+
if (layers.has(l.relation) && SYNTHETIC.has(l.relation)) {
|
|
1086
|
+
if (!ids.has(l.source) || sessions.has(l.source))
|
|
1087
|
+
continue;
|
|
1088
|
+
const id = `${l.relation}:${l.target}`;
|
|
1089
|
+
if (!layerNodes.has(id))
|
|
1090
|
+
layerNodes.set(id, { id, kind: l.relation, name: l.target });
|
|
1091
|
+
edges.push({ source: l.source, target: id, relation: l.relation });
|
|
1092
|
+
continue;
|
|
963
1093
|
}
|
|
964
|
-
|
|
965
|
-
|
|
1094
|
+
if (!GRAPH_RELATIONS.has(l.relation) || !ids.has(l.source) || !ids.has(l.target))
|
|
1095
|
+
continue;
|
|
1096
|
+
if (sessions.has(l.target) || (sessions.has(l.source) && l.relation !== 'related'))
|
|
1097
|
+
continue;
|
|
1098
|
+
if (l.relation === 'related') {
|
|
1099
|
+
const pair = [l.source, l.target].sort().join('\n');
|
|
1100
|
+
if (pairs.has(pair))
|
|
1101
|
+
continue;
|
|
1102
|
+
pairs.add(pair);
|
|
1103
|
+
}
|
|
1104
|
+
edges.push({ source: l.source, target: l.target, relation: l.relation });
|
|
1105
|
+
}
|
|
1106
|
+
nodes.push(...[...layerNodes.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)));
|
|
1107
|
+
return { nodes, edges };
|
|
1108
|
+
}
|
|
1109
|
+
/** What an Entity node carries of its state (#271): every Entity its effective
|
|
1110
|
+
* staleness-days; an active or paused one its open actions and stale-action
|
|
1111
|
+
* count too (#277: the sidebar's Paused rows count them), and an active one
|
|
1112
|
+
* its Stale flag. A done Entity carries no action signal. */
|
|
1113
|
+
function entitySignals(i) {
|
|
1114
|
+
const staleness_days = i.staleness_days ?? DEFAULT_STALENESS_DAYS;
|
|
1115
|
+
if (i.status !== 'active' && i.status !== 'paused')
|
|
1116
|
+
return { staleness_days };
|
|
1117
|
+
const today = localDay();
|
|
1118
|
+
const added = JSON.parse(i.action_added ?? '[]');
|
|
1119
|
+
return {
|
|
1120
|
+
open_actions: i.next_action_count,
|
|
1121
|
+
...(i.status === 'active' && { stale: i.stale === 1 }),
|
|
1122
|
+
stale_actions: added.filter(day => actionAge(day, staleness_days, today).stale).length,
|
|
1123
|
+
staleness_days,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
/** Mark each Unlinked note (CONTEXT: no Parent, Related or Wiki-link edge to
|
|
1127
|
+
* another Entity or Knowledge note, either way; tags and Sessions do not
|
|
1128
|
+
* count) and give it `best`, the slug its placing line names: the closest
|
|
1129
|
+
* linked note (src/closest.ts, which weighs each linked note's name and
|
|
1130
|
+
* description) to the Unlinked note's name, else to its description; the
|
|
1131
|
+
* same kind when one matches, else any kind; none when nothing matches. */
|
|
1132
|
+
function markUnlinked(nodes, links) {
|
|
1133
|
+
const notes = new Map(nodes.filter(n => n.kind === 'entity' || n.kind === 'knowledge').map(n => [n.id, n]));
|
|
1134
|
+
const linked = new Set();
|
|
1135
|
+
for (const l of links) {
|
|
1136
|
+
if (!GRAPH_RELATIONS.has(l.relation) || l.source === l.target || !notes.has(l.source) || !notes.has(l.target))
|
|
1137
|
+
continue;
|
|
1138
|
+
linked.add(l.source);
|
|
1139
|
+
linked.add(l.target);
|
|
1140
|
+
}
|
|
1141
|
+
const candidates = [...notes.values()].filter(n => linked.has(n.id))
|
|
1142
|
+
.map(n => ({ kind: n.kind, slug: n.id, name: n.name, description: n.description ?? null }));
|
|
1143
|
+
const closest = (n, among) => closestSlugs(n.name, among)[0] ?? (n.description ? closestSlugs(n.description, among)[0] : undefined);
|
|
1144
|
+
for (const n of notes.values()) {
|
|
1145
|
+
if (linked.has(n.id))
|
|
1146
|
+
continue;
|
|
1147
|
+
n.unlinked = true;
|
|
1148
|
+
const best = closest(n, candidates.filter(c => c.kind === n.kind)) ?? closest(n, candidates);
|
|
1149
|
+
if (best !== undefined)
|
|
1150
|
+
n.best = best;
|
|
1151
|
+
}
|
|
966
1152
|
}
|
package/dist/digest.js
CHANGED
|
@@ -67,18 +67,22 @@ export function digest(input, entitySlugs, knowledgeSlugs = new Set()) {
|
|
|
67
67
|
ops.push({ op: 'update', slug, fields });
|
|
68
68
|
}
|
|
69
69
|
// 2. Session note
|
|
70
|
-
const noteBody = buildSessionNoteBody(input, touched);
|
|
71
70
|
const related = touched;
|
|
72
71
|
// Typechecked against the Op Spec's session-create schema so digest output
|
|
73
72
|
// can never silently diverge from what batch validation accepts.
|
|
73
|
+
// A blank decision is dropped here: the Op Spec takes non-empty text only,
|
|
74
|
+
// and one blank line must not fail the whole save. The name hash below still
|
|
75
|
+
// reads the raw input, so the slug a save before this derived is unchanged.
|
|
76
|
+
const decisions = input.decisions.map(d => d.trim()).filter(Boolean);
|
|
74
77
|
const noteFields = {
|
|
75
|
-
synthesis:
|
|
78
|
+
synthesis: input.narrative.trim(),
|
|
76
79
|
'session-date': input.session_date,
|
|
77
|
-
...(
|
|
80
|
+
...(decisions.length > 0 ? { decisions } : {}),
|
|
81
|
+
...(related.length > 0 ? { related, entities_touched: touched } : {}),
|
|
78
82
|
};
|
|
79
|
-
// Named by the session date and its
|
|
80
|
-
// session derives the same slug and batch skips it as a duplicate.
|
|
81
|
-
const noteName = `Session ${input.session_date} ${createHash('sha256').update(
|
|
83
|
+
// Named by the session date and its whole content, so a second save of the
|
|
84
|
+
// same session derives the same slug and batch skips it as a duplicate.
|
|
85
|
+
const noteName = `Session ${input.session_date} ${createHash('sha256').update(sessionIdentity(input, touched)).digest('hex').slice(0, 8)}`;
|
|
82
86
|
ops.push({ op: 'create', kind: 'session', name: noteName, fields: noteFields });
|
|
83
87
|
return ops;
|
|
84
88
|
}
|
|
@@ -90,7 +94,10 @@ function buildSessionLogSummary(input) {
|
|
|
90
94
|
}
|
|
91
95
|
return parts.join(' | ');
|
|
92
96
|
}
|
|
93
|
-
|
|
97
|
+
/** What the session note's name hashes: narrative, decisions and touched
|
|
98
|
+
* slugs, in the text the note body held before its sections were split
|
|
99
|
+
* (#210), so a session saved before then is still skipped when saved again. */
|
|
100
|
+
function sessionIdentity(input, touched) {
|
|
94
101
|
const lines = [input.narrative.trim()];
|
|
95
102
|
if (input.decisions.length > 0) {
|
|
96
103
|
lines.push('');
|
package/dist/document.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type SessionLogEntry, type SessionLogType } from './types.js';
|
|
2
|
+
export declare const ACTION_RE: RegExp;
|
|
2
3
|
/** The action text without its trailing added-date stamp, if any. */
|
|
3
4
|
export declare function stripActionStamp(text: string): string;
|
|
4
5
|
/** The day an action's stamp names; null when the line is undated. */
|
|
@@ -45,6 +46,7 @@ export declare function firstNonEmptyLine(s: string | null | undefined): string;
|
|
|
45
46
|
* every consumer (Related coverage, mention harvesting, export) sees the bare slug.
|
|
46
47
|
* Non-global — wrap with `new RegExp(WIKI_LINK_RE.source, 'g')` for iteration. */
|
|
47
48
|
export declare const WIKI_LINK_RE: RegExp;
|
|
49
|
+
export declare function escapeRegex(s: string): string;
|
|
48
50
|
/** What documentFor needs to know about a file to bind its section set.
|
|
49
51
|
*
|
|
50
52
|
* KIND is the whole context — deliberately no type/subtype narrowing. A file
|
package/dist/document.js
CHANGED
|
@@ -16,7 +16,7 @@ import { localDay } from './dates.js';
|
|
|
16
16
|
// One checkbox line of Next Actions, tested line by line. `m` so a CRLF
|
|
17
17
|
// line's trailing \r is not taken as action text.
|
|
18
18
|
const OPEN_ACTION_RE = /^- \[ \] (.+)$/m;
|
|
19
|
-
const ACTION_RE = /^- \[([ xX])\] (.+)$/m;
|
|
19
|
+
export const ACTION_RE = /^- \[([ xX])\] (.+)$/m;
|
|
20
20
|
// --- Next-action added-date stamp ---
|
|
21
21
|
// Adding an action stamps its line `(added YYYY-MM-DD)` with the local day
|
|
22
22
|
// (Apply does the stamping — every add path goes through it). The stamp is
|
|
@@ -109,7 +109,7 @@ export function firstNonEmptyLine(s) {
|
|
|
109
109
|
* every consumer (Related coverage, mention harvesting, export) sees the bare slug.
|
|
110
110
|
* Non-global — wrap with `new RegExp(WIKI_LINK_RE.source, 'g')` for iteration. */
|
|
111
111
|
export const WIKI_LINK_RE = /\[\[\s*([^\]|]+?)\s*(?:\|\s*([^\]]+?)\s*)?\]\]/;
|
|
112
|
-
function escapeRegex(s) {
|
|
112
|
+
export function escapeRegex(s) {
|
|
113
113
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
114
114
|
}
|
|
115
115
|
/**
|
package/dist/git.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type GitConfig, type ChangelogEntry, type Journal } from './types.js';
|
|
1
|
+
import { type GitConfig, type ChangelogEntry, type FileChange, type Journal } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Bidirectional sync: pull remote changes, push local commits.
|
|
4
4
|
* All work must be committed before calling — no file save/restore needed.
|
|
@@ -8,7 +8,7 @@ export declare function gitSync(vaultPath: string): Promise<{
|
|
|
8
8
|
pushed: boolean;
|
|
9
9
|
remote: boolean;
|
|
10
10
|
}>;
|
|
11
|
-
export declare function gitChangelog(vaultPath: string, since: string): Promise<ChangelogEntry[]>;
|
|
11
|
+
export declare function gitChangelog(vaultPath: string, since: string, limit?: number): Promise<ChangelogEntry[]>;
|
|
12
12
|
/**
|
|
13
13
|
* Latest git time per file (repo-relative, forward-slash paths). Used to
|
|
14
14
|
* break day-granularity frontmatter ties in stats recents. `modified` maps
|
|
@@ -16,6 +16,17 @@ export declare function gitChangelog(vaultPath: string, since: string): Promise<
|
|
|
16
16
|
* Files with no history (not yet committed) are absent from the map.
|
|
17
17
|
*/
|
|
18
18
|
export declare function gitFileTimes(vaultPath: string, mode: 'modified' | 'added'): Promise<Map<string, string>>;
|
|
19
|
+
/**
|
|
20
|
+
* The Journal's changesSince: HEAD as the cursor, and the indexed-dir files
|
|
21
|
+
* whose content differs between `from` and HEAD. A diff between two points,
|
|
22
|
+
* not a walk of the commits in between, so the status is the net one: a file
|
|
23
|
+
* made and removed inside the span is not there. core.quotePath off keeps
|
|
24
|
+
* non-ASCII paths (legitimate slugs) unquoted.
|
|
25
|
+
*/
|
|
26
|
+
export declare function gitChangesSince(vaultPath: string, from?: string): Promise<{
|
|
27
|
+
cursor: string;
|
|
28
|
+
files: FileChange[];
|
|
29
|
+
}>;
|
|
19
30
|
export declare function gitCommitAndPush(vaultPath: string, files: string[], message: string, config?: GitConfig): Promise<void>;
|
|
20
31
|
/**
|
|
21
32
|
* The production Journal (see CONTEXT.md): vault persistence backed by git.
|