hippo-memory 1.12.12 → 1.13.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 (44) hide show
  1. package/dist/api.d.ts +89 -0
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +57 -1
  4. package/dist/api.js.map +1 -1
  5. package/dist/audit.d.ts +1 -1
  6. package/dist/audit.d.ts.map +1 -1
  7. package/dist/audit.js.map +1 -1
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +279 -2
  10. package/dist/cli.js.map +1 -1
  11. package/dist/db.d.ts.map +1 -1
  12. package/dist/db.js +77 -1
  13. package/dist/db.js.map +1 -1
  14. package/dist/mcp/server.d.ts.map +1 -1
  15. package/dist/mcp/server.js +105 -1
  16. package/dist/mcp/server.js.map +1 -1
  17. package/dist/predictions.d.ts +122 -0
  18. package/dist/predictions.d.ts.map +1 -0
  19. package/dist/predictions.js +386 -0
  20. package/dist/predictions.js.map +1 -0
  21. package/dist/server.d.ts.map +1 -1
  22. package/dist/server.js +175 -0
  23. package/dist/server.js.map +1 -1
  24. package/dist/src/api.js +57 -1
  25. package/dist/src/api.js.map +1 -1
  26. package/dist/src/audit.js.map +1 -1
  27. package/dist/src/cli.js +279 -2
  28. package/dist/src/cli.js.map +1 -1
  29. package/dist/src/db.js +77 -1
  30. package/dist/src/db.js.map +1 -1
  31. package/dist/src/mcp/server.js +105 -1
  32. package/dist/src/mcp/server.js.map +1 -1
  33. package/dist/src/predictions.js +386 -0
  34. package/dist/src/predictions.js.map +1 -0
  35. package/dist/src/server.js +175 -0
  36. package/dist/src/server.js.map +1 -1
  37. package/dist/src/store.js +1 -1
  38. package/dist/src/store.js.map +1 -1
  39. package/dist/store.d.ts +17 -0
  40. package/dist/store.d.ts.map +1 -1
  41. package/dist/store.js +1 -1
  42. package/dist/store.js.map +1 -1
  43. package/openclaw.plugin.json +1 -1
  44. package/package.json +1 -1
