hippo-memory 1.55.0 → 1.57.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 (77) hide show
  1. package/README.md +11 -0
  2. package/dist/api.d.ts +19 -9
  3. package/dist/api.js +112 -35
  4. package/dist/card-detail.d.ts +1 -1
  5. package/dist/card-detail.js +1 -1
  6. package/dist/cli/shared.d.ts +137 -0
  7. package/dist/cli/shared.js +830 -0
  8. package/dist/cli/sleep.d.ts +10 -0
  9. package/dist/cli/sleep.js +171 -0
  10. package/dist/cli.d.ts +0 -7
  11. package/dist/cli.js +313 -1806
  12. package/dist/config.d.ts +5 -0
  13. package/dist/config.js +21 -0
  14. package/dist/connectors/github/webhook.d.ts +19 -0
  15. package/dist/connectors/github/webhook.js +313 -0
  16. package/dist/connectors/slack/webhook.d.ts +22 -0
  17. package/dist/connectors/slack/webhook.js +203 -0
  18. package/dist/consolidate.js +3 -2
  19. package/dist/context-auto.d.ts +3 -0
  20. package/dist/context-auto.js +34 -0
  21. package/dist/customer-notes.js +2 -1
  22. package/dist/dashboard.js +2 -1
  23. package/dist/db.js +67 -1
  24. package/dist/decisions.js +2 -1
  25. package/dist/delivery-recorder.d.ts +127 -0
  26. package/dist/delivery-recorder.js +218 -0
  27. package/dist/eval-stats.d.ts +58 -0
  28. package/dist/eval-stats.js +111 -0
  29. package/dist/goals.d.ts +49 -25
  30. package/dist/goals.js +39 -22
  31. package/dist/graph-extract.js +1 -1
  32. package/dist/graph-recall.d.ts +1 -1
  33. package/dist/graph-recall.js +1 -1
  34. package/dist/graph.js +1 -1
  35. package/dist/hooks.d.ts +1 -3
  36. package/dist/hooks.js +2 -4
  37. package/dist/http-util.d.ts +31 -0
  38. package/dist/http-util.js +46 -0
  39. package/dist/incidents.js +2 -1
  40. package/dist/index.d.ts +5 -2
  41. package/dist/index.js +5 -2
  42. package/dist/mcp/server.js +173 -285
  43. package/dist/memory.d.ts +19 -0
  44. package/dist/memory.js +38 -0
  45. package/dist/policies.js +2 -1
  46. package/dist/predictions.js +2 -1
  47. package/dist/processes.js +2 -1
  48. package/dist/project-briefs.js +3 -1
  49. package/dist/prompt-recall.js +1 -1
  50. package/dist/recall-history.d.ts +5 -0
  51. package/dist/recall-history.js +9 -0
  52. package/dist/recall-pipeline.d.ts +101 -0
  53. package/dist/recall-pipeline.js +313 -0
  54. package/dist/recall-scope.d.ts +22 -0
  55. package/dist/recall-scope.js +27 -1
  56. package/dist/recall-trace.d.ts +69 -0
  57. package/dist/recall-trace.js +136 -0
  58. package/dist/search.d.ts +0 -20
  59. package/dist/search.js +2 -49
  60. package/dist/server.js +1901 -2384
  61. package/dist/skills.js +2 -1
  62. package/dist/store-cards.d.ts +53 -0
  63. package/dist/store-cards.js +512 -0
  64. package/dist/store.d.ts +2 -89
  65. package/dist/store.js +6 -562
  66. package/dist/tenant.d.ts +22 -0
  67. package/dist/tenant.js +26 -0
  68. package/dist/token-ledger.d.ts +2 -0
  69. package/dist/token-ledger.js +5 -0
  70. package/dist/tokenize.d.ts +2 -0
  71. package/dist/tokenize.js +8 -0
  72. package/dist/version.d.ts +1 -1
  73. package/dist/version.js +1 -1
  74. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  75. package/extensions/openclaw-plugin/package.json +1 -1
  76. package/openclaw.plugin.json +1 -1
  77. package/package.json +2 -1
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { type DatabaseSyncLike } from './db.js';
19
19
  import type { RerankStep } from './search.js';
