@threadcrumbs/common 0.1.2 → 0.1.4

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 CHANGED
@@ -39,21 +39,54 @@ repos/@threadcrumbs/
39
39
  threadnauts/
40
40
  ```
41
41
 
42
- Each app depends on it with `"@threadcrumbs/common": "link:../common"`, so an edit here shows up
43
- in both apps without a release. Clone this repo next to an app before running `pnpm install`
44
- there.
45
-
46
- An app that publishes to npm depends on a published version instead. This package is on npm as
42
+ Each app depends on a published version, such as `"@threadcrumbs/common": "^0.1.2"`, because
43
+ that's what npm installs for the app's users. Its `pnpm-workspace.yaml` also overrides that with
44
+ `link:../common`, so an edit here shows up in both apps without a release. Clone this repo next to
45
+ an app before running `pnpm install` there. The apps' CI strips the override and uses npm, since
46
+ this repo is private. This package is on npm as
47
47
  [`@threadcrumbs/common`](https://www.npmjs.com/package/@threadcrumbs/common).
48
48
 
49
- ## Publishing a version
49
+ ## Publishing an app, and this package with it
50
+
51
+ From the app's folder, run:
52
+
53
+ ```
54
+ pnpm auto-publish minor
55
+ ```
50
56
 
51
- 1. Raise `version` in `package.json`, and commit it.
52
- 2. Run `npm pack --dry-run` and check the list: only `package.json`, `README.md`, `LICENSE.md`,
53
- `NOTICES.md`, and the folders in `files`.
54
- 3. Tag the commit `v<version>`, and push the commit and the tag.
55
- 4. Run `npm publish`. `publishConfig` makes it public, and npm asks for your second factor.
56
- 5. Check it with `npm view @threadcrumbs/common version`.
57
+ Use `patch`, `minor` or `major` for how far to bump the app. Leave it off to publish a version
58
+ that's already in `package.json` but not on npm yet. Add `--dry-run` to see the plan and every
59
+ problem without changing anything, or `--yes` to skip the final question.
60
+
61
+ Before it changes anything, it checks:
62
+ - that npm is logged in, and Cloudflare too for Threadnauts;
63
+ - that the app, this repo and the app's community repo are on `master`, committed, and not behind
64
+ `origin`;
65
+ - that the community repo's `CHANGELOG.md` has a `## <version>` entry.
66
+
67
+ Then it shows the plan and waits for you to type `yes`. The plan, in order:
68
+ 1. **This package, if needed.** It's published first, when anything in its `files` changed since
69
+ the published version's commit (npm records that as `gitHead`). It gets a patch bump, its
70
+ tests run, then it's published, tagged and pushed.
71
+ 2. **The app's version.** The app is bumped, and its range on this package raised when this
72
+ package was just published. The lockfile is refreshed and the change committed.
73
+ 3. **Test and build.** The app's tests run and it builds once.
74
+ 4. **Pro content.** For Threadnauts, `pro-dist/` is checked against `dist/pro-manifest.json` and
75
+ uploaded to the production content bucket through the key-service repo.
76
+ 5. **Publish.** The app is published with `--ignore-scripts`, so npm ships the build that was
77
+ just tested and uploaded, rather than building again. npm may ask for your one-time code.
78
+ 6. **Push.** The app is tagged `v<version>` and pushed, which deploys its website. The community
79
+ repo is pushed if it has unpushed commits.
80
+ 7. **Check.** It waits for npm to list the version, then checks `npx` runs it.
81
+
82
+ If a step fails, it stops there. Run it again with the same arguments and it carries on: a
83
+ version that was bumped but never published is published as it is, not bumped twice. If it stops
84
+ after npm has the new version, it prints the commands that are left to run by hand. The script
85
+ is `tools/release.mjs`. It isn't in `files`, so it never ships.
86
+
87
+ `pnpm auto-publish` always publishes the app as well. To publish only this package, do it by
88
+ hand: raise `version`, commit, run `npm pack --dry-run` to check the file list, tag
89
+ `v<version>`, push, `npm publish`, then `npm view @threadcrumbs/common version`.
57
90
 
58
91
  ## Settings it reads
59
92
 
@@ -1,5 +1,6 @@
1
1
  import fsp from 'node:fs/promises'
2
2
  import { requestCost } from './anthropic-prices.mjs'
