hippo-memory 1.63.0 → 1.63.1

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 CHANGED
@@ -1,6 +1,6 @@
1
- # 🦛 Hippo: memory for AI agents that learns what is wrong
1
+ # 🦛 Hippo
2
2
 
3
- **Hippo learns what is wrong and ranks it down.** Good memory is knowing what to forget: what turned out wrong, what got replaced, what nobody used.
3
+ **Make your agent's memory work like a brain. Hippo is long-term memory for coding agents.**
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/hippo-memory)](https://npmjs.com/package/hippo-memory)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/hippo-memory)](https://npmjs.com/package/hippo-memory)
@@ -1,7 +1,7 @@
1
1
  export declare function printAuditPruneUsage(): void;
2
2
  export declare function printSlackBackfillUsage(): void;
3
3
  export declare function printSlackWorkspacesUsage(): void;
4
- export declare const USAGE_HEADER = "\nHippo - memory for AI agents that learns what is wrong and ranks it down\n\nUsage: hippo <command> [options]\n\nCommands:";
4
+ export declare const USAGE_HEADER = "\nHippo - Make your agent's memory work like a brain. Hippo is long-term memory for coding agents.\n\nUsage: hippo <command> [options]\n\nCommands:";
5
5
  export declare const USAGE_EXAMPLES = "\n\nExamples:\n hippo init\n hippo remember \"FRED cache can silently drop series\" --tag error\n hippo recall \"data pipeline issues\" --budget 2000\n hippo context --auto --budget 1500\n hippo conflicts\n hippo reject mem_abc123 --reason \"leaked credential\"\n hippo reject --value \"never store my key again\" --reason \"secret\"\n hippo rejections\n hippo unreject a1b2c3d4e5f6\n hippo dormant \"staging hostname\"\n hippo dormant restore mem_abc123\n hippo tokens --days 7\n hippo session log --id sess_123 --task \"Ship feature\" --type progress --content \"Build is green, next step is docs\"\n hippo session latest --json\n hippo session resume\n hippo snapshot save --task \"Ship feature\" --summary \"Tests are green\" --next-step \"Open the PR\" --session sess_123\n hippo handoff create --summary \"PR is open, tests green\" --next \"Merge after review\" --session sess_123 --artifact src/foo.ts\n hippo handoff create --summary s --constraint a --constraint b --outcome partial --target-runtime codex --card-id c1 --tests pass\n hippo card create --title \"Add cards table\" --repo hippo --depends-on card_abc\n hippo card claim card_abc --runtime codex\n hippo card complete card_abc --outcome success\n hippo card heartbeat card_abc --run 7\n hippo card reclaim\n hippo embed --status\n hippo watch \"npm run build\"\n hippo learn --git --days 30\n hippo promote mem_abc123\n hippo sync\n hippo setup\n hippo hook install claude-code\n hippo decide \"Use PostgreSQL for new services\" --context \"JSONB support\"\n hippo incident \"Prod outage: DB connection pool exhausted\" --context \"spike at 14:00\"\n hippo process new \"Release\" --step \"run tests\" --step \"bump version\" --step \"publish\"\n hippo policy new \"Data retention\" --text \"Delete logs after 90 days\" --from 2026-01-01\n hippo policy asof 2026-03-01 --name \"Data retention\"\n hippo skill new \"Run tests\" --instructions \"npm test before every commit\" --trigger \"before commit\"\n hippo skill export\n hippo brief new \"hippo\" --summary \"Agent-memory library; E2 first-class objects in progress\"\n hippo brief refresh \"hippo\"\n hippo note new \"Acme Corp\" --text \"Renewal call: wants SSO before Q3; champion is the VP Eng\"\n hippo note list --customer \"Acme Corp\" --status active\n hippo graph extract\n hippo invalidate \"REST API\" --dry-run\n hippo invalidate \"REST API\" --reason \"migrated to GraphQL\"\n hippo invalidate --id mem_a1b2c3d4e5f6 --reason \"superseded by new policy\"\n hippo invalidate --churn --dry-run\n hippo export memories.json\n hippo export --format markdown memories.md\n hippo sleep --dry-run\n hippo outcome --good\n hippo status\n";
6
6
  /** Each verb's help blocks for the command table, keyed like COMMANDS; a block opens with a newline. */
7
7
  export declare const VERB_USAGE: {
package/dist/cli/usage.js CHANGED
@@ -21,7 +21,7 @@ export function printSlackWorkspacesUsage() {
21
21
  console.log(' remove --team <T> Remove a workspace registration');
22
22
  }
23
23
  export const USAGE_HEADER = `
24
- Hippo - memory for AI agents that learns what is wrong and ranks it down
24
+ Hippo - Make your agent's memory work like a brain. Hippo is long-term memory for coding agents.
25
25
 
26
26
  Usage: hippo <command> [options]
27
27
 
@@ -41,6 +41,8 @@ interface LazyDb {
41
41
  close: () => void;
42
42
  }
43
43
  export declare function lazyConsolidateDb(hippoRoot: string, dryRun: boolean): LazyDb;
44
+ /** Re-syncs the full-text index with `memories`; a store open that is already current no longer counts the two. */
45
+ export declare function syncFtsIndex(hippoRoot: string, dryRun: boolean, result: ConsolidationResult): void;
44
46
  /** State every sleep stage reads or appends to; the pending lists are flushed in one transaction at the end. */
45
47
  export interface SleepRun {
46
48
  hippoRoot: string;
@@ -1,4 +1,4 @@
1
- import { openHippoDb, closeHippoDb } from '../db.js';
1
+ import { openHippoDb, closeHippoDb, ftsRowCounts, isFtsAvailable, repairFtsDrift } from '../db.js';
2
2
  import { NO_MERGE_TAGS } from '../shared.js';
3
3
  export const REPLAY_COUNT_DEFAULT = 5;
4
4
  export function keptAsWritten(entry) {
@@ -25,6 +25,23 @@ export function lazyConsolidateDb(hippoRoot, dryRun) {
25
25
  };
26
26
  return { get, close };
27
27
  }
28
+ /** Re-syncs the full-text index with `memories`; a store open that is already current no longer counts the two. */
29
+ export function syncFtsIndex(hippoRoot, dryRun, result) {
30
+ const db = openHippoDb(hippoRoot);
31
+ try {
32
+ if (!isFtsAvailable(db))
33
+ return;
34
+ const counts = ftsRowCounts(db);
35
+ if (counts === null || counts.memories === counts.fts)
36
+ return;
37
+ if (!dryRun)
38
+ repairFtsDrift(db);
39
+ result.details.push(` 🔎 ${dryRun ? 'would re-sync' : 're-synced'} the full-text index (${counts.fts} indexed rows for ${counts.memories} memories)`);
40
+ }
41
+ finally {
42
+ closeHippoDb(db);
43
+ }
44
+ }
28
45
  export function newConsolidationResult(dryRun) {
29
46
  return {
30
47
  decayed: 0,
@@ -21,7 +21,7 @@ import { log } from '../log.js';
21
21
  import { decayPass } from './decay.js';
22
22
  import { retireHeldTexts, mergePass } from './merge.js';
23
23
  import { detectConflicts } from './conflicts.js';
24
- import { lazyConsolidateDb, newConsolidationResult } from './run.js';
24
+ import { lazyConsolidateDb, newConsolidationResult, syncFtsIndex } from './run.js';
25
25
  import { promoteSessionTraces, replayPass } from './traces.js';
26
26
  import { llmPasses } from './llm-passes.js';
27
27
  import { physicsPass } from './physics-pass.js';
@@ -33,6 +33,7 @@ export async function consolidate(hippoRoot, options = {}) {
33
33
  const dryRun = options.dryRun ?? false;
34
34
  const result = newConsolidationResult(dryRun);
35
35
  const halfLife = migrateHalfLives(hippoRoot, dryRun, result);
36
+ syncFtsIndex(hippoRoot, dryRun, result);
36
37
  // Host-wide by design: per-tenant filtering would mean N runs per host and no cross-tenant dedup.
37
38
  // The api.sleep audit row tags this with the admin synthetic actor.
38
39
  const all = loadAllEntries(hippoRoot);
@@ -1,5 +1,10 @@
1
1
  import type { DatabaseSyncLike } from './sqlite.js';
2
2
  export declare const MEMORY_QUARANTINE_DDL = "\n CREATE TABLE IF NOT EXISTS memory_quarantine (\n tenant_id TEXT NOT NULL DEFAULT 'default',\n memory_id TEXT NOT NULL,\n original_scope TEXT,\n reason TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),\n quarantined_at TEXT NOT NULL,\n decided_at TEXT,\n decided_by TEXT,\n PRIMARY KEY (tenant_id, memory_id)\n ) WITHOUT ROWID;\n CREATE INDEX IF NOT EXISTS idx_memory_quarantine_status\n ON memory_quarantine(tenant_id, status, quarantined_at DESC);\n";
3
3
  export declare function ensureContinuityTables(db: DatabaseSyncLike): void;
4
+ export declare const MEMORIES_FTS_DDL = "CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(id UNINDEXED, content, tags)";
5
+ /** Names of every table and index a `CREATE ... IF NOT EXISTS` in `ddl` makes. */
6
+ export declare function createdObjectNames(ddl: string): string[];
7
+ /** Every object runMigrations re-asserts on a stamped store; the open fast path requires all of them. */
8
+ export declare const REQUIRED_SCHEMA_OBJECTS: readonly string[];
4
9
  export declare function ensureContinuityIndexes(db: DatabaseSyncLike): void;
5
10
  //# sourceMappingURL=continuity.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { META_TABLE_DDL } from './meta.js';
1
2
  // Shared by migration v48 and the stamped-store self-heal below.
2
3
  export const MEMORY_QUARANTINE_DDL = `
3
4
  CREATE TABLE IF NOT EXISTS memory_quarantine (
@@ -115,31 +116,48 @@ const CARD_COMMENTS_DDL = `
115
116
  // Before the loop on stamped stores: a table lost after its migration stamped
116
117
  // is never re-migrated, and v4/v16/v22 ALTER or read it. Fresh stores use the chain.
117
118
  export function ensureContinuityTables(db) {
118
- db.exec(TASK_SNAPSHOTS_DDL);
119
- db.exec(SESSION_EVENTS_DDL);
120
- db.exec(SESSION_HANDOFFS_DDL);
121
- db.exec(CARDS_DDL);
122
- db.exec(CARD_DEPS_DDL);
123
- db.exec(CARD_RUNS_DDL);
124
- db.exec(CARD_COMMENTS_DDL);
125
- db.exec(MEMORY_QUARANTINE_DDL);
119
+ for (const ddl of CONTINUITY_TABLE_DDL)
120
+ db.exec(ddl);
126
121
  }
122
+ const CONTINUITY_TABLE_DDL = [
123
+ TASK_SNAPSHOTS_DDL,
124
+ SESSION_EVENTS_DDL,
125
+ SESSION_HANDOFFS_DDL,
126
+ CARDS_DDL,
127
+ CARD_DEPS_DDL,
128
+ CARD_RUNS_DDL,
129
+ CARD_COMMENTS_DDL,
130
+ MEMORY_QUARANTINE_DDL,
131
+ ];
132
+ const CONTINUITY_INDEX_DDL = [
133
+ `CREATE INDEX IF NOT EXISTS idx_task_snapshots_status_updated ON task_snapshots(status, updated_at DESC, id DESC)`,
134
+ `CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_status ON task_snapshots(tenant_id, status, updated_at DESC)`,
135
+ `CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_scope ON task_snapshots(tenant_id, scope, status)`,
136
+ `CREATE INDEX IF NOT EXISTS idx_session_events_session_created ON session_events(session_id, created_at DESC, id DESC)`,
137
+ `CREATE INDEX IF NOT EXISTS idx_session_events_task_created ON session_events(task, created_at DESC, id DESC)`,
138
+ `CREATE INDEX IF NOT EXISTS idx_session_events_tenant_session ON session_events(tenant_id, session_id, created_at DESC, id DESC)`,
139
+ `CREATE INDEX IF NOT EXISTS idx_session_handoffs_session ON session_handoffs(session_id, created_at DESC)`,
140
+ `CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_session ON session_handoffs(tenant_id, session_id, created_at DESC)`,
141
+ `CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_outcome ON session_handoffs(tenant_id, outcome, created_at DESC)`,
142
+ `CREATE INDEX IF NOT EXISTS idx_cards_tenant_status ON cards(tenant_id, status, updated_at DESC)`,
143
+ `CREATE INDEX IF NOT EXISTS idx_card_deps_tenant_child ON card_deps(tenant_id, child)`,
144
+ `CREATE INDEX IF NOT EXISTS idx_card_deps_tenant_parent ON card_deps(tenant_id, parent)`,
145
+ `CREATE INDEX IF NOT EXISTS idx_card_runs_tenant_card ON card_runs(tenant_id, card, started DESC)`,
146
+ `CREATE INDEX IF NOT EXISTS idx_card_comments_tenant_card ON card_comments(tenant_id, card_id, created_at DESC)`,
147
+ `CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_card ON session_handoffs(tenant_id, card_id, created_at DESC)`,
148
+ ];
149
+ // Lives with the other re-asserted DDL so the required-object list below derives from one place.
150
+ export const MEMORIES_FTS_DDL = `CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(id UNINDEXED, content, tags)`;
151
+ const CREATED_NAME = /CREATE\s+(?:VIRTUAL\s+)?(?:TABLE|INDEX)\s+IF\s+NOT\s+EXISTS\s+(\w+)/gi;
152
+ /** Names of every table and index a `CREATE ... IF NOT EXISTS` in `ddl` makes. */
153
+ export function createdObjectNames(ddl) {
154
+ return [...ddl.matchAll(CREATED_NAME)].map((m) => m[1]);
155
+ }
156
+ /** Every object runMigrations re-asserts on a stamped store; the open fast path requires all of them. */
157
+ export const REQUIRED_SCHEMA_OBJECTS = Object.freeze([META_TABLE_DDL, ...CONTINUITY_TABLE_DDL, ...CONTINUITY_INDEX_DDL, MEMORIES_FTS_DDL].flatMap(createdObjectNames));
127
158
  // After the loop: tenant_id (v16) and scope (v23) do not exist yet on a genuine old store.
128
159
  export function ensureContinuityIndexes(db) {
129
- db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_status_updated ON task_snapshots(status, updated_at DESC, id DESC)`);
130
- db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_status ON task_snapshots(tenant_id, status, updated_at DESC)`);
131
- db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_scope ON task_snapshots(tenant_id, scope, status)`);
132
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_events_session_created ON session_events(session_id, created_at DESC, id DESC)`);
133
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_events_task_created ON session_events(task, created_at DESC, id DESC)`);
134
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_events_tenant_session ON session_events(tenant_id, session_id, created_at DESC, id DESC)`);
135
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_session ON session_handoffs(session_id, created_at DESC)`);
136
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_session ON session_handoffs(tenant_id, session_id, created_at DESC)`);
137
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_outcome ON session_handoffs(tenant_id, outcome, created_at DESC)`);
138
- db.exec(`CREATE INDEX IF NOT EXISTS idx_cards_tenant_status ON cards(tenant_id, status, updated_at DESC)`);
139
- db.exec(`CREATE INDEX IF NOT EXISTS idx_card_deps_tenant_child ON card_deps(tenant_id, child)`);
140
- db.exec(`CREATE INDEX IF NOT EXISTS idx_card_deps_tenant_parent ON card_deps(tenant_id, parent)`);
141
- db.exec(`CREATE INDEX IF NOT EXISTS idx_card_runs_tenant_card ON card_runs(tenant_id, card, started DESC)`);
142
- db.exec(`CREATE INDEX IF NOT EXISTS idx_card_comments_tenant_card ON card_comments(tenant_id, card_id, created_at DESC)`);
143
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_card ON session_handoffs(tenant_id, card_id, created_at DESC)`);
160
+ for (const ddl of CONTINUITY_INDEX_DDL)
161
+ db.exec(ddl);
144
162
  }
145
163
  //# sourceMappingURL=continuity.js.map
package/dist/db/meta.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { DatabaseSyncLike } from './sqlite.js';
2
+ export declare const META_TABLE_DDL = "\n CREATE TABLE IF NOT EXISTS meta (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n ";
2
3
  export declare function ensureMetaTable(db: DatabaseSyncLike): void;
3
4
  export declare function getSchemaVersion(db: DatabaseSyncLike): number;
4
5
  export declare function setSchemaVersion(db: DatabaseSyncLike, version: number): void;
package/dist/db/meta.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { tableExists } from './tables.js';
2
- export function ensureMetaTable(db) {
3
- db.exec(`
2
+ export const META_TABLE_DDL = `
4
3
  CREATE TABLE IF NOT EXISTS meta (
5
4
  key TEXT PRIMARY KEY,
6
5
  value TEXT NOT NULL
7
6
  );
8
- `);
7
+ `;
8
+ export function ensureMetaTable(db) {
9
+ db.exec(META_TABLE_DDL);
9
10
  }
10
11
  export function getSchemaVersion(db) {
11
12
  if (!tableExists(db, 'meta'))
@@ -5,5 +5,14 @@ export declare class IncompatibleBinaryError extends Error {
5
5
  }
6
6
  /** Refuse a store stamped for a newer binary. Fails closed: runMigrations creates meta first, so a failed read is a real error. */
7
7
  export declare function assertBinaryCompatible(db: DatabaseSyncLike): void;
8
+ /** The fast path's single probe: versions, the binary minimum, the FTS flag, and counts of the meta defaults and required objects. */
9
+ export declare const SCHEMA_PROBE_SQL: string;
8
10
  export declare function runMigrations(db: DatabaseSyncLike, hippoRoot?: string, busyWaitMs?: number): void;
11
+ /** Row counts of `memories` and its full-text index, which differ once the index drifts; null when either table is missing. */
12
+ export declare function ftsRowCounts(db: DatabaseSyncLike): {
13
+ memories: number;
14
+ fts: number;
15
+ } | null;
16
+ /** Re-syncs `memories_fts` with `memories` when their counts differ and returns whether it did; sleep runs it because a fast open does not. */
17
+ export declare function repairFtsDrift(db: DatabaseSyncLike): boolean;
9
18
  //# sourceMappingURL=migrate.d.ts.map
@@ -4,7 +4,7 @@ import { importLegacyEmbeddingIndex } from '../vector-store.js';
4
4
  import { execWithBusyRetry } from './busy.js';
5
5
  import { tableExists } from './tables.js';
6
6
  import { ensureMetaTable, getMeta, getSchemaVersion, setMeta, setSchemaVersion } from './meta.js';
7
- import { ensureContinuityIndexes, ensureContinuityTables } from './continuity.js';
7
+ import { MEMORIES_FTS_DDL, REQUIRED_SCHEMA_OBJECTS, ensureContinuityIndexes, ensureContinuityTables } from './continuity.js';
8
8
  import { CURRENT_SCHEMA_VERSION, MIGRATIONS } from './migrations/index.js';
9
9
  export function getCurrentSchemaVersion() {
10
10
  return CURRENT_SCHEMA_VERSION;
@@ -12,15 +12,64 @@ export function getCurrentSchemaVersion() {
12
12
  /** Thrown by {@link assertBinaryCompatible}; doctor uses it to pick the upgrade fix over a generic permissions fix. */
13
13
  export class IncompatibleBinaryError extends Error {
14
14
  }
15
- /** Refuse a store stamped for a newer binary. Fails closed: runMigrations creates meta first, so a failed read is a real error. */
16
- export function assertBinaryCompatible(db) {
17
- const minRequired = getMeta(db, 'min_compatible_binary');
15
+ function assertMinBinary(minRequired) {
18
16
  if (minRequired && compareSemver(minRequired, PACKAGE_VERSION) > 0) {
19
17
  throw new IncompatibleBinaryError(`hippo-memory: this database requires hippo-memory >= ${minRequired}, but the running binary is ${PACKAGE_VERSION}. ` +
20
18
  `Upgrade hippo-memory to open it; an older binary does not know this schema and could expose private rows or damage the store.`);
21
19
  }
22
20
  }
21
+ /** Refuse a store stamped for a newer binary. Fails closed: runMigrations creates meta first, so a failed read is a real error. */
22
+ export function assertBinaryCompatible(db) {
23
+ assertMinBinary(getMeta(db, 'min_compatible_binary'));
24
+ }
25
+ const META_DEFAULTS = [
26
+ ['schema_version', String(CURRENT_SCHEMA_VERSION)],
27
+ ['last_retrieval_ids', '[]'],
28
+ ['last_trace_id', ''],
29
+ ['total_remembered', '0'],
30
+ ['total_recalled', '0'],
31
+ ['total_forgotten', '0'],
32
+ ['fts5_available', '0'],
33
+ ];
34
+ // Names come from our own constants and match \w+, so inlining them as literals is safe.
35
+ const sqlList = (names) => names.map((n) => `'${n}'`).join(', ');
36
+ /** The fast path's single probe: versions, the binary minimum, the FTS flag, and counts of the meta defaults and required objects. */
37
+ export const SCHEMA_PROBE_SQL = `SELECT
38
+ (SELECT user_version FROM pragma_user_version) AS user_version,
39
+ (SELECT value FROM meta WHERE key = 'schema_version') AS schema_version,
40
+ (SELECT value FROM meta WHERE key = 'min_compatible_binary') AS min_binary,
41
+ (SELECT value FROM meta WHERE key = 'fts5_available') AS fts5,
42
+ (SELECT COUNT(*) FROM meta WHERE key IN (${sqlList(META_DEFAULTS.map(([key]) => key))})) AS meta_keys,
43
+ (SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table', 'index') AND name IN (${sqlList(REQUIRED_SCHEMA_OBJECTS)})) AS objects`;
44
+ /** True when the store is current and whole, so the open can skip every DDL statement. Runs the binary check either way. */
45
+ function schemaIsCurrent(db) {
46
+ let probe;
47
+ try {
48
+ // SAFETY: the SELECT names exactly these six columns and always returns one row.
49
+ probe = db.prepare(SCHEMA_PROBE_SQL).get();
50
+ }
51
+ catch (err) {
52
+ // A store with no meta table yet is the slow path's job; anything else is a real error.
53
+ if (err instanceof Error && /no such table: meta/.test(err.message))
54
+ return false;
55
+ throw err;
56
+ }
57
+ if (!probe)
58
+ return false;
59
+ assertMinBinary(probe.min_binary);
60
+ return Number(probe.user_version) === CURRENT_SCHEMA_VERSION
61
+ && Number(probe.schema_version) === CURRENT_SCHEMA_VERSION
62
+ && Number(probe.meta_keys) === META_DEFAULTS.length
63
+ && Number(probe.objects) === REQUIRED_SCHEMA_OBJECTS.length
64
+ && probe.fts5 === '1';
65
+ }
23
66
  export function runMigrations(db, hippoRoot, busyWaitMs) {
67
+ if (!schemaIsCurrent(db))
68
+ migrateAndHeal(db, hippoRoot, busyWaitMs);
69
+ if (hippoRoot)
70
+ importLegacyVectors(db, hippoRoot);
71
+ }
72
+ function migrateAndHeal(db, hippoRoot, busyWaitMs) {
24
73
  ensureMetaTable(db);
25
74
  // Before anything writes, so a stale binary never repairs or migrates a store it does not understand.
26
75
  assertBinaryCompatible(db);
@@ -30,36 +79,46 @@ export function runMigrations(db, hippoRoot, busyWaitMs) {
30
79
  for (const migration of MIGRATIONS) {
31
80
  if (migration.version <= currentVersion)
32
81
  continue;
33
- execWithBusyRetry(db, 'BEGIN IMMEDIATE', busyWaitMs);
34
- try {
35
- // A newer binary may have migrated and raised the minimum while we waited for the lock.
36
- assertBinaryCompatible(db);
37
- // Re-read under the write lock: another process may have applied this
38
- // migration while we waited, and re-running one is not idempotent.
39
- const applied = getSchemaVersion(db);
40
- if (applied >= migration.version) {
41
- db.exec('COMMIT');
42
- currentVersion = applied;
43
- continue;
44
- }
45
- migration.up(db, { hippoRoot });
46
- setSchemaVersion(db, migration.version);
47
- db.exec('COMMIT');
48
- currentVersion = migration.version;
49
- }
50
- catch (error) {
51
- try {
52
- db.exec('ROLLBACK');
53
- }
54
- catch { /* already rolled back; keep the original error */ }
55
- throw error;
56
- }
82
+ currentVersion = applyMigration(db, migration, hippoRoot, busyWaitMs);
57
83
  }
84
+ healUserVersion(db, currentVersion, busyWaitMs);
58
85
  ensureContinuityIndexes(db);
59
86
  ensureMetaDefaults(db);
60
87
  ensureOptionalFts(db);
61
- if (hippoRoot)
62
- importLegacyVectors(db, hippoRoot);
88
+ }
89
+ /** Applies one migration under the write lock and returns the version the store is now at. */
90
+ function applyMigration(db, migration, hippoRoot, busyWaitMs) {
91
+ execWithBusyRetry(db, 'BEGIN IMMEDIATE', busyWaitMs);
92
+ try {
93
+ // A newer binary may have migrated and raised the minimum while we waited for the lock.
94
+ assertBinaryCompatible(db);
95
+ // Re-read under the write lock: another process may have applied this
96
+ // migration while we waited, and re-running one is not idempotent.
97
+ const applied = getSchemaVersion(db);
98
+ if (applied >= migration.version) {
99
+ db.exec('COMMIT');
100
+ return applied;
101
+ }
102
+ migration.up(db, { hippoRoot });
103
+ setSchemaVersion(db, migration.version);
104
+ db.exec('COMMIT');
105
+ return migration.version;
106
+ }
107
+ catch (error) {
108
+ try {
109
+ db.exec('ROLLBACK');
110
+ }
111
+ catch { /* already rolled back; keep the original error */ }
112
+ throw error;
113
+ }
114
+ }
115
+ // The fast path requires user_version as well, and a store stamped only in meta would otherwise never reach it.
116
+ function healUserVersion(db, version, busyWaitMs) {
117
+ // SAFETY: PRAGMA user_version returns one row with one integer column.
118
+ const row = db.prepare('PRAGMA user_version').get();
119
+ const target = Math.max(0, Math.trunc(version));
120
+ if (Number(row?.user_version ?? 0) < target)
121
+ execWithBusyRetry(db, `PRAGMA user_version = ${target}`, busyWaitMs);
63
122
  }
64
123
  // A failed import leaves embeddings.json in place for the next open to retry; the store still opens.
65
124
  function importLegacyVectors(db, hippoRoot) {
@@ -71,27 +130,18 @@ function importLegacyVectors(db, hippoRoot) {
71
130
  }
72
131
  }
73
132
  function ensureMetaDefaults(db) {
74
- const defaults = [
75
- ['schema_version', String(CURRENT_SCHEMA_VERSION)],
76
- ['last_retrieval_ids', '[]'],
77
- ['last_trace_id', ''],
78
- ['total_remembered', '0'],
79
- ['total_recalled', '0'],
80
- ['total_forgotten', '0'],
81
- ['fts5_available', '0'],
82
- ];
83
133
  // Read-first: an already-current store has all 7 keys, so this is one
84
134
  // SELECT and zero writes instead of 7 no-op INSERT OR IGNOREs, each of
85
135
  // which takes a RESERVED lock even when nothing changes.
86
- const keys = defaults.map(([key]) => key);
136
+ const keys = META_DEFAULTS.map(([key]) => key);
87
137
  const present = new Set(db
88
138
  .prepare(`SELECT key FROM meta WHERE key IN (${keys.map(() => '?').join(',')})`)
89
139
  .all(...keys)
90
140
  .map((r) => r.key));
91
- if (present.size === defaults.length)
141
+ if (present.size === META_DEFAULTS.length)
92
142
  return;
93
143
  const stmt = db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)`);
94
- for (const [key, value] of defaults) {
144
+ for (const [key, value] of META_DEFAULTS) {
95
145
  if (!present.has(key))
96
146
  stmt.run(key, value);
97
147
  }
@@ -99,8 +149,8 @@ function ensureMetaDefaults(db) {
99
149
  function ensureOptionalFts(db) {
100
150
  let available = false;
101
151
  try {
102
- db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(id UNINDEXED, content, tags)`);
103
- backfillFtsIndex(db);
152
+ db.exec(MEMORIES_FTS_DDL);
153
+ repairFtsDrift(db);
104
154
  available = true;
105
155
  }
