hippo-memory 1.52.7 → 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 (84) hide show
  1. package/README.md +159 -99
  2. package/dist/api.d.ts +52 -18
  3. package/dist/api.js +155 -86
  4. package/dist/audit.d.ts +2 -1
  5. package/dist/audit.js +63 -0
  6. package/dist/autolearn.js +3 -2
  7. package/dist/capture.d.ts +40 -0
  8. package/dist/capture.js +141 -119
  9. package/dist/churn-git.js +2 -2
  10. package/dist/cli.d.ts +1 -4
  11. package/dist/cli.js +695 -702
  12. package/dist/codex-patch.d.ts +12 -0
  13. package/dist/codex-patch.js +71 -0
  14. package/dist/config.d.ts +0 -1
  15. package/dist/config.js +0 -4
  16. package/dist/connectors/slack/types.d.ts +0 -1
  17. package/dist/consolidate.js +85 -32
  18. package/dist/context-render.d.ts +36 -0
  19. package/dist/context-render.js +154 -0
  20. package/dist/dag.js +3 -2
  21. package/dist/dashboard.js +4 -0
  22. package/dist/db.js +6 -6
  23. package/dist/dedupe.d.ts +6 -6
  24. package/dist/dedupe.js +10 -9
  25. package/dist/doctor.d.ts +1 -1
  26. package/dist/doctor.js +35 -2
  27. package/dist/dormant.d.ts +4 -0
  28. package/dist/dormant.js +17 -2
  29. package/dist/embedding-provider.d.ts +2 -1
  30. package/dist/embedding-provider.js +2 -1
  31. package/dist/embeddings.js +23 -3
  32. package/dist/extensions/openclaw-plugin/index.js +1 -0
  33. package/dist/extract.js +5 -1
  34. package/dist/forward-claim-detector.d.ts +1 -1
  35. package/dist/forward-claim-detector.js +1 -1
  36. package/dist/graph-recall.d.ts +3 -1
  37. package/dist/graph-recall.js +13 -10
  38. package/dist/handoff.d.ts +2 -0
  39. package/dist/hooks.d.ts +19 -5
  40. package/dist/hooks.js +131 -30
  41. package/dist/importers.js +5 -12
  42. package/dist/judgment.d.ts +30 -0
  43. package/dist/judgment.js +122 -0
  44. package/dist/mcp/server.js +174 -213
  45. package/dist/merged-row.d.ts +6 -0
  46. package/dist/merged-row.js +35 -0
  47. package/dist/multihop.d.ts +2 -1
  48. package/dist/multihop.js +7 -4
  49. package/dist/physics-state.d.ts +0 -4
  50. package/dist/physics-state.js +0 -6
  51. package/dist/predictions.d.ts +2 -17
  52. package/dist/predictions.js +2 -15
  53. package/dist/reject-flow.d.ts +7 -5
  54. package/dist/reject-flow.js +41 -12
  55. package/dist/salience.js +12 -5
  56. package/dist/same-text.d.ts +17 -0
  57. package/dist/same-text.js +38 -0
  58. package/dist/scheduler.d.ts +4 -0
  59. package/dist/scheduler.js +8 -0
  60. package/dist/search.d.ts +7 -0
  61. package/dist/search.js +16 -32
  62. package/dist/secret-detect.d.ts +2 -0
  63. package/dist/secret-detect.js +9 -3
  64. package/dist/server-detect.js +9 -33
  65. package/dist/server.js +6 -62
  66. package/dist/session-digest.d.ts +79 -0
  67. package/dist/session-digest.js +528 -0
  68. package/dist/shared.d.ts +11 -3
  69. package/dist/shared.js +41 -31
  70. package/dist/store.d.ts +4 -5
  71. package/dist/store.js +26 -13
  72. package/dist/token-ledger.d.ts +46 -8
  73. package/dist/token-ledger.js +140 -21
  74. package/dist/version.d.ts +1 -1
  75. package/dist/version.js +1 -1
  76. package/dist-ui/assets/index-BhT8RvO6.js +61 -0
  77. package/dist-ui/index.html +1 -1
  78. package/extensions/openclaw-plugin/README.md +4 -4
  79. package/extensions/openclaw-plugin/index.ts +1 -0
  80. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  81. package/extensions/openclaw-plugin/package.json +1 -1
  82. package/openclaw.plugin.json +2 -2
  83. package/package.json +2 -2
  84. package/dist-ui/assets/index-BgmA7Hwe.js +0 -61
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`);
@@ -172,6 +172,7 @@ function runHippo(args, cwd) {
172
172
  encoding: 'utf8',
173
173
  timeout: 30_000,
174
174
  stdio: ['pipe', 'pipe', 'pipe'],
175
+ windowsHide: true,
175
176
  });
176
177
  // `encoding: 'utf8'` above selects the ExecFileSyncOptionsWithStringEncoding
177
178
  // overload, so `result` is always a `string` here — no runtime check needed.
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;
@@ -61,12 +61,9 @@ function loadByIdsChunked(root, tenantId, ids) {
61
61
  }
62
62
  return out;
63
63
  }
64
- /**
65
- * Traverse one store's graph from the seeds present in it and accumulate new graph hits
66
- * into `hitsByOrigin`. Mutates `seenMemoryIds` so a memory is surfaced at most once across
67
- * stores. Pure reads.
68
- */
69
- function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds, hitsByOrigin, opts) {
64
+ /** Traverse one store's graph from its seeds into `hitsByOrigin`. Pure reads; mutates `seenMemoryIds`
65
+ * and `seenContent` so a memory, or a share/promote copy of it, surfaces at most once across stores. */
66
+ function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds, seenContent, hitsByOrigin, opts) {
70
67
  const { hops, maxNeighbors, tenantId, includeSuperseded, asOfDate, recallScope } = opts;
71
68
  // Seeds = graph entities (in THIS store) whose source memory is a base result.
72
69
  const seedEntities = loadEntitiesByMemoryId(root, tenantId, baseResults.map((r) => r.entry.id));
@@ -147,6 +144,8 @@ function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds,
147
144
  continue; // not found / wrong tenant / already in base
148
145
  if (seenMemoryIds.has(mem.id))
149
146
  continue; // another reached entity already added it
147
+ if (seenContent.has(mem.content))
148
+ continue; // share/promote copy: same text, another id
150
149
  const via = reached.get(ent.id);
151
150
  // A node reached as the `to` endpoint of a `supersedes` edge IS the superseded
152
151
  // (older) version — the graph is the authoritative signal (the memory mirror's
@@ -175,6 +174,7 @@ function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds,
175
174
  const origin = originMemByEntityId.get(ent.id) ?? baseResults[0].entry.id;
176
175
  const originScore = baseScoreByMemId.get(origin) ?? baseResults[baseResults.length - 1].score;
177
176
  seenMemoryIds.add(mem.id);
177
+ seenContent.add(mem.content);
178
178
  const hit = {
179
179
  entry: mem,
180
180
  score: originScore * (1 - HOP_DISCOUNT * via.hops),
@@ -209,11 +209,12 @@ export function graphExpandRecall(baseResults, opts) {
209
209
  const recallScope = opts.recallScope ?? {};
210
210
  const baseScoreByMemId = new Map(baseResults.map((r) => [r.entry.id, r.score]));
211
211
  const seenMemoryIds = new Set(baseResults.map((r) => r.entry.id));
212
+ const seenContent = new Set(baseResults.map((r) => r.entry.content));
212
213
  const hitsByOrigin = new Map();
213
214
  // Expand against each distinct store the seeds may live in (local + global).
214
215
  const roots = globalRoot && globalRoot !== hippoRoot ? [hippoRoot, globalRoot] : [hippoRoot];
215
216
  for (const root of roots) {
216
- produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds, hitsByOrigin, {
217
+ produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds, seenContent, hitsByOrigin, {
217
218
  hops, maxNeighbors, tenantId, includeSuperseded, asOfDate, recallScope,
218
219
  });
219
220
  }
@@ -246,15 +247,17 @@ export function graphExpandRecall(baseResults, opts) {
246
247
  // baseResults is score-ordered, so slice(0, N) is the top N.
247
248
  const protectedCount = Math.min(Math.max(minResults, 1), baseResults.length);
248
249
  const keep = new Set(baseResults.slice(0, protectedCount));
249
- 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);
250
252
  // T2 note: PLAIN stable score sort on purpose -- both input lists are
251
253
  // deterministically ordered by this point, stability inherits that, and a
252
254
  // base-vs-graph-hit tie keeps the BASE result first (the concat order),
253
255
  // preserving pre-T2 semantics.
254
256
  for (const r of [...baseResults.slice(protectedCount), ...allHits].sort((a, b) => b.score - a.score)) {
255
- if (usedTokens + r.tokens > budget)
257
+ const tokens = price(r);
258
+ if (usedTokens + tokens > budget)
256
259
  continue;
257
- usedTokens += r.tokens;
260
+ usedTokens += tokens;
258
261
  keep.add(r);
259
262
  }
260
263
  // DISPLAY order: base order preserved (it may be MMR-diversified); each kept new hit
package/dist/handoff.d.ts CHANGED
@@ -12,6 +12,8 @@ export interface HandoffEvidence {
12
12
  gitRef?: string | null;
13
13
  dirtyTree?: boolean | null;
14
14
  testStatus?: 'pass' | 'fail' | 'unknown' | null;
15
+ /** 'transcript' when hippo read the handoff off the session's transcript at session end; a later exit may replace it. */
16
+ derivedFrom?: 'transcript';
15
17
  }
16
18
  /** Narrows an unvalidated value (e.g. CLI input or event content) to a HandoffOutcome. */
17
19
  export declare function isHandoffOutcome(v: string | boolean | string[] | null | undefined): v is HandoffOutcome;
package/dist/hooks.d.ts CHANGED
@@ -10,12 +10,14 @@
10
10
  * sequence, writing both outputs to the log file. The parent returns in
11
11
  * <100ms so the TUI teardown can't kill the child before it finishes.
12
12
  * - SessionStart: `hippo last-sleep --path <path>` - prints the log
13
- * written by the previous session's detached worker and then clears it,
14
- * so the user actually sees what was consolidated.
13
+ * written by the previous session's detached worker to stderr, which
14
+ * keeps it out of the model's context, and then clears it.
15
15
  * Earlier Claude Code forms are detected and migrated automatically:
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;
@@ -60,7 +66,7 @@ export interface CodexWrapperMetadata {
60
66
  installedAt: string;
61
67
  }
62
68
  export interface EnsureCodexWrapperResult {
63
- status: 'installed' | 'already-installed' | 'not-found';
69
+ status: 'installed' | 'already-installed' | 'not-found' | 'source-checkout';
64
70
  metadataPath?: string;
65
71
  realCodexPath?: string;
66
72
  commandPath?: 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.
@@ -172,7 +186,7 @@ export declare function isCodexWrapperInstalled(): boolean;
172
186
  * doing it from postinstall or routine commands is a consent violation and
173
187
  * reads as binary hijacking to security scanners (issue #133).
174
188
  */
175
- export declare function repairCodexWrapperIfInstalled(): EnsureCodexWrapperResult;
189
+ export declare function repairCodexWrapperIfInstalled(hippoCliPath?: string): EnsureCodexWrapperResult;
176
190
  export declare function resolveCodexSessionTranscript(options: CodexSessionTranscriptOptions): string | null;
177
191
  export declare function resolveJsonHookPaths(target: JsonHookTarget): JsonHookPaths;
178
192
  export declare function installJsonHooks(target: JsonHookTarget): InstallResult;