hippo-memory 1.52.7 → 1.52.9
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 +159 -99
- package/dist/api.d.ts +52 -18
- package/dist/api.js +155 -86
- package/dist/audit.d.ts +2 -1
- package/dist/audit.js +63 -0
- package/dist/autolearn.js +3 -2
- package/dist/capture.d.ts +40 -0
- package/dist/capture.js +141 -119
- package/dist/churn-git.js +2 -2
- package/dist/cli.d.ts +1 -4
- package/dist/cli.js +695 -702
- package/dist/codex-patch.d.ts +12 -0
- package/dist/codex-patch.js +71 -0
- package/dist/config.d.ts +0 -1
- package/dist/config.js +0 -4
- package/dist/connectors/slack/types.d.ts +0 -1
- package/dist/consolidate.js +85 -32
- package/dist/context-render.d.ts +36 -0
- package/dist/context-render.js +154 -0
- package/dist/dag.js +3 -2
- package/dist/dashboard.js +4 -0
- package/dist/db.js +6 -6
- package/dist/dedupe.d.ts +6 -6
- package/dist/dedupe.js +10 -9
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +35 -2
- package/dist/dormant.d.ts +4 -0
- package/dist/dormant.js +17 -2
- package/dist/embedding-provider.d.ts +2 -1
- package/dist/embedding-provider.js +2 -1
- package/dist/embeddings.js +23 -3
- package/dist/extensions/openclaw-plugin/index.js +1 -0
- package/dist/extract.js +5 -1
- package/dist/forward-claim-detector.d.ts +1 -1
- package/dist/forward-claim-detector.js +1 -1
- package/dist/graph-recall.d.ts +3 -1
- package/dist/graph-recall.js +13 -10
- package/dist/handoff.d.ts +2 -0
- package/dist/hooks.d.ts +19 -5
- package/dist/hooks.js +131 -30
- package/dist/importers.js +5 -12
- package/dist/judgment.d.ts +30 -0
- package/dist/judgment.js +122 -0
- package/dist/mcp/server.js +174 -213
- package/dist/merged-row.d.ts +6 -0
- package/dist/merged-row.js +35 -0
- package/dist/multihop.d.ts +2 -1
- package/dist/multihop.js +7 -4
- package/dist/physics-state.d.ts +0 -4
- package/dist/physics-state.js +0 -6
- package/dist/predictions.d.ts +2 -17
- package/dist/predictions.js +2 -15
- package/dist/reject-flow.d.ts +7 -5
- package/dist/reject-flow.js +41 -12
- package/dist/salience.js +12 -5
- package/dist/same-text.d.ts +17 -0
- package/dist/same-text.js +38 -0
- package/dist/scheduler.d.ts +4 -0
- package/dist/scheduler.js +8 -0
- package/dist/search.d.ts +7 -0
- package/dist/search.js +16 -32
- package/dist/secret-detect.d.ts +2 -0
- package/dist/secret-detect.js +9 -3
- package/dist/server-detect.js +9 -33
- package/dist/server.js +6 -62
- package/dist/session-digest.d.ts +79 -0
- package/dist/session-digest.js +528 -0
- package/dist/shared.d.ts +11 -3
- package/dist/shared.js +41 -31
- package/dist/store.d.ts +4 -5
- package/dist/store.js +26 -13
- package/dist/token-ledger.d.ts +46 -8
- package/dist/token-ledger.js +140 -21
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist-ui/assets/index-BhT8RvO6.js +61 -0
- package/dist-ui/index.html +1 -1
- package/extensions/openclaw-plugin/README.md +4 -4
- package/extensions/openclaw-plugin/index.ts +1 -0
- package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +2 -2
- package/package.json +2 -2
- package/dist-ui/assets/index-BgmA7Hwe.js +0 -61
package/dist/shared.js
CHANGED
|
@@ -9,13 +9,14 @@ import * as path from 'path';
|
|
|
9
9
|
import { generateId } from './memory.js';
|
|
10
10
|
import { initStore, loadAllEntries, loadIndex, loadSearchEntries, loadRecallSearchEntries, writeEntry, readEntry, } from './store.js';
|
|
11
11
|
import { passesScopeFilterForRecall, passesCliRecallScopeFilter } from './recall-scope.js';
|
|
12
|
-
import { search, hybridSearch } from './search.js';
|
|
12
|
+
import { search, hybridSearch, fitBudget } from './search.js';
|
|
13
13
|
import { evalNow } from './ablation.js';
|
|
14
14
|
import { deriveOriginProject, classifyOriginProject, resolveGlobalRootDir } from './project-identity.js';
|
|
15
15
|
import { detectSecret } from './secret-detect.js';
|
|
16
16
|
import { isQuarantineScope } from './quarantine.js';
|
|
17
17
|
import { RejectedValueError } from './rejection.js';
|
|
18
18
|
import { embedMemory, embedAll } from './embeddings.js';
|
|
19
|
+
import { duplicateKey, storedTextKeys } from './same-text.js';
|
|
19
20
|
/**
|
|
20
21
|
* Returns the path to the global Hippo store.
|
|
21
22
|
* Resolution order: $HIPPO_HOME > $XDG_DATA_HOME/hippo > ~/.hippo/
|
|
@@ -70,11 +71,8 @@ export function promoteToGlobal(localRoot, id, opts) {
|
|
|
70
71
|
origin_project: entry.origin_project ?? deriveOriginProject(path.dirname(path.resolve(localRoot))),
|
|
71
72
|
};
|
|
72
73
|
writeEntry(globalRoot, globalEntry, { actor: opts?.actor });
|
|
73
|
-
// Fire-and-forget: embedMemory
|
|
74
|
-
|
|
75
|
-
// here would be redundant (capture.ts:598 pre-guards instead; both
|
|
76
|
-
// contracts are correct, see docs/plans/2026-07-18-global-row-embeddings.md).
|
|
77
|
-
void embedMemory(globalRoot, globalEntry).catch(() => { });
|
|
74
|
+
// Fire-and-forget: embedMemory gates on availability and never rejects.
|
|
75
|
+
void embedMemory(globalRoot, globalEntry);
|
|
78
76
|
return globalEntry;
|
|
79
77
|
}
|
|
80
78
|
/**
|
|
@@ -108,7 +106,7 @@ export function searchBoth(query, localRoot, globalRoot, options = {}) {
|
|
|
108
106
|
// Remove duplicates by content (local/global IDs differ after promote/share)
|
|
109
107
|
const seen = new Set();
|
|
110
108
|
const deduped = tagged.filter((r) => {
|
|
111
|
-
const key = r.entry.content
|
|
109
|
+
const key = duplicateKey(r.entry.content);
|
|
112
110
|
if (seen.has(key))
|
|
113
111
|
return false;
|
|
114
112
|
seen.add(key);
|
|
@@ -136,7 +134,7 @@ export function searchBoth(query, localRoot, globalRoot, options = {}) {
|
|
|
136
134
|
* Async version of searchBoth that calls hybridSearch instead of search.
|
|
137
135
|
*/
|
|
138
136
|
export async function searchBothHybrid(query, localRoot, globalRoot, options = {}) {
|
|
139
|
-
const { budget = 4000, now = evalNow(), embeddingWeight, explain, mmr, mmrLambda, localBump = 1.2, minResults, scope, includeSuperseded, asOf, tenantId, summaryDeboost, summaryFreshness, entryFilter, recallScope } = options;
|
|
137
|
+
const { budget = 4000, now = evalNow(), embeddingWeight, explain, mmr, mmrLambda, localBump = 1.2, minResults, cost, scope, includeSuperseded, asOf, tenantId, summaryDeboost, summaryFreshness, entryFilter, recallScope } = options;
|
|
140
138
|
// When an admission filter is active, lift the per-store candidate cap
|
|
141
139
|
// (default 200): excluded rows matching the query could otherwise fill the
|
|
142
140
|
// window before any admitted row is even loaded (codex gating round 6).
|
|
@@ -177,10 +175,10 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
|
|
|
177
175
|
if (localEntries.length === 0 && globalEntries.length === 0)
|
|
178
176
|
return [];
|
|
179
177
|
const localResults = await hybridSearch(query, localEntries, {
|
|
180
|
-
budget, now, hippoRoot: localRoot, embeddingWeight, explain, mmr, mmrLambda, minResults, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness,
|
|
178
|
+
budget, now, hippoRoot: localRoot, embeddingWeight, explain, mmr, mmrLambda, minResults, cost, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness,
|
|
181
179
|
});
|
|
182
180
|
const globalResults = await hybridSearch(query, globalEntries, {
|
|
183
|
-
budget, now, hippoRoot: globalRoot, embeddingWeight, explain, mmr, mmrLambda, minResults, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness,
|
|
181
|
+
budget, now, hippoRoot: globalRoot, embeddingWeight, explain, mmr, mmrLambda, minResults, cost, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness,
|
|
184
182
|
});
|
|
185
183
|
// Tag global results. Local memories get a configurable priority bump.
|
|
186
184
|
const tagged = [
|
|
@@ -197,7 +195,7 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
|
|
|
197
195
|
// Remove duplicates by content (local/global IDs differ after promote/share)
|
|
198
196
|
const seen = new Set();
|
|
199
197
|
const deduped = tagged.filter((r) => {
|
|
200
|
-
const key = r.entry.content
|
|
198
|
+
const key = duplicateKey(r.entry.content);
|
|
201
199
|
if (seen.has(key))
|
|
202
200
|
return false;
|
|
203
201
|
seen.add(key);
|
|
@@ -206,17 +204,7 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
|
|
|
206
204
|
// T2 note: PLAIN stable score sort on purpose -- see searchBoth above;
|
|
207
205
|
// same rationale (deterministic inputs + stability; local-first on ties).
|
|
208
206
|
deduped.sort((a, b) => b.score - a.score);
|
|
209
|
-
|
|
210
|
-
const effectiveMinHybrid = minResults ?? 1;
|
|
211
|
-
const results = [];
|
|
212
|
-
let usedTokens = 0;
|
|
213
|
-
for (let i = 0; i < deduped.length; i++) {
|
|
214
|
-
if (results.length >= effectiveMinHybrid && usedTokens + deduped[i].tokens > budget)
|
|
215
|
-
continue;
|
|
216
|
-
usedTokens += deduped[i].tokens;
|
|
217
|
-
results.push(deduped[i]);
|
|
218
|
-
}
|
|
219
|
-
return results;
|
|
207
|
+
return fitBudget(deduped, budget, minResults ?? 1, cost);
|
|
220
208
|
}
|
|
221
209
|
// ---------------------------------------------------------------------------
|
|
222
210
|
// Multi-agent shared memory
|
|
@@ -232,6 +220,19 @@ const TRANSFERABLE_TAGS = new Set([
|
|
|
232
220
|
'powershell', 'quant', 'backtest', 'pattern', 'rule', 'gotcha',
|
|
233
221
|
'sub-agent', 'review', 'best-practice',
|
|
234
222
|
]);
|
|
223
|
+
/** Tags whose rows only a hand-run share or promote may copy to the global store; derived rows inherit them. */
|
|
224
|
+
export const NEVER_AUTO_SHARE_TAGS = new Set([
|
|
225
|
+
'git-learned',
|
|
226
|
+
'session-digest',
|
|
227
|
+
]);
|
|
228
|
+
export function neverAutoShareTags(sources) {
|
|
229
|
+
return [...NEVER_AUTO_SHARE_TAGS].filter((tag) => sources.some((s) => s.tags.includes(tag)));
|
|
230
|
+
}
|
|
231
|
+
/** Tags whose rows sleep keeps as written: never merged, never sent to LLM extraction. Conflict detection keeps its own list. */
|
|
232
|
+
export const NO_MERGE_TAGS = new Set([
|
|
233
|
+
'extracted',
|
|
234
|
+
'session-digest',
|
|
235
|
+
]);
|
|
235
236
|
/**
|
|
236
237
|
* Estimate how well a memory would transfer to other projects.
|
|
237
238
|
* Returns 0..1 where >0.5 = good candidate for sharing.
|
|
@@ -301,10 +302,9 @@ export function shareMemory(localRoot, id, options = {}) {
|
|
|
301
302
|
// Single-row producer: embed here unless the caller opts out. autoShare
|
|
302
303
|
// sets skipEmbed so it can batch its whole run through one embedAll() at
|
|
303
304
|
// the end instead of N serialized full-index rewrites (embedMemory rewrites
|
|
304
|
-
// the whole index JSON per call).
|
|
305
|
-
// promoteToGlobal above.
|
|
305
|
+
// the whole index JSON per call).
|
|
306
306
|
if (!options.skipEmbed) {
|
|
307
|
-
void embedMemory(globalRoot, globalEntry)
|
|
307
|
+
void embedMemory(globalRoot, globalEntry);
|
|
308
308
|
}
|
|
309
309
|
return globalEntry;
|
|
310
310
|
}
|
|
@@ -358,7 +358,7 @@ export function listPeers(globalRoot, tenantId) {
|
|
|
358
358
|
.sort((a, b) => b.count - a.count);
|
|
359
359
|
}
|
|
360
360
|
/**
|
|
361
|
-
* Auto-share:
|
|
361
|
+
* Auto-share: local memories with high transfer scores, not already global, no NEVER_AUTO_SHARE_TAGS tag.
|
|
362
362
|
* Returns the list of shared entries.
|
|
363
363
|
*
|
|
364
364
|
* L9: `options.tenantId` is opt-in. When provided, the LOCAL-entries read is
|
|
@@ -392,17 +392,22 @@ export function autoShare(localRoot, options = {}) {
|
|
|
392
392
|
// per-tenant filtering on the global root would defeat the purpose.
|
|
393
393
|
const globalEntries = loadAllEntries(globalRoot);
|
|
394
394
|
// Build set of global content hashes to avoid duplicates
|
|
395
|
-
const globalContentSet =
|
|
395
|
+
const globalContentSet = storedTextKeys(globalEntries);
|
|
396
396
|
const candidates = localEntries.filter((entry) => {
|
|
397
397
|
// CD5: shareMemory refuses quarantined rows; filtering here keeps sleep from aborting on one.
|
|
398
398
|
if (isQuarantineScope(entry.scope ?? null))
|
|
399
399
|
return false;
|
|
400
|
+
// Before the score: these rows describe one project only, and a git seed's 'error' tag clears the bar.
|
|
401
|
+
if (entry.tags.some((t) => NEVER_AUTO_SHARE_TAGS.has(t))) {
|
|
402
|
+
if (options.stats)
|
|
403
|
+
options.stats.neverAutoShareSkipped = (options.stats.neverAutoShareSkipped ?? 0) + 1;
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
400
406
|
const score = transferScore(entry);
|
|
401
407
|
if (score < minScore)
|
|
402
408
|
return false;
|
|
403
|
-
// Skip if already shared (
|
|
404
|
-
|
|
405
|
-
if (globalContentSet.has(contentKey))
|
|
409
|
+
// Skip if already shared (same text apart from spacing)
|
|
410
|
+
if (globalContentSet.has(duplicateKey(entry.content)))
|
|
406
411
|
return false;
|
|
407
412
|
// v39 S4 producer veto: secret rows never auto-share, regardless of
|
|
408
413
|
// transfer score. (shareMemory would throw; filtering here keeps the
|
|
@@ -457,7 +462,7 @@ export function autoShare(localRoot, options = {}) {
|
|
|
457
462
|
}
|
|
458
463
|
/**
|
|
459
464
|
* Copy all global memories into the local store.
|
|
460
|
-
* Skips entries that already exist locally
|
|
465
|
+
* Skips entries that already exist locally, by ID or by text (promote and share copy under a new ID).
|
|
461
466
|
* Returns the count of newly copied entries.
|
|
462
467
|
*/
|
|
463
468
|
export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
|
|
@@ -468,6 +473,8 @@ export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
|
|
|
468
473
|
// the local-root context provides one.
|
|
469
474
|
const globalEntries = loadAllEntries(globalRoot);
|
|
470
475
|
const localIndex = loadIndex(localRoot);
|
|
476
|
+
const textKey = (e) => `${e.tenantId}\n${e.content}`;
|
|
477
|
+
const localText = new Set(loadAllEntries(localRoot).map(textKey));
|
|
471
478
|
// v39 (codex P1-4): syncing down must not re-import what ambient context
|
|
472
479
|
// excludes - other-project rows are skipped by default and secret rows
|
|
473
480
|
// are never copied. origin_project is preserved on the copy (writeEntry
|
|
@@ -484,6 +491,8 @@ export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
|
|
|
484
491
|
// Skip if already present by ID
|
|
485
492
|
if (localIndex.entries[entry.id])
|
|
486
493
|
continue;
|
|
494
|
+
if (localText.has(textKey(entry)))
|
|
495
|
+
continue;
|
|
487
496
|
if (detectSecret(entry).flagged)
|
|
488
497
|
continue;
|
|
489
498
|
if (!opts.includeCrossProject &&
|
|
@@ -499,6 +508,7 @@ export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
|
|
|
499
508
|
}
|
|
500
509
|
throw err;
|
|
501
510
|
}
|
|
511
|
+
localText.add(textKey(entry));
|
|
502
512
|
count++;
|
|
503
513
|
}
|
|
504
514
|
if (rejected > 0) {
|
package/dist/store.d.ts
CHANGED
|
@@ -645,6 +645,7 @@ export declare function loadLatestHandoff(hippoRoot: string, tenantId: string, s
|
|
|
645
645
|
unfinishedOnly?: boolean;
|
|
646
646
|
maxAgeMs?: number;
|
|
647
647
|
scopeFilter?: 'default-deny';
|
|
648
|
+
excludeSessionId?: string;
|
|
648
649
|
}): SessionHandoff | null;
|
|
649
650
|
/**
|
|
650
651
|
* Load a specific handoff by its row ID.
|
|
@@ -652,12 +653,10 @@ export declare function loadLatestHandoff(hippoRoot: string, tenantId: string, s
|
|
|
652
653
|
export declare function loadHandoffById(hippoRoot: string, tenantId: string, id: number): SessionHandoff | null;
|
|
653
654
|
/** Stamp the outcome on a session's newest handoff, only if it has none yet. Returns rows changed. */
|
|
654
655
|
export declare function stampHandoffOutcome(hippoRoot: string, tenantId: string, sessionId: string, outcome: HandoffOutcome): number;
|
|
655
|
-
/**
|
|
656
|
-
* Auto-write a handoff at session-end from the session's active snapshot (DF1 T3).
|
|
656
|
+
/** Auto-write a handoff at session-end (DF1 T3) from the session's active snapshot, else from `derived`, its transcript state.
|
|
657
657
|
* @param evidence best-effort git state; outcome comes from the newest session_complete event.
|
|
658
|
-
* @returns null
|
|
659
|
-
|
|
660
|
-
export declare function writeSessionEndHandoff(hippoRoot: string, tenantId: string, sessionId: string, evidence: HandoffEvidence | null): SessionHandoff | null;
|
|
658
|
+
* @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript. */
|
|
659
|
+
export declare function writeSessionEndHandoff(hippoRoot: string, tenantId: string, sessionId: string, evidence: HandoffEvidence | null, derived?: Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null): SessionHandoff | null;
|
|
661
660
|
export declare function transitionCard(db: DatabaseSyncLike, tenantId: string, cardId: string, from: CardStatus[], to: CardStatus, extra?: {
|
|
662
661
|
setSql?: string;
|
|
663
662
|
whereSql?: string;
|
package/dist/store.js
CHANGED
|
@@ -16,6 +16,7 @@ import { isRecallBoostAblated } from './ablation.js';
|
|
|
16
16
|
import { rarestPromptTerms, RAREST_TERM_COUNT } from './prompt-recall.js';
|
|
17
17
|
import { appendAuditEvent } from './audit.js';
|
|
18
18
|
import { resolveTenantId } from './tenant.js';
|
|
19
|
+
import { redactSecretsStrict } from './secret-detect.js';
|
|
19
20
|
import { deriveOriginProject, originFromSource, findHippoStoreDir, realpathOrResolve } from './project-identity.js';
|
|
20
21
|
import { checkRejectionGuard, RejectedValueError, rejectionDigest, normalizeValueForRejection, insertRejectedValue, findRejectedValue, } from './rejection.js';
|
|
21
22
|
// AT1 (plan §5): resolveConflict's kind-aware loser removal needs
|
|
@@ -2146,7 +2147,7 @@ export function saveActiveTaskSnapshot(hippoRoot, tenantId, snapshot) {
|
|
|
2146
2147
|
const result = db.prepare(`
|
|
2147
2148
|
INSERT INTO task_snapshots(task, summary, next_step, status, source, session_id, scope, tenant_id, created_at, updated_at)
|
|
2148
2149
|
VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)
|
|
2149
|
-
`).run(snapshot.task, snapshot.summary, snapshot.next_step, snapshot.source ?? 'cli', snapshot.session_id ?? null, snapshot.scope ?? null, tenantId, now, now);
|
|
2150
|
+
`).run(redactSecretsStrict(snapshot.task), redactSecretsStrict(snapshot.summary), redactSecretsStrict(snapshot.next_step), snapshot.source ?? 'cli', snapshot.session_id ?? null, snapshot.scope ?? null, tenantId, now, now);
|
|
2150
2151
|
db.exec('COMMIT');
|
|
2151
2152
|
const id = Number(result.lastInsertRowid ?? 0);
|
|
2152
2153
|
// SAFETY: row's shape matches the ten columns named in the SELECT above.
|
|
@@ -2834,6 +2835,10 @@ export function loadLatestHandoff(hippoRoot, tenantId, sessionId, opts = {}) {
|
|
|
2834
2835
|
conditions.push('session_id = ?');
|
|
2835
2836
|
params.push(sessionId);
|
|
2836
2837
|
}
|
|
2838
|
+
if (opts.excludeSessionId) {
|
|
2839
|
+
conditions.push('session_id != ?');
|
|
2840
|
+
params.push(opts.excludeSessionId);
|
|
2841
|
+
}
|
|
2837
2842
|
if (opts.unfinishedOnly) {
|
|
2838
2843
|
// codex P2: restrict to each session's newest revision first — stampHandoffOutcome
|
|
2839
2844
|
// only stamps the newest row, so an older null-outcome revision must not resurrect.
|
|
@@ -2904,20 +2909,28 @@ export function stampHandoffOutcome(hippoRoot, tenantId, sessionId, outcome) {
|
|
|
2904
2909
|
closeHippoDb(db);
|
|
2905
2910
|
}
|
|
2906
2911
|
}
|
|
2907
|
-
/**
|
|
2908
|
-
* Auto-write a handoff at session-end from the session's active snapshot (DF1 T3).
|
|
2912
|
+
/** Auto-write a handoff at session-end (DF1 T3) from the session's active snapshot, else from `derived`, its transcript state.
|
|
2909
2913
|
* @param evidence best-effort git state; outcome comes from the newest session_complete event.
|
|
2910
|
-
* @returns null
|
|
2911
|
-
|
|
2912
|
-
export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence) {
|
|
2914
|
+
* @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript. */
|
|
2915
|
+
export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence, derived = null) {
|
|
2913
2916
|
assertTenantId('writeSessionEndHandoff', tenantId);
|
|
2914
|
-
const
|
|
2915
|
-
if (!snapshot || snapshot.session_id !== sessionId)
|
|
2916
|
-
return null;
|
|
2917
|
+
const active = loadActiveTaskSnapshot(hippoRoot, tenantId);
|
|
2917
2918
|
const existing = loadLatestHandoff(hippoRoot, tenantId, sessionId);
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2919
|
+
let snapshot;
|
|
2920
|
+
let handoffEvidence = evidence;
|
|
2921
|
+
if (active && active.session_id === sessionId) {
|
|
2922
|
+
// Strict '>': a same-millisecond tie must not swallow the session's only write (test 6e).
|
|
2923
|
+
if (existing && existing.updatedAt > active.updated_at)
|
|
2924
|
+
return null;
|
|
2925
|
+
snapshot = active;
|
|
2926
|
+
}
|
|
2927
|
+
else {
|
|
2928
|
+
// Only an earlier transcript read gives way; `hippo handoff create` and unmarked older handoffs keep winning.
|
|
2929
|
+
if (!derived || (existing && existing.evidence?.derivedFrom !== 'transcript'))
|
|
2930
|
+
return null;
|
|
2931
|
+
snapshot = { ...derived, scope: null };
|
|
2932
|
+
handoffEvidence = { ...evidence, derivedFrom: 'transcript' };
|
|
2933
|
+
}
|
|
2921
2934
|
const db = openHippoDb(hippoRoot);
|
|
2922
2935
|
let outcome = null;
|
|
2923
2936
|
try {
|
|
@@ -2947,7 +2960,7 @@ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence)
|
|
|
2947
2960
|
nextAction: snapshot.next_step,
|
|
2948
2961
|
artifacts: carryForward ? existing.artifacts : [],
|
|
2949
2962
|
scope: snapshot.scope,
|
|
2950
|
-
evidence,
|
|
2963
|
+
evidence: handoffEvidence,
|
|
2951
2964
|
outcome,
|
|
2952
2965
|
constraints: carryForward ? existing.constraints : undefined,
|
|
2953
2966
|
targetRuntime: carryForward ? existing.targetRuntime : undefined,
|
package/dist/token-ledger.d.ts
CHANGED
|
@@ -3,20 +3,24 @@ import type { DatabaseSyncLike } from './db.js';
|
|
|
3
3
|
* Where a block of memory text was sent.
|
|
4
4
|
* - `hook`: the per-prompt `UserPromptSubmit` hook (`hippo context --pinned-only`).
|
|
5
5
|
* - `hook_recall`: the same hook's Z1 prompt-recall section (docs/plans/2026-09-26-z1-prompt-recall.md).
|
|
6
|
+
* - `compact_resume`: the snapshot the SessionStart(compact) hook prints (`hippo compact-resume`).
|
|
6
7
|
* - `context`, `recall`: the CLI commands.
|
|
7
8
|
* - `mcp_recall`, `mcp_context`: the MCP tools.
|
|
8
9
|
* - `http_recall`, `http_context`, `http_assemble`: the HTTP API.
|
|
9
10
|
*/
|
|
10
|
-
export type TokenSurface = 'hook' | 'hook_recall' | 'context' | 'recall' | 'mcp_recall' | 'mcp_context' | 'http_recall' | 'http_context' | 'http_assemble';
|
|
11
|
+
export type TokenSurface = 'hook' | 'hook_recall' | 'compact_resume' | 'context' | 'recall' | 'mcp_recall' | 'mcp_context' | 'http_recall' | 'http_context' | 'http_assemble';
|
|
11
12
|
/** All surfaces, in report order. */
|
|
12
13
|
export declare const TOKEN_SURFACES: readonly TokenSurface[];
|
|
14
|
+
/** Surfaces whose re-reads are counted: only a hook payload tells a sub-agent's block from its parent's, as both carry one session id. */
|
|
15
|
+
export declare const REREAD_SURFACES: readonly TokenSurface[];
|
|
13
16
|
/**
|
|
14
17
|
* What happened to a block.
|
|
15
18
|
* - `inject`: sent to the agent.
|
|
16
19
|
* - `skip`: identical to the session's last injected block, so not sent again.
|
|
17
20
|
* - `reset`: the host compacted its context, so the next block must be sent.
|
|
21
|
+
* - `reread`: tokens later calls read again, booked at session end as one row per session, hook surface and UTC day of the calls.
|
|
18
22
|
*/
|
|
19
|
-
export type TokenEvent = 'inject' | 'skip' | 'reset';
|
|
23
|
+
export type TokenEvent = 'inject' | 'skip' | 'reset' | 'reread';
|
|
20
24
|
/** Rows older than this are pruned on write. */
|
|
21
25
|
export declare const TOKEN_LEDGER_RETENTION_DAYS = 90;
|
|
22
26
|
/**
|
|
@@ -33,7 +37,7 @@ export interface TokenUse {
|
|
|
33
37
|
sessionId?: string | null;
|
|
34
38
|
surface: TokenSurface;
|
|
35
39
|
event: TokenEvent;
|
|
36
|
-
/** Memories (and continuity blocks) in the text. */
|
|
40
|
+
/** Memories (and continuity blocks) in the text; for a `reread`, how many times blocks were read again. */
|
|
37
41
|
items: number;
|
|
38
42
|
/** Estimated tokens of the text; for a `skip`, the tokens not sent. */
|
|
39
43
|
tokens: number;
|
|
@@ -77,6 +81,8 @@ export interface TokenSurfaceSummary {
|
|
|
77
81
|
skipped: number;
|
|
78
82
|
/** Tokens those skipped blocks would have cost. */
|
|
79
83
|
tokensAvoided: number;
|
|
84
|
+
/** Tokens of these blocks that later model calls read again, from sessions that have ended; 0 off {@link REREAD_SURFACES}. */
|
|
85
|
+
tokensReread: number;
|
|
80
86
|
/** Distinct session ids seen (rows without one are not counted). */
|
|
81
87
|
sessions: number;
|
|
82
88
|
}
|
|
@@ -87,20 +93,32 @@ export interface TokenSummary {
|
|
|
87
93
|
surfaces: TokenSurfaceSummary[];
|
|
88
94
|
totalTokens: number;
|
|
89
95
|
totalTokensAvoided: number;
|
|
96
|
+
totalTokensReread: number;
|
|
90
97
|
/** Mean tokens sent per session, over rows that carry a session id. */
|
|
91
98
|
meanTokensPerSession: number;
|
|
99
|
+
/** Distinct session ids in the window. */
|
|
100
|
+
sessions: number;
|
|
101
|
+
/** Sessions that sent, skipped or re-read a {@link REREAD_SURFACES} block, the ones whose re-reads can be counted. */
|
|
102
|
+
hookSessions: number;
|
|
103
|
+
/** Sessions whose re-reads were counted at session end; open or crashed sessions are not. */
|
|
104
|
+
rereadSessions: number;
|
|
92
105
|
}
|
|
93
106
|
/**
|
|
94
107
|
* Sum the ledger for one tenant since `sinceIso`. Surfaces with no rows are
|
|
95
108
|
* omitted.
|
|
96
109
|
*/
|
|
97
110
|
export declare function summarizeTokenUse(db: DatabaseSyncLike, tenantId: string, sinceIso: string): TokenSummary;
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
*/
|
|
111
|
+
/** JSON-value string check without a runtime `typeof` (anti-slop rule). */
|
|
112
|
+
interface ModelTagged {
|
|
113
|
+
model?: unknown;
|
|
114
|
+
}
|
|
115
|
+
/** Claude Code writes its own API errors and limit notices as assistant lines from this model; no model call made them. */
|
|
116
|
+
export declare function isSyntheticMessage(message: ModelTagged): boolean;
|
|
117
|
+
/** A hook payload's non-empty `session_id`, or null; with `requiredSource`, also null when its `source` differs. */
|
|
103
118
|
export declare function hookPayloadSessionId(stdinText: string | undefined, requiredSource?: string | null): string | null;
|
|
119
|
+
/** 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
|
+
* Its `session_id` is the parent's, so a sub-agent's blocks and compactions must not count as the parent's. */
|
|
121
|
+
export declare function isSubagentPayload(stdinText: string | undefined): boolean;
|
|
104
122
|
/** Tokens hippo sent and skipped in one session, for {@link tokensBySession}. */
|
|
105
123
|
export interface SessionTokens {
|
|
106
124
|
sessionId: string;
|
|
@@ -117,4 +135,24 @@ export interface SessionTokens {
|
|
|
117
135
|
* transcript file name, so these join to the host's own usage records.
|
|
118
136
|
*/
|
|
119
137
|
export declare function tokensBySession(db: DatabaseSyncLike, tenantId: string, sinceIso: string): SessionTokens[];
|
|
138
|
+
/** One model API call in a host transcript, for {@link recordRereads}. */
|
|
139
|
+
export interface ApiCall {
|
|
140
|
+
/** Epoch milliseconds of the call's first transcript line. */
|
|
141
|
+
at: number;
|
|
142
|
+
/** Compactions before the call, in file order. */
|
|
143
|
+
compactions: number;
|
|
144
|
+
}
|
|
145
|
+
/** What {@link readApiCalls} found in a transcript. */
|
|
146
|
+
export interface TranscriptCalls {
|
|
147
|
+
calls: ApiCall[];
|
|
148
|
+
/** Candidate lines that were not valid JSON, skipped. */
|
|
149
|
+
malformed: number;
|
|
150
|
+
}
|
|
151
|
+
/** Main-thread model calls in a Claude Code transcript, in file order; rejects when the file cannot be read. */
|
|
152
|
+
export declare function readApiCalls(transcriptPath: string): Promise<TranscriptCalls>;
|
|
153
|
+
/** Calls that carried a block sent at `at` (epoch ms): every later call in the first later call's context window. */
|
|
154
|
+
export declare function carryingCalls(calls: readonly ApiCall[], at: number): ApiCall[];
|
|
155
|
+
/** Replace a session's `reread` rows in one transaction, one per {@link REREAD_SURFACES} surface and UTC day; returns the tokens booked. */
|
|
156
|
+
export declare function recordRereads(db: DatabaseSyncLike, tenantId: string, sessionId: string, calls: readonly ApiCall[]): number;
|
|
157
|
+
export {};
|
|
120
158
|
//# sourceMappingURL=token-ledger.d.ts.map
|
package/dist/token-ledger.js
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
* and how many tokens it costs.
|
|
4
4
|
*
|
|
5
5
|
* One row per block of memory text sent to an agent, on every surface: the
|
|
6
|
-
* per-prompt hook,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* per-prompt hook, the block `hippo compact-resume` restores after compaction,
|
|
7
|
+
* `hippo context`, `hippo recall`, the MCP tools and the HTTP API. The ledger
|
|
8
|
+
* answers the question a buyer asks first ("what does this cost me per
|
|
9
|
+
* session?") and is the input for the token-savings evals.
|
|
10
|
+
*
|
|
11
|
+
* Every later model call re-reads a sent block until the host compacts; at session end the worker counts
|
|
12
|
+
* those calls from the transcript as `reread` rows for the {@link REREAD_SURFACES} blocks, dated by call day.
|
|
9
13
|
*
|
|
10
14
|
* It also backs TE2, inject only on change: the per-prompt hook compares the
|
|
11
15
|
* hash of the block it is about to send with the last block it sent in the
|
|
@@ -15,15 +19,19 @@
|
|
|
15
19
|
* every budget in hippo uses. Rows hold counts, surfaces, session ids and
|
|
16
20
|
* hashes, never memory content or query text.
|
|
17
21
|
*
|
|
18
|
-
* DB
|
|
19
|
-
* call sites; a ledger failure
|
|
22
|
+
* DB helpers take the caller's handle; {@link readApiCalls} streams one
|
|
23
|
+
* transcript file. Writes are best-effort at the call sites; a ledger failure
|
|
24
|
+
* must never break recall.
|
|
20
25
|
*/
|
|
21
26
|
import { createHash } from 'node:crypto';
|
|
27
|
+
import { open } from 'node:fs/promises';
|
|
22
28
|
/** All surfaces, in report order. */
|
|
23
29
|
export const TOKEN_SURFACES = [
|
|
24
|
-
'hook', 'hook_recall', 'context', 'recall', 'mcp_recall', 'mcp_context',
|
|
30
|
+
'hook', 'hook_recall', 'compact_resume', 'context', 'recall', 'mcp_recall', 'mcp_context',
|
|
25
31
|
'http_recall', 'http_context', 'http_assemble',
|
|
26
32
|
];
|
|
33
|
+
/** Surfaces whose re-reads are counted: only a hook payload tells a sub-agent's block from its parent's, as both carry one session id. */
|
|
34
|
+
export const REREAD_SURFACES = ['hook', 'hook_recall', 'compact_resume'];
|
|
27
35
|
/** Rows older than this are pruned on write. */
|
|
28
36
|
export const TOKEN_LEDGER_RETENTION_DAYS = 90;
|
|
29
37
|
/**
|
|
@@ -90,6 +98,7 @@ export function summarizeTokenUse(db, tenantId, sinceIso) {
|
|
|
90
98
|
SUM(CASE WHEN event = 'inject' THEN tokens ELSE 0 END) AS tokens,
|
|
91
99
|
SUM(CASE WHEN event = 'skip' THEN 1 ELSE 0 END) AS skipped,
|
|
92
100
|
SUM(CASE WHEN event = 'skip' THEN tokens ELSE 0 END) AS avoided,
|
|
101
|
+
SUM(CASE WHEN event = 'reread' THEN tokens ELSE 0 END) AS reread,
|
|
93
102
|
COUNT(DISTINCT session_id) AS sessions
|
|
94
103
|
FROM token_ledger
|
|
95
104
|
WHERE tenant_id = ? AND ts >= ?
|
|
@@ -106,14 +115,17 @@ export function summarizeTokenUse(db, tenantId, sinceIso) {
|
|
|
106
115
|
tokens: Number(r.tokens),
|
|
107
116
|
skipped: Number(r.skipped),
|
|
108
117
|
tokensAvoided: Number(r.avoided),
|
|
118
|
+
tokensReread: Number(r.reread),
|
|
109
119
|
sessions: Number(r.sessions),
|
|
110
120
|
});
|
|
111
121
|
}
|
|
112
|
-
// SAFETY: the SELECT names exactly these
|
|
122
|
+
// SAFETY: the SELECT names exactly these four aggregate columns.
|
|
113
123
|
const perSession = db.prepare(`SELECT COUNT(DISTINCT session_id) AS sessions,
|
|
124
|
+
COUNT(DISTINCT CASE WHEN event <> 'reset' AND surface IN (${REREAD_SURFACES.map(() => '?').join(', ')}) THEN session_id END) AS hook_sessions,
|
|
125
|
+
COUNT(DISTINCT CASE WHEN event = 'reread' THEN session_id END) AS reread_sessions,
|
|
114
126
|
SUM(CASE WHEN event = 'inject' THEN tokens ELSE 0 END) AS tokens
|
|
115
127
|
FROM token_ledger
|
|
116
|
-
WHERE tenant_id = ? AND ts >= ? AND session_id IS NOT NULL`).get(tenantId, sinceIso);
|
|
128
|
+
WHERE tenant_id = ? AND ts >= ? AND session_id IS NOT NULL`).get(...REREAD_SURFACES, tenantId, sinceIso);
|
|
117
129
|
const sessionCount = Number(perSession?.sessions ?? 0);
|
|
118
130
|
const sessionTokens = Number(perSession?.tokens ?? 0);
|
|
119
131
|
return {
|
|
@@ -121,10 +133,17 @@ export function summarizeTokenUse(db, tenantId, sinceIso) {
|
|
|
121
133
|
surfaces,
|
|
122
134
|
totalTokens: surfaces.reduce((s, x) => s + x.tokens, 0),
|
|
123
135
|
totalTokensAvoided: surfaces.reduce((s, x) => s + x.tokensAvoided, 0),
|
|
136
|
+
totalTokensReread: surfaces.reduce((s, x) => s + x.tokensReread, 0),
|
|
124
137
|
meanTokensPerSession: sessionCount > 0 ? Math.round(sessionTokens / sessionCount) : 0,
|
|
138
|
+
sessions: sessionCount,
|
|
139
|
+
hookSessions: Number(perSession?.hook_sessions ?? 0),
|
|
140
|
+
rereadSessions: Number(perSession?.reread_sessions ?? 0),
|
|
125
141
|
};
|
|
126
142
|
}
|
|
127
|
-
/**
|
|
143
|
+
/** Claude Code writes its own API errors and limit notices as assistant lines from this model; no model call made them. */
|
|
144
|
+
export function isSyntheticMessage(message) {
|
|
145
|
+
return message.model === '<synthetic>';
|
|
146
|
+
}
|
|
128
147
|
function isJsonString(value) {
|
|
129
148
|
return value !== undefined && value !== null && value.constructor === String;
|
|
130
149
|
}
|
|
@@ -132,31 +151,35 @@ function isJsonString(value) {
|
|
|
132
151
|
function isJsonObject(value) {
|
|
133
152
|
return value !== undefined && value !== null && !Array.isArray(value) && value.constructor === Object;
|
|
134
153
|
}
|
|
135
|
-
/**
|
|
136
|
-
|
|
137
|
-
* text is empty, malformed, has no non-empty session id, or (with
|
|
138
|
-
* `requiredSource`) a different `source`.
|
|
139
|
-
*/
|
|
140
|
-
export function hookPayloadSessionId(stdinText, requiredSource = null) {
|
|
154
|
+
/** A Claude Code hook payload on stdin as a JSON object; null when empty, malformed or not an object. */
|
|
155
|
+
function parseHookPayload(stdinText) {
|
|
141
156
|
if (!stdinText || stdinText.trim() === '')
|
|
142
157
|
return null;
|
|
143
|
-
let payload;
|
|
144
158
|
try {
|
|
145
159
|
// SAFETY: JSON.parse returns a JSON value by definition.
|
|
146
|
-
payload = JSON.parse(stdinText.trim());
|
|
160
|
+
const payload = JSON.parse(stdinText.trim());
|
|
161
|
+
return isJsonObject(payload) ? payload : null;
|
|
147
162
|
}
|
|
148
163
|
catch {
|
|
149
164
|
return null;
|
|
150
165
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
166
|
+
}
|
|
167
|
+
/** A hook payload's non-empty `session_id`, or null; with `requiredSource`, also null when its `source` differs. */
|
|
168
|
+
export function hookPayloadSessionId(stdinText, requiredSource = null) {
|
|
169
|
+
const payload = parseHookPayload(stdinText);
|
|
170
|
+
const sessionId = payload?.session_id;
|
|
171
|
+
if (!payload || !isJsonString(sessionId) || sessionId.trim() === '')
|
|
155
172
|
return null;
|
|
156
173
|
if (requiredSource !== null && payload.source !== requiredSource)
|
|
157
174
|
return null;
|
|
158
175
|
return sessionId;
|
|
159
176
|
}
|
|
177
|
+
/** 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
|
+
* Its `session_id` is the parent's, so a sub-agent's blocks and compactions must not count as the parent's. */
|
|
179
|
+
export function isSubagentPayload(stdinText) {
|
|
180
|
+
const agentId = parseHookPayload(stdinText)?.agent_id;
|
|
181
|
+
return isJsonString(agentId) && agentId.trim() !== '';
|
|
182
|
+
}
|
|
160
183
|
/**
|
|
161
184
|
* Ledger totals per session id since `sinceIso`, across every surface.
|
|
162
185
|
* Claude Code hook rows carry the host's session id, which is also the
|
|
@@ -178,4 +201,100 @@ export function tokensBySession(db, tenantId, sinceIso) {
|
|
|
178
201
|
injections: Number(r.injections),
|
|
179
202
|
}));
|
|
180
203
|
}
|
|
204
|
+
/** Main-thread model calls in a Claude Code transcript, in file order; rejects when the file cannot be read. */
|
|
205
|
+
export async function readApiCalls(transcriptPath) {
|
|
206
|
+
const file = await open(transcriptPath);
|
|
207
|
+
const calls = [];
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
let compactions = 0;
|
|
210
|
+
let malformed = 0;
|
|
211
|
+
try {
|
|
212
|
+
for await (const line of file.readLines({ encoding: 'utf8' })) {
|
|
213
|
+
// Transcripts can pass 100 MB, so only lines that can be a call or a boundary are parsed.
|
|
214
|
+
if (!line.includes('"usage"') && !line.includes('"compact_boundary"'))
|
|
215
|
+
continue;
|
|
216
|
+
let entry;
|
|
217
|
+
try {
|
|
218
|
+
// SAFETY: JSON.parse returns a JSON value by definition.
|
|
219
|
+
entry = JSON.parse(line);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
malformed += 1; // reported by the caller: one torn line must not void the session
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
// Sidechain calls, sidechain compactions and `<synthetic>` messages never touch the main context.
|
|
226
|
+
if (!isJsonObject(entry) || entry.isSidechain === true)
|
|
227
|
+
continue;
|
|
228
|
+
if (entry.subtype === 'compact_boundary') {
|
|
229
|
+
compactions += 1;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
// One call spans lines sharing a message id.
|
|
233
|
+
const message = entry.message;
|
|
234
|
+
if (entry.type !== 'assistant' || !isJsonObject(message))
|
|
235
|
+
continue;
|
|
236
|
+
if (!isJsonObject(message.usage) || isSyntheticMessage(message) || !isJsonString(message.id))
|
|
237
|
+
continue;
|
|
238
|
+
if (seen.has(message.id))
|
|
239
|
+
continue;
|
|
240
|
+
seen.add(message.id);
|
|
241
|
+
const at = isJsonString(entry.timestamp) ? Date.parse(entry.timestamp) : Number.NaN;
|
|
242
|
+
if (!Number.isNaN(at))
|
|
243
|
+
calls.push({ at, compactions });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
await file.close();
|
|
248
|
+
}
|
|
249
|
+
return { calls, malformed };
|
|
250
|
+
}
|
|
251
|
+
/** Calls that carried a block sent at `at` (epoch ms): every later call in the first later call's context window. */
|
|
252
|
+
export function carryingCalls(calls, at) {
|
|
253
|
+
// Windows come from file order, not boundary timestamps: the compact-resume block is booked before its boundary's timestamp.
|
|
254
|
+
const first = calls.find((call) => call.at > at);
|
|
255
|
+
if (!first)
|
|
256
|
+
return [];
|
|
257
|
+
return calls.filter((call) => call.at > at && call.compactions === first.compactions);
|
|
258
|
+
}
|
|
259
|
+
/** Replace a session's `reread` rows in one transaction, one per {@link REREAD_SURFACES} surface and UTC day; returns the tokens booked. */
|
|
260
|
+
export function recordRereads(db, tenantId, sessionId, calls) {
|
|
261
|
+
db.exec('BEGIN IMMEDIATE');
|
|
262
|
+
let committed = false;
|
|
263
|
+
try {
|
|
264
|
+
// SAFETY: the SELECT names exactly these three columns.
|
|
265
|
+
const rows = db.prepare(`SELECT ts, surface, tokens FROM token_ledger
|
|
266
|
+
WHERE tenant_id = ? AND session_id = ? AND event = 'inject' AND surface IN (${REREAD_SURFACES.map(() => '?').join(', ')})`).all(tenantId, sessionId, ...REREAD_SURFACES);
|
|
267
|
+
const days = new Map();
|
|
268
|
+
const add = (surface, at, rereads, tokens) => {
|
|
269
|
+
const key = `${surface} ${new Date(at).toISOString().slice(0, 10)}`;
|
|
270
|
+
const day = days.get(key) ?? { surface, at, rereads: 0, tokens: 0 };
|
|
271
|
+
days.set(key, { surface, at: Math.max(day.at, at), rereads: day.rereads + rereads, tokens: day.tokens + tokens });
|
|
272
|
+
};
|
|
273
|
+
for (const row of rows) {
|
|
274
|
+
const sentAt = Date.parse(row.ts);
|
|
275
|
+
// Dated by when the calls happened, so a --days window counts re-reads in it; the empty send-day row keeps the session in coverage.
|
|
276
|
+
add(row.surface, sentAt, 0, 0);
|
|
277
|
+
// The first carrying call is the send itself; every later one is a re-read.
|
|
278
|
+
for (const call of carryingCalls(calls, sentAt).slice(1))
|
|
279
|
+
add(row.surface, call.at, 1, Number(row.tokens));
|
|
280
|
+
}
|
|
281
|
+
db.prepare(`DELETE FROM token_ledger WHERE tenant_id = ? AND session_id = ? AND event = 'reread'`).run(tenantId, sessionId);
|
|
282
|
+
for (const day of days.values()) {
|
|
283
|
+
recordTokenUse(db, {
|
|
284
|
+
tenantId, sessionId, surface: day.surface, event: 'reread', items: day.rereads, tokens: day.tokens, now: new Date(day.at).toISOString(),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
db.exec('COMMIT');
|
|
288
|
+
committed = true;
|
|
289
|
+
return [...days.values()].reduce((sum, day) => sum + day.tokens, 0);
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
if (!committed) {
|
|
293
|
+
try {
|
|
294
|
+
db.exec('ROLLBACK');
|
|
295
|
+
}
|
|
296
|
+
catch { /* preserve the original throw */ }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
181
300
|
//# sourceMappingURL=token-ledger.js.map
|
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.52.
|
|
19
|
+
export declare const PACKAGE_VERSION = "1.52.9";
|
|
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
|