hafez 2.1.0 → 2.2.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 +24 -4
- package/dist/apply.d.ts +2 -1
- package/dist/apply.js +16 -19
- package/dist/batch-ops.js +3 -1
- package/dist/cli/block-hook.js +24 -9
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +106 -48
- package/dist/cli/format.d.ts +8 -3
- package/dist/cli/format.js +42 -4
- package/dist/cli/index.js +29 -17
- package/dist/cli/install.d.ts +9 -0
- package/dist/cli/install.js +36 -15
- package/dist/cli/onboard.js +30 -9
- package/dist/cli/upgrade.js +4 -3
- package/dist/dates.d.ts +4 -0
- package/dist/dates.js +12 -1
- package/dist/db.d.ts +18 -8
- package/dist/db.js +96 -52
- package/dist/git.js +74 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +30 -10
- package/dist/knowledge-index.d.ts +1 -1
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +11 -0
- package/dist/surfaces/items.d.ts +56 -1
- package/dist/surfaces/items.js +84 -5
- package/dist/surfaces/surfaces.d.ts +8 -5
- package/dist/surfaces/surfaces.js +31 -16
- package/dist/types.d.ts +63 -19
- package/dist/validate.js +44 -56
- package/dist/vault.d.ts +20 -7
- package/dist/vault.js +29 -8
- package/package.json +2 -2
- package/skill/hafez/SKILL.md +5 -2
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Hafez
|
|
2
2
|
|
|
3
|
+
[](https://github.com/shrvn-r/hafez/actions/workflows/public.yml)
|
|
4
|
+
|
|
3
5
|
**A knowledge and project vault shared between you and your agents.** *Hafez* (حافظ) is Persian for "the keeper — the one who memorizes," after the poet. Plain markdown files you own, backed by SQLite full-text search and git.
|
|
4
6
|
|
|
5
7
|
## Why
|
|
@@ -77,6 +79,23 @@ plus `--allow-remote=root`, which npm 12+ requires for a remote URL:
|
|
|
77
79
|
npm install -g https://github.com/shrvn-r/hafez/releases/latest/download/hafez.tgz --allow-scripts=better-sqlite3 --allow-remote=root
|
|
78
80
|
```
|
|
79
81
|
|
|
82
|
+
### Cloud sessions
|
|
83
|
+
|
|
84
|
+
A Claude Code cloud session (claude.ai/code, the mobile app) starts in a fresh
|
|
85
|
+
container: no `hafez`, no vault, no Block. Keep the vault in a private GitHub
|
|
86
|
+
repo and print the environment's setup script for it:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
hafez onboard --cloud --vault-repo <owner>/<vault-repo>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Paste the script into the cloud environment's Setup script; the Trusted network
|
|
93
|
+
policy is enough. It installs Hafez, registers the vault when it is attached to
|
|
94
|
+
the session, and writes a Block with one rule more: a session started without
|
|
95
|
+
the vault attached attaches it, clones it and registers it, with one approval
|
|
96
|
+
from you. The script never clones the vault itself, because it runs without
|
|
97
|
+
GitHub credentials. Writes push to the vault's `main` like anywhere else.
|
|
98
|
+
|
|
80
99
|
## Updating
|
|
81
100
|
|
|
82
101
|
Tell your agent: **update hafez**. Or paste: "Run `hafez upgrade` and follow
|
|
@@ -161,7 +180,7 @@ Three kinds of documents, all markdown + YAML frontmatter:
|
|
|
161
180
|
|
|
162
181
|
- **Entities** (`entities/*.md`) — the things you're working on. Types: `capture` (inbox item), `entity` (anything worth tracking), `project` (has outcomes). Statuses: `active | paused | done`. Bodies start from a per-type template (capture: `## Notes`; entity: `## Context`; project: `## Purpose` + `## Goals`) plus a `## Session Log` of dated agent entries; `## Brief` (freeform handoff context) and `## Next Actions` (checkbox list) appear when first written to.
|
|
163
182
|
- **Knowledge** (`knowledge/*.md`) — durable insights that outlive any one project. Subtypes `insight | plan`; confidence levels `observation → pattern → principle` (promoted manually). Bodies hold `## Synthesis`, `## Evidence`, and `## Sources` — evidence accumulates over time via `--add-evidence`.
|
|
164
|
-
- **Sessions** (`sessions/*.md`) — session summaries written
|
|
183
|
+
- **Sessions** (`sessions/*.md`) — session summaries written at save time, never updated. Readable (`hafez read <slug>`), listed newest first (`hafez query --kind session`), and the look back from an entity (`hafez query --related-to <slug>`); search and query reach them only under `--kind session` or `--kind all`.
|
|
165
184
|
|
|
166
185
|
Frontmatter at a glance:
|
|
167
186
|
|
|
@@ -181,9 +200,9 @@ Links (`parent`, `related`) are validated — they must point at slugs that exis
|
|
|
181
200
|
|
|
182
201
|
| Command | What it does |
|
|
183
202
|
|---|---|
|
|
184
|
-
| `hafez read <slug> [--depth frontmatter\|summary\|full]` | Read an entity or
|
|
185
|
-
| `hafez query [--filter active\|paused\|done\|stale\|capture\|all] [--type] [--kind entity\|knowledge\|all] [--domain] [--tag] [--parent] [--related-to] [--limit]` | Filter and list |
|
|
186
|
-
| `hafez search <terms> [--kind
|
|
203
|
+
| `hafez read <slug> [--depth frontmatter\|summary\|full] [--archive]` | Read an entity, knowledge or session note; `--archive` an entity's archived log entries |
|
|
204
|
+
| `hafez query [--filter active\|paused\|done\|stale\|capture\|all] [--type] [--kind entity\|knowledge\|session\|all] [--domain] [--tag] [--parent] [--related-to] [--limit]` | Filter and list |
|
|
205
|
+
| `hafez search <terms> [--kind entity\|knowledge\|session\|all]` | FTS5 full-text search (default: entities and knowledge) |
|
|
187
206
|
| `hafez create <entity\|knowledge> <name> [flags]` | Create a document |
|
|
188
207
|
| `hafez capture <name> [--notes]` | Quick inbox capture |
|
|
189
208
|
| `hafez promote <slug> <entity\|project\|knowledge>` | Promote a capture upward |
|
|
@@ -197,6 +216,7 @@ Links (`parent`, `related`) are validated — they must point at slugs that exis
|
|
|
197
216
|
| `hafez validate` / `hafez index rebuild` | Integrity check / rebuild the read index |
|
|
198
217
|
| `hafez onboard` | Setup status, then the script your agent follows: Mode, install, permissions, seeding |
|
|
199
218
|
| `hafez onboard --install --mode <always-on\|invoke-only>` / `--remove` | Write (or remove) the two skills and, always-on, the marked Block in the harness's instructions file |
|
|
219
|
+
| `hafez onboard --cloud --vault-repo <owner>/<repo>` | Print the setup script for Claude Code cloud sessions |
|
|
200
220
|
| `hafez schema [op] [--examples]` | Machine-readable JSON schema for every operation |
|
|
201
221
|
| `hafez export --okf [--out <dir>]` | Read-only OKF v0.1 bundle export |
|
|
202
222
|
|
package/dist/apply.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type VaultKind } from './vault.js';
|
|
1
2
|
import type { BatchOperation, BatchResult, CreateEntityFields, DeletableKind, CreateKnowledgeFields, KnowledgeSubtype, UpdateFields, UpdateResult } from './types.js';
|
|
2
3
|
export interface AppliedOp {
|
|
3
4
|
filesWritten: string[];
|
|
@@ -7,7 +8,7 @@ export interface AppliedOp {
|
|
|
7
8
|
indexRemovals: string[];
|
|
8
9
|
indexUpserts: Array<{
|
|
9
10
|
slug: string;
|
|
10
|
-
kind:
|
|
11
|
+
kind: VaultKind;
|
|
11
12
|
}>;
|
|
12
13
|
/** Set by the update arm: the line each Next Actions query matched. */
|
|
13
14
|
updateResult?: UpdateResult;
|
package/dist/apply.js
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
// pure (no filesystem access); the frontmatter builders live here and are
|
|
19
19
|
// exported so the batch validate phase can run them against a simulated vault.
|
|
20
20
|
import { writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from 'fs';
|
|
21
|
-
import { join,
|
|
22
|
-
import { parseFilePath, serializeFile, slugify, resolveFilePath, kindFromPath, kindDir, archiveLogPath, findVaultFile, vaultFileExists } from './vault.js';
|
|
21
|
+
import { join, dirname } from 'path';
|
|
22
|
+
import { parseFilePath, serializeFile, slugify, resolveFilePath, vaultRel, kindFromPath, kindDir, archiveLogPath, findVaultFile, vaultFileExists } from './vault.js';
|
|
23
23
|
import { validateEntityFrontmatter, validateKnowledgeFrontmatter } from './schema.js';
|
|
24
24
|
import { bodyTemplate, knowledgeBodyTemplate, sessionBodyTemplate } from './templates.js';
|
|
25
25
|
import { formatSessionLogEntry, stampAction, documentFor, documentForFile } from './document.js';
|
|
@@ -403,9 +403,9 @@ function applyUpdate(op, ctx) {
|
|
|
403
403
|
// Only the files THIS update wrote. Keying off the archive file merely
|
|
404
404
|
// existing swept any external hand-edit sitting in entities/archive/
|
|
405
405
|
// into an unrelated commit.
|
|
406
|
-
const files = [
|
|
406
|
+
const files = [vaultRel(vaultPath, filePath)];
|
|
407
407
|
if (wroteArchive)
|
|
408
|
-
files.push(
|
|
408
|
+
files.push(vaultRel(vaultPath, archivePath));
|
|
409
409
|
return {
|
|
410
410
|
filesWritten: files,
|
|
411
411
|
filesDeleted: [],
|
|
@@ -433,7 +433,7 @@ function applyCreateEntity(op, ctx) {
|
|
|
433
433
|
body = documentFor({ kind: 'entity' }).syncRelatedSection(body, fm);
|
|
434
434
|
writeFileSync(filePath, serializeFile(fm, body));
|
|
435
435
|
return {
|
|
436
|
-
filesWritten: [
|
|
436
|
+
filesWritten: [vaultRel(vaultPath, filePath)],
|
|
437
437
|
filesDeleted: [],
|
|
438
438
|
result: { op: 'create', slug, status: 'ok', created: true },
|
|
439
439
|
commitMessage: `create: ${op.name}`,
|
|
@@ -455,7 +455,7 @@ function applyCreateKnowledge(op, ctx) {
|
|
|
455
455
|
body = documentFor({ kind: 'knowledge' }).syncRelatedSection(body, fm);
|
|
456
456
|
writeFileSync(filePath, serializeFile(fm, body));
|
|
457
457
|
return {
|
|
458
|
-
filesWritten: [
|
|
458
|
+
filesWritten: [vaultRel(vaultPath, filePath)],
|
|
459
459
|
filesDeleted: [],
|
|
460
460
|
result: { op: 'create', slug, status: 'ok', created: true },
|
|
461
461
|
commitMessage: `create: ${op.name}`,
|
|
@@ -490,15 +490,13 @@ function applyCreateSession(op, ctx) {
|
|
|
490
490
|
let sessionBody = sessionBodyTemplate({ synthesis: kf.synthesis });
|
|
491
491
|
sessionBody = documentFor({ kind: 'session' }).syncRelatedSection(sessionBody, fm);
|
|
492
492
|
writeFileSync(filePath, serializeFile(fm, sessionBody));
|
|
493
|
-
// Sessions are not indexed (db.ts only scans entities/ and knowledge/).
|
|
494
|
-
// They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
|
|
495
493
|
return {
|
|
496
|
-
filesWritten: [
|
|
494
|
+
filesWritten: [vaultRel(vaultPath, filePath)],
|
|
497
495
|
filesDeleted: [],
|
|
498
496
|
result: { op: 'create', slug, status: 'ok', created: true },
|
|
499
497
|
commitMessage: `create: ${op.name}`,
|
|
500
498
|
indexRemovals: [],
|
|
501
|
-
indexUpserts: [],
|
|
499
|
+
indexUpserts: [{ slug, kind: 'session' }],
|
|
502
500
|
};
|
|
503
501
|
}
|
|
504
502
|
function applyCapture(op, ctx) {
|
|
@@ -518,7 +516,7 @@ function applyCapture(op, ctx) {
|
|
|
518
516
|
mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
|
|
519
517
|
writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
|
|
520
518
|
return {
|
|
521
|
-
filesWritten: [
|
|
519
|
+
filesWritten: [vaultRel(vaultPath, filePath)],
|
|
522
520
|
filesDeleted: [],
|
|
523
521
|
result: { op: 'capture', slug, status: 'ok', created: true },
|
|
524
522
|
commitMessage: `capture: ${op.name}`,
|
|
@@ -545,7 +543,7 @@ function applyLink(op, ctx) {
|
|
|
545
543
|
body = documentForFile(parsedFile).syncRelatedSection(body, fm);
|
|
546
544
|
writeFileSync(filePath, serializeFile(fm, body));
|
|
547
545
|
return {
|
|
548
|
-
filesWritten: [
|
|
546
|
+
filesWritten: [vaultRel(vaultPath, filePath)],
|
|
549
547
|
filesDeleted: [],
|
|
550
548
|
result: { op: 'link', slug: op.slug, status: 'ok' },
|
|
551
549
|
commitMessage: `link: ${op.slug} → ${op.target} (${op.relation})`,
|
|
@@ -571,7 +569,7 @@ function applyUnlink(op, ctx) {
|
|
|
571
569
|
body = documentForFile(parsedFile).syncRelatedSection(body, fm);
|
|
572
570
|
writeFileSync(filePath, serializeFile(fm, body));
|
|
573
571
|
return {
|
|
574
|
-
filesWritten: [
|
|
572
|
+
filesWritten: [vaultRel(vaultPath, filePath)],
|
|
575
573
|
filesDeleted: [],
|
|
576
574
|
result: { op: 'unlink', slug: op.slug, status: 'ok' },
|
|
577
575
|
commitMessage: `unlink: ${op.slug} ✕ ${op.target} (${op.relation})`,
|
|
@@ -631,10 +629,10 @@ function applyPromote(op, ctx) {
|
|
|
631
629
|
catch (err) {
|
|
632
630
|
throw new HafezError('VALIDATION_FAILED', `Failed to promote '${op.slug}' to knowledge: ${err instanceof Error ? err.message : String(err)}`);
|
|
633
631
|
}
|
|
634
|
-
modifiedFiles.push(
|
|
632
|
+
modifiedFiles.push(vaultRel(vaultPath, newPath));
|
|
635
633
|
// The deletion must be staged too — `git add` of a removed path stages
|
|
636
634
|
// the removal; omitting it leaves the vault permanently dirty.
|
|
637
|
-
deletedFiles.push(
|
|
635
|
+
deletedFiles.push(vaultRel(vaultPath, filePath));
|
|
638
636
|
}
|
|
639
637
|
else {
|
|
640
638
|
// Same-kind: capture→entity, capture→project, entity→project
|
|
@@ -661,7 +659,7 @@ function applyPromote(op, ctx) {
|
|
|
661
659
|
}));
|
|
662
660
|
newBody = doc.syncRelatedSection(newBody, fm);
|
|
663
661
|
writeFileSync(filePath, serializeFile(fm, newBody));
|
|
664
|
-
modifiedFiles.push(
|
|
662
|
+
modifiedFiles.push(vaultRel(vaultPath, filePath));
|
|
665
663
|
}
|
|
666
664
|
return {
|
|
667
665
|
filesWritten: modifiedFiles,
|
|
@@ -686,11 +684,10 @@ function applyDelete(op, ctx) {
|
|
|
686
684
|
unlinkSync(filePath);
|
|
687
685
|
return {
|
|
688
686
|
filesWritten: [],
|
|
689
|
-
filesDeleted: [
|
|
687
|
+
filesDeleted: [vaultRel(vaultPath, filePath)],
|
|
690
688
|
result: { op: 'delete', slug: op.slug, status: 'ok' },
|
|
691
689
|
commitMessage: `delete: ${op.slug} (${kind})`,
|
|
692
|
-
|
|
693
|
-
indexRemovals: kind === 'knowledge' ? [op.slug] : [],
|
|
690
|
+
indexRemovals: [op.slug],
|
|
694
691
|
indexUpserts: [],
|
|
695
692
|
deletedKind: kind,
|
|
696
693
|
};
|
package/dist/batch-ops.js
CHANGED
|
@@ -264,7 +264,9 @@ export function duplicateSessionWarning(slug) {
|
|
|
264
264
|
* batch). The builder runs before the claim — it must see the pre-claim
|
|
265
265
|
* world so a self-referencing parent still fails. */
|
|
266
266
|
function claimNew(ctx, errors, slug, info, build) {
|
|
267
|
-
|
|
267
|
+
// A session note holds its slug too: every kind shares one index row per
|
|
268
|
+
// slug, so a create must not take one a session already has.
|
|
269
|
+
const taken = ctx.existsAnywhere(slug) || ctx.sessionExists(slug);
|
|
268
270
|
if (taken)
|
|
269
271
|
errors.push(`Slug '${slug}' already exists`);
|
|
270
272
|
if (build) {
|
package/dist/cli/block-hook.js
CHANGED
|
@@ -26,11 +26,16 @@ export async function blockHook(ctx) {
|
|
|
26
26
|
// leave an index file in it.
|
|
27
27
|
if (typeof vaultPath !== 'string' || !isVault(vaultPath))
|
|
28
28
|
return null;
|
|
29
|
-
const
|
|
29
|
+
const dir = ctx.cwd ?? process.cwd();
|
|
30
|
+
// A deleted working directory: no folder to match.
|
|
31
|
+
if (!existsSync(dir))
|
|
32
|
+
return null;
|
|
33
|
+
const cwd = canonical(dir);
|
|
30
34
|
const folders = foldersOf(cwd);
|
|
31
35
|
const remotes = remoteUrls(cwd);
|
|
32
36
|
const { createHafez } = await import('../index.js');
|
|
33
|
-
const
|
|
37
|
+
const os = createHafez({ vaultPath, git: { push: false } });
|
|
38
|
+
const { items } = await os.query({ filter: 'all' }).finally(() => os.close());
|
|
34
39
|
const matches = items
|
|
35
40
|
.filter(e => folders.some(f => basename(f) === e.slug) || (e.resource !== null && resourceMatches(e.resource, folders, remotes)))
|
|
36
41
|
// Active first: the status tuple is ordered active, paused, done.
|
|
@@ -59,13 +64,25 @@ function foldersOf(cwd) {
|
|
|
59
64
|
const [top, common] = out?.split('\n') ?? [];
|
|
60
65
|
// Before git 2.31 `--path-format` is echoed back, not obeyed: no git root.
|
|
61
66
|
if (top && common && isAbsolute(top) && isAbsolute(common)) {
|
|
62
|
-
|
|
67
|
+
const root = canonical(top);
|
|
68
|
+
folders.push(root);
|
|
63
69
|
// A linked worktree shares the main checkout's `.git` as its common dir.
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
// The shape is read off git's own path: a symlinked `.git` resolves to a
|
|
71
|
+
// directory with some other name.
|
|
72
|
+
const main = canonical(dirname(common));
|
|
73
|
+
if (basename(common) === '.git' && main !== root)
|
|
74
|
+
folders.push(main);
|
|
66
75
|
}
|
|
67
76
|
return [...new Set(folders)];
|
|
68
77
|
}
|
|
78
|
+
/** One spelling per folder, so paths from git, the working directory and a
|
|
79
|
+
* `resource` compare equal: git prints `C:/…` on Windows, realpath `C:\…`.
|
|
80
|
+
* The native realpath, since only it returns the filesystem's own letter case
|
|
81
|
+
* (`c:\users\…` is `C:\Users\…`) and long names for 8.3 ones. */
|
|
82
|
+
function canonical(path) {
|
|
83
|
+
const abs = resolve(path);
|
|
84
|
+
return existsSync(abs) ? realpathSync.native(abs) : abs;
|
|
85
|
+
}
|
|
69
86
|
/** Every remote's URL, normalised. */
|
|
70
87
|
function remoteUrls(cwd) {
|
|
71
88
|
const out = git(cwd, 'config', '--get-regexp', '^remote\\..*\\.url$');
|
|
@@ -82,9 +99,7 @@ function normaliseUrl(url) {
|
|
|
82
99
|
.replace(/\.git$/, '');
|
|
83
100
|
}
|
|
84
101
|
function resourceMatches(resource, folders, remotes) {
|
|
85
|
-
if (isAbsolute(resource))
|
|
86
|
-
|
|
87
|
-
return folders.includes(existsSync(path) ? realpathSync(path) : path);
|
|
88
|
-
}
|
|
102
|
+
if (isAbsolute(resource))
|
|
103
|
+
return folders.includes(canonical(resource));
|
|
89
104
|
return remotes.includes(normaliseUrl(resource));
|
|
90
105
|
}
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface CommandOpts {
|
|
|
9
9
|
export declare function cmdRead(os: Hafez, args: string[], opts?: CommandOpts): Promise<string>;
|
|
10
10
|
/** Every flag the query parser accepts, for the branch-support matrix. */
|
|
11
11
|
export declare const QUERY_FLAG_NAMES: string[];
|
|
12
|
-
export type QueryBranch = 'entity' | 'knowledge' | 'all' | 'related-to';
|
|
12
|
+
export type QueryBranch = 'entity' | 'knowledge' | 'session' | 'all' | 'related-to';
|
|
13
13
|
/** Which flags each kind branch actually applies. A branch that cannot honour
|
|
14
14
|
* a flag rejects it (below) instead of dropping it: `--kind knowledge --filter
|
|
15
15
|
* stale` used to return every knowledge note with exit 0, and `--confidence`
|
package/dist/cli/commands.js
CHANGED
|
@@ -7,18 +7,18 @@ import { BatchOperationSchema, normalizeAliases, opErrorLine, batchInvalidHeader
|
|
|
7
7
|
import { formatBatchError } from './batch-errors.js';
|
|
8
8
|
import { parseDigestInput, digest } from '../digest.js';
|
|
9
9
|
import { detectAgent } from '../harness.js';
|
|
10
|
-
import { getActionItems } from '../document.js';
|
|
10
|
+
import { getActionItems, parseSessionLogHeading } from '../document.js';
|
|
11
11
|
import { resemblances } from '../near-match.js';
|
|
12
12
|
import { actionAge, agedActions, staleHandles, stalenessDaysOf } from '../stale.js';
|
|
13
13
|
import { slugify } from '../vault.js';
|
|
14
14
|
import { ITEMS, footerOp } from '../surfaces/items.js';
|
|
15
15
|
import { placeIn } from '../surfaces/surfaces.js';
|
|
16
|
-
import { formatEntityHeader, formatKnowledgeHeader, formatQueryTable, formatKnowledgeTable, formatSearchResults, formatValidation, formatStats, formatChangelog } from './format.js';
|
|
16
|
+
import { formatEntityHeader, formatKnowledgeHeader, formatSessionHeader, formatQueryTable, formatKnowledgeTable, formatSessionTable, formatSearchResults, formatValidation, formatStats, formatChangelog } from './format.js';
|
|
17
17
|
function jsonOut(data) {
|
|
18
18
|
return JSON.stringify(data, null, 2);
|
|
19
19
|
}
|
|
20
|
-
const READ_USAGE = 'Usage: hafez read <slug> [--depth frontmatter|summary|full]';
|
|
21
|
-
const READ_FLAGS = { depth: 'string' };
|
|
20
|
+
const READ_USAGE = 'Usage: hafez read <slug> [--depth frontmatter|summary|full] [--archive]';
|
|
21
|
+
const READ_FLAGS = { depth: 'string', archive: 'boolean' };
|
|
22
22
|
export async function cmdRead(os, args, opts = {}) {
|
|
23
23
|
const { flags, positionals } = parseArgs(args, READ_FLAGS, READ_USAGE);
|
|
24
24
|
const slug = positionals[0];
|
|
@@ -33,23 +33,52 @@ export async function cmdRead(os, args, opts = {}) {
|
|
|
33
33
|
? new RenderedError('NOT_FOUND', jsonOut({ slug, closest }), 'stdout')
|
|
34
34
|
: new RenderedError('NOT_FOUND', ITEMS.slugMissRecovery.render({ slug, closest }), 'stderr');
|
|
35
35
|
});
|
|
36
|
+
if (flags.archive) {
|
|
37
|
+
// The archive is an entity's: reached from it on purpose (#34).
|
|
38
|
+
if (result.kind !== 'entity')
|
|
39
|
+
throw new UsageError(`--archive reads an entity's archived log entries; '${slug}' is a ${result.kind === 'session' ? 'session note' : 'knowledge note'}\n${READ_USAGE}`);
|
|
40
|
+
const archive = await os.archive(slug);
|
|
41
|
+
if (opts.json)
|
|
42
|
+
return jsonOut({ slug, archive });
|
|
43
|
+
return archive ?? `no archive for ${slug}`;
|
|
44
|
+
}
|
|
45
|
+
// A session note is read, never written: no Footer (#35).
|
|
46
|
+
if (result.kind === 'session') {
|
|
47
|
+
if (opts.json)
|
|
48
|
+
return jsonOut(result);
|
|
49
|
+
return formatSessionHeader(result.frontmatter, result.body);
|
|
50
|
+
}
|
|
36
51
|
const footer = await footerFor(os, slug, result, depth);
|
|
52
|
+
const pointers = result.kind === 'entity' ? await pointersFor(os, slug) : null;
|
|
37
53
|
if (opts.json) {
|
|
38
54
|
const writes = footerOp(footer);
|
|
39
55
|
// A frontmatter-depth read carries no body, so it has no actions to list.
|
|
40
56
|
if (result.kind !== 'entity' || depth === 'frontmatter')
|
|
41
|
-
return jsonOut({ ...result, writes });
|
|
42
|
-
return jsonOut({ ...result, actions: agedActions(result.body, result.frontmatter), writes });
|
|
57
|
+
return jsonOut({ ...result, ...pointers, writes });
|
|
58
|
+
return jsonOut({ ...result, actions: agedActions(result.body, result.frontmatter), ...pointers, writes });
|
|
43
59
|
}
|
|
44
60
|
const fm = result.frontmatter;
|
|
45
61
|
const days = stalenessDaysOf(fm);
|
|
46
62
|
const staleMark = (added) => ITEMS.staleMarker.render(actionAge(added, days));
|
|
47
63
|
const text = result.kind === 'entity'
|
|
48
|
-
? formatEntityHeader(fm, ITEMS.handles.render(result.body, staleMark), await parentLine(os, fm.parent)
|
|
64
|
+
? formatEntityHeader(fm, ITEMS.handles.render(result.body, staleMark), await parentLine(os, fm.parent), [
|
|
65
|
+
ITEMS.sessionsLine.render({ slug, ...pointers.sessions }),
|
|
66
|
+
ITEMS.archivePointer.render({ slug, entries: pointers.archive_entries }),
|
|
67
|
+
].filter(Boolean))
|
|
49
68
|
: formatKnowledgeHeader(fm, result.body);
|
|
50
69
|
// Trailing newlines only: the Footer brings its own blank line.
|
|
51
70
|
return `${text.replace(/\n+$/, '')}\n${ITEMS.writeLine.render(footer)}`;
|
|
52
71
|
}
|
|
72
|
+
/** What hangs off an entity elsewhere: the sessions that touched it (the
|
|
73
|
+
* look back, #35) and its archived log entries (#34). Counted at read time,
|
|
74
|
+
* nothing written. */
|
|
75
|
+
async function pointersFor(os, slug) {
|
|
76
|
+
const sessions = (await os.related_to(slug)).items.filter((i) => i.kind === 'session');
|
|
77
|
+
const latest = sessions.reduce((max, s) => (max === null || s.session_date > max ? s.session_date : max), null);
|
|
78
|
+
const archive = await os.archive(slug);
|
|
79
|
+
const entries = archive ? archive.split('\n').filter(line => parseSessionLogHeading(line)).length : 0;
|
|
80
|
+
return { sessions: { count: sessions.length, latest }, archive_entries: entries };
|
|
81
|
+
}
|
|
53
82
|
/** The `parent:` line's value: the slug with the parent's description. The
|
|
54
83
|
* one thing `read` takes from another file; a parent that cannot be read
|
|
55
84
|
* (a broken slug) is named by its slug alone. */
|
|
@@ -75,7 +104,7 @@ async function staleAfterWrite(os, slug) {
|
|
|
75
104
|
}
|
|
76
105
|
/** The Footer for a file `read` returned at `depth`. The Footer is the same
|
|
77
106
|
* at every depth, so its Handle comes from the whole file, not from the body
|
|
78
|
-
* this depth shows. */
|
|
107
|
+
* this depth shows. A session note has none: it is never written to. */
|
|
79
108
|
async function footerFor(os, slug, result, depth) {
|
|
80
109
|
return { slug, agent: detectAgent(), ...(result.kind === 'entity'
|
|
81
110
|
? {
|
|
@@ -84,14 +113,16 @@ async function footerFor(os, slug, result, depth) {
|
|
|
84
113
|
}
|
|
85
114
|
: { target: { kind: 'knowledge', subtype: result.frontmatter.subtype } }) };
|
|
86
115
|
}
|
|
87
|
-
const QUERY_USAGE = `Usage: hafez query [--kind entity|knowledge|all] [--filter ...] [--type t] [--parent slug] [--related-to slug] [--domain d] [--confidence c] [--subtype s] [--tag t] [--since d] [--before d] [--created-since d] [--created-before d] [--sort-by f] [--sort-order asc|desc] [--limit n] [--offset n]
|
|
116
|
+
const QUERY_USAGE = `Usage: hafez query [--kind entity|knowledge|session|all] [--filter ...] [--type t] [--parent slug] [--related-to slug] [--domain d] [--confidence c] [--subtype s] [--tag t] [--since d] [--before d] [--created-since d] [--created-before d] [--sort-by f] [--sort-order asc|desc] [--limit n] [--offset n]
|
|
88
117
|
|
|
89
|
-
--filter active|paused|done matches entity status; knowledge
|
|
90
|
-
status, so under --kind all they are always included
|
|
91
|
-
entities. --filter stale|capture are entity-only
|
|
92
|
-
knowledge. --filter/--type/--parent are rejected with
|
|
93
|
-
--confidence/--subtype are rejected with --kind
|
|
94
|
-
silently dropped. --
|
|
118
|
+
--filter active|paused|done matches entity status; knowledge and session
|
|
119
|
+
notes carry no status, so under --kind all they are always included
|
|
120
|
+
alongside the matching entities. --filter stale|capture are entity-only
|
|
121
|
+
views and return no knowledge. --filter/--type/--parent are rejected with
|
|
122
|
+
--kind knowledge, and --confidence/--subtype are rejected with --kind
|
|
123
|
+
entity, rather than being silently dropped. --kind session lists session
|
|
124
|
+
notes newest first and takes the time window, sort and paging flags only.
|
|
125
|
+
--related-to takes --kind to narrow to one kind and no other query flag.`;
|
|
95
126
|
const QUERY_FLAGS = {
|
|
96
127
|
kind: 'string', filter: 'string', type: 'string', parent: 'string', 'related-to': 'string',
|
|
97
128
|
domain: 'string', confidence: 'string', subtype: 'string', tag: 'string',
|
|
@@ -107,8 +138,9 @@ export const QUERY_FLAG_NAMES = Object.keys(QUERY_FLAGS);
|
|
|
107
138
|
export const QUERY_FLAG_SUPPORT = {
|
|
108
139
|
entity: ['kind', 'filter', 'type', 'parent', 'domain', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
|
|
109
140
|
knowledge: ['kind', 'domain', 'confidence', 'subtype', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
|
|
141
|
+
session: ['kind', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
|
|
110
142
|
all: ['kind', 'filter', 'type', 'parent', 'domain', 'confidence', 'subtype', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
|
|
111
|
-
'related-to': ['related-to'],
|
|
143
|
+
'related-to': ['related-to', 'kind'],
|
|
112
144
|
};
|
|
113
145
|
const FLAG_SCOPE_REASON = {
|
|
114
146
|
filter: 'it selects entity status (or the entity-only stale/capture views); knowledge notes carry no status',
|
|
@@ -117,16 +149,17 @@ const FLAG_SCOPE_REASON = {
|
|
|
117
149
|
confidence: 'knowledge notes only',
|
|
118
150
|
subtype: 'knowledge notes only',
|
|
119
151
|
};
|
|
120
|
-
const QUERY_KINDS = ['entity', 'knowledge', 'all'];
|
|
152
|
+
const QUERY_KINDS = ['entity', 'knowledge', 'session', 'all'];
|
|
121
153
|
function rejectUnsupportedFlags(branch, flags) {
|
|
122
154
|
const supported = new Set(QUERY_FLAG_SUPPORT[branch]);
|
|
123
155
|
for (const [name, value] of Object.entries(flags)) {
|
|
124
156
|
if (value === undefined || supported.has(name))
|
|
125
157
|
continue;
|
|
126
158
|
const where = branch === 'related-to'
|
|
127
|
-
? 'with --related-to (it returns every entity and
|
|
159
|
+
? 'with --related-to (it returns every entity, knowledge note and session note linked to the slug)'
|
|
128
160
|
: `with --kind ${branch}`;
|
|
129
|
-
const
|
|
161
|
+
const scope = branch === 'session' && (name === 'domain' || name === 'tag') ? 'a session note carries none' : FLAG_SCOPE_REASON[name];
|
|
162
|
+
const reason = scope ? ` — ${scope}` : '';
|
|
130
163
|
throw new UsageError(`--${name} is not supported ${where}${reason}\n${QUERY_USAGE}`);
|
|
131
164
|
}
|
|
132
165
|
}
|
|
@@ -142,9 +175,10 @@ function intFlag(raw, name) {
|
|
|
142
175
|
export async function cmdQuery(os, args, opts = {}) {
|
|
143
176
|
const { flags } = parseArgs(args, QUERY_FLAGS, QUERY_USAGE);
|
|
144
177
|
const relatedTo = str(flags['related-to']);
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
178
|
+
const kindFlag = str(flags.kind);
|
|
179
|
+
const branch = relatedTo !== undefined ? 'related-to' : (kindFlag ?? 'entity');
|
|
180
|
+
if (kindFlag !== undefined && !QUERY_KINDS.includes(kindFlag)) {
|
|
181
|
+
throw new UsageError(`--kind must be one of ${QUERY_KINDS.join(', ')} (got "${kindFlag}")\n${QUERY_USAGE}`);
|
|
148
182
|
}
|
|
149
183
|
rejectUnsupportedFlags(branch, flags);
|
|
150
184
|
const filter = (str(flags.filter) ?? 'active');
|
|
@@ -165,20 +199,16 @@ export async function cmdQuery(os, args, opts = {}) {
|
|
|
165
199
|
const offset = intFlag(str(flags.offset), 'offset');
|
|
166
200
|
const sortOpts = { sort_by: sortBy, sort_order: sortOrder, limit, offset };
|
|
167
201
|
const timeOpts = { since, before, createdSince, createdBefore };
|
|
168
|
-
// --related-to:
|
|
202
|
+
// --related-to: every kind linked to the slug, the sessions that touched
|
|
203
|
+
// an entity included; --kind narrows to one (the entity header's
|
|
204
|
+
// `--kind session --related-to` line)
|
|
169
205
|
if (relatedTo) {
|
|
170
|
-
const
|
|
206
|
+
const linked = await os.related_to(relatedTo);
|
|
207
|
+
const items = kindFlag && kindFlag !== 'all' ? linked.items.filter(i => i.kind === kindFlag) : linked.items;
|
|
208
|
+
const result = { items, total: items.length };
|
|
171
209
|
if (opts.json)
|
|
172
210
|
return jsonOut(result);
|
|
173
|
-
|
|
174
|
-
const entities = results.filter(r => r.kind === 'entity');
|
|
175
|
-
const knowledge = results.filter(r => r.kind === 'knowledge');
|
|
176
|
-
const sections = [];
|
|
177
|
-
if (entities.length > 0)
|
|
178
|
-
sections.push('## Entities\n\n' + formatQueryTable(entities, total));
|
|
179
|
-
if (knowledge.length > 0)
|
|
180
|
-
sections.push('## Knowledge\n\n' + formatKnowledgeTable(knowledge, total));
|
|
181
|
-
return sections.length > 0 ? sections.join('\n\n') : 'No results.';
|
|
211
|
+
return formatUnifiedSections(result.items, result.total);
|
|
182
212
|
}
|
|
183
213
|
const queryOpts = { filter, type, parent, domain, ...timeOpts, tags, ...sortOpts };
|
|
184
214
|
// --kind knowledge: query knowledge only. The time window applies here too —
|
|
@@ -190,22 +220,19 @@ export async function cmdQuery(os, args, opts = {}) {
|
|
|
190
220
|
return jsonOut(result);
|
|
191
221
|
return formatKnowledgeTable(result.items, result.total);
|
|
192
222
|
}
|
|
193
|
-
// --kind
|
|
223
|
+
// --kind session: the session notes alone, newest first
|
|
224
|
+
if (branch === 'session') {
|
|
225
|
+
const result = await os.query_sessions({ ...timeOpts, ...sortOpts });
|
|
226
|
+
if (opts.json)
|
|
227
|
+
return jsonOut(result);
|
|
228
|
+
return formatSessionTable(result.items, result.total);
|
|
229
|
+
}
|
|
230
|
+
// --kind all: every kind via the unified query, sessions included
|
|
194
231
|
if (branch === 'all') {
|
|
195
232
|
const result = await os.queryUnified({ ...queryOpts, kind: 'all', confidence, subtype });
|
|
196
233
|
if (opts.json)
|
|
197
234
|
return jsonOut(result);
|
|
198
|
-
|
|
199
|
-
const entities = items.filter(i => i.kind === 'entity');
|
|
200
|
-
const knowledge = items.filter(i => i.kind === 'knowledge');
|
|
201
|
-
const sections = [];
|
|
202
|
-
if (entities.length > 0)
|
|
203
|
-
sections.push('## Entities\n\n' + formatQueryTable(entities));
|
|
204
|
-
if (knowledge.length > 0)
|
|
205
|
-
sections.push('## Knowledge\n\n' + formatKnowledgeTable(knowledge));
|
|
206
|
-
if (items.length < total)
|
|
207
|
-
sections.push(`\nShowing ${items.length} of ${total}`);
|
|
208
|
-
return sections.length > 0 ? sections.join('\n\n') : 'No results.';
|
|
235
|
+
return formatUnifiedSections(result.items, result.total);
|
|
209
236
|
}
|
|
210
237
|
// --kind entity (default): query entities
|
|
211
238
|
const result = await os.query(queryOpts);
|
|
@@ -213,14 +240,38 @@ export async function cmdQuery(os, args, opts = {}) {
|
|
|
213
240
|
return jsonOut(result);
|
|
214
241
|
return formatQueryTable(result.items, result.total);
|
|
215
242
|
}
|
|
216
|
-
|
|
243
|
+
/** One section per kind present, in the order entities, knowledge,
|
|
244
|
+
* sessions; a page line when the page is short of the total. */
|
|
245
|
+
function formatUnifiedSections(items, total) {
|
|
246
|
+
const entities = items.filter((i) => i.kind === 'entity');
|
|
247
|
+
const knowledge = items.filter((i) => i.kind === 'knowledge');
|
|
248
|
+
const sessions = items.filter((i) => i.kind === 'session');
|
|
249
|
+
const sections = [];
|
|
250
|
+
if (entities.length > 0)
|
|
251
|
+
sections.push('## Entities\n\n' + formatQueryTable(entities));
|
|
252
|
+
if (knowledge.length > 0)
|
|
253
|
+
sections.push('## Knowledge\n\n' + formatKnowledgeTable(knowledge));
|
|
254
|
+
if (sessions.length > 0)
|
|
255
|
+
sections.push('## Sessions\n\n' + formatSessionTable(sessions));
|
|
256
|
+
if (items.length < total)
|
|
257
|
+
sections.push(`\nShowing ${items.length} of ${total}`);
|
|
258
|
+
return sections.length > 0 ? sections.join('\n\n') : 'No results.';
|
|
259
|
+
}
|
|
260
|
+
const SEARCH_USAGE = `Usage: hafez search <terms> [--kind entity|knowledge|session|all]
|
|
261
|
+
|
|
262
|
+
Without --kind, entities and knowledge notes are searched. --kind all adds
|
|
263
|
+
the session notes; --kind session searches them alone.`;
|
|
217
264
|
const SEARCH_FLAGS = { kind: 'string' };
|
|
265
|
+
const SEARCH_KINDS = ['entity', 'knowledge', 'session', 'all'];
|
|
218
266
|
export async function cmdSearch(os, args, opts = {}) {
|
|
219
267
|
const { flags, positionals } = parseArgs(args, SEARCH_FLAGS, SEARCH_USAGE);
|
|
220
268
|
const terms = positionals.join(' ');
|
|
221
269
|
if (!terms)
|
|
222
270
|
throw new UsageError(SEARCH_USAGE);
|
|
223
|
-
const kind =
|
|
271
|
+
const kind = str(flags.kind);
|
|
272
|
+
if (kind !== undefined && !SEARCH_KINDS.includes(kind)) {
|
|
273
|
+
throw new UsageError(`--kind must be one of ${SEARCH_KINDS.join(', ')} (got "${kind}")\n${SEARCH_USAGE}`);
|
|
274
|
+
}
|
|
224
275
|
const results = await os.search(terms, kind);
|
|
225
276
|
if (opts.json)
|
|
226
277
|
return jsonOut(results);
|
|
@@ -462,7 +513,14 @@ async function updateFailure(os, slug, err, opts) {
|
|
|
462
513
|
return err;
|
|
463
514
|
let retry;
|
|
464
515
|
try {
|
|
465
|
-
|
|
516
|
+
const file = await os.read(slug, 'full');
|
|
517
|
+
// The Op Spec resolves no session note, so the update missed; the slug
|
|
518
|
+
// names one all the same. Say what it is instead of offering a Footer
|
|
519
|
+
// it will never have.
|
|
520
|
+
if (file.kind === 'session') {
|
|
521
|
+
return new RenderedError('VALIDATION_FAILED', `'${slug}' is a session note: written once and read, never updated. What matters from it belongs on the entity it touched.`, 'stderr');
|
|
522
|
+
}
|
|
523
|
+
retry = { footer: await footerFor(os, slug, file, 'full') };
|
|
466
524
|
}
|
|
467
525
|
catch (readErr) {
|
|
468
526
|
if (!(readErr instanceof HafezError) || readErr.code !== 'NOT_FOUND')
|
package/dist/cli/format.d.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import type { QueryResult, KnowledgeQueryResult, ValidationReport, SearchResult, VaultStats, ChangelogEntry } from '../types.js';
|
|
1
|
+
import type { QueryResult, KnowledgeQueryResult, SessionQueryResult, ValidationReport, SearchResult, VaultStats, ChangelogEntry } from '../types.js';
|
|
2
2
|
/** `parent` is the rendered value of the `parent:` line (the slug with the
|
|
3
|
-
* parent's description); absent, the slug alone.
|
|
4
|
-
|
|
3
|
+
* parent's description); absent, the slug alone. `pointers` are the lines
|
|
4
|
+
* naming what hangs off the entity elsewhere (its sessions, its archive). */
|
|
5
|
+
export declare function formatEntityHeader(fm: Record<string, any>, body: string, parent?: string | undefined, pointers?: string[]): string;
|
|
6
|
+
/** A session note: its day, then the entities it touched. */
|
|
7
|
+
export declare function formatSessionHeader(fm: Record<string, any>, body: string): string;
|
|
5
8
|
export declare function formatKnowledgeHeader(fm: Record<string, any>, body: string): string;
|
|
6
9
|
export type QueryTableRow = Pick<QueryResult, 'slug' | 'name' | 'type' | 'status' | 'next_action' | 'next_action_count'>;
|
|
7
10
|
export declare function formatQueryTable(results: QueryTableRow[], total?: number): string;
|
|
8
11
|
export type KnowledgeTableRow = Pick<KnowledgeQueryResult, 'slug' | 'name' | 'confidence' | 'domain' | 'reinforcement_count'>;
|
|
9
12
|
export declare function formatKnowledgeTable(results: KnowledgeTableRow[], total?: number): string;
|
|
13
|
+
export type SessionTableRow = Pick<SessionQueryResult, 'slug' | 'name' | 'session_date' | 'related'>;
|
|
14
|
+
export declare function formatSessionTable(results: SessionTableRow[], total?: number): string;
|
|
10
15
|
export declare function formatSearchResults(results: SearchResult[]): string;
|
|
11
16
|
export declare function formatStats(stats: VaultStats): string;
|
|
12
17
|
export declare function formatChangelog(entries: ChangelogEntry[]): string;
|