hippo-memory 1.57.0 → 1.58.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 (91) hide show
  1. package/README.md +11 -0
  2. package/dist/agent-memories/claude-code.js +1 -1
  3. package/dist/agent-memories/gemini.js +1 -1
  4. package/dist/api-errors.d.ts +27 -0
  5. package/dist/api-errors.js +37 -0
  6. package/dist/api.d.ts +5 -5
  7. package/dist/api.js +40 -47
  8. package/dist/audit.d.ts +4 -0
  9. package/dist/audit.js +11 -0
  10. package/dist/autolearn.d.ts +1 -1
  11. package/dist/autolearn.js +7 -5
  12. package/dist/capture-contract.d.ts +47 -0
  13. package/dist/capture-contract.js +49 -0
  14. package/dist/capture-error.js +2 -1
  15. package/dist/capture.d.ts +0 -13
  16. package/dist/capture.js +5 -66
  17. package/dist/cli/shared.js +10 -6
  18. package/dist/cli.js +100 -39
  19. package/dist/client.js +9 -0
  20. package/dist/codex-patch.js +1 -1
  21. package/dist/compaction-record.d.ts +1 -1
  22. package/dist/compaction-record.js +3 -2
  23. package/dist/config.d.ts +5 -0
  24. package/dist/config.js +17 -0
  25. package/dist/connectors/github/dlq.js +5 -2
  26. package/dist/connectors/github/octokit-client.js +4 -2
  27. package/dist/connectors/slack/dlq.js +6 -2
  28. package/dist/connectors/slack/web-client.js +7 -5
  29. package/dist/consolidate.d.ts +10 -0
  30. package/dist/consolidate.js +36 -34
  31. package/dist/customer-notes.js +14 -13
  32. package/dist/dag.js +3 -2
  33. package/dist/dashboard.js +1 -1
  34. package/dist/db.d.ts +12 -0
  35. package/dist/db.js +62 -1
  36. package/dist/decisions.js +9 -8
  37. package/dist/doctor.js +5 -0
  38. package/dist/embedding-provider.js +3 -3
  39. package/dist/embeddings.d.ts +4 -4
  40. package/dist/embeddings.js +72 -16
  41. package/dist/extract.js +3 -2
  42. package/dist/http-retry.d.ts +21 -0
  43. package/dist/http-retry.js +50 -0
  44. package/dist/http-util.d.ts +8 -0
  45. package/dist/http-util.js +10 -0
  46. package/dist/importers.d.ts +2 -0
  47. package/dist/importers.js +16 -5
  48. package/dist/incidents.js +11 -10
  49. package/dist/judgment.js +10 -17
  50. package/dist/log.d.ts +25 -0
  51. package/dist/log.js +48 -0
  52. package/dist/mcp/server.js +52 -24
  53. package/dist/mcp/tool-args.d.ts +21 -0
  54. package/dist/mcp/tool-args.js +80 -0
  55. package/dist/memory.js +3 -2
  56. package/dist/overlap-index.d.ts +7 -0
  57. package/dist/overlap-index.js +38 -0
  58. package/dist/pilot-arm.d.ts +9 -0
  59. package/dist/pilot-arm.js +47 -0
  60. package/dist/policies.js +12 -11
  61. package/dist/predictions.js +9 -8
  62. package/dist/processes.js +14 -13
  63. package/dist/project-briefs.js +16 -15
  64. package/dist/project-identity.d.ts +1 -1
  65. package/dist/project-identity.js +25 -1
  66. package/dist/raw-archive.js +7 -6
  67. package/dist/recall-scope.d.ts +2 -1
  68. package/dist/recall-scope.js +2 -1
  69. package/dist/refine-llm.js +3 -2
  70. package/dist/reject-flow.js +6 -9
  71. package/dist/rejection.d.ts +2 -1
  72. package/dist/rejection.js +2 -1
  73. package/dist/search.js +14 -2
  74. package/dist/secret-detect.d.ts +13 -1
  75. package/dist/secret-detect.js +33 -1
  76. package/dist/server.d.ts +3 -1
  77. package/dist/server.js +180 -409
  78. package/dist/session-digest.js +2 -1
  79. package/dist/shared.js +7 -6
  80. package/dist/skills.js +15 -14
  81. package/dist/store.js +4 -4
  82. package/dist/token-ledger.d.ts +4 -2
  83. package/dist/token-ledger.js +2 -2
  84. package/dist/version.d.ts +1 -1
  85. package/dist/version.js +1 -1
  86. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  87. package/extensions/openclaw-plugin/package.json +1 -1
  88. package/openclaw.plugin.json +1 -1
  89. package/package.json +1 -1
  90. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  91. package/dist/connectors/slack/ratelimit.js +0 -18
