hippo-memory 1.52.8 → 1.52.9

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 +158 -98
  2. package/dist/api.d.ts +51 -18
  3. package/dist/api.js +121 -76
  4. package/dist/audit.d.ts +2 -1
  5. package/dist/audit.js +63 -0
  6. package/dist/capture.d.ts +37 -0
  7. package/dist/capture.js +111 -81
  8. package/dist/cli.js +627 -654
  9. package/dist/codex-patch.d.ts +12 -0
  10. package/dist/codex-patch.js +71 -0
  11. package/dist/config.d.ts +0 -1
  12. package/dist/config.js +0 -4
  13. package/dist/connectors/slack/types.d.ts +0 -1
  14. package/dist/consolidate.js +85 -32
  15. package/dist/context-render.d.ts +36 -0
  16. package/dist/context-render.js +154 -0
  17. package/dist/dag.js +3 -2
  18. package/dist/db.js +6 -6
  19. package/dist/dedupe.d.ts +6 -6
  20. package/dist/dedupe.js +10 -9
  21. package/dist/doctor.d.ts +1 -1
  22. package/dist/doctor.js +35 -2
  23. package/dist/dormant.d.ts +4 -0
  24. package/dist/dormant.js +17 -2
  25. package/dist/embedding-provider.d.ts +2 -1
  26. package/dist/embedding-provider.js +2 -1
  27. package/dist/embeddings.js +23 -3
  28. package/dist/extract.js +5 -1
  29. package/dist/forward-claim-detector.d.ts +1 -1
  30. package/dist/forward-claim-detector.js +1 -1
  31. package/dist/graph-recall.d.ts +3 -1
  32. package/dist/graph-recall.js +5 -3
  33. package/dist/hooks.d.ts +15 -1
  34. package/dist/hooks.js +122 -27
  35. package/dist/importers.js +5 -12
  36. package/dist/judgment.d.ts +30 -0
  37. package/dist/judgment.js +122 -0
  38. package/dist/mcp/server.js +171 -210
  39. package/dist/merged-row.d.ts +6 -0
  40. package/dist/merged-row.js +35 -0
  41. package/dist/multihop.d.ts +2 -1
  42. package/dist/multihop.js +7 -4
  43. package/dist/physics-state.d.ts +0 -4
  44. package/dist/physics-state.js +0 -6
  45. package/dist/predictions.d.ts +2 -17
  46. package/dist/predictions.js +2 -15
  47. package/dist/reject-flow.d.ts +7 -5
  48. package/dist/reject-flow.js +41 -12
  49. package/dist/salience.js +12 -5
  50. package/dist/same-text.d.ts +17 -0
  51. package/dist/same-text.js +38 -0
  52. package/dist/scheduler.d.ts +4 -0
  53. package/dist/scheduler.js +8 -0
  54. package/dist/search.d.ts +7 -0
  55. package/dist/search.js +16 -32
  56. package/dist/secret-detect.d.ts +2 -0
  57. package/dist/secret-detect.js +6 -0
  58. package/dist/server-detect.js +9 -33
  59. package/dist/server.js +6 -62
  60. package/dist/session-digest.d.ts +79 -0
  61. package/dist/session-digest.js +528 -0
  62. package/dist/shared.d.ts +10 -2
  63. package/dist/shared.js +35 -30
  64. package/dist/store.d.ts +1 -0
  65. package/dist/store.js +4 -0
  66. package/dist/token-ledger.d.ts +46 -8
  67. package/dist/token-ledger.js +140 -21
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/extensions/openclaw-plugin/README.md +4 -4
  71. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  72. package/extensions/openclaw-plugin/package.json +1 -1
  73. package/openclaw.plugin.json +2 -2
  74. 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
@@ -12,6 +12,7 @@ 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
14
  import { isEmbeddingAvailable } from './embeddings.js';
15
+ import { CODEX_TRUST_LINE, codexHomeDir, isCodexPresent, isJsonObject } from './hooks.js';
15
16
  /** Minimum Node.js version hippo supports (package.json engines). */
16
17
  export const MIN_NODE = '22.16.0';
17
18
  function versionAtLeast(actual, min) {
@@ -34,6 +35,29 @@ function readJson(file) {
34
35
  return null;
35
36
  }
36
37
  }
