hippo-memory 1.47.0 → 1.49.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
@@ -256,7 +256,7 @@ hippo recall "data pipeline" --why --limit 5
256
256
 
257
257
  Input enters the buffer. Important things get encoded into episodic memory. During "sleep," repeated episodes compress into semantic patterns. Weak memories decay and disappear.
258
258
 
259
- The store is SQLite (`.hippo/hippo.db`). The markdown files and `index.json` are mirrors written after each change. From 1.46.0, `index.json` is no longer refreshed on every write; it comes only from an explicit export, so read the store through the CLI, the MCP server or the HTTP API.
259
+ The store is SQLite (`.hippo/hippo.db`). The markdown files are mirrors written after each change. `index.json` is no longer refreshed by writes, deletes or recalls: it is written only when you call `rebuildIndex()` from the package, so a copy an older version left on disk goes stale. Read the store through the CLI, the MCP server or the HTTP API.
260
260
 
261
261
  ```mermaid
262
262
  flowchart TD
@@ -629,6 +629,7 @@ hippo watch "npm run build"
629
629
  | `hippo dormant restore <id>` | Bring a dormant memory back to active memory |
630
630
  | `hippo dormant forget <id>` | Delete a dormant memory permanently |
631
631
  | `hippo doctor [--json]` | Check the install: Node, store, schema, sleep, agent hooks; each problem names its fix |
632
+ | `hippo support-bundle [--out <file>] [--include-logs]` | Write a redacted JSON file for a support ticket: versions, doctor checks, config, store counts and log names, never memory text; `--include-logs` adds each log's last 200 lines, which can quote it |
632
633
  | `hippo tokens [--days n]` | Estimated tokens of memory text handed to agents, per surface, and what skipping unchanged hook blocks saved |
633
634
  | `hippo failures [--days n]` | Failed tool calls the capture-error hook saw, by outcome, and how many errors first happened in another session |
634
635
  | `hippo embed` | Embed all memories for semantic search |
package/dist/api.d.ts CHANGED
@@ -40,6 +40,8 @@ export interface Actor {
40
40
  /** 'cli' | 'localhost:cli' | 'api_key:<key_id>' | 'mcp' | 'connector:slack' | 'connector:github' */
41
41
  subject: string;
42
42
  role: 'admin' | 'member';
43
+ /** EI2: restricted scopes a member key may read (auth.ts grantScope). Unused for admin actors. */
44
+ scopes?: readonly string[];
43
45
  }
44
46
  export interface Context {
45
47
  hippoRoot: string;
@@ -875,6 +877,14 @@ export interface AuthRevokeResult {
875
877
  revokedAt: string;
876
878
  }
877
879
  export declare function authRevoke(ctx: Context, keyId: string): AuthRevokeResult;
880
+ /** Shared result shape for authGrant/authUngrant, named per the file's oxlint anti-slop rule. */
881
+ export interface AuthGrantResult {
882
+ ok: true;
883
+ }
884
+ /** Grant `keyId` read access to one restricted `scope` (ROADMAP Part VIII EI2). Admin only. */
885
+ export declare function authGrant(ctx: Context, keyId: string, scope: string): AuthGrantResult;
886
+ /** Revoke `keyId`'s grant on `scope`. Same authorization and lookup rules as authGrant. */
887
+ export declare function authUngrant(ctx: Context, keyId: string, scope: string): AuthGrantResult;
878
888
  export interface AuditListOpts {
879
889
  op?: AuditOp;
880
890
  /** ISO timestamp lower bound. */
package/dist/api.js CHANGED
@@ -21,7 +21,7 @@ import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './s
21
21
  import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
22
22
  import { evalNow } from './ablation.js';
23
23
  import { archiveRawMemory } from './raw-archive.js';
24
- import { createApiKey, listApiKeys, revokeApiKey, } from './auth.js';
24
+ import { createApiKey, listApiKeys, revokeApiKey, grantScope, ungrantScope, } from './auth.js';
25
25
  import { applyGoalStackBoost } from './goals.js';
26
26
  import { markRetrieved, estimateTokens, hybridSearch, physicsSearch } from './search.js';
27
27
  import { compareEntryIdentity, compareScoredResults } from './compare.js';
@@ -87,7 +87,7 @@ export class ForbiddenError extends Error {
87
87
  // back-compat (`api.isPrivateScope`, test imports). NOTE: the import statement
88
88
  // is required — a bare `export { x } from` re-export does not bind the local
89
89
  // names this module's ~9 call sites use.
90
- import { isPrivateScope, passesScopeFilterForRecall, assertScopeRequestAllowed } from './recall-scope.js';
90
+ import { isPrivateScope, passesScopeFilterForRecall, assertScopeRequestAllowed, isRestrictedScope } from './recall-scope.js';
91
91
  export { isPrivateScope, passesScopeFilterForRecall };
92
92
  export { passesCliRecallScopeFilter, ScopeForbiddenError } from './recall-scope.js';
93
93
  // v39: classifyOriginProject lives in project-identity.ts (leaf) so
@@ -188,13 +188,13 @@ export function buildSuppressionSummary(counts) {
188
188
  */
189
189
  export function recall(ctx, opts) {
190
190
  // A member key may not unlock a private or quarantined scope by naming it.
191
- assertScopeRequestAllowed(ctx.actor.role, opts.scope);
191
+ assertScopeRequestAllowed(ctx.actor, opts.scope);
192
192
  const windowSize = recallWindowSize(opts);
193
193
  return recallFrom(ctx, opts, windowSize, loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false));
194
194
  }
195
195
  /** Mode-aware recall that strengthens each returned row; never writes last_retrieval_ids (v1.11.5 lock). */
196
196
  export async function retrieve(ctx, opts) {
197
- assertScopeRequestAllowed(ctx.actor.role, opts.scope);
197
+ assertScopeRequestAllowed(ctx.actor, opts.scope);
198
198
  const windowSize = recallWindowSize(opts);
199
199
  let candidates = loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false);
200
200
  if (opts.mode === 'hybrid' || opts.mode === 'physics') {
@@ -288,7 +288,7 @@ function recallFrom(ctx, opts, windowSize, all) {
288
288
  // anchored `<source>:private:*` rule (v1.2.1 generalization) and
289
289
  // defense-in-depth: connector authors cannot silently surface private
290
290
  // rows to no-scope callers even if the SQL clause regresses.
291
- entries = current.filter((e) => !isPrivateScope(e.scope ?? null));
291
+ entries = current.filter((e) => !isRestrictedScope(e.scope ?? null));
292
292
  }
293
293
  // v1.12.13 / C5 — WYSIATI dropped_pre_rank counter (JS scope filter drops
294
294
  // for api.recall; cmdRecall pipeline rolls --outcome/--layer/--as-of/etc.
@@ -725,7 +725,7 @@ function recallFrom(ctx, opts, windowSize, all) {
725
725
  * - all rows fail the scope/tenant filter
726
726
  */
727
727
  export function assemble(ctx, sessionId, opts = {}) {
728
- assertScopeRequestAllowed(ctx.actor.role, opts.scope);
728
+ assertScopeRequestAllowed(ctx.actor, opts.scope);
729
729
  const budget = opts.budget ?? 4000;
730
730
  const freshTailCount = opts.freshTailCount ?? 10;
731
731
  const summarizeOlder = opts.summarizeOlder ?? true;
@@ -1152,6 +1152,7 @@ export function supersede(ctx, oldId, newContent) {
1152
1152
  source: old.source,
1153
1153
  confidence: 'verified',
1154
1154
  tenantId: ctx.tenantId,
1155
+ scope: old.scope,
1155
1156
  });
1156
1157
  // Race-safe transition: open a fresh db handle, BEGIN IMMEDIATE, run all
1157
1158
  // three steps (CAS on old + writeEntryDbOnly(new) + supersede audit row)
@@ -1216,11 +1217,11 @@ export function supersede(ctx, oldId, newContent) {
1216
1217
  }
1217
1218
  // Mirrors after COMMIT, while the db handle is still open. Same
1218
1219
  // invariant as the original writeEntry: a mirror failure leaves disk
1219
- // MISSING the markdown for the new memory (self-heals on next backfill
1220
- // via writeIndexMirror reading the DB) but DOES NOT desync the DB or
1220
+ // MISSING the markdown for the new memory (rebuildIndex rewrites every
1221
+ // markdown mirror from the DB) but DOES NOT desync the DB or
1221
1222
  // roll back the supersede. Logged + swallowed, non-fatal.
1222
1223
  try {
1223
- writeEntryMirrors(ctx.hippoRoot, db, newEntry);
1224
+ writeEntryMirrors(ctx.hippoRoot, newEntry);
1224
1225
  }
1225
1226
  catch (mirrorErr) {
1226
1227
  console.error('supersede: mirror write failed (non-fatal, will self-heal):', mirrorErr);
@@ -1401,6 +1402,50 @@ export function authRevoke(ctx, keyId) {
1401
1402
  closeHippoDb(db);
1402
1403
  }
1403
1404
  }
1405
+ /** Grant `keyId` read access to one restricted `scope` (ROADMAP Part VIII EI2). Admin only. */
1406
+ export function authGrant(ctx, keyId, scope) {
1407
+ return changeScopeGrant(ctx, keyId, scope, 'auth_grant');
1408
+ }
1409
+ /** Revoke `keyId`'s grant on `scope`. Same authorization and lookup rules as authGrant. */
1410
+ export function authUngrant(ctx, keyId, scope) {
1411
+ return changeScopeGrant(ctx, keyId, scope, 'auth_ungrant');
1412
+ }
1413
+ function changeScopeGrant(ctx, keyId, scope, op) {
1414
+ if (ctx.actor.role !== 'admin') {
1415
+ throw new ForbiddenError('Only an admin key can change scope grants');
1416
+ }
1417
+ const db = openHippoDb(ctx.hippoRoot);
1418
+ try {
1419
+ // SAFETY: row's shape matches the single tenant_id column in the SELECT.
1420
+ const row = db
1421
+ .prepare(`SELECT tenant_id, revoked_at FROM api_keys WHERE key_id = ?`)
1422
+ .get(keyId);
1423
+ if (!row || row.tenant_id !== ctx.tenantId) {
1424
+ throw new Error(`Unknown key_id: ${keyId}`);
1425
+ }
1426
+ if (op === 'auth_grant' && row.revoked_at) {
1427
+ throw new Error(`${keyId} is revoked; a grant on it would never apply`);
1428
+ }
1429
+ if (!isRestrictedScope(scope)) {
1430
+ throw new Error(`${scope} is not a restricted scope; it is already readable by default`);
1431
+ }
1432
+ if (op === 'auth_grant')
1433
+ grantScope(db, keyId, scope);
1434
+ else
1435
+ ungrantScope(db, keyId, scope);
1436
+ try {
1437
+ appendAuditEvent(db, { tenantId: ctx.tenantId, actor: ctx.actor.subject, op, targetId: keyId, metadata: { scope } });
1438
+ }
1439
+ catch (err) {
1440
+ // Audit must not undo a grant change that already committed; surface it instead.
1441
+ console.error(`auth: audit write failed for ${op} ${keyId}: ${err instanceof Error ? err.message : String(err)}`);
1442
+ }
1443
+ return { ok: true };
1444
+ }
1445
+ finally {
1446
+ closeHippoDb(db);
1447
+ }
1448
+ }
1404
1449
  /**
1405
1450
  * Read audit events scoped to `ctx.tenantId`. Read-only — no audit emit (matches
1406
1451
  * A5: cmdAuditList does not record a 'recall'-style read event).
@@ -1998,7 +2043,7 @@ export function restoreDormant(ctx, id) {
1998
2043
  }
1999
2044
  throw err;
2000
2045
  }
2001
- writeEntryMirrors(ctx.hippoRoot, db, restored);
2046
+ writeEntryMirrors(ctx.hippoRoot, restored);
2002
2047
  return restored;
2003
2048
  }
2004
2049
  finally {
package/dist/audit.d.ts CHANGED
@@ -16,7 +16,7 @@ export interface AuditResult {
16
16
  export declare function auditMemory(entry: MemoryEntry): AuditIssue | null;
17
17
  export declare function auditMemories(entries: MemoryEntry[]): AuditResult;
18
18
  export declare function isContentWorthStoring(content: string): boolean;
19
- export type AuditOp = 'remember' | 'recall' | 'promote' | 'supersede' | 'forget' | 'archive_raw' | 'auth_revoke' | 'auth_create' | 'outcome' | 'consolidate' | 'audit_prune' | 'summary_marked_dirty' | 'summary_marked_clean' | 'summary_rebuilt' | 'predict_create' | 'predict_close' | 'predict_baserate' | 'recall_autodebias_hint' | 'recall_autodebias_hint_no_class_match' | 'recall_autodebias_hint_tiebreak' | 'recall_anchor_detected_query_repeat' | 'recall_anchor_detected_memory_dominance' | 'recall_anchor_skipped_no_session' | 'recall_availability_detected' | 'decision_create' | 'decision_supersede' | 'decision_close' | 'incident_open' | 'incident_resolve' | 'incident_close' | 'process_create' | 'process_supersede' | 'process_close' | 'policy_create' | 'policy_supersede' | 'policy_close' | 'skill_create' | 'skill_supersede' | 'skill_close' | 'project_brief_create' | 'project_brief_supersede' | 'project_brief_close' | 'customer_note_create' | 'customer_note_supersede' | 'customer_note_close' | 'mv_rescue' | 'reject_value' | 'reject_refusal' | 'unreject_value' | 'half_life_migrate' | 'dormant_restore' | 'conflict_resolve';
19
+ export type AuditOp = 'remember' | 'recall' | 'promote' | 'supersede' | 'forget' | 'archive_raw' | 'auth_revoke' | 'auth_create' | 'outcome' | 'consolidate' | 'audit_prune' | 'summary_marked_dirty' | 'summary_marked_clean' | 'summary_rebuilt' | 'predict_create' | 'predict_close' | 'predict_baserate' | 'recall_autodebias_hint' | 'recall_autodebias_hint_no_class_match' | 'recall_autodebias_hint_tiebreak' | 'recall_anchor_detected_query_repeat' | 'recall_anchor_detected_memory_dominance' | 'recall_anchor_skipped_no_session' | 'recall_availability_detected' | 'decision_create' | 'decision_supersede' | 'decision_close' | 'incident_open' | 'incident_resolve' | 'incident_close' | 'process_create' | 'process_supersede' | 'process_close' | 'policy_create' | 'policy_supersede' | 'policy_close' | 'skill_create' | 'skill_supersede' | 'skill_close' | 'project_brief_create' | 'project_brief_supersede' | 'project_brief_close' | 'customer_note_create' | 'customer_note_supersede' | 'customer_note_close' | 'mv_rescue' | 'reject_value' | 'reject_refusal' | 'unreject_value' | 'half_life_migrate' | 'dormant_restore' | 'conflict_resolve' | 'auth_grant' | 'auth_ungrant';
20
20
  export interface AppendAuditOpts {
21
21
  tenantId: string;
22
22
  actor: string;
package/dist/auth.d.ts CHANGED
@@ -16,9 +16,17 @@ export interface ValidateResult {
16
16
  keyId?: string;
17
17
  /** v1.12.0 A5 v2 sub-1: 'admin' | 'member'. Present only when valid=true. */
18
18
  role?: 'admin' | 'member';
19
+ /** EI2: scope grants for this key. Present only when valid=true. */
20
+ scopes?: string[];
19
21
  }
20
22
  export declare function validateApiKey(db: DatabaseSyncLike, plaintext: string): ValidateResult;
21
23
  export declare function revokeApiKey(db: DatabaseSyncLike, keyId: string): void;
24
+ /** EI2: grant `keyId` read access to one restricted `scope`. Idempotent. */
25
+ export declare function grantScope(db: DatabaseSyncLike, keyId: string, scope: string): void;
26
+ /** EI2: revoke `keyId`'s grant on `scope`. Not an error when no such grant exists. */
27
+ export declare function ungrantScope(db: DatabaseSyncLike, keyId: string, scope: string): void;
28
+ /** EI2: every restricted scope `keyId` may read. */
29
+ export declare function listScopeGrants(db: DatabaseSyncLike, keyId: string): string[];
22
30
  export interface ApiKeyListItem {
23
31
  keyId: string;
24
32
  tenantId: string;
@@ -31,6 +39,8 @@ export interface ApiKeyListItem {
31
39
  * Fail-safe-to-member cast: any non-'admin' value reads as 'member'.
32
40
  */
33
41
  role: 'admin' | 'member';
42
+ /** EI2: restricted scopes this key may read. */
43
+ scopes: string[];
34
44
  }
35
45
  export declare function listApiKeys(db: DatabaseSyncLike, opts: {
36
46
  active: boolean;
package/dist/auth.js CHANGED
@@ -74,12 +74,30 @@ export function validateApiKey(db, plaintext) {
74
74
  // 'superuser', or a NULL slipped past the NOT NULL constraint) downgrades to
75
75
  // 'member'. The migration constrains to 'admin' DEFAULT, but defense-in-depth.
76
76
  const role = row.role === 'admin' ? 'admin' : 'member';
77
- return { valid: true, tenantId: row.tenant_id, keyId, role };
77
+ const scopes = listScopeGrants(db, keyId);
78
+ return { valid: true, tenantId: row.tenant_id, keyId, role, scopes };
78
79
  }
79
80
  export function revokeApiKey(db, keyId) {
80
81
  db.prepare(`UPDATE api_keys SET revoked_at = ? WHERE key_id = ? AND revoked_at IS NULL`)
81
82
  .run(new Date().toISOString(), keyId);
82
83
  }
84
+ /** EI2: grant `keyId` read access to one restricted `scope`. Idempotent. */
85
+ export function grantScope(db, keyId, scope) {
86
+ db.prepare(`INSERT INTO api_key_scope_grants (key_id, scope, granted_at) VALUES (?, ?, ?)
87
+ ON CONFLICT(key_id, scope) DO NOTHING`).run(keyId, scope, new Date().toISOString());
88
+ }
89
+ /** EI2: revoke `keyId`'s grant on `scope`. Not an error when no such grant exists. */
90
+ export function ungrantScope(db, keyId, scope) {
91
+ db.prepare(`DELETE FROM api_key_scope_grants WHERE key_id = ? AND scope = ?`).run(keyId, scope);
92
+ }
93
+ /** EI2: every restricted scope `keyId` may read. */
94
+ export function listScopeGrants(db, keyId) {
95
+ // SAFETY: rows' shape matches the single `scope` column named in the SELECT above.
96
+ const rows = db
97
+ .prepare(`SELECT scope FROM api_key_scope_grants WHERE key_id = ? ORDER BY scope`)
98
+ .all(keyId);
99
+ return rows.map((r) => r.scope);
100
+ }
83
101
  export function listApiKeys(db, opts) {
84
102
  const sql = opts.active
85
103
  ? `SELECT key_id, tenant_id, label, created_at, revoked_at, role FROM api_keys WHERE revoked_at IS NULL ORDER BY id DESC`
@@ -92,6 +110,7 @@ export function listApiKeys(db, opts) {
92
110
  keyId: r.key_id, tenantId: r.tenant_id, label: r.label,
93
111
  createdAt: r.created_at, revokedAt: r.revoked_at,
94
112
  role: r.role === 'admin' ? 'admin' : 'member',
113
+ scopes: listScopeGrants(db, r.key_id),
95
114
  }));
96
115
  }
97
116
  //# sourceMappingURL=auth.js.map
package/dist/cli.js CHANGED
@@ -60,6 +60,8 @@ import { computeSystemEnergy, vecNorm } from './physics.js';
60
60
  import { loadConfig } from './config.js';
61
61
  import { openHippoDb, closeHippoDb } from './db.js';
62
62
  import { runDoctor, formatDoctor } from './doctor.js';
63
+ import { buildSupportBundle, TAIL_MAX_LINES } from './support-bundle.js';
64
+ import { PACKAGE_VERSION } from './version.js';
63
65
  import { captureToolFailure } from './capture-error.js';
64
66
  import { blockHash, hookPayloadSessionId, lastSentState, recordTokenUse, shouldSkipUnchanged } from './token-ledger.js';
65
67
  import { FAILURE_LOG_RETENTION_DAYS } from './failure-log.js';
@@ -264,7 +266,7 @@ export const BOOLEAN_FLAGS = new Set([
264
266
  'all', 'all-tenants', 'archive', 'auto', 'bad', 'bootstrap', 'classic', 'continuity',
265
267
  'cross-project', 'dry-run', 'equal-sources', 'error', 'evc-adaptive', 'extract',
266
268
  'filter-conflicts', 'fix', 'force', 'forget', 'git', 'global', 'good', 'graph-stream',
267
- 'help', 'include-superseded', 'inferred', 'json', 'last-session', 'multihop', 'no-hooks',
269
+ 'help', 'include-logs', 'include-superseded', 'inferred', 'json', 'last-session', 'multihop', 'no-hooks',
268
270
  'no-learn', 'no-mmr', 'no-propagate', 'no-schedule', 'no-share', 'no-summarize-older',
269
271
  'observed', 'open', 'physics', 'pin', 'pinned-only', 'reject-loser', 'rerank-utility',
270
272
  'reset-physics', 'save-baseline', 'show-cases', 'stats', 'stdin',
@@ -488,7 +490,7 @@ function cmdInit(hippoRoot, flags) {
488
490
  initStore(hippoRoot);
489
491
  console.log('Initialized Hippo at', hippoRoot);
490
492
  console.log(' Directories: buffer/ episodic/ semantic/ conflicts/');
491
- console.log(' Files: hippo.db index.json stats.json');
493
+ console.log(' Files: hippo.db stats.json');
492
494
  }
493
495
  const globalRoot = getGlobalRoot();
494
496
  registerWorkspace(globalRoot, path.dirname(hippoRoot));
@@ -850,6 +852,7 @@ function cmdSupersede(hippoRoot, oldId, newContent, flags) {
850
852
  source: old.source,
851
853
  confidence: 'verified',
852
854
  tenantId: old.tenantId,
855
+ scope: old.scope,
853
856
  });
854
857
  // AT1: write the SUCCESSOR first. The rejection guard fires on the new
855
858
  // content — if it refuses, nothing has been mutated yet (the old ordering
@@ -7883,6 +7886,41 @@ function cmdAuthRevoke(hippoRoot, keyId, flags) {
7883
7886
  }
7884
7887
  console.log(`Revoked ${keyId} at ${revokedAt}`);
7885
7888
  }
7889
+ /** EI2: `hippo auth grant|ungrant <key_id> <scope>`, routed through api so the tenant, restricted-scope and audit checks live in one place. */
7890
+ function cmdAuthScopeGrant(hippoRoot, keyId, scope, grant, flags) {
7891
+ const root = resolveAuthRoot(hippoRoot, flags);
7892
+ // The local CLI owns every tenant (as auth revoke does), so the grant runs in the key's own tenant.
7893
+ const db = openHippoDb(root);
7894
+ let keyTenant;
7895
+ try {
7896
+ // SAFETY: row's shape matches the single tenant_id column in the SELECT.
7897
+ const row = db.prepare(`SELECT tenant_id FROM api_keys WHERE key_id = ?`).get(keyId);
7898
+ keyTenant = row?.tenant_id;
7899
+ }
7900
+ finally {
7901
+ closeHippoDb(db);
7902
+ }
7903
+ if (keyTenant === undefined) {
7904
+ console.error(`Unknown key_id: ${keyId}`);
7905
+ process.exit(1);
7906
+ }
7907
+ const ctx = { hippoRoot: root, tenantId: keyTenant, actor: api.adminActor('cli') };
7908
+ try {
7909
+ if (grant)
7910
+ api.authGrant(ctx, keyId, scope);
7911
+ else
7912
+ api.authUngrant(ctx, keyId, scope);
7913
+ }
7914
+ catch (err) {
7915
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
7916
+ process.exit(1);
7917
+ }
7918
+ if (flags['json']) {
7919
+ console.log(JSON.stringify({ keyId, scope, granted: grant }));
7920
+ return;
7921
+ }
7922
+ console.log(grant ? `Granted ${keyId} read access to ${scope}` : `Removed ${keyId}'s grant on ${scope}`);
7923
+ }
7886
7924
  // ---------------------------------------------------------------------------
