hippo-memory 1.52.9 → 1.53.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.
Files changed (74) hide show
  1. package/README.md +34 -10
  2. package/dist/agent-memories/apply.d.ts +47 -0
  3. package/dist/agent-memories/apply.js +253 -0
  4. package/dist/agent-memories/claude-code.d.ts +11 -0
  5. package/dist/agent-memories/claude-code.js +113 -0
  6. package/dist/agent-memories/codex.d.ts +3 -0
  7. package/dist/agent-memories/codex.js +47 -0
  8. package/dist/agent-memories/copilot.d.ts +3 -0
  9. package/dist/agent-memories/copilot.js +125 -0
  10. package/dist/agent-memories/files.d.ts +37 -0
  11. package/dist/agent-memories/files.js +77 -0
  12. package/dist/agent-memories/folder-store.d.ts +17 -0
  13. package/dist/agent-memories/folder-store.js +44 -0
  14. package/dist/agent-memories/gemini.d.ts +3 -0
  15. package/dist/agent-memories/gemini.js +103 -0
  16. package/dist/agent-memories/git.d.ts +8 -0
  17. package/dist/agent-memories/git.js +11 -0
  18. package/dist/agent-memories/keys.d.ts +9 -0
  19. package/dist/agent-memories/keys.js +20 -0
  20. package/dist/agent-memories/legacy.d.ts +17 -0
  21. package/dist/agent-memories/legacy.js +45 -0
  22. package/dist/agent-memories/markdown.d.ts +13 -0
  23. package/dist/agent-memories/markdown.js +123 -0
  24. package/dist/agent-memories/openclaw.d.ts +3 -0
  25. package/dist/agent-memories/openclaw.js +42 -0
  26. package/dist/agent-memories/plan.d.ts +78 -0
  27. package/dist/agent-memories/plan.js +123 -0
  28. package/dist/agent-memories/qwen-code.d.ts +5 -0
  29. package/dist/agent-memories/qwen-code.js +50 -0
  30. package/dist/agent-memories/report.d.ts +52 -0
  31. package/dist/agent-memories/report.js +88 -0
  32. package/dist/agent-memories/source.d.ts +16 -0
  33. package/dist/agent-memories/source.js +32 -0
  34. package/dist/agent-memories/sync.d.ts +33 -0
  35. package/dist/agent-memories/sync.js +336 -0
  36. package/dist/agent-memories/tools.d.ts +33 -0
  37. package/dist/agent-memories/tools.js +19 -0
  38. package/dist/agent-memories/types.d.ts +42 -0
  39. package/dist/agent-memories/types.js +2 -0
  40. package/dist/api.d.ts +2 -2
  41. package/dist/api.js +26 -27
  42. package/dist/audit.d.ts +1 -1
  43. package/dist/audit.js +5 -2
  44. package/dist/capture.d.ts +11 -22
  45. package/dist/capture.js +76 -81
  46. package/dist/cli.d.ts +0 -2
  47. package/dist/cli.js +131 -151
  48. package/dist/compaction-items.d.ts +18 -0
  49. package/dist/compaction-items.js +60 -0
  50. package/dist/compaction-record.d.ts +94 -0
  51. package/dist/compaction-record.js +546 -0
  52. package/dist/config.d.ts +4 -0
  53. package/dist/config.js +13 -0
  54. package/dist/consolidate.js +2 -2
  55. package/dist/db.d.ts +5 -1
  56. package/dist/db.js +40 -8
  57. package/dist/doctor.js +34 -2
  58. package/dist/dormant.d.ts +5 -3
  59. package/dist/dormant.js +9 -0
  60. package/dist/gated-write.d.ts +9 -0
  61. package/dist/gated-write.js +24 -0
  62. package/dist/hooks.d.ts +4 -2
  63. package/dist/hooks.js +8 -7
  64. package/dist/memory.d.ts +19 -2
  65. package/dist/memory.js +35 -3
  66. package/dist/shared.js +10 -7
  67. package/dist/store.d.ts +8 -2
  68. package/dist/store.js +21 -2
  69. package/dist/version.d.ts +1 -1
  70. package/dist/version.js +1 -1
  71. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  72. package/extensions/openclaw-plugin/package.json +1 -1
  73. package/openclaw.plugin.json +1 -1
  74. package/package.json +1 -1
package/dist/config.js CHANGED
@@ -82,6 +82,9 @@ const DEFAULT_CONFIG = {
82
82
  churnStaleness: {
83
83
  enabled: false,
84
84
  },
85
+ agentMemories: {
86
+ tools: null,
87
+ },
85
88
  };
