@kaydash9999/banker-plugins 0.20.2 → 0.21.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 +203 -1
- package/PRIVACY.md +34 -4
- package/README.md +46 -15
- package/bin/banker.js +228 -51
- package/bin/lib/codex-hooks.mjs +249 -0
- package/bin/lib/telemetry-config.mjs +33 -16
- package/codex/manifest.json +14 -11
- package/codex/skills/graceful-pause/SKILL.md +30 -0
- package/codex/skills/progress/SKILL.md +42 -0
- package/codex/skills/progress/scripts/progress-plan.mjs +722 -0
- package/codex/skills/progress/scripts/progress.mjs +295 -0
- package/codex/transform-matrix.md +19 -14
- package/commands/setup.md +23 -0
- package/hooks/obsidize-hook.mjs +116 -4
- package/hooks/progress-plan.mjs +1 -1
- package/hooks/progress.mjs +143 -38
- package/hooks/register.mjs +13 -3
- package/hooks/telemetry-count-codex.mjs +222 -0
- package/hooks/update-checkin.mjs +15 -7
- package/hooks/update-fetch.mjs +15 -9
- package/hooks/update-notify.mjs +30 -17
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +11 -5
- package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
- package/skills/3d-intro-build/references/curate.mjs +48 -14
- package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
- package/skills/3d-intro-build/references/prompts.md +4 -4
- package/skills/3d-intro-build/references/serve.mjs +6 -1
- package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
- package/skills/3d-intro-build/references/video-pool.mjs +250 -135
- package/skills/3d-intro-setup/SKILL.md +41 -10
- package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
- package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
- package/skills/anti-drm/SKILL.md +6 -3
- package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
- package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
- package/skills/append_wiki/SKILL.md +34 -8
- package/skills/audit-mock/SKILL.md +1 -1
- package/skills/audit-security/SKILL.md +64 -2
- package/skills/compact-wiki/SKILL.md +37 -6
- package/skills/deep-research/SKILL.md +11 -0
- package/skills/docs-setup/SKILL.md +37 -7
- package/skills/lfm/SKILL.md +148 -0
- package/skills/lineage/SKILL.md +25 -15
- package/skills/lineage/lineage.py +564 -301
- package/skills/motion-graphic-make/SKILL.md +1 -1
- package/skills/motion-graphic-make/references/curate.mjs +48 -14
- package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
- package/skills/motion-graphic-make/references/serve.mjs +6 -1
- package/skills/motion-graphic-setup/SKILL.md +58 -13
- package/skills/obsidizer/SKILL.md +30 -11
- package/skills/obsidizer/obsidize.mjs +50 -12
- package/skills/omc-patch/SKILL.md +86 -23
- package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
- package/skills/payload-mon/SKILL.md +6 -2
- package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
- package/skills/payload-mon/scripts/payload-size.mjs +4 -1
- package/skills/pdf-vision-extract/SKILL.md +11 -6
- package/skills/ralph-qa/SKILL.md +125 -151
- package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
- package/skills/ralph-qa/references/payload-scan.mjs +22 -7
- package/skills/ralph-qa/references/seat-payload.mjs +646 -0
- package/skills/ralph-qa/references/seat-run.mjs +956 -0
- package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
- package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
- package/skills/rfp-author/SKILL.md +5 -3
- package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
- package/skills/setup-bypass-permissions/SKILL.md +27 -9
- package/skills/setup-bypass-permissions/scripts/codex-bypass.mjs +135 -0
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +210 -0
- package/skills/setup-harness-factory/SKILL.md +17 -1
- package/skills/setup-insane-search/SKILL.md +16 -0
- package/skills/setup-java/SKILL.md +20 -1
- package/skills/setup-lsp/SKILL.md +56 -13
- package/skills/setup-mcp/SKILL.md +73 -4
- package/skills/setup-node/SKILL.md +31 -6
- package/skills/setup-omc/SKILL.md +24 -0
- package/skills/setup-omc-hud/SKILL.md +42 -9
- package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
- package/skills/setup-omc-hud/assets/codex-config.mjs +210 -0
- package/skills/setup-omc-hud/assets/codex-status-line.mjs +90 -0
- package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
- package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
- package/skills/setup-playwright/SKILL.md +45 -2
- package/skills/setup-pwsh/SKILL.md +19 -0
- package/skills/setup-python/SKILL.md +45 -7
- package/skills/setup-sandbox/SKILL.md +30 -5
- package/skills/setup-stitch/SKILL.md +22 -0
- package/skills/setup-tmux/SKILL.md +24 -4
- package/skills/smart-compact/SKILL.md +58 -12
- package/skills/smart-compact/scripts/codex-gate.mjs +384 -0
- package/skills/tone-compact/SKILL.md +2 -1
- package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
- package/skills/ultra-chain/SKILL.md +12 -7
- package/skills/ultra-chain/references/preview-lib.mjs +51 -15
- package/skills/ultra-chain/references/serve.mjs +6 -1
- package/skills/ultra-interview/SKILL.md +14 -2
- package/skills/update-banker/SKILL.md +19 -3
- package/skills/vertical-pptx/SKILL.md +6 -3
- package/skills/vertical-pptx-setup/SKILL.md +76 -20
|
@@ -34,7 +34,8 @@ if sys.version_info < (3, 7):
|
|
|
34
34
|
sys.exit(2)
|
|
35
35
|
|
|
36
36
|
SCHEMA_VERSION = 2 # cache dir schema (v2: redacted summaries only)
|
|
37
|
-
SUMMARIZER_VERSION =
|
|
37
|
+
SUMMARIZER_VERSION = 4 # bump when summarize_turn or the page redaction changes (B-3 cache key)
|
|
38
|
+
# 3: cut from redacted text (3.0.2); 4: split secrets joined first (3.0.8)
|
|
38
39
|
USER_FOLD = 400 # collapse user messages longer than this (E-1)
|
|
39
40
|
ECHO_ASK = 40 # echo-exchange: user question length ceiling (A-4)
|
|
40
41
|
ECHO_REPLY = 5 # echo-exchange: assistant reply length ceiling (A-4)
|
|
@@ -345,10 +346,9 @@ def _rejoin_split(text, patterns, entropy=False, trail=None):
|
|
|
345
346
|
return "".join(out) + text[at:]
|
|
346
347
|
|
|
347
348
|
|
|
348
|
-
def
|
|
349
|
-
"""
|
|
350
|
-
|
|
351
|
-
counts, out, sub = {}, text, _sub_for(trail)
|
|
349
|
+
def _hide_patterns(out, mode, sub, counts):
|
|
350
|
+
"""`out` with each value the page patterns and the entropy rule find replaced by its marker, or by a mask with
|
|
351
|
+
mode mask; `counts` adds them up by name."""
|
|
352
352
|
for name, pat in SECRET_PATTERNS:
|
|
353
353
|
def repl(m, n=name):
|
|
354
354
|
counts[n] = counts.get(n, 0) + 1
|
|
@@ -361,32 +361,43 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full", trail=None
|
|
|
361
361
|
counts["entropy"] = counts.get("entropy", 0) + 1
|
|
362
362
|
return _mask(s) if mode == "mask" else "[REDACTED:entropy]"
|
|
363
363
|
return s
|
|
364
|
-
|
|
365
|
-
|
|
364
|
+
return sub(ENTROPY_LONG, ent_repl, out)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _scan_detected(held, sub, counts):
|
|
368
|
+
"""held[0] with each value detect-secrets finds in it replaced: raises ImportError when it is not installed.
|
|
369
|
+
held[0] keeps what was replaced before a failure, as one pass over a local did. The scratch file it scans goes
|
|
370
|
+
whatever happens, a write that fails too (a full disk, a character UTF-8 cannot hold): it holds the text before
|
|
371
|
+
detect-secrets hid anything."""
|
|
372
|
+
from detect_secrets import SecretsCollection
|
|
373
|
+
from detect_secrets.settings import default_settings
|
|
374
|
+
tf = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8")
|
|
375
|
+
tmp = tf.name
|
|
366
376
|
try:
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
with
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
377
|
+
with tf:
|
|
378
|
+
tf.write(held[0])
|
|
379
|
+
col = SecretsCollection()
|
|
380
|
+
with default_settings():
|
|
381
|
+
col.scan_file(tmp)
|
|
382
|
+
for fname in col.files:
|
|
383
|
+
for s in col[fname]:
|
|
384
|
+
sv = s.secret_value
|
|
385
|
+
if sv and sv in held[0]:
|
|
386
|
+
held[0] = sub(re.compile(re.escape(sv)), lambda m: "[REDACTED:ds]", held[0])
|
|
387
|
+
counts["ds:detect-secrets"] = counts.get(
|
|
388
|
+
"ds:detect-secrets", 0) + 1
|
|
389
|
+
finally:
|
|
374
390
|
try:
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
finally:
|
|
386
|
-
try:
|
|
387
|
-
os.unlink(tmp)
|
|
388
|
-
except OSError:
|
|
389
|
-
pass
|
|
391
|
+
os.unlink(tmp)
|
|
392
|
+
except OSError:
|
|
393
|
+
pass
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _hide_detected(out, sub, counts):
|
|
397
|
+
"""`out` after detect-secrets, when it is installed; a WARN once a run when it is not, or when it fails."""
|
|
398
|
+
held = [out]
|
|
399
|
+
try:
|
|
400
|
+
_scan_detected(held, sub, counts)
|
|
390
401
|
except ImportError:
|
|
391
402
|
if not getattr(redact, "_ds_warned", False):
|
|
392
403
|
print("[lineage] WARN: detect-secrets unavailable, using fallback "
|
|
@@ -397,16 +408,30 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full", trail=None
|
|
|
397
408
|
print(f"[lineage] WARN: detect-secrets error ({e}), continuing "
|
|
398
409
|
"with fallback", file=sys.stderr)
|
|
399
410
|
redact._ds_err_warned = True
|
|
411
|
+
return held[0]
|
|
400
412
|
|
|
413
|
+
|
|
414
|
+
def _hide_extra(out, extra, sub, counts):
|
|
415
|
+
"""`out` with each --redact-extra keyword replaced; a marker an earlier pass left stays whole: a keyword inside
|
|
416
|
+
its name is no secret."""
|
|
417
|
+
for kw in [k.strip() for k in extra.split(",") if k.strip()]:
|
|
418
|
+
pat = re.compile(r"\[REDACTED(?::[^\]\s]*)?\]|" + re.escape(kw), re.IGNORECASE)
|
|
419
|
+
hits = []
|
|
420
|
+
out = sub(pat, lambda m: m.group(0) if m.group(0).upper().startswith("[REDACTED")
|
|
421
|
+
else hits.append(1) or "[REDACTED]", out)
|
|
422
|
+
if hits:
|
|
423
|
+
counts[f"custom:{kw}"] = len(hits)
|
|
424
|
+
return out
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def redact(text: str, extra: "str | None" = None, mode: str = "full", trail=None):
|
|
428
|
+
"""Multi-layer redaction; page_redact and review_redact join split secrets first (3.0.8). Returns (text, counts).
|
|
429
|
+
With `trail` (a _Trail), where each character of the result came from is followed too."""
|
|
430
|
+
counts, sub = {}, _sub_for(trail)
|
|
431
|
+
out = _hide_patterns(text, mode, sub, counts)
|
|
432
|
+
out = _hide_detected(out, sub, counts)
|
|
401
433
|
if extra:
|
|
402
|
-
|
|
403
|
-
# a marker an earlier pass left stays whole: a keyword inside its name is no secret
|
|
404
|
-
pat = re.compile(r"\[REDACTED(?::[^\]\s]*)?\]|" + re.escape(kw), re.IGNORECASE)
|
|
405
|
-
hits = []
|
|
406
|
-
out = sub(pat, lambda m: m.group(0) if m.group(0).upper().startswith("[REDACTED")
|
|
407
|
-
else hits.append(1) or "[REDACTED]", out)
|
|
408
|
-
if hits:
|
|
409
|
-
counts[f"custom:{kw}"] = len(hits)
|
|
434
|
+
out = _hide_extra(out, extra, sub, counts)
|
|
410
435
|
return out, counts
|
|
411
436
|
|
|
412
437
|
|
|
@@ -431,8 +456,8 @@ _CU_SKIP = (r"(?!" + _CU_LEAD + r"(?:\+[\"']?%[-_0^#]?[A-Za-z%]|(?:\d+:\d+|(?:"
|
|
|
431
456
|
+ r"|[^\s:=\"'`(]*\)):" + _CU_REF + r")(?![^\s\"'`;|&)\\^])"
|
|
432
457
|
r"|\$\{\w+:[-=?+]?[/~.][^\s}\"'`(:]*\}[^\s:]*(?!\S)))")
|
|
433
458
|
|
|
434
|
-
#
|
|
435
|
-
#
|
|
459
|
+
# Patterns every page (the reviewed flow and --rulebase) and every part file hides with before the
|
|
460
|
+
# page patterns above, while keys are whole (3.0.2).
|
|
436
461
|
# A value a redaction already replaced: a marker ([REDACTED...]) or a value that is only a mask
|
|
437
462
|
# (abcd****wxyz: up to 4 characters, a whole run of *, up to 4 characters, then the value ends; a
|
|
438
463
|
# closing `, |, * or ) and then . , ; : ! ? may stand between, as in **password: ****h12** or
|
|
@@ -698,7 +723,68 @@ def encode_cwd(cwd) -> str:
|
|
|
698
723
|
return re.sub(r"[^A-Za-z0-9]", "-", str(cwd))
|
|
699
724
|
|
|
700
725
|
|
|
726
|
+
def codex_sessions_dir():
|
|
727
|
+
"""Where Codex keeps its session records (rollouts): <CODEX_HOME>/sessions/YYYY/MM/DD/rollout-*.jsonl."""
|
|
728
|
+
home = os.environ.get("CODEX_HOME") or str(pathlib.Path.home() / ".codex")
|
|
729
|
+
return pathlib.Path(home) / "sessions"
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _codex_thread():
|
|
733
|
+
"""This Codex session's id when lineage runs inside Codex (Codex gives its shell CODEX_THREAD_ID, the id at the
|
|
734
|
+
end of the rollout file name), else None."""
|
|
735
|
+
tid = os.environ.get("CODEX_THREAD_ID", "")
|
|
736
|
+
return tid if re.fullmatch(r"[0-9A-Za-z-]{8,64}", tid) else None
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _mtime(path):
|
|
740
|
+
"""A file's change time, or 0 for one that went away between the listing and the look."""
|
|
741
|
+
try:
|
|
742
|
+
return path.stat().st_mtime
|
|
743
|
+
except OSError:
|
|
744
|
+
return 0
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _codex_rollouts(pattern):
|
|
748
|
+
try:
|
|
749
|
+
found = list(codex_sessions_dir().glob(f"*/*/*/{pattern}"))
|
|
750
|
+
except OSError:
|
|
751
|
+
return []
|
|
752
|
+
return sorted(found, key=_mtime)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _codex_meta(path):
|
|
756
|
+
"""The session_meta payload a rollout opens with, else None (not a Codex rollout, or unreadable). Only a regular
|
|
757
|
+
file is opened, so a named pipe is not waited on."""
|
|
758
|
+
try:
|
|
759
|
+
if not stat.S_ISREG(os.stat(str(path)).st_mode):
|
|
760
|
+
return None
|
|
761
|
+
with open(path, encoding="utf-8") as f:
|
|
762
|
+
obj = json.loads(f.readline())
|
|
763
|
+
return obj["payload"] if obj.get("type") == "session_meta" and isinstance(obj.get("payload"), dict) else None
|
|
764
|
+
except (OSError, UnicodeDecodeError, ValueError, RecursionError, KeyError, TypeError, AttributeError):
|
|
765
|
+
return None
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _codex_cwd(path):
|
|
769
|
+
"""The cwd a rollout's first record (session_meta) names, else None. A subagent's rollout (its source names
|
|
770
|
+
the parent thread) gives None: what the parent handed it reads as a user turn, so it is no session of the user."""
|
|
771
|
+
try:
|
|
772
|
+
with open(path, encoding="utf-8") as f:
|
|
773
|
+
obj = json.loads(f.readline())
|
|
774
|
+
meta = obj["payload"] if obj.get("type") == "session_meta" else {}
|
|
775
|
+
source = meta.get("source")
|
|
776
|
+
if isinstance(source, dict) and "subagent" in source:
|
|
777
|
+
return None
|
|
778
|
+
return meta.get("cwd")
|
|
779
|
+
except (OSError, UnicodeDecodeError, ValueError, RecursionError, KeyError, TypeError, AttributeError):
|
|
780
|
+
return None
|
|
781
|
+
|
|
782
|
+
|
|
701
783
|
def auto_discover_jsonl():
|
|
784
|
+
tid = _codex_thread()
|
|
785
|
+
if tid: # inside Codex: this session or nothing, never a Claude Code one
|
|
786
|
+
found = _codex_rollouts(f"rollout-*-{tid}.jsonl")
|
|
787
|
+
return found[-1] if found else None
|
|
702
788
|
projdir = pathlib.Path.home() / ".claude" / "projects" / encode_cwd(
|
|
703
789
|
pathlib.Path.cwd())
|
|
704
790
|
if not projdir.exists():
|
|
@@ -712,7 +798,10 @@ def auto_discover_jsonl():
|
|
|
712
798
|
|
|
713
799
|
|
|
714
800
|
def project_jsonl_files():
|
|
715
|
-
"""All *.jsonl in the encoded project dir, for --all-sessions."""
|
|
801
|
+
"""All *.jsonl in the encoded project dir, for --all-sessions. Inside Codex, the Codex sessions of this folder."""
|
|
802
|
+
if _codex_thread():
|
|
803
|
+
here = str(pathlib.Path.cwd())
|
|
804
|
+
return [p for p in _codex_rollouts("rollout-*.jsonl") if _codex_cwd(p) == here]
|
|
716
805
|
projdir = pathlib.Path.home() / ".claude" / "projects" / encode_cwd(
|
|
717
806
|
pathlib.Path.cwd())
|
|
718
807
|
if not projdir.exists():
|
|
@@ -884,6 +973,74 @@ def _content_parts(content, line_no):
|
|
|
884
973
|
return text_parts, tools
|
|
885
974
|
|
|
886
975
|
|
|
976
|
+
def _is_codex_meta(obj):
|
|
977
|
+
"""Whether a record opens a Codex session record (rollout): a session_meta with a payload."""
|
|
978
|
+
return obj.get("type") == "session_meta" and isinstance(obj.get("payload"), dict)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
_CODEX_ITEM_ROLE = {"UserMessage": "user", "AgentMessage": "assistant"}
|
|
982
|
+
_CODEX_INNER_TOOL_RE = re.compile(r"\btools\.([A-Za-z_][A-Za-z0-9_]{0,63})\(")
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _codex_item_text(item):
|
|
986
|
+
"""The text parts of a completed item (Codex 0.160: content [{type: text|Text, text}])."""
|
|
987
|
+
content = item.get("content")
|
|
988
|
+
if not isinstance(content, list):
|
|
989
|
+
return ""
|
|
990
|
+
return "\n".join(c["text"] for c in content if isinstance(c, dict) and isinstance(c.get("text"), str))
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _codex_tools(p, pt):
|
|
994
|
+
"""Tool counts of one call record. Codex 0.160 runs tools from code (`exec` with `tools.exec_command(...)` in its
|
|
995
|
+
input), so the inner tool names count, not the wrapper."""
|
|
996
|
+
name = p.get("name")
|
|
997
|
+
name = name if isinstance(name, str) and name else pt[:-5]
|
|
998
|
+
inner = _CODEX_INNER_TOOL_RE.findall(p.get("input")) if isinstance(p.get("input"), str) else []
|
|
999
|
+
counts = {}
|
|
1000
|
+
for n in inner or [name]:
|
|
1001
|
+
counts[n] = counts.get(n, 0) + 1
|
|
1002
|
+
return counts
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _codex_record(obj):
|
|
1006
|
+
"""(role, text, tools) a Codex rollout record shows as a turn, else None. What the user typed and what the
|
|
1007
|
+
model said come from the event records (0.144: user_message, agent_message; 0.160: item_completed with a
|
|
1008
|
+
UserMessage or AgentMessage item), so what Codex puts into the model input (environment, instructions, an
|
|
1009
|
+
injected skill body) is not a turn; a tool call is an assistant record with no text, merged into the turn around
|
|
1010
|
+
it like a Claude Code tool_use; a compaction is a divider."""
|
|
1011
|
+
p = obj.get("payload")
|
|
1012
|
+
kind = obj.get("type")
|
|
1013
|
+
if kind == "compacted":
|
|
1014
|
+
return "mark", "compaction", {}
|
|
1015
|
+
if not isinstance(p, dict):
|
|
1016
|
+
return None
|
|
1017
|
+
pt = p.get("type")
|
|
1018
|
+
if kind == "event_msg" and pt in ("user_message", "agent_message"):
|
|
1019
|
+
msg = p.get("message")
|
|
1020
|
+
return ("user" if pt == "user_message" else "assistant"), (msg if isinstance(msg, str) else ""), {}
|
|
1021
|
+
item = p.get("item")
|
|
1022
|
+
if kind == "event_msg" and pt == "item_completed" and isinstance(item, dict) and item.get("type") in _CODEX_ITEM_ROLE:
|
|
1023
|
+
return _CODEX_ITEM_ROLE[item["type"]], _codex_item_text(item), {}
|
|
1024
|
+
if kind == "response_item" and isinstance(pt, str) and pt.endswith("_call"):
|
|
1025
|
+
return "assistant", "", _codex_tools(p, pt)
|
|
1026
|
+
return None
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _codex_turn(obj, line_no, session_id, session_name):
|
|
1030
|
+
"""The turn dict of one Codex rollout record, else None."""
|
|
1031
|
+
got = _codex_record(obj)
|
|
1032
|
+
if got is None:
|
|
1033
|
+
return None
|
|
1034
|
+
role, text, tools = got
|
|
1035
|
+
text = _CTRL_RE.sub("", text.strip())
|
|
1036
|
+
if not text and not tools:
|
|
1037
|
+
return None
|
|
1038
|
+
role, text, tools, ts, uuid_ = _clean_strings(line_no, role, text, tools, _stamp(obj.get("timestamp")), None)
|
|
1039
|
+
uuid_ = _turn_id(uuid_, line_no, ts)
|
|
1040
|
+
return {"role": role, "text": text, "ts": ts, "uuid": uuid_, "tools": tools, "line_no": line_no,
|
|
1041
|
+
"parts": [text] if text else [], "session": session_id, "session_name": session_name, "meta": False}
|
|
1042
|
+
|
|
1043
|
+
|
|
887
1044
|
def parse_turns(stream, unsafe_schema: bool = False, session_id=None,
|
|
888
1045
|
session_name=None):
|
|
889
1046
|
"""Yield dicts: {role, text, ts, uuid, tools, line_no, parts, session,
|
|
@@ -894,11 +1051,23 @@ def parse_turns(stream, unsafe_schema: bool = False, session_id=None,
|
|
|
894
1051
|
needs it) and `session` (session guards for merge/echo).
|
|
895
1052
|
"""
|
|
896
1053
|
seen_unknown_type = set()
|
|
1054
|
+
codex = None # set by the first record: a Codex rollout or a Claude transcript
|
|
1055
|
+
last_said = None # Codex: (role, text) of the last message, so one said twice counts once
|
|
897
1056
|
for line_no, raw in enumerate(stream, 1):
|
|
898
1057
|
raw = raw.strip()
|
|
899
1058
|
if not raw:
|
|
900
1059
|
continue
|
|
901
1060
|
obj = _json_object(raw, line_no)
|
|
1061
|
+
if obj is not None and codex is None:
|
|
1062
|
+
codex = _is_codex_meta(obj)
|
|
1063
|
+
if obj is not None and codex:
|
|
1064
|
+
turn = _codex_turn(obj, line_no, session_id, session_name)
|
|
1065
|
+
said = (turn["role"], turn["text"]) if turn and turn["text"] else None
|
|
1066
|
+
if turn and not (said and said == last_said):
|
|
1067
|
+
yield turn
|
|
1068
|
+
if said:
|
|
1069
|
+
last_said = said
|
|
1070
|
+
continue
|
|
902
1071
|
if obj is None or not _is_turn_record(obj, seen_unknown_type, unsafe_schema, line_no):
|
|
903
1072
|
continue
|
|
904
1073
|
msg = obj.get("message") or {}
|
|
@@ -1210,7 +1379,8 @@ def _read_text(path):
|
|
|
1210
1379
|
|
|
1211
1380
|
|
|
1212
1381
|
def _read_cached(p):
|
|
1213
|
-
"""The summary cached at p, else at the same name with .txt,
|
|
1382
|
+
"""The summary cached at p, else at the same name with .txt, the name 3.0.2 and earlier wrote (their digests
|
|
1383
|
+
carry summarizer version 2 or 3, so since version 4 no file of theirs is found).
|
|
1214
1384
|
None when neither holds one: missing, bytes that are not UTF-8 (a DRM encrypted them), or
|
|
1215
1385
|
nothing a person can see (a write that stopped early, or only blank-looking letters); the
|
|
1216
1386
|
turn is then summarized again (3.0.4, 3.0.5)."""
|
|
@@ -1300,11 +1470,13 @@ def _is_block_start(lines, i):
|
|
|
1300
1470
|
if (_FENCE_RE.match(line) or _HEAD_RE.match(line) or _HR_RE.match(line)
|
|
1301
1471
|
or _QUOTE_RE.match(line) or _LIST_RE.match(line)):
|
|
1302
1472
|
return True
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1473
|
+
return _is_table_start(lines, i)
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def _is_table_start(lines, i):
|
|
1477
|
+
"""Whether lines[i] starts a table: it has a pipe AND the next line is a delimiter row."""
|
|
1478
|
+
return bool("|" in lines[i] and i + 1 < len(lines)
|
|
1479
|
+
and "-" in lines[i + 1] and _TABLE_DELIM_RE.match(lines[i + 1]))
|
|
1308
1480
|
|
|
1309
1481
|
|
|
1310
1482
|
def _render_table(lines, i):
|
|
@@ -1323,85 +1495,90 @@ def _render_table(lines, i):
|
|
|
1323
1495
|
% (thead, body)), i
|
|
1324
1496
|
|
|
1325
1497
|
|
|
1498
|
+
def _md_fence(lines, i, fence):
|
|
1499
|
+
"""A code block from the fence at lines[i] to the first closing fence at least as long (or the end), and the
|
|
1500
|
+
index past it."""
|
|
1501
|
+
i += 1
|
|
1502
|
+
buf = []
|
|
1503
|
+
while i < len(lines):
|
|
1504
|
+
mc = _FENCE_CLOSE_RE.match(lines[i])
|
|
1505
|
+
if mc and len(mc.group(1)) >= len(fence):
|
|
1506
|
+
i += 1
|
|
1507
|
+
break
|
|
1508
|
+
buf.append(lines[i])
|
|
1509
|
+
i += 1
|
|
1510
|
+
return "<pre><code>%s</code></pre>" % html.escape("\n".join(buf)), i
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def _md_quote(lines, i, depth):
|
|
1514
|
+
"""The blockquote of the run of `>` lines at lines[i], rendered one level deeper, and the index past it."""
|
|
1515
|
+
buf = []
|
|
1516
|
+
while i < len(lines) and _QUOTE_RE.match(lines[i]):
|
|
1517
|
+
buf.append(re.sub(r"^\s*>\s?", "", lines[i]))
|
|
1518
|
+
i += 1
|
|
1519
|
+
return "<blockquote>%s</blockquote>" % render_markdown("\n".join(buf), depth + 1), i
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def _md_list(lines, i):
|
|
1523
|
+
"""The list of the run of list lines at lines[i] (ordered when the first is numbered; nested ones flatten),
|
|
1524
|
+
and the index past it."""
|
|
1525
|
+
tag = "ol" if re.match(r"^\s*\d+[.)]", lines[i]) else "ul"
|
|
1526
|
+
items = []
|
|
1527
|
+
while i < len(lines):
|
|
1528
|
+
mli = _LIST_RE.match(lines[i])
|
|
1529
|
+
if not mli:
|
|
1530
|
+
break
|
|
1531
|
+
items.append(_md_inline(html.escape(mli.group(3))))
|
|
1532
|
+
i += 1
|
|
1533
|
+
return "<%s>%s</%s>" % (tag, "".join("<li>%s</li>" % it for it in items), tag), i
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def _md_paragraph(lines, i):
|
|
1537
|
+
"""The paragraph that starts at lines[i] (at least that line: C-2, no endless loop), its lines joined with <br>
|
|
1538
|
+
(N5: pre-wrap keeps leading spaces without a second break), and the index past it."""
|
|
1539
|
+
buf = [lines[i]]
|
|
1540
|
+
i += 1
|
|
1541
|
+
while i < len(lines) and lines[i].strip() and not _is_block_start(lines, i):
|
|
1542
|
+
buf.append(lines[i])
|
|
1543
|
+
i += 1
|
|
1544
|
+
return "<p>%s</p>" % "<br>".join(_md_inline(html.escape(b)) for b in buf), i
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def _md_block(lines, i, depth):
|
|
1548
|
+
"""The HTML of the block that starts at lines[i] (None for a blank line), and the index past it."""
|
|
1549
|
+
line = lines[i]
|
|
1550
|
+
mf = _FENCE_RE.match(line)
|
|
1551
|
+
if mf:
|
|
1552
|
+
return _md_fence(lines, i, mf.group(1))
|
|
1553
|
+
mh = _HEAD_RE.match(line)
|
|
1554
|
+
if mh:
|
|
1555
|
+
hl = max(3, min(6, len(mh.group(1)))) # h3..h6 (avoid page-header clash)
|
|
1556
|
+
return "<h%d>%s</h%d>" % (hl, _md_inline(html.escape(mh.group(2).strip())), hl), i + 1
|
|
1557
|
+
if _HR_RE.match(line):
|
|
1558
|
+
return "<hr>", i + 1
|
|
1559
|
+
if _QUOTE_RE.match(line):
|
|
1560
|
+
return _md_quote(lines, i, depth)
|
|
1561
|
+
if _is_table_start(lines, i):
|
|
1562
|
+
return _render_table(lines, i)
|
|
1563
|
+
if _LIST_RE.match(line):
|
|
1564
|
+
return _md_list(lines, i)
|
|
1565
|
+
if not line.strip():
|
|
1566
|
+
return None, i + 1
|
|
1567
|
+
return _md_paragraph(lines, i)
|
|
1568
|
+
|
|
1569
|
+
|
|
1326
1570
|
def render_markdown(text, depth=0):
|
|
1327
1571
|
"""Block-level markdown → HTML. External deps: none. Escapes first; no stage
|
|
1328
1572
|
re-runs over prior markup. Nested lists flatten; footnotes unsupported.
|
|
1329
1573
|
"""
|
|
1330
1574
|
if depth > _BLOCKQUOTE_MAX_DEPTH:
|
|
1331
1575
|
return "<p>%s</p>" % _md_inline(html.escape(text))
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
mf = _FENCE_RE.match(line)
|
|
1339
|
-
if mf:
|
|
1340
|
-
fence = mf.group(1)
|
|
1341
|
-
i += 1
|
|
1342
|
-
buf = []
|
|
1343
|
-
while i < n:
|
|
1344
|
-
mc = _FENCE_CLOSE_RE.match(lines[i])
|
|
1345
|
-
if mc and len(mc.group(1)) >= len(fence):
|
|
1346
|
-
i += 1
|
|
1347
|
-
break
|
|
1348
|
-
buf.append(lines[i])
|
|
1349
|
-
i += 1
|
|
1350
|
-
out.append("<pre><code>%s</code></pre>"
|
|
1351
|
-
% html.escape("\n".join(buf)))
|
|
1352
|
-
continue
|
|
1353
|
-
mh = _HEAD_RE.match(line)
|
|
1354
|
-
if mh:
|
|
1355
|
-
hl = max(3, min(6, len(mh.group(1)))) # h3..h6 (avoid page-header clash)
|
|
1356
|
-
out.append("<h%d>%s</h%d>"
|
|
1357
|
-
% (hl, _md_inline(html.escape(mh.group(2).strip())), hl))
|
|
1358
|
-
i += 1
|
|
1359
|
-
continue
|
|
1360
|
-
if _HR_RE.match(line):
|
|
1361
|
-
out.append("<hr>")
|
|
1362
|
-
i += 1
|
|
1363
|
-
continue
|
|
1364
|
-
if _QUOTE_RE.match(line):
|
|
1365
|
-
buf = []
|
|
1366
|
-
while i < n and _QUOTE_RE.match(lines[i]):
|
|
1367
|
-
buf.append(re.sub(r"^\s*>\s?", "", lines[i]))
|
|
1368
|
-
i += 1
|
|
1369
|
-
out.append("<blockquote>%s</blockquote>"
|
|
1370
|
-
% render_markdown("\n".join(buf), depth + 1))
|
|
1371
|
-
continue
|
|
1372
|
-
if ("|" in line and i + 1 < n and "-" in lines[i + 1]
|
|
1373
|
-
and _TABLE_DELIM_RE.match(lines[i + 1])):
|
|
1374
|
-
tbl, i = _render_table(lines, i)
|
|
1375
|
-
out.append(tbl)
|
|
1376
|
-
continue
|
|
1377
|
-
ml = _LIST_RE.match(line)
|
|
1378
|
-
if ml:
|
|
1379
|
-
ordered = bool(re.match(r"^\s*\d+[.)]", line))
|
|
1380
|
-
tag = "ol" if ordered else "ul"
|
|
1381
|
-
items = []
|
|
1382
|
-
while i < n:
|
|
1383
|
-
mli = _LIST_RE.match(lines[i])
|
|
1384
|
-
if not mli:
|
|
1385
|
-
break
|
|
1386
|
-
items.append(_md_inline(html.escape(mli.group(3))))
|
|
1387
|
-
i += 1
|
|
1388
|
-
out.append("<%s>%s</%s>"
|
|
1389
|
-
% (tag, "".join("<li>%s</li>" % it for it in items), tag))
|
|
1390
|
-
continue
|
|
1391
|
-
if not line.strip():
|
|
1392
|
-
i += 1
|
|
1393
|
-
continue
|
|
1394
|
-
# paragraph: force-consume at least this line (C-2: no infinite loop)
|
|
1395
|
-
buf = [line]
|
|
1396
|
-
i += 1
|
|
1397
|
-
while i < n and lines[i].strip() and not _is_block_start(lines, i):
|
|
1398
|
-
buf.append(lines[i])
|
|
1399
|
-
i += 1
|
|
1400
|
-
# source newlines within a paragraph become <br> (N5). pre-wrap on the
|
|
1401
|
-
# container preserves leading/aligned spaces without double-breaking,
|
|
1402
|
-
# because we join with <br> and emit no literal newline between lines.
|
|
1403
|
-
out.append("<p>%s</p>"
|
|
1404
|
-
% "<br>".join(_md_inline(html.escape(b)) for b in buf))
|
|
1576
|
+
lines = _CTRL_RE.sub("", text).split("\n")
|
|
1577
|
+
out, i = [], 0
|
|
1578
|
+
while i < len(lines):
|
|
1579
|
+
block, i = _md_block(lines, i, depth)
|
|
1580
|
+
if block is not None:
|
|
1581
|
+
out.append(block)
|
|
1405
1582
|
return "\n".join(out)
|
|
1406
1583
|
|
|
1407
1584
|
|
|
@@ -1614,102 +1791,110 @@ def _fmt_time(ts):
|
|
|
1614
1791
|
return ts[11:16] if ts and len(ts) >= 16 else ""
|
|
1615
1792
|
|
|
1616
1793
|
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
last_date = None
|
|
1621
|
-
last_session = None
|
|
1622
|
-
cache_hits = 0
|
|
1623
|
-
cache_total = 0
|
|
1624
|
-
redact_counts = {}
|
|
1625
|
-
open_attr = " open" if open_details else ""
|
|
1626
|
-
|
|
1627
|
-
def _red(text):
|
|
1628
|
-
r, c = page_redact(text, extra=redact_extra, mode=redact_mode)
|
|
1629
|
-
for k, v in c.items():
|
|
1630
|
-
redact_counts[k] = redact_counts.get(k, 0) + v
|
|
1631
|
-
return r
|
|
1794
|
+
class _Rows:
|
|
1795
|
+
"""What render_rows carries from turn to turn: the rows so far, the redaction settings and what they hid by kind,
|
|
1796
|
+
the open flag and markdown, the date and session the last pills showed, and the rule summaries' cache hits."""
|
|
1632
1797
|
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
rows.append('<div class="day" data-mark="%s">'
|
|
1640
|
-
'<span class="pill pill-session">%s</span></div>'
|
|
1641
|
-
% (name, name))
|
|
1642
|
-
last_date = None
|
|
1643
|
-
if ts:
|
|
1644
|
-
d = ts[:10]
|
|
1645
|
-
if d != last_date:
|
|
1646
|
-
rows.append('<div class="day" data-mark="%s">'
|
|
1647
|
-
'<span class="pill pill-date">%s</span></div>'
|
|
1648
|
-
% (html.escape(d), html.escape(d)))
|
|
1649
|
-
last_date = d
|
|
1650
|
-
time_hm = html.escape(_fmt_time(ts))
|
|
1651
|
-
role = t["role"]
|
|
1652
|
-
|
|
1653
|
-
if role == "mark":
|
|
1654
|
-
rows.append('<div class="day"><span class="pill pill-compact">'
|
|
1655
|
-
'⋯ 이전 대화 요약(compaction) ⋯</span></div>')
|
|
1656
|
-
continue
|
|
1798
|
+
def __init__(self, redact_extra, redact_mode, open_details, markdown):
|
|
1799
|
+
self.extra, self.mode, self.markdown = redact_extra, redact_mode, markdown
|
|
1800
|
+
self.open_attr = " open" if open_details else ""
|
|
1801
|
+
self.rows, self.counts = [], {}
|
|
1802
|
+
self.last_date = self.last_session = None
|
|
1803
|
+
self.hits = self.total = 0
|
|
1657
1804
|
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
body = render_body(red, markdown=markdown)
|
|
1664
|
-
rows.append(
|
|
1665
|
-
'<div class="row me"><span class="time">%s</span>'
|
|
1666
|
-
'<div class="bubble"><details%s>'
|
|
1667
|
-
'<summary><span class="sum">%s</span></summary>'
|
|
1668
|
-
'<div class="detail">%s</div></details></div></div>'
|
|
1669
|
-
% (time_hm, open_attr, summary, body))
|
|
1670
|
-
else:
|
|
1671
|
-
rows.append(
|
|
1672
|
-
'<div class="row me"><span class="time">%s</span>'
|
|
1673
|
-
'<div class="bubble"><div class="plain">%s</div></div></div>'
|
|
1674
|
-
% (time_hm, render_body(red, markdown=markdown)))
|
|
1675
|
-
continue
|
|
1805
|
+
def red(self, text):
|
|
1806
|
+
r, c = page_redact(text, extra=self.extra, mode=self.mode)
|
|
1807
|
+
for k, v in c.items():
|
|
1808
|
+
self.counts[k] = self.counts.get(k, 0) + v
|
|
1809
|
+
return r
|
|
1676
1810
|
|
|
1677
|
-
# assistant or agent bubble
|
|
1678
|
-
summary, hit = _page_summary(t, summaries, session_id, rebuild,
|
|
1679
|
-
redact_extra, redact_mode)
|
|
1680
|
-
if hit is not None:
|
|
1681
|
-
cache_total += 1
|
|
1682
|
-
cache_hits += bool(hit)
|
|
1683
|
-
esc_sum = html.escape(_red(summary))
|
|
1684
|
-
detail_body = render_body(_red(t["text"]), markdown=markdown)
|
|
1685
|
-
|
|
1686
|
-
tools_html = ""
|
|
1687
|
-
if t.get("tools"):
|
|
1688
|
-
ts_list = ", ".join(f"{_hide_keywords(k, redact_extra)}×{v}"
|
|
1689
|
-
for k, v in sorted(t["tools"].items()))
|
|
1690
|
-
total = sum(t["tools"].values())
|
|
1691
|
-
tools_html = ('<div class="tools">🔧 도구 %d건: %s</div>'
|
|
1692
|
-
% (total, html.escape(ts_list)))
|
|
1693
|
-
|
|
1694
|
-
if role == "agent":
|
|
1695
|
-
who = html.escape(_red(t.get("agent_from") or "agent"))
|
|
1696
|
-
avatar = "🤝"
|
|
1697
|
-
from_line = '<div class="from">%s</div>' % who
|
|
1698
|
-
row_cls = "row bot agent"
|
|
1699
|
-
else:
|
|
1700
|
-
avatar = "C"
|
|
1701
|
-
from_line = ""
|
|
1702
|
-
row_cls = "row bot"
|
|
1703
1811
|
|
|
1704
|
-
|
|
1705
|
-
|
|
1812
|
+
def _pills(rs, t, all_sessions):
|
|
1813
|
+
"""The session pill (--all-sessions, when the session changes) and the date pill (when the day changes) before
|
|
1814
|
+
turn `t`."""
|
|
1815
|
+
if all_sessions and t.get("session") != rs.last_session:
|
|
1816
|
+
rs.last_session = t.get("session")
|
|
1817
|
+
name = html.escape(rs.red(t.get("session_name") or rs.last_session or "session"))
|
|
1818
|
+
rs.rows.append('<div class="day" data-mark="%s">'
|
|
1819
|
+
'<span class="pill pill-session">%s</span></div>'
|
|
1820
|
+
% (name, name))
|
|
1821
|
+
rs.last_date = None
|
|
1822
|
+
ts = t.get("ts") or ""
|
|
1823
|
+
if ts:
|
|
1824
|
+
d = ts[:10]
|
|
1825
|
+
if d != rs.last_date:
|
|
1826
|
+
rs.rows.append('<div class="day" data-mark="%s">'
|
|
1827
|
+
'<span class="pill pill-date">%s</span></div>'
|
|
1828
|
+
% (html.escape(d), html.escape(d)))
|
|
1829
|
+
rs.last_date = d
|
|
1830
|
+
|
|
1831
|
+
|
|
1832
|
+
def _user_row(rs, t, summaries, time_hm):
|
|
1833
|
+
"""A user bubble: plain, or folded under a summary (the reviewer's, else cut from it) past USER_FOLD."""
|
|
1834
|
+
red = rs.red(t["text"])
|
|
1835
|
+
if len(red) > USER_FOLD:
|
|
1836
|
+
reviewed = _reviewed_fold(t, summaries)
|
|
1837
|
+
summary = html.escape(rs.red(reviewed) if reviewed else user_fold_summary(red))
|
|
1838
|
+
body = render_body(red, markdown=rs.markdown)
|
|
1839
|
+
return ('<div class="row me"><span class="time">%s</span>'
|
|
1840
|
+
'<div class="bubble"><details%s>'
|
|
1841
|
+
'<summary><span class="sum">%s</span></summary>'
|
|
1842
|
+
'<div class="detail">%s</div></details></div></div>'
|
|
1843
|
+
% (time_hm, rs.open_attr, summary, body))
|
|
1844
|
+
return ('<div class="row me"><span class="time">%s</span>'
|
|
1845
|
+
'<div class="bubble"><div class="plain">%s</div></div></div>'
|
|
1846
|
+
% (time_hm, render_body(red, markdown=rs.markdown)))
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
def _tools_line(tools, redact_extra):
|
|
1850
|
+
"""The `🔧 도구 N건` line of a bot bubble ("" without tools), the --redact-extra keywords hidden in the names."""
|
|
1851
|
+
if not tools:
|
|
1852
|
+
return ""
|
|
1853
|
+
ts_list = ", ".join(f"{_hide_keywords(k, redact_extra)}×{v}"
|
|
1854
|
+
for k, v in sorted(tools.items()))
|
|
1855
|
+
return ('<div class="tools">🔧 도구 %d건: %s</div>'
|
|
1856
|
+
% (sum(tools.values()), html.escape(ts_list)))
|
|
1857
|
+
|
|
1858
|
+
|
|
1859
|
+
def _bot_row(rs, t, summary, time_hm):
|
|
1860
|
+
"""An assistant or agent bubble: the summary, the details (body, tools) and, for an agent, who sent it."""
|
|
1861
|
+
esc_sum = html.escape(rs.red(summary))
|
|
1862
|
+
detail_body = render_body(rs.red(t["text"]), markdown=rs.markdown)
|
|
1863
|
+
tools_html = _tools_line(t.get("tools"), rs.extra)
|
|
1864
|
+
role = t["role"]
|
|
1865
|
+
if role == "agent":
|
|
1866
|
+
who = html.escape(rs.red(t.get("agent_from") or "agent"))
|
|
1867
|
+
avatar, from_line, row_cls = "🤝", '<div class="from">%s</div>' % who, "row bot agent"
|
|
1868
|
+
else:
|
|
1869
|
+
avatar, from_line, row_cls = "C", "", "row bot"
|
|
1870
|
+
return ('<div class="%s"><div class="avatar%s">%s</div>'
|
|
1706
1871
|
'<div class="bubble"><details%s>'
|
|
1707
1872
|
'<summary><span class="sum">%s</span></summary>'
|
|
1708
1873
|
'<div class="detail">%s%s%s</div></details></div>'
|
|
1709
1874
|
'<span class="time">%s</span></div>'
|
|
1710
1875
|
% (row_cls, " agent" if role == "agent" else "", avatar,
|
|
1711
|
-
open_attr, esc_sum, from_line, detail_body, tools_html, time_hm))
|
|
1712
|
-
|
|
1876
|
+
rs.open_attr, esc_sum, from_line, detail_body, tools_html, time_hm))
|
|
1877
|
+
|
|
1878
|
+
|
|
1879
|
+
def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
|
|
1880
|
+
open_details=False, markdown=True, all_sessions=False, summaries=None):
|
|
1881
|
+
rs = _Rows(redact_extra, redact_mode, open_details, markdown)
|
|
1882
|
+
for t in turns:
|
|
1883
|
+
_pills(rs, t, all_sessions)
|
|
1884
|
+
time_hm = html.escape(_fmt_time(t.get("ts") or ""))
|
|
1885
|
+
if t["role"] == "mark":
|
|
1886
|
+
rs.rows.append('<div class="day"><span class="pill pill-compact">'
|
|
1887
|
+
'⋯ 이전 대화 요약(compaction) ⋯</span></div>')
|
|
1888
|
+
elif t["role"] == "user":
|
|
1889
|
+
rs.rows.append(_user_row(rs, t, summaries, time_hm))
|
|
1890
|
+
else:
|
|
1891
|
+
summary, hit = _page_summary(t, summaries, session_id, rebuild,
|
|
1892
|
+
redact_extra, redact_mode)
|
|
1893
|
+
if hit is not None:
|
|
1894
|
+
rs.total += 1
|
|
1895
|
+
rs.hits += bool(hit)
|
|
1896
|
+
rs.rows.append(_bot_row(rs, t, summary, time_hm))
|
|
1897
|
+
return rs.rows, rs.counts, rs.hits, rs.total
|
|
1713
1898
|
|
|
1714
1899
|
|
|
1715
1900
|
_TAG_RE = re.compile(r"<[^>]*>")
|
|
@@ -2004,12 +2189,15 @@ def _review_view(t, red, args):
|
|
|
2004
2189
|
def _pack_turn(n, t, tid, why, session_id, args, counts):
|
|
2005
2190
|
"""One turn of the pack under its final id `tid` (the text as the page shows it, the rules'
|
|
2006
2191
|
decision, the reviewer's slot), and the reviewer's view of it. The cached decision keys on
|
|
2007
|
-
the text with the page's patterns only (`llm_key`), as 3.0.1 did
|
|
2192
|
+
the text with the page's patterns only (`llm_key`), as 3.0.1 did, its split secrets joined first: a key
|
|
2193
|
+
found only once joined keys anew, so no decision 3.0.8 or earlier cached for such a turn is read (it is
|
|
2194
|
+
reviewed once more)."""
|
|
2008
2195
|
red, found = page_redact(t["text"], extra=args.redact_extra, mode=args.redact_mode)
|
|
2009
2196
|
for k, v in found.items():
|
|
2010
2197
|
counts[k] = counts.get(k, 0) + v
|
|
2011
2198
|
hit = any(name in found for name, _ in REVIEW_SECRET_PATTERNS)
|
|
2012
|
-
key_text = redact(t["text"],
|
|
2199
|
+
key_text = (redact(_rejoin_secrets(t["text"], args.redact_extra), extra=args.redact_extra,
|
|
2200
|
+
mode=args.redact_mode)[0] if hit else red)
|
|
2013
2201
|
preview, clipped = _preview(review_redact(t["text"], args.redact_extra)[0])
|
|
2014
2202
|
session = t.get("session") or session_id
|
|
2015
2203
|
context = _decision_context(why, _keeps(args))
|
|
@@ -2025,6 +2213,8 @@ def _pack_turn(n, t, tid, why, session_id, args, counts):
|
|
|
2025
2213
|
"llm": dict(cached, cached=True) if cached else {"keep": None, "summary": None}}
|
|
2026
2214
|
if t.get("meta"):
|
|
2027
2215
|
turn["meta"] = True
|
|
2216
|
+
if t["role"] == "user" and _bang_output(t["text"]):
|
|
2217
|
+
turn["bang_output"] = True # for the dropped-turn WARN only: no part file carries it
|
|
2028
2218
|
for k in ("session", "session_name", "agent_from"):
|
|
2029
2219
|
if t.get(k):
|
|
2030
2220
|
turn[k] = t[k] if k == "session" else page_redact(t[k], args.redact_extra, args.redact_mode)[0]
|
|
@@ -2093,7 +2283,8 @@ def _sheet(k, of, part_path, group, args):
|
|
|
2093
2283
|
turns = []
|
|
2094
2284
|
for x, view in group:
|
|
2095
2285
|
y = {key: v for key, v in x.items()
|
|
2096
|
-
if key not in ("text", "session", "session_name", "agent_from", "redactions", "llm_key"
|
|
2286
|
+
if key not in ("text", "session", "session_name", "agent_from", "redactions", "llm_key",
|
|
2287
|
+
"bang_output")}
|
|
2097
2288
|
y["rule"] = {k: v for k, v in x["rule"].items() if k != "review_summary"}
|
|
2098
2289
|
y["rule"]["summary"] = view["summary"]
|
|
2099
2290
|
y["tools"] = {_hide_keywords(name, args.redact_extra): n for name, n in x["tools"].items()}
|
|
@@ -2348,7 +2539,7 @@ def _show(x, llm_summary, turns, summaries, stats, prior):
|
|
|
2348
2539
|
if isinstance(v, int):
|
|
2349
2540
|
prior[k] = prior.get(k, 0) + v
|
|
2350
2541
|
# what the gate's critic reads: the reviewer's summary, else the rules' cut from the
|
|
2351
|
-
# fully redacted text (the page's
|
|
2542
|
+
# fully redacted text (the page's may hold a --redact-mode mask: 4 + 4 characters of a value)
|
|
2352
2543
|
sample = (llm_summary if shows else None) or x["rule"].get("review_summary")
|
|
2353
2544
|
turns.append({"uuid": x["id"], "role": x["role"], "text": x["text"],
|
|
2354
2545
|
"ts": x.get("ts") or None, "tools": dict(x.get("tools") or {}),
|
|
@@ -2370,6 +2561,23 @@ def _kept_by_flag(pack, x):
|
|
|
2370
2561
|
return pack.get("keep_tool_only") is True and tool_only
|
|
2371
2562
|
|
|
2372
2563
|
|
|
2564
|
+
def _bang_output(text):
|
|
2565
|
+
"""Whether `text` is the record Claude Code writes after a `!` command and nothing else (2.1.304, no isMeta): a
|
|
2566
|
+
<bash-stdout> block then a <bash-stderr> block, or one <bash-stderr> block when the command failed. String checks
|
|
2567
|
+
only, linear in the text: a pattern repeating the blocks could take exponential time on many closing tags."""
|
|
2568
|
+
if text.startswith("<bash-stdout>"):
|
|
2569
|
+
return "</bash-stdout><bash-stderr>" in text and text.endswith("</bash-stderr>")
|
|
2570
|
+
return text.startswith("<bash-stderr>") and text.endswith("</bash-stderr>")
|
|
2571
|
+
|
|
2572
|
+
|
|
2573
|
+
def _typed_by_user(x):
|
|
2574
|
+
"""Whether pack turn `x` holds words the user typed: a user turn the rules keep that is neither a body the
|
|
2575
|
+
harness injected (meta) nor what Claude Code writes after a `!` command (bang_output, judged on the text before
|
|
2576
|
+
redaction: a hidden value at the end of the output takes the closing tags with it)."""
|
|
2577
|
+
return (x["role"] == "user" and x["rule"]["keep"] and not x.get("meta")
|
|
2578
|
+
and x.get("bang_output") is not True)
|
|
2579
|
+
|
|
2580
|
+
|
|
2373
2581
|
def _review_result(pack, merged, args):
|
|
2374
2582
|
"""(turns to render, their summaries, cache writes, counts, prior redactions, the ids of
|
|
2375
2583
|
turns the user typed that the reviewers dropped) from the pack and the decisions: a
|
|
@@ -2384,7 +2592,7 @@ def _review_result(pack, merged, args):
|
|
|
2384
2592
|
held = llm_keep is False and _kept_by_flag(pack, x)
|
|
2385
2593
|
stats["flag-kept"] += held
|
|
2386
2594
|
keep = bool(_first(None if held else llm_keep, x["rule"]["keep"]))
|
|
2387
|
-
if not keep and x
|
|
2595
|
+
if not keep and _typed_by_user(x):
|
|
2388
2596
|
typed.append(x["id"]) # typed by the user: the rules keep these
|
|
2389
2597
|
llm_summary = _reviewer_summary(x, d, args, stats)
|
|
2390
2598
|
if x["id"] in merged: # reviewed, even if left to the rules
|
|
@@ -2613,114 +2821,169 @@ def _flag_clash(args):
|
|
|
2613
2821
|
return None
|
|
2614
2822
|
|
|
2615
2823
|
|
|
2616
|
-
def
|
|
2617
|
-
|
|
2618
|
-
|
|
2824
|
+
def _merge_env_extra(args):
|
|
2825
|
+
"""LINEAGE_REDACT_EXTRA's keywords after --redact-extra's."""
|
|
2619
2826
|
env_extra = os.environ.get("LINEAGE_REDACT_EXTRA", "").strip()
|
|
2620
2827
|
if env_extra:
|
|
2621
2828
|
args.redact_extra = ",".join(
|
|
2622
2829
|
p for p in (args.redact_extra, env_extra) if p)
|
|
2623
2830
|
|
|
2624
|
-
clash = _flag_clash(args)
|
|
2625
|
-
if clash:
|
|
2626
|
-
print(f"[lineage] ERROR: {clash}", file=sys.stderr)
|
|
2627
|
-
return 2
|
|
2628
2831
|
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2832
|
+
def _purge_cache():
|
|
2833
|
+
import shutil
|
|
2834
|
+
if CACHE_BASE.exists():
|
|
2835
|
+
shutil.rmtree(CACHE_BASE)
|
|
2836
|
+
print(f"[lineage] cache purged: {CACHE_BASE}", file=sys.stderr)
|
|
2837
|
+
else:
|
|
2838
|
+
print("[lineage] no cache to purge", file=sys.stderr)
|
|
2839
|
+
return 0
|
|
2637
2840
|
|
|
2638
|
-
if args.apply_review:
|
|
2639
|
-
if any((args.last, args.from_, args.to, args.turns, args.session,
|
|
2640
|
-
args.all_sessions, args.from_transcript)):
|
|
2641
|
-
print("[lineage] note: --apply-review renders the pack; source and range "
|
|
2642
|
-
"flags were applied at --emit-review", file=sys.stderr)
|
|
2643
|
-
return apply_review(args)
|
|
2644
2841
|
|
|
2645
|
-
|
|
2842
|
+
def _run_apply(args):
|
|
2843
|
+
if any((args.last, args.from_, args.to, args.turns, args.session,
|
|
2844
|
+
args.all_sessions, args.from_transcript)):
|
|
2845
|
+
print("[lineage] note: --apply-review renders the pack; source and range "
|
|
2846
|
+
"flags were applied at --emit-review", file=sys.stderr)
|
|
2847
|
+
return apply_review(args)
|
|
2848
|
+
|
|
2849
|
+
|
|
2850
|
+
def _read_all_sessions(args):
|
|
2851
|
+
"""Every session of the project folder, merged record by record in time order."""
|
|
2852
|
+
files = project_jsonl_files()
|
|
2853
|
+
if not files:
|
|
2854
|
+
print("[lineage] --all-sessions: no jsonl found in project dir",
|
|
2855
|
+
file=sys.stderr)
|
|
2856
|
+
return 2, [], "session", None
|
|
2857
|
+
starts = {p: session_start(p) for p in files}
|
|
2646
2858
|
turns = []
|
|
2647
|
-
|
|
2648
|
-
|
|
2859
|
+
for p in files:
|
|
2860
|
+
name = discover_session_name(p) or (p.stem[-36:] if p.stem.startswith("rollout-") else p.stem)[:8]
|
|
2861
|
+
part = _load_turns_from(p, args.unsafe_schema, p.stem, name)
|
|
2862
|
+
meta = _codex_meta(p)
|
|
2863
|
+
if meta is not None and not any(t["role"] == "user" for t in part):
|
|
2864
|
+
print(f"[lineage] WARN: {p.name}: no user turn read from this Codex {meta.get('cli_version', '?')} "
|
|
2865
|
+
"record (a new record shape?); left out", file=sys.stderr)
|
|
2866
|
+
continue
|
|
2867
|
+
fill_missing_ts(part)
|
|
2868
|
+
for t in part:
|
|
2869
|
+
t["session_ts"] = starts[p] or "9999"
|
|
2870
|
+
turns.extend(part)
|
|
2871
|
+
# record-level chronological sort (stable → same-ts keeps file order)
|
|
2872
|
+
turns.sort(key=lambda t: t.get("ts") or t.get("session_ts") or "9999")
|
|
2873
|
+
print(f"[lineage] --all-sessions: merged {len(files)} sessions",
|
|
2874
|
+
file=sys.stderr)
|
|
2875
|
+
return None, turns, "all-sessions", None
|
|
2876
|
+
|
|
2877
|
+
|
|
2878
|
+
def _read_transcript(args):
|
|
2879
|
+
"""A transcript pasted on stdin ('-') or read from a file."""
|
|
2880
|
+
if args.from_transcript == "-":
|
|
2881
|
+
turns = list(parse_turns(sys.stdin, unsafe_schema=args.unsafe_schema,
|
|
2882
|
+
session_id="stdin"))
|
|
2883
|
+
return None, turns, "stdin", None
|
|
2884
|
+
jsonl_path = pathlib.Path(args.from_transcript)
|
|
2885
|
+
session_id = "file-" + jsonl_path.stem
|
|
2886
|
+
turns = _load_turns_from(jsonl_path, args.unsafe_schema, session_id, None)
|
|
2887
|
+
return None, turns, session_id, jsonl_path
|
|
2888
|
+
|
|
2889
|
+
|
|
2890
|
+
def _read_discovered(args):
|
|
2891
|
+
"""The newest session of the project folder."""
|
|
2892
|
+
f = auto_discover_jsonl()
|
|
2893
|
+
if not f:
|
|
2894
|
+
print("[lineage] No jsonl found. Use one of:",
|
|
2895
|
+
"\n --session FILE explicit jsonl path",
|
|
2896
|
+
"\n --all-sessions merge all project sessions",
|
|
2897
|
+
"\n --from-transcript - paste transcript via stdin",
|
|
2898
|
+
"\n --from-transcript FILE read transcript from file",
|
|
2899
|
+
"\n(non-ASCII cwd may not auto-encode — use --session)",
|
|
2900
|
+
file=sys.stderr)
|
|
2901
|
+
return 2, [], "session", None
|
|
2902
|
+
turns = _load_turns_from(f, args.unsafe_schema, f.stem, None)
|
|
2903
|
+
print(f"[lineage] auto-discovered session: {f.name}", file=sys.stderr)
|
|
2904
|
+
return None, turns, f.stem, f
|
|
2905
|
+
|
|
2906
|
+
|
|
2907
|
+
def _read_input(args):
|
|
2908
|
+
"""(exit code or None, raw turns, session id, jsonl path) from the source the flags name: a Claude Code
|
|
2909
|
+
transcript or a Codex session record (rollout), told apart by its first record. The code is 2 when there is no
|
|
2910
|
+
source, or the code a schema marker stopped the read with. A Codex rollout that gives no user turn stops with 2
|
|
2911
|
+
too: every Codex session starts with what the user typed, so none means this Codex version writes its record in a
|
|
2912
|
+
shape lineage does not read, and an empty page would hide that."""
|
|
2913
|
+
rc, turns, session_id, jsonl_path = _read_source(args)
|
|
2914
|
+
meta = _codex_meta(jsonl_path) if rc is None and jsonl_path is not None else None
|
|
2915
|
+
if meta is not None and not any(t["role"] == "user" for t in turns):
|
|
2916
|
+
print(f"[lineage] ERROR: {jsonl_path.name} is a Codex {meta.get('cli_version', '?')} session record, but no "
|
|
2917
|
+
"user turn was read from it; this Codex version may write its record in a new shape "
|
|
2918
|
+
"(nothing written; update banker or report it)", file=sys.stderr)
|
|
2919
|
+
return 2, [], session_id, None
|
|
2920
|
+
return rc, turns, session_id, jsonl_path
|
|
2921
|
+
|
|
2922
|
+
|
|
2923
|
+
def _read_source(args):
|
|
2924
|
+
"""(exit code or None, raw turns, session id, jsonl path) as read, before the Codex shape check."""
|
|
2649
2925
|
try:
|
|
2650
2926
|
if args.all_sessions:
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
return 2
|
|
2656
|
-
starts = {p: session_start(p) for p in files}
|
|
2657
|
-
for p in files:
|
|
2658
|
-
name = discover_session_name(p) or p.stem[:8]
|
|
2659
|
-
part = _load_turns_from(p, args.unsafe_schema, p.stem, name)
|
|
2660
|
-
fill_missing_ts(part)
|
|
2661
|
-
for t in part:
|
|
2662
|
-
t["session_ts"] = starts[p] or "9999"
|
|
2663
|
-
turns.extend(part)
|
|
2664
|
-
# record-level chronological sort (stable → same-ts keeps file order)
|
|
2665
|
-
turns.sort(key=lambda t: t.get("ts") or t.get("session_ts") or "9999")
|
|
2666
|
-
session_id = "all-sessions"
|
|
2667
|
-
print(f"[lineage] --all-sessions: merged {len(files)} sessions",
|
|
2668
|
-
file=sys.stderr)
|
|
2669
|
-
elif args.from_transcript:
|
|
2670
|
-
if args.from_transcript == "-":
|
|
2671
|
-
turns = list(parse_turns(sys.stdin, unsafe_schema=args.unsafe_schema,
|
|
2672
|
-
session_id="stdin"))
|
|
2673
|
-
session_id = "stdin"
|
|
2674
|
-
else:
|
|
2675
|
-
jsonl_path = pathlib.Path(args.from_transcript)
|
|
2676
|
-
session_id = "file-" + jsonl_path.stem
|
|
2677
|
-
turns = _load_turns_from(jsonl_path, args.unsafe_schema,
|
|
2678
|
-
session_id, None)
|
|
2679
|
-
elif args.session:
|
|
2927
|
+
return _read_all_sessions(args)
|
|
2928
|
+
if args.from_transcript:
|
|
2929
|
+
return _read_transcript(args)
|
|
2930
|
+
if args.session:
|
|
2680
2931
|
jsonl_path = pathlib.Path(args.session)
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
else:
|
|
2685
|
-
f = auto_discover_jsonl()
|
|
2686
|
-
if not f:
|
|
2687
|
-
print("[lineage] No jsonl found. Use one of:",
|
|
2688
|
-
"\n --session FILE explicit jsonl path",
|
|
2689
|
-
"\n --all-sessions merge all project sessions",
|
|
2690
|
-
"\n --from-transcript - paste transcript via stdin",
|
|
2691
|
-
"\n --from-transcript FILE read transcript from file",
|
|
2692
|
-
"\n(non-ASCII cwd may not auto-encode — use --session)",
|
|
2693
|
-
file=sys.stderr)
|
|
2694
|
-
return 2
|
|
2695
|
-
jsonl_path = f
|
|
2696
|
-
session_id = f.stem
|
|
2697
|
-
turns = _load_turns_from(f, args.unsafe_schema, session_id, None)
|
|
2698
|
-
print(f"[lineage] auto-discovered session: {f.name}", file=sys.stderr)
|
|
2932
|
+
turns = _load_turns_from(jsonl_path, args.unsafe_schema, jsonl_path.stem, None)
|
|
2933
|
+
return None, turns, jsonl_path.stem, jsonl_path
|
|
2934
|
+
return _read_discovered(args)
|
|
2699
2935
|
except SystemExit as e:
|
|
2700
|
-
return int(e.code) if isinstance(e.code, int) else 2
|
|
2936
|
+
return (int(e.code) if isinstance(e.code, int) else 2), [], "session", None
|
|
2701
2937
|
|
|
2702
|
-
# stdin → derive per-turn uuid from the content so the caches work (keyed: see _stdin_id_key)
|
|
2703
|
-
if args.from_transcript == "-":
|
|
2704
|
-
key = _stdin_id_key()
|
|
2705
|
-
for t in turns:
|
|
2706
|
-
t["uuid"] = hmac.new(key, str(t["text"]).encode(), hashlib.sha256).hexdigest()[:16]
|
|
2707
2938
|
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2939
|
+
def _stdin_ids(turns):
|
|
2940
|
+
"""stdin → derive per-turn uuid from the content so the caches work (keyed: see _stdin_id_key)."""
|
|
2941
|
+
key = _stdin_id_key()
|
|
2942
|
+
for t in turns:
|
|
2943
|
+
t["uuid"] = hmac.new(key, str(t["text"]).encode(), hashlib.sha256).hexdigest()[:16]
|
|
2944
|
+
|
|
2945
|
+
|
|
2946
|
+
def _run_emit(turns, session_id, jsonl_path, args):
|
|
2947
|
+
drops = heuristic_drops(turns, args)
|
|
2948
|
+
window = review_window(turns, drops, args)
|
|
2949
|
+
if window is None:
|
|
2950
|
+
return 2
|
|
2951
|
+
return emit_review(turns, drops, window, session_id,
|
|
2952
|
+
default_output(args, jsonl_path, session_id), args)
|
|
2953
|
+
|
|
2954
|
+
|
|
2955
|
+
def _rulebase_turns(turns, args):
|
|
2956
|
+
"""The turns a --rulebase page shows: echo exchanges and tool-only turns gone (unless kept), then the range.
|
|
2957
|
+
None on an invalid --turns."""
|
|
2718
2958
|
if args.drop_trivia:
|
|
2719
2959
|
turns = drop_echo_exchanges(turns)
|
|
2720
2960
|
if args.hide_tool_only:
|
|
2721
2961
|
turns = [t for t in turns
|
|
2722
2962
|
if not (t["role"] == "assistant" and not t["text"].strip())]
|
|
2723
|
-
|
|
2963
|
+
return select_range(turns, args)
|
|
2964
|
+
|
|
2965
|
+
|
|
2966
|
+
def main(argv=None) -> int:
|
|
2967
|
+
args = build_arg_parser().parse_args(argv)
|
|
2968
|
+
_merge_env_extra(args)
|
|
2969
|
+
clash = _flag_clash(args)
|
|
2970
|
+
if clash:
|
|
2971
|
+
print(f"[lineage] ERROR: {clash}", file=sys.stderr)
|
|
2972
|
+
return 2
|
|
2973
|
+
if args.purge_cache:
|
|
2974
|
+
return _purge_cache()
|
|
2975
|
+
if args.apply_review:
|
|
2976
|
+
return _run_apply(args)
|
|
2977
|
+
rc, turns, session_id, jsonl_path = _read_input(args)
|
|
2978
|
+
if rc is not None:
|
|
2979
|
+
return rc
|
|
2980
|
+
if args.from_transcript == "-":
|
|
2981
|
+
_stdin_ids(turns)
|
|
2982
|
+
# ---- Pipeline order (R6): classify → merge → echo → hide-tool-only → range ----
|
|
2983
|
+
turns = merge_assistant_runs(classify_turns(turns, drop_trivia=args.drop_trivia))
|
|
2984
|
+
if args.emit_review:
|
|
2985
|
+
return _run_emit(turns, session_id, jsonl_path, args)
|
|
2986
|
+
turns = _rulebase_turns(turns, args)
|
|
2724
2987
|
if turns is None:
|
|
2725
2988
|
return 2
|
|
2726
2989
|
if args.output is None:
|
|
@@ -2823,8 +3086,8 @@ def render_and_write(turns, session_id, args, all_sessions=False, markdown=True,
|
|
|
2823
3086
|
date_range = first if first == last else f"{first} ~ {last}"
|
|
2824
3087
|
|
|
2825
3088
|
html_doc = HTML_TEMPLATE
|
|
2826
|
-
html_doc = html_doc.replace("{{TITLE}}", html.escape(title), 1)
|
|
2827
|
-
html_doc = html_doc.replace("{{HEADER_TITLE}}", html.escape(title), 1)
|
|
3089
|
+
html_doc = html_doc.replace("{{TITLE}}", html.escape(_show_bidi_controls(title)), 1)
|
|
3090
|
+
html_doc = html_doc.replace("{{HEADER_TITLE}}", html.escape(_show_bidi_controls(title)), 1)
|
|
2828
3091
|
html_doc = html_doc.replace("{{DATE_RANGE}}", html.escape(_show_bidi_controls(date_range)), 1)
|
|
2829
3092
|
html_doc = html_doc.replace("{{TURNS}}", _show_bidi_controls("\n".join(rows)), 1)
|
|
2830
3093
|
|