@sejuneoh/aitop 0.0.0-stage → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,352 @@
1
+ // Claude Code: ~/.claude/sessions/<pid>.json 으로 살아 있는 세션을 찾고,
2
+ // ~/.claude/projects/*/<sessionId>.jsonl 대화 기록에서 진행 단계·토큰·비용을 읽는다.
3
+ import { readdirSync, readFileSync, existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
4
+ import { loadCache } from '../cache.mjs';
5
+ import { contextWindow } from '../pricing.mjs';
6
+ import { join, basename } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import { isAlive } from '../procs.mjs';
9
+ import { ClaudeTracker } from './claude-tracker.mjs';
10
+ import { emptyTokens, mergeActivity, MEMBER_FADE_MS } from './common.mjs';
11
+ import { mergeFiles } from './files.mjs';
12
+ import { debug, debugIo } from '../log.mjs';
13
+
14
+ const HOME = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude');
15
+ const SESSIONS = join(HOME, 'sessions');
16
+ const PROJECTS = join(HOME, 'projects');
17
+ const ENDED_WINDOW_MS = 24 * 3600 * 1000;
18
+ // finished 표시 없이 중간에 끊긴 팀원 기록이 남기 때문에, 이 시간 넘게 기록이 없으면 실행 중으로 보지 않는다.
19
+ const MEMBER_STALE_MS = 15 * 60_000;
20
+
21
+ const trackers = new Map();
22
+ const subTrackers = new Map();
23
+ const pathCache = new Map();
24
+
25
+ function readJson(p) {
26
+ try {
27
+ return JSON.parse(readFileSync(p, 'utf8'));
28
+ } catch (e) {
29
+ debugIo('claude', 'session file read failed', e, p);
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function findTranscript(sessionId) {
35
+ const cached = pathCache.get(sessionId);
36
+ if (cached && (cached.path ? existsSync(cached.path) : Date.now() - cached.at < 5000)) return cached.path;
37
+ let path = null;
38
+ try {
39
+ for (const dir of readdirSync(PROJECTS)) {
40
+ const p = join(PROJECTS, dir, `${sessionId}.jsonl`);
41
+ if (existsSync(p)) {
42
+ path = p;
43
+ break;
44
+ }
45
+ }
46
+ } catch (e) {
47
+ debugIo('claude', 'projects read failed', e);
48
+ }
49
+ pathCache.set(sessionId, { path, at: Date.now() });
50
+ return path;
51
+ }
52
+
53
+ // 이번 갱신에서 쓴 tracker 경로. 갱신이 끝나면 여기 없는 tracker 는 지운다.
54
+ let seen = new Set();
55
+
56
+ function tracker(map, path, budget) {
57
+ let t = map.get(path);
58
+ if (!t) map.set(path, (t = new ClaudeTracker(path)));
59
+ t.update(budget);
60
+ seen.add(path);
61
+ return t;
62
+ }
63
+
64
+ function prune() {
65
+ for (const map of [trackers, subTrackers]) for (const path of map.keys()) if (!seen.has(path)) map.delete(path);
66
+ }
67
+
68
+ export function _stats() {
69
+ return { trackers: trackers.size, subTrackers: subTrackers.size };
70
+ }
71
+
72
+ function loadingPhase(t) {
73
+ return t?.loading != null ? { kind: 'think', text: `기록 읽는 중 ${Math.floor(t.loading * 100)}%` } : null;
74
+ }
75
+
76
+ // 서브에이전트 전체(끝난 것 포함)의 토큰·비용 합계. 메인 세션 합계에 더해 화면에 보인다.
77
+ function emptySub() {
78
+ return { count: 0, tokens: emptyTokens(), cost: 0, costNoCache: 0, costUnknown: false, files: [], activity: [] };
79
+ }
80
+
81
+ // 메인 tracker 와 서브에이전트 합계를 화면에 보일 세션 숫자로 합친다. 기록을 아직 읽지 않았으면(t 없음) 비용은 null.
82
+ function sessionTotals(t, sub) {
83
+ const main = t?.tokens ?? emptyTokens();
84
+ const tokens = { main: main.total, sub: sub.tokens.total, subCount: sub.count };
85
+ for (const k of Object.keys(main)) tokens[k] = main[k] + sub.tokens[k];
86
+ const used = t?.context ?? 0;
87
+ return {
88
+ tokens,
89
+ cost: t ? t.cost + sub.cost : null,
90
+ costNoCache: t ? t.costNoCache + sub.costNoCache : null,
91
+ costUnknown: Boolean(t?.costUnknown || sub.costUnknown),
92
+ context: { used, window: contextWindow(t?.model, used) },
93
+ files: t ? mergeFiles([{ agent: '메인', files: t.files }, ...sub.files]) : [],
94
+ turns: t?.turns ?? [],
95
+ // 시간별 토큰: 메인과 서브에이전트를 칸별로 더한다.
96
+ activity: mergeActivity([t?.activity, ...sub.activity]),
97
+ };
98
+ }
99
+
100
+ function readMembers(transcript, parent, budget) {
101
+ const dir = join(transcript.replace(/\.jsonl$/, ''), 'subagents');
102
+ const sub = emptySub();
103
+ if (!existsSync(dir)) return { members: [], sub };
104
+ const members = [];
105
+ for (const f of readdirSync(dir)) {
106
+ if (!f.endsWith('.meta.json')) continue;
107
+ const id = f.slice('agent-'.length, -'.meta.json'.length);
108
+ const meta = readJson(join(dir, f)) ?? {};
109
+ const t = tracker(subTrackers, join(dir, `agent-${id}.jsonl`), budget);
110
+ sub.count++;
111
+ for (const k of Object.keys(sub.tokens)) sub.tokens[k] += t.tokens[k];
112
+ sub.cost += t.cost;
113
+ sub.costNoCache += t.costNoCache;
114
+ sub.costUnknown ||= t.costUnknown;
115
+ sub.activity.push(t.activity);
116
+ if (t.files.length) sub.files.push({ agent: meta.agentType ?? 'agent', files: t.files, startedAt: t.firstAt ?? undefined });
117
+ const age = Date.now() - t.mtimeMs;
118
+ const awaited = Boolean(meta.toolUseId && parent?.pending.has(meta.toolUseId));
119
+ const running = !meta.finished && !meta.stoppedByUser && (awaited || age < MEMBER_STALE_MS);
120
+ const finished = !running;
121
+ if (finished && age > MEMBER_FADE_MS) continue;
122
+ // 첫 응답이 오기 전에는 모델·토큰이 없으므로 '시작 중' 으로 보인다.
123
+ const started = Boolean(t.model || t.tokens.total > 0);
124
+ members.push({
125
+ id,
126
+ name: meta.agentType ?? 'agent',
127
+ model: t.model,
128
+ task: meta.description ?? '',
129
+ status: finished ? 'done' : 'busy',
130
+ started,
131
+ phase: finished ? { kind: 'done', text: '완료' } : !started ? { kind: 'idle', text: '시작 중' } : loadingPhase(t) ?? t.phase(),
132
+ now: t.lastTool,
133
+ tokens: t.tokens.total,
134
+ cost: t.cost,
135
+ costNoCache: t.costNoCache,
136
+ costUnknown: t.costUnknown,
137
+ startedAt: t.events[0]?.at,
138
+ });
139
+ }
140
+ members.sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
141
+ return { members, sub };
142
+ }
143
+
144
+ function statusOf(entry, alive, mtime) {
145
+ if (!alive) return 'ended';
146
+ if (entry.waitingFor) return 'waiting';
147
+ if (['busy', 'shell', 'idle'].includes(entry.status)) return entry.status;
148
+ return Date.now() - mtime < 5000 ? 'busy' : 'idle';
149
+ }
150
+
151
+ // 응답 대기는 Claude Code 가 세션 파일에 적은 값이다. 그동안 서브에이전트가 돌고 있으면 함께 적어 구분되게 한다.
152
+ function withRunningMembers(phase, status, members) {
153
+ if (status !== 'waiting') return phase;
154
+ const running = members.filter((m) => m.status !== 'done' && m.phase?.kind !== 'done').length;
155
+ return running ? { ...phase, text: `${phase.text} · 에이전트 ${running}개 실행 중` } : phase;
156
+ }
157
+
158
+ function phaseOf(status, entry, t) {
159
+ switch (status) {
160
+ case 'ended':
161
+ return { kind: 'ended', text: '종료' };
162
+ case 'waiting':
163
+ return { kind: 'waiting', text: entry.waitingFor ? `응답 대기 · ${entry.waitingFor}` : '응답 대기' };
164
+ case 'shell':
165
+ return { kind: 'tool', text: '쉘 명령 실행 중' };
166
+ case 'idle':
167
+ return { kind: 'idle', text: '다음 지시 대기' };
168
+ default:
169
+ return loadingPhase(t) ?? (t ? t.phase() : { kind: 'think', text: '작업 중' });
170
+ }
171
+ }
172
+
173
+ // sessions/<pid>.json 은 세션이 끝나면 지워지므로, 끝난 세션은 최근에 바뀐 기록 파일로 찾는다.
174
+ // 폴더를 매번 훑지 않게 목록은 잠깐 재사용하고, 기록 앞부분에서 이름·폴더만 읽어 둔다.
175
+ const DISCOVER_TTL_MS = 10_000;
176
+ const HEAD_BYTES = 256 * 1024;
177
+ let discovered = { at: 0, list: [] };
178
+ const heads = new Map();
179
+
180
+ function recentTranscripts() {
181
+ if (Date.now() - discovered.at < DISCOVER_TTL_MS) return discovered.list;
182
+ const list = [];
183
+ let dirs = [];
184
+ try {
185
+ dirs = readdirSync(PROJECTS);
186
+ } catch (e) {
187
+ debugIo('claude', 'projects read failed', e);
188
+ }
189
+ for (const dir of dirs) {
190
+ let names;
191
+ try {
192
+ names = readdirSync(join(PROJECTS, dir));
193
+ } catch (e) {
194
+ debugIo('claude', 'project folder read failed', e, dir);
195
+ continue;
196
+ }
197
+ for (const n of names) {
198
+ if (!n.endsWith('.jsonl')) continue;
199
+ const path = join(PROJECTS, dir, n);
200
+ try {
201
+ const mtime = statSync(path).mtimeMs;
202
+ if (Date.now() - mtime < ENDED_WINDOW_MS) list.push({ sessionId: n.slice(0, -'.jsonl'.length), path, mtime });
203
+ } catch {}
204
+ }
205
+ }
206
+ discovered = { at: Date.now(), list };
207
+ return list;
208
+ }
209
+
210
+ // 기록이 바뀌었으면(mtime) 앞부분을 다시 읽어 뒤늦게 붙은 제목도 반영한다.
211
+ function readHead(path, mtime) {
212
+ const cached = heads.get(path);
213
+ if (cached && cached.mtime === mtime) return cached;
214
+ const head = { mtime, cwd: null, title: null, branch: null, startedAt: null };
215
+ try {
216
+ const fd = openSync(path, 'r');
217
+ const buf = Buffer.alloc(HEAD_BYTES);
218
+ const n = readSync(fd, buf, 0, HEAD_BYTES, 0);
219
+ closeSync(fd);
220
+ const text = buf.toString('utf8', 0, n);
221
+ for (const line of text.slice(0, text.lastIndexOf('\n')).split('\n')) {
222
+ let x;
223
+ try {
224
+ x = JSON.parse(line);
225
+ } catch (e) {
226
+ debug('claude', 'transcript head line parse failed', e, path);
227
+ continue;
228
+ }
229
+ head.cwd ??= x.cwd ?? null;
230
+ head.branch ??= x.gitBranch ?? null;
231
+ if (x.timestamp) head.startedAt ??= Date.parse(x.timestamp) || null;
232
+ if (x.type === 'ai-title' && x.aiTitle) head.title = x.aiTitle;
233
+ }
234
+ } catch (e) {
235
+ debugIo('claude', 'transcript head read failed', e, path);
236
+ }
237
+ heads.set(path, head);
238
+ return head;
239
+ }
240
+
241
+ // 캐시가 있는지는 기록이 바뀔 때만 다시 확인한다 (끝난 세션 수십 개를 매초 확인하지 않게).
242
+ const cacheChecked = new Map();
243
+
244
+ function hasCache(path, mtime) {
245
+ const c = cacheChecked.get(path);
246
+ if (c && c.mtime === mtime) return c.ok;
247
+ const ok = Boolean(loadCache(path));
248
+ cacheChecked.set(path, { mtime, ok });
249
+ return ok;
250
+ }
251
+
252
+ // 끝난 세션의 기록 본문은 선택했을 때, 이미 캐시가 있을 때, 또는 --once 처럼 한도 없이 읽을 때만 읽는다.
253
+ // 한 번 읽기 시작한 세션은 선택을 옮겨도 계속 읽어서, 서브에이전트까지 포함한 합계가 흔들리지 않게 한다.
254
+ function endedFromTranscripts(known, selectedId, budget) {
255
+ const out = [];
256
+ const eager = budget.left === Infinity;
257
+ const recent = recentTranscripts();
258
+ const recentPaths = new Set(recent.map((f) => f.path));
259
+ for (const map of [heads, cacheChecked]) for (const p of map.keys()) if (!recentPaths.has(p)) map.delete(p);
260
+ for (const f of recent) {
261
+ if (known.has(f.sessionId)) continue;
262
+ const head = readHead(f.path, f.mtime);
263
+ const want = eager || f.sessionId === selectedId || trackers.has(f.path) || hasCache(f.path, f.mtime);
264
+ const t = want ? tracker(trackers, f.path, budget) : null;
265
+ const { sub } = t ? readMembers(f.path, t, budget) : { sub: emptySub() };
266
+ out.push({
267
+ tool: 'claude',
268
+ id: f.sessionId,
269
+ pid: null,
270
+ kind: '-',
271
+ name: t?.title ?? head.title ?? `${basename(head.cwd ?? '')} · ${f.sessionId.slice(0, 8)}`,
272
+ cwd: head.cwd,
273
+ project: basename(head.cwd ?? ''),
274
+ branch: t?.branch ?? head.branch,
275
+ model: t?.model ?? null,
276
+ live: false,
277
+ lazy: !t,
278
+ status: 'ended',
279
+ phase: loadingPhase(t) ?? { kind: 'ended', text: '종료' },
280
+ waitingFor: null,
281
+ now: '',
282
+ startedAt: head.startedAt,
283
+ updatedAt: f.mtime,
284
+ ...sessionTotals(t, sub),
285
+ events: t?.events ?? [],
286
+ members: [],
287
+ });
288
+ }
289
+ return out;
290
+ }
291
+
292
+ export function collectClaude({ showEnded, budget, selectedId }) {
293
+ let files;
294
+ try {
295
+ files = readdirSync(SESSIONS).filter((n) => n.endsWith('.json'));
296
+ } catch (e) {
297
+ debugIo('claude', 'sessions read failed', e);
298
+ return [];
299
+ }
300
+ seen = new Set();
301
+ const latest = new Map();
302
+ for (const f of files) {
303
+ const e = readJson(join(SESSIONS, f));
304
+ if (!e?.sessionId) continue;
305
+ e.alive = isAlive(e.pid);
306
+ const prev = latest.get(e.sessionId);
307
+ if (!prev || (e.alive && !prev.alive) || (e.updatedAt ?? e.startedAt) > (prev.updatedAt ?? prev.startedAt)) latest.set(e.sessionId, e);
308
+ }
309
+
310
+ // 읽기 한도가 큰 기록 하나에 다 쓰이지 않도록, 살아 있는 세션의 새 줄부터 읽는다.
311
+ const entries = [...latest.values()].sort((a, b) => Number(b.alive) - Number(a.alive));
312
+ const out = [];
313
+ for (const e of entries) {
314
+ const touched = e.updatedAt ?? e.startedAt ?? 0;
315
+ if (!e.alive && !(showEnded && Date.now() - touched < ENDED_WINDOW_MS)) continue;
316
+
317
+ const transcript = findTranscript(e.sessionId);
318
+ const t = transcript ? tracker(trackers, transcript, budget) : null;
319
+ const { members, sub } = transcript ? readMembers(transcript, t, budget) : { members: [], sub: emptySub() };
320
+ const mtime = Math.max(t?.mtimeMs ?? 0, touched);
321
+ const status = statusOf(e, e.alive, mtime);
322
+ const autoName = !e.name || e.name === e.sessionId.slice(0, 8);
323
+
324
+ out.push({
325
+ tool: 'claude',
326
+ id: e.sessionId,
327
+ pid: e.pid,
328
+ kind: e.kind === 'bg' ? 'background' : e.kind ?? 'interactive',
329
+ name: (autoName ? t?.title : e.name) ?? `${basename(e.cwd ?? '')} · ${e.sessionId.slice(0, 8)}`,
330
+ cwd: e.cwd,
331
+ project: basename(e.cwd ?? ''),
332
+ branch: t?.branch,
333
+ model: t?.model,
334
+ live: e.alive,
335
+ status,
336
+ // Claude Code 가 상태를 남기지 않아 기록 수정 시각으로 추측한 경우
337
+ statusGuessed: e.alive && !e.waitingFor && !['busy', 'shell', 'idle'].includes(e.status),
338
+ phase: withRunningMembers(phaseOf(status, e, t), status, members),
339
+ waitingFor: e.waitingFor ?? null,
340
+ waitDetail: status === 'waiting' ? (t?.waitDetail(e.waitingFor) ?? null) : null,
341
+ now: t?.lastTool ?? t?.events.at(-1)?.text ?? '',
342
+ startedAt: e.startedAt,
343
+ updatedAt: mtime,
344
+ ...sessionTotals(t, sub),
345
+ events: t?.events ?? [],
346
+ members: status === 'ended' ? [] : members,
347
+ });
348
+ }
349
+ if (showEnded) out.push(...endedFromTranscripts(new Set(out.map((s) => s.id)), selectedId, budget));
350
+ prune();
351
+ return out;
352
+ }
@@ -0,0 +1,141 @@
1
+ // 날짜별 사용량 장부의 Codex CLI 몫: 최근 7일 안에 바뀐 rollout-*.jsonl 의 token_count 를 날짜와 5분 칸에 나눠 모은다.
2
+ // token_count 는 세션 누적값(total_token_usage)이라, 같은 파일의 직전 누적값과의 차이만 그 줄의 시각에 더한다.
3
+ // 같은 누적값이 되풀이되면 차이가 0 이라 두 번 세지 않고, 누적값이 줄었으면 새 카운터가 시작된 것으로 보고 새 누적값을 그대로 더한다.
4
+ // 가격은 그 줄 앞의 마지막 turn_context 모델로 매긴다. 기록은 읽기만 하고, 어디까지 읽었는지는 캐시에 둔다.
5
+ import { readdirSync, statSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { JsonlTail } from '../tail.mjs';
8
+ import { loadCache, markDirty } from '../cache.mjs';
9
+ import { codexCost } from '../pricing.mjs';
10
+ import { dayDirs } from './codex.mjs';
11
+ import { dayKey, lastDays, addSlot } from './common.mjs';
12
+ import { debugIo } from '../log.mjs';
13
+
14
+ const KIND = 'codex-ledger';
15
+ const DAYS = 7;
16
+ const KEEP_DAYS = 10;
17
+ const LIST_TTL_MS = 30_000;
18
+ const SCAN_DAYS = 30;
19
+
20
+ // total_token_usage 를 codexCost 가 받는 모양으로 바꾼다 (input 은 캐시 제외).
21
+ export function tokensOf(t) {
22
+ const cached = t.cached_input_tokens ?? 0;
23
+ return { input: (t.input_tokens ?? 0) - cached, cacheRead: cached, output: t.output_tokens ?? 0 };
24
+ }
25
+
26
+ // 직전 누적값 prev 에서 cur 까지 늘어난 양. 어느 값이든 줄었으면 새 카운터로 보고 cur 전체를 돌려준다.
27
+ export function deltaOf(prev, cur) {
28
+ if (!prev || cur.input < prev.input || cur.cacheRead < prev.cacheRead || cur.output < prev.output) return { ...cur };
29
+ return { input: cur.input - prev.input, cacheRead: cur.cacheRead - prev.cacheRead, output: cur.output - prev.output };
30
+ }
31
+
32
+ export class CodexBook {
33
+ constructor(path) {
34
+ this.tail = new JsonlTail(path);
35
+ this.tail.onReset = () => this.clear();
36
+ this.clear();
37
+ const c = loadCache(path, KIND);
38
+ if (c) {
39
+ this.tail.offset = c.offset;
40
+ this.model = c.model ?? null;
41
+ this.last = c.last ?? null;
42
+ this.days = c.days ?? {};
43
+ this.slots = c.slots ?? {};
44
+ }
45
+ }
46
+
47
+ clear() {
48
+ this.model = null;
49
+ this.last = null;
50
+ this.days = {};
51
+ // 지출 속도용 5분 칸 (common.mjs addSlot).
52
+ this.slots = {};
53
+ }
54
+
55
+ get pending() {
56
+ return Math.max(0, this.tail.size - this.tail.offset);
57
+ }
58
+
59
+ update(budget) {
60
+ const n = this.tail.read((x) => this.apply(x), budget);
61
+ if (n) markDirty(this.tail.path, () => this.snapshot(), KIND);
62
+ return n;
63
+ }
64
+
65
+ snapshot() {
66
+ const keep = new Set(lastDays(KEEP_DAYS));
67
+ const days = Object.fromEntries(Object.entries(this.days).filter(([k]) => keep.has(k)));
68
+ return { offset: this.tail.lineOffset, model: this.model, last: this.last, days, slots: this.slots };
69
+ }
70
+
71
+ apply(x) {
72
+ const p = x.payload ?? {};
73
+ if (x.type === 'turn_context') {
74
+ this.model = p.model ?? this.model;
75
+ return;
76
+ }
77
+ if (x.type !== 'event_msg' || p.type !== 'token_count' || !p.info?.total_token_usage) return;
78
+ const cur = tokensOf(p.info.total_token_usage);
79
+ const d = deltaOf(this.last, cur);
80
+ this.last = cur;
81
+ const tokens = d.input + d.cacheRead + d.output;
82
+ if (tokens <= 0) return;
83
+ const cost = codexCost(this.model, d);
84
+ const at = Date.parse(x.timestamp) || Date.now();
85
+ const day = (this.days[dayKey(at)] ??= { tokens: 0, cost: 0, costNoCache: 0, unknown: false });
86
+ day.tokens += tokens;
87
+ day.cost += cost?.total ?? 0;
88
+ day.costNoCache += cost?.noCache ?? 0;
89
+ if (!cost) day.unknown = true;
90
+ addSlot(this.slots, at, cost?.total ?? 0, cost?.noCache ?? 0, !cost);
91
+ }
92
+ }
93
+
94
+ let listed = { at: 0, files: [] };
95
+
96
+ // 며칠 전에 시작했거나 다시 이어 쓴 세션도 시작한 날짜 폴더에 쓰므로, 30일치 폴더에서 7일 안에 바뀐 기록을 고른다.
97
+ // 7일보다 오래된 줄은 날짜 키로 걸러진다.
98
+ function recentFiles() {
99
+ if (Date.now() - listed.at < LIST_TTL_MS) return listed.files;
100
+ const cutoff = Date.now() - DAYS * 86400_000;
101
+ const files = [];
102
+ for (const dir of dayDirs(SCAN_DAYS)) {
103
+ let names = [];
104
+ try {
105
+ names = readdirSync(dir);
106
+ } catch (e) {
107
+ debugIo('ledger', 'codex day folder read failed', e, dir);
108
+ }
109
+ for (const n of names) {
110
+ if (!n.startsWith('rollout-') || !n.endsWith('.jsonl')) continue;
111
+ const p = join(dir, n);
112
+ try {
113
+ if (statSync(p).mtimeMs >= cutoff) files.push(p);
114
+ } catch {}
115
+ }
116
+ }
117
+ listed = { at: Date.now(), files };
118
+ return files;
119
+ }
120
+
121
+ const books = new Map();
122
+
123
+ // 기록마다 날짜별 합계(days)와 5분 칸(slots), 아직 읽지 않은 크기(pending)·전체 크기(size).
124
+ export function collectCodexLedger({ budget } = {}) {
125
+ const out = { days: [], slots: [], pending: 0, size: 0 };
126
+ const files = recentFiles();
127
+ const keep = new Set(files);
128
+ for (const p of books.keys()) if (!keep.has(p)) books.delete(p);
129
+ for (const p of files) {
130
+ let b = books.get(p);
131
+ if (!b) books.set(p, (b = new CodexBook(p)));
132
+ b.update(budget);
133
+ out.pending += b.pending;
134
+ out.size += b.tail.size;
135
+ out.days.push(b.days);
136
+ out.slots.push(b.slots);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ export const _test = { reset: () => ((listed = { at: 0, files: [] }), books.clear()) };