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.
Files changed (75) hide show
  1. package/README.md +72 -5
  2. package/dist/apply.d.ts +14 -1
  3. package/dist/apply.js +94 -32
  4. package/dist/batch-ops.d.ts +31 -27
  5. package/dist/batch-ops.js +83 -16
  6. package/dist/cli/block-hook.d.ts +6 -0
  7. package/dist/cli/block-hook.js +27 -12
  8. package/dist/cli/commands.d.ts +3 -1
  9. package/dist/cli/commands.js +183 -73
  10. package/dist/cli/format.d.ts +11 -3
  11. package/dist/cli/format.js +61 -4
  12. package/dist/cli/index.js +40 -19
  13. package/dist/cli/install.d.ts +9 -0
  14. package/dist/cli/install.js +36 -15
  15. package/dist/cli/onboard.js +30 -9
  16. package/dist/cli/upgrade.js +5 -4
  17. package/dist/contracts.d.ts +9 -0
  18. package/dist/contracts.js +9 -0
  19. package/dist/dates.d.ts +7 -0
  20. package/dist/dates.js +21 -1
  21. package/dist/db.d.ts +50 -10
  22. package/dist/db.js +251 -65
  23. package/dist/digest.js +14 -7
  24. package/dist/document.d.ts +2 -0
  25. package/dist/document.js +2 -2
  26. package/dist/git.d.ts +13 -2
  27. package/dist/git.js +121 -14
  28. package/dist/graph/activity.d.ts +5 -0
  29. package/dist/graph/activity.js +70 -0
  30. package/dist/graph/client.js +326 -0
  31. package/dist/graph/copy-fonts.d.ts +1 -0
  32. package/dist/graph/copy-fonts.js +13 -0
  33. package/dist/graph/fields.d.ts +8 -0
  34. package/dist/graph/fields.js +12 -0
  35. package/dist/graph/fonts/geist-OFL.txt +93 -0
  36. package/dist/graph/fonts/geist-mono-OFL.txt +93 -0
  37. package/dist/graph/fonts/geist-mono.woff2 +0 -0
  38. package/dist/graph/fonts/geist.woff2 +0 -0
  39. package/dist/graph/fonts/outfit-OFL.txt +93 -0
  40. package/dist/graph/fonts/outfit.woff2 +0 -0
  41. package/dist/graph/fonts.d.ts +14 -0
  42. package/dist/graph/fonts.js +17 -0
  43. package/dist/graph/live.d.ts +16 -0
  44. package/dist/graph/live.js +99 -0
  45. package/dist/graph/open.d.ts +1 -0
  46. package/dist/graph/open.js +20 -0
  47. package/dist/graph/server.d.ts +17 -0
  48. package/dist/graph/server.js +189 -0
  49. package/dist/health.d.ts +6 -0
  50. package/dist/health.js +310 -0
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.js +66 -18
  53. package/dist/internal.d.ts +21 -0
  54. package/dist/internal.js +9 -0
  55. package/dist/knowledge-index.d.ts +1 -1
  56. package/dist/read-json.d.ts +26 -0
  57. package/dist/read-json.js +53 -0
  58. package/dist/schema.d.ts +2 -0
  59. package/dist/schema.js +11 -0
  60. package/dist/stale.d.ts +2 -2
  61. package/dist/stale.js +4 -9
  62. package/dist/surfaces/items.d.ts +100 -1
  63. package/dist/surfaces/items.js +148 -10
  64. package/dist/surfaces/surfaces.d.ts +8 -5
  65. package/dist/surfaces/surfaces.js +34 -17
  66. package/dist/templates.d.ts +2 -0
  67. package/dist/templates.js +6 -1
  68. package/dist/types.d.ts +239 -25
  69. package/dist/validate.js +44 -56
  70. package/dist/vault.d.ts +29 -9
  71. package/dist/vault.js +39 -8
  72. package/dist/workstream.d.ts +17 -0
  73. package/dist/workstream.js +78 -0
  74. package/package.json +10 -4
  75. 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 { ENTITY_AGE_DAYS_SQL, ENTITY_STALE_SQL } from './stale.js';
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 = '11';
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 — knowledge rows are indexed with a NULL
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 = 'knowledge' OR i.status = @status)");
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 (KNOWLEDGE_ONLY_SORTS.has(opts.sort_by) && opts.kind === 'entity') {
250
- outOfScope('sort_by', opts.sort_by, 'entity');
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
- let kindFilter = '';
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 && kind !== 'all') {
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 indexed yet)
661
- const targetEntityPath = join(vaultPath, kindDir('entity'), `${targetSlug}.md`);
662
- const targetKnowledgePath = join(vaultPath, kindDir('knowledge'), `${targetSlug}.md`);
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
- : existsSync(targetEntityPath) || existsSync(targetKnowledgePath);
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 file of scanVaultDir(join(vaultPath, kindDir('entity')))) {
679
- allVaultSlugs.add(basename(file, '.md'));
680
- }
681
- for (const file of scanVaultDir(join(vaultPath, kindDir('knowledge')))) {
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 file of scanVaultDir(join(vaultPath, kindDir('knowledge')))) {
688
- indexFile(file, 'knowledge', allVaultSlugs);
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
- 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),
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' : 'entities only';
874
- throw new HafezError('VALIDATION_FAILED', `Invalid ${label} for a ${branch} query: "${name}" — ${belongsTo}. Use kind: 'all' to query both.`);
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.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
- };
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
- 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
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
- const items = result.items.map(i => {
958
- if (i.kind === 'entity') {
959
- return { ...toQueryResult(i.slug, i), kind: 'entity' };
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
- else {
962
- return { ...toKnowledgeResult(i.slug, i), kind: 'knowledge' };
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
- return { items, total: result.total };
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: noteBody,
78
+ synthesis: input.narrative.trim(),
76
79
  'session-date': input.session_date,
77
- ...(related.length > 0 ? { related } : {}),
80
+ ...(decisions.length > 0 ? { decisions } : {}),
81
+ ...(related.length > 0 ? { related, entities_touched: touched } : {}),
78
82
  };
79
- // Named by the session date and its note, so a second save of the same
80
- // session derives the same slug and batch skips it as a duplicate.
81
- const noteName = `Session ${input.session_date} ${createHash('sha256').update(noteBody).digest('hex').slice(0, 8)}`;
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
- function buildSessionNoteBody(input, touched) {
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('');
@@ -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.