hippo-memory 1.53.2 → 1.54.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/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);
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. */