hippo-memory 1.56.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 (129) 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 +21 -14
  7. package/dist/api.js +97 -71
  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/card-detail.d.ts +1 -1
  18. package/dist/card-detail.js +1 -1
  19. package/dist/cli/shared.d.ts +137 -0
  20. package/dist/cli/shared.js +834 -0
  21. package/dist/cli/sleep.d.ts +10 -0
  22. package/dist/cli/sleep.js +171 -0
  23. package/dist/cli.d.ts +0 -7
  24. package/dist/cli.js +322 -1827
  25. package/dist/client.js +9 -0
  26. package/dist/codex-patch.js +1 -1
  27. package/dist/compaction-record.d.ts +1 -1
  28. package/dist/compaction-record.js +3 -2
  29. package/dist/config.d.ts +5 -0
  30. package/dist/config.js +17 -0
  31. package/dist/connectors/github/dlq.js +5 -2
  32. package/dist/connectors/github/octokit-client.js +4 -2
  33. package/dist/connectors/github/webhook.d.ts +19 -0
  34. package/dist/connectors/github/webhook.js +313 -0
  35. package/dist/connectors/slack/dlq.js +6 -2
  36. package/dist/connectors/slack/web-client.js +7 -5
  37. package/dist/connectors/slack/webhook.d.ts +22 -0
  38. package/dist/connectors/slack/webhook.js +203 -0
  39. package/dist/consolidate.d.ts +10 -0
  40. package/dist/consolidate.js +38 -35
  41. package/dist/context-auto.d.ts +3 -0
  42. package/dist/context-auto.js +34 -0
  43. package/dist/customer-notes.js +16 -14
  44. package/dist/dag.js +3 -2
  45. package/dist/dashboard.js +3 -2
  46. package/dist/db.d.ts +12 -0
  47. package/dist/db.js +62 -1
  48. package/dist/decisions.js +11 -9
  49. package/dist/doctor.js +5 -0
  50. package/dist/embedding-provider.js +3 -3
  51. package/dist/embeddings.d.ts +4 -4
  52. package/dist/embeddings.js +72 -16
  53. package/dist/eval-stats.d.ts +58 -0
  54. package/dist/eval-stats.js +111 -0
  55. package/dist/extract.js +3 -2
  56. package/dist/goals.d.ts +49 -25
  57. package/dist/goals.js +39 -22
  58. package/dist/graph-extract.js +1 -1
  59. package/dist/graph-recall.d.ts +1 -1
  60. package/dist/graph-recall.js +1 -1
  61. package/dist/graph.js +1 -1
  62. package/dist/hooks.d.ts +1 -3
  63. package/dist/hooks.js +2 -4
  64. package/dist/http-retry.d.ts +21 -0
  65. package/dist/http-retry.js +50 -0
  66. package/dist/http-util.d.ts +39 -0
  67. package/dist/http-util.js +56 -0
  68. package/dist/importers.d.ts +2 -0
  69. package/dist/importers.js +16 -5
  70. package/dist/incidents.js +13 -11
  71. package/dist/index.d.ts +5 -2
  72. package/dist/index.js +5 -2
  73. package/dist/judgment.js +10 -17
  74. package/dist/log.d.ts +25 -0
  75. package/dist/log.js +48 -0
  76. package/dist/mcp/server.js +224 -308
  77. package/dist/mcp/tool-args.d.ts +21 -0
  78. package/dist/mcp/tool-args.js +80 -0
  79. package/dist/memory.d.ts +19 -0
  80. package/dist/memory.js +41 -2
  81. package/dist/overlap-index.d.ts +7 -0
  82. package/dist/overlap-index.js +38 -0
  83. package/dist/pilot-arm.d.ts +9 -0
  84. package/dist/pilot-arm.js +47 -0
  85. package/dist/policies.js +14 -12
  86. package/dist/predictions.js +11 -9
  87. package/dist/processes.js +16 -14
  88. package/dist/project-briefs.js +19 -16
  89. package/dist/project-identity.d.ts +1 -1
  90. package/dist/project-identity.js +25 -1
  91. package/dist/prompt-recall.js +1 -1
  92. package/dist/raw-archive.js +7 -6
  93. package/dist/recall-history.d.ts +5 -0
  94. package/dist/recall-history.js +9 -0
  95. package/dist/recall-pipeline.d.ts +101 -0
  96. package/dist/recall-pipeline.js +313 -0
  97. package/dist/recall-scope.d.ts +24 -1
  98. package/dist/recall-scope.js +29 -2
  99. package/dist/refine-llm.js +3 -2
  100. package/dist/reject-flow.js +6 -9
  101. package/dist/rejection.d.ts +2 -1
  102. package/dist/rejection.js +2 -1
  103. package/dist/search.d.ts +0 -20
  104. package/dist/search.js +16 -51
  105. package/dist/secret-detect.d.ts +13 -1
  106. package/dist/secret-detect.js +33 -1
  107. package/dist/server.d.ts +3 -1
  108. package/dist/server.js +1854 -2566
  109. package/dist/session-digest.js +2 -1
  110. package/dist/shared.js +7 -6
  111. package/dist/skills.js +17 -15
  112. package/dist/store-cards.d.ts +53 -0
  113. package/dist/store-cards.js +512 -0
  114. package/dist/store.d.ts +2 -89
  115. package/dist/store.js +10 -566
  116. package/dist/tenant.d.ts +22 -0
  117. package/dist/tenant.js +26 -0
  118. package/dist/token-ledger.d.ts +4 -2
  119. package/dist/token-ledger.js +2 -2
  120. package/dist/tokenize.d.ts +2 -0
  121. package/dist/tokenize.js +8 -0
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.js +1 -1
  124. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  125. package/extensions/openclaw-plugin/package.json +1 -1
  126. package/openclaw.plugin.json +1 -1
  127. package/package.json +1 -1
  128. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  129. package/dist/connectors/slack/ratelimit.js +0 -18
