hippo-memory 1.67.0 → 1.69.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.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-memories/copilot.d.ts +3 -1
  3. package/dist/agent-memories/copilot.js +2 -2
  4. package/dist/api/auth.d.ts +14 -18
  5. package/dist/api/auth.js +93 -60
  6. package/dist/api/context-select.d.ts +10 -0
  7. package/dist/api/context-select.js +16 -18
  8. package/dist/api/context.js +101 -60
  9. package/dist/api/forget.d.ts +2 -2
  10. package/dist/api/forget.js +11 -0
  11. package/dist/api/outcome.d.ts +4 -4
  12. package/dist/api/outcome.js +29 -6
  13. package/dist/api/promote.d.ts +3 -3
  14. package/dist/api/promote.js +45 -90
  15. package/dist/api/remember.d.ts +3 -2
  16. package/dist/api/remember.js +38 -11
  17. package/dist/api/types.d.ts +9 -0
  18. package/dist/auth.d.ts +19 -0
  19. package/dist/auth.js +14 -6
  20. package/dist/capture/compact.js +21 -2
  21. package/dist/capture/copilot-transcript.d.ts +3 -0
  22. package/dist/capture/copilot-transcript.js +8 -1
  23. package/dist/capture/session-worker.d.ts +13 -0
  24. package/dist/capture/session-worker.js +194 -0
  25. package/dist/capture-contract.d.ts +3 -1
  26. package/dist/capture-contract.js +36 -0
  27. package/dist/cli/session-hooks.js +90 -22
  28. package/dist/cli/setup.js +70 -24
  29. package/dist/cli/usage.js +4 -1
  30. package/dist/cli.js +1 -1
  31. package/dist/connectors/github/ingest.js +2 -1
  32. package/dist/connectors/slack/deletion.js +2 -1
  33. package/dist/connectors/slack/ingest.js +2 -1
  34. package/dist/embeddings.d.ts +10 -10
  35. package/dist/embeddings.js +103 -18
  36. package/dist/env.d.ts +2 -0
  37. package/dist/env.js +2 -0
  38. package/dist/hooks/copilot.d.ts +50 -8
  39. package/dist/hooks/copilot.js +116 -30
  40. package/dist/hooks/json-hooks.d.ts +2 -0
  41. package/dist/hooks/json-hooks.js +11 -2
  42. package/dist/hooks/shared.d.ts +7 -1
  43. package/dist/hooks/shared.js +18 -3
  44. package/dist/mcp/memory-tools.d.ts +2 -2
  45. package/dist/mcp/memory-tools.js +9 -8
  46. package/dist/mcp/request.js +3 -3
  47. package/dist/memory.d.ts +2 -0
  48. package/dist/memory.js +7 -0
  49. package/dist/prompt-recall.d.ts +5 -0
  50. package/dist/prompt-recall.js +13 -0
  51. package/dist/raw-archive.d.ts +2 -2
  52. package/dist/raw-archive.js +5 -3
  53. package/dist/recall-scope.d.ts +2 -5
  54. package/dist/recall-scope.js +5 -6
  55. package/dist/server/routes/admin.js +2 -2
  56. package/dist/server/routes/memories.js +9 -6
  57. package/dist/server-detect.js +3 -2
  58. package/dist/server.d.ts +20 -4
  59. package/dist/server.js +23 -11
  60. package/dist/store/audit-event.d.ts +1 -1
  61. package/dist/store/audit-event.js +1 -1
  62. package/dist/store/entry-writes-group.d.ts +9 -0
  63. package/dist/store/entry-writes-group.js +157 -0
  64. package/dist/store/handoffs.d.ts +5 -2
  65. package/dist/store/handoffs.js +35 -5
  66. package/dist/store/key-writes.d.ts +9 -0
  67. package/dist/store/key-writes.js +30 -0
  68. package/dist/store/open.d.ts +2 -0
  69. package/dist/store/open.js +10 -0
  70. package/dist/store/search-rows.js +3 -9
  71. package/dist/store/sessions.d.ts +5 -0
  72. package/dist/store/sessions.js +4 -1
  73. package/dist/store/vector-writes.d.ts +6 -0
  74. package/dist/store/vector-writes.js +59 -0
  75. package/dist/store-port.d.ts +143 -1
  76. package/dist/store-port.js +48 -12
  77. package/dist/version.d.ts +1 -1
  78. package/dist/version.js +1 -1
  79. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  80. package/extensions/openclaw-plugin/package.json +1 -1
  81. package/openclaw.plugin.json +1 -1
  82. package/package.json +1 -1
