hafez 1.4.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 +26 -33
- package/dist/apply.d.ts +5 -2
- package/dist/apply.js +225 -114
- package/dist/batch-ops.d.ts +89 -26
- package/dist/batch-ops.js +176 -18
- package/dist/cli/args.d.ts +43 -5
- package/dist/cli/args.js +69 -8
- 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 +4 -3
- package/dist/cli/commands.js +300 -99
- package/dist/cli/format.d.ts +7 -3
- package/dist/cli/format.js +27 -10
- package/dist/cli/index.d.ts +18 -4
- package/dist/cli/index.js +72 -30
- 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 +13 -4
- package/dist/cli/upgrade.js +90 -44
- package/dist/closest.d.ts +8 -0
- package/dist/closest.js +65 -0
- package/dist/contracts.d.ts +17 -1
- package/dist/contracts.js +44 -0
- package/dist/db.d.ts +15 -2
- package/dist/db.js +69 -48
- package/dist/digest.d.ts +6 -4
- package/dist/digest.js +28 -17
- package/dist/document.d.ts +41 -3
- package/dist/document.js +218 -62
- package/dist/export-okf.d.ts +13 -0
- package/dist/export-okf.js +11 -7
- package/dist/harness.d.ts +58 -0
- package/dist/harness.js +50 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +94 -104
- package/dist/merge.js +7 -5
- package/dist/near-match.d.ts +9 -0
- package/dist/near-match.js +34 -0
- 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/types.d.ts +45 -16
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +109 -0
- package/dist/vault.js +1 -1
- 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 -210
- package/skill/SKILL.md +0 -137
package/dist/cli/upgrade.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// `hafez upgrade` — agent-directed update check. It CHECKS AND PRINTS; it
|
|
4
4
|
// never installs. The printed steps install code and overwrite the user's
|
|
5
5
|
// agent instructions, so the privileged step is the human's — same discipline
|
|
6
|
-
// as `onboard` (writes nothing
|
|
6
|
+
// as bare `onboard` (writes nothing; only `--install`/`--remove`, run after
|
|
7
|
+
// the user's yes, write outside the vault) and as the release skill
|
|
7
8
|
// (`npm publish` is the user's action). It also sidesteps a process replacing
|
|
8
9
|
// its own binary mid-run.
|
|
9
10
|
//
|
|
@@ -11,6 +12,11 @@
|
|
|
11
12
|
// or the native SQLite binding is unbuilt, which is exactly when someone is
|
|
12
13
|
// trying to upgrade. Nothing here touches the core.
|
|
13
14
|
//
|
|
15
|
+
// The skills and the Block are checked by their hashes, in every place
|
|
16
|
+
// `onboard --install` writes them (install.ts), and only where they are
|
|
17
|
+
// installed: no Block means invoke-only, never a Block to offer. Refreshing
|
|
18
|
+
// them is `onboard --install`'s job, so that is the step printed.
|
|
19
|
+
//
|
|
14
20
|
// Untrusted input: the registry response is rendered into a document an agent
|
|
15
21
|
// will execute commands from. Only the `version` field is read, only after it
|
|
16
22
|
// matches plain X.Y.Z, and no caught error's message, cause, or stack ever
|
|
@@ -21,8 +27,9 @@ import { existsSync, readFileSync } from 'fs';
|
|
|
21
27
|
import { homedir } from 'os';
|
|
22
28
|
import { dirname, join } from 'path';
|
|
23
29
|
import { fileURLToPath } from 'url';
|
|
24
|
-
import { UsageError } from './args.js';
|
|
25
|
-
import {
|
|
30
|
+
import { UsageError, helpIfAsked } from './args.js';
|
|
31
|
+
import { HARNESSES, detectAgent, isHarness } from '../harness.js';
|
|
32
|
+
import { checkInstall, fixBrokenMarkers } from './install.js';
|
|
26
33
|
const PLAIN_SEMVER = /^\d+\.\d+\.\d+$/;
|
|
27
34
|
const REGISTRY_TIMEOUT_MS = 3000;
|
|
28
35
|
const NPM_PROBE_TIMEOUT_MS = 3000;
|
|
@@ -122,28 +129,59 @@ function devCheckoutStep(packageRoot) {
|
|
|
122
129
|
}
|
|
123
130
|
return { kind: 'command', text: `cd '${packageRoot}' && ${DEV_CHECKOUT_WORK}` };
|
|
124
131
|
}
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return {
|
|
130
|
-
kind: 'action',
|
|
131
|
-
text: `No bundled skill at ${source}, so the installed copy at ${target} cannot be refreshed from this ` +
|
|
132
|
-
'package. Re-copy it from the installed hafez package after upgrading — see the README Updating section.',
|
|
133
|
-
};
|
|
132
|
+
function readIntegration(home) {
|
|
133
|
+
try {
|
|
134
|
+
const { skills, blocks } = checkInstall(home);
|
|
135
|
+
return { mode: blocks.length > 0 ? 'always-on' : 'invoke-only', blocks, skills };
|
|
134
136
|
}
|
|
135
|
-
|
|
136
|
-
return
|
|
137
|
+
catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const HARNESS_IDS = Object.keys(HARNESSES).join('|');
|
|
142
|
+
/** `onboard --install` writes both skills on every run, so a skill needs no
|
|
143
|
+
* run of its own when a Block's run is printed. Broken markers make that run
|
|
144
|
+
* refuse, so they get only the hand fix until it is done. When upgrading,
|
|
145
|
+
* every installed piece is refreshed: the new version renders new text, and
|
|
146
|
+
* this check can only compare against the running one's. */
|
|
147
|
+
function integrationSteps(integration, upgrading, env) {
|
|
148
|
+
if (integration === null) {
|
|
149
|
+
return [{ kind: 'action', text: 'The installed skills and Block could not be read, so they were not checked.' }];
|
|
150
|
+
}
|
|
151
|
+
const broken = integration.blocks.filter(b => b.state === 'broken');
|
|
152
|
+
if (broken.length > 0) {
|
|
153
|
+
return broken.map(b => ({
|
|
137
154
|
kind: 'action',
|
|
138
|
-
text: '
|
|
139
|
-
|
|
140
|
-
|
|
155
|
+
text: fixBrokenMarkers(b.path, 'hafez upgrade'),
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
const { blocks, skills } = integration;
|
|
159
|
+
const skillDrift = skills.some(s => s.state === 'stale' || s.state === 'hand-edited');
|
|
160
|
+
const blockDrift = (b) => b.state === 'stale' || (b.state === 'hand-edited' && b.renderChanged);
|
|
161
|
+
const installed = blocks.length > 0 || skills.some(s => s.state !== 'absent');
|
|
162
|
+
if (!installed || (!upgrading && !skillDrift && !blocks.some(blockDrift)))
|
|
163
|
+
return [];
|
|
164
|
+
if (blocks.length > 0) {
|
|
165
|
+
// Skills alone: any Block's run writes them, so pick one that run will not
|
|
166
|
+
// stop on to ask about a hand-edit the status says needs nothing.
|
|
167
|
+
const skillsOnly = [blocks.find(b => b.state !== 'hand-edited') ?? blocks[0]];
|
|
168
|
+
const targets = upgrading ? blocks : blocks.filter(blockDrift);
|
|
169
|
+
return (targets.length > 0 ? targets : skillsOnly).map(b => ({
|
|
170
|
+
kind: 'command',
|
|
171
|
+
text: `hafez onboard --install --mode always-on --harness ${b.harness}`,
|
|
172
|
+
}));
|
|
141
173
|
}
|
|
142
|
-
|
|
174
|
+
const harness = detectAgent(env);
|
|
175
|
+
if (isHarness(harness))
|
|
176
|
+
return [{ kind: 'command', text: `hafez onboard --install --mode invoke-only --harness ${harness}` }];
|
|
177
|
+
return [{
|
|
178
|
+
kind: 'action',
|
|
179
|
+
text: `Run \`hafez onboard --install --mode invoke-only --harness <${HARNESS_IDS}>\`, naming the harness you are running in.`,
|
|
180
|
+
}];
|
|
143
181
|
}
|
|
144
182
|
/** The action list, in execution order. Every command is built from static
|
|
145
|
-
* constants; the only interpolated values are the regex-validated version
|
|
146
|
-
* the quoted
|
|
183
|
+
* constants; the only interpolated values are the regex-validated version,
|
|
184
|
+
* the quoted package root, and harness ids from the HARNESSES table. */
|
|
147
185
|
function deriveSteps(state, ctx) {
|
|
148
186
|
const steps = [];
|
|
149
187
|
let alternativeInstall = null;
|
|
@@ -169,12 +207,7 @@ function deriveSteps(state, ctx) {
|
|
|
169
207
|
steps.push({ kind: 'action', text: ETARGET_NOTE });
|
|
170
208
|
}
|
|
171
209
|
}
|
|
172
|
-
|
|
173
|
-
// installed package, so on the main upgrade path the bundled copy is equally
|
|
174
|
-
// stale and any byte-compare reports "current" in exactly the silent-drift
|
|
175
|
-
// case detection would exist for. Copying an identical file is a no-op.
|
|
176
|
-
if (state.skill === 'present')
|
|
177
|
-
steps.push(skillCopyStep(state.skillSource, state.skillTarget, ctx.skillSourceExists));
|
|
210
|
+
steps.push(...integrationSteps(state.integration, upgrading, ctx.env));
|
|
178
211
|
// A SCHEMA_VERSION bump does not self-heal: the read-only path warns and
|
|
179
212
|
// returns null, so the first post-upgrade query answers *empty* until a
|
|
180
213
|
// write command or an explicit rebuild.
|
|
@@ -195,14 +228,9 @@ export async function buildUpgradeState(deps) {
|
|
|
195
228
|
npmMajor: probeNpmMajor(deps),
|
|
196
229
|
// Published tarballs carry only dist, skill, README.md and LICENSE.
|
|
197
230
|
install: exists(join(deps.packageRoot, 'src')) ? 'dev-checkout' : 'registry',
|
|
198
|
-
|
|
199
|
-
skillSource: join(deps.packageRoot, 'skill', 'SKILL.md'),
|
|
200
|
-
skillTarget: deps.installedSkillPath,
|
|
201
|
-
};
|
|
202
|
-
return {
|
|
203
|
-
...base,
|
|
204
|
-
...deriveSteps(base, { packageRoot: deps.packageRoot, skillSourceExists: exists(base.skillSource) }),
|
|
231
|
+
integration: readIntegration(deps.home),
|
|
205
232
|
};
|
|
233
|
+
return { ...base, ...deriveSteps(base, { packageRoot: deps.packageRoot, env: deps.env }) };
|
|
206
234
|
}
|
|
207
235
|
function versionLines(state) {
|
|
208
236
|
const lines = [`Installed version: ${state.current}`];
|
|
@@ -223,6 +251,31 @@ function versionLines(state) {
|
|
|
223
251
|
lines.push('Could not compare: the installed version is not a plain X.Y.Z. Check manually.');
|
|
224
252
|
return lines;
|
|
225
253
|
}
|
|
254
|
+
const BLOCK_STATE = {
|
|
255
|
+
current: () => 'current',
|
|
256
|
+
stale: () => 'stale',
|
|
257
|
+
'hand-edited': b => b.renderChanged
|
|
258
|
+
? "hand-edited, and the Block Hafez writes has changed since. `onboard --install` leaves it alone and prints a diff; replace it with --confirm only after the user's yes"
|
|
259
|
+
: 'hand-edited, over the current Block — nothing to update',
|
|
260
|
+
broken: () => 'broken markers',
|
|
261
|
+
};
|
|
262
|
+
function integrationLines(integration) {
|
|
263
|
+
if (integration === null)
|
|
264
|
+
return ['Skills and Block: could not be read.'];
|
|
265
|
+
const lines = integration.mode === 'always-on'
|
|
266
|
+
? ['Mode: always-on (a Block is installed).']
|
|
267
|
+
: [
|
|
268
|
+
'Mode: invoke-only (no Block in any instructions file Hafez writes); installing a Block is not offered.',
|
|
269
|
+
"A Block pasted into Cursor's User Rules lives in its Settings and is not checked here.",
|
|
270
|
+
];
|
|
271
|
+
for (const b of integration.blocks)
|
|
272
|
+
lines.push(`- Block in ${b.path}: ${BLOCK_STATE[b.state](b)}`);
|
|
273
|
+
for (const s of integration.skills) {
|
|
274
|
+
const lost = s.state === 'hand-edited' ? ' — `onboard --install` replaces it, and the edit is lost' : '';
|
|
275
|
+
lines.push(`- ${s.path}: ${s.state}${lost}`);
|
|
276
|
+
}
|
|
277
|
+
return lines;
|
|
278
|
+
}
|
|
226
279
|
export function renderUpgrade(state) {
|
|
227
280
|
const out = [
|
|
228
281
|
'# Hafez — Upgrade Check',
|
|
@@ -240,16 +293,7 @@ export function renderUpgrade(state) {
|
|
|
240
293
|
out.push('Install source: development checkout (this package has a src/ directory) — upgrade with git, not npm.');
|
|
241
294
|
}
|
|
242
295
|
out.push('');
|
|
243
|
-
out.push(state.
|
|
244
|
-
? `Skill: installed at ${state.skillTarget}. The skill ships inside the hafez package, so an upgraded package leaves this copy stale — the re-copy step is idempotent, run it either way.`
|
|
245
|
-
: `Skill: none at ${state.skillTarget} — nothing to re-copy (a non-Claude-Code harness, not drift).`);
|
|
246
|
-
out.push('');
|
|
247
|
-
out.push('Integration snippet — compare this against the Hafez section of the');
|
|
248
|
-
out.push("user's instructions file (Claude Code: ~/.claude/CLAUDE.md; any");
|
|
249
|
-
out.push('AGENTS.md harness: its global AGENTS.md) and reconcile differences');
|
|
250
|
-
out.push('with them. Do not rewrite their file unasked.');
|
|
251
|
-
out.push('');
|
|
252
|
-
out.push(INTEGRATION_SNIPPET);
|
|
296
|
+
out.push(...integrationLines(state.integration));
|
|
253
297
|
out.push('');
|
|
254
298
|
out.push('## Steps');
|
|
255
299
|
out.push('');
|
|
@@ -279,7 +323,8 @@ function defaultDeps() {
|
|
|
279
323
|
// The documented escape hatch for private registries and proxied networks.
|
|
280
324
|
registryUrl: process.env.HAFEZ_REGISTRY_URL || DEFAULT_REGISTRY_URL,
|
|
281
325
|
packageRoot: join(here, '..', '..'),
|
|
282
|
-
|
|
326
|
+
home: homedir(),
|
|
327
|
+
env: process.env,
|
|
283
328
|
// Bounded for the same reason the registry fetch is: a global prefix on a
|
|
284
329
|
// network mount or a cold cache can hang npm, and `upgrade` has to stay
|
|
285
330
|
// answerable when the environment is the thing that is broken. A timeout
|
|
@@ -295,6 +340,7 @@ const USAGE = 'Usage: hafez upgrade [--json]';
|
|
|
295
340
|
/** `deps` is the third parameter, unreachable from dispatch (PreVaultRun is
|
|
296
341
|
* `(args, ctx)`) — the tests inject it directly. */
|
|
297
342
|
export async function cmdUpgrade(args, ctx, deps = defaultDeps()) {
|
|
343
|
+
helpIfAsked(args, USAGE);
|
|
298
344
|
for (const arg of args) {
|
|
299
345
|
if (arg.startsWith('--'))
|
|
300
346
|
throw new UsageError(`Unknown flag ${arg}\n${USAGE}`);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface SlugCandidate {
|
|
2
|
+
slug: string;
|
|
3
|
+
name: string;
|
|
4
|
+
description: string | null;
|
|
5
|
+
}
|
|
6
|
+
/** Up to five slugs closest to `miss`, closest first. Ties go to the note
|
|
7
|
+
* whose name the miss covers most, then to slug order. */
|
|
8
|
+
export declare function closestSlugs(miss: string, candidates: SlugCandidate[]): string[];
|
package/dist/closest.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// src/closest.ts
|
|
2
|
+
//
|
|
3
|
+
// The closest slugs a `read` miss offers (#91). A miss is usually a slug
|
|
4
|
+
// guessed from a repo folder name, so the words of the miss are matched
|
|
5
|
+
// against each note's name and slug at full weight and its description at
|
|
6
|
+
// half. A note is close when its matched weight covers at least half the
|
|
7
|
+
// words of the miss.
|
|
8
|
+
import { tokenize } from './stopwords.js';
|
|
9
|
+
const LIMIT = 5;
|
|
10
|
+
/** Lowercased words with stopwords dropped and a plural `s` stripped, so
|
|
11
|
+
* `launchpads` and `launchpad` are one word. */
|
|
12
|
+
function words(text) {
|
|
13
|
+
return tokenize(text, { stem: true });
|
|
14
|
+
}
|
|
15
|
+
/** At most one insertion, deletion or substitution apart. */
|
|
16
|
+
function oneEditApart(a, b) {
|
|
17
|
+
if (Math.abs(a.length - b.length) > 1)
|
|
18
|
+
return false;
|
|
19
|
+
let i = 0;
|
|
20
|
+
while (i < a.length && i < b.length && a[i] === b[i])
|
|
21
|
+
i++;
|
|
22
|
+
if (a.length === b.length)
|
|
23
|
+
return a.slice(i + 1) === b.slice(i + 1);
|
|
24
|
+
return a.length > b.length ? a.slice(i + 1) === b.slice(i) : a.slice(i) === b.slice(i + 1);
|
|
25
|
+
}
|
|
26
|
+
/** A word of the miss matches a word of a note when equal, when the miss cut
|
|
27
|
+
* it short (four letters or more), or when one typo apart (five or more). */
|
|
28
|
+
function matches(missWord, word) {
|
|
29
|
+
if (missWord === word)
|
|
30
|
+
return true;
|
|
31
|
+
if (missWord.length >= 4 && word.startsWith(missWord))
|
|
32
|
+
return true;
|
|
33
|
+
return Math.min(missWord.length, word.length) >= 5 && oneEditApart(missWord, word);
|
|
34
|
+
}
|
|
35
|
+
/** Up to five slugs closest to `miss`, closest first. Ties go to the note
|
|
36
|
+
* whose name the miss covers most, then to slug order. */
|
|
37
|
+
export function closestSlugs(miss, candidates) {
|
|
38
|
+
const missWords = words(miss);
|
|
39
|
+
if (missWords.length === 0)
|
|
40
|
+
return [];
|
|
41
|
+
const scored = candidates.map(c => {
|
|
42
|
+
const nameWords = [...new Set([...words(c.name), ...words(c.slug)])];
|
|
43
|
+
const descriptionWords = words(c.description ?? '');
|
|
44
|
+
let weight = 0;
|
|
45
|
+
const covered = new Set();
|
|
46
|
+
for (const m of missWords) {
|
|
47
|
+
const hit = nameWords.filter(w => matches(m, w));
|
|
48
|
+
if (hit.length > 0) {
|
|
49
|
+
weight += 1;
|
|
50
|
+
for (const w of hit)
|
|
51
|
+
covered.add(w);
|
|
52
|
+
}
|
|
53
|
+
else if (descriptionWords.some(w => matches(m, w))) {
|
|
54
|
+
weight += 0.5;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const coverage = nameWords.length > 0 ? covered.size / nameWords.length : 0;
|
|
58
|
+
return { slug: c.slug, score: weight / missWords.length, coverage };
|
|
59
|
+
});
|
|
60
|
+
return scored
|
|
61
|
+
.filter(s => s.score >= 0.5)
|
|
62
|
+
.sort((a, b) => b.score - a.score || b.coverage - a.coverage || a.slug.localeCompare(b.slug))
|
|
63
|
+
.slice(0, LIMIT)
|
|
64
|
+
.map(s => s.slug);
|
|
65
|
+
}
|
package/dist/contracts.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { KnowledgeSubtype } from './types.js';
|
|
1
|
+
import type { KnowledgeSubtype, UpdateFields } from './types.js';
|
|
2
2
|
export declare const ENTITY_TYPES: readonly ["capture", "entity", "project"];
|
|
3
3
|
export declare const ENTITY_STATUSES: readonly ["active", "paused", "done"];
|
|
4
4
|
export declare const SESSION_LOG_TYPES: readonly ["progress", "decision", "blocker", "research"];
|
|
@@ -73,3 +73,19 @@ export declare function unreachableFields(target: DocTarget, fields: Record<stri
|
|
|
73
73
|
}>;
|
|
74
74
|
/** Human label for a target, used in every gate's error text. */
|
|
75
75
|
export declare function describeTarget(target: DocTarget): string;
|
|
76
|
+
/** Which Footer a document's `read` ends with. Entity and project share one;
|
|
77
|
+
* a capture's is the promote line, since it is sorted before it is written to. */
|
|
78
|
+
export type FooterForm = 'entity' | 'insight' | 'plan' | 'capture';
|
|
79
|
+
export declare function footerForm(target: DocTarget): FooterForm;
|
|
80
|
+
/** The update fields each Footer names, in the order it names them. Every
|
|
81
|
+
* other field that contract's update accepts is in WRITE_LINE_OMITS with its
|
|
82
|
+
* reason; drift check 3 (tests/read-footer.test.ts) holds the two together
|
|
83
|
+
* against the Op Spec update shape and the kind gate. */
|
|
84
|
+
export declare const WRITE_LINE_FIELDS: {
|
|
85
|
+
readonly entity: readonly ["session_log", "complete_action", "remove_action", "add_actions", "brief", "current_state", "related", "parent"];
|
|
86
|
+
readonly insight: readonly ["add_evidence", "add_source", "synthesis", "confidence", "domain", "related"];
|
|
87
|
+
readonly plan: readonly ["status", "domain", "related"];
|
|
88
|
+
};
|
|
89
|
+
export type WriteLineField = (typeof WRITE_LINE_FIELDS)[keyof typeof WRITE_LINE_FIELDS][number];
|
|
90
|
+
/** Update fields a contract accepts that its Footer leaves out, and why. */
|
|
91
|
+
export declare const WRITE_LINE_OMITS: Record<Exclude<FooterForm, 'capture'>, Partial<Record<keyof UpdateFields, string>>>;
|
package/dist/contracts.js
CHANGED
|
@@ -172,3 +172,47 @@ export function describeTarget(target) {
|
|
|
172
172
|
? `type '${target.type}'`
|
|
173
173
|
: `knowledge subtype '${target.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE}'`;
|
|
174
174
|
}
|
|
175
|
+
export function footerForm(target) {
|
|
176
|
+
if (target.kind === 'knowledge')
|
|
177
|
+
return target.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE;
|
|
178
|
+
return target.type === 'capture' ? 'capture' : 'entity';
|
|
179
|
+
}
|
|
180
|
+
/** The update fields each Footer names, in the order it names them. Every
|
|
181
|
+
* other field that contract's update accepts is in WRITE_LINE_OMITS with its
|
|
182
|
+
* reason; drift check 3 (tests/read-footer.test.ts) holds the two together
|
|
183
|
+
* against the Op Spec update shape and the kind gate. */
|
|
184
|
+
export const WRITE_LINE_FIELDS = {
|
|
185
|
+
entity: ['session_log', 'complete_action', 'remove_action', 'add_actions', 'brief', 'current_state', 'related', 'parent'],
|
|
186
|
+
insight: ['add_evidence', 'add_source', 'synthesis', 'confidence', 'domain', 'related'],
|
|
187
|
+
plan: ['status', 'domain', 'related'],
|
|
188
|
+
};
|
|
189
|
+
const SET_AT_CREATE = 'set at create, rarely rewritten; `hafez schema update` has it';
|
|
190
|
+
const INSIGHT_ALIAS = 'an alias of synthesis';
|
|
191
|
+
const PLAN_BODY = 'a plan\'s body is Goal, Steps and Dependencies, which no op writes';
|
|
192
|
+
/** Update fields a contract accepts that its Footer leaves out, and why. */
|
|
193
|
+
export const WRITE_LINE_OMITS = {
|
|
194
|
+
entity: {
|
|
195
|
+
status: 'a lifecycle change, not a write-back: the `hafez` skill\'s rare ops carry the status line',
|
|
196
|
+
agent: 'detected from the Harness; the override is never shown',
|
|
197
|
+
add_action: 'the one-line form of add_actions, written by the same flag',
|
|
198
|
+
clear_actions: 'drops lines unseen; the Footer drops one line by its Handle',
|
|
199
|
+
description: SET_AT_CREATE,
|
|
200
|
+
resource: SET_AT_CREATE,
|
|
201
|
+
domain: SET_AT_CREATE,
|
|
202
|
+
tags: SET_AT_CREATE,
|
|
203
|
+
},
|
|
204
|
+
insight: {
|
|
205
|
+
insight: INSIGHT_ALIAS,
|
|
206
|
+
description: SET_AT_CREATE,
|
|
207
|
+
tags: SET_AT_CREATE,
|
|
208
|
+
},
|
|
209
|
+
plan: {
|
|
210
|
+
synthesis: PLAN_BODY,
|
|
211
|
+
add_evidence: PLAN_BODY,
|
|
212
|
+
add_source: PLAN_BODY,
|
|
213
|
+
insight: INSIGHT_ALIAS,
|
|
214
|
+
confidence: 'an insight matures by confidence; a plan moves by status',
|
|
215
|
+
description: SET_AT_CREATE,
|
|
216
|
+
tags: SET_AT_CREATE,
|
|
217
|
+
},
|
|
218
|
+
};
|
package/dist/db.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type VaultIndexRow } from './knowledge-index.js';
|
|
2
|
-
import type { SearchResult, VaultStats, QueryResult, KnowledgeQueryResult, UnifiedResult, EntityQueryOpts, KnowledgeQueryOpts, UnifiedQueryOpts } from './types.js';
|
|
3
|
-
export declare const SCHEMA_VERSION = "
|
|
2
|
+
import type { SearchResult, VaultStats, QueryResult, KnowledgeQueryResult, UnifiedResult, EntityQueryOpts, KnowledgeQueryOpts, UnifiedQueryOpts, SortField } from './types.js';
|
|
3
|
+
export declare const SCHEMA_VERSION = "11";
|
|
4
4
|
interface IndexItem {
|
|
5
5
|
slug: string;
|
|
6
6
|
kind: 'entity' | 'knowledge';
|
|
@@ -90,4 +90,17 @@ export declare function createIndex(vaultPath: string): HafezIndex;
|
|
|
90
90
|
export declare function createIndex(vaultPath: string, opts: {
|
|
91
91
|
readonly: true;
|
|
92
92
|
}): HafezIndex | null;
|
|
93
|
+
/** Opts a branch cannot map into SQL. Declared per branch and rejected on
|
|
94
|
+
* sight: the CLI has rejected these since 43124ba, but the rule lived only
|
|
95
|
+
* there, so a library caller got the silent drop the CLI had already closed.
|
|
96
|
+
* Asserted against the CLI's flag matrix in query-surface.test.ts. */
|
|
97
|
+
export declare const ENTITY_ONLY_OPTS: readonly ["filter", "type", "parent"];
|
|
98
|
+
export declare const KNOWLEDGE_ONLY_OPTS: readonly ["confidence", "subtype"];
|
|
99
|
+
/** Every opt any branch might carry, flattened. Only the unified read uses it,
|
|
100
|
+
* and only to check: the checks must see an opt the caller's own type forbids,
|
|
101
|
+
* because an untyped caller can still send it. */
|
|
102
|
+
export type UnmappableCheckOpts = Omit<EntityQueryOpts, 'sort_by'> & Omit<KnowledgeQueryOpts, 'sort_by'> & {
|
|
103
|
+
kind?: 'entity' | 'knowledge' | 'all';
|
|
104
|
+
sort_by?: SortField;
|
|
105
|
+
};
|
|
93
106
|
export {};
|
package/dist/db.js
CHANGED
|
@@ -7,6 +7,9 @@ import { ensureLocalExclude } from './knowledge-index.js';
|
|
|
7
7
|
import { WIKI_LINK_RE, documentForFile } from './document.js';
|
|
8
8
|
import { ENTITY_STATUSES, ENTITY_TYPES, CONFIDENCE_LEVELS, VALID_SUBTYPES, DEFAULT_KNOWLEDGE_SUBTYPE } from './contracts.js';
|
|
9
9
|
import { HafezError } from './types.js';
|
|
10
|
+
import { STOPWORDS } from './stopwords.js';
|
|
11
|
+
import { ENTITY_AGE_DAYS_SQL, ENTITY_STALE_SQL } from './stale.js';
|
|
12
|
+
import { localDay } from './dates.js';
|
|
10
13
|
// Bump this when indexing logic changes to force a full rebuild.
|
|
11
14
|
// v5: wiki link harvesting (mention relations), parent link indexing, relation filter on queries.
|
|
12
15
|
// v6: description column on items.
|
|
@@ -18,8 +21,10 @@ import { HafezError } from './types.js';
|
|
|
18
21
|
// v10: re-indexing an externally changed file is delete-then-insert instead of
|
|
19
22
|
// INSERT OR REPLACE. Indexes written by v9 and earlier may hold ghost FTS
|
|
20
23
|
// rows (REPLACE never fired the delete trigger) and must be rebuilt, not reused.
|
|
24
|
+
// v11: next_action and the searchable body store action text with its
|
|
25
|
+
// added-date stamp stripped.
|
|
21
26
|
// Existing instances will fully rebuild the index on first access after upgrade.
|
|
22
|
-
export const SCHEMA_VERSION = '
|
|
27
|
+
export const SCHEMA_VERSION = '11';
|
|
23
28
|
const SCHEMA = `
|
|
24
29
|
CREATE TABLE IF NOT EXISTS items (
|
|
25
30
|
slug TEXT PRIMARY KEY,
|
|
@@ -79,18 +84,6 @@ CREATE TABLE IF NOT EXISTS meta (
|
|
|
79
84
|
value TEXT NOT NULL
|
|
80
85
|
);
|
|
81
86
|
`;
|
|
82
|
-
const STOPWORDS = new Set([
|
|
83
|
-
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of',
|
|
84
|
-
'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
85
|
-
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
|
|
86
|
-
'may', 'might', 'can', 'shall', 'not', 'no', 'nor', 'so', 'if', 'then', 'than',
|
|
87
|
-
'that', 'this', 'these', 'those', 'it', 'its', 'my', 'your', 'his', 'her',
|
|
88
|
-
'our', 'their', 'what', 'which', 'who', 'whom', 'how', 'when', 'where', 'why',
|
|
89
|
-
'all', 'each', 'every', 'both', 'few', 'more', 'most', 'some', 'any', 'such',
|
|
90
|
-
'about', 'into', 'over', 'after', 'before',
|
|
91
|
-
'work', 'works', 'working', 'use', 'using', 'used', 'get', 'got', 'make', 'made',
|
|
92
|
-
'need', 'want', 'like', 'just', 'also', 'still', 'already', 'very', 'much',
|
|
93
|
-
]);
|
|
94
87
|
function hashContent(content) {
|
|
95
88
|
return createHash('sha256').update(content).digest('hex');
|
|
96
89
|
}
|
|
@@ -213,13 +206,8 @@ function queryItemsFn(db, opts) {
|
|
|
213
206
|
if (opts.filter === 'stale') {
|
|
214
207
|
conditions.push("i.kind = 'entity'");
|
|
215
208
|
conditions.push("i.status = 'active'");
|
|
216
|
-
conditions.push(`(
|
|
217
|
-
|
|
218
|
-
CASE
|
|
219
|
-
WHEN i.staleness_days IS NOT NULL THEN i.staleness_days
|
|
220
|
-
ELSE 14
|
|
221
|
-
END
|
|
222
|
-
)`);
|
|
209
|
+
conditions.push(`(${ENTITY_STALE_SQL})`);
|
|
210
|
+
params.today = localDay();
|
|
223
211
|
}
|
|
224
212
|
if (opts.filter === 'capture') {
|
|
225
213
|
conditions.push("i.type = 'capture'");
|
|
@@ -256,14 +244,13 @@ function queryItemsFn(db, opts) {
|
|
|
256
244
|
assertCount(opts.offset, 'offset');
|
|
257
245
|
let sortCol = 'i.last_touched';
|
|
258
246
|
if (opts.sort_by) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (
|
|
262
|
-
|
|
247
|
+
// Same rule as the unmappable opts, one code path over: a sort field the
|
|
248
|
+
// branch cannot honour is rejected, not quietly swapped for recency.
|
|
249
|
+
if (KNOWLEDGE_ONLY_SORTS.has(opts.sort_by) && opts.kind === 'entity') {
|
|
250
|
+
outOfScope('sort_by', opts.sort_by, 'entity');
|
|
263
251
|
}
|
|
264
|
-
|
|
252
|
+
if (SORT_COL_MAP[opts.sort_by])
|
|
265
253
|
sortCol = SORT_COL_MAP[opts.sort_by];
|
|
266
|
-
}
|
|
267
254
|
}
|
|
268
255
|
let sortOrder;
|
|
269
256
|
if (opts.sort_order) {
|
|
@@ -379,16 +366,11 @@ function getStatsFn(db) {
|
|
|
379
366
|
by_type[row.type] = row.cnt;
|
|
380
367
|
}
|
|
381
368
|
const stale = db.prepare(`
|
|
382
|
-
SELECT slug, name, type,
|
|
383
|
-
FROM items
|
|
384
|
-
WHERE kind = 'entity' AND status = 'active'
|
|
385
|
-
AND julianday('now') - julianday(last_touched) >
|
|
386
|
-
CASE
|
|
387
|
-
WHEN staleness_days IS NOT NULL THEN staleness_days
|
|
388
|
-
ELSE 14
|
|
389
|
-
END
|
|
369
|
+
SELECT slug, name, type, ${ENTITY_AGE_DAYS_SQL} as days_since_touched
|
|
370
|
+
FROM items i
|
|
371
|
+
WHERE kind = 'entity' AND status = 'active' AND ${ENTITY_STALE_SQL}
|
|
390
372
|
ORDER BY days_since_touched DESC
|
|
391
|
-
`).all();
|
|
373
|
+
`).all({ today: localDay() });
|
|
392
374
|
const no_next_action = db.prepare(`
|
|
393
375
|
SELECT slug, name, type FROM items
|
|
394
376
|
WHERE kind = 'entity' AND status = 'active' AND next_action_count = 0
|
|
@@ -627,7 +609,8 @@ export function createIndex(vaultPath, opts) {
|
|
|
627
609
|
last_reinforced: fm['last-reinforced'] ?? null,
|
|
628
610
|
created: fm.created,
|
|
629
611
|
last_touched: fm['last-touched'] ?? null,
|
|
630
|
-
|
|
612
|
+
// Search sees action text, not the added-date stamp (file format only).
|
|
613
|
+
body: doc.stripNextActionStamps(body),
|
|
631
614
|
file_hash: hash,
|
|
632
615
|
subtype: kind === 'knowledge' ? (fm.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE) : null,
|
|
633
616
|
description: fm.description ?? null,
|
|
@@ -878,6 +861,27 @@ function validateEntityFilterOpts(opts) {
|
|
|
878
861
|
assertEnum(opts?.filter, QUERY_FILTERS, 'filter');
|
|
879
862
|
assertEnum(opts?.type, ENTITY_TYPES, 'type');
|
|
880
863
|
}
|
|
864
|
+
/** Opts a branch cannot map into SQL. Declared per branch and rejected on
|
|
865
|
+
* sight: the CLI has rejected these since 43124ba, but the rule lived only
|
|
866
|
+
* there, so a library caller got the silent drop the CLI had already closed.
|
|
867
|
+
* Asserted against the CLI's flag matrix in query-surface.test.ts. */
|
|
868
|
+
export const ENTITY_ONLY_OPTS = ['filter', 'type', 'parent'];
|
|
869
|
+
export const KNOWLEDGE_ONLY_OPTS = ['confidence', 'subtype'];
|
|
870
|
+
/** The one sentence both scope rejections speak — opts here, sort fields in
|
|
871
|
+
* queryItemsFn — so a caller sees the same shape whichever one they hit. */
|
|
872
|
+
function outOfScope(label, name, branch) {
|
|
873
|
+
const belongsTo = branch === 'entity' ? 'knowledge notes only' : 'entities only';
|
|
874
|
+
throw new HafezError('VALIDATION_FAILED', `Invalid ${label} for a ${branch} query: "${name}" — ${belongsTo}. Use kind: 'all' to query both.`);
|
|
875
|
+
}
|
|
876
|
+
function rejectUnmappableOpts(opts, reject, branch) {
|
|
877
|
+
if (!opts)
|
|
878
|
+
return;
|
|
879
|
+
// Only a *defined* value is a request — an explicit `undefined` (what a
|
|
880
|
+
// spread of unset CLI flags produces) asks for nothing.
|
|
881
|
+
for (const name of reject)
|
|
882
|
+
if (opts[name] !== undefined)
|
|
883
|
+
outOfScope('opt', name, branch);
|
|
884
|
+
}
|
|
881
885
|
function validateKnowledgeFilterOpts(opts) {
|
|
882
886
|
assertEnum(opts?.confidence, CONFIDENCE_LEVELS, 'confidence');
|
|
883
887
|
assertEnum(opts?.subtype, VALID_SUBTYPES, 'subtype');
|
|
@@ -885,7 +889,9 @@ function validateKnowledgeFilterOpts(opts) {
|
|
|
885
889
|
function queryEntitiesFn(index, opts) {
|
|
886
890
|
index.syncIfStale();
|
|
887
891
|
validateEntityFilterOpts(opts);
|
|
888
|
-
validateKnowledgeFilterOpts
|
|
892
|
+
// Was validateKnowledgeFilterOpts — it checked the *value* of an opt this
|
|
893
|
+
// branch then discarded, so a valid-looking one returned unfiltered.
|
|
894
|
+
rejectUnmappableOpts(opts, KNOWLEDGE_ONLY_OPTS, 'entity');
|
|
889
895
|
validateTimeOpts(opts);
|
|
890
896
|
validateListOpts(opts);
|
|
891
897
|
const qopts = { kind: 'entity' };
|
|
@@ -908,6 +914,7 @@ function queryRelatedToFn(index, slug, relation) {
|
|
|
908
914
|
function queryKnowledgeFn(index, opts) {
|
|
909
915
|
index.syncIfStale();
|
|
910
916
|
validateKnowledgeFilterOpts(opts);
|
|
917
|
+
rejectUnmappableOpts(opts, ENTITY_ONLY_OPTS, 'knowledge');
|
|
911
918
|
validateTimeOpts(opts);
|
|
912
919
|
validateListOpts(opts);
|
|
913
920
|
const qopts = { kind: 'knowledge' };
|
|
@@ -919,19 +926,33 @@ function queryKnowledgeFn(index, opts) {
|
|
|
919
926
|
}
|
|
920
927
|
function queryUnifiedFn(index, opts) {
|
|
921
928
|
index.syncIfStale();
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
929
|
+
// The public type is a union discriminated on `kind`, so an out-of-scope opt
|
|
930
|
+
// is a compile error. The runtime still has to see every opt to reject it —
|
|
931
|
+
// an untyped caller (JS, JSON over MCP) reaches here regardless — so widen
|
|
932
|
+
// once, here, and let the checks below speak for both.
|
|
933
|
+
const o = opts;
|
|
934
|
+
assertEnum(o?.kind, ['entity', 'knowledge', 'all'], 'kind');
|
|
935
|
+
validateEntityFilterOpts(o);
|
|
936
|
+
validateKnowledgeFilterOpts(o);
|
|
937
|
+
// Only `kind: 'all'` (or unset) maps both sides. `kind` also narrows this
|
|
938
|
+
// query to one of them, and a narrowed branch maps no more than the
|
|
939
|
+
// dedicated one does: the out-of-scope conditions land on columns the
|
|
940
|
+
// selected kind leaves NULL, so every row is excluded and the caller gets
|
|
941
|
+
// 0 results with exit 0 — the same wrong answer, one function over.
|
|
942
|
+
if (o?.kind === 'entity')
|
|
943
|
+
rejectUnmappableOpts(o, KNOWLEDGE_ONLY_OPTS, 'entity');
|
|
944
|
+
if (o?.kind === 'knowledge')
|
|
945
|
+
rejectUnmappableOpts(o, ENTITY_ONLY_OPTS, 'knowledge');
|
|
946
|
+
validateTimeOpts(o);
|
|
947
|
+
validateListOpts(o);
|
|
927
948
|
const qopts = {};
|
|
928
|
-
if (
|
|
929
|
-
qopts.kind =
|
|
949
|
+
if (o?.kind && o.kind !== 'all')
|
|
950
|
+
qopts.kind = o.kind;
|
|
930
951
|
// When kind is 'all' or unset, no kind filter — both entities and knowledge are returned
|
|
931
|
-
mapEntityFilterOpts(qopts,
|
|
932
|
-
mapKnowledgeFilterOpts(qopts,
|
|
933
|
-
mapTimeOpts(qopts,
|
|
934
|
-
mapListOpts(qopts,
|
|
952
|
+
mapEntityFilterOpts(qopts, o);
|
|
953
|
+
mapKnowledgeFilterOpts(qopts, o);
|
|
954
|
+
mapTimeOpts(qopts, o);
|
|
955
|
+
mapListOpts(qopts, o);
|
|
935
956
|
const result = index.queryItems(qopts);
|
|
936
957
|
const items = result.items.map(i => {
|
|
937
958
|
if (i.kind === 'entity') {
|
package/dist/digest.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type
|
|
2
|
+
import { type BatchOperation } from './types.js';
|
|
3
3
|
export declare const DigestInputSchema: z.ZodObject<{
|
|
4
4
|
entities_touched: z.ZodArray<z.ZodString>;
|
|
5
5
|
decisions: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
6
6
|
narrative: z.ZodString;
|
|
7
7
|
session_date: z.ZodString;
|
|
8
|
-
agent: z.
|
|
8
|
+
agent: z.ZodOptional<z.ZodString>;
|
|
9
9
|
}, z.core.$strip>;
|
|
10
10
|
export type DigestInput = z.infer<typeof DigestInputSchema>;
|
|
11
11
|
/**
|
|
@@ -24,6 +24,8 @@ export declare function parseDigestInput(raw: unknown): {
|
|
|
24
24
|
/**
|
|
25
25
|
* Convert session context into a hafez batch payload.
|
|
26
26
|
* Pure function — no file I/O, no vault access.
|
|
27
|
-
*
|
|
27
|
+
* An unknown slug in entities_touched is a NOT_FOUND naming it (and only the
|
|
28
|
+
* unknown ones), never a session log that points nowhere. A knowledge slug is
|
|
29
|
+
* known: the note links it, but a session log is entity-only.
|
|
28
30
|
*/
|
|
29
|
-
export declare function digest(input: DigestInput,
|
|
31
|
+
export declare function digest(input: DigestInput, entitySlugs: Set<string>, knowledgeSlugs?: Set<string>): BatchOperation[];
|