106
156
  catch (err) {
@@ -112,16 +162,23 @@ function ensureOptionalFts(db) {
112
162
  if (getMeta(db, 'fts5_available') !== flag)
113
163
  setMeta(db, 'fts5_available', flag);
114
164
  }
115
- function backfillFtsIndex(db) {
116
- // SAFETY: this get() result's shape matches the single aliased `c` column
117
- // named in the SELECT above.
118
- const memCount = db.prepare(`SELECT COUNT(*) AS c FROM memories`).get()?.c ?? 0;
119
- // SAFETY: this get() result's shape matches the single aliased `c` column named in the SELECT above.
165
+ /** Row counts of `memories` and its full-text index, which differ once the index drifts; null when either table is missing. */
166
+ export function ftsRowCounts(db) {
167
+ if (!tableExists(db, 'memories_fts') || !tableExists(db, 'memories'))
168
+ return null;
169
+ // SAFETY: this get() result's shape matches the single aliased `c` column named in the SELECT.
170
+ const memories = db.prepare(`SELECT COUNT(*) AS c FROM memories`).get()?.c ?? 0;
120
171
  // memories_fts_docsize is FTS5's cheaper one-row-per-document shadow table; fall back for a foreign-built index.
121
172
  const ftsTable = tableExists(db, 'memories_fts_docsize') ? 'memories_fts_docsize' : 'memories_fts';
122
- const ftsCount = db.prepare(`SELECT COUNT(*) AS c FROM ${ftsTable}`).get()?.c ?? 0;
123
- if (memCount === ftsCount)
124
- return;
173
+ // SAFETY: this get() result's shape matches the single aliased `c` column named in the SELECT.
174
+ const fts = db.prepare(`SELECT COUNT(*) AS c FROM ${ftsTable}`).get()?.c ?? 0;
175
+ return { memories: Number(memories), fts: Number(fts) };
176
+ }
177
+ /** Re-syncs `memories_fts` with `memories` when their counts differ and returns whether it did; sleep runs it because a fast open does not. */
178
+ export function repairFtsDrift(db) {
179
+ const counts = ftsRowCounts(db);
180
+ if (counts === null || counts.memories === counts.fts)
181
+ return false;
125
182
  db.exec(`
126
183
  INSERT INTO memories_fts(id, content, tags)
127
184
  SELECT m.id, m.content, m.tags_json
@@ -134,5 +191,6 @@ function backfillFtsIndex(db) {
134
191
  DELETE FROM memories_fts
135
192
  WHERE id NOT IN (SELECT id FROM memories)
136
193
  `);
194
+ return true;
137
195
  }
138
196
  //# sourceMappingURL=migrate.js.map
package/dist/db.d.ts CHANGED
@@ -2,6 +2,6 @@ export type { DatabaseSyncLike } from './db/sqlite.js';
2
2
  export { isSqliteBusy, STORE_BUSY_MESSAGE, execWithBusyRetry } from './db/busy.js';
3
3
  export { getSchemaVersion, getMeta, setMeta, isFtsAvailable } from './db/meta.js';
4
4
  export { countTableRows, pruneConsolidationRuns } from './db/tables.js';
5
- export { getCurrentSchemaVersion, IncompatibleBinaryError } from './db/migrate.js';
5
+ export { getCurrentSchemaVersion, IncompatibleBinaryError, ftsRowCounts, repairFtsDrift } from './db/migrate.js';
6
6
  export { getHippoDbPath, HOOK_DB_WAIT_MS, noteStoreBusy, withSharedStoreHandles, SERVER_DB_WAIT_MS, withBusyWait, openHippoDb, openHippoDbReadOnly, closeHippoDb, } from './db/open.js';
7
7
  //# sourceMappingURL=db.d.ts.map
package/dist/db.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { isSqliteBusy, STORE_BUSY_MESSAGE, execWithBusyRetry } from './db/busy.js';
2
2
  export { getSchemaVersion, getMeta, setMeta, isFtsAvailable } from './db/meta.js';
3
3
  export { countTableRows, pruneConsolidationRuns } from './db/tables.js';
4
- export { getCurrentSchemaVersion, IncompatibleBinaryError } from './db/migrate.js';
4
+ export { getCurrentSchemaVersion, IncompatibleBinaryError, ftsRowCounts, repairFtsDrift } from './db/migrate.js';
5
5
  export { getHippoDbPath, HOOK_DB_WAIT_MS, noteStoreBusy, withSharedStoreHandles, SERVER_DB_WAIT_MS, withBusyWait, openHippoDb, openHippoDbReadOnly, closeHippoDb, } from './db/open.js';
6
6
  //# sourceMappingURL=db.js.map
package/dist/doctor.js CHANGED
@@ -11,7 +11,7 @@ import { findHippoStoreDir } from './project-identity.js';
11
11
  import { getGlobalRoot } from './shared.js';
12
12
  import { isInitialized } from './store/open.js';
13
13
  import { loadConfig } from './config.js';
14
- import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion, countTableRows, IncompatibleBinaryError } from './db.js';
14
+ import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion, countTableRows, ftsRowCounts, IncompatibleBinaryError } from './db.js';
15
15
  import { REPLAY_AFTER_MS, TRANSCRIPT_FILL_WINDOW_MS } from './compaction-record.js';
