hafez 1.1.0 → 1.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 +15 -8
- package/dist/batch-ops.d.ts +187 -0
- package/dist/batch-ops.js +322 -0
- package/dist/cli/args.d.ts +15 -0
- package/dist/cli/args.js +49 -0
- package/dist/cli/batch-errors.js +6 -3
- package/dist/cli/commands.d.ts +1 -109
- package/dist/cli/commands.js +197 -256
- package/dist/cli/help-agent.d.ts +0 -5
- package/dist/cli/help-agent.js +37 -9
- package/dist/cli/index.js +61 -69
- package/dist/cli/onboard.d.ts +1 -1
- package/dist/cli/onboard.js +7 -1
- package/dist/cli/resolve-vault.js +2 -1
- package/dist/cli/schema-introspect.d.ts +1 -1
- package/dist/cli/schema-introspect.js +6 -22
- package/dist/cli.js +20 -2
- package/dist/contracts.d.ts +6 -0
- package/dist/contracts.js +8 -2
- package/dist/db.js +20 -30
- package/dist/digest.js +2 -0
- package/dist/document.d.ts +108 -0
- package/dist/{sections.js → document.js} +173 -22
- package/dist/export-okf.js +10 -22
- package/dist/git.d.ts +13 -7
- package/dist/git.js +79 -21
- package/dist/index.d.ts +3 -4
- package/dist/index.js +508 -406
- package/dist/knowledge-index.js +9 -7
- package/dist/merge.js +13 -25
- package/dist/query.d.ts +1 -1
- package/dist/query.js +3 -1
- package/dist/types.d.ts +62 -1
- package/dist/vault.d.ts +34 -6
- package/dist/vault.js +71 -7
- package/package.json +4 -4
- package/skill/SKILL.md +7 -1
- package/dist/parser.d.ts +0 -18
- package/dist/parser.js +0 -68
- package/dist/sections.d.ts +0 -54
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { SessionLogEntry, SessionLogType } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Known structural section headings in Hafez entities, knowledge notes, and
|
|
4
|
+
* session files. Derived from contracts.ts (defaultSections + optionalSections
|
|
5
|
+
* + session sections) plus auto-generated sections not tracked in contracts
|
|
6
|
+
* (Related). Only these are treated as section boundaries. Any other `## `
|
|
7
|
+
* heading inside section content (e.g. in a brief or session log) is
|
|
8
|
+
* preserved as-is.
|
|
9
|
+
*/
|
|
10
|
+
export declare const STRUCTURAL_SECTIONS: Set<string>;
|
|
11
|
+
/**
|
|
12
|
+
* Find the next structural `## Heading` boundary after a given position.
|
|
13
|
+
* Skips `## ` headings that are not known structural sections (content headings).
|
|
14
|
+
* Returns the index of the `\n` before the `## `, or -1 if no structural heading found.
|
|
15
|
+
*/
|
|
16
|
+
export declare function findNextStructuralHeading(body: string, from: number): number;
|
|
17
|
+
/**
|
|
18
|
+
* Find a ## Heading section and its content.
|
|
19
|
+
* Returns { start, headingEnd, end, content } or null if not found.
|
|
20
|
+
* Matches heading exactly (e.g. "## Brief" won't match "## Briefing").
|
|
21
|
+
* Trailing whitespace on the heading line is tolerated — the splitter's
|
|
22
|
+
* boundary tokenizer trims headings, so anything it treats as a boundary
|
|
23
|
+
* must also be findable here (a hand-edited `## Session Log ` otherwise
|
|
24
|
+
* gets a duplicate section and archival never fires).
|
|
25
|
+
* Only treats known structural headings as section boundaries -- content
|
|
26
|
+
* inside a section may contain arbitrary `## ` headings without corruption.
|
|
27
|
+
*/
|
|
28
|
+
export declare function findSection(body: string, heading: string): {
|
|
29
|
+
start: number;
|
|
30
|
+
headingEnd: number;
|
|
31
|
+
end: number;
|
|
32
|
+
content: string;
|
|
33
|
+
} | null;
|
|
34
|
+
export interface DocSection {
|
|
35
|
+
heading: string;
|
|
36
|
+
content: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Split a body into preamble + structural sections. Non-structural `## `
|
|
40
|
+
* headings stay inside the enclosing section's content — sections are
|
|
41
|
+
* atomic units of authored content, never split at user headings.
|
|
42
|
+
* Headings and content come back trimmed; reassemble with
|
|
43
|
+
* `## ${heading}\n\n${content}` joined by blank lines.
|
|
44
|
+
*/
|
|
45
|
+
export declare function splitStructural(body: string): {
|
|
46
|
+
preamble: string;
|
|
47
|
+
sections: DocSection[];
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Replace a structural section's content, or create the section: before
|
|
51
|
+
* `insertBefore` when that section exists, at EOF otherwise.
|
|
52
|
+
*/
|
|
53
|
+
export declare function setSection(body: string, heading: string, content: string, insertBefore?: string): string;
|
|
54
|
+
/** Append content at the end of a structural section (creating it at EOF if absent). */
|
|
55
|
+
export declare function appendToSection(body: string, heading: string, content: string): string;
|
|
56
|
+
/** Insert a formatted session-log entry newest-first (creating the section at EOF if absent). */
|
|
57
|
+
export declare function prependSessionLogEntry(body: string, entry: string): string;
|
|
58
|
+
/** Body up to the real Session Log section — the `summary` read depth. */
|
|
59
|
+
export declare function bodyBeforeSessionLog(body: string): string;
|
|
60
|
+
export declare function parseSessionLogHeading(line: string): {
|
|
61
|
+
date: string;
|
|
62
|
+
agent: string;
|
|
63
|
+
type: SessionLogType;
|
|
64
|
+
} | null;
|
|
65
|
+
export declare function formatSessionLogEntry(entry: SessionLogEntry): string;
|
|
66
|
+
/** Entries inside the real Session Log section only — entry-shaped lines
|
|
67
|
+
* elsewhere in the body (e.g. quoted in a Brief) do not count. */
|
|
68
|
+
export declare function countSessionLogEntries(body: string): number;
|
|
69
|
+
export declare function parseSessionLog(body: string): Array<{
|
|
70
|
+
date: string;
|
|
71
|
+
agent: string;
|
|
72
|
+
type: SessionLogType;
|
|
73
|
+
summary: string;
|
|
74
|
+
}>;
|
|
75
|
+
export declare function extractOldestSessionLogEntry(body: string): {
|
|
76
|
+
entry: string;
|
|
77
|
+
remaining: string;
|
|
78
|
+
} | null;
|
|
79
|
+
export declare function getBrief(body: string): string | null;
|
|
80
|
+
export declare function setBrief(body: string, content: string): string;
|
|
81
|
+
export declare function removeBrief(body: string): string;
|
|
82
|
+
export declare function getNextActions(body: string): string[];
|
|
83
|
+
export declare function addNextAction(body: string, action: string): string;
|
|
84
|
+
export declare function completeNextAction(body: string, query: string): {
|
|
85
|
+
matched: string;
|
|
86
|
+
body: string;
|
|
87
|
+
};
|
|
88
|
+
export declare function removeNextAction(body: string, query: string): {
|
|
89
|
+
matched: string;
|
|
90
|
+
body: string;
|
|
91
|
+
};
|
|
92
|
+
export declare function clearNextActions(body: string): string;
|
|
93
|
+
export declare function firstNonEmptyLine(s: string | null | undefined): string;
|
|
94
|
+
/**
|
|
95
|
+
* One-line summary for a doc: frontmatter description first, then the first
|
|
96
|
+
* non-empty line of the kind's primary section (entity: Brief; knowledge:
|
|
97
|
+
* Synthesis, falling back to Goal for plans; session: Summary).
|
|
98
|
+
* Shared by the vault index and the OKF export so the two surfaces agree.
|
|
99
|
+
*/
|
|
100
|
+
export declare function deriveSummary(kind: 'entity' | 'knowledge' | 'session', fm: Record<string, any>, body: string): string;
|
|
101
|
+
/** The one wiki-link syntax the vault recognizes: [[slug]] or
|
|
102
|
+
* [[slug|display text]] — slug in group 1, display text in group 2.
|
|
103
|
+
* Non-global — wrap with `new RegExp(WIKI_LINK_RE.source, 'g')` for iteration. */
|
|
104
|
+
export declare const WIKI_LINK_RE: RegExp;
|
|
105
|
+
export declare function syncRelatedSection(body: string, frontmatter: {
|
|
106
|
+
parent?: string;
|
|
107
|
+
related?: string[];
|
|
108
|
+
}): string;
|
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
// src/document.ts
|
|
2
|
+
// The Document module (see CONTEXT.md): owns the body of a vault file —
|
|
3
|
+
// structural sections, session-log entries, wiki-links. Absorbs the former
|
|
4
|
+
// sections.ts and parser.ts; every body read or mutation goes through the
|
|
5
|
+
// single structural-heading model defined here. Pure string→string.
|
|
6
|
+
import { ALL_CONTRACTS, SUBTYPE_SECTIONS, SESSION_SECTIONS } from './contracts.js';
|
|
4
7
|
const UNCHECKED_RE = /^- \[ \] (.+)$/gm;
|
|
5
8
|
/**
|
|
6
|
-
* Known structural section headings in Hafez entities
|
|
7
|
-
* Derived from contracts.ts (defaultSections + optionalSections
|
|
8
|
-
* auto-generated sections not tracked in contracts
|
|
9
|
-
* Only these are treated as section boundaries. Any other `## `
|
|
10
|
-
* inside section content (e.g. in a brief or session log) is
|
|
9
|
+
* Known structural section headings in Hafez entities, knowledge notes, and
|
|
10
|
+
* session files. Derived from contracts.ts (defaultSections + optionalSections
|
|
11
|
+
* + session sections) plus auto-generated sections not tracked in contracts
|
|
12
|
+
* (Related). Only these are treated as section boundaries. Any other `## `
|
|
13
|
+
* heading inside section content (e.g. in a brief or session log) is
|
|
14
|
+
* preserved as-is.
|
|
11
15
|
*/
|
|
12
16
|
export const STRUCTURAL_SECTIONS = new Set([
|
|
13
17
|
...ALL_CONTRACTS.flatMap(c => [...c.defaultSections, ...c.optionalSections]),
|
|
14
18
|
...Object.values(SUBTYPE_SECTIONS).flat(),
|
|
19
|
+
...SESSION_SECTIONS,
|
|
15
20
|
'Related', // auto-generated by syncRelatedSection
|
|
16
|
-
'Summary', // used in session files
|
|
17
21
|
]);
|
|
18
22
|
/**
|
|
19
23
|
* Find the next structural `## Heading` boundary after a given position.
|
|
@@ -32,31 +36,176 @@ export function findNextStructuralHeading(body, from) {
|
|
|
32
36
|
}
|
|
33
37
|
return -1;
|
|
34
38
|
}
|
|
35
|
-
// --- Internal helpers ---
|
|
36
39
|
/**
|
|
37
40
|
* Find a ## Heading section and its content.
|
|
38
|
-
* Returns { start, end, content } or null if not found.
|
|
41
|
+
* Returns { start, headingEnd, end, content } or null if not found.
|
|
39
42
|
* Matches heading exactly (e.g. "## Brief" won't match "## Briefing").
|
|
43
|
+
* Trailing whitespace on the heading line is tolerated — the splitter's
|
|
44
|
+
* boundary tokenizer trims headings, so anything it treats as a boundary
|
|
45
|
+
* must also be findable here (a hand-edited `## Session Log ` otherwise
|
|
46
|
+
* gets a duplicate section and archival never fires).
|
|
40
47
|
* Only treats known structural headings as section boundaries -- content
|
|
41
48
|
* inside a section may contain arbitrary `## ` headings without corruption.
|
|
42
49
|
*/
|
|
43
50
|
export function findSection(body, heading) {
|
|
44
|
-
const re = new RegExp(`(^|\\n)(## ${escapeRegex(heading)})(?:\\n|$)`);
|
|
51
|
+
const re = new RegExp(`(^|\\n)(## ${escapeRegex(heading)}[ \\t]*)(?:\\n|$)`);
|
|
45
52
|
const match = re.exec(body);
|
|
46
53
|
if (!match)
|
|
47
54
|
return null;
|
|
48
55
|
const start = match.index + match[1].length;
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
const headingEnd = start + match[2].length;
|
|
57
|
+
const afterHeading = headingEnd + 1;
|
|
58
|
+
// Find the next structural ## heading or EOF. Scan from the heading's own
|
|
59
|
+
// trailing newline (afterHeading - 1), not past it — otherwise a structural
|
|
60
|
+
// heading on the immediately following line (no blank line between, as in
|
|
61
|
+
// hand-edited files) is invisible and mutators swallow the next section.
|
|
62
|
+
const nextHeading = findNextStructuralHeading(body, Math.max(0, afterHeading - 1));
|
|
52
63
|
const end = nextHeading === -1 ? body.length : nextHeading;
|
|
53
64
|
const rawContent = body.slice(afterHeading, end);
|
|
54
65
|
const content = rawContent.replace(/^\n+/, '').replace(/\n+$/, '');
|
|
55
|
-
return { start, end, content };
|
|
66
|
+
return { start, headingEnd, end, content };
|
|
56
67
|
}
|
|
57
68
|
function escapeRegex(s) {
|
|
58
69
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
59
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Split a body into preamble + structural sections. Non-structural `## `
|
|
73
|
+
* headings stay inside the enclosing section's content — sections are
|
|
74
|
+
* atomic units of authored content, never split at user headings.
|
|
75
|
+
* Headings and content come back trimmed; reassemble with
|
|
76
|
+
* `## ${heading}\n\n${content}` joined by blank lines.
|
|
77
|
+
*/
|
|
78
|
+
export function splitStructural(body) {
|
|
79
|
+
const marks = [];
|
|
80
|
+
const re = /(^|\n)## ([^\n]*)/g;
|
|
81
|
+
let m;
|
|
82
|
+
while ((m = re.exec(body)) !== null) {
|
|
83
|
+
const heading = m[2].trim();
|
|
84
|
+
if (!STRUCTURAL_SECTIONS.has(heading))
|
|
85
|
+
continue;
|
|
86
|
+
marks.push({ start: m.index + m[1].length, contentStart: m.index + m[0].length, heading });
|
|
87
|
+
}
|
|
88
|
+
if (marks.length === 0)
|
|
89
|
+
return { preamble: body.trim(), sections: [] };
|
|
90
|
+
const preamble = body.slice(0, marks[0].start).trim();
|
|
91
|
+
const sections = marks.map((mk, i) => ({
|
|
92
|
+
heading: mk.heading,
|
|
93
|
+
content: body.slice(mk.contentStart, i + 1 < marks.length ? marks[i + 1].start : body.length).trim(),
|
|
94
|
+
}));
|
|
95
|
+
return { preamble, sections };
|
|
96
|
+
}
|
|
97
|
+
// --- Generic section mutation ---
|
|
98
|
+
/**
|
|
99
|
+
* Replace a structural section's content, or create the section: before
|
|
100
|
+
* `insertBefore` when that section exists, at EOF otherwise.
|
|
101
|
+
*/
|
|
102
|
+
export function setSection(body, heading, content, insertBefore) {
|
|
103
|
+
const existing = findSection(body, heading);
|
|
104
|
+
if (existing) {
|
|
105
|
+
return body.slice(0, existing.start) + `## ${heading}\n\n${content}\n` + body.slice(existing.end);
|
|
106
|
+
}
|
|
107
|
+
if (insertBefore) {
|
|
108
|
+
const target = findSection(body, insertBefore);
|
|
109
|
+
if (target) {
|
|
110
|
+
return body.slice(0, target.start) + `## ${heading}\n\n${content}\n\n` + body.slice(target.start);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return body + `\n\n## ${heading}\n\n${content}\n`;
|
|
114
|
+
}
|
|
115
|
+
/** Append content at the end of a structural section (creating it at EOF if absent). */
|
|
116
|
+
export function appendToSection(body, heading, content) {
|
|
117
|
+
const section = findSection(body, heading);
|
|
118
|
+
if (!section)
|
|
119
|
+
return body + `\n\n## ${heading}\n\n${content}\n`;
|
|
120
|
+
if (section.end === body.length)
|
|
121
|
+
return body + '\n' + content + '\n';
|
|
122
|
+
return body.slice(0, section.end) + '\n' + content + body.slice(section.end);
|
|
123
|
+
}
|
|
124
|
+
/** Insert a formatted session-log entry newest-first (creating the section at EOF if absent). */
|
|
125
|
+
export function prependSessionLogEntry(body, entry) {
|
|
126
|
+
const section = findSection(body, 'Session Log');
|
|
127
|
+
if (!section)
|
|
128
|
+
return body + `\n\n## Session Log\n\n${entry}\n`;
|
|
129
|
+
const insertPos = section.headingEnd;
|
|
130
|
+
return body.slice(0, insertPos) + '\n\n' + entry + '\n' + body.slice(insertPos);
|
|
131
|
+
}
|
|
132
|
+
/** Body up to the real Session Log section — the `summary` read depth. */
|
|
133
|
+
export function bodyBeforeSessionLog(body) {
|
|
134
|
+
const section = findSection(body, 'Session Log');
|
|
135
|
+
return section ? body.slice(0, section.start).trim() : body;
|
|
136
|
+
}
|
|
137
|
+
// --- Session log parsing ---
|
|
138
|
+
const HEADING_RE = /^### (\d{4}-\d{2}-\d{2}) (?:—|--) (.+?)(?:\s+\[(\w+)\])?$/;
|
|
139
|
+
export function parseSessionLogHeading(line) {
|
|
140
|
+
const match = line.match(HEADING_RE);
|
|
141
|
+
if (!match)
|
|
142
|
+
return null;
|
|
143
|
+
return {
|
|
144
|
+
date: match[1],
|
|
145
|
+
agent: match[2],
|
|
146
|
+
type: match[3] || 'progress'
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
export function formatSessionLogEntry(entry) {
|
|
150
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
151
|
+
const lines = [`### ${today} — ${entry.agent} [${entry.type}]`, `Summary: ${entry.summary}`];
|
|
152
|
+
if (entry.body)
|
|
153
|
+
lines.push(entry.body);
|
|
154
|
+
return lines.join('\n');
|
|
155
|
+
}
|
|
156
|
+
/** Entries inside the real Session Log section only — entry-shaped lines
|
|
157
|
+
* elsewhere in the body (e.g. quoted in a Brief) do not count. */
|
|
158
|
+
export function countSessionLogEntries(body) {
|
|
159
|
+
const section = findSection(body, 'Session Log');
|
|
160
|
+
if (!section)
|
|
161
|
+
return 0;
|
|
162
|
+
return (section.content.match(/^### \d{4}-\d{2}-\d{2} (?:—|--)/gm) || []).length;
|
|
163
|
+
}
|
|
164
|
+
export function parseSessionLog(body) {
|
|
165
|
+
const section = findSection(body, 'Session Log');
|
|
166
|
+
if (!section)
|
|
167
|
+
return [];
|
|
168
|
+
const entries = [];
|
|
169
|
+
const lines = section.content.split('\n');
|
|
170
|
+
let current = null;
|
|
171
|
+
let summaryLine = null;
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
const heading = parseSessionLogHeading(line);
|
|
174
|
+
if (heading) {
|
|
175
|
+
if (current && summaryLine) {
|
|
176
|
+
entries.push({ ...current, summary: summaryLine });
|
|
177
|
+
}
|
|
178
|
+
current = heading;
|
|
179
|
+
summaryLine = null;
|
|
180
|
+
}
|
|
181
|
+
else if (current && !summaryLine && line.startsWith('Summary: ')) {
|
|
182
|
+
summaryLine = line.slice('Summary: '.length);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (current && summaryLine) {
|
|
186
|
+
entries.push({ ...current, summary: summaryLine });
|
|
187
|
+
}
|
|
188
|
+
return entries;
|
|
189
|
+
}
|
|
190
|
+
export function extractOldestSessionLogEntry(body) {
|
|
191
|
+
const section = findSection(body, 'Session Log');
|
|
192
|
+
if (!section)
|
|
193
|
+
return null;
|
|
194
|
+
// Work on the raw slice between the heading and the section's structural
|
|
195
|
+
// end, so the extracted entry can never swallow a following section
|
|
196
|
+
// (e.g. an auto-appended Related) into the archive.
|
|
197
|
+
const contentStart = section.headingEnd;
|
|
198
|
+
const region = body.slice(contentStart, section.end);
|
|
199
|
+
const entryStarts = [...region.matchAll(/^### \d{4}-\d{2}-\d{2} (?:—|--)/gm)];
|
|
200
|
+
if (entryStarts.length < 2)
|
|
201
|
+
return null;
|
|
202
|
+
// Entries are newest-first in the file, so the oldest is the last match
|
|
203
|
+
const oldestStart = entryStarts[entryStarts.length - 1].index;
|
|
204
|
+
const entry = region.slice(oldestStart).trim();
|
|
205
|
+
const remaining = body.slice(0, contentStart) + region.slice(0, oldestStart) + body.slice(section.end);
|
|
206
|
+
return { entry, remaining };
|
|
207
|
+
}
|
|
208
|
+
// --- Section removal / insertion helpers (Brief, Next Actions) ---
|
|
60
209
|
/**
|
|
61
210
|
* Remove a section (heading + content) from body, cleaning up extra whitespace.
|
|
62
211
|
*/
|
|
@@ -121,9 +270,9 @@ function insertSectionAfter(body, heading, content, after, beforeFallbacks) {
|
|
|
121
270
|
function normalizeForMatch(s) {
|
|
122
271
|
return s
|
|
123
272
|
.trim()
|
|
124
|
-
.replace(/[
|
|
125
|
-
.replace(/[
|
|
126
|
-
.replace(
|
|
273
|
+
.replace(/[‘’]/g, "'")
|
|
274
|
+
.replace(/[“”]/g, '"')
|
|
275
|
+
.replace(/—/g, '-');
|
|
127
276
|
}
|
|
128
277
|
/**
|
|
129
278
|
* Match an action by case-insensitive substring against unchecked items.
|
|
@@ -251,9 +400,11 @@ export function deriveSummary(kind, fm, body) {
|
|
|
251
400
|
}
|
|
252
401
|
return firstNonEmptyLine(findSection(body, 'Summary')?.content);
|
|
253
402
|
}
|
|
254
|
-
// ---
|
|
255
|
-
/**
|
|
256
|
-
|
|
403
|
+
// --- Wiki-links ---
|
|
404
|
+
/** The one wiki-link syntax the vault recognizes: [[slug]] or
|
|
405
|
+
* [[slug|display text]] — slug in group 1, display text in group 2.
|
|
406
|
+
* Non-global — wrap with `new RegExp(WIKI_LINK_RE.source, 'g')` for iteration. */
|
|
407
|
+
export const WIKI_LINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/;
|
|
257
408
|
export function syncRelatedSection(body, frontmatter) {
|
|
258
409
|
const targets = [];
|
|
259
410
|
if (frontmatter.parent)
|
package/dist/export-okf.js
CHANGED
|
@@ -7,24 +7,12 @@
|
|
|
7
7
|
// regenerated indexes) until the bundle directory is deleted and re-exported.
|
|
8
8
|
import { readdirSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
9
9
|
import { join, resolve, sep, basename } from 'path';
|
|
10
|
-
import { parseFilePath, serializeFile } from './vault.js';
|
|
11
|
-
import { deriveSummary } from './
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
];
|
|
17
|
-
// Local capturing variant of the shared WIKI_LINK_RE — captures display text in
|
|
18
|
-
// group 2, which the shared regex's consumers don't need.
|
|
19
|
-
const OKF_LINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
|
20
|
-
function scanDir(dirPath) {
|
|
21
|
-
try {
|
|
22
|
-
return readdirSync(dirPath).filter(f => f.endsWith('.md')).map(f => join(dirPath, f));
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
return [];
|
|
26
|
-
}
|
|
27
|
-
}
|
|
10
|
+
import { parseFilePath, serializeFile, scanVaultDir, kindDir } from './vault.js';
|
|
11
|
+
import { deriveSummary, WIKI_LINK_RE } from './document.js';
|
|
12
|
+
const SOURCE_KINDS = ['entity', 'knowledge', 'session'];
|
|
13
|
+
const KIND_DIRS = SOURCE_KINDS.map(kind => ({ dir: kindDir(kind), kind }));
|
|
14
|
+
// The shared wiki-link syntax (document.ts) — group 1 slug, group 2 display.
|
|
15
|
+
const OKF_LINK_RE = new RegExp(WIKI_LINK_RE.source, 'g');
|
|
28
16
|
function mapFrontmatter(doc) {
|
|
29
17
|
const { fm, kind, body } = doc;
|
|
30
18
|
const mapped = {};
|
|
@@ -88,7 +76,7 @@ export function exportOkf(vaultPath, outDir) {
|
|
|
88
76
|
// Enumerate and parse. Sessions are not in SQLite — disk enumeration is required.
|
|
89
77
|
const docs = [];
|
|
90
78
|
for (const { dir, kind } of KIND_DIRS) {
|
|
91
|
-
for (const filePath of
|
|
79
|
+
for (const filePath of scanVaultDir(join(resolvedVault, dir))) {
|
|
92
80
|
const slug = basename(filePath, '.md');
|
|
93
81
|
const relFile = `${dir}/${slug}.md`;
|
|
94
82
|
let fm, body;
|
|
@@ -160,9 +148,9 @@ export function exportOkf(vaultPath, outDir) {
|
|
|
160
148
|
}
|
|
161
149
|
// Root index.md — the only place okf_version may appear
|
|
162
150
|
const counts = [
|
|
163
|
-
['
|
|
164
|
-
['knowledge', report.knowledge],
|
|
165
|
-
['
|
|
151
|
+
[kindDir('entity'), report.entities],
|
|
152
|
+
[kindDir('knowledge'), report.knowledge],
|
|
153
|
+
[kindDir('session'), report.sessions],
|
|
166
154
|
];
|
|
167
155
|
const rootLines = ['# Vault Export', ''];
|
|
168
156
|
for (const [dir, count] of counts) {
|
package/dist/git.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type GitConfig, type ChangelogEntry } from './types.js';
|
|
1
|
+
import { type GitConfig, type ChangelogEntry, 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,12 +8,18 @@ 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
12
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Latest git time per file (repo-relative, forward-slash paths). Used to
|
|
14
|
+
* break day-granularity frontmatter ties in stats recents. `modified` maps
|
|
15
|
+
* each file to its most recent commit; `added` to the commit that added it.
|
|
16
|
+
* Files with no history (not yet committed) are absent from the map.
|
|
16
17
|
*/
|
|
17
|
-
export declare function
|
|
18
|
-
export declare function gitChangelog(vaultPath: string, since: string): Promise<ChangelogEntry[]>;
|
|
18
|
+
export declare function gitFileTimes(vaultPath: string, mode: 'modified' | 'added'): Promise<Map<string, string>>;
|
|
19
19
|
export declare function gitCommitAndPush(vaultPath: string, files: string[], message: string, config?: GitConfig): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* The production Journal (see CONTEXT.md): vault persistence backed by git.
|
|
22
|
+
* Local-only vaults (no origin remote) commit silently without pushing —
|
|
23
|
+
* that behavior lives in gitCommitAndPush/gitSync, inside this adapter.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createGitJournal(vaultPath: string, config?: GitConfig): Journal;
|
package/dist/git.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { simpleGit } from 'simple-git';
|
|
5
5
|
import { mergeVaultContent } from './merge.js';
|
|
6
|
+
import { VAULT_FILE_RE, INDEXED_DIR_PATHSPECS, classifyIndexedRelPath } from './vault.js';
|
|
6
7
|
import { HafezError } from './types.js';
|
|
7
8
|
const logMerge = (msg) => process.stderr.write(`[hafez-merge] ${msg}\n`);
|
|
8
9
|
const MAX_PUSH_RETRIES = 3;
|
|
@@ -40,7 +41,6 @@ async function ensureCleanState(git, vaultPath) {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
|
-
const VAULT_FILE_RE = /^(?:entities|knowledge|sessions)\/.+\.md$/;
|
|
44
44
|
/**
|
|
45
45
|
* True if the vault repo has an `origin` remote. A local-only vault (the
|
|
46
46
|
* default the README setup produces) has none — every pull/push must be
|
|
@@ -253,12 +253,13 @@ export async function gitSync(vaultPath) {
|
|
|
253
253
|
return { pulled, pushed: true, remote: true };
|
|
254
254
|
}
|
|
255
255
|
/**
|
|
256
|
-
* Unstage files (best effort). Used by
|
|
257
|
-
* after `git add` leaves the new blobs staged in
|
|
258
|
-
* successful commit would publish
|
|
259
|
-
* message. Clearing the index entries restores
|
|
256
|
+
* Unstage files (best effort). Used by the journal adapter's commit-failure
|
|
257
|
+
* repair: a commit that fails after `git add` leaves the new blobs staged in
|
|
258
|
+
* .git/index, and the next successful commit would publish rolled-back
|
|
259
|
+
* content under the wrong message. Clearing the index entries restores
|
|
260
|
+
* worktree/HEAD/index agreement.
|
|
260
261
|
*/
|
|
261
|
-
|
|
262
|
+
async function gitUnstage(vaultPath, files) {
|
|
262
263
|
try {
|
|
263
264
|
await simpleGit(vaultPath).raw(['reset', '-q', 'HEAD', '--', ...files]);
|
|
264
265
|
}
|
|
@@ -281,11 +282,16 @@ const FILE_STATUS_MAP = {
|
|
|
281
282
|
};
|
|
282
283
|
export async function gitChangelog(vaultPath, since) {
|
|
283
284
|
const git = simpleGit(vaultPath);
|
|
285
|
+
// git approxidate fills a date-only --since with the *current* time of
|
|
286
|
+
// day, so `--since <today>` means "since right now" and silently returns
|
|
287
|
+
// nothing — the obvious "what happened today" query was the one that
|
|
288
|
+
// failed. Normalise bare dates to local midnight.
|
|
289
|
+
const normalizedSince = /^\d{4}-\d{2}-\d{2}$/.test(since) ? `${since}T00:00:00` : since;
|
|
284
290
|
let output;
|
|
285
291
|
try {
|
|
286
292
|
output = await git.raw([
|
|
287
|
-
'log', `--since=${
|
|
288
|
-
'--pretty=format:%H|%aI|%s', '--',
|
|
293
|
+
'log', `--since=${normalizedSince}`, '--name-status',
|
|
294
|
+
'--pretty=format:%H|%aI|%s', '--', ...INDEXED_DIR_PATHSPECS,
|
|
289
295
|
]);
|
|
290
296
|
}
|
|
291
297
|
catch {
|
|
@@ -311,24 +317,49 @@ export async function gitChangelog(vaultPath, since) {
|
|
|
311
317
|
const operation = FILE_STATUS_MAP[statusChar];
|
|
312
318
|
if (!operation)
|
|
313
319
|
continue;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if (filePath.
|
|
317
|
-
kind = 'entity';
|
|
318
|
-
slug = filePath.replace('entities/', '').replace('.md', '');
|
|
319
|
-
}
|
|
320
|
-
else if (filePath.startsWith('knowledge/')) {
|
|
321
|
-
kind = 'knowledge';
|
|
322
|
-
slug = filePath.replace('knowledge/', '').replace('.md', '');
|
|
323
|
-
}
|
|
324
|
-
else {
|
|
320
|
+
// Only vault documents — scaffold files (.gitkeep) live in the same
|
|
321
|
+
// dirs and would otherwise surface as changelog rows.
|
|
322
|
+
if (!filePath.endsWith('.md'))
|
|
325
323
|
continue;
|
|
326
|
-
|
|
327
|
-
|
|
324
|
+
const classified = classifyIndexedRelPath(filePath);
|
|
325
|
+
if (!classified)
|
|
326
|
+
continue;
|
|
327
|
+
entries.push({ slug: classified.slug, kind: classified.kind, operation, timestamp, commit_message });
|
|
328
328
|
}
|
|
329
329
|
}
|
|
330
330
|
return entries;
|
|
331
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* Latest git time per file (repo-relative, forward-slash paths). Used to
|
|
334
|
+
* break day-granularity frontmatter ties in stats recents. `modified` maps
|
|
335
|
+
* each file to its most recent commit; `added` to the commit that added it.
|
|
336
|
+
* Files with no history (not yet committed) are absent from the map.
|
|
337
|
+
*/
|
|
338
|
+
export async function gitFileTimes(vaultPath, mode) {
|
|
339
|
+
const git = simpleGit(vaultPath);
|
|
340
|
+
let output;
|
|
341
|
+
try {
|
|
342
|
+
const args = ['log', '--pretty=format:%x00%aI', '--name-only', '--', ...INDEXED_DIR_PATHSPECS];
|
|
343
|
+
if (mode === 'added')
|
|
344
|
+
args.splice(1, 0, '--diff-filter=A');
|
|
345
|
+
output = await git.raw(args);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return new Map(); // no git history
|
|
349
|
+
}
|
|
350
|
+
const times = new Map();
|
|
351
|
+
let current = '';
|
|
352
|
+
for (const line of output.split('\n')) {
|
|
353
|
+
if (line.startsWith('\0')) {
|
|
354
|
+
current = line.slice(1);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const file = line.trim();
|
|
358
|
+
if (file && !times.has(file))
|
|
359
|
+
times.set(file, current); // first hit = newest
|
|
360
|
+
}
|
|
361
|
+
return times;
|
|
362
|
+
}
|
|
332
363
|
export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
|
|
333
364
|
const git = simpleGit(vaultPath);
|
|
334
365
|
// If a previous run crashed mid-rebase/merge, the repo is stuck.
|
|
@@ -365,3 +396,30 @@ export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
|
|
|
365
396
|
return;
|
|
366
397
|
await pushResolving(git, vaultPath, (conflictFiles) => `Git conflict with remote (files: ${conflictFiles.join(', ')}). Changes saved locally.`, `Git push failed after ${MAX_PUSH_RETRIES} attempts. Changes saved locally.`);
|
|
367
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* The production Journal (see CONTEXT.md): vault persistence backed by git.
|
|
401
|
+
* Local-only vaults (no origin remote) commit silently without pushing —
|
|
402
|
+
* that behavior lives in gitCommitAndPush/gitSync, inside this adapter.
|
|
403
|
+
*/
|
|
404
|
+
export function createGitJournal(vaultPath, config = {}) {
|
|
405
|
+
return {
|
|
406
|
+
async commit(written, deleted, message) {
|
|
407
|
+
const files = [...written, ...deleted];
|
|
408
|
+
try {
|
|
409
|
+
await gitCommitAndPush(vaultPath, files, message, config);
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
// Commit-stage failure: nothing landed, but `git add` may have staged
|
|
413
|
+
// the new blobs — clean up our own partial state before rethrowing.
|
|
414
|
+
// Push-stage failure keeps the local commit (GIT_PUSH_FAILED).
|
|
415
|
+
if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
|
|
416
|
+
await gitUnstage(vaultPath, files);
|
|
417
|
+
}
|
|
418
|
+
throw err;
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
sync: () => gitSync(vaultPath),
|
|
422
|
+
changelog: (since) => gitChangelog(vaultPath, since),
|
|
423
|
+
fileTimes: (mode) => gitFileTimes(vaultPath, mode),
|
|
424
|
+
};
|
|
425
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +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, EntityFrontmatter, KnowledgeFrontmatter, ParsedFile, SessionLogEntry, UpdateFields, UpdateResult, CreateEntityFields, CreateKnowledgeFields, QueryFilter, QueryResult, KnowledgeQueryResult, UnifiedResult, ValidationReport, BatchOperation, BatchResult, ReadDepth, ConfidenceLevel, LinkRelation, GitConfig, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype } from './types.js';
|
|
5
|
-
export { getBrief, getNextActions, findSection } from './
|
|
6
|
-
export { parseSessionLogHeading, parseSessionLog } from './
|
|
7
|
-
export { formatStats, formatChangelog, formatValidation } from './cli/format.js';
|
|
4
|
+
export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, 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
|
+
export { getBrief, getNextActions, findSection } from './document.js';
|
|
6
|
+
export { parseSessionLogHeading, parseSessionLog } from './document.js';
|