hippo-memory 1.66.0 → 1.67.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 (53) hide show
  1. package/README.md +2 -0
  2. package/dist/api/auth.d.ts +11 -2
  3. package/dist/api/auth.js +25 -2
  4. package/dist/auth.d.ts +3 -1
  5. package/dist/auth.js +6 -2
  6. package/dist/capture/compact.d.ts +2 -0
  7. package/dist/capture/compact.js +12 -6
  8. package/dist/capture/copilot-transcript.d.ts +15 -0
  9. package/dist/capture/copilot-transcript.js +40 -0
  10. package/dist/capture/failure-reading.js +5 -2
  11. package/dist/capture/transcript.js +7 -1
  12. package/dist/capture/working-state.js +14 -3
  13. package/dist/capture-contract.d.ts +4 -2
  14. package/dist/capture-contract.js +3 -3
  15. package/dist/cli/context.js +11 -8
  16. package/dist/cli/hook-blocks.d.ts +2 -0
  17. package/dist/cli/hook-blocks.js +6 -0
  18. package/dist/cli/session-hooks.d.ts +1 -1
  19. package/dist/cli/session-hooks.js +28 -16
  20. package/dist/cli/setup.js +66 -7
  21. package/dist/cli/shared.d.ts +5 -0
  22. package/dist/cli/shared.js +22 -3
  23. package/dist/cli/usage.js +20 -9
  24. package/dist/db/sqlite-blocked.js +1 -1
  25. package/dist/delivery-recorder.d.ts +3 -1
  26. package/dist/delivery-recorder.js +1 -1
  27. package/dist/hooks/copilot.d.ts +45 -0
  28. package/dist/hooks/copilot.js +187 -0
  29. package/dist/hooks/json-hooks.d.ts +1 -1
  30. package/dist/hooks/json-hooks.js +41 -2
  31. package/dist/hooks/shared.d.ts +5 -1
  32. package/dist/hooks/shared.js +9 -0
  33. package/dist/mcp/request.d.ts +0 -2
  34. package/dist/mcp/request.js +16 -11
  35. package/dist/prompt-hook.d.ts +6 -0
  36. package/dist/prompt-hook.js +15 -7
  37. package/dist/search/vector.js +2 -5
  38. package/dist/server/routes/admin.js +1 -1
  39. package/dist/server/types.d.ts +5 -4
  40. package/dist/server.d.ts +5 -5
  41. package/dist/server.js +13 -14
  42. package/dist/stdin.d.ts +5 -0
  43. package/dist/stdin.js +62 -0
  44. package/dist/store/key-audit.d.ts +6 -0
  45. package/dist/store/key-audit.js +25 -0
  46. package/dist/store-port.d.ts +29 -8
  47. package/dist/store-port.js +28 -1
  48. package/dist/version.d.ts +1 -1
  49. package/dist/version.js +1 -1
  50. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  51. package/extensions/openclaw-plugin/package.json +1 -1
  52. package/openclaw.plugin.json +1 -1
  53. package/package.json +1 -1
@@ -2,7 +2,7 @@ import type { JsonObject } from '../working-memory.js';
2
2
  import type { JsonValue } from '../json.js';
3
3
  /** JSON-value plain-object check (excludes arrays and null); `constructor` avoids the `typeof` tag banned by anti-slop/no-runtime-typeof. */
4
4
  export declare function isJsonObject(value: JsonValue | undefined): value is JsonObject;
