hafez 2.2.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 (67) hide show
  1. package/README.md +48 -1
  2. package/dist/apply.d.ts +12 -0
  3. package/dist/apply.js +82 -17
  4. package/dist/batch-ops.d.ts +31 -27
  5. package/dist/batch-ops.js +80 -15
  6. package/dist/cli/block-hook.d.ts +6 -0
  7. package/dist/cli/block-hook.js +3 -3
  8. package/dist/cli/commands.d.ts +2 -0
  9. package/dist/cli/commands.js +98 -46
  10. package/dist/cli/format.d.ts +4 -1
  11. package/dist/cli/format.js +19 -0
  12. package/dist/cli/index.js +11 -2
  13. package/dist/cli/upgrade.js +1 -1
  14. package/dist/contracts.d.ts +9 -0
  15. package/dist/contracts.js +9 -0
  16. package/dist/dates.d.ts +3 -0
  17. package/dist/dates.js +9 -0
  18. package/dist/db.d.ts +34 -4
  19. package/dist/db.js +163 -21
  20. package/dist/digest.js +14 -7
  21. package/dist/document.d.ts +2 -0
  22. package/dist/document.js +2 -2
  23. package/dist/git.d.ts +13 -2
  24. package/dist/git.js +47 -5
  25. package/dist/graph/activity.d.ts +5 -0
  26. package/dist/graph/activity.js +70 -0
  27. package/dist/graph/client.js +326 -0
  28. package/dist/graph/copy-fonts.d.ts +1 -0
  29. package/dist/graph/copy-fonts.js +13 -0
  30. package/dist/graph/fields.d.ts +8 -0
  31. package/dist/graph/fields.js +12 -0
  32. package/dist/graph/fonts/geist-OFL.txt +93 -0
  33. package/dist/graph/fonts/geist-mono-OFL.txt +93 -0
  34. package/dist/graph/fonts/geist-mono.woff2 +0 -0
  35. package/dist/graph/fonts/geist.woff2 +0 -0
  36. package/dist/graph/fonts/outfit-OFL.txt +93 -0
  37. package/dist/graph/fonts/outfit.woff2 +0 -0
  38. package/dist/graph/fonts.d.ts +14 -0
  39. package/dist/graph/fonts.js +17 -0
  40. package/dist/graph/live.d.ts +16 -0
  41. package/dist/graph/live.js +99 -0
  42. package/dist/graph/open.d.ts +1 -0
  43. package/dist/graph/open.js +20 -0
  44. package/dist/graph/server.d.ts +17 -0
  45. package/dist/graph/server.js +189 -0
  46. package/dist/health.d.ts +6 -0
  47. package/dist/health.js +310 -0
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +37 -9
  50. package/dist/internal.d.ts +21 -0
  51. package/dist/internal.js +9 -0
  52. package/dist/read-json.d.ts +26 -0
  53. package/dist/read-json.js +53 -0
  54. package/dist/stale.d.ts +2 -2
  55. package/dist/stale.js +4 -9
  56. package/dist/surfaces/items.d.ts +44 -0
  57. package/dist/surfaces/items.js +68 -9
  58. package/dist/surfaces/surfaces.js +3 -1
  59. package/dist/templates.d.ts +2 -0
  60. package/dist/templates.js +6 -1
  61. package/dist/types.d.ts +178 -8
  62. package/dist/vault.d.ts +9 -2
  63. package/dist/vault.js +10 -0
  64. package/dist/workstream.d.ts +17 -0
  65. package/dist/workstream.js +78 -0
  66. package/package.json +9 -3
  67. package/skill/hafez/SKILL.md +12 -6
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.
@@ -24,8 +26,9 @@ import { localDay } from './dates.js';
24
26
  // v11: next_action and the searchable body store action text with its
25
27
  // added-date stamp stripped.
26
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).
27
30
  // Existing instances will fully rebuild the index on first access after upgrade.
