therookie 0.4.26 → 0.4.28

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.26",
3
+ "version": "0.4.28",
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": [
@@ -117,6 +117,7 @@ export async function runDoctor(flags) {
117
117
  printTemplateManifestStatus(serverStatus)
118
118
  printQueueStatus()
119
119
  printOrphanPending()
120
+ printRecallPush()
120
121
  printIdentityMismatch()
121
122
  await printStage1Metric(cfg)
122
123
  await printDedupMetric(cfg)
@@ -448,6 +449,57 @@ function printOrphanPending() {
448
449
  }
449
450
  }
450
451
 
452
+ // spec 2026-09-08 §5.1 — 회상 푸시 무주입률. recall-push.sh 가 로컬에 남긴 계측을 읽는다.
453
+ // 서버 전송 없음. 파일이 없으면(아직 안 돌았거나 구버전) 침묵한다.
454
+ export function summarizeRecallPush(lines, max = 1000) {
455
+ const rows = []
456
+ // 파일은 append 전용이라 최신이 뒤에 온다 — 뒤에서부터 max 줄만 집계한다.
457
+ for (let i = lines.length - 1; i >= 0 && rows.length < max; i--) {
458
+ try {
459
+ const o = JSON.parse(lines[i])
460
+ if (o && typeof o.injected === 'number') rows.push(o)
461
+ } catch {
462
+ /* 깨진 줄은 건너뛴다 */
463
+ }
464
+ }
465
+ if (rows.length === 0) return null
466
+ const silent = rows.filter((r) => r.injected === 0).length
467
+ // 중앙값은 chars 기준이다 — 컨텍스트 예산은 문자·토큰 감각이고, 한국어는 바이트가 3배라
468
+ // 바이트로 재면 합격 기준(§4.1)이 왜곡된다. bytes 는 파일에만 남긴다.
469
+ const chars = rows.map((r) => (typeof r.chars === 'number' ? r.chars : 0)).sort((a, b) => a - b)
470
+ const median = chars[Math.floor(chars.length / 2)]
471
+ const epochs = new Set(rows.map((r) => String(r.epoch ?? '')))
472
+ return {
473
+ turns: rows.length,
474
+ silent,
475
+ silentRatio: silent / rows.length,
476
+ medianChars: median,
477
+ epochs: epochs.size,
478
+ }
479
+ }
480
+
481
+ function printRecallPush() {
482
+ const dir = join(homedir(), '.rookie', 'recall-push')
483
+ const f = join(dir, 'metrics.jsonl')
484
+ // 훅이 크기 초과 시 .1 로 로테이트한다 — 직후에는 현재 파일이 거의 비어 있어
485
+ // 이것만 읽으면 최근 1000턴을 과소 집계한다(codex 코드검토 R2 P1). 앞에 이어 붙인다.
486
+ const rotated = f + '.1'
487
+ if (!existsSync(f) && !existsSync(rotated)) return
488
+ let s = null
489
+ try {
490
+ const read = (p) => (existsSync(p) ? readFileSync(p, 'utf8').split('\n').filter(Boolean) : [])
491
+ s = summarizeRecallPush([...read(rotated), ...read(f)])
492
+ } catch {
493
+ console.log('— 회상 푸시: 계측 읽기 실패')
494
+ return
495
+ }
496
+ if (!s) return
497
+ const pct = Math.round(s.silentRatio * 100)
498
+ console.log(
499
+ `— 회상 푸시: 최근 ${s.turns}턴 중 무주입 ${s.silent}건(${pct}%), 주입 길이 중앙값 ${s.medianChars}자, epoch ${s.epochs}개`
500
+ )
501
+ }
502
+
451
503
  // B6-3 (spec 2026-07-11 §B6): 세션 identity pinning 위반 가시화 — 세션 도중 계정 전환으로
452
504
  // flush 가 중단된 이력. Stop hook(flush-session.sh)이 남긴 로그를 집계한다. 0건이면 침묵.
