therookie 0.4.12 → 0.4.14
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 +1 -1
- package/src/commands/update.js +9 -0
- package/src/lib/deploy-artifacts.js +9 -0
- package/src/lib/merge-hooks.js +1 -1
- package/templates/hook/bin/recall-episode-metric.sh +313 -0
- package/templates/hook/session-start.sh +17 -0
- package/templates/hook/settings.partial.json +2 -1
- package/version.json +2 -2
package/package.json
CHANGED
package/src/commands/update.js
CHANGED
|
@@ -136,6 +136,13 @@ export async function runUpdate(flags) {
|
|
|
136
136
|
)
|
|
137
137
|
chmodSync(join(pretoolBinDir, 'work-recall-gate.sh'), 0o755)
|
|
138
138
|
|
|
139
|
+
// spec 2026-07-25 §3 — recall 소비 실패율 계측 Stop 파서(순수 관측, flush-session 과 별개 훅).
|
|
140
|
+
cpSync(
|
|
141
|
+
join(TEMPLATES, 'hook', 'bin', 'recall-episode-metric.sh'),
|
|
142
|
+
join(pretoolBinDir, 'recall-episode-metric.sh')
|
|
143
|
+
)
|
|
144
|
+
chmodSync(join(pretoolBinDir, 'recall-episode-metric.sh'), 0o755)
|
|
145
|
+
|
|
139
146
|
const libDir = join(binDir, 'lib')
|
|
140
147
|
mkdirSync(libDir, { recursive: true })
|
|
141
148
|
cpSync(join(TEMPLATES, 'hook', 'lib', 'queue.sh'), join(libDir, 'queue.sh'))
|
|
@@ -168,6 +175,7 @@ export async function runUpdate(flags) {
|
|
|
168
175
|
guardCmd: claudeHookCommand(join(binDir, 'guard-destructive-db.sh')),
|
|
169
176
|
skillGateCmd: claudeHookCommand(join(binDir, 'user-prompt-skill-gate.sh')),
|
|
170
177
|
workRecallGateCmd: claudeHookCommand(join(pretoolBinDir, 'work-recall-gate.sh')),
|
|
178
|
+
recallEpisodeMetricCmd: claudeHookCommand(join(pretoolBinDir, 'recall-episode-metric.sh')),
|
|
171
179
|
})
|
|
172
180
|
|
|
173
181
|
// v6 P0b — 글로벌 SKILL.md 갱신 (backup-and-overwrite). 같은 본문이면 no-op.
|
|
@@ -211,6 +219,7 @@ async function mergeSettings(settingsPath, partialPath, subs) {
|
|
|
211
219
|
.replaceAll('__SESSION_START_CMD__', escapeJsonStringValue(subs.sessionStartCmd))
|
|
212
220
|
.replaceAll('__PRETOOL_MD_READ_CMD__', escapeJsonStringValue(subs.pretoolMdReadCmd))
|
|
213
221
|
.replaceAll('__POSTTOOL_RECALL_NUDGE_CMD__', escapeJsonStringValue(subs.posttoolRecallNudgeCmd))
|
|
222
|
+
.replaceAll('__RECALL_EPISODE_METRIC_CMD__', escapeJsonStringValue(subs.recallEpisodeMetricCmd))
|
|
214
223
|
.replaceAll('__GUARD_DESTRUCTIVE_DB_CMD__', escapeJsonStringValue(subs.guardCmd))
|
|
215
224
|
.replaceAll('__USER_PROMPT_SKILL_GATE_CMD__', escapeJsonStringValue(subs.skillGateCmd))
|
|
216
225
|
.replaceAll('__WORK_RECALL_GATE_CMD__', escapeJsonStringValue(subs.workRecallGateCmd))
|
|
@@ -66,6 +66,13 @@ export function installSharedHookRuntime({ scope, home, cwd } = {}) {
|
|
|
66
66
|
)
|
|
67
67
|
chmodSync(join(pretoolBinDir, 'work-recall-gate.sh'), 0o755)
|
|
68
68
|
|
|
69
|
+
// spec 2026-07-25 §3 — recall 소비 실패율 계측 Stop 파서(순수 관측, flush-session 과 별개 훅).
|
|
70
|
+
cpSync(
|
|
71
|
+
join(TEMPLATES, 'hook', 'bin', 'recall-episode-metric.sh'),
|
|
72
|
+
join(pretoolBinDir, 'recall-episode-metric.sh')
|
|
73
|
+
)
|
|
74
|
+
chmodSync(join(pretoolBinDir, 'recall-episode-metric.sh'), 0o755)
|
|
75
|
+
|
|
69
76
|
// spec post-v5-decisions §17 — hook offline queue 라이브러리 배포
|
|
70
77
|
const libDir = join(binDir, 'lib')
|
|
71
78
|
mkdirSync(libDir, { recursive: true })
|
|
@@ -121,6 +128,7 @@ export async function applyClaudeSetup({ scope, cfg, home, cwd } = {}) {
|
|
|
121
128
|
guardCmd: claudeHookCommand(join(binDir, 'guard-destructive-db.sh')),
|
|
122
129
|
skillGateCmd: claudeHookCommand(join(binDir, 'user-prompt-skill-gate.sh')),
|
|
123
130
|
workRecallGateCmd: claudeHookCommand(join(pretoolBinDir, 'work-recall-gate.sh')),
|
|
131
|
+
recallEpisodeMetricCmd: claudeHookCommand(join(pretoolBinDir, 'recall-episode-metric.sh')),
|
|
124
132
|
})
|
|
125
133
|
|
|
126
134
|
// §18 U-3 — 클라이언트 manifest 저장. 다음 hook turn 부터 미스매치 경고 비교 가능.
|
|
@@ -175,6 +183,7 @@ async function mergeSettings(settingsPath, partialPath, subs) {
|
|
|
175
183
|
.replaceAll('__SESSION_START_CMD__', escapeJsonStringValue(subs.sessionStartCmd))
|
|
176
184
|
.replaceAll('__PRETOOL_MD_READ_CMD__', escapeJsonStringValue(subs.pretoolMdReadCmd))
|
|
177
185
|
.replaceAll('__POSTTOOL_RECALL_NUDGE_CMD__', escapeJsonStringValue(subs.posttoolRecallNudgeCmd))
|
|
186
|
+
.replaceAll('__RECALL_EPISODE_METRIC_CMD__', escapeJsonStringValue(subs.recallEpisodeMetricCmd))
|
|
178
187
|
.replaceAll('__GUARD_DESTRUCTIVE_DB_CMD__', escapeJsonStringValue(subs.guardCmd))
|
|
179
188
|
.replaceAll('__USER_PROMPT_SKILL_GATE_CMD__', escapeJsonStringValue(subs.skillGateCmd))
|
|
180
189
|
.replaceAll('__WORK_RECALL_GATE_CMD__', escapeJsonStringValue(subs.workRecallGateCmd))
|
package/src/lib/merge-hooks.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// refresh-context.sh 포함 — 과거 UserPromptSubmit 로 실배포됐던 legacy hook.
|
|
9
9
|
// 마커 소실 구버전 설정에서 이를 보존하면 매 turn 대용량 주입 회귀가 남는다(codex R3-P1).
|
|
10
10
|
export const ROOKIE_HOOK_SCRIPTS =
|
|
11
|
-
/[\\/]\.rookie[\\/]bin[\\/](?:bin[\\/])?(?:flush-session|session-start|user-prompt-skill-gate|guard-destructive-db|pretool-md-read|posttool-recall-nudge|work-recall-gate|sweep-recovery|refresh-context)\.sh\b/
|
|
11
|
+
/[\\/]\.rookie[\\/]bin[\\/](?:bin[\\/])?(?:flush-session|session-start|user-prompt-skill-gate|guard-destructive-db|pretool-md-read|posttool-recall-nudge|work-recall-gate|recall-episode-metric|sweep-recovery|refresh-context)\.sh\b/
|
|
12
12
|
|
|
13
13
|
export function isRookieEntry(entry) {
|
|
14
14
|
if (!entry || typeof entry !== 'object') return false
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Stop hook — 세션 raw transcript 를 episode 단위로 사후 파싱해 recall 소비 실패율을 계측한다.
|
|
3
|
+
# spec: docs/superpowers/specs/2026-07-25-recall-consumption-enforcement.md §3
|
|
4
|
+
#
|
|
5
|
+
# 8회 반복된 "회상 안 함 / 해놓고 안 믿고 밖에서 찾음" 문제의 근본 = 재발률을 한 번도 측정하지
|
|
6
|
+
# 않아 "고쳤다"가 반증 불가능했던 것. 이 훅은 관측만 한다 — 어떤 도구도 차단하지 않는다.
|
|
7
|
+
#
|
|
8
|
+
# flush-session.sh 와 별개의 Stop 훅(무간섭). Stop 은 매 assistant 응답마다 발사되고 transcript 는
|
|
9
|
+
# 누적이라, 파서가 매번 전체를 재계산해 세션의 모든 episode 를 upsert 로 보낸다(서버 UNIQUE 멱등).
|
|
10
|
+
#
|
|
11
|
+
# IMPORTANT: set -e 금지. 차단 절대 X — 항상 exit 0.
|
|
12
|
+
set +e
|
|
13
|
+
|
|
14
|
+
# self-reference 방지: 차차 internal LLM 호출에서 발사된 hook 은 즉시 종료.
|
|
15
|
+
[ "${ROOKIE_INTERNAL_INVOCATION:-}" = "1" ] && exit 0
|
|
16
|
+
command -v node >/dev/null 2>&1 || exit 0
|
|
17
|
+
|
|
18
|
+
INPUT=$(cat 2>/dev/null) || exit 0
|
|
19
|
+
[ -z "$INPUT" ] && exit 0
|
|
20
|
+
|
|
21
|
+
SESSION_ID=$(printf '%s' "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);process.stdout.write((j.session_id||j.sessionId||'').toString())}catch{}})" 2>/dev/null)
|
|
22
|
+
TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);process.stdout.write((j.transcript_path||j.transcriptPath||'').toString())}catch{}})" 2>/dev/null)
|
|
23
|
+
[ -z "$SESSION_ID" ] && exit 0
|
|
24
|
+
[ -z "$TRANSCRIPT_PATH" ] && exit 0
|
|
25
|
+
[ -f "$TRANSCRIPT_PATH" ] || exit 0
|
|
26
|
+
|
|
27
|
+
# config 해석 (env > 프로젝트-로컬 > 전역) — session-start.sh 패턴.
|
|
28
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
29
|
+
if [ -f "$SCRIPT_DIR/lib/config-resolve.sh" ]; then
|
|
30
|
+
# shellcheck source=/dev/null
|
|
31
|
+
. "$SCRIPT_DIR/lib/config-resolve.sh"
|
|
32
|
+
elif [ -f "$HOME/.rookie/bin/lib/config-resolve.sh" ]; then
|
|
33
|
+
# shellcheck source=/dev/null
|
|
34
|
+
. "$HOME/.rookie/bin/lib/config-resolve.sh"
|
|
35
|
+
fi
|
|
36
|
+
if command -v rookie_resolve_credentials >/dev/null 2>&1; then
|
|
37
|
+
rookie_resolve_credentials "$PWD" || exit 0
|
|
38
|
+
ENDPOINT="$ROOKIE_RESOLVED_ENDPOINT"; TOKEN="$ROOKIE_RESOLVED_TOKEN"; USER_ID="$ROOKIE_RESOLVED_USER_ID"
|
|
39
|
+
else
|
|
40
|
+
CONFIG="$HOME/.rookie/config.json"; [ -f "$CONFIG" ] || exit 0
|
|
41
|
+
ENDPOINT=$(node -e "console.log(require('$CONFIG').endpoint)" 2>/dev/null || echo "")
|
|
42
|
+
TOKEN=$(node -e "console.log(require('$CONFIG').token)" 2>/dev/null || echo "")
|
|
43
|
+
USER_ID=$(node -e "console.log(require('$CONFIG').user_id||'')" 2>/dev/null || echo "")
|
|
44
|
+
fi
|
|
45
|
+
[ -z "$TOKEN" ] && exit 0
|
|
46
|
+
[ -z "$ENDPOINT" ] && exit 0
|
|
47
|
+
|
|
48
|
+
# identity pin — 세션 도중 계정 전환 시 이전 계정 transcript 로 계산한 metric 이 현재 owner 로
|
|
49
|
+
# 오귀속되는 교차 오염 차단(flush-session.sh 패턴, codex 코드검토 P1). pin 없음(구세션)은 통과.
|
|
50
|
+
if command -v identity_pin_check >/dev/null 2>&1; then
|
|
51
|
+
if ! identity_pin_check "$SESSION_ID" "$TOKEN"; then
|
|
52
|
+
command -v identity_pin_log_mismatch >/dev/null 2>&1 && identity_pin_log_mismatch "$SESSION_ID" "recall-episode" || true
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
BND_DIR="${ROOKIE_RECALL_EPISODE_DIR:-$HOME/.rookie/recall-episode}"
|
|
58
|
+
|
|
59
|
+
# transcript 를 episode[] JSON 으로 재구성. 회상 신호 정규식은 user-prompt-skill-gate.sh 와 정합(spec §3.2).
|
|
60
|
+
EPISODES=$(ROOKIE_TP="$TRANSCRIPT_PATH" ROOKIE_SID="$SESSION_ID" ROOKIE_BND_DIR="$BND_DIR" node -e '
|
|
61
|
+
try {
|
|
62
|
+
const fs=require("fs"), path=require("path"), crypto=require("crypto");
|
|
63
|
+
const tp=process.env.ROOKIE_TP, sid=process.env.ROOKIE_SID, bndDir=process.env.ROOKIE_BND_DIR;
|
|
64
|
+
|
|
65
|
+
// ── 회상 신호 정규식 (user-prompt-skill-gate.sh L59-70 정합) ──────────────
|
|
66
|
+
const PAST_TIME=/어제|그때|저번|지난번|예전|아까|(방금|(\d+|한|두|두어|세|네|몇)\s*(분|시간|일|주|주일|달|개월|년))\s*전(?=[\s,.!?)\]]|$|에|부터|까지|의|엔|은|는|이|가|도|만|과|와|랑)|(전|이전)에\s*(만든|만들었|했던|하던|쓰던|썼던|정한|결정한|작업한|구현한|짠)/;
|
|
67
|
+
const PAST_HOW=/어떻게[\s\S]{0,15}?(했|였|만들었|제작했|구현했|처리했|뽑았|짰|썼|캡[처쳐]했)(?!으면|으려|으실|으세|었으면)|어떻게\s*(하더라|했더라|됐)/;
|
|
68
|
+
const REPRO=/재현|기억\s*나(?!면|다면|시|실|셔|세)|했었(?!으면)|했잖|하지\s*않았(?:나|어|었)/;
|
|
69
|
+
const AMNESIA=/기억(을|은|이)?\s*(못|안)\s*(하|해|되|나|난)(?!면|다면|시|실|셔|세)|까먹(?!지)|처음\s*(인\s*것|인|것)\s*처럼|또\s*(잊(?!지)|까먹(?!지))/;
|
|
70
|
+
const CONTINUE=/이어서|이어가|계속\s*(하|해|진행)|마저|어디까지|재개|하던\s*(거|것|작업)/;
|
|
71
|
+
const DECIDED=/뭐였|뭐더라|왜\s*였|왜\s*그랬|어떻게\s*됐|결정했|정했었/;
|
|
72
|
+
const ENVQ=/(포트|유저명|계정|경로|도메인|호스트|엔드포인트|서버\s*주소)[^\n]{0,12}(뭐|몇|어디|알아|기억|였)/;
|
|
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
|
+
|
|
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
|
+
|
|
102
|
+
// ── compact/clear boundary offsets (이 transcript 것만, 정렬) ──────────────
|
|
103
|
+
let boundaries=[];
|
|
104
|
+
try {
|
|
105
|
+
const key=crypto.createHash("sha256").update(sid).digest("hex");
|
|
106
|
+
const raw=fs.readFileSync(path.join(bndDir,key+".boundaries"),"utf8");
|
|
107
|
+
for(const line of raw.split("\n")){ const s=line.trim(); if(!s) continue;
|
|
108
|
+
try{ const b=JSON.parse(s); if(b && b.transcript_path===tp && typeof b.offset_bytes==="number") boundaries.push(b.offset_bytes); }catch{} }
|
|
109
|
+
boundaries.sort((a,b)=>a-b);
|
|
110
|
+
} catch {}
|
|
111
|
+
// episode 시작 offset 이후 처음 나오는 boundary (episode 를 그 지점에서 강제 종료).
|
|
112
|
+
const firstBoundaryAfter=(start)=>{ for(const b of boundaries){ if(b>start) return b; } return Infinity; };
|
|
113
|
+
|
|
114
|
+
// ── transcript 순차 파싱 (byte offset 추적) ──────────────
|
|
115
|
+
const buf=fs.readFileSync(tp);
|
|
116
|
+
const full=buf.toString("utf8");
|
|
117
|
+
const rawLines=full.split("\n");
|
|
118
|
+
const episodes=[];
|
|
119
|
+
let cur=null; // 현재 episode
|
|
120
|
+
let seq=0;
|
|
121
|
+
let off=0; // 현재 라인 시작 byte offset
|
|
122
|
+
const userText=(m)=>{
|
|
123
|
+
const c=m.content;
|
|
124
|
+
if(typeof c==="string") return c.trim();
|
|
125
|
+
if(Array.isArray(c)) return c.filter(b=>b&&b.type==="text"&&typeof b.text==="string").map(b=>b.text).join("\n").trim();
|
|
126
|
+
return "";
|
|
127
|
+
};
|
|
128
|
+
const closeIfPastBoundary=(lineOff)=>{ if(cur && lineOff>=cur._boundary){ cur=null; } };
|
|
129
|
+
|
|
130
|
+
for(const raw of rawLines){
|
|
131
|
+
const lineOff=off;
|
|
132
|
+
off+=Buffer.byteLength(raw,"utf8")+1; // +1 = split 로 사라진 \n
|
|
133
|
+
const t=raw.trim(); if(!t) continue;
|
|
134
|
+
let j; try{ j=JSON.parse(t); }catch{ continue; }
|
|
135
|
+
const m=j.message||j;
|
|
136
|
+
const role=m&&m.role;
|
|
137
|
+
|
|
138
|
+
// boundary 를 넘어선 라인은 이전 episode 에 귀속시키지 않는다.
|
|
139
|
+
closeIfPastBoundary(lineOff);
|
|
140
|
+
|
|
141
|
+
// recall 응답의 verified_solution 판정(codex P1) — recall 호출 이후 + "tool_result 성격 라인"에서만
|
|
142
|
+
// `"verified_solution"` JSON key 를 스캔한다. 일반 대화(text 블록)의 단어 언급은 tool_result 가
|
|
143
|
+
// 아니라 배제된다. transcript shape 다양성 커버: ① user content[] 의 tool_result 블록
|
|
144
|
+
// ② top-level toolUseResult 필드 ③ type==="tool_result" 라인. (MCP recall 응답도 results+meta 를
|
|
145
|
+
// stringify 해 이 JSON key 를 남긴다 — packages/rookie-mcp/src/tools.js.)
|
|
146
|
+
if(cur && cur.recall_called && !cur.verified_solution_served){
|
|
147
|
+
// raw 라인 t 는 JSON 이스케이프(\"verified_solution\")라 key 패턴이 안 맞는다 → tool_result 의
|
|
148
|
+
// **파싱된** 필드만 모아 스캔(이스케이프 해제 상태). 이게 tool_result 한정 + JSON key 정확도를 동시에 준다.
|
|
149
|
+
let probe="";
|
|
150
|
+
const cc=m&&m.content;
|
|
151
|
+
if(Array.isArray(cc)){
|
|
152
|
+
for(const b of cc){
|
|
153
|
+
if(b && b.type==="tool_result"){
|
|
154
|
+
probe += typeof b.content==="string" ? b.content
|
|
155
|
+
: Array.isArray(b.content) ? b.content.map(x=>x&&typeof x.text==="string"?x.text:"").join(" ")
|
|
156
|
+
: JSON.stringify(b.content||"");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if(j.toolUseResult!==undefined) probe += " " + (typeof j.toolUseResult==="string"?j.toolUseResult:JSON.stringify(j.toolUseResult));
|
|
161
|
+
if(j.type==="tool_result" && j.content!==undefined) probe += " " + (typeof j.content==="string"?j.content:JSON.stringify(j.content));
|
|
162
|
+
if(probe && /"verified_solution"/.test(probe)) cur.verified_solution_served=true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if(role==="user"){
|
|
166
|
+
const txt=userText(m);
|
|
167
|
+
if(!txt) continue; // tool_result-only user 라인 등은 프롬프트 아님
|
|
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
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
cur={
|
|
186
|
+
episode_seq: seq++,
|
|
187
|
+
episode_start_byte_offset: lineOff,
|
|
188
|
+
parser_version: 2,
|
|
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
|
+
first_action_after_recall: null, failure_classes: [],
|
|
193
|
+
_boundary: firstBoundaryAfter(lineOff),
|
|
194
|
+
_recallSeen: false,
|
|
195
|
+
_sawTool: false,
|
|
196
|
+
_ts: typeof j.timestamp==="string" ? j.timestamp : null,
|
|
197
|
+
};
|
|
198
|
+
episodes.push(cur);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if(role==="assistant" && cur){
|
|
203
|
+
const c=m.content;
|
|
204
|
+
const blocks=Array.isArray(c)?c:[];
|
|
205
|
+
let hadText=false, sawToolThisMsg=false;
|
|
206
|
+
for(const b of blocks){
|
|
207
|
+
if(!b||typeof b!=="object") continue;
|
|
208
|
+
if(b.type==="text" && typeof b.text==="string" && b.text.trim()) hadText=true;
|
|
209
|
+
if(b.type==="tool_use"){
|
|
210
|
+
sawToolThisMsg=true;
|
|
211
|
+
cur._sawTool=true; // filler episode 판별용(§D3 귀속에서 건너뛸 대상)
|
|
212
|
+
const name=String(b.name||"");
|
|
213
|
+
const cmd=b.input&&typeof b.input.command==="string"?b.input.command:"";
|
|
214
|
+
let action="other";
|
|
215
|
+
if(/recall/i.test(name) || /api\/rookie\/recall/.test(cmd)) action="recall";
|
|
216
|
+
else if(/^(WebSearch|WebFetch)$/.test(name)) action="external_search";
|
|
217
|
+
// 외부 Bash HTTP(curl/wget 로 밖에서 답 찾기)도 외부검색 — recall/내부 API·로컬 dev 는 제외(codex P1·P2).
|
|
218
|
+
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";
|
|
219
|
+
else if(/^(Edit|Write|NotebookEdit)$/.test(name)) action="edit";
|
|
220
|
+
else if(name==="AskUserQuestion") action="ask";
|
|
221
|
+
if(action==="recall"){ cur.recall_called=true; cur._recallSeen=true; }
|
|
222
|
+
if(action==="external_search"){ cur.external_search=true; }
|
|
223
|
+
// recall 이후의 첫 실질 행동을 1회만 기록.
|
|
224
|
+
if(cur._recallSeen && action!=="recall" && cur.first_action_after_recall===null){
|
|
225
|
+
cur.first_action_after_recall=action;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// recall 을 받고서 도구 없이 텍스트로만 답하면(=자체 지식/재조사) self_answer 근사.
|
|
230
|
+
if(cur._recallSeen && !sawToolThisMsg && hadText && cur.first_action_after_recall===null){
|
|
231
|
+
cur.first_action_after_recall="self_answer";
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── 실패 클래스 판정 (프록시 — spec §2·§8) ──────────────
|
|
239
|
+
for(const e of episodes){
|
|
240
|
+
const fc=[];
|
|
241
|
+
if(e.recall_signal && !e.recall_called) fc.push("missed_recall");
|
|
242
|
+
if(e.recall_called && (e.first_action_after_recall==="external_search" || (e.external_search && !["consume","edit","ask"].includes(e.first_action_after_recall||""))))
|
|
243
|
+
fc.push("external_search_before_consumption");
|
|
244
|
+
// self_answer(도구 없이 텍스트 답변)는 정상 소비일 수도 있어 이탈로 단정하지 않는다(codex P1) —
|
|
245
|
+
// 명확한 이탈(외부검색)만 ignored 로 센다. 프록시 한계상 보수적으로(false positive < false negative).
|
|
246
|
+
if(e.verified_solution_served && e.first_action_after_recall==="external_search")
|
|
247
|
+
fc.push("ignored_verified_solution");
|
|
248
|
+
if(e.recall_called && e.amnesia_next_prompt) fc.push("user_correction_after_memory_available");
|
|
249
|
+
// v2 §D3 — 교정 발화를 recall 여부로 가른다. 회상조차 안 하고 지적당한 쪽이 더 나쁜 신호다.
|
|
250
|
+
if(e.correction_next_prompt) fc.push(e.recall_called?"correction_after_recall":"correction_after_no_recall");
|
|
251
|
+
e.failure_classes=fc;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── v2 §D6: 활성 시각 컷오프 ─────────────────────────────────────────────
|
|
255
|
+
// 파서는 매 Stop 마다 transcript 를 처음부터 재파싱한다. 컷오프가 없으면 배포 후 살아 있는
|
|
256
|
+
// 옛 세션이 **배포 이전 발화까지** v2 행으로 적재해 "v2 = 배포 후" 전제가 깨진다.
|
|
257
|
+
// 활성 시각은 첫 v2 실행 때 한 번만 기록(wx = 원자적, 이미 있으면 유지).
|
|
258
|
+
// ⚠️ **기록에 성공한 시각만 채택한다.** 메모리상의 "지금"을 쓰면 매 Stop 마다 컷오프가
|
|
259
|
+
// 앞으로 밀려(불안정) 무엇이 왜 빠졌는지 사후 확인이 불가능해진다.
|
|
260
|
+
let activatedAt=null;
|
|
261
|
+
try{
|
|
262
|
+
const ap=path.join(bndDir,".v2-activated");
|
|
263
|
+
const readAct=()=>{ try{ const v=JSON.parse(fs.readFileSync(ap,"utf8")).activated_at; return typeof v==="string"?v:null; }catch{ return null; } };
|
|
264
|
+
activatedAt=readAct();
|
|
265
|
+
if(!activatedAt){
|
|
266
|
+
const now=new Date().toISOString();
|
|
267
|
+
try{
|
|
268
|
+
fs.mkdirSync(bndDir,{recursive:true,mode:0o700});
|
|
269
|
+
fs.writeFileSync(ap,JSON.stringify({activated_at:now}),{flag:"wx",mode:0o600});
|
|
270
|
+
activatedAt=now;
|
|
271
|
+
}catch{ activatedAt=readAct(); } // 경쟁(형제가 먼저 만듦)이면 그 값. 손상·쓰기불가면 null.
|
|
272
|
+
}
|
|
273
|
+
}catch{}
|
|
274
|
+
// timestamp 없는/파싱 불가 개시 라인은 **제외**(fail-closed). D1 의 human 판정과 방향이 반대인데
|
|
275
|
+
// 의도적이다 — 저기서는 분모 누락이, 여기서는 baseline 오염이 위험이다.
|
|
276
|
+
// 비교는 문자열이 아니라 epoch ms 로 한다(codex 코드검토 R1 P2): transcript timestamp 는
|
|
277
|
+
// ms 유무·offset 표기가 섞일 수 있어 문자열 대소 비교가 경계에서 틀린다.
|
|
278
|
+
const actMs = activatedAt ? Date.parse(activatedAt) : NaN;
|
|
279
|
+
// 활성 시각을 세우지 못하면(파일 IO·JSON 오류) 전량 emit 하지 않는다 — 컷오프 없는 emit 은
|
|
280
|
+
// 배포 이전 발화를 v2 baseline 에 섞는 것이라 계측 목적 자체를 깬다(fail-closed).
|
|
281
|
+
const emitted = Number.isFinite(actMs)
|
|
282
|
+
? episodes.filter(e=>{ const t=typeof e._ts==="string"?Date.parse(e._ts):NaN; return Number.isFinite(t) && t>=actMs; })
|
|
283
|
+
: [];
|
|
284
|
+
for(const e of emitted){ delete e._boundary; delete e._recallSeen; delete e._sawTool; delete e._ts; }
|
|
285
|
+
|
|
286
|
+
// route schema 는 max 500 — 긴 세션이 400 으로 전량 드롭되지 않게 최근 500 만 보낸다(codex P2).
|
|
287
|
+
// 버린 개수를 함께 실어 조용한 절단을 관측 가능하게 만든다(§D1).
|
|
288
|
+
const truncated = Math.max(0, emitted.length-500);
|
|
289
|
+
process.stdout.write(JSON.stringify({episodes:emitted.slice(-500), truncated_count:truncated}));
|
|
290
|
+
} catch { process.stdout.write("{\"episodes\":[],\"truncated_count\":0}"); }
|
|
291
|
+
' 2>/dev/null)
|
|
292
|
+
|
|
293
|
+
[ -z "$EPISODES" ] && exit 0
|
|
294
|
+
|
|
295
|
+
# 파서는 {episodes, truncated_count} 를 낸다(v2). episode 0건이면 보낼 것이 없다.
|
|
296
|
+
BODY=$(ROOKIE_SID="$SESSION_ID" ROOKIE_EP="$EPISODES" node -e 'try{
|
|
297
|
+
const p=JSON.parse(process.env.ROOKIE_EP);
|
|
298
|
+
if(!p||!Array.isArray(p.episodes)||p.episodes.length===0) process.exit(0);
|
|
299
|
+
process.stdout.write(JSON.stringify({session_id:process.env.ROOKIE_SID,episodes:p.episodes,truncated_count:p.truncated_count||0}));
|
|
300
|
+
}catch{}' 2>/dev/null)
|
|
301
|
+
[ -z "$BODY" ] && exit 0
|
|
302
|
+
|
|
303
|
+
# 계측 POST — 세션 종료를 지연시키지 않게 background + fail-soft.
|
|
304
|
+
( curl -sS --max-time 8 -X POST \
|
|
305
|
+
-H "Authorization: Bearer $TOKEN" \
|
|
306
|
+
-H "x-rookie-hostname: $(hostname)" \
|
|
307
|
+
-H "x-rookie-user-id: $USER_ID" \
|
|
308
|
+
-H "Content-Type: application/json" \
|
|
309
|
+
-d "$BODY" \
|
|
310
|
+
"$ENDPOINT/api/rookie/metric/recall-episode" >/dev/null 2>&1 || true ) &
|
|
311
|
+
disown 2>/dev/null || true
|
|
312
|
+
|
|
313
|
+
exit 0
|
|
@@ -69,6 +69,23 @@ if [ "$SOURCE" = "clear" ] || [ "$SOURCE" = "compact" ]; then
|
|
|
69
69
|
if command -v skill_gate_record_epoch >/dev/null 2>&1; then
|
|
70
70
|
skill_gate_record_epoch "$SESSION_ID" "$TRANSCRIPT_PATH" || true
|
|
71
71
|
fi
|
|
72
|
+
# spec 2026-07-25 §3.2 — recall episode 파서용 append-only boundary log.
|
|
73
|
+
# skill-gate 의 .epoch(단일 offset·덮어쓰기, skill-로드 감지 전용)와 별개다. episode 파서는
|
|
74
|
+
# multiple compact 이력 + transcript_path 검증이 필요하므로 전용 append-only 파일을 쓴다.
|
|
75
|
+
if [ -n "$SESSION_ID" ] && [ -n "$TRANSCRIPT_PATH" ]; then
|
|
76
|
+
ROOKIE_BND_SID="$SESSION_ID" ROOKIE_BND_TP="$TRANSCRIPT_PATH" ROOKIE_BND_SRC="$SOURCE" node -e '
|
|
77
|
+
try {
|
|
78
|
+
const fs=require("fs"), path=require("path"), crypto=require("crypto"), os=require("os");
|
|
79
|
+
const sid=process.env.ROOKIE_BND_SID, tp=process.env.ROOKIE_BND_TP, src=process.env.ROOKIE_BND_SRC;
|
|
80
|
+
let size=0; try{ size=fs.statSync(tp).size; }catch{}
|
|
81
|
+
const dir=path.join(os.homedir(),".rookie","recall-episode");
|
|
82
|
+
fs.mkdirSync(dir,{recursive:true,mode:0o700});
|
|
83
|
+
const key=crypto.createHash("sha256").update(sid).digest("hex");
|
|
84
|
+
const line=JSON.stringify({transcript_path:tp,offset_bytes:size,source:src,recorded_at:new Date().toISOString()})+"\n";
|
|
85
|
+
fs.appendFileSync(path.join(dir,key+".boundaries"),line,{mode:0o600});
|
|
86
|
+
} catch {}
|
|
87
|
+
' 2>/dev/null || true
|
|
88
|
+
fi
|
|
72
89
|
fi
|
|
73
90
|
|
|
74
91
|
# Codex hook 은 실행 cwd 가 프로젝트와 다를 수 있어 stdin cwd 우선(없으면 $PWD — Claude 무변화).
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
"matcher": ".*",
|
|
6
6
|
"__rookie_managed": true,
|
|
7
7
|
"hooks": [
|
|
8
|
-
{ "type": "command", "command": "__FLUSH_SESSION_CMD__" }
|
|
8
|
+
{ "type": "command", "command": "__FLUSH_SESSION_CMD__" },
|
|
9
|
+
{ "type": "command", "command": "__RECALL_EPISODE_METRIC_CMD__" }
|
|
9
10
|
]
|
|
10
11
|
}
|
|
11
12
|
],
|
package/version.json
CHANGED