therookie 0.4.30 → 0.4.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "therookie",
3
- "version": "0.4.30",
3
+ "version": "0.4.32",
4
4
  "description": "the Rookie — the AI new hire that remembers what you teach it. A CLI that connects a personal AI memory to AI tools like Claude and Codex.",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/src/cli.js CHANGED
@@ -111,6 +111,9 @@ const VALUE_FLAGS = new Set([
111
111
  'projectId',
112
112
  'repo-path',
113
113
  'repo-url',
114
+ // spec 2026-09-08 §2.4 — `rookie save --session <id>`. 화이트리스트에 없으면 값이 안 붙어
115
+ // flags.session === true 가 되고 저장 마커가 조용히 안 남는다(실측으로 잡힌 버그).
116
+ 'session',
114
117
  ])
115
118
 
116
119
  export function parseFlags(args) {
@@ -460,28 +460,44 @@ function printOrphanPending() {
460
460
  // 서버 전송 없음. 파일이 없으면(아직 안 돌았거나 구버전) 침묵한다.
461
461
  export function summarizeRecallPush(lines, max = 1000) {
462
462
  const rows = []
463
+ // spec 2026-09-08 훅 타임아웃 유실 — 시작 줄(phase:'start')과 완료 줄을 turn 으로 짝지어,
464
+ // 짝 없는 시작이 곧 "훅이 제한 시간을 넘겨 통째로 죽은 턴"이다. 유실은 무주입과 다르다 —
465
+ // 무주입은 넣을 게 없던 것이고 유실은 넣으려다 못 넣은 것이라, 무주입률에 섞지 않는다.
466
+ const startTurns = new Set()
467
+ const endTurns = new Set()
463
468
  // 파일은 append 전용이라 최신이 뒤에 온다 — 뒤에서부터 max 줄만 집계한다.
464
469
  for (let i = lines.length - 1; i >= 0 && rows.length < max; i--) {
465
470
  try {
466
471
  const o = JSON.parse(lines[i])
467
- if (o && typeof o.injected === 'number') rows.push(o)
472
+ if (!o) continue
473
+ if (o.phase === 'start') {
474
+ if (o.turn) startTurns.add(o.turn)
475
+ continue
476
+ }
477
+ if (o.turn) endTurns.add(o.turn)
478
+ // 구버전 줄에는 phase 가 없다 — 완료 줄로 읽는다(하위호환).
479
+ if (typeof o.injected === 'number') rows.push(o)
468
480
  } catch {
469
481
  /* 깨진 줄은 건너뛴다 */
470
482
  }
471
483
  }
472
- if (rows.length === 0) return null
484
+ let lost = 0
485
+ for (const t of startTurns) if (!endTurns.has(t)) lost++
486
+ if (rows.length === 0 && lost === 0) return null
473
487
  const silent = rows.filter((r) => r.injected === 0).length
474
488
  // 중앙값은 chars 기준이다 — 컨텍스트 예산은 문자·토큰 감각이고, 한국어는 바이트가 3배라
475
489
  // 바이트로 재면 합격 기준(§4.1)이 왜곡된다. bytes 는 파일에만 남긴다.
476
490
  const chars = rows.map((r) => (typeof r.chars === 'number' ? r.chars : 0)).sort((a, b) => a - b)
477
- const median = chars[Math.floor(chars.length / 2)]
491
+ // 완료 줄이 하나도 없는 창(시작 줄만 남은 경우)에서 undefined 가 새어 나가지 않게 0 으로 닫는다.
492
+ const median = chars.length > 0 ? chars[Math.floor(chars.length / 2)] : 0
478
493
  const epochs = new Set(rows.map((r) => String(r.epoch ?? '')))
479
494
  return {
480
495
  turns: rows.length,
481
496
  silent,
482
- silentRatio: silent / rows.length,
497
+ silentRatio: rows.length > 0 ? silent / rows.length : 0,
483
498
  medianChars: median,
484
499
  epochs: epochs.size,
500
+ lost,
485
501
  }
486
502
  }
487
503
 
@@ -502,9 +518,17 @@ function printRecallPush() {
502
518
  }
503
519
  if (!s) return
504
520
  const pct = Math.round(s.silentRatio * 100)
505
- console.log(
506
- `— 회상 푸시: 최근 ${s.turns}턴 중 무주입 ${s.silent}건(${pct}%), 주입 길이 중앙값 ${s.medianChars}자, epoch ${s.epochs}개`
507
- )
521
+ if (s.turns > 0) {
522
+ console.log(
523
+ `— 회상 푸시: 최근 ${s.turns}턴 중 무주입 ${s.silent}건(${pct}%), 주입 길이 중앙값 ${s.medianChars}자, epoch ${s.epochs}개`
524
+ )
525
+ }
526
+ // 유실 0 이면 침묵 — 정상 상태를 매번 알리지 않는다.
527
+ if (s.lost > 0) {
528
+ console.log(
529
+ `⚠️ 훅 유실: 회상 ${s.lost}건 — 훅이 제한 시간을 넘겨 주입이 버려졌습니다. 부하가 높았는지 확인하세요.`
530
+ )
531
+ }
508
532
  }
509
533
 
510
534
  // B6-3 (spec 2026-07-11 §B6): 세션 identity pinning 위반 가시화 — 세션 도중 계정 전환으로
@@ -55,15 +55,14 @@ process.stdin.on("data",c=>d+=c).on("end",async()=>{
55
55
  // 병렬 세션 안전: 쓰기는 append 전용(O_APPEND, 한 줄은 원자적)이고, 크기 초과 시에만
56
56
  // rename 으로 로테이트한다. 파일을 읽어 다시 쓰는 방식은 두 세션이 같은 스냅샷을 보고
57
57
  // 서로의 줄을 덮어쓸 수 있어 쓰지 않는다(codex 코드검토 R1 P1).
58
- const metric=(layer,candidates,injected,text)=>{
58
+ // spec 2026-09-08 훅 타임아웃 유실 — 시작 줄과 완료 줄을 turn 으로 짝지어, 짝 없는 시작이
59
+ // 곧 "프로세스가 통째로 죽은 턴"이 된다. 시작 줄은 node 진입 직후에만 쓴다(집계 범위 = node 단계).
60
+ const turn=crypto.randomBytes(4).toString("hex");
61
+ const writeLine=(obj)=>{
59
62
  try{
60
63
  const dir=process.env.ROOKIE_PUSH_DIR;
61
64
  const f=path.join(dir,"metrics.jsonl");
62
- const s=String(text||"");
63
- const line=JSON.stringify({
64
- ts:new Date().toISOString(),epoch,layer,candidates,injected,
65
- chars:s.length,bytes:Buffer.byteLength(s)
66
- })+"\n";
65
+ const line=JSON.stringify({ts:new Date().toISOString(),epoch,layer:"B",turn,...obj})+"\n";
67
66
  fs.mkdirSync(dir,{recursive:true,mode:0o700});
68
67
  try{
69
68
  if(fs.statSync(f).size>METRIC_MAX_BYTES) fs.renameSync(f,f+".1");
@@ -71,11 +70,18 @@ process.stdin.on("data",c=>d+=c).on("end",async()=>{
71
70
  fs.appendFileSync(f,line,{mode:0o600});
72
71
  }catch{}
73
72
  };
73
+ const metricStart=()=>writeLine({phase:"start"});
74
+ // 종료 경로마다 한 번. reason 은 코드의 return·catch 와 1:1 (설계 §3.4).
75
+ const metricEnd=(reason,candidates,injected,text)=>{
76
+ const t=String(text||"");
77
+ writeLine({phase:"end",reason,candidates,injected,chars:t.length,bytes:Buffer.byteLength(t)});
78
+ };
74
79
  try{
80
+ metricStart();
75
81
  const p=JSON.parse(d);
76
82
  const sid=String(p.session_id||p.sessionId||"");
77
83
  let prompt=String(p.prompt||"").trim();
78
- if(!prompt) return; // 빈 발화 = 질의 불성립(유일한 기계 조건)
84
+ if(!prompt){ metricEnd("empty_prompt",0,0,""); return; } // 빈 발화 = 질의 불성립
79
85
  prompt=prompt.slice(0,500); // RecallQuerySchema q max(500) — 초과는 400 무주입
80
86
  const K=parseInt(process.env.ROOKIE_RECALL_PUSH_TOPK||"3",10);
81
87
  const CAP=parseInt(process.env.ROOKIE_RECALL_PUSH_CAP||"600",10);
@@ -101,7 +107,7 @@ process.stdin.on("data",c=>d+=c).on("end",async()=>{
101
107
  headers:{Authorization:"Bearer "+process.env.ROOKIE_TK,"x-rookie-hostname":process.env.ROOKIE_HOST||""},
102
108
  signal:AbortSignal.timeout(TIMEOUT)
103
109
  });
104
- if(!res.ok) return;
110
+ if(!res.ok){ metricEnd("http_error",0,0,""); return; }
105
111
  const body=await res.json();
106
112
  const results=Array.isArray(body&&body.results)?body.results:[];
107
113
  // 임계 없음 — 걸러내는 것은 형식 불량(id·요약 없음, 점수 비수치)과 기주입뿐이다.
@@ -109,7 +115,7 @@ process.stdin.on("data",c=>d+=c).on("end",async()=>{
109
115
  .filter(r=>r&&typeof r.relevance==="number"&&r.id&&typeof r.summary==="string"&&r.summary.trim()&&!seen.has(r.id))
110
116
  .sort((a,b)=>b.relevance-a.relevance)
111
117
  .slice(0,K);
112
- if(!cands.length){ metric("B",results.length,0,""); return; }
118
+ if(!cands.length){ metricEnd("no_hit",results.length,0,""); return; }
113
119
 
114
120
  // 주입 문구는 지시가 아니라 자료다. 관련도를 보증하지 않으므로 라벨에 명시하고,
115
121
  // 날짜·출처를 앞에 놓아 ITEM 절삭에 잘려 나가지 않게 한다.
@@ -123,16 +129,20 @@ process.stdin.on("data",c=>d+=c).on("end",async()=>{
123
129
  if(msg.length+item.length>CAP) break; // 총량 상한 — relevance 순 절삭
124
130
  msg+=item; used.push(r.id);
125
131
  }
126
- if(!used.length){ metric("B",results.length,0,""); return; }
132
+ if(!used.length){ metricEnd("no_hit",results.length,0,""); return; }
127
133
  if(stFile){
128
134
  try{
129
135
  fs.mkdirSync(process.env.ROOKIE_PUSH_DIR,{recursive:true,mode:0o700});
130
136
  fs.writeFileSync(stFile,JSON.stringify({epoch,ids:st.ids.concat(used)}),{mode:0o600});
131
137
  }catch{} // dedup 기록 실패 → 다음 턴 중복 주입 위험만, 주입은 진행
132
138
  }
133
- metric("B",results.length,used.length,msg);
139
+ metricEnd("ok",results.length,used.length,msg);
134
140
  process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:"UserPromptSubmit",additionalContext:msg}}));
135
- }catch{ /* fail-soft — 조용히 무주입 */ }
141
+ }catch{
142
+ // fetch 타임아웃·네트워크·JSON 파싱 실패 전부. 세분하지 않는다(설계 §3.4) — 이 계측의 목적은
143
+ // 타임아웃 유실을 세는 것이지 실패 원인을 분류하는 것이 아니다.
144
+ try{ metricEnd("error",0,0,""); }catch{}
145
+ }
136
146
  });
137
147
  ' 2>/dev/null
138
148
 
package/version.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "cli_version": "0.4.30",
2
+ "cli_version": "0.4.32",
3
3
  "skill_version": "1.40.0"
4
4
  }