86
89
  function isMemoryValueConfig(value) {
87
90
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -92,6 +95,15 @@ function isDormantConfig(value) {
92
95
  function isChurnStalenessConfig(value) {
93
96
  return typeof value === 'object' && value !== null && !Array.isArray(value);
94
97
  }
98
+ function agentMemoryTools(value) {
99
+ if (value === undefined || value === null)
100
+ return null;
101
+ if (Array.isArray(value) && value.every((t) => String(t) === t))
102
+ return value;
103
+ console.error(`Warning: config.json's "agentMemories.tools" must be a list of tool ids like ["claude-code", "codex"] ` +
104
+ `(got ${JSON.stringify(value)}) - importing none.`);
105
+ return [];
106
+ }
95
107
  export function loadConfig(hippoRoot) {
96
108
  const configPath = path.join(hippoRoot, 'config.json');
97
109
  if (!fs.existsSync(configPath))
@@ -192,6 +204,7 @@ export function loadConfig(hippoRoot) {
192
204
  churnStaleness: {
193
205
  enabled: churnStalenessEnabled,
194
206
  },
207
+ agentMemories: { tools: agentMemoryTools(raw.agentMemories?.tools) },
195
208
  };
196
209
  }
197
210
  catch (err) {
@@ -153,7 +153,7 @@ export async function consolidate(hippoRoot, options = {}) {
153
153
  for (const e of all)
154
154
  e.half_life_days = halfLife.halfLives.get(e.id) ?? e.half_life_days;
155
155
  const backingObjects = memoriesBackingObjects(hippoRoot);
156
- // Retirable: auto-deletable (never pinned, never raw) and not backing a first-class object.
156
+ // Retirable: auto-deletable (never pinned, raw or kept for good) and not backing a first-class object.
157
157
  const retirable = (entry) => canAutoDelete(entry) && !backingObjects.has(entry.id);
158
158
  const snapshot = new Map(structuredClone(all).map((e) => [e.id, e]));
159
159
  // Load decay options from config + session context
@@ -174,7 +174,7 @@ export async function consolidate(hippoRoot, options = {}) {
174
174
  // so it stays where it is (stored strength refreshed) but sits out the
175
175
  // rest of this cycle the way a deleted row would. Anything else goes
176
176
  // dormant when config.dormant is on, and is deleted otherwise.
177
- // Only called for rows `retirable` allows (never pinned, never raw, never backing a first-class object).
177
+ // Only called for rows `retirable` allows (never pinned, raw, kept for good or backing a first-class object).
178
178
  const retireFaded = (entry, strength) => {
179
179
  const why = `(strength ${strength.toFixed(4)} < ${DECAY_THRESHOLD})`;
180
180
  // A faded secret is deleted, never kept dormant: keeping it would hold a
package/dist/db.d.ts CHANGED
@@ -16,7 +16,11 @@ export declare function getCurrentSchemaVersion(): number;
16
16
  /** Thrown by {@link assertBinaryCompatible}; doctor uses it to pick the upgrade fix over a generic permissions fix. */
17
17
  export declare class IncompatibleBinaryError extends Error {
18
18
  }
19
- export declare function openHippoDb(hippoRoot: string): DatabaseSyncLike;
19
+ export declare function isSqliteBusy(error: unknown): boolean;
20
+ /** `busyWaitMs` shortens every lock wait of this open, for a hook that must finish inside its own timeout. */
21
+ export declare function openHippoDb(hippoRoot: string, opts?: {
22
+ busyWaitMs?: number;
23
+ }): DatabaseSyncLike;
20
24
  /** Open an existing store without changing it: no mkdir, WAL switch, migration or mirror cleanup. Throws when hippo.db is missing. */
21
25
  export declare function openHippoDbReadOnly(hippoRoot: string): DatabaseSyncLike;
22
26
  export declare function getSchemaVersion(db: DatabaseSyncLike): number;
package/dist/db.js CHANGED
@@ -11,7 +11,7 @@ const require = createRequire(import.meta.url);
11
11
  // runtime (Node's built-in synchronous SQLite module); there are no bundled
12
12
  // types for it here, so this require + cast is the module's documented boundary.
13
13
  const { DatabaseSync } = require('node:sqlite');
14
- const CURRENT_SCHEMA_VERSION = 48;
14
+ const CURRENT_SCHEMA_VERSION = 49;
15
15
  const MIGRATIONS = [
16
16
  {
17
17
  version: 1,
@@ -2471,6 +2471,36 @@ const MIGRATIONS = [
2471
2471
  db.exec(MEMORY_QUARANTINE_DDL);
2472
2472
  },
2473
2473
  },
2474
+ {
2475
+ version: 49,
2476
+ up: (db) => {
2477
+ // Compaction record (src/compaction-record.ts): one row per Claude Code compaction, written before it (started)
2478
+ // and after it (summarised, done). Not a memory row. Additive only: no min_compatible_binary bump.
2479
+ db.exec(`
2480
+ CREATE TABLE IF NOT EXISTS compactions (
2481
+ tenant_id TEXT NOT NULL DEFAULT 'default',
2482
+ id TEXT NOT NULL,
2483
+ session_id TEXT NOT NULL,
2484
+ origin_project TEXT NOT NULL DEFAULT '',
2485
+ compact_trigger TEXT,
2486
+ cwd TEXT,
2487
+ transcript_path TEXT,
2488
+ snapshot_saved INTEGER NOT NULL DEFAULT 0,
2489
+ started_at TEXT NOT NULL,
2490
+ summarised_at TEXT,
2491
+ summary TEXT,
2492
+ items_json TEXT,
2493
+ items_written INTEGER NOT NULL DEFAULT 0,
2494
+ status TEXT NOT NULL DEFAULT 'started' CHECK (status IN ('started','summarised','done','no-summary')),
2495
+ PRIMARY KEY (tenant_id, id)
2496
+ );
2497
+ CREATE INDEX IF NOT EXISTS idx_compactions_session
2498
+ ON compactions(tenant_id, session_id, started_at);
2499
+ CREATE INDEX IF NOT EXISTS idx_compactions_status
2500
+ ON compactions(tenant_id, status);
2501
+ `);
2502
+ },
2503
+ },
2474
2504
  ];
2475
2505
  function tableHasColumn(db, tableName, columnName) {
2476
2506
  if (!/^[a-z_]+$/i.test(tableName))
@@ -2504,7 +2534,7 @@ function assertBinaryCompatible(db) {
2504
2534
  `Upgrade hippo-memory to open it; an older binary does not know this schema and could expose private rows or damage the store.`);
2505
2535
  }
2506
2536
  }
2507
- function isSqliteBusy(error) {
2537
+ export function isSqliteBusy(error) {
2508
2538
  const code = error?.errcode;
2509
2539
  return code === 5 || code === 6 || code === 517;
2510
2540
  }
@@ -2526,16 +2556,18 @@ function execWithBusyRetry(db, sql, timeoutMs = 30000) {
2526
2556
  }
2527
2557
  }
2528
2558
  }
2529
- export function openHippoDb(hippoRoot) {
2559
+ /** `busyWaitMs` shortens every lock wait of this open, for a hook that must finish inside its own timeout. */
2560
+ export function openHippoDb(hippoRoot, opts) {
2530
2561
  fs.mkdirSync(hippoRoot, { recursive: true });
2531
2562
  const db = new DatabaseSync(getHippoDbPath(hippoRoot));
2563
+ const busyWaitMs = opts?.busyWaitMs;
2532
2564
  try {
2533
- db.exec('PRAGMA busy_timeout = 5000');
2534
- execWithBusyRetry(db, 'PRAGMA journal_mode = WAL');
2565
+ db.exec(`PRAGMA busy_timeout = ${busyWaitMs ?? 5000}`);
2566
+ execWithBusyRetry(db, 'PRAGMA journal_mode = WAL', busyWaitMs);
2535
2567
  db.exec('PRAGMA synchronous = NORMAL');
2536
2568
  db.exec('PRAGMA wal_autocheckpoint = 100');
2537
2569
  db.exec('PRAGMA foreign_keys = ON');
2538
- runMigrations(db, hippoRoot);
2570
+ runMigrations(db, hippoRoot, busyWaitMs);
2539
2571
  // Path A backfill: delete any orphan markdown mirrors for already-archived
2540
2572
  // raw_archive rows. Idempotent via per-row raw_archive.mirror_cleaned_at
2541
2573
  // (v21). Wrapped in try/catch — a filesystem failure must not prevent DB open.
@@ -2576,7 +2608,7 @@ export function openHippoDbReadOnly(hippoRoot) {
2576
2608
  throw error;
2577
2609
  }
2578
2610
  }
2579
- function runMigrations(db, hippoRoot) {
2611
+ function runMigrations(db, hippoRoot, busyWaitMs) {
2580
2612
  ensureMetaTable(db);
2581
2613
  // Before anything writes, so a stale binary never repairs or migrates a store it does not understand.
2582
2614
  assertBinaryCompatible(db);
@@ -2586,7 +2618,7 @@ function runMigrations(db, hippoRoot) {
2586
2618
  for (const migration of MIGRATIONS) {
2587
2619
  if (migration.version <= currentVersion)
2588
2620
  continue;
2589
- execWithBusyRetry(db, 'BEGIN IMMEDIATE');
2621
+ execWithBusyRetry(db, 'BEGIN IMMEDIATE', busyWaitMs);
2590
2622
  try {
2591
2623
  // A newer binary may have migrated and raised the minimum while we waited for the lock.
2592
2624
  assertBinaryCompatible(db);
package/dist/doctor.js CHANGED
@@ -11,6 +11,7 @@ import { findHippoStoreDir } from './project-identity.js';
11
11
  import { getGlobalRoot } from './shared.js';
12
12
  import { isInitialized } from './store.js';
13
13
  import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion, countTableRows, IncompatibleBinaryError } from './db.js';
14
+ import { REPLAY_AFTER_MS, TRANSCRIPT_FILL_WINDOW_MS } from './compaction-record.js';
14
15
  import { isEmbeddingAvailable } from './embeddings.js';
15
16
  import { CODEX_TRUST_LINE, codexHomeDir, isCodexPresent, isJsonObject } from './hooks.js';
16
17
  /** Minimum Node.js version hippo supports (package.json engines). */
@@ -96,6 +97,36 @@ function sleepCheck(db, now) {
96
97
  return { id: 'sleep', status: 'info', detail: `sleep history unavailable (${message})` };
97
98
  }
98
99
  }
100
+ /** Compaction records the PostCompact hook left unfinished, which `hippo sleep` replays. */
101
+ function compactionsCheck(db, now) {
102
+ const stuckBefore = new Date(now.getTime() - REPLAY_AFTER_MS).toISOString();
103
+ const transcriptFloor = new Date(now.getTime() - TRANSCRIPT_FILL_WINDOW_MS).toISOString();
104
+ try {
105
+ // SAFETY: COUNT aggregate row.
106
+ const row = db.prepare(`SELECT COUNT(*) AS total,
107
+ COUNT(CASE WHEN status = 'summarised' AND summarised_at < ? THEN 1 END) AS summarised,
108
+ COUNT(CASE WHEN status = 'started' AND started_at < ? AND started_at > ? AND transcript_path IS NOT NULL THEN 1 END) AS started
109
+ FROM compactions`).get(stuckBefore, stuckBefore, transcriptFloor);
110
+ const total = Number(row?.total ?? 0);
111
+ const summarised = Number(row?.summarised ?? 0);
112
+ const started = Number(row?.started ?? 0);
113
+ const stuck = summarised + started;
114
+ if (stuck === 0)
115
+ return { id: 'compactions', status: 'pass', detail: `${total} compaction${total === 1 ? '' : 's'} recorded, none stuck` };
116
+ return {
117
+ id: 'compactions',
118
+ status: 'warn',
119
+ detail: `${stuck} compaction${stuck === 1 ? '' : 's'} unfinished after 10 minutes (${summarised} with a summary whose memories are not saved yet, ${started} with no summary yet)`,
120
+ fix: 'hippo sleep (replays them)',
121
+ };
122
+ }
123
+ catch (err) {
124
+ const message = err instanceof Error ? err.message : String(err);
125
+ return message.includes('no such table')
126
+ ? { id: 'compactions', status: 'info', detail: 'no compaction records yet (hippo creates them on the next write)' }
127
+ : { id: 'compactions', status: 'warn', detail: `cannot read the compaction records: ${message}` };
128
+ }
129
+ }
99
130
  /** Run every check. Never throws for a broken install; broken parts become failed checks. */
100
131
  export function runDoctor(opts) {
101
132
  const cwd = opts.cwd ?? process.cwd();
@@ -162,6 +193,7 @@ export function runDoctor(opts) {
162
193
  }
163
194
  checks.push(failuresCheck(db, since, have));
164
195
  checks.push(sleepCheck(db, now));
196
+ checks.push(compactionsCheck(db, now));
165
197
  }
166
198
  catch (err) {
167
199
  checks.push({
@@ -185,9 +217,9 @@ export function runDoctor(opts) {
185
217
  const hooks = [
186
218
  ['hippo context --pinned-only', 'per-prompt memory'],
187
219
  ['hippo session-end', 'session-end capture and sleep'],
188
- ['hippo pre-compact', 'compaction snapshot and capture'],
220
+ ['hippo pre-compact', 'compaction snapshot and memories request'],
189
221
  ['hippo compact-resume', 'resume after compaction'],
190
- ['hippo post-compact', 'the message after compaction'],
222
+ ['hippo post-compact', 'saving the memories a compaction lists'],
191
223
  ['hippo capture-error', 'failed-tool capture'],
192
224
  ];
193
225
  const missing = hooks.filter(([marker]) => !text.includes(marker)).map(([, what]) => what);
package/dist/dormant.d.ts CHANGED
@@ -19,8 +19,8 @@
19
19
  */
20
20
  import type { DatabaseSyncLike } from './db.js';
21
21
  import type { MemoryEntry } from './memory.js';
22
- /** Why sleep made a memory dormant. Only the decay pass does today. */
23
- export type DormantReason = 'decay';
22
+ /** Why a memory went dormant: sleep's decay pass, or an imported agent memory whose note was deleted. */
23
+ export type DormantReason = 'decay' | 'source-deleted';
24
24
  /** One memory that sleep is moving out of active memory into the dormant store. */
25
25
  export interface DormantMove {
26
26
  /** The memory as it stood when it faded; restored verbatim apart from its recall clock. */
@@ -39,7 +39,7 @@ export interface DormantMemory {
39
39
  tags: string[];
40
40
  /** Live strength when it went dormant. */
41
41
  strength: number;
42
- /** Why it went dormant (`decay`). */
42
+ /** Why it went dormant (`decay` or `source-deleted`). */
43
43
  reason: string;
44
44
  /** ISO time it went dormant. */
45
45
  dormantAt: string;
@@ -73,6 +73,8 @@ export interface DormantSnapshot {
73
73
  export declare function readDormantSnapshot(db: DatabaseSyncLike, tenantId: string, id: string): DormantSnapshot | null;
74
74
  /** Every dormant memory of a tenant whose snapshot still reads back. */
75
75
  export declare function listDormantSnapshots(db: DatabaseSyncLike, tenantId: string): DormantSnapshot[];
76
+ /** Readable snapshots whose entry's source starts with `prefix`; a malformed snapshot is passed over, not an error. */
77
+ export declare function dormantSnapshotsBySourcePrefix(db: DatabaseSyncLike, tenantId: string, prefix: string): DormantSnapshot[];
76
78
  /** Put `entry` in place of a tenant's dormant memory `id`, keeping when and why that one went dormant. */
77
79
  export declare function replaceDormantEntry(db: DatabaseSyncLike, tenantId: string, id: string, entry: MemoryEntry): void;
78
80
  /** Whether a tenant has a dormant memory with this id (snapshot readable or not). */
package/dist/dormant.js CHANGED
@@ -83,6 +83,15 @@ export function listDormantSnapshots(db, tenantId) {
83
83
  FROM dormant_memories WHERE tenant_id = ?`).all(tenantId);
84
84
  return rows.flatMap((row) => toSnapshot(row) ?? []);
85
85
  }
86
+ /** Readable snapshots whose entry's source starts with `prefix`; a malformed snapshot is passed over, not an error. */
87
+ export function dormantSnapshotsBySourcePrefix(db, tenantId, prefix) {
88
+ // SAFETY: rows' shape matches the seven columns named in the SELECT.
89
+ const rows = db.prepare(`SELECT tenant_id, id, content, entry_json, reason, strength, dormant_at
90
+ FROM dormant_memories
91
+ WHERE tenant_id = ?
92
+ AND CASE WHEN json_valid(entry_json) THEN json_extract(entry_json, '$.source') END LIKE ? ESCAPE '\\'`).all(tenantId, `${escapeLike(prefix)}%`);
93
+ return rows.flatMap((row) => toSnapshot(row) ?? []).filter((s) => String(s.entry.source).startsWith(prefix));
94
+ }
86
95
  function toSnapshot(row) {
87
96
  const entry = parseSnapshot(row);
88
97
  return entry ? { entry, reason: row.reason, strength: row.strength, dormantAt: row.dormant_at } : null;
@@ -0,0 +1,9 @@
1
+ import type { DatabaseSyncLike } from './db.js';
2
+ import type { MemoryEntry } from './memory.js';
3
+ export type GatedWriteResult = 'written' | 'skipped:not-worth-storing' | 'skipped:secret' | 'skipped:rejected';
4
+ /** Runs on the caller's handle so it nests in the caller's transaction (writeEntry would open a second handle and wait on that lock); the caller mirrors after commit with an entry it stamped itself. The rejection audit lands inside that transaction, which is safe because a batch that rolls back stays `summarised` and replay writes the audit again. */
5
+ export declare function gatedWrite(db: DatabaseSyncLike, hippoRoot: string, entry: MemoryEntry, opts?: {
6
+ actor?: string;
7
+ worthCheck?: boolean;
8
+ }): GatedWriteResult;
9
+ //# sourceMappingURL=gated-write.d.ts.map
@@ -0,0 +1,24 @@
1
+ // The one write path for text no person typed into hippo: capture, compaction items and imported agent memories.
2
+ import { isContentWorthStoring } from './audit.js';
3
+ import { RejectedValueError } from './rejection.js';
4
+ import { detectSecret } from './secret-detect.js';
5
+ import { auditRejectionRefusal, stampOriginProject, writeEntryDbOnly } from './store.js';
6
+ /** Runs on the caller's handle so it nests in the caller's transaction (writeEntry would open a second handle and wait on that lock); the caller mirrors after commit with an entry it stamped itself. The rejection audit lands inside that transaction, which is safe because a batch that rolls back stays `summarised` and replay writes the audit again. */
7
+ export function gatedWrite(db, hippoRoot, entry, opts) {
8
+ // Off for imported agent memories: a person wrote those notes, and a one-line preference fails the check.
9
+ if (opts?.worthCheck !== false && !isContentWorthStoring(entry.content))
10
+ return 'skipped:not-worth-storing';
11
+ if (detectSecret(entry).flagged)
12
+ return 'skipped:secret';
13
+ try {
14
+ writeEntryDbOnly(db, stampOriginProject(hippoRoot, entry), opts);
15
+ return 'written';
16
+ }
17
+ catch (err) {
18
+ if (!(err instanceof RejectedValueError))
19
+ throw err;
20
+ auditRejectionRefusal(db, err, opts?.actor ?? 'cli');
21
+ return 'skipped:rejected';
22
+ }
23
+ }
24
+ //# sourceMappingURL=gated-write.js.map
package/dist/hooks.d.ts CHANGED
@@ -16,6 +16,8 @@
16
16
  * - < 0.20.2: `Stop` hook firing `hippo sleep` on every assistant turn.
17
17
  * - < 0.21.0: bare `hippo sleep` in SessionEnd, no `--log-file`.
18
18
  * - 0.22.x: separate sleep + capture SessionEnd entries.
19
+ * PreCompact and PostCompact entries go in too: the first records the compaction and
20
+ * asks the summariser for a "Memories for hippo" list, the second saves that list.
19
21
  * Codex's hooks.json gets only two groups (per-prompt memory and
20
22
  * compact-resume); see installCodexHooks.
21
23
  *
@@ -91,7 +93,7 @@ export interface InstallResult {
91
93
  installedUserPromptSubmit: boolean;
92
94
  installedPreCompact: boolean;
93
95
  installedCompactResume: boolean;
94
- /** PostCompact -> `hippo post-compact` (tells the user what compaction saved). */
96
+ /** PostCompact -> `hippo post-compact` (saves the summary's memories, tells the user how many). */
95
97
  installedPostCompact: boolean;
96
98
  /** PostToolUseFailure -> `hippo capture-error` (failed tool calls become error memories). */
97
99
  installedCaptureError: boolean;
@@ -150,7 +152,7 @@ declare const HIPPO_OPENCODE_PLUGIN_MARKER = "HIPPO_OPENCODE_PLUGIN_V1";
150
152
  export declare const OPENCODE_PLUGIN_SOURCE = "// HIPPO_OPENCODE_PLUGIN_V1\n// hippo-memory opencode plugin. DO NOT EDIT \u2014 regenerated on every\n// `hippo hook install opencode` from src/hooks.ts OPENCODE_PLUGIN_SOURCE\n// in https://github.com/kitfunso/hippo-memory. Local changes will be lost.\n\nexport const HippoPlugin = async ({ $ }) => {\n return {\n event: async ({ event }) => {\n // Defense in depth: opencode currently runs in Bun where $ is the shell\n // template helper. A non-Bun runtime would have $ as undefined; fail\n // closed instead of crashing the host session.\n if (typeof $ !== \"function\") return;\n try {\n if (event.type === \"session.idle\") {\n await $`hippo session-end`.quiet().nothrow();\n } else if (event.type === \"session.created\") {\n await $`hippo last-sleep`.quiet().nothrow();\n }\n } catch {\n // hippo CLI not on PATH or other failure \u2014 never crash the host session.\n }\n },\n };\n};\n";
151
153
  export { HIPPO_OPENCODE_PLUGIN_MARKER };
152
154
  /** Codex's config folder: $CODEX_HOME, else ~/.codex, as the Codex hooks docs describe. */
153
- export declare function codexHomeDir(home?: string): string;
155
+ export declare function codexHomeDir(home?: string, env?: Readonly<Record<string, string | undefined>>): string;
154
156
  /** Codex counts as installed only when its config folder exists: Codex itself refuses a CODEX_HOME that is not a folder. */
155
157
  export declare function isCodexPresent(home?: string): boolean;
156
158
  /** Codex hashes each hook and skips new or changed ones until the user reviews them in `/hooks`, so the reminder says what they would trust. */
package/dist/hooks.js CHANGED
@@ -16,6 +16,8 @@
16
16
  * - < 0.20.2: `Stop` hook firing `hippo sleep` on every assistant turn.
17
17
  * - < 0.21.0: bare `hippo sleep` in SessionEnd, no `--log-file`.
18
18
  * - 0.22.x: separate sleep + capture SessionEnd entries.
19
+ * PreCompact and PostCompact entries go in too: the first records the compaction and
20
+ * asks the summariser for a "Memories for hippo" list, the second saves that list.
19
21
  * Codex's hooks.json gets only two groups (per-prompt memory and
20
22
  * compact-resume); see installCodexHooks.
21
23
  *
@@ -124,8 +126,8 @@ function homeDir() {
124
126
  return process.env.HOME || process.env.USERPROFILE || os.homedir();
125
127
  }
126
128
  /** Codex's config folder: $CODEX_HOME, else ~/.codex, as the Codex hooks docs describe. */
127
- export function codexHomeDir(home = homeDir()) {
128
- return process.env.CODEX_HOME || path.join(home, '.codex');
129
+ export function codexHomeDir(home = homeDir(), env = process.env) {
130
+ return env.CODEX_HOME || path.join(home, '.codex');
129
131
  }
130
132
  /** Codex counts as installed only when its config folder exists: Codex itself refuses a CODEX_HOME that is not a folder. */
131
133
  export function isCodexPresent(home = homeDir()) {
@@ -731,8 +733,8 @@ export function installJsonHooks(target) {
731
733
  });
732
734
  installedUserPromptSubmit = true;
733
735
  }
734
- // PreCompact: fires on manual AND auto compaction (no matcher). Writes a
735
- // working-state snapshot before the transcript summary drops detail.
736
+ // PreCompact: fires on manual AND auto compaction (no matcher). Records the compaction, asks the
737
+ // summariser for a "Memories for hippo" list and saves a working-state snapshot before the summary drops detail.
736
738
  // Exit-0 contract lives in the verb itself (src/capture.ts cmdPreCompact),
737
739
  // not here — this is install-time wiring only.
738
740
  let installedPreCompact = false;
@@ -772,9 +774,8 @@ export function installJsonHooks(target) {
772
774
  });
773
775
  installedCompactResume = true;
774
776
  }
775
- // PostCompact: tells the user what pre-compact saved. PreCompact itself
776
- // must stay silent, because Claude Code hands PreCompact stdout to the
777
- // summarising model as instructions; PostCompact stdout is only shown.
777
+ // PostCompact: saves the memories the summariser listed and prints one line, which Claude Code only shows.
778
+ // PreCompact stdout, by contrast, is handed to the summariser as instructions, so pre-compact prints just the request.
778
779
  let installedPostCompact = false;
779
780
  if (!hookArrayContains(hooks.PostCompact, HIPPO_POST_COMPACT_MARKER)) {
780
781
  if (!Array.isArray(hooks.PostCompact))
package/dist/memory.d.ts CHANGED
@@ -213,8 +213,17 @@ export declare function resolveConfidence(entry: MemoryEntry, now?: Date): Confi
213
213
  * on an older base (src/half-life-migration.ts).
214
214
  */
215
215
  export declare const DEFAULT_HALF_LIFE_DAYS = 365;
216
- export declare const AUTO_DELETABLE_SQL = "pinned = 0 AND kind != 'raw'";
217
- export declare function canAutoDelete(entry: Pick<MemoryEntry, 'pinned' | 'kind'>): boolean;
216
+ export declare const COMPACTION_MEMORY_TAG = "compaction-memory";
217
+ export declare const COMPACTION_SOURCE_PREFIX = "compaction:";
218
+ /** A row with `tag` and a source starting `sourcePrefix` is kept for good. Both, since merge copies source tags onto rows whose source is 'consolidation'. */
219
+ export interface KeepPair {
220
+ readonly tag: string;
221
+ readonly sourcePrefix: string;
222
+ }
223
+ export declare const KEEP_PAIRS: readonly KeepPair[];
224
+ export declare const AUTO_DELETABLE_SQL: string;
225
+ export declare function isKeptForGood(entry: Pick<MemoryEntry, 'tags' | 'source' | 'superseded_by'>): boolean;
226
+ export declare function canAutoDelete(entry: Pick<MemoryEntry, 'pinned' | 'kind' | 'tags' | 'source' | 'superseded_by'>): boolean;
218
227
  export interface CreateMemoryOptions {
219
228
  layer?: Layer;
220
229
  tags?: string[];
@@ -239,6 +248,14 @@ export interface CreateMemoryOptions {
239
248
  }
240
249
  /** Create a new memory entry with defaults. Untyped JavaScript callers that omit the options get the compiled default half-life. */
241
250
  export declare function createMemory(content: string, options: CreateMemoryOptions): MemoryEntry;
251
+ /** The row that replaces `old`: a supersede never changes where a memory belongs, so source, scope, session and a stamped origin carry over. */
252
+ export declare function createSuccessor(old: MemoryEntry, content: string, opts: {
253
+ tenantId: string;
254
+ baseHalfLifeDays: number;
255
+ layer?: Layer;
256
+ tags?: string[];
257
+ pinned?: boolean;
258
+ }): MemoryEntry;
242
259
  /**
243
260
  * Compute how well new content fits existing knowledge patterns.
244
261
  * Returns 0..1 where:
package/dist/memory.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { randomUUID } from 'crypto';
6
6
  import { isDecayAblated, isOutcomeSlowAblated, isRecallBoostAblated, evalNow, } from './ablation.js';
7
+ import { AGENT_MEMORY_TOOLS, toolSourcePrefix } from './agent-memories/tools.js';
7
8
  export var Layer;
8
9
  (function (Layer) {
9
10
  Layer["Buffer"] = "buffer";
@@ -314,10 +315,22 @@ export function resolveConfidence(entry, now = evalNow()) {
314
315
  * on an older base (src/half-life-migration.ts).
315
316
  */
316
317
  export const DEFAULT_HALF_LIFE_DAYS = 365;
317
- // Pinned means keep; raw rows leave only through archiveRawMemory. The SQL twin guards the DELETE itself.
318
- export const AUTO_DELETABLE_SQL = "pinned = 0 AND kind != 'raw'";
318
+ export const COMPACTION_MEMORY_TAG = 'compaction-memory';
319
+ export const COMPACTION_SOURCE_PREFIX = 'compaction:';
320
+ export const KEEP_PAIRS = [
321
+ { tag: COMPACTION_MEMORY_TAG, sourcePrefix: COMPACTION_SOURCE_PREFIX },
322
+ ...AGENT_MEMORY_TOOLS.map((t) => ({ tag: t.tag, sourcePrefix: toolSourcePrefix(t.id) })),
323
+ ];
324
+ const sqlText = (s) => `'${s.replace(/'/g, "''")}'`;
325
+ // json_each matches the tag as a whole element; substr, not LIKE, keeps the prefix case-sensitive like startsWith.
326
+ const keepPairSql = (p) => `(COALESCE(superseded_by, '') = '' AND EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(tags_json) THEN tags_json ELSE '[]' END) WHERE value = ${sqlText(p.tag)}) AND substr(source, 1, ${p.sourcePrefix.length}) = ${sqlText(p.sourcePrefix)})`;
327
+ // Pinned and kept rows stay (a superseded row is not kept: its successor carries the tag and source); raw rows leave only through archiveRawMemory. The SQL twin guards the DELETE itself.
328
+ export const AUTO_DELETABLE_SQL = `pinned = 0 AND kind != 'raw'${KEEP_PAIRS.map((p) => ` AND NOT ${keepPairSql(p)}`).join('')}`;
329
+ export function isKeptForGood(entry) {
330
+ return !entry.superseded_by && KEEP_PAIRS.some((p) => entry.tags.includes(p.tag) && entry.source.startsWith(p.sourcePrefix));
331
+ }
319
332
  export function canAutoDelete(entry) {
320
- return !entry.pinned && entry.kind !== 'raw';
333
+ return !entry.pinned && entry.kind !== 'raw' && !isKeptForGood(entry);
321
334
  }
322
335
  export function createMemory(content, options = {}) {
323
336
  const trimmed = content.trim();
@@ -373,6 +386,25 @@ export function createMemory(content, options = {}) {
373
386
  entry.strength = calculateStrength(entry);
374
387
  return entry;
375
388
  }
389
+ /** The row that replaces `old`: a supersede never changes where a memory belongs, so source, scope, session and a stamped origin carry over. */
390
+ export function createSuccessor(old, content, opts) {
391
+ const next = createMemory(content, {
392
+ layer: opts.layer ?? old.layer,
393
+ tags: opts.tags ?? [...old.tags],
394
+ pinned: opts.pinned ?? old.pinned,
395
+ source: old.source,
396
+ confidence: 'verified',
397
+ tenantId: opts.tenantId,
398
+ scope: old.scope,
399
+ source_session_id: old.source_session_id,
400
+ baseHalfLifeDays: opts.baseHalfLifeDays,
401
+ });
402
+ // A legacy null origin has nothing to carry, so the store stamps it from its own location.
403
+ if (typeof old.origin_project === 'string') {
404
+ next.origin_project = old.origin_project;
405
+ }
406
+ return next;
407
+ }
376
408
  /**
377
409
  * Compute how well new content fits existing knowledge patterns.
378
410
  * Returns 0..1 where:
package/dist/shared.js CHANGED
@@ -6,7 +6,8 @@
6
6
  */
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
- import { generateId } from './memory.js';
9
+ import { generateId, COMPACTION_MEMORY_TAG } from './memory.js';
10
+ import { AGENT_MEMORY_SOURCE_PREFIX, AGENT_MEMORY_TAGS } from './agent-memories/tools.js';
10
11
  import { initStore, loadAllEntries, loadIndex, loadSearchEntries, loadRecallSearchEntries, writeEntry, readEntry, } from './store.js';
11
12
  import { passesScopeFilterForRecall, passesCliRecallScopeFilter } from './recall-scope.js';
12
13
  import { search, hybridSearch, fitBudget } from './search.js';
@@ -224,6 +225,7 @@ const TRANSFERABLE_TAGS = new Set([
224
225
  export const NEVER_AUTO_SHARE_TAGS = new Set([
225
226
  'git-learned',
226
227
  'session-digest',
228
+ ...AGENT_MEMORY_TAGS,
227
229
  ]);
228
230
  export function neverAutoShareTags(sources) {
229
231
  return [...NEVER_AUTO_SHARE_TAGS].filter((tag) => sources.some((s) => s.tags.includes(tag)));
@@ -232,6 +234,8 @@ export function neverAutoShareTags(sources) {
232
234
  export const NO_MERGE_TAGS = new Set([
233
235
  'extracted',
234
236
  'session-digest',
237
+ COMPACTION_MEMORY_TAG,
238
+ ...AGENT_MEMORY_TAGS,
235
239
  ]);
236
240
  /**
237
241
  * Estimate how well a memory would transfer to other projects.
@@ -398,7 +402,7 @@ export function autoShare(localRoot, options = {}) {
398
402
  if (isQuarantineScope(entry.scope ?? null))
399
403
  return false;
400
404
  // Before the score: these rows describe one project only, and a git seed's 'error' tag clears the bar.
401
- if (entry.tags.some((t) => NEVER_AUTO_SHARE_TAGS.has(t))) {
405
+ if (entry.tags.some((t) => NEVER_AUTO_SHARE_TAGS.has(t)) || entry.source.startsWith(AGENT_MEMORY_SOURCE_PREFIX)) {
402
406
  if (options.stats)
403
407
  options.stats.neverAutoShareSkipped = (options.stats.neverAutoShareSkipped ?? 0) + 1;
404
408
  return false;
@@ -481,16 +485,15 @@ export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
481
485
  // only stamps when the field is missing).
482
486
  const currentName = deriveOriginProject(path.dirname(path.resolve(localRoot)));
483
487
  let count = 0;
484
- // AT1 (plan §3 containment, the roadmap threat this whole feature targets
485
- // — a locally-rejected value must not silently resurrect via sync down
486
- // from global): per-item catch, no signature change (bare number return;
487
- // see the syncGlobalToLocal callers in cli.ts + tests). Counted locally
488
- // and printed as one summary line, same pattern as learnFromMemoryMd.
488
+ // A locally rejected value must not come back through sync down: caught per item, printed as one line.
489
489
  let rejected = 0;
490
490
  for (const entry of globalEntries) {
491
491
  // Skip if already present by ID
492
492
  if (localIndex.entries[entry.id])
493
493
  continue;
494
+ // Only the global store's user pass sets an imported note's row aside, so a copy would outlive the note.
495
+ if (entry.source.startsWith(AGENT_MEMORY_SOURCE_PREFIX))
496
+ continue;
494
497
  if (localText.has(textKey(entry)))
495
498
  continue;
496
499
  if (detectSecret(entry).flagged)
package/dist/store.d.ts CHANGED
@@ -367,7 +367,7 @@ export declare function loadChildrenOf(hippoRoot: string, parentId: string, tena
367
367
  * Default keeps `deleteEntry` byte-identical to its pre-split behavior.
368
368
  *
369
369
  * Returns `{tenantId, dagParentId}` for the removed row, or `null` if no row with `id`
370
- * existed or `automatic` refused it (pinned or raw at DELETE time, so a late pin wins).
370
+ * existed or `automatic` refused it (pinned, raw or kept for good at DELETE time, so a late pin wins).
371
371
  */
372
372
  export declare function deleteEntryCore(db: ReturnType<typeof openHippoDb>, id: string, opts?: {
373
373
  actor?: string;
@@ -400,7 +400,7 @@ export declare function deleteEntry(hippoRoot: string, id: string, opts?: {
400
400
  * `dormant` (src/dormant.ts): each move's snapshot is inserted into `dormant_memories` and its `memories` row
401
401
  * leaves exactly like a delete (FTS row, DAG parent dirty-mark, mirrors), in the same transaction, so a memory
402
402
  * is never in both places or in neither. Deletes and moves both skip rows that are no longer auto-deletable
403
- * (pinned or raw since the caller decided). Returns the ids that left `memories`, deleted or moved. */
403
+ * (pinned, raw or kept for good since the caller decided). Returns the ids that left `memories`, deleted or moved. */
404
404
  export declare function batchWriteAndDelete(hippoRoot: string, toWrite: MemoryEntry[], toDeleteIds: string[], opts?: {
405
405
  snapshot?: ReadonlyMap<string, MemoryEntry>;
406
406
  dormant?: DormantMove[];
@@ -415,6 +415,12 @@ export declare function batchWriteAndDelete(hippoRoot: string, toWrite: MemoryEn
415
415
  export declare function loadAllEntries(hippoRoot: string, tenantId?: string): MemoryEntry[];
416
416
  /** Every memory row on an open connection, so a caller can read inside its own transaction. */
417
417
  export declare function selectAllEntries(db: DatabaseSyncLike, tenantId?: string): MemoryEntry[];
418
+ /** Live rows whose source starts with `prefix`, on the caller's handle; LIKE folds case, so the prefix is checked again exactly. */
419
+ export declare function selectLiveEntriesBySourcePrefix(db: DatabaseSyncLike, tenantId: string, prefix: string): MemoryEntry[];
420
+ /** Rewrites a live row's tags and its full-text row on the caller's transaction, with no audit row. */
421
+ export declare function setEntryTagsInTx(db: DatabaseSyncLike, entry: MemoryEntry): void;
422
+ /** Removes a row from `memories` and full-text search on the caller's transaction, as sleep's dormant move does, and marks its summary parent dirty. */
423
+ export declare function deleteEntryRowInTx(db: DatabaseSyncLike, entry: MemoryEntry, actor: string): void;
418
424
  export declare function loadContentsWithTag(hippoRoot: string, tenantId: string, tag: string): string[];
419
425
  export interface AmbientRecallRequest {
420
426
  terms: string[];
package/dist/store.js CHANGED
@@ -1555,7 +1555,7 @@ export function loadChildrenOf(hippoRoot, parentId, tenantId) {
1555
1555
  * Default keeps `deleteEntry` byte-identical to its pre-split behavior.
1556
1556
  *
1557
1557
  * Returns `{tenantId, dagParentId}` for the removed row, or `null` if no row with `id`
1558
- * existed or `automatic` refused it (pinned or raw at DELETE time, so a late pin wins).
1558
+ * existed or `automatic` refused it (pinned, raw or kept for good at DELETE time, so a late pin wins).
1559
1559
  */
1560
1560
  export function deleteEntryCore(db, id, opts) {
1561
1561
  // SAFETY: row's shape matches the three columns named in the SELECT above.
@@ -1622,7 +1622,7 @@ function mergeOwnChanges(base, ours, live) {
1622
1622
  * `dormant` (src/dormant.ts): each move's snapshot is inserted into `dormant_memories` and its `memories` row
1623
1623
  * leaves exactly like a delete (FTS row, DAG parent dirty-mark, mirrors), in the same transaction, so a memory
1624
1624
  * is never in both places or in neither. Deletes and moves both skip rows that are no longer auto-deletable
1625
- * (pinned or raw since the caller decided). Returns the ids that left `memories`, deleted or moved. */
1625
+ * (pinned, raw or kept for good since the caller decided). Returns the ids that left `memories`, deleted or moved. */
1626
1626
  export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
1627
1627
  const dormantMoves = opts?.dormant ?? [];
1628
1628
  if (toWrite.length === 0 && toDeleteIds.length === 0 && dormantMoves.length === 0)
@@ -1810,6 +1810,25 @@ export function selectAllEntries(db, tenantId) {
1810
1810
  : db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories ORDER BY created ASC, id ASC`).all();
1811
1811
  return rows.map(rowToEntry);
1812
1812
  }
1813
+ /** Live rows whose source starts with `prefix`, on the caller's handle; LIKE folds case, so the prefix is checked again exactly. */
1814
+ export function selectLiveEntriesBySourcePrefix(db, tenantId, prefix) {
1815
+ // SAFETY: selects exactly MEMORY_SELECT_COLUMNS, matching MemoryRow's field set.
1816
+ const rows = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE tenant_id = ? AND superseded_by IS NULL AND source LIKE ? ESCAPE '\\'`).all(tenantId, `${prefix.replace(/[%_\\]/g, '\\$&')}%`);
1817
+ return rows.map(rowToEntry).filter((entry) => entry.source.startsWith(prefix));
1818
+ }
1819
+ /** Rewrites a live row's tags and its full-text row on the caller's transaction, with no audit row. */
1820
+ export function setEntryTagsInTx(db, entry) {
1821
+ db.prepare(`UPDATE memories SET tags_json = ?, updated_at = datetime('now') WHERE id = ? AND tenant_id = ?`)
1822
+ .run(JSON.stringify(entry.tags), entry.id, entry.tenantId);
1823
+ syncFtsRow(db, entry);
1824
+ }
1825
+ /** Removes a row from `memories` and full-text search on the caller's transaction, as sleep's dormant move does, and marks its summary parent dirty. */
1826
+ export function deleteEntryRowInTx(db, entry, actor) {
1827
+ db.prepare('DELETE FROM memories WHERE id = ? AND tenant_id = ?').run(entry.id, entry.tenantId);
1828
+ deleteFtsRow(db, entry.id);
1829
+ if (entry.dag_parent_id)
1830
+ markSummaryDirtyInTx(db, entry.dag_parent_id, entry.tenantId, actor);
1831
+ }
1813
1832
  // Content of every tenant row tagged `tag`, without reading the rest of the store.
1814
1833
  // `instr` is a substring prefilter over the raw JSON; `includes` below re-checks exactly.
1815
1834
  export function loadContentsWithTag(hippoRoot, tenantId, tag) {
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.52.9";
19
+ export declare const PACKAGE_VERSION = "1.53.0";
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.52.9';
19
+ export const PACKAGE_VERSION = '1.53.0';
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) => {