5
- export type JsonHookTarget = 'claude-code' | 'codex';
5
+ export type JsonHookTarget = 'claude-code' | 'codex' | 'copilot';
6
6
  export interface ToolDetection {
7
7
  name: string;
8
8
  configDir: string;
@@ -24,10 +24,14 @@ export declare const HIPPO_POST_COMPACT_MARKER = "hippo post-compact";
24
24
  export declare function homeDir(): string;
25
25
  /** Codex's config folder: $CODEX_HOME, else ~/.codex, as the Codex hooks docs describe. */
26
26
  export declare function codexHomeDir(home?: string, env?: Readonly<Record<string, string | undefined>>): string;
27
+ /** Copilot's config folder: $COPILOT_HOME, else ~/.copilot under os.homedir(), as the Copilot apps resolve it (a HOME that differs from the profile must not move it). */
28
+ export declare function copilotHomeDir(home?: string, env?: Readonly<Record<string, string | undefined>>): string;
27
29
  /** Claude Code's config folder, where it reads settings.json: $CLAUDE_CONFIG_DIR when set and non-empty, else ~/.claude under os.homedir(), as Claude Code does (a HOME that differs from the profile must not move it). */
28
30
  export declare function claudeConfigDir(home?: string, env?: Readonly<Record<string, string | undefined>>): string;
29
31
  /** Codex counts as installed only when its config folder exists: Codex itself refuses a CODEX_HOME that is not a folder. */
30
32
  export declare function isCodexPresent(home?: string): boolean;
33
+ /** Copilot counts as installed only when its config folder exists, so setup never creates ~/.copilot on a machine without it. */
34
+ export declare function isCopilotPresent(home?: string): boolean;
31
35
  /** Codex hashes each hook and skips new or changed ones until the user reviews them in `/hooks`, so the reminder says what they would trust. */
32
36
  export declare const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus up to 5 that match the prompt. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
33
37
  /**
@@ -24,6 +24,10 @@ export function homeDir() {
24
24
  export function codexHomeDir(home = homeDir(), env = processEnv()) {
25
25
  return env.CODEX_HOME || path.join(home, '.codex');
26
26
  }
27
+ /** Copilot's config folder: $COPILOT_HOME, else ~/.copilot under os.homedir(), as the Copilot apps resolve it (a HOME that differs from the profile must not move it). */
28
+ export function copilotHomeDir(home = os.homedir(), env = processEnv()) {
29
+ return env.COPILOT_HOME || path.join(home, '.copilot');
30
+ }
27
31
  /** Claude Code's config folder, where it reads settings.json: $CLAUDE_CONFIG_DIR when set and non-empty, else ~/.claude under os.homedir(), as Claude Code does (a HOME that differs from the profile must not move it). */
28
32
  export function claudeConfigDir(home = os.homedir(), env = processEnv()) {
29
33
  return env.CLAUDE_CONFIG_DIR || path.join(home, '.claude');
@@ -32,6 +36,10 @@ export function claudeConfigDir(home = os.homedir(), env = processEnv()) {
32
36
  export function isCodexPresent(home = homeDir()) {
33
37
  return fs.statSync(codexHomeDir(home), { throwIfNoEntry: false })?.isDirectory() === true;
34
38
  }
39
+ /** Copilot counts as installed only when its config folder exists, so setup never creates ~/.copilot on a machine without it. */
40
+ export function isCopilotPresent(home = os.homedir()) {
41
+ return fs.statSync(copilotHomeDir(home), { throwIfNoEntry: false })?.isDirectory() === true;
42
+ }
35
43
  /** Codex hashes each hook and skips new or changed ones until the user reviews them in `/hooks`, so the reminder says what they would trust. */
36
44
  export const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus up to 5 that match the prompt. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
37
45
  /**
@@ -68,6 +76,7 @@ export function detectInstalledTools() {
68
76
  { name: 'opencode', configDir: '~/.config/opencode', detected: exists('.config', 'opencode'), kind: 'plugin', notes: 'installs a TS plugin at ~/.config/opencode/plugins/hippo.ts' },
69
77
  { name: 'openclaw', configDir: '~/.openclaw', detected: exists('.openclaw'), kind: 'plugin', notes: 'install via `openclaw plugins install hippo-memory`' },
70
78
  { name: 'codex', configDir: '~/.codex', detected: isCodexPresent(home), kind: 'wrapper', notes: 'memory hooks in hooks.json, and wraps the detected codex launcher for session-end consolidation' },
79
+ { name: 'copilot', configDir: copilotHomeDir(), detected: isCopilotPresent(), kind: 'json-hook', notes: 'hooks in hooks/hippo.json, the MCP server in mcp-config.json and a block in copilot-instructions.md' },
71
80
  { name: 'cursor', configDir: '~/.cursor', detected: exists('.cursor'), kind: 'markdown-instruction', notes: 'no hook API - patches AGENTS.md in the project' },
72
81
  { name: 'pi', configDir: '~/.pi', detected: exists('.pi'), kind: 'markdown-instruction', notes: 'no hook API - patches AGENTS.md in the project' },
73
82
  ];
@@ -4,8 +4,6 @@ import { type McpContext, type McpRequest, type McpResponse } from './protocol.j
4
4
  * ledger failure never fails the tool call. Other tools are not recorded.
5
5
  */
6
6
  export declare function recordMcpTokens(toolName: string, output: string, ctx?: McpContext): Promise<void>;
7
- /** The tools listed and run under a store other than hippo.db. */
8
- export declare const STORE_READY_TOOLS: ReadonlySet<string>;
9
7
  /**
10
8
  * Transport-agnostic MCP dispatcher. Both the stdio loop (below) and the
11
9
  * HTTP/SSE transport in src/server.ts route every incoming JSON-RPC message
@@ -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) {
@@ -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,9 @@ 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';
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
15
  export type { ApiKeyRecord } from './auth.js';
16
- export type { AppendAuditOpts } from './audit.js';
16
+ export type { AppendAuditOpts, AuditEvent, ListAuditAfterOpts } from './audit.js';
17
17
  export type { ContinuityBlock } from './api/recall-types.js';
18
18
  export type { ActiveGoals, GetActiveGoalsOpts, Goal, GoalRecallLogRow, RetrievalPolicy } from './goals.js';
19
19
  export type { MemoryEntry } from './memory.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,7 +46,7 @@ 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
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
52
  export { decodeVector, EMBEDDING_MODEL_META_KEY, rankVectorRows } from './vector-store.js';
@@ -98,7 +98,7 @@ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
98
98
  const V1_ROUTES = [
99
99
  { method: 'POST', path: '/v1/memories', handler: handleCreateMemory },
100
100
  { method: 'GET', path: '/v1/graph', handler: handleGetGraph },
101
- { method: 'GET', path: '/v1/memories', storeReady: true, handler: handleRecallMemories },
101
+ { method: 'GET', path: '/v1/memories', storeReady: 'base', handler: handleRecallMemories },
102
102
  { method: 'GET', pattern: '/v1/sessions/:id/assemble', handler: handleAssembleSession },
103
103
  { method: 'GET', pattern: '/v1/recall/drill/:id', handler: handleDrillRecall },
104
104
  { method: 'POST', pattern: '/v1/memories/:id/archive', handler: handleArchiveMemory },
@@ -110,7 +110,7 @@ const V1_ROUTES = [
110
110
  { method: 'POST', path: '/v1/sleep', handler: handleSleep },
111
111
  { method: 'POST', path: '/v1/auth/keys', handler: handleCreateAuthKey },
112
112
  { method: 'GET', path: '/v1/auth/keys', handler: handleListAuthKeys },
113
- { method: 'DELETE', pattern: '/v1/auth/keys/:keyId', handler: handleRevokeAuthKey },
113
+ { method: 'DELETE', pattern: '/v1/auth/keys/:keyId', storeReady: 'keyAudit', handler: handleRevokeAuthKey },
114
114
  { method: 'GET', path: '/v1/quarantine', handler: handleListQuarantine },
115
115
  { method: 'POST', pattern: '/v1/quarantine/:id/approve', handler: handleApproveQuarantine },
116
116
  { method: 'POST', pattern: '/v1/quarantine/:id/reject', handler: handleRejectQuarantine },
@@ -177,8 +177,7 @@ async function dispatchV1Route(r, method, path) {
177
177
  const run = routeMatches(route, method, path);
178
178
  if (run === null)
179
179
  continue;
180
- if (!route.storeReady)
181
- await refuseUnportedRoute(r.req, r.opts);
180
+ await refuseUnportedRoute(r.req, r.opts, route.storeReady);
182
181
  await run(r);
183
182
  return true;
184
183
  }
@@ -229,7 +228,7 @@ async function dispatchAddonRoute({ req, res, opts }, method, path) {
229
228
  const route = method === 'POST' ? opts.routes?.find((r) => r.path === path) : undefined;
230
229
  if (!route)
231
230
  return false;
232
- await refuseUnportedRoute(req, opts);
231
+ await refuseUnportedRoute(req, opts, route.storeReady);
233
232
  const ctx = await buildContextWithAuth(req, opts);
234
233
  const body = await parseJsonBody(req, ctx);
235
234
  sendJson(res, 200, await route.handler({ ctx, body }));
@@ -248,9 +247,9 @@ function assertSqliteStore(opts) {
248
247
  if (opts.store.kind !== 'sqlite')
249
248
  throw new HttpError(501, STORE_NOT_PORTED_MESSAGE);
250
249
  }
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')
250
+ /** 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. */
251
+ async function refuseUnportedRoute(req, opts, group) {
252
+ if (opts.store.kind === 'sqlite' || (group !== undefined && hasGroup(opts.store, group)))
254
253
  return;
255
254
  await requireAuth(req, opts);
256
255
  assertSqliteStore(opts);
@@ -519,7 +518,7 @@ export async function serve(opts) {
519
518
  const host = opts.host ?? '127.0.0.1';
520
519
  const requestedPort = opts.port ?? Number(envPort() ?? 6789);
521
520
  // 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 })));
521
+ const routes = Object.freeze((opts.routes ?? []).map(({ path, handler, storeReady }) => Object.freeze(storeReady === undefined ? { path, handler } : { path, handler, storeReady })));
523
522
  assertAddonRoutes(routes);
524
523
  const publicJsonBodies = assertPublicJson(opts.publicJson ?? {});
525
524
  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
@@ -1,5 +1,5 @@
1
1
  import { type ApiKeyRecord } from './auth.js';
2
- import { type AppendAuditOpts } from './audit.js';
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';
@@ -37,7 +37,7 @@ export interface RecallWrites {
37
37
  readonly opts: StrengthenOptions;
38
38
  };
39
39
  }
40
- /** The reads behind recall's vector arm, one group a store sets in full as `HippoStore.vectors` or leaves unset. */
40
+ /** The reads behind recall's vector arm. */
41
41
  export interface VectorReads {
42
42
  /** The stored model and whether any vector exists, in one snapshot. */
43
43
  embeddingIndexState(): Promise<EmbeddingIndexState>;
@@ -48,8 +48,33 @@ export interface VectorReads {
48
48
  /** The particles of `ids` only; an empty list reads nothing. */
49
49
  physicsParticles(ids: readonly string[]): Promise<Map<string, PhysicsParticle>>;
50
50
  }
51
+ export interface KeyRevoke {
52
+ readonly tenantId: string;
53
+ readonly keyId: string;
54
+ readonly actor: string;
55
+ readonly at: string;
56
+ }
57
+ export interface KeyAudit {
58
+ /** Sets revoked_at to `at` and appends one auth_revoke row (the key's tenant, `actor`, the key id) in one transaction, then resolves to `at`. A revoked
59
+ * key resolves to its own revoked_at and writes nothing; a key missing from `tenantId` rejects with NotFoundError `Unknown key_id: <keyId>`. */
60
+ revokeApiKey(revoke: KeyRevoke): Promise<string>;
61
+ /** As `listAuditEventsAfter`: rows above `afterId` in ascending id order, with its RangeErrors, limit clamp and tenant filter. */
62
+ auditEventsAfter(opts: ListAuditAfterOpts): Promise<AuditEvent[]>;
63
+ /** 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
+ auditHighId(): Promise<number>;
65
+ }
66
+ /** 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
+ export interface StoreGroups {
68
+ /** Unset on a store built before them, where hybrid and physics recall under an embedding provider answer 501. */
69
+ readonly vectors: VectorReads;
70
+ readonly keyAudit: KeyAudit;
71
+ }
72
+ export type StoreGroup = 'base' | keyof StoreGroups;
73
+ export declare function hasGroup(store: HippoStore, group: StoreGroup): boolean;
74
+ /** The group's methods; a store without them throws StoreNotPortedError, which answers 501 as any unported path does. */
75
+ export declare function requireGroup<G extends keyof StoreGroups>(store: HippoStore, group: G): StoreGroups[G];
51
76
  /** What `serve()` reads and writes through. Each method is atomic and no transaction spans an await, since SQLite's lock wait blocks the event loop; a lock timeout throws `StoreBusyError`. */
52
- export interface HippoStore {
77
+ export interface HippoStore extends Partial<StoreGroups> {
53
78
  /** 'sqlite' is hippo.db under the served root. Under any other kind, an unported route answers 501 and a hippo.db open inside a request throws. */
54
79
  readonly kind: string;
55
80
  /** The api_keys row for `keyId` with its scope grants, revoked or not; null when no row matches. */
@@ -78,8 +103,6 @@ export interface HippoStore {
78
103
  bumpRecallStats(recalled: number): Promise<void>;
79
104
  /** One token-ledger row. Throws, so the caller decides whether a ledger failure matters. */
80
105
  recordTokens(use: TokenUse): Promise<void>;
81
- /** Unset on a store built before them, where hybrid and physics recall under an embedding provider answer 501. */
82
- readonly vectors?: VectorReads;
83
106
  /** Releases the store's connections; `serve()` closes only a store it made itself. */
84
107
  close(): Promise<void>;
85
108
  }
@@ -89,9 +112,7 @@ export declare function storeFor(ctx: {
89
112
  readonly store?: HippoStore;
90
113
  }): HippoStore;
91
114
  /** The built-in store: today's synchronous hippo.db functions behind the port, each call on its own short-lived handles, so close has nothing to release. */
92
- export declare function sqliteStore(hippoRoot: string): HippoStore & {
93
- readonly vectors: VectorReads;
94
- };
115
+ export declare function sqliteStore(hippoRoot: string): HippoStore & StoreGroups;
95
116
  /** sqliteStore's continuity read, for the synchronous recall that cannot await the port. */
96
117
  export declare function continuityAt(hippoRoot: string, tenantId: string, eventLimit: number, key: ContinuityKey | null): ContinuityBlock;
97
118
  /** sqliteStore's finishRecall, for the synchronous recall that cannot await the port. */
@@ -1,19 +1,32 @@
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
2
  import { readApiKeyRecord } from './auth.js';
3
- import { appendAuditEvent } from './audit.js';
3
+ import { appendAuditEvent, listAuditEventsAfter } from './audit.js';
4
4
  import { closeHippoDb, openHippoDb, withWriteScope } from './db.js';
5
+ import { StoreNotPortedError } from './db/sqlite-blocked.js';
5
6
  import { embeddingIndexStateAt, loadStoredVectors } from './embeddings.js';
6
7
  import { activeGoalsWithPolicies, localGoalRecallRows, writeGoalRecallLog, } from './goals.js';
7
8
  import { loadPhysicsState } from './physics-state.js';
8
9
  import { planningFallacyEvidenceAt } from './predictions/planning-fallacy.js';
9
10
  import { writeRecallTrace } from './recall-trace.js';
10
11
  import { loadEntriesByIds, loadFreshRawMemories } from './store/entry-reads.js';
12
+ import { auditHighIdAt, revokeKeyAt } from './store/key-audit.js';
11
13
  import { strengthenRetrievedInOwnTx } from './store/entry-writes.js';
12
14
  import { loadLatestHandoff } from './store/handoffs.js';
13
15
  import { updateStats } from './store/index-and-stats.js';
14
16
  import { loadRecallSearchEntries, loadVectorCandidateEntries } from './store/search-rows.js';
15
17
  import { listSessionEvents, loadActiveTaskSnapshot } from './store/sessions.js';
16
18
  import { recordTokenUse } from './token-ledger.js';
19
+ export function hasGroup(store, group) {
20
+ return group === 'base' || store[group] !== undefined;
21
+ }
22
+ /** The group's methods; a store without them throws StoreNotPortedError, which answers 501 as any unported path does. */
23
+ export function requireGroup(store, group) {
24
+ const groups = store;
25
+ const methods = groups[group];
26
+ if (methods === undefined)
27
+ throw new StoreNotPortedError(store.kind, group);
28
+ return methods;
29
+ }
17
30
  function onHandle(hippoRoot, fn) {
18
31
  const db = openHippoDb(hippoRoot);
19
32
  try {
@@ -84,9 +97,23 @@ export function sqliteStore(hippoRoot) {
84
97
  return ids.length === 0 ? new Map() : onHandle(hippoRoot, (db) => loadPhysicsState(db, [...ids]));
85
98
  },
86
99
  },
100
+ keyAudit: sqliteKeyAudit(hippoRoot),
87
101
  async close() { },
88
102
  };
89
103
  }
104
+ function sqliteKeyAudit(hippoRoot) {
105
+ return {
106
+ async revokeApiKey(revoke) {
107
+ return onHandle(hippoRoot, (db) => revokeKeyAt(db, revoke));
108
+ },
109
+ async auditEventsAfter(opts) {
110
+ return onHandle(hippoRoot, (db) => listAuditEventsAfter(db, opts));
111
+ },
112
+ async auditHighId() {
113
+ return onHandle(hippoRoot, auditHighIdAt);
114
+ },
115
+ };
116
+ }
90
117
  /** sqliteStore's continuity read, for the synchronous recall that cannot await the port. */
91
118
  export function continuityAt(hippoRoot, tenantId, eventLimit, key) {
92
119
  const activeSnapshot = loadActiveTaskSnapshot(hippoRoot, tenantId, key ?? undefined);