20
+ import { type DeliveryEventInput } from './delivery-recorder.js';
20
21
  /** One ranked result to persist alongside its trace row. */
21
22
  export interface RecallTraceResultInput {
22
23
  memoryId: string;
@@ -114,4 +115,72 @@ export interface RecordTraceOutcomeInput {
114
115
  * Fail-soft: never throws.
115
116
  */
116
117
  export declare function recordTraceOutcome(db: DatabaseSyncLike, input: RecordTraceOutcomeInput): void;
118
+ /** Pruned on write, counted back from the event's ts capped at the real clock, so a far-future fake time spares real rows. */
119
+ export declare const DELIVERY_LEDGER_RETENTION_DAYS = 90;
120
+ /** Lock wait for the ledger's own connection: a busy store drops the row rather than slow the hook. */
121
+ export declare const DELIVERY_LEDGER_WAIT_MS = 50;
122
+ /** Two prompt-identical events without a host turn id this close together are one turn fired twice. */
123
+ export declare const DELIVERY_DUPLICATE_WINDOW_MS = 2000;
124
+ /** One stored `delivery_candidates` row. */
125
+ export interface DeliveryCandidateRow {
126
+ event_id: number;
127
+ tenant_id: string;
128
+ memory_id: string;
129
+ source_store: string;
130
+ pool: string;
131
+ stage: string;
132
+ outcome: string;
133
+ reason: string | null;
134
+ cand_rank: number | null;
135
+ score: number | null;
136
+ tokens: number | null;
137
+ }
138
+ /** One stored `delivery_events` row with its candidate rows. */
139
+ export interface DeliveryEventRow {
140
+ id: number;
141
+ ts: string;
142
+ ledger_version: number;
143
+ tenant_id: string;
144
+ runtime: string;
145
+ event_type: string;
146
+ surface: string;
147
+ store_hash: string;
148
+ write_store: string;
149
+ project_hash: string | null;
150
+ session_id: string | null;
151
+ session_state: string;
152
+ host_turn_id: string | null;
153
+ turn_seq: number | null;
154
+ duplicate_of: number | null;
155
+ prompt_hash: string | null;
156
+ prompt_length: number;
157
+ query_hash: string | null;
158
+ recall_trace_id: number | null;
159
+ block_state: string;
160
+ prompt_recall: number;
161
+ considered_count: number;
162
+ filtered_count: number;
163
+ selected_count: number;
164
+ emitted_count: number;
165
+ rejected_count: number;
166
+ rejected_unlisted: number;
167
+ sections_shown: number;
168
+ sections_dropped: number;
169
+ budget_tokens: number;
170
+ selected_tokens: number;
171
+ injected_tokens: number;
172
+ static_hash: string | null;
173
+ recall_hash: string | null;
174
+ emitted_hash: string | null;
175
+ elapsed_ms: number;
176
+ candidates: DeliveryCandidateRow[];
177
+ }
178
+ /** One event plus its candidates in one write transaction, then prune; fail-soft. The caller must not hold a transaction on `db`. */
179
+ export declare function writeDeliveryEvent(db: DatabaseSyncLike, input: DeliveryEventInput): number | null;
180
+ /** Write on a short-lived connection that waits at most {@link DELIVERY_LEDGER_WAIT_MS} for the lock. Fail-soft. */
181
+ export declare function writeDeliveryEventAtRoot(root: string, input: DeliveryEventInput): number | null;
182
+ /** On a caller's open handle, which saves a second open and close per turn; the handle's own lock wait comes back after. */
183
+ export declare function writeDeliveryEventOnHandle(db: DatabaseSyncLike, input: DeliveryEventInput): number | null;
184
+ /** A session's delivery events in write order, each with its candidate rows; `sessionId` null reads session-less events. */
185
+ export declare function readDeliveryEvents(db: DatabaseSyncLike, tenantId: string, sessionId: string | null): DeliveryEventRow[];
117
186
  //# sourceMappingURL=recall-trace.d.ts.map
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { createHash } from 'node:crypto';
19
19
  import { openHippoDb, closeHippoDb } from './db.js';
20
+ import { DELIVERY_LEDGER_VERSION } from './delivery-recorder.js';
20
21
  /**
21
22
  * Strip a RerankStep down to {stage, multiplier, scoreBefore, scoreAfter}
22
23
  * before persisting (F3 privacy fix, codex cross-model finding). `note` is
@@ -183,4 +184,139 @@ export function recordTraceOutcome(db, input) {
183
184
  console.error(`[hippo] recall trace outcome write failed: ${error instanceof Error ? error.message : String(error)}`);
184
185
  }
185
186
  }
187
+ /** Pruned on write, counted back from the event's ts capped at the real clock, so a far-future fake time spares real rows. */
188
+ export const DELIVERY_LEDGER_RETENTION_DAYS = 90;
189
+ /** Lock wait for the ledger's own connection: a busy store drops the row rather than slow the hook. */
190
+ export const DELIVERY_LEDGER_WAIT_MS = 50;
191
+ /** Two prompt-identical events without a host turn id this close together are one turn fired twice. */
192
+ export const DELIVERY_DUPLICATE_WINDOW_MS = 2000;
193
+ const DELIVERY_EVENT_COLUMNS = [
194
+ 'ts', 'ledger_version', 'tenant_id', 'runtime', 'event_type', 'surface', 'store_hash', 'write_store', 'project_hash',
195
+ 'session_id', 'session_state', 'host_turn_id', 'turn_seq', 'duplicate_of', 'prompt_hash', 'prompt_length', 'query_hash',
196
+ 'recall_trace_id', 'block_state', 'prompt_recall', 'considered_count', 'filtered_count', 'selected_count', 'emitted_count',
197
+ 'rejected_count', 'rejected_unlisted', 'sections_shown', 'sections_dropped', 'budget_tokens', 'selected_tokens',
198
+ 'injected_tokens', 'static_hash', 'recall_hash', 'emitted_hash', 'elapsed_ms',
199
+ ];
200
+ function findDuplicateTurn(db, input) {
201
+ if (input.hostTurnId !== null) {
202
+ // SAFETY: a single `id` column, undefined when no row matches.
203
+ const row = db.prepare(`
204
+ SELECT id FROM delivery_events
205
+ WHERE tenant_id = ? AND session_id = ? AND event_type = ? AND host_turn_id = ? AND turn_seq IS NOT NULL
206
+ ORDER BY id LIMIT 1
207
+ `).get(input.tenantId, input.sessionId, input.eventType, input.hostTurnId);
208
+ return row?.id ?? null;
209
+ }
210
+ if (input.promptHash === null)
211
+ return null;
212
+ // SAFETY: rows carry exactly the `id` and `ts` columns selected.
213
+ const rows = db.prepare(`
214
+ SELECT id, ts FROM delivery_events
215
+ WHERE tenant_id = ? AND session_id = ? AND event_type = ? AND prompt_hash = ? AND host_turn_id IS NULL AND turn_seq IS NOT NULL
216
+ ORDER BY id
217
+ `).all(input.tenantId, input.sessionId, input.eventType, input.promptHash);
218
+ const at = Date.parse(input.ts);
219
+ // Absolute difference: two hook processes can commit out of ts order.
220
+ return rows.find((r) => Math.abs(Date.parse(r.ts) - at) <= DELIVERY_DUPLICATE_WINDOW_MS)?.id ?? null;
221
+ }
222
+ function nextTurnSeq(db, input) {
223
+ // SAFETY: a single MAX aggregate aliased `m`, NULL when the session has no turns yet.
224
+ const row = db.prepare(`
225
+ SELECT MAX(turn_seq) AS m FROM delivery_events
226
+ WHERE tenant_id = ? AND session_id = ? AND event_type = ? AND turn_seq IS NOT NULL
227
+ `).get(input.tenantId, input.sessionId, input.eventType);
228
+ return (row.m ?? 0) + 1;
229
+ }
230
+ /** One event plus its candidates in one write transaction, then prune; fail-soft. The caller must not hold a transaction on `db`. */
231
+ export function writeDeliveryEvent(db, input) {
232
+ try {
233
+ db.exec('BEGIN IMMEDIATE');
234
+ try {
235
+ // Missing-session and sub-agent events are not turns of a session, so they get no number and no duplicate check.
236
+ const isTurn = input.sessionId !== null && (input.sessionState === 'payload' || input.sessionState === 'env');
237
+ const duplicateOf = isTurn ? findDuplicateTurn(db, input) : null;
238
+ const turnSeq = isTurn && duplicateOf === null ? nextTurnSeq(db, input) : null;
239
+ const values = [
240
+ input.ts, DELIVERY_LEDGER_VERSION, input.tenantId, input.runtime, input.eventType, input.surface, input.storeHash,
241
+ input.writeStore, input.projectHash, input.sessionId, input.sessionState, input.hostTurnId, turnSeq, duplicateOf,
242
+ input.promptHash, input.promptLength, input.queryHash, input.recallTraceId, input.blockState, input.promptRecall ? 1 : 0,
243
+ input.consideredCount, input.filteredCount, input.selectedCount, input.emittedCount, input.rejectedCount,
244
+ input.rejectedUnlisted, input.sectionsShown, input.sectionsDropped, input.budgetTokens, input.selectedTokens,
245
+ input.injectedTokens, input.staticHash, input.recallHash, input.emittedHash, Math.round(input.elapsedMs),
246
+ ];
247
+ const eventId = Number(db.prepare(`
248
+ INSERT INTO delivery_events (${DELIVERY_EVENT_COLUMNS.join(', ')})
249
+ VALUES (${DELIVERY_EVENT_COLUMNS.map(() => '?').join(', ')})
250
+ `).run(...values).lastInsertRowid);
251
+ const insertCandidate = db.prepare(`
252
+ INSERT INTO delivery_candidates (event_id, tenant_id, memory_id, source_store, pool, stage, outcome, reason, cand_rank, score, tokens)
253
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
254
+ `);
255
+ for (const c of input.candidates) {
256
+ insertCandidate.run(eventId, input.tenantId, c.memoryId, c.sourceStore, c.pool, c.stage, c.outcome, c.reason, c.rank, c.score, c.tokens);
257
+ }
258
+ const pruneFrom = Math.min(Date.parse(input.ts), Date.now());
259
+ const cutoff = new Date(pruneFrom - DELIVERY_LEDGER_RETENTION_DAYS * 86_400_000).toISOString();
260
+ db.prepare(`DELETE FROM delivery_events WHERE ts < ?`).run(cutoff);
261
+ db.exec('COMMIT');
262
+ return eventId;
263
+ }
264
+ catch (error) {
265
+ try {
266
+ db.exec('ROLLBACK');
267
+ }
268
+ catch { /* SQLite may already have rolled back (SQLITE_FULL, IOERR); keep the original error */ }
269
+ throw error;
270
+ }
271
+ }
272
+ catch (error) {
273
+ // eslint-disable-next-line no-console
274
+ console.error(`[hippo] delivery ledger write failed: ${error instanceof Error ? error.message : String(error)}`);
275
+ return null;
276
+ }
277
+ }
278
+ /** Write on a short-lived connection that waits at most {@link DELIVERY_LEDGER_WAIT_MS} for the lock. Fail-soft. */
279
+ export function writeDeliveryEventAtRoot(root, input) {
280
+ let db;
281
+ try {
282
+ db = openHippoDb(root, { busyWaitMs: DELIVERY_LEDGER_WAIT_MS });
283
+ }
284
+ catch (error) {
285
+ // eslint-disable-next-line no-console
286
+ console.error(`[hippo] delivery ledger write failed: ${error instanceof Error ? error.message : String(error)}`);
287
+ return null;
288
+ }
289
+ try {
290
+ return writeDeliveryEvent(db, input);
291
+ }
292
+ finally {
293
+ closeHippoDb(db);
294
+ }
295
+ }
296
+ /** On a caller's open handle, which saves a second open and close per turn; the handle's own lock wait comes back after. */
297
+ export function writeDeliveryEventOnHandle(db, input) {
298
+ const prior = Math.trunc(Number(db.prepare('PRAGMA busy_timeout').get().timeout));
299
+ db.exec(`PRAGMA busy_timeout = ${DELIVERY_LEDGER_WAIT_MS}`);
300
+ try {
301
+ return writeDeliveryEvent(db, input);
302
+ }
303
+ finally {
304
+ db.exec(`PRAGMA busy_timeout = ${prior}`);
305
+ }
306
+ }
307
+ /** A session's delivery events in write order, each with its candidate rows; `sessionId` null reads session-less events. */
308
+ export function readDeliveryEvents(db, tenantId, sessionId) {
309
+ // SAFETY: SELECT * over delivery_events returns exactly the columns DeliveryEventRow names, less `candidates`.
310
+ const events = db.prepare(`SELECT * FROM delivery_events WHERE tenant_id = ? AND session_id IS ? ORDER BY id`)
311
+ .all(tenantId, sessionId);
312
+ const candidates = db.prepare(`
313
+ SELECT * FROM delivery_candidates WHERE event_id = ?
314
+ ORDER BY outcome = 'rejected', cand_rank, memory_id
315
+ `);
316
+ return events.map((e) => ({
317
+ ...e,
318
+ // SAFETY: SELECT * over delivery_candidates returns exactly the columns DeliveryCandidateRow names.
319
+ candidates: candidates.all(e.id),
320
+ }));
321
+ }
186
322
  //# sourceMappingURL=recall-trace.js.map
package/dist/search.d.ts CHANGED
@@ -7,7 +7,6 @@ import { MemoryEntry } from './memory.js';
7
7
  import type { PhysicsConfig } from './physics-config.js';
8
8
  export declare const CHURN_STALE_RANK_MULTIPLIER = 0.5;
9
9
  export declare function churnStaleFactor(entry: MemoryEntry): number;
10
- export declare function tokenize(text: string): string[];
11
10
  /**
12
11
  * Tokenized BM25 corpus. Callers can pre-build this with `buildCorpus` once
13
12
  * and reuse across many `hybridSearch` calls on the same entry set — the
@@ -287,25 +286,6 @@ export declare function search(query: string, entries: MemoryEntry[], options?:
287
286
  includeSuperseded?: boolean;
288
287
  asOf?: string;
289
288
  }): SearchResult[];
290
- /**
291
- * Update retrieval metadata on entries that were returned by a search.
292
- * Returns the mutated copies (caller must persist to disk).
293
- *
294
- * EVAL-ONLY ablation (see ablation.ts): with HIPPO_ABLATE_RECALL_BOOST set,
295
- * this returns the entries UNMUTATED - neutralizing all three strengthening
296
- * sub-effects (clock reset, retrieval_count, half-life increment) at the
297
- * single shared write site. The entries (not an empty array) must be
298
- * returned because callers derive `last_retrieval_ids` from the return
299
- * value, and a later `hippo outcome --good/--bad` targets those ids - an
300
- * empty return would silently co-ablate the outcome channel in the
301
- * strengthen-off arm (codex round-7 P2). PERSISTENCE is gated separately at
302
- * each persisting caller (CLI recall, api context, MCP recall/context,
303
- * consolidation replay): writeEntry on identical rows still refreshes
304
- * updated_at, rewrites mirrors, and marks DAG parents dirty (codex round-6
305
- * P2), so those write loops skip under the flag.
306
- * The default `now` honors HIPPO_FAKE_NOW (simulated-time protocols).
307
- */
308
- export declare function markRetrieved(entries: MemoryEntry[], now?: Date): MemoryEntry[];
309
289
  export interface MatchExplanation {
310
290
  /** Human-readable reason string */
311
291
  reason: string;
package/dist/search.js CHANGED
@@ -3,7 +3,8 @@
3
3
  * Zero external dependencies when embeddings are not available.
4
4
  */
5
5
  import { estimateTokens } from './token-ledger.js';
6
- import { calculateStrength, netWrong, CHURN_STALE_TAG } from './memory.js';
6
+ import { calculateStrength, CHURN_STALE_TAG } from './memory.js';
7
+ import { tokenize } from './tokenize.js';
7
8
  import { isOutcomeFastAblated, isRecallBoostAblated, isRecencyAblated, evalRecencyScaleDays, evalNow } from './ablation.js';
8
9
  import { extractPathTags, pathBoostMultiplier } from './path-context.js';
9
10
  import { detectScope, scopeMatch } from './scope.js';
@@ -20,16 +21,6 @@ export const CHURN_STALE_RANK_MULTIPLIER = 0.5; // SHORTCUT: untuned; FE3 measur
20
21
  export function churnStaleFactor(entry) {
21
22
  return entry.tags.includes(CHURN_STALE_TAG) ? CHURN_STALE_RANK_MULTIPLIER : 1.0;
22
23
  }
23
- // ---------------------------------------------------------------------------
24
- // Tokenizer
25
- // ---------------------------------------------------------------------------
26
- export function tokenize(text) {
27
- return text
28
- .toLowerCase()
29
- .replace(/[^\w\s]/g, ' ')
30
- .split(/\s+/)
31
- .filter((t) => t.length > 1);
32
- }
33
24
  const BM25_K1 = 1.5;
34
25
  const BM25_B = 0.75;
35
26
  export function buildCorpus(texts) {
@@ -902,44 +893,6 @@ export function search(query, entries, options = {}) {
902
893
  }
903
894
  return fitBudget(dedupedSync, budget, minResults, options.cost);
904
895
  }
905
- /**
906
- * Update retrieval metadata on entries that were returned by a search.
907
- * Returns the mutated copies (caller must persist to disk).
908
- *
909
- * EVAL-ONLY ablation (see ablation.ts): with HIPPO_ABLATE_RECALL_BOOST set,
910
- * this returns the entries UNMUTATED - neutralizing all three strengthening
911
- * sub-effects (clock reset, retrieval_count, half-life increment) at the
912
- * single shared write site. The entries (not an empty array) must be
913
- * returned because callers derive `last_retrieval_ids` from the return
914
- * value, and a later `hippo outcome --good/--bad` targets those ids - an
915
- * empty return would silently co-ablate the outcome channel in the
916
- * strengthen-off arm (codex round-7 P2). PERSISTENCE is gated separately at
917
- * each persisting caller (CLI recall, api context, MCP recall/context,
918
- * consolidation replay): writeEntry on identical rows still refreshes
919
- * updated_at, rewrites mirrors, and marks DAG parents dirty (codex round-6
920
- * P2), so those write loops skip under the flag.
921
- * The default `now` honors HIPPO_FAKE_NOW (simulated-time protocols).
922
- */
923
- // Confidence is deliberately absent below: it is an epistemic tier, not a
924
- // recency signal, and a stored 'stale' is always a deliberate mark (2026-09-07).
925
- export function markRetrieved(entries, now = evalNow()) {
926
- if (isRecallBoostAblated())
927
- return entries;
928
- return entries.map((e) => {
929
- if (e.superseded_by)
930
- return e;
931
- const wrong = netWrong(e) > 0;
932
- const updated = {
933
- ...e,
934
- retrieval_count: e.retrieval_count + 1,
935
- last_retrieved: wrong ? e.last_retrieved : now.toISOString(),
936
- // +2 days half-life per retrieval (PLAN.md); a wrong memory keeps both, since last_retrieved is the decay anchor
937
- half_life_days: wrong ? e.half_life_days : e.half_life_days + 2,
938
- };
939
- updated.strength = calculateStrength(updated, now);
940
- return updated;
941
- });
942
- }
943
896
  /**
944
897
  * Explain why a search result matched a query.
945
898
  * Computes which query terms overlapped with the document and whether