hafez 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -8
- package/dist/batch-ops.d.ts +187 -0
- package/dist/batch-ops.js +322 -0
- package/dist/cli/args.d.ts +15 -0
- package/dist/cli/args.js +49 -0
- package/dist/cli/batch-errors.js +6 -3
- package/dist/cli/commands.d.ts +1 -109
- package/dist/cli/commands.js +197 -256
- package/dist/cli/help-agent.d.ts +0 -5
- package/dist/cli/help-agent.js +37 -9
- package/dist/cli/index.js +61 -69
- package/dist/cli/onboard.d.ts +1 -1
- package/dist/cli/onboard.js +7 -1
- package/dist/cli/resolve-vault.js +2 -1
- package/dist/cli/schema-introspect.d.ts +1 -1
- package/dist/cli/schema-introspect.js +6 -22
- package/dist/cli.js +20 -2
- package/dist/contracts.d.ts +6 -0
- package/dist/contracts.js +8 -2
- package/dist/db.js +20 -30
- package/dist/digest.js +2 -0
- package/dist/document.d.ts +108 -0
- package/dist/{sections.js → document.js} +173 -22
- package/dist/export-okf.js +10 -22
- package/dist/git.d.ts +13 -7
- package/dist/git.js +79 -21
- package/dist/index.d.ts +3 -4
- package/dist/index.js +508 -406
- package/dist/knowledge-index.js +9 -7
- package/dist/merge.js +13 -25
- package/dist/query.d.ts +1 -1
- package/dist/query.js +3 -1
- package/dist/types.d.ts +62 -1
- package/dist/vault.d.ts +34 -6
- package/dist/vault.js +71 -7
- package/package.json +4 -4
- package/skill/SKILL.md +7 -1
- package/dist/parser.d.ts +0 -18
- package/dist/parser.js +0 -68
- package/dist/sections.d.ts +0 -54
package/dist/knowledge-index.js
CHANGED
|
@@ -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 {
|
|
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 './
|
|
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
|
|
22
|
+
for (const file of scanVaultDir(dir)) {
|
|
23
23
|
try {
|
|
24
|
-
const
|
|
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, '
|
|
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 './
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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 === '
|
|
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 === '
|
|
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
|
|
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 =>
|
|
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:
|
|
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'
|
|
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
|
}
|
|
@@ -112,6 +135,7 @@ export interface CreateEntityFields {
|
|
|
112
135
|
tags?: string[];
|
|
113
136
|
brief?: string;
|
|
114
137
|
add_action?: string;
|
|
138
|
+
add_actions?: string[];
|
|
115
139
|
}
|
|
116
140
|
export interface CreateKnowledgeFields {
|
|
117
141
|
subtype?: KnowledgeSubtype;
|
|
@@ -259,6 +283,18 @@ export type BatchOperation = {
|
|
|
259
283
|
slug: string;
|
|
260
284
|
target: 'entity' | 'project' | 'knowledge';
|
|
261
285
|
};
|
|
286
|
+
export interface BatchOpValidation {
|
|
287
|
+
index: number;
|
|
288
|
+
op: string;
|
|
289
|
+
slug: string;
|
|
290
|
+
created?: boolean;
|
|
291
|
+
errors: string[];
|
|
292
|
+
warning?: string;
|
|
293
|
+
}
|
|
294
|
+
export interface BatchValidationReport {
|
|
295
|
+
valid: boolean;
|
|
296
|
+
operations: BatchOpValidation[];
|
|
297
|
+
}
|
|
262
298
|
export type HafezErrorCode = 'NOT_FOUND' | 'SLUG_EXISTS' | 'VALIDATION_FAILED' | 'GIT_PUSH_FAILED' | 'GIT_COMMIT_FAILED' | 'VAULT_LOCKED';
|
|
263
299
|
export declare class HafezError extends Error {
|
|
264
300
|
code: HafezErrorCode;
|
|
@@ -268,10 +304,34 @@ export declare class HafezError extends Error {
|
|
|
268
304
|
export interface GitConfig {
|
|
269
305
|
push?: boolean;
|
|
270
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
|
+
}
|
|
271
329
|
export interface HafezConfig {
|
|
272
330
|
vaultPath: string;
|
|
273
331
|
readOnly?: boolean;
|
|
274
332
|
git?: GitConfig;
|
|
333
|
+
/** Override the persistence layer. Defaults to the git adapter. */
|
|
334
|
+
persistence?: Journal;
|
|
275
335
|
onWrite?: (slug: string, operation: string) => void;
|
|
276
336
|
}
|
|
277
337
|
export interface Hafez {
|
|
@@ -294,6 +354,7 @@ export interface Hafez {
|
|
|
294
354
|
unlink(slug: string, target: string, relation: LinkRelation): Promise<void>;
|
|
295
355
|
promote(slug: string, target: 'entity' | 'project' | 'knowledge'): Promise<string>;
|
|
296
356
|
batch(operations: BatchOperation[]): Promise<BatchResult[]>;
|
|
357
|
+
validateBatch(operations: BatchOperation[]): Promise<BatchValidationReport>;
|
|
297
358
|
sync(): Promise<{
|
|
298
359
|
pulled: boolean;
|
|
299
360
|
pushed: boolean;
|
|
@@ -304,7 +365,7 @@ export interface Hafez {
|
|
|
304
365
|
total: number;
|
|
305
366
|
}>;
|
|
306
367
|
related_to(slug: string, relation?: string): Promise<{
|
|
307
|
-
items:
|
|
368
|
+
items: UnifiedResult[];
|
|
308
369
|
total: number;
|
|
309
370
|
}>;
|
|
310
371
|
query_knowledge(opts?: KnowledgeQueryOpts): Promise<{
|
package/dist/vault.d.ts
CHANGED
|
@@ -1,10 +1,38 @@
|
|
|
1
|
+
import type { ParsedVaultFile } from './types.js';
|
|
1
2
|
export declare function slugify(name: string): string;
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
export declare
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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;
|
|
28
|
+
export declare function kindFromPath(filePath: string): 'entity' | 'knowledge';
|
|
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;
|
|
8
36
|
export declare function parseContent(content: string): {
|
|
9
37
|
frontmatter: Record<string, any>;
|
|
10
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,
|
|
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,12 +67,14 @@ 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
|
-
|
|
29
|
-
return join(vaultPath, dir, `${slug}.md`);
|
|
70
|
+
return join(vaultPath, KIND_DIRS[kind], `${slug}.md`);
|
|
30
71
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
72
|
+
// Separator-agnostic: OS paths use '/' or '\' depending on platform, and a
|
|
73
|
+
// POSIX-only check silently classifies every entity as knowledge on Windows
|
|
74
|
+
// (all updates then fail on kind rules). Exported pure so win32 paths are
|
|
75
|
+
// unit-testable from any platform.
|
|
76
|
+
export function kindFromPath(filePath) {
|
|
77
|
+
return /[/\\]entities[/\\]/.test(filePath) ? 'entity' : 'knowledge';
|
|
34
78
|
}
|
|
35
79
|
// Fields that must always be arrays when present (may be scalar in hand-edited YAML)
|
|
36
80
|
const ARRAY_FIELDS = ['related', 'tags', 'domain'];
|
|
@@ -42,10 +86,30 @@ function normalizeFrontmatter(fm) {
|
|
|
42
86
|
}
|
|
43
87
|
return fm;
|
|
44
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
|
+
}
|
|
45
99
|
export function parseFilePath(path) {
|
|
46
100
|
const content = readFileSync(path, 'utf-8');
|
|
47
101
|
const { data, content: body } = matter(content, MATTER_OPTIONS);
|
|
48
|
-
|
|
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 };
|
|
49
113
|
}
|
|
50
114
|
export function parseContent(content) {
|
|
51
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.
|
|
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",
|
|
@@ -34,15 +34,15 @@
|
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/better-sqlite3": "^7.6.13",
|
|
37
|
-
"@types/proper-lockfile": "^4.1.4",
|
|
38
37
|
"@types/js-yaml": "^4.0.9",
|
|
39
38
|
"@types/node": "^25.4.0",
|
|
39
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
40
40
|
"tsx": "^4.21.0",
|
|
41
41
|
"typescript": "^5.9.3",
|
|
42
42
|
"vitest": "^4.0.18"
|
|
43
43
|
},
|
|
44
44
|
"license": "MIT",
|
|
45
|
-
"description": "
|
|
45
|
+
"description": "A knowledge and project vault shared between you and your agents — plain markdown + git, schema-enforced writes, FTS5 search, operated by agents through a CLI",
|
|
46
46
|
"keywords": [
|
|
47
47
|
"knowledge-base",
|
|
48
48
|
"knowledge-vault",
|
package/skill/SKILL.md
CHANGED
|
@@ -41,7 +41,7 @@ Quick reference for common operations:
|
|
|
41
41
|
| Add evidence to knowledge | `hafez update <slug> --add-evidence "text"` |
|
|
42
42
|
| Add source to knowledge | `hafez update <slug> --add-source "url"` |
|
|
43
43
|
| Export OKF bundle | `hafez export --okf [--out <dir>]` (re-export overwrites, never deletes) |
|
|
44
|
-
| Digest session → batch | `echo '{...}' \| hafez digest \| hafez batch` |
|
|
44
|
+
| Digest session → batch | `echo '{...}' \| hafez digest \| hafez batch` (no pipes? `--file` on both) |
|
|
45
45
|
|
|
46
46
|
Note: `--insight` is an alias for `--synthesis`.
|
|
47
47
|
|
|
@@ -143,6 +143,10 @@ echo '[
|
|
|
143
143
|
|
|
144
144
|
Batch output includes created slugs: `Batch complete: 3 operations (created: new-thing, insight)`
|
|
145
145
|
|
|
146
|
+
On shells that can't pipe stdin to native executables (Windows PowerShell 5.1), write the payload to a file and use `hafez batch --file payload.json` — same JSON, same behavior. (`cmd /c "hafez batch < payload.json"` also works.)
|
|
147
|
+
|
|
148
|
+
**Slug contract.** Slugs derive deterministically from names: lowercase, non-alphanumeric runs become single `-` (`"Auth Refactor!"` → `auth-refactor`). Batch executes sequentially, so later ops can reference the slugs of earlier creates in the same payload — create-then-link works in one batch. Collisions hard-error, so a wrong prediction can't touch the wrong document; `hafez batch --dry-run` reports the derived slug for every create op if you want to verify first.
|
|
149
|
+
|
|
146
150
|
For a single operation, individual commands are fine — they auto-sync.
|
|
147
151
|
|
|
148
152
|
## Session End — Digest
|
|
@@ -163,6 +167,8 @@ echo '{
|
|
|
163
167
|
}' | hafez digest | hafez batch
|
|
164
168
|
```
|
|
165
169
|
|
|
170
|
+
On shells that can't pipe (PowerShell 5.1), use files instead — digest stays inspectable in the middle: `hafez digest --file summary.json > ops.json`, then `hafez batch --file ops.json`.
|
|
171
|
+
|
|
166
172
|
Digest adds a session log entry to each touched entity and creates a session note linking them all. Authoritative field docs: `hafez schema digest`.
|
|
167
173
|
|
|
168
174
|
Digest itself is pure — it only prints batch JSON; `hafez batch` is what writes. If you are unsure the slugs exist, run `hafez digest` alone first: unknown slugs warn on stderr (they are omitted from the output). Fix `entities_touched`, regenerate, and pipe to batch **once** — re-running the full pipe after a successful batch creates a duplicate session note.
|
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/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;
|