@wooojin/forgen 0.4.5 → 0.4.6
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +232 -0
- package/dist/core/auto-compound-runner.d.ts +13 -1
- package/dist/core/auto-compound-runner.js +93 -1
- package/dist/core/doctor.js +9 -0
- package/dist/core/harness.js +60 -1
- package/dist/core/notify.d.ts +18 -0
- package/dist/core/notify.js +93 -0
- package/dist/core/spawn.d.ts +13 -2
- package/dist/core/spawn.js +154 -32
- package/dist/core/state-gc.d.ts +10 -0
- package/dist/core/state-gc.js +71 -0
- package/dist/core/statusline-cli.js +55 -0
- package/dist/core/usage-telemetry.d.ts +34 -0
- package/dist/core/usage-telemetry.js +101 -0
- package/dist/hooks/context-guard.d.ts +13 -0
- package/dist/hooks/context-guard.js +155 -4
- package/dist/hooks/post-tool-use.js +3 -0
- package/dist/hooks/pre-tool-use.js +31 -0
- package/dist/hooks/session-recovery.js +11 -0
- package/dist/hooks/shared/read-stdin.js +44 -29
- package/dist/host/codex-adapter.js +5 -0
- package/dist/host/install-codex.js +7 -1
- package/dist/services/session.js +13 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
package/dist/core/spawn.js
CHANGED
|
@@ -8,19 +8,39 @@ import { loadGlobalConfig } from './global-config.js';
|
|
|
8
8
|
import { createLogger } from './logger.js';
|
|
9
9
|
import { STATE_DIR } from './paths.js';
|
|
10
10
|
import { getHostRuntime } from '../host/host-runtime.js';
|
|
11
|
+
import { sendNotification } from './notify.js';
|
|
11
12
|
const log = createLogger('spawn');
|
|
12
13
|
/** Phase 2: host-runtime 어댑터 위임. */
|
|
13
14
|
function findRuntimeLauncher(runtime) {
|
|
14
15
|
return getHostRuntime(runtime).launcher;
|
|
15
16
|
}
|
|
16
|
-
|
|
17
|
+
/**
|
|
18
|
+
* 0.4.6 — runtime 별 transcript 디렉토리.
|
|
19
|
+
*
|
|
20
|
+
* - claude: ~/.claude/projects/<sanitized-cwd>/<session>.jsonl
|
|
21
|
+
* - codex: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sid>.jsonl
|
|
22
|
+
*
|
|
23
|
+
* Codex 는 cwd 별 격리 없이 날짜별 격리 — 같은 사용자의 모든 codex 세션이
|
|
24
|
+
* 동일 날짜 dir 에 들어감. session attribution 은 파일 basename 의 session-id 로.
|
|
25
|
+
*
|
|
26
|
+
* Note: codex 는 일별 dir 라 자정 가로지르는 세션은 두 dir 에 걸칠 수 있음 —
|
|
27
|
+
* 본 함수는 시작 시점 dir 만 반환. 호출 측이 needed 시 보강.
|
|
28
|
+
*/
|
|
29
|
+
function transcriptProjectDir(cwd, runtime = 'claude') {
|
|
30
|
+
if (runtime === 'codex') {
|
|
31
|
+
const d = new Date();
|
|
32
|
+
const y = d.getUTCFullYear();
|
|
33
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
34
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
35
|
+
return path.join(os.homedir(), '.codex', 'sessions', String(y), m, day);
|
|
36
|
+
}
|
|
17
37
|
// Claude Code는 cwd의 /를 -로 치환하고 선행 -를 유지
|
|
18
38
|
const sanitized = cwd.replace(/\//g, '-');
|
|
19
39
|
return path.join(os.homedir(), '.claude', 'projects', sanitized);
|
|
20
40
|
}
|
|
21
41
|
/** 스냅샷용 — 세션 시작 전 존재하는 transcript basename 집합. */
|
|
22
|
-
function snapshotExistingTranscripts(cwd) {
|
|
23
|
-
const dir = transcriptProjectDir(cwd);
|
|
42
|
+
function snapshotExistingTranscripts(cwd, runtime = 'claude') {
|
|
43
|
+
const dir = transcriptProjectDir(cwd, runtime);
|
|
24
44
|
if (!fs.existsSync(dir))
|
|
25
45
|
return new Set();
|
|
26
46
|
try {
|
|
@@ -43,8 +63,8 @@ function snapshotExistingTranscripts(cwd) {
|
|
|
43
63
|
* 여전히 후보가 여러 개이면 (rare: 훅이 추가 파일을 쓴 경우) 가장 최근 수정본
|
|
44
64
|
* 을 고르되 debug 로그를 남긴다.
|
|
45
65
|
*/
|
|
46
|
-
function findSessionTranscript(cwd, sessionStartMs, preSnapshot) {
|
|
47
|
-
const dir = transcriptProjectDir(cwd);
|
|
66
|
+
function findSessionTranscript(cwd, sessionStartMs, preSnapshot, runtime = 'claude') {
|
|
67
|
+
const dir = transcriptProjectDir(cwd, runtime);
|
|
48
68
|
if (!fs.existsSync(dir))
|
|
49
69
|
return null;
|
|
50
70
|
let candidates;
|
|
@@ -81,6 +101,14 @@ function findSessionTranscript(cwd, sessionStartMs, preSnapshot) {
|
|
|
81
101
|
* 파일 전체를 메모리에 올렸다. 수백 MB 규모 transcript에서는 heap spike가
|
|
82
102
|
* 발생했고, 카운트 외엔 내용이 필요 없으니 streaming line-by-line로 충분하다.
|
|
83
103
|
*/
|
|
104
|
+
/**
|
|
105
|
+
* 0.4.6 — claude/codex 양 schema 호환.
|
|
106
|
+
*
|
|
107
|
+
* Claude JSONL: {type: 'user' | 'queue-operation', ...}
|
|
108
|
+
* Codex JSONL: {type: 'response_item', payload: {role: 'user' | 'developer' | 'assistant', ...}}
|
|
109
|
+
*
|
|
110
|
+
* 단일 함수에서 둘 다 처리 — schema 자동 감지.
|
|
111
|
+
*/
|
|
84
112
|
async function countUserMessages(transcriptPath) {
|
|
85
113
|
const { createInterface } = await import('node:readline');
|
|
86
114
|
const stream = fs.createReadStream(transcriptPath, { encoding: 'utf-8' });
|
|
@@ -91,8 +119,15 @@ async function countUserMessages(transcriptPath) {
|
|
|
91
119
|
if (!line)
|
|
92
120
|
continue;
|
|
93
121
|
try {
|
|
94
|
-
const
|
|
95
|
-
|
|
122
|
+
const obj = JSON.parse(line);
|
|
123
|
+
const t = obj.type;
|
|
124
|
+
// Claude schema
|
|
125
|
+
if (t === 'user' || t === 'queue-operation') {
|
|
126
|
+
count++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
// Codex schema (response_item with role=user)
|
|
130
|
+
if (t === 'response_item' && obj.payload?.role === 'user')
|
|
96
131
|
count++;
|
|
97
132
|
}
|
|
98
133
|
catch { /* skip malformed */ }
|
|
@@ -150,7 +185,7 @@ export async function spawnClaude(args, context, runtime = 'claude') {
|
|
|
150
185
|
// 세션 시작 전 timestamp + 기존 transcript 스냅샷 기록 (종료 후 finder 용).
|
|
151
186
|
// Audit fix #8 (2026-04-21): 스냅샷으로 동시 세션 transcript 오선택을 차단.
|
|
152
187
|
const sessionStartTime = Date.now();
|
|
153
|
-
const preSnapshot = snapshotExistingTranscripts(context.cwd);
|
|
188
|
+
const preSnapshot = snapshotExistingTranscripts(context.cwd, runtime);
|
|
154
189
|
return new Promise((resolve, reject) => {
|
|
155
190
|
const child = spawn(launcher, cleanArgs, {
|
|
156
191
|
stdio: 'inherit',
|
|
@@ -166,21 +201,35 @@ export async function spawnClaude(args, context, runtime = 'claude') {
|
|
|
166
201
|
}
|
|
167
202
|
});
|
|
168
203
|
child.on('exit', async (code) => {
|
|
169
|
-
if (runtime !== 'claude') {
|
|
204
|
+
if (runtime !== 'claude' && runtime !== 'codex') {
|
|
170
205
|
resolve(code ?? 0);
|
|
171
206
|
return;
|
|
172
207
|
}
|
|
173
|
-
// 세션 종료 후 하네스
|
|
208
|
+
// 세션 종료 후 하네스 작업.
|
|
209
|
+
// 0.4.6 — codex 도 transcript 인식 (~/.codex/sessions/YYYY/MM/DD/rollout-<sid>.jsonl).
|
|
210
|
+
// Codex transcript 의 schema 는 claude 와 다르므로 auto-compound 의 input parsing 은
|
|
211
|
+
// 별개 작업 (0.4.7). 현재 단계는 *transcript 위치 인식 + count* 만 codex 호환.
|
|
174
212
|
try {
|
|
175
|
-
const transcript = findSessionTranscript(context.cwd, sessionStartTime, preSnapshot);
|
|
213
|
+
const transcript = findSessionTranscript(context.cwd, sessionStartTime, preSnapshot, runtime);
|
|
176
214
|
if (!transcript) {
|
|
177
215
|
log.debug('이 세션에서 생성된 transcript를 찾을 수 없음 (snapshot diff)');
|
|
178
216
|
}
|
|
179
217
|
else {
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
218
|
+
// 0.4.6 — claude/codex 양 schema 호환 (countUserMessages + extractSummary).
|
|
219
|
+
// codex transcript 의 sessionId 는 파일명 패턴 'rollout-<ts>-<sid>.jsonl' 의 끝부분.
|
|
220
|
+
let sessionId;
|
|
221
|
+
if (runtime === 'codex') {
|
|
222
|
+
const m = path.basename(transcript, '.jsonl').match(/rollout-[\dT-]+-(.+)$/);
|
|
223
|
+
sessionId = m ? m[1] : path.basename(transcript, '.jsonl');
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
sessionId = path.basename(transcript, '.jsonl');
|
|
227
|
+
}
|
|
228
|
+
// 1. FTS5 인덱싱 (claude only — codex schema FTS 호환은 미검증, 별도 작업)
|
|
229
|
+
if (runtime === 'claude') {
|
|
230
|
+
await indexTranscriptToFTS(context.cwd, transcript, sessionId);
|
|
231
|
+
}
|
|
232
|
+
// 2. 자동 compound (10+ user 메시지인 경우만) — 양 runtime 호환
|
|
184
233
|
const userMsgCount = await countUserMessages(transcript);
|
|
185
234
|
if (userMsgCount >= 10) {
|
|
186
235
|
await runAutoCompound(context.cwd, transcript, sessionId);
|
|
@@ -198,17 +247,52 @@ export async function spawnClaude(args, context, runtime = 'claude') {
|
|
|
198
247
|
});
|
|
199
248
|
}
|
|
200
249
|
const RESUME_COOLDOWN_MS = 30_000;
|
|
201
|
-
const
|
|
250
|
+
const MAX_RESUMES_TOKEN = 3;
|
|
251
|
+
const MAX_RESUMES_RATE = 10; // ADR-008: rate-limit wait 은 5h 단위라 더 관대
|
|
252
|
+
const MAX_RESUMES = MAX_RESUMES_TOKEN; // backward compat — 기존 token-limit 호출자
|
|
253
|
+
const RATE_LIMIT_HARD_CAP_MS = 6 * 60 * 60 * 1000; // 6h max single wait
|
|
254
|
+
const COUNTDOWN_INTERVAL_MS = 30_000;
|
|
255
|
+
/**
|
|
256
|
+
* Exponential backoff for rate-limit when resetAt 파싱 실패.
|
|
257
|
+
* 1m → 5m → 15m → 30m → 1h → 2h cap. 합 ≤ 6h.
|
|
258
|
+
*/
|
|
259
|
+
export function rateLimitBackoffMs(attempt) {
|
|
260
|
+
const schedule = [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000, 2 * 60 * 60_000];
|
|
261
|
+
return schedule[Math.min(attempt, schedule.length - 1)];
|
|
262
|
+
}
|
|
202
263
|
/**
|
|
203
|
-
*
|
|
204
|
-
|
|
264
|
+
* Foreground countdown — 30s 마다 남은 시간 단일 라인 갱신, Ctrl+C 시 abort.
|
|
265
|
+
*/
|
|
266
|
+
async function countdownSleep(totalMs, label) {
|
|
267
|
+
const deadline = Date.now() + totalMs;
|
|
268
|
+
while (Date.now() < deadline) {
|
|
269
|
+
const remainMs = deadline - Date.now();
|
|
270
|
+
const remainMin = Math.floor(remainMs / 60_000);
|
|
271
|
+
const remainSec = Math.floor((remainMs % 60_000) / 1000);
|
|
272
|
+
const eta = remainMin >= 60
|
|
273
|
+
? `${Math.floor(remainMin / 60)}h ${remainMin % 60}m`
|
|
274
|
+
: `${remainMin}m ${remainSec}s`;
|
|
275
|
+
process.stdout.write(`\r[forgen] ${label} — ${eta} remaining (Ctrl+C to abort) `);
|
|
276
|
+
const tick = Math.min(COUNTDOWN_INTERVAL_MS, remainMs);
|
|
277
|
+
await new Promise(resolve => setTimeout(resolve, tick));
|
|
278
|
+
}
|
|
279
|
+
process.stdout.write('\n');
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* 토큰 한도 / API rate-limit 도달 시 자동 재시작을 지원하는 claude 실행 래퍼.
|
|
283
|
+
* context-guard가 pending-resume.json 마커를 생성하면 reason 별 정책으로 처리.
|
|
284
|
+
*
|
|
285
|
+
* - reason='token-limit': 30s 쿨다운, MAX_RESUMES_TOKEN=3
|
|
286
|
+
* - reason='rate-limit': resetAt 정밀 sleep (+60s 버퍼) 또는 exponential backoff,
|
|
287
|
+
* MAX_RESUMES_RATE=10, hard cap 6h
|
|
205
288
|
*/
|
|
206
289
|
export async function spawnClaudeWithResume(args, context, contextFactory, runtime = 'claude') {
|
|
207
|
-
let
|
|
290
|
+
let tokenResumeCount = 0;
|
|
291
|
+
let rateResumeCount = 0;
|
|
208
292
|
let currentContext = context;
|
|
209
293
|
while (true) {
|
|
210
294
|
const exitCode = await spawnClaude(args, currentContext, runtime);
|
|
211
|
-
if (runtime !== 'claude') {
|
|
295
|
+
if (runtime !== 'claude' && runtime !== 'codex') {
|
|
212
296
|
if (exitCode !== 0)
|
|
213
297
|
process.exit(exitCode);
|
|
214
298
|
break;
|
|
@@ -222,20 +306,56 @@ export async function spawnClaudeWithResume(args, context, contextFactory, runti
|
|
|
222
306
|
try {
|
|
223
307
|
const marker = JSON.parse(fs.readFileSync(resumePath, 'utf-8'));
|
|
224
308
|
fs.unlinkSync(resumePath);
|
|
225
|
-
if (marker.reason
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
309
|
+
if (marker.reason === 'token-limit') {
|
|
310
|
+
if (tokenResumeCount >= MAX_RESUMES_TOKEN) {
|
|
311
|
+
console.log(`[forgen] 최대 자동 재시작 횟수(${MAX_RESUMES_TOKEN}) 도달. 수동으로 다시 시작하세요.`);
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
tokenResumeCount++;
|
|
315
|
+
console.log(`[forgen] 토큰 한도 도달. ${RESUME_COOLDOWN_MS / 1000}초 후 자동 재시작합니다... (${tokenResumeCount}/${MAX_RESUMES_TOKEN})`);
|
|
316
|
+
await new Promise(resolve => setTimeout(resolve, RESUME_COOLDOWN_MS));
|
|
317
|
+
console.log('[forgen] 세션 재시작 중...');
|
|
318
|
+
currentContext = await contextFactory();
|
|
319
|
+
continue;
|
|
229
320
|
}
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
321
|
+
if (marker.reason === 'rate-limit') {
|
|
322
|
+
if (rateResumeCount >= MAX_RESUMES_RATE) {
|
|
323
|
+
console.log(`[forgen] rate-limit 자동 재시작 한도(${MAX_RESUMES_RATE}) 도달. 수동 재시작 필요.`);
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
rateResumeCount++;
|
|
327
|
+
let sleepMs;
|
|
328
|
+
let label;
|
|
329
|
+
if (marker.resetAt) {
|
|
330
|
+
const resetMs = Date.parse(marker.resetAt);
|
|
331
|
+
if (Number.isFinite(resetMs)) {
|
|
332
|
+
const target = resetMs - Date.now() + 60_000; // 60s 버퍼
|
|
333
|
+
sleepMs = Math.min(Math.max(target, 0), RATE_LIMIT_HARD_CAP_MS);
|
|
334
|
+
label = `Rate limit. Resuming after reset (${marker.resetAt})`;
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
sleepMs = rateLimitBackoffMs(rateResumeCount - 1);
|
|
338
|
+
label = `Rate limit. Backoff #${rateResumeCount}`;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
sleepMs = rateLimitBackoffMs(rateResumeCount - 1);
|
|
343
|
+
label = `Rate limit. Backoff #${rateResumeCount}`;
|
|
344
|
+
}
|
|
345
|
+
if (sleepMs >= RATE_LIMIT_HARD_CAP_MS) {
|
|
346
|
+
console.log(`[forgen] rate-limit reset 시각이 hard cap(6h) 초과 — 수동 재시작 필요. handoff 저장됨.`);
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
await countdownSleep(sleepMs, label);
|
|
350
|
+
console.log('[forgen] 세션 재시작 중...');
|
|
351
|
+
sendNotification('forgen — Rate limit 회복', `${runtime} 세션 재기동 중 (${rateResumeCount}/${MAX_RESUMES_RATE} resume)`);
|
|
352
|
+
currentContext = await contextFactory();
|
|
353
|
+
continue;
|
|
233
354
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
currentContext = await contextFactory();
|
|
355
|
+
// 알 수 없는 reason → exit (fail-open, 수동 처리)
|
|
356
|
+
if (exitCode !== 0)
|
|
357
|
+
process.exit(exitCode);
|
|
358
|
+
break;
|
|
239
359
|
}
|
|
240
360
|
catch {
|
|
241
361
|
if (exitCode !== 0)
|
|
@@ -244,3 +364,5 @@ export async function spawnClaudeWithResume(args, context, contextFactory, runti
|
|
|
244
364
|
}
|
|
245
365
|
}
|
|
246
366
|
}
|
|
367
|
+
// MAX_RESUMES re-export for backward-compat tests
|
|
368
|
+
export { MAX_RESUMES };
|
package/dist/core/state-gc.d.ts
CHANGED
|
@@ -17,6 +17,16 @@ export interface PruneOptions {
|
|
|
17
17
|
/** Current time for deterministic tests. Defaults to Date.now(). */
|
|
18
18
|
now?: number;
|
|
19
19
|
}
|
|
20
|
+
export interface RotateReport {
|
|
21
|
+
scanned: number;
|
|
22
|
+
rotated: number;
|
|
23
|
+
bytesFreed: number;
|
|
24
|
+
sample: string[];
|
|
25
|
+
}
|
|
26
|
+
export declare function rotateAppendOnlyLogs(opts?: {
|
|
27
|
+
stateDir?: string;
|
|
28
|
+
maxBytes?: number;
|
|
29
|
+
}): RotateReport;
|
|
20
30
|
/**
|
|
21
31
|
* Prune session-scoped files older than `retentionMs` from the state and
|
|
22
32
|
* outcomes directories. Defaults to a dry-run so callers must opt-in to
|
package/dist/core/state-gc.js
CHANGED
|
@@ -78,6 +78,77 @@ function pruneDir(dir, cutoff, dryRun, filter) {
|
|
|
78
78
|
}
|
|
79
79
|
return out;
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* 0.4.6 #14 — append-only jsonl 로그 회전.
|
|
83
|
+
*
|
|
84
|
+
* state-gc 의 SESSION_SCOPED_PREFIXES 는 session 별 파일을 prefix-base 로 잡지만,
|
|
85
|
+
* 단일 aggregate jsonl (hook-timing, prompt-history, usage-telemetry 등) 은 매번
|
|
86
|
+
* append 되어 무한 grow. 본 함수가 size cap (default 10MB) 초과 시 `<name>.1` 로
|
|
87
|
+
* rotate 하고 `<name>.2` 는 삭제 (한 단계만 보존).
|
|
88
|
+
*
|
|
89
|
+
* 회전 정책:
|
|
90
|
+
* - cap 미만: no-op
|
|
91
|
+
* - cap 초과: <name>.2 삭제 → <name>.1 → <name>.2, <name> → <name>.1, 새 빈 <name>
|
|
92
|
+
* - 0.4.6 신설 jsonl 들 (prompt-history, usage-telemetry, rate-limit-misses) 포함
|
|
93
|
+
*
|
|
94
|
+
* fail-open: 모든 I/O 실패는 silent — 호출 측 차단 안 함.
|
|
95
|
+
*/
|
|
96
|
+
const ROTATABLE_LOGS = [
|
|
97
|
+
'hook-errors.jsonl',
|
|
98
|
+
'hook-timing.jsonl',
|
|
99
|
+
'implicit-feedback.jsonl',
|
|
100
|
+
'match-eval-log.jsonl',
|
|
101
|
+
'solution-quarantine.jsonl',
|
|
102
|
+
'prompt-history.jsonl',
|
|
103
|
+
'usage-telemetry.jsonl',
|
|
104
|
+
'rate-limit-misses.jsonl',
|
|
105
|
+
];
|
|
106
|
+
const DEFAULT_MAX_LOG_BYTES = 10 * 1024 * 1024; // 10MB
|
|
107
|
+
export function rotateAppendOnlyLogs(opts = {}) {
|
|
108
|
+
const stateDir = opts.stateDir ?? STATE_DIR;
|
|
109
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_LOG_BYTES;
|
|
110
|
+
const out = { scanned: 0, rotated: 0, bytesFreed: 0, sample: [] };
|
|
111
|
+
if (!fs.existsSync(stateDir))
|
|
112
|
+
return out;
|
|
113
|
+
for (const name of ROTATABLE_LOGS) {
|
|
114
|
+
const full = path.join(stateDir, name);
|
|
115
|
+
let stat;
|
|
116
|
+
try {
|
|
117
|
+
stat = fs.statSync(full);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
out.scanned++;
|
|
123
|
+
if (stat.size <= maxBytes)
|
|
124
|
+
continue;
|
|
125
|
+
try {
|
|
126
|
+
const r1 = `${full}.1`;
|
|
127
|
+
const r2 = `${full}.2`;
|
|
128
|
+
// .2 삭제
|
|
129
|
+
try {
|
|
130
|
+
fs.unlinkSync(r2);
|
|
131
|
+
out.bytesFreed += fs.existsSync(r2) ? 0 : (fs.statSync(r2).size ?? 0);
|
|
132
|
+
}
|
|
133
|
+
catch { /* no .2 */ }
|
|
134
|
+
// .1 → .2
|
|
135
|
+
try {
|
|
136
|
+
if (fs.existsSync(r1))
|
|
137
|
+
fs.renameSync(r1, r2);
|
|
138
|
+
}
|
|
139
|
+
catch { /* skip */ }
|
|
140
|
+
// active → .1
|
|
141
|
+
fs.renameSync(full, r1);
|
|
142
|
+
// 새 빈 파일
|
|
143
|
+
fs.writeFileSync(full, '');
|
|
144
|
+
out.rotated++;
|
|
145
|
+
if (out.sample.length < 20)
|
|
146
|
+
out.sample.push(name);
|
|
147
|
+
}
|
|
148
|
+
catch { /* fail-open per file */ }
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
81
152
|
/**
|
|
82
153
|
* Prune session-scoped files older than `retentionMs` from the state and
|
|
83
154
|
* outcomes directories. Defaults to a dry-run so callers must opt-in to
|
|
@@ -15,6 +15,13 @@ import * as path from 'node:path';
|
|
|
15
15
|
import * as os from 'node:os';
|
|
16
16
|
import { execSync } from 'node:child_process';
|
|
17
17
|
import { loadActiveRules } from '../store/rule-store.js';
|
|
18
|
+
import { getUsageStats } from './usage-telemetry.js';
|
|
19
|
+
import { STATE_DIR } from './paths.js';
|
|
20
|
+
// 0.4.6 perf #13 — statusline 출력을 5초 캐싱.
|
|
21
|
+
// claude statusLine 은 짧은 간격으로 재호출되는데 매번 git/find/rule-store 를
|
|
22
|
+
// 실행하면 ~100ms 누적. CACHE_TTL_MS 동안 동일 출력 재사용.
|
|
23
|
+
const STATUSLINE_CACHE_PATH = path.join(STATE_DIR, 'statusline-cache.txt');
|
|
24
|
+
const CACHE_TTL_MS = 5_000;
|
|
18
25
|
// ANSI codes
|
|
19
26
|
const DIM = '\x1b[2m';
|
|
20
27
|
const CYAN = '\x1b[36m';
|
|
@@ -116,6 +123,23 @@ function buildLine1(payload, cwd) {
|
|
|
116
123
|
parts.push(`${GREEN}${gitBranch}${RESET}`);
|
|
117
124
|
return parts.join(` ${DIM}|${RESET} `);
|
|
118
125
|
}
|
|
126
|
+
/** Build usage line: "📊 87/5h · 412/wk (claude)" — 0.4.6 신설 */
|
|
127
|
+
function buildUsageLine() {
|
|
128
|
+
try {
|
|
129
|
+
const stats = getUsageStats();
|
|
130
|
+
if (stats.week.total === 0)
|
|
131
|
+
return null;
|
|
132
|
+
const dominant = stats.week.codex > stats.week.claude ? 'codex' : 'claude';
|
|
133
|
+
return [
|
|
134
|
+
`${YELLOW}📊 ${stats.hour5.total}/5h${RESET}`,
|
|
135
|
+
`${YELLOW}${stats.week.total}/wk${RESET}`,
|
|
136
|
+
`${DIM}(${dominant})${RESET}`,
|
|
137
|
+
].join(` ${DIM}·${RESET} `);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
119
143
|
function buildLine3(claudeDir, cwd) {
|
|
120
144
|
const settings = getSettingsJson(claudeDir);
|
|
121
145
|
const claudeMdCount = countClaudeMd(cwd);
|
|
@@ -136,15 +160,46 @@ function buildLine3(claudeDir, cwd) {
|
|
|
136
160
|
`${YELLOW}${hookCount} hooks${RESET}`,
|
|
137
161
|
].join(` ${DIM}|${RESET} `);
|
|
138
162
|
}
|
|
163
|
+
/** 0.4.6 perf #13: cached output if fresh. */
|
|
164
|
+
function readCacheIfFresh() {
|
|
165
|
+
try {
|
|
166
|
+
const stat = fs.statSync(STATUSLINE_CACHE_PATH);
|
|
167
|
+
if (Date.now() - stat.mtimeMs < CACHE_TTL_MS) {
|
|
168
|
+
return fs.readFileSync(STATUSLINE_CACHE_PATH, 'utf-8');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch { /* no cache or stale */ }
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
function writeCache(content) {
|
|
175
|
+
try {
|
|
176
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
177
|
+
fs.writeFileSync(STATUSLINE_CACHE_PATH, content);
|
|
178
|
+
}
|
|
179
|
+
catch { /* fail-open */ }
|
|
180
|
+
}
|
|
139
181
|
export async function handleStatusline() {
|
|
182
|
+
// 캐시 hit 시 stdin payload 무시하고 바로 출력 (5초 윈도우 내 동일 출력 가정).
|
|
183
|
+
// 라인 단위 cache → console.log 라인별 (테스트 호환).
|
|
184
|
+
const cached = readCacheIfFresh();
|
|
185
|
+
if (cached !== null) {
|
|
186
|
+
for (const line of cached.split('\n').filter(Boolean))
|
|
187
|
+
console.log(line);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
140
190
|
const payload = readStdinJson();
|
|
141
191
|
const cwd = payload.workspace?.current_dir ?? process.cwd();
|
|
142
192
|
const claudeDir = path.join(os.homedir(), '.claude');
|
|
143
193
|
const line1 = buildLine1(payload, cwd);
|
|
144
194
|
const line3 = buildLine3(claudeDir, cwd);
|
|
195
|
+
const usageLine = buildUsageLine();
|
|
145
196
|
// Line 2 (context/usage): stdin JSON spec 미확인으로 생략 — TODO
|
|
146
197
|
// Line 4 (tool counts): 추적 인프라 없음 — TODO
|
|
147
198
|
// Line 5 (active task): 추적 인프라 없음 — TODO
|
|
148
199
|
console.log(line1);
|
|
149
200
|
console.log(line3);
|
|
201
|
+
if (usageLine)
|
|
202
|
+
console.log(usageLine);
|
|
203
|
+
const cacheBody = usageLine ? `${line1}\n${line3}\n${usageLine}\n` : `${line1}\n${line3}\n`;
|
|
204
|
+
writeCache(cacheBody);
|
|
150
205
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen Usage Telemetry — ADR-008 §"테마 A unattended resilience"
|
|
3
|
+
*
|
|
4
|
+
* 5h / weekly window 의 tool call 수를 sliding window 로 추적하여 statusline
|
|
5
|
+
* (`forgen me`) 에 노출. rate-limit hit 전에 사용자가 사용량을 가시화할 수 있도록.
|
|
6
|
+
*
|
|
7
|
+
* 정책:
|
|
8
|
+
* - 각 PostToolUse 마다 append-only JSONL 에 timestamp 한 줄 기록
|
|
9
|
+
* - read 시 sliding window 로 필터 + 카운트 (스트리밍 — 메모리 절약)
|
|
10
|
+
* - 10K 엔트리 누적 시 weekly cap 밖 엔트리 prune (rewrite once)
|
|
11
|
+
* - fail-open: 모든 I/O 실패는 로그 후 무시, 호출 측 차단 안 함
|
|
12
|
+
*
|
|
13
|
+
* 0.4.6 신설. limit prediction 은 의도적으로 제외 — Anthropic 의 실제 limit 가
|
|
14
|
+
* 계정/플랜별 가변이라 hard-code 부정확. raw count 만 노출하고 사용자가 판단.
|
|
15
|
+
*/
|
|
16
|
+
export interface UsageStats {
|
|
17
|
+
hour5: {
|
|
18
|
+
claude: number;
|
|
19
|
+
codex: number;
|
|
20
|
+
total: number;
|
|
21
|
+
};
|
|
22
|
+
week: {
|
|
23
|
+
claude: number;
|
|
24
|
+
codex: number;
|
|
25
|
+
total: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export declare function recordToolCall(runtime?: 'claude' | 'codex'): void;
|
|
29
|
+
/**
|
|
30
|
+
* sliding window count. fail-open: 파일 미존재/parse 실패는 0 반환.
|
|
31
|
+
*
|
|
32
|
+
* @param now epoch ms (테스트 결정성)
|
|
33
|
+
*/
|
|
34
|
+
export declare function getUsageStats(now?: number): UsageStats;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen Usage Telemetry — ADR-008 §"테마 A unattended resilience"
|
|
3
|
+
*
|
|
4
|
+
* 5h / weekly window 의 tool call 수를 sliding window 로 추적하여 statusline
|
|
5
|
+
* (`forgen me`) 에 노출. rate-limit hit 전에 사용자가 사용량을 가시화할 수 있도록.
|
|
6
|
+
*
|
|
7
|
+
* 정책:
|
|
8
|
+
* - 각 PostToolUse 마다 append-only JSONL 에 timestamp 한 줄 기록
|
|
9
|
+
* - read 시 sliding window 로 필터 + 카운트 (스트리밍 — 메모리 절약)
|
|
10
|
+
* - 10K 엔트리 누적 시 weekly cap 밖 엔트리 prune (rewrite once)
|
|
11
|
+
* - fail-open: 모든 I/O 실패는 로그 후 무시, 호출 측 차단 안 함
|
|
12
|
+
*
|
|
13
|
+
* 0.4.6 신설. limit prediction 은 의도적으로 제외 — Anthropic 의 실제 limit 가
|
|
14
|
+
* 계정/플랜별 가변이라 hard-code 부정확. raw count 만 노출하고 사용자가 판단.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
import { STATE_DIR } from './paths.js';
|
|
19
|
+
import { createLogger } from './logger.js';
|
|
20
|
+
const log = createLogger('usage-telemetry');
|
|
21
|
+
const TELEMETRY_PATH = path.join(STATE_DIR, 'usage-telemetry.jsonl');
|
|
22
|
+
const HOUR5_MS = 5 * 60 * 60 * 1000;
|
|
23
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
24
|
+
const PRUNE_THRESHOLD = 10_000; // 10K lines 누적 시 prune
|
|
25
|
+
export function recordToolCall(runtime = 'claude') {
|
|
26
|
+
try {
|
|
27
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
28
|
+
const entry = { ts: Date.now(), rt: runtime };
|
|
29
|
+
fs.appendFileSync(TELEMETRY_PATH, `${JSON.stringify(entry)}\n`);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
log.debug('telemetry append 실패', e);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* sliding window count. fail-open: 파일 미존재/parse 실패는 0 반환.
|
|
37
|
+
*
|
|
38
|
+
* @param now epoch ms (테스트 결정성)
|
|
39
|
+
*/
|
|
40
|
+
export function getUsageStats(now = Date.now()) {
|
|
41
|
+
const stats = {
|
|
42
|
+
hour5: { claude: 0, codex: 0, total: 0 },
|
|
43
|
+
week: { claude: 0, codex: 0, total: 0 },
|
|
44
|
+
};
|
|
45
|
+
try {
|
|
46
|
+
if (!fs.existsSync(TELEMETRY_PATH))
|
|
47
|
+
return stats;
|
|
48
|
+
const cutoff5h = now - HOUR5_MS;
|
|
49
|
+
const cutoffWeek = now - WEEK_MS;
|
|
50
|
+
const raw = fs.readFileSync(TELEMETRY_PATH, 'utf-8');
|
|
51
|
+
const lines = raw.split('\n');
|
|
52
|
+
let total = 0;
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
if (!line)
|
|
55
|
+
continue;
|
|
56
|
+
total++;
|
|
57
|
+
try {
|
|
58
|
+
const e = JSON.parse(line);
|
|
59
|
+
if (typeof e.ts !== 'number' || e.ts < cutoffWeek)
|
|
60
|
+
continue;
|
|
61
|
+
const rt = e.rt === 'codex' ? 'codex' : 'claude';
|
|
62
|
+
stats.week[rt]++;
|
|
63
|
+
stats.week.total++;
|
|
64
|
+
if (e.ts >= cutoff5h) {
|
|
65
|
+
stats.hour5[rt]++;
|
|
66
|
+
stats.hour5.total++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch { /* skip malformed line */ }
|
|
70
|
+
}
|
|
71
|
+
if (total > PRUNE_THRESHOLD)
|
|
72
|
+
pruneOldEntries(now);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
log.debug('telemetry read 실패', e);
|
|
76
|
+
}
|
|
77
|
+
return stats;
|
|
78
|
+
}
|
|
79
|
+
/** 누적 엔트리가 PRUNE_THRESHOLD 초과 시 weekly cap 밖 entry 제거 (rewrite). */
|
|
80
|
+
function pruneOldEntries(now) {
|
|
81
|
+
try {
|
|
82
|
+
const cutoff = now - WEEK_MS;
|
|
83
|
+
const raw = fs.readFileSync(TELEMETRY_PATH, 'utf-8');
|
|
84
|
+
const kept = [];
|
|
85
|
+
for (const line of raw.split('\n')) {
|
|
86
|
+
if (!line)
|
|
87
|
+
continue;
|
|
88
|
+
try {
|
|
89
|
+
const e = JSON.parse(line);
|
|
90
|
+
if (typeof e.ts === 'number' && e.ts >= cutoff)
|
|
91
|
+
kept.push(line);
|
|
92
|
+
}
|
|
93
|
+
catch { /* drop malformed */ }
|
|
94
|
+
}
|
|
95
|
+
fs.writeFileSync(TELEMETRY_PATH, kept.join('\n') + (kept.length ? '\n' : ''));
|
|
96
|
+
log.debug(`telemetry pruned to ${kept.length} entries`);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
log.debug('telemetry prune 실패', e);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -9,6 +9,19 @@
|
|
|
9
9
|
* 또한 UserPromptSubmit에서 현재 대화 길이를 추적하여
|
|
10
10
|
* context 한계에 접근 시 preemptive 경고를 제공합니다.
|
|
11
11
|
*/
|
|
12
|
+
export declare const TOKEN_LIMIT_REGEX: RegExp;
|
|
13
|
+
export declare const RATE_LIMIT_REGEX: RegExp;
|
|
14
|
+
/**
|
|
15
|
+
* Best-effort reset 시각 파서 (ADR-008 §2).
|
|
16
|
+
*
|
|
17
|
+
* 5 패턴 시도, 모두 실패 시 null. 실제 메시지 포맷은 Claude/Codex CLI 의
|
|
18
|
+
* 공식 contract 가 아니므로 hotfix 가능한 best-effort.
|
|
19
|
+
*
|
|
20
|
+
* @param msg stderr/error message text
|
|
21
|
+
* @param now epoch ms (테스트 결정성 위해 주입 가능)
|
|
22
|
+
* @returns ISO timestamp 또는 null
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseRateLimitResetAt(msg: string, now?: number): string | null;
|
|
12
25
|
/** 경고 표시 여부 판정 (순수 함수) */
|
|
13
26
|
export declare function shouldWarn(contextPercent: {
|
|
14
27
|
promptCount: number;
|