Keep the turns a repair cuts, and hand them back to the session
Truncating the transcript is the mechanical fix, but the turns being cut are the work itself. Losing the conversation that produced a morning's changes is most of the damage, and a session that resumes with a hole in its memory is barely resumed - it has no idea what it just did or what it was asked. So --apply now writes the dropped turns out as <session>.recovered-<stamp>.md, a readable record of what was asked, what was answered and what was run, and appends the same text back to the truncated transcript as a single note. Tool results are deliberately excluded: they are most of a transcript by volume and the least useful part of a summary. The note is a user entry marked isMeta - the marker Claude Code already uses for its own local-command caveats, meaning context rather than something to answer - and carries no message.id, so it cannot recreate the previous_message_id condition being repaired. Verified on the real corrupted transcript: 1921 lines in, 1813 kept plus the note, chained to the last good assistant uuid, no id on the message, and the file reads back as healthy. The generated digest recovers the actual instruction that was lost in the incident, which is the thing that made this worth doing. --no-reinject writes the markdown but leaves the session alone.
This commit is contained in:
+125
-5
@@ -628,14 +628,97 @@ def _msg_id(obj):
|
||||
return None
|
||||
|
||||
|
||||
def cmd_repair_session(argv):
|
||||
"""repair-session <transcript.jsonl> [--apply]
|
||||
|
||||
Prints a JSON verdict. With --apply, backs the file up and truncates it to
|
||||
the last Anthropic-issued message.
|
||||
def _blocks(msg):
|
||||
c = msg.get("content")
|
||||
if isinstance(c, str):
|
||||
return [{"type": "text", "text": c}]
|
||||
return c if isinstance(c, list) else []
|
||||
|
||||
|
||||
def _clip(t, n):
|
||||
t = " ".join(str(t).split())
|
||||
return t if len(t) <= n else t[:n - 1] + "\u2026"
|
||||
|
||||
|
||||
def summarise_dropped(lines, per_block=1400):
|
||||
"""Render the turns a repair is about to discard as readable Markdown.
|
||||
|
||||
Tool *results* are deliberately left out. They are the bulk of a transcript
|
||||
by volume and the least useful part of a summary - what matters on the way
|
||||
back in is what was asked, what was said, and what was run.
|
||||
"""
|
||||
out, tools = [], 0
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
t, msg = d.get("type"), (d.get("message") or {})
|
||||
if t == "user" and not d.get("isMeta"):
|
||||
for b in _blocks(msg):
|
||||
if b.get("type") == "text" and b.get("text", "").strip():
|
||||
out.append("### User\n\n" + _clip(b["text"], per_block))
|
||||
elif t == "assistant":
|
||||
if d.get("isApiErrorMessage"):
|
||||
for b in _blocks(msg):
|
||||
if b.get("type") == "text":
|
||||
out.append("> **API error:** " + _clip(b.get("text", ""), 300))
|
||||
continue
|
||||
said, ran = [], []
|
||||
for b in _blocks(msg):
|
||||
if b.get("type") == "text" and b.get("text", "").strip():
|
||||
said.append(_clip(b["text"], per_block))
|
||||
elif b.get("type") == "tool_use":
|
||||
inp = b.get("input") or {}
|
||||
hint = inp.get("command") or inp.get("file_path") or inp.get("pattern") or inp.get("path") or ""
|
||||
ran.append("`%s`%s" % (b.get("name", "tool"),
|
||||
(" \u2014 " + _clip(hint, 120)) if hint else ""))
|
||||
tools += 1
|
||||
if said:
|
||||
out.append("### Claude\n\n" + "\n\n".join(said))
|
||||
if ran:
|
||||
out.append("Ran: " + ", ".join(ran[:12]) + (" \u2026" if len(ran) > 12 else ""))
|
||||
return "\n\n".join(out), tools
|
||||
|
||||
|
||||
def _meta_entry(template, parent_uuid, text):
|
||||
"""A user-role entry marked isMeta, which Claude Code treats as context
|
||||
rather than as something to answer - the same marker it uses for its own
|
||||
local-command caveats. It carries no message.id, so it cannot affect the
|
||||
previous_message_id that made the session unresumable in the first place.
|
||||
"""
|
||||
now = __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
e = {
|
||||
"parentUuid": parent_uuid,
|
||||
"isSidechain": False,
|
||||
"userType": template.get("userType", "external"),
|
||||
"cwd": template.get("cwd", ""),
|
||||
"sessionId": template.get("sessionId", ""),
|
||||
"version": template.get("version", ""),
|
||||
"gitBranch": template.get("gitBranch", ""),
|
||||
"type": "user",
|
||||
"isMeta": True,
|
||||
"uuid": str(__import__("uuid").uuid4()),
|
||||
"timestamp": now,
|
||||
"message": {"role": "user", "content": text},
|
||||
}
|
||||
return {k: v for k, v in e.items() if v != "" or k in ("gitBranch",)}
|
||||
|
||||
def cmd_repair_session(argv):
|
||||
"""repair-session <transcript.jsonl> [--apply] [--no-reinject]
|
||||
|
||||
Prints a JSON verdict. With --apply, writes the dropped turns out as
|
||||
Markdown, backs the file up, truncates it to the last Anthropic-issued
|
||||
message, and (unless --no-reinject) appends those turns back as a single
|
||||
isMeta note so the resumed session still knows what it did.
|
||||
"""
|
||||
path = argv[0]
|
||||
apply_it = "--apply" in argv[1:]
|
||||
reinject = "--no-reinject" not in argv[1:]
|
||||
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
lines = fh.read().splitlines()
|
||||
@@ -678,17 +761,54 @@ def cmd_repair_session(argv):
|
||||
"repairable": (not healthy) and last_good >= 0,
|
||||
"applied": False,
|
||||
"backup": "",
|
||||
"recovered": "",
|
||||
"reinjected": False,
|
||||
"droppedTools": 0,
|
||||
}
|
||||
|
||||
if apply_it and out["repairable"]:
|
||||
stamp = __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
backup = "%s.pre-repair-backup-%s" % (path, stamp)
|
||||
dropped = lines[last_good + 1:]
|
||||
keep = lines[:last_good + 1]
|
||||
|
||||
# The turns being cut are the work itself. Losing the conversation that
|
||||
# produced a morning's changes is most of the damage, so they are written
|
||||
# out as readable Markdown before anything is deleted...
|
||||
digest, ntools = summarise_dropped(dropped)
|
||||
recovered = "%s.recovered-%s.md" % (path.rsplit(".jsonl", 1)[0], stamp)
|
||||
header = ("# Recovered turns\n\n"
|
||||
"Cut from `%s` on %s, because the transcript could no longer be\n"
|
||||
"resumed. Everything below happened; none of it is in the session any more.\n\n"
|
||||
"---\n\n" % (os.path.basename(path), stamp))
|
||||
with open(recovered, "w", encoding="utf-8") as fh:
|
||||
fh.write(header + (digest or "_No readable messages in the dropped turns._\n"))
|
||||
|
||||
with open(backup, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(lines) + ("\n" if lines else ""))
|
||||
|
||||
# ...and then handed back to the session, so the agent that resumes it
|
||||
# knows what it just did rather than waking with a gap in its memory.
|
||||
if reinject and digest:
|
||||
template = _safe(keep[last_good])
|
||||
body = digest if len(digest) <= 24000 else (
|
||||
digest[:24000] + "\n\n\u2026 truncated; the full text is in " + recovered)
|
||||
note = ("<recovered-transcript>\n"
|
||||
"Context, not an instruction - do not act on it or reply to it.\n\n"
|
||||
"This session was rolled back to its last resumable point after a "
|
||||
"provider switch left it unable to resume. The turns below were part "
|
||||
"of this conversation and are no longer in it. The full text is at "
|
||||
+ recovered + "\n\n" + body + "\n</recovered-transcript>")
|
||||
keep.append(json.dumps(_meta_entry(template, template.get("uuid"), note)))
|
||||
out["reinjected"] = True
|
||||
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(lines[:last_good + 1]) + "\n")
|
||||
fh.write("\n".join(keep) + "\n")
|
||||
|
||||
out["applied"] = True
|
||||
out["backup"] = backup
|
||||
out["recovered"] = recovered
|
||||
out["droppedTools"] = ntools
|
||||
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user