@sejuneoh/aitop 0.0.0-stage → 0.21.2

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,374 @@
1
+ // Codex CLI: 실행 중인 codex 프로세스의 작업 폴더와 ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl 을 짝지어 읽는다.
2
+ import { readdirSync, statSync, existsSync, realpathSync, openSync, readSync, closeSync } from 'node:fs';
3
+ import { join, basename } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { JsonlTail } from '../tail.mjs';
6
+ import { loadCache, markDirty } from '../cache.mjs';
7
+ import { findProcesses, isCodex, openFiles, processStart } from '../procs.mjs';
8
+ import { addActivity, emptyTokens, parseTs, pushEvent, describeTool, isNoisePrompt } from './common.mjs';
9
+ import { addChange, mergeFiles } from './files.mjs';
10
+ import { codexCost } from '../pricing.mjs';
11
+ import { parsePatch, extractPatch, patchSucceeded } from './patch.mjs';
12
+ import { debug, debugIo } from '../log.mjs';
13
+
14
+ // 지시 탭에 쓰는 지시 목록은 최근 것만 남긴다.
15
+ const MAX_TURNS = 50;
16
+
17
+ const ROOT = join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'sessions');
18
+ const RECENT_MS = 24 * 3600 * 1000;
19
+
20
+ const states = new Map();
21
+
22
+ export function dayDirs(days = 7) {
23
+ const dirs = [];
24
+ // 며칠 동안 켜 둔 세션도 시작한 날짜 폴더에 기록하므로 일주일치 폴더를 본다.
25
+ for (let back = 0; back < days; back++) {
26
+ const d = new Date(Date.now() - back * 86400_000);
27
+ const p = join(ROOT, String(d.getFullYear()), String(d.getMonth() + 1).padStart(2, '0'), String(d.getDate()).padStart(2, '0'));
28
+ if (existsSync(p)) dirs.push(p);
29
+ }
30
+ return dirs;
31
+ }
32
+
33
+ // 날짜 폴더를 매초 다시 훑지 않도록 파일 이름 목록만 잠깐 재사용한다. 수정 시각은 매번 확인한다.
34
+ const LIST_TTL_MS = 5000;
35
+ let listed = { at: 0, paths: [] };
36
+
37
+ function recentFiles() {
38
+ if (Date.now() - listed.at >= LIST_TTL_MS) {
39
+ const paths = [];
40
+ for (const dir of dayDirs()) for (const n of readdirSync(dir)) if (n.endsWith('.jsonl')) paths.push(join(dir, n));
41
+ listed = { at: Date.now(), paths };
42
+ }
43
+ const out = [];
44
+ for (const p of listed.paths) {
45
+ let m;
46
+ try {
47
+ m = statSync(p).mtimeMs;
48
+ } catch {
49
+ continue;
50
+ }
51
+ if (Date.now() - m < RECENT_MS) out.push({ path: p, mtime: m });
52
+ }
53
+ return out.sort((a, b) => b.mtime - a.mtime);
54
+ }
55
+
56
+ // 하루 넘게 쉰 세션은 최근 파일 목록에 없다. 짝을 못 찾은 codex 프로세스에만 같은 작업 폴더에서 가장 최근에
57
+ // 바뀐 파일을 30일치 폴더에서 찾되, 프로세스가 시작한 뒤에 쓰인 파일만 받는다. 그래야 첫 입력 전인 새 codex 에
58
+ // 지난 세션이 붙지 않는다. 시작 시각을 모르면 찾지 않는다.
59
+ // 찾은 파일은 pid 별로 기억한다. 못 찾았으면 날짜 폴더가 바뀔 때(새 파일·새 폴더)까지만 다시 찾지 않는다.
60
+ const OLD_DAYS = 30;
61
+ const HEAD_BYTES = 256 * 1024;
62
+ const cwdByPath = new Map();
63
+ const foundByPid = new Map();
64
+ let headBuf = null;
65
+
66
+ // session_meta 첫 줄은 지침 글 때문에 길 수 있어 JSON 전체 대신 앞부분의 cwd 만 찾는다.
67
+ function headCwd(path) {
68
+ if (cwdByPath.has(path)) return cwdByPath.get(path);
69
+ let cwd = null;
70
+ try {
71
+ const fd = openSync(path, 'r');
72
+ try {
73
+ headBuf ??= Buffer.alloc(HEAD_BYTES);
74
+ const m = /"cwd":("(?:[^"\\]|\\.)*")/.exec(headBuf.toString('utf8', 0, readSync(fd, headBuf, 0, HEAD_BYTES, 0)));
75
+ if (m) cwd = JSON.parse(m[1]);
76
+ } finally {
77
+ closeSync(fd);
78
+ }
79
+ } catch (e) {
80
+ debugIo('codex', 'rollout head read failed', e, path);
81
+ }
82
+ cwdByPath.set(path, cwd);
83
+ return cwd;
84
+ }
85
+
86
+ let realRoot = null;
87
+
88
+ function isRollout(p) {
89
+ if (realRoot === null) {
90
+ try {
91
+ realRoot = realpathSync(ROOT);
92
+ } catch (e) {
93
+ debugIo('codex', 'sessions realpath failed', e);
94
+ }
95
+ }
96
+ return (p.startsWith(ROOT + '/') || (realRoot !== null && p.startsWith(realRoot + '/'))) && /\/rollout-[^/]*\.jsonl$/.test(p);
97
+ }
98
+
99
+ // 날짜 폴더에 파일이 생기면 그 폴더의 수정 시각이 바뀐다.
100
+ function foldersSignature(dirs) {
101
+ return dirs
102
+ .map((d) => {
103
+ try {
104
+ return d + ':' + statSync(d).mtimeMs;
105
+ } catch {
106
+ return d;
107
+ }
108
+ })
109
+ .join('|');
110
+ }
111
+
112
+ function latestFor(dirs, cwd, since, taken) {
113
+ let best = null;
114
+ const listed = new Set();
115
+ for (const dir of dirs) {
116
+ let names;
117
+ try {
118
+ names = readdirSync(dir);
119
+ } catch (e) {
120
+ debugIo('codex', 'day folder read failed', e, dir);
121
+ continue;
122
+ }
123
+ for (const n of names) {
124
+ const p = join(dir, n);
125
+ if (!n.endsWith('.jsonl')) continue;
126
+ listed.add(p);
127
+ if (taken.has(p)) continue;
128
+ let m;
129
+ try {
130
+ m = statSync(p).mtimeMs;
131
+ } catch {
132
+ continue;
133
+ }
134
+ if (m >= since && (!best || m > best.mtime) && headCwd(p) === cwd) best = { path: p, mtime: m };
135
+ }
136
+ }
137
+ for (const p of cwdByPath.keys()) if (!listed.has(p)) cwdByPath.delete(p);
138
+ return best?.path ?? null;
139
+ }
140
+
141
+ function searchByCwd(pid, cwd, taken, startOf) {
142
+ const memo = foundByPid.get(pid);
143
+ if (memo?.path) return taken.has(memo.path) ? null : memo.path;
144
+ const dirs = dayDirs(OLD_DAYS);
145
+ const sig = foldersSignature(dirs);
146
+ if (memo && memo.sig === sig) return null;
147
+ const since = startOf(pid);
148
+ if (since === null) return null;
149
+ const path = latestFor(dirs, cwd, since, taken);
150
+ foundByPid.set(pid, { path, sig });
151
+ return path;
152
+ }
153
+
154
+ const SAVED = ['id', 'cwd', 'branch', 'model', 'busy', 'tokens', 'context', 'window', 'events', 'lastTool', 'startedAt', 'lastKind', 'files', 'turns', 'turnCount', 'activity'];
155
+
156
+ function newState(path) {
157
+ const st = { tail: new JsonlTail(path), id: basename(path), cwd: null, branch: null, model: null, busy: false, tokens: emptyTokens(), context: 0, window: 0, events: [], lastTool: null, startedAt: null, pending: new Map(), lastKind: null, files: [], turns: [], turnCount: 0, patches: {}, activity: {} };
158
+ const c = loadCache(path);
159
+ if (c) {
160
+ st.tail.offset = c.offset;
161
+ for (const k of SAVED) if (k in c) st[k] = c[k];
162
+ st.pending = new Map(c.pending ?? []);
163
+ }
164
+ return st;
165
+ }
166
+
167
+ function snapshot(st) {
168
+ const out = { offset: st.tail.lineOffset, pending: [...st.pending] };
169
+ for (const k of SAVED) out[k] = st[k];
170
+ return out;
171
+ }
172
+
173
+ function phaseOf(st, live) {
174
+ if (!live) return { kind: 'ended', text: '종료' };
175
+ if (!st.busy) return { kind: 'idle', text: '다음 지시 대기' };
176
+ const open = [...st.pending.values()];
177
+ if (open.length) return { kind: 'tool', text: `도구 실행 · ${open.at(-1)}` };
178
+ return st.lastKind === 'reply' ? { kind: 'write', text: '답변 작성 중' } : { kind: 'think', text: '생각 중' };
179
+ }
180
+
181
+ function applyLine(st, x) {
182
+ const p = x.payload ?? {};
183
+ if (x.type === 'session_meta') {
184
+ st.id = p.id ?? st.id;
185
+ st.cwd = p.cwd ?? st.cwd;
186
+ st.branch = p.git?.branch ?? st.branch;
187
+ st.startedAt = Date.parse(p.timestamp) || st.startedAt;
188
+ } else if (x.type === 'turn_context') {
189
+ st.model = p.model ?? st.model;
190
+ st.cwd = p.cwd ?? st.cwd;
191
+ } else if (x.type === 'event_msg') {
192
+ switch (p.type) {
193
+ case 'task_started':
194
+ st.busy = true;
195
+ if (p.model_context_window) st.window = p.model_context_window;
196
+ break;
197
+ case 'task_complete':
198
+ case 'turn_aborted':
199
+ st.busy = false;
200
+ st.pending.clear();
201
+ st.patches = {};
202
+ break;
203
+ case 'token_count': {
204
+ const t = p.info?.total_token_usage;
205
+ if (t) {
206
+ const cached = t.cached_input_tokens ?? 0;
207
+ // 누적값이라 직전 합계와의 차이를 그 줄의 시각 칸에 더한다.
208
+ addActivity(st.activity, parseTs(x.timestamp), (t.total_tokens ?? 0) - st.tokens.total);
209
+ st.tokens = { input: (t.input_tokens ?? 0) - cached, output: t.output_tokens ?? 0, cacheRead: cached, cacheWrite: 0, total: t.total_tokens ?? 0 };
210
+ }
211
+ if (p.info?.last_token_usage) st.context = p.info.last_token_usage.total_tokens ?? 0;
212
+ if (p.info?.model_context_window) st.window = p.info.model_context_window;
213
+ break;
214
+ }
215
+ case 'user_message':
216
+ if (p.message && !isNoisePrompt(p.message)) {
217
+ const e = pushEvent(st, x.timestamp, 'prompt', p.message);
218
+ if (e) {
219
+ st.turns.push({ n: ++st.turnCount, at: e.at, text: e.text.slice(0, 120) });
220
+ if (st.turns.length > MAX_TURNS) st.turns.shift();
221
+ }
222
+ }
223
+ st.lastKind = 'prompt';
224
+ break;
225
+ case 'agent_message':
226
+ pushEvent(st, x.timestamp, 'reply', p.message);
227
+ st.lastKind = 'reply';
228
+ break;
229
+ }
230
+ } else if (x.type === 'response_item') {
231
+ let what = null;
232
+ if (p.type === 'function_call') {
233
+ let args = {};
234
+ try {
235
+ args = JSON.parse(p.arguments ?? '{}');
236
+ } catch (e) {
237
+ debug('codex', 'function_call arguments parse failed', e);
238
+ }
239
+ what = describeTool(p.name, args);
240
+ // 예전 Codex 는 apply_patch 를 shell 명령 인자로 보냈다 (["apply_patch", "*** Begin Patch…"] 또는 bash -lc 안의 글).
241
+ const cmd = Array.isArray(args.command) ? args.command.join('\n') : String(args.command ?? '');
242
+ const text = /\bapply_patch\b/.test(cmd) ? extractPatch(cmd) : null;
243
+ if (p.call_id && text) st.patches[p.call_id] = { at: x.timestamp, text };
244
+ } else if (p.type === 'custom_tool_call') {
245
+ what = describeTool(p.name, p.input ?? '');
246
+ if (p.call_id && p.name === 'apply_patch') st.patches[p.call_id] = { at: x.timestamp, text: String(p.input ?? '') };
247
+ } else if (p.type === 'web_search_call') what = 'WebSearch';
248
+ else if (p.type === 'function_call_output' || p.type === 'custom_tool_call_output') {
249
+ // 성공한 패치만 변경 파일에 넣는다.
250
+ const patch = st.patches[p.call_id];
251
+ if (patch) {
252
+ delete st.patches[p.call_id];
253
+ if (patchSucceeded(p.output)) for (const r of parsePatch(patch.text, st.cwd)) addChange(st.files, r, patch.at);
254
+ }
255
+ st.pending.delete(p.call_id);
256
+ st.lastKind = 'result';
257
+ }
258
+ if (what) {
259
+ st.lastTool = what;
260
+ if (p.call_id && p.type !== 'web_search_call') st.pending.set(p.call_id, p.name);
261
+ pushEvent(st, x.timestamp, 'tool', what);
262
+ st.lastKind = 'tool';
263
+ }
264
+ }
265
+ }
266
+
267
+ export const _test = { newState, applyLine, phaseOf, snapshot };
268
+
269
+ export function collectCodex({ showEnded, budget, filesOf = openFiles, startOf = processStart }) {
270
+ const procs = findProcesses(isCodex);
271
+ if (!procs.length && !showEnded) return [];
272
+
273
+ const files = recentFiles();
274
+ const read = (path) => {
275
+ let st = states.get(path);
276
+ if (!st) states.set(path, (st = newState(path)));
277
+ if (st.tail.read((x) => applyLine(st, x), budget)) markDirty(path, () => snapshot(st));
278
+ };
279
+ for (const f of files) read(f.path);
280
+
281
+ const liveByPath = new Map();
282
+ const claim = (path, pid) => {
283
+ if (!files.some((f) => f.path === path)) {
284
+ try {
285
+ files.push({ path, mtime: statSync(path).mtimeMs });
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
290
+ read(path);
291
+ liveByPath.set(path, pid);
292
+ return true;
293
+ };
294
+
295
+ // 프로세스가 열어 둔 rollout 파일(Linux)이 가장 정확하므로 먼저 짝짓는다.
296
+ const rest = [];
297
+ for (const p of procs) {
298
+ const opened = filesOf(p.pid)?.find((f) => isRollout(f) && !liveByPath.has(f));
299
+ if (!opened || !claim(opened, p.pid)) rest.push(p);
300
+ }
301
+
302
+ // 남은 프로세스는 같은 폴더에서 돌고 있는 수만큼, 가장 최근에 바뀐 세션 파일을 '실행 중'으로 본다.
303
+ // 시작 시각을 아는 프로세스에는 시작한 뒤에 쓰인 파일만 준다. 새 codex 에 방금 끝난 세션이 붙지 않게 한다.
304
+ const slots = new Map();
305
+ for (const p of rest) slots.set(p.cwd, [...(slots.get(p.cwd) ?? []), p.pid]);
306
+ for (const f of files) {
307
+ if (liveByPath.has(f.path)) continue;
308
+ const free = slots.get(states.get(f.path).cwd);
309
+ const i = free?.findIndex((pid) => {
310
+ const since = startOf(pid);
311
+ return since === null || f.mtime >= since;
312
+ }) ?? -1;
313
+ if (i >= 0) liveByPath.set(f.path, free.splice(i, 1)[0]);
314
+ }
315
+
316
+ const alive = new Set(procs.map((p) => p.pid));
317
+ for (const pid of foundByPid.keys()) if (!alive.has(pid)) foundByPid.delete(pid);
318
+ for (const [cwd, pids] of slots) {
319
+ for (const pid of [...pids]) {
320
+ const path = searchByCwd(pid, cwd, liveByPath, startOf);
321
+ if (path && claim(path, pid)) pids.splice(pids.indexOf(pid), 1);
322
+ }
323
+ }
324
+
325
+ const seen = new Set(files.map((f) => f.path));
326
+ for (const path of states.keys()) if (!seen.has(path)) states.delete(path);
327
+
328
+ const out = [];
329
+ for (const f of files) {
330
+ const st = states.get(f.path);
331
+ const pid = liveByPath.get(f.path);
332
+ const live = pid !== undefined;
333
+ if (!live && !showEnded) continue;
334
+ // 가격표에 없는 모델은 비용을 ? 로 보인다.
335
+ const cost = codexCost(st.model, st.tokens);
336
+ const used = st.tokens.total > 0;
337
+ out.push({
338
+ tool: 'codex',
339
+ id: st.id,
340
+ pid: pid ?? null,
341
+ kind: 'interactive',
342
+ name: basename(st.cwd ?? '') || 'codex',
343
+ cwd: st.cwd,
344
+ project: basename(st.cwd ?? ''),
345
+ branch: st.branch,
346
+ model: st.model,
347
+ live,
348
+ status: !live ? 'ended' : st.busy ? 'busy' : 'idle',
349
+ phase: phaseOf(st, live),
350
+ cost: cost?.total ?? (used ? 0 : null),
351
+ costNoCache: cost?.noCache ?? null,
352
+ costUnknown: used && !cost,
353
+ waitingFor: null,
354
+ now: st.lastTool ?? st.events.at(-1)?.text ?? '',
355
+ startedAt: st.startedAt,
356
+ updatedAt: f.mtime,
357
+ tokens: { ...st.tokens, sub: 0 },
358
+ context: { used: st.context, window: st.window || 258_400 },
359
+ events: st.events,
360
+ files: mergeFiles([{ agent: '메인', files: st.files }]),
361
+ turns: st.turns,
362
+ activity: st.activity,
363
+ members: [],
364
+ });
365
+ }
366
+
367
+ // 아직 세션 파일을 만들지 않은(첫 입력 전) codex 프로세스도 보여 준다.
368
+ for (const [cwd, pids] of slots) {
369
+ for (const pid of pids) {
370
+ out.push({ tool: 'codex', id: `pid-${pid}`, pid, kind: 'interactive', name: basename(cwd ?? '') || 'codex', cwd, project: basename(cwd ?? ''), live: true, status: 'idle', phase: { kind: 'idle', text: '첫 입력 대기' }, cost: null, now: '', updatedAt: Date.now(), tokens: emptyTokens(), context: { used: 0, window: 0 }, events: [], members: [] });
371
+ }
372
+ }
373
+ return out;
374
+ }
@@ -0,0 +1,228 @@
1
+ import { basename } from 'node:path';
2
+ import { mask, maskStrict, oneLine, plainText, stripControl, STRICT_NAME } from '../text.mjs';
3
+
4
+ export const MAX_EVENTS = 40;
5
+ // 끝난 서브에이전트(팀원)는 이 시간 동안 "완료"로 보였다가 목록에서 사라진다.
6
+ export const MEMBER_FADE_MS = 6000;
7
+
8
+ export function emptyTokens() {
9
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
10
+ }
11
+
12
+ const two = (n) => String(n).padStart(2, '0');
13
+ // 사용자가 보는 날짜(로컬 시간대) 기준 날짜 키.
14
+ export function dayKey(ms) {
15
+ const d = new Date(ms);
16
+ return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
17
+ }
18
+
19
+ // 오늘부터 거꾸로 n 일의 날짜 키.
20
+ export function lastDays(n, now = Date.now()) {
21
+ const keys = [];
22
+ const d = new Date(now);
23
+ for (let i = 0; i < n; i++) {
24
+ keys.push(dayKey(d.getTime()));
25
+ d.setDate(d.getDate() - 1);
26
+ }
27
+ return keys;
28
+ }
29
+
30
+ // 지출 속도용 짧은 구간 장부. 비용을 5분 칸(키 = 칸 시작 시각 ms)에 모으고, 그 장부에서 가장 새 칸부터
31
+ // 2시간 안의 칸만 남긴다. 기준이 시계가 아니라 기록 안의 시각이라 처음 읽기 중에도 칸 수가 일정하다.
32
+ export const SLOT_MS = 5 * 60_000;
33
+ const SLOT_KEEP_MS = 2 * 3600_000;
34
+
35
+ export function addSlot(slots, at, cost, costNoCache, unknown) {
36
+ if (!Number.isFinite(at)) return;
37
+ const k = Math.floor(at / SLOT_MS) * SLOT_MS;
38
+ let s = slots[k];
39
+ if (!s) {
40
+ s = slots[k] = { cost: 0, costNoCache: 0, unknown: false };
41
+ const newest = Math.max(...Object.keys(slots).map(Number));
42
+ for (const key of Object.keys(slots)) if (Number(key) < newest - SLOT_KEEP_MS) delete slots[key];
43
+ if (!slots[k]) return;
44
+ }
45
+ s.cost += cost;
46
+ s.costNoCache += costNoCache;
47
+ if (unknown) s.unknown = true;
48
+ }
49
+
50
+ // 최근 1시간 합계. 시작 시각이 now 로부터 60분 안인 칸만 센다 (5분 단위 근사라 55~60분 구간).
51
+ export const lastHourOf = (books, now = Date.now()) => slotsSince(books, now - 3600_000);
52
+
53
+ // 시작 시각이 from 이후인 칸의 합계.
54
+ function slotsSince(books, from) {
55
+ const out = { cost: 0, costNoCache: 0, unknown: false };
56
+ for (const slots of books)
57
+ for (const [k, s] of Object.entries(slots ?? {})) {
58
+ if (Number(k) < from) continue;
59
+ out.cost += s.cost;
60
+ out.costNoCache += s.costNoCache;
61
+ out.unknown ||= s.unknown;
62
+ }
63
+ return out;
64
+ }
65
+
66
+ // 하루 예산의 예상 속도용: 최근 1시간 중 로컬 자정 이후의 몫만 센다.
67
+ // ms 는 실제로 센 칸이 덮는 길이다: 구간 시작을 칸 경계로 올린 시각부터 지금까지 (55~60분, 자정 직후면 더 짧다).
68
+ export function recentTodayOf(books, now = Date.now()) {
69
+ const d = new Date(now);
70
+ const from = Math.max(now - 3600_000, new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime());
71
+ const start = Math.ceil(from / SLOT_MS) * SLOT_MS;
72
+ return { ...slotsSince(books, start), ms: Math.max(0, now - start) };
73
+ }
74
+
75
+ // 세션별 활동 장부: 토큰을 1시간 칸(키 = 칸 시작 시각 ms)에 모은다. 상세 패널의 24시간 막대가 쓴다.
76
+ // 기준은 장부 안 가장 새 칸이라 처음 읽기 중에도 칸 수가 ACT_HOURS 를 넘지 않는다 (세션 하나에 숫자 24개).
77
+ export const ACT_MS = 3600_000;
78
+ export const ACT_HOURS = 24;
79
+ // 시계가 어긋난 줄 하나가 미래 칸을 만들어 앞 칸을 모두 지우지 않게, 지금보다 이만큼 넘게 앞선 시각은 버린다.
80
+ export const ACT_SKEW_MS = 5 * 60_000;
81
+
82
+ export function addActivity(act, at, tokens, now = Date.now()) {
83
+ if (!Number.isFinite(at) || !(tokens > 0) || at > now + ACT_SKEW_MS) return;
84
+ const k = Math.floor(at / ACT_MS) * ACT_MS;
85
+ if (act[k] == null) {
86
+ const newest = Math.max(k, ...Object.keys(act).map(Number));
87
+ if (k <= newest - ACT_HOURS * ACT_MS) return;
88
+ for (const key of Object.keys(act)) if (Number(key) <= newest - ACT_HOURS * ACT_MS) delete act[key];
89
+ act[k] = 0;
90
+ }
91
+ act[k] += tokens;
92
+ }
93
+
94
+ // 여러 장부(메인 + 서브에이전트)를 칸별로 더한다.
95
+ export function mergeActivity(books) {
96
+ const out = {};
97
+ for (const b of books) for (const [k, v] of Object.entries(b ?? {})) out[k] = (out[k] ?? 0) + v;
98
+ return out;
99
+ }
100
+
101
+ // now 가 든 칸까지 거꾸로 n 칸의 토큰 (오래된 칸 → 지금 칸).
102
+ export function activitySeries(act, now = Date.now(), n = ACT_HOURS) {
103
+ const last = Math.floor(now / ACT_MS) * ACT_MS;
104
+ return Array.from({ length: n }, (_, i) => act?.[last - (n - 1 - i) * ACT_MS] ?? 0);
105
+ }
106
+
107
+ // 기록 줄의 timestamp. 없거나 읽을 수 없으면 지금 시각.
108
+ export const parseTs = (ts) => (ts && Date.parse(ts)) || Date.now();
109
+
110
+ // 답변(reply)은 마크다운 기호를 지운 평문으로 남긴다.
111
+ export function pushEvent(state, at, kind, text) {
112
+ const t = mask(oneLine(kind === 'reply' ? plainText(text) : text));
113
+ if (!t) return null;
114
+ const e = { at: parseTs(at), kind, text: t };
115
+ state.events.push(e);
116
+ if (state.events.length > MAX_EVENTS) state.events.shift();
117
+ return e;
118
+ }
119
+
120
+ const short = (p) => (p ? basename(String(p)) : '');
121
+
122
+ // 도구 호출을 "무엇을 하는지" 한 줄로 줄인다. Claude·Codex 도구 이름을 모두 다룬다.
123
+ // 도구 설명은 화면·--json 어디로 나가든 비밀값을 가린 뒤 한 줄로 만든다.
124
+ export function describeTool(name, input = {}) {
125
+ return mask(oneLine(rawDescribeTool(name, input)));
126
+ }
127
+
128
+ function rawDescribeTool(name, input) {
129
+ if (typeof input === 'string') {
130
+ const file = input.match(/\*\*\* (?:Update|Add|Delete) File: (.+)/);
131
+ return file ? `${name} ${short(file[1])}` : name;
132
+ }
133
+ switch (name) {
134
+ case 'Bash':
135
+ case 'exec_command':
136
+ case 'shell':
137
+ return `$ ${input.description ?? input.cmd ?? input.command ?? ''}`;
138
+ case 'Read':
139
+ case 'Write':
140
+ case 'Edit':
141
+ case 'NotebookEdit':
142
+ return `${name} ${short(input.file_path ?? input.notebook_path)}`;
143
+ case 'Grep':
144
+ case 'Glob':
145
+ return `${name} ${input.pattern ?? ''}`;
146
+ case 'WebFetch':
147
+ return `WebFetch ${(input.url ?? '').replace(/^https?:\/\//, '').split('/')[0]}`;
148
+ case 'WebSearch':
149
+ return `WebSearch ${input.query ?? ''}`;
150
+ case 'Agent':
151
+ case 'Task':
152
+ return `▸ ${input.subagent_type ?? 'agent'}: ${input.description ?? ''}`;
153
+ case 'TodoWrite':
154
+ return 'TodoWrite';
155
+ default: {
156
+ const first = Object.values(input).find((v) => typeof v === 'string');
157
+ return first ? `${name} ${first}` : name;
158
+ }
159
+ }
160
+ }
161
+
162
+ // 허락을 기다리는 도구 호출의 전체 내용. 한 줄 설명(describeTool)과 달리 Bash 는 설명 대신 실제 명령을, 파일 도구는 전체 경로를 남긴다.
163
+ // 화면 색을 바꾸는 ANSI 순서와 제어문자를 지우고 비밀값을 가린 뒤 DETAIL_MAX 자에서 자른다. 줄바꿈은 남긴다.
164
+ export const DETAIL_MAX = 2048;
165
+ const ANSI = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g;
166
+
167
+ export function toolDetail(name, input = {}) {
168
+ const raw = rawToolDetail(name, input);
169
+ if (!raw) return null;
170
+ const clean = maskStrict(stripControl(String(raw).replace(ANSI, '').replace(/\r\n?/g, '\n').replace(/\t/g, ' '))).trim();
171
+ if (!clean) return null;
172
+ return clean.length > DETAIL_MAX ? clean.slice(0, DETAIL_MAX - 1) + '…' : clean;
173
+ }
174
+
175
+ // 이름이 비밀값처럼 보이는 키(api_key, token, password, cookie 등)의 값은 문자열·숫자·객체·배열 가리지 않고 통째로 가린다.
176
+ // { name: 'API_KEY', value: '…' } 처럼 이름과 값을 나눠 담은 항목도 value 를 가린다. 너무 깊은 곳은 통째로 가린다.
177
+ const MAX_DEPTH = 32;
178
+ const secretLabel = (x) => typeof x === 'string' && STRICT_NAME.test(x.replace(/^-{1,2}/, '').replace(/[:=]$/, ''));
179
+
180
+ export function maskKeys(v, depth = 0) {
181
+ if (!v || typeof v !== 'object') return v;
182
+ if (depth > MAX_DEPTH) return '••••';
183
+ // ["--token", "x"], ["Authorization", "x"]: 비밀 이름 바로 다음 칸을 가린다.
184
+ if (Array.isArray(v)) {
185
+ let after = false;
186
+ return v.map((x) => {
187
+ const hide = after && x != null && typeof x !== 'boolean';
188
+ after = !hide && secretLabel(x);
189
+ return hide ? '••••' : maskKeys(x, depth + 1);
190
+ });
191
+ }
192
+ const label = v.name ?? v.Name ?? v.key ?? v.Key;
193
+ const pair = typeof label === 'string' && STRICT_NAME.test(label);
194
+ return Object.fromEntries(
195
+ Object.entries(v).map(([k, x]) => {
196
+ const secret = STRICT_NAME.test(k) || (pair && /^value$/i.test(k));
197
+ return [k, secret && x != null && typeof x !== 'boolean' ? '••••' : maskKeys(x, depth + 1)];
198
+ }),
199
+ );
200
+ }
201
+
202
+ function rawToolDetail(name, input) {
203
+ if (typeof input === 'string') return input;
204
+ if (!input || typeof input !== 'object') return null;
205
+ switch (name) {
206
+ case 'Bash':
207
+ case 'exec_command':
208
+ case 'shell': {
209
+ const cmd = input.command ?? input.cmd;
210
+ return Array.isArray(cmd) ? cmd.join(' ') : cmd;
211
+ }
212
+ case 'Read':
213
+ case 'Write':
214
+ case 'Edit':
215
+ case 'NotebookEdit':
216
+ return input.file_path ?? input.notebook_path;
217
+ default:
218
+ try {
219
+ return Object.keys(input).length ? JSON.stringify(maskKeys(input)) : null;
220
+ } catch {
221
+ return null;
222
+ }
223
+ }
224
+ }
225
+
226
+ export function isNoisePrompt(text) {
227
+ return /^\s*(<|\[Request interrupted|Caveat:|This session is being continued from a previous conversation)/.test(text);
228
+ }