@@ -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.d.ts CHANGED
@@ -267,4 +267,23 @@ export declare function createSuccessor(old: MemoryEntry, content: string, opts:
267
267
  * Rare shared tags signal stronger schema fit than common ones.
268
268
  */
269
269
  export declare function computeSchemaFit(content: string, tags: string[], existingEntries: MemoryEntry[]): number;
270
+ /**
271
+ * Update retrieval metadata on entries that were returned by a search.
272
+ * Returns the mutated copies (caller must persist to disk).
273
+ *
274
+ * EVAL-ONLY ablation (see ablation.ts): with HIPPO_ABLATE_RECALL_BOOST set,
275
+ * this returns the entries UNMUTATED - neutralizing all three strengthening
276
+ * sub-effects (clock reset, retrieval_count, half-life increment) at the
277
+ * single shared write site. The entries (not an empty array) must be
278
+ * returned because callers derive `last_retrieval_ids` from the return
279
+ * value, and a later `hippo outcome --good/--bad` targets those ids - an
280
+ * empty return would silently co-ablate the outcome channel in the
281
+ * strengthen-off arm. PERSISTENCE is gated separately at
282
+ * each persisting caller (CLI recall, api context, MCP recall/context,
283
+ * consolidation replay): writeEntry on identical rows still refreshes
284
+ * updated_at, rewrites mirrors, and marks DAG parents dirty,
285
+ * so those write loops skip under the flag.
286
+ * The default `now` honors HIPPO_FAKE_NOW (simulated-time protocols).
287
+ */
288
+ export declare function markRetrieved(entries: MemoryEntry[], now?: Date): MemoryEntry[];
270
289
  //# sourceMappingURL=memory.d.ts.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;
@@ -471,4 +472,42 @@ function inferValence(tags) {
471
472
  return 'positive';
472
473
  return 'neutral';
473
474
  }
475
+ /**
476
+ * Update retrieval metadata on entries that were returned by a search.
477
+ * Returns the mutated copies (caller must persist to disk).
478
+ *
479
+ * EVAL-ONLY ablation (see ablation.ts): with HIPPO_ABLATE_RECALL_BOOST set,
480
+ * this returns the entries UNMUTATED - neutralizing all three strengthening
481
+ * sub-effects (clock reset, retrieval_count, half-life increment) at the
482
+ * single shared write site. The entries (not an empty array) must be
483
+ * returned because callers derive `last_retrieval_ids` from the return
484
+ * value, and a later `hippo outcome --good/--bad` targets those ids - an
485
+ * empty return would silently co-ablate the outcome channel in the
486
+ * strengthen-off arm. PERSISTENCE is gated separately at
487
+ * each persisting caller (CLI recall, api context, MCP recall/context,
488
+ * consolidation replay): writeEntry on identical rows still refreshes
489
+ * updated_at, rewrites mirrors, and marks DAG parents dirty,
490
+ * so those write loops skip under the flag.
491
+ * The default `now` honors HIPPO_FAKE_NOW (simulated-time protocols).
492
+ */
493
+ // Confidence is deliberately absent below: it is an epistemic tier, not a
494
+ // recency signal, and a stored 'stale' is always a deliberate mark.
495
+ export function markRetrieved(entries, now = evalNow()) {
496
+ if (isRecallBoostAblated())
497
+ return entries;
498
+ return entries.map((e) => {
499
+ if (e.superseded_by)
500
+ return e;
501
+ const wrong = netWrong(e) > 0;
502
+ const updated = {
503
+ ...e,
504
+ retrieval_count: e.retrieval_count + 1,
505
+ last_retrieved: wrong ? e.last_retrieved : now.toISOString(),
506
+ // +2 days half-life per retrieval (PLAN.md); a wrong memory keeps both, since last_retrieved is the decay anchor
507
+ half_life_days: wrong ? e.half_life_days : e.half_life_days + 2,
508
+ };
509
+ updated.strength = calculateStrength(updated, now);
510
+ return updated;
511
+ });
512
+ }
474
513
  //# sourceMappingURL=memory.js.map
@@ -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,8 +36,10 @@
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
- import { writeEntry, assertTenantId } from './store.js';
41
+ import { writeEntry } from './store.js';
42
+ import { assertTenantId } from './tenant.js';
41
43
  import { markGraphDirty, removeGraphEntitiesForObject } from './graph.js';
42
44
  import { createMemory, Layer } from './memory.js';
43
45
  import { appendAuditEvent } from './audit.js';
@@ -61,7 +63,7 @@ export const VALID_POLICY_STATES = new Set([
61
63
  export function normalizePolicyDate(input, label = 'date') {
62
64
  const d = new Date(input);
63
65
  if (Number.isNaN(d.getTime())) {
64
- 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)`);
65
67
  }
66
68
  return d.toISOString();
67
69
  }
@@ -75,7 +77,7 @@ export function validatePolicyDates(validFromRaw, validToRaw, nowIso) {
75
77
  ? normalizePolicyDate(validToRaw, 'valid_to')
76
78
  : null;
77
79
  if (validTo !== null && validTo <= validFrom) {
78
- 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})`);
79
81
  }
80
82
  return { validFrom, validTo };
81
83
  }
@@ -122,10 +124,10 @@ function buildPolicyContent(policyName, policyText, validFrom, validTo) {
122
124
  export function savePolicy(hippoRoot, tenantId, opts, actor = 'cli') {
123
125
  assertTenantId('savePolicy', tenantId);
124
126
  if (!opts.policyName || opts.policyName.trim().length === 0) {
125
- throw new Error('savePolicy: policyName is required');
127
+ throw new BadRequestError('savePolicy: policyName is required');
126
128
  }
127
129
  if (!opts.policyText || opts.policyText.trim().length === 0) {
128
- throw new Error('savePolicy: policyText is required');
130
+ throw new BadRequestError('savePolicy: policyText is required');
129
131
  }
130
132
  const now = new Date().toISOString();
131
133
  // valid_from defaults to the precise creation instant (the honest effective
@@ -162,10 +164,10 @@ export function savePolicy(hippoRoot, tenantId, opts, actor = 'cli') {
162
164
  // shape for the matching row, or undefined when no policy/tenant pair matches.
163
165
  const pred = db.prepare(`SELECT status, version FROM policies WHERE id = ? AND tenant_id = ?`).get(opts.supersedesPolicyId, tenantId);
164
166
  if (!pred) {
165
- 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}`);
166
168
  }
167
169
  if (pred.status !== 'active') {
168
- 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.`);
169
171
  }
170
172
  version = pred.version + 1;
171
173
  }
@@ -183,7 +185,7 @@ export function savePolicy(hippoRoot, tenantId, opts, actor = 'cli') {
183
185
  WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
184
186
  `).run(policyId, now, opts.supersedesPolicyId, tenantId, policyId);
185
187
  if (sup.changes === 0) {
186
- 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).`);
187
189
  }