28
- export const SCHEMA_VERSION = '12';
31
+ export const SCHEMA_VERSION = '13';
29
32
  const SCHEMA = `
30
33
  CREATE TABLE IF NOT EXISTS items (
31
34
  slug TEXT PRIMARY KEY,
@@ -52,7 +55,8 @@ CREATE TABLE IF NOT EXISTS items (
52
55
  last_session_date TEXT,
53
56
  last_session_type TEXT,
54
57
  last_session_summary TEXT,
55
- session_date TEXT
58
+ session_date TEXT,
59
+ action_added TEXT
56
60
  );
57
61
 
58
62
  CREATE TABLE IF NOT EXISTS links (
@@ -322,6 +326,14 @@ function indexEntriesFn(db) {
322
326
  domain: getDomains.all(row.slug).map(d => d.target),
323
327
  }));
324
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
+ }
325
337
  function searchFn(db, query, kind) {
326
338
  const { fts: ftsQuery, terms } = sanitizeFtsQuery(query);
327
339
  if (!ftsQuery)
@@ -435,6 +447,7 @@ export function __setAfterScanHook(fn) { afterScanHook = fn; }
435
447
  export function createIndex(vaultPath, opts) {
436
448
  const dbPath = join(vaultPath, '.hafez.db');
437
449
  const isReadOnly = opts?.readonly ?? false;
450
+ const sync = opts?.sync ?? 'read';
438
451
  if (isReadOnly) {
439
452
  if (!existsSync(dbPath))
440
453
  return null;
@@ -483,11 +496,12 @@ export function createIndex(vaultPath, opts) {
483
496
  search: (q, k) => searchFn(db, q, k),
484
497
  getStats: () => getStatsFn(db),
485
498
  indexEntries: () => indexEntriesFn(db),
499
+ graphRows: () => graphRowsFn(db),
486
500
  close: () => { try {
487
501
  db.close();
488
502
  }
489
503
  catch { } },
490
- });
504
+ }, sync);
491
505
  }
492
506
  // --- Write-capable path ---
493
507
  // The db files are a disposable local cache — keep them out of the user's
@@ -574,12 +588,12 @@ export function createIndex(vaultPath, opts) {
574
588
  (slug, kind, name, type, status, parent, next_action, next_action_count, staleness_days,
575
589
  confidence, reinforcement_count, last_reinforced, created, last_touched, body, file_hash, subtype,
576
590
  description, resource, brief, session_log_count, last_session_date, last_session_type, last_session_summary,
577
- session_date)
591
+ session_date, action_added)
578
592
  VALUES
579
593
  (@slug, @kind, @name, @type, @status, @parent, @next_action, @next_action_count, @staleness_days,
580
594
  @confidence, @reinforcement_count, @last_reinforced, @created, @last_touched, @body, @file_hash, @subtype,
581
595
  @description, @resource, @brief, @session_log_count, @last_session_date, @last_session_type, @last_session_summary,
582
- @session_date)
596
+ @session_date, @action_added)
583
597
  `);
584
598
  const deleteItemStmt = db.prepare('DELETE FROM items WHERE slug = ?');
585
599
  const deleteLinksStmt = db.prepare('DELETE FROM links WHERE source = ?');
@@ -644,6 +658,9 @@ export function createIndex(vaultPath, opts) {
644
658
  last_session_summary: lastLog?.summary?.slice(0, 300) ?? null,
645
659
  // A session note's one day; the created stamp when digest gave none.
646
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,
647
664
  };
648
665
  // Replace item and rebuild links. Delete-then-insert keeps the FTS index
649
666
  // trigger-consistent for externally changed files (hand edit, git pull),
@@ -784,18 +801,34 @@ export function createIndex(vaultPath, opts) {
784
801
  search: (q, k) => searchFn(db, q, k),
785
802
  getStats: () => getStatsFn(db),
786
803
  indexEntries: () => indexEntriesFn(db),
804
+ graphRows: () => graphRowsFn(db),
787
805
  close: () => db.close(),
788
- });
806
+ }, sync);
789
807
  }
