hippo-memory 1.66.0 → 1.68.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 (60) hide show
  1. package/README.md +2 -0
  2. package/dist/api/auth.d.ts +16 -11
  3. package/dist/api/auth.js +117 -61
  4. package/dist/api/types.d.ts +9 -0
  5. package/dist/auth.d.ts +22 -1
  6. package/dist/auth.js +20 -8
  7. package/dist/capture/compact.d.ts +2 -0
  8. package/dist/capture/compact.js +12 -6
  9. package/dist/capture/copilot-transcript.d.ts +15 -0
  10. package/dist/capture/copilot-transcript.js +40 -0
  11. package/dist/capture/failure-reading.js +5 -2
  12. package/dist/capture/transcript.js +7 -1
  13. package/dist/capture/working-state.js +14 -3
  14. package/dist/capture-contract.d.ts +4 -2
  15. package/dist/capture-contract.js +3 -3
  16. package/dist/cli/context.js +11 -8
  17. package/dist/cli/hook-blocks.d.ts +2 -0
  18. package/dist/cli/hook-blocks.js +6 -0
  19. package/dist/cli/session-hooks.d.ts +1 -1
  20. package/dist/cli/session-hooks.js +28 -16
  21. package/dist/cli/setup.js +66 -7
  22. package/dist/cli/shared.d.ts +5 -0
  23. package/dist/cli/shared.js +22 -3
  24. package/dist/cli/usage.js +20 -9
  25. package/dist/db/sqlite-blocked.js +1 -1
  26. package/dist/delivery-recorder.d.ts +3 -1
  27. package/dist/delivery-recorder.js +1 -1
  28. package/dist/embeddings.d.ts +10 -10
  29. package/dist/embeddings.js +103 -18
  30. package/dist/hooks/copilot.d.ts +45 -0
  31. package/dist/hooks/copilot.js +187 -0
  32. package/dist/hooks/json-hooks.d.ts +1 -1
  33. package/dist/hooks/json-hooks.js +41 -2
  34. package/dist/hooks/shared.d.ts +5 -1
  35. package/dist/hooks/shared.js +9 -0
  36. package/dist/mcp/request.d.ts +0 -2
  37. package/dist/mcp/request.js +16 -11
  38. package/dist/prompt-hook.d.ts +6 -0
  39. package/dist/prompt-hook.js +15 -7
  40. package/dist/search/vector.js +2 -5
  41. package/dist/server/routes/admin.js +3 -3
  42. package/dist/server/types.d.ts +5 -4
  43. package/dist/server.d.ts +11 -8
  44. package/dist/server.js +20 -19
  45. package/dist/stdin.d.ts +5 -0
  46. package/dist/stdin.js +62 -0
  47. package/dist/store/key-audit.d.ts +6 -0
  48. package/dist/store/key-audit.js +25 -0
  49. package/dist/store/key-writes.d.ts +9 -0
  50. package/dist/store/key-writes.js +30 -0
  51. package/dist/store/vector-writes.d.ts +6 -0
  52. package/dist/store/vector-writes.js +59 -0
  53. package/dist/store-port.d.ts +101 -9
  54. package/dist/store-port.js +53 -2
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  58. package/extensions/openclaw-plugin/package.json +1 -1
  59. package/openclaw.plugin.json +1 -1
  60. package/package.json +1 -1
package/README.md CHANGED
@@ -844,6 +844,8 @@ 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).
848
+
847
849
  ### What the hook adds (Claude Code example)
848
850
 
849
851
  ````markdown
@@ -1,6 +1,6 @@
1
1
  import { type ApiKeyListItem, type ApiKeyListRow } from '../auth.js';
2
2
  import type { KeysetPosition } from '../keyset.js';
