@ixo/oracle-runtime-workers 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +4 -1
  2. package/package.json +4 -2
  3. package/src/channels/auth.ts +54 -11
  4. package/src/channels/contract.ts +122 -13
  5. package/src/channels/turns.ts +12 -2
  6. package/src/core/budgeted-llm.ts +115 -6
  7. package/src/core/context-budget.ts +24 -26
  8. package/src/core/context-window.ts +39 -12
  9. package/src/core/domain-context/document.ts +100 -0
  10. package/src/core/domain-context/findings.ts +25 -0
  11. package/src/core/domain-context/index.ts +28 -0
  12. package/src/core/domain-context/integrity.ts +60 -0
  13. package/src/core/domain-context/lru.ts +63 -0
  14. package/src/core/domain-context/resolver.ts +651 -0
  15. package/src/core/domain-context/transport.ts +340 -0
  16. package/src/core/domain-context/turn-context.ts +443 -0
  17. package/src/core/domain-context/types.ts +119 -0
  18. package/src/core/domain-context/validator.ts +41 -0
  19. package/src/core/env.ts +60 -6
  20. package/src/core/index.ts +2 -0
  21. package/src/core/llm.ts +225 -4
  22. package/src/core/main-agent-types.ts +28 -0
  23. package/src/core/main-agent.ts +61 -4
  24. package/src/core/middlewares/tool-execution.ts +19 -2
  25. package/src/core/registries.ts +14 -3
  26. package/src/core/state.ts +2 -1
  27. package/src/core/sub-agent-fallback.ts +14 -2
  28. package/src/core/turn-budget.ts +19 -22
  29. package/src/do/contracts.ts +23 -2
  30. package/src/do/domain-context-store.ts +123 -0
  31. package/src/do/run-request.ts +6 -0
  32. package/src/do/sse-stream.ts +1 -1
  33. package/src/do/transcript.ts +9 -0
  34. package/src/do/turn-body.ts +2 -1
  35. package/src/do/turn-metadata.ts +13 -6
  36. package/src/do/user-oracle-do.ts +237 -60
  37. package/src/index.ts +16 -0
  38. package/src/llm/byo-adapter.ts +6 -0
  39. package/src/llm/byo-catalog.ts +7 -4
  40. package/src/llm/byo-client.ts +42 -7
  41. package/src/llm/provider-error.ts +21 -3
  42. package/src/llm/stream-liveness.ts +498 -0
  43. package/src/plugins/firecrawl/firecrawl-tools.ts +6 -0
  44. package/src/shell/app.ts +41 -8
@@ -3,8 +3,12 @@
3
3
  * (`context-window.ts`) so that they scale with the model:
4
4
  *
5
5
  * - `summarizeAtTokens` — the history is condensed once it is this large
6
- * (default half the window; Hermes fires at 50%),
7
- * never above what the summarizer may read;
6
+ * (default 60% of the window, whatever its size;
7
+ * never above the request cap nor above what the
8
+ * summarizer reads, which only binds below a
9
+ * ~34k window). The summary is written by the
10
+ * turn's own model (the `summarizer` role), so no
11
+ * second window bounds it;
8
12
  * - `pruneAtTokens` — above this, old tool results are demoted to
9
13
  * one-liners in the request (no model call, no
10
14
  * state change) before the model sees them;
@@ -24,7 +28,7 @@ import { NOOP_LOGGER } from './utils';
24
28
  export const CHARS_PER_TOKEN = 4;
25
29
 
