hafez 1.3.0 → 1.4.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 +67 -9
- package/dist/apply.d.ts +30 -0
- package/dist/apply.js +582 -0
- package/dist/batch-ops.d.ts +296 -13
- package/dist/batch-ops.js +186 -62
- package/dist/cli/args.d.ts +5 -1
- package/dist/cli/args.js +6 -2
- package/dist/cli/commands.d.ts +24 -0
- package/dist/cli/commands.js +197 -115
- package/dist/cli/help-agent.js +24 -0
- package/dist/cli/index.d.ts +41 -0
- package/dist/cli/index.js +150 -130
- package/dist/cli/upgrade.d.ts +42 -0
- package/dist/cli/upgrade.js +305 -0
- package/dist/contracts.d.ts +49 -6
- package/dist/contracts.js +104 -23
- package/dist/dates.d.ts +10 -0
- package/dist/dates.js +27 -0
- package/dist/db.d.ts +27 -2
- package/dist/db.js +309 -33
- package/dist/document.d.ts +97 -88
- package/dist/document.js +512 -382
- package/dist/export-okf.js +41 -15
- package/dist/git.js +165 -48
- package/dist/index.d.ts +2 -2
- package/dist/index.js +212 -727
- package/dist/knowledge-index.d.ts +23 -1
- package/dist/knowledge-index.js +62 -6
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +96 -64
- package/dist/schema.js +4 -4
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +38 -11
- package/dist/vault.d.ts +18 -0
- package/dist/vault.js +50 -4
- package/package.json +1 -1
- package/skill/SKILL.md +51 -110
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -169
package/dist/document.js
CHANGED
|
@@ -1,140 +1,18 @@
|
|
|
1
1
|
// src/document.ts
|
|
2
2
|
// The Document module (see CONTEXT.md): owns the body of a vault file —
|
|
3
|
-
// structural sections, session-log entries, wiki-links.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
3
|
+
// structural sections, session-log entries, wiki-links. Every body read or
|
|
4
|
+
// mutation goes through the single structural-heading model defined here.
|
|
5
|
+
// Pure string→string.
|
|
6
|
+
//
|
|
7
|
+
// Which headings are structural is per-file knowledge: documentFor(ctx)
|
|
8
|
+
// derives the section set from the file's kind/type/subtype via contracts
|
|
9
|
+
// and returns the Document operations bound to it. There is deliberately no
|
|
10
|
+
// global fallback set — a session-only heading like '## Decisions' quoted
|
|
11
|
+
// inside an entity's Brief must NOT terminate that section.
|
|
12
|
+
import { ALL_CONTRACTS, SUBTYPE_SECTIONS, SESSION_SECTIONS, getContract } from './contracts.js';
|
|
13
|
+
import { localDay } from './dates.js';
|
|
7
14
|
const UNCHECKED_RE = /^- \[ \] (.+)$/gm;
|
|
8
|
-
|
|
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.
|
|
15
|
-
*/
|
|
16
|
-
export const STRUCTURAL_SECTIONS = new Set([
|
|
17
|
-
...ALL_CONTRACTS.flatMap(c => [...c.defaultSections, ...c.optionalSections]),
|
|
18
|
-
...Object.values(SUBTYPE_SECTIONS).flat(),
|
|
19
|
-
...SESSION_SECTIONS,
|
|
20
|
-
'Related', // auto-generated by syncRelatedSection
|
|
21
|
-
]);
|
|
22
|
-
/**
|
|
23
|
-
* Find the next structural `## Heading` boundary after a given position.
|
|
24
|
-
* Skips `## ` headings that are not known structural sections (content headings).
|
|
25
|
-
* Returns the index of the `\n` before the `## `, or -1 if no structural heading found.
|
|
26
|
-
*/
|
|
27
|
-
export function findNextStructuralHeading(body, from) {
|
|
28
|
-
const re = /\n## ([^\n]+)/g;
|
|
29
|
-
re.lastIndex = from;
|
|
30
|
-
let match;
|
|
31
|
-
while ((match = re.exec(body)) !== null) {
|
|
32
|
-
const headingName = match[1].trim();
|
|
33
|
-
if (STRUCTURAL_SECTIONS.has(headingName)) {
|
|
34
|
-
return match.index;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return -1;
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Find a ## Heading section and its content.
|
|
41
|
-
* Returns { start, headingEnd, end, content } or null if not found.
|
|
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).
|
|
47
|
-
* Only treats known structural headings as section boundaries -- content
|
|
48
|
-
* inside a section may contain arbitrary `## ` headings without corruption.
|
|
49
|
-
*/
|
|
50
|
-
export function findSection(body, heading) {
|
|
51
|
-
const re = new RegExp(`(^|\\n)(## ${escapeRegex(heading)}[ \\t]*)(?:\\n|$)`);
|
|
52
|
-
const match = re.exec(body);
|
|
53
|
-
if (!match)
|
|
54
|
-
return null;
|
|
55
|
-
const start = match.index + match[1].length;
|
|
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));
|
|
63
|
-
const end = nextHeading === -1 ? body.length : nextHeading;
|
|
64
|
-
const rawContent = body.slice(afterHeading, end);
|
|
65
|
-
const content = rawContent.replace(/^\n+/, '').replace(/\n+$/, '');
|
|
66
|
-
return { start, headingEnd, end, content };
|
|
67
|
-
}
|
|
68
|
-
function escapeRegex(s) {
|
|
69
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
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 ---
|
|
15
|
+
// --- Set-independent helpers ---
|
|
138
16
|
const HEADING_RE = /^### (\d{4}-\d{2}-\d{2}) (?:—|--) (.+?)(?:\s+\[(\w+)\])?$/;
|
|
139
17
|
export function parseSessionLogHeading(line) {
|
|
140
18
|
const match = line.match(HEADING_RE);
|
|
@@ -147,120 +25,36 @@ export function parseSessionLogHeading(line) {
|
|
|
147
25
|
};
|
|
148
26
|
}
|
|
149
27
|
export function formatSessionLogEntry(entry) {
|
|
150
|
-
const today =
|
|
28
|
+
const today = localDay();
|
|
151
29
|
const lines = [`### ${today} — ${entry.agent} [${entry.type}]`, `Summary: ${entry.summary}`];
|
|
152
30
|
if (entry.body)
|
|
153
31
|
lines.push(entry.body);
|
|
154
32
|
return lines.join('\n');
|
|
155
33
|
}
|
|
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) ---
|
|
209
34
|
/**
|
|
210
|
-
*
|
|
35
|
+
* Split a Session Log section's content into raw entry blocks, each beginning
|
|
36
|
+
* with a parseable entry heading. Non-entry text before the first heading is
|
|
37
|
+
* not an entry and is dropped. Document owns entry block splitting — merge's
|
|
38
|
+
* dedup/sort policy stays in merge.ts.
|
|
211
39
|
*/
|
|
212
|
-
function
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const after = body.slice(section.end);
|
|
218
|
-
// Clean up: avoid triple+ newlines at the splice point
|
|
219
|
-
const trimmedBefore = before.replace(/\n+$/, '');
|
|
220
|
-
const trimmedAfter = after.replace(/^\n+/, '');
|
|
221
|
-
if (!trimmedBefore)
|
|
222
|
-
return trimmedAfter;
|
|
223
|
-
if (!trimmedAfter)
|
|
224
|
-
return trimmedBefore;
|
|
225
|
-
return trimmedBefore + '\n\n' + trimmedAfter;
|
|
40
|
+
export function splitLogEntries(content) {
|
|
41
|
+
return content
|
|
42
|
+
.split(/\n(?=### \d{4}-\d{2}-\d{2})/)
|
|
43
|
+
.map(b => b.trim())
|
|
44
|
+
.filter(b => b && parseSessionLogHeading(b.split('\n')[0]) !== null);
|
|
226
45
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
* `before` is an array of headings to try inserting before (first found wins).
|
|
230
|
-
*/
|
|
231
|
-
function insertSectionBefore(body, heading, content, before) {
|
|
232
|
-
const sectionBlock = `## ${heading}\n\n${content}`;
|
|
233
|
-
for (const target of before) {
|
|
234
|
-
const targetSection = findSection(body, target);
|
|
235
|
-
if (targetSection) {
|
|
236
|
-
const insertPoint = targetSection.start;
|
|
237
|
-
const textBefore = body.slice(0, insertPoint).replace(/\n+$/, '');
|
|
238
|
-
const textAfter = body.slice(insertPoint);
|
|
239
|
-
if (!textBefore)
|
|
240
|
-
return sectionBlock + '\n\n' + textAfter;
|
|
241
|
-
return textBefore + '\n\n' + sectionBlock + '\n\n' + textAfter;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
// No target found — append at EOF
|
|
245
|
-
const trimmed = body.replace(/\n+$/, '');
|
|
246
|
-
return trimmed + '\n\n' + sectionBlock;
|
|
46
|
+
export function firstNonEmptyLine(s) {
|
|
47
|
+
return s?.split('\n').find(l => l.trim())?.trim() ?? '';
|
|
247
48
|
}
|
|
248
|
-
/**
|
|
249
|
-
*
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const sectionBlock = `## ${heading}\n\n${content}`;
|
|
258
|
-
if (!textAfter)
|
|
259
|
-
return textBefore + '\n\n' + sectionBlock;
|
|
260
|
-
return textBefore + '\n\n' + sectionBlock + '\n\n' + textAfter;
|
|
261
|
-
}
|
|
262
|
-
// After-target not found — fall back to insertBefore logic
|
|
263
|
-
return insertSectionBefore(body, heading, content, beforeFallbacks);
|
|
49
|
+
/** The one wiki-link syntax the vault recognizes: [[slug]] or
|
|
50
|
+
* [[slug|display text]] — slug in group 1, display text in group 2.
|
|
51
|
+
* Padding inside the brackets is Obsidian-idiomatic ([[ slug ]], [[slug | Label]])
|
|
52
|
+
* and is absorbed by the pattern, so both captures come out already trimmed —
|
|
53
|
+
* every consumer (Related coverage, mention harvesting, export) sees the bare slug.
|
|
54
|
+
* Non-global — wrap with `new RegExp(WIKI_LINK_RE.source, 'g')` for iteration. */
|
|
55
|
+
export const WIKI_LINK_RE = /\[\[\s*([^\]|]+?)\s*(?:\|\s*([^\]]+?)\s*)?\]\]/;
|
|
56
|
+
function escapeRegex(s) {
|
|
57
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
264
58
|
}
|
|
265
59
|
/**
|
|
266
60
|
* Normalize text for matching: trim whitespace, replace smart quotes with
|
|
@@ -274,113 +68,494 @@ function normalizeForMatch(s) {
|
|
|
274
68
|
.replace(/[“”]/g, '"')
|
|
275
69
|
.replace(/—/g, '-');
|
|
276
70
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const allActions = [];
|
|
288
|
-
let m;
|
|
289
|
-
const re = new RegExp(UNCHECKED_RE.source, 'gm');
|
|
290
|
-
while ((m = re.exec(section.content)) !== null) {
|
|
291
|
-
allActions.push(m[1]);
|
|
292
|
-
if (normalizeForMatch(m[1]).toLowerCase().includes(normalizedQuery)) {
|
|
293
|
-
matches.push(m[1]);
|
|
294
|
-
}
|
|
71
|
+
const ENTITY_CONTRACTS = ALL_CONTRACTS.filter(c => c.kind === 'item');
|
|
72
|
+
function sectionNamesFor(ctx) {
|
|
73
|
+
if (ctx.kind === 'session')
|
|
74
|
+
return [...SESSION_SECTIONS];
|
|
75
|
+
if (ctx.kind === 'knowledge') {
|
|
76
|
+
const subtypeTable = SUBTYPE_SECTIONS;
|
|
77
|
+
const names = [...getContract('knowledge').defaultSections];
|
|
78
|
+
for (const s of Object.keys(subtypeTable))
|
|
79
|
+
names.push(...subtypeTable[s]);
|
|
80
|
+
return names;
|
|
295
81
|
}
|
|
296
|
-
|
|
297
|
-
throw new Error(`No matching action for "${query}". Actions: ${JSON.stringify(allActions)}`);
|
|
298
|
-
}
|
|
299
|
-
if (matches.length > 1)
|
|
300
|
-
throw new Error(`Ambiguous match for "${query}": ${matches.join(', ')}`);
|
|
301
|
-
return matches[0];
|
|
82
|
+
return ENTITY_CONTRACTS.flatMap(c => [...c.defaultSections, ...c.optionalSections]);
|
|
302
83
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
return null;
|
|
308
|
-
return section.content || null;
|
|
84
|
+
/** The structural set a context binds — exposed for introspection and the
|
|
85
|
+
* contract-coverage tests; runtime consumers go through documentFor. */
|
|
86
|
+
export function structuralSectionsFor(ctx) {
|
|
87
|
+
return new Set([...sectionNamesFor(ctx), 'Related']);
|
|
309
88
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
89
|
+
const docCache = new Map();
|
|
90
|
+
/** The Document operations bound to one file's structural section set. */
|
|
91
|
+
export function documentFor(ctx) {
|
|
92
|
+
const key = ctx.kind;
|
|
93
|
+
let doc = docCache.get(key);
|
|
94
|
+
if (!doc) {
|
|
95
|
+
doc = makeDoc(new Set(structuralSectionsFor(ctx)));
|
|
96
|
+
docCache.set(key, doc);
|
|
316
97
|
}
|
|
317
|
-
|
|
318
|
-
return insertSectionBefore(body, 'Brief', content, ['Next Actions', 'Current State', 'Session Log']);
|
|
98
|
+
return doc;
|
|
319
99
|
}
|
|
320
|
-
|
|
321
|
-
|
|
100
|
+
/** Bind from a parsed vault file — kind from Layout. */
|
|
101
|
+
export function documentForFile(parsed) {
|
|
102
|
+
return documentFor({ kind: parsed.kind });
|
|
322
103
|
}
|
|
323
|
-
// ---
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
104
|
+
// --- The bound implementation ---
|
|
105
|
+
function makeDoc(structuralSections) {
|
|
106
|
+
/**
|
|
107
|
+
* Find the next structural `## Heading` boundary after a given position.
|
|
108
|
+
* Skips `## ` headings that are not known structural sections (content headings).
|
|
109
|
+
* Returns the index of the `\n` before the `## `, or -1 if no structural heading found.
|
|
110
|
+
*/
|
|
111
|
+
function findNextStructuralHeading(body, from) {
|
|
112
|
+
const re = /\n## ([^\n]+)/g;
|
|
113
|
+
re.lastIndex = from;
|
|
114
|
+
let match;
|
|
115
|
+
while ((match = re.exec(body)) !== null) {
|
|
116
|
+
const headingName = match[1].trim();
|
|
117
|
+
if (structuralSections.has(headingName)) {
|
|
118
|
+
return match.index;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return -1;
|
|
333
122
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Find a ## Heading section and its content.
|
|
125
|
+
* Returns { start, headingEnd, end, content } or null if not found.
|
|
126
|
+
* Matches heading exactly (e.g. "## Brief" won't match "## Briefing").
|
|
127
|
+
* Trailing whitespace on the heading line is tolerated — the splitter's
|
|
128
|
+
* boundary tokenizer trims headings, so anything it treats as a boundary
|
|
129
|
+
* must also be findable here (a hand-edited `## Session Log ` otherwise
|
|
130
|
+
* gets a duplicate section and archival never fires).
|
|
131
|
+
* Only treats known structural headings as section boundaries -- content
|
|
132
|
+
* inside a section may contain arbitrary `## ` headings without corruption.
|
|
133
|
+
*/
|
|
134
|
+
function findSection(body, heading) {
|
|
135
|
+
const re = new RegExp(`(^|\\n)(## ${escapeRegex(heading)}[ \\t]*)(?:\\n|$)`);
|
|
136
|
+
const match = re.exec(body);
|
|
137
|
+
if (!match)
|
|
138
|
+
return null;
|
|
139
|
+
const start = match.index + match[1].length;
|
|
140
|
+
const headingEnd = start + match[2].length;
|
|
141
|
+
const afterHeading = headingEnd + 1;
|
|
142
|
+
// Find the next structural ## heading or EOF. Scan from the heading's own
|
|
143
|
+
// trailing newline (afterHeading - 1), not past it — otherwise a structural
|
|
144
|
+
// heading on the immediately following line (no blank line between, as in
|
|
145
|
+
// hand-edited files) is invisible and mutators swallow the next section.
|
|
146
|
+
const nextHeading = findNextStructuralHeading(body, Math.max(0, afterHeading - 1));
|
|
147
|
+
const end = nextHeading === -1 ? body.length : nextHeading;
|
|
148
|
+
const rawContent = body.slice(afterHeading, end);
|
|
149
|
+
const content = rawContent.replace(/^\n+/, '').replace(/\n+$/, '');
|
|
150
|
+
return { start, headingEnd, end, content };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Split a body into preamble + structural sections. Non-structural `## `
|
|
154
|
+
* headings stay inside the enclosing section's content — sections are
|
|
155
|
+
* atomic units of authored content, never split at user headings.
|
|
156
|
+
* Headings and content come back trimmed; reassemble with
|
|
157
|
+
* `## ${heading}\n\n${content}` joined by blank lines.
|
|
158
|
+
*/
|
|
159
|
+
function splitStructural(body) {
|
|
160
|
+
const marks = [];
|
|
161
|
+
const re = /(^|\n)## ([^\n]*)/g;
|
|
162
|
+
let m;
|
|
163
|
+
while ((m = re.exec(body)) !== null) {
|
|
164
|
+
const heading = m[2].trim();
|
|
165
|
+
if (!structuralSections.has(heading))
|
|
166
|
+
continue;
|
|
167
|
+
marks.push({ start: m.index + m[1].length, contentStart: m.index + m[0].length, heading });
|
|
168
|
+
}
|
|
169
|
+
if (marks.length === 0)
|
|
170
|
+
return { preamble: body.trim(), sections: [] };
|
|
171
|
+
const preamble = body.slice(0, marks[0].start).trim();
|
|
172
|
+
const sections = marks.map((mk, i) => ({
|
|
173
|
+
heading: mk.heading,
|
|
174
|
+
content: body.slice(mk.contentStart, i + 1 < marks.length ? marks[i + 1].start : body.length).trim(),
|
|
175
|
+
}));
|
|
176
|
+
return { preamble, sections };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Replace a structural section's content, or create the section: before
|
|
180
|
+
* `insertBefore` when that section exists, at EOF otherwise.
|
|
181
|
+
*/
|
|
182
|
+
function setSection(body, heading, content, insertBefore) {
|
|
183
|
+
const existing = findSection(body, heading);
|
|
184
|
+
if (existing) {
|
|
185
|
+
return body.slice(0, existing.start) + `## ${heading}\n\n${content}\n` + body.slice(existing.end);
|
|
186
|
+
}
|
|
187
|
+
if (insertBefore) {
|
|
188
|
+
const target = findSection(body, insertBefore);
|
|
189
|
+
if (target) {
|
|
190
|
+
return body.slice(0, target.start) + `## ${heading}\n\n${content}\n\n` + body.slice(target.start);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return body + `\n\n## ${heading}\n\n${content}\n`;
|
|
194
|
+
}
|
|
195
|
+
/** Append content at the end of a structural section (creating it at EOF if absent). */
|
|
196
|
+
function appendToSection(body, heading, content) {
|
|
197
|
+
const section = findSection(body, heading);
|
|
198
|
+
if (!section)
|
|
199
|
+
return body + `\n\n## ${heading}\n\n${content}\n`;
|
|
200
|
+
if (section.end === body.length)
|
|
201
|
+
return body + '\n' + content + '\n';
|
|
202
|
+
return body.slice(0, section.end) + '\n' + content + body.slice(section.end);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Rename a structural section's heading in place, keeping its content.
|
|
206
|
+
* No-op when the section is absent. Any trailing whitespace on the old
|
|
207
|
+
* heading line is dropped with it.
|
|
208
|
+
*/
|
|
209
|
+
function renameSection(body, from, to) {
|
|
210
|
+
const section = findSection(body, from);
|
|
211
|
+
if (!section)
|
|
212
|
+
return body;
|
|
213
|
+
return body.slice(0, section.start) + `## ${to}` + body.slice(section.headingEnd);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Ensure a structural section exists: no-op when present, otherwise insert
|
|
217
|
+
* an empty section after `opts.after` (when that section exists) or at EOF.
|
|
218
|
+
*/
|
|
219
|
+
function ensureSection(body, heading, opts = {}) {
|
|
220
|
+
if (findSection(body, heading))
|
|
221
|
+
return body;
|
|
222
|
+
const block = `## ${heading}`;
|
|
223
|
+
if (opts.after) {
|
|
224
|
+
const anchor = findSection(body, opts.after);
|
|
225
|
+
if (anchor) {
|
|
226
|
+
const before = body.slice(0, anchor.end).replace(/\n+$/, '');
|
|
227
|
+
const after = body.slice(anchor.end).replace(/^\n+/, '');
|
|
228
|
+
return after ? `${before}\n\n${block}\n\n${after}` : `${before}\n\n${block}\n`;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return body.replace(/\n+$/, '') + `\n\n${block}\n`;
|
|
232
|
+
}
|
|
233
|
+
/** Insert a formatted session-log entry newest-first (creating the section at EOF if absent). */
|
|
234
|
+
function prependSessionLogEntry(body, entry) {
|
|
235
|
+
const section = findSection(body, 'Session Log');
|
|
236
|
+
if (!section)
|
|
237
|
+
return body + `\n\n## Session Log\n\n${entry}\n`;
|
|
238
|
+
const insertPos = section.headingEnd;
|
|
239
|
+
return body.slice(0, insertPos) + '\n\n' + entry + '\n' + body.slice(insertPos);
|
|
240
|
+
}
|
|
241
|
+
/** Body up to the real Session Log section — the `summary` read depth. */
|
|
242
|
+
function bodyBeforeSessionLog(body) {
|
|
243
|
+
const section = findSection(body, 'Session Log');
|
|
244
|
+
return section ? body.slice(0, section.start).trim() : body;
|
|
245
|
+
}
|
|
246
|
+
/** Entries inside the real Session Log section only — entry-shaped lines
|
|
247
|
+
* elsewhere in the body (e.g. quoted in a Brief) do not count. */
|
|
248
|
+
function countSessionLogEntries(body) {
|
|
249
|
+
const section = findSection(body, 'Session Log');
|
|
250
|
+
if (!section)
|
|
251
|
+
return 0;
|
|
252
|
+
return (section.content.match(/^### \d{4}-\d{2}-\d{2} (?:—|--)/gm) || []).length;
|
|
253
|
+
}
|
|
254
|
+
function parseSessionLog(body) {
|
|
255
|
+
const section = findSection(body, 'Session Log');
|
|
256
|
+
if (!section)
|
|
257
|
+
return [];
|
|
258
|
+
const entries = [];
|
|
340
259
|
const lines = section.content.split('\n');
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
260
|
+
let current = null;
|
|
261
|
+
let summaryLine = null;
|
|
262
|
+
for (const line of lines) {
|
|
263
|
+
const heading = parseSessionLogHeading(line);
|
|
264
|
+
if (heading) {
|
|
265
|
+
if (current && summaryLine) {
|
|
266
|
+
entries.push({ ...current, summary: summaryLine });
|
|
267
|
+
}
|
|
268
|
+
current = heading;
|
|
269
|
+
summaryLine = null;
|
|
270
|
+
}
|
|
271
|
+
else if (current && !summaryLine && line.startsWith('Summary: ')) {
|
|
272
|
+
summaryLine = line.slice('Summary: '.length);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (current && summaryLine) {
|
|
276
|
+
entries.push({ ...current, summary: summaryLine });
|
|
277
|
+
}
|
|
278
|
+
return entries;
|
|
350
279
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
280
|
+
function extractOldestSessionLogEntry(body) {
|
|
281
|
+
const section = findSection(body, 'Session Log');
|
|
282
|
+
if (!section)
|
|
283
|
+
return null;
|
|
284
|
+
// Work on the raw slice between the heading and the section's structural
|
|
285
|
+
// end, so the extracted entry can never swallow a following section
|
|
286
|
+
// (e.g. an auto-appended Related) into the archive.
|
|
287
|
+
const contentStart = section.headingEnd;
|
|
288
|
+
const region = body.slice(contentStart, section.end);
|
|
289
|
+
const entryStarts = [...region.matchAll(/^### \d{4}-\d{2}-\d{2} (?:—|--)/gm)];
|
|
290
|
+
if (entryStarts.length < 2)
|
|
291
|
+
return null;
|
|
292
|
+
// Entries are newest-first in the file, so the oldest is the last match
|
|
293
|
+
const oldestStart = entryStarts[entryStarts.length - 1].index;
|
|
294
|
+
const entry = region.slice(oldestStart).trim();
|
|
295
|
+
const remaining = body.slice(0, contentStart) + region.slice(0, oldestStart) + body.slice(section.end);
|
|
296
|
+
return { entry, remaining };
|
|
362
297
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
298
|
+
/**
|
|
299
|
+
* Remove a section (heading + content) from body, cleaning up extra whitespace.
|
|
300
|
+
*/
|
|
301
|
+
function removeSection(body, heading) {
|
|
302
|
+
const section = findSection(body, heading);
|
|
303
|
+
if (!section)
|
|
304
|
+
return body;
|
|
305
|
+
const before = body.slice(0, section.start);
|
|
306
|
+
const after = body.slice(section.end);
|
|
307
|
+
// Clean up: avoid triple+ newlines at the splice point
|
|
308
|
+
const trimmedBefore = before.replace(/\n+$/, '');
|
|
309
|
+
const trimmedAfter = after.replace(/^\n+/, '');
|
|
310
|
+
if (!trimmedBefore)
|
|
311
|
+
return trimmedAfter;
|
|
312
|
+
if (!trimmedAfter)
|
|
313
|
+
return trimmedBefore;
|
|
314
|
+
return trimmedBefore + '\n\n' + trimmedAfter;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Insert a new section before a target heading, or at EOF.
|
|
318
|
+
* `before` is an array of headings to try inserting before (first found wins).
|
|
319
|
+
*/
|
|
320
|
+
function insertSectionBefore(body, heading, content, before) {
|
|
321
|
+
const sectionBlock = `## ${heading}\n\n${content}`;
|
|
322
|
+
for (const target of before) {
|
|
323
|
+
const targetSection = findSection(body, target);
|
|
324
|
+
if (targetSection) {
|
|
325
|
+
const insertPoint = targetSection.start;
|
|
326
|
+
const textBefore = body.slice(0, insertPoint).replace(/\n+$/, '');
|
|
327
|
+
const textAfter = body.slice(insertPoint);
|
|
328
|
+
if (!textBefore)
|
|
329
|
+
return sectionBlock + '\n\n' + textAfter;
|
|
330
|
+
return textBefore + '\n\n' + sectionBlock + '\n\n' + textAfter;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// No target found — append at EOF
|
|
334
|
+
const trimmed = body.replace(/\n+$/, '');
|
|
335
|
+
return trimmed + '\n\n' + sectionBlock;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Insert a new section after a target heading (and its content), or fall back to inserting before fallbacks.
|
|
339
|
+
*/
|
|
340
|
+
function insertSectionAfter(body, heading, content, after, beforeFallbacks) {
|
|
341
|
+
const afterSection = findSection(body, after);
|
|
342
|
+
if (afterSection) {
|
|
343
|
+
const insertPoint = afterSection.end;
|
|
344
|
+
const textBefore = body.slice(0, insertPoint).replace(/\n+$/, '');
|
|
345
|
+
const textAfter = body.slice(insertPoint).replace(/^\n+/, '');
|
|
346
|
+
const sectionBlock = `## ${heading}\n\n${content}`;
|
|
347
|
+
if (!textAfter)
|
|
348
|
+
return textBefore + '\n\n' + sectionBlock;
|
|
349
|
+
return textBefore + '\n\n' + sectionBlock + '\n\n' + textAfter;
|
|
350
|
+
}
|
|
351
|
+
// After-target not found — fall back to insertBefore logic
|
|
352
|
+
return insertSectionBefore(body, heading, content, beforeFallbacks);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Match an action by case-insensitive substring against unchecked items.
|
|
356
|
+
* Throws on 0 or >1 matches.
|
|
357
|
+
*/
|
|
358
|
+
function matchAction(body, query) {
|
|
359
|
+
const section = findSection(body, 'Next Actions');
|
|
360
|
+
if (!section)
|
|
361
|
+
throw new Error(`No matching action for "${query}"`);
|
|
362
|
+
const normalizedQuery = normalizeForMatch(query).toLowerCase();
|
|
363
|
+
const matches = [];
|
|
364
|
+
const allActions = [];
|
|
365
|
+
let m;
|
|
366
|
+
const re = new RegExp(UNCHECKED_RE.source, 'gm');
|
|
367
|
+
while ((m = re.exec(section.content)) !== null) {
|
|
368
|
+
allActions.push(m[1]);
|
|
369
|
+
if (normalizeForMatch(m[1]).toLowerCase().includes(normalizedQuery)) {
|
|
370
|
+
matches.push(m[1]);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (matches.length === 0) {
|
|
374
|
+
throw new Error(`No matching action for "${query}". Actions: ${JSON.stringify(allActions)}`);
|
|
375
|
+
}
|
|
376
|
+
if (matches.length > 1)
|
|
377
|
+
throw new Error(`Ambiguous match for "${query}": ${matches.join(', ')}`);
|
|
378
|
+
return matches[0];
|
|
379
|
+
}
|
|
380
|
+
// --- Brief functions ---
|
|
381
|
+
function getBrief(body) {
|
|
382
|
+
const section = findSection(body, 'Brief');
|
|
383
|
+
if (!section)
|
|
384
|
+
return null;
|
|
385
|
+
return section.content || null;
|
|
386
|
+
}
|
|
387
|
+
function setBrief(body, content) {
|
|
388
|
+
const existing = findSection(body, 'Brief');
|
|
389
|
+
if (existing) {
|
|
390
|
+
// Replace existing brief content
|
|
391
|
+
const updated = removeSection(body, 'Brief');
|
|
392
|
+
return setBrief(updated, content);
|
|
393
|
+
}
|
|
394
|
+
// Insert before: Next Actions > Current State > Session Log > EOF
|
|
395
|
+
return insertSectionBefore(body, 'Brief', content, ['Next Actions', 'Current State', 'Session Log']);
|
|
396
|
+
}
|
|
397
|
+
function removeBrief(body) {
|
|
398
|
+
return removeSection(body, 'Brief');
|
|
399
|
+
}
|
|
400
|
+
// --- Next Actions functions ---
|
|
401
|
+
function getNextActions(body) {
|
|
402
|
+
const section = findSection(body, 'Next Actions');
|
|
403
|
+
if (!section)
|
|
404
|
+
return [];
|
|
405
|
+
const actions = [];
|
|
406
|
+
let m;
|
|
407
|
+
const re = new RegExp(UNCHECKED_RE.source, 'gm');
|
|
408
|
+
while ((m = re.exec(section.content)) !== null) {
|
|
409
|
+
actions.push(m[1]);
|
|
410
|
+
}
|
|
411
|
+
return actions;
|
|
374
412
|
}
|
|
375
|
-
|
|
413
|
+
function addNextAction(body, action) {
|
|
414
|
+
const section = findSection(body, 'Next Actions');
|
|
415
|
+
if (section) {
|
|
416
|
+
// Add the new item at the end of unchecked items (before any checked items)
|
|
417
|
+
const lines = section.content.split('\n');
|
|
418
|
+
const lastUncheckedIdx = lines.reduce((last, line, i) => line.startsWith('- [ ] ') ? i : last, -1);
|
|
419
|
+
const insertIdx = lastUncheckedIdx === -1 ? 0 : lastUncheckedIdx + 1;
|
|
420
|
+
lines.splice(insertIdx, 0, `- [ ] ${action}`);
|
|
421
|
+
const newContent = lines.join('\n');
|
|
422
|
+
const before = body.slice(0, section.start).replace(/\n+$/, '');
|
|
423
|
+
const after = body.slice(section.end).replace(/^\n+/, '');
|
|
424
|
+
const sectionBlock = `## Next Actions\n\n${newContent}`;
|
|
425
|
+
const parts = [before, sectionBlock, after].filter(Boolean);
|
|
426
|
+
return parts.join('\n\n');
|
|
427
|
+
}
|
|
428
|
+
// No section — insert after Brief if present, otherwise before Session Log
|
|
429
|
+
return insertSectionAfter(body, 'Next Actions', `- [ ] ${action}`, 'Brief', ['Session Log']);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Edit the matched action's line inside the Next Actions section only.
|
|
433
|
+
* Matching was always section-scoped; editing must be too — an identical
|
|
434
|
+
* checkbox line quoted elsewhere in the body (a Brief restating a blocker)
|
|
435
|
+
* is authored content, not an action. Works on the section's raw slice and
|
|
436
|
+
* on whole lines, so action text is never interpreted as a replacement
|
|
437
|
+
* pattern ($&, $', $1 survive byte-identical).
|
|
438
|
+
*/
|
|
439
|
+
function editActionLine(body, matched, edit) {
|
|
440
|
+
const section = findSection(body, 'Next Actions');
|
|
441
|
+
if (!section)
|
|
442
|
+
return body;
|
|
443
|
+
const lines = body.slice(section.headingEnd, section.end).split('\n');
|
|
444
|
+
const idx = lines.indexOf(`- [ ] ${matched}`);
|
|
445
|
+
if (idx === -1)
|
|
446
|
+
return body;
|
|
447
|
+
edit(lines, idx);
|
|
448
|
+
return body.slice(0, section.headingEnd) + lines.join('\n') + body.slice(section.end);
|
|
449
|
+
}
|
|
450
|
+
function completeNextAction(body, query) {
|
|
451
|
+
const matched = matchAction(body, query);
|
|
452
|
+
// Replace the unchecked item with checked
|
|
453
|
+
const updatedBody = editActionLine(body, matched, (lines, i) => { lines[i] = `- [x] ${matched}`; });
|
|
454
|
+
// Check if any unchecked items remain
|
|
455
|
+
const remaining = getNextActions(updatedBody);
|
|
456
|
+
if (remaining.length === 0) {
|
|
457
|
+
return { matched, body: clearNextActions(updatedBody) };
|
|
458
|
+
}
|
|
459
|
+
return { matched, body: updatedBody };
|
|
460
|
+
}
|
|
461
|
+
function removeNextAction(body, query) {
|
|
462
|
+
const matched = matchAction(body, query);
|
|
463
|
+
// Remove the line
|
|
464
|
+
const updatedBody = editActionLine(body, matched, (lines, i) => { lines.splice(i, 1); });
|
|
465
|
+
// Check if any unchecked items remain
|
|
466
|
+
const remaining = getNextActions(updatedBody);
|
|
467
|
+
if (remaining.length === 0) {
|
|
468
|
+
return { matched, body: clearNextActions(updatedBody) };
|
|
469
|
+
}
|
|
470
|
+
return { matched, body: updatedBody };
|
|
471
|
+
}
|
|
472
|
+
function clearNextActions(body) {
|
|
473
|
+
return removeSection(body, 'Next Actions');
|
|
474
|
+
}
|
|
475
|
+
// --- Related section sync ---
|
|
476
|
+
function syncRelatedSection(body, frontmatter) {
|
|
477
|
+
const targets = [];
|
|
478
|
+
if (frontmatter.parent)
|
|
479
|
+
targets.push({ slug: frontmatter.parent, label: 'parent' });
|
|
480
|
+
for (const slug of frontmatter.related ?? [])
|
|
481
|
+
targets.push({ slug });
|
|
482
|
+
if (targets.length === 0) {
|
|
483
|
+
// No frontmatter links — remove Related section if present
|
|
484
|
+
const existing = findSection(body, 'Related');
|
|
485
|
+
if (!existing)
|
|
486
|
+
return body;
|
|
487
|
+
const before = body.slice(0, existing.start).replace(/\n+$/, '');
|
|
488
|
+
const after = body.slice(existing.end).replace(/^\n+/, '');
|
|
489
|
+
return [before, after].filter(Boolean).join('\n\n');
|
|
490
|
+
}
|
|
491
|
+
// Strip existing Related section before checking coverage
|
|
492
|
+
let authoredBody = body;
|
|
493
|
+
const existingRelated = findSection(body, 'Related');
|
|
494
|
+
if (existingRelated) {
|
|
495
|
+
const before = body.slice(0, existingRelated.start);
|
|
496
|
+
const after = body.slice(existingRelated.end);
|
|
497
|
+
authoredBody = (before + after).replace(/\n{3,}/g, '\n\n');
|
|
498
|
+
}
|
|
499
|
+
// Find which targets are NOT mentioned in authored body
|
|
500
|
+
const mentionedSlugs = new Set();
|
|
501
|
+
let match;
|
|
502
|
+
const re = new RegExp(WIKI_LINK_RE.source, 'g');
|
|
503
|
+
while ((match = re.exec(authoredBody)) !== null) {
|
|
504
|
+
mentionedSlugs.add(match[1]);
|
|
505
|
+
}
|
|
506
|
+
const uncovered = targets.filter(t => !mentionedSlugs.has(t.slug));
|
|
507
|
+
if (uncovered.length === 0) {
|
|
508
|
+
// All covered inline — return body without Related section
|
|
509
|
+
return authoredBody.replace(/\n+$/, '');
|
|
510
|
+
}
|
|
511
|
+
// Build Related section
|
|
512
|
+
const lines = uncovered.map(t => t.label ? `- [[${t.slug}]] (${t.label})` : `- [[${t.slug}]]`);
|
|
513
|
+
const section = `## Related\n\n${lines.join('\n')}`;
|
|
514
|
+
// Append at end
|
|
515
|
+
return authoredBody.replace(/\n+$/, '') + '\n\n' + section;
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
findNextStructuralHeading,
|
|
519
|
+
findSection,
|
|
520
|
+
splitStructural,
|
|
521
|
+
setSection,
|
|
522
|
+
appendToSection,
|
|
523
|
+
renameSection,
|
|
524
|
+
ensureSection,
|
|
525
|
+
prependSessionLogEntry,
|
|
526
|
+
bodyBeforeSessionLog,
|
|
527
|
+
countSessionLogEntries,
|
|
528
|
+
parseSessionLog,
|
|
529
|
+
extractOldestSessionLogEntry,
|
|
530
|
+
getBrief,
|
|
531
|
+
setBrief,
|
|
532
|
+
removeBrief,
|
|
533
|
+
getNextActions,
|
|
534
|
+
addNextAction,
|
|
535
|
+
completeNextAction,
|
|
536
|
+
removeNextAction,
|
|
537
|
+
clearNextActions,
|
|
538
|
+
syncRelatedSection,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
// --- Entity-bound convenience exports (public API) ---
|
|
542
|
+
// getBrief / getNextActions / parseSessionLog are read-only accessors on
|
|
543
|
+
// entity bodies; they bind the entity Document (Brief, Next Actions, and
|
|
544
|
+
// Session Log only exist on entity kinds).
|
|
545
|
+
const ENTITY_UNION_DOC = documentFor({ kind: 'entity' });
|
|
546
|
+
export function getBrief(body) {
|
|
547
|
+
return ENTITY_UNION_DOC.getBrief(body);
|
|
376
548
|
}
|
|
377
|
-
export function
|
|
378
|
-
return
|
|
549
|
+
export function getNextActions(body) {
|
|
550
|
+
return ENTITY_UNION_DOC.getNextActions(body);
|
|
379
551
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
552
|
+
export function parseSessionLog(body) {
|
|
553
|
+
return ENTITY_UNION_DOC.parseSessionLog(body);
|
|
554
|
+
}
|
|
555
|
+
export function countSessionLogEntries(body) {
|
|
556
|
+
return ENTITY_UNION_DOC.countSessionLogEntries(body);
|
|
383
557
|
}
|
|
558
|
+
// --- Summary derivation ---
|
|
384
559
|
/**
|
|
385
560
|
* One-line summary for a doc: frontmatter description first, then the first
|
|
386
561
|
* non-empty line of the kind's primary section (entity: Brief; knowledge:
|
|
@@ -390,59 +565,14 @@ export function firstNonEmptyLine(s) {
|
|
|
390
565
|
export function deriveSummary(kind, fm, body) {
|
|
391
566
|
if (fm.description)
|
|
392
567
|
return fm.description;
|
|
568
|
+
const doc = documentFor({ kind });
|
|
393
569
|
if (kind === 'entity')
|
|
394
|
-
return firstNonEmptyLine(getBrief(body));
|
|
570
|
+
return firstNonEmptyLine(doc.getBrief(body));
|
|
395
571
|
if (kind === 'knowledge') {
|
|
396
|
-
const synthesis = findSection(body, 'Synthesis');
|
|
572
|
+
const synthesis = doc.findSection(body, 'Synthesis');
|
|
397
573
|
if (synthesis?.content)
|
|
398
574
|
return firstNonEmptyLine(synthesis.content);
|
|
399
|
-
return firstNonEmptyLine(findSection(body, 'Goal')?.content);
|
|
400
|
-
}
|
|
401
|
-
return firstNonEmptyLine(findSection(body, 'Summary')?.content);
|
|
402
|
-
}
|
|
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 = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/;
|
|
408
|
-
export function syncRelatedSection(body, frontmatter) {
|
|
409
|
-
const targets = [];
|
|
410
|
-
if (frontmatter.parent)
|
|
411
|
-
targets.push({ slug: frontmatter.parent, label: 'parent' });
|
|
412
|
-
for (const slug of frontmatter.related ?? [])
|
|
413
|
-
targets.push({ slug });
|
|
414
|
-
if (targets.length === 0) {
|
|
415
|
-
// No frontmatter links — remove Related section if present
|
|
416
|
-
const existing = findSection(body, 'Related');
|
|
417
|
-
if (!existing)
|
|
418
|
-
return body;
|
|
419
|
-
const before = body.slice(0, existing.start).replace(/\n+$/, '');
|
|
420
|
-
const after = body.slice(existing.end).replace(/^\n+/, '');
|
|
421
|
-
return [before, after].filter(Boolean).join('\n\n');
|
|
422
|
-
}
|
|
423
|
-
// Strip existing Related section before checking coverage
|
|
424
|
-
let authoredBody = body;
|
|
425
|
-
const existingRelated = findSection(body, 'Related');
|
|
426
|
-
if (existingRelated) {
|
|
427
|
-
const before = body.slice(0, existingRelated.start);
|
|
428
|
-
const after = body.slice(existingRelated.end);
|
|
429
|
-
authoredBody = (before + after).replace(/\n{3,}/g, '\n\n');
|
|
430
|
-
}
|
|
431
|
-
// Find which targets are NOT mentioned in authored body
|
|
432
|
-
const mentionedSlugs = new Set();
|
|
433
|
-
let match;
|
|
434
|
-
const re = new RegExp(WIKI_LINK_RE.source, 'g');
|
|
435
|
-
while ((match = re.exec(authoredBody)) !== null) {
|
|
436
|
-
mentionedSlugs.add(match[1]);
|
|
437
|
-
}
|
|
438
|
-
const uncovered = targets.filter(t => !mentionedSlugs.has(t.slug));
|
|
439
|
-
if (uncovered.length === 0) {
|
|
440
|
-
// All covered inline — return body without Related section
|
|
441
|
-
return authoredBody.replace(/\n+$/, '');
|
|
575
|
+
return firstNonEmptyLine(doc.findSection(body, 'Goal')?.content);
|
|
442
576
|
}
|
|
443
|
-
|
|
444
|
-
const lines = uncovered.map(t => t.label ? `- [[${t.slug}]] (${t.label})` : `- [[${t.slug}]]`);
|
|
445
|
-
const section = `## Related\n\n${lines.join('\n')}`;
|
|
446
|
-
// Append at end
|
|
447
|
-
return authoredBody.replace(/\n+$/, '') + '\n\n' + section;
|
|
577
|
+
return firstNonEmptyLine(doc.findSection(body, 'Summary')?.content);
|
|
448
578
|
}
|