7887
7925
  // Audit log subcommands (A5 stub auth — `hippo audit list`)
7888
7926
  // ---------------------------------------------------------------------------
@@ -7939,6 +7977,8 @@ const VALID_AUDIT_OPS = new Set([
7939
7977
  'conflict_resolve', // AT1 — emitted by resolveConflict on every resolution path; lockstep
7940
7978
  'half_life_migrate', // Decay default change — emitted by migrateDefaultHalfLife; lockstep with AuditOp union
7941
7979
  'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
7980
+ 'auth_grant', // EI2: emitted by api.authGrant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
7981
+ 'auth_ungrant', // EI2: emitted by api.authUngrant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
7942
7982
  ]);
7943
7983
  function formatAuditRow(ev) {
7944
7984
  const target = ev.targetId ?? '-';
@@ -8262,7 +8302,7 @@ function cmdGoal(hippoRoot, args, flags) {
8262
8302
  function cmdAuth(hippoRoot, args, flags) {
8263
8303
  const sub = args[0];
8264
8304
  if (!sub) {
8265
- console.error('Usage: hippo auth <create|list|revoke> [options]');
8305
+ console.error('Usage: hippo auth <create|list|revoke|grant|ungrant> [options]');
8266
8306
  process.exit(1);
8267
8307
  }
8268
8308
  const subArgs = args.slice(1);
@@ -8282,8 +8322,18 @@ function cmdAuth(hippoRoot, args, flags) {
8282
8322
  cmdAuthRevoke(hippoRoot, keyId, flags);
8283
8323
  return;
8284
8324
  }
8325
+ case 'grant':
8326
+ case 'ungrant': {
8327
+ const [keyId, scope] = subArgs;
8328
+ if (!keyId || !scope) {
8329
+ console.error(`Usage: hippo auth ${sub} <key_id> <scope>`);
8330
+ process.exit(1);
8331
+ }
8332
+ cmdAuthScopeGrant(hippoRoot, keyId, scope, sub === 'grant', flags);
8333
+ return;
8334
+ }
8285
8335
  default:
8286
- console.error(`Unknown auth subcommand: ${sub}. Expected: create | list | revoke.`);
8336
+ console.error(`Unknown auth subcommand: ${sub}. Expected: create | list | revoke | grant | ungrant.`);
8287
8337
  process.exit(1);
8288
8338
  }
8289
8339
  }
@@ -8675,6 +8725,10 @@ Commands:
8675
8725
  PostToolUseFailure hook payload on stdin; skips routine failures)
