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.
- package/README.md +48 -1
- package/dist/apply.d.ts +12 -0
- package/dist/apply.js +82 -17
- package/dist/batch-ops.d.ts +31 -27
- package/dist/batch-ops.js +80 -15
- package/dist/cli/block-hook.d.ts +6 -0
- package/dist/cli/block-hook.js +3 -3
- package/dist/cli/commands.d.ts +2 -0
- package/dist/cli/commands.js +98 -46
- package/dist/cli/format.d.ts +4 -1
- package/dist/cli/format.js +19 -0
- package/dist/cli/index.js +11 -2
- package/dist/cli/upgrade.js +1 -1
- package/dist/contracts.d.ts +9 -0
- package/dist/contracts.js +9 -0
- package/dist/dates.d.ts +3 -0
- package/dist/dates.js +9 -0
- package/dist/db.d.ts +34 -4
- package/dist/db.js +163 -21
- 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 +47 -5
- 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 +37 -9
- package/dist/internal.d.ts +21 -0
- package/dist/internal.js +9 -0
- package/dist/read-json.d.ts +26 -0
- package/dist/read-json.js +53 -0
- package/dist/stale.d.ts +2 -2
- package/dist/stale.js +4 -9
- package/dist/surfaces/items.d.ts +44 -0
- package/dist/surfaces/items.js +68 -9
- package/dist/surfaces/surfaces.js +3 -1
- package/dist/templates.d.ts +2 -0
- package/dist/templates.js +6 -1
- package/dist/types.d.ts +178 -8
- package/dist/vault.d.ts +9 -2
- package/dist/vault.js +10 -0
- package/dist/workstream.d.ts +17 -0
- package/dist/workstream.js +78 -0
- package/package.json +9 -3
- 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 {
|
|
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 = '
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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:
|
|
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.
|
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}
|
|
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
|
+
}
|