package/README.md CHANGED
@@ -844,7 +844,7 @@ For Codex, it adds two hooks to `$CODEX_HOME/hooks.json` (else `~/.codex/hooks.j
844
844
 
845
845
  **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.
846
846
 
847
- For GitHub Copilot, once `$COPILOT_HOME` (else `~/.copilot`) exists, `hippo setup` writes hippo's own `hooks/hippo.json`, adds a `hippo` server to `mcp-config.json` and puts a block in `copilot-instructions.md` that asks the agent to call `hippo_recall` and `hippo_remember`. The Copilot CLI, including its sessions inside VS Code, uses all four hooks, the server and the block. VS Code's Local agent runs only the session-start hook and reads neither of the other files, so it gets no pre-compact snapshot or end-of-session capture yet, and its recall tools need hippo added to VS Code's own `mcp.json`. The hooks, that `mcp.json` recipe and how to remove it all: [integrations/copilot.md](integrations/copilot.md).
847
+ For GitHub Copilot, when `$COPILOT_HOME` (else `~/.copilot`) or a VS Code User folder exists, `hippo setup` writes hippo's own `hooks/hippo.json`. When the Copilot folder was there before setup, it also adds a `hippo` server to `mcp-config.json` and a block to `copilot-instructions.md`. The block asks the agent to call `hippo_recall` and `hippo_remember`. The Copilot CLI, including its sessions inside VS Code, uses the session-start, tool-failure, pre-compact and session-end hooks, the server and the block. VS Code's agent mode (the Local agent, VS Code 1.109.3 or later) reads the same hooks file. It gets session start, the pre-compact snapshot and a capture after every reply. Setup also adds hippo to VS Code's own `mcp.json` and writes `prompts/hippo.instructions.md`, so its recall tools and instructions work too. The hooks, the VS Code files and how to remove them: [integrations/copilot.md](integrations/copilot.md).
848
848
 
849
849
  ### What the hook adds (Claude Code example)
850
850
 
@@ -1,3 +1,5 @@
1
- import type { Adapter } from './types.js';
1
+ import type { Adapter, AdapterContext } from './types.js';
2
+ /** VS Code's user-data resolution: portable install first, then the app-data override, then the platform default. */
3
+ export declare function vscodeDataFolders(ctx: Pick<AdapterContext, 'env' | 'home' | 'platform'>): string[];
2
4
  export declare const copilotAdapter: Adapter;
3
5
  //# sourceMappingURL=copilot.d.ts.map
@@ -7,7 +7,7 @@ import { gitLayout } from './git.js';
7
7
  const PRODUCTS = ['Code', 'Code - Insiders'];
8
8
  const MEMORY_TOOL = ['github.copilot-chat', 'memory-tool', 'memories'];
9
9
  /** VS Code's user-data resolution: portable install first, then the app-data override, then the platform default. */
10
- function dataFolders(ctx) {
10
+ export function vscodeDataFolders(ctx) {
11
11
  const { env, home, platform } = ctx;
12
12
  if (env.VSCODE_PORTABLE)
13
13
  return [path.join(env.VSCODE_PORTABLE, 'user-data')];
@@ -103,7 +103,7 @@ function projectContainers(data, ctx, matches, warnings) {
103
103
  export const copilotAdapter = {
104
104
  tool: 'copilot',
105
105
  list(ctx, scope) {
106
- const data = dataFolders(ctx);
106
+ const data = vscodeDataFolders(ctx);
107
107
  const warnings = [];
108
108
  const found = [];
109
109
  if (scope === 'user') {
@@ -1,7 +1,6 @@
1
1
  import { type ApiKeyListItem, type ApiKeyListRow } from '../auth.js';
2
2
  import type { KeysetPosition } from '../keyset.js';
3
- import { type HippoStore } from '../store-port.js';
4
- import type { Context } from './types.js';
3
+ import type { Context, StoreReply } from './types.js';
5
4
  export interface AuthCreateOpts {
6
5
  label?: string;
7
6
  /**
@@ -30,7 +29,7 @@ export interface AuthCreateResult {
30
29
  * Only an admin actor can mint (ForbiddenError otherwise), and a key never
31
30
  * outranks its minter: a resolver admin is tenant-only, so it mints members.
32
31
  */
33
- export declare function authCreate(ctx: Context, opts: AuthCreateOpts): AuthCreateResult;
32
+ export declare function authCreate<C extends Context>(ctx: C, opts: AuthCreateOpts): StoreReply<C, AuthCreateResult>;
34
33
  export interface AuthCreateSelfOpts {
35
34
  label?: string;
36
35
  /** Days until the new key expires. */
@@ -42,8 +41,9 @@ export interface AuthCreateSelfResult extends AuthCreateResult {
42
41
  /** ISO time the key stops working. */
43
42
  expiresAt: string;
44
43
  }
45
- /** Mint a member key for the caller an auth resolver vouched for, whatever its role; the binary floor, the cap's revokes, the mint and its audit rows commit or fail together. */
46
- export declare function authCreateSelf(ctx: Context, opts: AuthCreateSelfOpts): AuthCreateSelfResult;
44
+ /** Mint a member key for the caller an auth resolver vouched for, whatever its role; the binary floor, the cap's revokes, the mint and its audit rows commit or fail together.
45
+ * With `ctx.store`, its keyWrites group runs that transaction; hippo.db is opened only when there is no store. */
46
+ export declare function authCreateSelf<C extends Context>(ctx: C, opts: AuthCreateSelfOpts): StoreReply<C, AuthCreateSelfResult>;
47
47
  /**
48
48
  * List API keys visible to the calling tenant.
49
49
  *
@@ -52,27 +52,22 @@ export declare function authCreateSelf(ctx: Context, opts: AuthCreateSelfOpts):
52
52
  * scoped because future multi-tenant deployments will share a hippoRoot, and
53
53
  * tenant A must not see tenant B's keys. Read-only, so no audit emit.
54
54
  */
55
- export declare function authList(ctx: Context, opts: {
55
+ export declare function authList<C extends Context>(ctx: C, opts: {
56
56
  active: boolean;
57
- }): ApiKeyListItem[];
58
- /** One page of the caller's tenant's keys, newest first, with the row ids a next-page cursor is built from. */
59
- export declare function authListRows(ctx: Context, opts: {
57
+ }): StoreReply<C, ApiKeyListItem[]>;
58
+ type KeyListOpts = {
60
59
  active: boolean;
61
60
  limit?: number;
62
61
  after?: KeysetPosition;
63
- }): ApiKeyListRow[];
62
+ };
63
+ /** One page of the caller's tenant's keys, newest first, with the row ids a next-page cursor is built from.
64
+ * With `ctx.store`, its keyWrites group reads them; hippo.db is opened only when there is no store. */
65
+ export declare function authListRows<C extends Context>(ctx: C, opts: KeyListOpts): StoreReply<C, ApiKeyListRow[]>;
64
66
  export interface AuthRevokeResult {
65
67
  ok: true;
66
68
  revokedAt: string;
67
69
  }
68
- /** A promise when `ctx` carries a store, since every store method is async; today's plain result when it carries none.
69
- * hippoRoot keeps the second test off TypeScript's weak-type rule, which would fail a ctx that has no store key at all. */
70
- export type AuthRevokeReply<C extends Context> = C extends {
71
- readonly store: HippoStore;
72
- } ? Promise<AuthRevokeResult> : C extends {
73
- readonly hippoRoot: string;
74
- readonly store?: undefined;
75
- } ? AuthRevokeResult : AuthRevokeResult | Promise<AuthRevokeResult>;
70
+ export type AuthRevokeReply<C extends Context> = StoreReply<C, AuthRevokeResult>;
76
71
  /** Revoke a key in the caller's tenant: a member API key may revoke only itself, a resolver member only the keys it minted.
77
72
  * With `ctx.store`, its keyAudit group revokes and writes the auth_revoke row; hippo.db is opened only when there is no store. */
78
73
  export declare function authRevoke<C extends Context>(ctx: C, keyId: string): AuthRevokeReply<C>;
@@ -89,4 +84,5 @@ export interface AuthGrantResult {
89
84
  export declare function authGrant(ctx: Context, keyId: string, scope: string): AuthGrantResult;
90
85
  /** Revoke `keyId`'s grant on `scope`. Same authorization and lookup rules as authGrant. */
91
86
  export declare function authUngrant(ctx: Context, keyId: string, scope: string): AuthGrantResult;
87
+ export {};
92
88
  //# sourceMappingURL=auth.d.ts.map
package/dist/api/auth.js CHANGED
@@ -2,31 +2,17 @@
2
2
  import { openHippoDb, closeHippoDb } from '../db.js';
3
3
  import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '../api-errors.js';
4
4
  import { appendAuditEvent, reportAuditWriteFailure } from '../audit.js';
5
- import { createApiKey, forgetVerifiedKey, listApiKeyRows, listLiveOwnedKeyIds, revokeApiKey, grantScope, ungrantScope, } from '../auth.js';
5
+ import { forgetVerifiedKey, insertApiKey, listApiKeyRows, mintApiKey, revokeApiKey, grantScope, ungrantScope, } from '../auth.js';
6
6
  import { DAY_MS } from '../dashboard-snapshot.js';
7
7
  import { isRestrictedScope } from '../recall-scope.js';
8
8
  import { requireGroup } from '../store-port.js';
9
+ import { createAuditOf, createKeyAt, createSelfKeyAt } from '../store/key-writes.js';
9
10
  import { selectApiKeyOwner } from '../store/tenant-lookup.js';
10
11
  const API_KEY_SUBJECT = 'api_key:';
11
12
  /** The key id an API-key actor's subject names, or null for any other actor. */
12
13
  function keyIdOfSubject(subject) {
13
14
  return subject.startsWith(API_KEY_SUBJECT) ? subject.slice(API_KEY_SUBJECT.length) : null;
14
15
  }
15
- function inTransaction(db, fn) {
16
- db.exec('BEGIN IMMEDIATE');
17
- try {
18
- const out = fn();
19
- db.exec('COMMIT');
20
- return out;
21
- }
22
- catch (err) {
23
- try {
24
- db.exec('ROLLBACK');
25
- }
26
- catch { /* keep the original error */ }
27
- throw err;
28
- }
29
- }
30
16
  /**
31
17
  * Mint a new API key. The new key is ALWAYS bound to `ctx.tenantId`. Callers
32
18
  * cannot override the tenant via the opts bag — a previous `tenantId` field
@@ -39,38 +25,56 @@ function inTransaction(db, fn) {
39
25
  * outranks its minter: a resolver admin is tenant-only, so it mints members.
40
26
  */
41
27
  export function authCreate(ctx, opts) {
28
+ // With a store, its keyWrites group stores the key and its auth_create row together, and hippo.db is never opened.
29
+ const reply = ctx.store ? createThroughStore(ctx, ctx.store, opts) : createOnHippoDb(ctx, opts);
30
+ // SAFETY: as in authRevoke, a C typed with a store gets the promise its path returns and a C that hides one gets the union.
31
+ return reply;
32
+ }
33
+ /** A key row in the caller's tenant, made here so only its hash ever reaches a store. */
34
+ function newKey(ctx, fields) {
35
+ const { keyId, plaintext, keyHash } = mintApiKey();
36
+ return { plaintext, key: { ...fields, keyId, keyHash, tenantId: ctx.tenantId, createdAt: new Date().toISOString() } };
37
+ }
38
+ function adminKeyMint(ctx, opts) {
42
39
  if (ctx.actor.role !== 'admin') {
43
40
  throw new ForbiddenError('Only an admin key can create API keys');
44
41
  }
45
42
  if (ctx.actor.viaAuthResolver && opts.role === 'admin') {
46
43
  throw new ForbiddenError('A key minted through the auth resolver can only be a member key');
47
44
  }
45
+ const role = opts.role ?? (ctx.actor.viaAuthResolver ? 'member' : 'admin');
46
+ const label = opts.label ?? null;
47
+ const { plaintext, key } = newKey(ctx, { label, role, ownerSubject: null, expiresAt: null });
48
+ // The plaintext is NEVER logged; metadata carries label + role, keyId is non-secret.
49
+ return { plaintext, mint: { key, actor: ctx.actor.subject, metadata: { label, role } } };
50
+ }
51
+ function createResult({ key }, plaintext) {
52
+ return { keyId: key.keyId, plaintext, tenantId: key.tenantId, role: key.role };
53
+ }
54
+ async function createThroughStore(ctx, store, opts) {
55
+ const keyWrites = requireGroup(store, 'keyWrites');
56
+ const { plaintext, mint } = adminKeyMint(ctx, opts);
57
+ await keyWrites.createApiKey(mint);
58
+ return createResult(mint, plaintext);
59
+ }
60
+ function createOnHippoDb(ctx, opts) {
61
+ const { plaintext, mint } = adminKeyMint(ctx, opts);
48
62
  const db = openHippoDb(ctx.hippoRoot);
49
63
  try {
50
- const role = opts.role ?? (ctx.actor.viaAuthResolver ? 'member' : 'admin');
51
- const mint = () => createApiKey(db, { tenantId: ctx.tenantId, label: opts.label, role });
52
- // The plaintext is NEVER logged; metadata carries label + role, keyId is non-secret.
53
- const audit = (keyId) => appendAuditEvent(db, {
54
- tenantId: ctx.tenantId, actor: ctx.actor.subject, op: 'auth_create', targetId: keyId, metadata: { label: opts.label ?? null, role },
55
- });
56
64
  // A resolver admin's keys are found again only through their audit rows, so the key and its row commit together.
57
65
  if (ctx.actor.viaAuthResolver) {
58
- const result = inTransaction(db, () => {
59
- const minted = mint();
60
- audit(minted.keyId);
61
- return minted;
62
- });
63
- return { keyId: result.keyId, plaintext: result.plaintext, tenantId: ctx.tenantId, role };
66
+ createKeyAt(db, mint);
67
+ return createResult(mint, plaintext);
64
68
  }
65
- const result = mint();
69
+ insertApiKey(db, mint.key);
66
70
  try {
67
- audit(result.keyId);
71
+ appendAuditEvent(db, createAuditOf(mint));
68
72
  }
69
73
  catch (error) {
70
74
  // Audit must not crash a successful mint.
71
- reportAuditWriteFailure('auth_create', String(error), result.keyId);
75
+ reportAuditWriteFailure('auth_create', String(error), mint.key.keyId);
72
76
  }
73
- return { keyId: result.keyId, plaintext: result.plaintext, tenantId: ctx.tenantId, role };
77
+ return createResult(mint, plaintext);
74
78
  }
75
79
  finally {
76
80
  closeHippoDb(db);
@@ -86,34 +90,43 @@ function assertSelfMintOpts({ ttlDays, perSubject }) {
86
90
  throw new RangeError(`authCreateSelf: perSubject must be a whole number of at least 1, got ${String(perSubject)}`);
87
91
  }
88
92
  }
89
- /** Mint a member key for the caller an auth resolver vouched for, whatever its role; the binary floor, the cap's revokes, the mint and its audit rows commit or fail together. */
93
+ /** Mint a member key for the caller an auth resolver vouched for, whatever its role; the binary floor, the cap's revokes, the mint and its audit rows commit or fail together.
94
+ * With `ctx.store`, its keyWrites group runs that transaction; hippo.db is opened only when there is no store. */
90
95
  export function authCreateSelf(ctx, opts) {
96
+ const reply = ctx.store ? createSelfThroughStore(ctx, ctx.store, opts) : createSelfOnHippoDb(ctx, opts);
97
+ // SAFETY: as in authRevoke, a C typed with a store gets the promise its path returns and a C that hides one gets the union.
98
+ return reply;
99
+ }
100
+ function selfKeyMint(ctx, opts) {
91
101
  if (!ctx.actor.viaAuthResolver) {
92
102
  throw new ForbiddenError('Only a caller signed in through the auth resolver can mint its own key');
93
103
  }
94
104
  assertSelfMintOpts(opts);
95
- const { tenantId, actor: { subject } } = ctx;
96
- const now = Date.now();
97
- const expiresAt = new Date(now + opts.ttlDays * DAY_MS).toISOString();
105
+ const { subject } = ctx.actor;
106
+ const label = opts.label ?? null;
107
+ const expiresAt = new Date(Date.now() + opts.ttlDays * DAY_MS).toISOString();
108
+ // Not destructured: a binding pattern makes TypeScript infer F as KeyFields and lose ownerSubject's string type.
109
+ const made = newKey(ctx, { label, role: 'member', ownerSubject: subject, expiresAt });
110
+ // Same op and actor as an admin mint, so a lookup by audit row finds this key too.
111
+ return { plaintext: made.plaintext, mint: { key: made.key, actor: subject, metadata: { label, role: 'member', self: true, expiresAt }, perSubject: opts.perSubject } };
112
+ }
113
+ function selfResult({ key }, plaintext) {
114
+ return { keyId: key.keyId, plaintext, tenantId: key.tenantId, role: 'member', expiresAt: key.expiresAt };
115
+ }
116
+ /** The store revokes the replaced keys without this process's verified-key cache, so they leave it here once the mint commits. */
117
+ async function createSelfThroughStore(ctx, store, opts) {
118
+ const keyWrites = requireGroup(store, 'keyWrites');
119
+ const { plaintext, mint } = selfKeyMint(ctx, opts);
120
+ for (const keyId of await keyWrites.createSelfApiKey(mint))
121
+ forgetVerifiedKey(keyId);
122
+ return selfResult(mint, plaintext);
123
+ }
124
+ function createSelfOnHippoDb(ctx, opts) {
125
+ const { plaintext, mint } = selfKeyMint(ctx, opts);
98
126
  const db = openHippoDb(ctx.hippoRoot);
99
127
  try {
100
- // IMMEDIATE takes the write lock before the count, so two mints for one subject cannot both see room under the cap.
101
- const result = inTransaction(db, () => {
102
- const live = listLiveOwnedKeyIds(db, tenantId, subject, now);
103
- const replaced = live.slice(0, Math.max(0, live.length - opts.perSubject + 1));
104
- const minted = createApiKey(db, { tenantId, label: opts.label, role: 'member', ownerSubject: subject, expiresAt });
105
- for (const keyId of replaced) {
106
- revokeApiKey(db, keyId);
107
- appendAuditEvent(db, { tenantId, actor: subject, op: 'auth_revoke', targetId: keyId, metadata: { replacedBy: minted.keyId } });
108
- }
109
- // Same op and actor as an admin mint, so a lookup by audit row finds this key too.
110
- appendAuditEvent(db, {
111
- tenantId, actor: subject, op: 'auth_create', targetId: minted.keyId,
112
- metadata: { label: opts.label ?? null, role: 'member', self: true, expiresAt },
113
- });
114
- return minted;
115
- });
116
- return { keyId: result.keyId, plaintext: result.plaintext, tenantId, role: 'member', expiresAt };
128
+ createSelfKeyAt(db, mint);
129
+ return selfResult(mint, plaintext);
117
130
  }
118
131
  finally {
119
132
  closeHippoDb(db);
@@ -128,30 +141,50 @@ export function authCreateSelf(ctx, opts) {
128
141
  * tenant A must not see tenant B's keys. Read-only, so no audit emit.
129
142
  */
130
143
  export function authList(ctx, opts) {
131
- return authListRows(ctx, opts).map((r) => r.key);
144
+ const keysOf = (rows) => rows.map((r) => r.key);
145
+ const rows = listRows(ctx, opts);
146
+ // SAFETY: the promise comes back exactly when ctx has a store, as StoreReply says.
147
+ return (rows instanceof Promise ? rows.then(keysOf) : keysOf(rows));
132
148
  }
133
- /** One page of the caller's tenant's keys, newest first, with the row ids a next-page cursor is built from. */
149
+ /** One page of the caller's tenant's keys, newest first, with the row ids a next-page cursor is built from.
150
+ * With `ctx.store`, its keyWrites group reads them; hippo.db is opened only when there is no store. */
134
151
  export function authListRows(ctx, opts) {
152
+ // SAFETY: the promise comes back exactly when ctx has a store, as StoreReply says.
153
+ return listRows(ctx, opts);
154
+ }
155
+ function listRows(ctx, opts) {
156
+ return ctx.store ? listThroughStore(ctx, ctx.store, opts) : listOnHippoDb(ctx, opts);
157
+ }
158
+ async function listThroughStore(ctx, store, opts) {
159
+ const keyWrites = requireGroup(store, 'keyWrites');
160
+ const keyId = memberKeyIdOf(ctx.actor);
161
+ const filter = memberListFilter(ctx.actor, keyId === null ? undefined : (await store.findApiKey(keyId))?.ownerSubject);
162
+ return filter === null ? [] : keyWrites.listApiKeys({ ...opts, tenantId: ctx.tenantId, ...filter });
163
+ }
164
+ function listOnHippoDb(ctx, opts) {
135
165
  const db = openHippoDb(ctx.hippoRoot);
136
166
  try {
137
- const filter = memberListFilter(db, ctx);
167
+ const keyId = memberKeyIdOf(ctx.actor);
168
+ const filter = memberListFilter(ctx.actor, keyId === null ? undefined : selectApiKeyOwner(db, keyId)?.ownerSubject);
138
169
  return filter === null ? [] : listApiKeyRows(db, { ...opts, tenantId: ctx.tenantId, ...filter });
139
170
  }
140
171
  finally {
141
172
  closeHippoDb(db);
142
173
  }
143
174
  }
175
+ /** The key id of a member API key caller, whose owner decides what it lists; null for any other caller. */
176
+ function memberKeyIdOf(actor) {
177
+ return actor.role === 'admin' || actor.viaAuthResolver ? null : keyIdOfSubject(actor.subject);
178
+ }
144
179
  /** A member sees only the keys its person minted, or just its own key when that has no owner; an admin sees the whole tenant; null means no keys. */
145
- function memberListFilter(db, ctx) {
146
- const { actor } = ctx;
180
+ function memberListFilter(actor, owner) {
147
181
  if (actor.role === 'admin')
148
182
  return {};
149
183
  if (actor.viaAuthResolver)
150
184
  return { ownerSubject: actor.subject };
151
- const keyId = keyIdOfSubject(actor.subject);
185
+ const keyId = memberKeyIdOf(actor);
152
186
  if (keyId === null)
153
187
  return null;
154
- const owner = selectApiKeyOwner(db, keyId)?.ownerSubject;
155
188
  return owner ? { ownerSubject: owner } : { keyId };
156
189
  }
157
190
  /** Throws unless `ctx` may revoke `keyId`; a member gets one answer for a missing, foreign or unowned key, so it cannot probe key ids. */
@@ -1,12 +1,22 @@
1
1
  import type { AmbientLoadResult } from '../store/candidates.js';
2
2
  import { type MemoryEntry } from '../memory.js';
3
+ import type { ContextReads, HippoStore } from '../store-port.js';
3
4
  import { type HippoConfig } from '../config.js';
4
5
  import type { ProjectRef } from '../project-identity.js';
5
6
  import type { DeliveryObserver } from '../delivery-recorder.js';
6
7
  import type { ContextCost, ContextOpts, ContextResultEntry } from './context-types.js';
7
8
  import type { Context } from './types.js';
9
+ /** One store getContext reads rows from: its base search and its context reads. */
10
+ export interface ContextSource {
11
+ readonly store: HippoStore;
12
+ readonly reads: ContextReads;
13
+ }
8
14
  /** What getContext resolved from opts and config before it read a row. */
9
15
  export interface ContextPlan {
16
+ /** The served store, else hippo.db under the root. */
17
+ local: ContextSource;
18
+ /** The served store when it is not hippo.db; then every read and write goes through it and no hippo.db opens. */
19
+ other: HippoStore | undefined;
10
20
  pinnedOnly: boolean;
11
21
  limit: number;
12
22
  includeRecent: number;
@@ -241,7 +241,7 @@ export async function selectBySearch(ctx, plan, left, pools, admission) {
241
241
  const results = plan.hasGlobal && !plan.primaryIsGlobal
242
242
  ? await searchBothStores(ctx, plan, left, minResults, pools, admission.bothStoresAdmit)
243
243
  : await searchLocalRows(ctx, plan, left, minResults, pools.local.entries, admission.admit);
244
- auditContextRecall(ctx, plan, results.length);
244
+ await auditContextRecall(ctx, plan, results.length);
245
245
  return results;
246
246
  }
247
247
  // The pools were admitted at load, before ranking, dedupe and budget: a post-filter would let an excluded row fill the
@@ -273,6 +273,7 @@ async function searchLocalRows(ctx, plan, left, minResults, localEntries, admit)
273
273
  const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
274
274
  const localCost = cost && ((r) => price(r.entry, primaryIsGlobal));
275
275
  const vectorCandidates = contextVectorSpec(ctx, plan, admit);
276
+ const store = plan.local.store;
276
277
  const ctxResults = usePhysicsCtx
277
278
  ? await physicsSearch(query, localEntries, {
278
279
  budget: left,
@@ -282,6 +283,7 @@ async function searchLocalRows(ctx, plan, left, minResults, localEntries, admit)
282
283
  physicsConfig: ctxConfig.physics,
283
284
  scope: plan.activeScope,
284
285
  vectorCandidates,
286
+ store,
285
287
  })
286
288
  : await hybridSearch(query, localEntries, {
287
289
  budget: left,
@@ -290,6 +292,7 @@ async function searchLocalRows(ctx, plan, left, minResults, localEntries, admit)
290
292
  hippoRoot: ctx.hippoRoot,
291
293
  scope: plan.activeScope,
292
294
  vectorCandidates,
295
+ store,
293
296
  });
294
297
  return ctxResults.map((r) => ({
295
298
  entry: r.entry,
@@ -299,21 +302,21 @@ async function searchLocalRows(ctx, plan, left, minResults, localEntries, admit)
299
302
  }));
300
303
  }
301
304
  // Same 'recall' op api.recall emits; the pinned-only and no-query branches never search, so they never emit.
302
- function auditContextRecall(ctx, plan, resultCount) {
303
- const ctxRecallMetadata = {
304
- ...auditQueryFields(plan.query),
305
- results: resultCount,
306
- mode: 'context',
305
+ async function auditContextRecall(ctx, plan, resultCount) {
306
+ const row = {
307
+ tenantId: ctx.tenantId,
308
+ actor: ctx.actor.subject,
309
+ op: 'recall',
310
+ metadata: { ...auditQueryFields(plan.query), results: resultCount, mode: 'context' },
307
311
  };
312
+ if (plan.other) {
313
+ await plan.other.appendAuditEvents([row]);
314
+ return;
315
+ }
308
316
  if (plan.hasLocal) {
309
317
  const localDb = openHippoDb(ctx.hippoRoot);
310
318
  try {
311
- appendAuditEvent(localDb, {
312
- tenantId: ctx.tenantId,
313
- actor: ctx.actor.subject,
314
- op: 'recall',
315
- metadata: ctxRecallMetadata,
316
- });
319
+ appendAuditEvent(localDb, row);
317
320
  }
318
321
  finally {
319
322
  closeHippoDb(localDb);
@@ -322,12 +325,7 @@ function auditContextRecall(ctx, plan, resultCount) {
322
325
  if (plan.hasGlobal && !plan.primaryIsGlobal) {
323
326
  const globalDb = openHippoDb(plan.globalRoot);
324
327
  try {
325
- appendAuditEvent(globalDb, {
326
- tenantId: ctx.tenantId,
327
- actor: ctx.actor.subject,
328
- op: 'recall',
329
- metadata: ctxRecallMetadata,
330
- });
328
+ appendAuditEvent(globalDb, row);
331
329
  }
332
330
  finally {
333
331
  closeHippoDb(globalDb);