453
505
  function printIdentityMismatch() {
@@ -20,6 +20,10 @@ import { apiPost } from '../api.js'
20
20
  //
21
21
  // 중복 스킵도 함께 센다. applied 만 보면 "보냈는데 전부 중복이라 0건" 과 "안 보냈다" 가
22
22
  // 화면에서 구분되지 않는다(그게 이 결함이 오래 안 보인 이유다).
23
+ // 아무것도 저장되지 않았을 때의 요약 문구. 호출부가 "저장 0건" 을 판별할 때 쓴다
24
+ // (2026-09-03: 호출부가 함수 안의 지역 변수 parts 를 참조해 ReferenceError 로 죽었다).
25
+ export const EMPTY_SAVE_SUMMARY = '0건'
26
+
23
27
  export function formatSaveSummary(s) {
24
28
  const parts = []
25
29
  if (s.working_cards?.ids?.length) parts.push(`카드 ${s.working_cards.ids.length}`)
@@ -39,7 +43,7 @@ export function formatSaveSummary(s) {
39
43
  )
40
44
  }
41
45
  if (s.verifications?.applied) parts.push(`검증 ${s.verifications.applied}`)
42
- return parts.length > 0 ? parts.join(' · ') : '0건'
46
+ return parts.length > 0 ? parts.join(' · ') : EMPTY_SAVE_SUMMARY
43
47
  }
44
48
 
