hafez 1.2.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 +470 -0
- package/dist/batch-ops.js +446 -0
- package/dist/cli/args.d.ts +19 -0
- package/dist/cli/args.js +53 -0
- package/dist/cli/batch-errors.js +6 -3
- package/dist/cli/commands.d.ts +24 -108
- package/dist/cli/commands.js +297 -313
- package/dist/cli/help-agent.d.ts +0 -5
- package/dist/cli/help-agent.js +44 -5
- package/dist/cli/index.d.ts +41 -0
- package/dist/cli/index.js +172 -172
- package/dist/cli/resolve-vault.js +2 -1
- package/dist/cli/schema-introspect.d.ts +1 -1
- package/dist/cli/schema-introspect.js +6 -22
- package/dist/cli/upgrade.d.ts +42 -0
- package/dist/cli/upgrade.js +305 -0
- package/dist/cli.js +0 -0
- package/dist/contracts.d.ts +55 -6
- package/dist/contracts.js +110 -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 +318 -56
- package/dist/digest.js +2 -0
- package/dist/document.d.ts +117 -0
- package/dist/document.js +578 -0
- package/dist/export-okf.js +48 -34
- package/dist/git.d.ts +7 -8
- package/dist/git.js +198 -63
- package/dist/index.d.ts +3 -4
- package/dist/index.js +302 -915
- package/dist/knowledge-index.d.ts +23 -1
- package/dist/knowledge-index.js +71 -13
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +106 -86
- package/dist/schema.js +4 -4
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +86 -12
- package/dist/vault.d.ts +51 -6
- package/dist/vault.js +114 -11
- package/package.json +2 -2
- package/skill/SKILL.md +51 -110
- package/dist/parser.d.ts +0 -18
- package/dist/parser.js +0 -68
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -167
- package/dist/sections.d.ts +0 -54
- package/dist/sections.js +0 -297
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
|
-
}
|
package/dist/query.d.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import type { QueryResult, KnowledgeQueryResult, EntityQueryOpts, KnowledgeQueryOpts, UnifiedResult } from './types.js';
|
|
2
|
-
import type { HafezIndex } from './db.js';
|
|
3
|
-
export declare function queryEntities(index: HafezIndex, opts?: EntityQueryOpts): {
|
|
4
|
-
items: QueryResult[];
|
|
5
|
-
total: number;
|
|
6
|
-
};
|
|
7
|
-
export declare function queryChildren(index: HafezIndex, parentSlug: string): {
|
|
8
|
-
items: QueryResult[];
|
|
9
|
-
total: number;
|
|
10
|
-
};
|
|
11
|
-
export declare function queryRelatedTo(index: HafezIndex, slug: string, relation?: string): {
|
|
12
|
-
items: (QueryResult | KnowledgeQueryResult)[];
|
|
13
|
-
total: number;
|
|
14
|
-
};
|
|
15
|
-
export declare function queryKnowledge(index: HafezIndex, opts?: KnowledgeQueryOpts): {
|
|
16
|
-
items: KnowledgeQueryResult[];
|
|
17
|
-
total: number;
|
|
18
|
-
};
|
|
19
|
-
export declare function queryUnified(index: HafezIndex, opts?: EntityQueryOpts & {
|
|
20
|
-
kind?: 'entity' | 'knowledge' | 'all';
|
|
21
|
-
}): {
|
|
22
|
-
items: UnifiedResult[];
|
|
23
|
-
total: number;
|
|
24
|
-
};
|
package/dist/query.js
DELETED
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import { HafezError } from './types.js';
|
|
2
|
-
function toQueryResult(slug, row) {
|
|
3
|
-
return {
|
|
4
|
-
slug,
|
|
5
|
-
name: row.name,
|
|
6
|
-
type: row.type,
|
|
7
|
-
status: row.status,
|
|
8
|
-
description: row.description ?? null,
|
|
9
|
-
resource: row.resource ?? null,
|
|
10
|
-
domain: row.domain ?? [],
|
|
11
|
-
last_touched: row.last_touched,
|
|
12
|
-
next_action: row.next_action ?? null,
|
|
13
|
-
next_action_count: row.next_action_count ?? 0,
|
|
14
|
-
parent: row.parent ?? null,
|
|
15
|
-
related: row.related ?? [],
|
|
16
|
-
staleness_days: row.staleness_days ?? null,
|
|
17
|
-
tags: row.tags ?? [],
|
|
18
|
-
brief: row.brief ?? null,
|
|
19
|
-
session_log_count: row.session_log_count ?? 0,
|
|
20
|
-
last_session_date: row.last_session_date ?? null,
|
|
21
|
-
last_session_type: row.last_session_type ?? null,
|
|
22
|
-
last_session_summary: row.last_session_summary ?? null,
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
function toKnowledgeResult(slug, row) {
|
|
26
|
-
return {
|
|
27
|
-
slug,
|
|
28
|
-
name: row.name,
|
|
29
|
-
description: row.description ?? null,
|
|
30
|
-
domain: row.domain ?? [],
|
|
31
|
-
confidence: row.confidence ?? 'observation',
|
|
32
|
-
related: row.related ?? [],
|
|
33
|
-
reinforcement_count: row.reinforcement_count ?? 0,
|
|
34
|
-
last_reinforced: row.last_reinforced ?? null,
|
|
35
|
-
last_touched: row.last_touched ?? null,
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
const STATUS_FILTERS = new Set(['active', 'paused', 'done']);
|
|
39
|
-
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
40
|
-
function validateDateOpts(opts) {
|
|
41
|
-
const fields = ['since', 'before', 'createdSince', 'createdBefore'];
|
|
42
|
-
for (const f of fields) {
|
|
43
|
-
if (opts?.[f] !== undefined && !ISO_DATE.test(opts[f])) {
|
|
44
|
-
throw new HafezError('VALIDATION_FAILED', `Invalid date format for ${f}: "${opts[f]}". Must be YYYY-MM-DD.`);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
export function queryEntities(index, opts) {
|
|
49
|
-
index.syncIfStale();
|
|
50
|
-
validateDateOpts(opts);
|
|
51
|
-
const qopts = { kind: 'entity' };
|
|
52
|
-
if (opts?.filter && STATUS_FILTERS.has(opts.filter))
|
|
53
|
-
qopts.status = opts.filter;
|
|
54
|
-
if (opts?.filter === 'stale')
|
|
55
|
-
qopts.filter = 'stale';
|
|
56
|
-
if (opts?.filter === 'capture')
|
|
57
|
-
qopts.filter = 'capture';
|
|
58
|
-
if (opts?.type)
|
|
59
|
-
qopts.type = opts.type;
|
|
60
|
-
if (opts?.parent)
|
|
61
|
-
qopts.parent = opts.parent;
|
|
62
|
-
if (opts?.domain)
|
|
63
|
-
qopts.domain = opts.domain;
|
|
64
|
-
if (opts?.since)
|
|
65
|
-
qopts.since = opts.since;
|
|
66
|
-
if (opts?.before)
|
|
67
|
-
qopts.before = opts.before;
|
|
68
|
-
if (opts?.createdSince)
|
|
69
|
-
qopts.createdSince = opts.createdSince;
|
|
70
|
-
if (opts?.createdBefore)
|
|
71
|
-
qopts.createdBefore = opts.createdBefore;
|
|
72
|
-
if (opts?.tags)
|
|
73
|
-
qopts.tags = opts.tags;
|
|
74
|
-
if (opts?.sort_by)
|
|
75
|
-
qopts.sort_by = opts.sort_by;
|
|
76
|
-
if (opts?.sort_order)
|
|
77
|
-
qopts.sort_order = opts.sort_order;
|
|
78
|
-
if (opts?.limit != null)
|
|
79
|
-
qopts.limit = opts.limit;
|
|
80
|
-
if (opts?.offset != null)
|
|
81
|
-
qopts.offset = opts.offset;
|
|
82
|
-
const { items, total } = index.queryItems(qopts);
|
|
83
|
-
return { items: items.map(i => toQueryResult(i.slug, i)), total };
|
|
84
|
-
}
|
|
85
|
-
export function queryChildren(index, parentSlug) {
|
|
86
|
-
return queryEntities(index, { parent: parentSlug });
|
|
87
|
-
}
|
|
88
|
-
export function queryRelatedTo(index, slug, relation) {
|
|
89
|
-
index.syncIfStale();
|
|
90
|
-
const { items, total } = index.queryItems({ relatedTo: slug, relation });
|
|
91
|
-
return {
|
|
92
|
-
items: items.map(i => i.kind === 'entity' ? toQueryResult(i.slug, i) : toKnowledgeResult(i.slug, i)),
|
|
93
|
-
total,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
export function queryKnowledge(index, opts) {
|
|
97
|
-
index.syncIfStale();
|
|
98
|
-
const qopts = { kind: 'knowledge' };
|
|
99
|
-
if (opts?.domain)
|
|
100
|
-
qopts.domain = opts.domain;
|
|
101
|
-
if (opts?.confidence)
|
|
102
|
-
qopts.confidence = opts.confidence;
|
|
103
|
-
if (opts?.subtype)
|
|
104
|
-
qopts.subtype = opts.subtype;
|
|
105
|
-
if (opts?.tags)
|
|
106
|
-
qopts.tags = opts.tags;
|
|
107
|
-
if (opts?.sort_by)
|
|
108
|
-
qopts.sort_by = opts.sort_by;
|
|
109
|
-
if (opts?.sort_order)
|
|
110
|
-
qopts.sort_order = opts.sort_order;
|
|
111
|
-
if (opts?.limit != null)
|
|
112
|
-
qopts.limit = opts.limit;
|
|
113
|
-
if (opts?.offset != null)
|
|
114
|
-
qopts.offset = opts.offset;
|
|
115
|
-
const { items, total } = index.queryItems(qopts);
|
|
116
|
-
return { items: items.map(i => toKnowledgeResult(i.slug, i)), total };
|
|
117
|
-
}
|
|
118
|
-
export function queryUnified(index, opts) {
|
|
119
|
-
index.syncIfStale();
|
|
120
|
-
validateDateOpts(opts);
|
|
121
|
-
const qopts = {};
|
|
122
|
-
if (opts?.kind && opts.kind !== 'all')
|
|
123
|
-
qopts.kind = opts.kind;
|
|
124
|
-
// When kind is 'all' or unset, no kind filter — both entities and knowledge are returned
|
|
125
|
-
if (opts?.filter && STATUS_FILTERS.has(opts.filter))
|
|
126
|
-
qopts.status = opts.filter;
|
|
127
|
-
if (opts?.filter === 'stale')
|
|
128
|
-
qopts.filter = 'stale';
|
|
129
|
-
if (opts?.filter === 'capture')
|
|
130
|
-
qopts.filter = 'capture';
|
|
131
|
-
if (opts?.type)
|
|
132
|
-
qopts.type = opts.type;
|
|
133
|
-
if (opts?.parent)
|
|
134
|
-
qopts.parent = opts.parent;
|
|
135
|
-
if (opts?.domain)
|
|
136
|
-
qopts.domain = opts.domain;
|
|
137
|
-
if (opts?.confidence)
|
|
138
|
-
qopts.confidence = opts.confidence;
|
|
139
|
-
if (opts?.since)
|
|
140
|
-
qopts.since = opts.since;
|
|
141
|
-
if (opts?.before)
|
|
142
|
-
qopts.before = opts.before;
|
|
143
|
-
if (opts?.createdSince)
|
|
144
|
-
qopts.createdSince = opts.createdSince;
|
|
145
|
-
if (opts?.createdBefore)
|
|
146
|
-
qopts.createdBefore = opts.createdBefore;
|
|
147
|
-
if (opts?.tags)
|
|
148
|
-
qopts.tags = opts.tags;
|
|
149
|
-
if (opts?.sort_by)
|
|
150
|
-
qopts.sort_by = opts.sort_by;
|
|
151
|
-
if (opts?.sort_order)
|
|
152
|
-
qopts.sort_order = opts.sort_order;
|
|
153
|
-
if (opts?.limit !== undefined)
|
|
154
|
-
qopts.limit = opts.limit;
|
|
155
|
-
if (opts?.offset !== undefined)
|
|
156
|
-
qopts.offset = opts.offset;
|
|
157
|
-
const result = index.queryItems(qopts);
|
|
158
|
-
const items = result.items.map(i => {
|
|
159
|
-
if (i.kind === 'entity') {
|
|
160
|
-
return { ...toQueryResult(i.slug, i), kind: 'entity' };
|
|
161
|
-
}
|
|
162
|
-
else {
|
|
163
|
-
return { ...toKnowledgeResult(i.slug, i), kind: 'knowledge' };
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
return { items, total: result.total };
|
|
167
|
-
}
|
package/dist/sections.d.ts
DELETED
|
@@ -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;
|
package/dist/sections.js
DELETED
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
// src/sections.ts
|
|
2
|
-
// Pure string→string functions for Brief and Next Actions section manipulation.
|
|
3
|
-
import { ALL_CONTRACTS, SUBTYPE_SECTIONS } from './contracts.js';
|
|
4
|
-
const UNCHECKED_RE = /^- \[ \] (.+)$/gm;
|
|
5
|
-
/**
|
|
6
|
-
* Known structural section headings in Hafez entities and knowledge notes.
|
|
7
|
-
* Derived from contracts.ts (defaultSections + optionalSections) plus
|
|
8
|
-
* auto-generated sections not tracked in contracts (Related, Summary).
|
|
9
|
-
* Only these are treated as section boundaries. Any other `## ` heading
|
|
10
|
-
* inside section content (e.g. in a brief or session log) is preserved as-is.
|
|
11
|
-
*/
|
|
12
|
-
export const STRUCTURAL_SECTIONS = new Set([
|
|
13
|
-
...ALL_CONTRACTS.flatMap(c => [...c.defaultSections, ...c.optionalSections]),
|
|
14
|
-
...Object.values(SUBTYPE_SECTIONS).flat(),
|
|
15
|
-
'Related', // auto-generated by syncRelatedSection
|
|
16
|
-
'Summary', // used in session files
|
|
17
|
-
]);
|
|
18
|
-
/**
|
|
19
|
-
* Find the next structural `## Heading` boundary after a given position.
|
|
20
|
-
* Skips `## ` headings that are not known structural sections (content headings).
|
|
21
|
-
* Returns the index of the `\n` before the `## `, or -1 if no structural heading found.
|
|
22
|
-
*/
|
|
23
|
-
export function findNextStructuralHeading(body, from) {
|
|
24
|
-
const re = /\n## ([^\n]+)/g;
|
|
25
|
-
re.lastIndex = from;
|
|
26
|
-
let match;
|
|
27
|
-
while ((match = re.exec(body)) !== null) {
|
|
28
|
-
const headingName = match[1].trim();
|
|
29
|
-
if (STRUCTURAL_SECTIONS.has(headingName)) {
|
|
30
|
-
return match.index;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return -1;
|
|
34
|
-
}
|
|
35
|
-
// --- Internal helpers ---
|
|
36
|
-
/**
|
|
37
|
-
* Find a ## Heading section and its content.
|
|
38
|
-
* Returns { start, end, content } or null if not found.
|
|
39
|
-
* Matches heading exactly (e.g. "## Brief" won't match "## Briefing").
|
|
40
|
-
* Only treats known structural headings as section boundaries -- content
|
|
41
|
-
* inside a section may contain arbitrary `## ` headings without corruption.
|
|
42
|
-
*/
|
|
43
|
-
export function findSection(body, heading) {
|
|
44
|
-
const re = new RegExp(`(^|\\n)(## ${escapeRegex(heading)})(?:\\n|$)`);
|
|
45
|
-
const match = re.exec(body);
|
|
46
|
-
if (!match)
|
|
47
|
-
return null;
|
|
48
|
-
const start = match.index + match[1].length;
|
|
49
|
-
const afterHeading = start + match[2].length + 1;
|
|
50
|
-
// Find the next structural ## heading or EOF
|
|
51
|
-
const nextHeading = findNextStructuralHeading(body, afterHeading);
|
|
52
|
-
const end = nextHeading === -1 ? body.length : nextHeading;
|
|
53
|
-
const rawContent = body.slice(afterHeading, end);
|
|
54
|
-
const content = rawContent.replace(/^\n+/, '').replace(/\n+$/, '');
|
|
55
|
-
return { start, end, content };
|
|
56
|
-
}
|
|
57
|
-
function escapeRegex(s) {
|
|
58
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Remove a section (heading + content) from body, cleaning up extra whitespace.
|
|
62
|
-
*/
|
|
63
|
-
function removeSection(body, heading) {
|
|
64
|
-
const section = findSection(body, heading);
|
|
65
|
-
if (!section)
|
|
66
|
-
return body;
|
|
67
|
-
const before = body.slice(0, section.start);
|
|
68
|
-
const after = body.slice(section.end);
|
|
69
|
-
// Clean up: avoid triple+ newlines at the splice point
|
|
70
|
-
const trimmedBefore = before.replace(/\n+$/, '');
|
|
71
|
-
const trimmedAfter = after.replace(/^\n+/, '');
|
|
72
|
-
if (!trimmedBefore)
|
|
73
|
-
return trimmedAfter;
|
|
74
|
-
if (!trimmedAfter)
|
|
75
|
-
return trimmedBefore;
|
|
76
|
-
return trimmedBefore + '\n\n' + trimmedAfter;
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Insert a new section before a target heading, or at EOF.
|
|
80
|
-
* `before` is an array of headings to try inserting before (first found wins).
|
|
81
|
-
*/
|
|
82
|
-
function insertSectionBefore(body, heading, content, before) {
|
|
83
|
-
const sectionBlock = `## ${heading}\n\n${content}`;
|
|
84
|
-
for (const target of before) {
|
|
85
|
-
const targetSection = findSection(body, target);
|
|
86
|
-
if (targetSection) {
|
|
87
|
-
const insertPoint = targetSection.start;
|
|
88
|
-
const textBefore = body.slice(0, insertPoint).replace(/\n+$/, '');
|
|
89
|
-
const textAfter = body.slice(insertPoint);
|
|
90
|
-
if (!textBefore)
|
|
91
|
-
return sectionBlock + '\n\n' + textAfter;
|
|
92
|
-
return textBefore + '\n\n' + sectionBlock + '\n\n' + textAfter;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
// No target found — append at EOF
|
|
96
|
-
const trimmed = body.replace(/\n+$/, '');
|
|
97
|
-
return trimmed + '\n\n' + sectionBlock;
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* Insert a new section after a target heading (and its content), or fall back to inserting before fallbacks.
|
|
101
|
-
*/
|
|
102
|
-
function insertSectionAfter(body, heading, content, after, beforeFallbacks) {
|
|
103
|
-
const afterSection = findSection(body, after);
|
|
104
|
-
if (afterSection) {
|
|
105
|
-
const insertPoint = afterSection.end;
|
|
106
|
-
const textBefore = body.slice(0, insertPoint).replace(/\n+$/, '');
|
|
107
|
-
const textAfter = body.slice(insertPoint).replace(/^\n+/, '');
|
|
108
|
-
const sectionBlock = `## ${heading}\n\n${content}`;
|
|
109
|
-
if (!textAfter)
|
|
110
|
-
return textBefore + '\n\n' + sectionBlock;
|
|
111
|
-
return textBefore + '\n\n' + sectionBlock + '\n\n' + textAfter;
|
|
112
|
-
}
|
|
113
|
-
// After-target not found — fall back to insertBefore logic
|
|
114
|
-
return insertSectionBefore(body, heading, content, beforeFallbacks);
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Normalize text for matching: trim whitespace, replace smart quotes with
|
|
118
|
-
* straight equivalents, normalize em-dashes to regular dashes.
|
|
119
|
-
* Applied to both query and action text before comparison only — stored text is unchanged.
|
|
120
|
-
*/
|
|
121
|
-
function normalizeForMatch(s) {
|
|
122
|
-
return s
|
|
123
|
-
.trim()
|
|
124
|
-
.replace(/[\u2018\u2019]/g, "'")
|
|
125
|
-
.replace(/[\u201C\u201D]/g, '"')
|
|
126
|
-
.replace(/\u2014/g, '-');
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Match an action by case-insensitive substring against unchecked items.
|
|
130
|
-
* Throws on 0 or >1 matches.
|
|
131
|
-
*/
|
|
132
|
-
function matchAction(body, query) {
|
|
133
|
-
const section = findSection(body, 'Next Actions');
|
|
134
|
-
if (!section)
|
|
135
|
-
throw new Error(`No matching action for "${query}"`);
|
|
136
|
-
const normalizedQuery = normalizeForMatch(query).toLowerCase();
|
|
137
|
-
const matches = [];
|
|
138
|
-
const allActions = [];
|
|
139
|
-
let m;
|
|
140
|
-
const re = new RegExp(UNCHECKED_RE.source, 'gm');
|
|
141
|
-
while ((m = re.exec(section.content)) !== null) {
|
|
142
|
-
allActions.push(m[1]);
|
|
143
|
-
if (normalizeForMatch(m[1]).toLowerCase().includes(normalizedQuery)) {
|
|
144
|
-
matches.push(m[1]);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
if (matches.length === 0) {
|
|
148
|
-
throw new Error(`No matching action for "${query}". Actions: ${JSON.stringify(allActions)}`);
|
|
149
|
-
}
|
|
150
|
-
if (matches.length > 1)
|
|
151
|
-
throw new Error(`Ambiguous match for "${query}": ${matches.join(', ')}`);
|
|
152
|
-
return matches[0];
|
|
153
|
-
}
|
|
154
|
-
// --- Brief functions ---
|
|
155
|
-
export function getBrief(body) {
|
|
156
|
-
const section = findSection(body, 'Brief');
|
|
157
|
-
if (!section)
|
|
158
|
-
return null;
|
|
159
|
-
return section.content || null;
|
|
160
|
-
}
|
|
161
|
-
export function setBrief(body, content) {
|
|
162
|
-
const existing = findSection(body, 'Brief');
|
|
163
|
-
if (existing) {
|
|
164
|
-
// Replace existing brief content
|
|
165
|
-
const updated = removeSection(body, 'Brief');
|
|
166
|
-
return setBrief(updated, content);
|
|
167
|
-
}
|
|
168
|
-
// Insert before: Next Actions > Current State > Session Log > EOF
|
|
169
|
-
return insertSectionBefore(body, 'Brief', content, ['Next Actions', 'Current State', 'Session Log']);
|
|
170
|
-
}
|
|
171
|
-
export function removeBrief(body) {
|
|
172
|
-
return removeSection(body, 'Brief');
|
|
173
|
-
}
|
|
174
|
-
// --- Next Actions functions ---
|
|
175
|
-
export function getNextActions(body) {
|
|
176
|
-
const section = findSection(body, 'Next Actions');
|
|
177
|
-
if (!section)
|
|
178
|
-
return [];
|
|
179
|
-
const actions = [];
|
|
180
|
-
let m;
|
|
181
|
-
const re = new RegExp(UNCHECKED_RE.source, 'gm');
|
|
182
|
-
while ((m = re.exec(section.content)) !== null) {
|
|
183
|
-
actions.push(m[1]);
|
|
184
|
-
}
|
|
185
|
-
return actions;
|
|
186
|
-
}
|
|
187
|
-
export function addNextAction(body, action) {
|
|
188
|
-
const section = findSection(body, 'Next Actions');
|
|
189
|
-
if (section) {
|
|
190
|
-
// Add the new item at the end of unchecked items (before any checked items)
|
|
191
|
-
const lines = section.content.split('\n');
|
|
192
|
-
const lastUncheckedIdx = lines.reduce((last, line, i) => line.startsWith('- [ ] ') ? i : last, -1);
|
|
193
|
-
const insertIdx = lastUncheckedIdx === -1 ? 0 : lastUncheckedIdx + 1;
|
|
194
|
-
lines.splice(insertIdx, 0, `- [ ] ${action}`);
|
|
195
|
-
const newContent = lines.join('\n');
|
|
196
|
-
const before = body.slice(0, section.start).replace(/\n+$/, '');
|
|
197
|
-
const after = body.slice(section.end).replace(/^\n+/, '');
|
|
198
|
-
const sectionBlock = `## Next Actions\n\n${newContent}`;
|
|
199
|
-
const parts = [before, sectionBlock, after].filter(Boolean);
|
|
200
|
-
return parts.join('\n\n');
|
|
201
|
-
}
|
|
202
|
-
// No section — insert after Brief if present, otherwise before Session Log
|
|
203
|
-
return insertSectionAfter(body, 'Next Actions', `- [ ] ${action}`, 'Brief', ['Session Log']);
|
|
204
|
-
}
|
|
205
|
-
export function completeNextAction(body, query) {
|
|
206
|
-
const matched = matchAction(body, query);
|
|
207
|
-
// Replace the unchecked item with checked
|
|
208
|
-
const updatedBody = body.replace(`- [ ] ${matched}`, `- [x] ${matched}`);
|
|
209
|
-
// Check if any unchecked items remain
|
|
210
|
-
const remaining = getNextActions(updatedBody);
|
|
211
|
-
if (remaining.length === 0) {
|
|
212
|
-
return { matched, body: clearNextActions(updatedBody) };
|
|
213
|
-
}
|
|
214
|
-
return { matched, body: updatedBody };
|
|
215
|
-
}
|
|
216
|
-
export function removeNextAction(body, query) {
|
|
217
|
-
const matched = matchAction(body, query);
|
|
218
|
-
// Remove the line
|
|
219
|
-
const line = `- [ ] ${matched}`;
|
|
220
|
-
const updatedBody = body.split('\n').filter(l => l !== line).join('\n');
|
|
221
|
-
// Check if any unchecked items remain
|
|
222
|
-
const remaining = getNextActions(updatedBody);
|
|
223
|
-
if (remaining.length === 0) {
|
|
224
|
-
return { matched, body: clearNextActions(updatedBody) };
|
|
225
|
-
}
|
|
226
|
-
return { matched, body: updatedBody };
|
|
227
|
-
}
|
|
228
|
-
export function clearNextActions(body) {
|
|
229
|
-
return removeSection(body, 'Next Actions');
|
|
230
|
-
}
|
|
231
|
-
// --- Summary derivation ---
|
|
232
|
-
export function firstNonEmptyLine(s) {
|
|
233
|
-
return s?.split('\n').find(l => l.trim())?.trim() ?? '';
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* One-line summary for a doc: frontmatter description first, then the first
|
|
237
|
-
* non-empty line of the kind's primary section (entity: Brief; knowledge:
|
|
238
|
-
* Synthesis, falling back to Goal for plans; session: Summary).
|
|
239
|
-
* Shared by the vault index and the OKF export so the two surfaces agree.
|
|
240
|
-
*/
|
|
241
|
-
export function deriveSummary(kind, fm, body) {
|
|
242
|
-
if (fm.description)
|
|
243
|
-
return fm.description;
|
|
244
|
-
if (kind === 'entity')
|
|
245
|
-
return firstNonEmptyLine(getBrief(body));
|
|
246
|
-
if (kind === 'knowledge') {
|
|
247
|
-
const synthesis = findSection(body, 'Synthesis');
|
|
248
|
-
if (synthesis?.content)
|
|
249
|
-
return firstNonEmptyLine(synthesis.content);
|
|
250
|
-
return firstNonEmptyLine(findSection(body, 'Goal')?.content);
|
|
251
|
-
}
|
|
252
|
-
return firstNonEmptyLine(findSection(body, 'Summary')?.content);
|
|
253
|
-
}
|
|
254
|
-
// --- Related section functions ---
|
|
255
|
-
/** Matches [[slug]] or [[slug|display text]], capturing slug in group 1. Non-global — wrap with `new RegExp(WIKI_LINK_RE.source, 'g')` for iteration. */
|
|
256
|
-
export const WIKI_LINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/;
|
|
257
|
-
export function syncRelatedSection(body, frontmatter) {
|
|
258
|
-
const targets = [];
|
|
259
|
-
if (frontmatter.parent)
|
|
260
|
-
targets.push({ slug: frontmatter.parent, label: 'parent' });
|
|
261
|
-
for (const slug of frontmatter.related ?? [])
|
|
262
|
-
targets.push({ slug });
|
|
263
|
-
if (targets.length === 0) {
|
|
264
|
-
// No frontmatter links — remove Related section if present
|
|
265
|
-
const existing = findSection(body, 'Related');
|
|
266
|
-
if (!existing)
|
|
267
|
-
return body;
|
|
268
|
-
const before = body.slice(0, existing.start).replace(/\n+$/, '');
|
|
269
|
-
const after = body.slice(existing.end).replace(/^\n+/, '');
|
|
270
|
-
return [before, after].filter(Boolean).join('\n\n');
|
|
271
|
-
}
|
|
272
|
-
// Strip existing Related section before checking coverage
|
|
273
|
-
let authoredBody = body;
|
|
274
|
-
const existingRelated = findSection(body, 'Related');
|
|
275
|
-
if (existingRelated) {
|
|
276
|
-
const before = body.slice(0, existingRelated.start);
|
|
277
|
-
const after = body.slice(existingRelated.end);
|
|
278
|
-
authoredBody = (before + after).replace(/\n{3,}/g, '\n\n');
|
|
279
|
-
}
|
|
280
|
-
// Find which targets are NOT mentioned in authored body
|
|
281
|
-
const mentionedSlugs = new Set();
|
|
282
|
-
let match;
|
|
283
|
-
const re = new RegExp(WIKI_LINK_RE.source, 'g');
|
|
284
|
-
while ((match = re.exec(authoredBody)) !== null) {
|
|
285
|
-
mentionedSlugs.add(match[1]);
|
|
286
|
-
}
|
|
287
|
-
const uncovered = targets.filter(t => !mentionedSlugs.has(t.slug));
|
|
288
|
-
if (uncovered.length === 0) {
|
|
289
|
-
// All covered inline — return body without Related section
|
|
290
|
-
return authoredBody.replace(/\n+$/, '');
|
|
291
|
-
}
|
|
292
|
-
// Build Related section
|
|
293
|
-
const lines = uncovered.map(t => t.label ? `- [[${t.slug}]] (${t.label})` : `- [[${t.slug}]]`);
|
|
294
|
-
const section = `## Related\n\n${lines.join('\n')}`;
|
|
295
|
-
// Append at end
|
|
296
|
-
return authoredBody.replace(/\n+$/, '') + '\n\n' + section;
|
|
297
|
-
}
|