@kaydash9999/banker-plugins 0.11.0 → 0.13.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 +52 -0
- package/README.md +28 -8
- package/codex/manifest.json +8 -1
- package/codex/transform-matrix.md +2 -2
- package/commands/setup.md +2 -0
- package/package.json +6 -1
- package/skills/3d-intro-build/SKILL.md +214 -0
- package/skills/3d-intro-build/references/LICENSE +21 -0
- package/skills/3d-intro-build/references/NOTICE.md +32 -0
- package/skills/3d-intro-build/references/assemble.mjs +252 -0
- package/skills/3d-intro-build/references/azure-adapter.mjs +495 -0
- package/skills/3d-intro-build/references/curate.mjs +301 -0
- package/skills/3d-intro-build/references/index-template.html +73 -0
- package/skills/3d-intro-build/references/prompts.md +171 -0
- package/skills/3d-intro-build/references/scrub-engine.js +448 -0
- package/skills/3d-intro-build/references/serve.mjs +123 -0
- package/skills/3d-intro-setup/SKILL.md +196 -0
- package/skills/3d-intro-setup/references/azure-adapter.mjs +495 -0
- package/skills/lineage/SKILL.md +126 -134
- package/skills/lineage/lineage.py +1041 -354
- package/skills/motion-graphic-make/SKILL.md +57 -0
- package/skills/motion-graphic-setup/SKILL.md +141 -0
- package/skills/ralph-qa/SKILL.md +253 -77
- package/skills/ralph-qa/references/verifier-probe.mjs +860 -0
- package/skills/refresh-readme/SKILL.md +3 -3
- package/skills/vertical-pptx/SKILL.md +409 -0
- package/skills/vertical-pptx/build_a4p.js +923 -0
- package/skills/vertical-pptx/build_a4p.py +916 -0
- package/skills/vertical-pptx/verify_a4p.mjs +2168 -0
- package/skills/vertical-pptx-setup/SKILL.md +223 -0
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""lineage — Export
|
|
2
|
+
"""lineage — Export Claude Code session(s) as a single KakaoTalk-style HTML file.
|
|
3
3
|
|
|
4
|
-
Deterministic core.
|
|
5
|
-
|
|
4
|
+
Deterministic core. One-line summary per assistant turn (cached, redacted).
|
|
5
|
+
Renders assistant markdown, filters harness noise, folds long messages, and
|
|
6
|
+
self-verifies the output markup. See SKILL.md for the full design and the
|
|
7
|
+
`work/lineage-change-request.html` spec for the classification rules.
|
|
8
|
+
|
|
9
|
+
Runtime-agnostic: works from any transcript via --session / --from-transcript.
|
|
10
|
+
Auto-discovery assumes Claude Code's ~/.claude/projects/<encoded-cwd>/ layout.
|
|
6
11
|
"""
|
|
7
12
|
import argparse
|
|
8
13
|
import hashlib
|
|
@@ -20,119 +25,106 @@ if sys.version_info < (3, 7):
|
|
|
20
25
|
% sys.version.split()[0])
|
|
21
26
|
sys.exit(2)
|
|
22
27
|
|
|
23
|
-
SCHEMA_VERSION = 2
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def discover_session_name(path):
|
|
31
|
-
"""Best-effort scan a jsonl for `customTitle`. Returns slug-safe string or None."""
|
|
32
|
-
try:
|
|
33
|
-
with open(path, encoding="utf-8") as f:
|
|
34
|
-
for line in f:
|
|
35
|
-
if '"customTitle"' not in line:
|
|
36
|
-
continue
|
|
37
|
-
m = _TITLE_RE.search(line)
|
|
38
|
-
if m:
|
|
39
|
-
name = m.group(1).strip()
|
|
40
|
-
# slugify: keep alnum + dash; collapse whitespace
|
|
41
|
-
name = re.sub(r"\s+", "-", name)
|
|
42
|
-
name = re.sub(r"[^\w가-힣.-]", "", name)
|
|
43
|
-
return name[:40] or None
|
|
44
|
-
except (OSError, UnicodeDecodeError):
|
|
45
|
-
return None
|
|
46
|
-
return None
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _with_timestamp_suffix(path_str):
|
|
50
|
-
"""Append `_YYMMDD+HHMM` to the basename before the extension.
|
|
28
|
+
SCHEMA_VERSION = 2 # cache dir schema (v2: redacted summaries only)
|
|
29
|
+
SUMMARIZER_VERSION = 2 # bump when summarize_turn logic changes (B-3 cache key)
|
|
30
|
+
USER_FOLD = 400 # collapse user messages longer than this (E-1)
|
|
31
|
+
ECHO_ASK = 40 # echo-exchange: user question length ceiling (A-4)
|
|
32
|
+
ECHO_REPLY = 5 # echo-exchange: assistant reply length ceiling (A-4)
|
|
33
|
+
_BLOCKQUOTE_MAX_DEPTH = 32 # blockquote recursion cap (C-2)
|
|
34
|
+
CACHE_BASE = pathlib.Path.home() / ".cache" / "lineage"
|
|
51
35
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
36
|
+
# ---------------------------------------------------------------- Noise / classify
|
|
37
|
+
# NOTE: JSONL records carry RAW `<...>` tags. Every regex below uses raw `<`/`>`
|
|
38
|
+
# (NOT the HTML-escaped `<` seen in the display-only change-request document).
|
|
39
|
+
_NOISE_TAGS = ("system-reminder", "local-command-caveat", "local-command-stdout",
|
|
40
|
+
"local-command-stderr", "teammate-message", "command-message",
|
|
41
|
+
"command-args", "command-name", "task-notification")
|
|
42
|
+
_NOISE_BLOCK_RE = re.compile(r"<(%s)\b[^>]*>.*?</\1>" % "|".join(_NOISE_TAGS), re.S)
|
|
43
|
+
_NOISE_CLOSE_RE = re.compile(r"</(%s)>" % "|".join(_NOISE_TAGS))
|
|
44
|
+
_NOISE_OPEN_RE = re.compile(r"<(?:%s)\b" % "|".join(_NOISE_TAGS)) # leading orphan open (R7)
|
|
45
|
+
|
|
46
|
+
_SKILL_BODY_RE = re.compile(r"^\s*Base directory for this skill:")
|
|
47
|
+
_WORKFLOW_BODY_RE = re.compile(r'^\s*Run the "[^"\n]+" workflow\.')
|
|
48
|
+
_COMPACTION_RE = re.compile(
|
|
49
|
+
r"^\s*This session is being continued from a previous conversation")
|
|
50
|
+
_HOOK_FEEDBACK_RE = re.compile(r"^\s*Stop hook feedback:")
|
|
51
|
+
_INTERRUPT_RE = re.compile(r"^\s*\[Request interrupted")
|
|
52
|
+
_IMAGE_NOTE_RE = re.compile(r"^\s*\[Image:\s*original\s+\d+x\d+")
|
|
53
|
+
_SYS_ERROR_RE = re.compile(r"^\s*(?:Login expired|Please run /login|API Error)")
|
|
54
|
+
|
|
55
|
+
# Manipulation commands: judged by NAME, not by "has no args" (A-4). Plugin
|
|
56
|
+
# commands (name contains ':') are never manipulation — always preserved.
|
|
57
|
+
_HARNESS_CMDS = {
|
|
58
|
+
"agents", "bug", "clear", "compact", "config", "context", "copy", "cost",
|
|
59
|
+
"doctor", "effort", "exit", "export", "help", "hooks", "ide",
|
|
60
|
+
"install-github-app", "login", "logout", "mcp", "memory",
|
|
61
|
+
"migrate-installer", "model", "output-style", "permissions",
|
|
62
|
+
"privacy-settings", "quit", "release-notes", "resume", "status",
|
|
63
|
+
"statusline", "terminal-setup", "todos", "upgrade", "usage", "vim",
|
|
64
|
+
}
|
|
65
|
+
# Deliberately NOT in the set: /add-dir (changes readable dirs), /init (writes
|
|
66
|
+
# CLAUDE.md) — these do real work and are kept.
|
|
67
|
+
|
|
68
|
+
_AGENT_WRAP_RE = re.compile(
|
|
69
|
+
r"<(agent-message|teammate-message)\b([^>]*)>(.*?)</\1>", re.S)
|
|
70
|
+
_AGENT_FROM_RE = re.compile(r'(?:from|teammate_id)="([^"]+)"')
|
|
71
|
+
_PEER_LEAD_RE = re.compile(r"^\s*Another Claude session sent a message:\s*")
|
|
72
|
+
_BARE_CMD_RE = re.compile(r"^/([\w-]+)\s*$")
|
|
73
|
+
_CMD_NAME_RE = re.compile(r"<command-name>([\s\S]*?)</command-name>")
|
|
74
|
+
_CMD_ARGS_RE = re.compile(r"<command-args>([\s\S]*?)</command-args>")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def clean_user_text(text, drop_harness=True):
|
|
78
|
+
"""Return the genuine user text, or "" if the record is pure harness noise.
|
|
79
|
+
|
|
80
|
+
Judgement is by FORM, not position:
|
|
81
|
+
- wrapper blocks (`<system-reminder>...`) are always stripped;
|
|
82
|
+
- a record that is ONLY a bare manipulation command (`/copy`) → dropped;
|
|
83
|
+
- anything that survives stripping is a real message (possibly quoting a
|
|
84
|
+
tag) and is kept — EXCEPT a leading orphan wrapper-open (truncated block);
|
|
85
|
+
- a pure command record → the command name (+args), dropped if harness.
|
|
55
86
|
"""
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if
|
|
60
|
-
return
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
87
|
+
out = _NOISE_CLOSE_RE.sub("", _NOISE_BLOCK_RE.sub("", text)).strip()
|
|
88
|
+
|
|
89
|
+
mb = _BARE_CMD_RE.match(out) # bare `/name` with no wrapper
|
|
90
|
+
if mb and drop_harness and mb.group(1) in _HARNESS_CMDS:
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
if out:
|
|
94
|
+
# Something survived stripping = a real message that QUOTED a tag.
|
|
95
|
+
# Keep it, unless it starts with an orphan wrapper-open (R7: a block
|
|
96
|
+
# truncated by the record boundary — not conversation).
|
|
97
|
+
return "" if _NOISE_OPEN_RE.match(out) else out
|
|
98
|
+
|
|
99
|
+
# Nothing survived: a pure command record. Recover the command name.
|
|
100
|
+
m = _CMD_NAME_RE.search(text)
|
|
101
|
+
if not m:
|
|
102
|
+
return ""
|
|
103
|
+
name = m.group(1).strip()
|
|
104
|
+
if drop_harness and ":" not in name and name.lstrip("/") in _HARNESS_CMDS:
|
|
105
|
+
return ""
|
|
106
|
+
a = _CMD_ARGS_RE.search(text)
|
|
107
|
+
args = a.group(1).strip() if a and a.group(1).strip() else ""
|
|
108
|
+
return (name + (" " + args if args else "")).strip()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def split_agent_message(text):
|
|
112
|
+
"""(sender, body) if `text` IS an agent/teammate wrapper, else (None, None).
|
|
113
|
+
|
|
114
|
+
Structural first-line match only (NOT substring): a user who merely quotes
|
|
115
|
+
`<agent-message>` mid-sentence must not be reclassified. The harness note
|
|
116
|
+
that trails the closing tag is auto-excluded because only the wrapper's
|
|
117
|
+
inner text is taken. An idle-notification JSON body → dropped ("").
|
|
118
|
+
"""
|
|
119
|
+
m = _AGENT_WRAP_RE.match(_PEER_LEAD_RE.sub("", text.lstrip()))
|
|
120
|
+
if not m:
|
|
121
|
+
return None, None
|
|
122
|
+
who = _AGENT_FROM_RE.search(m.group(2) or "")
|
|
123
|
+
body = m.group(3).strip()
|
|
124
|
+
if body.startswith('{"type"'): # idle/status notification
|
|
125
|
+
return (who.group(1) if who else "agent"), ""
|
|
126
|
+
return (who.group(1) if who else "agent"), body
|
|
64
127
|
|
|
65
|
-
# ---------------------------------------------------------------- HTML template
|
|
66
|
-
HTML_TEMPLATE = r"""<!DOCTYPE html>
|
|
67
|
-
<html lang="ko">
|
|
68
|
-
<head>
|
|
69
|
-
<meta charset="UTF-8">
|
|
70
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
71
|
-
<title>{{TITLE}}</title>
|
|
72
|
-
<style>
|
|
73
|
-
:root{--bg:#abc1d1;--me:#fee500;--me-text:#3c1e1e;--bot:#fff;--bot-text:#222;
|
|
74
|
-
--meta:#516680;--header:#3b5e7a;}
|
|
75
|
-
*{box-sizing:border-box}
|
|
76
|
-
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo",
|
|
77
|
-
"Malgun Gothic","Helvetica Neue",sans-serif;background:var(--bg);color:#222;
|
|
78
|
-
font-size:14px;line-height:1.45}
|
|
79
|
-
header{position:sticky;top:0;background:var(--header);color:#fff;padding:12px 16px;
|
|
80
|
-
display:flex;justify-content:space-between;align-items:center;
|
|
81
|
-
box-shadow:0 1px 4px rgba(0,0,0,.15);z-index:10}
|
|
82
|
-
header h1{margin:0;font-size:15px;font-weight:600}
|
|
83
|
-
header .sub{font-size:11px;opacity:.85}
|
|
84
|
-
.room{padding:12px 8px 60px 8px;max-width:780px;margin:0 auto}
|
|
85
|
-
.day{text-align:center;color:#fff;font-size:11px;margin:18px 0 10px;
|
|
86
|
-
letter-spacing:.5px;text-shadow:0 1px 2px rgba(0,0,0,.2)}
|
|
87
|
-
.row{display:flex;margin:6px 0;align-items:flex-end}
|
|
88
|
-
.row.me{justify-content:flex-end}
|
|
89
|
-
.row.bot{justify-content:flex-start}
|
|
90
|
-
.avatar{width:32px;height:32px;border-radius:8px;background:#5a7fa3;color:#fff;
|
|
91
|
-
display:flex;align-items:center;justify-content:center;font-size:12px;
|
|
92
|
-
font-weight:600;margin-right:6px;flex-shrink:0}
|
|
93
|
-
.bubble{max-width:78%;padding:8px 12px;border-radius:14px;word-break:break-word;
|
|
94
|
-
white-space:pre-wrap;box-shadow:0 1px 1px rgba(0,0,0,.08)}
|
|
95
|
-
.me .bubble{background:var(--me);color:var(--me-text);border-bottom-right-radius:4px}
|
|
96
|
-
.bot .bubble{background:var(--bot);color:var(--bot-text);
|
|
97
|
-
border-bottom-left-radius:4px;padding:0;overflow:hidden}
|
|
98
|
-
.bot details{cursor:pointer}
|
|
99
|
-
.bot summary{padding:8px 12px;list-style:none;outline:none;position:relative;font-weight:500}
|
|
100
|
-
.bot summary::-webkit-details-marker{display:none}
|
|
101
|
-
.bot summary::after{content:"\25BE";position:absolute;right:10px;top:8px;
|
|
102
|
-
color:#999;font-size:10px;transition:transform .15s}
|
|
103
|
-
.bot details[open] summary::after{transform:rotate(180deg)}
|
|
104
|
-
.bot details[open] summary{border-bottom:1px solid #eee;background:#fafbfc}
|
|
105
|
-
.bot .detail{padding:8px 12px 10px;font-size:13px;color:#333}
|
|
106
|
-
.bot .detail code{background:#f1f3f5;padding:1px 5px;border-radius:3px;
|
|
107
|
-
font-family:"SF Mono",Menlo,Consolas,monospace;font-size:12px}
|
|
108
|
-
.bot .detail pre{background:#1e1e1e;color:#e0e0e0;padding:8px 10px;border-radius:6px;
|
|
109
|
-
overflow-x:auto;font-size:11.5px;line-height:1.4;margin:6px 0}
|
|
110
|
-
.bot .detail .tools{background:#eef3f8;border-left:3px solid #5a7fa3;
|
|
111
|
-
padding:6px 10px;margin-top:6px;font-size:11.5px;color:#365675;
|
|
112
|
-
border-radius:4px}
|
|
113
|
-
.time{font-size:10px;color:var(--meta);margin:0 4px;white-space:nowrap}
|
|
114
|
-
.legend{position:fixed;bottom:8px;left:50%;transform:translateX(-50%);
|
|
115
|
-
background:rgba(0,0,0,.65);color:#fff;font-size:10.5px;padding:5px 12px;
|
|
116
|
-
border-radius:14px;backdrop-filter:blur(4px)}
|
|
117
|
-
@media(max-width:480px){.bubble{max-width:85%;font-size:13px}.avatar{display:none}}
|
|
118
|
-
</style>
|
|
119
|
-
</head>
|
|
120
|
-
<body>
|
|
121
|
-
<header><div><h1>{{HEADER_TITLE}}</h1><div class="sub">메시지를 탭하여 상세 펼치기</div></div>
|
|
122
|
-
<div class="sub">{{DATE_RANGE}}</div></header>
|
|
123
|
-
<div class="room">
|
|
124
|
-
{{TURNS}}
|
|
125
|
-
</div>
|
|
126
|
-
<div class="legend">A: 전체 펼침 | Z: 전체 접기</div>
|
|
127
|
-
<script>
|
|
128
|
-
document.addEventListener('keydown',function(e){
|
|
129
|
-
if(e.key==='a'||e.key==='A')document.querySelectorAll('details').forEach(function(d){d.open=true});
|
|
130
|
-
if(e.key==='z'||e.key==='Z')document.querySelectorAll('details').forEach(function(d){d.open=false});
|
|
131
|
-
});
|
|
132
|
-
</script>
|
|
133
|
-
</body>
|
|
134
|
-
</html>
|
|
135
|
-
"""
|
|
136
128
|
|
|
137
129
|
# ---------------------------------------------------------------- Redaction
|
|
138
130
|
SECRET_PATTERNS = [
|
|
@@ -164,14 +156,7 @@ def _mask(s: str) -> str:
|
|
|
164
156
|
|
|
165
157
|
|
|
166
158
|
def redact(text: str, extra: str = None, mode: str = "full"):
|
|
167
|
-
"""Multi-layer redaction. Returns (redacted_text, count_by_kind).
|
|
168
|
-
|
|
169
|
-
Layers, in order:
|
|
170
|
-
1. Known patterns (AWS IAM, GitHub PAT, Slack, JWT, private key, password literals).
|
|
171
|
-
2. Shannon entropy ≥ 4.5 on 32+ base64-like runs.
|
|
172
|
-
3. detect-secrets (when installed) — additional pattern coverage.
|
|
173
|
-
4. User keywords from --redact-extra.
|
|
174
|
-
"""
|
|
159
|
+
"""Multi-layer redaction. Returns (redacted_text, count_by_kind)."""
|
|
175
160
|
counts = {}
|
|
176
161
|
out = text
|
|
177
162
|
for name, pat in SECRET_PATTERNS:
|
|
@@ -233,26 +218,94 @@ def redact(text: str, extra: str = None, mode: str = "full"):
|
|
|
233
218
|
return out, counts
|
|
234
219
|
|
|
235
220
|
|
|
236
|
-
# ----------------------------------------------------------------
|
|
221
|
+
# ---------------------------------------------------------------- Discovery
|
|
222
|
+
_TITLE_RE = re.compile(r'"customTitle"\s*:\s*"([^"]+)"')
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def discover_session_name(path):
|
|
226
|
+
"""Best-effort scan a jsonl for `customTitle`. Slug-safe string or None."""
|
|
227
|
+
try:
|
|
228
|
+
with open(path, encoding="utf-8") as f:
|
|
229
|
+
for line in f:
|
|
230
|
+
if '"customTitle"' not in line:
|
|
231
|
+
continue
|
|
232
|
+
m = _TITLE_RE.search(line)
|
|
233
|
+
if m:
|
|
234
|
+
name = m.group(1).strip()
|
|
235
|
+
name = re.sub(r"\s+", "-", name)
|
|
236
|
+
name = re.sub(r"[^\w가-힣.-]", "", name)
|
|
237
|
+
return name[:40] or None
|
|
238
|
+
except (OSError, UnicodeDecodeError):
|
|
239
|
+
return None
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def encode_cwd(cwd) -> str:
|
|
244
|
+
"""Encode a path the way Claude Code names its project dirs.
|
|
245
|
+
|
|
246
|
+
Every non-alphanumeric byte maps to '-' (verified: `/` AND `_` both become
|
|
247
|
+
'-', with no separator collapsing). The spec's `[\\/:.]` fix omitted `_`
|
|
248
|
+
and failed on this very repo (`build_plugin` → dir uses `build-plugin`).
|
|
249
|
+
Non-ASCII (e.g. Hangul) also maps to '-'; if that mis-encodes, the caller
|
|
250
|
+
falls back to the explicit --session / --from-transcript options.
|
|
251
|
+
"""
|
|
252
|
+
return re.sub(r"[^A-Za-z0-9]", "-", str(cwd))
|
|
253
|
+
|
|
254
|
+
|
|
237
255
|
def auto_discover_jsonl():
|
|
238
|
-
|
|
239
|
-
|
|
256
|
+
projdir = pathlib.Path.home() / ".claude" / "projects" / encode_cwd(
|
|
257
|
+
pathlib.Path.cwd())
|
|
240
258
|
if not projdir.exists():
|
|
241
259
|
return None
|
|
242
|
-
|
|
243
|
-
|
|
260
|
+
try:
|
|
261
|
+
files = sorted(projdir.glob("*.jsonl"),
|
|
262
|
+
key=lambda p: p.stat().st_mtime, reverse=True)
|
|
263
|
+
except OSError:
|
|
264
|
+
return None
|
|
244
265
|
return files[0] if files else None
|
|
245
266
|
|
|
246
267
|
|
|
268
|
+
def project_jsonl_files():
|
|
269
|
+
"""All *.jsonl in the encoded project dir, for --all-sessions."""
|
|
270
|
+
projdir = pathlib.Path.home() / ".claude" / "projects" / encode_cwd(
|
|
271
|
+
pathlib.Path.cwd())
|
|
272
|
+
if not projdir.exists():
|
|
273
|
+
return []
|
|
274
|
+
try:
|
|
275
|
+
return sorted(projdir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime)
|
|
276
|
+
except OSError:
|
|
277
|
+
return []
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def session_start(path):
|
|
281
|
+
"""First timestamp in a jsonl (for chronological session ordering)."""
|
|
282
|
+
try:
|
|
283
|
+
with open(path, encoding="utf-8") as f:
|
|
284
|
+
for line in f:
|
|
285
|
+
if '"timestamp"' not in line:
|
|
286
|
+
continue
|
|
287
|
+
m = re.search(r'"timestamp"\s*:\s*"([^"]+)"', line)
|
|
288
|
+
if m:
|
|
289
|
+
return m.group(1)
|
|
290
|
+
except (OSError, UnicodeDecodeError):
|
|
291
|
+
return None
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------- JSONL parse
|
|
247
296
|
KNOWN_TYPES = {"user", "assistant", "agent-setting", "permission-mode", "summary"}
|
|
248
297
|
KNOWN_CONTENT_ITEMS = {"text", "tool_use", "tool_result", "thinking"}
|
|
298
|
+
_CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]") # strip control chars (keeps \t\n)
|
|
249
299
|
|
|
250
300
|
|
|
251
|
-
def parse_turns(stream, unsafe_schema: bool = False
|
|
252
|
-
|
|
301
|
+
def parse_turns(stream, unsafe_schema: bool = False, session_id=None,
|
|
302
|
+
session_name=None):
|
|
303
|
+
"""Yield dicts: {role, text, ts, uuid, tools, line_no, parts, session,
|
|
304
|
+
session_name}.
|
|
253
305
|
|
|
254
|
-
|
|
255
|
-
(
|
|
306
|
+
Raw turns only — harness classification (noise/agent/command) happens later
|
|
307
|
+
in classify_turns(). Every turn carries `parts` (R1: merge_assistant_runs
|
|
308
|
+
needs it) and `session` (session guards for merge/echo).
|
|
256
309
|
"""
|
|
257
310
|
seen_unknown_type = set()
|
|
258
311
|
for line_no, raw in enumerate(stream, 1):
|
|
@@ -312,177 +365,762 @@ def parse_turns(stream, unsafe_schema: bool = False):
|
|
|
312
365
|
f"({type(content).__name__})", file=sys.stderr)
|
|
313
366
|
continue
|
|
314
367
|
|
|
315
|
-
text = "\n".join(p for p in text_parts if p).strip()
|
|
368
|
+
text = _CTRL_RE.sub("", "\n".join(p for p in text_parts if p).strip())
|
|
316
369
|
if not text and not tools:
|
|
317
370
|
continue
|
|
318
371
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
372
|
+
yield {"role": role, "text": text, "ts": ts, "uuid": uuid_,
|
|
373
|
+
"tools": tools, "line_no": line_no,
|
|
374
|
+
"parts": [text] if text else [], # R1: always seed parts
|
|
375
|
+
"session": session_id, "session_name": session_name}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------- Classify (A-1..A-4)
|
|
379
|
+
def classify_turns(turns, drop_trivia=True):
|
|
380
|
+
"""Transform raw turns per the 6-step user-record judgement order.
|
|
381
|
+
|
|
382
|
+
Returns a new list. Roles produced: user, assistant, agent, mark.
|
|
383
|
+
`drop_trivia` (= not --keep-trivia) gates injected bodies, harness commands,
|
|
384
|
+
echo exchanges and harness errors; wrapper blocks / hook-feedback /
|
|
385
|
+
interrupts / agent wrappers / compaction are ALWAYS handled (never restored).
|
|
386
|
+
"""
|
|
387
|
+
out = []
|
|
388
|
+
for t in turns:
|
|
389
|
+
if t["role"] != "user":
|
|
390
|
+
out.append(t)
|
|
391
|
+
continue
|
|
392
|
+
text = t["text"]
|
|
393
|
+
# 1. hook feedback / interrupt → always drop
|
|
394
|
+
if _HOOK_FEEDBACK_RE.match(text) or _INTERRUPT_RE.match(text):
|
|
395
|
+
continue
|
|
396
|
+
# 2. injected skill/workflow body & harness errors → drop if trivia
|
|
397
|
+
if drop_trivia and (_SKILL_BODY_RE.match(text)
|
|
398
|
+
or _WORKFLOW_BODY_RE.match(text)
|
|
399
|
+
or _SYS_ERROR_RE.match(text)):
|
|
400
|
+
continue
|
|
401
|
+
# 3. image note → replace with attachment marker
|
|
402
|
+
if _IMAGE_NOTE_RE.match(text):
|
|
403
|
+
out.append(dict(t, text="🖼 이미지 첨부"))
|
|
404
|
+
continue
|
|
405
|
+
# 4. compaction → divider mark
|
|
406
|
+
if _COMPACTION_RE.match(text):
|
|
407
|
+
out.append(dict(t, role="mark", text="compaction"))
|
|
408
|
+
continue
|
|
409
|
+
# 5. agent / teammate wrapper → agent bubble
|
|
410
|
+
who, body = split_agent_message(text)
|
|
411
|
+
if who is not None:
|
|
412
|
+
if not body: # idle notification
|
|
326
413
|
continue
|
|
414
|
+
out.append(dict(t, role="agent", text=body, agent_from=who))
|
|
415
|
+
continue
|
|
416
|
+
# 6. clean user text → drop if empty
|
|
417
|
+
cleaned = clean_user_text(text, drop_harness=drop_trivia)
|
|
418
|
+
if not cleaned:
|
|
419
|
+
continue
|
|
420
|
+
out.append(dict(t, text=cleaned))
|
|
421
|
+
return out
|
|
327
422
|
|
|
328
|
-
yield {"role": role, "text": text, "ts": ts, "uuid": uuid_,
|
|
329
|
-
"tools": tools, "line_no": line_no}
|
|
330
423
|
|
|
424
|
+
# ---------------------------------------------------------------- Merge / echo
|
|
425
|
+
def merge_assistant_runs(turns):
|
|
426
|
+
"""Merge consecutive assistant records (body + tool_use records) into one
|
|
427
|
+
turn. PURE — never mutates input (so calling twice can't double-count tools).
|
|
428
|
+
Merged turn keeps the FIRST record's timestamp. Same-session only (D-1).
|
|
429
|
+
Must run BEFORE --hide-tool-only so tool counts aren't lost (B-1).
|
|
430
|
+
"""
|
|
431
|
+
merged = []
|
|
432
|
+
for t in turns:
|
|
433
|
+
p = merged[-1] if merged else None
|
|
434
|
+
if (p and t["role"] == "assistant" and p["role"] == "assistant"
|
|
435
|
+
and t.get("session") == p.get("session")):
|
|
436
|
+
if t["text"]:
|
|
437
|
+
p["text"] = (p["text"] + "\n\n" + t["text"]).strip()
|
|
438
|
+
p["parts"].append(t["text"])
|
|
439
|
+
for k, v in t["tools"].items():
|
|
440
|
+
p["tools"][k] = p["tools"].get(k, 0) + v
|
|
441
|
+
continue
|
|
442
|
+
merged.append(dict(t, tools=dict(t["tools"]), parts=list(t["parts"])))
|
|
443
|
+
return merged
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def drop_echo_exchanges(turns):
|
|
447
|
+
"""Drop no-op exchanges: a short user question + a tool-less token reply.
|
|
448
|
+
Structural, not keyword-based (A-4). Same-session only. Dropping a reply
|
|
449
|
+
also drops its question (else the question is orphaned).
|
|
450
|
+
"""
|
|
451
|
+
drop = set()
|
|
452
|
+
for i, t in enumerate(turns):
|
|
453
|
+
if t["role"] != "user" or len(t["text"]) > ECHO_ASK:
|
|
454
|
+
continue
|
|
455
|
+
n = turns[i + 1] if i + 1 < len(turns) else None
|
|
456
|
+
if (n and n["role"] == "assistant" and not n["tools"]
|
|
457
|
+
and len(n["text"].strip()) <= ECHO_REPLY
|
|
458
|
+
and n.get("session") == t.get("session")):
|
|
459
|
+
drop |= {i, i + 1}
|
|
460
|
+
return [t for i, t in enumerate(turns) if i not in drop]
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def fill_missing_ts(turns):
|
|
464
|
+
"""Carry the previous timestamp forward to records that lack one, so a
|
|
465
|
+
record with no ts sorts right after its predecessor (D-1 stable ordering)
|
|
466
|
+
rather than jumping to the session start."""
|
|
467
|
+
last = None
|
|
468
|
+
for t in turns:
|
|
469
|
+
if t.get("ts"):
|
|
470
|
+
last = t["ts"]
|
|
471
|
+
elif last is not None:
|
|
472
|
+
t["ts"] = last
|
|
473
|
+
return turns
|
|
331
474
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
475
|
+
|
|
476
|
+
# ---------------------------------------------------------------- Summary
|
|
477
|
+
# Sentence boundary: terminator followed by whitespace/end, NOT preceded by a
|
|
478
|
+
# digit (guards "5.3", "2024. 08. 31.", "EOS.xlsx"). Lookbehind width 2.
|
|
479
|
+
_SENT_SPLIT = re.compile(r"(?<=[.!?…])(?<![0-9][.!?…])(?=\s|$)\s*|\n+")
|
|
480
|
+
_INTENT_ONLY_RE = re.compile(
|
|
481
|
+
r"^.{0,30}(?:하겠습니다|합니다|해보겠습니다|확인합니다|봅니다|"
|
|
482
|
+
r"보겠습니다|시작합니다|진행합니다)\.?$")
|
|
483
|
+
SHORT_DETAIL = 160
|
|
484
|
+
SINGLE_SPLIT_MIN = SHORT_DETAIL
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _cut(s, n):
|
|
488
|
+
s = s.strip()
|
|
489
|
+
return s if len(s) <= n else s[:max(1, n - 1)].rstrip() + "…"
|
|
343
490
|
|
|
344
491
|
|
|
345
492
|
def naive_summary(text: str) -> str:
|
|
346
|
-
"""
|
|
493
|
+
"""First sentence or ~120 chars."""
|
|
347
494
|
text = text.strip()
|
|
348
495
|
if not text:
|
|
349
496
|
return "(empty turn)"
|
|
350
|
-
|
|
497
|
+
parts = [s for s in _SENT_SPLIT.split(text) if s and s.strip()]
|
|
498
|
+
first = parts[0].strip() if parts else text
|
|
351
499
|
s = first.strip()
|
|
352
500
|
if len(s) > 120:
|
|
353
|
-
s = s[:117] + "
|
|
501
|
+
s = s[:117] + "…"
|
|
354
502
|
if len(s) < 8:
|
|
355
|
-
s =
|
|
503
|
+
s = text[:120].replace("\n", " ")
|
|
356
504
|
return s or "(empty)"
|
|
357
505
|
|
|
358
506
|
|
|
359
|
-
def
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
507
|
+
def _tail_block(blocks):
|
|
508
|
+
"""Last block that reads as prose (skip tables/headings/fences/<15 chars)."""
|
|
509
|
+
for b in reversed(blocks):
|
|
510
|
+
s = b.strip()
|
|
511
|
+
if len(s) < 15 or s.startswith(("|", "```")) or re.match(r"^#{1,6}\s", s):
|
|
512
|
+
continue
|
|
513
|
+
return b
|
|
514
|
+
return blocks[-1] if blocks else ""
|
|
363
515
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
516
|
+
|
|
517
|
+
def _summary_from(text, from_end=False):
|
|
518
|
+
parts = [s for s in _SENT_SPLIT.split(text) if s and s.strip()]
|
|
519
|
+
if not parts:
|
|
520
|
+
return ""
|
|
521
|
+
return (parts[-1] if from_end else parts[0]).strip()
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def summarize_turn(turn):
|
|
525
|
+
"""One-line summary that carries the CONCLUSION, not just the intent (B-2).
|
|
526
|
+
|
|
527
|
+
Reads head + tail: the conclusion clause of an execution turn lives at the
|
|
528
|
+
end. If the opener is a pure intent sentence, the tail takes over entirely.
|
|
367
529
|
"""
|
|
368
|
-
|
|
530
|
+
parts = [p for p in (turn.get("parts") or []) if p.strip()]
|
|
531
|
+
if len(parts) < 2:
|
|
532
|
+
if len(turn["text"]) < SINGLE_SPLIT_MIN:
|
|
533
|
+
return naive_summary(turn["text"])
|
|
534
|
+
parts = [b for b in re.split(r"\n\s*\n", turn["text"]) if b.strip()] or parts
|
|
535
|
+
if len(parts) < 2:
|
|
536
|
+
return naive_summary(turn["text"])
|
|
537
|
+
head = _summary_from(parts[0]) or naive_summary(parts[0])
|
|
538
|
+
if _INTENT_ONLY_RE.match(head):
|
|
539
|
+
only = _summary_from(_tail_block(parts), from_end=True)
|
|
540
|
+
if only and only != "(empty turn)":
|
|
541
|
+
return _cut(only, 120)
|
|
542
|
+
tail = _summary_from(_tail_block(parts), from_end=True)
|
|
543
|
+
if not tail or tail == head:
|
|
544
|
+
return _cut(head, 120)
|
|
545
|
+
head = _cut(head, 58)
|
|
546
|
+
return head + " … " + _cut(tail, max(1, 120 - len(head) - 3))
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# ---------------------------------------------------------------- Summary cache
|
|
550
|
+
def cache_dir(session_id: str):
|
|
551
|
+
d = CACHE_BASE / str(SCHEMA_VERSION) / (session_id or "default")
|
|
552
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
553
|
+
for p in (d, CACHE_BASE / str(SCHEMA_VERSION), CACHE_BASE):
|
|
554
|
+
try:
|
|
555
|
+
os.chmod(p, 0o700)
|
|
556
|
+
except OSError:
|
|
557
|
+
pass
|
|
558
|
+
return d
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def read_or_summarize(turn: dict, session_id: str, rebuild: bool = False,
|
|
562
|
+
redact_extra=None, redact_mode: str = "full"):
|
|
563
|
+
"""Return (summary, cache_hit). Cache key includes the content hash AND the
|
|
564
|
+
summarizer version (B-3), so changing the summarizer invalidates old entries.
|
|
565
|
+
Cached text is always redacted (secret hygiene).
|
|
566
|
+
"""
|
|
567
|
+
digest = hashlib.sha256(
|
|
568
|
+
(turn["text"] + "\x00s" + str(SUMMARIZER_VERSION)).encode()
|
|
569
|
+
).hexdigest()[:8]
|
|
570
|
+
p = cache_dir(turn.get("session") or session_id) / f"{turn['uuid']}-{digest}.txt"
|
|
369
571
|
if p.exists() and not rebuild:
|
|
370
|
-
|
|
371
|
-
|
|
572
|
+
try:
|
|
573
|
+
return p.read_text(encoding="utf-8").strip(), True
|
|
574
|
+
except (OSError, UnicodeDecodeError):
|
|
575
|
+
pass
|
|
576
|
+
raw_summary = summarize_turn(turn)
|
|
372
577
|
redacted, _ = redact(raw_summary, extra=redact_extra, mode=redact_mode)
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
578
|
+
try:
|
|
579
|
+
fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
580
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
581
|
+
f.write(redacted)
|
|
582
|
+
except OSError:
|
|
583
|
+
pass
|
|
376
584
|
return redacted, False
|
|
377
585
|
|
|
378
586
|
|
|
379
|
-
# ----------------------------------------------------------------
|
|
587
|
+
# ---------------------------------------------------------------- Markdown (C-1..C-3)
|
|
588
|
+
# Carve code spans and https-only links to opaque sentinels BEFORE emphasis, so
|
|
589
|
+
# no emphasis rule ever runs over emitted markup (C-2). Sentinel chars exclude
|
|
590
|
+
# every markdown metachar. Operates on ALREADY-ESCAPED text.
|
|
591
|
+
_SENTINEL = "\x00%d\x00"
|
|
592
|
+
_SENTINEL_RE = re.compile(r"\x00(\d+)\x00")
|
|
593
|
+
_MD_CARVE = re.compile(
|
|
594
|
+
r"`(?P<code>[^`\n]+)`"
|
|
595
|
+
r"|\[(?P<label>[^\]\n]+)\]\((?P<url>https?://[^\s)]+)\)")
|
|
596
|
+
_MD_BOLD = re.compile(r"\*\*(?=\S)(.+?)(?<=\S)\*\*", re.S)
|
|
597
|
+
_MD_DEL = re.compile(r"~~(?=\S)([^<]+?)(?<=\S)~~")
|
|
598
|
+
_MD_ITAL = re.compile(r"(?<![*\w])\*(?=\S)([^*<\n]+?)(?<=\S)\*(?![*\w])")
|
|
599
|
+
_FENCE_RE = re.compile(r"^\s*(`{3,})(\S*)\s*$")
|
|
600
|
+
_FENCE_CLOSE_RE = re.compile(r"^\s*(`{3,})\s*$")
|
|
601
|
+
_HEAD_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
|
602
|
+
_HR_RE = re.compile(r"^\s*([-*_])(?:\s*\1){2,}\s*$")
|
|
603
|
+
_QUOTE_RE = re.compile(r"^\s*>")
|
|
604
|
+
_LIST_RE = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+(.*)$")
|
|
605
|
+
_TABLE_DELIM_RE = re.compile(r"^\s*\|?[\s:|-]+\|[\s:|-]*$")
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _md_inline(escaped):
|
|
609
|
+
"""Inline markdown on an already-html.escaped string (carve → emphasis → restore)."""
|
|
610
|
+
stash = []
|
|
611
|
+
|
|
612
|
+
def carve(m):
|
|
613
|
+
if m.group("code") is not None:
|
|
614
|
+
stash.append("<code>%s</code>" % m.group("code"))
|
|
615
|
+
else:
|
|
616
|
+
stash.append(
|
|
617
|
+
'<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>'
|
|
618
|
+
% (m.group("url"), m.group("label")))
|
|
619
|
+
return _SENTINEL % (len(stash) - 1)
|
|
620
|
+
|
|
621
|
+
s = _MD_CARVE.sub(carve, escaped)
|
|
622
|
+
s = _MD_BOLD.sub(r"<strong>\1</strong>", s)
|
|
623
|
+
s = _MD_DEL.sub(r"<del>\1</del>", s)
|
|
624
|
+
s = _MD_ITAL.sub(r"<em>\1</em>", s)
|
|
625
|
+
return _SENTINEL_RE.sub(lambda m: stash[int(m.group(1))], s)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _is_block_start(lines, i):
|
|
629
|
+
line = lines[i]
|
|
630
|
+
if (_FENCE_RE.match(line) or _HEAD_RE.match(line) or _HR_RE.match(line)
|
|
631
|
+
or _QUOTE_RE.match(line) or _LIST_RE.match(line)):
|
|
632
|
+
return True
|
|
633
|
+
# table: this line has a pipe AND the next line is a delimiter row
|
|
634
|
+
if ("|" in line and i + 1 < len(lines)
|
|
635
|
+
and "-" in lines[i + 1] and _TABLE_DELIM_RE.match(lines[i + 1])):
|
|
636
|
+
return True
|
|
637
|
+
return False
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _render_table(lines, i):
|
|
641
|
+
header = [c.strip() for c in lines[i].strip().strip("|").split("|")]
|
|
642
|
+
i += 2 # skip header + delimiter
|
|
643
|
+
rows = []
|
|
644
|
+
while i < len(lines) and "|" in lines[i] and lines[i].strip():
|
|
645
|
+
rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
|
|
646
|
+
i += 1
|
|
647
|
+
thead = "".join("<th>%s</th>" % _md_inline(html.escape(c)) for c in header)
|
|
648
|
+
body = "".join(
|
|
649
|
+
"<tr>%s</tr>" % "".join("<td>%s</td>" % _md_inline(html.escape(c))
|
|
650
|
+
for c in r)
|
|
651
|
+
for r in rows)
|
|
652
|
+
return ("<table><thead><tr>%s</tr></thead><tbody>%s</tbody></table>"
|
|
653
|
+
% (thead, body)), i
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def render_markdown(text, depth=0):
|
|
657
|
+
"""Block-level markdown → HTML. External deps: none. Escapes first; no stage
|
|
658
|
+
re-runs over prior markup. Nested lists flatten; footnotes unsupported.
|
|
659
|
+
"""
|
|
660
|
+
if depth > _BLOCKQUOTE_MAX_DEPTH:
|
|
661
|
+
return "<p>%s</p>" % _md_inline(html.escape(text))
|
|
662
|
+
text = _CTRL_RE.sub("", text)
|
|
663
|
+
lines = text.split("\n")
|
|
664
|
+
out = []
|
|
665
|
+
i, n = 0, len(lines)
|
|
666
|
+
while i < n:
|
|
667
|
+
line = lines[i]
|
|
668
|
+
mf = _FENCE_RE.match(line)
|
|
669
|
+
if mf:
|
|
670
|
+
fence = mf.group(1)
|
|
671
|
+
i += 1
|
|
672
|
+
buf = []
|
|
673
|
+
while i < n:
|
|
674
|
+
mc = _FENCE_CLOSE_RE.match(lines[i])
|
|
675
|
+
if mc and len(mc.group(1)) >= len(fence):
|
|
676
|
+
i += 1
|
|
677
|
+
break
|
|
678
|
+
buf.append(lines[i])
|
|
679
|
+
i += 1
|
|
680
|
+
out.append("<pre><code>%s</code></pre>"
|
|
681
|
+
% html.escape("\n".join(buf)))
|
|
682
|
+
continue
|
|
683
|
+
mh = _HEAD_RE.match(line)
|
|
684
|
+
if mh:
|
|
685
|
+
hl = max(3, min(6, len(mh.group(1)))) # h3..h6 (avoid page-header clash)
|
|
686
|
+
out.append("<h%d>%s</h%d>"
|
|
687
|
+
% (hl, _md_inline(html.escape(mh.group(2).strip())), hl))
|
|
688
|
+
i += 1
|
|
689
|
+
continue
|
|
690
|
+
if _HR_RE.match(line):
|
|
691
|
+
out.append("<hr>")
|
|
692
|
+
i += 1
|
|
693
|
+
continue
|
|
694
|
+
if _QUOTE_RE.match(line):
|
|
695
|
+
buf = []
|
|
696
|
+
while i < n and _QUOTE_RE.match(lines[i]):
|
|
697
|
+
buf.append(re.sub(r"^\s*>\s?", "", lines[i]))
|
|
698
|
+
i += 1
|
|
699
|
+
out.append("<blockquote>%s</blockquote>"
|
|
700
|
+
% render_markdown("\n".join(buf), depth + 1))
|
|
701
|
+
continue
|
|
702
|
+
if ("|" in line and i + 1 < n and "-" in lines[i + 1]
|
|
703
|
+
and _TABLE_DELIM_RE.match(lines[i + 1])):
|
|
704
|
+
tbl, i = _render_table(lines, i)
|
|
705
|
+
out.append(tbl)
|
|
706
|
+
continue
|
|
707
|
+
ml = _LIST_RE.match(line)
|
|
708
|
+
if ml:
|
|
709
|
+
ordered = bool(re.match(r"^\s*\d+[.)]", line))
|
|
710
|
+
tag = "ol" if ordered else "ul"
|
|
711
|
+
items = []
|
|
712
|
+
while i < n:
|
|
713
|
+
mli = _LIST_RE.match(lines[i])
|
|
714
|
+
if not mli:
|
|
715
|
+
break
|
|
716
|
+
items.append(_md_inline(html.escape(mli.group(3))))
|
|
717
|
+
i += 1
|
|
718
|
+
out.append("<%s>%s</%s>"
|
|
719
|
+
% (tag, "".join("<li>%s</li>" % it for it in items), tag))
|
|
720
|
+
continue
|
|
721
|
+
if not line.strip():
|
|
722
|
+
i += 1
|
|
723
|
+
continue
|
|
724
|
+
# paragraph: force-consume at least this line (C-2: no infinite loop)
|
|
725
|
+
buf = [line]
|
|
726
|
+
i += 1
|
|
727
|
+
while i < n and lines[i].strip() and not _is_block_start(lines, i):
|
|
728
|
+
buf.append(lines[i])
|
|
729
|
+
i += 1
|
|
730
|
+
# source newlines within a paragraph become <br> (N5). pre-wrap on the
|
|
731
|
+
# container preserves leading/aligned spaces without double-breaking,
|
|
732
|
+
# because we join with <br> and emit no literal newline between lines.
|
|
733
|
+
out.append("<p>%s</p>"
|
|
734
|
+
% "<br>".join(_md_inline(html.escape(b)) for b in buf))
|
|
735
|
+
return "\n".join(out)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def render_body(text, markdown=True):
|
|
739
|
+
"""Render a message body: markdown HTML, or escaped pre-wrap text."""
|
|
740
|
+
if markdown:
|
|
741
|
+
return render_markdown(text)
|
|
742
|
+
return html.escape(text)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
# ---------------------------------------------------------------- HTML template
|
|
746
|
+
HTML_TEMPLATE = r"""<!DOCTYPE html>
|
|
747
|
+
<html lang="ko">
|
|
748
|
+
<head>
|
|
749
|
+
<meta charset="UTF-8">
|
|
750
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
751
|
+
<title>{{TITLE}}</title>
|
|
752
|
+
<style>
|
|
753
|
+
:root{--bg:#abc1d1;--me:#fee500;--me-text:#3c1e1e;--bot:#fff;--bot-text:#222;
|
|
754
|
+
--meta:#516680;--header:#3b5e7a;--agent:#e8eef4;--agent-text:#2a3b4d;
|
|
755
|
+
--agent-bar:#6b7f95;--pill-date-bg:#1c3a52;--pill-date-fg:#fff;
|
|
756
|
+
--pill-sess-bg:#fff;--pill-sess-fg:#284b66;--pill-cmp-fg:#eaf1f7;}
|
|
757
|
+
*{box-sizing:border-box}
|
|
758
|
+
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo",
|
|
759
|
+
"Malgun Gothic","Helvetica Neue",sans-serif;background:var(--bg);color:#222;
|
|
760
|
+
font-size:14px;line-height:1.45}
|
|
761
|
+
header{position:sticky;top:0;background:var(--header);color:#fff;padding:12px 16px;
|
|
762
|
+
display:flex;justify-content:space-between;align-items:center;gap:10px;
|
|
763
|
+
box-shadow:0 1px 4px rgba(0,0,0,.15);z-index:10}
|
|
764
|
+
header h1{margin:0;font-size:15px;font-weight:600}
|
|
765
|
+
header .sub{font-size:11px;opacity:.85}
|
|
766
|
+
header .now{font-size:11px;opacity:.95;text-align:right;white-space:nowrap;
|
|
767
|
+
font-variant-numeric:tabular-nums}
|
|
768
|
+
.room{padding:12px 8px 60px 8px;max-width:820px;margin:0 auto}
|
|
769
|
+
.day{text-align:center;margin:18px 0 10px}
|
|
770
|
+
.pill{display:inline-block;padding:3px 12px;border-radius:12px;font-size:11.5px;
|
|
771
|
+
font-weight:600;letter-spacing:.3px}
|
|
772
|
+
.pill-date{background:var(--pill-date-bg);color:var(--pill-date-fg)}
|
|
773
|
+
.pill-session{background:var(--pill-sess-bg);color:var(--pill-sess-fg);
|
|
774
|
+
border:1px solid #b6c6d4}
|
|
775
|
+
.pill-compact{background:transparent;color:var(--pill-cmp-fg);border:1px dashed
|
|
776
|
+
rgba(255,255,255,.7);font-weight:500}
|
|
777
|
+
.row{display:flex;margin:6px 0;align-items:flex-end}
|
|
778
|
+
.row.me{justify-content:flex-end}
|
|
779
|
+
.row.bot{justify-content:flex-start}
|
|
780
|
+
.avatar{width:32px;height:32px;border-radius:8px;background:#5a7fa3;color:#fff;
|
|
781
|
+
display:flex;align-items:center;justify-content:center;font-size:12px;
|
|
782
|
+
font-weight:600;margin-right:6px;flex-shrink:0}
|
|
783
|
+
.avatar.agent{background:var(--agent-bar)}
|
|
784
|
+
.bubble{max-width:78%;padding:8px 12px;border-radius:14px;word-break:break-word;
|
|
785
|
+
box-shadow:0 1px 1px rgba(0,0,0,.08)}
|
|
786
|
+
.me .bubble{background:var(--me);color:var(--me-text);border-bottom-right-radius:4px}
|
|
787
|
+
.bot .bubble{background:var(--bot);color:var(--bot-text);
|
|
788
|
+
border-bottom-left-radius:4px}
|
|
789
|
+
.row.agent .bubble{background:var(--agent);color:var(--agent-text);
|
|
790
|
+
border-left:3px solid var(--agent-bar)}
|
|
791
|
+
.me .bubble,.bot .bubble{overflow:hidden}
|
|
792
|
+
.me .bubble>details,.bot .bubble>details{margin:-8px -12px}
|
|
793
|
+
details{cursor:pointer}
|
|
794
|
+
summary{padding:8px 12px;list-style:none;outline:none;position:relative;
|
|
795
|
+
font-weight:500}
|
|
796
|
+
summary::-webkit-details-marker{display:none}
|
|
797
|
+
summary::after{content:"\25BE";position:absolute;right:10px;top:8px;
|
|
798
|
+
color:#999;font-size:10px;transition:transform .15s}
|
|
799
|
+
details[open]>summary::after{transform:rotate(180deg)}
|
|
800
|
+
details[open]>summary{border-bottom:1px solid #eee;background:#fafbfc}
|
|
801
|
+
details[open]>summary .sum{display:none} /* hide summary when open (both speakers) */
|
|
802
|
+
.me details[open]>summary{background:rgba(0,0,0,.05)}
|
|
803
|
+
.detail{padding:8px 12px 10px;font-size:13px}
|
|
804
|
+
.bot .detail,.row.agent .detail{color:#333}
|
|
805
|
+
.from{font-size:11px;color:var(--agent-bar);font-weight:600;margin-bottom:4px}
|
|
806
|
+
.detail p,.detail li,.detail blockquote{white-space:pre-wrap;margin:4px 0}
|
|
807
|
+
.detail h3,.detail h4,.detail h5,.detail h6{margin:8px 0 4px;font-size:13.5px}
|
|
808
|
+
.detail ul,.detail ol{margin:4px 0;padding-left:20px}
|
|
809
|
+
.detail code{background:#f1f3f5;padding:1px 5px;border-radius:3px;
|
|
810
|
+
font-family:"SF Mono",Menlo,Consolas,monospace;font-size:12px}
|
|
811
|
+
.detail pre{background:#1e1e1e;color:#e0e0e0;padding:8px 10px;border-radius:6px;
|
|
812
|
+
overflow-x:auto;font-size:11.5px;line-height:1.4;margin:6px 0}
|
|
813
|
+
.detail pre code{background:none;padding:0;color:inherit}
|
|
814
|
+
.detail table{border-collapse:collapse;margin:6px 0;font-size:12px}
|
|
815
|
+
.detail th,.detail td{border:1px solid #d4dde5;padding:3px 8px;text-align:left}
|
|
816
|
+
.detail blockquote{border-left:3px solid #c6d1dc;padding-left:8px;color:#556}
|
|
817
|
+
.detail a{color:#0e6b6b}
|
|
818
|
+
.me .plain{white-space:pre-wrap}
|
|
819
|
+
.tools{background:#eef3f8;border-left:3px solid #5a7fa3;padding:6px 10px;
|
|
820
|
+
margin-top:6px;font-size:11.5px;color:#365675;border-radius:4px}
|
|
821
|
+
.time{font-size:10px;color:var(--meta);margin:0 4px;white-space:nowrap}
|
|
822
|
+
.helpbtn{position:fixed;right:14px;bottom:14px;width:34px;height:34px;
|
|
823
|
+
border-radius:50%;background:var(--header);color:#fff;border:none;
|
|
824
|
+
font-size:16px;cursor:pointer;box-shadow:0 2px 6px rgba(0,0,0,.3);z-index:20}
|
|
825
|
+
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:none;
|
|
826
|
+
align-items:center;justify-content:center;z-index:30}
|
|
827
|
+
.overlay.on{display:flex}
|
|
828
|
+
.card{background:#fff;color:#222;border-radius:10px;padding:18px 20px;
|
|
829
|
+
max-width:420px;width:90%;box-shadow:0 8px 30px rgba(0,0,0,.3);font-size:13px}
|
|
830
|
+
.card h2{margin:0 0 10px;font-size:15px}
|
|
831
|
+
.card dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:4px 12px}
|
|
832
|
+
.card dt{font-family:"SF Mono",Menlo,Consolas,monospace;font-weight:600;color:#0e6b6b}
|
|
833
|
+
@media(max-width:480px){.bubble{max-width:86%;font-size:13px}.avatar{display:none}}
|
|
834
|
+
</style>
|
|
835
|
+
</head>
|
|
836
|
+
<body>
|
|
837
|
+
<header><div><h1>{{HEADER_TITLE}}</h1><div class="sub">메시지를 탭하여 펼치기 · ? 도움말</div></div>
|
|
838
|
+
<div class="now" id="now">{{DATE_RANGE}}</div></header>
|
|
839
|
+
<div class="room" id="room">
|
|
840
|
+
{{TURNS}}
|
|
841
|
+
</div>
|
|
842
|
+
<button class="helpbtn" id="helpbtn" aria-label="도움말" title="도움말 (?)">?</button>
|
|
843
|
+
<div class="overlay" id="overlay" role="dialog" aria-modal="true" aria-label="도움말">
|
|
844
|
+
<div class="card">
|
|
845
|
+
<h2>키보드 · 범례</h2>
|
|
846
|
+
<dl>
|
|
847
|
+
<dt>A</dt><dd>전체 펼치기</dd>
|
|
848
|
+
<dt>Z</dt><dd>전체 접기</dd>
|
|
849
|
+
<dt>J / K</dt><dd>다음 / 이전 내 메시지</dd>
|
|
850
|
+
<dt>T / B</dt><dd>맨 위 / 맨 아래</dd>
|
|
851
|
+
<dt>?</dt><dd>이 도움말</dd>
|
|
852
|
+
<dt>Esc</dt><dd>닫기</dd>
|
|
853
|
+
</dl>
|
|
854
|
+
<p style="margin:10px 0 0;color:#667">노랑=나 · 흰색=Claude · 회색=에이전트 보고.
|
|
855
|
+
날짜/세션/compaction 은 알약 형태로 구분됩니다.</p>
|
|
856
|
+
</div>
|
|
857
|
+
</div>
|
|
858
|
+
<script>
|
|
859
|
+
(function(){
|
|
860
|
+
var room=document.getElementById('room');
|
|
861
|
+
var overlay=document.getElementById('overlay');
|
|
862
|
+
var helpbtn=document.getElementById('helpbtn');
|
|
863
|
+
var nowEl=document.getElementById('now');
|
|
864
|
+
var lastFocus=null;
|
|
865
|
+
function all(open){var ds=room.querySelectorAll('details');for(var i=0;i<ds.length;i++)ds[i].open=open;}
|
|
866
|
+
function setHelp(on){
|
|
867
|
+
overlay.classList.toggle('on',on);
|
|
868
|
+
if(on){lastFocus=document.activeElement;overlay.focus&&overlay.focus();}
|
|
869
|
+
else if(lastFocus&&lastFocus.focus){lastFocus.focus();}
|
|
870
|
+
}
|
|
871
|
+
helpbtn.addEventListener('click',function(){setHelp(true);});
|
|
872
|
+
// overlay closes on BACKDROP click only (so the card text stays selectable)
|
|
873
|
+
overlay.addEventListener('click',function(e){if(e.target===overlay)setHelp(false);});
|
|
874
|
+
// J/K navigation anchored to the header height (top anchor), highlight cleared
|
|
875
|
+
var mine=[],curIdx=-1,anchor=0;
|
|
876
|
+
function measure(){
|
|
877
|
+
var h=document.querySelector('header');anchor=h?h.offsetHeight+6:56;
|
|
878
|
+
mine=[].slice.call(room.querySelectorAll('.row.me'));
|
|
879
|
+
}
|
|
880
|
+
function step(dir){
|
|
881
|
+
if(!mine.length)measure();if(!mine.length)return;
|
|
882
|
+
curIdx=Math.max(0,Math.min(mine.length-1,curIdx+dir));
|
|
883
|
+
var el=mine[curIdx];
|
|
884
|
+
for(var i=0;i<mine.length;i++)mine[i].style.outline='';
|
|
885
|
+
el.style.outline='2px solid #fee500';
|
|
886
|
+
var y=el.getBoundingClientRect().top+window.pageYOffset-anchor;
|
|
887
|
+
window.scrollTo(0,y);
|
|
888
|
+
}
|
|
889
|
+
// header shows the current date/session while scrolling
|
|
890
|
+
var marks=[].slice.call(room.querySelectorAll('[data-mark]'));
|
|
891
|
+
function updateNow(){
|
|
892
|
+
if(!marks.length)return;var y=window.pageYOffset+80,cur=null;
|
|
893
|
+
for(var i=0;i<marks.length;i++){if(marks[i].offsetTop<=y)cur=marks[i];}
|
|
894
|
+
if(cur&&nowEl)nowEl.textContent=cur.getAttribute('data-mark');
|
|
895
|
+
}
|
|
896
|
+
window.addEventListener('scroll',updateNow,{passive:true});
|
|
897
|
+
window.addEventListener('resize',measure);measure();updateNow();
|
|
898
|
+
document.addEventListener('keydown',function(e){
|
|
899
|
+
// never hijack browser shortcuts; die-safe under Korean IME (e.key='ㅁ')
|
|
900
|
+
if(e.ctrlKey||e.metaKey||e.altKey||e.isComposing)return;
|
|
901
|
+
var t=e.target;
|
|
902
|
+
if(t&&(t.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName||'')))return;
|
|
903
|
+
if(overlay.classList.contains('on')){if(e.key==='Escape'){setHelp(false);e.preventDefault();}return;}
|
|
904
|
+
var c=e.code||'',k=(e.key||'').toLowerCase();
|
|
905
|
+
if(c==='KeyA'||k==='a')all(true);
|
|
906
|
+
else if(c==='KeyZ'||k==='z')all(false);
|
|
907
|
+
else if(c==='KeyJ'||k==='j')step(1);
|
|
908
|
+
else if(c==='KeyK'||k==='k')step(-1);
|
|
909
|
+
else if(c==='KeyT'||k==='t')window.scrollTo(0,0);
|
|
910
|
+
else if(c==='KeyB'||k==='b')window.scrollTo(0,document.body.scrollHeight);
|
|
911
|
+
else if(e.key==='?'||(c==='Slash'&&e.shiftKey))setHelp(true);
|
|
912
|
+
else return;
|
|
913
|
+
e.preventDefault();
|
|
914
|
+
});
|
|
915
|
+
})();
|
|
916
|
+
</script>
|
|
917
|
+
</body>
|
|
918
|
+
</html>
|
|
919
|
+
"""
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
# ---------------------------------------------------------------- Render rows
|
|
923
|
+
def _fmt_time(ts):
|
|
924
|
+
return ts[11:16] if ts and len(ts) >= 16 else ""
|
|
925
|
+
|
|
926
|
+
|
|
380
927
|
def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
|
|
381
|
-
open_details=False):
|
|
928
|
+
open_details=False, markdown=True, all_sessions=False):
|
|
382
929
|
rows = []
|
|
383
|
-
details_tag = "<details open>" if open_details else "<details>"
|
|
384
930
|
last_date = None
|
|
931
|
+
last_session = None
|
|
385
932
|
cache_hits = 0
|
|
386
933
|
cache_total = 0
|
|
387
934
|
redact_counts = {}
|
|
935
|
+
open_attr = " open" if open_details else ""
|
|
936
|
+
|
|
937
|
+
def _red(text):
|
|
938
|
+
r, c = redact(text, extra=redact_extra, mode=redact_mode)
|
|
939
|
+
for k, v in c.items():
|
|
940
|
+
redact_counts[k] = redact_counts.get(k, 0) + v
|
|
941
|
+
return r
|
|
388
942
|
|
|
389
943
|
for t in turns:
|
|
390
|
-
ts = t
|
|
944
|
+
ts = t.get("ts") or ""
|
|
945
|
+
# session-transition divider (--all-sessions)
|
|
946
|
+
if all_sessions and t.get("session") != last_session:
|
|
947
|
+
last_session = t.get("session")
|
|
948
|
+
name = html.escape(t.get("session_name") or last_session or "session")
|
|
949
|
+
rows.append('<div class="day" data-mark="%s">'
|
|
950
|
+
'<span class="pill pill-session">%s</span></div>'
|
|
951
|
+
% (name, name))
|
|
952
|
+
last_date = None
|
|
391
953
|
if ts:
|
|
392
954
|
d = ts[:10]
|
|
393
955
|
if d != last_date:
|
|
394
|
-
rows.append(
|
|
956
|
+
rows.append('<div class="day" data-mark="%s">'
|
|
957
|
+
'<span class="pill pill-date">%s</span></div>'
|
|
958
|
+
% (html.escape(d), html.escape(d)))
|
|
395
959
|
last_date = d
|
|
396
|
-
time_hm = ts
|
|
960
|
+
time_hm = html.escape(_fmt_time(ts))
|
|
961
|
+
role = t["role"]
|
|
397
962
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
963
|
+
if role == "mark":
|
|
964
|
+
rows.append('<div class="day"><span class="pill pill-compact">'
|
|
965
|
+
'⋯ 이전 대화 요약(compaction) ⋯</span></div>')
|
|
966
|
+
continue
|
|
967
|
+
|
|
968
|
+
if role == "user":
|
|
969
|
+
red = _red(t["text"])
|
|
970
|
+
if len(red) > USER_FOLD:
|
|
971
|
+
summary = html.escape(_cut(red.replace("\n", " "), 90))
|
|
972
|
+
body = render_body(red, markdown=markdown)
|
|
973
|
+
rows.append(
|
|
974
|
+
'<div class="row me"><span class="time">%s</span>'
|
|
975
|
+
'<div class="bubble"><details%s>'
|
|
976
|
+
'<summary><span class="sum">%s</span></summary>'
|
|
977
|
+
'<div class="detail">%s</div></details></div></div>'
|
|
978
|
+
% (time_hm, open_attr, summary, body))
|
|
979
|
+
else:
|
|
980
|
+
rows.append(
|
|
981
|
+
'<div class="row me"><span class="time">%s</span>'
|
|
982
|
+
'<div class="bubble"><div class="plain">%s</div></div></div>'
|
|
983
|
+
% (time_hm, render_body(red, markdown=markdown)))
|
|
984
|
+
continue
|
|
985
|
+
|
|
986
|
+
# assistant or agent bubble
|
|
987
|
+
cache_total += 1
|
|
988
|
+
summary, hit = read_or_summarize(
|
|
989
|
+
t, session_id, rebuild=rebuild,
|
|
990
|
+
redact_extra=redact_extra, redact_mode=redact_mode)
|
|
991
|
+
if hit:
|
|
992
|
+
cache_hits += 1
|
|
993
|
+
esc_sum = html.escape(_red(summary))
|
|
994
|
+
detail_body = render_body(_red(t["text"]), markdown=markdown)
|
|
995
|
+
|
|
996
|
+
tools_html = ""
|
|
997
|
+
if t.get("tools"):
|
|
998
|
+
ts_list = ", ".join(f"{k}×{v}" for k, v in sorted(t["tools"].items()))
|
|
999
|
+
total = sum(t["tools"].values())
|
|
1000
|
+
tools_html = ('<div class="tools">🔧 도구 %d건: %s</div>'
|
|
1001
|
+
% (total, html.escape(ts_list)))
|
|
1002
|
+
|
|
1003
|
+
if role == "agent":
|
|
1004
|
+
who = html.escape(t.get("agent_from") or "agent")
|
|
1005
|
+
avatar = "🤝"
|
|
1006
|
+
from_line = '<div class="from">%s</div>' % who
|
|
1007
|
+
row_cls = "row bot agent"
|
|
409
1008
|
else:
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
if t["tools"]:
|
|
423
|
-
ts_list = ", ".join(f"{k}×{v}" for k, v in sorted(t["tools"].items()))
|
|
424
|
-
total = sum(t["tools"].values())
|
|
425
|
-
tools_html = (f'<div class="tools">🔧 도구 {total}건: '
|
|
426
|
-
f'{html.escape(ts_list)}</div>')
|
|
427
|
-
rows.append(
|
|
428
|
-
f'<div class="row bot"><div class="avatar">C</div>'
|
|
429
|
-
f'<div class="bubble">{details_tag}'
|
|
430
|
-
f'<summary>{esc_sum}</summary>'
|
|
431
|
-
f'<div class="detail">{esc_text}{tools_html}</div>'
|
|
432
|
-
f'</details></div>'
|
|
433
|
-
f'<span class="time">{html.escape(time_hm)}</span></div>'
|
|
434
|
-
)
|
|
1009
|
+
avatar = "C"
|
|
1010
|
+
from_line = ""
|
|
1011
|
+
row_cls = "row bot"
|
|
1012
|
+
|
|
1013
|
+
rows.append(
|
|
1014
|
+
'<div class="%s"><div class="avatar%s">%s</div>'
|
|
1015
|
+
'<div class="bubble"><details%s>'
|
|
1016
|
+
'<summary><span class="sum">%s</span></summary>'
|
|
1017
|
+
'<div class="detail">%s%s%s</div></details></div>'
|
|
1018
|
+
'<span class="time">%s</span></div>'
|
|
1019
|
+
% (row_cls, " agent" if role == "agent" else "", avatar,
|
|
1020
|
+
open_attr, esc_sum, from_line, detail_body, tools_html, time_hm))
|
|
435
1021
|
return rows, redact_counts, cache_hits, cache_total
|
|
436
1022
|
|
|
437
1023
|
|
|
438
|
-
# ---------------------------------------------------------------- Self-verify
|
|
1024
|
+
# ---------------------------------------------------------------- Self-verify (F-1)
|
|
1025
|
+
_VOID = frozenset(
|
|
1026
|
+
"area base br col embed hr img input link meta param source track wbr".split())
|
|
1027
|
+
_VERIFY_TAGS = ("details", "div", "p", "ul", "ol", "li", "table", "thead",
|
|
1028
|
+
"tbody", "tr", "th", "td", "blockquote", "pre", "code",
|
|
1029
|
+
"strong", "em", "del", "a", "summary", "h3", "h4", "h5", "h6")
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
class _BalanceParser(HTMLParser):
|
|
1033
|
+
"""Stack-based misnest detector, void-aware. Counts alone miss
|
|
1034
|
+
`<strong>a<em>b</strong>c</em>` (balanced but misnested)."""
|
|
1035
|
+
|
|
1036
|
+
def __init__(self):
|
|
1037
|
+
super().__init__(convert_charrefs=True)
|
|
1038
|
+
self.stack = []
|
|
1039
|
+
self.errs = []
|
|
1040
|
+
|
|
1041
|
+
def _check_attrs(self, tag, attrs):
|
|
1042
|
+
for k, _ in attrs:
|
|
1043
|
+
if k and k.lower().startswith("on"):
|
|
1044
|
+
self.errs.append("event-handler attr %s on <%s>" % (k, tag))
|
|
1045
|
+
|
|
1046
|
+
def handle_starttag(self, tag, attrs):
|
|
1047
|
+
self._check_attrs(tag, attrs)
|
|
1048
|
+
if tag not in _VOID:
|
|
1049
|
+
self.stack.append(tag)
|
|
1050
|
+
|
|
1051
|
+
def handle_startendtag(self, tag, attrs):
|
|
1052
|
+
self._check_attrs(tag, attrs)
|
|
1053
|
+
|
|
1054
|
+
def handle_endtag(self, tag):
|
|
1055
|
+
if tag in _VOID:
|
|
1056
|
+
return
|
|
1057
|
+
if not self.stack:
|
|
1058
|
+
self.errs.append("stray </%s>" % tag)
|
|
1059
|
+
return
|
|
1060
|
+
if self.stack[-1] == tag:
|
|
1061
|
+
self.stack.pop()
|
|
1062
|
+
elif tag in self.stack:
|
|
1063
|
+
self.errs.append("misnest </%s> (top=%s)" % (tag, self.stack[-1]))
|
|
1064
|
+
while self.stack and self.stack.pop() != tag:
|
|
1065
|
+
pass
|
|
1066
|
+
else:
|
|
1067
|
+
self.errs.append("stray </%s>" % tag)
|
|
1068
|
+
|
|
1069
|
+
|
|
439
1070
|
def self_verify(html_text: str):
|
|
440
1071
|
errs = []
|
|
1072
|
+
# strip script/style so their bodies aren't parsed as markup (avoids
|
|
1073
|
+
# false <a> counts from inline JS like `top<a-8`)
|
|
1074
|
+
markup = re.sub(r"<script\b[\s\S]*?</script>|<style\b[\s\S]*?</style>",
|
|
1075
|
+
"", html_text, flags=re.I)
|
|
441
1076
|
try:
|
|
442
1077
|
HTMLParser().feed(html_text)
|
|
443
1078
|
except Exception as e:
|
|
444
1079
|
errs.append(f"HTMLParser: {e}")
|
|
445
|
-
for tag in
|
|
446
|
-
opens = len(re.findall(rf"<{tag}\b",
|
|
447
|
-
closes = len(re.findall(rf"</{tag}>",
|
|
1080
|
+
for tag in _VERIFY_TAGS:
|
|
1081
|
+
opens = len(re.findall(rf"<{tag}\b", markup))
|
|
1082
|
+
closes = len(re.findall(rf"</{tag}>", markup))
|
|
448
1083
|
if opens != closes:
|
|
449
1084
|
errs.append(f"<{tag}> open={opens} close={closes} mismatch")
|
|
1085
|
+
bp = _BalanceParser()
|
|
1086
|
+
try:
|
|
1087
|
+
bp.feed(markup)
|
|
1088
|
+
except Exception as e:
|
|
1089
|
+
errs.append(f"BalanceParser: {e}")
|
|
1090
|
+
errs.extend(bp.errs[:10])
|
|
1091
|
+
if bp.stack:
|
|
1092
|
+
errs.append("unclosed tags: %s" % bp.stack[:10])
|
|
450
1093
|
if re.search(r'<link rel="stylesheet', html_text):
|
|
451
1094
|
errs.append("external stylesheet link found")
|
|
452
1095
|
if re.search(r"<script src=", html_text):
|
|
453
1096
|
errs.append("external script src found")
|
|
454
|
-
#
|
|
455
|
-
#
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
('class="sub">{{DATE_RANGE}}</div>', "{{DATE_RANGE}}"),
|
|
460
|
-
]
|
|
461
|
-
for marker, ph in template_markers:
|
|
1097
|
+
# Event-handler attributes are detected via the parser (real tag attrs),
|
|
1098
|
+
# NOT a text regex — escaped content like `<img onerror=` is inert text.
|
|
1099
|
+
for marker, ph in (("<title>{{TITLE}}</title>", "{{TITLE}}"),
|
|
1100
|
+
("<h1>{{HEADER_TITLE}}</h1>", "{{HEADER_TITLE}}"),
|
|
1101
|
+
('id="now">{{DATE_RANGE}}</div>', "{{DATE_RANGE}}")):
|
|
462
1102
|
if marker in html_text:
|
|
463
1103
|
errs.append(f"unsubstituted template placeholder: {ph}")
|
|
464
1104
|
return errs
|
|
465
1105
|
|
|
466
1106
|
|
|
467
|
-
# ----------------------------------------------------------------
|
|
1107
|
+
# ---------------------------------------------------------------- CLI
|
|
468
1108
|
def build_arg_parser() -> argparse.ArgumentParser:
|
|
469
1109
|
ap = argparse.ArgumentParser(
|
|
470
1110
|
prog="lineage",
|
|
471
|
-
description="Export Claude Code session as a KakaoTalk-style HTML file."
|
|
472
|
-
)
|
|
1111
|
+
description="Export Claude Code session(s) as a KakaoTalk-style HTML file.")
|
|
473
1112
|
ap.add_argument("--last", type=int, help="keep last N turns")
|
|
474
1113
|
ap.add_argument("--from", dest="from_", help="ISO timestamp from (inclusive)")
|
|
475
1114
|
ap.add_argument("--to", help="ISO timestamp to (inclusive)")
|
|
476
1115
|
ap.add_argument("--turns", help='range like "10-50" (1-indexed inclusive)')
|
|
477
1116
|
ap.add_argument("--output", default=None,
|
|
478
|
-
help="output HTML path
|
|
479
|
-
"work/lineage-{session-name}_YYMMDD+HHMM.html "
|
|
480
|
-
"(session-name = customTitle in jsonl, fallback session_id prefix)")
|
|
1117
|
+
help="output HTML path (default: work/lineage-{name}_YYMMDD+HHMM.html)")
|
|
481
1118
|
ap.add_argument("--session", help="jsonl path (overrides auto-discover)")
|
|
1119
|
+
ap.add_argument("--all-sessions", action="store_true",
|
|
1120
|
+
help="merge ALL sessions in the project dir, chronologically")
|
|
482
1121
|
ap.add_argument("--from-transcript",
|
|
483
1122
|
help="read transcript from PATH or '-' for stdin")
|
|
484
|
-
ap.add_argument("--redact-extra",
|
|
485
|
-
help="comma-separated keywords to also redact")
|
|
1123
|
+
ap.add_argument("--redact-extra", help="comma-separated keywords to also redact")
|
|
486
1124
|
ap.add_argument("--redact-mode", choices=["full", "mask"], default="full",
|
|
487
1125
|
help="redaction mode (full=[REDACTED], mask=abcd****wxyz)")
|
|
488
1126
|
ap.add_argument("--unsafe-schema", action="store_true",
|
|
@@ -494,36 +1132,49 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
|
|
494
1132
|
ap.add_argument("--skip-reviewer", action="store_true",
|
|
495
1133
|
help="skip reviewer quality gate (warn-only)")
|
|
496
1134
|
ap.add_argument("--reviewer-output",
|
|
497
|
-
help="path to Critic response JSON;
|
|
498
|
-
"enforce quality gate (PASS = all recoverable=true, "
|
|
499
|
-
"any failure → exit 2). Without this, only the "
|
|
500
|
-
"reviewer-input.json is written.")
|
|
1135
|
+
help="path to Critic response JSON; enforces the quality gate")
|
|
501
1136
|
ap.add_argument("--reviewer-timeout", type=int, default=60,
|
|
502
1137
|
help="seconds to wait for reviewer-output (default 60)")
|
|
503
|
-
ap.add_argument("--title", default="Session Lineage",
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
help="
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
help="keep
|
|
1138
|
+
ap.add_argument("--title", default="Session Lineage", help="HTML title")
|
|
1139
|
+
# Readability defaults are ON. Opt-out flags restore raw/older behavior.
|
|
1140
|
+
ap.add_argument("--hide-tool-only", dest="hide_tool_only", action="store_true",
|
|
1141
|
+
help="(DEFAULT) drop assistant turns that are only tool calls")
|
|
1142
|
+
ap.add_argument("--keep-tool-only", dest="hide_tool_only", action="store_false",
|
|
1143
|
+
help="keep tool-only assistant turns (override default)")
|
|
1144
|
+
ap.add_argument("--no-markdown", dest="markdown", action="store_false",
|
|
1145
|
+
help="disable markdown rendering (default: render ON)")
|
|
1146
|
+
ap.add_argument("--keep-trivia", dest="drop_trivia", action="store_false",
|
|
1147
|
+
help="keep manipulation commands / injected bodies / echo / "
|
|
1148
|
+
"harness errors (default: filter ON)")
|
|
513
1149
|
ap.add_argument("--open", dest="open_details", action="store_true",
|
|
514
|
-
help="
|
|
1150
|
+
help="render bubbles expanded (default: collapsed)")
|
|
515
1151
|
ap.add_argument("--collapse", dest="open_details", action="store_false",
|
|
516
|
-
help="render
|
|
517
|
-
ap.set_defaults(hide_tool_only=True, open_details=
|
|
1152
|
+
help="(DEFAULT) render bubbles collapsed")
|
|
1153
|
+
ap.set_defaults(hide_tool_only=True, open_details=False,
|
|
1154
|
+
markdown=True, drop_trivia=True)
|
|
518
1155
|
return ap
|
|
519
1156
|
|
|
520
1157
|
|
|
521
|
-
def
|
|
1158
|
+
def _open_stream(path):
|
|
1159
|
+
return open(path, encoding="utf-8", errors="strict")
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _load_turns_from(path, unsafe_schema, session_id, session_name):
|
|
1163
|
+
"""Parse one jsonl file, isolating per-file decode errors (D-3)."""
|
|
1164
|
+
try:
|
|
1165
|
+
with _open_stream(path) as stream:
|
|
1166
|
+
return list(parse_turns(stream, unsafe_schema=unsafe_schema,
|
|
1167
|
+
session_id=session_id,
|
|
1168
|
+
session_name=session_name))
|
|
1169
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
1170
|
+
print(f"[lineage] WARN: skipping unreadable session {path}: {e}",
|
|
1171
|
+
file=sys.stderr)
|
|
1172
|
+
return []
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def main(argv=None) -> int:
|
|
522
1176
|
args = build_arg_parser().parse_args(argv)
|
|
523
1177
|
|
|
524
|
-
# Default redaction keywords from env so project secrets need not be retyped
|
|
525
|
-
# each call (e.g. export LINEAGE_REDACT_EXTRA="acme-corp,db-pass"). Merged
|
|
526
|
-
# with any --redact-extra given on the CLI.
|
|
527
1178
|
env_extra = os.environ.get("LINEAGE_REDACT_EXTRA", "").strip()
|
|
528
1179
|
if env_extra:
|
|
529
1180
|
args.redact_extra = ",".join(
|
|
@@ -538,96 +1189,119 @@ def main(argv = None) -> int:
|
|
|
538
1189
|
print("[lineage] no cache to purge", file=sys.stderr)
|
|
539
1190
|
return 0
|
|
540
1191
|
|
|
541
|
-
# Resolve input
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
if args.from_transcript == "-":
|
|
546
|
-
stream = sys.stdin
|
|
547
|
-
session_id = "stdin"
|
|
548
|
-
else:
|
|
549
|
-
jsonl_path = pathlib.Path(args.from_transcript)
|
|
550
|
-
stream = open(jsonl_path, encoding="utf-8")
|
|
551
|
-
session_id = "file-" + jsonl_path.stem
|
|
552
|
-
elif args.session:
|
|
553
|
-
jsonl_path = pathlib.Path(args.session)
|
|
554
|
-
stream = open(jsonl_path, encoding="utf-8")
|
|
555
|
-
session_id = jsonl_path.stem
|
|
556
|
-
else:
|
|
557
|
-
f = auto_discover_jsonl()
|
|
558
|
-
if not f:
|
|
559
|
-
print("[lineage] No jsonl found. Use one of:",
|
|
560
|
-
"\n --session FILE explicit jsonl path",
|
|
561
|
-
"\n --from-transcript - paste transcript via stdin",
|
|
562
|
-
"\n --from-transcript FILE read transcript from file",
|
|
563
|
-
file=sys.stderr)
|
|
564
|
-
return 2
|
|
565
|
-
jsonl_path = f
|
|
566
|
-
stream = open(f, encoding="utf-8")
|
|
567
|
-
session_id = f.stem
|
|
568
|
-
print(f"[lineage] auto-discovered session: {f.name}", file=sys.stderr)
|
|
569
|
-
|
|
570
|
-
# Resolve default --output using session_name (customTitle in jsonl)
|
|
571
|
-
if args.output is None:
|
|
572
|
-
session_name = discover_session_name(jsonl_path) if jsonl_path else None
|
|
573
|
-
slug = session_name or session_id[:8]
|
|
574
|
-
args.output = f"work/lineage-{slug}.html"
|
|
575
|
-
if session_name:
|
|
576
|
-
print(f"[lineage] session name: {session_name}", file=sys.stderr)
|
|
577
|
-
|
|
1192
|
+
# ---- Resolve input → raw turns (parse + parts + session) ----
|
|
1193
|
+
turns = []
|
|
1194
|
+
session_id = "session"
|
|
1195
|
+
jsonl_path = None
|
|
578
1196
|
try:
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
1197
|
+
if args.all_sessions:
|
|
1198
|
+
files = project_jsonl_files()
|
|
1199
|
+
if not files:
|
|
1200
|
+
print("[lineage] --all-sessions: no jsonl found in project dir",
|
|
1201
|
+
file=sys.stderr)
|
|
1202
|
+
return 2
|
|
1203
|
+
starts = {p: session_start(p) for p in files}
|
|
1204
|
+
for p in files:
|
|
1205
|
+
name = discover_session_name(p) or p.stem[:8]
|
|
1206
|
+
part = _load_turns_from(p, args.unsafe_schema, p.stem, name)
|
|
1207
|
+
fill_missing_ts(part)
|
|
1208
|
+
for t in part:
|
|
1209
|
+
t["session_ts"] = starts[p] or "9999"
|
|
1210
|
+
turns.extend(part)
|
|
1211
|
+
# record-level chronological sort (stable → same-ts keeps file order)
|
|
1212
|
+
turns.sort(key=lambda t: t.get("ts") or t.get("session_ts") or "9999")
|
|
1213
|
+
session_id = "all-sessions"
|
|
1214
|
+
print(f"[lineage] --all-sessions: merged {len(files)} sessions",
|
|
1215
|
+
file=sys.stderr)
|
|
1216
|
+
elif args.from_transcript:
|
|
1217
|
+
if args.from_transcript == "-":
|
|
1218
|
+
turns = list(parse_turns(sys.stdin, unsafe_schema=args.unsafe_schema,
|
|
1219
|
+
session_id="stdin"))
|
|
1220
|
+
session_id = "stdin"
|
|
1221
|
+
else:
|
|
1222
|
+
jsonl_path = pathlib.Path(args.from_transcript)
|
|
1223
|
+
session_id = "file-" + jsonl_path.stem
|
|
1224
|
+
turns = _load_turns_from(jsonl_path, args.unsafe_schema,
|
|
1225
|
+
session_id, None)
|
|
1226
|
+
elif args.session:
|
|
1227
|
+
jsonl_path = pathlib.Path(args.session)
|
|
1228
|
+
session_id = jsonl_path.stem
|
|
1229
|
+
turns = _load_turns_from(jsonl_path, args.unsafe_schema,
|
|
1230
|
+
session_id, None)
|
|
1231
|
+
else:
|
|
1232
|
+
f = auto_discover_jsonl()
|
|
1233
|
+
if not f:
|
|
1234
|
+
print("[lineage] No jsonl found. Use one of:",
|
|
1235
|
+
"\n --session FILE explicit jsonl path",
|
|
1236
|
+
"\n --all-sessions merge all project sessions",
|
|
1237
|
+
"\n --from-transcript - paste transcript via stdin",
|
|
1238
|
+
"\n --from-transcript FILE read transcript from file",
|
|
1239
|
+
"\n(non-ASCII cwd may not auto-encode — use --session)",
|
|
1240
|
+
file=sys.stderr)
|
|
1241
|
+
return 2
|
|
1242
|
+
jsonl_path = f
|
|
1243
|
+
session_id = f.stem
|
|
1244
|
+
turns = _load_turns_from(f, args.unsafe_schema, session_id, None)
|
|
1245
|
+
print(f"[lineage] auto-discovered session: {f.name}", file=sys.stderr)
|
|
1246
|
+
except SystemExit as e:
|
|
1247
|
+
return int(e.code) if isinstance(e.code, int) else 2
|
|
1248
|
+
|
|
1249
|
+
# stdin → derive per-turn uuid from content hash so cache works
|
|
588
1250
|
if args.from_transcript == "-":
|
|
589
1251
|
for t in turns:
|
|
590
1252
|
t["uuid"] = hashlib.sha256(t["text"].encode()).hexdigest()[:16]
|
|
591
1253
|
|
|
592
|
-
#
|
|
593
|
-
|
|
1254
|
+
# ---- Pipeline order (R6): classify → merge → echo → hide-tool-only → range ----
|
|
1255
|
+
turns = classify_turns(turns, drop_trivia=args.drop_trivia)
|
|
1256
|
+
turns = merge_assistant_runs(turns)
|
|
1257
|
+
if args.drop_trivia:
|
|
1258
|
+
turns = drop_echo_exchanges(turns)
|
|
594
1259
|
if args.hide_tool_only:
|
|
595
1260
|
turns = [t for t in turns
|
|
596
1261
|
if not (t["role"] == "assistant" and not t["text"].strip())]
|
|
597
|
-
|
|
598
|
-
# Range filters
|
|
599
1262
|
if args.from_:
|
|
600
1263
|
turns = [t for t in turns if (t["ts"] or "") >= args.from_]
|
|
601
1264
|
if args.to:
|
|
602
|
-
|
|
1265
|
+
# A bare date bound is inclusive of the WHOLE end day: a full ISO
|
|
1266
|
+
# timestamp sorts AFTER the date string ("...T10:..." <= "2026-08-09"
|
|
1267
|
+
# is False), so append a high sentinel for date-only input.
|
|
1268
|
+
to_bound = args.to if "T" in args.to else args.to + "T99"
|
|
1269
|
+
turns = [t for t in turns if (t["ts"] or "") <= to_bound]
|
|
603
1270
|
if args.turns:
|
|
604
1271
|
m = re.match(r"^(\d+)-(\d+)$", args.turns)
|
|
605
|
-
if m:
|
|
606
|
-
lo, hi = int(m.group(1)), int(m.group(2))
|
|
607
|
-
turns = turns[lo - 1:hi]
|
|
608
|
-
else:
|
|
1272
|
+
if not m:
|
|
609
1273
|
print(f"[lineage] invalid --turns '{args.turns}'", file=sys.stderr)
|
|
610
1274
|
return 2
|
|
611
|
-
|
|
1275
|
+
lo, hi = max(1, int(m.group(1))), int(m.group(2))
|
|
1276
|
+
turns = turns[lo - 1:hi]
|
|
1277
|
+
if args.last and args.last > 0:
|
|
612
1278
|
turns = turns[-args.last:]
|
|
613
1279
|
|
|
614
1280
|
if len(turns) > 100:
|
|
615
1281
|
print(f"[lineage] WARN: {len(turns)} turns is large — render may be slow",
|
|
616
1282
|
file=sys.stderr)
|
|
617
1283
|
|
|
1284
|
+
# ---- Resolve default output path ----
|
|
1285
|
+
if args.output is None:
|
|
1286
|
+
session_name = discover_session_name(jsonl_path) if jsonl_path else None
|
|
1287
|
+
slug = ("all-sessions" if args.all_sessions
|
|
1288
|
+
else (session_name or session_id[:8]))
|
|
1289
|
+
args.output = f"work/lineage-{slug}.html"
|
|
1290
|
+
if session_name:
|
|
1291
|
+
print(f"[lineage] session name: {session_name}", file=sys.stderr)
|
|
1292
|
+
|
|
618
1293
|
rows, redact_counts, hits, total = render_rows(
|
|
619
1294
|
turns, session_id, args.redact_extra, args.redact_mode,
|
|
620
|
-
args.rebuild_summaries, open_details=args.open_details
|
|
621
|
-
|
|
1295
|
+
args.rebuild_summaries, open_details=args.open_details,
|
|
1296
|
+
markdown=args.markdown, all_sessions=args.all_sessions)
|
|
622
1297
|
|
|
623
1298
|
date_range = ""
|
|
624
|
-
if
|
|
625
|
-
|
|
626
|
-
|
|
1299
|
+
dated = [t for t in turns if t.get("ts")]
|
|
1300
|
+
if dated:
|
|
1301
|
+
first = (dated[0]["ts"] or "")[:10]
|
|
1302
|
+
last = (dated[-1]["ts"] or "")[:10]
|
|
627
1303
|
date_range = first if first == last else f"{first} ~ {last}"
|
|
628
1304
|
|
|
629
|
-
# count=1 → user-content literal {{X}} (e.g. SKILL.md citations) won't be
|
|
630
|
-
# touched. Template placeholders appear exactly once each in HTML_TEMPLATE.
|
|
631
1305
|
html_doc = HTML_TEMPLATE
|
|
632
1306
|
html_doc = html_doc.replace("{{TITLE}}", html.escape(args.title), 1)
|
|
633
1307
|
html_doc = html_doc.replace("{{HEADER_TITLE}}", html.escape(args.title), 1)
|
|
@@ -642,37 +1316,60 @@ def main(argv = None) -> int:
|
|
|
642
1316
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
643
1317
|
out.write_text(html_doc, encoding="utf-8")
|
|
644
1318
|
|
|
645
|
-
|
|
646
|
-
|
|
1319
|
+
_run_reviewer_gate(turns, out, session_id, args)
|
|
1320
|
+
|
|
1321
|
+
tool_total = sum(sum(t.get("tools", {}).values()) for t in turns)
|
|
1322
|
+
print(f"[lineage] turns={len(turns)} redacted={sum(redact_counts.values())} "
|
|
1323
|
+
f"tool_calls={tool_total} cache_hits={hits}/{total} "
|
|
1324
|
+
f"size={out.stat().st_size}B output={out}", file=sys.stderr)
|
|
1325
|
+
if redact_counts:
|
|
1326
|
+
details = ", ".join(f"{k}={v}" for k, v in sorted(redact_counts.items()))
|
|
1327
|
+
print(f"[lineage] redaction breakdown: {details}", file=sys.stderr)
|
|
1328
|
+
return 0
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def _with_timestamp_suffix(path_str):
|
|
1332
|
+
"""Append `_YYMMDD+HHMM` before the extension (idempotent)."""
|
|
1333
|
+
import datetime as _dt
|
|
1334
|
+
p = pathlib.Path(path_str)
|
|
1335
|
+
if re.search(r"_\d{6}\+\d{4}$", p.stem):
|
|
1336
|
+
return p
|
|
1337
|
+
stamp = _dt.datetime.now().strftime("_%y%m%d+%H%M")
|
|
1338
|
+
return p.with_name(f"{p.stem}{stamp}{p.suffix}")
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def _run_reviewer_gate(turns, out, session_id, args):
|
|
1342
|
+
"""Write reviewer samples and (if --reviewer-output) enforce the gate.
|
|
1343
|
+
Uses the new summarizer; the input/output contract is unchanged (N3)."""
|
|
1344
|
+
bots = [t for t in turns if t["role"] in ("assistant", "agent")]
|
|
647
1345
|
if not args.skip_reviewer and bots:
|
|
648
1346
|
import random
|
|
649
1347
|
sample = random.sample(bots, min(5, len(bots)))
|
|
650
1348
|
review_samples = []
|
|
651
1349
|
for i, t in enumerate(sample):
|
|
652
|
-
s, _ = read_or_summarize(
|
|
653
|
-
|
|
654
|
-
|
|
1350
|
+
s, _ = read_or_summarize(t, session_id, rebuild=False,
|
|
1351
|
+
redact_extra=args.redact_extra,
|
|
1352
|
+
redact_mode=args.redact_mode)
|
|
655
1353
|
red_s, _ = redact(s, extra=args.redact_extra, mode=args.redact_mode)
|
|
656
1354
|
red_d, _ = redact(t["text"][:500], extra=args.redact_extra,
|
|
657
1355
|
mode=args.redact_mode)
|
|
658
|
-
review_samples.append({
|
|
659
|
-
|
|
660
|
-
"original_detail": red_d,
|
|
661
|
-
"generated_summary": red_s,
|
|
662
|
-
})
|
|
1356
|
+
review_samples.append({"idx": i, "original_detail": red_d,
|
|
1357
|
+
"generated_summary": red_s})
|
|
663
1358
|
review_path = out.parent / f".{out.stem}.reviewer-input.json"
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1359
|
+
try:
|
|
1360
|
+
review_path.write_text(
|
|
1361
|
+
json.dumps(review_samples, ensure_ascii=False, indent=2),
|
|
1362
|
+
encoding="utf-8")
|
|
1363
|
+
print(f"[lineage] reviewer samples: {review_path}", file=sys.stderr)
|
|
1364
|
+
print("[lineage] next: invoke Skill('oh-my-claudecode:critic') with "
|
|
1365
|
+
"the JSON above; expected [{idx, recoverable, reason}, ...] "
|
|
1366
|
+
"(PASS = 5/5 recoverable)", file=sys.stderr)
|
|
1367
|
+
except OSError:
|
|
1368
|
+
pass
|
|
671
1369
|
elif args.skip_reviewer:
|
|
672
1370
|
print("[lineage] WARN: --skip-reviewer — quality gate not enforced",
|
|
673
1371
|
file=sys.stderr)
|
|
674
1372
|
|
|
675
|
-
# Reviewer quality gate (active when --reviewer-output is provided)
|
|
676
1373
|
if (not args.skip_reviewer) and bots and args.reviewer_output:
|
|
677
1374
|
import time as _time
|
|
678
1375
|
rop = pathlib.Path(args.reviewer_output)
|
|
@@ -682,43 +1379,33 @@ def main(argv = None) -> int:
|
|
|
682
1379
|
if not rop.exists():
|
|
683
1380
|
print(f"[lineage] ERROR: reviewer-output not found within "
|
|
684
1381
|
f"{args.reviewer_timeout}s: {rop}", file=sys.stderr)
|
|
685
|
-
|
|
1382
|
+
raise SystemExit(2)
|
|
686
1383
|
try:
|
|
687
1384
|
verdict = json.loads(rop.read_text(encoding="utf-8"))
|
|
688
|
-
except json.JSONDecodeError as e:
|
|
689
|
-
print(f"[lineage] ERROR: reviewer-output
|
|
1385
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
|
|
1386
|
+
print(f"[lineage] ERROR: reviewer-output parse failed: {e}",
|
|
690
1387
|
file=sys.stderr)
|
|
691
|
-
|
|
1388
|
+
raise SystemExit(2)
|
|
692
1389
|
if not isinstance(verdict, list) or not verdict:
|
|
693
1390
|
print("[lineage] ERROR: reviewer-output is not a non-empty array",
|
|
694
1391
|
file=sys.stderr)
|
|
695
|
-
|
|
1392
|
+
raise SystemExit(2)
|
|
696
1393
|
fails = [v for v in verdict
|
|
697
1394
|
if not (isinstance(v, dict) and v.get("recoverable") is True)]
|
|
698
1395
|
if fails:
|
|
699
1396
|
print(f"[lineage] FAIL: quality gate {len(verdict) - len(fails)}/"
|
|
700
|
-
f"{len(verdict)}
|
|
701
|
-
file=sys.stderr)
|
|
1397
|
+
f"{len(verdict)} recoverable. Reasons:", file=sys.stderr)
|
|
702
1398
|
for f in fails:
|
|
703
1399
|
if isinstance(f, dict):
|
|
704
1400
|
print(f" - idx={f.get('idx', '?')}: "
|
|
705
1401
|
f"{f.get('reason', '(no reason)')}", file=sys.stderr)
|
|
706
|
-
|
|
1402
|
+
raise SystemExit(2)
|
|
707
1403
|
print(f"[lineage] PASS: quality gate {len(verdict)}/{len(verdict)} "
|
|
708
|
-
"
|
|
709
|
-
|
|
710
|
-
tool_total = sum(sum(t["tools"].values()) for t in turns)
|
|
711
|
-
summary_line = (
|
|
712
|
-
f"[lineage] turns={len(turns)} redacted={sum(redact_counts.values())} "
|
|
713
|
-
f"tool_calls={tool_total} (filtered) cache_hits={hits}/{total} "
|
|
714
|
-
f"size={out.stat().st_size}B output={out}"
|
|
715
|
-
)
|
|
716
|
-
print(summary_line, file=sys.stderr)
|
|
717
|
-
if redact_counts:
|
|
718
|
-
details = ", ".join(f"{k}={v}" for k, v in sorted(redact_counts.items()))
|
|
719
|
-
print(f"[lineage] redaction breakdown: {details}", file=sys.stderr)
|
|
720
|
-
return 0
|
|
1404
|
+
"recoverable", file=sys.stderr)
|
|
721
1405
|
|
|
722
1406
|
|
|
723
1407
|
if __name__ == "__main__":
|
|
724
|
-
|
|
1408
|
+
try:
|
|
1409
|
+
sys.exit(main())
|
|
1410
|
+
except SystemExit as _e:
|
|
1411
|
+
sys.exit(int(_e.code) if isinstance(_e.code, int) else 0)
|