hafez 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +83 -32
  2. package/dist/apply.d.ts +33 -0
  3. package/dist/apply.js +693 -0
  4. package/dist/batch-ops.d.ts +372 -26
  5. package/dist/batch-ops.js +355 -73
  6. package/dist/cli/args.d.ts +48 -6
  7. package/dist/cli/args.js +74 -9
  8. package/dist/cli/batch-errors.js +19 -11
  9. package/dist/cli/block-hook.d.ts +9 -0
  10. package/dist/cli/block-hook.js +90 -0
  11. package/dist/cli/commands-schema.js +2 -0
  12. package/dist/cli/commands.d.ts +28 -3
  13. package/dist/cli/commands.js +440 -157
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +55 -0
  17. package/dist/cli/index.js +215 -153
  18. package/dist/cli/install.d.ts +53 -0
  19. package/dist/cli/install.js +297 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +265 -90
  22. package/dist/cli/resolve-vault.d.ts +11 -4
  23. package/dist/cli/resolve-vault.js +13 -11
  24. package/dist/cli/schema-introspect.js +4 -3
  25. package/dist/cli/upgrade.d.ts +51 -0
  26. package/dist/cli/upgrade.js +351 -0
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +65 -6
  30. package/dist/contracts.js +148 -23
  31. package/dist/dates.d.ts +10 -0
  32. package/dist/dates.js +27 -0
  33. package/dist/db.d.ts +40 -2
  34. package/dist/db.js +363 -66
  35. package/dist/digest.d.ts +6 -4
  36. package/dist/digest.js +28 -17
  37. package/dist/document.d.ts +135 -88
  38. package/dist/document.js +665 -379
  39. package/dist/export-okf.d.ts +13 -0
  40. package/dist/export-okf.js +50 -20
  41. package/dist/git.js +165 -48
  42. package/dist/harness.d.ts +58 -0
  43. package/dist/harness.js +50 -0
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +301 -826
  46. package/dist/knowledge-index.d.ts +23 -1
  47. package/dist/knowledge-index.js +62 -6
  48. package/dist/merge.d.ts +1 -1
  49. package/dist/merge.js +98 -64
  50. package/dist/near-match.d.ts +9 -0
  51. package/dist/near-match.js +34 -0
  52. package/dist/schema.js +4 -4
  53. package/dist/stale.d.ts +41 -0
  54. package/dist/stale.js +43 -0
  55. package/dist/stopwords.d.ts +6 -0
  56. package/dist/stopwords.js +25 -0
  57. package/dist/surfaces/items.d.ts +366 -0
  58. package/dist/surfaces/items.js +523 -0
  59. package/dist/surfaces/surfaces.d.ts +32 -0
  60. package/dist/surfaces/surfaces.js +230 -0
  61. package/dist/templates.d.ts +6 -0
  62. package/dist/templates.js +27 -27
  63. package/dist/types.d.ts +78 -22
  64. package/dist/validate.d.ts +7 -0
  65. package/dist/validate.js +109 -0
  66. package/dist/vault.d.ts +18 -0
  67. package/dist/vault.js +51 -5
  68. package/package.json +4 -3
  69. package/skill/hafez/SKILL.md +68 -0
  70. package/skill/hafez-save/SKILL.md +29 -0
  71. package/dist/cli/help-agent.d.ts +0 -1
  72. package/dist/cli/help-agent.js +0 -186
  73. package/dist/query.d.ts +0 -24
  74. package/dist/query.js +0 -169
  75. package/skill/SKILL.md +0 -196
package/dist/document.js CHANGED
@@ -1,140 +1,74 @@
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. 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';
7
- 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;
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 { createHash } from 'crypto';
13
+ import { HafezError } from './types.js';
14
+ import { ALL_CONTRACTS, SUBTYPE_SECTIONS, SESSION_SECTIONS, getContract } from './contracts.js';
15
+ import { localDay } from './dates.js';
16
+ // One checkbox line of Next Actions, tested line by line. `m` so a CRLF
17
+ // line's trailing \r is not taken as action text.
18
+ const OPEN_ACTION_RE = /^- \[ \] (.+)$/m;
19
+ const ACTION_RE = /^- \[([ xX])\] (.+)$/m;
20
+ // --- Next-action added-date stamp ---
21
+ // Adding an action stamps its line `(added YYYY-MM-DD)` with the local day
22
+ // (Apply does the stamping — every add path goes through it). The stamp is
23
+ // file format only: every reader of action text — the index, query, matching,
24
+ // export, merge identity — works on the unstamped text.
25
+ const ACTION_STAMP_RE = /\s*\(added (\d{4}-\d{2}-\d{2})\)\s*$/;
26
+ /** The action text without its trailing added-date stamp, if any. */
27
+ export function stripActionStamp(text) {
28
+ return text.replace(ACTION_STAMP_RE, '');
38
29
  }
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 };
30
+ /** The day an action's stamp names; null when the line is undated. */
31
+ export function actionAdded(text) {
32
+ return ACTION_STAMP_RE.exec(text)?.[1] ?? null;
67
33
  }