38
+ // Trust lives in Codex's own config.toml rows; doctor reads only the hooks file and reminds.
39
+ function codexCheck(home) {
40
+ const file = path.join(codexHomeDir(home), 'hooks.json');
41
+ const parsed = readJson(file);
42
+ // Codex drops every hook in a hooks.json it cannot parse, hippo's included.
43
+ if (fs.existsSync(file) && !isJsonObject(parsed)) {
44
+ 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' };
45
+ }
46
+ const text = JSON.stringify(parsed ?? '');
47
+ const codexHooks = [
48
+ ['hippo context --pinned-only', 'per-prompt memory'],
49
+ ['hippo compact-resume', 'resume after compaction'],
50
+ ];
51
+ const missing = codexHooks.filter(([marker]) => !text.includes(marker)).map(([, what]) => what);
52
+ if (missing.length === 0)
53
+ return { id: 'codex', status: 'pass', detail: `Codex: hippo memory hooks installed. ${CODEX_TRUST_LINE}` };
54
+ return {
55
+ id: 'codex',
56
+ status: 'warn',
57
+ detail: missing.length === codexHooks.length ? "Codex found, but hippo's memory hooks are not installed" : `Codex: hippo hooks missing for ${missing.join(', ')}`,
58
+ fix: 'hippo hook install codex (then trust the hooks once in /hooks)',
59
+ };
60
+ }
37
61
  // Migration 46 creates failure_log; a read-only open no longer creates it on an older store.
38
62
  const FAILURE_LOG_SCHEMA = 46;
39
63
  /** The failed-tool-call count over the last 7 days, or why it could not be read. */
