hippo-memory 1.45.0 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -15
- package/dist/ablation.d.ts +10 -1
- package/dist/ablation.js +17 -1
- package/dist/api.d.ts +60 -1
- package/dist/api.js +189 -7
- package/dist/audit.d.ts +1 -1
- package/dist/capture-error.d.ts +20 -0
- package/dist/capture-error.js +82 -0
- package/dist/capture.d.ts +25 -8
- package/dist/capture.js +100 -5
- package/dist/cli.d.ts +6 -1
- package/dist/cli.js +381 -48
- package/dist/config.d.ts +20 -0
- package/dist/config.js +35 -0
- package/dist/consolidate.d.ts +6 -0
- package/dist/consolidate.js +98 -13
- package/dist/db.js +57 -1
- package/dist/doctor.d.ts +34 -0
- package/dist/doctor.js +174 -0
- package/dist/dormant.d.ts +91 -0
- package/dist/dormant.js +121 -0
- package/dist/eval-stats.d.ts +123 -0
- package/dist/eval-stats.js +187 -0
- package/dist/half-life-migration.d.ts +55 -0
- package/dist/half-life-migration.js +111 -0
- package/dist/hooks.d.ts +4 -0
- package/dist/hooks.js +47 -0
- package/dist/mcp/server.d.ts +6 -0
- package/dist/mcp/server.js +70 -13
- package/dist/memory.d.ts +16 -2
- package/dist/memory.js +27 -5
- package/dist/physics-config.js +5 -1
- package/dist/recall-scope.d.ts +24 -0
- package/dist/recall-scope.js +41 -0
- package/dist/reject-flow.d.ts +3 -3
- package/dist/reject-flow.js +10 -3
- package/dist/search.d.ts +4 -4
- package/dist/search.js +23 -18
- package/dist/server.js +11 -1
- package/dist/store.d.ts +12 -1
- package/dist/store.js +58 -12
- package/dist/token-ledger.d.ts +119 -0
- package/dist/token-ledger.js +181 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
package/dist/search.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* BM25 search + optional embedding hybrid search for Hippo.
|
|
3
3
|
* Zero external dependencies when embeddings are not available.
|
|
4
4
|
*/
|
|
5
|
+
import { estimateTokens } from './token-ledger.js';
|
|
5
6
|
import { MemoryEntry } from './memory.js';
|
|
6
7
|
import type { PhysicsConfig } from './physics-config.js';
|
|
7
8
|
export declare function tokenize(text: string): string[];
|
|
@@ -17,10 +18,9 @@ export interface BM25Corpus {
|
|
|
17
18
|
N: number;
|
|
18
19
|
}
|
|
19
20
|
export declare function buildCorpus(texts: string[]): BM25Corpus;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export declare function estimateTokens(text: string): number;
|
|
21
|
+
export { estimateTokens };
|
|
22
|
+
/** Retrieval-time outcome nudge in [0.85, 1.15]; the E1 bm25-outcome baseline ranks with it too. */
|
|
23
|
+
export declare function outcomeMultiplier(entry: MemoryEntry): number;
|
|
24
24
|
export declare function detectTemporalDirection(query: string): 'recent' | 'oldest' | null;
|
|
25
25
|
export interface TemporalRange {
|
|
26
26
|
minTime: number;
|
package/dist/search.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* BM25 search + optional embedding hybrid search for Hippo.
|
|
3
3
|
* Zero external dependencies when embeddings are not available.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { estimateTokens } from './token-ledger.js';
|
|
6
|
+
import { calculateStrength, netWrong } from './memory.js';
|
|
7
|
+
import { isOutcomeFastAblated, isRecallBoostAblated, isRecencyAblated, evalRecencyScaleDays, evalNow } from './ablation.js';
|
|
7
8
|
import { extractPathTags, pathBoostMultiplier } from './path-context.js';
|
|
8
9
|
import { detectScope, scopeMatch } from './scope.js';
|
|
9
10
|
import { cosineSimilarity, embeddingModelRequiresReindex, loadEmbeddingIndex, } from './embeddings.js';
|
|
@@ -68,20 +69,28 @@ function bm25Score(corpus, docIdx, queryTerms) {
|
|
|
68
69
|
// ---------------------------------------------------------------------------
|
|
69
70
|
// Token budget estimation
|
|
70
71
|
// ---------------------------------------------------------------------------
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
export function estimateTokens(text) {
|
|
75
|
-
return Math.ceil(text.length / 4);
|
|
76
|
-
}
|
|
72
|
+
// Rough token estimate (characters / 4). Defined once in token-ledger.ts and
|
|
73
|
+
// re-exported here, where callers have always imported it from.
|
|
74
|
+
export { estimateTokens };
|
|
77
75
|
// ---------------------------------------------------------------------------
|
|
78
76
|
// Recency boost
|
|
79
77
|
// ---------------------------------------------------------------------------
|
|
80
78
|
function recencyBoost(entry, now) {
|
|
79
|
+
if (isRecencyAblated())
|
|
80
|
+
return 1; // EVAL-ONLY ablation (see ablation.ts)
|
|
81
81
|
const created = new Date(entry.created);
|
|
82
82
|
const ageDays = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24);
|
|
83
83
|
// Exponential decay: memories < 1 day get boost ~1.0, older get less
|
|
84
|
-
return Math.exp(-ageDays / 30);
|
|
84
|
+
return Math.exp(-ageDays / (evalRecencyScaleDays() ?? 30));
|
|
85
|
+
}
|
|
86
|
+
/** Retrieval-time outcome nudge in [0.85, 1.15]; the E1 bm25-outcome baseline ranks with it too. */
|
|
87
|
+
export function outcomeMultiplier(entry) {
|
|
88
|
+
const pos = entry.outcome_positive ?? 0;
|
|
89
|
+
const neg = entry.outcome_negative ?? 0;
|
|
90
|
+
// EVAL-ONLY ablation (see ablation.ts): the fast outcome channel.
|
|
91
|
+
if (isOutcomeFastAblated() || (pos === 0 && neg === 0))
|
|
92
|
+
return 1.0;
|
|
93
|
+
return Math.max(0.85, Math.min(1.15, 1 + 0.15 * Math.tanh((pos - neg) / 2)));
|
|
85
94
|
}
|
|
86
95
|
// ---------------------------------------------------------------------------
|
|
87
96
|
// Temporal-aware scoring
|
|
@@ -364,12 +373,7 @@ export async function hybridSearch(query, entries, options = {}) {
|
|
|
364
373
|
compositeScore *= pathBoost;
|
|
365
374
|
// Retrieval-time outcome personalization: nudge up/down from user feedback.
|
|
366
375
|
// Distinct from reward-factor-via-strength (slow); this is immediate.
|
|
367
|
-
|
|
368
|
-
const pos = entries[i].outcome_positive ?? 0;
|
|
369
|
-
const neg = entries[i].outcome_negative ?? 0;
|
|
370
|
-
const outcomeBoost = isOutcomeFastAblated() || (pos === 0 && neg === 0)
|
|
371
|
-
? 1.0
|
|
372
|
-
: Math.max(0.85, Math.min(1.15, 1 + 0.15 * Math.tanh((pos - neg) / 2)));
|
|
376
|
+
const outcomeBoost = outcomeMultiplier(entries[i]);
|
|
373
377
|
compositeScore *= outcomeBoost;
|
|
374
378
|
// Scope boost: memories tagged with the active scope get 1.5x; mismatching scopes get 0.5x
|
|
375
379
|
const scopeSignal = scopeMatch(entries[i].tags, activeScope);
|
|
@@ -929,12 +933,13 @@ export function markRetrieved(entries, now = evalNow()) {
|
|
|
929
933
|
return entries.map((e) => {
|
|
930
934
|
if (e.superseded_by)
|
|
931
935
|
return e;
|
|
936
|
+
const wrong = netWrong(e) > 0;
|
|
932
937
|
const updated = {
|
|
933
938
|
...e,
|
|
934
939
|
retrieval_count: e.retrieval_count + 1,
|
|
935
|
-
last_retrieved: now.toISOString(),
|
|
936
|
-
//
|
|
937
|
-
half_life_days: e.half_life_days + 2,
|
|
940
|
+
last_retrieved: wrong ? e.last_retrieved : now.toISOString(),
|
|
941
|
+
// +2 days half-life per retrieval (PLAN.md); a wrong memory keeps both, since last_retrieved is the decay anchor
|
|
942
|
+
half_life_days: wrong ? e.half_life_days : e.half_life_days + 2,
|
|
938
943
|
};
|
|
939
944
|
updated.strength = calculateStrength(updated, now);
|
|
940
945
|
return updated;
|
package/dist/server.js
CHANGED
|
@@ -22,7 +22,7 @@ export function __resetSessionRecallHistoryHttp() {
|
|
|
22
22
|
import { PACKAGE_VERSION } from './version.js';
|
|
23
23
|
import { validateApiKey } from './auth.js';
|
|
24
24
|
import { createRateLimiter } from './rate-limit.js';
|
|
25
|
-
import { remember, retrieve, RecallContractError, ForbiddenError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, adminActor, } from './api.js';
|
|
25
|
+
import { remember, retrieve, RecallContractError, ForbiddenError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, adminActor, recordTokens, } from './api.js';
|
|
26
26
|
import { buildGraphModel } from './graph-view.js';
|
|
27
27
|
import { MAX_ENTITY_NAME_LEN } from './graph.js';
|
|
28
28
|
import { savePrediction, closePrediction, loadPredictionById, loadPredictionsByClass, loadOpenPredictions, computePredictionBaserate, VALID_CLOSURE_STATES, } from './predictions.js';
|
|
@@ -114,6 +114,8 @@ const VALID_AUDIT_OPS = new Set([
|
|
|
114
114
|
'reject_refusal', // AT1 — emitted when the rejection guard refuses a write; lockstep
|
|
115
115
|
'unreject_value', // AT1 — emitted by `hippo unreject`; lockstep
|
|
116
116
|
'conflict_resolve', // AT1 — emitted by resolveConflict on every resolution path; lockstep
|
|
117
|
+
'half_life_migrate', // Decay default change — emitted by migrateDefaultHalfLife; lockstep with AuditOp union
|
|
118
|
+
'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
|
|
117
119
|
]);
|
|
118
120
|
// Cap on GET /v1/audit?limit=. Matches docs/api.md (when written) and is large
|
|
119
121
|
// enough to dump a small deployment's full audit log without paginating, but
|
|
@@ -303,6 +305,9 @@ function mapApiError(err) {
|
|
|
303
305
|
if (/already superseded/.test(lower)) {
|
|
304
306
|
return { status: 409, message };
|
|
305
307
|
}
|
|
308
|
+
if (/requires admin role/.test(lower)) {
|
|
309
|
+
return { status: 403, message };
|
|
310
|
+
}
|
|
306
311
|
return { status: 400, message };
|
|
307
312
|
}
|
|
308
313
|
function parseRequest(req) {
|
|
@@ -801,6 +806,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
801
806
|
if (includeContinuity) {
|
|
802
807
|
res.setHeader('Cache-Control', 'no-store');
|
|
803
808
|
}
|
|
809
|
+
recordTokens(ctx, 'http_recall', { items: result.results.length, tokens: result.tokens + (result.continuityTokens ?? 0), sessionId: sessionId ?? null });
|
|
804
810
|
sendJson(res, 200, result);
|
|
805
811
|
return;
|
|
806
812
|
}
|
|
@@ -842,6 +848,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
842
848
|
if (scope !== undefined)
|
|
843
849
|
assembleExtra.scope = scope;
|
|
844
850
|
const result = assemble(ctx, assembleMatch.id, assembleExtra);
|
|
851
|
+
recordTokens(ctx, 'http_assemble', { items: result.items.length, tokens: result.tokens, sessionId: assembleMatch.id });
|
|
845
852
|
sendJson(res, 200, result);
|
|
846
853
|
return;
|
|
847
854
|
}
|
|
@@ -1042,6 +1049,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
1042
1049
|
crossProject,
|
|
1043
1050
|
currentProject: resolveProjectIdentity(dirname(resolve(opts.hippoRoot))).name,
|
|
1044
1051
|
});
|
|
1052
|
+
recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
|
|
1045
1053
|
sendJson(res, 200, result);
|
|
1046
1054
|
return;
|
|
1047
1055
|
}
|
|
@@ -3011,6 +3019,8 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
3011
3019
|
tenantId: ctx.tenantId,
|
|
3012
3020
|
// v1.12.0: McpContext.actor stays string; extract subject at the boundary.
|
|
3013
3021
|
actor: ctx.actor.subject,
|
|
3022
|
+
// The caller's real role: MCP tools must not run a member key as admin.
|
|
3023
|
+
role: ctx.actor.role,
|
|
3014
3024
|
clientKey: buildMcpClientKey(req),
|
|
3015
3025
|
});
|
|
3016
3026
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { SessionHandoff, HandoffEvidence, HandoffOutcome } from './handoff.js';
|
|
|
10
10
|
import { Card, CardStatus, CardRun, CardComment } from './card.js';
|
|
11
11
|
import { type ResolveProjectIdentityOpts } from './project-identity.js';
|
|
12
12
|
import { RejectedValueError } from './rejection.js';
|
|
13
|
+
import { type DormantMove } from './dormant.js';
|
|
13
14
|
/** A value that round-trips through JSON.stringify/JSON.parse unchanged. */
|
|
14
15
|
type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
15
16
|
[key: string]: JsonValue;
|
|
@@ -108,6 +109,8 @@ export declare function assertNonEmpty<T>(arr: readonly T[], name: string): void
|
|
|
108
109
|
export declare function getHippoRoot(cwd?: string, opts?: ResolveProjectIdentityOpts): string;
|
|
109
110
|
export declare function isInitialized(hippoRoot: string): boolean;
|
|
110
111
|
export declare function initStore(hippoRoot: string): void;
|
|
112
|
+
/** `meta` key holding the default half-life base a store's memories are on (src/half-life-migration.ts). */
|
|
113
|
+
export declare const HALF_LIFE_BASE_META_KEY = "default_half_life_base";
|
|
111
114
|
/**
|
|
112
115
|
* Serialize a MemoryEntry to markdown with YAML frontmatter.
|
|
113
116
|
*/
|
|
@@ -405,9 +408,15 @@ export declare function deleteEntry(hippoRoot: string, id: string, opts?: {
|
|
|
405
408
|
automatic?: boolean;
|
|
406
409
|
}): boolean;
|
|
407
410
|
/** Consolidation's flush, one transaction. With `snapshot` (rows as the caller loaded them), a write keeps only
|
|
408
|
-
* the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone.
|
|
411
|
+
* the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone.
|
|
412
|
+
*
|
|
413
|
+
* `dormant` (src/dormant.ts): each move's snapshot is inserted into `dormant_memories` and its `memories` row
|
|
414
|
+
* leaves exactly like a delete (FTS row, DAG parent dirty-mark, mirrors), in the same transaction, so a memory
|
|
415
|
+
* is never in both places or in neither. Deletes and moves both skip rows that are no longer auto-deletable
|
|
416
|
+
* (pinned or raw since the caller decided). Returns the ids that left `memories`, deleted or moved. */
|
|
409
417
|
export declare function batchWriteAndDelete(hippoRoot: string, toWrite: MemoryEntry[], toDeleteIds: string[], opts?: {
|
|
410
418
|
snapshot?: ReadonlyMap<string, MemoryEntry>;
|
|
419
|
+
dormant?: DormantMove[];
|
|
411
420
|
}): string[];
|
|
412
421
|
/**
|
|
413
422
|
* Load all entries from SQLite.
|
|
@@ -417,6 +426,8 @@ export declare function batchWriteAndDelete(hippoRoot: string, toWrite: MemoryEn
|
|
|
417
426
|
* paths that surface results to a user MUST pass a resolved tenant.
|
|
418
427
|
*/
|
|
419
428
|
export declare function loadAllEntries(hippoRoot: string, tenantId?: string): MemoryEntry[];
|
|
429
|
+
/** Every memory row on an open connection, so a caller can read inside its own transaction. */
|
|
430
|
+
export declare function selectAllEntries(db: DatabaseSyncLike, tenantId?: string): MemoryEntry[];
|
|
420
431
|
export declare function loadAmbientCandidates(hippoRoot: string, tenantId: string, recentNeeded: number, admit: (e: MemoryEntry) => boolean): MemoryEntry[];
|
|
421
432
|
/**
|
|
422
433
|
* Load likely search candidates directly from SQLite.
|
package/dist/store.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'fs';
|
|
8
8
|
import * as path from 'path';
|
|
9
|
-
import { Layer, generateId, AUTO_DELETABLE_SQL } from './memory.js';
|
|
9
|
+
import { Layer, generateId, AUTO_DELETABLE_SQL, DEFAULT_HALF_LIFE_DAYS } from './memory.js';
|
|
10
10
|
import { dumpFrontmatter, parseFrontmatter } from './yaml.js';
|
|
11
11
|
import { openHippoDb, closeHippoDb, getMeta, setMeta, isFtsAvailable, pruneConsolidationRuns, getHippoDbPath, } from './db.js';
|
|
12
12
|
import { rowToSessionHandoff, isHandoffOutcome } from './handoff.js';
|
|
@@ -23,6 +23,7 @@ import { checkRejectionGuard, RejectedValueError, rejectionDigest, normalizeValu
|
|
|
23
23
|
// inside function bodies (never at module-evaluation time), so the cycle
|
|
24
24
|
// is the standard safe mutual-function-reference shape under NodeNext ESM.
|
|
25
25
|
import { archiveRawMemory } from './raw-archive.js';
|
|
26
|
+
import { insertDormantRow } from './dormant.js';
|
|
26
27
|
/**
|
|
27
28
|
* Emit an audit event for a mutation against `db`. Wrapped so a broken audit
|
|
28
29
|
* log can never crash the surrounding mutation — the SQLite store is still the
|
|
@@ -119,11 +120,27 @@ export function initStore(hippoRoot) {
|
|
|
119
120
|
if (bootstrapped) {
|
|
120
121
|
syncMirrorFiles(hippoRoot, db);
|
|
121
122
|
}
|
|
123
|
+
recordHalfLifeBaseForNewStore(db);
|
|
122
124
|
}
|
|
123
125
|
finally {
|
|
124
126
|
closeHippoDb(db);
|
|
125
127
|
}
|
|
126
128
|
}
|
|
129
|
+
/** `meta` key holding the default half-life base a store's memories are on (src/half-life-migration.ts). */
|
|
130
|
+
export const HALF_LIFE_BASE_META_KEY = 'default_half_life_base';
|
|
131
|
+
/**
|
|
132
|
+
* A store with no memories starts on the current default half-life base, so
|
|
133
|
+
* `hippo sleep` never migrates it. A store that already holds memories and
|
|
134
|
+
* no recorded base predates the record, and keeps reading as the legacy
|
|
135
|
+
* 7-day base until sleep migrates it.
|
|
136
|
+
*/
|
|
137
|
+
function recordHalfLifeBaseForNewStore(db) {
|
|
138
|
+
if (getMeta(db, HALF_LIFE_BASE_META_KEY, '') !== '')
|
|
139
|
+
return;
|
|
140
|
+
if (db.prepare(`SELECT 1 AS x FROM memories LIMIT 1`).get() !== undefined)
|
|
141
|
+
return;
|
|
142
|
+
setMeta(db, HALF_LIFE_BASE_META_KEY, String(DEFAULT_HALF_LIFE_DAYS));
|
|
143
|
+
}
|
|
127
144
|
function ensureMirrorDirectories(hippoRoot) {
|
|
128
145
|
const dirs = [
|
|
129
146
|
hippoRoot,
|
|
@@ -1613,9 +1630,15 @@ function mergeOwnChanges(base, ours, live) {
|
|
|
1613
1630
|
return row;
|
|
1614
1631
|
}
|
|
1615
1632
|
/** Consolidation's flush, one transaction. With `snapshot` (rows as the caller loaded them), a write keeps only
|
|
1616
|
-
* the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone.
|
|
1633
|
+
* the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone.
|
|
1634
|
+
*
|
|
1635
|
+
* `dormant` (src/dormant.ts): each move's snapshot is inserted into `dormant_memories` and its `memories` row
|
|
1636
|
+
* leaves exactly like a delete (FTS row, DAG parent dirty-mark, mirrors), in the same transaction, so a memory
|
|
1637
|
+
* is never in both places or in neither. Deletes and moves both skip rows that are no longer auto-deletable
|
|
1638
|
+
* (pinned or raw since the caller decided). Returns the ids that left `memories`, deleted or moved. */
|
|
1617
1639
|
export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
|
|
1618
|
-
|
|
1640
|
+
const dormantMoves = opts?.dormant ?? [];
|
|
1641
|
+
if (toWrite.length === 0 && toDeleteIds.length === 0 && dormantMoves.length === 0)
|
|
1619
1642
|
return [];
|
|
1620
1643
|
initStore(hippoRoot);
|
|
1621
1644
|
const db = openHippoDb(hippoRoot);
|
|
@@ -1722,7 +1745,26 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
|
|
|
1722
1745
|
tenantById.set(row.dag_parent_id, row.tenantId);
|
|
1723
1746
|
}
|
|
1724
1747
|
}
|
|
1725
|
-
|
|
1748
|
+
// Dormant moves: same eligibility and DAG bookkeeping as deletes.
|
|
1749
|
+
const movable = [];
|
|
1750
|
+
if (dormantMoves.length > 0) {
|
|
1751
|
+
const byId = new Map(dormantMoves.map((m) => [m.entry.id, m]));
|
|
1752
|
+
const placeholders = dormantMoves.map(() => '?').join(',');
|
|
1753
|
+
// SAFETY: rows' shape matches the three columns named in the SELECT.
|
|
1754
|
+
const rows = db.prepare(`SELECT id, dag_parent_id, tenant_id FROM memories WHERE id IN (${placeholders}) AND ${AUTO_DELETABLE_SQL}`).all(...byId.keys());
|
|
1755
|
+
for (const row of rows) {
|
|
1756
|
+
movable.push(byId.get(row.id));
|
|
1757
|
+
if (row.dag_parent_id) {
|
|
1758
|
+
dirtyParents.add(row.dag_parent_id);
|
|
1759
|
+
tenantById.set(row.dag_parent_id, row.tenant_id ?? 'default');
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
for (const move of movable) {
|
|
1764
|
+
insertDormantRow(db, move);
|
|
1765
|
+
}
|
|
1766
|
+
const removedIds = [...deletableIds, ...movable.map((m) => m.entry.id)];
|
|
1767
|
+
for (const id of removedIds) {
|
|
1726
1768
|
db.prepare('DELETE FROM memories WHERE id = ?').run(id);
|
|
1727
1769
|
deleteFtsRow(db, id);
|
|
1728
1770
|
}
|
|
@@ -1742,10 +1784,10 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
|
|
|
1742
1784
|
for (const entry of written)
|
|
1743
1785
|
writeMarkdownMirror(hippoRoot, entry);
|
|
1744
1786
|
});
|
|
1745
|
-
for (const id of
|
|
1787
|
+
for (const id of removedIds)
|
|
1746
1788
|
purgeMirrorBestEffort(hippoRoot, id, false, 'batchWriteAndDelete');
|
|
1747
1789
|
writeIndexMirror(hippoRoot, buildIndexFromDb(db));
|
|
1748
|
-
return
|
|
1790
|
+
return removedIds;
|
|
1749
1791
|
}
|
|
1750
1792
|
catch (error) {
|
|
1751
1793
|
try {
|
|
@@ -1769,17 +1811,21 @@ export function loadAllEntries(hippoRoot, tenantId) {
|
|
|
1769
1811
|
initStore(hippoRoot);
|
|
1770
1812
|
const db = openHippoDb(hippoRoot);
|
|
1771
1813
|
try {
|
|
1772
|
-
|
|
1773
|
-
// MemoryRow's field set.
|
|
1774
|
-
const rows = tenantId !== undefined
|
|
1775
|
-
? db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE tenant_id = ? ORDER BY created ASC, id ASC`).all(tenantId)
|
|
1776
|
-
: db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories ORDER BY created ASC, id ASC`).all();
|
|
1777
|
-
return rows.map(rowToEntry);
|
|
1814
|
+
return selectAllEntries(db, tenantId);
|
|
1778
1815
|
}
|
|
1779
1816
|
finally {
|
|
1780
1817
|
closeHippoDb(db);
|
|
1781
1818
|
}
|
|
1782
1819
|
}
|
|
1820
|
+
/** Every memory row on an open connection, so a caller can read inside its own transaction. */
|
|
1821
|
+
export function selectAllEntries(db, tenantId) {
|
|
1822
|
+
// SAFETY: both branches select exactly MEMORY_SELECT_COLUMNS, matching
|
|
1823
|
+
// MemoryRow's field set.
|
|
1824
|
+
const rows = tenantId !== undefined
|
|
1825
|
+
? db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE tenant_id = ? ORDER BY created ASC, id ASC`).all(tenantId)
|
|
1826
|
+
: db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories ORDER BY created ASC, id ASC`).all();
|
|
1827
|
+
return rows.map(rowToEntry);
|
|
1828
|
+
}
|
|
1783
1829
|
// The pins plus the `recentNeeded` newest rows that pass `admit`, for ambient
|
|
1784
1830
|
// injection. One connection: opening one costs ~5.8ms on a warm 1896-row store,
|
|
1785
1831
|
// so a second handle loses more than the narrower scan saves.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { DatabaseSyncLike } from './db.js';
|
|
2
|
+
/**
|
|
3
|
+
* Where a block of memory text was sent.
|
|
4
|
+
* - `hook`: the per-prompt `UserPromptSubmit` hook (`hippo context --pinned-only`).
|
|
5
|
+
* - `context`, `recall`: the CLI commands.
|
|
6
|
+
* - `mcp_recall`, `mcp_context`: the MCP tools.
|
|
7
|
+
* - `http_recall`, `http_context`, `http_assemble`: the HTTP API.
|
|
8
|
+
*/
|
|
9
|
+
export type TokenSurface = 'hook' | 'context' | 'recall' | 'mcp_recall' | 'mcp_context' | 'http_recall' | 'http_context' | 'http_assemble';
|
|
10
|
+
/** All surfaces, in report order. */
|
|
11
|
+
export declare const TOKEN_SURFACES: readonly TokenSurface[];
|
|
12
|
+
/**
|
|
13
|
+
* What happened to a block.
|
|
14
|
+
* - `inject`: sent to the agent.
|
|
15
|
+
* - `skip`: identical to the session's last injected block, so not sent again.
|
|
16
|
+
* - `reset`: the host compacted its context, so the next block must be sent.
|
|
17
|
+
*/
|
|
18
|
+
export type TokenEvent = 'inject' | 'skip' | 'reset';
|
|
19
|
+
/** Rows older than this are pruned on write. */
|
|
20
|
+
export declare const TOKEN_LEDGER_RETENTION_DAYS = 90;
|
|
21
|
+
/**
|
|
22
|
+
* Rough token estimate: characters / 4. The single estimate behind every
|
|
23
|
+
* token budget and ledger count in hippo.
|
|
24
|
+
*/
|
|
25
|
+
export declare function estimateTokens(text: string): number;
|
|
26
|
+
/** Stable 16-hex-char hash of a rendered block, for change detection. */
|
|
27
|
+
export declare function blockHash(text: string): string;
|
|
28
|
+
/** One ledger write. */
|
|
29
|
+
export interface TokenUse {
|
|
30
|
+
tenantId: string;
|
|
31
|
+
/** Host session id when known (hook payload, `HIPPO_SESSION_ID`); null otherwise. */
|
|
32
|
+
sessionId?: string | null;
|
|
33
|
+
surface: TokenSurface;
|
|
34
|
+
event: TokenEvent;
|
|
35
|
+
/** Memories (and continuity blocks) in the text. */
|
|
36
|
+
items: number;
|
|
37
|
+
/** Estimated tokens of the text; for a `skip`, the tokens not sent. */
|
|
38
|
+
tokens: number;
|
|
39
|
+
/** {@link blockHash} of the text, when the caller wants change detection. */
|
|
40
|
+
hash?: string | null;
|
|
41
|
+
/** Override the timestamp (tests). ISO string. */
|
|
42
|
+
now?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Append one ledger row and prune rows past {@link TOKEN_LEDGER_RETENTION_DAYS}.
|
|
46
|
+
*/
|
|
47
|
+
export declare function recordTokenUse(db: DatabaseSyncLike, use: TokenUse): void;
|
|
48
|
+
/** What a session last sent on a surface, for {@link lastSentState}. */
|
|
49
|
+
export interface LastSent {
|
|
50
|
+
/** {@link blockHash} of the last injected block. */
|
|
51
|
+
hash: string;
|
|
52
|
+
/** Consecutive `skip` rows since that injection. */
|
|
53
|
+
skipsSince: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The last block this session injected on `surface`, or null when the
|
|
57
|
+
* session has injected nothing, a `reset` came after it, or there is no
|
|
58
|
+
* session id (without one, the caller always injects).
|
|
59
|
+
*/
|
|
60
|
+
export declare function lastSentState(db: DatabaseSyncLike, tenantId: string, sessionId: string | null | undefined, surface: TokenSurface): LastSent | null;
|
|
61
|
+
/**
|
|
62
|
+
* Whether a block identical to the session's last injection should be
|
|
63
|
+
* skipped. `refreshTurns` resends an unchanged block after that many
|
|
64
|
+
* consecutive skips, so a long session still sees its pinned rules near the
|
|
65
|
+
* latest turn; 0 never resends an unchanged block.
|
|
66
|
+
*/
|
|
67
|
+
export declare function shouldSkipUnchanged(last: LastSent | null, hash: string, refreshTurns: number): boolean;
|
|
68
|
+
/** Per-surface totals for {@link summarizeTokenUse}. */
|
|
69
|
+
export interface TokenSurfaceSummary {
|
|
70
|
+
surface: TokenSurface;
|
|
71
|
+
/** Blocks sent. */
|
|
72
|
+
injected: number;
|
|
73
|
+
/** Tokens sent. */
|
|
74
|
+
tokens: number;
|
|
75
|
+
/** Blocks not sent because they were unchanged. */
|
|
76
|
+
skipped: number;
|
|
77
|
+
/** Tokens those skipped blocks would have cost. */
|
|
78
|
+
tokensAvoided: number;
|
|
79
|
+
/** Distinct session ids seen (rows without one are not counted). */
|
|
80
|
+
sessions: number;
|
|
81
|
+
}
|
|
82
|
+
/** Ledger totals over a window. */
|
|
83
|
+
export interface TokenSummary {
|
|
84
|
+
/** ISO start of the window (inclusive). */
|
|
85
|
+
since: string;
|
|
86
|
+
surfaces: TokenSurfaceSummary[];
|
|
87
|
+
totalTokens: number;
|
|
88
|
+
totalTokensAvoided: number;
|
|
89
|
+
/** Mean tokens sent per session, over rows that carry a session id. */
|
|
90
|
+
meanTokensPerSession: number;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Sum the ledger for one tenant since `sinceIso`. Surfaces with no rows are
|
|
94
|
+
* omitted.
|
|
95
|
+
*/
|
|
96
|
+
export declare function summarizeTokenUse(db: DatabaseSyncLike, tenantId: string, sinceIso: string): TokenSummary;
|
|
97
|
+
/**
|
|
98
|
+
* The `session_id` of a Claude Code hook payload on stdin, or null when the
|
|
99
|
+
* text is empty, malformed, has no non-empty session id, or (with
|
|
100
|
+
* `requiredSource`) a different `source`.
|
|
101
|
+
*/
|
|
102
|
+
export declare function hookPayloadSessionId(stdinText: string | undefined, requiredSource?: string | null): string | null;
|
|
103
|
+
/** Tokens hippo sent and skipped in one session, for {@link tokensBySession}. */
|
|
104
|
+
export interface SessionTokens {
|
|
105
|
+
sessionId: string;
|
|
106
|
+
/** Tokens of memory text sent to the agent. */
|
|
107
|
+
sent: number;
|
|
108
|
+
/** Tokens of unchanged blocks not sent. */
|
|
109
|
+
skipped: number;
|
|
110
|
+
/** Blocks sent. */
|
|
111
|
+
injections: number;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Ledger totals per session id since `sinceIso`, across every surface.
|
|
115
|
+
* Claude Code hook rows carry the host's session id, which is also the
|
|
116
|
+
* transcript file name, so these join to the host's own usage records.
|
|
117
|
+
*/
|
|
118
|
+
export declare function tokensBySession(db: DatabaseSyncLike, tenantId: string, sinceIso: string): SessionTokens[];
|
|
119
|
+
//# sourceMappingURL=token-ledger.d.ts.map
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token ledger (ROADMAP Part IX, TE0): what memory text hippo hands agents,
|
|
3
|
+
* and how many tokens it costs.
|
|
4
|
+
*
|
|
5
|
+
* One row per block of memory text sent to an agent, on every surface: the
|
|
6
|
+
* per-prompt hook, `hippo context`, `hippo recall`, the MCP tools and the
|
|
7
|
+
* HTTP API. The ledger answers the question a buyer asks first ("what does
|
|
8
|
+
* this cost me per session?") and is the input for the token-savings evals.
|
|
9
|
+
*
|
|
10
|
+
* It also backs TE2, inject only on change: the per-prompt hook compares the
|
|
11
|
+
* hash of the block it is about to send with the last block it sent in the
|
|
12
|
+
* same session and records a `skip` instead of sending it again.
|
|
13
|
+
*
|
|
14
|
+
* Token counts use {@link estimateTokens} (characters / 4), the same estimate
|
|
15
|
+
* every budget in hippo uses. Rows hold counts, surfaces, session ids and
|
|
16
|
+
* hashes, never memory content or query text.
|
|
17
|
+
*
|
|
18
|
+
* DB-only helpers: the caller owns the handle. Writes are best-effort at the
|
|
19
|
+
* call sites; a ledger failure must never break recall.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
/** All surfaces, in report order. */
|
|
23
|
+
export const TOKEN_SURFACES = [
|
|
24
|
+
'hook', 'context', 'recall', 'mcp_recall', 'mcp_context',
|
|
25
|
+
'http_recall', 'http_context', 'http_assemble',
|
|
26
|
+
];
|
|
27
|
+
/** Rows older than this are pruned on write. */
|
|
28
|
+
export const TOKEN_LEDGER_RETENTION_DAYS = 90;
|
|
29
|
+
/**
|
|
30
|
+
* Rough token estimate: characters / 4. The single estimate behind every
|
|
31
|
+
* token budget and ledger count in hippo.
|
|
32
|
+
*/
|
|
33
|
+
export function estimateTokens(text) {
|
|
34
|
+
return Math.ceil(text.length / 4);
|
|
35
|
+
}
|
|
36
|
+
/** Stable 16-hex-char hash of a rendered block, for change detection. */
|
|
37
|
+
export function blockHash(text) {
|
|
38
|
+
return createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Append one ledger row and prune rows past {@link TOKEN_LEDGER_RETENTION_DAYS}.
|
|
42
|
+
*/
|
|
43
|
+
export function recordTokenUse(db, use) {
|
|
44
|
+
const now = use.now ?? new Date().toISOString();
|
|
45
|
+
db.prepare(`INSERT INTO token_ledger (ts, tenant_id, session_id, surface, event, items, tokens, block_hash)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(now, use.tenantId, use.sessionId ?? null, use.surface, use.event, Math.max(0, Math.round(use.items)), Math.max(0, Math.round(use.tokens)), use.hash ?? null);
|
|
47
|
+
const cutoff = new Date(Date.parse(now) - TOKEN_LEDGER_RETENTION_DAYS * 86_400_000).toISOString();
|
|
48
|
+
db.prepare(`DELETE FROM token_ledger WHERE ts < ?`).run(cutoff);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The last block this session injected on `surface`, or null when the
|
|
52
|
+
* session has injected nothing, a `reset` came after it, or there is no
|
|
53
|
+
* session id (without one, the caller always injects).
|
|
54
|
+
*/
|
|
55
|
+
export function lastSentState(db, tenantId, sessionId, surface) {
|
|
56
|
+
if (!sessionId)
|
|
57
|
+
return null;
|
|
58
|
+
// SAFETY: the SELECT names exactly these three columns.
|
|
59
|
+
const anchor = db.prepare(`SELECT id, event, block_hash FROM token_ledger
|
|
60
|
+
WHERE tenant_id = ? AND session_id = ? AND surface = ? AND event IN ('inject', 'reset')
|
|
61
|
+
ORDER BY id DESC LIMIT 1`).get(tenantId, sessionId, surface);
|
|
62
|
+
if (!anchor || anchor.event === 'reset' || !anchor.block_hash)
|
|
63
|
+
return null;
|
|
64
|
+
// SAFETY: the SELECT names exactly this one aggregate column.
|
|
65
|
+
const skips = db.prepare(`SELECT COUNT(*) AS n FROM token_ledger
|
|
66
|
+
WHERE tenant_id = ? AND session_id = ? AND surface = ? AND event = 'skip' AND id > ?`).get(tenantId, sessionId, surface, anchor.id);
|
|
67
|
+
return { hash: anchor.block_hash, skipsSince: Number(skips?.n ?? 0) };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Whether a block identical to the session's last injection should be
|
|
71
|
+
* skipped. `refreshTurns` resends an unchanged block after that many
|
|
72
|
+
* consecutive skips, so a long session still sees its pinned rules near the
|
|
73
|
+
* latest turn; 0 never resends an unchanged block.
|
|
74
|
+
*/
|
|
75
|
+
export function shouldSkipUnchanged(last, hash, refreshTurns) {
|
|
76
|
+
if (!last || last.hash !== hash)
|
|
77
|
+
return false;
|
|
78
|
+
if (refreshTurns > 0 && last.skipsSince >= refreshTurns)
|
|
79
|
+
return false;
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Sum the ledger for one tenant since `sinceIso`. Surfaces with no rows are
|
|
84
|
+
* omitted.
|
|
85
|
+
*/
|
|
86
|
+
export function summarizeTokenUse(db, tenantId, sinceIso) {
|
|
87
|
+
// SAFETY: the SELECT names exactly these columns, all aggregates or TEXT.
|
|
88
|
+
const rows = db.prepare(`SELECT surface,
|
|
89
|
+
SUM(CASE WHEN event = 'inject' THEN 1 ELSE 0 END) AS injected,
|
|
90
|
+
SUM(CASE WHEN event = 'inject' THEN tokens ELSE 0 END) AS tokens,
|
|
91
|
+
SUM(CASE WHEN event = 'skip' THEN 1 ELSE 0 END) AS skipped,
|
|
92
|
+
SUM(CASE WHEN event = 'skip' THEN tokens ELSE 0 END) AS avoided,
|
|
93
|
+
COUNT(DISTINCT session_id) AS sessions
|
|
94
|
+
FROM token_ledger
|
|
95
|
+
WHERE tenant_id = ? AND ts >= ?
|
|
96
|
+
GROUP BY surface`).all(tenantId, sinceIso);
|
|
97
|
+
const bySurface = new Map(rows.map((r) => [r.surface, r]));
|
|
98
|
+
const surfaces = [];
|
|
99
|
+
for (const surface of TOKEN_SURFACES) {
|
|
100
|
+
const r = bySurface.get(surface);
|
|
101
|
+
if (!r)
|
|
102
|
+
continue;
|
|
103
|
+
surfaces.push({
|
|
104
|
+
surface,
|
|
105
|
+
injected: Number(r.injected),
|
|
106
|
+
tokens: Number(r.tokens),
|
|
107
|
+
skipped: Number(r.skipped),
|
|
108
|
+
tokensAvoided: Number(r.avoided),
|
|
109
|
+
sessions: Number(r.sessions),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// SAFETY: the SELECT names exactly these two aggregate columns.
|
|
113
|
+
const perSession = db.prepare(`SELECT COUNT(DISTINCT session_id) AS sessions,
|
|
114
|
+
SUM(CASE WHEN event = 'inject' THEN tokens ELSE 0 END) AS tokens
|
|
115
|
+
FROM token_ledger
|
|
116
|
+
WHERE tenant_id = ? AND ts >= ? AND session_id IS NOT NULL`).get(tenantId, sinceIso);
|
|
117
|
+
const sessionCount = Number(perSession?.sessions ?? 0);
|
|
118
|
+
const sessionTokens = Number(perSession?.tokens ?? 0);
|
|
119
|
+
return {
|
|
120
|
+
since: sinceIso,
|
|
121
|
+
surfaces,
|
|
122
|
+
totalTokens: surfaces.reduce((s, x) => s + x.tokens, 0),
|
|
123
|
+
totalTokensAvoided: surfaces.reduce((s, x) => s + x.tokensAvoided, 0),
|
|
124
|
+
meanTokensPerSession: sessionCount > 0 ? Math.round(sessionTokens / sessionCount) : 0,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** JSON-value string check without a runtime `typeof` (anti-slop rule). */
|
|
128
|
+
function isJsonString(value) {
|
|
129
|
+
return value !== undefined && value !== null && value.constructor === String;
|
|
130
|
+
}
|
|
131
|
+
/** JSON-value plain-object check (excludes arrays and null). */
|
|
132
|
+
function isJsonObject(value) {
|
|
133
|
+
return value !== undefined && value !== null && !Array.isArray(value) && value.constructor === Object;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The `session_id` of a Claude Code hook payload on stdin, or null when the
|
|
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) {
|
|
141
|
+
if (!stdinText || stdinText.trim() === '')
|
|
142
|
+
return null;
|
|
143
|
+
let payload;
|
|
144
|
+
try {
|
|
145
|
+
// SAFETY: JSON.parse returns a JSON value by definition.
|
|
146
|
+
payload = JSON.parse(stdinText.trim());
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
if (!isJsonObject(payload))
|
|
152
|
+
return null;
|
|
153
|
+
const sessionId = payload.session_id;
|
|
154
|
+
if (!isJsonString(sessionId) || sessionId.trim() === '')
|
|
155
|
+
return null;
|
|
156
|
+
if (requiredSource !== null && payload.source !== requiredSource)
|
|
157
|
+
return null;
|
|
158
|
+
return sessionId;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Ledger totals per session id since `sinceIso`, across every surface.
|
|
162
|
+
* Claude Code hook rows carry the host's session id, which is also the
|
|
163
|
+
* transcript file name, so these join to the host's own usage records.
|
|
164
|
+
*/
|
|
165
|
+
export function tokensBySession(db, tenantId, sinceIso) {
|
|
166
|
+
// SAFETY: the SELECT names exactly these columns, all aggregates or TEXT.
|
|
167
|
+
const rows = db.prepare(`SELECT session_id,
|
|
168
|
+
SUM(CASE WHEN event = 'inject' THEN tokens ELSE 0 END) AS sent,
|
|
169
|
+
SUM(CASE WHEN event = 'skip' THEN tokens ELSE 0 END) AS skipped,
|
|
170
|
+
SUM(CASE WHEN event = 'inject' THEN 1 ELSE 0 END) AS injections
|
|
171
|
+
FROM token_ledger
|
|
172
|
+
WHERE tenant_id = ? AND ts >= ? AND session_id IS NOT NULL
|
|
173
|
+
GROUP BY session_id`).all(tenantId, sinceIso);
|
|
174
|
+
return rows.map((r) => ({
|
|
175
|
+
sessionId: r.session_id,
|
|
176
|
+
sent: Number(r.sent),
|
|
177
|
+
skipped: Number(r.skipped),
|
|
178
|
+
injections: Number(r.injections),
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
//# 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.
|
|
19
|
+
export declare const PACKAGE_VERSION = "1.46.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.
|
|
19
|
+
export const PACKAGE_VERSION = '1.46.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) => {
|