hippo-memory 1.67.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.
@@ -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. */
@@ -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;
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. */
@@ -4,8 +4,10 @@
4
4
  * Falls back silently if the library is not installed.
5
5
  */
6
6
  import { MemoryEntry } from './memory.js';
7
+ import { type DatabaseSyncLike } from './db.js';
7
8
  import { EMBEDDING_MODEL_META_KEY } from './vector-store.js';
8
9
  import { type EmbeddingProvider } from './embedding-provider.js';
10
+ import type { HippoStore } from './store-port.js';
9
11
  export { EMBEDDING_MODEL_META_KEY };
10
12
  /**
11
13
  * Bump whenever `embeddingInputText`'s composition changes in a way that
@@ -73,10 +75,13 @@ export interface EmbeddingIndexState {
73
75
  export declare function indexedModel(state: EmbeddingIndexState): string | null;
74
76
  /** The one rebuild rule every store applies to its indexed model. */
75
77
  export declare function indexNeedsRebuild(indexed: string | null, providerId: string): boolean;
78
+ /** Whether a vector write under the index identity `model` first drops the stored index, which another model built. */
79
+ export declare function replacesIndex(state: EmbeddingIndexState, model: string): boolean;
76
80
  export declare function resolveIndexedEmbeddingModel(hippoRoot: string, index?: Record<string, number[]>): string | null;
77
81
  export declare function embeddingModelRequiresReindex(hippoRoot: string, model: string, index?: Record<string, number[]>): boolean;
78
82
  /** hippo.db's index state, the meta row and the EXISTS on one handle. */
79
83
  export declare function embeddingIndexStateAt(hippoRoot: string): EmbeddingIndexState;
84
+ export declare function embeddingIndexStateOn(db: DatabaseSyncLike): EmbeddingIndexState;
80
85
  /**
81
86
  * Cosine similarity between two vectors. Handles unnormalized vectors.
82
87
  * Returns 0 for empty or mismatched vectors.
@@ -88,14 +93,9 @@ export declare function loadEmbeddingIndex(hippoRoot: string): Record<string, nu
88
93
  export declare function loadStoredVectors(hippoRoot: string, ids: readonly string[]): Map<string, number[]>;
89
94
  /** Replace every stored vector with `index`; `model` defaults to the stored index identity. */
90
95
  export declare function saveEmbeddingIndex(hippoRoot: string, index: Record<string, number[]>, model?: string): void;
91
- /**
92
- * Embed a single memory entry and cache the result in the embedding index.
93
- */
94
- export declare function embedMemory(hippoRoot: string, entry: MemoryEntry, model?: string): Promise<void>;
95
- /**
96
- * Embed all entries in hippoRoot that don't already have cached vectors.
97
- * Prunes orphaned embeddings for memories that no longer exist.
98
- * Returns the count of newly embedded entries. `provider` defaults to the store's configured one.
99
- */
100
- export declare function embedAll(hippoRoot: string, model?: string, provider?: EmbeddingProvider): Promise<number>;
96
+ /** Embed a single memory entry and cache the result in the embedding index; with `store`, through its vectorWrites, never opening hippo.db. */
97
+ export declare function embedMemory(hippoRoot: string, entry: MemoryEntry, model?: string, store?: HippoStore): Promise<void>;
98
+ /** Embeds every entry with no cached vector, prunes vectors of deleted memories, and returns how many it embedded; `provider` defaults to the
99
+ * configured one. With `store`, every tenant's memories with no vector for the provider's model go through its `vectorWrites`, never hippo.db. */
100
+ export declare function embedAll(hippoRoot: string, model?: string, provider?: EmbeddingProvider, store?: HippoStore): Promise<number>;
101
101
  //# sourceMappingURL=embeddings.d.ts.map
@@ -16,6 +16,7 @@ import { resolveEmbeddingProvider } from './embedding-provider.js';
16
16
  import { DEFAULT_EMBEDDING_MODEL } from './local-embedding.js';
17
17
  import { redactSecretsStrict } from './secret-detect.js';
18
18
  import { log } from './log.js';
19
+ import { StoreNotPortedError } from './db/sqlite-blocked.js';
19
20
  export { EMBEDDING_MODEL_META_KEY };