@@ -123,8 +147,15 @@ export function runDoctor(opts) {
123
147
  const since = new Date(now.getTime() - 7 * 86_400_000).toISOString();
124
148
  try {
125
149
  // 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)` });
150
+ const row = db.prepare(`SELECT COUNT(CASE WHEN event = 'inject' THEN 1 END) AS n,
151
+ COALESCE(SUM(CASE WHEN event = 'inject' THEN tokens END), 0) AS t,
152
+ COALESCE(SUM(CASE WHEN event = 'reread' THEN tokens END), 0) AS r
153
+ FROM token_ledger WHERE ts >= ?`).get(since);
154
+ checks.push({
155
+ id: 'tokens',
156
+ status: 'info',
157
+ 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)`,
158
+ });
128
159
  }
129
160
  catch {
130
161
  checks.push({ id: 'tokens', status: 'info', detail: 'no token ledger yet (created on the next write)' });
@@ -176,6 +207,8 @@ export function runDoctor(opts) {
176
207
  else {
177
208
  checks.push({ id: 'claude-code', status: 'info', detail: 'Claude Code not found; other agents can use hippo over MCP (hippo mcp)' });
178
209
  }
210
+ if (isCodexPresent(home))
211
+ checks.push(codexCheck(home));
179
212
  checks.push({ id: 'embeddings', status: 'info', detail: isEmbeddingAvailable() ? 'local embeddings available (hybrid search)' : 'embeddings not installed; recall uses BM25 (optional: hippo embed --help)' });
180
213
  return { ok: !checks.some((c) => c.status === 'fail'), version: opts.version, store, checks };
181
214
  }
package/dist/dormant.d.ts CHANGED
@@ -71,6 +71,10 @@ 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
+ /** Put `entry` in place of a tenant's dormant memory `id`, keeping when and why that one went dormant. */
77
+ export declare function replaceDormantEntry(db: DatabaseSyncLike, tenantId: string, id: string, entry: MemoryEntry): void;
74
78
  /** Whether a tenant has a dormant memory with this id (snapshot readable or not). */
75
79
  export declare function hasDormantRow(db: DatabaseSyncLike, tenantId: string, id: string): boolean;
76
80
  /** Delete a tenant's dormant memory. Returns false when there was none. */
package/dist/dormant.js CHANGED
@@ -74,8 +74,23 @@ 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
+ function toSnapshot(row) {
87
+ const entry = parseSnapshot(row);
88
+ return entry ? { entry, reason: row.reason, strength: row.strength, dormantAt: row.dormant_at } : null;
89
+ }
90
+ /** Put `entry` in place of a tenant's dormant memory `id`, keeping when and why that one went dormant. */
91
+ export function replaceDormantEntry(db, tenantId, id, entry) {
92
+ db.prepare(`UPDATE dormant_memories SET id = ?, content = ?, entry_json = ? WHERE tenant_id = ? AND id = ?`)
93
+ .run(entry.id, entry.content, JSON.stringify(entry), tenantId, id);
79
94
  }
80
95
  /** Whether a tenant has a dormant memory with this id (snapshot readable or not). */
81
96
  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
  *
@@ -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,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
+ * Codex's hooks.json gets only two groups (per-prompt memory and
20
+ * compact-resume); see installCodexHooks.
19
21
  *
20
22
  * 2. Plugin install (OpenCode only). OpenCode does NOT share Claude Code's
21
23
  * JSON-hook schema — its config has `additionalProperties: false` and no
@@ -28,7 +30,11 @@
28
30
  * the installer + the migration that removes any pre-existing broken
29
31
  * `hooks` block from opencode.json.
30
32
  */
31
- export type JsonHookTarget = 'claude-code';
33
+ import type { JsonValue, JsonObject } from './working-memory.js';
34
+ /** JSON-value plain-object check (excludes arrays and null), typeof-free for the same
35
+ * reason as isJsonString above. */
36
+ export declare function isJsonObject(value: JsonValue | undefined): value is JsonObject;
37
+ export type JsonHookTarget = 'claude-code' | 'codex';
32
38
  export interface CodexWrapperPaths {
33
39
  wrapperDir: string;
34
40
  metadataPath: string;
@@ -93,6 +99,8 @@ export interface InstallResult {
93
99
  migratedFromStop: boolean;
94
100
  migratedLegacySessionEnd: boolean;
95
101
  migratedSplitSessionEnd: boolean;
102
+ /** The file exists but is not JSON hippo can merge into, so it was left untouched. */
103
+ invalidJson: boolean;
96
104
  }
97
105
  export interface ToolDetection {
98
106
  name: string;
@@ -141,6 +149,12 @@ declare const HIPPO_OPENCODE_PLUGIN_MARKER = "HIPPO_OPENCODE_PLUGIN_V1";
141
149
  */
142
150
  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
151
  export { HIPPO_OPENCODE_PLUGIN_MARKER };
152
+ /** Codex's config folder: $CODEX_HOME, else ~/.codex, as the Codex hooks docs describe. */
153
+ export declare function codexHomeDir(home?: string): string;
154
+ /** Codex counts as installed only when its config folder exists: Codex itself refuses a CODEX_HOME that is not a folder. */
155
+ export declare function isCodexPresent(home?: string): boolean;
156
+ /** 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. */
157
+ 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
158
  /**
145
159
  * Default log path consumed by `hippo last-sleep`. Shared fallback when
146
160
  * a caller doesn't pass --path explicitly.
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
+ * Codex's hooks.json gets only two groups (per-prompt memory and
20
+ * compact-resume); see installCodexHooks.
19
21
  *
20
22
  * 2. Plugin install (OpenCode only). OpenCode does NOT share Claude Code's
21
23
  * JSON-hook schema — its config has `additionalProperties: false` and no
@@ -40,7 +42,7 @@ function isJsonString(value) {
40
42
  }
41
43
  /** JSON-value plain-object check (excludes arrays and null), typeof-free for the same
42
44
  * reason as isJsonString above. */
43
- function isJsonObject(value) {
45
+ export function isJsonObject(value) {
44
46
  return value !== undefined && value !== null && !Array.isArray(value) && value.constructor === Object;
45
47
  }
46
48
  const HIPPO_SLEEP_MARKER = 'hippo sleep';
@@ -121,6 +123,16 @@ export { HIPPO_OPENCODE_PLUGIN_MARKER };
121
123
  function homeDir() {
122
124
  return process.env.HOME || process.env.USERPROFILE || os.homedir();
123
125
  }
126
+ /** 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
+ }
130
+ /** Codex counts as installed only when its config folder exists: Codex itself refuses a CODEX_HOME that is not a folder. */
131
+ export function isCodexPresent(home = homeDir()) {
132
+ return fs.statSync(codexHomeDir(home), { throwIfNoEntry: false })?.isDirectory() === true;
133
+ }
134
+ /** 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. */
135
+ export 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`.";
124
136
  /**
125
137
  * Default log path consumed by `hippo last-sleep`. Shared fallback when
126
138
  * a caller doesn't pass --path explicitly.
@@ -506,6 +518,12 @@ export function resolveJsonHookPaths(target) {
506
518
  logFile: path.join(logsDir, 'claude-code-sleep.log'),
507
519
  display: 'Claude Code',
508
520
  };
521
+ case 'codex':
522
+ return {
523
+ settings: path.join(codexHomeDir(home), 'hooks.json'),
524
+ logFile: path.join(logsDir, 'codex-sleep.log'),
525
+ display: 'Codex',
526
+ };
509
527
  }
510
528
  }
511
529
  function hookArrayContains(hookArray, marker) {
@@ -560,6 +578,59 @@ function hasLegacySplitSessionEnd(hookArray) {
560
578
  const hasCapture = serialized.includes(HIPPO_CAPTURE_MARKER);
561
579
  return (hasSleep || hasCapture) && !serialized.includes(HIPPO_SESSION_END_MARKER);
562
580
  }
581
+ function nothingInstalled(target, settingsPath) {
582
+ return {
583
+ target,
584
+ settingsPath,
585
+ installedSessionEnd: false,
586
+ installedSessionStart: false,
587
+ installedUserPromptSubmit: false,
588
+ installedPreCompact: false,
589
+ installedCompactResume: false,
590
+ installedPostCompact: false,
591
+ installedCaptureError: false,
592
+ migratedPinnedInjectRecent: false,
593
+ migratedFromStop: false,
594
+ migratedLegacySessionEnd: false,
595
+ migratedSplitSessionEnd: false,
596
+ invalidJson: false,
597
+ };
598
+ }
599
+ /** A command hook with a Windows form: Codex runs hooks in PowerShell there, whose execution policy can block npm's hippo.ps1. */
600
+ function codexCommandHook(command, timeout) {
601
+ return { type: 'command', command, commandWindows: command.replace(/^hippo /, 'hippo.cmd '), timeout };
602
+ }
603
+ /** Codex keys trust to each hook's position and hash and re-asks for a changed one, so hippo only appends and never edits an entry. */
604
+ function installCodexHooks(settingsPath, settings) {
605
+ const result = nothingInstalled('codex', settingsPath);
606
+ if (!isJsonObject(settings))
607
+ return { ...result, invalidJson: true };
608
+ if (settings.hooks === undefined)
609
+ settings.hooks = {};
610
+ const hooks = settings.hooks;
611
+ const events = ['UserPromptSubmit', 'SessionStart'];
612
+ if (!isJsonObject(hooks) || events.some((e) => hooks[e] !== undefined && !Array.isArray(hooks[e]))) {
613
+ return { ...result, invalidJson: true };
614
+ }
615
+ const append = (event, marker, group) => {
616
+ const groups = hooks[event];
617
+ if (hookArrayContains(groups, marker))
618
+ return false;
619
+ hooks[event] = [...(Array.isArray(groups) ? groups : []), group];
620
+ return true;
621
+ };
622
+ const installedUserPromptSubmit = append('UserPromptSubmit', HIPPO_PINNED_INJECT_MARKER, {
623
+ hooks: [codexCommandHook(HIPPO_PINNED_INJECT_COMMAND, 5)],
624
+ });
625
+ const installedCompactResume = append('SessionStart', HIPPO_COMPACT_RESUME_MARKER, {
626
+ matcher: 'compact',
627
+ hooks: [codexCommandHook(HIPPO_COMPACT_RESUME_MARKER, 10)],
628
+ });
629
+ if (installedUserPromptSubmit || installedCompactResume) {
630
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
631
+ }
632
+ return { ...result, installedUserPromptSubmit, installedCompactResume };
633
+ }
563
634
  export function installJsonHooks(target) {
564
635
  const { settings: settingsPath, logFile } = resolveJsonHookPaths(target);
565
636
  const dir = path.dirname(settingsPath);
@@ -571,23 +642,11 @@ export function installJsonHooks(target) {
571
642
  settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
572
643
  }
573
644
  catch {
574
- return {
575
- target,
576
- settingsPath,
577
- installedSessionEnd: false,
578
- installedSessionStart: false,
579
- installedUserPromptSubmit: false,
580
- installedPreCompact: false,
581
- installedCompactResume: false,
582
- installedPostCompact: false,
583
- installedCaptureError: false,
584
- migratedPinnedInjectRecent: false,
585
- migratedFromStop: false,
586
- migratedLegacySessionEnd: false,
587
- migratedSplitSessionEnd: false,
588
- };
645
+ return { ...nothingInstalled(target, settingsPath), invalidJson: true };
589
646
  }
590
647
  }
648
+ if (target === 'codex')
649
+ return installCodexHooks(settingsPath, settings);
591
650
  if (!settings.hooks)
592
651
  settings.hooks = {};
593
652
  // SAFETY: settings.hooks is either freshly initialised to {} on the line above, or an
@@ -777,8 +836,41 @@ export function installJsonHooks(target) {
777
836
  migratedFromStop,
778
837
  migratedLegacySessionEnd,
779
838
  migratedSplitSessionEnd,
839
+ invalidJson: false,
780
840
  };
781
841
  }
842
+ /** The exact command hippo writes for each Codex event; uninstall removes only these handlers. */
843
+ const CODEX_HOOK_COMMANDS = [
844
+ ['UserPromptSubmit', HIPPO_PINNED_INJECT_COMMAND],
845
+ ['SessionStart', HIPPO_COMPACT_RESUME_MARKER],
846
+ ];
847
+ /** A group loses only hippo's handlers and goes only once empty, so a user's hook beside or like hippo's stays. */
848
+ function uninstallCodexHooks(hooks) {
849
+ let changed = false;
850
+ for (const [event, command] of CODEX_HOOK_COMMANDS) {
851
+ const groups = hooks[event];
852
+ if (!Array.isArray(groups))
853
+ continue;
854
+ let removed = false;
855
+ const kept = groups.flatMap((group) => {
856
+ if (!isJsonObject(group) || !Array.isArray(group.hooks))
857
+ return [group];
858
+ const handlers = group.hooks.filter((h) => !(isJsonObject(h) && h.command === command));
859
+ if (handlers.length === group.hooks.length)
860
+ return [group];
861
+ removed = true;
862
+ return handlers.length > 0 ? [{ ...group, hooks: handlers }] : [];
863
+ });
864
+ if (!removed)
865
+ continue;
866
+ changed = true;
867
+ if (kept.length > 0)
868
+ hooks[event] = kept;
869
+ else
870
+ delete hooks[event];
871
+ }
872
+ return changed;
873
+ }
782
874
  export function uninstallJsonHooks(target) {
783
875
  const { settings: settingsPath } = resolveJsonHookPaths(target);
784
876
  if (!fs.existsSync(settingsPath))
@@ -790,11 +882,19 @@ export function uninstallJsonHooks(target) {
790
882
  catch {
791
883
  return false;
792
884
  }
793
- // SAFETY: Claude Code's settings.json always stores `hooks` as an object when
794
- // present; each event key below is still re-validated with Array.isArray before use.
795
- const hooks = settings.hooks;
796
- if (!hooks)
885
+ if (!isJsonObject(settings) || !isJsonObject(settings.hooks))
886
+ return false;
887
+ const changed = target === 'codex' ? uninstallCodexHooks(settings.hooks) : uninstallClaudeCodeHooks(settings.hooks);
888
+ if (!changed)
797
889
  return false;
890
+ if (Object.keys(settings.hooks).length === 0)
891
+ delete settings.hooks;
892
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
893
+ return true;
894
+ }
895
+ function uninstallClaudeCodeHooks(settingsHooks) {
896
+ // SAFETY: each event key below is re-validated with Array.isArray before use.
897
+ const hooks = settingsHooks;
798
898
  let changed = false;
799
899
  const markersByKey = {
800
900
  SessionEnd: [HIPPO_SESSION_END_MARKER, HIPPO_SLEEP_MARKER, HIPPO_CAPTURE_MARKER],
@@ -819,12 +919,7 @@ export function uninstallJsonHooks(target) {
819
919
  delete hooks[key];
820
920
  }
821
921
  }
822
- if (!changed)
823
- return false;
824
- if (Object.keys(hooks).length === 0)
825
- delete settings.hooks;
826
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
827
- return true;
922
+ return changed;
828
923
  }
829
924
  export function resolveOpencodePluginPath() {
830
925
  return path.join(homeDir(), '.config', 'opencode', 'plugins', 'hippo.ts');
@@ -971,7 +1066,7 @@ export function detectInstalledTools() {
971
1066
  { name: 'claude-code', configDir: '~/.claude', detected: exists('.claude'), kind: 'json-hook' },
972
1067
  { name: 'opencode', configDir: '~/.config/opencode', detected: exists('.config', 'opencode'), kind: 'plugin', notes: 'installs a TS plugin at ~/.config/opencode/plugins/hippo.ts' },
973
1068
  { name: 'openclaw', configDir: '~/.openclaw', detected: exists('.openclaw'), kind: 'plugin', notes: 'install via `openclaw plugins install hippo-memory`' },
974
- { name: 'codex', configDir: '~/.codex', detected: exists('.codex'), kind: 'wrapper', notes: 'wraps the detected codex launcher for session-end consolidation' },
1069
+ { name: 'codex', configDir: '~/.codex', detected: isCodexPresent(home), kind: 'wrapper', notes: 'memory hooks in hooks.json, and wraps the detected codex launcher for session-end consolidation' },
975
1070
  { name: 'cursor', configDir: '~/.cursor', detected: exists('.cursor'), kind: 'markdown-instruction', notes: 'no hook API - patches AGENTS.md in the project' },
976
1071
  { name: 'pi', configDir: '~/.pi', detected: exists('.pi'), kind: 'markdown-instruction', notes: 'no hook API - patches AGENTS.md in the project' },
977
1072
  ];
package/dist/importers.js CHANGED
@@ -7,7 +7,7 @@ import * as path from 'path';
7
7
  import { createHash } from 'node:crypto';
8
8
  import { createMemory, Layer } from './memory.js';
9
9
  import { initStore, loadAllEntries, writeEntry } from './store.js';
10
- import { textOverlap } from './search.js';
10
+ import { duplicateKey, storedTextKeys } from './same-text.js';
11
11
  import { getGlobalRoot, initGlobal } from './shared.js';
12
12
  import { remember, archiveRaw, isPrivateScope } from './api.js';
13
13
  import { openHippoDb, closeHippoDb } from './db.js';
@@ -26,7 +26,7 @@ export function importEntries(chunks, source, tags, options) {
26
26
  if (options.global) {
27
27
  initGlobal();
28
28
  }
29
- const existing = loadAllEntries(targetRoot, options.global ? undefined : options.tenantId);
29
+ const keys = storedTextKeys(loadAllEntries(targetRoot, options.global ? undefined : options.tenantId));
30
30
  const allTags = [...new Set([...tags, ...(options.extraTags ?? [])])];
31
31
  const baseHalfLifeDays = loadConfig(targetRoot).defaultHalfLifeDays;
32
32
  let total = 0;
@@ -53,15 +53,8 @@ export function importEntries(chunks, source, tags, options) {
53
53
  continue;
54
54
  }
55
55
  total++;
56
- // Dedup check: textOverlap > 0.7 with any existing memory = skip
57
- let isDuplicate = false;
58
- for (const existing_entry of existing) {
59
- if (textOverlap(chunk, existing_entry.content) > 0.7) {
60
- isDuplicate = true;
61
- break;
62
- }
63
- }
64
- if (isDuplicate) {
56
+ // Dedup check: skip only when the same text is already stored
57
+ if (keys.has(duplicateKey(chunk))) {
65
58
  skipped++;
66
59
  continue;
67
60
  }
@@ -112,7 +105,7 @@ export function importEntries(chunks, source, tags, options) {
112
105
  throw err;
113
106
  }
114
107
  // Add to existing so subsequent chunks dedup against freshly imported ones
115
- existing.push(entry);
108
+ keys.add(duplicateKey(chunk));
116
109
  }
117
110
  entries.push(entry);
118
111
  imported++;