45
49
  function readStdin() {
@@ -154,7 +158,7 @@ export async function runSave(flags) {
154
158
  const pendingReview = (s.self_rules?.rejected ?? []).some(
155
159
  (r) => r.reason_code === 'peer_review_required'
156
160
  )
157
- if (pendingReview && parts.length === 0) {
161
+ if (pendingReview && label === EMPTY_SAVE_SUMMARY) {
158
162
  console.error('⚠ 자기규칙 검토 대기 — 위 목록을 보고 재전송하세요.')
159
163
  } else {
160
164
  console.error(`⚠ 일부 미저장 — ${label}`)
@@ -1,14 +1,16 @@
1
1
  #!/bin/bash
2
2
  # UserPromptSubmit hook — 무조건 회상 푸시 (unconditional recall push).
3
- # 설계: docs/superpowers/specs/2026-08-08-unconditional-recall-push-design.md
3
+ # 설계: docs/superpowers/specs/2026-09-08-recall-always-inject-redesign.md (선행 2026-08-08)
4
4
  #
5
5
  # - 발동 조건 없음(E1 결정 제거): 매 사용자 발화를 그대로 recall 질의로 던진다. 빈 발화만 skip —
6
- # 길이 하한·낱말 필터는 결정의 재도입이라 두지 않는다(§1.1).
7
- # - hit(THRESH 이상 상위 K)만 additionalContext 로 그 턴의 생성 컨텍스트에 주입(E2). 무-hit → 0바이트.
8
- # - THRESH 기본 0.0165 = rrf k=60 단일 신호 최대치 1/61 바로 위 — "FTS·벡터 두 신호가 합의한
9
- # 결과만 주입"이라는 기계적 의미(worklog §96 분포 실측). 모델 판단·어휘 패턴 매칭이 아니다.
6
+ # 길이 하한·낱말 필터는 결정의 재도입이라 두지 않는다.
7
+ # - 주입 판정 없음: 상위 K 를 점수와 무관하게 넣는다. 옛 THRESH(0.0165)는 관련도가 아니라
8
+ # "어휘 암과 벡터 암이 같은 문서를 물었는가" 라는 검색 내부 사정을 재는 불리언이었고,
9
+ # 실측에서 주입의 78%를 죽였다(2026-09-08 §1 — '이어서 진행해' 0/5, '안녕' 4/5).
10
+ # - 관련도를 보증하지 않으므로 라벨에 그렇게 적고, 날짜·출처를 문장 앞에 놓아 절삭에도 살아남게 한다.
10
11
  # - 세션 dedup: 같은 기억 id 는 세션당 1회. /clear·compact 는 skill-gate <key>.epoch 값 변화로
11
12
  # 감지해 리셋한다(컨텍스트가 비워졌으니 재주입이 옳다).
13
+ # - 계측은 로컬 전용 — <push_dir>/metrics.jsonl. 서버 전송·스키마 변경 없다(§5.1).
12
14
  # - fail-soft: 타임아웃(늦으면 그 턴 무주입)·네트워크·파싱 실패 전부 조용히 무주입 종료.
13
15
  # 아무것도 막지 않는다 — deny 0, 기존 user-prompt-skill-gate.sh 불변.
14
16
  set -u
@@ -37,7 +39,7 @@ PUSH_DIR="${ROOKIE_RECALL_PUSH_DIR:-$HOME/.rookie/recall-push}"
37
39
  GATE_DIR="${ROOKIE_SKILL_GATE_DIR:-$HOME/.rookie/skill-gate}"
38
40
  HOST_VAL="$(hostname 2>/dev/null || echo "")"
39
41
 
40
- # payload 는 stdin 으로(ARG_MAX 회피). 질의·필터·dedup·주입 결정 전부 node 한 덩어리 —
42
+ # payload 는 stdin 으로(ARG_MAX 회피). 질의·dedup·주입 전부 node 한 덩어리 —
41
43
  # code 안에 작은따옴표 미사용(bash single-quote 보호).
42
44
  printf '%s' "$INPUT" | \
43
45
  ROOKIE_PUSH_DIR="$PUSH_DIR" ROOKIE_GATE_DIR="$GATE_DIR" \
@@ -45,29 +47,51 @@ printf '%s' "$INPUT" | \
45
47
  ROOKIE_HOST="$HOST_VAL" ROOKIE_CWD="$PWD" node -e '
46
48
  let d="";
47
49
  process.stdin.on("data",c=>d+=c).on("end",async()=>{
50
+ const fs=require("fs"),path=require("path"),crypto=require("crypto");
51
+ const METRIC_MAX_BYTES=parseInt(process.env.ROOKIE_RECALL_PUSH_METRIC_MAX_BYTES||"262144",10);
52
+ let epoch="";
53
+ // 로컬 계측 — 무주입도 반드시 남긴다(무주입률이 이 설계의 1차 관측 대상). 실패는 무시.
54
+ //
55
+ // 병렬 세션 안전: 쓰기는 append 전용(O_APPEND, 한 줄은 원자적)이고, 크기 초과 시에만
56
+ // rename 으로 로테이트한다. 파일을 읽어 다시 쓰는 방식은 두 세션이 같은 스냅샷을 보고
57
+ // 서로의 줄을 덮어쓸 수 있어 쓰지 않는다(codex 코드검토 R1 P1).
58
+ const metric=(layer,candidates,injected,text)=>{
59
+ try{
60
+ const dir=process.env.ROOKIE_PUSH_DIR;
61
+ 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";
67
+ fs.mkdirSync(dir,{recursive:true,mode:0o700});
68
+ try{
69
+ if(fs.statSync(f).size>METRIC_MAX_BYTES) fs.renameSync(f,f+".1");
70
+ }catch{} // 파일 없음·경합으로 이미 로테이트됨 — 둘 다 정상
71
+ fs.appendFileSync(f,line,{mode:0o600});
72
+ }catch{}
73
+ };
48
74
  try{
49
- const fs=require("fs"),path=require("path"),crypto=require("crypto");
50
75
  const p=JSON.parse(d);
51
76
  const sid=String(p.session_id||p.sessionId||"");
52
77
  let prompt=String(p.prompt||"").trim();
53
78
  if(!prompt) return; // 빈 발화 = 질의 불성립(유일한 기계 조건)
54
- prompt=prompt.slice(0,500); // RecallQuerySchema q max(500) — 초과는 400 무주입(R1 P1)
55
- const THRESH=Number(process.env.ROOKIE_RECALL_PUSH_THRESH||"0.0165");
79
+ prompt=prompt.slice(0,500); // RecallQuerySchema q max(500) — 초과는 400 무주입
56
80
  const K=parseInt(process.env.ROOKIE_RECALL_PUSH_TOPK||"3",10);
57
- const CAP=parseInt(process.env.ROOKIE_RECALL_PUSH_CAP||"1200",10);
81
+ const CAP=parseInt(process.env.ROOKIE_RECALL_PUSH_CAP||"600",10);
82
+ const ITEM=parseInt(process.env.ROOKIE_RECALL_PUSH_ITEM_CHARS||"200",10);
58
83
  const TIMEOUT=parseInt(process.env.ROOKIE_RECALL_PUSH_TIMEOUT_MS||"3500",10);
59
84
 
60
85
  // 세션 dedup 상태 — epoch(skill-gate 기록) 가 바뀌었으면 /clear·compact 경계로 보고 리셋.
61
86
  const key=sid?crypto.createHash("sha256").update(sid).digest("hex"):"";
62
- let epoch="";
63
87
  if(key){ try{ epoch=String(fs.readFileSync(path.join(process.env.ROOKIE_GATE_DIR,key+".epoch"),"utf8")).trim(); }catch{} }
64
88
  const stFile=key?path.join(process.env.ROOKIE_PUSH_DIR,key+".injected"):null;
65
89
  let st={epoch,ids:[]};
66
90
  if(stFile){ try{ const j=JSON.parse(fs.readFileSync(stFile,"utf8")); if(j&&j.epoch===epoch&&Array.isArray(j.ids)) st=j; }catch{} }
67
91
  const seen=new Set(st.ids);
68
92
 
69
- // 무조건 질의 — scope=all(현 구현상 project boost 없음, R1 P2).
70
- // x-rookie-hostname + repo_path 동시 전달 = workspace 격리 기억 가시성 계약(R2 P2, fail-closed).
93
+ // 무조건 질의 — scope=all(현 구현상 project boost 없음).
94
+ // x-rookie-hostname + repo_path 동시 전달 = workspace 격리 기억 가시성 계약(fail-closed).
71
95
  const u=new URL("/api/rookie/recall",process.env.ROOKIE_EP);
72
96
  u.searchParams.set("q",prompt);
73
97
  u.searchParams.set("scope","all");
@@ -80,32 +104,33 @@ process.stdin.on("data",c=>d+=c).on("end",async()=>{
80
104
  if(!res.ok) return;
81
105
  const body=await res.json();
82
106
  const results=Array.isArray(body&&body.results)?body.results:[];
107
+ // 임계 없음 — 걸러내는 것은 형식 불량(id·요약 없음, 점수 비수치)과 기주입뿐이다.
83
108
  const cands=results
84
- .filter(r=>r&&typeof r.relevance==="number"&&r.relevance>=THRESH&&r.id&&typeof r.summary==="string"&&r.summary.trim()&&!seen.has(r.id))
109
+ .filter(r=>r&&typeof r.relevance==="number"&&r.id&&typeof r.summary==="string"&&r.summary.trim()&&!seen.has(r.id))
85
110
  .sort((a,b)=>b.relevance-a.relevance)
86
111
  .slice(0,K);
87
- if(!cands.length) return; // 무-hit(또는 전부 기주입) → 0바이트
112
+ if(!cands.length){ metric("B",results.length,0,""); return; }
88
113
 
89
- // 주입 문구는 지시가 아니라 자료다(§3.2) — 요약 + 시각 + 출처 프로젝트(전역이면 명시).
90
- const MARK=["①","②","③","④","⑤"];
91
- let msg="🧠 관련 기억:";
114
+ // 주입 문구는 지시가 아니라 자료다. 관련도를 보증하지 않으므로 라벨에 명시하고,
115
+ // 날짜·출처를 앞에 놓아 ITEM 절삭에 잘려 나가지 않게 한다.
116
+ let msg="🧠 저장소 항목 (질의 연동, 관련도 미보증):";
92
117
  const used=[];
93
- for(let i=0;i<cands.length;i++){
94
- const r=cands[i];
118
+ for(const r of cands){
95
119
  const when=r.memory_at?String(r.memory_at).slice(0,10):"시각 미상";
96
120
  const src=r.project_name?String(r.project_name):"전역";
97
- const summary=String(r.summary).replace(/\s+/g," ").trim().slice(0,400);
98
- const item="\n"+(MARK[i]||"-")+" "+summary+" ("+when+", "+src+")";
121
+ const summary=String(r.summary).replace(/\s+/g," ").trim().slice(0,ITEM);
122
+ const item="\n- ["+when+" · "+src+"] "+summary;
99
123
  if(msg.length+item.length>CAP) break; // 총량 상한 — relevance 순 절삭
100
124
  msg+=item; used.push(r.id);
101
125
  }
102
- if(!used.length) return;
126
+ if(!used.length){ metric("B",results.length,0,""); return; }
103
127
  if(stFile){
104
128
  try{
105
129
  fs.mkdirSync(process.env.ROOKIE_PUSH_DIR,{recursive:true,mode:0o700});
106
130
  fs.writeFileSync(stFile,JSON.stringify({epoch,ids:st.ids.concat(used)}),{mode:0o600});
107
131
  }catch{} // dedup 기록 실패 → 다음 턴 중복 주입 위험만, 주입은 진행
108
132
  }
133
+ metric("B",results.length,used.length,msg);
109
134
  process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:"UserPromptSubmit",additionalContext:msg}}));
110
135
  }catch{ /* fail-soft — 조용히 무주입 */ }
111
136
  });
package/version.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "cli_version": "0.4.26",
2
+ "cli_version": "0.4.28",
3
3
  "skill_version": "1.39.0"
4
4
  }