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.
- package/README.md +83 -32
- package/dist/apply.d.ts +33 -0
- package/dist/apply.js +693 -0
- package/dist/batch-ops.d.ts +372 -26
- package/dist/batch-ops.js +355 -73
- package/dist/cli/args.d.ts +48 -6
- package/dist/cli/args.js +74 -9
- package/dist/cli/batch-errors.js +19 -11
- package/dist/cli/block-hook.d.ts +9 -0
- package/dist/cli/block-hook.js +90 -0
- package/dist/cli/commands-schema.js +2 -0
- package/dist/cli/commands.d.ts +28 -3
- package/dist/cli/commands.js +440 -157
- package/dist/cli/format.d.ts +7 -3
- package/dist/cli/format.js +27 -10
- package/dist/cli/index.d.ts +55 -0
- package/dist/cli/index.js +215 -153
- package/dist/cli/install.d.ts +53 -0
- package/dist/cli/install.js +297 -0
- package/dist/cli/onboard.d.ts +15 -2
- package/dist/cli/onboard.js +265 -90
- package/dist/cli/resolve-vault.d.ts +11 -4
- package/dist/cli/resolve-vault.js +13 -11
- package/dist/cli/schema-introspect.js +4 -3
- package/dist/cli/upgrade.d.ts +51 -0
- package/dist/cli/upgrade.js +351 -0
- package/dist/closest.d.ts +8 -0
- package/dist/closest.js +65 -0
- package/dist/contracts.d.ts +65 -6
- package/dist/contracts.js +148 -23
- package/dist/dates.d.ts +10 -0
- package/dist/dates.js +27 -0
- package/dist/db.d.ts +40 -2
- package/dist/db.js +363 -66
- package/dist/digest.d.ts +6 -4
- package/dist/digest.js +28 -17
- package/dist/document.d.ts +135 -88
- package/dist/document.js +665 -379
- package/dist/export-okf.d.ts +13 -0
- package/dist/export-okf.js +50 -20
- package/dist/git.js +165 -48
- package/dist/harness.d.ts +58 -0
- package/dist/harness.js +50 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +301 -826
- package/dist/knowledge-index.d.ts +23 -1
- package/dist/knowledge-index.js +62 -6
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +98 -64
- package/dist/near-match.d.ts +9 -0
- package/dist/near-match.js +34 -0
- package/dist/schema.js +4 -4
- package/dist/stale.d.ts +41 -0
- package/dist/stale.js +43 -0
- package/dist/stopwords.d.ts +6 -0
- package/dist/stopwords.js +25 -0
- package/dist/surfaces/items.d.ts +366 -0
- package/dist/surfaces/items.js +523 -0
- package/dist/surfaces/surfaces.d.ts +32 -0
- package/dist/surfaces/surfaces.js +230 -0
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +78 -22
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +109 -0
- package/dist/vault.d.ts +18 -0
- package/dist/vault.js +51 -5
- package/package.json +4 -3
- package/skill/hafez/SKILL.md +68 -0
- package/skill/hafez-save/SKILL.md +29 -0
- package/dist/cli/help-agent.d.ts +0 -1
- package/dist/cli/help-agent.js +0 -186
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -169
- package/skill/SKILL.md +0 -196
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
/** One indexed document, as the SQLite index can hand it over. index.md needs
|
|
2
|
+
* nothing the index does not already hold, so a regeneration reads rows
|
|
3
|
+
* instead of re-reading and re-parsing every file in the vault. Declared here
|
|
4
|
+
* (not in db.ts) to keep the module edge pointing one way: db.ts already
|
|
5
|
+
* imports from this file. */
|
|
6
|
+
export interface VaultIndexRow {
|
|
7
|
+
slug: string;
|
|
8
|
+
kind: 'entity' | 'knowledge';
|
|
9
|
+
name: string;
|
|
10
|
+
type: string | null;
|
|
11
|
+
status: string | null;
|
|
12
|
+
confidence: string | null;
|
|
13
|
+
description: string | null;
|
|
14
|
+
domain: string[];
|
|
15
|
+
body: string;
|
|
16
|
+
}
|
|
1
17
|
/** Ensure every generated local-only artifact is covered by .git/info/exclude. */
|
|
2
18
|
export declare function ensureLocalExclude(vaultPath: string): void;
|
|
3
|
-
|
|
19
|
+
/** Regenerate the vault-root index.md.
|
|
20
|
+
*
|
|
21
|
+
* `rows` is the SQLite index's view of the vault — every mutation refreshes
|
|
22
|
+
* those rows anyway, so passing them avoids a second full-vault read for the
|
|
23
|
+
* same content. Omitting them (standalone/one-off use) falls back to reading
|
|
24
|
+
* the files directly. */
|
|
25
|
+
export declare function generateVaultIndex(vaultPath: string, rows?: VaultIndexRow[]): void;
|
package/dist/knowledge-index.js
CHANGED
|
@@ -2,10 +2,28 @@
|
|
|
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
4
|
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
|
|
5
|
-
import { join, basename } from 'path';
|
|
5
|
+
import { join, basename, resolve } from 'path';
|
|
6
6
|
import { parseFilePath, scanVaultDir, kindDir } from './vault.js';
|
|
7
7
|
import { deriveSummary } from './document.js';
|
|
8
8
|
import { ENTITY_STATUSES } from './contracts.js';
|
|
9
|
+
/** Index rows in the shape the renderer below expects — the same
|
|
10
|
+
* {slug, fm, body} triple scanParsed produces from disk. */
|
|
11
|
+
function fromRows(rows, kind) {
|
|
12
|
+
return rows
|
|
13
|
+
.filter(r => r.kind === kind)
|
|
14
|
+
.map(r => ({
|
|
15
|
+
slug: r.slug,
|
|
16
|
+
body: r.body,
|
|
17
|
+
fm: {
|
|
18
|
+
name: r.name,
|
|
19
|
+
type: r.type ?? undefined,
|
|
20
|
+
status: r.status ?? undefined,
|
|
21
|
+
confidence: r.confidence ?? undefined,
|
|
22
|
+
description: r.description ?? undefined,
|
|
23
|
+
domain: r.domain,
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
9
27
|
function truncate(s) {
|
|
10
28
|
return s.length > 100 ? s.slice(0, 97) + '...' : s;
|
|
11
29
|
}
|
|
@@ -38,11 +56,42 @@ function scanParsed(dir) {
|
|
|
38
56
|
* show up in the user's `git status`, but forcing a tracked .gitignore entry
|
|
39
57
|
* would dirty their vault — so they go into .git/info/exclude instead. */
|
|
40
58
|
const LOCAL_EXCLUDES = ['/index.md', '/.hafez.db', '/.hafez.db-shm', '/.hafez.db-wal'];
|
|
59
|
+
/** Locate the git directory that owns `info/exclude` for this vault.
|
|
60
|
+
* `.git` is a directory in a normal clone, but a *gitfile* (`gitdir: <path>`)
|
|
61
|
+
* in a linked worktree or submodule — treating that file as "not a repo" is
|
|
62
|
+
* what let index.md and .hafez.db pollute `git status` there. Git reads
|
|
63
|
+
* `info/exclude` from the COMMON dir, so for a linked worktree the pointed-at
|
|
64
|
+
* per-worktree dir is one more hop from the answer: its `commondir` file names
|
|
65
|
+
* the shared dir (writing the exclude in the per-worktree dir has no effect).
|
|
66
|
+
* Returns null when the vault is not a git repo at all. */
|
|
67
|
+
function resolveExcludeGitDir(vaultPath) {
|
|
68
|
+
const dotGit = join(vaultPath, '.git');
|
|
69
|
+
if (!existsSync(dotGit))
|
|
70
|
+
return null;
|
|
71
|
+
const stat = statSync(dotGit);
|
|
72
|
+
if (stat.isDirectory())
|
|
73
|
+
return dotGit;
|
|
74
|
+
if (!stat.isFile())
|
|
75
|
+
return null;
|
|
76
|
+
const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, 'utf-8'));
|
|
77
|
+
if (!pointer)
|
|
78
|
+
return null;
|
|
79
|
+
const gitDir = resolve(vaultPath, pointer[1].trim());
|
|
80
|
+
if (!existsSync(gitDir))
|
|
81
|
+
return null;
|
|
82
|
+
const commonDirFile = join(gitDir, 'commondir');
|
|
83
|
+
if (existsSync(commonDirFile)) {
|
|
84
|
+
const commonDir = resolve(gitDir, readFileSync(commonDirFile, 'utf-8').trim());
|
|
85
|
+
if (existsSync(commonDir))
|
|
86
|
+
return commonDir;
|
|
87
|
+
}
|
|
88
|
+
return gitDir;
|
|
89
|
+
}
|
|
41
90
|
/** Ensure every generated local-only artifact is covered by .git/info/exclude. */
|
|
42
91
|
export function ensureLocalExclude(vaultPath) {
|
|
43
92
|
try {
|
|
44
|
-
const gitDir =
|
|
45
|
-
if (!
|
|
93
|
+
const gitDir = resolveExcludeGitDir(vaultPath);
|
|
94
|
+
if (!gitDir)
|
|
46
95
|
return;
|
|
47
96
|
const excludePath = join(gitDir, 'info', 'exclude');
|
|
48
97
|
const existing = existsSync(excludePath) ? readFileSync(excludePath, 'utf-8') : '';
|
|
@@ -56,17 +105,24 @@ export function ensureLocalExclude(vaultPath) {
|
|
|
56
105
|
}
|
|
57
106
|
catch { /* best effort — never fail a vault write over ignore hygiene */ }
|
|
58
107
|
}
|
|
59
|
-
|
|
108
|
+
/** Regenerate the vault-root index.md.
|
|
109
|
+
*
|
|
110
|
+
* `rows` is the SQLite index's view of the vault — every mutation refreshes
|
|
111
|
+
* those rows anyway, so passing them avoids a second full-vault read for the
|
|
112
|
+
* same content. Omitting them (standalone/one-off use) falls back to reading
|
|
113
|
+
* the files directly. */
|
|
114
|
+
export function generateVaultIndex(vaultPath, rows) {
|
|
60
115
|
const entitiesDir = join(vaultPath, kindDir('entity'));
|
|
61
116
|
const knowledgeDir = join(vaultPath, kindDir('knowledge'));
|
|
62
117
|
const hasEntities = existsSync(entitiesDir);
|
|
63
118
|
const hasKnowledge = existsSync(knowledgeDir);
|
|
64
119
|
if (!hasEntities && !hasKnowledge)
|
|
65
120
|
return;
|
|
121
|
+
const docsFor = (kind, dir) => rows ? fromRows(rows, kind) : scanParsed(dir);
|
|
66
122
|
const lines = ['# Vault Index', '', '> Auto-generated by hafez. Do not edit manually.', ''];
|
|
67
123
|
if (hasEntities) {
|
|
68
124
|
const byStatus = new Map();
|
|
69
|
-
for (const { slug, fm, body } of
|
|
125
|
+
for (const { slug, fm, body } of docsFor('entity', entitiesDir)) {
|
|
70
126
|
const status = fm.status ?? 'active';
|
|
71
127
|
const list = byStatus.get(status) ?? [];
|
|
72
128
|
list.push({ slug, name: fm.name, summary: truncate(deriveSummary('entity', fm, body)), suffix: fm.type ?? 'entity' });
|
|
@@ -92,7 +148,7 @@ export function generateVaultIndex(vaultPath) {
|
|
|
92
148
|
}
|
|
93
149
|
if (hasKnowledge) {
|
|
94
150
|
const entries = [];
|
|
95
|
-
for (const { slug, fm, body } of
|
|
151
|
+
for (const { slug, fm, body } of docsFor('knowledge', knowledgeDir)) {
|
|
96
152
|
entries.push({
|
|
97
153
|
slug,
|
|
98
154
|
name: fm.name,
|
package/dist/merge.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function mergeVaultContent(remote: string, local: string): string;
|
|
1
|
+
export declare function mergeVaultContent(remote: string, local: string, kind: 'entity' | 'knowledge' | 'session'): string;
|
package/dist/merge.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
// src/merge.ts
|
|
2
|
-
import { parseContent, serializeFile } from './vault.js';
|
|
3
|
-
import { parseSessionLogHeading,
|
|
2
|
+
import { parseContent, serializeFile, ARRAY_FIELDS } from './vault.js';
|
|
3
|
+
import { parseSessionLogHeading, splitLogEntries, documentFor, stripActionStamp } from './document.js';
|
|
4
4
|
import { APPEND_ONLY_SECTIONS } from './contracts.js';
|
|
5
5
|
// Fields that are ISO date strings — latest date wins
|
|
6
6
|
const DATE_FIELDS = ['last-touched', 'last-reinforced', 'created'];
|
|
7
7
|
// Fields that are numeric counters — max wins
|
|
8
8
|
const COUNT_FIELDS = ['reinforcement-count', 'staleness-days'];
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
export function mergeVaultContent(remote, local) {
|
|
9
|
+
// Array fields (vault.ARRAY_FIELDS — the same set parsing coerces) union with dedup
|
|
10
|
+
export function mergeVaultContent(remote, local, kind) {
|
|
12
11
|
const r = parseContent(remote);
|
|
13
12
|
const l = parseContent(local);
|
|
14
13
|
// Scalar policy: the side with the newer last-touched wins; local on tie.
|
|
@@ -16,7 +15,18 @@ export function mergeVaultContent(remote, local) {
|
|
|
16
15
|
// array/date/count frontmatter fields are always unioned regardless.
|
|
17
16
|
const localWins = String(l.frontmatter?.['last-touched'] ?? '') >= String(r.frontmatter?.['last-touched'] ?? '');
|
|
18
17
|
const fm = mergeFrontmatter(r.frontmatter, l.frontmatter, localWins);
|
|
19
|
-
|
|
18
|
+
// The Document binds by kind — the same binding every write path uses (see
|
|
19
|
+
// DocContext), so a section this merge preserves stays structural for the
|
|
20
|
+
// next update too. The two sides come from divergent histories whose type
|
|
21
|
+
// may differ (a promote race leaves one side 'capture' and the other
|
|
22
|
+
// 'project'); the kind-level set keeps every same-kind section structural,
|
|
23
|
+
// while cross-KIND headings (a session-only ## Decisions quoted in an
|
|
24
|
+
// entity Brief) are not boundaries. Known residual: a same-kind heading
|
|
25
|
+
// quoted at line start in one side's section splits at that false boundary
|
|
26
|
+
// and can survive the merge as its own section — the fix is fence/quote
|
|
27
|
+
// awareness in Document's splitter, not a narrower binding here.
|
|
28
|
+
const doc = documentFor({ kind });
|
|
29
|
+
const body = mergeBody(doc, r.body, l.body, localWins);
|
|
20
30
|
return serializeFile(fm, body);
|
|
21
31
|
}
|
|
22
32
|
function mergeFrontmatter(remote, local, localWins) {
|
|
@@ -39,7 +49,7 @@ function mergeFrontmatter(remote, local, localWins) {
|
|
|
39
49
|
merged[key] = r;
|
|
40
50
|
}
|
|
41
51
|
// Array fields: union
|
|
42
|
-
for (const key of
|
|
52
|
+
for (const key of ARRAY_FIELDS) {
|
|
43
53
|
merged[key] = unionArrays(remote[key], local[key]);
|
|
44
54
|
if (merged[key].length === 0)
|
|
45
55
|
delete merged[key];
|
|
@@ -47,20 +57,21 @@ function mergeFrontmatter(remote, local, localWins) {
|
|
|
47
57
|
return merged;
|
|
48
58
|
}
|
|
49
59
|
function unionArrays(a, b) {
|
|
50
|
-
// Local (b) items first
|
|
60
|
+
// Local (b) items first: nothing is truncated, but the local side's order is
|
|
61
|
+
// the one the user last saw, so it stays the order they get back.
|
|
51
62
|
const arrA = Array.isArray(a) ? a : a ? [a] : [];
|
|
52
63
|
const arrB = Array.isArray(b) ? b : b ? [b] : [];
|
|
53
64
|
return [...new Set([...arrB, ...arrA])];
|
|
54
65
|
}
|
|
55
66
|
// --- Body merge ---
|
|
56
|
-
function mergeBody(remoteBody, localBody, localWins) {
|
|
67
|
+
function mergeBody(doc, remoteBody, localBody, localWins) {
|
|
57
68
|
if (remoteBody === localBody)
|
|
58
69
|
return localBody;
|
|
59
70
|
// Structural split (see document.ts): sections are atomic — a user ##
|
|
60
71
|
// heading inside a Brief or a session-log entry body stays with its
|
|
61
72
|
// section instead of being split out and merged under its own policy.
|
|
62
|
-
const remote = splitStructural(remoteBody);
|
|
63
|
-
const local = splitStructural(localBody);
|
|
73
|
+
const remote = doc.splitStructural(remoteBody);
|
|
74
|
+
const local = doc.splitStructural(localBody);
|
|
64
75
|
// Build merged sections starting from local (local section order is canonical)
|
|
65
76
|
const localHeadings = new Set(local.sections.map(s => s.heading));
|
|
66
77
|
const mergedSections = [];
|
|
@@ -90,10 +101,16 @@ function mergeBody(remoteBody, localBody, localWins) {
|
|
|
90
101
|
mergedSections.push(rs);
|
|
91
102
|
}
|
|
92
103
|
}
|
|
93
|
-
// Reassemble
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
// Reassemble. A preamble that is itself a session-log entry stream is
|
|
105
|
+
// append-only content living outside any heading — the archive-log files
|
|
106
|
+
// under entities/archive/ are exactly this shape. Merging those by the
|
|
107
|
+
// scalar wins policy drops one machine's archived entries wholesale, so
|
|
108
|
+
// union them by the same entry policy the Session Log section uses.
|
|
109
|
+
const preamble = isLogEntryStream(remote.preamble) && isLogEntryStream(local.preamble)
|
|
110
|
+
? mergeSessionLogs(remote.preamble, local.preamble)
|
|
111
|
+
: localWins
|
|
112
|
+
? (local.preamble || remote.preamble)
|
|
113
|
+
: (remote.preamble || local.preamble);
|
|
97
114
|
const parts = [preamble, ...mergedSections.map(s => `## ${s.heading}\n\n${s.content}`)].filter(Boolean);
|
|
98
115
|
return parts.join('\n\n');
|
|
99
116
|
}
|
|
@@ -111,6 +128,12 @@ function mergeAppendOnlyLines(remoteContent, localContent) {
|
|
|
111
128
|
return localContent;
|
|
112
129
|
return [...localLines, ...remoteOnly].join('\n');
|
|
113
130
|
}
|
|
131
|
+
/** True when a block is nothing but session-log entries — it starts with an
|
|
132
|
+
* entry heading, so splitLogEntries drops no leading prose. */
|
|
133
|
+
function isLogEntryStream(text) {
|
|
134
|
+
const first = text.trim().split('\n')[0];
|
|
135
|
+
return first !== '' && parseSessionLogHeading(first) !== null;
|
|
136
|
+
}
|
|
114
137
|
function mergeSessionLogs(remoteContent, localContent) {
|
|
115
138
|
const remoteEntries = parseLogEntries(remoteContent);
|
|
116
139
|
const localEntries = parseLogEntries(localContent);
|
|
@@ -128,64 +151,75 @@ function mergeSessionLogs(remoteContent, localContent) {
|
|
|
128
151
|
return merged.map(e => e.raw).join('\n\n');
|
|
129
152
|
}
|
|
130
153
|
function parseLogEntries(content) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return entries;
|
|
154
|
+
// Document owns entry block splitting; the identity key and dedup/sort
|
|
155
|
+
// policy stay here — merge semantics, not document structure.
|
|
156
|
+
//
|
|
157
|
+
// Identity is the entry's whole content. Date+agent+type is not an identity:
|
|
158
|
+
// two machines each running a digest on the same day produce two entries with
|
|
159
|
+
// the same heading and different bodies, and keying on the heading collapses
|
|
160
|
+
// them to one — the other machine's session history is gone. Entries the two
|
|
161
|
+
// sides share came from the same commit, so they are byte-identical and still
|
|
162
|
+
// dedup.
|
|
163
|
+
return splitLogEntries(content).map(raw => {
|
|
164
|
+
const parsed = parseSessionLogHeading(raw.split('\n')[0]);
|
|
165
|
+
return { key: raw, date: parsed.date, raw };
|
|
166
|
+
});
|
|
148
167
|
}
|
|
149
168
|
function mergeNextActions(remoteContent, localContent) {
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
seen.add(item.text);
|
|
164
|
-
merged.push(item);
|
|
165
|
-
}
|
|
166
|
-
else {
|
|
167
|
-
// If remote has it checked but local has it unchecked, upgrade to checked
|
|
168
|
-
const existing = merged.find(m => m.text === item.text);
|
|
169
|
-
if (existing && item.checked && !existing.checked) {
|
|
170
|
-
existing.checked = true;
|
|
171
|
-
}
|
|
169
|
+
const remote = parseActionItems(remoteContent);
|
|
170
|
+
const local = parseActionItems(localContent);
|
|
171
|
+
// Start with local items; index by action text for the remote pass. Identity
|
|
172
|
+
// is the unstamped text: one action stamped on two days is one action, and
|
|
173
|
+
// the local line (its stamp included) wins.
|
|
174
|
+
const merged = local.items.map(i => ({ ...i, notes: [...i.notes] }));
|
|
175
|
+
const byText = new Map(merged.map(i => [stripActionStamp(i.text), i]));
|
|
176
|
+
for (const item of remote.items) {
|
|
177
|
+
const existing = byText.get(stripActionStamp(item.text));
|
|
178
|
+
if (!existing) {
|
|
179
|
+
merged.push({ ...item, notes: [...item.notes] });
|
|
180
|
+
byText.set(stripActionStamp(item.text), merged[merged.length - 1]);
|
|
181
|
+
continue;
|
|
172
182
|
}
|
|
183
|
+
// Checked on either side means done
|
|
184
|
+
if (item.checked)
|
|
185
|
+
existing.checked = true;
|
|
186
|
+
// Union the item's own notes/sub-bullets — they are authored content, not
|
|
187
|
+
// re-derivable from the checkbox line
|
|
188
|
+
existing.notes.push(...unionLines(item.notes, existing.notes));
|
|
189
|
+
}
|
|
190
|
+
const lines = [...local.preamble, ...unionLines(remote.preamble, local.preamble)];
|
|
191
|
+
for (const item of merged) {
|
|
192
|
+
lines.push(`- [${item.checked ? 'x' : ' '}] ${item.text}`);
|
|
193
|
+
lines.push(...item.notes);
|
|
173
194
|
}
|
|
174
|
-
return
|
|
195
|
+
return lines.join('\n').replace(/\n+$/, '');
|
|
175
196
|
}
|
|
197
|
+
/** Lines of `from` not already present in `into` (blank lines only from `into`). */
|
|
198
|
+
function unionLines(from, into) {
|
|
199
|
+
const seen = new Set(into.map(l => l.trim()).filter(Boolean));
|
|
200
|
+
return from.filter(l => l.trim() && !seen.has(l.trim()));
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Split a Next Actions section into its leading non-item lines and its items,
|
|
204
|
+
* each item carrying the lines that follow it (indented sub-bullets, notes)
|
|
205
|
+
* until the next checkbox. Non-checkbox lines are authored content — parsing
|
|
206
|
+
* checkbox lines alone and re-rendering from them deletes everything else.
|
|
207
|
+
* `- [X]` is the same as `- [x]`: a completed action, not an unknown line.
|
|
208
|
+
*/
|
|
176
209
|
function parseActionItems(content) {
|
|
210
|
+
const preamble = [];
|
|
177
211
|
const items = [];
|
|
178
212
|
for (const line of content.split('\n')) {
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
181
|
-
items.push({ checked:
|
|
182
|
-
continue;
|
|
213
|
+
const match = line.match(/^- \[([ xX])\] (.+)$/);
|
|
214
|
+
if (match) {
|
|
215
|
+
items.push({ checked: match[1].toLowerCase() === 'x', text: match[2], notes: [] });
|
|
183
216
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
217
|
+
else if (items.length === 0) {
|
|
218
|
+
preamble.push(line);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
items[items.length - 1].notes.push(line);
|
|
188
222
|
}
|
|
189
223
|
}
|
|
190
|
-
return items;
|
|
224
|
+
return { preamble, items };
|
|
191
225
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The notes whose names near-match `name` — all but one of the shorter
|
|
2
|
+
* name's tokens shared, at least three — and those that only resemble it (two
|
|
3
|
+
* shared or more). Up to three of each, most tokens shared first. */
|
|
4
|
+
export declare function resemblances<T extends {
|
|
5
|
+
name: string;
|
|
6
|
+
}>(name: string, notes: readonly T[]): {
|
|
7
|
+
near: T[];
|
|
8
|
+
weak: T[];
|
|
9
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// src/near-match.ts
|
|
2
|
+
// The Near-match rule (decision #60): how closely a new knowledge note's name
|
|
3
|
+
// resembles existing ones. Names only, never bodies, so the verdict is
|
|
4
|
+
// deterministic and explainable. Tokens are the name's words, lowercased,
|
|
5
|
+
// with the search stopwords dropped.
|
|
6
|
+
import { tokenize } from './stopwords.js';
|
|
7
|
+
/** A near-match shares at least this many tokens, whatever the name lengths. */
|
|
8
|
+
const NEAR_MIN_SHARED = 3;
|
|
9
|
+
/** A weaker resemblance shares at least this many. */
|
|
10
|
+
const WEAK_MIN_SHARED = 2;
|
|
11
|
+
/** Each list holds at most this many notes. */
|
|
12
|
+
const MAX_LISTED = 3;
|
|
13
|
+
function tokens(name) {
|
|
14
|
+
return new Set(tokenize(name));
|
|
15
|
+
}
|
|
16
|
+
/** The notes whose names near-match `name` — all but one of the shorter
|
|
17
|
+
* name's tokens shared, at least three — and those that only resemble it (two
|
|
18
|
+
* shared or more). Up to three of each, most tokens shared first. */
|
|
19
|
+
export function resemblances(name, notes) {
|
|
20
|
+
const mine = tokens(name);
|
|
21
|
+
const near = [];
|
|
22
|
+
const weak = [];
|
|
23
|
+
for (const note of notes) {
|
|
24
|
+
const theirs = tokens(note.name);
|
|
25
|
+
const shared = [...mine].filter(t => theirs.has(t)).length;
|
|
26
|
+
const shorter = Math.min(mine.size, theirs.size);
|
|
27
|
+
if (shared >= Math.max(NEAR_MIN_SHARED, shorter - 1))
|
|
28
|
+
near.push({ note, shared });
|
|
29
|
+
else if (shared >= WEAK_MIN_SHARED)
|
|
30
|
+
weak.push({ note, shared });
|
|
31
|
+
}
|
|
32
|
+
const top = (xs) => xs.sort((a, b) => b.shared - a.shared).slice(0, MAX_LISTED).map(x => x.note);
|
|
33
|
+
return { near: top(near), weak: top(weak) };
|
|
34
|
+
}
|
package/dist/schema.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/schema.ts
|
|
2
|
-
import { ENTITY_TYPES, ENTITY_STATUSES, CONFIDENCE_LEVELS, SESSION_LOG_TYPES, VALID_SUBTYPES, } from './contracts.js';
|
|
2
|
+
import { ENTITY_TYPES, ENTITY_STATUSES, CONFIDENCE_LEVELS, SESSION_LOG_TYPES, VALID_SUBTYPES, PLAN_STATUSES, } from './contracts.js';
|
|
3
3
|
export function validateEntityFrontmatter(fm) {
|
|
4
4
|
const errors = [];
|
|
5
5
|
if (!fm.name || typeof fm.name !== 'string')
|
|
@@ -35,9 +35,9 @@ export function validateKnowledgeSubtype(fm) {
|
|
|
35
35
|
if (fm.subtype && !VALID_SUBTYPES.includes(fm.subtype)) {
|
|
36
36
|
errors.push(`invalid subtype: ${fm.subtype} (expected: ${VALID_SUBTYPES.join(', ')})`);
|
|
37
37
|
}
|
|
38
|
-
// Plan-specific: status must be
|
|
39
|
-
if (fm.subtype === 'plan' && fm.status && !
|
|
40
|
-
errors.push(`invalid plan status: ${fm.status} (expected:
|
|
38
|
+
// Plan-specific: status must be one of the plan lifecycle statuses
|
|
39
|
+
if (fm.subtype === 'plan' && fm.status && !PLAN_STATUSES.includes(fm.status)) {
|
|
40
|
+
errors.push(`invalid plan status: ${fm.status} (expected: ${PLAN_STATUSES.join(', ')})`);
|
|
41
41
|
}
|
|
42
42
|
return errors;
|
|
43
43
|
}
|
package/dist/stale.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** The threshold an entity without `staleness-days` gets. */
|
|
2
|
+
export declare const DEFAULT_STALENESS_DAYS = 14;
|
|
3
|
+
/** An open action's added day, its age in days, and whether it is stale;
|
|
4
|
+
* undated gives null for both. */
|
|
5
|
+
export interface ActionAge {
|
|
6
|
+
added: string | null;
|
|
7
|
+
age_days: number | null;
|
|
8
|
+
stale: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** The entity side of the rule, as SQL over an index row `i` with `@today`
|
|
11
|
+
* bound to localDay(): whole days from its last-touched day to today.
|
|
12
|
+
* julianday of two bare YYYY-MM-DD days differs by exactly `dayNumber`'s
|
|
13
|
+
* difference, so entities and their actions count the same days. */
|
|
14
|
+
export declare const ENTITY_AGE_DAYS_SQL = "CAST(julianday(@today) - julianday(i.last_touched) AS INTEGER)";
|
|
15
|
+
/** An entity row is stale when its age is past its threshold, not equal to
|
|
16
|
+
* it — `actionAge`'s rule. */
|
|
17
|
+
export declare const ENTITY_STALE_SQL = "CAST(julianday(@today) - julianday(i.last_touched) AS INTEGER) > COALESCE(i.staleness_days, 14)";
|
|
18
|
+
/** How old an action added on `added` is today, and whether that is past
|
|
19
|
+
* `stalenessDays`: older than the threshold, not equal to it. */
|
|
20
|
+
export declare function actionAge(added: string | null, stalenessDays: number, today?: string): ActionAge;
|
|
21
|
+
/** An entity's threshold: its `staleness-days`, else the default. */
|
|
22
|
+
export declare function stalenessDaysOf(frontmatter: {
|
|
23
|
+
'staleness-days'?: number;
|
|
24
|
+
}): number;
|
|
25
|
+
/** One line of Next Actions as `read --json` gives it: an open line with its
|
|
26
|
+
* Handle and age, a ticked line as text alone. */
|
|
27
|
+
export type AgedAction = ({
|
|
28
|
+
handle: string;
|
|
29
|
+
text: string;
|
|
30
|
+
} & ActionAge) | {
|
|
31
|
+
text: string;
|
|
32
|
+
};
|
|
33
|
+
/** An entity body's Next Actions in file order, each open line aged against
|
|
34
|
+
* the entity's threshold. */
|
|
35
|
+
export declare function agedActions(body: string, frontmatter: {
|
|
36
|
+
'staleness-days'?: number;
|
|
37
|
+
}): AgedAction[];
|
|
38
|
+
/** The Handles of an entity body's Stale lines, in file order. */
|
|
39
|
+
export declare function staleHandles(body: string, frontmatter: {
|
|
40
|
+
'staleness-days'?: number;
|
|
41
|
+
}): string[];
|
package/dist/stale.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/stale.ts
|
|
2
|
+
// Stale (see CONTEXT.md): past the entity's `staleness-days` without
|
|
3
|
+
// movement. An open next action is stale when unticked that long since its
|
|
4
|
+
// added day; an undated one is stale. Ages count local days (src/dates.ts).
|
|
5
|
+
import { localDay } from './dates.js';
|
|
6
|
+
import { getActionItems } from './document.js';
|
|
7
|
+
/** The threshold an entity without `staleness-days` gets. */
|
|
8
|
+
export const DEFAULT_STALENESS_DAYS = 14;
|
|
9
|
+
/** A YYYY-MM-DD day as a whole day count, so differences ignore DST. */
|
|
10
|
+
const dayNumber = (day) => {
|
|
11
|
+
const [y, m, d] = day.split('-').map(Number);
|
|
12
|
+
return Date.UTC(y, m - 1, d) / 86_400_000;
|
|
13
|
+
};
|
|
14
|
+
/** The entity side of the rule, as SQL over an index row `i` with `@today`
|
|
15
|
+
* bound to localDay(): whole days from its last-touched day to today.
|
|
16
|
+
* julianday of two bare YYYY-MM-DD days differs by exactly `dayNumber`'s
|
|
17
|
+
* difference, so entities and their actions count the same days. */
|
|
18
|
+
export const ENTITY_AGE_DAYS_SQL = 'CAST(julianday(@today) - julianday(i.last_touched) AS INTEGER)';
|
|
19
|
+
/** An entity row is stale when its age is past its threshold, not equal to
|
|
20
|
+
* it — `actionAge`'s rule. */
|
|
21
|
+
export const ENTITY_STALE_SQL = `${ENTITY_AGE_DAYS_SQL} > COALESCE(i.staleness_days, ${DEFAULT_STALENESS_DAYS})`;
|
|
22
|
+
/** How old an action added on `added` is today, and whether that is past
|
|
23
|
+
* `stalenessDays`: older than the threshold, not equal to it. */
|
|
24
|
+
export function actionAge(added, stalenessDays, today = localDay()) {
|
|
25
|
+
if (added === null)
|
|
26
|
+
return { added, age_days: null, stale: true };
|
|
27
|
+
const age_days = dayNumber(today) - dayNumber(added);
|
|
28
|
+
return { added, age_days, stale: age_days > stalenessDays };
|
|
29
|
+
}
|
|
30
|
+
/** An entity's threshold: its `staleness-days`, else the default. */
|
|
31
|
+
export function stalenessDaysOf(frontmatter) {
|
|
32
|
+
return frontmatter['staleness-days'] ?? DEFAULT_STALENESS_DAYS;
|
|
33
|
+
}
|
|
34
|
+
/** An entity body's Next Actions in file order, each open line aged against
|
|
35
|
+
* the entity's threshold. */
|
|
36
|
+
export function agedActions(body, frontmatter) {
|
|
37
|
+
const days = stalenessDaysOf(frontmatter);
|
|
38
|
+
return getActionItems(body).map(item => item.handle === undefined ? { text: item.text } : { handle: item.handle, text: item.text, ...actionAge(item.added, days) });
|
|
39
|
+
}
|
|
40
|
+
/** The Handles of an entity body's Stale lines, in file order. */
|
|
41
|
+
export function staleHandles(body, frontmatter) {
|
|
42
|
+
return agedActions(body, frontmatter).flatMap(a => 'handle' in a && a.stale ? [a.handle] : []);
|
|
43
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const STOPWORDS: ReadonlySet<string>;
|
|
2
|
+
/** Lowercased words with stopwords dropped. `stem` also strips a plural `s`,
|
|
3
|
+
* so `launchpads` and `launchpad` are one word. */
|
|
4
|
+
export declare function tokenize(text: string, { stem }?: {
|
|
5
|
+
stem?: boolean;
|
|
6
|
+
}): string[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// src/stopwords.ts
|
|
2
|
+
// Words too common to tell two texts apart: dropped from full-text search
|
|
3
|
+
// terms (db.ts) and, through `tokenize`, from the words the Near-match rule
|
|
4
|
+
// (near-match.ts) and the read-miss closest slugs (closest.ts) compare.
|
|
5
|
+
export const STOPWORDS = new Set([
|
|
6
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of',
|
|
7
|
+
'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
8
|
+
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
|
|
9
|
+
'may', 'might', 'can', 'shall', 'not', 'no', 'nor', 'so', 'if', 'then', 'than',
|
|
10
|
+
'that', 'this', 'these', 'those', 'it', 'its', 'my', 'your', 'his', 'her',
|
|
11
|
+
'our', 'their', 'what', 'which', 'who', 'whom', 'how', 'when', 'where', 'why',
|
|
12
|
+
'all', 'each', 'every', 'both', 'few', 'more', 'most', 'some', 'any', 'such',
|
|
13
|
+
'about', 'into', 'over', 'after', 'before',
|
|
14
|
+
'work', 'works', 'working', 'use', 'using', 'used', 'get', 'got', 'make', 'made',
|
|
15
|
+
'need', 'want', 'like', 'just', 'also', 'still', 'already', 'very', 'much',
|
|
16
|
+
]);
|
|
17
|
+
/** Lowercased words with stopwords dropped. `stem` also strips a plural `s`,
|
|
18
|
+
* so `launchpads` and `launchpad` are one word. */
|
|
19
|
+
export function tokenize(text, { stem = false } = {}) {
|
|
20
|
+
const words = text
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
23
|
+
.filter(w => w && !STOPWORDS.has(w));
|
|
24
|
+
return stem ? words.map(w => (w.length > 3 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w)) : words;
|
|
25
|
+
}
|