20
21
  /**
21
22
  * Bump whenever `embeddingInputText`'s composition changes in a way that
@@ -109,6 +110,11 @@ export function indexedModel(state) {
109
110
  export function indexNeedsRebuild(indexed, providerId) {
110
111
  return indexed !== null && indexed !== embeddingIndexIdentity(providerId);
111
112
  }
113
+ /** Whether a vector write under the index identity `model` first drops the stored index, which another model built. */
114
+ export function replacesIndex(state, model) {
115
+ const indexed = indexedModel(state);
116
+ return indexed !== null && indexed !== model;
117
+ }
112
118
  export function resolveIndexedEmbeddingModel(hippoRoot, index) {
113
119
  const storedModel = loadStoredEmbeddingModel(hippoRoot);
114
120
  if (storedModel)
@@ -120,10 +126,13 @@ export function embeddingModelRequiresReindex(hippoRoot, model, index) {
120
126
  }
121
127
  /** hippo.db's index state, the meta row and the EXISTS on one handle. */
122
128
  export function embeddingIndexStateAt(hippoRoot) {
123
- return withVectorDb(hippoRoot, (db) => ({
129
+ return withVectorDb(hippoRoot, embeddingIndexStateOn);
130
+ }
131
+ export function embeddingIndexStateOn(db) {
132
+ return {
124
133
  storedModel: getMeta(db, EMBEDDING_MODEL_META_KEY, '').trim() || null,
125
134
  hasVectors: hasStoredVectors(db),
126
- }));
135
+ };
127
136
  }
128
137
  async function rebuildEmbeddingIndex(entries, provider) {
129
138
  const rebuilt = {};
@@ -273,12 +282,7 @@ async function acquireEmbedFileLock(hippoRoot) {
273
282
  }
274
283
  }
275
284
  async function withEmbedLock(hippoRoot, fn) {
276
- let resolve;
277
- const next = new Promise(r => { resolve = r; });
278
- const prev = _embedWriteLock;
279
- _embedWriteLock = next;
280
- await prev;
281
- try {
285
+ return withProcessEmbedLock(async () => {
282
286
  const release = await acquireEmbedFileLock(hippoRoot);
283
287
  try {
284
288
  return await fn();
@@ -286,6 +290,17 @@ async function withEmbedLock(hippoRoot, fn) {
286
290
  finally {
287
291
  release();
288
292
  }
293
+ });
294
+ }
295
+ // A store keeps each write whole in its own transaction, and a lock file in hippoRoot would not reach a worker on another host.
296
+ async function withProcessEmbedLock(fn) {
297
+ let resolve;
298
+ const next = new Promise(r => { resolve = r; });
299
+ const prev = _embedWriteLock;
300
+ _embedWriteLock = next;
301
+ await prev;
302
+ try {
303
+ return await fn();
289
304
  }
290
305
  finally {
291
306
  resolve();
@@ -301,10 +316,79 @@ function warnEmbedFailureOnce(source, rawMessage) {
301
316
  const message = redactSecretsStrict(rawMessage).replace(/\s+/g, ' ').replace(/\.+$/, '');
302
317
  log.warn(`embedding failed (${source}): ${message}. Memories are stored without embeddings until this is fixed.`);
303
318
  }
304
- /**
305
- * Embed a single memory entry and cache the result in the embedding index.
306
- */
307
- export async function embedMemory(hippoRoot, entry, model) {
319
+ // store-port.ts imports this module, so its requireGroup would close an import cycle.
320
+ function vectorGroups(store) {
321
+ if (store.vectors === undefined)
322
+ throw new StoreNotPortedError(store.kind, 'vectors');
323
+ if (store.vectorWrites === undefined)
324
+ throw new StoreNotPortedError(store.kind, 'vectorWrites');
325
+ return [store.vectors, store.vectorWrites];
326
+ }
327
+ /** Writes one page's vectors, one writeVectors per tenant, and stops at the first refused write; `withParticles` seeds each memory's particle. */
328
+ async function writeVectorPage(writes, write, page, vectors, withParticles) {
329
+ const byTenant = new Map();
330
+ page.forEach((entry, i) => {
331
+ const vector = vectors[i];
332
+ if (!vector || vector.length === 0)
333
+ return noteSkippedEmbedding(entry.id);
334
+ const row = withParticles ? { memoryId: entry.id, vector, particle: initializeParticle(entry, vector) } : { memoryId: entry.id, vector };
335
+ byTenant.set(entry.tenantId, [...(byTenant.get(entry.tenantId) ?? []), row]);
336
+ });
337
+ let written = 0;
338
+ for (const [tenantId, rows] of byTenant) {
339
+ const result = await writes.writeVectors({ ...write, tenantId, rows });
340
+ if (result.modelMismatch)
341
+ return { written, modelMismatch: true };
342
+ written += result.written;
343
+ }
344
+ return { written, modelMismatch: false };
345
+ }
346
+ const STORE_PAGE = 64;
347
+ const OTHER_MODEL_INDEX = "the vector index was built by another embedding model; run 'hippo embed' to rebuild it";
348
+ /** Embeds every memory with no vector under `model`, page by page, so a provider failure keeps the pages written; a rebuild reseeds the particles it drops. */
349
+ async function backfillInStore(writes, provider, model, rebuild) {
350
+ let count = 0;
351
+ let afterId;
352
+ for (;;) {
353
+ const page = await writes.entriesWithoutVector({ model, afterId, limit: STORE_PAGE });
354
+ if (page.length === 0)
355
+ return count;
356
+ afterId = page[page.length - 1].id;
357
+ const vectors = await provider.embed(page.map((e) => embeddingInputText(e)), 'passage');
358
+ const result = await writeVectorPage(writes, { model, replaceIndex: rebuild }, page, vectors, rebuild);
359
+ // Another process rebuilt the index mid-run; every later page would be refused too, after paying the provider for it.
360
+ if (result.modelMismatch)
361
+ throw new Error(OTHER_MODEL_INDEX);
362
+ count += result.written;
363
+ }
364
+ }
365
+ /** embedAll on a store: a model change re-embeds every memory, and the first page written drops the old index. */
366
+ async function embedAllInStore(store, provider) {
367
+ const [reads, writes] = vectorGroups(store);
368
+ const rebuild = indexNeedsRebuild(indexedModel(await reads.embeddingIndexState()), provider.id);
369
+ return backfillInStore(writes, provider, embeddingIndexIdentity(provider.id), rebuild);
370
+ }
371
+ /** embedMemory on a store; like the hippo.db path it resolves whatever fails. Unlike it, it leaves a model change to embedAll, so a stale
372
+ * read here never drops another process's rebuild; the read only saves a paid embed call, as the write refuses another model itself. */
373
+ function embedMemoryInStore(store, provider, entry) {
374
+ return withProcessEmbedLock(async () => {
375
+ const [reads, writes] = vectorGroups(store);
376
+ const model = embeddingIndexIdentity(provider.id);
377
+ try {
378
+ const refused = replacesIndex(await reads.embeddingIndexState(), model)
379
+ || (await writeVectorPage(writes, { model, replaceIndex: false }, [entry], await provider.embed([embeddingInputText(entry)], 'passage'), true)).modelMismatch;
380
+ if (refused)
381
+ warnEmbedFailureOnce('index', OTHER_MODEL_INDEX);
382
+ }
383
+ catch (err) {
384
+ warnEmbedFailureOnce(provider.kind, err instanceof Error ? err.message : String(err));
385
+ }
386
+ }).catch((err) => {
387
+ log.warn(`skipped embedding ${entry.id} (${err instanceof Error ? err.message : String(err)})`);
388
+ });
389
+ }
390
+ /** Embed a single memory entry and cache the result in the embedding index; with `store`, through its vectorWrites, never opening hippo.db. */
391
+ export async function embedMemory(hippoRoot, entry, model, store) {
308
392
  let provider;
309
393
  try {
310
394
  provider = resolveEmbeddingProvider(hippoRoot, { model });
@@ -316,6 +400,8 @@ export async function embedMemory(hippoRoot, entry, model) {
316
400
  }
317
401
  if (!provider.isAvailable())
318
402
  return;
403
+ if (store)
404
+ return embedMemoryInStore(store, provider, entry);
319
405
  return withEmbedLock(hippoRoot, async () => {
320
406
  // embedMemory is best-effort: an embedding failure (API down / bad key / 5xx)
321
407
  // must not reject the caller's write — `getEmbedding` historically swallowed
@@ -418,16 +504,15 @@ async function backfillPending(hippoRoot, provider, pending, model) {
418
504
  }
419
505
  return { count, backfillError };
420
506
  }
421
- /**
422
- * Embed all entries in hippoRoot that don't already have cached vectors.
423
- * Prunes orphaned embeddings for memories that no longer exist.
424
- * Returns the count of newly embedded entries. `provider` defaults to the store's configured one.
425
- */
426
- export async function embedAll(hippoRoot, model, provider = resolveEmbeddingProvider(hippoRoot, { model })) {
507
+ /** Embeds every entry with no cached vector, prunes vectors of deleted memories, and returns how many it embedded; `provider` defaults to the
508
+ * configured one. With `store`, every tenant's memories with no vector for the provider's model go through its `vectorWrites`, never hippo.db. */
509
+ export async function embedAll(hippoRoot, model, provider = resolveEmbeddingProvider(hippoRoot, { model }), store) {
427
510
  if (!provider.isAvailable()) {
428
511
  throwIfProviderKeyMissing(hippoRoot, provider);
429
512
  return 0;
430
513
  }
514
+ if (store)
515
+ return withProcessEmbedLock(() => embedAllInStore(store, provider));
431
516
  return withEmbedLock(hippoRoot, async () => {
432
517
  const identity = provider.id;
433
518
  // Host-wide by design. embedAll backfills vectors for all tenants'
@@ -44,7 +44,7 @@ export async function handleCreateAuthKey({ req, res, opts }) {
44
44
  // bound to the caller's authenticated tenant (ctx.tenantId, resolved
45
45
  // from the Bearer token). Forwarding body.tenantId here would let
46
46
  // tenant A mint a key for tenant B — see authCreate doc comment.
47
- const result = authCreate(ctx, {
47
+ const result = await authCreate(ctx, {
48
48
  label: labelRaw,
49
49
  role,
50
50
  });
@@ -68,7 +68,7 @@ export async function handleListAuthKeys({ req, res, opts, query }) {
68
68
  const limit = parseListLimit(query.get('limit'), MAX_AUTH_KEYS_PAGE, MAX_AUTH_KEYS_PAGE);
69
69
  const after = parseCursor(query.get('cursor'), 'integer', 'integer');
70
70
  const ctx = await buildContextWithAuth(req, opts);
71
- const page = pageOf(authListRows(ctx, { active, limit: limit + 1, after }), limit, (r) => ({ key: r.rowId, id: r.rowId }));
71
+ const page = pageOf(await authListRows(ctx, { active, limit: limit + 1, after }), limit, (r) => ({ key: r.rowId, id: r.rowId }));
72
72
  setNextCursorHeader(res, page.nextCursor);
73
73
  sendJson(res, 200, page.items.map((r) => r.key));
74
74
  return;
package/dist/server.d.ts CHANGED
@@ -11,8 +11,10 @@ export { HttpError } from './http-util.js';
11
11
  export { promptHookContext, type CallerProject } from './prompt-hook.js';
12
12
  export type { JsonValue } from './json.js';
13
13
  export { captureSessionTexts, type SessionCaptureRequest, type SessionCaptureResult } from './capture/session-texts.js';
14
- export { hasGroup, sqliteStore, type HippoStore, type KeyAudit, type KeyRevoke, type RecallSearchArgs, type RecallWrites, type StoreGroup, type StoreGroups, type VectorReads, } from './store-port.js';
15
- export type { ApiKeyRecord } from './auth.js';
14
+ export { hasGroup, sqliteStore, type HippoStore, type KeyAudit, type KeyListQuery, type KeyMint, type KeyRevoke, type KeyWrites, type RecallSearchArgs, type RecallWrites, type SelfKeyMint, type StoreGroup, type StoreGroups, type VectorReads, type VectorBackfillQuery, type VectorRowWrite, type VectorWrite, type VectorWriteResult, type VectorWrites, } from './store-port.js';
15
+ export type { HippoDbContext, StoreReply } from './api/types.js';
16
+ export type { ApiKeyListItem, ApiKeyListRow, ApiKeyRecord, ListApiKeysOpts, NewApiKey } from './auth.js';
17
+ export type { KeysetPosition } from './keyset.js';
16
18
  export type { AppendAuditOpts, AuditEvent, ListAuditAfterOpts } from './audit.js';
17
19
  export type { ContinuityBlock } from './api/recall-types.js';
18
20
  export type { ActiveGoals, GetActiveGoalsOpts, Goal, GoalRecallLogRow, RetrievalPolicy } from './goals.js';
@@ -27,8 +29,9 @@ export type { TokenUse } from './token-ledger.js';
27
29
  export type { EmbeddingIndexState } from './embeddings.js';
28
30
  export type { PhysicsParticle } from './physics.js';
29
31
  export { StoreBusyError } from './db.js';
30
- export { decodeVector, EMBEDDING_MODEL_META_KEY, rankVectorRows, type VectorMatch, type VectorRow } from './vector-store.js';
31
- export { bufferToFloat32 } from './physics-state.js';
32
+ export { decodeVector, EMBEDDING_MODEL_META_KEY, encodeVector, rankVectorRows, type VectorMatch, type VectorRow } from './vector-store.js';
33
+ export { bufferToFloat32, float32ToBuffer } from './physics-state.js';
34
+ export { replacesIndex } from './embeddings.js';
32
35
  export { OTHER_STORE_MARKER, OtherStoreFolderError, withSqliteAllowed } from './db.js';
33
36
  export { authCreate, type AuthCreateOpts, type AuthCreateResult } from './api.js';
34
37
  export { isSharedStore } from './config.js';
package/dist/server.js CHANGED
@@ -48,9 +48,11 @@ export { captureSessionTexts } from './capture/session-texts.js';
48
48
  // An add-on serves from another database by passing serve() its own HippoStore.
49
49
  export { hasGroup, sqliteStore, } from './store-port.js';
50
50
  export { StoreBusyError } from './db.js';
51
- // An add-on store decodes and ranks with hippo.db's own code, so both return the same ids in the same order.
52
- export { decodeVector, EMBEDDING_MODEL_META_KEY, rankVectorRows } from './vector-store.js';
53
- export { bufferToFloat32 } from './physics-state.js';
51
+ // An add-on store encodes, decodes and ranks vectors and particles with hippo.db's own code, and drops the index by its rule,
52
+ // so both stores keep the same bytes and return the same ids in the same order.
53
+ export { decodeVector, EMBEDDING_MODEL_META_KEY, encodeVector, rankVectorRows } from './vector-store.js';
54
+ export { bufferToFloat32, float32ToBuffer } from './physics-state.js';
55
+ export { replacesIndex } from './embeddings.js';
54
56
  // store copy --db writes the marker and reads the old hippo.db under the waiver.
55
57
  export { OTHER_STORE_MARKER, OtherStoreFolderError, withSqliteAllowed } from './db.js';
56
58
  // An add-on's install step mints the first admin key into a store folder it names, which `hippo auth create` cannot reach.
@@ -108,8 +110,8 @@ const V1_ROUTES = [
108
110
  { method: 'POST', path: '/v1/outcome', handler: handleApplyOutcome },
109
111
  { method: 'GET', path: '/v1/context', handler: handleGetContext },
110
112
  { method: 'POST', path: '/v1/sleep', handler: handleSleep },
111
- { method: 'POST', path: '/v1/auth/keys', handler: handleCreateAuthKey },
112
- { method: 'GET', path: '/v1/auth/keys', handler: handleListAuthKeys },
113
+ { method: 'POST', path: '/v1/auth/keys', storeReady: 'keyWrites', handler: handleCreateAuthKey },
114
+ { method: 'GET', path: '/v1/auth/keys', storeReady: 'keyWrites', handler: handleListAuthKeys },
113
115
  { method: 'DELETE', pattern: '/v1/auth/keys/:keyId', storeReady: 'keyAudit', handler: handleRevokeAuthKey },
114
116
  { method: 'GET', path: '/v1/quarantine', handler: handleListQuarantine },
115
117
  { method: 'POST', pattern: '/v1/quarantine/:id/approve', handler: handleApproveQuarantine },
@@ -0,0 +1,9 @@
1
+ import { type AppendAuditOpts } from '../audit.js';
2
+ import { type DatabaseSyncLike } from '../db.js';
3
+ import type { KeyMint, SelfKeyMint } from '../store-port.js';
4
+ /** The auth_create row a mint writes; the plaintext never reaches it. */
5
+ export declare function createAuditOf({ key, actor, metadata }: KeyMint): AppendAuditOpts;
6
+ export declare function createKeyAt(db: DatabaseSyncLike, mint: KeyMint): void;
7
+ /** IMMEDIATE takes the write lock before the count, so two mints for one owner cannot both see room under the cap. */
8
+ export declare function createSelfKeyAt(db: DatabaseSyncLike, mint: SelfKeyMint): string[];
9
+ //# sourceMappingURL=key-writes.d.ts.map
@@ -0,0 +1,30 @@
1
+ // hippo.db's half of the KeyWrites store group.
2
+ import { appendAuditEvent } from '../audit.js';
3
+ import { insertApiKey, listLiveOwnedKeyIds, revokeApiKey } from '../auth.js';
4
+ import { withWriteScope } from '../db.js';
5
+ /** The auth_create row a mint writes; the plaintext never reaches it. */
6
+ export function createAuditOf({ key, actor, metadata }) {
7
+ return { tenantId: key.tenantId, actor, op: 'auth_create', targetId: key.keyId, metadata };
8
+ }
9
+ export function createKeyAt(db, mint) {
10
+ withWriteScope(db, 'create_api_key', () => {
11
+ insertApiKey(db, mint.key);
12
+ appendAuditEvent(db, createAuditOf(mint));
13
+ });
14
+ }
15
+ /** IMMEDIATE takes the write lock before the count, so two mints for one owner cannot both see room under the cap. */
16
+ export function createSelfKeyAt(db, mint) {
17
+ const { key, actor, perSubject } = mint;
18
+ return withWriteScope(db, 'create_self_api_key', () => {
19
+ const live = listLiveOwnedKeyIds(db, key.tenantId, key.ownerSubject, Date.parse(key.createdAt));
20
+ const replaced = live.slice(0, Math.max(0, live.length - perSubject + 1));
21
+ insertApiKey(db, key);
22
+ for (const keyId of replaced) {
23
+ revokeApiKey(db, keyId, key.createdAt);
24
+ appendAuditEvent(db, { tenantId: key.tenantId, actor, op: 'auth_revoke', targetId: keyId, metadata: { replacedBy: key.keyId } });
25
+ }
26
+ appendAuditEvent(db, createAuditOf(mint));
27
+ return replaced;
28
+ });
29
+ }
30
+ //# sourceMappingURL=key-writes.js.map
@@ -0,0 +1,6 @@
1
+ import { type DatabaseSyncLike } from '../db.js';
2
+ import type { MemoryEntry } from '../memory.js';
3
+ import type { VectorBackfillQuery, VectorWrite, VectorWriteResult } from '../store-port.js';
4
+ export declare function entriesWithoutVectorAt(db: DatabaseSyncLike, query: VectorBackfillQuery): MemoryEntry[];
5
+ export declare function writeVectorsAt(db: DatabaseSyncLike, write: VectorWrite): VectorWriteResult;
6
+ //# sourceMappingURL=vector-writes.d.ts.map
@@ -0,0 +1,59 @@
1
+ // hippo.db's half of the VectorWrites store group.
2
+ import { setMeta, withWriteScope } from '../db.js';
3
+ import { embeddingIndexStateOn, replacesIndex } from '../embeddings.js';
4
+ import { float32ToBuffer } from '../physics-state.js';
5
+ import { EMBEDDING_MODEL_META_KEY, upsertVectors } from '../vector-store.js';
6
+ import { chunked } from './entry-reads.js';
7
+ import { MEMORY_SELECT_COLUMNS, rowToEntry } from './rows.js';
8
+ const MAX_BACKFILL_PAGE = 500;
9
+ export function entriesWithoutVectorAt(db, query) {
10
+ if (!Number.isInteger(query.limit))
11
+ throw new RangeError('limit must be an integer');
12
+ const limit = Math.max(1, Math.min(query.limit, MAX_BACKFILL_PAGE));
13
+ const tenantClause = query.tenantId === undefined ? '' : ' AND tenant_id = ?';
14
+ const tenantArgs = query.tenantId === undefined ? [] : [query.tenantId];
15
+ // SAFETY: selects exactly MEMORY_SELECT_COLUMNS, matching MemoryRow's field set.
16
+ const rows = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE id > ?${tenantClause}
17
+ AND NOT EXISTS (SELECT 1 FROM memory_vectors v WHERE v.memory_id = memories.id AND v.model = ?)
18
+ ORDER BY id LIMIT ?`).all(query.afterId ?? '', ...tenantArgs, query.model, limit);
19
+ return rows.map(rowToEntry);
20
+ }
21
+ function ownedIds(db, ids, tenantId) {
22
+ const owned = new Set();
23
+ for (const chunk of chunked([...new Set(ids)])) {
24
+ // SAFETY: the SELECT names one column, id.
25
+ const rows = db.prepare(`SELECT id FROM memories WHERE tenant_id = ? AND id IN (${chunk.map(() => '?').join(', ')})`).all(tenantId, ...chunk);
26
+ for (const row of rows)
27
+ owned.add(row.id);
28
+ }
29
+ return owned;
30
+ }
31
+ // savePhysicsState opens its own transaction and overwrites, so the first particle for a memory is inserted here instead.
32
+ function insertNewParticles(db, rows) {
33
+ const stmt = db.prepare(`
34
+ INSERT INTO memory_physics (memory_id, position_blob, velocity_blob, mass, charge, temperature, last_simulation)
35
+ VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(memory_id) DO NOTHING
36
+ `);
37
+ for (const { memoryId, particle: p } of rows) {
38
+ if (p)
39
+ stmt.run(memoryId, float32ToBuffer(p.position), float32ToBuffer(p.velocity), p.mass, p.charge, p.temperature, p.lastSimulation);
40
+ }
41
+ }
42
+ export function writeVectorsAt(db, write) {
43
+ return withWriteScope(db, 'write_vectors', () => {
44
+ const replaces = replacesIndex(embeddingIndexStateOn(db), write.model);
45
+ if (replaces && !write.replaceIndex)
46
+ return { written: 0, modelMismatch: true };
47
+ const owned = ownedIds(db, write.rows.map((r) => r.memoryId), write.tenantId);
48
+ const rows = write.rows.filter((r) => owned.has(r.memoryId) && r.vector.length > 0 && r.vector.every(Number.isFinite));
49
+ if (rows.length === 0)
50
+ return { written: 0, modelMismatch: false };
51
+ if (replaces)
52
+ db.exec('DELETE FROM memory_vectors; DELETE FROM memory_physics;');
53
+ const written = upsertVectors(db, rows.map((r) => [r.memoryId, r.vector]), write.model);
54
+ insertNewParticles(db, rows);
55
+ setMeta(db, EMBEDDING_MODEL_META_KEY, write.model);
56
+ return { written, modelMismatch: false };
57
+ });
58
+ }
59
+ //# sourceMappingURL=vector-writes.js.map
@@ -1,8 +1,9 @@
1
- import { type ApiKeyRecord } from './auth.js';
1
+ import { type ApiKeyListRow, type ApiKeyRecord, type ListApiKeysOpts, type NewApiKey } from './auth.js';
2
2
  import { type AppendAuditOpts, type AuditEvent, type ListAuditAfterOpts } from './audit.js';
3
3
  import type { ContinuityBlock } from './api/recall-types.js';
4
4
  import { type EmbeddingIndexState } from './embeddings.js';
5
5
  import { type ActiveGoals, type GetActiveGoalsOpts, type GoalRecallLogRow } from './goals.js';
6
+ import type { JsonValue } from './json.js';
6
7
  import type { MemoryEntry } from './memory.js';
7
8
  import type { PhysicsParticle } from './physics.js';
8
9
  import { type PlanningFallacyEvidence } from './predictions/planning-fallacy.js';
@@ -48,6 +49,44 @@ export interface VectorReads {
48
49
  /** The particles of `ids` only; an empty list reads nothing. */
49
50
  physicsParticles(ids: readonly string[]): Promise<Map<string, PhysicsParticle>>;
50
51
  }
52
+ /** One memory's vector, computed by the caller's embedding provider. */
53
+ export interface VectorRowWrite {
54
+ readonly memoryId: string;
55
+ readonly vector: readonly number[];
56
+ /** Kept only when the memory has no particle yet, under `memoryId` whatever its own memoryId says. */
57
+ readonly particle?: PhysicsParticle;
58
+ }
59
+ export interface VectorWrite {
60
+ readonly tenantId: string;
61
+ /** The index identity the vectors were built by, `embeddingIndexIdentity(provider.id)`. */
62
+ readonly model: string;
63
+ /** Set only by embedAll's rebuild, so a write that read the index state before another process rebuilt it cannot drop that rebuild. */
64
+ readonly replaceIndex: boolean;
65
+ readonly rows: readonly VectorRowWrite[];
66
+ }
67
+ export interface VectorWriteResult {
68
+ readonly written: number;
69
+ /** True when another model built the index and `replaceIndex` is false; the store then keeps nothing and `written` is 0. */
70
+ readonly modelMismatch: boolean;
71
+ }
72
+ export interface VectorBackfillQuery {
73
+ readonly model: string;
74
+ /** Only ids above this one in byte order; unset starts at the first. */
75
+ readonly afterId?: string;
76
+ /** Clamped to 1..500; a non-integer rejects with RangeError `limit must be an integer`. */
77
+ readonly limit: number;
78
+ /** Every tenant when unset, since the backfill covers the whole store. */
79
+ readonly tenantId?: string;
80
+ }
81
+ /** The writes behind embed on write and the backfill: core computes each vector, the store only keeps it.
82
+ * A store deletes a memory's vector and particle when the memory is deleted (hippo.db does it by trigger and foreign-key cascade). */
83
+ export interface VectorWrites {
84
+ /** Memories of any kind with no vector stored under `query.model`, by id ascending in byte order. */
85
+ entriesWithoutVector(query: VectorBackfillQuery): Promise<MemoryEntry[]>;
86
+ /** In one transaction: refuses with `modelMismatch` if `replacesIndex` and not `replaceIndex`; else, when a row is writable (memory in `tenantId`, vector non-empty
87
+ * and finite), drops every vector and particle if `replacesIndex`, keeps each writable row under `model` with its particle where none exists, sets `model`. No audit row. */
88
+ writeVectors(write: VectorWrite): Promise<VectorWriteResult>;
89
+ }
51
90
  export interface KeyRevoke {
52
91
  readonly tenantId: string;
53
92
  readonly keyId: string;
@@ -63,11 +102,43 @@ export interface KeyAudit {
63
102
  /** The highest audit id ever assigned, pruned rows included, 0 before the first; a tail that sees it drop knows the log was restored. */
64
103
  auditHighId(): Promise<number>;
65
104
  }
105
+ /** A key to insert and the actor and metadata of its auth_create row, whose tenant and target are the key's. Neither holds the plaintext. */
106
+ export interface KeyMint {
107
+ readonly key: NewApiKey;
108
+ readonly actor: string;
109
+ readonly metadata: Readonly<Record<string, JsonValue>>;
110
+ }
111
+ export interface SelfKeyMint extends KeyMint {
112
+ readonly key: NewApiKey & {
113
+ readonly ownerSubject: string;
114
+ readonly expiresAt: string;
115
+ };
116
+ /** Live keys the owner may hold in the tenant once this one is in. */
117
+ readonly perSubject: number;
118
+ }
119
+ /** `listApiKeyRows`'s filters, always inside one tenant. */
120
+ export interface KeyListQuery extends Omit<ListApiKeysOpts, 'tenantId'> {
121
+ readonly tenantId: string;
122
+ }
123
+ export interface KeyWrites {
124
+ /** Inserts the key and appends its auth_create row in one transaction; with either one failing, neither is written. A key id taken in any tenant
125
+ * rejects, since keys are looked up by id alone. */
126
+ createApiKey(mint: KeyMint): Promise<void>;
127
+ /** One transaction, serialized per (tenant, owner) before the count (hippo.db BEGIN IMMEDIATE, Postgres pg_advisory_xact_lock on the pair): revoke at `key.createdAt`
128
+ * the owner's oldest live keys in the key's tenant down to `perSubject - 1`, insert the key, append an auth_revoke row `{ replacedBy: <new key id> }` per revoked key,
129
+ * then the auth_create row; resolves to the revoked ids. Live: unrevoked, expires_at null or Date.parse(expires_at) after createdAt (NaN counts as expired). */
130
+ createSelfApiKey(mint: SelfKeyMint): Promise<string[]>;
131
+ /** As `listApiKeyRows`: newest inserted first, each with its scope grants; `active` drops revoked rows and an expires_at not above now's toISOString. */
132
+ listApiKeys(query: KeyListQuery): Promise<ApiKeyListRow[]>;
133
+ }
66
134
  /** The optional groups: a store sets each one whole or leaves it unset, and a route or MCP tool names the one it needs. */
67
135
  export interface StoreGroups {
68
136
  /** Unset on a store built before them, where hybrid and physics recall under an embedding provider answer 501. */
69
137
  readonly vectors: VectorReads;
70
138
  readonly keyAudit: KeyAudit;
139
+ readonly keyWrites: KeyWrites;
140
+ /** embedMemory and embedAll with a store need it and `vectors`. */
141
+ readonly vectorWrites: VectorWrites;
71
142
  }
72
143
  export type StoreGroup = 'base' | keyof StoreGroups;
73
144
  export declare function hasGroup(store: HippoStore, group: StoreGroup): boolean;
@@ -1,5 +1,5 @@
1
1
  // The async seam the server reaches its store through, so an add-on can serve from a database other than hippo.db.
2
- import { readApiKeyRecord } from './auth.js';
2
+ import { listApiKeyRows, readApiKeyRecord } from './auth.js';
3
3
  import { appendAuditEvent, listAuditEventsAfter } from './audit.js';
4
4
  import { closeHippoDb, openHippoDb, withWriteScope } from './db.js';
5
5
  import { StoreNotPortedError } from './db/sqlite-blocked.js';
@@ -10,11 +10,13 @@ import { planningFallacyEvidenceAt } from './predictions/planning-fallacy.js';
10
10
  import { writeRecallTrace } from './recall-trace.js';
11
11
  import { loadEntriesByIds, loadFreshRawMemories } from './store/entry-reads.js';
12
12
  import { auditHighIdAt, revokeKeyAt } from './store/key-audit.js';
13
+ import { createKeyAt, createSelfKeyAt } from './store/key-writes.js';
13
14
  import { strengthenRetrievedInOwnTx } from './store/entry-writes.js';
14
15
  import { loadLatestHandoff } from './store/handoffs.js';
15
16
  import { updateStats } from './store/index-and-stats.js';
16
17
  import { loadRecallSearchEntries, loadVectorCandidateEntries } from './store/search-rows.js';
17
18
  import { listSessionEvents, loadActiveTaskSnapshot } from './store/sessions.js';
19
+ import { entriesWithoutVectorAt, writeVectorsAt } from './store/vector-writes.js';
18
20
  import { recordTokenUse } from './token-ledger.js';
19
21
  export function hasGroup(store, group) {
20
22
  return group === 'base' || store[group] !== undefined;
@@ -98,9 +100,31 @@ export function sqliteStore(hippoRoot) {
98
100
  },
99
101
  },
100
102
  keyAudit: sqliteKeyAudit(hippoRoot),
103
+ keyWrites: sqliteKeyWrites(hippoRoot),
104
+ vectorWrites: {
105
+ async entriesWithoutVector(query) {
106
+ return onHandle(hippoRoot, (db) => entriesWithoutVectorAt(db, query));
107
+ },
108
+ async writeVectors(write) {
109
+ return onHandle(hippoRoot, (db) => writeVectorsAt(db, write));
110
+ },
111
+ },
101
112
  async close() { },
102
113
  };
103
114
  }
115
+ function sqliteKeyWrites(hippoRoot) {
116
+ return {
117
+ async createApiKey(mint) {
118
+ onHandle(hippoRoot, (db) => createKeyAt(db, mint));
119
+ },
120
+ async createSelfApiKey(mint) {
121
+ return onHandle(hippoRoot, (db) => createSelfKeyAt(db, mint));
122
+ },
123
+ async listApiKeys(query) {
124
+ return onHandle(hippoRoot, (db) => listApiKeyRows(db, query));
125
+ },
126
+ };
127
+ }
104
128
  function sqliteKeyAudit(hippoRoot) {
105
129
  return {
106
130
  async revokeApiKey(revoke) {
package/dist/version.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export declare const PACKAGE_VERSION = "1.67.0";
19
+ export declare const PACKAGE_VERSION = "1.68.0";
20
20
  /** The floor a store takes on its first expiring key: the first release with schema v53, set by hand in that release; scripts/check-expiring-keys-floor.mjs gates it. */
21
21
  export declare const EXPIRING_KEYS_MIN_BINARY = "1.64.0";
22
22
  /** The floor a store takes on its first session bind or owner snapshot: first release with v54; set by hand in that release. */
package/dist/version.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export const PACKAGE_VERSION = '1.67.0';
19
+ export const PACKAGE_VERSION = '1.68.0';
20
20
  /** The floor a store takes on its first expiring key: the first release with schema v53, set by hand in that release; scripts/check-expiring-keys-floor.mjs gates it. */
21
21
  export const EXPIRING_KEYS_MIN_BINARY = '1.64.0';
22
22
  /** The floor a store takes on its first session bind or owner snapshot: first release with v54; set by hand in that release. */
@@ -2,7 +2,7 @@
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
4
  "description": "Make your agent's memory work like a brain. Hippo is long-term memory for coding agents.",
5
- "version": "1.67.0",
5
+ "version": "1.68.0",
6
6
 
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.67.0",
3
+ "version": "1.68.0",
4
4
  "type": "module",
5
5
  "description": "Make your agent's memory work like a brain. Hippo is long-term memory for coding agents.",
6
6
  "main": "index.ts",
@@ -2,7 +2,7 @@
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
4
  "description": "Make your agent's memory work like a brain. Hippo is long-term memory for coding agents.",
5
- "version": "1.67.0",
5
+ "version": "1.68.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.67.0",
3
+ "version": "1.68.0",
4
4
  "description": "Make your agent's memory work like a brain. Hippo is long-term memory for coding agents.",
5
5
  "mcpName": "io.github.kitfunso/hippo-memory",
6
6
  "type": "module",