hafez 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  // src/knowledge-index.ts
2
2
  // Generates the vault-root index.md covering entities (grouped by status) and
3
3
  // knowledge (grouped by domain) — local-only, auto-regenerated on every entity/knowledge write.
4
- import { readdirSync, readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
4
+ import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
5
5
  import { join, basename } from 'path';
6
- import { parseFilePath } from './vault.js';
7
- import { deriveSummary } from './sections.js';
6
+ import { parseFilePath, scanVaultDir, kindDir } from './vault.js';
7
+ import { deriveSummary } from './document.js';
8
8
  import { ENTITY_STATUSES } from './contracts.js';
9
9
  function truncate(s) {
10
10
  return s.length > 100 ? s.slice(0, 97) + '...' : s;
@@ -19,9 +19,11 @@ function formatEntry(item) {
19
19
  /** Parse every .md directly in dir (non-recursive — auto-excludes archive/), skipping malformed files. */
20
20
  function scanParsed(dir) {
21
21
  const docs = [];
22
- for (const file of readdirSync(dir).filter(f => f.endsWith('.md'))) {
22
+ for (const file of scanVaultDir(dir)) {
23
23
  try {
24
- const { frontmatter: fm, body } = parseFilePath(join(dir, file));
24
+ const parsed = parseFilePath(file);
25
+ const fm = parsed.frontmatter;
26
+ const body = parsed.body;
25
27
  if (!fm.name)
26
28
  continue;
27
29
  docs.push({ slug: basename(file, '.md'), fm, body });
@@ -55,8 +57,8 @@ export function ensureLocalExclude(vaultPath) {
55
57
  catch { /* best effort — never fail a vault write over ignore hygiene */ }
56
58
  }
57
59
  export function generateVaultIndex(vaultPath) {
58
- const entitiesDir = join(vaultPath, 'entities');
59
- const knowledgeDir = join(vaultPath, 'knowledge');
60
+ const entitiesDir = join(vaultPath, kindDir('entity'));
61
+ const knowledgeDir = join(vaultPath, kindDir('knowledge'));
60
62
  const hasEntities = existsSync(entitiesDir);
61
63
  const hasKnowledge = existsSync(knowledgeDir);
62
64
  if (!hasEntities && !hasKnowledge)
package/dist/merge.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/merge.ts
2
2
  import { parseContent, serializeFile } from './vault.js';
3
- import { parseSessionLogHeading } from './parser.js';
3
+ import { parseSessionLogHeading, splitStructural } from './document.js';
4
+ import { APPEND_ONLY_SECTIONS } from './contracts.js';
4
5
  // Fields that are ISO date strings — latest date wins
5
6
  const DATE_FIELDS = ['last-touched', 'last-reinforced', 'created'];
6
7
  // Fields that are numeric counters — max wins
@@ -51,41 +52,28 @@ function unionArrays(a, b) {
51
52
  const arrB = Array.isArray(b) ? b : b ? [b] : [];
52
53
  return [...new Set([...arrB, ...arrA])];
53
54
  }
54
- function splitSections(body) {
55
- const parts = body.split(/\n(?=## )/g);
56
- const sections = [];
57
- let preamble = '';
58
- for (const part of parts) {
59
- if (part.startsWith('## ')) {
60
- const nlIdx = part.indexOf('\n');
61
- const heading = nlIdx === -1 ? part : part.slice(0, nlIdx);
62
- const content = nlIdx === -1 ? '' : part.slice(nlIdx + 1);
63
- sections.push({ heading: heading.trim(), content: content.trim() });
64
- }
65
- else {
66
- preamble = part.trim();
67
- }
68
- }
69
- return { preamble, sections };
70
- }
55
+ // --- Body merge ---
71
56
  function mergeBody(remoteBody, localBody, localWins) {
72
57
  if (remoteBody === localBody)
73
58
  return localBody;
74
- const remote = splitSections(remoteBody);
75
- const local = splitSections(localBody);
59
+ // Structural split (see document.ts): sections are atomic — a user ##
60
+ // heading inside a Brief or a session-log entry body stays with its
61
+ // section instead of being split out and merged under its own policy.
62
+ const remote = splitStructural(remoteBody);
63
+ const local = splitStructural(localBody);
76
64
  // Build merged sections starting from local (local section order is canonical)
77
65
  const localHeadings = new Set(local.sections.map(s => s.heading));
78
66
  const mergedSections = [];
79
67
  for (const ls of local.sections) {
80
68
  const rs = remote.sections.find(s => s.heading === ls.heading);
81
- if (ls.heading === '## Session Log' && rs) {
69
+ if (ls.heading === 'Session Log' && rs) {
82
70
  mergedSections.push({ heading: ls.heading, content: mergeSessionLogs(rs.content, ls.content) });
83
71
  }
84
- else if (ls.heading === '## Next Actions' && rs) {
72
+ else if (ls.heading === 'Next Actions' && rs) {
85
73
  mergedSections.push({ heading: ls.heading, content: mergeNextActions(rs.content, ls.content) });
86
74
  }
87
- else if ((ls.heading === '## Evidence' || ls.heading === '## Sources') && rs) {
88
- // Append-only sections: union lines from both sides, dedup identical
75
+ else if (APPEND_ONLY_SECTIONS.has(ls.heading) && rs) {
76
+ // Append-only sections (contracts.ts): union lines from both sides, dedup identical
89
77
  mergedSections.push({ heading: ls.heading, content: mergeAppendOnlyLines(rs.content, ls.content) });
90
78
  }
91
79
  else if (rs && !localWins) {
@@ -106,7 +94,7 @@ function mergeBody(remoteBody, localBody, localWins) {
106
94
  const preamble = localWins
107
95
  ? (local.preamble || remote.preamble)
108
96
  : (remote.preamble || local.preamble);
109
- const parts = [preamble, ...mergedSections.map(s => `${s.heading}\n\n${s.content}`)].filter(Boolean);
97
+ const parts = [preamble, ...mergedSections.map(s => `## ${s.heading}\n\n${s.content}`)].filter(Boolean);
110
98
  return parts.join('\n\n');
111
99
  }
112
100
  /** Union merge for append-only free-text sections: local lines first, then
package/dist/query.d.ts CHANGED
@@ -9,7 +9,7 @@ export declare function queryChildren(index: HafezIndex, parentSlug: string): {
9
9
  total: number;
10
10
  };
11
11
  export declare function queryRelatedTo(index: HafezIndex, slug: string, relation?: string): {
12
- items: (QueryResult | KnowledgeQueryResult)[];
12
+ items: UnifiedResult[];
13
13
  total: number;
14
14
  };
15
15
  export declare function queryKnowledge(index: HafezIndex, opts?: KnowledgeQueryOpts): {
package/dist/query.js CHANGED
@@ -89,7 +89,9 @@ export function queryRelatedTo(index, slug, relation) {
89
89
  index.syncIfStale();
90
90
  const { items, total } = index.queryItems({ relatedTo: slug, relation });
91
91
  return {
92
- items: items.map(i => i.kind === 'entity' ? toQueryResult(i.slug, i) : toKnowledgeResult(i.slug, i)),
92
+ items: items.map(i => i.kind === 'entity'
93
+ ? { ...toQueryResult(i.slug, i), kind: 'entity' }
94
+ : { ...toKnowledgeResult(i.slug, i), kind: 'knowledge' }),
93
95
  total,
94
96
  };
95
97
  }
package/dist/types.d.ts CHANGED
@@ -61,7 +61,30 @@ export interface KnowledgeFrontmatter {
61
61
  status?: 'active' | 'done';
62
62
  'last-touched'?: string;
63
63
  }
64
+ export interface SessionFrontmatter {
65
+ name: string;
66
+ created: string;
67
+ 'session-date'?: string;
68
+ related?: string[];
69
+ }
70
+ /** What parseFilePath returns: the kind comes from Layout (the file's
71
+ * directory), so consumers switch on `kind` instead of sniffing keys. */
72
+ export type ParsedVaultFile = {
73
+ kind: 'entity';
74
+ frontmatter: EntityFrontmatter;
75
+ body: string;
76
+ } | {
77
+ kind: 'knowledge';
78
+ frontmatter: KnowledgeFrontmatter;
79
+ body: string;
80
+ } | {
81
+ kind: 'session';
82
+ frontmatter: SessionFrontmatter;
83
+ body: string;
84
+ };
64
85
  export interface ParsedFile<T = EntityFrontmatter | KnowledgeFrontmatter> {
86
+ /** Populated by read(): which side of the vault the slug resolved to. */
87
+ kind?: 'entity' | 'knowledge';
65
88
  frontmatter: T;
66
89
  body: string;
67
90
  }
@@ -281,10 +304,34 @@ export declare class HafezError extends Error {
281
304
  export interface GitConfig {
282
305
  push?: boolean;
283
306
  }
307
+ /**
308
+ * The persistence seam (see CONTEXT.md: Journal) — everything core needs
309
+ * from the history layer behind vault mutations. Git satisfies it in
310
+ * production (src/git.ts); tests may inject an in-memory adapter.
311
+ */
312
+ export interface Journal {
313
+ /** Persist one logical change: stage written+deleted paths (vault-relative)
314
+ * and commit under message. Must clean up its own partial state (e.g.
315
+ * staged files) before rethrowing on a commit-stage failure; a push-stage
316
+ * failure surfaces as GIT_PUSH_FAILED with the commit kept. */
317
+ commit(written: string[], deleted: string[], message: string): Promise<void>;
318
+ /** Bidirectional sync with the remote, if any. */
319
+ sync(): Promise<{
320
+ pulled: boolean;
321
+ pushed: boolean;
322
+ remote: boolean;
323
+ }>;
324
+ /** History-derived changelog for entities/ and knowledge/ since a date. */
325
+ changelog(since: string): Promise<ChangelogEntry[]>;
326
+ /** Newest history timestamp per vault file (modified = any change, added = creation). */
327
+ fileTimes(mode: 'modified' | 'added'): Promise<Map<string, string>>;
328
+ }
284
329
  export interface HafezConfig {
285
330
  vaultPath: string;
286
331
  readOnly?: boolean;
287
332
  git?: GitConfig;
333
+ /** Override the persistence layer. Defaults to the git adapter. */
334
+ persistence?: Journal;
288
335
  onWrite?: (slug: string, operation: string) => void;
289
336
  }
290
337
  export interface Hafez {
@@ -318,7 +365,7 @@ export interface Hafez {
318
365
  total: number;
319
366
  }>;
320
367
  related_to(slug: string, relation?: string): Promise<{
321
- items: (QueryResult | KnowledgeQueryResult)[];
368
+ items: UnifiedResult[];
322
369
  total: number;
323
370
  }>;
324
371
  query_knowledge(opts?: KnowledgeQueryOpts): Promise<{
package/dist/vault.d.ts CHANGED
@@ -1,11 +1,38 @@
1
+ import type { ParsedVaultFile } from './types.js';
1
2
  export declare function slugify(name: string): string;
2
- export declare function resolveFilePath(vaultPath: string, slug: string, kind: 'entity' | 'knowledge'): string;
3
+ export type VaultKind = 'entity' | 'knowledge' | 'session';
4
+ /** Kinds that live in the SQLite read index (sessions are files-and-git only). */
5
+ export declare const INDEXED_KINDS: ReadonlyArray<'entity' | 'knowledge'>;
6
+ /** Vault directory for a kind, relative to the vault root. */
7
+ export declare function kindDir(kind: VaultKind): string;
8
+ /** A vault content file: anything a sync/merge must treat semantically. */
9
+ export declare const VAULT_FILE_RE: RegExp;
10
+ /** True for vault-relative paths whose kind lives in the read index. */
11
+ export declare function isIndexedRelPath(relPath: string): boolean;
12
+ /** History pathspecs covering the indexed dirs (changelog, file times). */
13
+ export declare const INDEXED_DIR_PATHSPECS: ReadonlyArray<string>;
14
+ /** Classify a vault-relative path into an indexed kind + slug; null outside
15
+ * the indexed dirs (sessions, non-vault files). Nested paths keep their
16
+ * historical shape: entities/archive/x-log.md → entity 'archive/x-log'. */
17
+ export declare function classifyIndexedRelPath(relPath: string): {
18
+ kind: 'entity' | 'knowledge';
19
+ slug: string;
20
+ } | null;
21
+ /** All .md files directly in dir — non-recursive (archive/ subdirs are
22
+ * invisible by construction) and dotfile-free: dotfile slugs are rejected by
23
+ * resolveFilePath, so listing them would surface files no operation can reach. */
24
+ export declare function scanVaultDir(dirPath: string): string[];
25
+ /** Where an entity's overflow session-log entries are archived. */
26
+ export declare function archiveLogPath(vaultPath: string, slug: string): string;
27
+ export declare function resolveFilePath(vaultPath: string, slug: string, kind: VaultKind): string;
3
28
  export declare function kindFromPath(filePath: string): 'entity' | 'knowledge';
4
- export declare function slugExists(vaultPath: string, slug: string): boolean;
5
- export declare function parseFilePath(path: string): {
6
- frontmatter: Record<string, any>;
7
- body: string;
8
- };
29
+ /** Full-kind path classification (kindFromPath only distinguishes the
30
+ * indexed kinds — sessions default to knowledge there for compatibility).
31
+ * Session files live directly in sessions/, so only the immediate parent
32
+ * counts — matching anywhere in the path misclassifies every file of a
33
+ * vault that happens to live under a directory named "sessions". */
34
+ export declare function vaultKindFromPath(filePath: string): VaultKind;
35
+ export declare function parseFilePath(path: string): ParsedVaultFile;
9
36
  export declare function parseContent(content: string): {
10
37
  frontmatter: Record<string, any>;
11
38
  body: string;
package/dist/vault.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/vault.ts
2
2
  import matter from 'gray-matter';
3
3
  import yaml from 'js-yaml';
4
- import { readFileSync, existsSync } from 'fs';
4
+ import { readFileSync, readdirSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import { HafezError } from './types.js';
7
7
  // Prevent gray-matter from auto-coercing YAML date strings into JS Date objects
@@ -16,6 +16,48 @@ export function slugify(name) {
16
16
  .replace(/^-|-$/g, '')
17
17
  .replace(/-{2,}/g, '-');
18
18
  }
19
+ const KIND_DIRS = { entity: 'entities', knowledge: 'knowledge', session: 'sessions' };
20
+ /** Kinds that live in the SQLite read index (sessions are files-and-git only). */
21
+ export const INDEXED_KINDS = ['entity', 'knowledge'];
22
+ /** Vault directory for a kind, relative to the vault root. */
23
+ export function kindDir(kind) {
24
+ return KIND_DIRS[kind];
25
+ }
26
+ /** A vault content file: anything a sync/merge must treat semantically. */
27
+ export const VAULT_FILE_RE = new RegExp(`^(?:${Object.values(KIND_DIRS).join('|')})/.+\\.md$`);
28
+ /** True for vault-relative paths whose kind lives in the read index. */
29
+ export function isIndexedRelPath(relPath) {
30
+ return INDEXED_KINDS.some(k => relPath.startsWith(`${KIND_DIRS[k]}/`));
31
+ }
32
+ /** History pathspecs covering the indexed dirs (changelog, file times). */
33
+ export const INDEXED_DIR_PATHSPECS = INDEXED_KINDS.map(k => `${KIND_DIRS[k]}/`);
34
+ /** Classify a vault-relative path into an indexed kind + slug; null outside
35
+ * the indexed dirs (sessions, non-vault files). Nested paths keep their
36
+ * historical shape: entities/archive/x-log.md → entity 'archive/x-log'. */
37
+ export function classifyIndexedRelPath(relPath) {
38
+ for (const kind of INDEXED_KINDS) {
39
+ const prefix = `${KIND_DIRS[kind]}/`;
40
+ if (relPath.startsWith(prefix)) {
41
+ return { kind, slug: relPath.replace(prefix, '').replace('.md', '') };
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+ /** All .md files directly in dir — non-recursive (archive/ subdirs are
47
+ * invisible by construction) and dotfile-free: dotfile slugs are rejected by
48
+ * resolveFilePath, so listing them would surface files no operation can reach. */
49
+ export function scanVaultDir(dirPath) {
50
+ try {
51
+ return readdirSync(dirPath).filter(f => f.endsWith('.md') && !f.startsWith('.')).map(f => join(dirPath, f));
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ }
57
+ /** Where an entity's overflow session-log entries are archived. */
58
+ export function archiveLogPath(vaultPath, slug) {
59
+ return join(vaultPath, KIND_DIRS.entity, 'archive', `${slug}-log.md`);
60
+ }
19
61
  // Slugs are path components: anything with separators (or a leading dot, or a
20
62
  // Windows drive colon) could escape the vault — "../../outside/secret" reads,
21
63
  // writes, and deletes files anywhere on disk. Block only those; spaces and
@@ -25,8 +67,7 @@ export function resolveFilePath(vaultPath, slug, kind) {
25
67
  if (!slug || slug.startsWith('.') || /[/\\:\0]/.test(slug)) {
26
68
  throw new HafezError('VALIDATION_FAILED', `Invalid slug: '${slug}'`);
27
69
  }
28
- const dir = kind === 'entity' ? 'entities' : 'knowledge';
29
- return join(vaultPath, dir, `${slug}.md`);
70
+ return join(vaultPath, KIND_DIRS[kind], `${slug}.md`);
30
71
  }
31
72
  // Separator-agnostic: OS paths use '/' or '\' depending on platform, and a
32
73
  // POSIX-only check silently classifies every entity as knowledge on Windows
@@ -35,10 +76,6 @@ export function resolveFilePath(vaultPath, slug, kind) {
35
76
  export function kindFromPath(filePath) {
36
77
  return /[/\\]entities[/\\]/.test(filePath) ? 'entity' : 'knowledge';
37
78
  }
38
- export function slugExists(vaultPath, slug) {
39
- return existsSync(resolveFilePath(vaultPath, slug, 'entity'))
40
- || existsSync(resolveFilePath(vaultPath, slug, 'knowledge'));
41
- }
42
79
  // Fields that must always be arrays when present (may be scalar in hand-edited YAML)
43
80
  const ARRAY_FIELDS = ['related', 'tags', 'domain'];
44
81
  function normalizeFrontmatter(fm) {
@@ -49,10 +86,30 @@ function normalizeFrontmatter(fm) {
49
86
  }
50
87
  return fm;
51
88
  }
89
+ /** Full-kind path classification (kindFromPath only distinguishes the
90
+ * indexed kinds — sessions default to knowledge there for compatibility).
91
+ * Session files live directly in sessions/, so only the immediate parent
92
+ * counts — matching anywhere in the path misclassifies every file of a
93
+ * vault that happens to live under a directory named "sessions". */
94
+ export function vaultKindFromPath(filePath) {
95
+ if (new RegExp(`[/\\\\]${KIND_DIRS.session}[/\\\\][^/\\\\]+$`).test(filePath))
96
+ return 'session';
97
+ return kindFromPath(filePath);
98
+ }
52
99
  export function parseFilePath(path) {
53
100
  const content = readFileSync(path, 'utf-8');
54
101
  const { data, content: body } = matter(content, MATTER_OPTIONS);
55
- return { frontmatter: normalizeFrontmatter(data), body: body.trim() };
102
+ const fm = normalizeFrontmatter(data);
103
+ const trimmed = body.trim();
104
+ const kind = vaultKindFromPath(path);
105
+ // Types over classes (C7): the frontmatter is normalized once here and
106
+ // asserted to the kind's shape — validation of required fields stays with
107
+ // schema.ts and the index builder, which tolerate malformed files.
108
+ if (kind === 'entity')
109
+ return { kind, frontmatter: fm, body: trimmed };
110
+ if (kind === 'session')
111
+ return { kind, frontmatter: fm, body: trimmed };
112
+ return { kind: 'knowledge', frontmatter: fm, body: trimmed };
56
113
  }
57
114
  export function parseContent(content) {
58
115
  const { data, content: body } = matter(content, MATTER_OPTIONS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hafez",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -17,7 +17,7 @@
17
17
  "LICENSE"
18
18
  ],
19
19
  "scripts": {
20
- "build": "rm -rf dist && tsc",
20
+ "build": "rm -rf dist && tsc && chmod +x dist/cli.js",
21
21
  "install:local": "chmod +x \"$(pwd)/dist/cli.js\" && mkdir -p ~/.local/bin && ln -sf \"$(pwd)/dist/cli.js\" ~/.local/bin/hafez && rm -f ~/.claude/skills/hafez/SKILL.md && mkdir -p ~/.claude/skills/hafez && cp \"$(pwd)/skill/SKILL.md\" ~/.claude/skills/hafez/SKILL.md",
22
22
  "pretest": "npm run build",
23
23
  "test": "vitest run",
package/dist/parser.d.ts DELETED
@@ -1,18 +0,0 @@
1
- import type { SessionLogEntry, SessionLogType } from './types.js';
2
- export declare function parseSessionLogHeading(line: string): {
3
- date: string;
4
- agent: string;
5
- type: SessionLogType;
6
- } | null;
7
- export declare function formatSessionLogEntry(entry: SessionLogEntry): string;
8
- export declare function countSessionLogEntries(body: string): number;
9
- export declare function parseSessionLog(body: string): Array<{
10
- date: string;
11
- agent: string;
12
- type: SessionLogType;
13
- summary: string;
14
- }>;
15
- export declare function extractOldestSessionLogEntry(body: string): {
16
- entry: string;
17
- remaining: string;
18
- } | null;
package/dist/parser.js DELETED
@@ -1,68 +0,0 @@
1
- import { findNextStructuralHeading } from './sections.js';
2
- const HEADING_RE = /^### (\d{4}-\d{2}-\d{2}) (?:—|--) (.+?)(?:\s+\[(\w+)\])?$/;
3
- export function parseSessionLogHeading(line) {
4
- const match = line.match(HEADING_RE);
5
- if (!match)
6
- return null;
7
- return {
8
- date: match[1],
9
- agent: match[2],
10
- type: match[3] || 'progress'
11
- };
12
- }
13
- export function formatSessionLogEntry(entry) {
14
- const today = new Date().toISOString().slice(0, 10);
15
- const lines = [`### ${today} — ${entry.agent} [${entry.type}]`, `Summary: ${entry.summary}`];
16
- if (entry.body)
17
- lines.push(entry.body);
18
- return lines.join('\n');
19
- }
20
- export function countSessionLogEntries(body) {
21
- return (body.match(/^### \d{4}-\d{2}-\d{2} (?:—|--)/gm) || []).length;
22
- }
23
- export function parseSessionLog(body) {
24
- // Inline section extraction to avoid circular import with sections.ts
25
- const idx = body.indexOf('## Session Log');
26
- if (idx === -1)
27
- return [];
28
- const afterPos = idx + '## Session Log'.length;
29
- // Find end: next structural ## heading or end of string
30
- const nextH = findNextStructuralHeading(body, afterPos);
31
- const sectionContent = nextH !== -1 ? body.slice(afterPos, nextH) : body.slice(afterPos);
32
- const entries = [];
33
- const lines = sectionContent.split('\n');
34
- let current = null;
35
- let summaryLine = null;
36
- for (const line of lines) {
37
- const heading = parseSessionLogHeading(line);
38
- if (heading) {
39
- if (current && summaryLine) {
40
- entries.push({ ...current, summary: summaryLine });
41
- }
42
- current = heading;
43
- summaryLine = null;
44
- }
45
- else if (current && !summaryLine && line.startsWith('Summary: ')) {
46
- summaryLine = line.slice('Summary: '.length);
47
- }
48
- }
49
- if (current && summaryLine) {
50
- entries.push({ ...current, summary: summaryLine });
51
- }
52
- return entries;
53
- }
54
- export function extractOldestSessionLogEntry(body) {
55
- const sectionStart = body.indexOf('## Session Log');
56
- if (sectionStart === -1)
57
- return null;
58
- const afterHeader = body.slice(sectionStart + '## Session Log'.length);
59
- const entryStarts = [...afterHeader.matchAll(/^### \d{4}-\d{2}-\d{2} (?:—|--)/gm)];
60
- if (entryStarts.length < 2)
61
- return null;
62
- // Entries are newest-first in the file, so the oldest is the last match
63
- const lastIdx = entryStarts.length - 1;
64
- const oldestStart = entryStarts[lastIdx].index;
65
- const entry = afterHeader.slice(oldestStart).trim();
66
- const remaining = body.slice(0, sectionStart + '## Session Log'.length) + afterHeader.slice(0, oldestStart);
67
- return { entry, remaining };
68
- }
@@ -1,54 +0,0 @@
1
- /**
2
- * Known structural section headings in Hafez entities and knowledge notes.
3
- * Derived from contracts.ts (defaultSections + optionalSections) plus
4
- * auto-generated sections not tracked in contracts (Related, Summary).
5
- * Only these are treated as section boundaries. Any other `## ` heading
6
- * inside section content (e.g. in a brief or session log) is preserved as-is.
7
- */
8
- export declare const STRUCTURAL_SECTIONS: Set<string>;
9
- /**
10
- * Find the next structural `## Heading` boundary after a given position.
11
- * Skips `## ` headings that are not known structural sections (content headings).
12
- * Returns the index of the `\n` before the `## `, or -1 if no structural heading found.
13
- */
14
- export declare function findNextStructuralHeading(body: string, from: number): number;
15
- /**
16
- * Find a ## Heading section and its content.
17
- * Returns { start, end, content } or null if not found.
18
- * Matches heading exactly (e.g. "## Brief" won't match "## Briefing").
19
- * Only treats known structural headings as section boundaries -- content
20
- * inside a section may contain arbitrary `## ` headings without corruption.
21
- */
22
- export declare function findSection(body: string, heading: string): {
23
- start: number;
24
- end: number;
25
- content: string;
26
- } | null;
27
- export declare function getBrief(body: string): string | null;
28
- export declare function setBrief(body: string, content: string): string;
29
- export declare function removeBrief(body: string): string;
30
- export declare function getNextActions(body: string): string[];
31
- export declare function addNextAction(body: string, action: string): string;
32
- export declare function completeNextAction(body: string, query: string): {
33
- matched: string;
34
- body: string;
35
- };
36
- export declare function removeNextAction(body: string, query: string): {
37
- matched: string;
38
- body: string;
39
- };
40
- export declare function clearNextActions(body: string): string;
41
- export declare function firstNonEmptyLine(s: string | null | undefined): string;
42
- /**
43
- * One-line summary for a doc: frontmatter description first, then the first
44
- * non-empty line of the kind's primary section (entity: Brief; knowledge:
45
- * Synthesis, falling back to Goal for plans; session: Summary).
46
- * Shared by the vault index and the OKF export so the two surfaces agree.
47
- */
48
- export declare function deriveSummary(kind: 'entity' | 'knowledge' | 'session', fm: Record<string, any>, body: string): string;
49
- /** Matches [[slug]] or [[slug|display text]], capturing slug in group 1. Non-global — wrap with `new RegExp(WIKI_LINK_RE.source, 'g')` for iteration. */
50
- export declare const WIKI_LINK_RE: RegExp;
51
- export declare function syncRelatedSection(body: string, frontmatter: {
52
- parent?: string;
53
- related?: string[];
54
- }): string;