hippo-memory 1.52.8 → 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 (122) hide show
  1. package/README.md +185 -101
  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 +53 -20
  41. package/dist/api.js +141 -97
  42. package/dist/audit.d.ts +2 -1
  43. package/dist/audit.js +68 -2
  44. package/dist/capture.d.ts +48 -22
  45. package/dist/capture.js +186 -161
  46. package/dist/cli.d.ts +0 -2
  47. package/dist/cli.js +750 -797
  48. package/dist/codex-patch.d.ts +12 -0
  49. package/dist/codex-patch.js +71 -0
  50. package/dist/compaction-items.d.ts +18 -0
  51. package/dist/compaction-items.js +60 -0
  52. package/dist/compaction-record.d.ts +94 -0
  53. package/dist/compaction-record.js +546 -0
  54. package/dist/config.d.ts +4 -1
  55. package/dist/config.js +13 -4
  56. package/dist/connectors/slack/types.d.ts +0 -1
  57. package/dist/consolidate.js +87 -34
  58. package/dist/context-render.d.ts +36 -0
  59. package/dist/context-render.js +154 -0
  60. package/dist/dag.js +3 -2
  61. package/dist/db.d.ts +5 -1
  62. package/dist/db.js +46 -14
  63. package/dist/dedupe.d.ts +6 -6
  64. package/dist/dedupe.js +10 -9
  65. package/dist/doctor.d.ts +1 -1
  66. package/dist/doctor.js +69 -4
  67. package/dist/dormant.d.ts +9 -3
  68. package/dist/dormant.js +26 -2
  69. package/dist/embedding-provider.d.ts +2 -1
  70. package/dist/embedding-provider.js +2 -1
  71. package/dist/embeddings.js +23 -3
  72. package/dist/extract.js +5 -1
  73. package/dist/forward-claim-detector.d.ts +1 -1
  74. package/dist/forward-claim-detector.js +1 -1
  75. package/dist/gated-write.d.ts +9 -0
  76. package/dist/gated-write.js +24 -0
  77. package/dist/graph-recall.d.ts +3 -1
  78. package/dist/graph-recall.js +5 -3
  79. package/dist/hooks.d.ts +18 -2
  80. package/dist/hooks.js +128 -32
  81. package/dist/importers.js +5 -12
  82. package/dist/judgment.d.ts +30 -0
  83. package/dist/judgment.js +122 -0
  84. package/dist/mcp/server.js +171 -210
  85. package/dist/memory.d.ts +19 -2
  86. package/dist/memory.js +35 -3
  87. package/dist/merged-row.d.ts +6 -0
  88. package/dist/merged-row.js +35 -0
  89. package/dist/multihop.d.ts +2 -1
  90. package/dist/multihop.js +7 -4
  91. package/dist/physics-state.d.ts +0 -4
  92. package/dist/physics-state.js +0 -6
  93. package/dist/predictions.d.ts +2 -17
  94. package/dist/predictions.js +2 -15
  95. package/dist/reject-flow.d.ts +7 -5
  96. package/dist/reject-flow.js +41 -12
  97. package/dist/salience.js +12 -5
  98. package/dist/same-text.d.ts +17 -0
  99. package/dist/same-text.js +38 -0
  100. package/dist/scheduler.d.ts +4 -0
  101. package/dist/scheduler.js +8 -0
  102. package/dist/search.d.ts +7 -0
  103. package/dist/search.js +16 -32
  104. package/dist/secret-detect.d.ts +2 -0
  105. package/dist/secret-detect.js +6 -0
  106. package/dist/server-detect.js +9 -33
  107. package/dist/server.js +6 -62
  108. package/dist/session-digest.d.ts +79 -0
  109. package/dist/session-digest.js +528 -0
  110. package/dist/shared.d.ts +10 -2
  111. package/dist/shared.js +44 -36
  112. package/dist/store.d.ts +9 -2
  113. package/dist/store.js +25 -2
  114. package/dist/token-ledger.d.ts +46 -8
  115. package/dist/token-ledger.js +140 -21
  116. package/dist/version.d.ts +1 -1
  117. package/dist/version.js +1 -1
  118. package/extensions/openclaw-plugin/README.md +4 -4
  119. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  120. package/extensions/openclaw-plugin/package.json +1 -1
  121. package/openclaw.plugin.json +2 -2
  122. package/package.json +2 -2
