hippo-memory 1.52.9 → 1.53.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.
Files changed (76) hide show
  1. package/README.md +47 -13
  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 +28 -29
  42. package/dist/audit.d.ts +4 -3
  43. package/dist/audit.js +10 -6
  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 +133 -170
  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 +573 -0
  52. package/dist/config.d.ts +4 -0
  53. package/dist/config.js +13 -0
  54. package/dist/consolidate.d.ts +0 -2
  55. package/dist/consolidate.js +3 -36
  56. package/dist/db.d.ts +5 -1
  57. package/dist/db.js +40 -8
  58. package/dist/dedupe.js +3 -2
  59. package/dist/doctor.js +34 -2
  60. package/dist/dormant.d.ts +5 -3
  61. package/dist/dormant.js +9 -0
  62. package/dist/gated-write.d.ts +9 -0
  63. package/dist/gated-write.js +24 -0
  64. package/dist/hooks.d.ts +4 -2
  65. package/dist/hooks.js +8 -7
  66. package/dist/memory.d.ts +19 -2
  67. package/dist/memory.js +32 -3
  68. package/dist/shared.js +10 -7
  69. package/dist/store.d.ts +14 -2
  70. package/dist/store.js +75 -19
  71. package/dist/version.d.ts +1 -1
  72. package/dist/version.js +1 -1
  73. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  74. package/extensions/openclaw-plugin/package.json +1 -1
  75. package/openclaw.plugin.json +1 -1
  76. 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) {
@@ -37,8 +37,6 @@ export interface ConsolidationResult {
37
37
  /** Ids the decay pass removes (or would remove, under dryRun). */
38
38
  removedIds?: string[];
39
39
  }
40
- /** Tables whose rows keep a first-class object's backing memory in `memory_id` (ON DELETE SET NULL); tests/dormant-memories.test.ts pins it to the schema. */
41
- export declare const MEMORY_BACKED_TABLES: readonly ["predictions", "decisions", "incidents", "processes", "policies", "skills", "project_briefs", "customer_notes"];
42
40
  /**
43
41
  * Run a full consolidation pass.
44
42
  */
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { evalNow, isRecallBoostAblated } from './ablation.js';
10
10
  import { Layer, calculateStrength, canAutoDelete, createMemory } from './memory.js';
11
- import { loadAllEntries, batchWriteAndDelete, appendConsolidationRun, replaceDetectedConflicts, loadSessionDecayContext, incrementSleepCount, findPromotableSessions, traceExistsForSession, listSessionEvents, } from './store.js';
11
+ import { loadAllEntries, batchWriteAndDelete, appendConsolidationRun, replaceDetectedConflicts, loadSessionDecayContext, incrementSleepCount, findPromotableSessions, traceExistsForSession, listSessionEvents, memoriesBackingObjects, } from './store.js';
12
12
  import { textOverlap, markRetrieved, tokenize } from './search.js';
13
13
  import { compareEntryIdentity } from './compare.js';
14
14
  import { duplicateKey, mergedText } from './same-text.js';
@@ -72,39 +72,6 @@ function keptAsWritten(entry) {
72
72
  function isJsonString(value) {
73
73
  return typeof value === 'string';
74
74
  }
75
- /** Tables whose rows keep a first-class object's backing memory in `memory_id` (ON DELETE SET NULL); tests/dormant-memories.test.ts pins it to the schema. */
76
- export const MEMORY_BACKED_TABLES = ['predictions', 'decisions', 'incidents', 'processes', 'policies', 'skills', 'project_briefs', 'customer_notes'];
77
- /**
78
- * Ids of memories that back a first-class object (a decision, incident, prediction,
79
- * process, policy, skill, project brief or customer note). Sleep never
80
- * retires these: deleting or moving one to dormant storage fires the
81
- * object's ON DELETE SET NULL and a restore cannot repair the link. Their
82
- * lifecycle belongs to the object. A table missing from an older schema is
83
- * skipped.
84
- */
85
- function memoriesBackingObjects(hippoRoot) {
86
- const ids = new Set();
87
- const db = openHippoDb(hippoRoot);
88
- try {
89
- for (const table of MEMORY_BACKED_TABLES) {
90
- try {
91
- // SAFETY: SELECT of one nullable TEXT column, filtered to non-null.
92
- const rows = db.prepare(`SELECT memory_id FROM ${table} WHERE memory_id IS NOT NULL`).all();
93
- for (const r of rows)
94
- ids.add(r.memory_id);
95
- }
96
- catch (err) {
97
- // A missing table is an older schema; any other error could hide a backing memory, so sleep stops.
98
- if (!(err instanceof Error && err.message.includes('no such table')))
99
- throw err;
100
- }
101
- }
102
- }
103
- finally {
104
- closeHippoDb(db);
105
- }
106
- return ids;
107
- }
108
75
  /**
109
76
  * Run a full consolidation pass.
110
77
  */
@@ -153,7 +120,7 @@ export async function consolidate(hippoRoot, options = {}) {
153
120
  for (const e of all)
154
121
  e.half_life_days = halfLife.halfLives.get(e.id) ?? e.half_life_days;
155
122
  const backingObjects = memoriesBackingObjects(hippoRoot);
156
- // Retirable: auto-deletable (never pinned, never raw) and not backing a first-class object.
123
+ // Retirable: auto-deletable (never pinned, raw or kept for good) and not backing a first-class object.
157
124
  const retirable = (entry) => canAutoDelete(entry) && !backingObjects.has(entry.id);
158
125
  const snapshot = new Map(structuredClone(all).map((e) => [e.id, e]));
159
126
  // Load decay options from config + session context
@@ -174,7 +141,7 @@ export async function consolidate(hippoRoot, options = {}) {
174
141
  // so it stays where it is (stored strength refreshed) but sits out the
175
142
  // rest of this cycle the way a deleted row would. Anything else goes
176
143
  // 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).
144
+ // Only called for rows `retirable` allows (never pinned, raw, kept for good or backing a first-class object).
178
145
  const retireFaded = (entry, strength) => {
179
146
  const why = `(strength ${strength.toFixed(4)} < ${DECAY_THRESHOLD})`;
180
147
  // 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/dedupe.js CHANGED
@@ -21,7 +21,7 @@
21
21
  * superseded rows are not candidates at all (v1.38.1).
22
22
  */
23
23
  import { textOverlap } from './search.js';
24
- import { loadAllEntries, deleteEntry } from './store.js';
24
+ import { loadAllEntries, deleteEntry, memoriesBackingObjects } from './store.js';
25
25
  import { compareEntryIdentity } from './compare.js';
26
26
  import { canAutoDelete } from './memory.js';
27
27
  import { derivationPartitionKey } from './recall-scope.js';
@@ -97,6 +97,7 @@ export function deduplicateStore(hippoRoot, options = {}) {
97
97
  // primary-key id alone.
98
98
  const removed = new Set();
99
99
  const pairs = [];
100
+ const backing = memoriesBackingObjects(hippoRoot);
100
101
  for (const tenantEntries of entriesByTenant.values()) {
101
102
  // The v1.26.3 survivor total order (see the file-level docstring:
102
103
  // strength bucket desc -> retrieval_count desc -> compareEntryIdentity),
@@ -116,7 +117,7 @@ export function deduplicateStore(hippoRoot, options = {}) {
116
117
  if (removed.has(tenantEntries[i].id))
117
118
  continue;
118
119
  for (let j = i + 1; j < tenantEntries.length; j++) {
119
- if (removed.has(tenantEntries[j].id) || !canAutoDelete(tenantEntries[j]))
120
+ if (removed.has(tenantEntries[j].id) || !canAutoDelete(tenantEntries[j]) || backing.has(tenantEntries[j].id))
120
121
  continue;
121
122
  if (texts[j] !== texts[i])
122
123
  continue;
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
+ /** An imported agent memory (its tool's tag, and a source starting that tool's prefix) is kept for good: the agent's note file is its record. 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,19 @@ 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 = AGENT_MEMORY_TOOLS.map((t) => ({ tag: t.tag, sourcePrefix: toolSourcePrefix(t.id) }));
321
+ const sqlText = (s) => `'${s.replace(/'/g, "''")}'`;
322
+ // json_each matches the tag as a whole element; substr, not LIKE, keeps the prefix case-sensitive like startsWith.
323
+ 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)})`;
324
+ // 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.
325
+ export const AUTO_DELETABLE_SQL = `pinned = 0 AND kind != 'raw'${KEEP_PAIRS.map((p) => ` AND NOT ${keepPairSql(p)}`).join('')}`;
326
+ export function isKeptForGood(entry) {
327
+ return !entry.superseded_by && KEEP_PAIRS.some((p) => entry.tags.includes(p.tag) && entry.source.startsWith(p.sourcePrefix));
328
+ }
319
329
  export function canAutoDelete(entry) {
320
- return !entry.pinned && entry.kind !== 'raw';
330
+ return !entry.pinned && entry.kind !== 'raw' && !isKeptForGood(entry);
321
331
  }
322
332
  export function createMemory(content, options = {}) {
323
333
  const trimmed = content.trim();
@@ -373,6 +383,25 @@ export function createMemory(content, options = {}) {
373
383
  entry.strength = calculateStrength(entry);
374
384
  return entry;
375
385
  }
386
+ /** The row that replaces `old`: a supersede never changes where a memory belongs, so source, scope, session and a stamped origin carry over. */
387
+ export function createSuccessor(old, content, opts) {
388
+ const next = createMemory(content, {
389
+ layer: opts.layer ?? old.layer,
390
+ tags: opts.tags ?? [...old.tags],
391
+ pinned: opts.pinned ?? old.pinned,
392
+ source: old.source,
393
+ confidence: 'verified',
394
+ tenantId: opts.tenantId,
395
+ scope: old.scope,
396
+ source_session_id: old.source_session_id,
397
+ baseHalfLifeDays: opts.baseHalfLifeDays,
398
+ });
399
+ // A legacy null origin has nothing to carry, so the store stamps it from its own location.
400
+ if (typeof old.origin_project === 'string') {
401
+ next.origin_project = old.origin_project;
402
+ }
403
+ return next;
404
+ }
376
405
  /**
377
406
  * Compute how well new content fits existing knowledge patterns.
378
407
  * 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)