68
- function escapeRegex(s) {
69
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
34
+ /** Stamp an action with its added date; text that already ends in a stamp is
35
+ * returned as is, so a carried-over line is never double-stamped. */
36
+ export function stampAction(text, day) {
37
+ return ACTION_STAMP_RE.test(text) ? text : `${text.trimEnd()} (added ${day})`;
70
38
  }
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 };
39
+ // --- Next-action Handles ---
40
+ // `read` renders a Handle in front of each open action so an agent can tick
41
+ // or drop a line without quoting its text. Derived at read time, never stored.
42
+ const HANDLE_MIN_LENGTH = 4;
43
+ /** A query that may name a line by Handle: lowercase hex, at least the minimum. */
44
+ const HANDLE_QUERY_RE = new RegExp(`^[0-9a-f]{${HANDLE_MIN_LENGTH},}$`);
45
+ /** The full hashes Handles are cut from, one per open action line, in order:
46
+ * bare hex of the SHA-256 of the normalised, unstamped text. A repeated text
47
+ * hashes with its occurrence number, so identical lines get distinct hashes. */
48
+ function actionHashes(lines) {
49
+ const seen = new Map();
50
+ return lines.map(line => {
51
+ const text = normalizeForMatch(stripActionStamp(line));
52
+ const n = seen.get(text) ?? 0;
53
+ seen.set(text, n + 1);
54
+ return createHash('sha256').update(n === 0 ? text : `${text}\0${n}`).digest('hex');
55
+ });
96
56
  }
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`;
57
+ /** The Handles of a list of open action lines, in order: each line's hash cut
58
+ * to the shortest prefix (at least four) that no other line's hash shares. */
59
+ function actionHandles(lines) {
60
+ const hashes = actionHashes(lines);
61
+ return hashes.map((hash, i) => {
62
+ let length = HANDLE_MIN_LENGTH;
63
+ hashes.forEach((other, j) => {
64
+ // Capped at the full hash: two identical hashes share every prefix.
65
+ while (j !== i && length < hash.length && other.startsWith(hash.slice(0, length)))
66
+ length++;
67
+ });
68
+ return hash.slice(0, length);
69
+ });
114
70
  }
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 ---
71
+ // --- Set-independent helpers ---
138
72
  const HEADING_RE = /^### (\d{4}-\d{2}-\d{2}) (?:—|--) (.+?)(?:\s+\[(\w+)\])?$/;
139
73
  export function parseSessionLogHeading(line) {
140
74
  const match = line.match(HEADING_RE);
@@ -147,120 +81,36 @@ export function parseSessionLogHeading(line) {
147
81
  };
148
82
  }
149
83
  export function formatSessionLogEntry(entry) {
150
- const today = new Date().toISOString().slice(0, 10);
84
+ const today = localDay();
151
85
  const lines = [`### ${today} — ${entry.agent} [${entry.type}]`, `Summary: ${entry.summary}`];
152
86
  if (entry.body)
153
87
  lines.push(entry.body);
154
88
  return lines.join('\n');
155
89
  }
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
90
  /**
210
- * Remove a section (heading + content) from body, cleaning up extra whitespace.
91
+ * Split a Session Log section's content into raw entry blocks, each beginning
92
+ * with a parseable entry heading. Non-entry text before the first heading is
93
+ * not an entry and is dropped. Document owns entry block splitting — merge's
94
+ * dedup/sort policy stays in merge.ts.
211
95
  */