@@ -0,0 +1,386 @@
1
+ /**
2
+ * E2 prediction first-class object (v0.31 / docs/plans/2026-05-26-e2-prediction-object.md).
3
+ *
4
+ * Canonical store for ex-ante claims that can be closed against ex-post
5
+ * outcomes. The `predictions` table holds every field (including
6
+ * `claim_text`); a memory row mirrors the claim for recall/inspect surfaces
7
+ * but is NOT the source of truth — ON DELETE SET NULL on memory_id means
8
+ * memory deletion gracefully orphans the prediction without losing data.
9
+ *
10
+ * Tenant scoping: every helper requires tenantId. The schema's BEFORE INSERT
11
+ * + BEFORE UPDATE triggers (`trg_predictions_tenant_match_*`) enforce that
12
+ * `predictions.tenant_id` matches the referenced memory's tenant_id when
13
+ * `memory_id IS NOT NULL`. Cross-tenant references are unrepresentable at
14
+ * the schema level.
15
+ *
16
+ * Dual-write atomicity: `savePrediction` writes the memory + predictions
17
+ * row inside `writeEntry`'s SAVEPOINT 'write_entry' (store.ts:1196). The
18
+ * afterWrite hook (store.ts:1199-1201) runs inside the same SAVEPOINT, so
19
+ * a failure in either step rolls back both. Pattern matches supersede
20
+ * (api.ts:1486) and the Slack/GitHub connectors.
21
+ *
22
+ * J3 (reference-class / planning-fallacy detector) reads from
23
+ * `loadPredictionsByClass` to compute per-class base rates from
24
+ * (estimate_value, actual_value) at query time. J3 is a follow-up episode;
25
+ * this module ships the data layer.
26
+ */
27
+ import { openHippoDb, closeHippoDb } from './db.js';
28
+ import { writeEntry, assertTenantId } from './store.js';
29
+ import { createMemory, Layer } from './memory.js';
30
+ import { appendAuditEvent } from './audit.js';
31
+ export const VALID_CLOSURE_STATES = new Set([
32
+ 'open',
33
+ 'closed',
34
+ 'closed-unknown',
35
+ ]);
36
+ function rowToPrediction(row) {
37
+ return {
38
+ id: row.id,
39
+ memoryId: row.memory_id,
40
+ tenantId: row.tenant_id,
41
+ classTag: row.class_tag,
42
+ claimText: row.claim_text,
43
+ estimateValue: row.estimate_value,
44
+ estimateUnit: row.estimate_unit,
45
+ targetDate: row.target_date,
46
+ actualValue: row.actual_value,
47
+ closureState: row.closure_state,
48
+ closedAt: row.closed_at,
49
+ closureNote: row.closure_note,
50
+ createdAt: row.created_at,
51
+ };
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // Public API
55
+ // ---------------------------------------------------------------------------
56
+ /**
57
+ * Create a new prediction. Writes a memory mirror + a predictions table
58
+ * row atomically inside `writeEntry`'s SAVEPOINT 'write_entry'. On any
59
+ * failure (audit write, predictions INSERT, trigger ABORT), the SAVEPOINT
60
+ * rolls back — neither the memory row nor the predictions row lands.
61
+ *
62
+ * The memory is tagged `['prediction', classTag]` with `source='prediction'`
63
+ * and `kind='distilled'`. It surfaces in `hippo recall` so the agent can
64
+ * see open predictions naturally; the predictions table is the canonical
65
+ * structured store used by J3.
66
+ */
67
+ export function savePrediction(hippoRoot, tenantId, opts, actor = 'cli') {
68
+ assertTenantId('savePrediction', tenantId);
69
+ if (!opts.classTag)
70
+ throw new Error('savePrediction: classTag is required');
71
+ if (!opts.claimText)
72
+ throw new Error('savePrediction: claimText is required');
73
+ const now = new Date().toISOString();
74
+ const mem = createMemory(opts.claimText, {
75
+ tags: ['prediction', opts.classTag],
76
+ layer: Layer.Semantic,
77
+ confidence: 'observed',
78
+ source: 'prediction',
79
+ kind: 'distilled',
80
+ tenantId,
81
+ });
82
+ // Captured for the return value; populated inside afterWrite hook so the
83
+ // INSERT and the memory write share a SAVEPOINT.
84
+ let savedRow;
85
+ writeEntry(hippoRoot, mem, {
86
+ actor,
87
+ afterWrite: (db, memoryId) => {
88
+ const result = db.prepare(`
89
+ INSERT INTO predictions(
90
+ memory_id, tenant_id, class_tag, claim_text,
91
+ estimate_value, estimate_unit, target_date,
92
+ actual_value, closure_state, closed_at, closure_note, created_at
93
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', NULL, NULL, ?)
94
+ `).run(memoryId, tenantId, opts.classTag, opts.claimText, opts.estimateValue ?? null, opts.estimateUnit ?? null, opts.targetDate ?? null, null, // actual_value — null until close
95
+ now);
96
+ const predictionId = Number(result.lastInsertRowid ?? 0);
97
+ const row = db.prepare(`
98
+ SELECT id, memory_id, tenant_id, class_tag, claim_text,
99
+ estimate_value, estimate_unit, target_date,
100
+ actual_value, closure_state, closed_at, closure_note, created_at
101
+ FROM predictions WHERE id = ?
102
+ `).get(predictionId);
103
+ if (!row) {
104
+ throw new Error('Failed to reload saved prediction row');
105
+ }
106
+ savedRow = row;
107
+ // GDPR-light audit metadata: prediction_id + class_tag + flags only.
108
+ // No claim_text in metadata; the predictions table holds it canonically.
109
+ appendAuditEvent(db, {
110
+ tenantId,
111
+ actor,
112
+ op: 'predict_create',
113
+ targetId: String(predictionId),
114
+ metadata: {
115
+ prediction_id: predictionId,
116
+ class_tag: opts.classTag,
117
+ has_estimate: opts.estimateValue !== undefined && opts.estimateValue !== null,
118
+ target_date: opts.targetDate ?? null,
119
+ },
120
+ });
121
+ },
122
+ });
123
+ if (!savedRow) {
124
+ // Cannot reach here without the afterWrite throwing first; defensive.
125
+ throw new Error('savePrediction: afterWrite did not populate the row');
126
+ }
127
+ return rowToPrediction(savedRow);
128
+ }
129
+ /**
130
+ * Close an existing open prediction. Updates the predictions row only;
131
+ * the memory mirror is NOT mutated in v1 (predictions table is canonical).
132
+ * J3 computes accuracy (clean vs regressed) from (estimateValue,
133
+ * actualValue) at query time.
134
+ */
135
+ export function closePrediction(hippoRoot, tenantId, id, opts, actor = 'cli') {
136
+ assertTenantId('closePrediction', tenantId);
137
+ if (!VALID_CLOSURE_STATES.has(opts.closureState)) {
138
+ throw new Error(`closePrediction: closureState must be one of ${Array.from(VALID_CLOSURE_STATES).join('|')}; got ${opts.closureState}`);
139
+ }
140
+ const now = new Date().toISOString();
141
+ const db = openHippoDb(hippoRoot);
142
+ try {
143
+ db.exec('BEGIN IMMEDIATE');
144
+ try {
145
+ // Codex review finding 2026-05-26: WHERE clause requires
146
+ // closure_state='open' so duplicate close requests / retries against
147
+ // an already-closed prediction return a clear error instead of
148
+ // silently overwriting actual_value + emitting a duplicate
149
+ // predict_close audit row. Zero changed rows → caller decides
150
+ // whether it's a "not found" or "already closed" case based on the
151
+ // load-then-close pattern.
152
+ const updateResult = db.prepare(`
153
+ UPDATE predictions
154
+ SET actual_value = ?, closure_state = ?, closed_at = ?, closure_note = ?
155
+ WHERE id = ? AND tenant_id = ? AND closure_state = 'open'
156
+ `).run(opts.actualValue ?? null, opts.closureState, now, opts.closureNote ?? null, id, tenantId);
157
+ if (updateResult.changes === 0) {
158
+ // Distinguish "not found" from "already closed" so callers (CLI, HTTP)
159
+ // can surface the right error to the user.
160
+ const existing = db.prepare(`
161
+ SELECT closure_state FROM predictions WHERE id = ? AND tenant_id = ?
162
+ `).get(id, tenantId);
163
+ if (!existing) {
164
+ throw new Error(`closePrediction: prediction ${id} not found for tenant ${tenantId}`);
165
+ }
166
+ throw new Error(`closePrediction: prediction ${id} is already closed (state='${existing.closure_state}'); ` +
167
+ `cannot re-close. Open predictions only.`);
168
+ }
169
+ const row = db.prepare(`
170
+ SELECT id, memory_id, tenant_id, class_tag, claim_text,
171
+ estimate_value, estimate_unit, target_date,
172
+ actual_value, closure_state, closed_at, closure_note, created_at
173
+ FROM predictions WHERE id = ? AND tenant_id = ?
174
+ `).get(id, tenantId);
175
+ if (!row) {
176
+ throw new Error(`closePrediction: prediction ${id} not found after UPDATE`);
177
+ }
178
+ appendAuditEvent(db, {
179
+ tenantId,
180
+ actor,
181
+ op: 'predict_close',
182
+ targetId: String(id),
183
+ metadata: {
184
+ prediction_id: id,
185
+ closure_state: opts.closureState,
186
+ has_actual: opts.actualValue !== undefined && opts.actualValue !== null,
187
+ },
188
+ });
189
+ db.exec('COMMIT');
190
+ return rowToPrediction(row);
191
+ }
192
+ catch (e) {
193
+ try {
194
+ db.exec('ROLLBACK');
195
+ }
196
+ catch {
197
+ // Ignore rollback failures — the throw below is what matters.
198
+ }
199
+ throw e;
200
+ }
201
+ }
202
+ finally {
203
+ closeHippoDb(db);
204
+ }
205
+ }
206
+ export function loadPredictionById(hippoRoot, tenantId, id) {
207
+ assertTenantId('loadPredictionById', tenantId);
208
+ const db = openHippoDb(hippoRoot);
209
+ try {
210
+ const row = db.prepare(`
211
+ SELECT id, memory_id, tenant_id, class_tag, claim_text,
212
+ estimate_value, estimate_unit, target_date,
213
+ actual_value, closure_state, closed_at, closure_note, created_at
214
+ FROM predictions WHERE id = ? AND tenant_id = ?
215
+ `).get(id, tenantId);
216
+ return row ? rowToPrediction(row) : null;
217
+ }
218
+ finally {
219
+ closeHippoDb(db);
220
+ }
221
+ }
222
+ export function loadPredictionsByClass(hippoRoot, tenantId, classTag, opts = {}) {
223
+ assertTenantId('loadPredictionsByClass', tenantId);
224
+ const limit = opts.limit ?? 100;
225
+ const db = openHippoDb(hippoRoot);
226
+ try {
227
+ let rows;
228
+ if (opts.closureState) {
229
+ if (!VALID_CLOSURE_STATES.has(opts.closureState)) {
230
+ throw new Error(`loadPredictionsByClass: closureState must be one of ${Array.from(VALID_CLOSURE_STATES).join('|')}; got ${opts.closureState}`);
231
+ }
232
+ rows = db.prepare(`
233
+ SELECT id, memory_id, tenant_id, class_tag, claim_text,
234
+ estimate_value, estimate_unit, target_date,
235
+ actual_value, closure_state, closed_at, closure_note, created_at
236
+ FROM predictions
237
+ WHERE tenant_id = ? AND class_tag = ? AND closure_state = ?
238
+ ORDER BY created_at DESC, id DESC
239
+ LIMIT ?
240
+ `).all(tenantId, classTag, opts.closureState, limit);
241
+ }
242
+ else {
243
+ rows = db.prepare(`
244
+ SELECT id, memory_id, tenant_id, class_tag, claim_text,
245
+ estimate_value, estimate_unit, target_date,
246
+ actual_value, closure_state, closed_at, closure_note, created_at
247
+ FROM predictions
248
+ WHERE tenant_id = ? AND class_tag = ?
249
+ ORDER BY created_at DESC, id DESC
250
+ LIMIT ?
251
+ `).all(tenantId, classTag, limit);
252
+ }
253
+ return rows.map(rowToPrediction);
254
+ }
255
+ finally {
256
+ closeHippoDb(db);
257
+ }
258
+ }
259
+ /**
260
+ * Compute base-rate stats for closed predictions in a class. Used by J3
261
+ * reference-class / planning-fallacy detector. Direct application of
262
+ * Lovallo-Kahneman (2003) inside-vs-outside view.
263
+ *
264
+ * Filter: closure_state='closed' AND estimate_value IS NOT NULL AND
265
+ * actual_value IS NOT NULL. Excludes closed-unknown (no actual to
266
+ * compare against) and open (not yet resolved).
267
+ *
268
+ * Audit-emit is BUILT IN here (single source of truth, no caller-site
269
+ * drift risk). Plan-eng-critic round 1 HIGH recommendation: emit inside
270
+ * helper, not at 3 call sites.
271
+ */
272
+ export function computePredictionBaserate(hippoRoot, tenantId, classTag, actor = 'cli') {
273
+ assertTenantId('computePredictionBaserate', tenantId);
274
+ if (!classTag)
275
+ throw new Error('computePredictionBaserate: classTag is required');
276
+ const db = openHippoDb(hippoRoot);
277
+ try {
278
+ const rows = db.prepare(`
279
+ SELECT estimate_value, actual_value
280
+ FROM predictions
281
+ WHERE tenant_id = ?
282
+ AND class_tag = ?
283
+ AND closure_state = 'closed'
284
+ AND estimate_value IS NOT NULL
285
+ AND actual_value IS NOT NULL
286
+ `).all(tenantId, classTag);
287
+ const nClosed = rows.length;
288
+ if (nClosed === 0) {
289
+ // Audit zero-result reads too — agents probing empty classes is
290
+ // a signal worth recording.
291
+ appendAuditEvent(db, {
292
+ tenantId,
293
+ actor,
294
+ op: 'predict_baserate',
295
+ targetId: classTag,
296
+ metadata: { class_tag: classTag, n_closed: 0 },
297
+ });
298
+ return {
299
+ classTag,
300
+ nClosed: 0,
301
+ nRatioEligible: 0,
302
+ meanEstimate: null,
303
+ meanActual: null,
304
+ meanRatio: null,
305
+ p50Ratio: null,
306
+ mae: null,
307
+ summary: '',
308
+ };
309
+ }
310
+ const ratioEligible = rows.filter((r) => r.estimate_value > 0);
311
+ const nRatioEligible = ratioEligible.length;
312
+ const meanEstimate = rows.reduce((s, r) => s + r.estimate_value, 0) / nClosed;
313
+ const meanActual = rows.reduce((s, r) => s + r.actual_value, 0) / nClosed;
314
+ const mae = rows.reduce((s, r) => s + Math.abs(r.actual_value - r.estimate_value), 0) / nClosed;
315
+ let meanRatio = null;
316
+ let p50Ratio = null;
317
+ if (nRatioEligible > 0) {
318
+ const ratios = ratioEligible.map((r) => r.actual_value / r.estimate_value);
319
+ meanRatio = ratios.reduce((s, x) => s + x, 0) / nRatioEligible;
320
+ const sorted = ratios.slice().sort((a, b) => a - b);
321
+ p50Ratio = nRatioEligible % 2 === 1
322
+ ? sorted[(nRatioEligible - 1) / 2]
323
+ : (sorted[nRatioEligible / 2 - 1] + sorted[nRatioEligible / 2]) / 2;
324
+ }
325
+ const ratioPart = meanRatio !== null
326
+ ? `averaged ${meanRatio.toFixed(2)}x actual`
327
+ : 'no ratio-eligible rows (all estimates were 0)';
328
+ const summary = `Last ${nClosed} estimate${nClosed === 1 ? '' : 's'} in class ${classTag} ${ratioPart} (MAE ${mae.toFixed(2)}).`;
329
+ appendAuditEvent(db, {
330
+ tenantId,
331
+ actor,
332
+ op: 'predict_baserate',
333
+ targetId: classTag,
334
+ metadata: { class_tag: classTag, n_closed: nClosed },
335
+ });
336
+ return {
337
+ classTag,
338
+ nClosed,
339
+ nRatioEligible,
340
+ meanEstimate,
341
+ meanActual,
342
+ meanRatio,
343
+ p50Ratio,
344
+ mae,
345
+ summary,
346
+ };
347
+ }
348
+ finally {
349
+ closeHippoDb(db);
350
+ }
351
+ }
352
+ export function loadOpenPredictions(hippoRoot, tenantId, opts = {}) {
353
+ assertTenantId('loadOpenPredictions', tenantId);
354
+ const limit = opts.limit ?? 100;
355
+ const db = openHippoDb(hippoRoot);
356
+ try {
357
+ let rows;
358
+ if (opts.classTag) {
359
+ rows = db.prepare(`
360
+ SELECT id, memory_id, tenant_id, class_tag, claim_text,
361
+ estimate_value, estimate_unit, target_date,
362
+ actual_value, closure_state, closed_at, closure_note, created_at
363
+ FROM predictions
364
+ WHERE tenant_id = ? AND class_tag = ? AND closure_state = 'open'
365
+ ORDER BY created_at DESC, id DESC
366
+ LIMIT ?
367
+ `).all(tenantId, opts.classTag, limit);
368
+ }
369
+ else {
370
+ rows = db.prepare(`
371
+ SELECT id, memory_id, tenant_id, class_tag, claim_text,
372
+ estimate_value, estimate_unit, target_date,
373
+ actual_value, closure_state, closed_at, closure_note, created_at
374
+ FROM predictions
375
+ WHERE tenant_id = ? AND closure_state = 'open'
376
+ ORDER BY created_at DESC, id DESC
377
+ LIMIT ?
378
+ `).all(tenantId, limit);
379
+ }
380
+ return rows.map(rowToPrediction);
381
+ }
382
+ finally {
383
+ closeHippoDb(db);
384
+ }
385
+ }
386
+ //# sourceMappingURL=predictions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"predictions.js","sourceRoot":"","sources":["../src/predictions.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AA8GA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAED,MAAM,WAAW,SAAS;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AA0HD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAMrE;AAw1CD;;;;;;;;;;GAUG;AACH,wBAAsB,KAAK,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,YAAY,CAAC,CAyIlE"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AA2HA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAED,MAAM,WAAW,SAAS;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AA0HD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAMrE;AAqgDD;;;;;;;;;;GAUG;AACH,wBAAsB,KAAK,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,YAAY,CAAC,CAyIlE"}
package/dist/server.js CHANGED
@@ -7,6 +7,7 @@ import { PACKAGE_VERSION } from './version.js';
7
7
  import { validateApiKey } from './auth.js';
8
8
  import { createRateLimiter } from './rate-limit.js';
9
9
  import { remember, recall, RecallContractError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, adminActor, } from './api.js';
10
+ import { savePrediction, closePrediction, loadPredictionById, loadPredictionsByClass, loadOpenPredictions, computePredictionBaserate, VALID_CLOSURE_STATES, } from './predictions.js';
10
11
  import { handleMcpRequest } from './mcp/server.js';
11
12
  import { verifySlackSignature } from './connectors/slack/signature.js';
12
13
  import { isSlackEventEnvelope, isSlackMessageEvent } from './connectors/slack/types.js';
@@ -52,6 +53,9 @@ const VALID_AUDIT_OPS = new Set([
52
53
  'summary_marked_dirty', // v0.30 / E1 — lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS (v1.11.5 CRIT A institutional rule)
53
54
  'summary_marked_clean', // v0.30 / E3 — buildDag post-link clean op; lockstep
54
55
  'summary_rebuilt', // v0.30 / E3 — sleep-cycle rebuild op; lockstep
56
+ 'predict_create', // v0.31 / E2 prediction first-class object — emitted by savePrediction
57
+ 'predict_close', // v0.31 / E2 — emitted by closePrediction
58
+ 'predict_baserate', // v0.31 / J3 — emitted by computePredictionBaserate
55
59
  ]);
56
60
  // Cap on GET /v1/audit?limit=. Matches docs/api.md (when written) and is large
57
61
  // enough to dump a small deployment's full audit log without paginating, but
@@ -880,6 +884,177 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
880
884
  sendJson(res, 200, result);
881
885
  return;
882
886
  }
