@kaydash9999/banker-plugins 0.15.1 → 0.16.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +60 -0
- package/README.md +31 -9
- package/codex/manifest.json +6 -3
- package/codex/transform-matrix.md +2 -0
- package/commands/progress.md +10 -0
- package/commands/setup.md +1 -1
- package/hooks/graceful-pause.mjs +31 -71
- package/hooks/hooks.json +11 -1
- package/hooks/progress-fallback.mjs +10 -0
- package/hooks/progress.mjs +427 -0
- package/hooks/register.mjs +82 -5
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +235 -122
- package/skills/3d-intro-build/references/NOTICE.md +2 -1
- package/skills/3d-intro-build/references/assemble.mjs +164 -108
- package/skills/3d-intro-build/references/azure-adapter.mjs +115 -29
- package/skills/3d-intro-build/references/curate.mjs +573 -267
- package/skills/3d-intro-build/references/intro-fixes.css +61 -0
- package/skills/3d-intro-build/references/panel-glass.css +55 -0
- package/skills/3d-intro-build/references/preview-lib.mjs +256 -0
- package/skills/3d-intro-build/references/prompts.md +32 -9
- package/skills/3d-intro-build/references/serve.mjs +111 -103
- package/skills/3d-intro-build/references/step-nav.css +59 -0
- package/skills/3d-intro-build/references/step-nav.js +503 -0
- package/skills/3d-intro-build/references/verify-intro.mjs +474 -0
- package/skills/3d-intro-build/references/verify.md +45 -0
- package/skills/3d-intro-build/references/video-pool.mjs +780 -0
- package/skills/3d-intro-setup/SKILL.md +49 -12
- package/skills/3d-intro-setup/references/azure-adapter.mjs +115 -29
- package/skills/3d-intro-setup/references/video-pool.mjs +780 -0
- package/skills/lineage/SKILL.md +12 -8
- package/skills/lineage/lineage.py +190 -59
- package/skills/motion-graphic-make/SKILL.md +73 -28
- package/skills/motion-graphic-make/references/curate.mjs +607 -0
- package/skills/motion-graphic-make/references/preview-lib.mjs +256 -0
- package/skills/motion-graphic-make/references/serve.mjs +131 -0
- package/skills/omc-patch/SKILL.md +143 -0
- package/skills/omc-patch/scripts/omc-patch.mjs +577 -0
- package/skills/remains/SKILL.md +125 -0
- package/skills/remains/scripts/boxes.mjs +448 -0
- package/skills/trouble-shooting/SKILL.md +50 -0
package/skills/lineage/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: lineage
|
|
3
3
|
description: "현재 세션 대화를 카카오톡 스타일의 단일 HTML 로 내보냄. Claude 답변은 한 줄 요약 아래에 원문을 접어 두고, 펼치면 마크다운으로 렌더해 보여 줌. 기본은 세션 모델이 요약을 쓰고 노이즈 턴을 걸러 내며, `--rulebase` 는 모델 없이 규칙만 씀. 여러 세션을 합칠 수 있음. 'lineage'/'대화 export'/'카톡 스타일 html' 시 사용."
|
|
4
4
|
invocation: /lineage
|
|
5
|
-
version: 3.0.
|
|
5
|
+
version: 3.0.2
|
|
6
6
|
schema_version: 1
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -52,7 +52,7 @@ echo "..." | /lineage --from-transcript - # stdin paste
|
|
|
52
52
|
/lineage --keep-trivia # 조작 명령·주입 본문·에코도 남김 (기본 필터 ON). 검토자도 턴을 빼지 못함
|
|
53
53
|
/lineage --keep-tool-only # 도구 전용 turn 도 남김 (기본 ON=제거). 검토자도 그 턴을 빼지 못함
|
|
54
54
|
/lineage --redact-extra "acme-corp,db-pass"
|
|
55
|
-
/lineage --redact-mode mask # abcd**** 부분 마스킹
|
|
55
|
+
/lineage --redact-mode mask # abcd**** 부분 마스킹(검토자 패턴이 찾은 값은 전부 가림)
|
|
56
56
|
/lineage --rebuild-summaries # 캐시 무시하고 재요약 (기본 흐름은 검토 결정 캐시도 무시해 모든 턴을 다시 검토)
|
|
57
57
|
/lineage --purge-cache # 캐시 전부 삭제 후 종료
|
|
58
58
|
/lineage --skip-reviewer # 품질 게이트 끔 (경고)
|
|
@@ -73,6 +73,7 @@ echo "..." | /lineage --from-transcript - # stdin paste
|
|
|
73
73
|
## 실행 절차
|
|
74
74
|
|
|
75
75
|
`--rulebase`, `--purge-cache`, `--help` 가 있으면 아래 1~5 대신 `lineage.py` 를 사용자 인자 그대로 한 번 실행하고 끝낸다(모델 호출 없음).
|
|
76
|
+
`--rulebase` 실행이 `still holds secret-like text` WARN 을 내면 5단계처럼 사용자에게 알린다. 다시 만들 때는 그 값을 `LINEAGE_REDACT_EXTRA` 에 넣고 `--rebuild-summaries` 를 준다(규칙 요약 캐시는 키워드를 모른다).
|
|
76
77
|
그 밖에는 이 순서를 따른다. `<스킬 폴더>` 는 이 SKILL.md 가 있는 폴더의 절대경로다.
|
|
77
78
|
Python 은 3.7 이상을 쓴다(EL8 기본 `python3` 는 3.6 이라 `python3.11` 등).
|
|
78
79
|
|
|
@@ -123,6 +124,7 @@ Python 은 3.7 이상을 쓴다(EL8 기본 `python3` 는 3.6 이라 `python3.11`
|
|
|
123
124
|
5. **마무리** — 출력 HTML 경로를 알린다.
|
|
124
125
|
- 렌더가 성공하면 스크립트가 묶음, 파트 파일, 결정 파일, 게이트 샘플을 지운다. redact 된 세션 본문이 들어 있기 때문이다. 실패(exit 2)면 남겨 둔다.
|
|
125
126
|
- 렌더가 `reviewers dropped N typed user turn(s)` WARN 을 내면, 검토자가 뺀 사용자 입력 턴의 수와 id 를 사용자에게 알린다. 검토자가 기록 속 주입 문장을 따랐을 수 있다.
|
|
127
|
+
- 렌더가 `the page still holds secret-like text (<패턴>=N)` WARN 을 내면, 패턴 이름과 수를 사용자에게 알리고 공유하기 전에 페이지를 확인하라고 말한다. 검토자 패턴이 렌더된 페이지 글에서 다시 찾은 값이다(예: 마크다운으로 꾸민 `**Password**: 값`). 그 값을 `LINEAGE_REDACT_EXTRA` 에 넣어 다시 만들 수 있다(값을 명령줄에 그대로 치면 셸 기록에 남는다). 다시 만들어도 앞서 만든 페이지(`--rulebase` 는 그 옆의 게이트 샘플도)는 그 값을 가진 채 남으므로 지우라고 알린다.
|
|
126
128
|
- 품질 게이트는 1단계나 4단계에 `--reviewer-output <판정 파일>` 을 줄 때만 돈다. 검토자가 이미 모든 턴을 봤으므로 기본 흐름은 샘플을 쓰지 않는다.
|
|
127
129
|
- 판정 파일에는 아직 없는 경로를 준다. 그 자리에 판정 목록이 아닌 것(사용자 파일, 폴더, 판정 모양이 아닌 JSON)이 있으면 스크립트는 샘플을 쓰기 전에 exit 2 로 멈추고 그 파일을 건드리지 않는다. 1단계에 준 경로도 검토자를 띄우기 전에 exit 2 로 멈춘다.
|
|
128
130
|
- 4단계에서 판정 경로를 주었으면 다시 실행할 때도 같은 `--reviewer-output` 을 준다(묶음에는 1단계 경로만 남는다). 게이트 샘플이 남아 있는데 판정 경로도 `--skip-reviewer` 도 없으면 exit 2(`left by a gated run`)다. 판정을 읽지 않고 지나가지 않게 하기 위해서다.
|
|
@@ -219,7 +221,7 @@ Schema-tolerant 파서: 미지 record type → stderr WARN + 다음 line. `v1`/`
|
|
|
219
221
|
|
|
220
222
|
1차 `detect-secrets>=1.5`(pip 설치 시), 미설치 → 내장 fallback + WARN.
|
|
221
223
|
내장: AWS IAM(AKIA/ASIA)·GitHub PAT·Slack·JWT·private key·한/영 평문 password·Shannon entropy≥4.5.
|
|
222
|
-
확장 `--redact-extra "k1,k2"`. 부분 마스킹 `--redact-mode mask
|
|
224
|
+
확장 `--redact-extra "k1,k2"`. 부분 마스킹 `--redact-mode mask`(페이지 패턴만. 검토자 패턴이 찾은 값은 늘 전부 가린다).
|
|
223
225
|
|
|
224
226
|
### 5. Render
|
|
225
227
|
|
|
@@ -259,14 +261,15 @@ WARN 발견 시 stderr 보고, 출력은 그대로 작성(인간 검토 가능).
|
|
|
259
261
|
|
|
260
262
|
기본 흐름에서는 `--reviewer-output` 을 줄 때만 `--apply-review` 가 샘플을 쓴다(묶음 옆 `work/.lineage-review.reviewer-input.json`). 샘플마다 `id`(묶음의 턴 id)와 `key`(턴 id, 앞뒤 본문, 요약에서 만든 12자 지문)가 더 붙는다. `original_detail` 은 검토자가 본 앞뒤 본문(파트 파일의 `preview`), `generated_summary` 는 검토자 요약, 없으면 가린 본문에서 만든 규칙 요약(파트 파일의 `rule.summary`)이다. 둘 다 파트 파일과 같은 기준으로 가린다.
|
|
261
263
|
기본 흐름의 판정은 샘플마다 하나씩이고, 판정마다 그 샘플의 `id` 와 `key` 를 그대로 담는다. 빠진 샘플, 샘플에 없는 `idx`, 같은 `idx` 의 반복, 다른 `id` 나 `key`(다른 턴이나 결정을 고치기 전 샘플에 대한 답)가 있으면 exit 2 다. 그때 판정에 든 `recoverable: false` 항목의 이유를 함께 찍는다. `--rulebase` 의 critic 계약은 2.x 그대로다.
|
|
262
|
-
같은 턴이면 다시 실행해도 같은 턴을 샘플로 뽑는다. 결정을 고치면 그 샘플의 내용과 `key` 가 바뀐다. 읽은 판정 파일은 `<이름>.used` 로 옮겨, 다음 실행이 옛 판정을 다시 읽지 않는다. 샘플이 바뀌면 그 전에 있던 판정 파일(앞선 `--rulebase` 게이트가 남긴 것 등)을 읽지 않고 옮긴다. 판정 경로에 판정 목록(`idx` 가 든 객체가 하나라도 있는 JSON 배열)이 아닌 파일이나 폴더가 있으면 샘플을 쓰기 전에 exit 2 로 멈추고 옮기지 않는다. `<이름>.used` 에 판정이 아닌 파일이 있으면 덮지 않고, 비어 있거나 앞선 판정이 든 `<이름>.used.1` 같은 이름으로 옮긴다. 샘플을 쓰지 못하면 판정을 기다리지 않고 exit 2 다. PASS 면 샘플을 지운다. `--rulebase` 는 2.x
|
|
264
|
+
같은 턴이면 다시 실행해도 같은 턴을 샘플로 뽑는다. 결정을 고치면 그 샘플의 내용과 `key` 가 바뀐다. 읽은 판정 파일은 `<이름>.used` 로 옮겨, 다음 실행이 옛 판정을 다시 읽지 않는다. 샘플이 바뀌면 그 전에 있던 판정 파일(앞선 `--rulebase` 게이트가 남긴 것 등)을 읽지 않고 옮긴다. 판정 경로에 판정 목록(`idx` 가 든 객체가 하나라도 있는 JSON 배열)이 아닌 파일이나 폴더가 있으면 샘플을 쓰기 전에 exit 2 로 멈추고 옮기지 않는다. `<이름>.used` 에 판정이 아닌 파일이 있으면 덮지 않고, 비어 있거나 앞선 판정이 든 `<이름>.used.1` 같은 이름으로 옮긴다. 샘플을 쓰지 못하면 판정을 기다리지 않고 exit 2 다. PASS 면 샘플을 지운다. `--rulebase` 는 2.x 처럼 무작위 샘플을 쓰고 판정 파일을 옮기지 않는다. 샘플은 critic 모델이 읽으므로 검토자 패턴까지 값 전체를 가린 뒤 500자로 자른다.
|
|
263
265
|
|
|
264
266
|
## Cache & Secret Hygiene
|
|
265
267
|
|
|
266
268
|
캐시에 저장되는 텍스트는 redact() 적용 후의 redacted summary 만이다(평문 secret 미저장).
|
|
267
269
|
검토자 결정 캐시(`<uuid>-<digest>-llm.json`)도 redact 하고 자른 요약만 담는다. digest 는 redact 된 본문, 그 턴의 규칙 결정, keep 플래그의 해시다. 본문이 바뀌면 다시 검토한다. `keep: null` 은 그 실행의 규칙을 따른다는 뜻이라, 규칙 결정이나 keep 플래그가 다른 실행(`--keep-trivia` 실행과 기본 실행, `--all-sessions` 실행과 단일 세션 실행)에서도 다시 검토한다.
|
|
268
270
|
검토 묶음과 파트 파일은 redact 된 본문만 담고 0600 으로 쓴다(임시 파일은 mkstemp). 렌더가 성공하면 스크립트가 지운다(실행 절차 5).
|
|
269
|
-
|
|
271
|
+
stdin(`--from-transcript -`)으로 넣은 기록의 턴 id 는 본문을 캐시 폴더의 무작위 키(`stdin-id.key`, 0600)로 HMAC 한 값이다. 같은 머신에서는 id 가 그대로라 캐시를 다시 쓰고, 파트 파일을 읽는 모델은 id 로 본문을 맞혀 볼 수 없다.
|
|
272
|
+
검토자가 읽는 파트 파일은 `--redact-mode mask` 여도 가린 자리에 값의 일부를 남기지 않는다. 페이지에는 없는 패턴(Anthropic, OpenAI, Google 키, 따옴표 없는 password·비밀번호, 16진 키, URL 안 자격 증명, Bearer 토큰, Basic 인증 헤더(따옴표 친 키, 첨자 대입, `=>`, 헤더 설정 호출과 HAR 의 name/value, nginx 처럼 공백 뒤 따옴표 포함), curl `-u` 의 사용자와 비밀번호(붙여 쓴 `-uUSER:PW`, `-su`, `-4u` 같은 묶음, 따로 감싼 `"USER":"PW"`, 따옴표 친 명령 속의 이스케이프한 따옴표(`\"USER:PW\"`, 여러 겹 JSON), bash 의 `'\''` 와 `shlex.quote` 의 `'"'"'`, cmd 의 `^"`, PowerShell 의 `` `" ``, bash 의 `$'…'`, `${API_USER}` 같은 변수 사용자와 변수에 이은 사용자(`"$USER"@corp.com`, `"${ENV}"-deployer`), 인라인 코드나 괄호 안, 인자 목록, 빈 사용자나 빈 비밀번호 포함))도 파트 파일에서는 가린다. URL 의 비밀번호는 호스트 앞 마지막 `@` 까지 가린다. 이 패턴은 엔트로피 규칙보다 먼저 적용해 키를 통째로 가린다. 3.0.2 부터 검토자 패턴은 모든 페이지(기본 흐름과 `--rulebase`)에도 건다. 페이지에서도 검토자 패턴이 찾은 값은 `--redact-mode mask` 여도 전부 가리고, 페이지 패턴만 mask 를 따른다. 본문, 요약, 접힌 요약, 에이전트 이름, 세션 이름, 기본 흐름 묶음에 걸고, 도구 이름은 `--redact-extra` 키워드를 가린다. 렌더 뒤 페이지 글에 검토자 패턴을 다시 돌려 남은 것이 있으면 WARN 을 낸다. `--rulebase` 출력은 비밀이 없는 턴에서 2.x 와 같다. 비밀을 가린 턴은 2.x 와 다를 수 있다.
|
|
270
273
|
파트 파일의 규칙 요약은 가린 본문에서 만든다. 요약을 자른 자리에 비밀이 걸쳐도 조각이 남지 않는다.
|
|
271
274
|
`--redact-extra` 키워드 목록은 묶음과 파트 파일에 쓰지 않고, 가린 수만 `custom` 으로 센다. 파트 파일의 본문, 요약, 에이전트 이름, 도구 이름에서는 키워드를 가린다. 묶음은 에이전트 이름, 도구 이름, 세션 제목, 출력 경로를 페이지처럼 그대로 둔다. 기록 파일 이름(세션 id)도 캐시 위치를 정하는 값이라 그대로 둔다(0600, 렌더 성공 뒤 삭제).
|
|
272
275
|
결정 파일은 세션이 파일 도구로 써서 umask 권한으로 생긴다. `--apply-review` 가 읽을 때 0600 으로 바꾼다.
|
|
@@ -321,10 +324,10 @@ pip install 'detect-secrets>=1.5' # (선택) 강한 redaction
|
|
|
321
324
|
- 기본 흐름은 파트(40턴 이하, 고르게 나눔)마다 검토 에이전트 1개가 돈다. 이 저장소 실측은 40턴 파트 하나에 약 11만 토큰과 7분이었다. 긴 세션은 `--last N` 으로 범위를 줄이거나 `--rulebase` 를 쓴다.
|
|
322
325
|
- Claude Code 검토자(`Plan`)는 Write·Edit 가 도구로 막혀 있지만 Bash 는 있다. 셸로 쓰지 않는 것은 지시로 선다. banker 는 에이전트 정의를 배포하지 않아(모든 표면이 두 런타임 대상) 도구를 더 막을 수 없다.
|
|
323
326
|
- Codex 의 `workspace-write` 샌드박스에서는 `~/.cache` 에 쓸 수 없어 이번 실행의 검토 결정을 캐시에 쓰지 못한다(WARN 한 번). 앞선 실행이 남긴 결정은 읽으므로, 그 결정이 없는 턴만 다시 검토한다.
|
|
324
|
-
-
|
|
325
|
-
- 규칙 요약 캐시는 본문만으로 키를 잡는다. `--redact-mode` 나 `--redact-extra` 를 바꿔 다시 돌린 `--rulebase` 는 앞선 실행의 요약을 쓸 수 있다(2.x 동작 그대로). 기본 흐름의 묶음은 요약을 매번 새로 만든다.
|
|
327
|
+
- 규칙 요약은 가린 본문에서 자른다(요약기 버전 3). 0.15.1 까지 만든 요약 캐시는 쓰지 않는다. 그 캐시에는 평문 조각이 남아 있을 수 있어 `--purge-cache` 로 지운다.
|
|
328
|
+
- 규칙 요약 캐시는 본문만으로 키를 잡는다. `--redact-mode` 나 `--redact-extra` 를 바꿔 다시 돌린 `--rulebase` 는 앞선 실행의 요약을 쓸 수 있다(2.x 동작 그대로). 기본 흐름의 묶음은 요약을 매번 새로 만든다. 묶음 형식은 `lineage-review/2` 라 0.15.1 이 만든 묶음은 `--emit-review` 부터 다시 한다.
|
|
326
329
|
- 검토자는 긴 턴의 앞뒤만 본다(`clipped: true`). 가운데에만 있는 결론은 놓칠 수 있다.
|
|
327
|
-
- 검토자 패턴은 흔한 모양만 가린다. `/` 가 든 URL 비밀번호와 키 이름이 100자를 넘는 16진 키는 가리지 못한다(값이 길고 무작위면 엔트로피 규칙이 가릴 수 있다). curl `-u` 패턴은 값 전체가 `docker -u 1000:1000` 같은 uid:gid, `rsync -u host:/path`, `date -u +%H:%M` 같은 날짜 형식(`+%` 뒤에 글자가 오면 나머지는 보지 않는다), 양쪽 모두 변수인 꼴(`$UID:$GID`, `${UID}:${GID}`, `%USER%:%PASS%`, `$(id -u):$(id -g)`), 기본값이 콜론 없는 경로(`/`, `~`, `.` 로 시작)인 변수(`mktemp -u "${TMPDIR:-/tmp}/x"`)일 때만 둔다. 이스케이프한 따옴표 안에서도 같다. 다른 명령의 `-u 이름:값`(`-u root:root`, `ps -u postgres:postgres`, `rsync -avu host:dir/`, `ls -lu a:bcd`), 기본값이 경로가 아닌 변수(`${UID:-1000}:${GID:-1000}`, `${API_KEY:-…}`)는 가린다. 숫자만으로 된 이름과 비밀번호, `/` 로 시작하는 비밀번호, `$` 로 시작해 변수처럼 보이는 비밀번호(`$USER:$ecret`), `-U` 와 `--proxy-user`, 붙여 쓴 묶음(`-suUSER:PW`), `-u=USER:PW`, 공백이 든 비밀번호의 둘째 낱말부터, 인자 목록에서 다른 종류의 따옴표가 든 비밀번호(`'-u', "USER:P'W"`), 사용자 안에서 따옴표 뒤에 `-eu`, `--user` 같은 플래그 꼴과 따옴표가 오는 꼴(`"$USER"-eu":PW"`), requests 의 `auth=(...)`, value 가 name 보다 앞에 온 HAR 은 가리지 못한다. 백틱 명령 치환(`` `id -u`:`id -g` ``)과 `\$(id -u):\$(id -g)` 는 가운데 일부가 가려진다(3.0.0 과 같다).
|
|
330
|
+
- 검토자 패턴은 흔한 모양만 가린다. `/` 가 든 URL 비밀번호와 키 이름이 100자를 넘는 16진 키는 가리지 못한다(값이 길고 무작위면 엔트로피 규칙이 가릴 수 있다). curl `-u` 패턴은 값 전체가 `docker -u 1000:1000` 같은 uid:gid, `rsync -u host:/path`, `date -u +%H:%M` 같은 날짜 형식(`+%` 뒤에 글자가 오면 나머지는 보지 않는다), 양쪽 모두 변수인 꼴(`$UID:$GID`, `${UID}:${GID}`, `%USER%:%PASS%`, `$(id -u):$(id -g)`), 기본값이 콜론 없는 경로(`/`, `~`, `.` 로 시작)인 변수(`mktemp -u "${TMPDIR:-/tmp}/x"`), 앞선 가림이 남긴 표식(`-u [REDACTED:JWT]`)일 때만 둔다. 이스케이프한 따옴표 안에서도 같다. 다른 명령의 `-u 이름:값`(`-u root:root`, `ps -u postgres:postgres`, `rsync -avu host:dir/`, `ls -lu a:bcd`), 기본값이 경로가 아닌 변수(`${UID:-1000}:${GID:-1000}`, `${API_KEY:-…}`)는 가린다. 숫자만으로 된 이름과 비밀번호, `/` 로 시작하는 비밀번호, `$` 로 시작해 변수처럼 보이는 비밀번호(`$USER:$ecret`), `-U` 와 `--proxy-user`, 붙여 쓴 묶음(`-suUSER:PW`), `-u=USER:PW`, 공백이 든 비밀번호의 둘째 낱말부터, 인자 목록에서 다른 종류의 따옴표가 든 비밀번호(`'-u', "USER:P'W"`), 사용자 안에서 따옴표 뒤에 `-eu`, `--user` 같은 플래그 꼴과 따옴표가 오는 꼴(`"$USER"-eu":PW"`), requests 의 `auth=(...)`, value 가 name 보다 앞에 온 HAR 은 가리지 못한다. 백틱 명령 치환(`` `id -u`:`id -g` ``)과 `\$(id -u):\$(id -g)` 는 가운데 일부가 가려진다(3.0.0 과 같다). 값이 `/`, `~`, `$` 로 시작하는 password 값(`$PWD:/workspace`, `OLDPWD=/home/...`, `password=$DB_PASSWORD`), 이미 가린 표식, 값 전체가 마스크 꼴(`abcd****wxyz`, `****`)인 값은 두고, `PGPASSWORD=`, `MYSQL_PWD=` 의 값은 가린다. `password=os.environ[...]`, `암호: AES-256-GCM` 처럼 코드나 이름인 값과 `sk-` 로 시작하는 긴 세션 이름은 지나치게 가린다. Basic 인증 패턴은 `HTTP_AUTHORIZATION` 처럼 앞에 `_` 가 붙은 이름, 구분자 없는 `Authorization Basic …`, 백틱이나 대괄호(`["Basic …"]`) 안의 값을 가리지 못한다.
|
|
328
331
|
- 페이지 패턴 가운데 JWT 와 개인 키 패턴은 같은 머리(`eyJ`, `-----BEGIN`)가 긴 줄에 되풀이되면 시간이 줄 길이의 제곱으로 는다(2.x 동작 그대로). 기본 흐름은 턴마다 여러 번 가리므로 그만큼 더 걸린다.
|
|
329
332
|
- 마크다운의 **중첩 리스트는 평탄화**되고 각주는 미지원.
|
|
330
333
|
- 사용자가 질문에 래퍼 블록(`<task-notification>…`)을 **인용**하면 문장은 남고 그 블록만 사라진다.
|
|
@@ -340,6 +343,7 @@ pip install 'detect-secrets>=1.5' # (선택) 강한 redaction
|
|
|
340
343
|
└── test_lineage.py (회귀 테스트, repo 전용 · npm 미포함)
|
|
341
344
|
|
|
342
345
|
~/.cache/lineage/
|
|
346
|
+
├── stdin-id.key (stdin 턴 id 의 HMAC 키, 0600. --purge-cache 로 지우면 stdin id 가 바뀐다)
|
|
343
347
|
└── <schema_version>/<session_id>/<turn_uuid>-<digest>.txt (규칙 요약, 0700/0600)
|
|
344
348
|
<turn_uuid>-<digest>-llm.json (검토자 결정)
|
|
345
349
|
|
|
@@ -11,6 +11,7 @@ Auto-discovery assumes Claude Code's ~/.claude/projects/<encoded-cwd>/ layout.
|
|
|
11
11
|
"""
|
|
12
12
|
import argparse
|
|
13
13
|
import hashlib
|
|
14
|
+
import hmac
|
|
14
15
|
import html
|
|
15
16
|
import json
|
|
16
17
|
import math
|
|
@@ -27,13 +28,13 @@ if sys.version_info < (3, 7):
|
|
|
27
28
|
sys.exit(2)
|
|
28
29
|
|
|
29
30
|
SCHEMA_VERSION = 2 # cache dir schema (v2: redacted summaries only)
|
|
30
|
-
SUMMARIZER_VERSION =
|
|
31
|
+
SUMMARIZER_VERSION = 3 # bump when summarize_turn logic changes (B-3 cache key); 3: cut from redacted text
|
|
31
32
|
USER_FOLD = 400 # collapse user messages longer than this (E-1)
|
|
32
33
|
ECHO_ASK = 40 # echo-exchange: user question length ceiling (A-4)
|
|
33
34
|
ECHO_REPLY = 5 # echo-exchange: assistant reply length ceiling (A-4)
|
|
34
35
|
_BLOCKQUOTE_MAX_DEPTH = 32 # blockquote recursion cap (C-2)
|
|
35
36
|
CACHE_BASE = pathlib.Path.home() / ".cache" / "lineage"
|
|
36
|
-
REVIEW_SCHEMA = "lineage-review/
|
|
37
|
+
REVIEW_SCHEMA = "lineage-review/2" # --emit-review pack, read back by --apply-review; 2: llm_key, pages redacted (3.0.2)
|
|
37
38
|
REVIEW_PART_SCHEMA = "lineage-review-part/1"
|
|
38
39
|
REVIEW_PART = 40 # turns per part file: one reviewer's share
|
|
39
40
|
PREVIEW_HEAD = 1500 # a long turn's preview: head + tail, where conclusions sit
|
|
@@ -221,22 +222,27 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full"):
|
|
|
221
222
|
|
|
222
223
|
if extra:
|
|
223
224
|
for kw in [k.strip() for k in extra.split(",") if k.strip()]:
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
225
|
+
# a marker an earlier pass left stays whole: a keyword inside its name is no secret
|
|
226
|
+
pat = re.compile(r"\[REDACTED(?::[^\]\s]*)?\]|" + re.escape(kw), re.IGNORECASE)
|
|
227
|
+
hits = []
|
|
228
|
+
out = pat.sub(lambda m: m.group(0) if m.group(0).upper().startswith("[REDACTED")
|
|
229
|
+
else hits.append(1) or "[REDACTED]", out)
|
|
230
|
+
if hits:
|
|
231
|
+
counts[f"custom:{kw}"] = len(hits)
|
|
229
232
|
return out, counts
|
|
230
233
|
|
|
231
234
|
|
|
232
235
|
# A character of curl's user before the colon: no space, : or =, and a quote, backtick or ( only
|
|
233
|
-
# where no flag follows (-u, -Xu or --user, as either branch below starts)
|
|
236
|
+
# where no flag follows (-u, -Xu or --user, as either branch below starts) and no comma and quote
|
|
237
|
+
# follow (the end of an item in an argument list written without spaces). A match starts only
|
|
234
238
|
# there, so no user runs past the place where the next match may start: a long run stays linear,
|
|
235
239
|
# and a user joined to a variable by a - ("$USER"-bot) is read whole.
|
|
236
|
-
_CU_USER = r"(?:[^\s:=\"'`(]|[\"'`(](?!-(?:u|[A-Za-z0-9]{1,6}u[\s\"']|-user[\s=\"'])
|
|
240
|
+
_CU_USER = (r"(?:[^\s:=\"'`(]|[\"'`(](?!-(?:u|[A-Za-z0-9]{1,6}u[\s\"']|-user[\s=\"'])"
|
|
241
|
+
r"|,\\{0,7}[\"']))")
|
|
237
242
|
# The quotes that may open the value, bare or escaped (\" and \\\" in a quoted command, '\'' and
|
|
238
|
-
# '"'"' from bash and shlex.quote, ^" in cmd, `" in PowerShell, $' in bash): up to six in a row
|
|
239
|
-
|
|
243
|
+
# '"'"' from bash and shlex.quote, ^" in cmd, `" in PowerShell, $' in bash): up to six in a row,
|
|
244
|
+
# none that closes an item of an argument list.
|
|
245
|
+
_CU_LEAD = r"(?:[\\^`$]{0,7}[\"'](?!,\\{0,7}[\"'])){0,6}"
|
|
240
246
|
# Values read as a whole token, past the quotes that open them, that hold no user and password: a
|
|
241
247
|
# uid:gid (docker), a date format (date -u +%H:%M), two references ($UID:$GID, ${UID}:${GID},
|
|
242
248
|
# %USER%:%PASS%, $(id -u):$(id -g), whose inner `-u)` is a match of its own) and a path default
|
|
@@ -249,16 +255,34 @@ _CU_SKIP = (r"(?!" + _CU_LEAD + r"(?:\+[\"']?%[-_0^#]?[A-Za-z%]|(?:\d+:\d+|(?:"
|
|
|
249
255
|
|
|
250
256
|
# Extra patterns for text a reviewer model reads (part files) and nothing else: the page
|
|
251
257
|
# and --rulebase keep the patterns above, so their output stays as it was.
|
|
258
|
+
# A value a redaction already replaced: a marker ([REDACTED...]) or a value that is only a mask
|
|
259
|
+
# (abcd****wxyz: up to 4 characters, a whole run of *, up to 4 characters, then the value ends; a
|
|
260
|
+
# closing `, |, * or ) and then . , ; : ! ? may stand between, as in **password: ****h12** or
|
|
261
|
+
# `DB_PASSWORD=****`.). The
|
|
262
|
+
# reviewer patterns leave it, so a second pass over redacted text changes nothing. The check reads
|
|
263
|
+
# those few characters, not the rest of the token, so a long run of keywords stays linear.
|
|
264
|
+
_NOT_REDACTED = r"(?!\[REDACTED|[^\s'\"*]{0,4}\*{4,}(?!\*)[^\s'\"*]{0,4}(?=[`|*)]*[.,;:!?]*(?:[\s'\"]|$)))"
|
|
265
|
+
# After a colon: not the colon of a marker an earlier pass left ([REDACTED:GitHubPAT]), which
|
|
266
|
+
# splits no user from a password. A whole marker is one piece of a user: a key an earlier pattern
|
|
267
|
+
# of this pass hid ([REDACTED:OpenAIKey]:pw) still has its password hidden.
|
|
268
|
+
_NOT_MARKED = r"(?<!\[REDACTED:)"
|
|
269
|
+
_MARKER_UNIT = r"\[REDACTED:\w+\]"
|
|
270
|
+
|
|
252
271
|
REVIEW_SECRET_PATTERNS = [
|
|
253
272
|
("AnthropicKey", re.compile(r"(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_-]{20,}")),
|
|
254
273
|
("OpenAIKey", re.compile(r"(?<![A-Za-z0-9-])sk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}")),
|
|
255
274
|
("GoogleKey", re.compile(r"(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}")),
|
|
256
275
|
# bounded repeats: a long run of `key` or `a.` would otherwise take quadratic time
|
|
257
276
|
("HexKey", re.compile(r"(?i)(?:key|token|secret)[\w-]{0,100}\s*[:=]\s*['\"]?[0-9a-f]{32,}")),
|
|
277
|
+
# a value, not a path, a variable or a redaction ($PWD is a variable, PGPASSWORD a name);
|
|
278
|
+
# markdown, a table cell or a bracket that closes after the value stays outside the match
|
|
258
279
|
("PasswordBare", re.compile(
|
|
259
|
-
r"(?i)(?:password|passwd|passcode|pwd|암호|비번|비밀\s?번호|패스워드)\s*[:=]\s*
|
|
260
|
-
|
|
261
|
-
|
|
280
|
+
r"(?i)(?<!\$)(?:password|passwd|passcode|pwd|암호|비번|비밀\s?번호|패스워드)\s*[:=]\s*"
|
|
281
|
+
r"(?![/~$])" + _NOT_REDACTED + r"(?=[^\s'\"]{6})[^\s'\"]*[^\s'\"`|*)]")),
|
|
282
|
+
# the password runs to the last @ before the host: it may hold an @ of its own. A marker is
|
|
283
|
+
# no user or password, so a second pass leaves a token-only URL (https://[REDACTED:...]@host).
|
|
284
|
+
("UrlCredential", re.compile(r"(?i)[a-z][a-z0-9+.-]{0,30}://(?:" + _MARKER_UNIT + r"|[^\s:@/])*:" + _NOT_MARKED
|
|
285
|
+
+ r"(?!\[REDACTED)[^\s/]{3,}@")),
|
|
262
286
|
("Bearer", re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{16,}")),
|
|
263
287
|
# Authorization then `:`, `=`, `=>` or headers["Authorization"] =, a call's ("Authorization",
|
|
264
288
|
# "...") and a HAR name/value pair, or a space before a quote (nginx, Apache)
|
|
@@ -271,28 +295,53 @@ REVIEW_SECRET_PATTERNS = [
|
|
|
271
295
|
# curl's -u and --user flags, alone or last in a bundle (-su, -4u), with a user and password
|
|
272
296
|
# after a space, an = or nothing, quoted together or apart ("user":"pw"), with quotes bare,
|
|
273
297
|
# escaped or nested (_CU_LEAD) or none, in inline code or parentheses, or as two items of an
|
|
274
|
-
# argument list
|
|
298
|
+
# argument list (quotes bare or escaped, with or without spaces; a host:/path is left). An
|
|
299
|
+
# empty user with a token, and a
|
|
275
300
|
# long key as the user with no password, count too. The separators share no character with
|
|
276
301
|
# the user name, so a long run of them stays linear. The values _CU_SKIP names and a
|
|
277
|
-
# host:/path (rsync) are left.
|
|
302
|
+
# host:/path (rsync) are left, and so is a marker an earlier pass left (-u [REDACTED:JWT]).
|
|
278
303
|
("CurlUser", re.compile(
|
|
279
304
|
r"(?<![^\s\"'`(])(?:-u\s*|-[A-Za-z0-9]{1,6}u\s+|--user(?:\s+|=))" + _CU_SKIP + _CU_LEAD
|
|
280
|
-
+ r"(?:" + _CU_USER + r"*:(
|
|
281
|
-
r"
|
|
282
|
-
+ r"(
|
|
305
|
+
+ r"(?:(?:" + _MARKER_UNIT + r"|" + _CU_USER + r")*:" + _NOT_MARKED + r"(?!/|\[REDACTED)\S{3,}|" + _CU_USER
|
|
306
|
+
+ r"{15,}:" + _NOT_MARKED
|
|
307
|
+
+ r"(?=[\s\"'`).,;\\]|$))"
|
|
308
|
+
r"|(\\{0,7}[\"'])(?:-[A-Za-z0-9]{0,6}u|--user)\1\s*,\s*(\\{0,7}[\"'])" + _CU_SKIP
|
|
309
|
+
+ r"(?:(?:" + _MARKER_UNIT + r"|" + _CU_USER + r")*:" + _NOT_MARKED + r"(?!/|\[REDACTED)[^\s\"']{3,}|"
|
|
310
|
+
+ _CU_USER + r"{15,}:"
|
|
311
|
+
+ _NOT_MARKED + r")\2")),
|
|
283
312
|
]
|
|
284
313
|
|
|
285
314
|
|
|
315
|
+
def _reviewer_patterns(text):
|
|
316
|
+
"""`text` with what the reviewer patterns find replaced whole by [REDACTED:<name>], and the
|
|
317
|
+
count by name."""
|
|
318
|
+
counts = {}
|
|
319
|
+
for name, pat in REVIEW_SECRET_PATTERNS:
|
|
320
|
+
text, k = pat.subn(f"[REDACTED:{name}]", text)
|
|
321
|
+
if k:
|
|
322
|
+
counts[name] = k
|
|
323
|
+
return text, counts
|
|
324
|
+
|
|
325
|
+
|
|
286
326
|
def review_redact(text, extra=None):
|
|
287
327
|
"""Text as a reviewer model reads it: the reviewer-only patterns first, while the keys
|
|
288
328
|
are whole (the entropy rule would otherwise cut a key and leave the rest), then fully
|
|
289
329
|
redacted (never masked, whatever --redact-mode says). Returns (text, count)."""
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
330
|
+
pre, counts = _reviewer_patterns(text)
|
|
331
|
+
red, found = redact(pre, extra=extra, mode="full")
|
|
332
|
+
return red, sum(counts.values()) + sum(found.values())
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def page_redact(text, extra=None, mode="full"):
|
|
336
|
+
"""Text as every page shows it (3.0.2): the reviewer patterns first, while keys are whole,
|
|
337
|
+
each hit replaced whole whatever --redact-mode says (a mask keeps 4+4 characters, most of
|
|
338
|
+
a short password); then the page's redaction with this run's mode and keywords. Returns
|
|
339
|
+
(text, count_by_kind); a second pass over its output finds nothing."""
|
|
340
|
+
pre, counts = _reviewer_patterns(text)
|
|
341
|
+
red, found = redact(pre, extra=extra, mode=mode)
|
|
342
|
+
for k, v in found.items():
|
|
343
|
+
counts[k] = counts.get(k, 0) + v
|
|
344
|
+
return red, counts
|
|
296
345
|
|
|
297
346
|
|
|
298
347
|
# ---------------------------------------------------------------- Discovery
|
|
@@ -568,9 +617,15 @@ SHORT_DETAIL = 160
|
|
|
568
617
|
SINGLE_SPLIT_MIN = SHORT_DETAIL
|
|
569
618
|
|
|
570
619
|
|
|
620
|
+
_OPEN_MARKER_RE = re.compile(r"\[REDACTED[^\]]*$")
|
|
621
|
+
|
|
622
|
+
|
|
571
623
|
def _cut(s, n):
|
|
572
624
|
s = s.strip()
|
|
573
|
-
|
|
625
|
+
if len(s) <= n:
|
|
626
|
+
return s
|
|
627
|
+
head = _OPEN_MARKER_RE.sub("", s[:max(1, n - 1)]) # a marker cut in two goes whole
|
|
628
|
+
return head.rstrip() + "…"
|
|
574
629
|
|
|
575
630
|
|
|
576
631
|
def naive_summary(text: str) -> str:
|
|
@@ -631,6 +686,35 @@ def summarize_turn(turn):
|
|
|
631
686
|
|
|
632
687
|
|
|
633
688
|
# ---------------------------------------------------------------- Summary cache
|
|
689
|
+
def _stdin_id_key():
|
|
690
|
+
"""The key that turns a pasted turn's text into its id: random, kept 0600 in the cache folder,
|
|
691
|
+
so ids (and the caches keyed on them) stay the same on this machine. Part files carry the id
|
|
692
|
+
to a model that never sees the key, so an id gives no way to test a guessed password. With no
|
|
693
|
+
cache folder to keep it in, the key lasts one run."""
|
|
694
|
+
path = CACHE_BASE / "stdin-id.key"
|
|
695
|
+
try:
|
|
696
|
+
key = path.read_bytes()
|
|
697
|
+
if len(key) == 32:
|
|
698
|
+
return key
|
|
699
|
+
except OSError:
|
|
700
|
+
pass
|
|
701
|
+
key = os.urandom(32)
|
|
702
|
+
tmp = path.with_name("%s.%d" % (path.name, os.getpid()))
|
|
703
|
+
try:
|
|
704
|
+
CACHE_BASE.mkdir(parents=True, exist_ok=True)
|
|
705
|
+
os.chmod(str(CACHE_BASE), 0o700)
|
|
706
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0), 0o600)
|
|
707
|
+
with os.fdopen(fd, "wb") as f:
|
|
708
|
+
f.write(key)
|
|
709
|
+
os.replace(str(tmp), str(path)) # whole or not at all
|
|
710
|
+
except OSError:
|
|
711
|
+
try:
|
|
712
|
+
os.unlink(str(tmp))
|
|
713
|
+
except OSError:
|
|
714
|
+
pass
|
|
715
|
+
return key
|
|
716
|
+
|
|
717
|
+
|
|
634
718
|
def cache_dir(session_id: str):
|
|
635
719
|
d = CACHE_BASE / str(SCHEMA_VERSION) / (session_id or "default")
|
|
636
720
|
d.mkdir(parents=True, exist_ok=True)
|
|
@@ -642,11 +726,19 @@ def cache_dir(session_id: str):
|
|
|
642
726
|
return d
|
|
643
727
|
|
|
644
728
|
|
|
729
|
+
def _page_clean_turn(t, extra, mode):
|
|
730
|
+
"""`t` with its text and parts as a page shows them, so a summary cut from it cannot cut a
|
|
731
|
+
secret in two and keep the half no pattern recognises."""
|
|
732
|
+
def clean(s):
|
|
733
|
+
return page_redact(s, extra, mode)[0]
|
|
734
|
+
return dict(t, text=clean(t["text"]), parts=[clean(p) for p in t.get("parts") or []])
|
|
735
|
+
|
|
736
|
+
|
|
645
737
|
def read_or_summarize(turn: dict, session_id: str, rebuild: bool = False,
|
|
646
738
|
redact_extra=None, redact_mode: str = "full"):
|
|
647
739
|
"""Return (summary, cache_hit). Cache key includes the content hash AND the
|
|
648
740
|
summarizer version (B-3), so changing the summarizer invalidates old entries.
|
|
649
|
-
Cached text is always redacted (secret hygiene).
|
|
741
|
+
Cached text is always redacted (secret hygiene). It is cut from the redacted text (3.0.2).
|
|
650
742
|
"""
|
|
651
743
|
digest = hashlib.sha256(
|
|
652
744
|
(turn["text"] + "\x00s" + str(SUMMARIZER_VERSION)).encode()
|
|
@@ -662,8 +754,7 @@ def read_or_summarize(turn: dict, session_id: str, rebuild: bool = False,
|
|
|
662
754
|
return p.read_text(encoding="utf-8").strip(), True
|
|
663
755
|
except (OSError, UnicodeDecodeError):
|
|
664
756
|
pass
|
|
665
|
-
|
|
666
|
-
redacted, _ = redact(raw_summary, extra=redact_extra, mode=redact_mode)
|
|
757
|
+
redacted = summarize_turn(_page_clean_turn(turn, redact_extra, redact_mode))
|
|
667
758
|
if p is not None:
|
|
668
759
|
try:
|
|
669
760
|
fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
@@ -1051,7 +1142,7 @@ def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
|
|
|
1051
1142
|
open_attr = " open" if open_details else ""
|
|
1052
1143
|
|
|
1053
1144
|
def _red(text):
|
|
1054
|
-
r, c =
|
|
1145
|
+
r, c = page_redact(text, extra=redact_extra, mode=redact_mode)
|
|
1055
1146
|
for k, v in c.items():
|
|
1056
1147
|
redact_counts[k] = redact_counts.get(k, 0) + v
|
|
1057
1148
|
return r
|
|
@@ -1061,7 +1152,7 @@ def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
|
|
|
1061
1152
|
# session-transition divider (--all-sessions)
|
|
1062
1153
|
if all_sessions and t.get("session") != last_session:
|
|
1063
1154
|
last_session = t.get("session")
|
|
1064
|
-
name = html.escape(t.get("session_name") or last_session or "session")
|
|
1155
|
+
name = html.escape(_red(t.get("session_name") or last_session or "session"))
|
|
1065
1156
|
rows.append('<div class="day" data-mark="%s">'
|
|
1066
1157
|
'<span class="pill pill-session">%s</span></div>'
|
|
1067
1158
|
% (name, name))
|
|
@@ -1111,13 +1202,14 @@ def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
|
|
|
1111
1202
|
|
|
1112
1203
|
tools_html = ""
|
|
1113
1204
|
if t.get("tools"):
|
|
1114
|
-
ts_list = ", ".join(f"{k}×{v}"
|
|
1205
|
+
ts_list = ", ".join(f"{_hide_keywords(k, redact_extra)}×{v}"
|
|
1206
|
+
for k, v in sorted(t["tools"].items()))
|
|
1115
1207
|
total = sum(t["tools"].values())
|
|
1116
1208
|
tools_html = ('<div class="tools">🔧 도구 %d건: %s</div>'
|
|
1117
1209
|
% (total, html.escape(ts_list)))
|
|
1118
1210
|
|
|
1119
1211
|
if role == "agent":
|
|
1120
|
-
who = html.escape(t.get("agent_from") or "agent")
|
|
1212
|
+
who = html.escape(_red(t.get("agent_from") or "agent"))
|
|
1121
1213
|
avatar = "🤝"
|
|
1122
1214
|
from_line = '<div class="from">%s</div>' % who
|
|
1123
1215
|
row_cls = "row bot agent"
|
|
@@ -1137,6 +1229,21 @@ def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
|
|
|
1137
1229
|
return rows, redact_counts, cache_hits, cache_total
|
|
1138
1230
|
|
|
1139
1231
|
|
|
1232
|
+
_TAG_RE = re.compile(r"<[^>]*>")
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _residual_secrets(rows_html):
|
|
1236
|
+
"""What the reviewer patterns still find in a page's text (tags dropped, entities decoded),
|
|
1237
|
+
by pattern name: a field the page forgot to redact."""
|
|
1238
|
+
text = html.unescape(_TAG_RE.sub(" ", rows_html))
|
|
1239
|
+
left = {}
|
|
1240
|
+
for name, pat in REVIEW_SECRET_PATTERNS:
|
|
1241
|
+
n = len(pat.findall(text))
|
|
1242
|
+
if n:
|
|
1243
|
+
left[name] = n
|
|
1244
|
+
return left
|
|
1245
|
+
|
|
1246
|
+
|
|
1140
1247
|
# ---------------------------------------------------------------- Self-verify (F-1)
|
|
1141
1248
|
_VOID = frozenset(
|
|
1142
1249
|
"area base br col embed hr img input link meta param source track wbr".split())
|
|
@@ -1279,18 +1386,23 @@ def _decision_context(why, keeps):
|
|
|
1279
1386
|
str(keeps.get("keep_tool_only") is True)])
|
|
1280
1387
|
|
|
1281
1388
|
|
|
1282
|
-
def
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
return
|
|
1389
|
+
def _llm_digest(redacted_text, context):
|
|
1390
|
+
"""The decision cache key: the text with the page's patterns only (as 3.0.1 keyed it, so
|
|
1391
|
+
no turn is reviewed again for 3.0.2), the cache version and what the decision rests on."""
|
|
1392
|
+
return hashlib.sha256((redacted_text + "\x00llm" + str(LLM_CACHE_VERSION)
|
|
1393
|
+
+ "\x00" + context).encode()).hexdigest()[:12]
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def _llm_cache_path(turn_id, session, digest):
|
|
1397
|
+
return cache_dir(_safe_name(session)) / f"{_safe_name(turn_id)}-{_safe_name(digest)}-llm.json"
|
|
1286
1398
|
|
|
1287
1399
|
|
|
1288
|
-
def read_llm_cache(turn_id, session,
|
|
1400
|
+
def read_llm_cache(turn_id, session, digest):
|
|
1289
1401
|
"""A reviewer's earlier {keep, summary} for this exact (redacted) text under the same
|
|
1290
1402
|
rule call and keep flags (`context`), or None when no reviewer saw it so. Both values
|
|
1291
1403
|
null: the reviewer left the turn to the rules."""
|
|
1292
1404
|
try:
|
|
1293
|
-
path = _llm_cache_path(turn_id, session,
|
|
1405
|
+
path = _llm_cache_path(turn_id, session, digest)
|
|
1294
1406
|
except OSError as e: # no cache folder (a read-only HOME, say)
|
|
1295
1407
|
_warn_once("_no_llm_cache", f"reviewer decision cache unavailable ({e}); "
|
|
1296
1408
|
"every turn is reviewed")
|
|
@@ -1307,9 +1419,9 @@ def read_llm_cache(turn_id, session, redacted_text, context):
|
|
|
1307
1419
|
return {"keep": keep, "summary": summary}
|
|
1308
1420
|
|
|
1309
1421
|
|
|
1310
|
-
def write_llm_cache(turn_id, session,
|
|
1422
|
+
def write_llm_cache(turn_id, session, digest, keep, summary):
|
|
1311
1423
|
try:
|
|
1312
|
-
_write_private(_llm_cache_path(turn_id, session,
|
|
1424
|
+
_write_private(_llm_cache_path(turn_id, session, digest),
|
|
1313
1425
|
json.dumps({"keep": keep, "summary": summary}, ensure_ascii=False))
|
|
1314
1426
|
except OSError as e:
|
|
1315
1427
|
_warn_once("_no_llm_cache_write", f"reviewer decisions not cached ({e}); the next "
|
|
@@ -1371,7 +1483,7 @@ def _rule_summary(t, red, args):
|
|
|
1371
1483
|
The summary cache is keyed on the text alone, so a cached one may predate a
|
|
1372
1484
|
--redact-extra keyword or carry another --redact-mode."""
|
|
1373
1485
|
if t["role"] in ("assistant", "agent"):
|
|
1374
|
-
return
|
|
1486
|
+
return summarize_turn(_page_clean_turn(t, args.redact_extra, args.redact_mode))
|
|
1375
1487
|
if t["role"] == "user" and len(red) > USER_FOLD:
|
|
1376
1488
|
return user_fold_summary(red)
|
|
1377
1489
|
return None
|
|
@@ -1400,18 +1512,22 @@ def _review_view(t, red, args):
|
|
|
1400
1512
|
|
|
1401
1513
|
|
|
1402
1514
|
def _pack_turn(n, t, tid, why, session_id, args, counts):
|
|
1403
|
-
"""One turn of the pack under its final id `tid` (
|
|
1404
|
-
the reviewer's slot), and the reviewer's view of it.
|
|
1405
|
-
|
|
1515
|
+
"""One turn of the pack under its final id `tid` (the text as the page shows it, the rules'
|
|
1516
|
+
decision, the reviewer's slot), and the reviewer's view of it. The cached decision keys on
|
|
1517
|
+
the text with the page's patterns only (`llm_key`), as 3.0.1 did."""
|
|
1518
|
+
red, found = page_redact(t["text"], extra=args.redact_extra, mode=args.redact_mode)
|
|
1406
1519
|
for k, v in found.items():
|
|
1407
1520
|
counts[k] = counts.get(k, 0) + v
|
|
1521
|
+
hit = any(name in found for name, _ in REVIEW_SECRET_PATTERNS)
|
|
1522
|
+
key_text = redact(t["text"], extra=args.redact_extra, mode=args.redact_mode)[0] if hit else red
|
|
1408
1523
|
preview, clipped = _preview(review_redact(t["text"], args.redact_extra)[0])
|
|
1409
1524
|
session = t.get("session") or session_id
|
|
1410
1525
|
context = _decision_context(why, _keeps(args))
|
|
1411
|
-
|
|
1526
|
+
llm_key = _llm_digest(key_text, context)
|
|
1527
|
+
cached = None if args.rebuild_summaries else read_llm_cache(tid, session, llm_key)
|
|
1412
1528
|
view = _review_view(t, red, args)
|
|
1413
1529
|
turn = {"id": tid, "n": n, "role": t["role"], "ts": t.get("ts") or "",
|
|
1414
|
-
"tools": dict(t.get("tools") or {}), "text": red,
|
|
1530
|
+
"tools": dict(t.get("tools") or {}), "text": red, "llm_key": llm_key,
|
|
1415
1531
|
"redactions": _public_counts(found), "preview": preview, "clipped": clipped,
|
|
1416
1532
|
"rule": {"keep": why is None, "why": why,
|
|
1417
1533
|
"summary": _rule_summary(t, red, args),
|
|
@@ -1421,7 +1537,7 @@ def _pack_turn(n, t, tid, why, session_id, args, counts):
|
|
|
1421
1537
|
turn["meta"] = True
|
|
1422
1538
|
for k in ("session", "session_name", "agent_from"):
|
|
1423
1539
|
if t.get(k):
|
|
1424
|
-
turn[k] = t[k]
|
|
1540
|
+
turn[k] = t[k] if k == "session" else page_redact(t[k], args.redact_extra, args.redact_mode)[0]
|
|
1425
1541
|
return turn, view
|
|
1426
1542
|
|
|
1427
1543
|
|
|
@@ -1487,7 +1603,7 @@ def _sheet(k, of, part_path, group, args):
|
|
|
1487
1603
|
turns = []
|
|
1488
1604
|
for x, view in group:
|
|
1489
1605
|
y = {key: v for key, v in x.items()
|
|
1490
|
-
if key not in ("text", "session", "session_name", "agent_from", "redactions")}
|
|
1606
|
+
if key not in ("text", "session", "session_name", "agent_from", "redactions", "llm_key")}
|
|
1491
1607
|
y["rule"] = {k: v for k, v in x["rule"].items() if k != "review_summary"}
|
|
1492
1608
|
y["rule"]["summary"] = view["summary"]
|
|
1493
1609
|
y["tools"] = {_hide_keywords(name, args.redact_extra): n for name, n in x["tools"].items()}
|
|
@@ -1565,7 +1681,7 @@ def emit_review(turns, drops, window, session_id, output, args):
|
|
|
1565
1681
|
def _is_pack_turn(x):
|
|
1566
1682
|
return (isinstance(x, dict) and isinstance(x.get("id"), str)
|
|
1567
1683
|
and x.get("role") in ("user", "assistant", "agent", "mark")
|
|
1568
|
-
and isinstance(x.get("text"), str)
|
|
1684
|
+
and isinstance(x.get("text"), str) and isinstance(x.get("llm_key"), str)
|
|
1569
1685
|
and isinstance(x.get("rule"), dict) and isinstance(x["rule"].get("keep"), bool)
|
|
1570
1686
|
and _is_summary(x["rule"].get("summary"))
|
|
1571
1687
|
and isinstance(x.get("llm"), dict) and _is_keep(x["llm"].get("keep"))
|
|
@@ -1578,7 +1694,8 @@ def _load_pack(path):
|
|
|
1578
1694
|
except (OSError, ValueError, UnicodeDecodeError) as e:
|
|
1579
1695
|
return _bad(f"cannot read review pack {path}: {e}")
|
|
1580
1696
|
if not isinstance(pack, dict) or pack.get("schema") != REVIEW_SCHEMA:
|
|
1581
|
-
return _bad(f"{path} is not a {REVIEW_SCHEMA} pack"
|
|
1697
|
+
return _bad(f"{path} is not a {REVIEW_SCHEMA} pack (made by another lineage version? "
|
|
1698
|
+
"run --emit-review again)")
|
|
1582
1699
|
if not isinstance(pack.get("turns"), list) or not all(map(_is_pack_turn, pack["turns"])):
|
|
1583
1700
|
return _bad(f"{path}: malformed turns")
|
|
1584
1701
|
return pack
|
|
@@ -1780,8 +1897,8 @@ def _review_result(pack, merged, args):
|
|
|
1780
1897
|
typed.append(x["id"]) # typed by the user: the rules keep these
|
|
1781
1898
|
llm_summary = _reviewer_summary(x, d, args, stats)
|
|
1782
1899
|
if x["id"] in merged: # reviewed, even if left to the rules
|
|
1783
|
-
writes.append((x["id"], x.get("session") or pack.get("source"), x["
|
|
1784
|
-
|
|
1900
|
+
writes.append((x["id"], x.get("session") or pack.get("source"), x["llm_key"],
|
|
1901
|
+
llm_keep, llm_summary))
|
|
1785
1902
|
stats["restored"] += keep and not x["rule"]["keep"]
|
|
1786
1903
|
stats["dropped"] += x["rule"]["keep"] and not keep
|
|
1787
1904
|
if keep:
|
|
@@ -2091,10 +2208,11 @@ def main(argv=None) -> int:
|
|
|
2091
2208
|
except SystemExit as e:
|
|
2092
2209
|
return int(e.code) if isinstance(e.code, int) else 2
|
|
2093
2210
|
|
|
2094
|
-
# stdin → derive per-turn uuid from content
|
|
2211
|
+
# stdin → derive per-turn uuid from the content so the caches work (keyed: see _stdin_id_key)
|
|
2095
2212
|
if args.from_transcript == "-":
|
|
2213
|
+
key = _stdin_id_key()
|
|
2096
2214
|
for t in turns:
|
|
2097
|
-
t["uuid"] =
|
|
2215
|
+
t["uuid"] = hmac.new(key, str(t["text"]).encode(), hashlib.sha256).hexdigest()[:16]
|
|
2098
2216
|
|
|
2099
2217
|
# ---- Pipeline order (R6): classify → merge → echo → hide-tool-only → range ----
|
|
2100
2218
|
turns = classify_turns(turns, drop_trivia=args.drop_trivia)
|
|
@@ -2161,10 +2279,15 @@ def select_range(turns, args):
|
|
|
2161
2279
|
|
|
2162
2280
|
|
|
2163
2281
|
def default_output(args, jsonl_path, session_id):
|
|
2164
|
-
"""work/lineage-<session name or id>.html, or args.output when given.
|
|
2282
|
+
"""work/lineage-<session name or id>.html, or args.output when given. A name that holds a
|
|
2283
|
+
secret-like string gives way to the session id."""
|
|
2165
2284
|
if args.output is not None:
|
|
2166
2285
|
return args.output
|
|
2167
2286
|
session_name = discover_session_name(jsonl_path) if jsonl_path else None
|
|
2287
|
+
if session_name and page_redact(session_name)[0] != session_name:
|
|
2288
|
+
print(f"[lineage] session name: {page_redact(session_name)[0]} "
|
|
2289
|
+
"(holds a secret-like string; the file is named by the session id)", file=sys.stderr)
|
|
2290
|
+
session_name = None
|
|
2168
2291
|
slug = ("all-sessions" if args.all_sessions
|
|
2169
2292
|
else (session_name or session_id[:8]))
|
|
2170
2293
|
if session_name:
|
|
@@ -2183,6 +2306,13 @@ def render_and_write(turns, session_id, args, all_sessions=False, markdown=True,
|
|
|
2183
2306
|
markdown=markdown, all_sessions=all_sessions, summaries=summaries)
|
|
2184
2307
|
for k, v in (prior_redactions or {}).items():
|
|
2185
2308
|
redact_counts[k] = redact_counts.get(k, 0) + v
|
|
2309
|
+
left = _residual_secrets("\n".join(rows))
|
|
2310
|
+
if left:
|
|
2311
|
+
print("[lineage] WARN: the page still holds secret-like text ("
|
|
2312
|
+
+ ", ".join(f"{k}={v}" for k, v in sorted(left.items()))
|
|
2313
|
+
+ "); check the page before sharing it, or run again with the value in LINEAGE_REDACT_EXTRA"
|
|
2314
|
+
" (with --rulebase, add --rebuild-summaries)",
|
|
2315
|
+
file=sys.stderr)
|
|
2186
2316
|
|
|
2187
2317
|
date_range = ""
|
|
2188
2318
|
dated = [t for t in turns if t.get("ts")]
|
|
@@ -2239,7 +2369,8 @@ def _with_timestamp_suffix(path_str):
|
|
|
2239
2369
|
|
|
2240
2370
|
def _gate_samples(bots, summaries, session_id, args):
|
|
2241
2371
|
"""Up to 5 bot turns as the gate's critic reads them. --rulebase keeps the 2.x shape
|
|
2242
|
-
(random turns
|
|
2372
|
+
(random turns); a model reads the samples, so every value is hidden whole before the cut,
|
|
2373
|
+
as in part files (3.0.2). With reviewer summaries (the default flow) the pick is
|
|
2243
2374
|
seeded by the turns, so a rerun on the same turns samples the same ones, and a sample
|
|
2244
2375
|
names its turn and shows the head and tail the reviewer saw, fully redacted."""
|
|
2245
2376
|
import random
|
|
@@ -2248,8 +2379,8 @@ def _gate_samples(bots, summaries, session_id, args):
|
|
|
2248
2379
|
for i, t in enumerate(random.sample(bots, min(5, len(bots)))):
|
|
2249
2380
|
s, _ = _page_summary(t, summaries, session_id, False,
|
|
2250
2381
|
args.redact_extra, args.redact_mode)
|
|
2251
|
-
red_s
|
|
2252
|
-
red_d
|
|
2382
|
+
red_s = review_redact(_MASKED.sub("[REDACTED]", s), args.redact_extra)[0]
|
|
2383
|
+
red_d = review_redact(t["text"], args.redact_extra)[0][:500]
|
|
2253
2384
|
out.append({"idx": i, "original_detail": red_d, "generated_summary": red_s})
|
|
2254
2385
|
return out
|
|
2255
2386
|
seed = hashlib.sha256("\x00".join(t["uuid"] for t in bots).encode()).hexdigest()
|