therookie 0.4.13 → 0.4.15

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.13",
3
+ "version": "0.4.15",
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": [
@@ -72,6 +72,33 @@ try {
72
72
  const ENVQ=/(포트|유저명|계정|경로|도메인|호스트|엔드포인트|서버\s*주소)[^\n]{0,12}(뭐|몇|어디|알아|기억|였)/;
73
73
  const isSignal=(t)=>PAST_TIME.test(t)||PAST_HOW.test(t)||REPRO.test(t)||CONTINUE.test(t)||DECIDED.test(t)||ENVQ.test(t)||AMNESIA.test(t);
74
74
 
75
+ // ── v2 §D2: 교정 발화 (계측 라벨 전용) ────────────────────────────────────
76
+ // 두목이 "이미 아는 걸 왜 모르냐"고 짚는 발화. **강제 경로(user-prompt-skill-gate.sh)에는 넣지
77
+ // 않는다** — 넣는 순간 spec 2026-07-25 §7 이 금지한 "9번째 새 트리거 추가"가 된다.
78
+ // 집합은 오늘 사고 발화 + 오탐 후보로 실측해 확정(교정 9/10 hit, 일반 8/8 미탐지).
79
+ const CORRECTION=new RegExp([
80
+ "(저장|기록)(해두지|해놓지|한\\s*거|해\\s*둔\\s*거)?\\s*(않았|안\\s*했|안했)",
81
+ "기억(하고|해두고|해\\s*두고)\\s*있(잖|었잖)",
82
+ "(툴|도구)(에|로|에다)\\s*(저장|기록)",
83
+ "회상(해|해봐|해도|부터|은?\\s*했)",
84
+ "찾아(봤|보지|봐야)\\s*(않았|았어야|어야)",
85
+ "더루키(에서|에|를)[^\\n]{0,20}(찾|봐|확인|안\\s*찾|찾지)",
86
+ "(전에|이미|지난번에?)\\s*[^\\n]{0,10}?(했|됐|만들었|성공했)(잖|었잖)"
87
+ ].join("|"));
88
+
89
+ // ── v2 §D1: human 프롬프트 판정 ──────────────────────────────────────────
90
+ // 1단 = transcript 의 isMeta 필드(Skill 로드 결과·이미지 첨부·local-command-caveat 를 정확히 거른다).
91
+ // 2단 = isMeta 가 안 붙는 나머지를 문자열로 배제. 판정 실패는 **포함 방향**(fail-open) —
92
+ // human 을 놓치는 것이 분모 결함의 재발이라 그쪽이 더 비싸다.
93
+ const SYS_PREFIX=/^\s*<(command-name|task-notification|system-reminder|local-command|command-message)/;
94
+ const isHumanPrompt=(j,txt)=>{
95
+ if(j && j.isMeta===true) return false;
96
+ if(SYS_PREFIX.test(txt)) return false;
97
+ if(/^\s*\[Request interrupted by user/.test(txt)) return false;
98
+ if(/^\s*\/\S+$/.test(txt)) return false; // 붙여넣은 파일경로 1줄
99
+ return true;
100
+ };
101
+
75
102
  // ── compact/clear boundary offsets (이 transcript 것만, 정렬) ──────────────
76
103
  let boundaries=[];
77
104
  try {
@@ -138,22 +165,41 @@ try {
138
165
  if(role==="user"){
139
166
  const txt=userText(m);
140
167
  if(!txt) continue; // tool_result-only user 라인 등은 프롬프트 아님
141
- const sig=isSignal(txt), amn=AMNESIA.test(txt);
142
- // 직전 회상 episode 의 "다음 프롬프트"가 기억무시 지적이면 표시(관련 기억 있었는데 되물음).
143
- if(cur && amn && cur.recall_called) cur.amnesia_next_prompt=true;
144
- if(sig){
145
- cur={
146
- episode_seq: seq++,
147
- episode_start_byte_offset: lineOff,
148
- recall_signal: true, recall_called: false, verified_solution_served: false,
149
- external_search: false, amnesia_next_prompt: false,
150
- first_action_after_recall: null, failure_classes: [],
151
- _boundary: firstBoundaryAfter(lineOff),
152
- _recallSeen: false,
153
- };
154
- episodes.push(cur);
168
+ // v2 §D1 — human 프롬프트만 episode 를 연다. 시스템 주입(Skill 로드·이미지·훅 알림)은 제외.
169
+ if(!isHumanPrompt(j,txt)) continue;
170
+ const sig=isSignal(txt), amn=AMNESIA.test(txt), corr=CORRECTION.test(txt);
171
+
172
+ // v2 §D3 — 교정 발화 귀속. recall_called 전제를 제거하고(회상 안 한 쪽이 더 나쁜 신호),
173
+ // filler episode(도구 0건)는 건너뛰어 실제 작업 episode 에 붙인다. compact 경계는 안 넘는다.
174
+ if(amn||corr){
175
+ for(let k=episodes.length-1;k>=0;k--){
176
+ const e=episodes[k];
177
+ if(lineOff>=e._boundary) break; // 이 교정은 e 의 compact 구간 밖 — 더 거슬러 갈 수 없다
178
+ if(!e._sawTool) continue; // filler — 책임을 붙이지 않는다
179
+ if(amn) e.amnesia_next_prompt=true;
180
+ if(corr) e.correction_next_prompt=true;
181
+ break;
182
+ }
155
183
  }
156
- // 회상 신호 아닌 user 프롬프트는 현재 episode 를 이어간다(별 처리 없음, spec §3.2).
184
+
185
+ cur={
186
+ episode_seq: seq++,
187
+ episode_start_byte_offset: lineOff,
188
+ parser_version: 3,
189
+ is_human_prompt: true,
190
+ recall_signal: sig, recall_called: false, verified_solution_served: false,
191
+ external_search: false, amnesia_next_prompt: false, correction_next_prompt: false,
192
+ // spec 2026-08-06 A.5b — external_search 는 이제 **웹 도구만** 센다.
193
+ // external_search_after_recall: 회상 이후의 외부검색만(순서 있는 신호).
194
+ // bash_http_seen: 느슨한 Bash HTTP 관측 — 진단 전용, headline 금지.
195
+ external_search_after_recall: false, bash_http_seen: false,
196
+ first_action_after_recall: null, failure_classes: [],
197
+ _boundary: firstBoundaryAfter(lineOff),
198
+ _recallSeen: false,
199
+ _sawTool: false,
200
+ _ts: typeof j.timestamp==="string" ? j.timestamp : null,
201
+ };
202
+ episodes.push(cur);
157
203
  continue;
158
204
  }
159
205
 
@@ -166,17 +212,27 @@ try {
166
212
  if(b.type==="text" && typeof b.text==="string" && b.text.trim()) hadText=true;
167
213
  if(b.type==="tool_use"){
168
214
  sawToolThisMsg=true;
215
+ cur._sawTool=true; // filler episode 판별용(§D3 귀속에서 건너뛸 대상)
169
216
  const name=String(b.name||"");
170
217
  const cmd=b.input&&typeof b.input.command==="string"?b.input.command:"";
171
218
  let action="other";
172
219
  if(/recall/i.test(name) || /api\/rookie\/recall/.test(cmd)) action="recall";
173
220
  else if(/^(WebSearch|WebFetch)$/.test(name)) action="external_search";
174
- // 외부 Bash HTTP(curl/wget 로 밖에서 답 찾기)도 외부검색 — recall/내부 API·로컬 dev 는 제외(codex P1·P2).
175
- else if(name==="Bash" && /\b(curl|wget)\b/.test(cmd) && /https?:\/\//.test(cmd) && !/api\/rookie/.test(cmd) && !/(localhost|127\.0\.0\.1|0\.0\.0\.0)/.test(cmd)) action="external_search";
176
221
  else if(/^(Edit|Write|NotebookEdit)$/.test(name)) action="edit";
177
222
  else if(name==="AskUserQuestion") action="ask";
223
+ // spec 2026-08-06 A.5b B-1 — Bash 는 외부검색으로 세지 않는다. 명령문 전체에서 curl|wget 을
224
+ // 찾던 옛 판정이 heredoc 본문·경로에 그 단어가 든 **로컬 스크립트**까지 외부검색으로 셌다
225
+ // (§7 원문 대조: 강 3건 중 2건이 이 오탐). 정확히 세려면 셸 문법을 다시 구현해야 해서 철회했다.
226
+ // 느슨한 관측은 버리지 않고 **진단 전용 신호로 격리**한다.
227
+ const bashHttp = name==="Bash" && /\b(curl|wget)\b/.test(cmd) && /https?:\/\//.test(cmd)
228
+ && !/api\/rookie/.test(cmd) && !/(localhost|127\.0\.0\.1|0\.0\.0\.0)/.test(cmd)
229
+ if(bashHttp) cur.bash_http_seen=true;
178
230
  if(action==="recall"){ cur.recall_called=true; cur._recallSeen=true; }
179
- if(action==="external_search"){ cur.external_search=true; }
231
+ if(action==="external_search"){
232
+ cur.external_search=true;
233
+ // B-2 — 첫 행동 기록 여부와 **무관하게** 누적한다(회상 이후의 모든 외부검색).
234
+ if(cur._recallSeen) cur.external_search_after_recall=true;
235
+ }
180
236
  // recall 이후의 첫 실질 행동을 1회만 기록.
181
237
  if(cur._recallSeen && action!=="recall" && cur.first_action_after_recall===null){
182
238
  cur.first_action_after_recall=action;
@@ -203,19 +259,58 @@ try {
203
259
  if(e.verified_solution_served && e.first_action_after_recall==="external_search")
204
260
  fc.push("ignored_verified_solution");
205
261
  if(e.recall_called && e.amnesia_next_prompt) fc.push("user_correction_after_memory_available");
262
+ // v2 §D3 — 교정 발화를 recall 여부로 가른다. 회상조차 안 하고 지적당한 쪽이 더 나쁜 신호다.
263
+ if(e.correction_next_prompt) fc.push(e.recall_called?"correction_after_recall":"correction_after_no_recall");
206
264
  e.failure_classes=fc;
207
- delete e._boundary; delete e._recallSeen;
208
265
  }
209
266
 
210
- // route schema 는 max 500 — 긴 세션이 400 으로 전량 드롭되지 않게 최근 500 episode 만 보낸다(codex P2).
211
- process.stdout.write(JSON.stringify(episodes.slice(-500)));
212
- } catch { process.stdout.write("[]"); }
267
+ // ── v2 §D6: 활성 시각 컷오프 ─────────────────────────────────────────────
268
+ // 파서는 매 Stop 마다 transcript 를 처음부터 재파싱한다. 컷오프가 없으면 배포 후 살아 있는
269
+ // 옛 세션이 **배포 이전 발화까지** v2 행으로 적재해 "v2 = 배포 후" 전제가 깨진다.
270
+ // 활성 시각은 첫 v2 실행 때 한 번만 기록(wx = 원자적, 이미 있으면 유지).
271
+ // ⚠️ **기록에 성공한 시각만 채택한다.** 메모리상의 "지금"을 쓰면 매 Stop 마다 컷오프가
272
+ // 앞으로 밀려(불안정) 무엇이 왜 빠졌는지 사후 확인이 불가능해진다.
273
+ let activatedAt=null;
274
+ try{
275
+ const ap=path.join(bndDir,".v3-activated");
276
+ const readAct=()=>{ try{ const v=JSON.parse(fs.readFileSync(ap,"utf8")).activated_at; return typeof v==="string"?v:null; }catch{ return null; } };
277
+ activatedAt=readAct();
278
+ if(!activatedAt){
279
+ const now=new Date().toISOString();
280
+ try{
281
+ fs.mkdirSync(bndDir,{recursive:true,mode:0o700});
282
+ fs.writeFileSync(ap,JSON.stringify({activated_at:now}),{flag:"wx",mode:0o600});
283
+ activatedAt=now;
284
+ }catch{ activatedAt=readAct(); } // 경쟁(형제가 먼저 만듦)이면 그 값. 손상·쓰기불가면 null.
285
+ }
286
+ }catch{}
287
+ // timestamp 없는/파싱 불가 개시 라인은 **제외**(fail-closed). D1 의 human 판정과 방향이 반대인데
288
+ // 의도적이다 — 저기서는 분모 누락이, 여기서는 baseline 오염이 위험이다.
289
+ // 비교는 문자열이 아니라 epoch ms 로 한다(codex 코드검토 R1 P2): transcript timestamp 는
290
+ // ms 유무·offset 표기가 섞일 수 있어 문자열 대소 비교가 경계에서 틀린다.
291
+ const actMs = activatedAt ? Date.parse(activatedAt) : NaN;
292
+ // 활성 시각을 세우지 못하면(파일 IO·JSON 오류) 전량 emit 하지 않는다 — 컷오프 없는 emit 은
293
+ // 배포 이전 발화를 v2 baseline 에 섞는 것이라 계측 목적 자체를 깬다(fail-closed).
294
+ const emitted = Number.isFinite(actMs)
295
+ ? episodes.filter(e=>{ const t=typeof e._ts==="string"?Date.parse(e._ts):NaN; return Number.isFinite(t) && t>=actMs; })
296
+ : [];
297
+ for(const e of emitted){ delete e._boundary; delete e._recallSeen; delete e._sawTool; delete e._ts; }
298
+
299
+ // route schema 는 max 500 — 긴 세션이 400 으로 전량 드롭되지 않게 최근 500 만 보낸다(codex P2).
300
+ // 버린 개수를 함께 실어 조용한 절단을 관측 가능하게 만든다(§D1).
301
+ const truncated = Math.max(0, emitted.length-500);
302
+ process.stdout.write(JSON.stringify({episodes:emitted.slice(-500), truncated_count:truncated}));
303
+ } catch { process.stdout.write("{\"episodes\":[],\"truncated_count\":0}"); }
213
304
  ' 2>/dev/null)
214
305
 
215
306
  [ -z "$EPISODES" ] && exit 0
216
- [ "$EPISODES" = "[]" ] && exit 0
217
307
 
218
- BODY=$(ROOKIE_SID="$SESSION_ID" ROOKIE_EP="$EPISODES" node -e 'try{process.stdout.write(JSON.stringify({session_id:process.env.ROOKIE_SID,episodes:JSON.parse(process.env.ROOKIE_EP)}))}catch{}' 2>/dev/null)
308
+ # 파서는 {episodes, truncated_count} 를 낸다(v2). episode 0건이면 보낼 것이 없다.
309
+ BODY=$(ROOKIE_SID="$SESSION_ID" ROOKIE_EP="$EPISODES" node -e 'try{
310
+ const p=JSON.parse(process.env.ROOKIE_EP);
311
+ if(!p||!Array.isArray(p.episodes)||p.episodes.length===0) process.exit(0);
312
+ process.stdout.write(JSON.stringify({session_id:process.env.ROOKIE_SID,episodes:p.episodes,truncated_count:p.truncated_count||0}));
313
+ }catch{}' 2>/dev/null)
219
314
  [ -z "$BODY" ] && exit 0
220
315
 
221
316
  # 계측 POST — 세션 종료를 지연시키지 않게 background + fail-soft.
package/version.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "cli_version": "0.4.13",
3
- "skill_version": "1.34.0"
2
+ "cli_version": "0.4.15",
3
+ "skill_version": "1.35.0"
4
4
  }