hippo-memory 1.53.2 → 1.55.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.
package/README.md CHANGED
@@ -118,7 +118,7 @@ Run `hippo init` inside one project. This is everything it writes, in the projec
118
118
  - **Instruction files.** A block between `<!-- hippo:start -->` and `<!-- hippo:end -->` in the project's `CLAUDE.md` or `AGENTS.md`, only if that file already exists. Codex, Cursor, OpenClaw, OpenCode and Pi read `AGENTS.md`.
119
119
  - **Claude Code,** when the project has `CLAUDE.md` or `.claude/settings.json`: 7 hook entries in `~/.claude/settings.json`, one each on SessionEnd, UserPromptSubmit, PreCompact, PostCompact and PostToolUseFailure and two on SessionStart. [Framework Integrations](#framework-integrations) says what each one runs.
120
120
  - **OpenCode,** when the project has `.opencode/` or `opencode.json`: a plugin at `~/.config/opencode/plugins/hippo.ts`.
121
- - **Codex,** when the project has `AGENTS.md` or `.codex` and Codex is installed (`$CODEX_HOME`, else `~/.codex`, exists): 2 hook entries in Codex's `hooks.json`, one on UserPromptSubmit that sends your pinned memories plus the five most recent ones with every prompt and one on SessionStart after a compaction. **Codex runs them only after you trust them once in `/hooks`.** Init also prints `hippo hook install codex`, the opt-in that wraps the Codex launcher to capture sessions; `hippo hook uninstall codex` removes hippo's hooks and the wrapper.
121
+ - **Codex,** when the project has `AGENTS.md` or `.codex` and Codex is installed (`$CODEX_HOME`, else `~/.codex`, exists): 2 hook entries in Codex's `hooks.json`, one on UserPromptSubmit that sends your pinned memories plus up to 5 that match the prompt with every prompt and one on SessionStart after a compaction. **Codex runs them only after you trust them once in `/hooks`.** Init also prints `hippo hook install codex`, the opt-in that wraps the Codex launcher to capture sessions; `hippo hook uninstall codex` removes hippo's hooks and the wrapper.
122
122
  - **A daily run at 6:15am,** one per machine: a crontab line on Linux and macOS, a scheduled task named `hippo-daily-runner` on Windows. It runs `hippo learn --git --days 1` and then `hippo sleep` in every project listed in `~/.hippo/workspaces.json`, and init adds this project to that list.
123
123
  - **Agent memories.** On every run, the notes your coding agents keep about this project go into its store, and the ones about you go into the global store. [Agent memories](#agent-memories) lists what is read.
124
124
 
@@ -783,7 +783,7 @@ The block asks for nothing a hook already does, because each extra tool call re-
783
783
  For Claude Code, it also adds 7 hook entries to `~/.claude/settings.json`:
784
784
  - a `SessionEnd` hook that runs `hippo sleep` and then `hippo capture` when the session exits. Capture matches the last 20 user and 10 assistant messages of the transcript against word patterns for decisions, rules, errors and preferences. It uses no model and does not read earlier turns, so record lessons with `hippo remember` as you go.
785
785
  - a `SessionStart` hook that prints the previous session's consolidation output
786
- - a `UserPromptSubmit` hook that runs `hippo context --pinned-only --include-recent 5 --format additional-context` every turn. It re-injects pinned memories (`hippo remember <text> --pin`) plus the 5 newest memories in the store, so fresh same-session lessons appear on the next prompt before you pin them. It does not read your prompt unless you set `{"pinnedInject":{"promptRecall":true}}`, which swaps the 5 newest for memories that match the prompt. The block is rendered without live strength percentages, so it stays byte-identical while its memories do not change, and it is sent only when it changed since the session's last prompt: an unchanged block is skipped, resent every 10 skips (`pinnedInject.refreshTurns`, `0` never resends) and resent after compaction. `{"pinnedInject":{"skipUnchanged":false}}` sends it every turn as before. Opt out entirely with `{"pinnedInject":{"enabled":false}}` in `.hippo/config.json`.
786
+ - a `UserPromptSubmit` hook that runs `hippo context --pinned-only --include-recent 5 --format additional-context` every turn. It re-injects pinned memories (`hippo remember <text> --pin`) plus up to 5 memories that share words with your prompt. When nothing matches, it adds only the pinned ones. Since 1.55.0 this replaces the 5 newest memories, which cut the median block from 847 to 533 tokens in our eval. `{"pinnedInject":{"promptRecall":false}}` brings back the 5 newest, so fresh same-session lessons appear on the next prompt whatever you ask. The block is rendered without live strength percentages, so it stays byte-identical while its memories do not change, and it is sent only when it changed since the session's last prompt: an unchanged block is skipped, resent every 10 skips (`pinnedInject.refreshTurns`, `0` never resends) and resent after compaction. The prompt-matched memories go in a separate block that is never skipped, so they are sent on every prompt they match. `{"pinnedInject":{"skipUnchanged":false}}` sends the pinned block every turn as before. Opt out entirely with `{"pinnedInject":{"enabled":false}}` in `.hippo/config.json`.
787
787
  - a `PreCompact` hook that runs `hippo pre-compact` before the transcript gets summarized. It records the compaction in the store, saves a working-state snapshot (task/summary/next step) so mid-session compaction can't drop it, and asks the summariser to end its summary with a "Memories for hippo" list: the lessons, decisions and corrections from the session that should outlive it. The `SessionEnd` hook still owns extracting durable memories from the transcript.
788
788
  - a second `SessionStart` hook (matcher `compact`) that runs `hippo compact-resume`, printing that snapshot back into context right after compaction, if it is under 15 minutes old.
789
789
  - a `PostCompact` hook that runs `hippo post-compact`. It keeps the summary in the store with secrets scrubbed, and saves each item of that list as a memory that sleep never deletes: at most 10 per compaction, skipping an item an earlier compaction already saved and any item that looks like a secret. An item over 500 characters stays in the compaction's record only. It then prints one line, such as "Hippo saved 3 memories from this compaction and restored your task snapshot." If the store is busy, the summary waits in the store's `compactions-spool/` folder and `hippo sleep` finishes the save; `hippo doctor` names any compaction left unfinished for over 10 minutes. The session that compacted does not have those memories injected back into its own prompts, since it just read them in the summary; `hippo recall` still finds them. The hook prints nothing when there is no store to save to.
@@ -794,7 +794,7 @@ Only Claude Code saves memories at a compaction: hippo installs no `PreCompact`
794
794
  To remove: `hippo hook uninstall claude-code`
795
795
 
796
796
  For Codex, it adds two hooks to `$CODEX_HOME/hooks.json` (else `~/.codex/hooks.json`) and keeps every hook already there:
797
- - a `UserPromptSubmit` hook that runs the same `hippo context --pinned-only` command as Claude Code's, so your pinned memories plus the five most recent ones reach every prompt as developer context
797
+ - a `UserPromptSubmit` hook that runs the same `hippo context --pinned-only` command as Claude Code's, so your pinned memories plus up to 5 that match the prompt reach every prompt as developer context
798
798
  - a `SessionStart` hook (matcher `compact`) that runs `hippo compact-resume` after a compaction, so the next prompt sends that block again. Codex gets no `PreCompact` hook from hippo, so it restores a task snapshot only if one was saved with `hippo snapshot save` in the last 15 minutes
799
799
 
800
800
  **Codex runs a new or changed hook only after you trust it, so open `/hooks` in Codex once and trust both;** `hippo doctor` reminds you. The per-prompt hook was checked against a real Codex request; the compaction hook follows Codex's documented `compact` start source and has not been watched end to end in Codex. Each hook also carries a `commandWindows` form (`hippo.cmd ...`), because Codex runs hooks through PowerShell on Windows, where the execution policy can block npm's `hippo.ps1`. hippo only ever appends these two entries and never rewrites one, since Codex treats a changed command as a new hook to trust. To remove: `hippo hook uninstall codex`, which takes out only hippo's exact commands and leaves every other hook, including one of yours that runs hippo.
@@ -1056,7 +1056,7 @@ node run.mjs --adapter all
1056
1056
 
1057
1057
  ### How do I give Claude Code memory between sessions?
1058
1058
 
1059
- Run `npm install -g hippo-memory`, then `hippo init` in the project. If the project has a `CLAUDE.md`, init adds a short block telling Claude to run `hippo context --auto` when a session starts. It also adds 7 hook entries to Claude Code's settings that keep your pinned memories plus the five most recent ones in context, save a task snapshot and the memories a compaction summary lists, store failed tool calls as lessons, and run `hippo sleep` when the session ends, and it sets up a daily 6:15am run. [What hippo init changes](#what-hippo-init-changes) lists everything. The [Claude Code plugin](https://github.com/kitfunso/hippo-memory/tree/master/extensions/claude-code-plugin) is the alternative to these hooks; use one, not both. To set up every git repo up to three folders below your home directory at once, know what the scan changes first: each repo gets its own store, seeded from a year of its git history, the same hooks go in when one of those repos uses Claude Code, and the daily run is set up, but no block goes into any repo's `CLAUDE.md`. The command is `hippo init --scan ~`; run `hippo init` in the projects where you want the block.
1059
+ Run `npm install -g hippo-memory`, then `hippo init` in the project. If the project has a `CLAUDE.md`, init adds a short block telling Claude to run `hippo context --auto` when a session starts. It also adds 7 hook entries to Claude Code's settings that keep your pinned memories plus up to 5 that match the prompt in context, save a task snapshot and the memories a compaction summary lists, store failed tool calls as lessons, and run `hippo sleep` when the session ends, and it sets up a daily 6:15am run. [What hippo init changes](#what-hippo-init-changes) lists everything. The [Claude Code plugin](https://github.com/kitfunso/hippo-memory/tree/master/extensions/claude-code-plugin) is the alternative to these hooks; use one, not both. To set up every git repo up to three folders below your home directory at once, know what the scan changes first: each repo gets its own store, seeded from a year of its git history, the same hooks go in when one of those repos uses Claude Code, and the daily run is set up, but no block goes into any repo's `CLAUDE.md`. The command is `hippo init --scan ~`; run `hippo init` in the projects where you want the block.
1060
1060
 
1061
1061
  ### How do I give Cursor memory between sessions?
1062
1062
 
@@ -1064,7 +1064,7 @@ Run `npm install -g hippo-memory`, then `hippo init` in the project. If the proj
1064
1064
 
1065
1065
  ### How do I give Codex memory across sessions?
1066
1066
 
1067
- `hippo init` adds its instructions to your `AGENTS.md`, which Codex reads before it starts work. When Codex is installed, init also adds two hooks to Codex's `hooks.json`: one puts your pinned memories plus the five most recent ones into every prompt, the other makes the next prompt send them again after a compaction. Codex asks you to trust each new hook once in `/hooks`, and skips it until you do. Capturing Codex sessions is opt-in: `hippo hook install codex` wraps the Codex launcher, and `hippo hook uninstall codex` removes the wrapper and the hooks.
1067
+ `hippo init` adds its instructions to your `AGENTS.md`, which Codex reads before it starts work. When Codex is installed, init also adds two hooks to Codex's `hooks.json`: one puts your pinned memories plus up to 5 that match the prompt into every prompt, the other makes the next prompt send them again after a compaction. Codex asks you to trust each new hook once in `/hooks`, and skips it until you do. Capturing Codex sessions is opt-in: `hippo hook install codex` wraps the Codex launcher, and `hippo hook uninstall codex` removes the wrapper and the hooks.
1068
1068
 
1069
1069
  ### Which agents does hippo work with?
1070
1070
 
package/dist/api.d.ts CHANGED
@@ -43,6 +43,8 @@ export interface Actor {
43
43
  role: 'admin' | 'member';
44
44
  /** EI2: restricted scopes a member key may read (auth.ts grantScope). Unused for admin actors. */
45
45
  scopes?: readonly string[];
46
+ /** An auth resolver vouched for this caller, so its admin role stops at its own tenant. */
47
+ viaAuthResolver?: true;
46
48
  }
47
49
  export interface Context {
48
50
  hippoRoot: string;
@@ -869,8 +871,8 @@ export interface AuthCreateResult {
869
871
  * `src/server.ts` POST /v1/auth/keys mirrors this: it ignores any body
870
872
  * `tenantId` and uses the resolved Bearer's tenant exclusively.
871
873
  *
872
- * Only an admin actor can mint (ForbiddenError otherwise), so a member key
873
- * can never create a key, least of all an admin one.
874
+ * Only an admin actor can mint (ForbiddenError otherwise), and a key never
875
+ * outranks its minter: a resolver admin is tenant-only, so it mints members.
874
876
  */
875
877
  export declare function authCreate(ctx: Context, opts: AuthCreateOpts): AuthCreateResult;
876
878
  /**
package/dist/api.js CHANGED
@@ -6,7 +6,6 @@
6
6
  * (`hippo serve`, A1) call into this module so the business logic lives
7
7
  * in exactly one place.
8
8
  */
9
- import { createHash } from 'node:crypto';
10
9
  import { openHippoDb, closeHippoDb } from './db.js';
11
10
  import { writeEntry, writeEntryDbOnly, strengthenRetrieved, stampOriginProject, writeEntryMirrors, readEntry, deleteEntry, loadRecallSearchEntries, loadEntriesByIds, loadChildrenOf, loadFreshRawMemories, loadSessionRawMemories, countSessionRawMemories, DEFAULT_SEARCH_CANDIDATE_LIMIT, removeEntryMirrors, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, loadLatestHandoff, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS, loadIndex, saveIndex, loadAllEntries, loadAmbientCandidates, updateStats, isInitialized, markSummaryDirtyInTx, auditRejectionRefusal, memoriesBackingObjects, } from './store.js';
12
11
  import { RejectedValueError } from './rejection.js';
@@ -18,7 +17,7 @@ import { quarantineScopeFor, recordQuarantine, getQuarantineRow, listQuarantineR
18
17
  import { summarizeFailures } from './failure-log.js';
19
18
  import { formatHandoffEvidenceLine } from './handoff.js';
20
19
  import { createMemory, createSuccessor, applyOutcome, calculateStrength, CHURN_STALE_TAG, COMPACTION_MEMORY_TAG, } from './memory.js';
21
- import { appendAuditEvent, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
20
+ import { appendAuditEvent, auditQueryFields, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
22
21
  import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './shared.js';
23
22
  import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
24
23
  import { evalNow } from './ablation.js';
@@ -549,8 +548,7 @@ function recallFrom(ctx, opts, windowSize, all) {
549
548
  actor: ctx.actor.subject,
550
549
  op: 'recall',
551
550
  metadata: {
552
- query_hash: createHash('sha256').update(opts.query).digest('hex').slice(0, 16),
553
- query_length: opts.query.length,
551
+ ...auditQueryFields(opts.query),
554
552
  results: rankedOut.length,
555
553
  },
556
554
  });
@@ -1348,16 +1346,19 @@ export function archiveRaw(ctx, id, reason, opts = {}) {
1348
1346
  * `src/server.ts` POST /v1/auth/keys mirrors this: it ignores any body
1349
1347
  * `tenantId` and uses the resolved Bearer's tenant exclusively.
1350
1348
  *
1351
- * Only an admin actor can mint (ForbiddenError otherwise), so a member key
1352
- * can never create a key, least of all an admin one.
1349
+ * Only an admin actor can mint (ForbiddenError otherwise), and a key never
1350
+ * outranks its minter: a resolver admin is tenant-only, so it mints members.
1353
1351
  */
1354
1352
  export function authCreate(ctx, opts) {
1355
1353
  if (ctx.actor.role !== 'admin') {
1356
1354
  throw new ForbiddenError('Only an admin key can create API keys');
1357
1355
  }
1356
+ if (ctx.actor.viaAuthResolver && opts.role === 'admin') {
1357
+ throw new ForbiddenError('A key minted through the auth resolver can only be a member key');
1358
+ }
1358
1359
  const db = openHippoDb(ctx.hippoRoot);
1359
1360
  try {
1360
- const role = opts.role ?? 'admin';
1361
+ const role = opts.role ?? (ctx.actor.viaAuthResolver ? 'member' : 'admin');
1361
1362
  const result = createApiKey(db, { tenantId: ctx.tenantId, label: opts.label, role });
1362
1363
  // v1.12.4: audit emit (closes the gap v1.12.3 CHANGELOG flagged as deferred).
1363
1364
  // Mirrors the auth_revoke pattern at authRevoke — same try/catch so audit
@@ -1408,10 +1409,10 @@ export function authRevoke(ctx, keyId) {
1408
1409
  }
1409
1410
  const db = openHippoDb(ctx.hippoRoot);
1410
1411
  try {
1411
- // SAFETY: row's shape matches the three columns named in the SELECT
1412
+ // SAFETY: row's shape matches the four columns named in the SELECT
1412
1413
  // above.
1413
1414
  const row = db
1414
- .prepare(`SELECT key_id, tenant_id, revoked_at FROM api_keys WHERE key_id = ?`)
1415
+ .prepare(`SELECT key_id, tenant_id, revoked_at, role FROM api_keys WHERE key_id = ?`)
1415
1416
  .get(keyId);
1416
1417
  if (!row) {
1417
1418
  throw new Error(`Unknown key_id: ${keyId}`);
@@ -1420,6 +1421,9 @@ export function authRevoke(ctx, keyId) {
1420
1421
  if (row.tenant_id !== ctx.tenantId) {
1421
1422
  throw new Error(`Unknown key_id: ${keyId}`);
1422
1423
  }
1424
+ if (ctx.actor.viaAuthResolver && row.role === 'admin') {
1425
+ throw new ForbiddenError('An auth resolver admin cannot revoke an admin key, which outranks it');
1426
+ }
1423
1427
  let revokedAt;
1424
1428
  let alreadyRevoked = false;
1425
1429
  if (row.revoked_at) {
@@ -1930,7 +1934,7 @@ export async function getContext(ctx, opts = {}) {
1930
1934
  // 'recall' op emitted by api.recall for parity). pinnedOnly + '*' fallback
1931
1935
  // never hit the search engines, so they don't emit (matches cmdContext).
1932
1936
  const ctxRecallMetadata = {
1933
- query: query.slice(0, 200),
1937
+ ...auditQueryFields(query),
1934
1938
  results: selectedItems.length,
1935
1939
  mode: 'context',
1936
1940
  };
package/dist/audit.d.ts CHANGED
@@ -27,6 +27,11 @@ export interface AppendAuditOpts {
27
27
  targetId?: string;
28
28
  metadata?: unknown;
29
29
  }
30
+ export type AuditQueryFields = {
31
+ query_hash: string;
32
+ query_length: number;
33
+ };
34
+ export declare function auditQueryFields(query: string): AuditQueryFields;
30
35
  export declare function appendAuditEvent(db: DatabaseSyncLike, opts: AppendAuditOpts): void;
31
36
  export interface QueryAuditOpts {
32
37
  tenantId: string;
@@ -44,4 +49,18 @@ export interface AuditEvent {
44
49
  metadata: JsonObject;
45
50
  }
46
51
  export declare function queryAuditEvents(db: DatabaseSyncLike, opts: QueryAuditOpts): AuditEvent[];
52
+ export interface ListAuditAfterOpts {
53
+ /** Last id already consumed; 0 starts from the beginning. */
54
+ afterId: number;
55
+ /** Clamped to 1..10000; default 1000. */
56
+ limit?: number;
57
+ /** Omit for every tenant (deployment-wide export). */
58
+ tenantId?: string;
59
+ }
60
+ /**
61
+ * Cursor read: events with id > afterId, ascending by id. Ids are AUTOINCREMENT
62
+ * and never reused, but deletes (retention prune) leave gaps, so resume from
63
+ * the last id returned, never from a count.
64
+ */
65
+ export declare function listAuditEventsAfter(db: DatabaseSyncLike, opts: ListAuditAfterOpts): AuditEvent[];
47
66
  //# sourceMappingURL=audit.d.ts.map
package/dist/audit.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { canAutoDelete } from './memory.js';
2
3
  export const STOP_WORDS = new Set([
3
4
  'the', 'a', 'an', 'is', 'was', 'are', 'were', 'be', 'been', 'being',
@@ -240,6 +241,12 @@ function isBigIntValue(value) {
240
241
  function bigintSafeReplacer(_key, value) {
241
242
  return isBigIntValue(value) ? value.toString() : value;
242
243
  }
244
+ export function auditQueryFields(query) {
245
+ return {
246
+ query_hash: createHash('sha256').update(query).digest('hex').slice(0, 16),
247
+ query_length: query.length,
248
+ };
249
+ }
243
250
  export function appendAuditEvent(db, opts) {
244
251
  db.prepare(`INSERT INTO audit_log (ts, tenant_id, actor, op, target_id, metadata_json) VALUES (?, ?, ?, ?, ?, ?)`).run(new Date().toISOString(), opts.tenantId, opts.actor, opts.op, opts.targetId ?? null, JSON.stringify(opts.metadata ?? {}, bigintSafeReplacer));
245
252
  }
@@ -255,13 +262,40 @@ export function queryAuditEvents(db, opts) {
255
262
  params.push(opts.since);
256
263
  }
257
264
  const limit = Math.max(1, Math.min(opts.limit ?? 100, 10000));
258
- // SAFETY: the SELECT above names exactly these six columns in this order, so the
259
- // row shape matches this assertion.
265
+ // SAFETY: AUDIT_COLUMNS names exactly the AuditRow columns, in this order.
266
+ const rows = db
267
+ .prepare(`SELECT ${AUDIT_COLUMNS} FROM audit_log WHERE ${where.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`)
268
+ .all(...params, limit);
269
+ return rows.map(rowToAuditEvent);
270
+ }
271
+ /**
272
+ * Cursor read: events with id > afterId, ascending by id. Ids are AUTOINCREMENT
273
+ * and never reused, but deletes (retention prune) leave gaps, so resume from
274
+ * the last id returned, never from a count.
275
+ */
276
+ export function listAuditEventsAfter(db, opts) {
277
+ if (!Number.isInteger(opts.afterId) || opts.afterId < 0) {
278
+ throw new RangeError('afterId must be a non-negative integer');
279
+ }
280
+ if (opts.limit !== undefined && !Number.isInteger(opts.limit)) {
281
+ throw new RangeError('limit must be an integer');
282
+ }
283
+ const where = ['id > ?'];
284
+ const params = [opts.afterId];
285
+ if (opts.tenantId !== undefined) {
286
+ where.push('+tenant_id = ?');
287
+ params.push(opts.tenantId);
288
+ }
289
+ const limit = Math.max(1, Math.min(opts.limit ?? 1000, 10000));
290
+ // SAFETY: AUDIT_COLUMNS names exactly the AuditRow columns, in this order.
260
291
  const rows = db
261
- .prepare(`SELECT id, ts, tenant_id, actor, op, target_id, metadata_json
262
- FROM audit_log WHERE ${where.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`)
292
+ .prepare(`SELECT ${AUDIT_COLUMNS} FROM audit_log WHERE ${where.join(' AND ')} ORDER BY id ASC LIMIT ?`)
263
293
  .all(...params, limit);
264
- return rows.map((r) => ({
294
+ return rows.map(rowToAuditEvent);
295
+ }
296
+ const AUDIT_COLUMNS = 'id, ts, tenant_id, actor, op, target_id, metadata_json';
297
+ function rowToAuditEvent(r) {
298
+ return {
265
299
  id: r.id,
266
300
  ts: r.ts,
267
301
  tenantId: r.tenant_id,
@@ -271,7 +305,7 @@ export function queryAuditEvents(db, opts) {
271
305
  op: r.op,
272
306
  targetId: r.target_id,
273
307
  metadata: safeJsonParse(r.metadata_json),
274
- }));
308
+ };
275
309
  }
276
310
  function safeJsonParse(raw) {
277
311
  try {
package/dist/auth.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { DatabaseSyncLike } from './db.js';
2
+ /** Every minted API key starts with this, so the server can route a bearer token by shape. */
3
+ export declare const API_KEY_PREFIX = "hk_";
2
4
  export declare function _dummyHashForTests(): string;
3
5
  export interface CreateApiKeyOpts {
4
6
  tenantId: string;
package/dist/auth.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
2
- const KEY_PREFIX = 'hk_';
2
+ /** Every minted API key starts with this, so the server can route a bearer token by shape. */
3
+ export const API_KEY_PREFIX = 'hk_';
3
4
  const ID_LEN = 24; // base32 chars after prefix
4
5
  const SECRET_LEN = 32; // base32 chars after dot
5
6
  const SCRYPT_KEYLEN = 32;
@@ -40,7 +41,7 @@ function verifyKey(plaintext, stored) {
40
41
  return expected.length === actual.length && timingSafeEqual(expected, actual);
41
42
  }
42
43
  export function createApiKey(db, opts) {
43
- const keyId = `${KEY_PREFIX}${randBase32(ID_LEN)}`;
44
+ const keyId = `${API_KEY_PREFIX}${randBase32(ID_LEN)}`;
44
45
  const secret = randBase32(SECRET_LEN);
45
46
  const plaintext = `${keyId}.${secret}`;
46
47
  const hash = hashKey(plaintext);
package/dist/cli.js CHANGED
@@ -81,7 +81,7 @@ import { importChatGPT, importClaude, importCursor, importGenericFile, importMar
81
81
  import { cmdCapture, cmdPreCompact, cmdPostCompact, resolveLastSessionTranscript, truncateCodePointSafe, sanitizeLogMessage, transcriptWorkingState } from './capture.js';
82
82
  import { COMPACTION_DB_WAIT_MS, replayCompactionsAt } from './compaction-record.js';
83
83
  import { readStdinBounded } from './stdin.js';
84
- import { auditMemories, appendAuditEvent, AUDIT_OPS, } from './audit.js';
84
+ import { auditMemories, appendAuditEvent, auditQueryFields, AUDIT_OPS, } from './audit.js';
85
85
  import { listApiKeys, revokeApiKey } from './auth.js';
86
86
  import { buildProvenanceCoverage } from './provenance-coverage.js';
87
87
  import { buildCorrectionLatency } from './correction-latency.js';
@@ -1853,10 +1853,7 @@ async function cmdRecall(hippoRoot, query, flags) {
1853
1853
  }
1854
1854
  else if (process.env.HIPPO_ANCHORING !== 'off') {
1855
1855
  // SHA-256/16 per the recall-audit convention; hashQueryText is FNV-1a and brute-forceable on short queries.
1856
- emitCliAudit(hippoRoot, 'recall_anchor_skipped_no_session', undefined, {
1857
- query_hash: createHash('sha256').update(query).digest('hex').slice(0, 16),
1858
- query_length: query.length,
1859
- });
1856
+ emitCliAudit(hippoRoot, 'recall_anchor_skipped_no_session', undefined, auditQueryFields(query));
1860
1857
  }
1861
1858
  if (cmdAnchoringHint?.reason === 'memory_dominance') {
1862
1859
  emitCliAudit(hippoRoot, 'recall_anchor_detected_memory_dominance', cmdAnchoringHint.memoryId, {
@@ -1878,7 +1875,7 @@ async function cmdRecall(hippoRoot, query, flags) {
1878
1875
  }
1879
1876
  // A5 audit: one 'recall' event per query, before the early-empty return, in every participating store.
1880
1877
  const recallMetadata = {
1881
- query: query.slice(0, 200),
1878
+ ...auditQueryFields(query),
1882
1879
  results: results.length,
1883
1880
  };
1884
1881
  emitCliAudit(hippoRoot, 'recall', undefined, recallMetadata);
@@ -8776,7 +8773,7 @@ Commands:
8776
8773
  --budget <n> Token budget for the whole printed block (default: 1500)
8777
8774
  --pinned-only Only inject pinned memories (used by UserPromptSubmit hook)
8778
8775
  --include-recent <n> With --pinned-only, also inject the last N writes regardless of pinning
8779
- (the hook payload's "prompt" drives prompt recall instead of --include-recent when pinnedInject.promptRecall is on)
8776
+ (the hook payload's "prompt" drives prompt recall instead of --include-recent when pinnedInject.promptRecall is on, the default)
8780
8777
  --format <fmt> Output format: markdown (default), json, or additional-context (Claude Code hook JSON)
8781
8778
  --framing <mode> Framing: observe (default), suggest, assert
8782
8779
  sleep Run consolidation pass (auto-learns + dedup + auto-shares)
package/dist/config.d.ts CHANGED
@@ -71,8 +71,8 @@ export interface HippoConfig {
71
71
  * never resends an unchanged block. */
72
72
  refreshTurns: number;
73
73
  /** Z1: gate the hook's backfill on the prompt's own content instead of
74
- * the five newest memories. Default false: the eval failed its overlap gate
75
- * (docs/evals/2026-09-26-z1-prompt-recall-result.md). */
74
+ * the five newest memories. Default true since 1.55.0: overlap tied but median
75
+ * tokens fell 847 to 533 (docs/evals/2026-09-26-z1-prompt-recall-result.md). */
76
76
  promptRecall: boolean;
77
77
  /** Z1: overlap metric for the prompt-recall gate. Default 'jaccard' (tuned, docs/evals/2026-09-26-z1-prompt-recall-result.md). */
78
78
  promptRecallMetric: PromptRecallMetric;
package/dist/config.js CHANGED
@@ -47,7 +47,7 @@ const DEFAULT_CONFIG = {
47
47
  budget: 1500,
48
48
  skipUnchanged: true,
49
49
  refreshTurns: 10,
50
- promptRecall: false,
50
+ promptRecall: true,
51
51
  promptRecallMetric: 'jaccard',
52
52
  promptRecallThreshold: 0.04,
53
53
  promptRecallMinShared: 2,
package/dist/hooks.d.ts CHANGED
@@ -156,7 +156,7 @@ export declare function codexHomeDir(home?: string, env?: Readonly<Record<string
156
156
  /** Codex counts as installed only when its config folder exists: Codex itself refuses a CODEX_HOME that is not a folder. */
157
157
  export declare function isCodexPresent(home?: string): boolean;
158
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`.";
159
+ export declare const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus up to 5 that match the prompt. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
160
160
  /**
161
161
  * Default log path consumed by `hippo last-sleep`. Shared fallback when
162
162
  * a caller doesn't pass --path explicitly.
package/dist/hooks.js CHANGED
@@ -134,7 +134,7 @@ export function isCodexPresent(home = homeDir()) {
134
134
  return fs.statSync(codexHomeDir(home), { throwIfNoEntry: false })?.isDirectory() === true;
135
135
  }
136
136
  /** 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. */
137
- 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`.";
137
+ export const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus up to 5 that match the prompt. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
138
138
  /**
139
139
  * Default log path consumed by `hippo last-sleep`. Shared fallback when
140
140
  * a caller doesn't pass --path explicitly.
package/dist/index.d.ts CHANGED
@@ -21,4 +21,6 @@ export { importChatGPT, importClaude, importCursor, importGenericFile, importMar
21
21
  export { runFeatureEval, formatResult, resultToBaseline, detectRegressions, buildSyntheticCorpus, } from './eval-suite.js';
22
22
  export { computeSalience, SalienceDecision, SalienceResult, SalienceOptions, } from './salience.js';
23
23
  export { computeAmbientState, renderAmbientSummary, formatAmbientVector, AmbientState, } from './ambient.js';
24
+ export { appendAuditEvent, queryAuditEvents, listAuditEventsAfter, AUDIT_OPS, type AuditEvent, type AuditOp, type QueryAuditOpts, type ListAuditAfterOpts, } from './audit.js';
25
+ export { openHippoDb, openHippoDbReadOnly, closeHippoDb, type DatabaseSyncLike } from './db.js';
24
26
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -34,4 +34,6 @@ export { runFeatureEval, formatResult, resultToBaseline, detectRegressions, buil
34
34
  export { computeSalience, } from './salience.js';
35
35
  // Pineal gland: ambient state vector
36
36
  export { computeAmbientState, renderAmbientSummary, formatAmbientVector, } from './ambient.js';
37
+ export { appendAuditEvent, queryAuditEvents, listAuditEventsAfter, AUDIT_OPS, } from './audit.js';
38
+ export { openHippoDb, openHippoDbReadOnly, closeHippoDb } from './db.js';
37
39
  //# sourceMappingURL=index.js.map
@@ -50,6 +50,7 @@ export interface McpContext {
50
50
  role?: 'admin' | 'member';
51
51
  /** EI2: scope grants for the HTTP-MCP caller's key. Absent for stdio (admin, needs none). */
52
52
  scopes?: readonly string[];
53
+ viaAuthResolver?: true;
53
54
  /**
54
55
  * Per-client key for state isolation under HTTP-MCP. For stdio: 'stdio-${pid}'
55
56
  * (one process = one client). For HTTP-SSE / HTTP MCP: hash(bearer + remoteAddr)
@@ -25,9 +25,8 @@ import { recall as apiRecall, remember as apiRemember, outcome as apiOutcome, dr
25
25
  import { assertScopeRequestAllowed } from '../recall-scope.js';
26
26
  import { resolveProjectIdentity, classifyOriginProject, findHippoStoreDir } from '../project-identity.js';
27
27
  import { computePredictionBaserate } from '../predictions.js';
28
- import { appendAuditEvent } from '../audit.js';
28
+ import { appendAuditEvent, auditQueryFields } from '../audit.js';
29
29
  import { RejectedValueError } from '../rejection.js';
30
- import { createHash } from 'node:crypto';
31
30
  import { detectAnchoring, hashQueryText, buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, } from '../recall-history.js';
32
31
  import { detectAvailabilityBias } from '../availability.js';
33
32
  // v0.33 / J1 — Module-level per-(tenant, session) recall-history ring map
@@ -58,7 +57,10 @@ export function findHippoRoot(cwd = process.cwd(), opts) {
58
57
  * a member key never acts as admin through MCP.
59
58
  */
60
59
  function mcpActor(ctx) {
61
- return { subject: ctx?.actor ?? 'mcp', role: ctx?.role ?? 'admin', scopes: ctx?.scopes };
60
+ const actor = { subject: ctx?.actor ?? 'mcp', role: ctx?.role ?? 'admin', scopes: ctx?.scopes };
61
+ if (ctx?.viaAuthResolver)
62
+ actor.viaAuthResolver = true;
63
+ return actor;
62
64
  }
63
65
  // MCP stdio transport spec: messages are newline-delimited JSON-RPC, no embedded newlines.
64
66
  // https://modelcontextprotocol.io/specification/.../basic/transports#stdio
@@ -738,10 +740,7 @@ async function executeTool(name, args, ctx) {
738
740
  actor: ctx?.actor ?? 'mcp',
739
741
  op: 'recall_anchor_skipped_no_session',
740
742
  targetId: undefined,
741
- metadata: {
742
- query_hash: createHash('sha256').update(query).digest('hex').slice(0, 16),
743
- query_length: query.length,
744
- },
743
+ metadata: auditQueryFields(query),
745
744
  });
746
745
  }
747
746
  finally {
package/dist/server.d.ts CHANGED
@@ -11,8 +11,22 @@ export interface ServerHandle {
11
11
  * flow outside tests. */
12
12
  server?: import('node:http').Server;
13
13
  }
14
+ /** Identity an {@link AuthResolver} vouches for. The core sanitises it before use. */
15
+ export interface ResolvedBearer {
16
+ tenantId: string;
17
+ subject: string;
18
+ /** Not 'admin' means 'member'. Admin is tenant-only, yet can mint member API keys (POST /v1/auth/keys) that outlive IdP deprovisioning. */
19
+ role: 'admin' | 'member';
20
+ scopes?: readonly string[];
21
+ }
22
+ /** Sole judge of non-`hk_` bearer tokens: null is a 401; a throw or missed deadline is a 503, so throw only when upstream is down. */
23
+ export type AuthResolver = (token: string) => ResolvedBearer | null | Promise<ResolvedBearer | null>;
14
24
  export interface ServeOpts {
15
25
  hippoRoot: string;
26
+ /** Runs on every request and SSE heartbeat, so keep it cache-backed; API keys never reach it. */
27
+ authResolver?: AuthResolver;
28
+ /** Deadline for one authResolver call; defaults to 5000 ms. */
29
+ authResolverTimeoutMs?: number;
16
30
  port?: number;
17
31
  host?: string;
18
32
  /** Stop and exit on SIGINT/SIGTERM. Only `hippo serve` owns the process, so only it sets this. */