3
+ import { emptyFixed, emptySizes, setFixed, sizeCall, sizeCompaction, sizePrompt, sizeReply, sizeRequest, sizeResult, sizeResumed } from './context-breakdown.mjs'
3
4
  import { listFiles, parseJsonLine, readNewLines } from './fsutil.mjs'
4
5
  import {
5
6
  DETAIL_LIMITS,
@@ -9,19 +10,26 @@ import {
9
10
  clipBlock,
10
11
  diffHunk,
11
12
  emptyActivity,
13
+ emptySample,
12
14
  endCall,
13
15
  fileList,
14
16
  firstChange,
17
+ joinedText,
15
18
  keepNewest,
19
+ keepText,
16
20
  nextPlan,
21
+ openTool,
17
22
  patchLines,
18
23
  planItems,
24
+ samplePoint,
19
25
  settleCalls,
20
26
  startCall,
27
+ textList,
21
28
  toolFile,
22
29
  toolInput,
23
30
  toolTarget,
24
31
  } from './telemetry-fields.mjs'
32
+ import { mcpServer } from './tool-kinds.mjs'
25
33
 
26
34
  const HEAD_BYTES = 192 * 1024
27
35
  const TAIL_BYTES = 64 * 1024
@@ -64,11 +72,11 @@ export async function readLiveSessions(dir, isAlive = pidAlive) {
64
72
  return live
65
73
  }
66
74
 
67
- export function transcriptReader({ skim = false, detail = false } = {}) {
75
+ export function transcriptReader({ skim = false, detail = false, words = false, sizes = false } = {}) {
68
76
  const files = new Map()
69
77
 
70
78
  async function advance(entry, file, size) {
71
- if (!entry.state || size < entry.state.offset) entry.state = emptyState(entry.sidechain, detail)
79
+ if (!entry.state || size < entry.state.offset) entry.state = emptyState(entry.sidechain, detail, words, sizes)
72
80
  const state = entry.state
73
81
  if (size === state.offset) return state
74
82
  try {
@@ -133,9 +141,12 @@ async function skimInto(state, file, size) {
133
141
  }
134
142
  }
135
143
 
136
- function emptyState(sidechain, detail) {
144
+ function emptyState(sidechain, detail, words, sizes) {
137
145
  return {
138
146
  detail: detail ? emptyDetail() : null,
147
+ words: words ? { thoughts: [], replies: [] } : null,
148
+ sizes: sizes ? emptySizes() : null,
149
+ sent: sizes ? emptySent() : null,
139
150
  offset: 0,
140
151
  carry: Buffer.alloc(0),
141
152
  sidechain,
@@ -162,10 +173,12 @@ function emptyState(sidechain, detail) {
162
173
  open: new Map(),
163
174
  ending: null,
164
175
  handedBackAt: 0,
176
+ turnStartedAt: 0,
165
177
  awaiting: new Map(),
166
178
  cost: null,
167
179
  plan: null,
168
180
  prompts: 0,
181
+ contextSample: emptySample(),
169
182
  uuids: new Set(),
170
183
  }
171
184
  }
@@ -223,6 +236,7 @@ function applyRecord(state, record) {
223
236
  state.firstBranch ||= state.gitBranch
224
237
  }
225
238
  if (state.detail && record.type === 'system' && ownRecord(state, record)) applySystem(state.detail, record, at || state.lastAt)
239
+ if (state.sizes && ownRecord(state, record)) applySent(state, record)
226
240
  if (!isTurn(state, record)) return
227
241
  if (record.type === 'user') applyUser(state, record, at || state.lastAt)
228
242
  else applyAssistant(state, record, at || state.lastAt)
@@ -239,12 +253,88 @@ function applySystem(detail, record, at) {
239
253
  }
240
254
  }
241
255
 
256
+ const emptySent = () => ({ system: 0, tools: [], deferred: new Set(), instructions: new Map(), skills: new Map(), skillsOther: 0, memory: new Map(), agents: 0 })
257
+
258
+ function applySent(state, record) {
259
+ const sent = state.sent
260
+ const attachment = record.attachment
261
+ if (record.type === 'system' && record.subtype === 'compact_boundary') {
262
+ sizeCompaction(state.sizes)
263
+ state.sent = { ...emptySent(), system: sent.system, tools: sent.tools }
264
+ return
265
+ }
266
+ if (record.type === 'api-request-shape') {
267
+ const shape = record.shape || {}
268
+ sent.system = (Array.isArray(shape.system) ? shape.system : []).reduce((n, part) => n + text(part?.text).length, 0)
269
+ sent.tools = (Array.isArray(shape.tools) ? shape.tools : []).map((tool) => ({ name: text(tool?.name), chars: JSON.stringify(tool ?? null).length }))
270
+ } else if (attachment?.type === 'deferred_tools_delta') {
271
+ for (const name of [...listOf(attachment.addedNames), ...listOf(attachment.readdedNames)]) sent.deferred.add(text(name))
272
+ for (const name of listOf(attachment.removedNames)) sent.deferred.delete(text(name))
273
+ } else if (attachment?.type === 'mcp_instructions_delta') {
274
+ listOf(attachment.addedNames).forEach((name, i) => sent.instructions.set(text(name), text(listOf(attachment.addedBlocks)[i]).length))
275
+ for (const name of listOf(attachment.removedNames)) sent.instructions.delete(text(name))
276
+ } else if (attachment?.type === 'skill_listing') {
277
+ if (attachment.isInitial !== false) {
278
+ sent.skills = new Map()
279
+ sent.skillsOther = 0
280
+ }
281
+ const names = new Set(listOf(attachment.names).map(text))
282
+ for (const line of text(attachment.content).split('\n')) {
283
+ const name = /^\s*-\s*(\S+?):(?:\s|$)/.exec(line)?.[1]
284
+ if (name && names.has(name)) sent.skills.set(name, (sent.skills.get(name) || 0) + line.length + 1)
285
+ else sent.skillsOther += line.length + 1
286
+ }
287
+ } else if (attachment?.type === 'instructions') {
288
+ for (const file of listOf(attachment.files)) if (text(file?.path)) sent.memory.set(text(file.path), text(file.content).length)
289
+ } else if (attachment?.type === 'agent_listing_delta') {
290
+ sent.agents = (attachment.isInitial ? 0 : sent.agents) + listOf(attachment.addedLines).reduce((n, line) => n + text(line).length + 1, 0)
291
+ } else {
292
+ return
293
+ }
294
+ setFixed(state.sizes, fixedOf(sent))
295
+ }
296
+
297
+ const listOf = (value) => (Array.isArray(value) ? value : [])
298
+
299
+ function fixedOf(sent) {
300
+ const fixed = emptyFixed()
301
+ fixed.system = sent.system + sent.agents + sent.skillsOther
302
+ const servers = new Map()
303
+ const server = (name) => {
304
+ if (!servers.has(name)) servers.set(name, { names: new Set(), chars: 0 })
305
+ return servers.get(name)
306
+ }
307
+ const tool = (name, chars) => {
308
+ const owner = mcpServer(name)
309
+ if (!owner) {
310
+ fixed.builtInTools += chars
311
+ return
312
+ }
313
+ server(owner).names.add(name)
314
+ server(owner).chars += chars
315
+ }
316
+ for (const loaded of sent.tools) tool(loaded.name, loaded.chars)
317
+ for (const name of sent.deferred) tool(name, name.length + 1)
318
+ for (const [name, chars] of sent.instructions) server(name).chars += chars
319
+ fixed.mcp = new Map([...servers].map(([name, s]) => [name, { tools: s.names.size || null, chars: s.chars }]))
320
+ fixed.skills = new Map(sent.skills)
321
+ fixed.memory = new Map(sent.memory)
322
+ return fixed
323
+ }
324
+
325
+ function resultChars(block) {
326
+ if (typeof block?.content === 'string') return block.content.length
327
+ return listOf(block?.content).reduce((n, part) => n + text(part?.text).length, 0)
328
+ }
329
+
242
330
  function applyUser(state, record, at) {
243
331
  const content = record.message?.content
244
332
  let answered = false
333
+ if (state.sizes && record.isCompactSummary) sizeResumed(state.sizes)
245
334
  if (Array.isArray(content)) {
246
335
  for (const block of content) {
247
336
  if (block?.type !== 'tool_result') continue
337
+ if (state.sizes) sizeResult(state.sizes, block.tool_use_id, resultChars(block))
248
338
  state.open.delete(block.tool_use_id)
249
339
  answered = true
250
340
  const result = record.toolUseResult
@@ -254,6 +344,7 @@ function applyUser(state, record, at) {
254
344
  if (!failed) countEdits(state, result, call)
255
345
  }
256
346
  }
347
+ if (!state.ending || state.ending.handedBack) state.turnStartedAt = at
257
348
  state.ending = MID_TURN
258
349
  const notice = record.isMeta ? IDLE_NOTICE.exec(firstText(content)) : null
259
350
  if (notice) state.awaiting.delete(notice[1])
@@ -262,6 +353,7 @@ function applyUser(state, record, at) {
262
353
  if (!prompt || prompt.startsWith('<')) return
263
354
  state.open.clear()
264
355
  state.prompts++
356
+ if (state.sizes) sizePrompt(state.sizes, prompt.length)
265
357
  if (!state.firstPrompt) state.firstPrompt = prompt
266
358
  if (state.detail) {
267
359
  settleCalls(state.detail.activity)
@@ -347,9 +439,11 @@ function applyAssistant(state, record, at) {
347
439
  cacheWrite: number(usage.cache_creation_input_tokens),
348
440
  }
349
441
  const previous = state.usageById.get(message.id)
442
+ if (state.sizes && !previous) sizeRequest(state.sizes, next.input + next.cacheRead + next.cacheWrite, at)
350
443
  for (const key of Object.keys(next)) state.tokens[key] += next[key] - (previous?.[key] || 0)
351
444
  state.usageById.set(message.id, next)
352
445
  state.context = next.input + next.cacheRead + next.cacheWrite + next.output
446
+ samplePoint(state.contextSample, state.context, { update: Boolean(previous) })
353
447
  const cost = requestCost(message.model, usage)
354
448
  let added = null
355
449
  if (cost != null) {
@@ -365,11 +459,13 @@ function applyAssistant(state, record, at) {
365
459
  if (record.effort) state.effort = text(record.effort)
366
460
  const content = Array.isArray(message.content) ? message.content : []
367
461
  let calling = false
462
+ if (state.sizes) sizeReply(state.sizes, joinedText(content, 'text').length + joinedText(content, 'thinking').length)
368
463
  for (const block of content) {
369
464
  if (block?.type !== 'tool_use') continue
370
465
  calling = true
466
+ if (state.sizes) sizeCall(state.sizes, text(block.id), text(block.name), JSON.stringify(block.input ?? null).length)
371
467
  if (!state.open.has(block.id)) {
372
- state.open.set(block.id, { name: text(block.name), startedAt: at })
468
+ state.open.set(block.id, openTool(text(block.name), at, block.input, state.cwd))
373
469
  if (state.detail) {
374
470
  startCall(state.detail.activity, {
375
471
  id: text(block.id),
@@ -390,6 +486,11 @@ function applyAssistant(state, record, at) {
390
486
  state.open.clear()
391
487
  state.handedBackAt = at
392
488
  }
489
+ if (state.words && record.isApiErrorMessage !== true && message.model !== '<synthetic>') {
490
+ const id = text(message.id) || null
491
+ keepText(state.words.thoughts, 'thoughts', { id, at, text: joinedText(content, 'thinking') })
492
+ keepText(state.words.replies, 'replies', { id, at, text: joinedText(content, 'text') })
493
+ }
393
494
  if (!state.detail) return
394
495
  if (handedBack) settleCalls(state.detail.activity)
395
496
  if (record.isApiErrorMessage === true) {
@@ -415,7 +516,7 @@ function recordUsage(detail, message, next, previous, context, at, cost) {
415
516
  detail.models.set(model, totals)
416
517
  }
417
518
 
418
- export function transcriptDetail(state) {
519
+ export function transcriptDetail(state, words = state.words) {
419
520
  const detail = state.detail
420
521
  if (!detail) return null
421
522
  const models = [...detail.models].map(([model, m]) => ({ model, tokens: { ...m.tokens }, costUsd: m.unpriced ? null : m.usd }))
@@ -428,6 +529,8 @@ export function transcriptDetail(state) {
428
529
  plan: planItems(state.plan),
429
530
  files: fileList(detail.files, state.cwd),
430
531
  prompts: detail.prompts.map((p) => ({ ...p })),
532
+ thoughts: textList(words?.thoughts),
533
+ replies: textList(words?.replies),
431
534
  errors: detail.errors.map((e) => ({ ...e })),
432
535
  }
433
536
  }
@@ -0,0 +1,149 @@
1
+ const CHARS_PER_TOKEN = 4
2
+
3
+ const add = (map, key, n) => map.set(key, (map.get(key) || 0) + n)
4
+ const sum = (values) => [...values].reduce((total, n) => total + n, 0)
5
+ const noPending = () => ({ prompts: 0, replies: 0, tools: new Map() })
6
+
7
+ export function emptyFixed() {
8
+ return { system: 0, builtInTools: 0, mcp: new Map(), skills: new Map(), memory: new Map() }
9
+ }
10
+
11
+ export function emptySizes() {
12
+ return {
13
+ fixed: emptyFixed(),
14
+ first: null,
15
+ firstPrompt: 0,
16
+ resumed: false,
17
+ lastInput: null,
18
+ lastAt: 0,
19
+ compacted: false,
20
+ pending: noPending(),
21
+ weights: { prompts: 0, replies: 0, summary: 0, tools: new Map() },
22
+ callNames: new Map(),
23
+ }
24
+ }
25
+
26
+ export function setFixed(sizes, fixed) {
27
+ sizes.fixed = fixed
28
+ }
29
+
30
+ export function sizePrompt(sizes, chars) {
31
+ if (!(chars > 0)) return
32
+ if (sizes.lastInput == null) sizes.firstPrompt += chars
33
+ sizes.pending.prompts += chars
34
+ }
35
+
36
+ export function sizeReply(sizes, chars) {
37
+ if (chars > 0) sizes.pending.replies += chars
38
+ }
39
+
40
+ export function sizeCall(sizes, id, name, chars) {
41
+ if (id) sizes.callNames.set(id, name)
42
+ if (chars > 0) add(sizes.pending.tools, name, chars)
43
+ }
44
+
45
+ export function sizeResult(sizes, id, chars) {
46
+ const name = sizes.callNames.get(id)
47
+ sizes.callNames.delete(id)
48
+ if (name && chars > 0) add(sizes.pending.tools, name, chars)
49
+ }
50
+
51
+ export function sizeResumed(sizes) {
52
+ if (sizes.lastInput == null) sizes.resumed = true
53
+ }
54
+
55
+ export function sizeCompaction(sizes) {
56
+ sizes.compacted = true
57
+ sizes.pending = noPending()
58
+ sizes.callNames.clear()
59
+ }
60
+
61
+ const fixedChars = (fixed) =>
62
+ fixed.system + fixed.builtInTools + sum([...fixed.mcp.values()].map((m) => m.chars)) + sum(fixed.skills.values()) + sum(fixed.memory.values())
63
+
64
+ function spread(weights, pending, tokens) {
65
+ const chars = pending.prompts + pending.replies + sum(pending.tools.values())
66
+ if (!chars) {
67
+ weights.replies += tokens
68
+ return
69
+ }
70
+ weights.prompts += (tokens * pending.prompts) / chars
71
+ weights.replies += (tokens * pending.replies) / chars
72
+ for (const [name, n] of pending.tools) add(weights.tools, name, (tokens * n) / chars)
73
+ }
74
+
75
+ const counted = (sizes) => (sizes.first && !sizes.resumed ? sizes.first.input - sizes.first.prompt : 0)
76
+
77
+ function ratio(sizes) {
78
+ const fixed = counted(sizes)
79
+ return fixed > 0 && sizes.first.chars ? fixed / sizes.first.chars : 1 / CHARS_PER_TOKEN
80
+ }
81
+
82
+ export function sizeRequest(sizes, input, at) {
83
+ if (!(input > 0) || input === sizes.lastInput) return
84
+ if (at) sizes.lastAt = at
85
+ if (sizes.lastInput == null) {
86
+ if (!sizes.firstPrompt) sizes.resumed = true
87
+ sizes.first = { input, prompt: Math.round(sizes.firstPrompt / CHARS_PER_TOKEN), chars: fixedChars(sizes.fixed) }
88
+ spread(sizes.weights, sizes.pending, Math.min(input, sizes.first.prompt))
89
+ } else if (sizes.compacted) {
90
+ const fixed = sizes.first.chars ? Math.round(fixedChars(sizes.fixed) * ratio(sizes)) : counted(sizes)
91
+ sizes.weights = { prompts: 0, replies: 0, summary: Math.max(0, input - fixed), tools: new Map() }
92
+ } else if (input > sizes.lastInput) {
93
+ spread(sizes.weights, sizes.pending, input - sizes.lastInput)
94
+ }
95
+ sizes.compacted = false
96
+ sizes.lastInput = input
97
+ sizes.pending = noPending()
98
+ }
99
+
100
+ function scaled(entries, total) {
101
+ const weight = sum(entries.map((e) => e.weight))
102
+ if (!weight || total <= 0) return []
103
+ return entries.map(({ weight: w, ...rest }) => ({ ...rest, tokens: Math.round((total * w) / weight) })).filter((e) => e.tokens > 0)
104
+ }
105
+
106
+ function messageItems(weights, tokens) {
107
+ const entries = [
108
+ { id: 'prompts', weight: weights.prompts },
109
+ { id: 'replies', weight: weights.replies },
110
+ { id: 'summary', weight: weights.summary },
111
+ ...[...weights.tools].map(([name, weight]) => ({ id: 'tool', name, weight })),
112
+ ]
113
+ return scaled(entries, tokens).sort((a, b) => b.tokens - a.tokens)
114
+ }
115
+
116
+ const twoParts = (base, weights, system) => ({
117
+ ...base,
118
+ parts: [
119
+ { id: 'messages', tokens: base.total - system, items: messageItems(weights, base.total - system) },
120
+ { id: 'system', tokens: system, items: [] },
121
+ ],
122
+ })
123
+
124
+ export function contextBreakdown(sizes, { total, limit = null, reserved = null }) {
125
+ if (!sizes || !(total > 0)) return null
126
+ const base = { at: sizes.lastAt, total, limit: limit || null, reserved }
127
+ const fixed = counted(sizes)
128
+ if (fixed <= 0 || fixed > total) {
129
+ const system = Math.round(fixedChars(sizes.fixed) / CHARS_PER_TOKEN)
130
+ return system > 0 && system < total ? twoParts(base, sizes.weights, system) : null
131
+ }
132
+ if (!sizes.first.chars) return twoParts(base, sizes.weights, fixed)
133
+ const tokens = (n) => Math.round(n * ratio(sizes))
134
+ const { system, builtInTools, mcp, skills, memory } = sizes.fixed
135
+ const servers = [...mcp].map(([name, m]) => ({ name, tools: m.tools ?? null, tokens: tokens(m.chars) })).filter((m) => m.tokens > 0)
136
+ const skillItems = [...skills].map(([name, n]) => ({ name, tokens: tokens(n) })).filter((s) => s.tokens > 0)
137
+ const memoryItems = [...memory].map(([path, n]) => ({ path, tokens: tokens(n) })).filter((m) => m.tokens > 0)
138
+ const parts = [
139
+ { id: 'system', tokens: tokens(system), items: [] },
140
+ { id: 'builtInTools', tokens: tokens(builtInTools), items: [] },
141
+ { id: 'mcpTools', tokens: sum(servers.map((m) => m.tokens)), items: servers.sort((a, b) => b.tokens - a.tokens) },
142
+ { id: 'skills', tokens: sum(skillItems.map((s) => s.tokens)), items: skillItems.sort((a, b) => b.tokens - a.tokens) },
143
+ { id: 'memory', tokens: sum(memoryItems.map((m) => m.tokens)), items: memoryItems },
144
+ ].filter((p) => p.tokens > 0)
145
+ const fixedNow = sum(parts.map((p) => p.tokens))
146
+ if (fixedNow >= total) return twoParts(base, sizes.weights, fixed)
147
+ const messages = total - fixedNow
148
+ return { ...base, parts: [{ id: 'messages', tokens: messages, items: messageItems(sizes.weights, messages) }, ...parts] }
149
+ }
@@ -43,3 +43,14 @@ export function jsonlFollower({ empty, apply }) {
43
43
 
44
44
  return { read, sweep }
45
45
  }
46
+
47
+ export function recentReaders(make, max) {
48
+ const readers = new Map()
49
+ return (key) => {
50
+ const reader = readers.get(key) || make()
51
+ readers.delete(key)
52
+ readers.set(key, reader)
53
+ while (readers.size > max) readers.delete(readers.keys().next().value)
54
+ return reader
55
+ }
56
+ }