hafez 2.1.0 → 2.2.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 +24 -4
- package/dist/apply.d.ts +2 -1
- package/dist/apply.js +16 -19
- package/dist/batch-ops.js +3 -1
- package/dist/cli/block-hook.js +24 -9
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +106 -48
- package/dist/cli/format.d.ts +8 -3
- package/dist/cli/format.js +42 -4
- package/dist/cli/index.js +29 -17
- package/dist/cli/install.d.ts +9 -0
- package/dist/cli/install.js +36 -15
- package/dist/cli/onboard.js +30 -9
- package/dist/cli/upgrade.js +4 -3
- package/dist/dates.d.ts +4 -0
- package/dist/dates.js +12 -1
- package/dist/db.d.ts +18 -8
- package/dist/db.js +96 -52
- package/dist/git.js +74 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +30 -10
- package/dist/knowledge-index.d.ts +1 -1
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +11 -0
- package/dist/surfaces/items.d.ts +56 -1
- package/dist/surfaces/items.js +84 -5
- package/dist/surfaces/surfaces.d.ts +8 -5
- package/dist/surfaces/surfaces.js +31 -16
- package/dist/types.d.ts +63 -19
- package/dist/validate.js +44 -56
- package/dist/vault.d.ts +20 -7
- package/dist/vault.js +29 -8
- package/package.json +2 -2
- package/skill/hafez/SKILL.md +5 -2
package/dist/db.js
CHANGED
|
@@ -23,8 +23,9 @@ import { localDay } from './dates.js';
|
|
|
23
23
|
// rows (REPLACE never fired the delete trigger) and must be rebuilt, not reused.
|
|
24
24
|
// v11: next_action and the searchable body store action text with its
|
|
25
25
|
// added-date stamp stripped.
|
|
26
|
+
// v12: sessions are indexed (#35); session_date column.
|
|
26
27
|
// Existing instances will fully rebuild the index on first access after upgrade.
|
|
27
|
-
export const SCHEMA_VERSION = '
|
|
28
|
+
export const SCHEMA_VERSION = '12';
|
|
28
29
|
const SCHEMA = `
|
|
29
30
|
CREATE TABLE IF NOT EXISTS items (
|
|
30
31
|
slug TEXT PRIMARY KEY,
|
|
@@ -50,7 +51,8 @@ CREATE TABLE IF NOT EXISTS items (
|
|
|
50
51
|
session_log_count INTEGER DEFAULT 0,
|
|
51
52
|
last_session_date TEXT,
|
|
52
53
|
last_session_type TEXT,
|
|
53
|
-
last_session_summary TEXT
|
|
54
|
+
last_session_summary TEXT,
|
|
55
|
+
session_date TEXT
|
|
54
56
|
);
|
|
55
57
|
|
|
56
58
|
CREATE TABLE IF NOT EXISTS links (
|
|
@@ -100,8 +102,14 @@ const SORT_COL_MAP = {
|
|
|
100
102
|
staleness: 'i.staleness_days',
|
|
101
103
|
reinforcement_count: 'i.reinforcement_count',
|
|
102
104
|
last_reinforced: 'i.last_reinforced',
|
|
105
|
+
session_date: 'i.session_date',
|
|
106
|
+
};
|
|
107
|
+
/** Sort fields one branch cannot honour: the column is NULL for its rows. */
|
|
108
|
+
const SORT_REJECTS = {
|
|
109
|
+
entity: new Set(['reinforcement_count', 'last_reinforced', 'session_date']),
|
|
110
|
+
knowledge: new Set(['session_date']),
|
|
111
|
+
session: new Set(['reinforcement_count', 'last_reinforced', 'last_touched', 'staleness']),
|
|
103
112
|
};
|
|
104
|
-
const KNOWLEDGE_ONLY_SORTS = new Set(['reinforcement_count', 'last_reinforced']);
|
|
105
113
|
const SORT_FIELDS = Object.keys(SORT_COL_MAP);
|
|
106
114
|
const SORT_ORDERS = ['asc', 'desc'];
|
|
107
115
|
const QUERY_FILTERS = [...ENTITY_STATUSES, 'all', 'stale', 'capture'];
|
|
@@ -163,12 +171,18 @@ function queryItemsFn(db, opts) {
|
|
|
163
171
|
conditions.push('i.kind = @kind');
|
|
164
172
|
params.kind = opts.kind;
|
|
165
173
|
}
|
|
174
|
+
else if (!opts.kind) {
|
|
175
|
+
// Unset is the working set. Sessions are a record of what happened, not
|
|
176
|
+
// a thing being worked on, and each one names every entity it touched:
|
|
177
|
+
// in the default view they would crowd out the note being looked for.
|
|
178
|
+
conditions.push("i.kind != 'session'");
|
|
179
|
+
}
|
|
166
180
|
if (opts.status) {
|
|
167
|
-
// Status is an entity attribute —
|
|
181
|
+
// Status is an entity attribute — other kinds are indexed with a NULL
|
|
168
182
|
// status. In a kind-unset (unified) query the status filter narrows the
|
|
169
183
|
// entity side only; applying it to every row is what made `query --kind
|
|
170
184
|
// all` (filter defaults to active) return entities and nothing else.
|
|
171
|
-
conditions.push(opts.kind === 'entity' ? 'i.status = @status' : "(i.kind
|
|
185
|
+
conditions.push(opts.kind === 'entity' ? 'i.status = @status' : "(i.kind != 'entity' OR i.status = @status)");
|
|
172
186
|
params.status = opts.status;
|
|
173
187
|
}
|
|
174
188
|
if (opts.type) {
|
|
@@ -212,12 +226,14 @@ function queryItemsFn(db, opts) {
|
|
|
212
226
|
if (opts.filter === 'capture') {
|
|
213
227
|
conditions.push("i.type = 'capture'");
|
|
214
228
|
}
|
|
229
|
+
// A session's one day is its session date: the window reads it where an
|
|
230
|
+
// entity or plan has last_touched.
|
|
215
231
|
if (opts.since) {
|
|
216
|
-
conditions.push('i.last_touched >= @since');
|
|
232
|
+
conditions.push('COALESCE(i.last_touched, i.session_date) >= @since');
|
|
217
233
|
params.since = opts.since;
|
|
218
234
|
}
|
|
219
235
|
if (opts.before) {
|
|
220
|
-
conditions.push('i.last_touched <= @before');
|
|
236
|
+
conditions.push('COALESCE(i.last_touched, i.session_date) <= @before');
|
|
221
237
|
params.before = opts.before;
|
|
222
238
|
}
|
|
223
239
|
if (opts.createdSince) {
|
|
@@ -242,12 +258,12 @@ function queryItemsFn(db, opts) {
|
|
|
242
258
|
assertSortOrder(opts.sort_order);
|
|
243
259
|
assertCount(opts.limit, 'limit');
|
|
244
260
|
assertCount(opts.offset, 'offset');
|
|
245
|
-
let sortCol = 'i.last_touched';
|
|
261
|
+
let sortCol = opts.kind === 'session' ? 'i.session_date' : 'i.last_touched';
|
|
246
262
|
if (opts.sort_by) {
|
|
247
263
|
// Same rule as the unmappable opts, one code path over: a sort field the
|
|
248
264
|
// branch cannot honour is rejected, not quietly swapped for recency.
|
|
249
|
-
if (
|
|
250
|
-
outOfScope('sort_by', opts.sort_by,
|
|
265
|
+
if (opts.kind && opts.kind !== 'all' && SORT_REJECTS[opts.kind].has(opts.sort_by)) {
|
|
266
|
+
outOfScope('sort_by', opts.sort_by, opts.kind);
|
|
251
267
|
}
|
|
252
268
|
if (SORT_COL_MAP[opts.sort_by])
|
|
253
269
|
sortCol = SORT_COL_MAP[opts.sort_by];
|
|
@@ -280,7 +296,7 @@ function queryItemsFn(db, opts) {
|
|
|
280
296
|
i.confidence, i.reinforcement_count, i.last_reinforced,
|
|
281
297
|
i.created, i.last_touched, i.subtype,
|
|
282
298
|
i.description, i.resource, i.brief, i.session_log_count, i.last_session_date,
|
|
283
|
-
i.last_session_type, i.last_session_summary
|
|
299
|
+
i.last_session_type, i.last_session_summary, i.session_date
|
|
284
300
|
FROM items i ${where}
|
|
285
301
|
ORDER BY ${sortCol} ${sortOrder}${limitClause}`;
|
|
286
302
|
const rows = db.prepare(sql).all(params);
|
|
@@ -310,9 +326,13 @@ function searchFn(db, query, kind) {
|
|
|
310
326
|
const { fts: ftsQuery, terms } = sanitizeFtsQuery(query);
|
|
311
327
|
if (!ftsQuery)
|
|
312
328
|
return [];
|
|
313
|
-
|
|
329
|
+
// Same rule as queryItemsFn: unset is the working set, `all` is literal.
|
|
330
|
+
let kindFilter = "AND i.kind != 'session'";
|
|
314
331
|
const params = { query: ftsQuery };
|
|
315
|
-
if (kind
|
|
332
|
+
if (kind === 'all') {
|
|
333
|
+
kindFilter = '';
|
|
334
|
+
}
|
|
335
|
+
else if (kind) {
|
|
316
336
|
kindFilter = 'AND i.kind = @kind';
|
|
317
337
|
params.kind = kind;
|
|
318
338
|
}
|
|
@@ -553,11 +573,13 @@ export function createIndex(vaultPath, opts) {
|
|
|
553
573
|
INSERT INTO items
|
|
554
574
|
(slug, kind, name, type, status, parent, next_action, next_action_count, staleness_days,
|
|
555
575
|
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
|
|
576
|
+
description, resource, brief, session_log_count, last_session_date, last_session_type, last_session_summary,
|
|
577
|
+
session_date)
|
|
557
578
|
VALUES
|
|
558
579
|
(@slug, @kind, @name, @type, @status, @parent, @next_action, @next_action_count, @staleness_days,
|
|
559
580
|
@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
|
|
581
|
+
@description, @resource, @brief, @session_log_count, @last_session_date, @last_session_type, @last_session_summary,
|
|
582
|
+
@session_date)
|
|
561
583
|
`);
|
|
562
584
|
const deleteItemStmt = db.prepare('DELETE FROM items WHERE slug = ?');
|
|
563
585
|
const deleteLinksStmt = db.prepare('DELETE FROM links WHERE source = ?');
|
|
@@ -620,6 +642,8 @@ export function createIndex(vaultPath, opts) {
|
|
|
620
642
|
last_session_date: lastLog?.date ?? null,
|
|
621
643
|
last_session_type: lastLog?.type ?? null,
|
|
622
644
|
last_session_summary: lastLog?.summary?.slice(0, 300) ?? null,
|
|
645
|
+
// A session note's one day; the created stamp when digest gave none.
|
|
646
|
+
session_date: kind === 'session' ? (fm['session-date'] ?? fm.created) : null,
|
|
623
647
|
};
|
|
624
648
|
// Replace item and rebuild links. Delete-then-insert keeps the FTS index
|
|
625
649
|
// trigger-consistent for externally changed files (hand edit, git pull),
|
|
@@ -657,12 +681,12 @@ export function createIndex(vaultPath, opts) {
|
|
|
657
681
|
const linkRe = new RegExp(WIKI_LINK_RE.source, 'g');
|
|
658
682
|
while ((wikiMatch = linkRe.exec(authoredBody)) !== null) {
|
|
659
683
|
const targetSlug = wikiMatch[1];
|
|
660
|
-
// Check if target file exists on disk (not in index — target may not be
|
|
661
|
-
|
|
662
|
-
|
|
684
|
+
// Check if target file exists on disk (not in index — target may not be
|
|
685
|
+
// indexed yet), in every indexed dir — the same set the rebuild's
|
|
686
|
+
// knownSlugs covers, so a mention links the same way on either path.
|
|
663
687
|
const targetExists = knownSlugs
|
|
664
688
|
? knownSlugs.has(targetSlug)
|
|
665
|
-
:
|
|
689
|
+
: INDEXED_KINDS.some(k => existsSync(join(vaultPath, kindDir(k), `${targetSlug}.md`)));
|
|
666
690
|
if (targetExists) {
|
|
667
691
|
insertLinkStmt.run(slug, targetSlug, 'mention');
|
|
668
692
|
}
|
|
@@ -675,17 +699,15 @@ export function createIndex(vaultPath, opts) {
|
|
|
675
699
|
db.exec('DELETE FROM links');
|
|
676
700
|
// Collect all vault slugs for wiki link target validation
|
|
677
701
|
const allVaultSlugs = new Set();
|
|
678
|
-
for (const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
allVaultSlugs.add(basename(file, '.md'));
|
|
683
|
-
}
|
|
684
|
-
for (const file of scanVaultDir(join(vaultPath, kindDir('entity')))) {
|
|
685
|
-
indexFile(file, 'entity', allVaultSlugs);
|
|
702
|
+
for (const kind of INDEXED_KINDS) {
|
|
703
|
+
for (const file of scanVaultDir(join(vaultPath, kindDir(kind)))) {
|
|
704
|
+
allVaultSlugs.add(basename(file, '.md'));
|
|
705
|
+
}
|
|
686
706
|
}
|
|
687
|
-
for (const
|
|
688
|
-
|
|
707
|
+
for (const kind of INDEXED_KINDS) {
|
|
708
|
+
for (const file of scanVaultDir(join(vaultPath, kindDir(kind)))) {
|
|
709
|
+
indexFile(file, kind, allVaultSlugs);
|
|
710
|
+
}
|
|
689
711
|
}
|
|
690
712
|
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_sync', ?)")
|
|
691
713
|
.run(scanStart.toString());
|
|
@@ -772,6 +794,7 @@ function withReadSurface(core) {
|
|
|
772
794
|
queryChildren: (parentSlug) => queryEntitiesFn(index, { parent: parentSlug }),
|
|
773
795
|
queryRelatedTo: (slug, relation) => queryRelatedToFn(index, slug, relation),
|
|
774
796
|
queryKnowledge: (opts) => queryKnowledgeFn(index, opts),
|
|
797
|
+
querySessions: (opts) => querySessionsFn(index, opts),
|
|
775
798
|
queryUnified: (opts) => queryUnifiedFn(index, opts),
|
|
776
799
|
};
|
|
777
800
|
return index;
|
|
@@ -812,6 +835,23 @@ function toKnowledgeResult(slug, row) {
|
|
|
812
835
|
last_touched: row.last_touched ?? null,
|
|
813
836
|
};
|
|
814
837
|
}
|
|
838
|
+
function toSessionResult(slug, row) {
|
|
839
|
+
return {
|
|
840
|
+
slug,
|
|
841
|
+
name: row.name,
|
|
842
|
+
session_date: row.session_date,
|
|
843
|
+
created: row.created,
|
|
844
|
+
related: row.related ?? [],
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
/** One row of any kind, shaped for its kind. */
|
|
848
|
+
function toUnifiedResult(i) {
|
|
849
|
+
if (i.kind === 'entity')
|
|
850
|
+
return { ...toQueryResult(i.slug, i), kind: 'entity' };
|
|
851
|
+
if (i.kind === 'session')
|
|
852
|
+
return { ...toSessionResult(i.slug, i), kind: 'session' };
|
|
853
|
+
return { ...toKnowledgeResult(i.slug, i), kind: 'knowledge' };
|
|
854
|
+
}
|
|
815
855
|
const STATUS_FILTERS = new Set(ENTITY_STATUSES);
|
|
816
856
|
function mapEntityFilterOpts(qopts, opts) {
|
|
817
857
|
if (opts?.filter && STATUS_FILTERS.has(opts.filter))
|
|
@@ -867,11 +907,16 @@ function validateEntityFilterOpts(opts) {
|
|
|
867
907
|
* Asserted against the CLI's flag matrix in query-surface.test.ts. */
|
|
868
908
|
export const ENTITY_ONLY_OPTS = ['filter', 'type', 'parent'];
|
|
869
909
|
export const KNOWLEDGE_ONLY_OPTS = ['confidence', 'subtype'];
|
|
910
|
+
/** What a session query cannot map: both sides' opts, and the two every
|
|
911
|
+
* other kind shares — a session note carries no domain and no tags. */
|
|
912
|
+
export const SESSION_REJECTED_OPTS = [...ENTITY_ONLY_OPTS, ...KNOWLEDGE_ONLY_OPTS, 'domain', 'tags'];
|
|
870
913
|
/** The one sentence both scope rejections speak — opts here, sort fields in
|
|
871
914
|
* queryItemsFn — so a caller sees the same shape whichever one they hit. */
|
|
872
915
|
function outOfScope(label, name, branch) {
|
|
873
|
-
const belongsTo = branch === 'entity' ? 'knowledge notes only'
|
|
874
|
-
|
|
916
|
+
const belongsTo = branch === 'entity' ? 'knowledge notes only'
|
|
917
|
+
: branch === 'knowledge' ? 'entities only'
|
|
918
|
+
: 'entities and knowledge notes only';
|
|
919
|
+
throw new HafezError('VALIDATION_FAILED', `Invalid ${label} for a ${branch} query: "${name}" — ${belongsTo}. Use kind: 'all' to query every kind.`);
|
|
875
920
|
}
|
|
876
921
|
function rejectUnmappableOpts(opts, reject, branch) {
|
|
877
922
|
if (!opts)
|
|
@@ -901,15 +946,11 @@ function queryEntitiesFn(index, opts) {
|
|
|
901
946
|
const { items, total } = index.queryItems(qopts);
|
|
902
947
|
return { items: items.map(i => toQueryResult(i.slug, i)), total };
|
|
903
948
|
}
|
|
949
|
+
/** Every kind: the sessions that touched an entity are the look back from it. */
|
|
904
950
|
function queryRelatedToFn(index, slug, relation) {
|
|
905
951
|
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
|
-
};
|
|
952
|
+
const { items, total } = index.queryItems({ kind: 'all', relatedTo: slug, relation });
|
|
953
|
+
return { items: items.map(toUnifiedResult), total };
|
|
913
954
|
}
|
|
914
955
|
function queryKnowledgeFn(index, opts) {
|
|
915
956
|
index.syncIfStale();
|
|
@@ -924,6 +965,17 @@ function queryKnowledgeFn(index, opts) {
|
|
|
924
965
|
const { items, total } = index.queryItems(qopts);
|
|
925
966
|
return { items: items.map(i => toKnowledgeResult(i.slug, i)), total };
|
|
926
967
|
}
|
|
968
|
+
function querySessionsFn(index, opts) {
|
|
969
|
+
index.syncIfStale();
|
|
970
|
+
rejectUnmappableOpts(opts, SESSION_REJECTED_OPTS, 'session');
|
|
971
|
+
validateTimeOpts(opts);
|
|
972
|
+
validateListOpts(opts);
|
|
973
|
+
const qopts = { kind: 'session' };
|
|
974
|
+
mapTimeOpts(qopts, opts);
|
|
975
|
+
mapListOpts(qopts, opts);
|
|
976
|
+
const { items, total } = index.queryItems(qopts);
|
|
977
|
+
return { items: items.map(i => toSessionResult(i.slug, i)), total };
|
|
978
|
+
}
|
|
927
979
|
function queryUnifiedFn(index, opts) {
|
|
928
980
|
index.syncIfStale();
|
|
929
981
|
// The public type is a union discriminated on `kind`, so an out-of-scope opt
|
|
@@ -931,7 +983,7 @@ function queryUnifiedFn(index, opts) {
|
|
|
931
983
|
// an untyped caller (JS, JSON over MCP) reaches here regardless — so widen
|
|
932
984
|
// once, here, and let the checks below speak for both.
|
|
933
985
|
const o = opts;
|
|
934
|
-
assertEnum(o?.kind, ['entity', 'knowledge', 'all'], 'kind');
|
|
986
|
+
assertEnum(o?.kind, ['entity', 'knowledge', 'session', 'all'], 'kind');
|
|
935
987
|
validateEntityFilterOpts(o);
|
|
936
988
|
validateKnowledgeFilterOpts(o);
|
|
937
989
|
// Only `kind: 'all'` (or unset) maps both sides. `kind` also narrows this
|
|
@@ -943,24 +995,16 @@ function queryUnifiedFn(index, opts) {
|
|
|
943
995
|
rejectUnmappableOpts(o, KNOWLEDGE_ONLY_OPTS, 'entity');
|
|
944
996
|
if (o?.kind === 'knowledge')
|
|
945
997
|
rejectUnmappableOpts(o, ENTITY_ONLY_OPTS, 'knowledge');
|
|
998
|
+
if (o?.kind === 'session')
|
|
999
|
+
rejectUnmappableOpts(o, SESSION_REJECTED_OPTS, 'session');
|
|
946
1000
|
validateTimeOpts(o);
|
|
947
1001
|
validateListOpts(o);
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
qopts.kind = o.kind;
|
|
951
|
-
// When kind is 'all' or unset, no kind filter — both entities and knowledge are returned
|
|
1002
|
+
// Unset is the working set (entity + knowledge); 'all' reaches every kind.
|
|
1003
|
+
const qopts = { kind: o?.kind };
|
|
952
1004
|
mapEntityFilterOpts(qopts, o);
|
|
953
1005
|
mapKnowledgeFilterOpts(qopts, o);
|
|
954
1006
|
mapTimeOpts(qopts, o);
|
|
955
1007
|
mapListOpts(qopts, o);
|
|
956
1008
|
const result = index.queryItems(qopts);
|
|
957
|
-
|
|
958
|
-
if (i.kind === 'entity') {
|
|
959
|
-
return { ...toQueryResult(i.slug, i), kind: 'entity' };
|
|
960
|
-
}
|
|
961
|
-
else {
|
|
962
|
-
return { ...toKnowledgeResult(i.slug, i), kind: 'knowledge' };
|
|
963
|
-
}
|
|
964
|
-
});
|
|
965
|
-
return { items, total: result.total };
|
|
1009
|
+
return { items: result.items.map(toUnifiedResult), total: result.total };
|
|
966
1010
|
}
|
package/dist/git.js
CHANGED
|
@@ -6,6 +6,49 @@ import { mergeVaultContent } from './merge.js';
|
|
|
6
6
|
import { VAULT_FILE_RE, INDEXED_DIR_PATHSPECS, classifyIndexedRelPath, vaultKindFromRelPath } from './vault.js';
|
|
7
7
|
import { normalizeSinceDate } from './dates.js';
|
|
8
8
|
import { HafezError } from './types.js';
|
|
9
|
+
// Hafez runs git in the user's own shell, and git inherited that environment before simple-git
|
|
10
|
+
// guarded it. simple-git 4 guards it two ways, and both are opened in full: `allowEnvironment`
|
|
11
|
+
// names every key the process carries (the guard otherwise strips ambient GIT_* / EDITOR / PAGER
|
|
12
|
+
// keys and rejects supplied ones — a user's GIT_SSH_COMMAND or GIT_AUTHOR_* would vanish), and
|
|
13
|
+
// `unsafe` opens every category — `Required`, so a category the library adds fails the build
|
|
14
|
+
// instead of rejecting some user's shell at runtime (4.0 moved GIT_EXEC_PATH to allowUnsafeExec).
|
|
15
|
+
const OPEN_UNSAFE = {
|
|
16
|
+
allowUnsafeAlias: true,
|
|
17
|
+
allowUnsafeAskPass: true,
|
|
18
|
+
allowUnsafeCommandBinaries: true,
|
|
19
|
+
allowUnsafeConfigEnvCount: true,
|
|
20
|
+
allowUnsafeConfigPaths: true,
|
|
21
|
+
allowUnsafeCredentialHelper: true,
|
|
22
|
+
allowUnsafeDiffExternal: true,
|
|
23
|
+
allowUnsafeDiffTextConv: true,
|
|
24
|
+
allowUnsafeEditor: true,
|
|
25
|
+
allowUnsafeExec: true,
|
|
26
|
+
allowUnsafeFilter: true,
|
|
27
|
+
allowUnsafeFsMonitor: true,
|
|
28
|
+
allowUnsafeGitProxy: true,
|
|
29
|
+
allowUnsafeGpgProgram: true,
|
|
30
|
+
allowUnsafeHooksPath: true,
|
|
31
|
+
allowUnsafeInclude: true,
|
|
32
|
+
allowUnsafeMergeDriver: true,
|
|
33
|
+
allowUnsafePack: true,
|
|
34
|
+
allowUnsafePager: true,
|
|
35
|
+
allowUnsafeProtocolOverride: true,
|
|
36
|
+
allowUnsafeSshCommand: true,
|
|
37
|
+
allowUnsafeSubmodule: true,
|
|
38
|
+
allowUnsafeTemplateDir: true,
|
|
39
|
+
allowUnsafeUrlRewrite: true,
|
|
40
|
+
allowUnsafeCustomBinary: true,
|
|
41
|
+
// Otherwise the plugin exports GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS to the child and the
|
|
42
|
+
// vault's own hooks start failing on abbreviated long options only when hafez commits.
|
|
43
|
+
allowAbbreviatedOptions: true
|
|
44
|
+
};
|
|
45
|
+
// Hafez's own git calls are never interactive: GIT_EDITOR outranks core.editor, -c, VISUAL and
|
|
46
|
+
// EDITOR. `.env()` replaces the child env, hence the spread.
|
|
47
|
+
const vaultGit = (vaultPath) => simpleGit({
|
|
48
|
+
baseDir: vaultPath,
|
|
49
|
+
allowEnvironment: [...Object.keys(process.env), 'GIT_EDITOR'],
|
|
50
|
+
unsafe: OPEN_UNSAFE
|
|
51
|
+
}).env({ ...process.env, GIT_EDITOR: 'true' });
|
|
9
52
|
const logMerge = (msg) => process.stderr.write(`[hafez-merge] ${msg}\n`);
|
|
10
53
|
const MAX_PUSH_RETRIES = 3;
|
|
11
54
|
const RETRY_BASE_MS = 200;
|
|
@@ -279,7 +322,7 @@ async function pushResolving(git, vaultPath, conflictMessage, exhaustedMessage)
|
|
|
279
322
|
* All work must be committed before calling — no file save/restore needed.
|
|
280
323
|
*/
|
|
281
324
|
export async function gitSync(vaultPath) {
|
|
282
|
-
const git =
|
|
325
|
+
const git = vaultGit(vaultPath);
|
|
283
326
|
if (!(await hasOriginRemote(git))) {
|
|
284
327
|
return { pulled: false, pushed: false, remote: false };
|
|
285
328
|
}
|
|
@@ -330,7 +373,7 @@ export async function gitSync(vaultPath) {
|
|
|
330
373
|
*/
|
|
331
374
|
async function gitUnstage(vaultPath, files) {
|
|
332
375
|
try {
|
|
333
|
-
await
|
|
376
|
+
await vaultGit(vaultPath).raw(['reset', '-q', 'HEAD', '--', ...files]);
|
|
334
377
|
}
|
|
335
378
|
catch { /* best effort — rollback must not mask the original error */ }
|
|
336
379
|
}
|
|
@@ -390,8 +433,26 @@ function parseNameStatusLine(line) {
|
|
|
390
433
|
const operation = FILE_STATUS_MAP[match[1]];
|
|
391
434
|
return operation ? [{ path: match[2], operation }] : [];
|
|
392
435
|
}
|
|
436
|
+
/**
|
|
437
|
+
* Read-side "no history": the one state where `git log` has nothing to say
|
|
438
|
+
* and empty is the true answer — a repo with no commits yet. Everything else
|
|
439
|
+
* (a directory that is not a repository, an env-guard or unsafe-category
|
|
440
|
+
* rejection, a missing git binary, a corrupt repo) is a failure the write
|
|
441
|
+
* side already reports loudly, so the read side must not hide it. Matched on
|
|
442
|
+
* git's stderr text, which simple-git carries in `GitError.message`; both
|
|
443
|
+
* callers pass a `--` pathspec and no revision, and an unborn HEAD then says
|
|
444
|
+
* exactly this.
|
|
445
|
+
*/
|
|
446
|
+
const NO_HISTORY_RE = /does not have any commits yet/i;
|
|
447
|
+
function isNoHistory(err) {
|
|
448
|
+
return NO_HISTORY_RE.test(err instanceof Error ? err.message : String(err));
|
|
449
|
+
}
|
|
450
|
+
function gitReadFailed(err) {
|
|
451
|
+
const lines = (err instanceof Error ? err.message : String(err)).split('\n').map(l => l.trim()).filter(Boolean);
|
|
452
|
+
return new HafezError('GIT_READ_FAILED', `git read failed: ${lines[0] ?? 'unknown error'}`, lines);
|
|
453
|
+
}
|
|
393
454
|
export async function gitChangelog(vaultPath, since) {
|
|
394
|
-
const git =
|
|
455
|
+
const git = vaultGit(vaultPath);
|
|
395
456
|
// Bare dates become local midnight — the same calendar-day boundary the
|
|
396
457
|
// vault stamps and query comparisons use (see dates.ts).
|
|
397
458
|
const normalizedSince = normalizeSinceDate(since);
|
|
@@ -402,8 +463,10 @@ export async function gitChangelog(vaultPath, since) {
|
|
|
402
463
|
'--pretty=format:%H|%aI|%s', '--', ...INDEXED_DIR_PATHSPECS,
|
|
403
464
|
]);
|
|
404
465
|
}
|
|
405
|
-
catch {
|
|
406
|
-
|
|
466
|
+
catch (err) {
|
|
467
|
+
if (isNoHistory(err))
|
|
468
|
+
return [];
|
|
469
|
+
throw gitReadFailed(err);
|
|
407
470
|
}
|
|
408
471
|
if (!output.trim())
|
|
409
472
|
return [];
|
|
@@ -439,7 +502,7 @@ export async function gitChangelog(vaultPath, since) {
|
|
|
439
502
|
* Files with no history (not yet committed) are absent from the map.
|
|
440
503
|
*/
|
|
441
504
|
export async function gitFileTimes(vaultPath, mode) {
|
|
442
|
-
const git =
|
|
505
|
+
const git = vaultGit(vaultPath);
|
|
443
506
|
let output;
|
|
444
507
|
try {
|
|
445
508
|
const args = ['log', '--pretty=format:%x00%aI', '--name-only', '--', ...INDEXED_DIR_PATHSPECS];
|
|
@@ -447,8 +510,10 @@ export async function gitFileTimes(vaultPath, mode) {
|
|
|
447
510
|
args.splice(1, 0, '--diff-filter=A');
|
|
448
511
|
output = await git.raw(args);
|
|
449
512
|
}
|
|
450
|
-
catch {
|
|
451
|
-
|
|
513
|
+
catch (err) {
|
|
514
|
+
if (isNoHistory(err))
|
|
515
|
+
return new Map();
|
|
516
|
+
throw gitReadFailed(err);
|
|
452
517
|
}
|
|
453
518
|
const times = new Map();
|
|
454
519
|
let current = '';
|
|
@@ -464,7 +529,7 @@ export async function gitFileTimes(vaultPath, mode) {
|
|
|
464
529
|
return times;
|
|
465
530
|
}
|
|
466
531
|
export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
|
|
467
|
-
const git =
|
|
532
|
+
const git = vaultGit(vaultPath);
|
|
468
533
|
// If a previous run crashed mid-rebase/merge, the repo is stuck.
|
|
469
534
|
// Aborting restores tracked files to their pre-rebase state, which may
|
|
470
535
|
// overwrite files that index.ts just wrote. Save and restore them.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Hafez, HafezConfig } from './types.js';
|
|
2
2
|
export declare function createHafez(config: HafezConfig): Hafez;
|
|
3
3
|
export { HafezError } from './types.js';
|
|
4
|
-
export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, UnifiedQueryOpts, AllQueryOpts, SortField, EntitySortField, EntityFrontmatter, KnowledgeFrontmatter, ParsedFile, SessionLogEntry, UpdateFields, UpdateResult, CreateEntityFields, CreateKnowledgeFields, QueryFilter, QueryResult, KnowledgeQueryResult, UnifiedResult, ValidationReport, BatchOperation, BatchResult, ReadDepth, ConfidenceLevel, LinkRelation, GitConfig, Journal, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype } from './types.js';
|
|
4
|
+
export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, SessionQueryOpts, SessionQueryResult, SearchKind, UnifiedQueryOpts, AllQueryOpts, SortField, EntitySortField, KnowledgeSortField, SessionSortField, EntityFrontmatter, KnowledgeFrontmatter, ParsedFile, SessionLogEntry, UpdateFields, UpdateResult, CreateEntityFields, CreateKnowledgeFields, QueryFilter, QueryResult, KnowledgeQueryResult, UnifiedResult, ValidationReport, BatchOperation, BatchResult, ReadDepth, ConfidenceLevel, LinkRelation, GitConfig, Journal, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype } from './types.js';
|
|
5
5
|
export { getBrief, getNextActions } from './document.js';
|
|
6
6
|
export { parseSessionLogHeading, parseSessionLog } from './document.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { parseFilePath, slugify, kindFromPath, kindDir,
|
|
2
|
+
import { parseFilePath, slugify, kindFromPath, kindDir, isMutableRelPath, findVaultFile, findReadableFile, vaultFileExists, resolveFilePath, scanVaultDir, archiveLogPath, MUTABLE_KINDS } from './vault.js';
|
|
3
3
|
import { createGitJournal } from './git.js';
|
|
4
4
|
import { validateSessionLogEntry } from './schema.js';
|
|
5
5
|
import { validateVault } from './validate.js';
|
|
@@ -52,6 +52,7 @@ export function createHafez(config) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
// Layout answers where a slug lives; these bind the vault path once.
|
|
55
|
+
// Mutations resolve the mutable kinds; `read` reaches a session note too.
|
|
55
56
|
function findFile(slug) { return findVaultFile(vaultPath, slug); }
|
|
56
57
|
function exists(slug) { return vaultFileExists(vaultPath, slug); }
|
|
57
58
|
function regenerateIndexSafe() {
|
|
@@ -92,7 +93,7 @@ export function createHafez(config) {
|
|
|
92
93
|
return idx ? fn(idx) : empty;
|
|
93
94
|
}
|
|
94
95
|
async function read(slug, depth = 'summary') {
|
|
95
|
-
const filePath =
|
|
96
|
+
const filePath = findReadableFile(vaultPath, slug);
|
|
96
97
|
if (!filePath) {
|
|
97
98
|
// A miss carries the closest slugs as its details: the recovery the
|
|
98
99
|
// `read` miss output renders (#91).
|
|
@@ -100,9 +101,6 @@ export function createHafez(config) {
|
|
|
100
101
|
throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`, closest);
|
|
101
102
|
}
|
|
102
103
|
const parsed = parseFilePath(filePath);
|
|
103
|
-
// findFile only resolves the indexed kinds, so the session arm is unreachable
|
|
104
|
-
if (parsed.kind === 'session')
|
|
105
|
-
throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
|
|
106
104
|
if (depth === 'frontmatter')
|
|
107
105
|
return { ...parsed, body: '' };
|
|
108
106
|
if (depth === 'summary') {
|
|
@@ -110,6 +108,15 @@ export function createHafez(config) {
|
|
|
110
108
|
}
|
|
111
109
|
return parsed;
|
|
112
110
|
}
|
|
111
|
+
/** The archive is an attachment of its entity, reached from it on purpose
|
|
112
|
+
* (#34): a disk read behind the entity's own slug, never an index row. */
|
|
113
|
+
async function archive(slug) {
|
|
114
|
+
const entity = resolveFilePath(vaultPath, slug, 'entity');
|
|
115
|
+
if (!existsSync(entity))
|
|
116
|
+
throw new HafezError('NOT_FOUND', `Entity '${slug}' not found`);
|
|
117
|
+
const path = archiveLogPath(vaultPath, slug);
|
|
118
|
+
return existsSync(path) ? readFileSync(path, 'utf-8') : null;
|
|
119
|
+
}
|
|
113
120
|
function rollbackFiles(originals) {
|
|
114
121
|
for (const [path, content] of originals) {
|
|
115
122
|
if (content === null) {
|
|
@@ -216,12 +223,10 @@ export function createHafez(config) {
|
|
|
216
223
|
const deletedAll = [...new Set(applied.flatMap(a => a.filesDeleted))];
|
|
217
224
|
const finalize = () => {
|
|
218
225
|
updateIndexPostCommit(applied);
|
|
219
|
-
// index.md
|
|
220
|
-
if ([...writtenAll, ...deletedAll].some(
|
|
226
|
+
// index.md shows the mutable kinds: a session-only write leaves it.
|
|
227
|
+
if ([...writtenAll, ...deletedAll].some(isMutableRelPath)) {
|
|
221
228
|
regenerateIndexSafe();
|
|
222
229
|
}
|
|
223
|
-
// Session creates carry no index upserts — they are not indexed
|
|
224
|
-
// entities and never notified.
|
|
225
230
|
for (const a of applied) {
|
|
226
231
|
if (a.indexUpserts.length > 0)
|
|
227
232
|
onWrite?.(a.result.slug, single ? single.action : 'batch');
|
|
@@ -390,9 +395,11 @@ export function createHafez(config) {
|
|
|
390
395
|
simulateLinks: (slug, links) => { simulatedLinks.set(slug, links); },
|
|
391
396
|
// Read from disk, not the index: the validate phase must see the vault
|
|
392
397
|
// as `validate` will, and a stale index row would let a linked note go.
|
|
398
|
+
// The mutable kinds only: a session note's related are a record of
|
|
399
|
+
// what it touched, and a record must not keep a note from being deleted.
|
|
393
400
|
referrers: (slug) => {
|
|
394
401
|
const candidates = new Set(simulated.keys());
|
|
395
|
-
for (const kind of
|
|
402
|
+
for (const kind of MUTABLE_KINDS) {
|
|
396
403
|
for (const fp of scanVaultDir(join(vaultPath, kindDir(kind))))
|
|
397
404
|
candidates.add(basename(fp, '.md'));
|
|
398
405
|
}
|
|
@@ -518,6 +525,8 @@ export function createHafez(config) {
|
|
|
518
525
|
children: async (slug) => withIndex(idx => idx.queryChildren(slug), { items: [], total: 0 }),
|
|
519
526
|
related_to: async (slug, relation) => withIndex(idx => idx.queryRelatedTo(slug, relation), { items: [], total: 0 }),
|
|
520
527
|
query_knowledge: async (opts) => withIndex(idx => idx.queryKnowledge(opts), { items: [], total: 0 }),
|
|
528
|
+
query_sessions: async (opts) => withIndex(idx => idx.querySessions(opts), { items: [], total: 0 }),
|
|
529
|
+
archive,
|
|
521
530
|
validate,
|
|
522
531
|
search: async (query, kind) => withIndex(idx => { idx.syncIfStale(); return idx.search(query, kind); }, []),
|
|
523
532
|
rebuildIndex: async () => {
|
|
@@ -540,6 +549,17 @@ export function createHafez(config) {
|
|
|
540
549
|
return s;
|
|
541
550
|
},
|
|
542
551
|
changelog: (since) => journal.changelog(since),
|
|
552
|
+
close: () => {
|
|
553
|
+
// Forget the handle even if closing it throws: a retry must not
|
|
554
|
+
// re-throw, and getIndex() must never hand out a closed handle.
|
|
555
|
+
try {
|
|
556
|
+
_index?.close();
|
|
557
|
+
}
|
|
558
|
+
finally {
|
|
559
|
+
_index = null;
|
|
560
|
+
_indexInitialized = false;
|
|
561
|
+
}
|
|
562
|
+
},
|
|
543
563
|
};
|
|
544
564
|
}
|
|
545
565
|
export { HafezError } from './types.js';
|
package/dist/schema.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export declare function validateEntityFrontmatter(fm: Record<string, any>): string[];
|
|
2
2
|
export declare function validateKnowledgeFrontmatter(fm: Record<string, any>): string[];
|
|
3
|
+
/** A session note: its name and created stamp, nothing else is required. */
|
|
4
|
+
export declare function validateSessionFrontmatter(fm: Record<string, any>): string[];
|
|
3
5
|
export declare function validateKnowledgeSubtype(fm: Record<string, any>): string[];
|
|
4
6
|
export declare function validateSlugReference(slug: string, field: string, exists: (s: string) => boolean): string | null;
|
|
5
7
|
export declare function validateSessionLogEntry(entry: {
|
package/dist/schema.js
CHANGED
|
@@ -30,6 +30,17 @@ export function validateKnowledgeFrontmatter(fm) {
|
|
|
30
30
|
errors.push(...validateKnowledgeSubtype(fm));
|
|
31
31
|
return errors;
|
|
32
32
|
}
|
|
33
|
+
/** A session note: its name and created stamp, nothing else is required. */
|
|
34
|
+
export function validateSessionFrontmatter(fm) {
|
|
35
|
+
const errors = [];
|
|
36
|
+
if (!fm.name || typeof fm.name !== 'string')
|
|
37
|
+
errors.push('missing required field: name');
|
|
38
|
+
if (!fm.created)
|
|
39
|
+
errors.push('missing required field: created');
|
|
40
|
+
if (fm.related !== undefined && !Array.isArray(fm.related))
|
|
41
|
+
errors.push('session related must be an array');
|
|
42
|
+
return errors;
|
|
43
|
+
}
|
|
33
44
|
export function validateKnowledgeSubtype(fm) {
|
|
34
45
|
const errors = [];
|
|
35
46
|
if (fm.subtype && !VALID_SUBTYPES.includes(fm.subtype)) {
|