16
16
  import { isEmbeddingAvailable } from './local-embedding.js';
17
17
  import { CODEX_TRUST_LINE, codexHomeDir, isCodexPresent, isJsonObject } from './hooks/shared.js';
@@ -191,6 +191,25 @@ function memoriesCheck(db) {
191
191
  }
192
192
  return memoryCheck;
193
193
  }
194
+ /** Whether the full-text index still matches `memories`; opening a current store no longer checks, so doctor reports and sleep repairs. */
195
+ function ftsCheck(db) {
196
+ try {
197
+ const counts = ftsRowCounts(db);
198
+ if (counts === null)
199
+ return { id: 'fts', status: 'info', detail: 'no full-text index; search uses slower LIKE matching' };
200
+ if (counts.memories === counts.fts)
201
+ return { id: 'fts', status: 'pass', detail: `full-text index in sync (${counts.fts} rows)` };
202
+ return {
203
+ id: 'fts',
204
+ status: 'warn',
205
+ detail: `full-text index out of sync: ${counts.fts} indexed rows for ${counts.memories} memories, so search can miss memories or match removed ones`,
206
+ fix: 'hippo sleep (re-syncs the index)',
207
+ };
208
+ }
209
+ catch (err) {
210
+ return { id: 'fts', status: 'warn', detail: `cannot read the full-text index: ${err instanceof Error ? err.message : String(err)}` };
211
+ }
212
+ }
194
213
  function tokensCheck(db, since) {
195
214
  try {
196
215
  // SAFETY: COUNT/SUM aggregate row.
@@ -217,6 +236,7 @@ function databaseChecks(store, now) {
217
236
  const have = getSchemaVersion(db);
218
237
  checks.push(schemaCheck(have, getCurrentSchemaVersion()));
219
238
  checks.push(memoriesCheck(db));
239
+ checks.push(ftsCheck(db));
220
240
  const since = new Date(now.getTime() - 7 * 86_400_000).toISOString();
221
241
  checks.push(tokensCheck(db, since));
222
242
  checks.push(failuresCheck(db, since, have));
@@ -31,19 +31,9 @@ function moveRowToArchive(db, id, row, opts) {
31
31
  // Flip kind to 'archived' so the BEFORE DELETE trigger no longer fires, then delete.
32
32
  db.prepare(`UPDATE memories SET kind = 'archived' WHERE id = ?`).run(id);
33
33
  db.prepare(`DELETE FROM memories WHERE id = ?`).run(id);
34
- // FTS5 is a virtual table — no FK CASCADE applies. Purge the FTS row so the
35
- // archived content is not searchable after archive. Without this the original
36
- // raw text remains in memories_fts until the next DB-open backfill, defeating
37
- // GDPR right-to-be-forgotten.
38
- if (isFtsAvailable(db)) {
39
- try {
40
- db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(id);
41
- }
42
- catch {
43
- // Best effort only. The DELETE on memories already succeeded; FTS will
44
- // self-heal on next DB open via backfillFtsIndex.
45
- }
46
- }
34
+ // Archived text must be unsearchable at commit, so a failed purge fails (and rolls back) the whole archive.
35
+ if (isFtsAvailable(db))
36
+ db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(id);
47
37
  }
48
38
  // Emit the archive_raw audit event inside the SAVEPOINT so the audit row is
49
39
  // committed atomically with the row deletion. Use the row's own tenant_id
@@ -64,13 +64,10 @@ function recordHalfLifeBaseForNewStore(db) {
64
64
  setMeta(db, TYPED_HALF_LIFE_META_KEY, '1');
65
65
  }
66
66
  function bootstrapLegacyStore(db, hippoRoot) {
67
- // SAFETY: countRow's shape matches the single `COUNT(*) AS count` column
68
- // selected above; `.get()` returns undefined only when no row exists.
69
- const countRow = db.prepare(`SELECT COUNT(*) AS count FROM memories`).get();
70
- const memoryCount = Number(countRow?.count ?? 0);
71
- if (memoryCount > 0)
67
+ // Existence, not COUNT(*): a count walks every row on each write's open.
68
+ if (db.prepare(`SELECT 1 AS x FROM memories LIMIT 1`).get() !== undefined)
72
69
  return false;
73
- // memoryCount misses an all-rejected bootstrap (memories stays empty), which would re-run the
70
+ // The row check misses an all-rejected bootstrap (memories stays empty), which would re-run the
74
71
  // import on every open and duplicate consolidation_runs; this meta flag settles it.
75
72
  if (getMeta(db, 'legacy_bootstrap_completed', '0') === '1')
76
73
  return false;
package/dist/version.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export declare const PACKAGE_VERSION = "1.63.0";
19
+ export declare const PACKAGE_VERSION = "1.63.1";
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export declare function compareSemver(a: string, b: string): number;
22
22
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export const PACKAGE_VERSION = '1.63.0';
19
+ export const PACKAGE_VERSION = '1.63.1';
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export function compareSemver(a, b) {
22
22
  const parse = (v) => {
@@ -1,6 +1,8 @@
1
1
  # Hippo Memory - OpenClaw Plugin
2
2
 
3
- Biologically-inspired memory for OpenClaw agents. Memories decay by default, retrieval strengthens them, errors stick longer, and sleep consolidation merges related episodes into one memory.
3
+ Make your agent's memory work like a brain. Hippo is long-term memory for coding agents.
4
+
5
+ Memories decay by default, retrieval strengthens them, errors stick longer, and sleep consolidation merges related episodes into one memory.
4
6
 
5
7
  ## Install
6
8
 
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
- "description": "Memory for AI agents that learns what is wrong and ranks it down. Injects context at session start and captures errors.",
5
- "version": "1.63.0",
4
+ "description": "Make your agent's memory work like a brain. Hippo is long-term memory for coding agents.",
5
+ "version": "1.63.1",
6
6
 
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.63.0",
3
+ "version": "1.63.1",
4
4
  "type": "module",
5
- "description": "Hippo Memory plugin for OpenClaw - biologically-inspired agent memory",
5
+ "description": "Make your agent's memory work like a brain. Hippo is long-term memory for coding agents.",
6
6
  "main": "index.ts",
7
7
  "openclaw": {
8
8
  "extensions": [
@@ -13,4 +13,4 @@
13
13
  "hippo-memory": ">=0.4.0"
14
14
  },
15
15
  "license": "MIT"
16
- }
16
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
- "description": "Memory for AI agents that learns what is wrong and ranks it down. Injects context at session start and captures errors.",
5
- "version": "1.63.0",
4
+ "description": "Make your agent's memory work like a brain. Hippo is long-term memory for coding agents.",
5
+ "version": "1.63.1",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.63.0",
4
- "description": "Memory for AI agents that learns what is wrong and ranks it down. MCP server, hooks for Claude Code, OpenCode and Codex, AGENTS.md instructions for Codex, Cursor, OpenClaw and Pi. SQLite, zero runtime deps.",
3
+ "version": "1.63.1",
4
+ "description": "Make your agent's memory work like a brain. Hippo is long-term memory for coding agents.",
5
5
  "mcpName": "io.github.kitfunso/hippo-memory",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",