hippo-memory 1.46.0 → 1.48.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/dist/doctor.js CHANGED
@@ -9,8 +9,8 @@ import * as os from 'node:os';
9
9
  import * as path from 'node:path';
10
10
  import { findHippoStoreDir } from './project-identity.js';
11
11
  import { getGlobalRoot } from './shared.js';
12
- import { isInitialized, loadStats } from './store.js';
13
- import { openHippoDb, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion } from './db.js';
12
+ import { isInitialized } from './store.js';
13
+ import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion, countTableRows, IncompatibleBinaryError } from './db.js';
14
14
  import { isEmbeddingAvailable } from './embeddings.js';
15
15
  /** Minimum Node.js version hippo supports (package.json engines). */
16
16
  export const MIN_NODE = '22.16.0';
@@ -34,14 +34,42 @@ function readJson(file) {
34
34
  return null;
35
35
  }
36
36
  }
37
- function countRows(db, table) {
37
+ // Migration 46 creates failure_log; a read-only open no longer creates it on an older store.
38
+ const FAILURE_LOG_SCHEMA = 46;
39
+ /** The failed-tool-call count over the last 7 days, or why it could not be read. */
40
+ function failuresCheck(db, since, schemaVersion) {
38
41
  try {
39
- // SAFETY: COUNT(*) returns one row with one numeric column.
40
- const row = db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();
41
- return Number(row?.n ?? 0);
42
+ // SAFETY: COUNT aggregate row.
43
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM failure_log WHERE ts >= ?`).get(since);
44
+ return { id: 'failures', status: 'info', detail: `${Number(row?.n ?? 0)} failed tool calls logged in 7 days (hippo failures for detail)` };
42
45
  }
43
- catch {
44
- return null;
46
+ catch (err) {
47
+ const message = err instanceof Error ? err.message : String(err);
48
+ if (!message.includes('no such table')) {
49
+ return { id: 'failures', status: 'warn', detail: `cannot read the failure log: ${message}` };
50
+ }
51
+ return schemaVersion < FAILURE_LOG_SCHEMA
52
+ ? { id: 'failures', status: 'info', detail: 'no failure log yet (hippo creates it on the next write)' }
53
+ : { id: 'failures', status: 'warn', detail: 'the failure_log table is missing, so failed tool calls are not being logged' };
54
+ }
55
+ }
56
+ /** How long ago the store last slept (consolidated), or why that history could not be read. */
57
+ function sleepCheck(db, now) {
58
+ try {
59
+ // SAFETY: row's shape matches the single `timestamp` column named in the SELECT above.
60
+ const row = db.prepare(`SELECT timestamp FROM consolidation_runs ORDER BY timestamp DESC, id DESC LIMIT 1`).get();
61
+ const when = row?.timestamp !== undefined ? Date.parse(row.timestamp) : Number.NaN;
62
+ if (Number.isNaN(when)) {
63
+ return { id: 'sleep', status: 'warn', detail: 'hippo has never slept (consolidated) in this store', fix: 'hippo sleep (the session-end hook runs it automatically)' };
64
+ }
65
+ const days = Math.floor((now.getTime() - when) / 86_400_000);
66
+ return days > 7
67
+ ? { id: 'sleep', status: 'warn', detail: `last sleep ${days} days ago`, fix: 'hippo sleep, and check the session-end hook is installed' }
68
+ : { id: 'sleep', status: 'pass', detail: `last sleep ${days === 0 ? 'today' : `${days} day${days === 1 ? '' : 's'} ago`}` };
69
+ }
70
+ catch (err) {
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ return { id: 'sleep', status: 'info', detail: `sleep history unavailable (${message})` };
45
73
  }
46
74
  }
47
75
  /** Run every check. Never throws for a broken install; broken parts become failed checks. */
@@ -58,10 +86,14 @@ export function runDoctor(opts) {
58
86
  const globalRoot = getGlobalRoot();
59
87
  const hasGlobal = isInitialized(globalRoot);
60
88
  let store = null;
61
- if (local !== null) {
89
+ if (local !== null && isInitialized(local)) {
62
90
  store = local;
63
91
  checks.push({ id: 'store', status: 'pass', detail: `project store at ${local}${hasGlobal ? ` (global store at ${globalRoot} too)` : ''}` });
64
92
  }
93
+ else if (local !== null) {
94
+ // The walk stops at the first .hippo it finds, so a bare one (no hippo.db) blocks a parent or global store too.
95
+ checks.push({ id: 'store', status: 'fail', detail: `${local} has no hippo.db, so hippo commands run here stop at it`, fix: `run hippo init in ${path.dirname(local)}, or remove that .hippo folder` });
96
+ }
65
97
  else if (hasGlobal) {
66
98
  store = globalRoot;
67
99
  checks.push({ id: 'store', status: 'warn', detail: `no project store here; using the global store at ${globalRoot}`, fix: 'hippo init (in the project root)' });
@@ -72,7 +104,7 @@ export function runDoctor(opts) {
72
104
  if (store !== null) {
73
105
  let db = null;
74
106
  try {
75
- db = openHippoDb(store);
107
+ db = openHippoDbReadOnly(store);
76
108
  const have = getSchemaVersion(db);
77
109
  const want = getCurrentSchemaVersion();
78
110
  checks.push(have === want
@@ -80,8 +112,8 @@ export function runDoctor(opts) {
80
112
  : have > want
81
113
  ? { id: 'schema', status: 'fail', detail: `database schema v${have} is newer than this hippo (v${want})`, fix: 'npm install -g hippo-memory@latest' }
82
114
  : { id: 'schema', status: 'info', detail: `database schema v${have}; hippo migrates it to v${want} on the next write` });
83
- const memories = countRows(db, 'memories');
84
- const dormant = countRows(db, 'dormant_memories');
115
+ const memories = countTableRows(db, 'memories');
116
+ const dormant = countTableRows(db, 'dormant_memories');
85
117
  const memoryCheck = { id: 'memories', status: 'info', detail: `${memories ?? '?'} memories${dormant !== null ? `, ${dormant} dormant` : ''}` };
86
118
  if (memories === 0) {
87
119
  memoryCheck.status = 'warn';
@@ -97,33 +129,21 @@ export function runDoctor(opts) {
97
129
  catch {
98
130
  checks.push({ id: 'tokens', status: 'info', detail: 'no token ledger yet (created on the next write)' });
99
131
  }
132
+ checks.push(failuresCheck(db, since, have));
133
+ checks.push(sleepCheck(db, now));
100
134
  }
101
135
  catch (err) {
102
- checks.push({ id: 'schema', status: 'fail', detail: `cannot open the database: ${err instanceof Error ? err.message : String(err)}`, fix: 'check file permissions on the .hippo folder' });
136
+ checks.push({
137
+ id: 'schema',
138
+ status: 'fail',
139
+ detail: `cannot open the database: ${err instanceof Error ? err.message : String(err)}`,
140
+ fix: err instanceof IncompatibleBinaryError ? 'npm install -g hippo-memory@latest' : 'check file permissions on the .hippo folder',
141
+ });
103
142
  }
104
143
  finally {
105
144
  if (db !== null)
106
145
  closeHippoDb(db);
107
146
  }
108
- try {
109
- const runs = loadStats(store)['consolidation_runs'];
110
- const last = Array.isArray(runs) && runs.length > 0 ? runs[runs.length - 1] : null;
111
- // SAFETY: the constructor check above narrows `last` to a plain JSON object.
112
- const ts = last !== null && last !== undefined && !Array.isArray(last) && last.constructor === Object ? last.timestamp : undefined;
113
- const when = ts !== undefined && ts !== null ? Date.parse(String(ts)) : Number.NaN;
114
- if (Number.isNaN(when)) {
115
- checks.push({ id: 'sleep', status: 'warn', detail: 'hippo has never slept (consolidated) in this store', fix: 'hippo sleep (the session-end hook runs it automatically)' });
116
- }
117
- else {
118
- const days = Math.floor((now.getTime() - when) / 86_400_000);
119
- checks.push(days > 7
120
- ? { id: 'sleep', status: 'warn', detail: `last sleep ${days} days ago`, fix: 'hippo sleep, and check the session-end hook is installed' }
121
- : { id: 'sleep', status: 'pass', detail: `last sleep ${days === 0 ? 'today' : `${days} day${days === 1 ? '' : 's'} ago`}` });
122
- }
123
- }
124
- catch {
125
- checks.push({ id: 'sleep', status: 'info', detail: 'sleep history unavailable' });
126
- }
127
147
  }
128
148
  const claudeDir = path.join(home, '.claude');
129
149
  if (fs.existsSync(claudeDir)) {
@@ -0,0 +1,49 @@
1
+ /** Failure log (ROADMAP CD13): every failed tool call the capture-error hook sees, stored or not. */
2
+ import type { CaptureErrorOutcome, RoutineRule } from './capture-error.js';
3
+ import type { DatabaseSyncLike } from './db.js';
4
+ /** Rows older than this are pruned on write, which also bounds how far back a repeat can be found. */
5
+ export declare const FAILURE_LOG_RETENTION_DAYS = 90;
6
+ /** A capture-error outcome, or `store-failed` when storing the lesson threw. */
7
+ export type FailureOutcome = CaptureErrorOutcome | 'store-failed';
8
+ /** One failed tool call, for {@link recordFailure}. Never the failure text: it can carry paths and secrets. */
9
+ export interface FailureEvent {
10
+ tenantId: string;
11
+ /** Host session id from the hook payload; null when it had none. */
12
+ sessionId?: string | null;
13
+ tool?: string | null;
14
+ outcome: FailureOutcome;
15
+ /** The routine check that skipped it, for `skipped-routine`. */
16
+ rule?: RoutineRule | null;
17
+ /** Hash of the lesson text's signature, the key dedupe uses; null when the payload had no readable error. */
18
+ sigHash?: string | null;
19
+ /** Hash of the untruncated error plus the command's first two words, finer than `sigHash`. */
20
+ detailHash?: string | null;
21
+ /** Override the timestamp (tests). ISO string. */
22
+ now?: string;
23
+ }
24
+ /** Append one failure row and prune rows past {@link FAILURE_LOG_RETENTION_DAYS}. */
25
+ export declare function recordFailure(db: DatabaseSyncLike, event: FailureEvent): void;
26
+ /** Rated failures and repeats in one session, for {@link failuresBySession}. */
27
+ export interface SessionFailures {
28
+ sessionId: string;
29
+ /** Failures hippo treats as lessons (stored, duplicate or store-failed) in the window. */
30
+ failures: number;
31
+ /** Of those, failures whose signature another session hit first. */
32
+ repeats: number;
33
+ }
34
+ /** Rated failures per session since `sinceIso`, the input for repeat-error rate per arm (CD11, CD12). */
35
+ export declare function failuresBySession(db: DatabaseSyncLike, tenantId: string, sinceIso: string): SessionFailures[];
36
+ /** Failure log totals over a window, for {@link summarizeFailures}. Counts only: a rate needs a holdout arm (CD11). */
37
+ export interface FailureSummary {
38
+ /** ISO start of the window (inclusive). */
39
+ since: string;
40
+ outcomes: Record<FailureOutcome, number>;
41
+ total: number;
42
+ /** Rated failures from sessions with an id: the failures a repeat is counted among. */
43
+ rated: number;
44
+ repeats: number;
45
+ sessions: number;
46
+ }
47
+ /** Sum the failure log for one tenant since `sinceIso`. */
48
+ export declare function summarizeFailures(db: DatabaseSyncLike, tenantId: string, sinceIso: string): FailureSummary;
49
+ //# sourceMappingURL=failure-log.d.ts.map
@@ -0,0 +1,58 @@
1
+ /** Rows older than this are pruned on write, which also bounds how far back a repeat can be found. */
2
+ export const FAILURE_LOG_RETENTION_DAYS = 90;
3
+ /** Longest session id or tool name kept; the hook payload is not trusted to be short. */
4
+ const MAX_FIELD = 128;
5
+ /** Append one failure row and prune rows past {@link FAILURE_LOG_RETENTION_DAYS}. */
6
+ export function recordFailure(db, event) {
7
+ // Normalised, because the window and prune compare timestamps as strings.
8
+ const now = new Date(event.now ?? Date.now()).toISOString();
9
+ db.prepare(`INSERT INTO failure_log (ts, tenant_id, session_id, tool, outcome, skip_rule, sig_hash, detail_hash)
10
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(now, event.tenantId, event.sessionId?.slice(0, MAX_FIELD) ?? null, event.tool?.slice(0, MAX_FIELD) ?? null, event.outcome, event.rule ?? null, event.sigHash ?? null, event.detailHash ?? null);
11
+ const cutoff = new Date(Date.parse(now) - FAILURE_LOG_RETENTION_DAYS * 86_400_000).toISOString();
12
+ db.prepare(`DELETE FROM failure_log WHERE ts < ?`).run(cutoff);
13
+ }
14
+ /** Rated failures per session since `sinceIso`, the input for repeat-error rate per arm (CD11, CD12). */
15
+ export function failuresBySession(db, tenantId, sinceIso) {
16
+ // SAFETY: the SELECT names exactly these three TEXT columns.
17
+ const rows = db.prepare(`SELECT ts, session_id, sig_hash FROM failure_log
18
+ WHERE tenant_id = ? AND session_id IS NOT NULL AND sig_hash IS NOT NULL
19
+ AND outcome IN ('stored', 'duplicate', 'store-failed')
20
+ ORDER BY id`).all(tenantId);
21
+ const firstSession = new Map();
22
+ const bySession = new Map();
23
+ for (const row of rows) {
24
+ if (!firstSession.has(row.sig_hash))
25
+ firstSession.set(row.sig_hash, row.session_id);
26
+ // Rows before the window are not counted but still decide which session hit a signature first.
27
+ if (row.ts < sinceIso)
28
+ continue;
29
+ const repeat = firstSession.get(row.sig_hash) !== row.session_id;
30
+ const s = bySession.get(row.session_id) ?? { sessionId: row.session_id, failures: 0, repeats: 0 };
31
+ bySession.set(row.session_id, { ...s, failures: s.failures + 1, repeats: s.repeats + (repeat ? 1 : 0) });
32
+ }
33
+ return [...bySession.values()];
34
+ }
35
+ /** Sum the failure log for one tenant since `sinceIso`. */
36
+ export function summarizeFailures(db, tenantId, sinceIso) {
37
+ // SAFETY: the SELECT names exactly these two columns, TEXT and an aggregate.
38
+ const rows = db.prepare(`SELECT outcome, COUNT(*) AS n FROM failure_log WHERE tenant_id = ? AND ts >= ? GROUP BY outcome`).all(tenantId, sinceIso);
39
+ const counts = new Map(rows.map((r) => [r.outcome, Number(r.n)]));
40
+ const outcomes = {
41
+ stored: counts.get('stored') ?? 0,
42
+ duplicate: counts.get('duplicate') ?? 0,
43
+ 'store-failed': counts.get('store-failed') ?? 0,
44
+ 'skipped-interrupt': counts.get('skipped-interrupt') ?? 0,
45
+ 'skipped-routine': counts.get('skipped-routine') ?? 0,
46
+ 'skipped-invalid': counts.get('skipped-invalid') ?? 0,
47
+ };
48
+ const sessions = failuresBySession(db, tenantId, sinceIso);
49
+ return {
50
+ since: sinceIso,
51
+ outcomes,
52
+ total: Object.values(outcomes).reduce((sum, n) => sum + n, 0),
53
+ rated: sessions.reduce((sum, s) => sum + s.failures, 0),
54
+ repeats: sessions.reduce((sum, s) => sum + s.repeats, 0),
55
+ sessions: sessions.length,
56
+ };
57
+ }
58
+ //# sourceMappingURL=failure-log.js.map
@@ -41,8 +41,8 @@ export interface RejectFlowResult {
41
41
  * normalized digest matches (kind-aware), one aggregate `reject_value`
42
42
  * audit, COMMIT. Then post-commit (mirrors the existing purge+reaper
43
43
  * pattern verbatim from api.archiveRaw, api.ts:1913-1938): best-effort
44
- * mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids, one
45
- * index mirror rewrite.
44
+ * mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids.
45
+ * index.json itself is only refreshed by `rebuildIndex()`.
46
46
  */
47
47
  export declare function rejectValue(opts: RejectFlowOpts): RejectFlowResult;
48
48
  export type UnrejectOutcome = {
@@ -15,7 +15,7 @@ import { openHippoDb, closeHippoDb } from './db.js';
15
15
  import { appendAuditEvent } from './audit.js';
16
16
  import { archiveRawMemory } from './raw-archive.js';
17
17
  import { purgeDormantByDigest } from './dormant.js';
18
- import { initStore, deleteEntryCore, purgeMirrorBestEffort, writeIndexMirror, buildIndexFromDb, } from './store.js';
18
+ import { initStore, deleteEntryCore, purgeMirrorBestEffort, } from './store.js';
19
19
  import { rejectionDigest, normalizeValueForRejection, insertRejectedValue, deleteRejectedValue, listRejectedValues, } from './rejection.js';
20
20
  /**
21
21
  * `hippo reject` / `api.reject` core flow. ONE connection, one transaction:
@@ -23,8 +23,8 @@ import { rejectionDigest, normalizeValueForRejection, insertRejectedValue, delet
23
23
  * normalized digest matches (kind-aware), one aggregate `reject_value`
24
24
  * audit, COMMIT. Then post-commit (mirrors the existing purge+reaper
25
25
  * pattern verbatim from api.archiveRaw, api.ts:1913-1938): best-effort
26
- * mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids, one
27
- * index mirror rewrite.
26
+ * mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids.
27
+ * index.json itself is only refreshed by `rebuildIndex()`.
28
28
  */
29
29
  export function rejectValue(opts) {
30
30
  if (!opts.reason.trim()) {
@@ -138,7 +138,7 @@ export function rejectValue(opts) {
138
138
  }
139
139
  // Post-commit, db handle still open (same pattern as api.archiveRaw):
140
140
  // best-effort mirror purge per removed id, reaper-backstop stamp for
141
- // raw ids, one index mirror rewrite.
141
+ // raw ids.
142
142
  for (const id of removedIds) {
143
143
  // AT1 fix: purgeMirrorBestEffort retries once, then — for non-raw ids,
144
144
  // which cleanupArchivedMirrors' reaper never scans — reports the
@@ -150,9 +150,6 @@ export function rejectValue(opts) {
150
150
  db.prepare(`UPDATE raw_archive SET mirror_cleaned_at = ? WHERE memory_id = ?`).run(new Date().toISOString(), id);
151
151
  }
152
152
  }
153
- if (removedIds.length > 0) {
154
- writeIndexMirror(opts.hippoRoot, buildIndexFromDb(db));
155
- }
156
153
  return { digest, content, removedIds, removedRawIds };
157
154
  }
158
155
  finally {
@@ -38,4 +38,6 @@ export declare function detectSecret(entry: {
38
38
  * CS1 pre-compact snapshot fields — can scrub it in place instead.
39
39
  */
40
40
  export declare function redactSecrets(text: string): string;
41
+ /** Stricter redaction for text that leaves the machine: no co-occurrence guard, plus Bearer headers and JWTs. */
42
+ export declare function redactSecretsStrict(text: string): string;
41
43
  //# sourceMappingURL=secret-detect.d.ts.map
@@ -48,6 +48,11 @@ const SECRET_PATTERNS = [
48
48
  ];
49
49
  const KEYISH_CONTEXT_RE = /key|token|secret|credential|bearer|auth|password/i;
50
50
  const CO_OCCURRENCE_GUARDED = new Set(['sk-style-key', 'sk-underscore-key']);
51
+ // redactSecretsStrict-only: too noisy for whole-entry memory scanning, worth hiding once text leaves the machine.
52
+ const STRICT_ONLY_PATTERNS = [
53
+ /\b[Bb]earer\s+[A-Za-z0-9._~+/-]{16,}=*/g,
54
+ /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g,
55
+ ];
51
56
  /**
52
57
  * Scan a memory's tags + content for secret material.
53
58
  * Pure and deterministic; no filesystem or store access.
@@ -75,21 +80,28 @@ export function detectSecret(entry) {
75
80
  * CS1 pre-compact snapshot fields — can scrub it in place instead.
76
81
  */
77
82
  export function redactSecrets(text) {
83
+ return redactText(text, false);
84
+ }
85
+ /** Stricter redaction for text that leaves the machine: no co-occurrence guard, plus Bearer headers and JWTs. */
86
+ export function redactSecretsStrict(text) {
87
+ return redactText(text, true);
88
+ }
89
+ function redactText(text, strict) {
78
90
  if (!text)
79
91
  return text;
80
92
  let result = text;
81
- // PEM/OpenSSH blocks first: the pattern-table entry matches only the
82
- // BEGIN delimiter, which is fine for detectSecret's flag-or-not decision
83
- // but would leave the base64 payload behind here. Consume through the
84
- // matching END delimiter; a truncated block with no END is redacted to
85
- // the end of the text (codex round 3).
93
+ // PEM/OpenSSH blocks first: SECRET_PATTERNS only matches BEGIN; consume through END, or to the end of a truncated block.
86
94
  result = result.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----(?:[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----|[\s\S]*$)/g, '[REDACTED]');
87
95
  for (const { name, re } of SECRET_PATTERNS) {
88
- if (CO_OCCURRENCE_GUARDED.has(name) && !KEYISH_CONTEXT_RE.test(text))
96
+ if (!strict && CO_OCCURRENCE_GUARDED.has(name) && !KEYISH_CONTEXT_RE.test(text))
89
97
  continue;
90
98
  const flags = re.flags.includes('g') ? re.flags : `${re.flags}g`;
91
99
  result = result.replace(new RegExp(re.source, flags), '[REDACTED]');
92
100
  }
101
+ if (strict) {
102
+ for (const re of STRICT_ONLY_PATTERNS)
103
+ result = result.replace(re, '[REDACTED]');
104
+ }
93
105
  return result;
94
106
  }
95
107
  //# sourceMappingURL=secret-detect.js.map
package/dist/store.d.ts CHANGED
@@ -190,23 +190,13 @@ interface LegacyStats {
190
190
  total_forgotten: JsonValue;
191
191
  consolidation_runs: JsonValue;
192
192
  }
193
- /**
194
- * Derive the current `HippoIndex` (entries + last-retrieval/trace lockstep
195
- * meta) from SQLite, the source of truth. Exported (AT1) for the same
196
- * reason as `writeIndexMirror` below: `src/reject-flow.ts` needs to rebuild
197
- * the index mirror post-commit after a (possibly multi-row) reject removal,
198
- * without duplicating this query.
199
- */
193
+ /** Derive the current `HippoIndex` from SQLite. Exported for `rebuildIndex`
194
+ * (the only index.json writer) and the longmemeval benchmark. */
200
195
  export declare function buildIndexFromDb(db: ReturnType<typeof openHippoDb>): HippoIndex;
201
- /**
202
- * Write the `index.json` mirror file for a given (already-derived) index.
203
- * Exported (AT1) so `src/reject-flow.ts` can replicate `deleteEntry`'s exact
204
- * post-commit "removeEntryMirrors then rewrite the index once" sequence for
205
- * the reject verb's (possibly multi-row) removal, without duplicating
206
- * `buildIndexFromDb`'s query.
207
- */
196
+ /** Write the `index.json` mirror file for an already-derived index. Exported for
197
+ * `rebuildIndex` (the only index.json writer) and the longmemeval benchmark. */
208
198
  export declare function writeIndexMirror(hippoRoot: string, index: HippoIndex): void;
209
- /** Load the derived index from SQLite. Read-only: writers refresh index.json, so readers never race on it. */
199
+ /** Load the derived index from SQLite. Read-only: index.json is only ever written by `rebuildIndex`. */
210
200
  export declare function loadIndex(hippoRoot: string): HippoIndex;
211
201
  /**
212
202
  * Persist mutable index metadata. Entry rows themselves are derived from SQLite.
@@ -215,10 +205,8 @@ export declare function loadIndex(hippoRoot: string): HippoIndex;
215
205
  * land atomically — callers (getContext, cmdRecall) fold a freshly-written
216
206
  * trace id into `index.last_trace_id` before calling this, relying on BOTH
217
207
  * meta keys committing together. Wrapped in BEGIN/COMMIT so a crash or a
218
- * mid-write failure can never advance one key without the other. The
219
- * filesystem mirror write stays AFTER commit — the DB is the source of
220
- * truth, the mirror is best-effort (matches every other per-call-handle
221
- * site's convention).
208
+ * mid-write failure can never advance one key without the other. index.json
209
+ * is left untouched; only `rebuildIndex` writes it.
222
210
  */
223
211
  export declare function saveIndex(hippoRoot: string, index: HippoIndex): void;
224
212
  /**
@@ -277,13 +265,8 @@ export declare function writeEntryDbOnly(db: DatabaseSyncLike, entry: MemoryEntr
277
265
  actor?: string;
278
266
  afterWrite?: (db: DatabaseSyncLike, memoryId: string) => void;
279
267
  }): void;
280
- /**
281
- * Filesystem mirrors path. Caller passes `hippoRoot` + an open `db` handle
282
- * (used by `buildIndexFromDb` to derive the index from the source of truth).
283
- * MUST be invoked AFTER the outer transaction commits — a mirror write
284
- * during a tx that subsequently rolls back would leave orphan markdown.
285
- */
286
- export declare function writeEntryMirrors(hippoRoot: string, db: DatabaseSyncLike, entry: MemoryEntry): void;
268
+ /** Markdown mirror path, invoked AFTER commit (a rolled-back tx must leave no orphan markdown). */
269
+ export declare function writeEntryMirrors(hippoRoot: string, entry: MemoryEntry): void;
287
270
  /**
288
271
  * Read a memory entry by ID.
289
272
  *
package/dist/store.js CHANGED
@@ -940,6 +940,7 @@ function upsertEntryRow(db, entry, bypassRejectionGuard = false) {
940
940
  if (!bypassRejectionGuard) {
941
941
  checkRejectionGuard(db, entry.tenantId ?? 'default', entry.id, entry.content);
942
942
  }
943
+ const isNewRow = db.prepare(`SELECT 1 FROM memories WHERE id = ?`).get(entry.id) === undefined;
943
944
  db.prepare(`
944
945
  INSERT INTO memories(
945
946
  id, created, last_retrieved, retrieval_count, strength, half_life_days, layer,
@@ -996,13 +997,14 @@ function upsertEntryRow(db, entry, bypassRejectionGuard = false) {
996
997
  dag_level_3_built_at = excluded.dag_level_3_built_at,
997
998
  updated_at = datetime('now')
998
999
  `).run(entry.id, entry.created, entry.last_retrieved, entry.retrieval_count, entry.strength, entry.half_life_days, entry.layer, JSON.stringify(entry.tags ?? []), entry.emotional_valence, entry.schema_fit, entry.source, entry.outcome_score, entry.outcome_positive ?? 0, entry.outcome_negative ?? 0, JSON.stringify(entry.conflicts_with ?? []), entry.pinned ? 1 : 0, entry.confidence, entry.content, JSON.stringify(entry.parents ?? []), entry.starred ? 1 : 0, entry.trace_outcome ?? null, entry.source_session_id ?? null, entry.valid_from ?? entry.created, entry.superseded_by ?? null, entry.extracted_from ?? null, entry.dag_level ?? 0, entry.dag_parent_id ?? null, entry.kind ?? 'distilled', entry.scope ?? null, entry.owner ?? null, entry.artifact_ref ?? null, entry.tenantId ?? 'default', entry.origin_project ?? null, entry.descendant_count ?? 0, entry.earliest_at ?? null, entry.latest_at ?? null, entry.dag_level_3_built_at ?? null);
999
- syncFtsRow(db, entry);
1000
+ syncFtsRow(db, entry, isNewRow);
1000
1001
  }
1001
- function syncFtsRow(db, entry) {
1002
+ function syncFtsRow(db, entry, isNewRow = false) {
1002
1003
  if (!isFtsAvailable(db))
1003
1004
  return;
1004
1005
  try {
1005
- db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(entry.id);
1006
+ if (!isNewRow)
1007
+ db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(entry.id);
1006
1008
  db.prepare(`INSERT INTO memories_fts(id, content, tags) VALUES (?, ?, ?)`).run(entry.id, entry.content, entry.tags.join(' '));
1007
1009
  }
1008
1010
  catch {
@@ -1019,13 +1021,8 @@ function deleteFtsRow(db, id) {
1019
1021
  // Best effort.
1020
1022
  }
1021
1023
  }
1022
- /**
1023
- * Derive the current `HippoIndex` (entries + last-retrieval/trace lockstep
1024
- * meta) from SQLite, the source of truth. Exported (AT1) for the same
1025
- * reason as `writeIndexMirror` below: `src/reject-flow.ts` needs to rebuild
1026
- * the index mirror post-commit after a (possibly multi-row) reject removal,
1027
- * without duplicating this query.
1028
- */
1024
+ /** Derive the current `HippoIndex` from SQLite. Exported for `rebuildIndex`
1025
+ * (the only index.json writer) and the longmemeval benchmark. */
1029
1026
  export function buildIndexFromDb(db) {
1030
1027
  // SAFETY: rows' shape matches the seven columns named in the SELECT below.
1031
1028
  const rows = db.prepare(`SELECT id, created, last_retrieved, strength, layer, tags_json, pinned FROM memories ORDER BY created ASC, id ASC`).all();
@@ -1076,13 +1073,8 @@ function buildStatsFromDb(db) {
1076
1073
  })),
1077
1074
  };
1078
1075
  }
1079
- /**
1080
- * Write the `index.json` mirror file for a given (already-derived) index.
1081
- * Exported (AT1) so `src/reject-flow.ts` can replicate `deleteEntry`'s exact
1082
- * post-commit "removeEntryMirrors then rewrite the index once" sequence for
1083
- * the reject verb's (possibly multi-row) removal, without duplicating
1084
- * `buildIndexFromDb`'s query.
1085
- */
1076
+ /** Write the `index.json` mirror file for an already-derived index. Exported for
1077
+ * `rebuildIndex` (the only index.json writer) and the longmemeval benchmark. */
1086
1078
  export function writeIndexMirror(hippoRoot, index) {
1087
1079
  mirrorBestEffort('index.json', () => fs.writeFileSync(path.join(hippoRoot, 'index.json'), JSON.stringify(index, null, 2), 'utf8'));
1088
1080
  }
@@ -1115,10 +1107,9 @@ function syncMirrorFiles(hippoRoot, db) {
1115
1107
  ORDER BY updated_at DESC, id DESC
1116
1108
  `).all();
1117
1109
  mirrorBestEffort('conflict mirrors', () => writeConflictMirrors(hippoRoot, conflicts.map(rowToMemoryConflict)));
1118
- writeIndexMirror(hippoRoot, buildIndexFromDb(db));
1119
1110
  writeStatsMirror(hippoRoot, buildStatsFromDb(db));
1120
1111
  }
1121
- /** Load the derived index from SQLite. Read-only: writers refresh index.json, so readers never race on it. */
1112
+ /** Load the derived index from SQLite. Read-only: index.json is only ever written by `rebuildIndex`. */
1122
1113
  export function loadIndex(hippoRoot) {
1123
1114
  initStore(hippoRoot);
1124
1115
  const db = openHippoDb(hippoRoot);
@@ -1136,10 +1127,8 @@ export function loadIndex(hippoRoot) {
1136
1127
  * land atomically — callers (getContext, cmdRecall) fold a freshly-written
1137
1128
  * trace id into `index.last_trace_id` before calling this, relying on BOTH
1138
1129
  * meta keys committing together. Wrapped in BEGIN/COMMIT so a crash or a
1139
- * mid-write failure can never advance one key without the other. The
1140
- * filesystem mirror write stays AFTER commit — the DB is the source of
1141
- * truth, the mirror is best-effort (matches every other per-call-handle
1142
- * site's convention).
1130
+ * mid-write failure can never advance one key without the other. index.json
1131
+ * is left untouched; only `rebuildIndex` writes it.
1143
1132
  */
1144
1133
  export function saveIndex(hippoRoot, index) {
1145
1134
  initStore(hippoRoot);
@@ -1158,7 +1147,6 @@ export function saveIndex(hippoRoot, index) {
1158
1147
  catch { /* already rolled back; keep the original error */ }
1159
1148
  throw error;
1160
1149
  }
1161
- writeIndexMirror(hippoRoot, buildIndexFromDb(db));
1162
1150
  }
1163
1151
  finally {
1164
1152
  closeHippoDb(db);
@@ -1222,7 +1210,7 @@ export function writeEntry(hippoRoot, entry, opts) {
1222
1210
  try {
1223
1211
  writeEntryDbOnly(db, stamped, opts);
1224
1212
  opts?.afterCommit?.();
1225
- writeEntryMirrors(hippoRoot, db, stamped);
1213
+ writeEntryMirrors(hippoRoot, stamped);
1226
1214
  }
1227
1215
  catch (error) {
1228
1216
  // AT1 (plan §3): writeEntryDbOnly's own SAVEPOINT has already unwound by
@@ -1286,15 +1274,9 @@ export function writeEntryDbOnly(db, entry, opts) {
1286
1274
  throw e;
1287
1275
  }
1288
1276
  }
1289
- /**
1290
- * Filesystem mirrors path. Caller passes `hippoRoot` + an open `db` handle
1291
- * (used by `buildIndexFromDb` to derive the index from the source of truth).
1292
- * MUST be invoked AFTER the outer transaction commits — a mirror write
1293
- * during a tx that subsequently rolls back would leave orphan markdown.
1294
- */
1295
- export function writeEntryMirrors(hippoRoot, db, entry) {
1277
+ /** Markdown mirror path, invoked AFTER commit (a rolled-back tx must leave no orphan markdown). */
1278
+ export function writeEntryMirrors(hippoRoot, entry) {
1296
1279
  mirrorBestEffort(`${entry.id}.md`, () => writeMarkdownMirror(hippoRoot, entry));
1297
- writeIndexMirror(hippoRoot, buildIndexFromDb(db));
1298
1280
  }
1299
1281
  /**
1300
1282
  * Read a memory entry by ID.
@@ -1611,7 +1593,6 @@ export function deleteEntry(hippoRoot, id, opts) {
1611
1593
  if (!result)
1612
1594
  return false;
1613
1595
  purgeMirrorBestEffort(hippoRoot, id, false, 'deleteEntry');
1614
- writeIndexMirror(hippoRoot, buildIndexFromDb(db));
1615
1596
  return true;
1616
1597
  }
1617
1598
  finally {
@@ -1786,7 +1767,6 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
1786
1767
  });
1787
1768
  for (const id of removedIds)
1788
1769
  purgeMirrorBestEffort(hippoRoot, id, false, 'batchWriteAndDelete');
1789
- writeIndexMirror(hippoRoot, buildIndexFromDb(db));
1790
1770
  return removedIds;
1791
1771
  }
1792
1772
  catch (error) {
@@ -1978,7 +1958,9 @@ export function rebuildIndex(hippoRoot) {
1978
1958
  }
1979
1959
  }
1980
1960
  syncMirrorFiles(hippoRoot, db);
1981
- return buildIndexFromDb(db);
1961
+ const index = buildIndexFromDb(db);
1962
+ writeIndexMirror(hippoRoot, index);
1963
+ return index;
1982
1964
  }
1983
1965
  finally {
1984
1966
  closeHippoDb(db);
@@ -0,0 +1,12 @@
1
+ import { type DoctorOpts } from './doctor.js';
2
+ import type { JsonObject } from './working-memory.js';
3
+ export interface SupportBundleOpts extends DoctorOpts {
4
+ readonly cwd: string;
5
+ readonly home: string;
6
+ readonly now: Date;
7
+ readonly includeLogs: boolean;
8
+ }
9
+ export declare const TAIL_MAX_LINES = 200;
10
+ /** Read-only: builds one redacted support-ticket snapshot. Never reads a memory content column. */
11
+ export declare function buildSupportBundle(opts: SupportBundleOpts): JsonObject;
12
+ //# sourceMappingURL=support-bundle.d.ts.map