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.
- package/README.md +11 -0
- package/dist/agent-memories/claude-code.js +1 -1
- package/dist/agent-memories/gemini.js +1 -1
- package/dist/api-errors.d.ts +27 -0
- package/dist/api-errors.js +37 -0
- package/dist/api.d.ts +21 -14
- package/dist/api.js +97 -71
- package/dist/audit.d.ts +4 -0
- package/dist/audit.js +11 -0
- package/dist/autolearn.d.ts +1 -1
- package/dist/autolearn.js +7 -5
- package/dist/capture-contract.d.ts +47 -0
- package/dist/capture-contract.js +49 -0
- package/dist/capture-error.js +2 -1
- package/dist/capture.d.ts +0 -13
- package/dist/capture.js +5 -66
- package/dist/card-detail.d.ts +1 -1
- package/dist/card-detail.js +1 -1
- package/dist/cli/shared.d.ts +137 -0
- package/dist/cli/shared.js +834 -0
- package/dist/cli/sleep.d.ts +10 -0
- package/dist/cli/sleep.js +171 -0
- package/dist/cli.d.ts +0 -7
- package/dist/cli.js +322 -1827
- package/dist/client.js +9 -0
- package/dist/codex-patch.js +1 -1
- package/dist/compaction-record.d.ts +1 -1
- package/dist/compaction-record.js +3 -2
- package/dist/config.d.ts +5 -0
- package/dist/config.js +17 -0
- package/dist/connectors/github/dlq.js +5 -2
- package/dist/connectors/github/octokit-client.js +4 -2
- package/dist/connectors/github/webhook.d.ts +19 -0
- package/dist/connectors/github/webhook.js +313 -0
- package/dist/connectors/slack/dlq.js +6 -2
- package/dist/connectors/slack/web-client.js +7 -5
- package/dist/connectors/slack/webhook.d.ts +22 -0
- package/dist/connectors/slack/webhook.js +203 -0
- package/dist/consolidate.d.ts +10 -0
- package/dist/consolidate.js +38 -35
- package/dist/context-auto.d.ts +3 -0
- package/dist/context-auto.js +34 -0
- package/dist/customer-notes.js +16 -14
- package/dist/dag.js +3 -2
- package/dist/dashboard.js +3 -2
- package/dist/db.d.ts +12 -0
- package/dist/db.js +62 -1
- package/dist/decisions.js +11 -9
- package/dist/doctor.js +5 -0
- package/dist/embedding-provider.js +3 -3
- package/dist/embeddings.d.ts +4 -4
- package/dist/embeddings.js +72 -16
- package/dist/eval-stats.d.ts +58 -0
- package/dist/eval-stats.js +111 -0
- package/dist/extract.js +3 -2
- package/dist/goals.d.ts +49 -25
- package/dist/goals.js +39 -22
- package/dist/graph-extract.js +1 -1
- package/dist/graph-recall.d.ts +1 -1
- package/dist/graph-recall.js +1 -1
- package/dist/graph.js +1 -1
- package/dist/hooks.d.ts +1 -3
- package/dist/hooks.js +2 -4
- package/dist/http-retry.d.ts +21 -0
- package/dist/http-retry.js +50 -0
- package/dist/http-util.d.ts +39 -0
- package/dist/http-util.js +56 -0
- package/dist/importers.d.ts +2 -0
- package/dist/importers.js +16 -5
- package/dist/incidents.js +13 -11
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/judgment.js +10 -17
- package/dist/log.d.ts +25 -0
- package/dist/log.js +48 -0
- package/dist/mcp/server.js +224 -308
- package/dist/mcp/tool-args.d.ts +21 -0
- package/dist/mcp/tool-args.js +80 -0
- package/dist/memory.d.ts +19 -0
- package/dist/memory.js +41 -2
- package/dist/overlap-index.d.ts +7 -0
- package/dist/overlap-index.js +38 -0
- package/dist/pilot-arm.d.ts +9 -0
- package/dist/pilot-arm.js +47 -0
- package/dist/policies.js +14 -12
- package/dist/predictions.js +11 -9
- package/dist/processes.js +16 -14
- package/dist/project-briefs.js +19 -16
- package/dist/project-identity.d.ts +1 -1
- package/dist/project-identity.js +25 -1
- package/dist/prompt-recall.js +1 -1
- package/dist/raw-archive.js +7 -6
- package/dist/recall-history.d.ts +5 -0
- package/dist/recall-history.js +9 -0
- package/dist/recall-pipeline.d.ts +101 -0
- package/dist/recall-pipeline.js +313 -0
- package/dist/recall-scope.d.ts +24 -1
- package/dist/recall-scope.js +29 -2
- package/dist/refine-llm.js +3 -2
- package/dist/reject-flow.js +6 -9
- package/dist/rejection.d.ts +2 -1
- package/dist/rejection.js +2 -1
- package/dist/search.d.ts +0 -20
- package/dist/search.js +16 -51
- package/dist/secret-detect.d.ts +13 -1
- package/dist/secret-detect.js +33 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +1854 -2566
- package/dist/session-digest.js +2 -1
- package/dist/shared.js +7 -6
- package/dist/skills.js +17 -15
- package/dist/store-cards.d.ts +53 -0
- package/dist/store-cards.js +512 -0
- package/dist/store.d.ts +2 -89
- package/dist/store.js +10 -566
- package/dist/tenant.d.ts +22 -0
- package/dist/tenant.js +26 -0
- package/dist/token-ledger.d.ts +4 -2
- package/dist/token-ledger.js +2 -2
- package/dist/tokenize.d.ts +2 -0
- package/dist/tokenize.js +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/dist/connectors/slack/ratelimit.d.ts +0 -9
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
127
|
+
throw new BadRequestError('savePolicy: policyName is required');
|
|
126
128
|
}
|
|
127
129
|
if (!opts.policyText || opts.policyText.trim().length === 0) {
|
|
128
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
252
|
+
throw new NotFoundError(`closePolicy: policy ${id} not found for tenant ${tenantId}`);
|
|
251
253
|
}
|
|
252
|
-
throw new
|
|
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
|
|
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
|
|
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.
|
package/dist/predictions.js
CHANGED
|
@@ -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
|
|
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
|
|
76
|
+
throw new BadRequestError('savePrediction: classTag is required');
|
|
75
77
|
if (!opts.claimText)
|
|
76
|
-
throw new
|
|
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
|
|
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
|
|
175
|
+
throw new NotFoundError(`closePrediction: prediction ${id} not found for tenant ${tenantId}`);
|
|
174
176
|
}
|
|
175
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
75
|
+
throw new BadRequestError(`saveProcess: step ${i + 1} is empty`);
|
|
74
76
|
}
|
|
75
77
|
if (trimmed.length > MAX_PROCESS_STEP_LEN) {
|
|
76
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
266
|
+
throw new NotFoundError(`closeProcess: process ${id} not found for tenant ${tenantId}`);
|
|
265
267
|
}
|
|
266
|
-
throw new
|
|
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
|
|
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
|
|
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).
|