hafez 2.0.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.
@@ -9,6 +9,11 @@ import type { Mode } from '../cli/install.js';
9
9
  export type SurfaceId = 'block' | 'hafezSkillDescription' | 'hafezSkillBody' | 'hafezSaveDescription' | 'hafezSaveBody' | 'read' | 'readHeader' | 'readFooter' | 'readMiss' | 'updateSuccess' | 'updateMisuse' | 'createKnowledge' | 'digestError' | 'batchMisuse' | 'mechanised' | 'schema' | 'onboard';
10
10
  /** Places another Item inside this one's text, counted against the same Surface. */
11
11
  export type Place = (id: string) => string;
12
+ /** What a static Surface is composed for. `vaultRepo`: the Block for a cloud
13
+ * container, whose vault is that GitHub repo (#160). */
14
+ export interface SurfaceCtx {
15
+ vaultRepo?: string;
16
+ }
12
17
  export interface Item {
13
18
  /** The Item's row text in the owner table. */
14
19
  label: string;
@@ -21,7 +26,7 @@ export interface Item {
21
26
  evidence: string;
22
27
  /** Static text. A function form may place nested Items, and may give the
23
28
  * fallback a shorter form than the Owner. */
24
- text?: string | ((place: Place, at: 'owner' | 'fallback') => string);
29
+ text?: string | ((place: Place, at: 'owner' | 'fallback', ctx: SurfaceCtx) => string);
25
30
  /** What the binary computes: for Items owned by command output, and for a
26
31
  * static Item a binary Surface places filled in (the create line in the
27
32
  * `read` miss). */
@@ -94,6 +99,19 @@ declare function renderUpdateSummary({ slug, fields, result }: {
94
99
  /** The suffix a Stale open line carries on `read`: its age, or that it is
95
100
  * undated. A fresh line carries none. */
96
101
  declare function renderStaleMarker({ stale, age_days }: ActionAge): string;
102
+ /** The `read` header line naming the sessions that touched an entity, with
103
+ * the look back from it. None: no line. */
104
+ declare function renderSessionsLine({ slug, count, latest }: {
105
+ slug: string;
106
+ count: number;
107
+ latest: string | null;
108
+ }): string;
109
+ /** The `read` header line naming an entity's archived log entries, with the
110
+ * command that reads them. No archive: no line. */
111
+ declare function renderArchivePointer({ slug, entries }: {
112
+ slug: string;
113
+ entries: number;
114
+ }): string;
97
115
  /** The line after an update's success while Stale lines remain on the entity. */
98
116
  declare function renderStaleReminder({ handles }: {
99
117
  handles: string[];
@@ -129,6 +147,15 @@ declare function renderTeachingLines({ harness, mode, line2Only }: {
129
147
  mode: Mode;
130
148
  line2Only?: boolean;
131
149
  }): string;
150
+ /** A GitHub `<owner>/<repo>`, as one shell word safe to print in a command
151
+ * and to name a directory by: not `.` or `..`, not a clone URL's `.git`. */
152
+ export declare const isVaultRepo: (value: string) => boolean;
153
+ /** `onboard --cloud`: the cloud environment's setup script. It cannot clone
154
+ * (it runs without GitHub credentials) and no step may fail session start: a
155
+ * failed install exits 0, leaving a session without Hafez rather than none. */
156
+ declare function renderCloudSetup({ vaultRepo }: {
157
+ vaultRepo: string;
158
+ }): string;
132
159
  export declare const ITEMS: {
133
160
  loadTriggers: {
134
161
  label: string;
@@ -159,6 +186,20 @@ export declare const ITEMS: {
159
186
  description: string | null;
160
187
  }) => string;
161
188
  };
189
+ sessionsLine: {
190
+ label: string;
191
+ owner: "readHeader";
192
+ invokeOnly: string;
193
+ evidence: string;
194
+ render: typeof renderSessionsLine;
195
+ };
196
+ archivePointer: {
197
+ label: string;
198
+ owner: "readHeader";
199
+ invokeOnly: string;
200
+ evidence: string;
201
+ render: typeof renderArchivePointer;
202
+ };
162
203
  writeTriggers: {
163
204
  label: string;
164
205
  owner: "block";
@@ -354,6 +395,20 @@ export declare const ITEMS: {
354
395
  evidence: string;
355
396
  render: typeof renderHarnessPermissions;
356
397
  };
398
+ cloudVault: {
399
+ label: string;
400
+ owner: "block";
401
+ invokeOnly: string;
402
+ evidence: string;
403
+ text: (_place: Place, _at: "owner" | "fallback", { vaultRepo }: SurfaceCtx) => string;
404
+ };
405
+ cloudSetup: {
406
+ label: string;
407
+ owner: "onboard";
408
+ invokeOnly: string;
409
+ evidence: string;
410
+ render: typeof renderCloudSetup;
411
+ };
357
412
  teachingLines: {
358
413
  label: string;
359
414
  owner: "onboard";
@@ -116,6 +116,11 @@ const setOrCleared = (field, name = field.replace(/_/g, ' ')) => f => {
116
116
  return undefined;
117
117
  return value === null || value === '' || (Array.isArray(value) && value.length === 0) ? `${name} cleared` : `${name} set`;
118
118
  };
119
+ /** `<n> action(s) <verb>` over a Next Actions field's one-line and array forms. */
120
+ function countPhrase(one, many, verb) {
121
+ const n = (one !== undefined ? 1 : 0) + (many?.length ?? 0);
122
+ return n > 0 ? `${n} action${n === 1 ? '' : 's'} ${verb}` : undefined;
123
+ }
119
124
  /** What each update field did, for the success line; `null` for a field that
120
125
  * changes nothing of its own. Keyed by every update field, so a new field
121
126
  * without a phrase fails the build. Key order is the line's order: the
@@ -123,12 +128,11 @@ const setOrCleared = (field, name = field.replace(/_/g, ' ')) => f => {
123
128
  const CHANGE_PHRASES = {
124
129
  session_log: (_f, r) => r.logged ? 'log added' : undefined,
125
130
  clear_actions: f => f.clear_actions ? 'actions cleared' : undefined,
126
- complete_action: f => f.complete_action !== undefined ? '1 action ticked' : undefined,
127
- remove_action: f => f.remove_action !== undefined ? '1 action removed' : undefined,
128
- add_actions: f => {
129
- const n = (f.add_action !== undefined ? 1 : 0) + (f.add_actions?.length ?? 0);
130
- return n > 0 ? `${n} action${n === 1 ? '' : 's'} added` : undefined;
131
- },
131
+ complete_action: f => countPhrase(f.complete_action, f.complete_actions, 'ticked'),
132
+ complete_actions: null, // counted with complete_action
133
+ remove_action: f => countPhrase(f.remove_action, f.remove_actions, 'removed'),
134
+ remove_actions: null, // counted with remove_action
135
+ add_actions: f => countPhrase(f.add_action, f.add_actions, 'added'),
132
136
  add_action: null, // counted with add_actions
133
137
  brief: setOrCleared('brief'),
134
138
  current_state: f => f.current_state !== undefined ? 'current state set' : undefined,
@@ -158,6 +162,20 @@ function renderStaleMarker({ stale, age_days }) {
158
162
  return '';
159
163
  return age_days === null ? ' · stale, undated' : ` · stale ${age_days}d`;
160
164
  }
165
+ /** The `read` header line naming the sessions that touched an entity, with
166
+ * the look back from it. None: no line. */
167
+ function renderSessionsLine({ slug, count, latest }) {
168
+ if (count === 0)
169
+ return '';
170
+ return `sessions: ${count}${latest ? ` · latest ${latest}` : ''} · hafez query --kind session --related-to ${slug}`;
171
+ }
172
+ /** The `read` header line naming an entity's archived log entries, with the
173
+ * command that reads them. No archive: no line. */
174
+ function renderArchivePointer({ slug, entries }) {
175
+ if (entries === 0)
176
+ return '';
177
+ return `archive: ${entries} older log ${entries === 1 ? 'entry' : 'entries'} · hafez read ${slug} --archive`;
178
+ }
161
179
  /** The line after an update's success while Stale lines remain on the entity. */
162
180
  function renderStaleReminder({ handles }) {
163
181
  const n = handles.length;
@@ -217,6 +235,38 @@ function renderTeachingLines({ harness, mode, line2Only = false }) {
217
235
  '4. If a hafez command ever asks for permission, setup is unfinished: run `hafez onboard` again.',
218
236
  ].join('\n');
219
237
  }
238
+ /** A GitHub `<owner>/<repo>`, as one shell word safe to print in a command
239
+ * and to name a directory by: not `.` or `..`, not a clone URL's `.git`. */
240
+ export const isVaultRepo = (value) => /^[A-Za-z0-9-]+\/[\w.-]+$/.test(value) && !/\/\.\.?$/.test(value) && !value.endsWith('.git');
241
+ /** Where a cloud container clones a repo attached to the session. */
242
+ const CLOUD_HOME = '/home/user';
243
+ const repoName = (vaultRepo) => vaultRepo.slice(vaultRepo.indexOf('/') + 1);
244
+ /** The Block's self-heal rule in a cloud container: a session started without
245
+ * the vault attached attaches, clones and registers it (verified, #160). */
246
+ function renderCloudVault(vaultRepo) {
247
+ const path = `${CLOUD_HOME}/${repoName(vaultRepo)}`;
248
+ return `If \`hafez init\` shows no registered vault: attach \`${vaultRepo}\` to this session (add_repo, push access), clone it to ${path}, run \`hafez init --register ${path}\`, then continue. Never create a throwaway vault.`;
249
+ }
250
+ /** `onboard --cloud`: the cloud environment's setup script. It cannot clone
251
+ * (it runs without GitHub credentials) and no step may fail session start: a
252
+ * failed install exits 0, leaving a session without Hafez rather than none. */
253
+ function renderCloudSetup({ vaultRepo }) {
254
+ return [
255
+ `Each Claude Code cloud session is a fresh container: no \`hafez\`, no vault, no Block. Paste this into the cloud environment's Setup script; the Trusted network policy is enough.`,
256
+ '',
257
+ fence([
258
+ '#!/bin/bash',
259
+ 'npm install -g hafez --allow-scripts=better-sqlite3 || exit 0',
260
+ `VAULT=${CLOUD_HOME}/${repoName(vaultRepo)}`,
261
+ 'if [ -d "$VAULT/.git" ]; then',
262
+ ' hafez init --register "$VAULT"',
263
+ 'fi',
264
+ `hafez onboard --install --mode always-on --harness claude-code --vault-repo ${vaultRepo} || true`,
265
+ ].join('\n')),
266
+ '',
267
+ `Attach ${vaultRepo} when you start a session, with push access, and the script registers it. The script cannot clone it: it runs without GitHub credentials. A session started without it attached is told by the Block to attach, clone and register it.`,
268
+ ].join('\n');
269
+ }
220
270
  const BATCH_EXAMPLE_OP = '{"op":"create","kind":"entity","name":"Token Rotation","fields":{"type":"entity"}}';
221
271
  export const ITEMS = {
222
272
  loadTriggers: {
@@ -255,13 +305,28 @@ export const ITEMS = {
255
305
  * in brackets when it has one. */
256
306
  render: ({ slug, description }) => description ? `${slug} (${description})` : slug,
257
307
  },
308
+ sessionsLine: {
309
+ label: 'Sessions line: the look back from an entity',
310
+ owner: 'readHeader',
311
+ invokeOnly: 'same',
312
+ evidence: '#35',
313
+ render: renderSessionsLine,
314
+ },
315
+ archivePointer: {
316
+ label: 'Archive pointer',
317
+ owner: 'readHeader',
318
+ invokeOnly: 'same',
319
+ evidence: '#34',
320
+ render: renderArchivePointer,
321
+ },
258
322
  writeTriggers: {
259
- label: 'Write triggers: work landed, insight, weighing, "note this"',
323
+ label: 'Write triggers: work landed, decision made, insight, weighing, "note this"',
260
324
  owner: 'block',
261
325
  invokeOnly: 'absent',
262
- evidence: 'M11, M2, never-ask decision #42',
326
+ evidence: 'M11, M2, never-ask decision #42, decision-record miss #159',
263
327
  text: place => [
264
- '- Work landed (a commit, a merged PR, CI green): update the entity you read. The end of its `read` output shows the command.',
328
+ '- Work landed (a commit, a merged PR, CI green): update the entity that owns the work. That is the entity you read, unless the work belongs to another: read that one first. The end of its `read` output shows the command.',
329
+ '- A decision was made: if the repo keeps its own decisions record, write it there now and give Hafez one line that points at it (`hafez update <slug> --log "decision: <one line>, see decisions.md"`). Otherwise it goes in the save\'s `decisions`.',
265
330
  `- An insight emerged that is useful beyond this repo: create a knowledge note. ${place('knowledgeBoundary')}`,
266
331
  '- The user says "note this" or "record that": write it.',
267
332
  `- You are weighing whether something is worth writing: write it, as knowledge with \`--confidence ${CONFIDENCE_LEVELS[0]}\`.`,
@@ -434,9 +499,11 @@ export const ITEMS = {
434
499
  evidence: 'help decision #49',
435
500
  text: fence([
436
501
  `hafez query --filter ${oneOf([...ENTITY_STATUSES, 'stale', 'capture'])}`,
437
- 'hafez search "<terms>" --kind <entity|knowledge>',
502
+ 'hafez search "<terms>" --kind <entity|knowledge|session|all>',
503
+ 'hafez query --kind session --related-to <slug>',
438
504
  'hafez stats',
439
- ].join('\n')),
505
+ ].join('\n'))
506
+ + '\n\n`search` and `query` reach entities and knowledge notes; `--kind all` adds the session notes, `--kind session` lists them alone, newest first. `hafez read <slug> --archive` prints an entity\'s archived log entries.',
440
507
  },
441
508
  rareOps: {
442
509
  label: 'Rare ops: promote, link, unlink, status, delete, validate',
@@ -513,6 +580,22 @@ export const ITEMS = {
513
580
  evidence: 'never-ask decision #42; onboard decision #58',
514
581
  render: renderHarnessPermissions,
515
582
  },
583
+ cloudVault: {
584
+ label: 'Cloud session: no vault registered, attach the vault repo, clone, register',
585
+ owner: 'block',
586
+ invokeOnly: 'absent',
587
+ evidence: '#160, verified 2026-10-16',
588
+ // Placed only in a Block composed for a cloud container, whose begin
589
+ // marker names the repo (renderBlock).
590
+ text: (_place, _at, { vaultRepo = '<owner>/<repo>' }) => renderCloudVault(vaultRepo),
591
+ },
592
+ cloudSetup: {
593
+ label: 'The cloud environment\'s setup script',
594
+ owner: 'onboard',
595
+ invokeOnly: 'not offered',
596
+ evidence: '#160, verified 2026-10-16',
597
+ render: renderCloudSetup,
598
+ },
516
599
  teachingLines: {
517
600
  label: 'The four teaching lines (to the user, not the agent)',
518
601
  owner: 'onboard',
@@ -1,16 +1,19 @@
1
- import { type FolderMatch, type Item, type ItemId, type SurfaceId } from './items.js';
1
+ import { type FolderMatch, type Item, type ItemId, type SurfaceCtx, type SurfaceId } from './items.js';
2
2
  export interface Surface<Id extends string = ItemId> {
3
3
  /** How the owner table names this Surface. */
4
4
  label: string;
5
5
  /** Static Surfaces only: the text, built by placing Items. */
6
- compose?: (place: (id: Id) => string) => string;
6
+ compose?: (place: (id: Id) => string, ctx: SurfaceCtx) => string;
7
7
  }
8
8
  export declare const SURFACES: Record<SurfaceId, Surface>;
9
9
  export declare const contentHash: (text: string) => string;
10
10
  /** An Item's text as a binary Surface places it at run time, in the form its
11
11
  * role there (Owner or fallback) calls for. */
12
12
  export declare function placeIn(surfaceId: SurfaceId, id: ItemId): string;
13
- export declare function renderBlock(): string;
13
+ /** The Block; with `vaultRepo`, the Block for a cloud container whose vault
14
+ * is that repo. Its begin marker names the repo, so a check reads it from
15
+ * there and never from the Block's wording, which a release may change. */
16
+ export declare function renderBlock(vaultRepo?: string): string;
14
17
  /** `onboard --block`, what the SessionStart hook prints: the Block, then the
15
18
  * read line for each entity matching the folder. */
16
19
  export declare function renderBlockHook(matches: FolderMatch[]): string;
@@ -26,7 +29,7 @@ export declare function renderOwnerTable(): string;
26
29
  /** Replace the marked owner-table region of `doc` with a fresh render. */
27
30
  export declare function spliceOwnerTable(doc: string): string;
28
31
  /** Every problem with the Item table: an Item with no Owner or no evidence
29
- * pointer, an Item its Owner or static fallback never places, or one placed more than once in a
30
- * Surface or in a Surface that is neither its Owner nor its declared fallback.
32
+ * pointer, an Item its Owner or static fallback never places in any context, or one placed more
33
+ * than once in a Surface or in a Surface that is neither its Owner nor its declared fallback.
31
34
  * Placement is checked for static Surfaces only; binary ones place at run time. */
32
35
  export declare function itemProblems(items: Record<string, Item>, surfaces: Record<string, Surface<string>>): string[];
@@ -13,11 +13,12 @@ import { ITEMS } from './items.js';
13
13
  export const SURFACES = {
14
14
  block: {
15
15
  label: 'Block',
16
- compose: place => [
16
+ compose: (place, { vaultRepo }) => [
17
17
  '## Hafez',
18
18
  '',
19
19
  'Hafez is this user\'s vault: what they are working on (entities) and what they have learned (knowledge). You reach it only through the `hafez` command.',
20
20
  '',
21
+ ...(vaultRepo === undefined ? [] : [`**Cloud session.** ${place('cloudVault')}`, '']),
21
22
  `**Load.** ${place('loadTriggers')}`,
22
23
  '',
23
24
  '**Write as it happens.** Each of these is due the moment it occurs:',
@@ -96,11 +97,14 @@ export const SURFACES = {
96
97
  schema: { label: '`schema`' },
97
98
  onboard: { label: '`onboard`' },
98
99
  };
100
+ /** Every context a static Surface is composed in: the default, and the
101
+ * Block for a cloud container. */
102
+ const CONTEXTS = [{}, { vaultRepo: '<owner>/<repo>' }];
99
103
  export const contentHash = (text) => createHash('sha256').update(text).digest('hex').slice(0, 12);
100
104
  /** Compose a static Surface, placing each Item's text in the form its role
101
105
  * (Owner or fallback) calls for. `onPlace` sees every placement, nested ones
102
106
  * included. */
103
- function composeWith(items, surfaceId, surface, onPlace = () => { }) {
107
+ function composeWith(items, surfaceId, surface, ctx = {}, onPlace = () => { }) {
104
108
  const place = (id) => {
105
109
  onPlace(id);
106
110
  const item = items[id];
@@ -108,16 +112,16 @@ function composeWith(items, surfaceId, surface, onPlace = () => { }) {
108
112
  throw new Error(`${surfaceId} places unknown Item ${id}`);
109
113
  const text = item.text;
110
114
  if (typeof text === 'function')
111
- return text(place, item.owner === surfaceId ? 'owner' : 'fallback');
115
+ return text(place, item.owner === surfaceId ? 'owner' : 'fallback', ctx);
112
116
  if (text === undefined)
113
117
  throw new Error(`${surfaceId} places ${id}, which has no text`);
114
118
  return text;
115
119
  };
116
120
  if (!surface.compose)
117
121
  throw new Error(`${surfaceId} is not a static surface`);
118
- return surface.compose(place);
122
+ return surface.compose(place, ctx);
119
123
  }
120
- const compose = (id) => composeWith(ITEMS, id, SURFACES[id]);
124
+ const compose = (id, ctx = {}) => composeWith(ITEMS, id, SURFACES[id], ctx);
121
125
  /** An Item's text as a binary Surface places it at run time, in the form its
122
126
  * role there (Owner or fallback) calls for. */
123
127
  export function placeIn(surfaceId, id) {
@@ -147,9 +151,13 @@ function skillFile(name, description, body) {
147
151
  const text = compose(body);
148
152
  return `---\nname: ${name}\ndescription: >\n${fold(compose(description))}\nhash: ${contentHash(text)}\n---\n${text}`;
149
153
  }
150
- export function renderBlock() {
151
- const inner = compose('block');
152
- return `<!-- hafez:begin ${contentHash(inner)} -->\n${inner}<!-- hafez:end -->\n`;
154
+ /** The Block; with `vaultRepo`, the Block for a cloud container whose vault
155
+ * is that repo. Its begin marker names the repo, so a check reads it from
156
+ * there and never from the Block's wording, which a release may change. */
157
+ export function renderBlock(vaultRepo) {
158
+ const inner = compose('block', { vaultRepo });
159
+ const repo = vaultRepo === undefined ? '' : ` vault-repo=${vaultRepo}`;
160
+ return `<!-- hafez:begin ${contentHash(inner)}${repo} -->\n${inner}<!-- hafez:end -->\n`;
153
161
  }
154
162
  /** `onboard --block`, what the SessionStart hook prints: the Block, then the
155
163
  * read line for each entity matching the folder. */
@@ -185,21 +193,28 @@ export function spliceOwnerTable(doc) {
185
193
  }
186
194
  // --- Drift check 2: the typed Item table ---
187
195
  /** Every problem with the Item table: an Item with no Owner or no evidence
188
- * pointer, an Item its Owner or static fallback never places, or one placed more than once in a
189
- * Surface or in a Surface that is neither its Owner nor its declared fallback.
196
+ * pointer, an Item its Owner or static fallback never places in any context, or one placed more
197
+ * than once in a Surface or in a Surface that is neither its Owner nor its declared fallback.
190
198
  * Placement is checked for static Surfaces only; binary ones place at run time. */
191
199
  export function itemProblems(items, surfaces) {
192
200
  const problems = [];
201
+ // Per Surface, the most times each Item is placed in one of its contexts.
193
202
  const placed = {};
194
203
  for (const [sid, surface] of Object.entries(surfaces)) {
195
204
  if (!surface.compose)
196
205
  continue;
197
- const counts = (placed[sid] = {});
198
- try {
199
- composeWith(items, sid, surface, id => { counts[id] = (counts[id] ?? 0) + 1; });
200
- }
201
- catch (err) {
202
- problems.push(err.message);
206
+ const most = (placed[sid] = {});
207
+ for (const ctx of CONTEXTS) {
208
+ const counts = {};
209
+ try {
210
+ composeWith(items, sid, surface, ctx, id => { counts[id] = (counts[id] ?? 0) + 1; });
211
+ }
212
+ catch (err) {
213
+ if (!problems.includes(err.message))
214
+ problems.push(err.message);
215
+ }
216
+ for (const [id, n] of Object.entries(counts))
217
+ most[id] = Math.max(most[id] ?? 0, n);
203
218
  }
204
219
  }
205
220
  for (const [id, item] of Object.entries(items)) {
package/dist/types.d.ts CHANGED
@@ -13,8 +13,14 @@ export type PlanStatus = typeof PLAN_STATUSES[number];
13
13
  * lives on knowledge notes only. Asking for one on the entity branch is a
14
14
  * validation error, not a silent fall back to recency, so the entity opts
15
15
  * type declares only what that branch can actually sort by. */
16
- export type SortField = 'last_touched' | 'created' | 'name' | 'staleness' | 'last_reinforced' | 'reinforcement_count';
17
- export type EntitySortField = Exclude<SortField, 'last_reinforced' | 'reinforcement_count'>;
16
+ export type SortField = 'last_touched' | 'created' | 'name' | 'staleness' | 'last_reinforced' | 'reinforcement_count' | 'session_date';
17
+ export type EntitySortField = Exclude<SortField, 'last_reinforced' | 'reinforcement_count' | 'session_date'>;
18
+ export type KnowledgeSortField = Exclude<SortField, 'session_date'>;
19
+ /** A session note carries only its dates and its name. */
20
+ export type SessionSortField = 'session_date' | 'created' | 'name';
21
+ /** What `search` reaches. Unset is the working set, entity + knowledge;
22
+ * `all` is literal, sessions included. */
23
+ export type SearchKind = 'entity' | 'knowledge' | 'session' | 'all';
18
24
  export interface EntityQueryOpts {
19
25
  filter?: QueryFilter;
20
26
  type?: EntityType;
@@ -39,14 +45,28 @@ export interface KnowledgeQueryOpts {
39
45
  createdSince?: string;
40
46
  createdBefore?: string;
41
47
  tags?: string[];
42
- sort_by?: SortField;
48
+ sort_by?: KnowledgeSortField;
49
+ sort_order?: 'asc' | 'desc';
50
+ limit?: number;
51
+ offset?: number;
52
+ }
53
+ /** Sessions carry no status, domain or tags: the time window (`since`/`before`
54
+ * against `session-date`), sort and paging are all a session query takes. */
55
+ export interface SessionQueryOpts {
56
+ since?: string;
57
+ before?: string;
58
+ createdSince?: string;
59
+ createdBefore?: string;
60
+ sort_by?: SessionSortField;
43
61
  sort_order?: 'asc' | 'desc';
44
62
  limit?: number;
45
63
  offset?: number;
46
64
  }
47
65
  /** `kind: 'all'` (or unset): reaches both sides, so it takes opts from both
48
66
  * and can honour every sort field. Status filters narrow the entity side only
49
- * — knowledge notes carry no status and are never excluded by one. */
67
+ * — knowledge notes carry no status and are never excluded by one. Unset
68
+ * is the working set, entity + knowledge; `all` is literal and reaches the
69
+ * sessions too. */
50
70
  export interface AllQueryOpts extends Omit<EntityQueryOpts, 'sort_by'> {
51
71
  kind?: 'all';
52
72
  confidence?: ConfidenceLevel;
@@ -63,6 +83,8 @@ export type UnifiedQueryOpts = AllQueryOpts | (EntityQueryOpts & {
63
83
  kind: 'entity';
64
84
  }) | (KnowledgeQueryOpts & {
65
85
  kind: 'knowledge';
86
+ }) | (SessionQueryOpts & {
87
+ kind: 'session';
66
88
  });
67
89
  export interface EntityFrontmatter {
68
90
  name: string;
@@ -114,11 +136,8 @@ export type ParsedVaultFile = {
114
136
  frontmatter: SessionFrontmatter;
115
137
  body: string;
116
138
  };
117
- /** What read() returns: the indexed arms of ParsedVaultFile (a session slug is
118
- * never resolvable through read()), so consumers narrow on `kind`. */
119
- export type ParsedFile = Extract<ParsedVaultFile, {
120
- kind: 'entity' | 'knowledge';
121
- }>;
139
+ /** What read() returns: any vault file, so consumers narrow on `kind`. */
140
+ export type ParsedFile = ParsedVaultFile;
122
141
  export interface SessionLogEntry {
123
142
  type: SessionLogType;
124
143
  summary: string;
@@ -141,7 +160,9 @@ export interface UpdateFields {
141
160
  add_action?: string;
142
161
  add_actions?: string[];
143
162
  complete_action?: string;
163
+ complete_actions?: string[];
144
164
  remove_action?: string;
165
+ remove_actions?: string[];
145
166
  clear_actions?: boolean;
146
167
  parent?: string;
147
168
  description?: string | null;
@@ -158,6 +179,8 @@ export interface UpdateFields {
158
179
  export interface UpdateResult {
159
180
  completed_action?: string;
160
181
  removed_action?: string;
182
+ completed_actions?: string[];
183
+ removed_actions?: string[];
161
184
  logged?: true;
162
185
  }
163
186
  export interface CreateEntityFields {
@@ -217,10 +240,19 @@ export interface KnowledgeQueryResult {
217
240
  last_reinforced: string | null;
218
241
  last_touched: string | null;
219
242
  }
243
+ export interface SessionQueryResult {
244
+ slug: string;
245
+ name: string;
246
+ session_date: string;
247
+ created: string;
248
+ related: string[];
249
+ }
220
250
  export type UnifiedResult = (QueryResult & {
221
251
  kind: 'entity';
222
252
  }) | (KnowledgeQueryResult & {
223
253
  kind: 'knowledge';
254
+ }) | (SessionQueryResult & {
255
+ kind: 'session';
224
256
  });
225
257
  export interface ValidationIssue {
226
258
  slug: string;
@@ -233,10 +265,11 @@ export interface ValidationReport {
233
265
  missing_fields: ValidationIssue[];
234
266
  total_entities: number;
235
267
  total_knowledge: number;
268
+ total_sessions: number;
236
269
  }
237
270
  export interface SearchResult {
238
271
  slug: string;
239
- kind: 'entity' | 'knowledge';
272
+ kind: 'entity' | 'knowledge' | 'session';
240
273
  name: string;
241
274
  snippet: string;
242
275
  type?: EntityType;
@@ -271,7 +304,7 @@ export interface VaultStats {
271
304
  }
272
305
  export interface ChangelogEntry {
273
306
  slug: string;
274
- kind: 'entity' | 'knowledge';
307
+ kind: 'entity' | 'knowledge' | 'session';
275
308
  operation: 'created' | 'updated' | 'deleted';
276
309
  timestamp: string;
277
310
  commit_message: string;
@@ -351,7 +384,7 @@ export interface BatchValidationReport {
351
384
  valid: boolean;
352
385
  operations: BatchOpValidation[];
353
386
  }
354
- export type HafezErrorCode = 'NOT_FOUND' | 'SLUG_EXISTS' | 'VALIDATION_FAILED' | 'GIT_PUSH_FAILED' | 'GIT_COMMIT_FAILED' | 'VAULT_LOCKED';
387
+ export type HafezErrorCode = 'NOT_FOUND' | 'SLUG_EXISTS' | 'VALIDATION_FAILED' | 'GIT_PUSH_FAILED' | 'GIT_COMMIT_FAILED' | 'GIT_READ_FAILED' | 'VAULT_LOCKED';
355
388
  export declare class HafezError extends Error {
356
389
  code: HafezErrorCode;
357
390
  details?: string[] | undefined;
@@ -366,10 +399,11 @@ export interface GitConfig {
366
399
  * production (src/git.ts); tests may inject an in-memory adapter.
367
400
  */
368
401
  export interface Journal {
369
- /** Persist one logical change: stage written+deleted paths (vault-relative)
370
- * and commit under message. Must clean up its own partial state (e.g.
371
- * staged files) before rethrowing on a commit-stage failure; a push-stage
372
- * failure surfaces as GIT_PUSH_FAILED with the commit kept. */
402
+ /** Persist one logical change: stage written+deleted paths (vault-relative,
403
+ * '/'-separated on every platform) and commit under message. Must clean up
404
+ * its own partial state (e.g. staged files) before rethrowing on a
405
+ * commit-stage failure; a push-stage failure surfaces as GIT_PUSH_FAILED
406
+ * with the commit kept. */
373
407
  commit(written: string[], deleted: string[], message: string): Promise<void>;
374
408
  /** Bidirectional sync with the remote, if any. */
375
409
  sync(): Promise<{
@@ -377,9 +411,11 @@ export interface Journal {
377
411
  pushed: boolean;
378
412
  remote: boolean;
379
413
  }>;
380
- /** History-derived changelog for entities/ and knowledge/ since a date. */
414
+ /** History-derived changelog for the indexed dirs since a date. Empty when
415
+ * the repo has no commits; any other git failure throws `GIT_READ_FAILED`. */
381
416
  changelog(since: string): Promise<ChangelogEntry[]>;
382
- /** Newest history timestamp per vault file (modified = any change, added = creation). */
417
+ /** Newest history timestamp per vault file (modified = any change, added = creation).
418
+ * Empty when the repo has no commits; any other git failure throws `GIT_READ_FAILED`. */
383
419
  fileTimes(mode: 'modified' | 'added'): Promise<Map<string, string>>;
384
420
  }
385
421
  export interface HafezConfig {
@@ -428,9 +464,21 @@ export interface Hafez {
428
464
  items: KnowledgeQueryResult[];
429
465
  total: number;
430
466
  }>;
467
+ query_sessions(opts?: SessionQueryOpts): Promise<{
468
+ items: SessionQueryResult[];
469
+ total: number;
470
+ }>;
471
+ /** An entity's archived log entries (`entities/archive/<slug>-log.md`), as
472
+ * written; null when it has none. Read from disk, never indexed: the
473
+ * archive is reached from its entity on purpose, never found by search. */
474
+ archive(slug: string): Promise<string | null>;
431
475
  validate(): Promise<ValidationReport>;
432
- search(query: string, kind?: 'entity' | 'knowledge' | 'all'): Promise<SearchResult[]>;
476
+ search(query: string, kind?: SearchKind): Promise<SearchResult[]>;
433
477
  rebuildIndex(): Promise<void>;
434
478
  stats(): Promise<VaultStats>;
435
479
  changelog(since: string): Promise<ChangelogEntry[]>;
480
+ /** Release the SQLite index handle. Idempotent; the next call that needs
481
+ * the index reopens it. Call before deleting the vault: Windows cannot
482
+ * remove a directory holding an open file. */
483
+ close(): void;
436
484
  }