@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.
Files changed (32) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +52 -0
  4. package/README.md +28 -8
  5. package/codex/manifest.json +8 -1
  6. package/codex/transform-matrix.md +2 -2
  7. package/commands/setup.md +2 -0
  8. package/package.json +6 -1
  9. package/skills/3d-intro-build/SKILL.md +214 -0
  10. package/skills/3d-intro-build/references/LICENSE +21 -0
  11. package/skills/3d-intro-build/references/NOTICE.md +32 -0
  12. package/skills/3d-intro-build/references/assemble.mjs +252 -0
  13. package/skills/3d-intro-build/references/azure-adapter.mjs +495 -0
  14. package/skills/3d-intro-build/references/curate.mjs +301 -0
  15. package/skills/3d-intro-build/references/index-template.html +73 -0
  16. package/skills/3d-intro-build/references/prompts.md +171 -0
  17. package/skills/3d-intro-build/references/scrub-engine.js +448 -0
  18. package/skills/3d-intro-build/references/serve.mjs +123 -0
  19. package/skills/3d-intro-setup/SKILL.md +196 -0
  20. package/skills/3d-intro-setup/references/azure-adapter.mjs +495 -0
  21. package/skills/lineage/SKILL.md +126 -134
  22. package/skills/lineage/lineage.py +1041 -354
  23. package/skills/motion-graphic-make/SKILL.md +57 -0
  24. package/skills/motion-graphic-setup/SKILL.md +141 -0
  25. package/skills/ralph-qa/SKILL.md +253 -77
  26. package/skills/ralph-qa/references/verifier-probe.mjs +860 -0
  27. package/skills/refresh-readme/SKILL.md +3 -3
  28. package/skills/vertical-pptx/SKILL.md +409 -0
  29. package/skills/vertical-pptx/build_a4p.js +923 -0
  30. package/skills/vertical-pptx/build_a4p.py +916 -0
  31. package/skills/vertical-pptx/verify_a4p.mjs +2168 -0
  32. package/skills/vertical-pptx-setup/SKILL.md +223 -0
@@ -1,8 +1,13 @@
1
1
  #!/usr/bin/env python3
2
- """lineage — Export a Claude Code session as a single KakaoTalk-style HTML file.
2
+ """lineage — Export Claude Code session(s) as a single KakaoTalk-style HTML file.
3
3
 
4
- Deterministic core. Claude summarizes one line per assistant turn (cached).
5
- See SKILL.md for full design.
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 # v2: cache stores redacted summary only (v1 plaintext)
24
- DEFAULT_OUTPUT = "work/session-chat.html"
25
-
26
-
27
- _TITLE_RE = re.compile(r'"customTitle"\s*:\s*"([^"]+)"')
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
- Example: work/session-chat.html -> work/session-chat_260523+1846.html
53
- Already-stamped paths (matching `_YYMMDD+HHMM` immediately before suffix)
54
- are returned unchanged for idempotency.
36
+ # ---------------------------------------------------------------- Noise / classify
37
+ # NOTE: JSONL records carry RAW `<...>` tags. Every regex below uses raw `<`/`>`
38
+ # (NOT the HTML-escaped `&lt;` 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
- import datetime as _dt
57
- p = pathlib.Path(path_str)
58
- stem = p.stem
59
- if re.search(r"_\d{6}\+\d{4}$", stem):
60
- return p
61
- stamp = _dt.datetime.now().strftime("_%y%m%d+%H%M")
62
- return p.with_name(f"{stem}{stamp}{p.suffix}")
63
- CACHE_BASE = pathlib.Path.home() / ".cache" / "lineage"
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
- # ---------------------------------------------------------------- JSONL parse
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
- encoded = str(pathlib.Path.cwd()).replace("/", "-")
239
- projdir = pathlib.Path.home() / ".claude" / "projects" / encoded
256
+ projdir = pathlib.Path.home() / ".claude" / "projects" / encode_cwd(
257
+ pathlib.Path.cwd())
240
258
  if not projdir.exists():
241
259
  return None
242
- files = sorted(projdir.glob("*.jsonl"),
243
- key=lambda p: p.stat().st_mtime, reverse=True)
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
- """Yield dicts: {role, text, ts, uuid, tools, line_no}.
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
- Schema-tolerant: unknown content item types graceful skip with WARN
255
- (or hard error if unsafe_schema=False AND a critical field missing).
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
- if role == "user":
320
- stripped = text.lstrip()
321
- if stripped.startswith("<system-reminder>"):
322
- continue
323
- if stripped.startswith("<teammate-message"):
324
- continue
325
- if stripped.startswith("<command-name>"):
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
- # ---------------------------------------------------------------- Summary cache
333
- def cache_dir(session_id: str):
334
- d = CACHE_BASE / str(SCHEMA_VERSION) / session_id
335
- d.mkdir(parents=True, exist_ok=True)
336
- try:
337
- os.chmod(d, 0o700)
338
- os.chmod(CACHE_BASE / str(SCHEMA_VERSION), 0o700)
339
- os.chmod(CACHE_BASE, 0o700)
340
- except OSError:
341
- pass
342
- return d
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
- """Heuristic 1-line summary: first sentence or 80 chars."""
493
+ """First sentence or ~120 chars."""
347
494
  text = text.strip()
348
495
  if not text:
349
496
  return "(empty turn)"
350
- first = next((s for s in re.split(r"[\n.!?]", text) if s.strip()), text)
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 = (text[:120].replace("\n", " "))
503
+ s = text[:120].replace("\n", " ")
356
504
  return s or "(empty)"
357
505
 
358
506
 
359
- def read_or_summarize(turn: dict, session_id: str,
360
- rebuild: bool = False,
361
- redact_extra=None, redact_mode: str = "full"):
362
- """Return (summary, cache_hit).
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
- Cached text is ALWAYS redacted to prevent plaintext secret persistence
365
- on disk. The 0600 file mode is defence-in-depth; redaction is the primary
366
- safeguard.
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
- p = cache_dir(session_id) / f"{turn['uuid']}.txt"
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
- return p.read_text(encoding="utf-8").strip(), True
371
- raw_summary = naive_summary(turn["text"])
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
- fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
374
- with os.fdopen(fd, "w", encoding="utf-8") as f:
375
- f.write(redacted)
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
- # ---------------------------------------------------------------- Render
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["ts"] or ""
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(f'<div class="day">━━ {html.escape(d)} ━━</div>')
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[11:16] if ts else ""
960
+ time_hm = html.escape(_fmt_time(ts))
961
+ role = t["role"]
397
962
 