3
- import type { Context } from './types.js';
3
+ import type { Context, StoreReply } from './types.js';
4
4
  export interface AuthCreateOpts {
5
5
  label?: string;
6
6
  /**
@@ -29,7 +29,7 @@ export interface AuthCreateResult {
29
29
  * Only an admin actor can mint (ForbiddenError otherwise), and a key never
30
30
  * outranks its minter: a resolver admin is tenant-only, so it mints members.
31
31
  */
32
- export declare function authCreate(ctx: Context, opts: AuthCreateOpts): AuthCreateResult;
32
+ export declare function authCreate<C extends Context>(ctx: C, opts: AuthCreateOpts): StoreReply<C, AuthCreateResult>;
33
33
  export interface AuthCreateSelfOpts {
34
34
  label?: string;
35
35
  /** Days until the new key expires. */
@@ -41,8 +41,9 @@ export interface AuthCreateSelfResult extends AuthCreateResult {
41
41
  /** ISO time the key stops working. */
42
42
  expiresAt: string;
43
43
  }
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
- 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>;
46
47
  /**
47
48
  * List API keys visible to the calling tenant.
48
49
  *
@@ -51,22 +52,25 @@ export declare function authCreateSelf(ctx: Context, opts: AuthCreateSelfOpts):
51
52
  * scoped because future multi-tenant deployments will share a hippoRoot, and
52
53
  * tenant A must not see tenant B's keys. Read-only, so no audit emit.
53
54
  */
54
- export declare function authList(ctx: Context, opts: {
55
+ export declare function authList<C extends Context>(ctx: C, opts: {
55
56
  active: boolean;
56
- }): ApiKeyListItem[];
57
- /** One page of the caller's tenant's keys, newest first, with the row ids a next-page cursor is built from. */
58
- export declare function authListRows(ctx: Context, opts: {
57
+ }): StoreReply<C, ApiKeyListItem[]>;
58
+ type KeyListOpts = {
59
59
  active: boolean;
60
60
  limit?: number;
61
61
  after?: KeysetPosition;
62
- }): 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[]>;
63
66
  export interface AuthRevokeResult {
64
67
  ok: true;
65
68
  revokedAt: string;
66
69
  }
70
+ export type AuthRevokeReply<C extends Context> = StoreReply<C, AuthRevokeResult>;
67
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.
68
- * The auth_revoke row carries the KEY ROW's tenant, as cmdAuthRevoke does, and is skipped for an already-revoked key. */
69
- export declare function authRevoke(ctx: Context, keyId: string): AuthRevokeResult;
72
+ * With `ctx.store`, its keyAudit group revokes and writes the auth_revoke row; hippo.db is opened only when there is no store. */
73
+ export declare function authRevoke<C extends Context>(ctx: C, keyId: string): AuthRevokeReply<C>;
70
74
  /**
71
75
  * The tenant that owns `keyId`, or undefined for an unknown key. Host admin only: it reads across tenants,
72
76
  * so the local CLI can run revoke and grant in the key's own tenant.
@@ -80,4 +84,5 @@ export interface AuthGrantResult {
80
84
  export declare function authGrant(ctx: Context, keyId: string, scope: string): AuthGrantResult;
81
85
  /** Revoke `keyId`'s grant on `scope`. Same authorization and lookup rules as authGrant. */
82
86
  export declare function authUngrant(ctx: Context, keyId: string, scope: string): AuthGrantResult;
87
+ export {};
83
88
  //# sourceMappingURL=auth.d.ts.map
package/dist/api/auth.js CHANGED
@@ -2,30 +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, 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
+ import { requireGroup } from '../store-port.js';
9
+ import { createAuditOf, createKeyAt, createSelfKeyAt } from '../store/key-writes.js';
8
10
  import { selectApiKeyOwner } from '../store/tenant-lookup.js';
9
11
  const API_KEY_SUBJECT = 'api_key:';
10
12
  /** The key id an API-key actor's subject names, or null for any other actor. */
11
13
  function keyIdOfSubject(subject) {
12
14
  return subject.startsWith(API_KEY_SUBJECT) ? subject.slice(API_KEY_SUBJECT.length) : null;
13
15
  }
14
- function inTransaction(db, fn) {
15
- db.exec('BEGIN IMMEDIATE');
16
- try {
17
- const out = fn();
18
- db.exec('COMMIT');
19
- return out;
20
- }
21
- catch (err) {
22
- try {
23
- db.exec('ROLLBACK');
24
- }
25
- catch { /* keep the original error */ }
26
- throw err;
27
- }
28
- }
29
16
  /**
30
17
  * Mint a new API key. The new key is ALWAYS bound to `ctx.tenantId`. Callers
31
18
  * cannot override the tenant via the opts bag — a previous `tenantId` field
@@ -38,38 +25,56 @@ function inTransaction(db, fn) {
38
25
  * outranks its minter: a resolver admin is tenant-only, so it mints members.
39
26
  */
40
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) {
41
39
  if (ctx.actor.role !== 'admin') {
42
40
  throw new ForbiddenError('Only an admin key can create API keys');
43
41
  }
44
42
  if (ctx.actor.viaAuthResolver && opts.role === 'admin') {
45
43
  throw new ForbiddenError('A key minted through the auth resolver can only be a member key');
46
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);
47
62
  const db = openHippoDb(ctx.hippoRoot);
48
63
  try {
49
- const role = opts.role ?? (ctx.actor.viaAuthResolver ? 'member' : 'admin');
50
- const mint = () => createApiKey(db, { tenantId: ctx.tenantId, label: opts.label, role });
51
- // The plaintext is NEVER logged; metadata carries label + role, keyId is non-secret.
52
- const audit = (keyId) => appendAuditEvent(db, {
53
- tenantId: ctx.tenantId, actor: ctx.actor.subject, op: 'auth_create', targetId: keyId, metadata: { label: opts.label ?? null, role },
54
- });
55
64
  // A resolver admin's keys are found again only through their audit rows, so the key and its row commit together.
56
65
  if (ctx.actor.viaAuthResolver) {
57
- const result = inTransaction(db, () => {
58
- const minted = mint();
59
- audit(minted.keyId);
60
- return minted;
61
- });
62
- return { keyId: result.keyId, plaintext: result.plaintext, tenantId: ctx.tenantId, role };
66
+ createKeyAt(db, mint);
67
+ return createResult(mint, plaintext);
63
68
  }
64
- const result = mint();
69
+ insertApiKey(db, mint.key);
65
70
  try {
66
- audit(result.keyId);
71
+ appendAuditEvent(db, createAuditOf(mint));
67
72
  }
68
73
  catch (error) {
69
74
  // Audit must not crash a successful mint.
70
- reportAuditWriteFailure('auth_create', String(error), result.keyId);
75
+ reportAuditWriteFailure('auth_create', String(error), mint.key.keyId);
71
76
  }
72
- return { keyId: result.keyId, plaintext: result.plaintext, tenantId: ctx.tenantId, role };
77
+ return createResult(mint, plaintext);
73
78
  }
74
79
  finally {
75
80
  closeHippoDb(db);
@@ -85,34 +90,43 @@ function assertSelfMintOpts({ ttlDays, perSubject }) {
85
90
  throw new RangeError(`authCreateSelf: perSubject must be a whole number of at least 1, got ${String(perSubject)}`);
86
91
  }
87
92
  }
88
- /** 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. */
89
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) {
90
101
  if (!ctx.actor.viaAuthResolver) {
91
102
  throw new ForbiddenError('Only a caller signed in through the auth resolver can mint its own key');
92
103
  }
93
104
  assertSelfMintOpts(opts);
94
- const { tenantId, actor: { subject } } = ctx;
95
- const now = Date.now();
96
- 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);
97
126
  const db = openHippoDb(ctx.hippoRoot);
98
127
  try {
99
- // IMMEDIATE takes the write lock before the count, so two mints for one subject cannot both see room under the cap.
100
- const result = inTransaction(db, () => {
101
- const live = listLiveOwnedKeyIds(db, tenantId, subject, now);
102
- const replaced = live.slice(0, Math.max(0, live.length - opts.perSubject + 1));
103
- const minted = createApiKey(db, { tenantId, label: opts.label, role: 'member', ownerSubject: subject, expiresAt });
104
- for (const keyId of replaced) {
105
- revokeApiKey(db, keyId);
106
- appendAuditEvent(db, { tenantId, actor: subject, op: 'auth_revoke', targetId: keyId, metadata: { replacedBy: minted.keyId } });
107
- }
108
- // Same op and actor as an admin mint, so a lookup by audit row finds this key too.
109
- appendAuditEvent(db, {
110
- tenantId, actor: subject, op: 'auth_create', targetId: minted.keyId,
111
- metadata: { label: opts.label ?? null, role: 'member', self: true, expiresAt },
112
- });
113
- return minted;
114
- });
115
- return { keyId: result.keyId, plaintext: result.plaintext, tenantId, role: 'member', expiresAt };
128
+ createSelfKeyAt(db, mint);
129
+ return selfResult(mint, plaintext);
116
130
  }
117
131
  finally {
118
132
  closeHippoDb(db);
@@ -127,30 +141,50 @@ export function authCreateSelf(ctx, opts) {
127
141
  * tenant A must not see tenant B's keys. Read-only, so no audit emit.
128
142
  */
129
143
  export function authList(ctx, opts) {
130
- 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));
131
148
  }
132
- /** 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. */
133
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) {
134
165
  const db = openHippoDb(ctx.hippoRoot);
135
166
  try {
136
- const filter = memberListFilter(db, ctx);
167
+ const keyId = memberKeyIdOf(ctx.actor);
168
+ const filter = memberListFilter(ctx.actor, keyId === null ? undefined : selectApiKeyOwner(db, keyId)?.ownerSubject);
137
169
  return filter === null ? [] : listApiKeyRows(db, { ...opts, tenantId: ctx.tenantId, ...filter });
138
170
  }
139
171
  finally {
140
172
  closeHippoDb(db);
141
173
  }
142
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
+ }
143
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. */
144
- function memberListFilter(db, ctx) {
145
- const { actor } = ctx;
180
+ function memberListFilter(actor, owner) {
146
181
  if (actor.role === 'admin')
147
182
  return {};
148
183
  if (actor.viaAuthResolver)
149
184
  return { ownerSubject: actor.subject };
150
- const keyId = keyIdOfSubject(actor.subject);
185
+ const keyId = memberKeyIdOf(actor);
151
186
  if (keyId === null)
152
187
  return null;
153
- const owner = selectApiKeyOwner(db, keyId)?.ownerSubject;
154
188
  return owner ? { ownerSubject: owner } : { keyId };
155
189
  }
156
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. */
@@ -168,11 +202,33 @@ function assertMayRevoke(ctx, keyId, row) {
168
202
  }
169
203
  }
170
204
  /** Revoke a key in the caller's tenant: a member API key may revoke only itself, a resolver member only the keys it minted.
171
- * The auth_revoke row carries the KEY ROW's tenant, as cmdAuthRevoke does, and is skipped for an already-revoked key. */
205
+ * With `ctx.store`, its keyAudit group revokes and writes the auth_revoke row; hippo.db is opened only when there is no store. */
172
206
  export function authRevoke(ctx, keyId) {
207
+ const reply = ctx.store ? revokeThroughStore(ctx, ctx.store, keyId) : revokeOnHippoDb(ctx, keyId);
208
+ // SAFETY: a C typed with a store gets the promise its path returns; a C whose type hides a runtime store (a Pick of
209
+ // Context) is typed as the union, which a caller has to await anyway.
210
+ return reply;
211
+ }
212
+ function assertMemberKeyRevokesSelf(ctx, keyId) {
173
213
  if (ctx.actor.role !== 'admin' && !ctx.actor.viaAuthResolver && keyIdOfSubject(ctx.actor.subject) !== keyId) {
174
214
  throw new ForbiddenError('A member key can revoke only itself');
175
215
  }
216
+ }
217
+ function keyOwnerOf(record) {
218
+ return record ? { tenantId: record.tenantId, revokedAt: record.revokedAt, role: record.role, ownerSubject: record.ownerSubject ?? null } : undefined;
219
+ }
220
+ /** The store keeps no handle on this process's verified-key cache, so the key leaves it here once the revoke commits. */
221
+ async function revokeThroughStore(ctx, store, keyId) {
222
+ const keyAudit = requireGroup(store, 'keyAudit');
223
+ assertMemberKeyRevokesSelf(ctx, keyId);
224
+ assertMayRevoke(ctx, keyId, keyOwnerOf(await store.findApiKey(keyId)));
225
+ const revokedAt = await keyAudit.revokeApiKey({ tenantId: ctx.tenantId, keyId, actor: ctx.actor.subject, at: new Date().toISOString() });
226
+ forgetVerifiedKey(keyId);
227
+ return { ok: true, revokedAt };
228
+ }
229
+ /** The auth_revoke row carries the KEY ROW's tenant, as cmdAuthRevoke does, and is skipped for an already-revoked key. */
230
+ function revokeOnHippoDb(ctx, keyId) {
231
+ assertMemberKeyRevokesSelf(ctx, keyId);
176
232
  const db = openHippoDb(ctx.hippoRoot);
177
233
  try {
178
234
  const row = selectApiKeyOwner(db, keyId);
@@ -25,6 +25,15 @@ export interface Context {
25
25
  actor: Actor;
26
26
  store?: HippoStore;
27
27
  }
28
+ export type HippoDbContext = Context & {
29
+ store?: undefined;
30
+ };
31
+ export type StoreReply<C extends Context, R> = C extends {
32
+ readonly store: HippoStore;
33
+ } ? Promise<R> : C extends {
34
+ readonly hippoRoot: string;
35
+ readonly store?: undefined;
36
+ } ? R : R | Promise<R>;
28
37
  /**
29
38
  * Helper for building process-local Actor values (admin and host admin),
30
39
  * used by CLI and CLI-run connector Context constructors so the role
package/dist/auth.d.ts CHANGED
@@ -23,6 +23,25 @@ export interface CreateApiKeyResult {
23
23
  keyId: string;
24
24
  plaintext: string;
25
25
  }
26
+ /** A fresh key: the plaintext its caller sees once, and the scrypt hash a store keeps in its place. */
27
+ export interface MintedApiKey extends CreateApiKeyResult {
28
+ keyHash: string;
29
+ }
30
+ export declare function mintApiKey(): MintedApiKey;
31
+ /** One api_keys row as core hands it to a store: the hash of the secret, never the secret. */
32
+ export interface NewApiKey {
33
+ readonly keyId: string;
34
+ readonly keyHash: string;
35
+ readonly tenantId: string;
36
+ readonly label: string | null;
37
+ readonly role: 'admin' | 'member';
38
+ readonly createdAt: string;
39
+ /** The auth-resolver subject a self-service key belongs to; null for a key an admin or the CLI mints. */
40
+ readonly ownerSubject: string | null;
41
+ /** ISO time the key stops working; null means it never expires. */
42
+ readonly expiresAt: string | null;
43
+ }
44
+ export declare function insertApiKey(db: DatabaseSyncLike, key: NewApiKey): void;
26
45
  export declare function createApiKey(db: DatabaseSyncLike, opts: CreateApiKeyOpts): CreateApiKeyResult;
27
46
  export interface ValidateResult {
28
47
  valid: boolean;
@@ -84,7 +103,9 @@ export declare class VerifiedKeyCache {
84
103
  }
85
104
  /** Verify a bearer API key against `store`, served at `hippoRoot`; a cache hit skips both scrypt and the store. Null when invalid. A token whose cache entry lapsed re-reads its record without scrypt; a throw from `beforeScrypt` refuses before scrypt runs. */
86
105
  export declare function verifyApiKeyCached(hippoRoot: string, plaintext: string, store: HippoStore, beforeScrypt?: () => void): Promise<VerifiedApiKey | null>;
87
- export declare function revokeApiKey(db: DatabaseSyncLike, keyId: string): void;
106
+ export declare function revokeApiKey(db: DatabaseSyncLike, keyId: string, at?: string): void;
107
+ /** A store other than hippo.db revokes without this process's cache, so its caller drops the key here once the revoke commits. */
108
+ export declare function forgetVerifiedKey(keyId: string): void;
88
109
  /** Grant `keyId` read access to one restricted `scope`. Idempotent. */
89
110
  export declare function grantScope(db: DatabaseSyncLike, keyId: string, scope: string): void;
90
111
  /** Revoke `keyId`'s grant on `scope`. Not an error when no such grant exists. */
package/dist/auth.js CHANGED
@@ -36,17 +36,25 @@ function verifyKey(plaintext, stored) {
36
36
  const actual = scryptSync(plaintext, salt, expected.length);
37
37
  return expected.length === actual.length && timingSafeEqual(expected, actual);
38
38
  }
39
- export function createApiKey(db, opts) {
39
+ export function mintApiKey() {
40
40
  const keyId = `${API_KEY_PREFIX}${randBase32(ID_LEN)}`;
41
- const secret = randBase32(SECRET_LEN);
42
- const plaintext = `${keyId}.${secret}`;
43
- const hash = hashKey(plaintext);
41
+ const plaintext = `${keyId}.${randBase32(SECRET_LEN)}`;
42
+ return { keyId, plaintext, keyHash: hashKey(plaintext) };
43
+ }
44
+ export function insertApiKey(db, key) {
44
45
  // openHippoDb runs runMigrations synchronously before returning the db handle,
45
46
  // so migration v26 (adds role column) is in place before this INSERT runs.
46
47
  // An older binary ignores expires_at and would honour an expired key, so the store shuts it out before the first one exists.
47
- if (opts.expiresAt !== undefined)
48
+ if (key.expiresAt !== null)
48
49
  raiseMinBinary(db, EXPIRING_KEYS_MIN_BINARY);
49
- db.prepare(`INSERT INTO api_keys (key_id, key_hash, tenant_id, label, created_at, role, owner_subject, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(keyId, hash, opts.tenantId, opts.label ?? null, new Date().toISOString(), opts.role ?? 'admin', opts.ownerSubject ?? null, opts.expiresAt ?? null);
50
+ db.prepare(`INSERT INTO api_keys (key_id, key_hash, tenant_id, label, created_at, role, owner_subject, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(key.keyId, key.keyHash, key.tenantId, key.label, key.createdAt, key.role, key.ownerSubject, key.expiresAt);
51
+ }
52
+ export function createApiKey(db, opts) {
53
+ const { keyId, plaintext, keyHash } = mintApiKey();
54
+ insertApiKey(db, {
55
+ keyId, keyHash, tenantId: opts.tenantId, label: opts.label ?? null, role: opts.role ?? 'admin', createdAt: new Date().toISOString(),
56
+ ownerSubject: opts.ownerSubject ?? null, expiresAt: opts.expiresAt ?? null,
57
+ });
50
58
  return { keyId, plaintext };
51
59
  }
52
60
  /** The api_keys row for `keyId` with its scope grants; null when no row matches. */
@@ -187,9 +195,13 @@ export async function verifyApiKeyCached(hippoRoot, plaintext, store, beforeScry
187
195
  verifiedKeys.set(hippoRoot, keyId, plaintext, found, Date.now());
188
196
  return found?.key ?? null;
189
197
  }
190
- export function revokeApiKey(db, keyId) {
198
+ export function revokeApiKey(db, keyId, at = new Date().toISOString()) {
191
199
  db.prepare(`UPDATE api_keys SET revoked_at = ? WHERE key_id = ? AND revoked_at IS NULL`)
192
- .run(new Date().toISOString(), keyId);
200
+ .run(at, keyId);
201
+ verifiedKeys.delete(keyId);
202
+ }
203
+ /** A store other than hippo.db revokes without this process's cache, so its caller drops the key here once the revoke commits. */
204
+ export function forgetVerifiedKey(keyId) {
193
205
  verifiedKeys.delete(keyId);
194
206
  }
195
207
  /** Grant `keyId` read access to one restricted `scope`. Idempotent. */
@@ -1,3 +1,4 @@
1
+ import { type HookRuntime } from '../capture-contract.js';
1
2
  /**
2
3
  * Log-forgery guard: messages here interpolate payload-controlled values
3
4
  * (transcript paths, session ids). Strip C0 control chars — newlines above
@@ -11,6 +12,7 @@ export interface PreCompactOptions {
11
12
  stdinText?: string;
12
13
  stdinTimedOut?: boolean;
13
14
  logFile?: string;
15
+ runtime?: HookRuntime;
14
16
  }
15
17
  /**
16
18
  * PreCompact hook entry point. Exit code 2 on PreCompact BLOCKS compaction,
@@ -59,7 +59,8 @@ function printPreCompactInstruction(logFile) {
59
59
  }
60
60
  }
61
61
  /** Runs the PreCompact producer: records the compaction, asks the summariser for memories, saves a working-state snapshot. Never extracts memories itself; SessionEnd capture owns that. */
62
- function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile) {
62
+ function runPreCompact(hippoRoot, options, logFile) {
63
+ const { stdinText, stdinTimedOut = false, runtime = 'claude-code' } = options;
63
64
  // The PreCompact hook fires in every Claude Code project, including
64
65
  // ones that never ran `hippo init`, so gate before any store-opening call
65
66
  // (saveActiveTaskSnapshot etc. call initStore, which would create one).
@@ -67,19 +68,20 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile) {
67
68
  appendPreCompactLog(logFile, 'skip: store not initialized');
68
69
  return;
69
70
  }
70
- const receipt = readClaudeCodePreCompact(stdinText, stdinTimedOut);
71
+ const receipt = readClaudeCodePreCompact(stdinText, stdinTimedOut, runtime);
71
72
  if (receipt.status !== 'received') {
72
73
  appendPreCompactLog(logFile, `skip: ${receipt.reason}`);
73
74
  return;
74
75
  }
75
76
  const { sessionId, transcriptPath: payloadTranscriptPath, cwd: payloadCwd, trigger: payloadTrigger } = receipt.input;
76
77
  // The record is the "something saved before every compaction", so it lands even when no snapshot is derivable below.
78
+ // Copilot has no PostCompact hook to close a record, so its compactions get the snapshot alone.
77
79
  let recordId = null;
78
- if (sessionId !== null && sessionId !== '') {
80
+ if (runtime === 'claude-code' && sessionId !== null && sessionId !== '') {
79
81
  recordId = recordCompactionStart(hippoRoot, { sessionId, trigger: payloadTrigger, cwd: payloadCwd, transcriptPath: payloadTranscriptPath }, (message) => appendPreCompactLog(logFile, message));
80
82
  printPreCompactInstruction(logFile);
81
83
  }
82
- const transcriptPath = resolvePreCompactTranscript(payloadTranscriptPath, stdinText, logFile);
84
+ const transcriptPath = resolvePreCompactTranscript(payloadTranscriptPath, stdinText, logFile, runtime);
83
85
  if (!transcriptPath)
84
86
  return;
85
87
  // Nothing derivable skips the write, so a user-authored active snapshot is never clobbered with junk.
@@ -89,7 +91,7 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile) {
89
91
  saveDerivedSnapshot(hippoRoot, logFile, sessionId, recordId, derived);
90
92
  }
91
93
  /** The transcript to snapshot, or null after logging why there is none. */
92
- function resolvePreCompactTranscript(payloadTranscriptPath, stdinText, logFile) {
94
+ function resolvePreCompactTranscript(payloadTranscriptPath, stdinText, logFile, runtime) {
93
95
  // A payload transcript_path is EXCLUSIVE: auto-discovery would snapshot a DIFFERENT session's
94
96
  // transcript under THIS payload's session_id, so it runs only on a manual invocation (no payload).
95
97
  let transcriptPath;
@@ -102,6 +104,10 @@ function resolvePreCompactTranscript(payloadTranscriptPath, stdinText, logFile)
102
104
  return null;
103
105
  }
104
106
  }
107
+ else if (runtime === 'copilot') {
108
+ // The scan only knows Claude Code's folders, so a manual Copilot run would snapshot a Claude session.
109
+ transcriptPath = null;
110
+ }
105
111
  else {
106
112
  transcriptPath = resolveLastSessionTranscript(undefined, stdinText, { mayScan: true });
107
113
  }
@@ -147,7 +153,7 @@ function saveDerivedSnapshot(hippoRoot, logFile, sessionId, recordId, derived) {
147
153
  export async function cmdPreCompact(hippoRoot, options) {
148
154
  const logFile = options.logFile ?? defaultPreCompactLogPath();
149
155
  try {
150
- runPreCompact(hippoRoot, options.stdinText, options.stdinTimedOut ?? false, logFile);
156
+ runPreCompact(hippoRoot, options, logFile);
151
157
  }
152
158
  catch (err) {
153
159
  appendPreCompactLog(logFile, `pre-compact failed: ${errorMessage(err)}`);
@@ -0,0 +1,15 @@
1
+ /** The envelope fields a Copilot event-log line is read by; any may be absent. */
2
+ export interface CopilotEventLine {
3
+ type?: unknown;
4
+ data?: unknown;
5
+ agentId?: unknown;
6
+ }
7
+ export interface CopilotTurn {
8
+ role: 'user' | 'assistant';
9
+ text: string;
10
+ }
11
+ /** The Copilot CLI log for a session, `<copilotHome>/session-state/<id>/events.jsonl`; null for an unsafe id or a missing file. */
12
+ export declare function copilotTranscriptFor(sessionId: string): string | null;
13
+ /** The turn one Copilot event-log line carries: the text of a human user.message or of an assistant.message; null for any other line, a sub-agent's included. */
14
+ export declare function copilotTurn(line: CopilotEventLine): CopilotTurn | null;
15
+ //# sourceMappingURL=copilot-transcript.d.ts.map
@@ -0,0 +1,40 @@
1
+ // The Copilot event log: one `{type, data, id, timestamp, parentId}` object per line, written by the Copilot CLI and VS Code Local alike.
2
+ // Matched line by line, since the tail readers start mid-file and never see the session.start line.
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { copilotHomeDir } from '../hooks/shared.js';
6
+ import { isObjectLike, isStringValue } from '../capture-contract.js';
7
+ // A session id becomes a folder name, so anything beyond word characters and hyphens could climb out of session-state.
8
+ const SESSION_ID_RE = /^[\w-]+$/;
9
+ // user.message sources that mark text hippo did not get from the human: a hidden skill injection or another agent's prompt.
10
+ const INJECTED_SOURCE_PREFIXES = ['skill-', 'agent-'];
11
+ /** The Copilot CLI log for a session, `<copilotHome>/session-state/<id>/events.jsonl`; null for an unsafe id or a missing file. */
12
+ export function copilotTranscriptFor(sessionId) {
13
+ if (!SESSION_ID_RE.test(sessionId))
14
+ return null;
15
+ const file = path.join(copilotHomeDir(), 'session-state', sessionId, 'events.jsonl');
16
+ return fs.existsSync(file) ? file : null;
17
+ }
18
+ function isSubagentLine(line, data) {
19
+ if (isStringValue(line.agentId) && line.agentId !== '')
20
+ return true;
21
+ return isStringValue(data.parentToolCallId) && data.parentToolCallId !== '';
22
+ }
23
+ function isInjectedUserMessage(data) {
24
+ if (data.isAutopilotContinuation === true)
25
+ return true;
26
+ const source = data.source;
27
+ return isStringValue(source) && INJECTED_SOURCE_PREFIXES.some((prefix) => source.startsWith(prefix));
28
+ }
29
+ /** The turn one Copilot event-log line carries: the text of a human user.message or of an assistant.message; null for any other line, a sub-agent's included. */
30
+ export function copilotTurn(line) {
31
+ const role = line.type === 'user.message' ? 'user' : line.type === 'assistant.message' ? 'assistant' : null;
32
+ const data = isObjectLike(line.data) ? line.data : null;
33
+ if (role === null || data === null || isSubagentLine(line, data))
34
+ return null;
35
+ if (role === 'user' && isInjectedUserMessage(data))
36
+ return null;
37
+ const text = isStringValue(data.content) ? data.content.trim() : '';
38
+ return text ? { role, text } : null;
39
+ }
40
+ //# sourceMappingURL=copilot-transcript.js.map
@@ -14,6 +14,9 @@ const LEADING_CD = /^\s*(?:(?:cd|pushd)\b[^;&|]*(?:&&|\|\||;)\s*)+/;
14
14
  const NO_MATCH = /\bno (?:matches|files|results) found\b/i;
15
15
  /** Shell commands whose exit code 1 means "nothing found" or "differs", not an error. */
16
16
  const QUIET_EXIT_1 = /^\s*(?:grep|rg|egrep|fgrep|find|test|\[|diff|cmp|git diff|git grep)\b/;
17
+ // Claude Code's tool names, then the Copilot CLI's and VS Code's; tests/copilot-failure-reading.test.ts cites where each comes from.
18
+ const SEARCH_TOOLS = new Set(['Grep', 'Glob', 'grep', 'glob', 'rg', 'grep_search', 'file_search', 'semantic_search']);
19
+ const SHELL_TOOLS = new Set(['Bash', 'bash', 'powershell', 'run_in_terminal']);
17
20
  function isString(v) {
18
21
  return v !== undefined && v !== null && v.constructor === String;
19
22
  }
@@ -58,9 +61,9 @@ export function lessonFromFailure(payload, scrub = (text) => text) {
58
61
  return routine('os-permission');
59
62
  if (NO_MATCH.test(error))
60
63
  return routine('no-match');
61
- if (tool === 'Grep' || tool === 'Glob')
64
+ if (SEARCH_TOOLS.has(tool))
62
65
  return routine('search-tool');
63
- if (tool === 'Bash' && QUIET_EXIT_1.test(command) && /exit code 1\b/i.test(error))
66
+ if (SHELL_TOOLS.has(tool) && QUIET_EXIT_1.test(command) && /exit code 1\b/i.test(error))
64
67
  return routine('quiet-exit');
65
68
  return { text, detail };
66
69
  }
@@ -3,8 +3,9 @@ import { claudeConfigDir } from '../hooks/shared.js';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
5
  import { isObjectLike, isStringValue } from '../capture-contract.js';
6
+ import { copilotTurn } from './copilot-transcript.js';
6
7
  /**
7
- * Build a compact text summary from a Claude Code / OpenCode JSONL transcript.
8
+ * Build a compact text summary from a Claude Code / OpenCode / Copilot JSONL transcript.
8
9
  * Keeps plain user messages and the final chunk of assistant text, drops
9
10
  * thinking blocks, tool_use, and tool_result noise. Capture reads the same
10
11
  * turns through `sessionTail`, one text per turn.
@@ -53,6 +54,11 @@ export function collectSessionTurns(jsonl, visit) {
53
54
  if (!isObjectLike(entry) || !('type' in entry))
54
55
  continue;
55
56
  visit?.(entry);
57
+ const copilot = copilotTurn(entry);
58
+ if (copilot !== null) {
59
+ turns.push(copilot);
60
+ continue;
61
+ }
56
62
  if (entry.type === 'user' || entry.type === 'assistant') {
57
63
  const message = 'message' in entry && isObjectLike(entry.message) ? entry.message : undefined;
58
64
  if (!message)