790
- 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
+ };
791
821
  const index = {
792
822
  ...core,
793
- queryEntities: (opts) => queryEntitiesFn(index, opts),
794
- queryChildren: (parentSlug) => queryEntitiesFn(index, { parent: parentSlug }),
795
- queryRelatedTo: (slug, relation) => queryRelatedToFn(index, slug, relation),
796
- queryKnowledge: (opts) => queryKnowledgeFn(index, opts),
797
- querySessions: (opts) => querySessionsFn(index, opts),
798
- 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)),
799
832
  };
800
833
  return index;
801
834
  }
@@ -932,7 +965,6 @@ function validateKnowledgeFilterOpts(opts) {
932
965
  assertEnum(opts?.subtype, VALID_SUBTYPES, 'subtype');
933
966
  }
934
967
  function queryEntitiesFn(index, opts) {
935
- index.syncIfStale();
936
968
  validateEntityFilterOpts(opts);
937
969
  // Was validateKnowledgeFilterOpts — it checked the *value* of an opt this
938
970
  // branch then discarded, so a valid-looking one returned unfiltered.
@@ -948,12 +980,10 @@ function queryEntitiesFn(index, opts) {
948
980
  }
949
981
  /** Every kind: the sessions that touched an entity are the look back from it. */
950
982
  function queryRelatedToFn(index, slug, relation) {
951
- index.syncIfStale();
952
983
  const { items, total } = index.queryItems({ kind: 'all', relatedTo: slug, relation });
953
984
  return { items: items.map(toUnifiedResult), total };
954
985
  }
955
986
  function queryKnowledgeFn(index, opts) {
956
- index.syncIfStale();
957
987
  validateKnowledgeFilterOpts(opts);
958
988
  rejectUnmappableOpts(opts, ENTITY_ONLY_OPTS, 'knowledge');
959
989
  validateTimeOpts(opts);
@@ -966,7 +996,6 @@ function queryKnowledgeFn(index, opts) {
966
996
  return { items: items.map(i => toKnowledgeResult(i.slug, i)), total };
967
997
  }
968
998
  function querySessionsFn(index, opts) {
969
- index.syncIfStale();
970
999
  rejectUnmappableOpts(opts, SESSION_REJECTED_OPTS, 'session');
971
1000
  validateTimeOpts(opts);
972
1001
  validateListOpts(opts);
@@ -977,7 +1006,6 @@ function querySessionsFn(index, opts) {
977
1006
  return { items: items.map(i => toSessionResult(i.slug, i)), total };
978
1007
  }
979
1008
  function queryUnifiedFn(index, opts) {
980
- index.syncIfStale();
981
1009
  // The public type is a union discriminated on `kind`, so an out-of-scope opt
982
1010
  // is a compile error. The runtime still has to see every opt to reject it —
983
1011
  // an untyped caller (JS, JSON over MCP) reaches here regardless — so widen
@@ -1008,3 +1036,117 @@ function queryUnifiedFn(index, opts) {
1008
1036
  const result = index.queryItems(qopts);
1009
1037
  return { items: result.items.map(toUnifiedResult), total: result.total };
1010
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}"`);
1050
+ }
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;
1093
+ }
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
+ }
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.
package/dist/git.js CHANGED
@@ -451,7 +451,7 @@ function gitReadFailed(err) {
451
451
  const lines = (err instanceof Error ? err.message : String(err)).split('\n').map(l => l.trim()).filter(Boolean);
452
452
  return new HafezError('GIT_READ_FAILED', `git read failed: ${lines[0] ?? 'unknown error'}`, lines);
453
453
  }
454
- export async function gitChangelog(vaultPath, since) {
454
+ export async function gitChangelog(vaultPath, since, limit) {
455
455
  const git = vaultGit(vaultPath);
456
456
  // Bare dates become local midnight — the same calendar-day boundary the
457
457
  // vault stamps and query comparisons use (see dates.ts).
@@ -459,7 +459,7 @@ export async function gitChangelog(vaultPath, since) {
459
459
  let output;
460
460
  try {
461
461
  output = await git.raw([
462
- 'log', `--since=${normalizedSince}`, '--name-status',
462
+ 'log', ...(since ? [`--since=${normalizedSince}`] : []), ...(limit === undefined ? [] : [`--max-count=${limit}`]), '--name-status',
463
463
  '--pretty=format:%H|%aI|%s', '--', ...INDEXED_DIR_PATHSPECS,
464
464
  ]);
465
465
  }
@@ -479,7 +479,7 @@ export async function gitChangelog(vaultPath, since) {
479
479
  const headerMatch = lines[0].match(/^([0-9a-f]+)\|(.+?)\|(.*)$/);
480
480
  if (!headerMatch)
481
481
  continue;
482
- const [, , timestamp, commit_message] = headerMatch;
482
+ const [, commit, timestamp, commit_message] = headerMatch;
483
483
  for (let i = 1; i < lines.length; i++) {
484
484
  for (const { path: filePath, operation } of parseNameStatusLine(lines[i])) {
485
485
  // Only vault documents — scaffold files (.gitkeep) live in the same
@@ -489,7 +489,7 @@ export async function gitChangelog(vaultPath, since) {
489
489
  const classified = classifyIndexedRelPath(filePath);
490
490
  if (!classified)
491
491
  continue;
492
- entries.push({ slug: classified.slug, kind: classified.kind, operation, timestamp, commit_message });
492
+ entries.push({ slug: classified.slug, kind: classified.kind, operation, timestamp, commit_message, commit });
493
493
  }
494
494
  }
495
495
  }
@@ -528,6 +528,47 @@ export async function gitFileTimes(vaultPath, mode) {
528
528
  }
529
529
  return times;
530
530
  }
531
+ const CHANGE_OPS = { created: 'create', updated: 'update', deleted: 'delete' };
532
+ /**
533
+ * The Journal's changesSince: HEAD as the cursor, and the indexed-dir files
534
+ * whose content differs between `from` and HEAD. A diff between two points,
535
+ * not a walk of the commits in between, so the status is the net one: a file
536
+ * made and removed inside the span is not there. core.quotePath off keeps
537
+ * non-ASCII paths (legitimate slugs) unquoted.
538
+ */
539
+ export async function gitChangesSince(vaultPath, from) {
540
+ const git = vaultGit(vaultPath);
541
+ let cursor;
542
+ try {
543
+ cursor = (await git.raw(['log', '-1', '--format=%H'])).trim();
544
+ }
545
+ catch (err) {
546
+ if (isNoHistory(err))
547
+ return { cursor: '', files: [] };
548
+ throw gitReadFailed(err);
549
+ }
550
+ if (from === undefined || from === cursor)
551
+ return { cursor, files: [] };
552
+ const files = [];
553
+ try {
554
+ if (from === '') {
555
+ const listed = await git.raw(['-c', 'core.quotePath=false', 'ls-tree', '-r', '--name-only', cursor, '--', ...INDEXED_DIR_PATHSPECS]);
556
+ for (const path of listed.split('\n').filter(Boolean))
557
+ files.push({ path, op: 'create' });
558
+ }
559
+ else {
560
+ const diff = await git.raw(['-c', 'core.quotePath=false', 'diff', '--name-status', from, cursor, '--', ...INDEXED_DIR_PATHSPECS]);
561
+ for (const line of diff.split('\n')) {
562
+ for (const { path, operation } of parseNameStatusLine(line))
563
+ files.push({ path, op: CHANGE_OPS[operation] });
564
+ }
565
+ }
566
+ }
567
+ catch (err) {
568
+ throw gitReadFailed(err);
569
+ }
570
+ return { cursor, files };
571
+ }
531
572
  export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
532
573
  const git = vaultGit(vaultPath);
533
574
  // If a previous run crashed mid-rebase/merge, the repo is stuck.
@@ -601,7 +642,8 @@ export function createGitJournal(vaultPath, config = {}) {
601
642
  }
602
643
  },
603
644
  sync: () => gitSync(vaultPath),
604
- changelog: (since) => gitChangelog(vaultPath, since),
645
+ changelog: (since, limit) => gitChangelog(vaultPath, since, limit),
605
646
  fileTimes: (mode) => gitFileTimes(vaultPath, mode),
647
+ changesSince: (from) => gitChangesSince(vaultPath, from),
606
648
  };
607
649
  }
@@ -0,0 +1,5 @@
1
+ import type { ActivityRow, Hafez } from '../types.js';
2
+ /** How many rows `/activity` lists. */
3
+ export declare const ACTIVITY_ROWS = 20;
4
+ /** `names` maps a slug to the name its node carries now. */
5
+ export declare function activity(os: Hafez, names: Map<string, string>): Promise<ActivityRow[]>;
@@ -0,0 +1,70 @@
1
+ import { parseSessionLog } from '../document.js';
2
+ import { localDay } from '../dates.js';
3
+ import { kindDir, noteAtRelPath } from '../vault.js';
4
+ /** How many rows `/activity` lists. */
5
+ export const ACTIVITY_ROWS = 20;
6
+ /** How many of the newest commits are read first: a history read costs
7
+ * ~25 ms a commit on drvfs, so the whole history is never read at once.
8
+ * While those collapse into fewer rows, twice as many are read, until the
9
+ * rows are full or a read covers fewer commits than it asked for (the
10
+ * history's end). */
11
+ const COMMITS_READ = 50;
12
+ const OP = { created: 'create', updated: 'update', deleted: 'delete' };
13
+ /** `names` maps a slug to the name its node carries now. */
14
+ export async function activity(os, names) {
15
+ for (let commits = COMMITS_READ;; commits *= 2) {
16
+ const entries = await os.changelog('', commits);
17
+ const { rows, full } = collapse(entries, names);
18
+ // The commits the read covered, counted by hash: every note commit lists
19
+ // at least one entry (a commit touching no note under a note dir, the
20
+ // scaffold's .gitkeep, is the history's first).
21
+ if (full || new Set(entries.map(e => e.commit)).size < commits)
22
+ return withAgents(os, rows);
23
+ }
24
+ }
25
+ /** The changelog's entries as rows, newest first; `full` once a row past the
26
+ * last has begun, so the last row's run of writes is known to be whole. */
27
+ function collapse(entries, names) {
28
+ const rows = [];
29
+ for (const e of entries) {
30
+ // The changelog lists every file under a kind dir, an archive log
31
+ // (`entities/archive/<slug>-log.md`) too; the Layout says which are notes.
32
+ if (!noteAtRelPath(`${kindDir(e.kind)}/${e.slug}.md`))
33
+ continue;
34
+ const op = OP[e.operation];
35
+ const last = rows[rows.length - 1];
36
+ if (last?.slug === e.slug) {
37
+ // A promote deletes and creates the note in one commit: one write, and
38
+ // the create is what happened to it.
39
+ if (last.commit === e.commit && (last.op === 'delete') !== (op === 'delete')) {
40
+ if (op !== 'delete' && last.count === 1)
41
+ Object.assign(last, { op, kind: e.kind });
42
+ }
43
+ else {
44
+ last.count++;
45
+ last.commit = e.commit;
46
+ }
47
+ continue;
48
+ }
49
+ if (rows.length === ACTIVITY_ROWS)
50
+ return { rows, full: true };
51
+ rows.push({ slug: e.slug, kind: e.kind, op, timestamp: e.timestamp, name: names.get(e.slug) ?? e.slug, count: 1, commit: e.commit });
52
+ }
53
+ return { rows, full: false };
54
+ }
55
+ /** Each Entity row with the agent its Session Log records for the write's day. */
56
+ function withAgents(os, rows) {
57
+ return Promise.all(rows.map(async ({ commit: _, ...row }) => {
58
+ const agent = row.kind === 'entity' && row.op !== 'delete' ? await agentOf(os, row.slug, localDay(new Date(row.timestamp))) : undefined;
59
+ return agent ? { ...row, agent } : row;
60
+ }));
61
+ }
62
+ /** The agent of the Entity's newest Session Log entry dated `day`. */
63
+ async function agentOf(os, slug, day) {
64
+ try {
65
+ return parseSessionLog((await os.read(slug, 'full')).body).find(entry => entry.date === day)?.agent;
66
+ }
67
+ catch {
68
+ return undefined; // gone since: nothing to read the agent from
69
+ }
70
+ }