@@ -39,6 +39,7 @@ export function __resetSessionRecallHistoryMcp() {
39
39
  import { openHippoDb, closeHippoDb } from '../db.js';
40
40
  import { recordTokenUse } from '../token-ledger.js';
41
41
  import { PACKAGE_VERSION } from '../version.js';
42
+ import { validateToolArgs } from './tool-args.js';
42
43
  // ── Find hippo root ──
43
44
  /** Same bounded walk as the CLI (ends at home, so HIPPO_HOME wins over ~/.hippo); cwd/opts are the test seam. */
44
45
  export function findHippoRoot(cwd = process.cwd(), opts) {
@@ -205,6 +206,10 @@ function planningSection(r) {
205
206
  return '';
206
207
  }
207
208
  // ── Tool definitions ──
209
+ // HTTP sets no budget cap; 25x the 4000 recall default leaves room for large-context clients while bounding one call's work.
210
+ const MAX_BUDGET_TOKENS = 100_000;
211
+ // Same ceiling as the HTTP list routes' parseListLimit.
212
+ const MAX_LIST_LIMIT = 1000;
208
213
  const TOOLS = [
209
214
  {
210
215
  name: 'hippo_recall',
@@ -213,7 +218,12 @@ const TOOLS = [
213
218
  type: 'object',
214
219
  properties: {
215
220
  query: { type: 'string', description: 'What to search for in memory (natural language)' },
216
- budget: { type: 'number', description: 'Max tokens to return (default: config.defaultBudget, 4000)' },
221
+ budget: {
222
+ type: 'number',
223
+ minimum: 0,
224
+ maximum: MAX_BUDGET_TOKENS,
225
+ description: `Max tokens to return (default: config.defaultBudget, 4000; max ${MAX_BUDGET_TOKENS})`,
226
+ },
217
227
  include_continuity: {
218
228
  type: 'boolean',
219
229
  description: 'Append continuity context (active snapshot + handoff + last 5 session events) below the memory results. Useful at session boot.',
@@ -259,7 +269,9 @@ const TOOLS = [
259
269
  },
260
270
  budget: {
261
271
  type: 'number',
262
- description: 'Token budget for the assembled context (default 4000). Eviction kicks in over budget.',
272
+ minimum: 0,
273
+ maximum: MAX_BUDGET_TOKENS,
274
+ description: `Token budget for the assembled context (default 4000; max ${MAX_BUDGET_TOKENS}). Eviction kicks in over budget.`,
263
275
  },
264
276
  fresh_tail_count: {
265
277
  type: 'number',
@@ -289,11 +301,15 @@ const TOOLS = [
289
301
  },
290
302
  limit: {
291
303
  type: 'number',
292
- description: 'Max children to return (default 50).',
304
+ minimum: 0,
305
+ maximum: MAX_LIST_LIMIT,
306
+ description: `Max children to return (default 50; max ${MAX_LIST_LIMIT}).`,
293
307
  },
294
308
  budget: {
295
309
  type: 'number',
296
- description: 'Max total token cost (~ chars/4) of returned children. Truncates chronologically.',
310
+ minimum: 0,
311
+ maximum: MAX_BUDGET_TOKENS,
312
+ description: `Max total token cost (~ chars/4) of returned children (max ${MAX_BUDGET_TOKENS}). Truncates chronologically.`,
297
313
  },
298
314
  depth: {
299
315
  type: 'integer',
@@ -342,7 +358,12 @@ const TOOLS = [
342
358
  inputSchema: {
343
359
  type: 'object',
344
360
  properties: {
345
- budget: { type: 'number', minimum: 0, description: 'Max tokens (default: config.defaultContextBudget, 3000)' },
361
+ budget: {
362
+ type: 'number',
363
+ minimum: 0,
364
+ maximum: MAX_BUDGET_TOKENS,
365
+ description: `Max tokens (default: config.defaultContextBudget, 3000; max ${MAX_BUDGET_TOKENS})`,
366
+ },
346
367
  scope: {
347
368
  type: 'string',
348
369
  description: 'Restrict memories, snapshot, handoff and trail to this scope exactly. When omitted, default-deny applies to ANY <source>:private:* (slack, github, ...) and unknown-legacy rows.',
@@ -426,6 +447,9 @@ const TOOLS = [
426
447
  },
427
448
  },
428
449
  ];
450
+ const TOOLS_BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
451
+ // api.retrieve rejects these itself, so MCP and HTTP callers get the same typed error code for the same bad value.
452
+ const ARGS_CHECKED_BY_API = new Map([['hippo_recall', new Set(['scorer_window'])]]);
429
453
  // ── Track last recalled IDs for outcome feedback ──
430
454
  //
431
455
  // Keyed per-client so two HTTP-MCP clients hitting the same tenant cannot
@@ -522,12 +546,7 @@ async function executeTool(name, args, ctx) {
522
546
  const summarizeOverflow = isJsonBoolean(args.summarize_overflow)
523
547
  ? args.summarize_overflow
524
548
  : undefined;
525
- // v1.7.2 T4 — scorer_window: Number-coerce so non-numeric input
526
- // (string 'abc', boolean, etc.) reaches api.retrieve() and produces
527
- // the same typed RecallContractError(code='invalid_scorer_window')
528
- // as HTTP. Codex CRITICAL[2]: do NOT use `typeof === 'number'` — that
529
- // would silently default-200 on string `"5"` while HTTP 400s on the
530
- // same value. Both transports must agree.
549
+ // Number-coerce, never typeof-check: "abc" must reach api.retrieve and fail as invalid_scorer_window, the same code HTTP returns.
531
550
  const scorerWindow = args.scorer_window === undefined
532
551
  ? undefined
533
552
  : Number(args.scorer_window);
@@ -760,17 +779,8 @@ async function executeTool(name, args, ctx) {
760
779
  return 'No summary_id provided.';
761
780
  const limit = Number(args.limit);
762
781
  const budget = Number(args.budget);
763
- // v0.30 / E5: depth walks N levels (default 1, hard cap 10).
764
- // independent-review MED #5 fold: reject out-of-range explicitly
765
- // (no silent clamp) so MCP callers see the constraint at their layer.
766
- let depth;
767
- if (args.depth !== undefined) {
768
- const depthRaw = Number(args.depth);
769
- if (!Number.isInteger(depthRaw) || depthRaw < 1 || depthRaw > 10) {
770
- return `depth must be an integer between 1 and 10 (got ${args.depth})`;
771
- }
772
- depth = depthRaw;
773
- }
782
+ // The inputSchema rejects a depth outside 1..10 before this runs, so no silent clamp hides the cap.
783
+ const depth = args.depth === undefined ? undefined : Number(args.depth);
774
784
  const apiCtx = {
775
785
  hippoRoot,
776
786
  tenantId,
@@ -862,7 +872,8 @@ async function executeTool(name, args, ctx) {
862
872
  }
863
873
  const halfLife = entry?.half_life_days ?? config.defaultHalfLifeDays;
864
874
  const tagStr = entry?.tags.join(', ') || tags.join(', ') || 'none';
865
- return `Remembered [${result.id}] (half-life: ${halfLife}d, tags: ${tagStr})`;
875
+ const warnings = (result.warnings ?? []).map((w) => `\nWarning: ${w}`).join('');
876
+ return `Remembered [${result.id}] (half-life: ${halfLife}d, tags: ${tagStr})${warnings}`;
866
877
  }
867
878
  case 'hippo_outcome': {
868
879
  const good = Boolean(args.good);
@@ -1043,7 +1054,8 @@ async function executeTool(name, args, ctx) {
1043
1054
  return peers.map((p) => `${p.project}: ${p.count} memories (latest: ${p.latest.slice(0, 10)})`).join('\n');
1044
1055
  }
1045
1056
  default:
1046
- return `Unknown tool: ${name}`;
1057
+ // handleMcpRequest rejects names missing from TOOLS, so reaching here means TOOLS and this switch drifted apart.
1058
+ throw new Error(`hippo-mcp: tool ${name} is declared but has no handler`);
1047
1059
  }
1048
1060
  }
1049
1061
  // ── Request handling ──
@@ -1074,8 +1086,24 @@ export async function handleMcpRequest(req, ctx) {
1074
1086
  case 'tools/call': {
1075
1087
  const nameValue = params?.name;
1076
1088
  const toolName = isJsonString(nameValue) ? nameValue : '';
1089
+ const tool = TOOLS_BY_NAME.get(toolName);
1090
+ if (!tool) {
1091
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${toolName.slice(0, 128)}` } };
1092
+ }
1077
1093
  const argumentsValue = params?.arguments;
1094
+ if (argumentsValue !== undefined && argumentsValue !== null && !isJsonObjectRecord(argumentsValue)) {
1095
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: `${toolName}: arguments must be an object` } };
1096
+ }
1078
1097
  const toolArgs = isJsonObjectRecord(argumentsValue) ? argumentsValue : {};
1098
+ // The MCP spec reports input validation as a tool result with isError, so the model can read it and retry.
1099
+ const problems = validateToolArgs(tool.inputSchema, toolArgs, ARGS_CHECKED_BY_API.get(toolName));
1100
+ if (problems.length > 0) {
1101
+ return {
1102
+ jsonrpc: '2.0',
1103
+ id,
1104
+ result: { content: [{ type: 'text', text: `Invalid arguments for ${toolName}: ${problems.join('; ')}` }], isError: true },
1105
+ };
1106
+ }
1079
1107
  const output = await executeTool(toolName, toolArgs, ctx);
1080
1108
  recordMcpTokens(toolName, output, ctx);
1081
1109
  return {
@@ -0,0 +1,21 @@
1
+ export type ToolArgValue = string | number | boolean | null | ToolArgValue[] | {
2
+ [key: string]: ToolArgValue;
3
+ };
4
+ /** One property of a tool's inputSchema. Keywords outside this subset are not supported. */
5
+ export interface ToolPropertySchema {
6
+ readonly type: 'string' | 'number' | 'integer' | 'boolean';
7
+ readonly description?: string;
8
+ readonly enum?: readonly (string | number | boolean)[];
9
+ readonly minimum?: number;
10
+ readonly maximum?: number;
11
+ readonly maxLength?: number;
12
+ }
13
+ /** A tool's inputSchema: an object with named properties and an optional required list. */
14
+ export interface ToolInputSchema {
15
+ readonly type: 'object';
16
+ readonly properties: Readonly<Record<string, ToolPropertySchema>>;
17
+ readonly required?: readonly string[];
18
+ }
19
+ /** One message per violation; undeclared properties pass, and `checkedDownstream` names keep the API layer's own error contract. */
20
+ export declare function validateToolArgs(schema: ToolInputSchema, args: Readonly<Record<string, ToolArgValue>>, checkedDownstream?: ReadonlySet<string>): string[];
21
+ //# sourceMappingURL=tool-args.d.ts.map
@@ -0,0 +1,80 @@
1
+ // Hand-written for the JSON Schema subset the hippo tool definitions use, so the MCP server needs no validator dependency.
2
+ function isArgString(v) {
3
+ return typeof v === 'string';
4
+ }
5
+ function isArgBoolean(v) {
6
+ return typeof v === 'boolean';
7
+ }
8
+ function isArgNumber(v) {
9
+ return typeof v === 'number' && Number.isFinite(v);
10
+ }
11
+ const NUMERIC_STRING = /^-?\d+(\.\d+)?$/;
12
+ // LLM clients often send numbers as strings, and the handlers Number-coerce, so "4000" counts as 4000 while "12abc" does not.
13
+ function asNumber(v) {
14
+ if (isArgNumber(v))
15
+ return v;
16
+ if (isArgString(v) && NUMERIC_STRING.test(v.trim()))
17
+ return Number(v.trim());
18
+ return null;
19
+ }
20
+ function describeValue(v) {
21
+ if (v === null)
22
+ return 'null';
23
+ if (Array.isArray(v))
24
+ return 'array';
25
+ if (isArgString(v))
26
+ return JSON.stringify(v.length > 40 ? `${v.slice(0, 40)}...` : v);
27
+ if (isArgNumber(v) || isArgBoolean(v))
28
+ return String(v);
29
+ return 'object';
30
+ }
31
+ function checkProperty(name, schema, raw) {
32
+ const got = ` (got ${describeValue(raw)})`;
33
+ const isNumeric = schema.type === 'number' || schema.type === 'integer';
34
+ const value = isNumeric ? asNumber(raw) : raw;
35
+ switch (schema.type) {
36
+ case 'string':
37
+ if (!isArgString(value))
38
+ return `${name} must be a string${got}`;
39
+ if (schema.maxLength !== undefined && value.length > schema.maxLength) {
40
+ return `${name} must be at most ${schema.maxLength} characters (got ${value.length})`;
41
+ }
42
+ break;
43
+ case 'boolean':
44
+ if (!isArgBoolean(value))
45
+ return `${name} must be a boolean${got}`;
46
+ break;
47
+ case 'number':
48
+ case 'integer':
49
+ if (value === null || !isArgNumber(value))
50
+ return `${name} must be a number${got}`;
51
+ if (schema.type === 'integer' && !Number.isInteger(value))
52
+ return `${name} must be an integer${got}`;
53
+ if (schema.minimum !== undefined && value < schema.minimum)
54
+ return `${name} must be >= ${schema.minimum}${got}`;
55
+ if (schema.maximum !== undefined && value > schema.maximum)
56
+ return `${name} must be <= ${schema.maximum}${got}`;
57
+ break;
58
+ }
59
+ if (schema.enum !== undefined && !schema.enum.some((allowed) => allowed === value)) {
60
+ return `${name} must be one of ${schema.enum.map((e) => JSON.stringify(e)).join(', ')}${got}`;
61
+ }
62
+ return null;
63
+ }
64
+ /** One message per violation; undeclared properties pass, and `checkedDownstream` names keep the API layer's own error contract. */
65
+ export function validateToolArgs(schema, args, checkedDownstream = new Set()) {
66
+ const problems = [];
67
+ for (const name of schema.required ?? []) {
68
+ if (!Object.hasOwn(args, name))
69
+ problems.push(`${name} is required`);
70
+ }
71
+ for (const [name, propSchema] of Object.entries(schema.properties)) {
72
+ if (!Object.hasOwn(args, name) || checkedDownstream.has(name))
73
+ continue;
74
+ const problem = checkProperty(name, propSchema, args[name]);
75
+ if (problem !== null)
76
+ problems.push(problem);
77
+ }
78
+ return problems;
79
+ }
80
+ //# sourceMappingURL=tool-args.js.map
package/dist/memory.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * Core data model for Hippo memory entries.
3
3
  * Based on the strength formula from PLAN.md.
4
4
  */
5
+ import { BadRequestError } from './api-errors.js';
5
6
  import { randomUUID } from 'crypto';
6
7
  import { isDecayAblated, isOutcomeSlowAblated, isRecallBoostAblated, evalNow, } from './ablation.js';
7
8
  import { AGENT_MEMORY_TOOLS, toolSourcePrefix } from './agent-memories/tools.js';
@@ -332,11 +333,11 @@ export function canAutoDelete(entry) {
332
333
  export function createMemory(content, options = {}) {
333
334
  const trimmed = content.trim();
334
335
  if (trimmed.length < 3) {
335
- throw new Error(`Memory content too short (${trimmed.length} chars, minimum 3): "${trimmed}"`);
336
+ throw new BadRequestError(`Memory content too short (${trimmed.length} chars, minimum 3): "${trimmed}"`);
336
337
  }
337
338
  const validOutcomes = ['success', 'failure', 'partial', null];
338
339
  if (options.trace_outcome !== undefined && !validOutcomes.includes(options.trace_outcome)) {
339
- throw new Error(`Invalid trace_outcome: ${options.trace_outcome}. Must be 'success', 'failure', 'partial', or null.`);
340
+ throw new BadRequestError(`Invalid trace_outcome: ${options.trace_outcome}. Must be 'success', 'failure', 'partial', or null.`);
340
341
  }
341
342
  const now = evalNow().toISOString(); // honors HIPPO_FAKE_NOW (eval-only)
342
343
  const layer = options.layer ?? Layer.Episodic;
@@ -0,0 +1,7 @@
1
+ /** Smallest number of shared tokens any qualifying pair can have, given either side's token count. */
2
+ export type MinShared = (size: number) => number;
3
+ /** Shared >= threshold * union >= threshold * size; one token of slack covers float rounding in the caller's Jaccard check. */
4
+ export declare function jaccardMinShared(threshold: number, atLeast?: number): MinShared;
5
+ /** Maps i to each j > i, ascending, that may share `minShared` tokens with set i; a superset the caller checks exactly, never a pair sharing no token. */
6
+ export declare function overlapPartners(sets: readonly ReadonlySet<string>[], minShared: MinShared): (i: number) => number[];
7
+ //# sourceMappingURL=overlap-index.d.ts.map
@@ -0,0 +1,38 @@
1
+ // All-pairs overlap join through an inverted index over rare-first token prefixes (prefix filtering, Chaudhuri et al. 2006).
2
+ /** Shared >= threshold * union >= threshold * size; one token of slack covers float rounding in the caller's Jaccard check. */
3
+ export function jaccardMinShared(threshold, atLeast = 1) {
4
+ return (size) => Math.max(atLeast, Math.ceil(threshold * size) - 1);
5
+ }
6
+ /** Maps i to each j > i, ascending, that may share `minShared` tokens with set i; a superset the caller checks exactly, never a pair sharing no token. */
7
+ export function overlapPartners(sets, minShared) {
8
+ const docFreq = new Map();
9
+ for (const set of sets)
10
+ for (const t of set)
11
+ docFreq.set(t, (docFreq.get(t) ?? 0) + 1);
12
+ const rareFirst = (a, b) => ((docFreq.get(a) ?? 0) - (docFreq.get(b) ?? 0)) || (a < b ? -1 : a > b ? 1 : 0);
13
+ // Under one total order the first token two sets share lies in both sets' first size - minShared + 1 tokens.
14
+ const prefixes = sets.map((set) => {
15
+ const keep = set.size - Math.max(1, minShared(set.size)) + 1;
16
+ return keep > 0 ? [...set].sort(rareFirst).slice(0, keep) : [];
17
+ });
18
+ const postings = new Map();
19
+ prefixes.forEach((prefix, i) => {
20
+ for (const t of prefix) {
21
+ const list = postings.get(t);
22
+ if (list)
23
+ list.push(i);
24
+ else
25
+ postings.set(t, [i]);
26
+ }
27
+ });
28
+ return (i) => {
29
+ const found = new Set();
30
+ for (const t of prefixes[i]) {
31
+ const list = postings.get(t) ?? [];
32
+ for (let k = list.length - 1; k >= 0 && list[k] > i; k--)
33
+ found.add(list[k]);
34
+ }
35
+ return [...found].sort((a, b) => a - b);
36
+ };
37
+ }
38
+ //# sourceMappingURL=overlap-index.js.map
@@ -0,0 +1,9 @@
1
+ import { type DatabaseSyncLike } from './db.js';
2
+ export type PilotArm = 'hippo' | 'holdout';
3
+ /** Deterministic split: the same session and rate always land in the same arm. */
4
+ export declare function hashArm(sessionId: string, rateBp: number): PilotArm;
5
+ /** The session's first stored arm, or null; never writes. No tenant filter: a session has one arm. */
6
+ export declare function readPilotArm(db: DatabaseSyncLike, sessionId: string): PilotArm | null;
7
+ /** The stored arm, else the hash arm written once; on any error the hash arm comes back unrecorded. */
8
+ export declare function ensurePilotArm(db: DatabaseSyncLike, tenantId: string, sessionId: string, rateBp: number, now?: string): PilotArm;
9
+ //# sourceMappingURL=pilot-arm.d.ts.map
@@ -0,0 +1,47 @@
1
+ // Pilot arm: one token_ledger row per session names its arm, `hippo` or `holdout`, so a pilot can compare them.
2
+ // `items` holds the holdout rate in basis points; readers outside this repo depend on these rows, so their shape is fixed.
3
+ import { createHash } from 'node:crypto';
4
+ import { execWithBusyRetry, HOOK_DB_WAIT_MS } from './db.js';
5
+ import { recordTokenUse } from './token-ledger.js';
6
+ /** Deterministic split: the same session and rate always land in the same arm. */
7
+ export function hashArm(sessionId, rateBp) {
8
+ const bucket = parseInt(createHash('sha256').update(sessionId).digest('hex').slice(0, 8), 16) % 10000;
9
+ return bucket < rateBp ? 'holdout' : 'hippo';
10
+ }
11
+ /** The session's first stored arm, or null; never writes. No tenant filter: a session has one arm. */
12
+ export function readPilotArm(db, sessionId) {
13
+ // SAFETY: the SELECT names exactly this one column.
14
+ const row = db.prepare(`SELECT block_hash FROM token_ledger WHERE session_id = ? AND surface = 'pilot' AND event = 'arm' ORDER BY id LIMIT 1`).get(sessionId);
15
+ return row?.block_hash === 'holdout' || row?.block_hash === 'hippo' ? row.block_hash : null;
16
+ }
17
+ /** The stored arm, else the hash arm written once; on any error the hash arm comes back unrecorded. */
18
+ // The hook's lock-wait bound holds only on a handle opened with `busyWaitMs: HOOK_DB_WAIT_MS`, as hook commands are.
19
+ export function ensurePilotArm(db, tenantId, sessionId, rateBp, now) {
20
+ const hashed = hashArm(sessionId, rateBp);
21
+ let began = false;
22
+ try {
23
+ // A stored row is the common case after the first prompt, so it must not take the write lock.
24
+ const existing = readPilotArm(db, sessionId);
25
+ if (existing !== null)
26
+ return existing;
27
+ execWithBusyRetry(db, 'BEGIN IMMEDIATE', HOOK_DB_WAIT_MS);
28
+ began = true;
29
+ const stored = readPilotArm(db, sessionId);
30
+ if (stored === null) {
31
+ recordTokenUse(db, { tenantId, sessionId, surface: 'pilot', event: 'arm', items: rateBp, tokens: 0, hash: hashed, now });
32
+ }
33
+ db.exec('COMMIT');
34
+ return stored ?? hashed;
35
+ }
36
+ catch {
37
+ // A prompt hook must not fail on pilot bookkeeping; concurrent callers still agree on the hash arm.
38
+ if (began) {
39
+ try {
40
+ db.exec('ROLLBACK');
41
+ }
42
+ catch { /* keep the hash arm */ }
43
+ }
44
+ return hashed;
45
+ }
46
+ }
47
+ //# sourceMappingURL=pilot-arm.js.map
package/dist/policies.js CHANGED
@@ -36,6 +36,7 @@
36
36
  * Dual-write atomicity: `savePolicy` writes the memory + policies row (and, on
37
37
  * supersede, the predecessor's UPDATE) inside writeEntry's SAVEPOINT.
38
38
  */
39
+ import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
39
40
  import { openHippoDb, closeHippoDb } from './db.js';
40
41
  import { writeEntry } from './store.js';
41
42
  import { assertTenantId } from './tenant.js';
@@ -62,7 +63,7 @@ export const VALID_POLICY_STATES = new Set([
62
63
  export function normalizePolicyDate(input, label = 'date') {
63
64
  const d = new Date(input);
64
65
  if (Number.isNaN(d.getTime())) {
65
- throw new Error(`policy: invalid ${label} "${input}" (expected an ISO-8601 date or datetime)`);
66
+ throw new BadRequestError(`policy: invalid ${label} "${input}" (expected an ISO-8601 date or datetime)`);
66
67
  }
67
68
  return d.toISOString();
68
69
  }
@@ -76,7 +77,7 @@ export function validatePolicyDates(validFromRaw, validToRaw, nowIso) {
76
77
  ? normalizePolicyDate(validToRaw, 'valid_to')
77
78
  : null;
78
79
  if (validTo !== null && validTo <= validFrom) {
79
- throw new Error(`policy: valid_to (${validTo}) must be strictly after valid_from (${validFrom})`);
80
+ throw new BadRequestError(`policy: valid_to (${validTo}) must be strictly after valid_from (${validFrom})`);
80
81
  }
81
82
  return { validFrom, validTo };
82
83
  }
@@ -123,10 +124,10 @@ function buildPolicyContent(policyName, policyText, validFrom, validTo) {
123
124
  export function savePolicy(hippoRoot, tenantId, opts, actor = 'cli') {
124
125
  assertTenantId('savePolicy', tenantId);
125
126
  if (!opts.policyName || opts.policyName.trim().length === 0) {
126
- throw new Error('savePolicy: policyName is required');
127
+ throw new BadRequestError('savePolicy: policyName is required');
127
128
  }
128
129
  if (!opts.policyText || opts.policyText.trim().length === 0) {
129
- throw new Error('savePolicy: policyText is required');
130
+ throw new BadRequestError('savePolicy: policyText is required');
130
131
  }
131
132
  const now = new Date().toISOString();
132
133
  // valid_from defaults to the precise creation instant (the honest effective
@@ -163,10 +164,10 @@ export function savePolicy(hippoRoot, tenantId, opts, actor = 'cli') {
163
164
  // shape for the matching row, or undefined when no policy/tenant pair matches.
164
165
  const pred = db.prepare(`SELECT status, version FROM policies WHERE id = ? AND tenant_id = ?`).get(opts.supersedesPolicyId, tenantId);
165
166
  if (!pred) {
166
- throw new Error(`savePolicy: policy ${opts.supersedesPolicyId} to supersede not found for tenant ${tenantId}`);
167
+ throw new NotFoundError(`savePolicy: policy ${opts.supersedesPolicyId} to supersede not found for tenant ${tenantId}`);
167
168
  }
168
169
  if (pred.status !== 'active') {
169
- throw new Error(`savePolicy: policy ${opts.supersedesPolicyId} is not active (status='${pred.status}'); only active policies can be superseded.`);
170
+ throw new ConflictError(`savePolicy: policy ${opts.supersedesPolicyId} is not active (status='${pred.status}'); only active policies can be superseded.`);
170
171
  }
171
172
  version = pred.version + 1;
172
173
  }
@@ -184,7 +185,7 @@ export function savePolicy(hippoRoot, tenantId, opts, actor = 'cli') {
184
185
  WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
185
186
  `).run(policyId, now, opts.supersedesPolicyId, tenantId, policyId);
186
187
  if (sup.changes === 0) {
187
- throw new Error(`savePolicy: policy ${opts.supersedesPolicyId} could not be superseded (no longer active or self-reference).`);
188
+ throw new ConflictError(`savePolicy: policy ${opts.supersedesPolicyId} could not be superseded (no longer active or self-reference).`);
188
189
  }
189
190
  appendAuditEvent(db, {
190
191
  tenantId,
@@ -248,9 +249,9 @@ export function closePolicy(hippoRoot, tenantId, id, actor = 'cli') {
248
249
  // shape, or undefined when the id/tenant pair doesn't exist.
249
250
  const existing = db.prepare(`SELECT status FROM policies WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
250
251
  if (!existing) {
251
- throw new Error(`closePolicy: policy ${id} not found for tenant ${tenantId}`);
252
+ throw new NotFoundError(`closePolicy: policy ${id} not found for tenant ${tenantId}`);
252
253
  }
253
- throw new Error(`closePolicy: policy ${id} is not active (status='${existing.status}'); only active policies can be closed.`);
254
+ throw new ConflictError(`closePolicy: policy ${id} is not active (status='${existing.status}'); only active policies can be closed.`);
254
255
  }
255
256
  // SAFETY: SELECT ${POLICY_COLS} projects exactly the PolicyRow columns;
256
257
  // .get() returns that row for the just-updated id, or undefined only in an
@@ -258,7 +259,7 @@ export function closePolicy(hippoRoot, tenantId, id, actor = 'cli') {
258
259
  const row = db.prepare(`SELECT ${POLICY_COLS} FROM policies WHERE id = ? AND tenant_id = ?`)
259
260
  .get(id, tenantId);
260
261
  if (!row)
261
- throw new Error(`closePolicy: policy ${id} not found after UPDATE`);
262
+ throw new NotFoundError(`closePolicy: policy ${id} not found after UPDATE`);
262
263
  appendAuditEvent(db, {
263
264
  tenantId,
264
265
  actor,
@@ -315,7 +316,7 @@ export function loadPolicies(hippoRoot, tenantId, opts = {}) {
315
316
  let rows;
316
317
  if (opts.status) {
317
318
  if (!VALID_POLICY_STATES.has(opts.status)) {
318
- throw new Error(`loadPolicies: status must be one of ${Array.from(VALID_POLICY_STATES).join('|')}; got ${opts.status}`);
319
+ throw new BadRequestError(`loadPolicies: status must be one of ${Array.from(VALID_POLICY_STATES).join('|')}; got ${opts.status}`);
319
320
  }
320
321
  // SAFETY: SELECT ${POLICY_COLS} projects exactly the PolicyRow columns;
321
322
  // .all() returns rows in that shape regardless of the status filter applied.
@@ -24,6 +24,7 @@
24
24
  * (estimate_value, actual_value) at query time. J3 is a follow-up episode;
25
25
  * this module ships the data layer.
26
26
  */
27
+ import { BadRequestError, NotFoundError } from './api-errors.js';
27
28
  import { openHippoDb, closeHippoDb } from './db.js';
28
29
  import { writeEntry } from './store.js';
29
30
  import { assertTenantId } from './tenant.js';
@@ -72,9 +73,9 @@ function rowToPrediction(row) {
72
73
  export function savePrediction(hippoRoot, tenantId, opts, actor = 'cli') {
73
74
  assertTenantId('savePrediction', tenantId);
74
75
  if (!opts.classTag)
75
- throw new Error('savePrediction: classTag is required');
76
+ throw new BadRequestError('savePrediction: classTag is required');
76
77
  if (!opts.claimText)
77
- throw new Error('savePrediction: claimText is required');
78
+ throw new BadRequestError('savePrediction: claimText is required');
78
79
  const now = new Date().toISOString();
79
80
  const mem = createMemory(opts.claimText, {
80
81
  tags: ['prediction', opts.classTag],
@@ -143,7 +144,7 @@ export function savePrediction(hippoRoot, tenantId, opts, actor = 'cli') {
143
144
  export function closePrediction(hippoRoot, tenantId, id, opts, actor = 'cli') {
144
145
  assertTenantId('closePrediction', tenantId);
145
146
  if (!VALID_CLOSURE_STATES.has(opts.closureState)) {
146
- throw new Error(`closePrediction: closureState must be one of ${Array.from(VALID_CLOSURE_STATES).join('|')}; got ${opts.closureState}`);
147
+ throw new BadRequestError(`closePrediction: closureState must be one of ${Array.from(VALID_CLOSURE_STATES).join('|')}; got ${opts.closureState}`);
147
148
  }
148
149
  const now = new Date().toISOString();
149
150
  const db = openHippoDb(hippoRoot);
@@ -171,9 +172,9 @@ export function closePrediction(hippoRoot, tenantId, id, opts, actor = 'cli') {
171
172
  SELECT closure_state FROM predictions WHERE id = ? AND tenant_id = ?
172
173
  `).get(id, tenantId);
173
174
  if (!existing) {
174
- throw new Error(`closePrediction: prediction ${id} not found for tenant ${tenantId}`);
175
+ throw new NotFoundError(`closePrediction: prediction ${id} not found for tenant ${tenantId}`);
175
176
  }
176
- throw new Error(`closePrediction: prediction ${id} is already closed (state='${existing.closure_state}'); ` +
177
+ throw new BadRequestError(`closePrediction: prediction ${id} is already closed (state='${existing.closure_state}'); ` +
177
178
  `cannot re-close. Open predictions only.`);
178
179
  }
179
180
  // SAFETY: row's shape matches the columns named in the SELECT above.
@@ -184,7 +185,7 @@ export function closePrediction(hippoRoot, tenantId, id, opts, actor = 'cli') {
184
185
  FROM predictions WHERE id = ? AND tenant_id = ?
185
186
  `).get(id, tenantId);
186
187
  if (!row) {
187
- throw new Error(`closePrediction: prediction ${id} not found after UPDATE`);
188
+ throw new NotFoundError(`closePrediction: prediction ${id} not found after UPDATE`);
188
189
  }
189
190
  appendAuditEvent(db, {
190
191
  tenantId,
@@ -239,7 +240,7 @@ export function loadPredictionsByClass(hippoRoot, tenantId, classTag, opts = {})
239
240
  let rows;
240
241
  if (opts.closureState) {
241
242
  if (!VALID_CLOSURE_STATES.has(opts.closureState)) {
242
- throw new Error(`loadPredictionsByClass: closureState must be one of ${Array.from(VALID_CLOSURE_STATES).join('|')}; got ${opts.closureState}`);
243
+ throw new BadRequestError(`loadPredictionsByClass: closureState must be one of ${Array.from(VALID_CLOSURE_STATES).join('|')}; got ${opts.closureState}`);
243
244
  }
244
245
  // SAFETY: rows' shape matches the columns named in the SELECT above.
245
246
  rows = db.prepare(`
@@ -296,7 +297,7 @@ export function computePredictionBaserate(hippoRoot, tenantId, classTag, actor =
296
297
  emitAudit = true) {
297
298
  assertTenantId('computePredictionBaserate', tenantId);
298
299
  if (!classTag)
299
- throw new Error('computePredictionBaserate: classTag is required');
300
+ throw new BadRequestError('computePredictionBaserate: classTag is required');
300
301
  const db = openHippoDb(hippoRoot);
301
302
  try {
302
303
  // SAFETY: rows' shape matches the two columns named in the SELECT above.
package/dist/processes.js CHANGED
@@ -30,6 +30,7 @@
30
30
  * 'write_entry' via the afterWrite hook, so a failure in any step rolls all of
31
31
  * them back. Pattern matches saveDecision (decisions.ts).
32
32
  */
33
+ import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
33
34
  import { openHippoDb, closeHippoDb } from './db.js';
34
35
  import { writeEntry } from './store.js';
35
36
  import { assertTenantId } from './tenant.js';
@@ -58,23 +59,23 @@ export const MAX_PROCESS_STEP_LEN = 2000;
58
59
  */
59
60
  export function validateProcessSteps(steps) {
60
61
  if (!Array.isArray(steps)) {
61
- throw new Error('saveProcess: steps must be an array of strings');
62
+ throw new BadRequestError('saveProcess: steps must be an array of strings');
62
63
  }
63
64
  if (steps.length > MAX_PROCESS_STEPS) {
64
- throw new Error(`saveProcess: steps exceeds the ${MAX_PROCESS_STEPS}-step cap (got ${steps.length})`);
65
+ throw new BadRequestError(`saveProcess: steps exceeds the ${MAX_PROCESS_STEPS}-step cap (got ${steps.length})`);
65
66
  }
66
67
  const out = [];
67
68
  for (let i = 0; i < steps.length; i++) {
68
69
  const raw = steps[i];
69
70
  if (!isString(raw)) {
70
- throw new Error(`saveProcess: step ${i + 1} is not a string`);
71
+ throw new BadRequestError(`saveProcess: step ${i + 1} is not a string`);
71
72
  }
72
73
  const trimmed = raw.trim();
73
74
  if (trimmed.length === 0) {
74
- throw new Error(`saveProcess: step ${i + 1} is empty`);
75
+ throw new BadRequestError(`saveProcess: step ${i + 1} is empty`);
75
76
  }
76
77
  if (trimmed.length > MAX_PROCESS_STEP_LEN) {
77
- throw new Error(`saveProcess: step ${i + 1} exceeds the ${MAX_PROCESS_STEP_LEN}-char cap`);
78
+ throw new BadRequestError(`saveProcess: step ${i + 1} exceeds the ${MAX_PROCESS_STEP_LEN}-char cap`);
78
79
  }
79
80
  out.push(trimmed);
80
81
  }
@@ -143,7 +144,7 @@ function buildProcessContent(processName, steps, description) {
143
144
  export function saveProcess(hippoRoot, tenantId, opts, actor = 'cli') {
144
145
  assertTenantId('saveProcess', tenantId);
145
146
  if (!opts.processName || opts.processName.trim().length === 0) {
146
- throw new Error('saveProcess: processName is required');
147
+ throw new BadRequestError('saveProcess: processName is required');
147
148
  }
148
149
  const steps = validateProcessSteps(opts.steps);
149
150
  const isSupersede = opts.supersedesProcessId !== undefined;
@@ -177,10 +178,10 @@ export function saveProcess(hippoRoot, tenantId, opts, actor = 'cli') {
177
178
  // the two selected columns 1:1.
178
179
  const pred = db.prepare(`SELECT status, version FROM processes WHERE id = ? AND tenant_id = ?`).get(opts.supersedesProcessId, tenantId);
179
180
  if (!pred) {
180
- throw new Error(`saveProcess: process ${opts.supersedesProcessId} to supersede not found for tenant ${tenantId}`);
181
+ throw new NotFoundError(`saveProcess: process ${opts.supersedesProcessId} to supersede not found for tenant ${tenantId}`);
181
182
  }
182
183
  if (pred.status !== 'active') {
183
- throw new Error(`saveProcess: process ${opts.supersedesProcessId} is not active (status='${pred.status}'); only active processes can be superseded.`);
184
+ throw new ConflictError(`saveProcess: process ${opts.supersedesProcessId} is not active (status='${pred.status}'); only active processes can be superseded.`);
184
185
  }
185
186
  version = pred.version + 1;
186
187
  }
@@ -198,7 +199,7 @@ export function saveProcess(hippoRoot, tenantId, opts, actor = 'cli') {
198
199
  WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
199
200
  `).run(processId, now, opts.supersedesProcessId, tenantId, processId);
200
201
  if (sup.changes === 0) {
201
- throw new Error(`saveProcess: process ${opts.supersedesProcessId} could not be superseded (no longer active or self-reference).`);
202
+ throw new ConflictError(`saveProcess: process ${opts.supersedesProcessId} could not be superseded (no longer active or self-reference).`);
202
203
  }
203
204
  appendAuditEvent(db, {
204
205
  tenantId,
@@ -262,16 +263,16 @@ export function closeProcess(hippoRoot, tenantId, id, actor = 'cli') {
262
263
  // single selected column.
263
264
  const existing = db.prepare(`SELECT status FROM processes WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
264
265
  if (!existing) {
265
- throw new Error(`closeProcess: process ${id} not found for tenant ${tenantId}`);
266
+ throw new NotFoundError(`closeProcess: process ${id} not found for tenant ${tenantId}`);
266
267
  }
267
- throw new Error(`closeProcess: process ${id} is not active (status='${existing.status}'); only active processes can be closed.`);
268
+ throw new ConflictError(`closeProcess: process ${id} is not active (status='${existing.status}'); only active processes can be closed.`);
268
269
  }
269
270
  // SAFETY: SELECT ${PROCESS_COLS} enumerates every ProcessRow field
270
271
  // 1:1 (see PROCESS_COLS above).
271
272
  const row = db.prepare(`SELECT ${PROCESS_COLS} FROM processes WHERE id = ? AND tenant_id = ?`)
272
273
  .get(id, tenantId);
273
274
  if (!row)
274
- throw new Error(`closeProcess: process ${id} not found after UPDATE`);
275
+ throw new NotFoundError(`closeProcess: process ${id} not found after UPDATE`);
275
276
  appendAuditEvent(db, {
276
277
  tenantId,
277
278
  actor,
@@ -318,7 +319,7 @@ export function loadProcesses(hippoRoot, tenantId, opts = {}) {
318
319
  let rows;
319
320
  if (opts.status) {
320
321
  if (!VALID_PROCESS_STATES.has(opts.status)) {
321
- throw new Error(`loadProcesses: status must be one of ${Array.from(VALID_PROCESS_STATES).join('|')}; got ${opts.status}`);
322
+ throw new BadRequestError(`loadProcesses: status must be one of ${Array.from(VALID_PROCESS_STATES).join('|')}; got ${opts.status}`);
322
323
  }
323
324
  // SAFETY: SELECT ${PROCESS_COLS} enumerates every ProcessRow field
324
325
  // 1:1 (see PROCESS_COLS above).