therookie 0.4.12 → 0.4.13
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 +231 -0
- package/templates/hook/session-start.sh +17 -0
- package/templates/hook/settings.partial.json +2 -1
- package/version.json +1 -1
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,231 @@
|
|
|
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
|
+
// ── compact/clear boundary offsets (이 transcript 것만, 정렬) ──────────────
|
|
76
|
+
let boundaries=[];
|
|
77
|
+
try {
|
|
78
|
+
const key=crypto.createHash("sha256").update(sid).digest("hex");
|
|
79
|
+
const raw=fs.readFileSync(path.join(bndDir,key+".boundaries"),"utf8");
|
|
80
|
+
for(const line of raw.split("\n")){ const s=line.trim(); if(!s) continue;
|
|
81
|
+
try{ const b=JSON.parse(s); if(b && b.transcript_path===tp && typeof b.offset_bytes==="number") boundaries.push(b.offset_bytes); }catch{} }
|
|
82
|
+
boundaries.sort((a,b)=>a-b);
|
|
83
|
+
} catch {}
|
|
84
|
+
// episode 시작 offset 이후 처음 나오는 boundary (episode 를 그 지점에서 강제 종료).
|
|
85
|
+
const firstBoundaryAfter=(start)=>{ for(const b of boundaries){ if(b>start) return b; } return Infinity; };
|
|
86
|
+
|
|
87
|
+
// ── transcript 순차 파싱 (byte offset 추적) ──────────────
|
|
88
|
+
const buf=fs.readFileSync(tp);
|
|
89
|
+
const full=buf.toString("utf8");
|
|
90
|
+
const rawLines=full.split("\n");
|
|
91
|
+
const episodes=[];
|
|
92
|
+
let cur=null; // 현재 episode
|
|
93
|
+
let seq=0;
|
|
94
|
+
let off=0; // 현재 라인 시작 byte offset
|
|
95
|
+
const userText=(m)=>{
|
|
96
|
+
const c=m.content;
|
|
97
|
+
if(typeof c==="string") return c.trim();
|
|
98
|
+
if(Array.isArray(c)) return c.filter(b=>b&&b.type==="text"&&typeof b.text==="string").map(b=>b.text).join("\n").trim();
|
|
99
|
+
return "";
|
|
100
|
+
};
|
|
101
|
+
const closeIfPastBoundary=(lineOff)=>{ if(cur && lineOff>=cur._boundary){ cur=null; } };
|
|
102
|
+
|
|
103
|
+
for(const raw of rawLines){
|
|
104
|
+
const lineOff=off;
|
|
105
|
+
off+=Buffer.byteLength(raw,"utf8")+1; // +1 = split 로 사라진 \n
|
|
106
|
+
const t=raw.trim(); if(!t) continue;
|
|
107
|
+
let j; try{ j=JSON.parse(t); }catch{ continue; }
|
|
108
|
+
const m=j.message||j;
|
|
109
|
+
const role=m&&m.role;
|
|
110
|
+
|
|
111
|
+
// boundary 를 넘어선 라인은 이전 episode 에 귀속시키지 않는다.
|
|
112
|
+
closeIfPastBoundary(lineOff);
|
|
113
|
+
|
|
114
|
+
// recall 응답의 verified_solution 판정(codex P1) — recall 호출 이후 + "tool_result 성격 라인"에서만
|
|
115
|
+
// `"verified_solution"` JSON key 를 스캔한다. 일반 대화(text 블록)의 단어 언급은 tool_result 가
|
|
116
|
+
// 아니라 배제된다. transcript shape 다양성 커버: ① user content[] 의 tool_result 블록
|
|
117
|
+
// ② top-level toolUseResult 필드 ③ type==="tool_result" 라인. (MCP recall 응답도 results+meta 를
|
|
118
|
+
// stringify 해 이 JSON key 를 남긴다 — packages/rookie-mcp/src/tools.js.)
|
|
119
|
+
if(cur && cur.recall_called && !cur.verified_solution_served){
|
|
120
|
+
// raw 라인 t 는 JSON 이스케이프(\"verified_solution\")라 key 패턴이 안 맞는다 → tool_result 의
|
|
121
|
+
// **파싱된** 필드만 모아 스캔(이스케이프 해제 상태). 이게 tool_result 한정 + JSON key 정확도를 동시에 준다.
|
|
122
|
+
let probe="";
|
|
123
|
+
const cc=m&&m.content;
|
|
124
|
+
if(Array.isArray(cc)){
|
|
125
|
+
for(const b of cc){
|
|
126
|
+
if(b && b.type==="tool_result"){
|
|
127
|
+
probe += typeof b.content==="string" ? b.content
|
|
128
|
+
: Array.isArray(b.content) ? b.content.map(x=>x&&typeof x.text==="string"?x.text:"").join(" ")
|
|
129
|
+
: JSON.stringify(b.content||"");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if(j.toolUseResult!==undefined) probe += " " + (typeof j.toolUseResult==="string"?j.toolUseResult:JSON.stringify(j.toolUseResult));
|
|
134
|
+
if(j.type==="tool_result" && j.content!==undefined) probe += " " + (typeof j.content==="string"?j.content:JSON.stringify(j.content));
|
|
135
|
+
if(probe && /"verified_solution"/.test(probe)) cur.verified_solution_served=true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if(role==="user"){
|
|
139
|
+
const txt=userText(m);
|
|
140
|
+
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);
|
|
155
|
+
}
|
|
156
|
+
// 회상 신호 아닌 user 프롬프트는 현재 episode 를 이어간다(별 처리 없음, spec §3.2).
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if(role==="assistant" && cur){
|
|
161
|
+
const c=m.content;
|
|
162
|
+
const blocks=Array.isArray(c)?c:[];
|
|
163
|
+
let hadText=false, sawToolThisMsg=false;
|
|
164
|
+
for(const b of blocks){
|
|
165
|
+
if(!b||typeof b!=="object") continue;
|
|
166
|
+
if(b.type==="text" && typeof b.text==="string" && b.text.trim()) hadText=true;
|
|
167
|
+
if(b.type==="tool_use"){
|
|
168
|
+
sawToolThisMsg=true;
|
|
169
|
+
const name=String(b.name||"");
|
|
170
|
+
const cmd=b.input&&typeof b.input.command==="string"?b.input.command:"";
|
|
171
|
+
let action="other";
|
|
172
|
+
if(/recall/i.test(name) || /api\/rookie\/recall/.test(cmd)) action="recall";
|
|
173
|
+
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
|
+
else if(/^(Edit|Write|NotebookEdit)$/.test(name)) action="edit";
|
|
177
|
+
else if(name==="AskUserQuestion") action="ask";
|
|
178
|
+
if(action==="recall"){ cur.recall_called=true; cur._recallSeen=true; }
|
|
179
|
+
if(action==="external_search"){ cur.external_search=true; }
|
|
180
|
+
// recall 이후의 첫 실질 행동을 1회만 기록.
|
|
181
|
+
if(cur._recallSeen && action!=="recall" && cur.first_action_after_recall===null){
|
|
182
|
+
cur.first_action_after_recall=action;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// recall 을 받고서 도구 없이 텍스트로만 답하면(=자체 지식/재조사) self_answer 근사.
|
|
187
|
+
if(cur._recallSeen && !sawToolThisMsg && hadText && cur.first_action_after_recall===null){
|
|
188
|
+
cur.first_action_after_recall="self_answer";
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── 실패 클래스 판정 (프록시 — spec §2·§8) ──────────────
|
|
196
|
+
for(const e of episodes){
|
|
197
|
+
const fc=[];
|
|
198
|
+
if(e.recall_signal && !e.recall_called) fc.push("missed_recall");
|
|
199
|
+
if(e.recall_called && (e.first_action_after_recall==="external_search" || (e.external_search && !["consume","edit","ask"].includes(e.first_action_after_recall||""))))
|
|
200
|
+
fc.push("external_search_before_consumption");
|
|
201
|
+
// self_answer(도구 없이 텍스트 답변)는 정상 소비일 수도 있어 이탈로 단정하지 않는다(codex P1) —
|
|
202
|
+
// 명확한 이탈(외부검색)만 ignored 로 센다. 프록시 한계상 보수적으로(false positive < false negative).
|
|
203
|
+
if(e.verified_solution_served && e.first_action_after_recall==="external_search")
|
|
204
|
+
fc.push("ignored_verified_solution");
|
|
205
|
+
if(e.recall_called && e.amnesia_next_prompt) fc.push("user_correction_after_memory_available");
|
|
206
|
+
e.failure_classes=fc;
|
|
207
|
+
delete e._boundary; delete e._recallSeen;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// route schema 는 max 500 — 긴 세션이 400 으로 전량 드롭되지 않게 최근 500 episode 만 보낸다(codex P2).
|
|
211
|
+
process.stdout.write(JSON.stringify(episodes.slice(-500)));
|
|
212
|
+
} catch { process.stdout.write("[]"); }
|
|
213
|
+
' 2>/dev/null)
|
|
214
|
+
|
|
215
|
+
[ -z "$EPISODES" ] && exit 0
|
|
216
|
+
[ "$EPISODES" = "[]" ] && exit 0
|
|
217
|
+
|
|
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)
|
|
219
|
+
[ -z "$BODY" ] && exit 0
|
|
220
|
+
|
|
221
|
+
# 계측 POST — 세션 종료를 지연시키지 않게 background + fail-soft.
|
|
222
|
+
( curl -sS --max-time 8 -X POST \
|
|
223
|
+
-H "Authorization: Bearer $TOKEN" \
|
|
224
|
+
-H "x-rookie-hostname: $(hostname)" \
|
|
225
|
+
-H "x-rookie-user-id: $USER_ID" \
|
|
226
|
+
-H "Content-Type: application/json" \
|
|
227
|
+
-d "$BODY" \
|
|
228
|
+
"$ENDPOINT/api/rookie/metric/recall-episode" >/dev/null 2>&1 || true ) &
|
|
229
|
+
disown 2>/dev/null || true
|
|
230
|
+
|
|
231
|
+
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