hippo-memory 1.54.0 → 1.56.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.
@@ -0,0 +1,127 @@
1
+ import type { MemoryEntry } from './memory.js';
2
+ import { type PromptRecallGate } from './prompt-recall.js';
3
+ export type DeliveryRuntime = 'claude-code' | 'codex' | 'unknown';
4
+ export type DeliveryEventType = 'prompt-submit' | 'pinned-manual';
5
+ export type DeliverySurface = 'hook' | 'context';
6
+ export type DeliveryWriteStore = 'local' | 'global';
7
+ export type DeliverySessionState = 'payload' | 'env' | 'missing' | 'subagent';
8
+ export type DeliveryBlockState = 'sent' | 'reused' | 'reused-recall-sent' | 'empty' | 'disabled';
9
+ export type DeliveryPool = 'pin' | 'recent' | 'prompt-recall' | 'strength' | 'search';
10
+ export type DeliveryStage = 'load' | 'eligible' | 'gate' | 'budget' | 'limit' | 'final';
11
+ export type DeliveryOutcome = 'emitted' | 'reused' | 'rejected';
12
+ export type DeliveryRejectReason = 'budget' | 'gate-below-threshold' | 'gate-max-items' | 'duplicate' | 'scope' | 'quality' | 'limit';
13
+ /** Row format version written to `delivery_events.ledger_version`. */
14
+ export declare const DELIVERY_LEDGER_VERSION = 1;
15
+ /** Rejected candidate rows kept per event; the rest only add to `rejected_unlisted`. */
16
+ export declare const DELIVERY_REJECTED_ROW_CAP = 16;
17
+ export interface DeliveryCandidateInput {
18
+ memoryId: string;
19
+ sourceStore: DeliveryWriteStore;
20
+ pool: DeliveryPool;
21
+ stage: DeliveryStage;
22
+ outcome: DeliveryOutcome;
23
+ reason: DeliveryRejectReason | null;
24
+ rank: number | null;
25
+ score: number | null;
26
+ tokens: number | null;
27
+ }
28
+ /** One event as the writer stores it; the writer adds `turn_seq`, `duplicate_of` and the format version. */
29
+ export interface DeliveryEventInput {
30
+ ts: string;
31
+ tenantId: string;
32
+ runtime: DeliveryRuntime;
33
+ eventType: DeliveryEventType;
34
+ surface: DeliverySurface;
35
+ storeHash: string;
36
+ writeStore: DeliveryWriteStore;
37
+ projectHash: string | null;
38
+ sessionId: string | null;
39
+ sessionState: DeliverySessionState;
40
+ hostTurnId: string | null;
41
+ promptHash: string | null;
42
+ promptLength: number;
43
+ queryHash: string | null;
44
+ recallTraceId: number | null;
45
+ blockState: DeliveryBlockState;
46
+ promptRecall: boolean;
47
+ consideredCount: number;
48
+ filteredCount: number;
49
+ selectedCount: number;
50
+ emittedCount: number;
51
+ rejectedCount: number;
52
+ rejectedUnlisted: number;
53
+ sectionsShown: number;
54
+ sectionsDropped: number;
55
+ budgetTokens: number;
56
+ selectedTokens: number;
57
+ injectedTokens: number;
58
+ staticHash: string | null;
59
+ recallHash: string | null;
60
+ emittedHash: string | null;
61
+ elapsedMs: number;
62
+ candidates: readonly DeliveryCandidateInput[];
63
+ }
64
+ export interface DeliveryFacts {
65
+ projectName: string;
66
+ budgetTokens: number;
67
+ promptRecall: boolean;
68
+ }
69
+ /** The shape of a returned context entry the observer reads. */
70
+ export interface DeliverySelected {
71
+ entry: MemoryEntry;
72
+ score: number;
73
+ tokens: number;
74
+ isGlobal?: boolean;
75
+ promptRecall?: boolean;
76
+ }
77
+ /** What getContext reports while it selects. Every method only reads; none changes what is selected. */
78
+ export interface DeliveryObserver {
79
+ facts(facts: DeliveryFacts): void;
80
+ sections(shown: number, dropped: number): void;
81
+ /** Returns `admit`'s own answer unchanged and lets its throws through. */
82
+ watchAdmit(admit: (e: MemoryEntry) => boolean): (e: MemoryEntry) => boolean;
83
+ /** The loader's quality floor dropped a row admit let through; with prompt recall on, eligibility reports it instead. */
84
+ qualityDropped(entry: MemoryEntry, isGlobal: boolean): void;
85
+ disabled(): void;
86
+ /** No `pool` means pin or recent by the entry's own flag. */
87
+ offer(entries: readonly MemoryEntry[], isGlobal: boolean, pool?: DeliveryPool): void;
88
+ reject(entry: MemoryEntry, stage: DeliveryStage, reason: DeliveryRejectReason, score?: number, tokens?: number): void;
89
+ dropMissing(before: readonly MemoryEntry[], after: readonly MemoryEntry[], stage: DeliveryStage, reason: DeliveryRejectReason): void;
90
+ gated(prompt: ReadonlySet<string>, candidates: readonly {
91
+ id: string;
92
+ tokens: ReadonlySet<string>;
93
+ }[], gate: PromptRecallGate, kept: readonly {
94
+ item: {
95
+ id: string;
96
+ };
97
+ }[]): void;
98
+ selected(items: readonly DeliverySelected[]): void;
99
+ }
100
+ /** What the renderer sent, reported once at its exit. */
101
+ export interface DeliveryOutcomeInput {
102
+ state: DeliveryBlockState;
103
+ staticHash?: string | null;
104
+ recallHash?: string | null;
105
+ /** The exact text the agent receives: the hook's additionalContext, or every stdout byte, newline included. */
106
+ emittedText?: string | null;
107
+ /** The static block was skipped as unchanged, so its entries are reused, not sent. */
108
+ staticReused?: boolean;
109
+ }
110
+ export interface DeliveryRecorder extends DeliveryObserver {
111
+ readonly root: string;
112
+ delivered(outcome: DeliveryOutcomeInput): void;
113
+ /** Builds the event and hands it to `write` once per call; throws on an injected fault, and writes nothing once broken. */
114
+ flush(write: (input: DeliveryEventInput) => number | null): void;
115
+ }
116
+ export interface DeliveryRecorderInit {
117
+ /** The store the event is written to. */
118
+ root: string;
119
+ storeHash: string;
120
+ writeStore: DeliveryWriteStore;
121
+ tenantId: string;
122
+ stdinText?: string;
123
+ envSessionId?: string;
124
+ }
125
+ /** A recorder for one call; every observer method is guarded, and a throw marks it broken instead of escaping. */
126
+ export declare function createDeliveryRecorder(init: DeliveryRecorderInit): DeliveryRecorder;
127
+ //# sourceMappingURL=delivery-recorder.d.ts.map
@@ -0,0 +1,218 @@
1
+ import { evalNow } from './ablation.js';
2
+ import { scoreOverlap } from './prompt-recall.js';
3
+ import { blockHash, estimateTokens, hookPayloadSessionId, hookPayloadString, isSubagentPayload } from './token-ledger.js';
4
+ /** Row format version written to `delivery_events.ledger_version`. */
5
+ export const DELIVERY_LEDGER_VERSION = 1;
6
+ /** Rejected candidate rows kept per event; the rest only add to `rejected_unlisted`. */
7
+ export const DELIVERY_REJECTED_ROW_CAP = 16;
8
+ // Deeper stages were closer to being sent, so the row cap keeps them first.
9
+ const STAGE_DEPTH = new Map([
10
+ ['load', 0], ['eligible', 1], ['gate', 2], ['budget', 3], ['limit', 4], ['final', 5],
11
+ ]);
12
+ function storeOf(isGlobal) {
13
+ return isGlobal === true ? 'global' : 'local';
14
+ }
15
+ function byDepthThenScore(a, b) {
16
+ const depth = (STAGE_DEPTH.get(b.stage ?? 'load') ?? 0) - (STAGE_DEPTH.get(a.stage ?? 'load') ?? 0);
17
+ if (depth !== 0)
18
+ return depth;
19
+ const score = (b.score ?? Number.NEGATIVE_INFINITY) - (a.score ?? Number.NEGATIVE_INFINITY);
20
+ if (score !== 0 && !Number.isNaN(score))
21
+ return score;
22
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
23
+ }
24
+ /** A recorder for one call; every observer method is guarded, and a throw marks it broken instead of escaping. */
25
+ export function createDeliveryRecorder(init) {
26
+ const startedMs = Date.now();
27
+ const ts = evalNow().toISOString();
28
+ // Test-only fault injection, as HIPPO_FAKE_NOW is for time.
29
+ const fault = process.env.HIPPO_TEST_DELIVERY_FAULT ?? '';
30
+ const payloadSession = hookPayloadSessionId(init.stdinText);
31
+ const subagent = isSubagentPayload(init.stdinText);
32
+ const envSession = init.envSessionId !== undefined && init.envSessionId !== '' ? init.envSessionId : null;
33
+ const prompt = hookPayloadString(init.stdinText, 'prompt');
34
+ const rawTurnId = hookPayloadString(init.stdinText, 'turn_id');
35
+ const hostTurnId = rawTurnId !== null && rawTurnId.trim() !== '' ? rawTurnId : null;
36
+ const hookEvent = hookPayloadString(init.stdinText, 'hook_event_name');
37
+ const sessionState = subagent
38
+ ? 'subagent'
39
+ : payloadSession !== null ? 'payload' : envSession !== null ? 'env' : 'missing';
40
+ const candidates = new Map();
41
+ const picked = new Map();
42
+ const filtered = new Set();
43
+ let facts = null;
44
+ let shown = 0;
45
+ let dropped = 0;
46
+ let disabledSeen = false;
47
+ let outcome = { state: 'empty' };
48
+ let broken = null;
49
+ let flushed = false;
50
+ const guard = (fn) => {
51
+ if (broken !== null)
52
+ return;
53
+ try {
54
+ if (fault === 'observe')
55
+ throw new Error('injected observe fault');
56
+ fn();
57
+ }
58
+ catch (error) {
59
+ broken = error instanceof Error ? error.message : String(error);
60
+ }
61
+ };
62
+ const rejectId = (id, stage, reason, score, tokens) => {
63
+ const held = candidates.get(id);
64
+ // First rejection wins; an id never offered is not a candidate of this call.
65
+ if (!held || held.reason !== null)
66
+ return;
67
+ candidates.set(id, { ...held, stage, reason, score, tokens });
68
+ };
69
+ const build = () => {
70
+ if (fault === 'build')
71
+ throw new Error('injected build fault');
72
+ const staticReused = outcome.staticReused === true;
73
+ const rows = [];
74
+ for (const p of picked.values()) {
75
+ const held = candidates.get(p.entry.id);
76
+ rows.push({
77
+ memoryId: p.entry.id,
78
+ sourceStore: p.sourceStore,
79
+ pool: p.promptRecall ? 'prompt-recall' : held?.pool === 'pin' || p.entry.pinned ? 'pin' : 'recent',
80
+ stage: 'final',
81
+ outcome: staticReused && !p.promptRecall ? 'reused' : 'emitted',
82
+ reason: null,
83
+ rank: p.rank,
84
+ score: p.score,
85
+ tokens: p.tokens,
86
+ });
87
+ }
88
+ const rejected = [];
89
+ let undecided = 0;
90
+ for (const c of candidates.values()) {
91
+ if (picked.has(c.id))
92
+ continue;
93
+ if (c.reason === null)
94
+ undecided += 1;
95
+ else
96
+ rejected.push(c);
97
+ }
98
+ rejected.sort(byDepthThenScore);
99
+ for (const c of rejected.slice(0, DELIVERY_REJECTED_ROW_CAP)) {
100
+ rows.push({
101
+ memoryId: c.id, sourceStore: c.sourceStore, pool: c.pool, stage: c.stage ?? 'load', outcome: 'rejected',
102
+ reason: c.reason, rank: null, score: c.score, tokens: c.tokens,
103
+ });
104
+ }
105
+ const overflow = Math.max(0, rejected.length - DELIVERY_REJECTED_ROW_CAP);
106
+ const emitted = outcome.emittedText ?? null;
107
+ return {
108
+ ts,
109
+ tenantId: init.tenantId,
110
+ runtime: hostTurnId !== null ? 'codex' : hookEvent !== null ? 'claude-code' : 'unknown',
111
+ eventType: hookEvent === 'UserPromptSubmit' ? 'prompt-submit' : 'pinned-manual',
112
+ surface: 'hook',
113
+ storeHash: init.storeHash,
114
+ writeStore: init.writeStore,
115
+ projectHash: facts !== null && facts.projectName !== '' ? blockHash(facts.projectName) : null,
116
+ sessionId: payloadSession ?? envSession,
117
+ sessionState,
118
+ hostTurnId,
119
+ promptHash: prompt !== null ? blockHash(prompt) : null,
120
+ promptLength: prompt?.length ?? 0,
121
+ queryHash: null,
122
+ recallTraceId: null,
123
+ blockState: disabledSeen ? 'disabled' : outcome.state,
124
+ promptRecall: facts?.promptRecall === true,
125
+ consideredCount: new Set([...candidates.keys(), ...picked.keys()]).size,
126
+ filteredCount: filtered.size,
127
+ selectedCount: picked.size,
128
+ emittedCount: rows.filter((r) => r.outcome === 'emitted').length,
129
+ rejectedCount: rejected.length + undecided,
130
+ rejectedUnlisted: overflow + undecided,
131
+ sectionsShown: shown,
132
+ sectionsDropped: dropped,
133
+ budgetTokens: facts?.budgetTokens ?? 0,
134
+ selectedTokens: [...picked.values()].reduce((sum, p) => sum + p.tokens, 0),
135
+ injectedTokens: emitted !== null ? estimateTokens(emitted) : 0,
136
+ staticHash: outcome.staticHash ?? null,
137
+ recallHash: outcome.recallHash ?? null,
138
+ emittedHash: emitted !== null ? blockHash(emitted) : null,
139
+ elapsedMs: Math.max(0, Date.now() - startedMs),
140
+ candidates: rows,
141
+ };
142
+ };
143
+ return {
144
+ root: init.root,
145
+ facts: (f) => guard(() => { facts = { ...f }; }),
146
+ sections: (s, d) => guard(() => { shown = s; dropped = d; }),
147
+ watchAdmit: (admit) => (e) => {
148
+ const ok = admit(e);
149
+ if (!ok)
150
+ guard(() => { filtered.add(e.id); });
151
+ return ok;
152
+ },
153
+ qualityDropped: (e, isGlobal) => guard(() => {
154
+ filtered.add(e.id);
155
+ if (!candidates.has(e.id)) {
156
+ candidates.set(e.id, {
157
+ id: e.id, sourceStore: storeOf(isGlobal), pool: 'recent', stage: null, reason: null, score: null, tokens: null,
158
+ });
159
+ }
160
+ rejectId(e.id, 'load', 'quality', null, null);
161
+ }),
162
+ disabled: () => guard(() => { disabledSeen = true; }),
163
+ offer: (entries, isGlobal, pool) => guard(() => {
164
+ for (const e of entries) {
165
+ const held = candidates.get(e.id);
166
+ const wanted = pool ?? (e.pinned ? 'pin' : 'recent');
167
+ // A loaded recent row the prompt-recall gate then judges belongs to that pool.
168
+ const relabel = held !== undefined && held.reason === null && held.pool === 'recent' && wanted === 'prompt-recall';
169
+ if (held !== undefined && !relabel)
170
+ continue;
171
+ candidates.set(e.id, {
172
+ id: e.id, sourceStore: storeOf(isGlobal), pool: wanted, stage: null, reason: null, score: null, tokens: null,
173
+ });
174
+ }
175
+ }),
176
+ reject: (e, stage, reason, score, tokens) => guard(() => rejectId(e.id, stage, reason, score ?? null, tokens ?? null)),
177
+ dropMissing: (before, after, stage, reason) => guard(() => {
178
+ const kept = new Set(after.map((e) => e.id));
179
+ for (const e of before)
180
+ if (!kept.has(e.id))
181
+ rejectId(e.id, stage, reason, null, null);
182
+ }),
183
+ gated: (prompt, items, gate, kept) => guard(() => {
184
+ const keptIds = new Set(kept.map((g) => g.item.id));
185
+ for (const c of items) {
186
+ if (keptIds.has(c.id))
187
+ continue;
188
+ const { score, shared } = scoreOverlap(prompt, c.tokens, gate.metric);
189
+ const cleared = score >= gate.threshold && shared >= gate.minShared;
190
+ rejectId(c.id, 'gate', cleared ? 'gate-max-items' : 'gate-below-threshold', score, null);
191
+ }
192
+ }),
193
+ selected: (items) => guard(() => {
194
+ picked.clear();
195
+ items.forEach((r, i) => {
196
+ picked.set(r.entry.id, {
197
+ entry: r.entry, rank: i + 1, score: r.score, tokens: r.tokens,
198
+ sourceStore: storeOf(r.isGlobal), promptRecall: r.promptRecall === true,
199
+ });
200
+ });
201
+ }),
202
+ delivered: (o) => guard(() => { outcome = { ...o }; }),
203
+ flush: (write) => {
204
+ if (flushed)
205
+ return;
206
+ flushed = true;
207
+ if (broken !== null) {
208
+ console.error(`[hippo] delivery ledger skipped: recorder failed: ${broken}`);
209
+ return;
210
+ }
211
+ const input = build();
212
+ if (fault === 'flush')
213
+ throw new Error('injected flush fault');
214
+ write(input);
215
+ },
216
+ };
217
+ }
218
+ //# sourceMappingURL=delivery-recorder.js.map
package/dist/hooks.d.ts CHANGED
@@ -156,7 +156,7 @@ export declare function codexHomeDir(home?: string, env?: Readonly<Record<string
156
156
  /** Codex counts as installed only when its config folder exists: Codex itself refuses a CODEX_HOME that is not a folder. */
157
157
  export declare function isCodexPresent(home?: string): boolean;
158
158
  /** Codex hashes each hook and skips new or changed ones until the user reviews them in `/hooks`, so the reminder says what they would trust. */
159
- export declare const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus the five most recent ones. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
159
+ export declare const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus up to 5 that match the prompt. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
160
160
  /**
161
161
  * Default log path consumed by `hippo last-sleep`. Shared fallback when
162
162
  * a caller doesn't pass --path explicitly.
package/dist/hooks.js CHANGED
@@ -134,7 +134,7 @@ export function isCodexPresent(home = homeDir()) {
134
134
  return fs.statSync(codexHomeDir(home), { throwIfNoEntry: false })?.isDirectory() === true;
135
135
  }
136
136
  /** Codex hashes each hook and skips new or changed ones until the user reviews them in `/hooks`, so the reminder says what they would trust. */
137
- export const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus the five most recent ones. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
137
+ export const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus up to 5 that match the prompt. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
138
138
  /**
139
139
  * Default log path consumed by `hippo last-sleep`. Shared fallback when
140
140
  * a caller doesn't pass --path explicitly.
@@ -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
@@ -23,8 +23,6 @@ export const MAX_FILES = 10;
23
23
  export const MAX_DIGEST_CHARS = 1200;
24
24
  /** Bounds a detached worker's memory; the final message sits at the end, so a tail read loses nothing. */
25
25
  export const READ_CAP_BYTES = 64 * 1024 * 1024;
26
- /** The pinned hook shows the newest 5 memories, so older digests were never injected. */
27
- const RECENT_DIGESTS = 5;
28
26
  const CLAUDE_EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
29
27
  function transcriptItem(value) {
30
28
  return isObjectLike(value) && 'type' in value ? value : null;
@@ -441,12 +439,10 @@ export function isSessionDigestRow(entry) {
441
439
  export function sessionDigestId(tenantId, key) {
442
440
  return `mem_${createHash('sha256').update(`${SESSION_DIGEST_TAG}\n${tenantId}\n${key}`).digest('hex').slice(0, 12)}`;
443
441
  }
444
- /** What hippo could have injected into the session: recent digests and the ambient handoff, never this session's own. */
442
+ /** What hippo could have injected into the session: any live digest (prompt recall reaches old ones) and the ambient handoff, never this session's own. */
445
443
  function injectedTexts(hippoRoot, opts) {
446
444
  const digests = loadAllEntries(hippoRoot, opts.tenantId)
447
445
  .filter((e) => isSessionDigestRow(e) && !e.superseded_by && e.source_session_id !== opts.key)
448
- .sort((a, b) => (a.created < b.created ? 1 : a.created > b.created ? -1 : 0))
449
- .slice(0, RECENT_DIGESTS)
450
446
  .map((e) => e.content);
451
447
  const handoff = loadLatestHandoff(hippoRoot, opts.tenantId, undefined, {
452
448
  unfinishedOnly: true,
@@ -116,6 +116,8 @@ interface ModelTagged {
116
116
  export declare function isSyntheticMessage(message: ModelTagged): boolean;
117
117
  /** A hook payload's non-empty `session_id`, or null; with `requiredSource`, also null when its `source` differs. */
118
118
  export declare function hookPayloadSessionId(stdinText: string | undefined, requiredSource?: string | null): string | null;
119
+ /** A hook payload's string `field` as sent, or null when the payload or the field is missing or not a string. */
120
+ export declare function hookPayloadString(stdinText: string | undefined, field: string): string | null;
119
121
  /** Whether a hook fired inside a sub-agent, the only payload with `agent_id` (https://code.claude.com/docs/en/hooks#common-input-fields).
120
122
  * Its `session_id` is the parent's, so a sub-agent's blocks and compactions must not count as the parent's. */
121
123
  export declare function isSubagentPayload(stdinText: string | undefined): boolean;
@@ -174,6 +174,11 @@ export function hookPayloadSessionId(stdinText, requiredSource = null) {
174
174
  return null;
175
175
  return sessionId;
176
176
  }
177
+ /** A hook payload's string `field` as sent, or null when the payload or the field is missing or not a string. */
178
+ export function hookPayloadString(stdinText, field) {
179
+ const value = parseHookPayload(stdinText)?.[field];
180
+ return isJsonString(value) ? value : null;
181
+ }
177
182
  /** Whether a hook fired inside a sub-agent, the only payload with `agent_id` (https://code.claude.com/docs/en/hooks#common-input-fields).
178
183
  * Its `session_id` is the parent's, so a sub-agent's blocks and compactions must not count as the parent's. */
179
184
  export function isSubagentPayload(stdinText) {
package/dist/version.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export declare const PACKAGE_VERSION = "1.54.0";
19
+ export declare const PACKAGE_VERSION = "1.56.0";
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export declare function compareSemver(a: string, b: string): number;
22
22
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export const PACKAGE_VERSION = '1.54.0';
19
+ export const PACKAGE_VERSION = '1.56.0';
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export function compareSemver(a, b) {
22
22
  const parse = (v) => {