26
30
  export interface ContextKnobs {
27
- /** Fraction of the window at which the history is summarized (default 0.5). */
31
+ /** Fraction of the window at which the history is summarized (default 0.6). */
28
32
  summarizeFraction: number;
29
33
  /** Fraction of the window above which old tool results are pruned per request (default 0.35). */
30
34
  pruneFraction: number;
@@ -47,7 +51,7 @@ export interface ContextKnobs {
47
51
  }
48
52
 
49
53
  export const DEFAULT_CONTEXT_KNOBS: ContextKnobs = {
50
- summarizeFraction: 0.5,
54
+ summarizeFraction: 0.6,
51
55
  pruneFraction: 0.35,
52
56
  resultCapFraction: 0.12,
53
57
  resultCapMaxChars: 200_000,
@@ -66,13 +70,11 @@ export interface ContextBudget {
66
70
  requestCapTokens: number;
67
71
  outputReserveTokens: number;
68
72
  /**
69
- * The summarizer's own input limit (what it may read to write the summary):
70
- * the smaller of what the main model's request cap and the summarizing
71
- * model's own request cap leave, less a margin for the summary prompt.
73
+ * The summarizer's input limit (what it may read to write the summary):
74
+ * the main model's request cap less a margin for the summary prompt. The
75
+ * summarizer is the main model, so its request has the same room.
72
76
  */
73
77
  summaryInputTokens: number;
74
- /** The summarizing model's window, when the host resolved it. */
75
- summaryWindowTokens?: number;
76
78
  summarizeTriggerMessages?: number;
77
79
  keepMessages: number;
78
80
  }
@@ -170,34 +172,31 @@ function requestCapFor(
170
172
  }
171
173
 
172
174
  /**
173
- * The budget for a turn on `resolution.model`. `summarizer` is the window of
174
- * the model that writes the summary (the `routing` role), resolved the same
175
- * way: the history it is handed is bounded by its own window too, not only
176
- * by the main model's. Omitted, the main model's window alone bounds it.
175
+ * The budget for a turn on `resolution.model`, from that model's window
176
+ * alone: the summary is written by the same model (the `summarizer` role),
177
+ * so no other window bounds what it reads.
177
178
  */
178
179
  export function contextBudgetFor(
179
180
  resolution: ContextWindowResolution,
180
181
  knobs: ContextKnobs = DEFAULT_CONTEXT_KNOBS,
181
- summarizer?: ContextWindowResolution,
182
182
  ): ContextBudget {
183
183
  const window = resolution.tokens;
184
184
  const { requestCapTokens, outputReserveTokens } = requestCapFor(
185
185
  window,
186
186
  knobs,
187
187
  );
188
- const summaryRequestCap = summarizer
189
- ? Math.min(
190
- requestCapTokens,
191
- requestCapFor(summarizer.tokens, knobs).requestCapTokens,
192
- )
193
- : requestCapTokens;
188
+ // The summarizer keeps only the newest `summaryInputTokens` of what it is
189
+ // asked to condense (LangChain trims with `strategy: 'last'`; the summary
190
+ // prompt needs its own room in the request) and drops the rest unread —
191
+ // the earlier summary first. Summarizing therefore starts no later than
192
+ // that limit, so the thread's memory is never lost unread. With the
193
+ // default knobs this only binds below a window of ~34.3k tokens (60% of
194
+ // the window exceeds 95% less the reply reserve and the prompt margin);
195
+ // above it the trigger is the plain fraction.
194
196
  const summaryInputTokens = Math.max(
195
197
  1_000,
196
- summaryRequestCap - SUMMARY_PROMPT_MARGIN_TOKENS,
198
+ requestCapTokens - SUMMARY_PROMPT_MARGIN_TOKENS,
197
199
  );
198
- // The summarizer keeps only the newest `summaryInputTokens` of what it is
199
- // asked to condense and drops the rest unread — the earlier summary first.
200
- // Summarizing starts no later than that, so nothing is lost unsummarized.
201
200
  const summarizeAtTokens = Math.min(
202
201
  Math.floor(window * knobs.summarizeFraction),
203
202
  requestCapTokens,
@@ -220,7 +219,6 @@ export function contextBudgetFor(
220
219
  requestCapTokens,
221
220
  outputReserveTokens,
222
221
  summaryInputTokens,
223
- ...(summarizer ? { summaryWindowTokens: summarizer.tokens } : {}),
224
222
  ...(knobs.summarizeTriggerMessages !== undefined
225
223
  ? { summarizeTriggerMessages: knobs.summarizeTriggerMessages }
226
224
  : {}),
@@ -284,5 +282,5 @@ export function messageToolCallTokens(
284
282
  }
285
283
 
286
284
  export function describeBudget(b: ContextBudget): string {
287
- return `model=${b.model} window=${b.windowTokens} (${b.origin}) summarizeAt=${b.summarizeAtTokens} pruneAt=${b.pruneAtTokens} resultCap=${b.resultCapChars}c requestCap=${b.requestCapTokens} reserve=${b.outputReserveTokens} summaryInput=${b.summaryInputTokens}${b.summaryWindowTokens !== undefined ? ` (summarizer window ${b.summaryWindowTokens})` : ''}`;
285
+ return `model=${b.model} window=${b.windowTokens} (${b.origin}) summarizeAt=${b.summarizeAtTokens} pruneAt=${b.pruneAtTokens} resultCap=${b.resultCapChars}c requestCap=${b.requestCapTokens} reserve=${b.outputReserveTokens} summaryInput=${b.summaryInputTokens}`;
288
286
  }
@@ -8,22 +8,30 @@
8
8
  *
9
9
  * Resolution order, first hit wins:
10
10
  *
11
- * 1. an operator override for the exact model id (`MODEL_CONTEXT_OVERRIDES`);
12
- * 2. the OpenRouter `/models` listing (the same cached fetch that serves
11
+ * 1. a window **learned** from the provider (below);
12
+ * 2. an operator override for the exact model id (`MODEL_CONTEXT_OVERRIDES`,
13
+ * optional — nothing needs one);
14
+ * 3. the OpenRouter `/models` listing (the same cached fetch that serves
13
15
  * the price catalog) — for OpenRouter ids directly, and for BYO
14
16
  * provider-native ids through the vendor prefix (`gpt-…` → `openai/gpt-…`);
15
- * 3. the deployment default (`MODEL_CONTEXT_TOKENS`, 100k unless set).
17
+ * 4. the built-in table (`BUILTIN_CONTEXT_WINDOWS` in `llm.ts`: the
18
+ * curated catalog's `contextTokens` plus every other id the runtime
19
+ * sends), looked up the same way — so an unreachable catalogue does not
20
+ * shrink a known model to the default;
21
+ * 5. the deployment default (`MODEL_CONTEXT_TOKENS`, 100k unless set), only
22
+ * for a model nothing above knows.
16
23
  *
17
- * On top of that, a window is **learned downwards**: when a provider rejects
18
- * a request as too long and names its limit, that smaller number is kept for
19
- * the model (persistently, when the host supplies a store) and wins over the
20
- * catalog from then on. A provider's error is the one source that is never
24
+ * A window is **learned downwards**: when a provider rejects a request as
25
+ * too long and names its limit, that smaller number is kept for the model
26
+ * (persistently, when the host supplies a store) and wins over every other
27
+ * source from then on. A provider's error is the one source that is never
21
28
  * wrong about its own limit; nothing is ever learned upwards.
22
29
  *
23
30
  * Modelled on Hermes Agent's `get_model_context_length` chain, trimmed to the
24
31
  * sources this runtime has.
25
32
  */
26
33
  import type { Logger } from '../plugin-api/types';
34
+ import { BUILTIN_CONTEXT_WINDOWS } from './llm';
27
35
  import { NOOP_LOGGER } from './utils';
28
36
 
29
37
  export const DEFAULT_CONTEXT_TOKENS = 100_000;
@@ -35,6 +43,7 @@ export type ContextWindowOrigin =
35
43
  | 'override'
36
44
  | 'learned'
37
45
  | 'catalog'
46
+ | 'builtin'
38
47
  | 'default';
39
48
 
40
49
  export interface ContextWindowResolution {
@@ -42,7 +51,7 @@ export interface ContextWindowResolution {
42
51
  model: string;
43
52
  tokens: number;
44
53
  origin: ContextWindowOrigin;
45
- /** The catalog id that answered, when the catalog did. */
54
+ /** The catalog (or built-in table) id that answered, when one did. */
46
55
  catalogId?: string;
47
56
  }
48
57
 
@@ -61,15 +70,20 @@ export interface ContextWindowResolverOptions {
61
70
  config: ContextWindowConfig;
62
71
  /** OpenRouter model id → context window (tokens). */
63
72
  catalog: () => Promise<ReadonlyMap<string, number>>;
73
+ /**
74
+ * Known model id → context window (tokens), consulted after the catalogue
75
+ * with the same candidate ids. Default {@link BUILTIN_CONTEXT_WINDOWS}.
76
+ */
77
+ builtin?: ReadonlyMap<string, number>;
64
78
  learned?: LearnedWindowStore;
65
79
  logger?: Logger;
66
80
  }
67
81
 
68
82
  /**
69
83
  * `MODEL_CONTEXT_OVERRIDES="openai/gpt-5.6-luna=400000, gpt-5.6-terra=1000000"`
70
- * — comma-separated `<model id>=<tokens>`; malformed entries are skipped
71
- * with a warning. `MODEL_CONTEXT_TOKENS` is the default for models nothing
72
- * else knows (min 16k).
84
+ * — optional, comma-separated `<model id>=<tokens>`; malformed entries are
85
+ * skipped with a warning. `MODEL_CONTEXT_TOKENS` is the default for models
86
+ * nothing else knows (min 16k).
73
87
  */
74
88
  export function contextWindowConfig(
75
89
  env: Record<string, unknown>,
@@ -212,8 +226,14 @@ export class ContextWindowResolver {
212
226
  const base = await this.baseline(model, opts.byoProvider);
213
227
  const learned = await this.learned(model);
214
228
  if (learned !== undefined && learned < base.tokens) {
229
+ this.logger.debug?.(
230
+ `[context] ${model}: window ${learned} (learned; ${base.origin} says ${base.tokens})`,
231
+ );
215
232
  return { ...base, tokens: learned, origin: 'learned' };
216
233
  }
234
+ this.logger.debug?.(
235
+ `[context] ${model}: window ${base.tokens} (${base.origin}${base.catalogId ? ` ${base.catalogId}` : ''})`,
236
+ );
217
237
  return base;
218
238
  }
219
239
 
@@ -235,13 +255,20 @@ export class ContextWindowResolver {
235
255
  `[context] model catalog unavailable: ${error instanceof Error ? error.message : String(error)}`,
236
256
  );
237
257
  }
258
+ const candidates = catalogCandidates(model, byoProvider);
238
259
  if (catalog) {
239
- for (const candidate of catalogCandidates(model, byoProvider)) {
260
+ for (const candidate of candidates) {
240
261
  const tokens = catalog.get(candidate);
241
262
  if (tokens !== undefined && isWindow(tokens))
242
263
  return { model, tokens, origin: 'catalog', catalogId: candidate };
243
264
  }
244
265
  }
266
+ const builtin = this.options.builtin ?? BUILTIN_CONTEXT_WINDOWS;
267
+ for (const candidate of candidates) {
268
+ const tokens = builtin.get(candidate);
269
+ if (tokens !== undefined && isWindow(tokens))
270
+ return { model, tokens, origin: 'builtin', catalogId: candidate };
271
+ }
245
272
  return { model, tokens: config.defaultTokens, origin: 'default' };
246
273
  }
247
274
 
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Reading a validated `domain.md` frontmatter: the document index entries and
3
+ * the brief (the bounded slice of the frontmatter the prompt carries).
4
+ */
5
+ import type { DomainDocument } from '@ixo/domain.md/workers';
6
+ import { z } from 'zod';
7
+
8
+ const documentEntry = z.object({
9
+ id: z.string(),
10
+ uri: z.string().nullable(),
11
+ cid: z.string().nullable(),
12
+ required_for_tasks: z.array(z.string()).optional(),
13
+ freshness: z
14
+ .object({
15
+ last_verified: z.string().nullable(),
16
+ max_age: z.string().nullable(),
17
+ })
18
+ .optional(),
19
+ role: z.string(),
20
+ media_type: z.string(),
21
+ disclosure_pass: z.number(),
22
+ sensitivity: z.string(),
23
+ access_policy: z.string(),
24
+ agent_use: z.object({
25
+ read: z.boolean(),
26
+ cite: z.boolean(),
27
+ summarize: z.boolean(),
28
+ }),
29
+ });
30
+ export type DomainDocumentEntry = z.infer<typeof documentEntry>;
31
+
32
+ const entriesSchema = z.object({ entries: z.array(documentEntry) });
33
+
34
+ /** The document index; empty when the frontmatter carries none (or a malformed one). */
35
+ export function entries(
36
+ document: DomainDocument | undefined,
37
+ ): DomainDocumentEntry[] {
38
+ const result = entriesSchema.safeParse(document?.frontmatter.documents);
39
+ return result.success ? result.data.entries : [];
40
+ }
41
+
42
+ /** `value` as a plain object, or `{}`. */
43
+ export function record(value: unknown): Record<string, unknown> {
44
+ return value && typeof value === 'object' && !Array.isArray(value)
45
+ ? { ...value }
46
+ : {};
47
+ }
48
+
49
+ /** A linked document is private unless both its access policy and its sensitivity are `public`. */
50
+ export function isPrivateEntry(entry: DomainDocumentEntry): boolean {
51
+ return entry.access_policy !== 'public' || entry.sensitivity !== 'public';
52
+ }
53
+
54
+ /**
55
+ * The parts of the frontmatter the model needs to orient itself: purpose and
56
+ * boundary, constitution, sources of truth, default agent mode, controllers,
57
+ * baseline rights, privacy, the critical don'ts and the document index
58
+ * (without URIs or CIDs). Serialized once per CID by the resolver.
59
+ */
60
+ export function briefBody(document: DomainDocument): Record<string, unknown> {
61
+ const front = document.frontmatter;
62
+ return {
63
+ domain: front.domain,
64
+ constitution: front.constitution,
65
+ source_of_truth: front.source_of_truth,
66
+ agent_default_mode: front.agent_default_mode,
67
+ controllers: record(front.controllers).summary,
68
+ rights: record(front.rights).agent_baseline,
69
+ privacy: front.privacy,
70
+ critical_do_not: front.critical_do_not,
71
+ documents: entries(document).map((entry) => ({
72
+ id: entry.id,
73
+ role: entry.role,
74
+ disclosure_pass: entry.disclosure_pass,
75
+ required_for_tasks: entry.required_for_tasks,
76
+ agent_use: entry.agent_use,
77
+ access_policy: entry.access_policy,
78
+ sensitivity: entry.sensitivity,
79
+ })),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * ISO 8601 durations of days and time parts (`P180D`, `PT12H`, `P1DT2H`) in
85
+ * milliseconds; `undefined` for any other form (years, months and weeks are
86
+ * calendar-dependent and refused).
87
+ */
88
+ export function durationMs(value: string): number | undefined {
89
+ const match = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(
90
+ value,
91
+ );
92
+ if (!match) return undefined;
93
+ const ms =
94
+ (Number(match[1] ?? 0) * 86_400 +
95
+ Number(match[2] ?? 0) * 3_600 +
96
+ Number(match[3] ?? 0) * 60 +
97
+ Number(match[4] ?? 0)) *
98
+ 1000;
99
+ return ms > 0 ? ms : undefined;
100
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Finding codes are diagnostics that reach the prompt block, the
3
+ * `router_update` stream and `domain_context_runs`, so every list of them is
4
+ * bounded where it enters a snapshot: a hostile index can make the validator
5
+ * report one finding per bad entry, tens of thousands in all.
6
+ */
7
+
8
+ /** Most entries one finding list holds, `findings-truncated` included. */
9
+ export const MAX_FINDINGS = 32;
10
+ /** Last entry of a list that was cut to {@link MAX_FINDINGS}. */
11
+ export const FINDINGS_TRUNCATED = 'findings-truncated';
12
+
13
+ /**
14
+ * `codes` without duplicates, in first-seen order, at most
15
+ * {@link MAX_FINDINGS} entries. A cut list (or one that already carried
16
+ * `findings-truncated`) keeps its first codes and ends with
17
+ * `findings-truncated`, so a bounded list can be extended and bounded again.
18
+ */
19
+ export function boundFindings(codes: Iterable<string>): string[] {
20
+ const unique = new Set(codes);
21
+ const truncated = unique.delete(FINDINGS_TRUNCATED);
22
+ const list = [...unique];
23
+ if (!truncated && list.length <= MAX_FINDINGS) return list;
24
+ return [...list.slice(0, MAX_FINDINGS - 1), FINDINGS_TRUNCATED];
25
+ }
@@ -0,0 +1,28 @@
1
+ export type {
2
+ DocumentRequest,
3
+ DomainAnchor,
4
+ DomainCapsuleInspection,
5
+ DomainContextOptions,
6
+ DomainContextPins,
7
+ DomainContextProvenance,
8
+ DomainDocumentRead,
9
+ DomainSnapshot,
10
+ DomainTransport,
11
+ } from './types';
12
+ export {
13
+ ANCHOR_LOOKUP_TIMEOUT_MS,
14
+ DEFAULT_ANCHOR_TTL_MS,
15
+ DomainContextResolver,
16
+ PINNED_ANCHOR_SOURCE,
17
+ } from './resolver';
18
+ export { createDomainTransport, DOCUMENT_READ_TIMEOUT_MS } from './transport';
19
+ export {
20
+ DEFAULT_PASS1,
21
+ DOCUMENT_PAGE_CHARS,
22
+ DOMAIN_CONTEXT_CLOSE,
23
+ DOMAIN_CONTEXT_OPEN,
24
+ DOMAIN_CONTEXT_PRECEDENCE,
25
+ prepareDomainContext,
26
+ provenance,
27
+ RESOLUTION_TIMEOUT_MS,
28
+ } from './turn-context';
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Byte-level checks every domain document passes before anything reads it:
3
+ * the anchor's CID must name the exact bytes, and the bytes must be strict
4
+ * UTF-8 without a byte-order mark.
5
+ */
6
+ import { CID } from 'multiformats/cid';
7
+ import { sha256 } from 'multiformats/hashes/sha2';
8
+
9
+ /** multicodec `raw`. */
10
+ const RAW_CODEC = 0x55;
11
+ /** multihash `sha2-256`. */
12
+ const SHA2_256 = 0x12;
13
+
14
+ /** Index (`domain.md`) and capsule manifest bound. */
15
+ export const INDEX_MAX_BYTES = 1024 * 1024;
16
+ /** Linked document bound. */
17
+ export const LINKED_DOCUMENT_MAX_BYTES = 2 * 1024 * 1024;
18
+
19
+ /**
20
+ * Throws unless `cid` is a CIDv1 over the raw codec with a sha2-256 digest of
21
+ * exactly `bytes`. Any other CID shape is refused rather than interpreted.
22
+ */
23
+ export async function verifyBytes(
24
+ bytes: Uint8Array,
25
+ cid: string,
26
+ ): Promise<void> {
27
+ let expected: CID;
28
+ try {
29
+ expected = CID.parse(cid);
30
+ } catch {
31
+ throw new Error('invalid-cid');
32
+ }
33
+ if (
34
+ expected.version !== 1 ||
35
+ expected.code !== RAW_CODEC ||
36
+ expected.multihash.code !== SHA2_256
37
+ )
38
+ throw new Error('unsupported-cid-codec');
39
+ const digest = await sha256.digest(bytes);
40
+ if (!CID.createV1(RAW_CODEC, digest).equals(expected))
41
+ throw new Error('cid-mismatch');
42
+ }
43
+
44
+ /** CIDv1 (raw, sha2-256) of `bytes`, as its default base32 string. */
45
+ export async function cidOf(bytes: Uint8Array): Promise<string> {
46
+ return CID.createV1(RAW_CODEC, await sha256.digest(bytes)).toString();
47
+ }
48
+
49
+ /** Strict UTF-8 decode; a byte-order mark or any invalid sequence is refused. */
50
+ export function decode(bytes: Uint8Array): string {
51
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf)
52
+ throw new Error('utf8-bom');
53
+ try {
54
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(
55
+ bytes,
56
+ );
57
+ } catch {
58
+ throw new Error('invalid-utf8');
59
+ }
60
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A small least-recently-used map bounded by entry count and by a total
3
+ * weight (bytes, characters). `Map` keeps insertion order, so re-inserting on
4
+ * every hit makes the first key the least recently used one.
5
+ */
6
+ export class LruMap<K, V> {
7
+ private readonly entries = new Map<K, { value: V; weight: number }>();
8
+ private totalWeight = 0;
9
+
10
+ constructor(
11
+ private readonly limits: {
12
+ maxEntries: number;
13
+ maxWeight?: number;
14
+ weigh?: (value: V) => number;
15
+ },
16
+ ) {}
17
+
18
+ get size(): number {
19
+ return this.entries.size;
20
+ }
21
+
22
+ get weight(): number {
23
+ return this.totalWeight;
24
+ }
25
+
26
+ has(key: K): boolean {
27
+ return this.entries.has(key);
28
+ }
29
+
30
+ get(key: K): V | undefined {
31
+ const entry = this.entries.get(key);
32
+ if (!entry) return undefined;
33
+ this.entries.delete(key);
34
+ this.entries.set(key, entry);
35
+ return entry.value;
36
+ }
37
+
38
+ /** Stores `value` unless it alone exceeds the weight bound; evicts the least recently used entries to fit. */
39
+ set(key: K, value: V): void {
40
+ const weight = this.limits.weigh?.(value) ?? 0;
41
+ const maxWeight = this.limits.maxWeight ?? Infinity;
42
+ this.delete(key);
43
+ if (weight > maxWeight) return;
44
+ while (
45
+ this.entries.size > 0 &&
46
+ (this.entries.size >= this.limits.maxEntries ||
47
+ this.totalWeight + weight > maxWeight)
48
+ ) {
49
+ const oldest = this.entries.keys().next();
50
+ if (oldest.done) break;
51
+ this.delete(oldest.value);
52
+ }
53
+ this.entries.set(key, { value, weight });
54
+ this.totalWeight += weight;
55
+ }
56
+
57
+ delete(key: K): void {
58
+ const entry = this.entries.get(key);
59
+ if (!entry) return;
60
+ this.entries.delete(key);
61
+ this.totalWeight -= entry.weight;
62
+ }
63
+ }