887
+ // ── E2 prediction first-class object (v0.31) ──
888
+ // docs/plans/2026-05-26-e2-prediction-object.md
889
+ //
890
+ // 4 routes: POST /v1/predictions (create), GET /v1/predictions (list),
891
+ // GET /v1/predictions/:id (show), POST /v1/predictions/:id/close (close).
892
+ // All Bearer-authed + tenant-scoped via buildContextWithAuth. closure_state
893
+ // validated against VALID_CLOSURE_STATES (3 states). DoS caps on claim
894
+ // (4096 chars) + closureNote (2048 chars) per v1.11.4 pattern.
895
+ if (method === 'POST' && path === '/v1/predictions') {
896
+ const body = await parseJsonBody(req);
897
+ const claim = body['claim'];
898
+ if (typeof claim !== 'string' || claim.length === 0) {
899
+ throw new HttpError(400, 'claim is required (non-empty string)');
900
+ }
901
+ if (claim.length > 4096) {
902
+ throw new HttpError(400, 'claim exceeds 4096-character cap');
903
+ }
904
+ const classTag = body['classTag'];
905
+ if (typeof classTag !== 'string' || classTag.length === 0) {
906
+ throw new HttpError(400, 'classTag is required (non-empty string)');
907
+ }
908
+ const estimate = body['estimate'];
909
+ let estimateValue;
910
+ if (estimate !== undefined && estimate !== null) {
911
+ if (typeof estimate !== 'number' || !Number.isFinite(estimate)) {
912
+ throw new HttpError(400, 'estimate must be a finite number');
913
+ }
914
+ estimateValue = estimate;
915
+ }
916
+ const unit = body['unit'];
917
+ let estimateUnit;
918
+ if (unit !== undefined && unit !== null) {
919
+ if (typeof unit !== 'string') {
920
+ throw new HttpError(400, 'unit must be a string');
921
+ }
922
+ estimateUnit = unit;
923
+ }
924
+ const targetDate = body['targetDate'];
925
+ let targetDateValue;
926
+ if (targetDate !== undefined && targetDate !== null) {
927
+ if (typeof targetDate !== 'string') {
928
+ throw new HttpError(400, 'targetDate must be an ISO date string');
929
+ }
930
+ targetDateValue = targetDate;
931
+ }
932
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
933
+ const prediction = savePrediction(opts.hippoRoot, ctx.tenantId, {
934
+ classTag,
935
+ claimText: claim,
936
+ estimateValue,
937
+ estimateUnit,
938
+ targetDate: targetDateValue,
939
+ }, ctx.actor.subject);
940
+ sendJson(res, 201, { prediction });
941
+ return;
942
+ }
943
+ if (method === 'GET' && path === '/v1/predictions') {
944
+ const classTag = query.get('class') ?? undefined;
945
+ const status = query.get('status') ?? 'all';
946
+ const limitRaw = query.get('limit');
947
+ let limit = 100;
948
+ if (limitRaw !== null) {
949
+ limit = Number(limitRaw);
950
+ if (!Number.isFinite(limit) || limit <= 0 || limit > 1000) {
951
+ throw new HttpError(400, 'limit must be a positive integer <= 1000');
952
+ }
953
+ }
954
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
955
+ let predictions;
956
+ if (status === 'all') {
957
+ if (classTag) {
958
+ predictions = loadPredictionsByClass(opts.hippoRoot, ctx.tenantId, classTag, { limit });
959
+ }
960
+ else {
961
+ predictions = loadOpenPredictions(opts.hippoRoot, ctx.tenantId, { limit });
962
+ }
963
+ }
964
+ else if (status === 'open') {
965
+ predictions = loadOpenPredictions(opts.hippoRoot, ctx.tenantId, {
966
+ classTag: classTag || undefined,
967
+ limit,
968
+ });
969
+ }
970
+ else {
971
+ if (!VALID_CLOSURE_STATES.has(status)) {
972
+ throw new HttpError(400, `status must be one of: open | closed | closed-unknown | all (got "${status}")`);
973
+ }
974
+ if (!classTag) {
975
+ throw new HttpError(400, 'status filter (non-open) requires class param');
976
+ }
977
+ predictions = loadPredictionsByClass(opts.hippoRoot, ctx.tenantId, classTag, {
978
+ closureState: status,
979
+ limit,
980
+ });
981
+ }
982
+ sendJson(res, 200, { predictions });
983
+ return;
984
+ }
985
+ // J3 reference-class / planning-fallacy detector (v0.31).
986
+ // Order matters: this must match BEFORE /v1/predictions/:id since 'stats'
987
+ // is not a number — the :id regex requires \d+ so they don't conflict,
988
+ // but routing this first avoids the dispatch order risk.
989
+ if (method === 'GET' && path === '/v1/predictions/stats') {
990
+ const classTag = query.get('class');
991
+ if (!classTag || classTag.length === 0) {
992
+ throw new HttpError(400, 'class param is required');
993
+ }
994
+ if (classTag.length > 256) {
995
+ throw new HttpError(400, 'class exceeds 256-character cap');
996
+ }
997
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
998
+ const baserate = computePredictionBaserate(opts.hippoRoot, ctx.tenantId, classTag, ctx.actor.subject);
999
+ sendJson(res, 200, { baserate });
1000
+ return;
1001
+ }
1002
+ const predictionByIdMatch = path.match(/^\/v1\/predictions\/(\d+)$/);
1003
+ if (method === 'GET' && predictionByIdMatch) {
1004
+ const id = parseInt(predictionByIdMatch[1], 10);
1005
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1006
+ const prediction = loadPredictionById(opts.hippoRoot, ctx.tenantId, id);
1007
+ if (!prediction) {
1008
+ throw new HttpError(404, `prediction ${id} not found`);
1009
+ }
1010
+ sendJson(res, 200, { prediction });
1011
+ return;
1012
+ }
1013
+ const predictionCloseMatch = path.match(/^\/v1\/predictions\/(\d+)\/close$/);
1014
+ if (method === 'POST' && predictionCloseMatch) {
1015
+ const id = parseInt(predictionCloseMatch[1], 10);
1016
+ const body = await parseJsonBody(req);
1017
+ const state = body['state'];
1018
+ if (typeof state !== 'string' || !VALID_CLOSURE_STATES.has(state) || state === 'open') {
1019
+ throw new HttpError(400, 'state is required and must be one of: closed | closed-unknown');
1020
+ }
1021
+ const actual = body['actual'];
1022
+ let actualValue;
1023
+ if (actual !== undefined && actual !== null) {
1024
+ if (typeof actual !== 'number' || !Number.isFinite(actual)) {
1025
+ throw new HttpError(400, 'actual must be a finite number');
1026
+ }
1027
+ actualValue = actual;
1028
+ }
1029
+ const note = body['note'];
1030
+ let closureNote;
1031
+ if (note !== undefined && note !== null) {
1032
+ if (typeof note !== 'string') {
1033
+ throw new HttpError(400, 'note must be a string');
1034
+ }
1035
+ if (note.length > 2048) {
1036
+ throw new HttpError(400, 'note exceeds 2048-character cap');
1037
+ }
1038
+ closureNote = note;
1039
+ }
1040
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1041
+ try {
1042
+ const prediction = closePrediction(opts.hippoRoot, ctx.tenantId, id, {
1043
+ closureState: state,
1044
+ actualValue,
1045
+ closureNote,
1046
+ }, ctx.actor.subject);
1047
+ sendJson(res, 200, { prediction });
1048
+ }
1049
+ catch (e) {
1050
+ const msg = e.message;
1051
+ if (msg.includes('not found')) {
1052
+ throw new HttpError(404, msg);
1053
+ }
1054
+ throw e;
1055
+ }
1056
+ return;
1057
+ }
883
1058
  // ── POST /v1/connectors/slack/events ──
884
1059
  //
885
1060
  // Slack Events API webhook. Auth is signature-based (HMAC over the raw