package/dist/dedupe.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Store-level deduplication. Scans for near-duplicate memories by content
3
- * Jaccard overlap, keeps the stronger copy (by strength + retrieval count),
2
+ * Store-level deduplication. Scans for memories with the same text apart
3
+ * from spacing, keeps the stronger copy (by strength + retrieval count),
4
4
  * removes the rest.
5
5
  *
6
6
  * Extracted from cli.ts in Episode A (v1.11.3) so `api.sleep` can dedupe
@@ -25,6 +25,7 @@ import { loadAllEntries, deleteEntry } 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';
28
+ import { duplicateKey } from './same-text.js';
28
29
  /** Quantization step for strength-tie comparisons. The historical 0.01
29
30
  * epsilon (see `strengthBucket` below) applied via rounding instead of a
30
31
  * raw abs-diff threshold, so the tiebreak is transitive. */
@@ -55,16 +56,15 @@ export function strengthBucket(strength) {
55
56
  return Number.isFinite(s) ? Math.round(s / STRENGTH_TIE_EPSILON) : 0;
56
57
  }
57
58
  /**
58
- * Scan the store for near-duplicate memories and remove the weaker copy.
59
- * Two memories are duplicates if their content has > threshold Jaccard
60
- * overlap AND they belong to the same tenant: the scan is partitioned by
59
+ * Scan the store for duplicates and remove the weaker copy: same text apart
60
+ * from spacing, since a near-duplicate can differ in a value (port, version,
61
+ * path, name), AND the same tenant: the scan is partitioned by
61
62
  * tenantId, so byte-identical content in two tenants is never a duplicate
62
63
  * pair (the tenant boundary is an isolation boundary; cross-tenant removal
63
64
  * was the v1.32.0 known-issue data-loss bug).
64
- * Keeps the one with higher strength (or more retrievals if tied).
65
+ * Keeps the one with higher strength (or more retrievals if tied). `threshold` is accepted for old callers and ignored.
65
66
  */
66
67
  export function deduplicateStore(hippoRoot, options = {}) {
67
- const threshold = options.threshold ?? 0.7;
68
68
  const dryRun = options.dryRun ?? false;
69
69
  // Only current distilled rows compete: raw rows are append-only (the delete
70
70
  // trigger would abort sleep mid-loop) and superseded rows are history, as in consolidate.ts.
@@ -111,15 +111,16 @@ export function deduplicateStore(hippoRoot, options = {}) {
111
111
  return retrievalDiff;
112
112
  return compareEntryIdentity(a, b);
113
113
  });