8676
8726
  doctor Check the install: Node, store, schema, sleep, agent hooks
8677
8727
  --json Machine-readable report (exit code 1 on any failure)
8728
+ support-bundle Write a redacted JSON file for a support ticket: versions, doctor,
8729
+ config without secrets, store counts, log names; never memory text
8730
+ --out <file> Where to write it (default: hippo-support-<time>.json here)
8731
+ --include-logs Add the last ${TAIL_MAX_LINES} lines of each hippo log, known secret shapes removed
8678
8732
  tokens Tokens of memory text hippo handed agents, per surface
8679
8733
  (hook, context, recall, MCP, HTTP), and what skipping
8680
8734
  unchanged hook blocks saved
@@ -8960,6 +9014,12 @@ Commands:
8960
9014
  auth revoke <key_id> Revoke an API key (subsequent validate fails)
8961
9015
  --json Output as JSON
8962
9016
  --global Operate on the global store
9017
+ auth grant <key_id> <scope> Let a member key read one restricted scope
9018
+ --json Output as JSON
9019
+ --global Operate on the global store
9020
+ auth ungrant <key_id> <scope> Remove a scope grant
9021
+ --json Output as JSON
9022
+ --global Operate on the global store
8963
9023
  audit <sub> Query the append-only audit log (A5 stub auth)