188
190
  appendAuditEvent(db, {
189
191
  tenantId,
@@ -247,9 +249,9 @@ export function closePolicy(hippoRoot, tenantId, id, actor = 'cli') {
247
249
  // shape, or undefined when the id/tenant pair doesn't exist.
248
250
  const existing = db.prepare(`SELECT status FROM policies WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
249
251
  if (!existing) {
250
- throw new Error(`closePolicy: policy ${id} not found for tenant ${tenantId}`);
252
+ throw new NotFoundError(`closePolicy: policy ${id} not found for tenant ${tenantId}`);
251
253
  }
252
- 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.`);
253
255
  }
254
256
  // SAFETY: SELECT ${POLICY_COLS} projects exactly the PolicyRow columns;
255
257
  // .get() returns that row for the just-updated id, or undefined only in an
@@ -257,7 +259,7 @@ export function closePolicy(hippoRoot, tenantId, id, actor = 'cli') {
257
259
  const row = db.prepare(`SELECT ${POLICY_COLS} FROM policies WHERE id = ? AND tenant_id = ?`)
258
260
  .get(id, tenantId);
259
261
  if (!row)
260
- throw new Error(`closePolicy: policy ${id} not found after UPDATE`);
262
+ throw new NotFoundError(`closePolicy: policy ${id} not found after UPDATE`);
261
263
  appendAuditEvent(db, {
262
264
  tenantId,
263
265
  actor,
@@ -314,7 +316,7 @@ export function loadPolicies(hippoRoot, tenantId, opts = {}) {
314
316
  let rows;
315
317
  if (opts.status) {
316
318
  if (!VALID_POLICY_STATES.has(opts.status)) {
317
- 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}`);
318
320
  }
319
321
  // SAFETY: SELECT ${POLICY_COLS} projects exactly the PolicyRow columns;
320
322
  // .all() returns rows in that shape regardless of the status filter applied.
@@ -24,8 +24,10 @@
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
- import { writeEntry, assertTenantId } from './store.js';
29
+ import { writeEntry } from './store.js';
30
+ import { assertTenantId } from './tenant.js';
29
31
  import { createMemory, Layer } from './memory.js';
30
32
  import { appendAuditEvent } from './audit.js';
31
33
  import { loadConfig } from './config.js';
@@ -71,9 +73,9 @@ function rowToPrediction(row) {
71
73
  export function savePrediction(hippoRoot, tenantId, opts, actor = 'cli') {
72
74
  assertTenantId('savePrediction', tenantId);
73
75
  if (!opts.classTag)
74
- throw new Error('savePrediction: classTag is required');
76
+ throw new BadRequestError('savePrediction: classTag is required');
75
77
  if (!opts.claimText)
76
- throw new Error('savePrediction: claimText is required');
78
+ throw new BadRequestError('savePrediction: claimText is required');
77
79
  const now = new Date().toISOString();
78
80
  const mem = createMemory(opts.claimText, {
79
81
  tags: ['prediction', opts.classTag],
@@ -142,7 +144,7 @@ export function savePrediction(hippoRoot, tenantId, opts, actor = 'cli') {
142
144
  export function closePrediction(hippoRoot, tenantId, id, opts, actor = 'cli') {
143
145
  assertTenantId('closePrediction', tenantId);
144
146
  if (!VALID_CLOSURE_STATES.has(opts.closureState)) {
145
- 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}`);
146
148
  }
147
149
  const now = new Date().toISOString();
148
150
  const db = openHippoDb(hippoRoot);
@@ -170,9 +172,9 @@ export function closePrediction(hippoRoot, tenantId, id, opts, actor = 'cli') {
170
172
  SELECT closure_state FROM predictions WHERE id = ? AND tenant_id = ?
171
173
  `).get(id, tenantId);
172
174
  if (!existing) {
173
- throw new Error(`closePrediction: prediction ${id} not found for tenant ${tenantId}`);
175
+ throw new NotFoundError(`closePrediction: prediction ${id} not found for tenant ${tenantId}`);
174
176
  }
175
- 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}'); ` +
176
178
  `cannot re-close. Open predictions only.`);
177
179
  }
178
180
  // SAFETY: row's shape matches the columns named in the SELECT above.
@@ -183,7 +185,7 @@ export function closePrediction(hippoRoot, tenantId, id, opts, actor = 'cli') {
183
185
  FROM predictions WHERE id = ? AND tenant_id = ?
184
186
  `).get(id, tenantId);
185
187
  if (!row) {
186
- throw new Error(`closePrediction: prediction ${id} not found after UPDATE`);
188
+ throw new NotFoundError(`closePrediction: prediction ${id} not found after UPDATE`);
187
189
  }
188
190
  appendAuditEvent(db, {
189
191
  tenantId,
@@ -238,7 +240,7 @@ export function loadPredictionsByClass(hippoRoot, tenantId, classTag, opts = {})
238
240
  let rows;
239
241
  if (opts.closureState) {
240
242
  if (!VALID_CLOSURE_STATES.has(opts.closureState)) {
241
- 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}`);
242
244
  }
243
245
  // SAFETY: rows' shape matches the columns named in the SELECT above.
244
246
  rows = db.prepare(`
@@ -295,7 +297,7 @@ export function computePredictionBaserate(hippoRoot, tenantId, classTag, actor =
295
297
  emitAudit = true) {
296
298
  assertTenantId('computePredictionBaserate', tenantId);
297
299
  if (!classTag)
298
- throw new Error('computePredictionBaserate: classTag is required');
300
+ throw new BadRequestError('computePredictionBaserate: classTag is required');
299
301
  const db = openHippoDb(hippoRoot);
300
302
  try {
301
303
  // SAFETY: rows' shape matches the two columns named in the SELECT above.
package/dist/processes.js CHANGED
@@ -30,8 +30,10 @@
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
- import { writeEntry, assertTenantId } from './store.js';
35
+ import { writeEntry } from './store.js';
36
+ import { assertTenantId } from './tenant.js';
35
37
  import { createMemory, Layer } from './memory.js';
36
38
  import { appendAuditEvent } from './audit.js';
37
39
  import { objectHalfLifeDays } from './half-life-migration.js';
@@ -57,23 +59,23 @@ export const MAX_PROCESS_STEP_LEN = 2000;
57
59
  */
58
60
  export function validateProcessSteps(steps) {
59
61
  if (!Array.isArray(steps)) {
60
- throw new Error('saveProcess: steps must be an array of strings');
62
+ throw new BadRequestError('saveProcess: steps must be an array of strings');
61
63
  }
62
64
  if (steps.length > MAX_PROCESS_STEPS) {
63
- 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})`);
64
66
  }
65
67
  const out = [];
66
68
  for (let i = 0; i < steps.length; i++) {
67
69
  const raw = steps[i];
68
70
  if (!isString(raw)) {
69
- throw new Error(`saveProcess: step ${i + 1} is not a string`);
71
+ throw new BadRequestError(`saveProcess: step ${i + 1} is not a string`);
70
72
  }
71
73
  const trimmed = raw.trim();
72
74
  if (trimmed.length === 0) {
73
- throw new Error(`saveProcess: step ${i + 1} is empty`);
75
+ throw new BadRequestError(`saveProcess: step ${i + 1} is empty`);
74
76
  }
75
77
  if (trimmed.length > MAX_PROCESS_STEP_LEN) {
76
- 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`);
77
79
  }
78
80
  out.push(trimmed);
79
81
  }
@@ -142,7 +144,7 @@ function buildProcessContent(processName, steps, description) {
142
144
  export function saveProcess(hippoRoot, tenantId, opts, actor = 'cli') {
143
145
  assertTenantId('saveProcess', tenantId);
144
146
  if (!opts.processName || opts.processName.trim().length === 0) {
145
- throw new Error('saveProcess: processName is required');
147
+ throw new BadRequestError('saveProcess: processName is required');
146
148
  }
147
149
  const steps = validateProcessSteps(opts.steps);
148
150
  const isSupersede = opts.supersedesProcessId !== undefined;
@@ -176,10 +178,10 @@ export function saveProcess(hippoRoot, tenantId, opts, actor = 'cli') {
176
178
  // the two selected columns 1:1.
177
179
  const pred = db.prepare(`SELECT status, version FROM processes WHERE id = ? AND tenant_id = ?`).get(opts.supersedesProcessId, tenantId);
178
180
  if (!pred) {
179
- 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}`);
180
182
  }
181
183
  if (pred.status !== 'active') {
182
- 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.`);
183
185
  }
184
186
  version = pred.version + 1;
185
187
  }
@@ -197,7 +199,7 @@ export function saveProcess(hippoRoot, tenantId, opts, actor = 'cli') {
197
199
  WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
198
200
  `).run(processId, now, opts.supersedesProcessId, tenantId, processId);
199
201
  if (sup.changes === 0) {
200
- 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).`);
201
203
  }
202
204
  appendAuditEvent(db, {
203
205
  tenantId,
@@ -261,16 +263,16 @@ export function closeProcess(hippoRoot, tenantId, id, actor = 'cli') {
261
263
  // single selected column.
262
264
  const existing = db.prepare(`SELECT status FROM processes WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
263
265
  if (!existing) {
264
- throw new Error(`closeProcess: process ${id} not found for tenant ${tenantId}`);
266
+ throw new NotFoundError(`closeProcess: process ${id} not found for tenant ${tenantId}`);
265
267
  }
266
- 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.`);
267
269
  }
268
270
  // SAFETY: SELECT ${PROCESS_COLS} enumerates every ProcessRow field
269
271
  // 1:1 (see PROCESS_COLS above).
270
272
  const row = db.prepare(`SELECT ${PROCESS_COLS} FROM processes WHERE id = ? AND tenant_id = ?`)
271
273
  .get(id, tenantId);
272
274
  if (!row)
273
- throw new Error(`closeProcess: process ${id} not found after UPDATE`);
275
+ throw new NotFoundError(`closeProcess: process ${id} not found after UPDATE`);
274
276
  appendAuditEvent(db, {
275
277
  tenantId,
276
278
  actor,
@@ -317,7 +319,7 @@ export function loadProcesses(hippoRoot, tenantId, opts = {}) {
317
319
  let rows;
318
320
  if (opts.status) {
319
321
  if (!VALID_PROCESS_STATES.has(opts.status)) {
320
- 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}`);
321
323
  }
322
324
  // SAFETY: SELECT ${PROCESS_COLS} enumerates every ProcessRow field
323
325
  // 1:1 (see PROCESS_COLS above).