114
+ const texts = tenantEntries.map((e) => duplicateKey(e.content));
114
115
  for (let i = 0; i < tenantEntries.length; i++) {
115
116
  if (removed.has(tenantEntries[i].id))
116
117
  continue;
117
118
  for (let j = i + 1; j < tenantEntries.length; j++) {
118
119
  if (removed.has(tenantEntries[j].id) || !canAutoDelete(tenantEntries[j]))
119
120
  continue;
120
- const similarity = textOverlap(tenantEntries[i].content, tenantEntries[j].content);
121
- if (similarity <= threshold)
121
+ if (texts[j] !== texts[i])
122
122
  continue;
123
+ const similarity = textOverlap(tenantEntries[i].content, tenantEntries[j].content);
123
124
  removed.add(tenantEntries[j].id);
124
125
  pairs.push({
125
126
  kept: tenantEntries[i].id,
package/dist/doctor.d.ts CHANGED
@@ -19,7 +19,7 @@ export interface DoctorReport {
19
19
  /** Inputs for {@link runDoctor}; defaults come from the process. */
20
20
  export interface DoctorOpts {
21
21
  cwd?: string;
22
- /** Home directory used to find agent configuration (~/.claude). */
22
+ /** Home directory used to find agent configuration (~/.claude, ~/.codex). */
23
23
  home?: string;
24
24
  version: string;
25
25
  nodeVersion?: string;
package/dist/doctor.js CHANGED
@@ -11,7 +11,9 @@ 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';
16
+ import { CODEX_TRUST_LINE, codexHomeDir, isCodexPresent, isJsonObject } from './hooks.js';
15
17
  /** Minimum Node.js version hippo supports (package.json engines). */
16
18
  export const MIN_NODE = '22.16.0';
17
19
  function versionAtLeast(actual, min) {
@@ -34,6 +36,29 @@ function readJson(file) {
34
36
  return null;
35
37
  }
36
38
  }
39
+ // Trust lives in Codex's own config.toml rows; doctor reads only the hooks file and reminds.
40
+ function codexCheck(home) {
41
+ const file = path.join(codexHomeDir(home), 'hooks.json');
42
+ const parsed = readJson(file);
43
+ // Codex drops every hook in a hooks.json it cannot parse, hippo's included.
44
+ if (fs.existsSync(file) && !isJsonObject(parsed)) {
45
+ return { id: 'codex', status: 'warn', detail: "Codex's hooks.json is not a JSON object, so Codex runs no hook from it", fix: 'repair hooks.json, then run: hippo hook install codex' };
46
+ }
47
+ const text = JSON.stringify(parsed ?? '');
48
+ const codexHooks = [
49
+ ['hippo context --pinned-only', 'per-prompt memory'],
50
+ ['hippo compact-resume', 'resume after compaction'],
51
+ ];
52
+ const missing = codexHooks.filter(([marker]) => !text.includes(marker)).map(([, what]) => what);
53
+ if (missing.length === 0)
54
+ return { id: 'codex', status: 'pass', detail: `Codex: hippo memory hooks installed. ${CODEX_TRUST_LINE}` };
55
+ return {
56
+ id: 'codex',
57
+ status: 'warn',
58
+ detail: missing.length === codexHooks.length ? "Codex found, but hippo's memory hooks are not installed" : `Codex: hippo hooks missing for ${missing.join(', ')}`,
59
+ fix: 'hippo hook install codex (then trust the hooks once in /hooks)',
60
+ };
61
+ }
37
62
  // Migration 46 creates failure_log; a read-only open no longer creates it on an older store.
38
63
  const FAILURE_LOG_SCHEMA = 46;
39
64
  /** The failed-tool-call count over the last 7 days, or why it could not be read. */
@@ -72,6 +97,36 @@ function sleepCheck(db, now) {
72
97
  return { id: 'sleep', status: 'info', detail: `sleep history unavailable (${message})` };
73
98
  }
74
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
+ }
75
130
  /** Run every check. Never throws for a broken install; broken parts become failed checks. */
76
131
  export function runDoctor(opts) {
77
132
  const cwd = opts.cwd ?? process.cwd();
@@ -123,14 +178,22 @@ export function runDoctor(opts) {
123
178
  const since = new Date(now.getTime() - 7 * 86_400_000).toISOString();
124
179
  try {
125
180
  // SAFETY: COUNT/SUM aggregate row.
126
- const row = db.prepare(`SELECT COUNT(*) AS n, COALESCE(SUM(tokens), 0) AS t FROM token_ledger WHERE ts >= ? AND event = 'inject'`).get(since);
127
- checks.push({ id: 'tokens', status: 'info', detail: `${Number(row?.n ?? 0)} memory blocks sent to agents in 7 days, about ${Number(row?.t ?? 0)} tokens (hippo tokens for detail)` });
181
+ const row = db.prepare(`SELECT COUNT(CASE WHEN event = 'inject' THEN 1 END) AS n,
182
+ COALESCE(SUM(CASE WHEN event = 'inject' THEN tokens END), 0) AS t,
183
+ COALESCE(SUM(CASE WHEN event = 'reread' THEN tokens END), 0) AS r
184
+ FROM token_ledger WHERE ts >= ?`).get(since);
185
+ checks.push({
186
+ id: 'tokens',
187
+ status: 'info',
188
+ detail: `${Number(row?.n ?? 0)} memory blocks sent to agents in 7 days, about ${Number(row?.t ?? 0)} tokens sent and ${Number(row?.r ?? 0)} re-read by later model calls (hippo tokens for detail)`,
189
+ });
128
190
  }
129
191
  catch {
130
192
  checks.push({ id: 'tokens', status: 'info', detail: 'no token ledger yet (created on the next write)' });
131
193
  }
132
194
  checks.push(failuresCheck(db, since, have));
133
195
  checks.push(sleepCheck(db, now));
196
+ checks.push(compactionsCheck(db, now));
134
197
  }
135
198
  catch (err) {
136
199
  checks.push({
@@ -154,9 +217,9 @@ export function runDoctor(opts) {
154
217
  const hooks = [
155
218
  ['hippo context --pinned-only', 'per-prompt memory'],
156
219
  ['hippo session-end', 'session-end capture and sleep'],
157
- ['hippo pre-compact', 'compaction snapshot and capture'],
220
+ ['hippo pre-compact', 'compaction snapshot and memories request'],
158
221
  ['hippo compact-resume', 'resume after compaction'],
159
- ['hippo post-compact', 'the message after compaction'],
222
+ ['hippo post-compact', 'saving the memories a compaction lists'],
160
223
  ['hippo capture-error', 'failed-tool capture'],
161
224
  ];
162
225
  const missing = hooks.filter(([marker]) => !text.includes(marker)).map(([, what]) => what);
@@ -176,6 +239,8 @@ export function runDoctor(opts) {
176
239
  else {
177
240
  checks.push({ id: 'claude-code', status: 'info', detail: 'Claude Code not found; other agents can use hippo over MCP (hippo mcp)' });
178
241
  }
242
+ if (isCodexPresent(home))
243
+ checks.push(codexCheck(home));
179
244
  checks.push({ id: 'embeddings', status: 'info', detail: isEmbeddingAvailable() ? 'local embeddings available (hybrid search)' : 'embeddings not installed; recall uses BM25 (optional: hippo embed --help)' });
180
245
  return { ok: !checks.some((c) => c.status === 'fail'), version: opts.version, store, checks };
181
246
  }
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;
@@ -71,6 +71,12 @@ export interface DormantSnapshot {
71
71
  * has no dormant memory with that id (another tenant's id reads as absent).
72
72
  */
73
73
  export declare function readDormantSnapshot(db: DatabaseSyncLike, tenantId: string, id: string): DormantSnapshot | null;
74
+ /** Every dormant memory of a tenant whose snapshot still reads back. */
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[];
78
+ /** Put `entry` in place of a tenant's dormant memory `id`, keeping when and why that one went dormant. */
79
+ export declare function replaceDormantEntry(db: DatabaseSyncLike, tenantId: string, id: string, entry: MemoryEntry): void;
74
80
  /** Whether a tenant has a dormant memory with this id (snapshot readable or not). */
75
81
  export declare function hasDormantRow(db: DatabaseSyncLike, tenantId: string, id: string): boolean;
76
82
  /** Delete a tenant's dormant memory. Returns false when there was none. */
package/dist/dormant.js CHANGED
@@ -74,8 +74,32 @@ export function readDormantSnapshot(db, tenantId, id) {
74
74
  // SAFETY: row's shape matches the seven columns named in the SELECT.
75
75
  const row = db.prepare(`SELECT tenant_id, id, content, entry_json, reason, strength, dormant_at
76
76
  FROM dormant_memories WHERE tenant_id = ? AND id = ?`).get(tenantId, id);
77
- const entry = row ? parseSnapshot(row) : null;
78
- return row && entry ? { entry, reason: row.reason, strength: row.strength, dormantAt: row.dormant_at } : null;
77
+ return row ? toSnapshot(row) : null;
78
+ }
79
+ /** Every dormant memory of a tenant whose snapshot still reads back. */
80
+ export function listDormantSnapshots(db, tenantId) {
81
+ // SAFETY: rows' shape matches the seven columns named in the SELECT.
82
+ const rows = db.prepare(`SELECT tenant_id, id, content, entry_json, reason, strength, dormant_at
83
+ FROM dormant_memories WHERE tenant_id = ?`).all(tenantId);
84
+ return rows.flatMap((row) => toSnapshot(row) ?? []);
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
+ }
95
+ function toSnapshot(row) {
96
+ const entry = parseSnapshot(row);
97
+ return entry ? { entry, reason: row.reason, strength: row.strength, dormantAt: row.dormant_at } : null;
98
+ }
99
+ /** Put `entry` in place of a tenant's dormant memory `id`, keeping when and why that one went dormant. */
100
+ export function replaceDormantEntry(db, tenantId, id, entry) {
101
+ db.prepare(`UPDATE dormant_memories SET id = ?, content = ?, entry_json = ? WHERE tenant_id = ? AND id = ?`)
102
+ .run(entry.id, entry.content, JSON.stringify(entry), tenantId, id);
79
103
  }
80
104
  /** Whether a tenant has a dormant memory with this id (snapshot readable or not). */
81
105
  export function hasDormantRow(db, tenantId, id) {
@@ -19,7 +19,8 @@
19
19
  * - API provider `id` is `${kind}:${model}`; switching to/from an API embedder
20
20
  * (or a dimension change) flips the identity and triggers the existing
21
21
  * reindex-on-change path.
22
- * - `resolveEmbeddingProvider` NEVER throws. `isAvailable()` is provider-aware
22
+ * - `resolveEmbeddingProvider` throws on an invalid config (unknown provider,
23
+ * bad apiBaseUrl); `embedMemory` turns that into a warning. `isAvailable()` is provider-aware
23
24
  * (local -> dependency installed; api -> key present). `embed()` MAY throw on
24
25
  * a hard transport/auth failure so a reindex can abort atomically; hot paths
25
26
  * wrap it and fall back to BM25.
@@ -19,7 +19,8 @@
19
19
  * - API provider `id` is `${kind}:${model}`; switching to/from an API embedder
20
20
  * (or a dimension change) flips the identity and triggers the existing
21
21
  * reindex-on-change path.
22
- * - `resolveEmbeddingProvider` NEVER throws. `isAvailable()` is provider-aware
22
+ * - `resolveEmbeddingProvider` throws on an invalid config (unknown provider,
23
+ * bad apiBaseUrl); `embedMemory` turns that into a warning. `isAvailable()` is provider-aware
23
24
  * (local -> dependency installed; api -> key present). `embed()` MAY throw on
24
25
  * a hard transport/auth failure so a reindex can abort atomically; hot paths
25
26
  * wrap it and fall back to BM25.
@@ -12,6 +12,7 @@ import { openHippoDb, closeHippoDb, getMeta, setMeta } from './db.js';
12
12
  import { initializeParticle, savePhysicsState, loadPhysicsState, resetAllPhysicsState } from './physics-state.js';
13
13
  import { loadConfig } from './config.js';
14
14
  import { resolveEmbeddingProvider } from './embedding-provider.js';
15
+ import { redactSecretsStrict } from './secret-detect.js';
15
16
  // Use createRequire for synchronous module resolution check in ESM
16
17
  const _require = createRequire(import.meta.url);
17
18
  // Cached availability check
@@ -460,11 +461,29 @@ async function withEmbedLock(hippoRoot, fn) {
460
461
  resolve();
461
462
  }
462
463
  }
464
+ // A bad key fails every write; one warning tells the user, N would bury the command's own output.
465
+ let _embedFailureWarned = false;
466
+ function warnEmbedFailureOnce(source, rawMessage) {
467
+ if (_embedFailureWarned)
468
+ return;
469
+ _embedFailureWarned = true;
470
+ // Strict scrub: this line can land in a hook log file, and an API may echo the key back in its error body.
471
+ const message = redactSecretsStrict(rawMessage).replace(/\s+/g, ' ').replace(/\.+$/, '');
472
+ console.error(`hippo: embedding failed (${source}): ${message}. Memories are stored without embeddings until this is fixed.`);
473
+ }
463
474
  /**
464
475
  * Embed a single memory entry and cache the result in the embedding index.
465
476
  */
466
477
  export async function embedMemory(hippoRoot, entry, model) {
467
- const provider = resolveEmbeddingProvider(hippoRoot, { model });
478
+ let provider;
479
+ try {
480
+ provider = resolveEmbeddingProvider(hippoRoot, { model });
481
+ }
482
+ catch (err) {
483
+ // Callers fire and forget, so this must resolve: a bad config warns once instead of rejecting.
484
+ warnEmbedFailureOnce('config', err instanceof Error ? err.message : String(err));
485
+ return;
486
+ }
468
487
  if (!provider.isAvailable())
469
488
  return;
470
489
  return withEmbedLock(hippoRoot, async () => {
@@ -513,8 +532,9 @@ export async function embedMemory(hippoRoot, entry, model) {
513
532
  // Physics init is best-effort — don't break embedding
514
533
  }
515
534
  }
516
- catch {
517
- // Provider failure (API down / bad key). Best-effort: leave the index as-is.
535
+ catch (err) {
536
+ // Provider failure (API down / bad key). Best-effort: leave the index as-is, but say so once.
537
+ warnEmbedFailureOnce(provider.kind, err instanceof Error ? err.message : String(err));
518
538
  }
519
539
  }).catch((err) => {
520
540
  console.error(`hippo: skipped embedding ${entry.id} (${err instanceof Error ? err.message : String(err)}); run 'hippo embed' to backfill`);
package/dist/extract.js CHANGED
@@ -3,6 +3,7 @@ import { writeEntry } from './store.js';
3
3
  import { loadConfig } from './config.js';
4
4
  import { RejectedValueError } from './rejection.js';
5
5
  import { redactSecrets } from './secret-detect.js';
6
+ import { neverAutoShareTags } from './shared.js';
6
7
  function isJsonString(value) {
7
8
  return typeof value === 'string';
8
9
  }
@@ -81,7 +82,10 @@ export async function extractFacts(text, opts) {
81
82
  }
82
83
  const INHERITABLE_PREFIXES = ['conv:', 'session:', 'scope:', 'path:'];
83
84
  export function storeExtractedFacts(hippoRoot, source, facts) {
84
- const inheritedTags = source.tags.filter((t) => INHERITABLE_PREFIXES.some((p) => t.startsWith(p)));
85
+ const inheritedTags = [
86
+ ...source.tags.filter((t) => INHERITABLE_PREFIXES.some((p) => t.startsWith(p))),
87
+ ...neverAutoShareTags([source]),
88
+ ];
85
89
  const entries = [];
86
90
  let rejected = 0;
87
91
  const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
@@ -11,7 +11,7 @@
11
11
  * Kahneman 2003 inside-vs-outside view).
12
12
  *
13
13
  * Iteration signal: the `recall_autodebias_hint_no_class_match` audit op
14
- * (emitted by computePlanningFallacyHint when a phrase matches but no class
14
+ * (emitted by computePlanningFallacyOutput when a phrase matches but no class
15
15
  * resolves) is the telemetry channel for deciding whether to add an
16
16
  * embedding-based detector in J3.3.
17
17
  *
@@ -11,7 +11,7 @@
11
11
  * Kahneman 2003 inside-vs-outside view).
12
12
  *
13
13
  * Iteration signal: the `recall_autodebias_hint_no_class_match` audit op
14
- * (emitted by computePlanningFallacyHint when a phrase matches but no class
14
+ * (emitted by computePlanningFallacyOutput when a phrase matches but no class
15
15
  * resolves) is the telemetry channel for deciding whether to add an
16
16
  * embedding-based detector in J3.3.
17
17
  *
@@ -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
@@ -1,4 +1,4 @@
1
- import { type SearchResult } from './search.js';
1
+ import { type ResultCost, type SearchResult } from './search.js';
2
2
  /** Hard cap on `--hops` (a higher value just walks more of a finite graph; this bounds
3
3
  * worst-case work and keeps the flag honest). */
4
4
  export declare const MAX_HOPS = 3;
@@ -23,6 +23,8 @@ export interface GraphExpandOpts {
23
23
  asOf?: string;
24
24
  /** Token budget for the augmented set (defaults to 4000, matching recall's default). */
25
25
  budget?: number;
26
+ /** Budget cost per result; defaults to the memory text. */
27
+ cost?: ResultCost;
26
28
  /** The recall --min-results floor: this many top base rows are kept regardless of
27
29
  * budget, so graph expansion never violates the floor. Defaults to 1. */
28
30
  minResults?: number;
@@ -247,15 +247,17 @@ export function graphExpandRecall(baseResults, opts) {
247
247
  // baseResults is score-ordered, so slice(0, N) is the top N.
248
248
  const protectedCount = Math.min(Math.max(minResults, 1), baseResults.length);
249
249
  const keep = new Set(baseResults.slice(0, protectedCount));
250
- let usedTokens = [...keep].reduce((s, r) => s + r.tokens, 0);
250
+ const price = opts.cost ?? ((r) => r.tokens);
251
+ let usedTokens = [...keep].reduce((s, r) => s + price(r), 0);
251
252
  // T2 note: PLAIN stable score sort on purpose -- both input lists are
252
253
  // deterministically ordered by this point, stability inherits that, and a
253
254
  // base-vs-graph-hit tie keeps the BASE result first (the concat order),
254
255
  // preserving pre-T2 semantics.
255
256
  for (const r of [...baseResults.slice(protectedCount), ...allHits].sort((a, b) => b.score - a.score)) {
256
- if (usedTokens + r.tokens > budget)
257
+ const tokens = price(r);
258
+ if (usedTokens + tokens > budget)
257
259
  continue;
258
- usedTokens += r.tokens;
260
+ usedTokens += tokens;
259
261
  keep.add(r);
260
262
  }
261
263
  // DISPLAY order: base order preserved (it may be MMR-diversified); each kept new hit
package/dist/hooks.d.ts CHANGED
@@ -16,6 +16,10 @@
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.
21
+ * Codex's hooks.json gets only two groups (per-prompt memory and
22
+ * compact-resume); see installCodexHooks.
19
23
  *
20
24
  * 2. Plugin install (OpenCode only). OpenCode does NOT share Claude Code's
21
25
  * JSON-hook schema — its config has `additionalProperties: false` and no
@@ -28,7 +32,11 @@
28
32
  * the installer + the migration that removes any pre-existing broken
29
33
  * `hooks` block from opencode.json.
30
34
  */
31
- export type JsonHookTarget = 'claude-code';
35
+ import type { JsonValue, JsonObject } from './working-memory.js';
36
+ /** JSON-value plain-object check (excludes arrays and null), typeof-free for the same
37
+ * reason as isJsonString above. */
38
+ export declare function isJsonObject(value: JsonValue | undefined): value is JsonObject;
39
+ export type JsonHookTarget = 'claude-code' | 'codex';
32
40
  export interface CodexWrapperPaths {
33
41
  wrapperDir: string;
34
42
  metadataPath: string;
@@ -85,7 +93,7 @@ export interface InstallResult {
85
93
  installedUserPromptSubmit: boolean;
86
94
  installedPreCompact: boolean;
87
95
  installedCompactResume: boolean;
88
- /** 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). */
89
97
  installedPostCompact: boolean;
90
98
  /** PostToolUseFailure -> `hippo capture-error` (failed tool calls become error memories). */
91
99
  installedCaptureError: boolean;
@@ -93,6 +101,8 @@ export interface InstallResult {
93
101
  migratedFromStop: boolean;
94
102
  migratedLegacySessionEnd: boolean;
95
103
  migratedSplitSessionEnd: boolean;
104
+ /** The file exists but is not JSON hippo can merge into, so it was left untouched. */
105
+ invalidJson: boolean;
96
106
  }
97
107
  export interface ToolDetection {
98
108
  name: string;
@@ -141,6 +151,12 @@ declare const HIPPO_OPENCODE_PLUGIN_MARKER = "HIPPO_OPENCODE_PLUGIN_V1";
141
151
  */
142
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";
143
153
  export { HIPPO_OPENCODE_PLUGIN_MARKER };
154
+ /** Codex's config folder: $CODEX_HOME, else ~/.codex, as the Codex hooks docs describe. */
155
+ export declare function codexHomeDir(home?: string, env?: Readonly<Record<string, string | undefined>>): string;
156
+ /** Codex counts as installed only when its config folder exists: Codex itself refuses a CODEX_HOME that is not a folder. */
157
+ export declare function isCodexPresent(home?: string): boolean;
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. */
159
+ export declare const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus the five most recent ones. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
144
160
  /**
145
161
  * Default log path consumed by `hippo last-sleep`. Shared fallback when
146
162
  * a caller doesn't pass --path explicitly.