therookie 0.4.27 → 0.4.29
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/doctor.js +60 -1
- package/src/commands/save.js +7 -0
- package/src/lib/orphan-extract-core.js +8 -1
- package/src/lib/session-write-marker.js +57 -0
- package/templates/hook/bin/recall-push.sh +48 -23
- package/templates/hook/flush-session.sh +8 -0
- package/templates/hook/lib/orphan-recovery.sh +42 -0
- package/version.json +1 -1
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -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)
|
|
@@ -423,8 +424,12 @@ function printOrphanPending() {
|
|
|
423
424
|
}
|
|
424
425
|
const expiredCount =
|
|
425
426
|
countDirEntries(join(root, 'expired')) + countDirEntries(join(root, 'codex', 'expired'))
|
|
427
|
+
// spec 2026-09-08 §2.4 — 사유별로 나눠 센다. 저장 0건이 쌓이는 것과 비정상 종료가 쌓이는 것은
|
|
428
|
+
// 원인도 조치도 다르다. 한 숫자로 뭉치면 진짜 복구 대기가 묻힌다.
|
|
429
|
+
const allMarkers = [...claude.markers, ...codex.markers]
|
|
430
|
+
const noWrites = allMarkers.filter((m) => m?.reason === 'no_writes').length
|
|
426
431
|
const s = summarizeOrphanBacklog({
|
|
427
|
-
pendingMarkers:
|
|
432
|
+
pendingMarkers: allMarkers,
|
|
428
433
|
expiredCount,
|
|
429
434
|
nowMs: Date.now(),
|
|
430
435
|
})
|
|
@@ -443,11 +448,65 @@ function printOrphanPending() {
|
|
|
443
448
|
)
|
|
444
449
|
}
|
|
445
450
|
}
|
|
451
|
+
if (noWrites > 0) {
|
|
452
|
+
console.log(`— 저장 0건 세션: ${noWrites}건 대기 — 그 세션의 결정·사실이 아직 어디에도 없습니다. rookie orphan-extract 로 확인하세요.`)
|
|
453
|
+
}
|
|
446
454
|
if (s.expiredCount > 0) {
|
|
447
455
|
console.log(`— orphan expired(손실): 누적 ${s.expiredCount}건 — TTL 초과로 tail 미추출 상태로 만료된 세션`)
|
|
448
456
|
}
|
|
449
457
|
}
|
|
450
458
|
|
|
459
|
+
// spec 2026-09-08 §5.1 — 회상 푸시 무주입률. recall-push.sh 가 로컬에 남긴 계측을 읽는다.
|
|
460
|
+
// 서버 전송 없음. 파일이 없으면(아직 안 돌았거나 구버전) 침묵한다.
|
|
461
|
+
export function summarizeRecallPush(lines, max = 1000) {
|
|
462
|
+
const rows = []
|
|
463
|
+
// 파일은 append 전용이라 최신이 뒤에 온다 — 뒤에서부터 max 줄만 집계한다.
|
|
464
|
+
for (let i = lines.length - 1; i >= 0 && rows.length < max; i--) {
|
|
465
|
+
try {
|
|
466
|
+
const o = JSON.parse(lines[i])
|
|
467
|
+
if (o && typeof o.injected === 'number') rows.push(o)
|
|
468
|
+
} catch {
|
|
469
|
+
/* 깨진 줄은 건너뛴다 */
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (rows.length === 0) return null
|
|
473
|
+
const silent = rows.filter((r) => r.injected === 0).length
|
|
474
|
+
// 중앙값은 chars 기준이다 — 컨텍스트 예산은 문자·토큰 감각이고, 한국어는 바이트가 3배라
|
|
475
|
+
// 바이트로 재면 합격 기준(§4.1)이 왜곡된다. bytes 는 파일에만 남긴다.
|
|
476
|
+
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)]
|
|
478
|
+
const epochs = new Set(rows.map((r) => String(r.epoch ?? '')))
|
|
479
|
+
return {
|
|
480
|
+
turns: rows.length,
|
|
481
|
+
silent,
|
|
482
|
+
silentRatio: silent / rows.length,
|
|
483
|
+
medianChars: median,
|
|
484
|
+
epochs: epochs.size,
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function printRecallPush() {
|
|
489
|
+
const dir = join(homedir(), '.rookie', 'recall-push')
|
|
490
|
+
const f = join(dir, 'metrics.jsonl')
|
|
491
|
+
// 훅이 크기 초과 시 .1 로 로테이트한다 — 직후에는 현재 파일이 거의 비어 있어
|
|
492
|
+
// 이것만 읽으면 최근 1000턴을 과소 집계한다(codex 코드검토 R2 P1). 앞에 이어 붙인다.
|
|
493
|
+
const rotated = f + '.1'
|
|
494
|
+
if (!existsSync(f) && !existsSync(rotated)) return
|
|
495
|
+
let s = null
|
|
496
|
+
try {
|
|
497
|
+
const read = (p) => (existsSync(p) ? readFileSync(p, 'utf8').split('\n').filter(Boolean) : [])
|
|
498
|
+
s = summarizeRecallPush([...read(rotated), ...read(f)])
|
|
499
|
+
} catch {
|
|
500
|
+
console.log('— 회상 푸시: 계측 읽기 실패')
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
if (!s) return
|
|
504
|
+
const pct = Math.round(s.silentRatio * 100)
|
|
505
|
+
console.log(
|
|
506
|
+
`— 회상 푸시: 최근 ${s.turns}턴 중 무주입 ${s.silent}건(${pct}%), 주입 길이 중앙값 ${s.medianChars}자, epoch ${s.epochs}개`
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
|
|
451
510
|
// B6-3 (spec 2026-07-11 §B6): 세션 identity pinning 위반 가시화 — 세션 도중 계정 전환으로
|
|
452
511
|
// flush 가 중단된 이력. Stop hook(flush-session.sh)이 남긴 로그를 집계한다. 0건이면 침묵.
|
|
453
512
|
function printIdentityMismatch() {
|
package/src/commands/save.js
CHANGED
|
@@ -13,6 +13,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
13
13
|
import { readFileSync } from 'node:fs'
|
|
14
14
|
import { loadConfig, getHostname } from '../config.js'
|
|
15
15
|
import { apiPost } from '../api.js'
|
|
16
|
+
import { recordSessionWrite } from '../lib/session-write-marker.js'
|
|
16
17
|
|
|
17
18
|
// 저장된 층을 **빠짐없이** 화면에 센다. 여기 없는 층은 서버가 저장해도 0건처럼 보인다 —
|
|
18
19
|
// 2026-09-02 저장에서 durable fact 3건이 그렇게 안 보였고, metric·recall 로 따로 확인해야
|
|
@@ -116,6 +117,12 @@ export async function runSave(flags) {
|
|
|
116
117
|
throw err
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
// spec 2026-09-08 §2.4 — 이 세션에 저장이 있었다는 로컬 마커. Stop hook 이 없으면 미추출 큐에 넣는다.
|
|
121
|
+
// --session 미전달이면 아무것도 안 한다(기존 호출자 호환).
|
|
122
|
+
if (res.all_committed !== false) {
|
|
123
|
+
recordSessionWrite(typeof flags.session === 'string' ? flags.session : '')
|
|
124
|
+
}
|
|
125
|
+
|
|
119
126
|
if (flags.json) {
|
|
120
127
|
// 서버 raw 응답 그대로 — 재가공 없음. SKILL 은 summary.working_cards.ids[0]·superseded_count 를 읽는다.
|
|
121
128
|
console.log(JSON.stringify(res))
|
|
@@ -32,6 +32,9 @@ export function listPendingExtractions({ orphanRoot }) {
|
|
|
32
32
|
sid,
|
|
33
33
|
runtime,
|
|
34
34
|
transcriptPath: m.transcript_path || '',
|
|
35
|
+
// spec 2026-09-08 §2.4 — 'no_writes'(저장 0건) 와 비정상 종료를 구분해 표시한다.
|
|
36
|
+
// 섞이면 진짜 복구 대기가 묻힌다.
|
|
37
|
+
reason: m.reason || 'crash',
|
|
35
38
|
recoveredAt: m.recovered_at || null,
|
|
36
39
|
// codex marker 는 codex 서브루트에 산다 — marker 명령이 이 루트를 겨냥해야
|
|
37
40
|
// 큐에서 실제로 빠진다(코드검토 P2). orphan-recovery.sh 의 ROOKIE_ORPHAN_DIR 규약.
|
|
@@ -101,7 +104,11 @@ export function buildDraft(messages, { userMax = 300, assistantMax = 400, pathCa
|
|
|
101
104
|
export function renderDraft(entry, draft) {
|
|
102
105
|
const lines = []
|
|
103
106
|
const clean = (s) => redactDraftText(s).redacted.replace(/\s+/g, ' ').trim()
|
|
104
|
-
|
|
107
|
+
// spec 2026-09-08 §2.4 — 사유를 앞에 붙인다. 저장 0건과 비정상 종료는 봐야 할 것이 다르다.
|
|
108
|
+
const why = entry.reason === 'no_writes' ? '저장 0건' : '비정상 종료'
|
|
109
|
+
lines.push(
|
|
110
|
+
`◦ [${entry.runtime}] 세션 ${hashId8(entry.sid)} · ${why} (recovered ${entry.recoveredAt ?? 'unknown'})`
|
|
111
|
+
)
|
|
105
112
|
if (draft.lastUser) lines.push(` 마지막 user: ${clean(draft.lastUser)}`)
|
|
106
113
|
if (draft.lastAssistant) lines.push(` 마지막 assistant: ${clean(draft.lastAssistant)}`)
|
|
107
114
|
if (draft.filePaths.length) lines.push(` 파일 경로: ${draft.filePaths.map((p) => clean(p)).join(' · ')}`)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// spec 2026-09-08-session-continuity-two-gaps.md §2.4 — 저장 0건 세션 감지용 로컬 마커.
|
|
2
|
+
//
|
|
3
|
+
// 서버는 그 세션에 저장이 있었는지 모른다 — `rookie save` 가 세션 식별자를 보내지 않아
|
|
4
|
+
// batch-mutate 를 세션에 귀속시킬 수 없다. 로컬에서 아는 편이 싸고 정확하다.
|
|
5
|
+
//
|
|
6
|
+
// 키에 epoch 를 넣는 이유: session_id 는 /clear·compact 후에도 같은 값이다(그래서 recall-push 가
|
|
7
|
+
// 구간 경계를 skill-gate 의 .epoch 로 따로 감지한다). 세션 단위 키만 쓰면 clear 이전 구간에
|
|
8
|
+
// 저장이 있었다는 이유로 이후 구간이 통째로 면제된다.
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
|
|
14
|
+
export function markerDir() {
|
|
15
|
+
return process.env.ROOKIE_SESSION_WRITES_DIR || join(homedir(), '.rookie', 'session-writes')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function gateDir() {
|
|
19
|
+
return process.env.ROOKIE_SKILL_GATE_DIR || join(homedir(), '.rookie', 'skill-gate')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 세션의 현재 구간 epoch. 파일이 없으면 빈 문자열(현행 recall-push 관행과 동일). */
|
|
23
|
+
export function readEpoch(sessionId) {
|
|
24
|
+
if (!sessionId) return ''
|
|
25
|
+
try {
|
|
26
|
+
const key = createHash('sha256').update(sessionId).digest('hex')
|
|
27
|
+
return String(readFileSync(join(gateDir(), key + '.epoch'), 'utf8')).trim()
|
|
28
|
+
} catch {
|
|
29
|
+
return ''
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function markerKey(sessionId, epoch) {
|
|
34
|
+
return createHash('sha256').update(`${sessionId}:${epoch}`).digest('hex')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 저장 성공 기록. sessionId 미전달이면 조용히 넘어간다(기존 호출자 호환). */
|
|
38
|
+
export function recordSessionWrite(sessionId) {
|
|
39
|
+
if (!sessionId) return false
|
|
40
|
+
try {
|
|
41
|
+
const dir = markerDir()
|
|
42
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
43
|
+
writeFileSync(join(dir, markerKey(sessionId, readEpoch(sessionId))), '', { mode: 0o600 })
|
|
44
|
+
return true
|
|
45
|
+
} catch {
|
|
46
|
+
return false // 마커 실패가 저장을 되돌리지는 않는다
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function hasSessionWrite(sessionId, epoch) {
|
|
51
|
+
if (!sessionId) return true // 판정 불가 — 큐에 넣지 않는다(오탐보다 무동작이 안전)
|
|
52
|
+
try {
|
|
53
|
+
return existsSync(join(markerDir(), markerKey(sessionId, epoch ?? readEpoch(sessionId))))
|
|
54
|
+
} catch {
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# UserPromptSubmit hook — 무조건 회상 푸시 (unconditional recall push).
|
|
3
|
-
# 설계: docs/superpowers/specs/2026-
|
|
3
|
+
# 설계: docs/superpowers/specs/2026-09-08-recall-always-inject-redesign.md (선행 2026-08-08)
|
|
4
4
|
#
|
|
5
5
|
# - 발동 조건 없음(E1 결정 제거): 매 사용자 발화를 그대로 recall 질의로 던진다. 빈 발화만 skip —
|
|
6
|
-
# 길이 하한·낱말 필터는 결정의 재도입이라 두지
|
|
7
|
-
# -
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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 회피).
|
|
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 무주입
|
|
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||"
|
|
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
|
|
70
|
-
// x-rookie-hostname + repo_path 동시 전달 = workspace 격리 기억 가시성 계약(
|
|
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.
|
|
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)
|
|
112
|
+
if(!cands.length){ metric("B",results.length,0,""); return; }
|
|
88
113
|
|
|
89
|
-
// 주입 문구는 지시가 아니라
|
|
90
|
-
|
|
91
|
-
let msg="🧠
|
|
114
|
+
// 주입 문구는 지시가 아니라 자료다. 관련도를 보증하지 않으므로 라벨에 명시하고,
|
|
115
|
+
// 날짜·출처를 앞에 놓아 ITEM 절삭에 잘려 나가지 않게 한다.
|
|
116
|
+
let msg="🧠 저장소 항목 (질의 연동, 관련도 미보증):";
|
|
92
117
|
const used=[];
|
|
93
|
-
for(
|
|
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,
|
|
98
|
-
const item="\n
|
|
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
|
});
|
|
@@ -301,6 +301,14 @@ if [ "$HTTP_CODE" = "200" ]; then
|
|
|
301
301
|
if command -v orphan_mark_flushed >/dev/null 2>&1 && [ "${MESSAGE_COUNT:-0}" -gt 0 ] 2>/dev/null; then
|
|
302
302
|
orphan_mark_flushed "$SESSION_ID" "stop" "$MESSAGE_COUNT" || true
|
|
303
303
|
fi
|
|
304
|
+
# spec 2026-09-08 §2.4 — 이 세션 구간에 `rookie save` 성공이 한 번도 없었으면 결정·사실이
|
|
305
|
+
# 어디에도 남지 않는다. transcript 는 있으므로 다음 세션이 건지도록 미추출 큐에 올린다.
|
|
306
|
+
# 내용 없는 세션(MESSAGE_COUNT=0)은 제외 — 건질 게 없다.
|
|
307
|
+
if command -v orphan_session_had_write >/dev/null 2>&1 \
|
|
308
|
+
&& [ "${MESSAGE_COUNT:-0}" -gt 0 ] 2>/dev/null \
|
|
309
|
+
&& ! orphan_session_had_write "$SESSION_ID"; then
|
|
310
|
+
orphan_mark_no_writes "$SESSION_ID" "$TRANSCRIPT_PATH" || true
|
|
311
|
+
fi
|
|
304
312
|
else
|
|
305
313
|
# §17 H-2: 큐 적재 + 1회 chain drain (일시적 깜빡임 회복)
|
|
306
314
|
if command -v queue_enqueue >/dev/null 2>&1; then
|
|
@@ -84,6 +84,48 @@ orphan_mark_baseline() {
|
|
|
84
84
|
"$(printf '{"status":"baseline_skipped","reason":"%s"}' "$reason")"
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
# spec 2026-09-08-session-continuity-two-gaps.md §2.4 — 저장 0건 세션을 미추출 큐에 넣는다.
|
|
88
|
+
#
|
|
89
|
+
# 세션 안에서 `rookie save` 가 한 번도 성공하지 않았으면 그 세션의 결정·사실은 어디에도 없다.
|
|
90
|
+
# transcript 는 남아 있으므로 다음 세션이 orphan-extract 로 건질 수 있게 큐에 올린다.
|
|
91
|
+
#
|
|
92
|
+
# 마커 키에 epoch 를 넣는 이유: session_id 는 /clear·compact 후에도 같은 값이라(그래서 recall-push 가
|
|
93
|
+
# 구간 경계를 .epoch 로 따로 본다), 세션 단위 키만 쓰면 clear 이전 구간의 저장이 이후 구간을 면제한다.
|
|
94
|
+
_orphan_session_write_key() {
|
|
95
|
+
local sid="$1" gate_dir epoch
|
|
96
|
+
gate_dir="${ROOKIE_SKILL_GATE_DIR:-$HOME/.rookie/skill-gate}"
|
|
97
|
+
local sid_hash
|
|
98
|
+
sid_hash="$(printf '%s' "$sid" | shasum -a 256 2>/dev/null | cut -d" " -f1)"
|
|
99
|
+
epoch="$(cat "$gate_dir/$sid_hash.epoch" 2>/dev/null | tr -d "[:space:]")"
|
|
100
|
+
printf '%s:%s' "$sid" "$epoch" | shasum -a 256 2>/dev/null | cut -d" " -f1
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# 이 세션 구간에 저장이 있었는가. 판정 불가(빈 sid·shasum 없음)면 "있었다"로 본다 —
|
|
104
|
+
# 무동작이 오탐보다 안전하다.
|
|
105
|
+
orphan_session_had_write() {
|
|
106
|
+
local sid="$1"
|
|
107
|
+
[ -n "$sid" ] || return 0
|
|
108
|
+
command -v shasum >/dev/null 2>&1 || return 0
|
|
109
|
+
local dir="${ROOKIE_SESSION_WRITES_DIR:-$HOME/.rookie/session-writes}"
|
|
110
|
+
local key
|
|
111
|
+
key="$(_orphan_session_write_key "$sid")"
|
|
112
|
+
[ -n "$key" ] || return 0
|
|
113
|
+
[ -f "$dir/$key" ]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# 저장 0건이면 pending-extraction 에 reason=no_writes 로 올린다.
|
|
117
|
+
# 비정상 종료(crash) 사유와 섞이지 않게 reason 을 남긴다 — 섞이면 진짜 복구 대기가 묻힌다.
|
|
118
|
+
orphan_mark_no_writes() {
|
|
119
|
+
local sid="$1" transcript="${2:-}"
|
|
120
|
+
[ -n "$sid" ] || return 1
|
|
121
|
+
# 이미 추출을 마쳤거나 대기 중이면 덮지 않는다.
|
|
122
|
+
[ -f "$(_orphan_client_extracted_dir)/$sid.json" ] && return 0
|
|
123
|
+
[ -f "$(_orphan_pending_dir)/$sid.json" ] && return 0
|
|
124
|
+
_orphan_write_marker "$(_orphan_pending_dir)" "$sid" \
|
|
125
|
+
"$(printf '{"status":"pending_extraction","reason":"no_writes","transcript_path":"%s","recovered_at":"%s"}' \
|
|
126
|
+
"$transcript" "$(date -u +%Y-%m-%dT%H:%M:%SZ)")"
|
|
127
|
+
}
|
|
128
|
+
|
|
87
129
|
# P0.4 — 짧고 내용 없는(low_value) 세션 marker. record_pending 대신 호출 → 재적재 차단.
|
|
88
130
|
orphan_mark_low_value() {
|
|
89
131
|
local sid="$1" reason="${2:-low_value}" mc="${3:-0}"
|
package/version.json
CHANGED