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
@@ -5,7 +5,7 @@ import { getGlobalRoot, initGlobal } from '../shared.js';
5
5
  import { loadConfig } from '../config.js';
6
6
  import { resolveTenantId } from '../tenant.js';
7
7
  import { rethrowIfSqliteBlocked, runWithRequestStores } from '../db.js';
8
- import { storeFor } from '../store-port.js';
8
+ import { hasGroup, storeFor } from '../store-port.js';
9
9
  import { estimateTokens } from '../token-ledger.js';
10
10
  import { PACKAGE_VERSION } from '../version.js';
11
11
  import { validateToolArgs } from './tool-args.js';
@@ -60,7 +60,7 @@ export async function recordMcpTokens(toolName, output, ctx) {
60
60
  }
61
61
  }
62
62
  const TOOL_HANDLERS = new Map([
63
- ['hippo_recall', { handler: runRecallTool, storeReady: true }],
63
+ ['hippo_recall', { handler: runRecallTool, storeReady: 'base' }],
64
64
  ['hippo_assemble', { handler: runAssembleTool }],
65
65
  ['hippo_drill', { handler: runDrillTool }],
66
66
  ['hippo_predict_baserate', { handler: runPredictBaserateTool }],
@@ -74,12 +74,14 @@ const TOOL_HANDLERS = new Map([
74
74
  ['hippo_share', { handler: runShareTool }],
75
75
  ['hippo_peers', { handler: runPeersTool }],
76
76
  ]);
77
- /** The tools listed and run under a store other than hippo.db. */
78
- export const STORE_READY_TOOLS = new Set([...TOOL_HANDLERS].filter(([, entry]) => entry.storeReady).map(([name]) => name));
79
- /** The served store's kind when it is not hippo.db, else null. */
80
- function otherStoreKind(ctx) {
81
- const kind = ctx?.store?.kind;
82
- return kind !== undefined && kind !== 'sqlite' ? kind : null;
77
+ /** The served store when it is not hippo.db, else null. */
78
+ function otherStore(ctx) {
79
+ const store = ctx?.store;
80
+ return store !== undefined && store.kind !== 'sqlite' ? store : null;
81
+ }
82
+ function runsOn(store, toolName) {
83
+ const group = TOOL_HANDLERS.get(toolName)?.storeReady;
84
+ return group !== undefined && hasGroup(store, group);
83
85
  }
84
86
  async function executeTool(name, args, ctx) {
85
87
  // When a transport hands us a context (HTTP path), trust it: the HTTP
@@ -130,8 +132,10 @@ export async function handleMcpRequest(req, ctx) {
130
132
  };
131
133
  case 'notifications/initialized':
132
134
  return null;
133
- case 'tools/list':
134
- return { jsonrpc: '2.0', id, result: { tools: otherStoreKind(ctx) ? TOOLS.filter((t) => STORE_READY_TOOLS.has(t.name)) : TOOLS } };
135
+ case 'tools/list': {
136
+ const other = otherStore(ctx);
137
+ return { jsonrpc: '2.0', id, result: { tools: other ? TOOLS.filter((t) => runsOn(other, t.name)) : TOOLS } };
138
+ }
135
139
  case 'tools/call': {
136
140
  const nameValue = params?.name;
137
141
  const toolName = isJsonString(nameValue) ? nameValue : '';
@@ -140,7 +144,8 @@ export async function handleMcpRequest(req, ctx) {
140
144
  return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${toolName.slice(0, 128)}` } };
141
145
  }
142
146
  // The same refusal a ported tool gives when it reaches hippo.db, so a client handles one shape.
143
- if (otherStoreKind(ctx) && !STORE_READY_TOOLS.has(toolName)) {
147
+ const other = otherStore(ctx);
148
+ if (other && !runsOn(other, toolName)) {
144
149
  return { jsonrpc: '2.0', id, error: { code: -32603, message: STORE_NOT_PORTED_MESSAGE } };
145
150
  }
146
151
  const refusal = sharedStoreRefusal(toolName, ctx);
@@ -21,7 +21,13 @@ export interface ContextView {
21
21
  /** The static block's hash the caller says it printed last. Undefined keeps the ledger-only skip rule;
22
22
  * a string or null also needs that hash to match, because the ledger counts blocks a caller that prints elsewhere may never have shown. */
23
23
  readonly printedHash?: string | null;
24
+ /** The reply's JSON wrapper; absent means Claude Code's UserPromptSubmit. */
25
+ readonly envelope?: HookEnvelope;
24
26
  }
27
+ /** `copilot-session-start` is the Copilot harness's top-level reply, `vscode-session-start` the nested one VS Code Local reads. */
28
+ export type HookEnvelope = 'user-prompt-submit' | 'copilot-session-start' | 'vscode-session-start';
29
+ /** A Copilot sessionStart reply's wrapper, picked by the sender's casing: stdin.ts keeps a camelCase `sessionId`, which only the Copilot harness sends. */
30
+ export declare function sessionStartEnvelope(stdinText: string | undefined): HookEnvelope;
25
31
  export declare function hasContextData(result: ContextResult): boolean;
26
32
  export type RenderItem = {
27
33
  entry: MemoryEntry;
@@ -11,7 +11,7 @@ import { withLedgerDb } from './ledger-db.js';
11
11
  import { sessionPilotArm } from './pilot-arm.js';
12
12
  import { assertCallerProject, MAX_PROJECT_ALIASES } from './project-identity.js';
13
13
  import { writeDeliveryEventAtRoot, writeDeliveryEventOnHandle } from './recall-trace.js';
14
- import { blockHash, estimateTokens, isSubagentPayload, lastSentState, recordTokenUse, shouldSkipUnchanged } from './token-ledger.js';
14
+ import { blockHash, estimateTokens, hookPayloadString, isSubagentPayload, lastSentState, recordTokenUse, shouldSkipUnchanged, } from './token-ledger.js';
15
15
  /** With `db`, writes on the token ledger's handle (same store); without it, opens its own. A second flush is a no-op. */
16
16
  export function flushDeliveryRecorder(rec, db) {
17
17
  if (rec === null)
@@ -24,6 +24,16 @@ export function flushDeliveryRecorder(rec, db) {
24
24
  console.error(`[hippo] delivery ledger write failed:${error instanceof Error ? error.message : String(error)}`);
25
25
  }
26
26
  }
27
+ /** A Copilot sessionStart reply's wrapper, picked by the sender's casing: stdin.ts keeps a camelCase `sessionId`, which only the Copilot harness sends. */
28
+ export function sessionStartEnvelope(stdinText) {
29
+ return hookPayloadString(stdinText, 'sessionId') !== null ? 'copilot-session-start' : 'vscode-session-start';
30
+ }
31
+ function hookReply(envelope, additionalContext) {
32
+ if (envelope === 'copilot-session-start')
33
+ return JSON.stringify({ additionalContext });
34
+ const hookEventName = envelope === 'vscode-session-start' ? 'SessionStart' : 'UserPromptSubmit';
35
+ return JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext } });
36
+ }
27
37
  const NOTHING_RENDERED = Object.freeze({ stdout: '', staticHash: null });
28
38
  export function hasContextData(result) {
29
39
  return Boolean(result.entries.length > 0 ||
@@ -70,12 +80,7 @@ function renderAdditionalContext(view) {
70
80
  rec?.delivered({ state: 'reused', staticHash: blockHash(staticBlock), staticReused });
71
81
  return NOTHING_RENDERED;
72
82
  }
73
- const stdout = JSON.stringify({
74
- hookSpecificOutput: {
75
- hookEventName: 'UserPromptSubmit',
76
- additionalContext,
77
- },
78
- });
83
+ const stdout = hookReply(view.envelope ?? 'user-prompt-submit', additionalContext);
79
84
  rec?.delivered({
80
85
  state: staticReused ? 'reused-recall-sent' : 'sent',
81
86
  staticHash: staticBlock.trim() ? blockHash(staticBlock) : null,
@@ -94,6 +99,9 @@ function skipUnchangedStatic(view, surface, staticBlock, recallBlock, staticCoun
94
99
  const ledgerOpts = { sharedStore: view.sharedStore };
95
100
  if (!view.pinnedOnly || payloadSessionId === undefined)
96
101
  return false;
102
+ // Session start is the only injection Copilot gets, so a resumed session needs the whole block again.
103
+ if (view.envelope === 'copilot-session-start' || view.envelope === 'vscode-session-start')
104
+ return false;
97
105
  const injectCfg = loadConfig(hippoRoot).pinnedInject;
98
106
  if (injectCfg.skipUnchanged === false)
99
107
  return false;
@@ -1,9 +1,8 @@
1
1
  import { cosineSimilarity, indexedModel, indexNeedsRebuild } from '../embeddings.js';
2
2
  import { resolveEmbeddingProvider } from '../embedding-provider.js';
3
3
  import { rethrowIfSqliteBlocked } from '../db.js';
4
- import { StoreNotPortedError } from '../db/sqlite-blocked.js';
5
4
  import { log } from '../log.js';
6
- import { sqliteStore } from '../store-port.js';
5
+ import { requireGroup, sqliteStore } from '../store-port.js';
7
6
  import { redactSecretsStrict } from '../secret-detect.js';
8
7
  import { currentEntries } from './as-of.js';
9
8
  // Search runs on every hook prompt, so one line per reason per process says why vectors went unused without flooding stderr.
@@ -19,9 +18,7 @@ function reindexHint(storeKind) {
19
18
  }
20
19
  /** The store's vector reads; a store without them answers 501, as any unported path does. */
21
20
  export function requireVectorReads(store) {
22
- if (!store.vectors)
23
- throw new StoreNotPortedError(store.kind, 'vectors');
24
- return store.vectors;
21
+ return requireGroup(store, 'vectors');
25
22
  }
26
23
  /** The nearest admitted rows not already in `entries`. */
27
24
  export async function vectorCandidatesOutside(reads, entries, queryVector, spec) {
@@ -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;
@@ -79,7 +79,7 @@ export async function handleListAuthKeys({ req, res, opts, query }) {
79
79
  export async function handleRevokeAuthKey({ req, res, opts }, keyMatch) {
80
80
  validateIdSegment(keyMatch.keyId, 'key id');
81
81
  const ctx = await buildContextWithAuth(req, opts);
82
- const result = authRevoke(ctx, keyMatch.keyId);
82
+ const result = await authRevoke(ctx, keyMatch.keyId);
83
83
  sendJson(res, 200, result);
84
84
  return;
85
85
  }
@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { Context } from '../api.js';
3
3
  import type { JsonValue } from '../json.js';
4
4
  import type { RateLimiter } from '../rate-limit.js';
5
- import type { HippoStore } from '../store-port.js';
5
+ import type { HippoStore, StoreGroup } from '../store-port.js';
6
6
  export interface ServerHandle {
7
7
  port: number;
8
8
  url: string;
@@ -28,10 +28,11 @@ export interface AddonCall {
28
28
  readonly ctx: Context;
29
29
  readonly body: Readonly<Record<string, JsonValue>>;
30
30
  }
31
- /** A POST /v1 route an add-on mounts through serve(): core authenticates and parses first, then sends the returned value as 200 JSON. */
31
+ /** A POST /v1 route an add-on mounts through serve(): core authenticates and parses first, then sends the returned value as 200 JSON. Under another store it runs only when that store has its `storeReady` group. */
32
32
  export interface AddonRoute {
33
33
  readonly path: string;
34
34
  readonly handler: (call: AddonCall) => Promise<JsonValue>;
35
+ readonly storeReady?: StoreGroup;
35
36
  }
36
37
  export interface RateLimitSpec {
37
38
  ratePerSec: number;
@@ -76,10 +77,10 @@ export interface RouteRequest {
76
77
  opts: ResolvedServeOpts;
77
78
  query: URLSearchParams;
78
79
  }
79
- /** One /v1 route: an exact path, a matchPath pattern, or a regex, each paired with the handler for one method. `storeReady` routes run under any store. */
80
+ /** One /v1 route: an exact path, a matchPath pattern, or a regex, each paired with the handler for one method. Under another store it runs only when that store has its `storeReady` group. */
80
81
  export type Route = {
81
82
  method: string;
82
- storeReady?: true;
83
+ storeReady?: StoreGroup;
83
84
  } & ({
84
85
  path: string;
85
86
  handler: (r: RouteRequest) => Promise<void>;
package/dist/server.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { type Actor, authCreateSelf, type AuthCreateSelfOpts, type AuthCreateSelfResult, authRevoke, type Context } from './api.js';
2
- import { ForbiddenError } from './api-errors.js';
1
+ import { type Actor, authCreateSelf, type AuthCreateSelfOpts, type AuthCreateSelfResult, authRevoke, type AuthRevokeReply, type AuthRevokeResult, type Context } from './api.js';
2
+ import { ForbiddenError, NotFoundError } from './api-errors.js';
3
3
  import type { ServeOpts, ServerHandle } from './server/types.js';
4
- export { authCreateSelf, authRevoke, ForbiddenError, type AuthCreateSelfOpts, type AuthCreateSelfResult, type Context, type Actor };
4
+ export { authCreateSelf, authRevoke, ForbiddenError, NotFoundError, type AuthCreateSelfOpts, type AuthCreateSelfResult, type AuthRevokeReply, type AuthRevokeResult, type Context, type Actor, };
5
5
  export { isCrossSite, LOOPBACK_HOST_HEADER } from './http-util.js';
6
6
  export { __resetSessionRecallHistoryHttp } from './server/routes/recall.js';
7
7
  export { clientIpForRateLimit } from './server/client-ip.js';
@@ -11,9 +11,11 @@ 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 { sqliteStore, type HippoStore, type RecallSearchArgs, type RecallWrites, type VectorReads } from './store-port.js';
15
- export type { ApiKeyRecord } from './auth.js';
16
- export type { AppendAuditOpts } from './audit.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';
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';
19
21
  export type { MemoryEntry } from './memory.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
@@ -3,17 +3,17 @@ import { createServer } from 'node:http';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { detectServer, removePidfileIfOwned, writePidfile } from './server-detect.js';
5
5
  import { closeHippoDb, getHippoDbPath, isStoreBusy, openHippoDb, outsideRequestStores, runWithRequestStores, SERVER_DB_WAIT_MS, withSqliteBlocked } from './db.js';
6
- import { sqliteStore } from './store-port.js';
6
+ import { hasGroup, sqliteStore } from './store-port.js';
7
7
  import { markSharedStore } from './config.js';
8
8
  import { auditWriteFailureCount } from './audit.js';
9
9
  import { PACKAGE_VERSION } from './version.js';
10
10
  import { errorFields, log } from './log.js';
11
11
  import { createRateLimiter } from './rate-limit.js';
12
- import { authCreateSelf, authRevoke, RecallContractError } from './api.js';
12
+ import { authCreateSelf, authRevoke, RecallContractError, } from './api.js';
13
13
  import { handleSlackEventsWebhook } from './connectors/slack/webhook.js';
14
14
  import { handleGitHubEventsWebhook } from './connectors/github/webhook.js';
15
15
  import { BodyTimeoutError, BodyTooLargeError, closeAfterReply, HttpError, JSON_HEADERS, sendJson, STORE_NOT_PORTED_MESSAGE } from './http-util.js';
16
- import { ForbiddenError } from './api-errors.js';
16
+ import { ForbiddenError, NotFoundError } from './api-errors.js';
17
17
  import { buildContextWithAuth, isLoopback, LIMITER_MAX_KEYS, requireAuth } from './server/auth.js';
18
18
  import { enforceRateLimit } from './server/client-ip.js';
19
19
  import { drainAndClose } from './server/lifecycle.js';
@@ -33,7 +33,7 @@ import { handleAssembleSession, handleDrillRecall, handleGetContext, handleRecal
33
33
  import { handleCloseSkill, handleCreateSkill, handleExportSkills, handleGetSkill, handleListSkills, handleSupersedeSkill } from './server/routes/skills.js';
34
34
  import { parseJsonBody } from './server/validation.js';
35
35
  // Add-on packages mint and revoke keys through these without importing the whole api surface.
36
- export { authCreateSelf, authRevoke, ForbiddenError };
36
+ export { authCreateSelf, authRevoke, ForbiddenError, NotFoundError, };
37
37
  // Published on the hippo-memory/server subpath before they moved to http-util.ts, so they stay exported here.
38
38
  export { isCrossSite, LOOPBACK_HOST_HEADER } from './http-util.js';
39
39
  // The code behind these lives in src/server/; this subpath keeps exporting them.
@@ -46,11 +46,13 @@ export { promptHookContext } from './prompt-hook.js';
46
46
  // A session-end route stores the turns its caller read from a transcript on the caller's own machine.
47
47
  export { captureSessionTexts } from './capture/session-texts.js';
48
48
  // An add-on serves from another database by passing serve() its own HippoStore.
49
- export { sqliteStore } from './store-port.js';
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.
@@ -98,7 +100,7 @@ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
98
100
  const V1_ROUTES = [
99
101
  { method: 'POST', path: '/v1/memories', handler: handleCreateMemory },
100
102
  { method: 'GET', path: '/v1/graph', handler: handleGetGraph },
101
- { method: 'GET', path: '/v1/memories', storeReady: true, handler: handleRecallMemories },
103
+ { method: 'GET', path: '/v1/memories', storeReady: 'base', handler: handleRecallMemories },
102
104
  { method: 'GET', pattern: '/v1/sessions/:id/assemble', handler: handleAssembleSession },
103
105
  { method: 'GET', pattern: '/v1/recall/drill/:id', handler: handleDrillRecall },
104
106
  { method: 'POST', pattern: '/v1/memories/:id/archive', handler: handleArchiveMemory },
@@ -108,9 +110,9 @@ 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: 'DELETE', pattern: '/v1/auth/keys/:keyId', handler: handleRevokeAuthKey },
113
+ { method: 'POST', path: '/v1/auth/keys', storeReady: 'keyWrites', handler: handleCreateAuthKey },
114
+ { method: 'GET', path: '/v1/auth/keys', storeReady: 'keyWrites', handler: handleListAuthKeys },
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 },
116
118
  { method: 'POST', pattern: '/v1/quarantine/:id/reject', handler: handleRejectQuarantine },
@@ -177,8 +179,7 @@ async function dispatchV1Route(r, method, path) {
177
179
  const run = routeMatches(route, method, path);
178
180
  if (run === null)
179
181
  continue;
180
- if (!route.storeReady)
181
- await refuseUnportedRoute(r.req, r.opts);
182
+ await refuseUnportedRoute(r.req, r.opts, route.storeReady);
182
183
  await run(r);
183
184
  return true;
184
185
  }
@@ -229,7 +230,7 @@ async function dispatchAddonRoute({ req, res, opts }, method, path) {
229
230
  const route = method === 'POST' ? opts.routes?.find((r) => r.path === path) : undefined;
230
231
  if (!route)
231
232
  return false;
232
- await refuseUnportedRoute(req, opts);
233
+ await refuseUnportedRoute(req, opts, route.storeReady);
233
234
  const ctx = await buildContextWithAuth(req, opts);
234
235
  const body = await parseJsonBody(req, ctx);
235
236
  sendJson(res, 200, await route.handler({ ctx, body }));
@@ -248,9 +249,9 @@ function assertSqliteStore(opts) {
248
249
  if (opts.store.kind !== 'sqlite')
249
250
  throw new HttpError(501, STORE_NOT_PORTED_MESSAGE);
250
251
  }
251
- /** Under another store, a route not yet ported answers 501 without running; the caller is checked first, so a bad key is still a 401. */
252
- async function refuseUnportedRoute(req, opts) {
253
- if (opts.store.kind === 'sqlite')
252
+ /** Under another store, a route that names no group, or one the store lacks, answers 501 without running; the caller is checked first, so a bad key is still a 401. */
253
+ async function refuseUnportedRoute(req, opts, group) {
254
+ if (opts.store.kind === 'sqlite' || (group !== undefined && hasGroup(opts.store, group)))
254
255
  return;
255
256
  await requireAuth(req, opts);
256
257
  assertSqliteStore(opts);
@@ -519,7 +520,7 @@ export async function serve(opts) {
519
520
  const host = opts.host ?? '127.0.0.1';
520
521
  const requestedPort = opts.port ?? Number(envPort() ?? 6789);
521
522
  // A frozen copy, so a route the caller adds or renames after boot never skips the check below.
522
- const routes = Object.freeze((opts.routes ?? []).map(({ path, handler }) => Object.freeze({ path, handler })));
523
+ const routes = Object.freeze((opts.routes ?? []).map(({ path, handler, storeReady }) => Object.freeze(storeReady === undefined ? { path, handler } : { path, handler, storeReady })));
523
524
  assertAddonRoutes(routes);
524
525
  const publicJsonBodies = assertPublicJson(opts.publicJson ?? {});
525
526
  const { perAddress: limiter, callerLimiter, failedAuthLimiter } = bootLimiters(opts.rateLimits);
package/dist/stdin.d.ts CHANGED
@@ -9,4 +9,9 @@ export interface BoundedStdin {
9
9
  * idle (default 1000ms, or `HIPPO_STDIN_WAIT_MS`), refreshed on each chunk
10
10
  * so a slow but real write is not cut off, and capped at `waitMs * 10`. */
11
11
  export declare function readStdinBounded(waitMs?: number): Promise<BoundedStdin>;
12
+ /** A hook payload with Copilot's camelCase fields copied to the snake_case keys hippo reads. The camelCase keys stay,
13
+ * so the sessionStart reply can follow the sender's casing; text that needs no change comes back exactly as sent. */
14
+ export declare function normaliseHookPayload(text: string | undefined): string | undefined;
15
+ /** The stdin read every hook verb uses: {@link readStdinBounded}, with the payload passed through {@link normaliseHookPayload}. */
16
+ export declare function readHookStdin(waitMs?: number): Promise<BoundedStdin>;
12
17
  //# sourceMappingURL=stdin.d.ts.map
package/dist/stdin.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { envStdinWaitMs } from './env.js';
2
+ import { isJsonObject } from './hooks/shared.js';
3
+ import { isJsonString } from './json.js';
2
4
  function defaultWaitMs() {
3
5
  return envStdinWaitMs() ?? 1000;
4
6
  }
@@ -39,4 +41,64 @@ export function readStdinBounded(waitMs = defaultWaitMs()) {
39
41
  stdin.once('error', done);
40
42
  });
41
43
  }
44
+ // The Copilot harness's camelCase hook fields and the snake_case keys every hippo payload reader looks up.
45
+ const CAMEL_TO_SNAKE = [
46
+ ['sessionId', 'session_id'],
47
+ ['transcriptPath', 'transcript_path'],
48
+ ['toolName', 'tool_name'],
49
+ ['hookEventName', 'hook_event_name'],
50
+ ['customInstructions', 'custom_instructions'],
51
+ ];
52
+ /** Copilot sends `toolArgs` as the model's raw JSON string; Claude Code and VS Code send `tool_input` already parsed. */
53
+ function parsedToolArgs(value) {
54
+ if (!isJsonString(value))
55
+ return value;
56
+ try {
57
+ // SAFETY: JSON.parse returns a JSON value by definition.
58
+ return JSON.parse(value);
59
+ }
60
+ catch {
61
+ // Arguments that are not JSON stay the string the model wrote.
62
+ return value;
63
+ }
64
+ }
65
+ /** The snake_case keys a payload lacks, filled from its camelCase twins; a snake_case key already present wins. */
66
+ function snakeCaseFields(payload) {
67
+ const added = {};
68
+ for (const [camel, snake] of CAMEL_TO_SNAKE) {
69
+ if (camel in payload && !(snake in payload))
70
+ added[snake] = payload[camel];
71
+ }
72
+ if ('toolArgs' in payload && !('tool_input' in payload))
73
+ added.tool_input = parsedToolArgs(payload.toolArgs);
74
+ const error = payload.error;
75
+ // An error object gives its message, so readers that expect the documented string still get one.
76
+ if (isJsonObject(error) && isJsonString(error.message))
77
+ added.error = error.message;
78
+ return added;
79
+ }
80
+ /** A hook payload with Copilot's camelCase fields copied to the snake_case keys hippo reads. The camelCase keys stay,
81
+ * so the sessionStart reply can follow the sender's casing; text that needs no change comes back exactly as sent. */
82
+ export function normaliseHookPayload(text) {
83
+ if (text === undefined || text.trim() === '')
84
+ return text;
85
+ let payload;
86
+ try {
87
+ // SAFETY: JSON.parse returns a JSON value by definition.
88
+ payload = JSON.parse(text.trim());
89
+ }
90
+ catch {
91
+ // Not JSON: each reader already treats that as a malformed payload, so it passes through untouched.
92
+ return text;
93
+ }
94
+ if (!isJsonObject(payload))
95
+ return text;
96
+ const added = snakeCaseFields(payload);
97
+ return Object.keys(added).length === 0 ? text : JSON.stringify({ ...payload, ...added });
98
+ }
99
+ /** The stdin read every hook verb uses: {@link readStdinBounded}, with the payload passed through {@link normaliseHookPayload}. */
100
+ export async function readHookStdin(waitMs) {
101
+ const read = await readStdinBounded(waitMs);
102
+ return { text: normaliseHookPayload(read.text), timedOut: read.timedOut };
103
+ }
42
104
  //# sourceMappingURL=stdin.js.map
@@ -0,0 +1,6 @@
1
+ import { type DatabaseSyncLike } from '../db.js';
2
+ import type { KeyRevoke } from '../store-port.js';
3
+ /** The revoke and its audit row commit together, so a failed audit write leaves the key live and the caller sees the error. */
4
+ export declare function revokeKeyAt(db: DatabaseSyncLike, revoke: KeyRevoke): string;
5
+ export declare function auditHighIdAt(db: DatabaseSyncLike): number;
6
+ //# sourceMappingURL=key-audit.d.ts.map
@@ -0,0 +1,25 @@
1
+ // hippo.db's half of the KeyAudit store group.
2
+ import { NotFoundError } from '../api-errors.js';
3
+ import { appendAuditEvent } from '../audit.js';
4
+ import { revokeApiKey } from '../auth.js';
5
+ import { withWriteScope } from '../db.js';
6
+ import { selectApiKeyOwner } from './tenant-lookup.js';
7
+ /** The revoke and its audit row commit together, so a failed audit write leaves the key live and the caller sees the error. */
8
+ export function revokeKeyAt(db, revoke) {
9
+ return withWriteScope(db, 'revoke_api_key', () => {
10
+ const row = selectApiKeyOwner(db, revoke.keyId);
11
+ if (!row || row.tenantId !== revoke.tenantId)
12
+ throw new NotFoundError(`Unknown key_id: ${revoke.keyId}`);
13
+ if (row.revokedAt)
14
+ return row.revokedAt;
15
+ revokeApiKey(db, revoke.keyId, revoke.at);
16
+ appendAuditEvent(db, { tenantId: row.tenantId, actor: revoke.actor, op: 'auth_revoke', targetId: revoke.keyId });
17
+ return revoke.at;
18
+ });
19
+ }
20
+ export function auditHighIdAt(db) {
21
+ // SAFETY: the SELECT names one column, seq; `.get` returns undefined before audit_log's first row.
22
+ const row = db.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'audit_log'").get();
23
+ return row?.seq ?? 0;
24
+ }
25
+ //# sourceMappingURL=key-audit.js.map
@@ -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