8964
9024
  audit list List audit events for the active tenant
8965
9025
  --op <op> Filter by op (remember | recall | promote |
@@ -9433,6 +9493,38 @@ async function main(command, args, flags, hippoRoot) {
9433
9493
  process.exit(1);
9434
9494
  break;
9435
9495
  }
9496
+ case 'support-bundle': {
9497
+ const outFlag = cardStringFlag(flags, 'out');
9498
+ if (outFlag === '') {
9499
+ console.error('--out requires a file path.');
9500
+ process.exit(1);
9501
+ }
9502
+ const includeLogs = flags['include-logs'] === true;
9503
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
9504
+ const now = new Date();
9505
+ const bundle = buildSupportBundle({ cwd: process.cwd(), home, version: PACKAGE_VERSION, includeLogs, now });
9506
+ const stamp = now.toISOString().replace(/[:.]/g, '-');
9507
+ const file = outFlag ?? path.join(process.cwd(), `hippo-support-${stamp}.json`);
9508
+ const json = JSON.stringify(bundle, null, 2);
9509
+ try {
9510
+ fs.writeFileSync(file, `${json}\n`, { flag: 'wx', mode: 0o600 });
9511
+ }
9512
+ catch (err) {
9513
+ if (err instanceof Error && 'code' in err && err.code === 'EEXIST') {
9514
+ console.error(`${file} already exists; pass --out to choose another file. Nothing was written.`);
9515
+ }
9516
+ else {
9517
+ console.error(err instanceof Error ? err.message : String(err));
9518
+ }
9519
+ process.exit(1);
9520
+ }
9521
+ const kb = Math.round(Buffer.byteLength(json) / 1024);
9522
+ console.log(`Wrote ${file} (${kb} KB).`);
9523
+ console.log(includeLogs
9524
+ ? `It holds versions, doctor checks, config with secrets removed, store counts, and the last ${TAIL_MAX_LINES} lines of each hippo log with known secret shapes removed. Those log lines can quote memory text. Read it before you attach it to a ticket.`
9525
+ : 'It holds versions, doctor checks, config with secrets removed, store counts and log file names. It never holds memory text. Read it before you attach it to a ticket.');
9526
+ break;
9527
+ }
9436
9528
  case 'snapshot':
9437
9529
  cmdSnapshot(hippoRoot, args, flags);
9438
9530
  break;
@@ -19,6 +19,8 @@ export interface ConsolidationResult {
19
19
  semanticCreated: number;
20
20
  replayed: number;
21
21
  promotedTraces: number;
22
+ /** T7: sessions skipped because their events span two derivation scopes. */
23
+ tracesSkippedMixedScope: number;
22
24
  extractionCandidates: number;
23
25
  extracted: number;
24
26
  dagCandidateClusters: number;
@@ -25,6 +25,7 @@ import { rescueSet, rankNonPinnedByTenant, validateWeights } from './memory-valu
25
25
  import { MEMORY_VALUE_WEIGHTS, SOURCE_ARTIFACT_SHA256 } from './memory-value-weights.js';
26
26
  import { appendAuditEvent } from './audit.js';
27
27
  import { migrateDefaultHalfLife } from './half-life-migration.js';
28
+ import { derivationScope, commonDerivationScope, derivationPartitionKey } from './recall-scope.js';
28
29
  const DECAY_THRESHOLD = 0.05;
29
30
  const MERGE_OVERLAP_THRESHOLD = 0.35; // Jaccard similarity for "related"
30
31
  const MERGE_MIN_CLUSTER = 2; // minimum cluster size to merge
@@ -108,6 +109,7 @@ export async function consolidate(hippoRoot, options = {}) {
108
109
  semanticCreated: 0,
109
110
  replayed: 0,
110
111
  promotedTraces: 0,
112
+ tracesSkippedMixedScope: 0,
111
113
  extractionCandidates: 0,
112
114
  extracted: 0,
113
115
  dagCandidateClusters: 0,
@@ -368,6 +370,13 @@ export async function consolidate(hippoRoot, options = {}) {
368
370
  session_id: session.session_id,
369
371
  limit: 1000,
370
372
  });
373
+ // T7: a mixed-scope session would otherwise leak into one trace.
374
+ const sessionScope = commonDerivationScope(events.map((e) => e.scope));
375
+ if (!sessionScope.ok) {
376
+ result.tracesSkippedMixedScope++;
377
+ result.details.push(` ⏭ skipped session ${session.session_id}: events span mixed scopes`);
378
+ continue;
379
+ }
371
380
  const completeEvent = events.find((e) => e.event_type === 'session_complete');
372
381
  if (!completeEvent)
373
382
  continue; // defence-in-depth; findPromotableSessions filters already.
@@ -391,6 +400,7 @@ export async function consolidate(hippoRoot, options = {}) {
391
400
  source_session_id: session.session_id,
392
401
  tags: ['auto-promoted'],
393
402
  source: 'auto-promote',
403
+ scope: sessionScope.scope,
394
404
  // T1 fix (2026-08-15 hardening pass): stamp the trace into the SAME
395
405
  // tenant the traceExistsForSession idempotency check (above) runs
396
406
  // under. Before
@@ -671,11 +681,12 @@ export async function consolidate(hippoRoot, options = {}) {
671
681
  // before this fix — byte-identical behavior there.
672
682
  const mergeCandidatesByTenant = new Map();
673
683
  for (const entry of mergeCandidates) {
674
- const bucket = mergeCandidatesByTenant.get(entry.tenantId);
684
+ const key = derivationPartitionKey(entry.tenantId, entry.scope);
685
+ const bucket = mergeCandidatesByTenant.get(key);
675
686
  if (bucket)
676
687
  bucket.push(entry);
677
688
  else
678
- mergeCandidatesByTenant.set(entry.tenantId, [entry]);
689
+ mergeCandidatesByTenant.set(key, [entry]);
679
690
  }
680
691
  // AT1 consolidation-loop fix (docs/plans/2026-08-15-at1-rejected-value-tombstone.md):
681
692
  // reuses the single consolidateDb handle opened once above (before the
@@ -686,7 +697,9 @@ export async function consolidate(hippoRoot, options = {}) {
686
697
  // declared up at the try's opening above 1.4, not here — it has to
687
698
  // survive the try/finally that now wraps this whole section; see the
688
699
  // handle-leak restructure comment there.)
689
- for (const [mergeTenant, tenantCandidates] of mergeCandidatesByTenant) {
700
+ for (const [, tenantCandidates] of mergeCandidatesByTenant) {
701
+ const mergeTenant = tenantCandidates[0].tenantId;
702
+ const mergeScope = derivationScope(tenantCandidates[0].scope);
690
703
  for (let i = 0; i < tenantCandidates.length; i++) {
691
704
  if (used.has(tenantCandidates[i].id))
692
705
  continue;
@@ -722,6 +735,7 @@ export async function consolidate(hippoRoot, options = {}) {
722
735
  source: 'consolidation',
723
736
  confidence: 'inferred',
724
737
  tenantId: mergeTenant,
738
+ scope: mergeScope,
725
739
  });
726
740
  }
727
741
  // mergeContents is DETERMINISTIC CONCATENATION (not an LLM paraphrase)
package/dist/dag.js CHANGED
@@ -2,6 +2,7 @@ import { createMemory, Layer } from './memory.js';
2
2
  import { writeEntry, loadAllDirtySummaries, loadChildrenOfSummary, applyRebuildResult, clearSummaryDirtyAfterBuild, } from './store.js';
3
3
  import { RejectedValueError } from './rejection.js';
4
4
  import { redactSecrets } from './secret-detect.js';
5
+ import { derivationScope, derivationPartitionKey } from './recall-scope.js';
5
6
  export function clusterFacts(facts) {
6
7
  if (facts.length === 0)
7
8
  return [];
@@ -93,13 +94,16 @@ export async function buildDag(hippoRoot, facts, opts) {
93
94
  // behavior there.
94
95
  const unparentedByTenant = new Map();
95
96
  for (const fact of unparented) {
96
- const bucket = unparentedByTenant.get(fact.tenantId);
97
+ const key = derivationPartitionKey(fact.tenantId, fact.scope);
98
+ const bucket = unparentedByTenant.get(key);
97
99
  if (bucket)
98
100
  bucket.push(fact);
99
101
  else
100
- unparentedByTenant.set(fact.tenantId, [fact]);
102
+ unparentedByTenant.set(key, [fact]);
101
103
  }
102
- for (const [factTenant, tenantFacts] of unparentedByTenant) {
104
+ for (const [, tenantFacts] of unparentedByTenant) {
105
+ const factTenant = tenantFacts[0].tenantId;
106
+ const factScope = derivationScope(tenantFacts[0].scope);
103
107
  const clusters = clusterFacts(tenantFacts);
104
108
  const eligibleClusters = clusters.filter((c) => c.members.length >= 3);
105
109
  result.candidateClusters += eligibleClusters.length;
@@ -118,6 +122,7 @@ export async function buildDag(hippoRoot, facts, opts) {
118
122
  confidence: 'inferred',
119
123
  dag_level: 2,
120
124
  tenantId: factTenant,
125
+ scope: factScope,
121
126
  });
122
127
  // Schema v25: cache descendant_count + earliest/latest_at on the summary
123
128
  // row so DAG-aware recall (docs/plans/2026-05-05-dag-recall.md Task 2)
@@ -299,11 +304,14 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
299
304
  const byTenant = new Map();
300
305
  for (const l2 of unparented) {
301
306
  const tid = l2.tenantId ?? 'default';
302
- const list = byTenant.get(tid) ?? [];
307
+ const key = derivationPartitionKey(tid, l2.scope);
308
+ const list = byTenant.get(key) ?? [];
303
309
  list.push(l2);
304
- byTenant.set(tid, list);
310
+ byTenant.set(key, list);
305
311
  }
306
- for (const [tenantId, tenantL2s] of byTenant) {
312
+ for (const [, tenantL2s] of byTenant) {
313
+ const tenantId = tenantL2s[0].tenantId ?? 'default';
314
+ const scope = derivationScope(tenantL2s[0].scope);
307
315
  const clusters = clusterFacts(tenantL2s);
308
316
  const eligible = clusters.filter((c) => c.members.length >= 2);
309
317
  result.candidateClusters += eligible.length;
@@ -321,6 +329,7 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
321
329
  confidence: 'inferred',
322
330
  dag_level: 3,
323
331
  tenantId, // HIGH #1 fold: thread tenant explicitly
332
+ scope,
324
333
  });
325
334
  profileEntry.descendant_count = cluster.members.length;
326
335
  profileEntry.earliest_at = memberCreatedAts[0];
package/dist/db.d.ts CHANGED
@@ -13,11 +13,18 @@ export interface DatabaseSyncLike {
13
13
  }
14
14
  export declare function getHippoDbPath(hippoRoot: string): string;
15
15
  export declare function getCurrentSchemaVersion(): number;
16
+ /** Thrown by {@link assertBinaryCompatible}; doctor uses it to pick the upgrade fix over a generic permissions fix. */
17
+ export declare class IncompatibleBinaryError extends Error {
18
+ }
16
19
  export declare function openHippoDb(hippoRoot: string): DatabaseSyncLike;
20
+ /** Open an existing store without changing it: no mkdir, WAL switch, migration or mirror cleanup. Throws when hippo.db is missing. */
21
+ export declare function openHippoDbReadOnly(hippoRoot: string): DatabaseSyncLike;
17
22
  export declare function getSchemaVersion(db: DatabaseSyncLike): number;
18
23
  export declare function closeHippoDb(db: DatabaseSyncLike): void;
19
24
  export declare function getMeta(db: DatabaseSyncLike, key: string, fallback?: string): string;
20
25
  export declare function setMeta(db: DatabaseSyncLike, key: string, value: string): void;
26
+ /** Row count of one table; null when the table is missing or unreadable, which callers show as unknown. */
27
+ export declare function countTableRows(db: DatabaseSyncLike, table: string): number | null;
21
28
  export declare function isFtsAvailable(db: DatabaseSyncLike): boolean;
22
29
  export declare function pruneConsolidationRuns(db: DatabaseSyncLike, keep?: number): void;
23
30
  export {};