212
- function removeSection(body, heading) {
213
- const section = findSection(body, heading);
214
- if (!section)
215
- return body;
216
- const before = body.slice(0, section.start);
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;
96
+ export function splitLogEntries(content) {
97
+ return content
98
+ .split(/\n(?=### \d{4}-\d{2}-\d{2})/)
99
+ .map(b => b.trim())
100
+ .filter(b => b && parseSessionLogHeading(b.split('\n')[0]) !== null);
226
101
  }
227
- /**
228
- * Insert a new section before a target heading, or at EOF.
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;
102
+ export function firstNonEmptyLine(s) {
103
+ return s?.split('\n').find(l => l.trim())?.trim() ?? '';
247
104
  }
248
- /**
249
- * Insert a new section after a target heading (and its content), or fall back to inserting before fallbacks.
250
- */
251
- function insertSectionAfter(body, heading, content, after, beforeFallbacks) {
252
- const afterSection = findSection(body, after);
253
- if (afterSection) {
254
- const insertPoint = afterSection.end;
255
- const textBefore = body.slice(0, insertPoint).replace(/\n+$/, '');
256
- const textAfter = body.slice(insertPoint).replace(/^\n+/, '');
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);
105
+ /** The one wiki-link syntax the vault recognizes: [[slug]] or
106
+ * [[slug|display text]] — slug in group 1, display text in group 2.
107
+ * Padding inside the brackets is Obsidian-idiomatic ([[ slug ]], [[slug | Label]])
108
+ * and is absorbed by the pattern, so both captures come out already trimmed —
109
+ * every consumer (Related coverage, mention harvesting, export) sees the bare slug.
110
+ * Non-global — wrap with `new RegExp(WIKI_LINK_RE.source, 'g')` for iteration. */
111
+ export const WIKI_LINK_RE = /\[\[\s*([^\]|]+?)\s*(?:\|\s*([^\]]+?)\s*)?\]\]/;
112
+ function escapeRegex(s) {
113
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
264
114
  }
265
115
  /**
266
116
  * Normalize text for matching: trim whitespace, replace smart quotes with
@@ -274,113 +124,594 @@ function normalizeForMatch(s) {
274
124
  .replace(/[“”]/g, '"')
275
125
  .replace(/—/g, '-');
276
126
  }
277
- /**
278
- * Match an action by case-insensitive substring against unchecked items.
279
- * Throws on 0 or >1 matches.
280
- */
281
- function matchAction(body, query) {
282
- const section = findSection(body, 'Next Actions');
283
- if (!section)
284
- throw new Error(`No matching action for "${query}"`);
285
- const normalizedQuery = normalizeForMatch(query).toLowerCase();
286
- const matches = [];
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
- }
127
+ const ENTITY_CONTRACTS = ALL_CONTRACTS.filter(c => c.kind === 'item');
128
+ function sectionNamesFor(ctx) {
129
+ if (ctx.kind === 'session')
130
+ return [...SESSION_SECTIONS];
131
+ if (ctx.kind === 'knowledge') {
132
+ const subtypeTable = SUBTYPE_SECTIONS;
133
+ const names = [...getContract('knowledge').defaultSections];
134
+ for (const s of Object.keys(subtypeTable))
135
+ names.push(...subtypeTable[s]);
136
+ return names;
295
137
  }
296
- if (matches.length === 0) {
297
- throw new Error(`No matching action for "${query}". Actions: ${JSON.stringify(allActions)}`);
138
+ return ENTITY_CONTRACTS.flatMap(c => [...c.defaultSections, ...c.optionalSections]);
139
+ }
140
+ /** The structural set a context binds — exposed for introspection and the
141
+ * contract-coverage tests; runtime consumers go through documentFor. */
142
+ export function structuralSectionsFor(ctx) {
143
+ return new Set([...sectionNamesFor(ctx), 'Related']);
144
+ }
145
+ const docCache = new Map();
146
+ /** The Document operations bound to one file's structural section set. */
147
+ export function documentFor(ctx) {
148
+ const key = ctx.kind;
149
+ let doc = docCache.get(key);
150
+ if (!doc) {
151
+ doc = makeDoc(new Set(structuralSectionsFor(ctx)));
152
+ docCache.set(key, doc);
298
153
  }
299
- if (matches.length > 1)
300
- throw new Error(`Ambiguous match for "${query}": ${matches.join(', ')}`);
301
- return matches[0];
154
+ return doc;
302
155
  }
303
- // --- Brief functions ---
304
- export function getBrief(body) {
305
- const section = findSection(body, 'Brief');
306
- if (!section)
307
- return null;
308
- return section.content || null;
156
+ /** Bind from a parsed vault file — kind from Layout. */
157
+ export function documentForFile(parsed) {
158
+ return documentFor({ kind: parsed.kind });
309
159
  }
310
- export function setBrief(body, content) {
311
- const existing = findSection(body, 'Brief');
312
- if (existing) {
313
- // Replace existing brief content
314
- const updated = removeSection(body, 'Brief');
315
- return setBrief(updated, content);
316
- }
317
- // Insert before: Next Actions > Current State > Session Log > EOF
318
- return insertSectionBefore(body, 'Brief', content, ['Next Actions', 'Current State', 'Session Log']);
160
+ // --- The bound implementation ---
161
+ function makeDoc(structuralSections) {
162
+ /**
163
+ * Find the next structural `## Heading` boundary after a given position.
164
+ * Skips `## ` headings that are not known structural sections (content headings).
165
+ * Returns the index of the `\n` before the `## `, or -1 if no structural heading found.
166
+ */
167
+ function findNextStructuralHeading(body, from) {
168
+ const re = /\n## ([^\n]+)/g;
169
+ re.lastIndex = from;
170
+ let match;
171
+ while ((match = re.exec(body)) !== null) {
172
+ const headingName = match[1].trim();
173
+ if (structuralSections.has(headingName)) {
174
+ return match.index;
175
+ }
176
+ }
177
+ return -1;
178
+ }
179
+ /**
180
+ * Find a ## Heading section and its content.
181
+ * Returns { start, headingEnd, end, content } or null if not found.
182
+ * Matches heading exactly (e.g. "## Brief" won't match "## Briefing").
183
+ * Trailing whitespace on the heading line is tolerated — the splitter's
184
+ * boundary tokenizer trims headings, so anything it treats as a boundary
185
+ * must also be findable here (a hand-edited `## Session Log ` otherwise
186
+ * gets a duplicate section and archival never fires).
187
+ * Only treats known structural headings as section boundaries -- content
188
+ * inside a section may contain arbitrary `## ` headings without corruption.
189
+ */
190
+ function findSection(body, heading) {
191
+ const re = new RegExp(`(^|\\n)(## ${escapeRegex(heading)}[ \\t]*)(?:\\n|$)`);
192
+ const match = re.exec(body);
193
+ if (!match)
194
+ return null;
195
+ const start = match.index + match[1].length;
196
+ const headingEnd = start + match[2].length;
197
+ const afterHeading = headingEnd + 1;
198
+ // Find the next structural ## heading or EOF. Scan from the heading's own
199
+ // trailing newline (afterHeading - 1), not past it — otherwise a structural
200
+ // heading on the immediately following line (no blank line between, as in
201
+ // hand-edited files) is invisible and mutators swallow the next section.
202
+ const nextHeading = findNextStructuralHeading(body, Math.max(0, afterHeading - 1));
203
+ const end = nextHeading === -1 ? body.length : nextHeading;
204
+ const rawContent = body.slice(afterHeading, end);
205
+ const content = rawContent.replace(/^\n+/, '').replace(/\n+$/, '');
206
+ return { start, headingEnd, end, content };
207
+ }
208
+ /**
209
+ * Split a body into preamble + structural sections. Non-structural `## `
210
+ * headings stay inside the enclosing section's content — sections are
211
+ * atomic units of authored content, never split at user headings.
212
+ * Headings and content come back trimmed; reassemble with
213
+ * `## ${heading}\n\n${content}` joined by blank lines.
214
+ */
215
+ function splitStructural(body) {
216
+ const marks = [];
217
+ const re = /(^|\n)## ([^\n]*)/g;
218
+ let m;
219
+ while ((m = re.exec(body)) !== null) {
220
+ const heading = m[2].trim();
221
+ if (!structuralSections.has(heading))
222
+ continue;
223
+ marks.push({ start: m.index + m[1].length, contentStart: m.index + m[0].length, heading });
224
+ }
225
+ if (marks.length === 0)
226
+ return { preamble: body.trim(), sections: [] };
227
+ const preamble = body.slice(0, marks[0].start).trim();
228
+ const sections = marks.map((mk, i) => ({
229
+ heading: mk.heading,
230
+ content: body.slice(mk.contentStart, i + 1 < marks.length ? marks[i + 1].start : body.length).trim(),
231
+ }));
232
+ return { preamble, sections };
233
+ }
234
+ /**
235
+ * Replace a structural section's content, or create the section: before
236
+ * `insertBefore` when that section exists, at EOF otherwise.
237
+ */
238
+ function setSection(body, heading, content, insertBefore) {
239
+ const existing = findSection(body, heading);
240
+ if (existing) {
241
+ return body.slice(0, existing.start) + `## ${heading}\n\n${content}\n` + body.slice(existing.end);
242
+ }
243
+ if (insertBefore) {
244
+ const target = findSection(body, insertBefore);
245
+ if (target) {
246
+ return body.slice(0, target.start) + `## ${heading}\n\n${content}\n\n` + body.slice(target.start);
247
+ }
248
+ }
249
+ return body + `\n\n## ${heading}\n\n${content}\n`;
250
+ }
251
+ /** Append content at the end of a structural section (creating it at EOF if absent). */
252
+ function appendToSection(body, heading, content) {
253
+ const section = findSection(body, heading);
254
+ if (!section)
255
+ return body + `\n\n## ${heading}\n\n${content}\n`;
256
+ if (section.end === body.length)
257
+ return body + '\n' + content + '\n';
258
+ return body.slice(0, section.end) + '\n' + content + body.slice(section.end);
259
+ }
260
+ /**
261
+ * Rename a structural section's heading in place, keeping its content.
262
+ * No-op when the section is absent. Any trailing whitespace on the old
263
+ * heading line is dropped with it.
264
+ */
265
+ function renameSection(body, from, to) {
266
+ const section = findSection(body, from);
267
+ if (!section)
268
+ return body;
269
+ return body.slice(0, section.start) + `## ${to}` + body.slice(section.headingEnd);
270
+ }
271
+ /**
272
+ * Ensure a structural section exists: no-op when present, otherwise insert
273
+ * an empty section after `opts.after` (when that section exists) or at EOF.
274
+ */
275
+ function ensureSection(body, heading, opts = {}) {
276
+ if (findSection(body, heading))
277
+ return body;
278
+ const block = `## ${heading}`;
279
+ if (opts.after) {
280
+ const anchor = findSection(body, opts.after);
281
+ if (anchor) {
282
+ const before = body.slice(0, anchor.end).replace(/\n+$/, '');
283
+ const after = body.slice(anchor.end).replace(/^\n+/, '');
284
+ return after ? `${before}\n\n${block}\n\n${after}` : `${before}\n\n${block}\n`;
285
+ }
286
+ }
287
+ return body.replace(/\n+$/, '') + `\n\n${block}\n`;
288
+ }
289
+ /** Insert a formatted session-log entry newest-first (creating the section at EOF if absent). */
290
+ function prependSessionLogEntry(body, entry) {
291
+ const section = findSection(body, 'Session Log');
292
+ if (!section)
293
+ return body + `\n\n## Session Log\n\n${entry}\n`;
294
+ const insertPos = section.headingEnd;
295
+ return body.slice(0, insertPos) + '\n\n' + entry + '\n' + body.slice(insertPos);
296
+ }
297
+ /** Body up to the real Session Log section — the `summary` read depth. */
298
+ function bodyBeforeSessionLog(body) {
299
+ const section = findSection(body, 'Session Log');
300
+ return section ? body.slice(0, section.start).trim() : body;
301
+ }
302
+ /** Entries inside the real Session Log section only — entry-shaped lines
303
+ * elsewhere in the body (e.g. quoted in a Brief) do not count. */
304
+ function countSessionLogEntries(body) {
305
+ const section = findSection(body, 'Session Log');
306
+ if (!section)
307
+ return 0;
308
+ return (section.content.match(/^### \d{4}-\d{2}-\d{2} (?:—|--)/gm) || []).length;
309
+ }
310
+ function parseSessionLog(body) {
311
+ const section = findSection(body, 'Session Log');
312
+ if (!section)
313
+ return [];
314
+ const entries = [];
315
+ const lines = section.content.split('\n');
316
+ let current = null;
317
+ let summaryLine = null;
318
+ for (const line of lines) {
319
+ const heading = parseSessionLogHeading(line);
320
+ if (heading) {
321
+ if (current && summaryLine) {
322
+ entries.push({ ...current, summary: summaryLine });
323
+ }
324
+ current = heading;
325
+ summaryLine = null;
326
+ }
327
+ else if (current && !summaryLine && line.startsWith('Summary: ')) {
328
+ summaryLine = line.slice('Summary: '.length);
329
+ }
330
+ }
331
+ if (current && summaryLine) {
332
+ entries.push({ ...current, summary: summaryLine });
333
+ }
334
+ return entries;
335
+ }
336
+ function extractOldestSessionLogEntry(body) {
337
+ const section = findSection(body, 'Session Log');
338
+ if (!section)
339
+ return null;
340
+ // Work on the raw slice between the heading and the section's structural
341
+ // end, so the extracted entry can never swallow a following section
342
+ // (e.g. an auto-appended Related) into the archive.
343
+ const contentStart = section.headingEnd;
344
+ const region = body.slice(contentStart, section.end);
345
+ const entryStarts = [...region.matchAll(/^### \d{4}-\d{2}-\d{2} (?:—|--)/gm)];
346
+ if (entryStarts.length < 2)
347
+ return null;
348
+ // Entries are newest-first in the file, so the oldest is the last match
349
+ const oldestStart = entryStarts[entryStarts.length - 1].index;
350
+ const entry = region.slice(oldestStart).trim();
351
+ const remaining = body.slice(0, contentStart) + region.slice(0, oldestStart) + body.slice(section.end);
352
+ return { entry, remaining };
353
+ }
354
+ /**
355
+ * Remove a section (heading + content) from body, cleaning up extra whitespace.
356
+ */
357
+ function removeSection(body, heading) {
358
+ const section = findSection(body, heading);
359
+ if (!section)
360
+ return body;
361
+ const before = body.slice(0, section.start);
362
+ const after = body.slice(section.end);
363
+ // Clean up: avoid triple+ newlines at the splice point
364
+ const trimmedBefore = before.replace(/\n+$/, '');
365
+ const trimmedAfter = after.replace(/^\n+/, '');
366
+ if (!trimmedBefore)
367
+ return trimmedAfter;
368
+ if (!trimmedAfter)
369
+ return trimmedBefore;
370
+ return trimmedBefore + '\n\n' + trimmedAfter;
371
+ }
372
+ /**
373
+ * Insert a new section before a target heading, or at EOF.
374
+ * `before` is an array of headings to try inserting before (first found wins).
375
+ */
376
+ function insertSectionBefore(body, heading, content, before) {
377
+ const sectionBlock = `## ${heading}\n\n${content}`;
378
+ for (const target of before) {
379
+ const targetSection = findSection(body, target);
380
+ if (targetSection) {
381
+ const insertPoint = targetSection.start;
382
+ const textBefore = body.slice(0, insertPoint).replace(/\n+$/, '');
383
+ const textAfter = body.slice(insertPoint);
384
+ if (!textBefore)
385
+ return sectionBlock + '\n\n' + textAfter;
386
+ return textBefore + '\n\n' + sectionBlock + '\n\n' + textAfter;
387
+ }
388
+ }
389
+ // No target found — append at EOF
390
+ const trimmed = body.replace(/\n+$/, '');
391
+ return trimmed + '\n\n' + sectionBlock;
392
+ }
393
+ /**
394
+ * Insert a new section after a target heading (and its content), or fall back to inserting before fallbacks.
395
+ */
396
+ function insertSectionAfter(body, heading, content, after, beforeFallbacks) {
397
+ const afterSection = findSection(body, after);
398
+ if (afterSection) {
399
+ const insertPoint = afterSection.end;
400
+ const textBefore = body.slice(0, insertPoint).replace(/\n+$/, '');
401
+ const textAfter = body.slice(insertPoint).replace(/^\n+/, '');
402
+ const sectionBlock = `## ${heading}\n\n${content}`;
403
+ if (!textAfter)
404
+ return textBefore + '\n\n' + sectionBlock;
405
+ return textBefore + '\n\n' + sectionBlock + '\n\n' + textAfter;
406
+ }
407
+ // After-target not found — fall back to insertBefore logic
408
+ return insertSectionBefore(body, heading, content, beforeFallbacks);
409
+ }
410
+ /** The Next Actions section's raw lines (from the heading's line end to the
411
+ * section end) — the line positions an ActionMatch counts in. */
412
+ function actionSectionLines(body) {
413
+ const section = findSection(body, 'Next Actions');
414
+ return section ? body.slice(section.headingEnd, section.end).split('\n') : [];
415
+ }
416
+ /** A section line as it leaves the file (archive): without a CRLF line's \r. */
417
+ function withoutCR(line) {
418
+ return line.replace(/\r$/, '');
419
+ }
420
+ /** Where the action at `lines[i]` ends: past every line it carries (sub-
421
+ * bullets, notes — anything up to the next checkbox line), trailing blank
422
+ * lines left out. The item rule merge.ts parses by; whatever moves the
423
+ * action moves these with it. */
424
+ function actionEnd(lines, i) {
425
+ let end = i + 1;
426
+ for (let j = i + 1; j < lines.length && !ACTION_RE.test(lines[j]); j++) {
427
+ if (lines[j].trim())
428
+ end = j + 1;
429
+ }
430
+ return end;
431
+ }
432
+ /** The action at `lines[i]` and the lines it carries, as they leave the file. */
433
+ function actionBlock(lines, i) {
434
+ return lines.slice(i, actionEnd(lines, i)).map(withoutCR).join('\n');
435
+ }
436
+ /** Every open line of Next Actions: its position and raw text, stamp included. */
437
+ function openActions(body) {
438
+ return actionSectionLines(body).flatMap((l, line) => {
439
+ const m = OPEN_ACTION_RE.exec(l);
440
+ return m ? [{ line, raw: m[1] }] : [];
441
+ });
442
+ }
443
+ /**
444
+ * Find the open action a query names. Four or more lowercase hex that
445
+ * prefix exactly one open line's hash name that line, so a Handle from an
446
+ * earlier read still works after a sibling changed its rendered length;
447
+ * prefixing two or more is ambiguous. Anything else is text: a
448
+ * case-insensitive substring of the unstamped text, the query's own stamp
449
+ * (copied from a read) ignored. Throws on 0 or >1 matches; error text
450
+ * lists the unstamped actions.
451
+ */
452
+ function matchAction(body, query) {
453
+ if (!findSection(body, 'Next Actions'))
454
+ throw new HafezError('VALIDATION_FAILED', `No matching action for "${query}"`);
455
+ const open = openActions(body);
456
+ const toMatch = (a) => ({ line: a.line, text: stripActionStamp(a.raw) });
457
+ const ambiguous = (matches) => new HafezError('VALIDATION_FAILED', `Ambiguous match for "${query}": ${matches.map(a => stripActionStamp(a.raw)).join(', ')}`);
458
+ if (HANDLE_QUERY_RE.test(query)) {
459
+ const hashes = actionHashes(open.map(a => a.raw));
460
+ const byHandle = open.filter((_, i) => hashes[i].startsWith(query));
461
+ if (byHandle.length === 1)
462
+ return toMatch(byHandle[0]);
463
+ if (byHandle.length > 1)
464
+ throw ambiguous(byHandle);
465
+ }
466
+ // A query that is only a stamp keeps it, so it cannot strip to '' and match every line.
467
+ const normalizedQuery = normalizeForMatch(stripActionStamp(query) || query).toLowerCase();
468
+ const matches = open.filter(a => normalizeForMatch(stripActionStamp(a.raw)).toLowerCase().includes(normalizedQuery));
469
+ if (matches.length === 0) {
470
+ throw new HafezError('VALIDATION_FAILED', `No matching action for "${query}". Actions: ${JSON.stringify(open.map(a => stripActionStamp(a.raw)))}`);
471
+ }
472
+ if (matches.length > 1)
473
+ throw ambiguous(matches);
474
+ return toMatch(matches[0]);
475
+ }
476
+ // --- Brief functions ---
477
+ function getBrief(body) {
478
+ const section = findSection(body, 'Brief');
479
+ if (!section)
480
+ return null;
481
+ return section.content || null;
482
+ }
483
+ function setBrief(body, content) {
484
+ const existing = findSection(body, 'Brief');
485
+ if (existing) {
486
+ // Replace existing brief content
487
+ const updated = removeSection(body, 'Brief');
488
+ return setBrief(updated, content);
489
+ }
490
+ // Insert before: Next Actions > Current State > Session Log > EOF
491
+ return insertSectionBefore(body, 'Brief', content, ['Next Actions', 'Current State', 'Session Log']);
492
+ }
493
+ function removeBrief(body) {
494
+ return removeSection(body, 'Brief');
495
+ }
496
+ // --- Next Actions functions ---
497
+ function getNextActions(body) {
498
+ return openActions(body).map(a => stripActionStamp(a.raw));
499
+ }
500
+ /** Every checkbox line of Next Actions in file order, open lines with their Handle. */
501
+ function getActionItems(body) {
502
+ const handles = actionHandles(openActions(body).map(a => a.raw));
503
+ let open = 0;
504
+ return actionSectionLines(body).flatMap(l => {
505
+ const m = ACTION_RE.exec(l);
506
+ if (!m)
507
+ return [];
508
+ const text = stripActionStamp(m[2]);
509
+ return [m[1] === ' ' ? { handle: handles[open++], text, added: actionAdded(m[2]) } : { text }];
510
+ });
511
+ }
512
+ /** The body with each open Next Actions line rendered `- [ ] <handle> <text>`
513
+ * plus whatever `mark` gives for its added day — what `read` shows. Ticked
514
+ * lines and everything outside the section are left as they are. */
515
+ function renderActionHandles(body, mark = () => '') {
516
+ const open = openActions(body);
517
+ if (open.length === 0)
518
+ return body;
519
+ const handles = actionHandles(open.map(a => a.raw));
520
+ return editActionLines(body, lines => open.forEach((a, i) => { lines[a.line] = `- [ ] ${handles[i]} ${a.raw}${mark(actionAdded(a.raw))}`; }));
521
+ }
522
+ function addNextAction(body, action) {
523
+ const section = findSection(body, 'Next Actions');
524
+ if (section) {
525
+ // Add the new item at the end of unchecked items (before any checked items)
526
+ const lines = section.content.split('\n');
527
+ const lastUncheckedIdx = lines.reduce((last, line, i) => line.startsWith('- [ ] ') ? i : last, -1);
528
+ const insertIdx = lastUncheckedIdx === -1 ? 0 : actionEnd(lines, lastUncheckedIdx);
529
+ lines.splice(insertIdx, 0, `- [ ] ${action}`);
530
+ const newContent = lines.join('\n');
531
+ const before = body.slice(0, section.start).replace(/\n+$/, '');
532
+ const after = body.slice(section.end).replace(/^\n+/, '');
533
+ const sectionBlock = `## Next Actions\n\n${newContent}`;
534
+ const parts = [before, sectionBlock, after].filter(Boolean);
535
+ return parts.join('\n\n');
536
+ }
537
+ // No section — insert after Brief if present, otherwise before Session Log
538
+ return insertSectionAfter(body, 'Next Actions', `- [ ] ${action}`, 'Brief', ['Session Log']);
539
+ }
540
+ /**
541
+ * Edit the Next Actions section's raw lines, and nothing outside it. An
542
+ * identical checkbox line quoted elsewhere in the body (a Brief restating
543
+ * a blocker) is authored content, not an action. Works on whole lines, so
544
+ * action text is never interpreted as a replacement pattern ($&, $', $1
545
+ * survive byte-identical).
546
+ */
547
+ function editActionLines(body, edit) {
548
+ const section = findSection(body, 'Next Actions');
549
+ if (!section)
550
+ return body;
551
+ const lines = body.slice(section.headingEnd, section.end).split('\n');
552
+ edit(lines);
553
+ return body.slice(0, section.headingEnd) + lines.join('\n') + body.slice(section.end);
554
+ }
555
+ // complete / remove edit one line and nothing else. They do not collapse
556
+ // an emptied section — that is pruneNextActions, run once by the caller
557
+ // after every edit of the op, so a tick that empties the open list and an
558
+ // add in the same op never remove-then-recreate the section (which lost
559
+ // the checked lines and re-homed the section below Current State).
560
+ // Each takes a query, or a match resolved earlier against this same body.
561
+ // `matched` is reported unstamped; the line edited keeps its stamp.
562
+ function completeNextAction(body, action) {
563
+ const match = typeof action === 'string' ? matchAction(body, action) : action;
564
+ return { matched: match.text, body: editActionLines(body, lines => { lines[match.line] = lines[match.line].replace('- [ ] ', '- [x] '); }) };
565
+ }
566
+ // `line` is the removed line as the file held it, stamp included, with the
567
+ // lines it carries — what the archive log keeps.
568
+ function removeNextAction(body, action) {
569
+ const match = typeof action === 'string' ? matchAction(body, action) : action;
570
+ const section = actionSectionLines(body);
571
+ const end = actionEnd(section, match.line);
572
+ return { matched: match.text, line: actionBlock(section, match.line), body: editActionLines(body, lines => { lines.splice(match.line, end - match.line); }) };
573
+ }
574
+ /** Every checkbox line of Next Actions, open and ticked, as the file holds
575
+ * them — each with the lines it carries. */
576
+ function actionLines(body) {
577
+ const section = actionSectionLines(body);
578
+ return section.flatMap((l, i) => ACTION_RE.test(l) ? [actionBlock(section, i)] : []);
579
+ }
580
+ /** Take the oldest ticked lines out of Next Actions until at most `keep`
581
+ * remain. Lines are added in order and ticked in place, so file order is
582
+ * age order. `lines` are the taken lines as the file held them, oldest
583
+ * first, each with the lines it carries. */
584
+ function extractOldestTickedActions(body, keep) {
585
+ const section = actionSectionLines(body);
586
+ const ticked = section.flatMap((l, i) => ACTION_RE.exec(l)?.[1].trim() ? [i] : []);
587
+ const oldest = ticked.slice(0, Math.max(0, ticked.length - keep));
588
+ if (oldest.length === 0)
589
+ return { lines: [], body };
590
+ const taken = new Set(oldest.flatMap(i => Array.from({ length: actionEnd(section, i) - i }, (_, k) => i + k)));
591
+ return {
592
+ lines: oldest.map(i => actionBlock(section, i)),
593
+ body: editActionLines(body, lines => { lines.splice(0, lines.length, ...lines.filter((_, i) => !taken.has(i))); }),
594
+ };
595
+ }
596
+ /** The body with the stamp stripped from every checkbox line (open and
597
+ * ticked) of Next Actions — for surfaces that carry action text but not the
598
+ * vault's file format (export). Nothing outside the section is touched. */
599
+ function stripNextActionStamps(body) {
600
+ const section = findSection(body, 'Next Actions');
601
+ if (!section)
602
+ return body;
603
+ const content = body.slice(section.headingEnd, section.end)
604
+ .split('\n')
605
+ .map(line => /^- \[[ xX]\] /.test(line) ? stripActionStamp(line) : line)
606
+ .join('\n');
607
+ return body.slice(0, section.headingEnd) + content + body.slice(section.end);
608
+ }
609
+ /** Remove the section once no open item remains (appear-on-write, removed when emptied). */
610
+ function pruneNextActions(body) {
611
+ if (!findSection(body, 'Next Actions'))
612
+ return body;
613
+ return getNextActions(body).length === 0 ? clearNextActions(body) : body;
614
+ }
615
+ function clearNextActions(body) {
616
+ return removeSection(body, 'Next Actions');
617
+ }
618
+ // --- Related section sync ---
619
+ function syncRelatedSection(body, frontmatter) {
620
+ const targets = [];
621
+ if (frontmatter.parent)
622
+ targets.push({ slug: frontmatter.parent, label: 'parent' });
623
+ for (const slug of frontmatter.related ?? [])
624
+ targets.push({ slug });
625
+ if (targets.length === 0) {
626
+ // No frontmatter links — remove Related section if present
627
+ const existing = findSection(body, 'Related');
628
+ if (!existing)
629
+ return body;
630
+ const before = body.slice(0, existing.start).replace(/\n+$/, '');
631
+ const after = body.slice(existing.end).replace(/^\n+/, '');
632
+ return [before, after].filter(Boolean).join('\n\n');
633
+ }
634
+ // Strip existing Related section before checking coverage
635
+ let authoredBody = body;
636
+ const existingRelated = findSection(body, 'Related');
637
+ if (existingRelated) {
638
+ const before = body.slice(0, existingRelated.start);
639
+ const after = body.slice(existingRelated.end);
640
+ authoredBody = (before + after).replace(/\n{3,}/g, '\n\n');
641
+ }
642
+ // Find which targets are NOT mentioned in authored body
643
+ const mentionedSlugs = new Set();
644
+ let match;
645
+ const re = new RegExp(WIKI_LINK_RE.source, 'g');
646
+ while ((match = re.exec(authoredBody)) !== null) {
647
+ mentionedSlugs.add(match[1]);
648
+ }
649
+ const uncovered = targets.filter(t => !mentionedSlugs.has(t.slug));
650
+ if (uncovered.length === 0) {
651
+ // All covered inline — return body without Related section
652
+ return authoredBody.replace(/\n+$/, '');
653
+ }
654
+ // Build Related section
655
+ const lines = uncovered.map(t => t.label ? `- [[${t.slug}]] (${t.label})` : `- [[${t.slug}]]`);
656
+ const section = `## Related\n\n${lines.join('\n')}`;
657
+ // Append at end
658
+ return authoredBody.replace(/\n+$/, '') + '\n\n' + section;
659
+ }
660
+ return {
661
+ findNextStructuralHeading,
662
+ findSection,
663
+ splitStructural,
664
+ setSection,
665
+ appendToSection,
666
+ renameSection,
667
+ ensureSection,
668
+ prependSessionLogEntry,
669
+ bodyBeforeSessionLog,
670
+ countSessionLogEntries,
671
+ parseSessionLog,
672
+ extractOldestSessionLogEntry,
673
+ getBrief,
674
+ setBrief,
675
+ removeBrief,
676
+ getNextActions,
677
+ getActionItems,
678
+ renderActionHandles,
679
+ addNextAction,
680
+ matchNextAction: matchAction,
681
+ completeNextAction,
682
+ removeNextAction,
683
+ actionLines,
684
+ extractOldestTickedActions,
685
+ pruneNextActions,
686
+ clearNextActions,
687
+ stripNextActionStamps,
688
+ syncRelatedSection,
689
+ };
319
690
  }
320
- export function removeBrief(body) {
321
- return removeSection(body, 'Brief');
691
+ // --- Entity-bound convenience exports (public API) ---
692
+ // getBrief / getNextActions / parseSessionLog are read-only accessors on
693
+ // entity bodies; they bind the entity Document (Brief, Next Actions, and
694
+ // Session Log only exist on entity kinds).
695
+ const ENTITY_UNION_DOC = documentFor({ kind: 'entity' });
696
+ export function getBrief(body) {
697
+ return ENTITY_UNION_DOC.getBrief(body);
322
698
  }
323
- // --- Next Actions functions ---
324
699
  export function getNextActions(body) {
325
- const section = findSection(body, 'Next Actions');
326
- if (!section)
327
- return [];
328
- const actions = [];
329
- let m;
330
- const re = new RegExp(UNCHECKED_RE.source, 'gm');
331
- while ((m = re.exec(section.content)) !== null) {
332
- actions.push(m[1]);
333
- }
334
- return actions;
700
+ return ENTITY_UNION_DOC.getNextActions(body);
335
701
  }
336
- export function addNextAction(body, action) {
337
- const section = findSection(body, 'Next Actions');
338
- if (section) {
339
- // Add the new item at the end of unchecked items (before any checked items)
340
- const lines = section.content.split('\n');
341
- const lastUncheckedIdx = lines.reduce((last, line, i) => line.startsWith('- [ ] ') ? i : last, -1);
342
- const insertIdx = lastUncheckedIdx === -1 ? 0 : lastUncheckedIdx + 1;
343
- lines.splice(insertIdx, 0, `- [ ] ${action}`);
344
- const newContent = lines.join('\n');
345
- const before = body.slice(0, section.start).replace(/\n+$/, '');
346
- const after = body.slice(section.end).replace(/^\n+/, '');
347
- const sectionBlock = `## Next Actions\n\n${newContent}`;
348
- const parts = [before, sectionBlock, after].filter(Boolean);
349
- return parts.join('\n\n');
350
- }
351
- // No section — insert after Brief if present, otherwise before Session Log
352
- return insertSectionAfter(body, 'Next Actions', `- [ ] ${action}`, 'Brief', ['Session Log']);
702
+ export function getActionItems(body) {
703
+ return ENTITY_UNION_DOC.getActionItems(body);
353
704
  }
354
- export function completeNextAction(body, query) {
355
- const matched = matchAction(body, query);
356
- // Replace the unchecked item with checked
357
- const updatedBody = body.replace(`- [ ] ${matched}`, `- [x] ${matched}`);
358
- // Check if any unchecked items remain
359
- const remaining = getNextActions(updatedBody);
360
- if (remaining.length === 0) {
361
- return { matched, body: clearNextActions(updatedBody) };
362
- }
363
- return { matched, body: updatedBody };
705
+ export function renderActionHandles(body, mark) {
706
+ return ENTITY_UNION_DOC.renderActionHandles(body, mark);
364
707
  }
365
- export function removeNextAction(body, query) {
366
- const matched = matchAction(body, query);
367
- // Remove the line
368
- const line = `- [ ] ${matched}`;
369
- const updatedBody = body.split('\n').filter(l => l !== line).join('\n');
370
- // Check if any unchecked items remain
371
- const remaining = getNextActions(updatedBody);
372
- if (remaining.length === 0) {
373
- return { matched, body: clearNextActions(updatedBody) };
374
- }
375
- return { matched, body: updatedBody };
708
+ export function parseSessionLog(body) {
709
+ return ENTITY_UNION_DOC.parseSessionLog(body);
376
710
  }
377
- export function clearNextActions(body) {
378
- return removeSection(body, 'Next Actions');
711
+ export function countSessionLogEntries(body) {
712
+ return ENTITY_UNION_DOC.countSessionLogEntries(body);
379
713
  }
380
714
  // --- Summary derivation ---
381
- export function firstNonEmptyLine(s) {
382
- return s?.split('\n').find(l => l.trim())?.trim() ?? '';
383
- }
384
715
  /**
385
716
  * One-line summary for a doc: frontmatter description first, then the first
386
717
  * non-empty line of the kind's primary section (entity: Brief; knowledge:
@@ -390,59 +721,14 @@ export function firstNonEmptyLine(s) {
390
721
  export function deriveSummary(kind, fm, body) {
391
722
  if (fm.description)
392
723
  return fm.description;
724
+ const doc = documentFor({ kind });
393
725
  if (kind === 'entity')
394
- return firstNonEmptyLine(getBrief(body));
726
+ return firstNonEmptyLine(doc.getBrief(body));
395
727
  if (kind === 'knowledge') {
396
- const synthesis = findSection(body, 'Synthesis');
728
+ const synthesis = doc.findSection(body, 'Synthesis');
397
729
  if (synthesis?.content)
398
730
  return firstNonEmptyLine(synthesis.content);
399
- return firstNonEmptyLine(findSection(body, 'Goal')?.content);
731
+ return firstNonEmptyLine(doc.findSection(body, 'Goal')?.content);
400
732
  }
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+$/, '');
442
- }
443
- // Build Related section
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;
733
+ return firstNonEmptyLine(doc.findSection(body, 'Summary')?.content);
448
734
  }