398
- red_text, c = redact(t["text"], extra=redact_extra, mode=redact_mode)
399
- for k, v in c.items():
400
- redact_counts[k] = redact_counts.get(k, 0) + v
401
- esc_text = html.escape(red_text)
402
-
403
- if t["role"] == "user":
404
- rows.append(
405
- f'<div class="row me">'
406
- f'<span class="time">{html.escape(time_hm)}</span>'
407
- f'<div class="bubble">{esc_text}</div></div>'
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
- cache_total += 1
411
- summary, hit = read_or_summarize(
412
- t, session_id, rebuild=rebuild,
413
- redact_extra=redact_extra, redact_mode=redact_mode)
414
- if hit:
415
- cache_hits += 1
416
- # Already-redacted; final pass for any custom extras added later
417
- red_sum, c2 = redact(summary, extra=redact_extra, mode=redact_mode)
418
- for k, v in c2.items():
419
- redact_counts[k] = redact_counts.get(k, 0) + v
420
- esc_sum = html.escape(red_sum)
421
- tools_html = ""
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 ("details", "div"):
446
- opens = len(re.findall(rf"<{tag}\b", html_text))
447
- closes = len(re.findall(rf"</{tag}>", html_text))
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
- # Check substitution by looking for the template's wrapping context, not
455
- # the bare placeholder (which can appear in chat content as a literal).
456
- template_markers = [
457
- ("<title>{{TITLE}}</title>", "{{TITLE}}"),
458
- ("<h1>{{HEADER_TITLE}}</h1>", "{{HEADER_TITLE}}"),
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 `&lt;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
- # ---------------------------------------------------------------- Main
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. Default: "
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; if provided, parse + "
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
- help="HTML title")
505
- # Readability defaults (ON). Use the opt-out flags to restore raw output.
506
- ap.add_argument("--hide-tool-only", dest="hide_tool_only",
507
- action="store_true",
508
- help="(DEFAULT) omit assistant turns that contain only tool "
509
- "calls (no prose) — reduces noise in tool-heavy sessions")
510
- ap.add_argument("--keep-tool-only", dest="hide_tool_only",
511
- action="store_false",
512
- help="keep tool-only assistant turns (override the default)")
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="(DEFAULT) render Claude bubbles expanded (<details open>)")
1150
+ help="render bubbles expanded (default: collapsed)")
515
1151
  ap.add_argument("--collapse", dest="open_details", action="store_false",
516
- help="render Claude bubbles collapsed (override the default)")
517
- ap.set_defaults(hide_tool_only=True, open_details=True)
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 main(argv = None) -> int:
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
- session_id: str
543
- jsonl_path = None # set when reading from a real file (not stdin)
544
- if args.from_transcript:
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
- try:
580
- turns = list(parse_turns(stream, unsafe_schema=args.unsafe_schema))
581
- except SystemExit as e:
582
- return int(e.code) if isinstance(e.code, int) else 2
583
- finally:
584
- if stream is not sys.stdin:
585
- stream.close()
586
-
587
- # stdin mode → derive per-turn uuid from hash so cache works
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
- # Drop tool-only assistant turns (no prose) — these are already guaranteed
593
- # to carry tools (text-less + tool-less turns were skipped in parse_turns).
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
- turns = [t for t in turns if (t["ts"] or "") <= args.to]
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
- if args.last:
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 turns:
625
- first = (turns[0]["ts"] or "")[:10]
626
- last = (turns[-1]["ts"] or "")[:10]
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
- # Reviewer samples (assistant turns only)
646
- bots = [t for t in turns if t["role"] == "assistant"]
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
- t, session_id, rebuild=False,
654
- redact_extra=args.redact_extra, redact_mode=args.redact_mode)
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
- "idx": i,
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
- review_path.write_text(
665
- json.dumps(review_samples, ensure_ascii=False, indent=2),
666
- encoding="utf-8")
667
- print(f"[lineage] reviewer samples: {review_path}", file=sys.stderr)
668
- print("[lineage] next: invoke Skill('oh-my-claudecode:critic') with the "
669
- "JSON above; expected output [{idx, recoverable, reason}, ...] "
670
- "(timeout 60s; PASS = 5/5 recoverable)", file=sys.stderr)
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
- return 2
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 JSON parse failed: {e}",
1385
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
1386
+ print(f"[lineage] ERROR: reviewer-output parse failed: {e}",
690
1387
  file=sys.stderr)
691
- return 2
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
- return 2
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)} samples recoverable. Reasons:",
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
- return 2
1402
+ raise SystemExit(2)
707
1403
  print(f"[lineage] PASS: quality gate {len(verdict)}/{len(verdict)} "
708
- "samples recoverable", file=sys.stderr)
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
- sys.exit(main())
1408
+ try:
1409
+ sys.exit(main())
1410
+ except SystemExit as _e:
1411
+ sys.exit(int(_e.code) if isinstance(_e.code, int) else 0)