mirror of
https://github.com/SeriousBug/dotfiles
synced 2026-07-27 12:35:37 -05:00
cc-compact: recursively summarize the /clear -> cc-compact lineage
Follow the chain of ancestor sessions each session was compacted from, up to --max-depth (default 10), summarizing each older session more tightly via a per-depth decay factor. Guards against cycles and already-visited sessions. Bounds the full chain to ~24k tokens worst case.
This commit is contained in:
parent
876ac92432
commit
1aea5cf659
|
|
@ -44,7 +44,8 @@ python3 ~/.claude/skills/cc-compact/compact_session.py --title "the session name
|
||||||
|
|
||||||
## Step 2: Read the output
|
## Step 2: Read the output
|
||||||
|
|
||||||
It prints a bounded, XML-tagged report:
|
Everything is wrapped in a `<compacted-session-chain>` element containing one or
|
||||||
|
more `<session depth="N">` blocks. Each block is a bounded, XML-tagged report:
|
||||||
|
|
||||||
- header: project, git branch, time span, record/prompt counts
|
- header: project, git branch, time span, record/prompt counts
|
||||||
- the first few exchanges (user prompt + the agent's reply) — the original intent
|
- the first few exchanges (user prompt + the agent's reply) — the original intent
|
||||||
|
|
@ -54,6 +55,14 @@ It prints a bounded, XML-tagged report:
|
||||||
- the last several tool calls — what the agent was physically doing last
|
- the last several tool calls — what the agent was physically doing last
|
||||||
- the final assistant message — what it was saying / waiting on last
|
- the final assistant message — what it was saying / waiting on last
|
||||||
|
|
||||||
|
`depth="0"` (`role="requested"`) is the session you asked for. If that session
|
||||||
|
was itself resumed from an earlier one via `/clear` → cc-compact, the script
|
||||||
|
follows that lineage backwards and emits each older session at `depth="1"`, `2`,
|
||||||
|
… (`role="ancestor"`), summarized more tightly at each step so the total stays
|
||||||
|
bounded. It walks up to `--max-depth` ancestors (default 10) and guards against
|
||||||
|
cycles. Read the deeper blocks as fading background: the further back, the
|
||||||
|
terser. You do not need to do anything to trigger this — it happens on its own.
|
||||||
|
|
||||||
## Step 3: Pick up the work
|
## Step 3: Pick up the work
|
||||||
|
|
||||||
The output is context for *you* — treat it like a resumed session, not something
|
The output is context for *you* — treat it like a resumed session, not something
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,48 @@ def resolve_latest():
|
||||||
return latest
|
return latest
|
||||||
|
|
||||||
|
|
||||||
|
# A compacted session's log records the cc-compact run it did on its own
|
||||||
|
# predecessor: the helper command (carrying --id/--file) and the report it
|
||||||
|
# printed (whose <file> tag and "Auto-selected" line hold the resolved absolute
|
||||||
|
# path). Following those references walks the /clear -> cc-compact lineage back.
|
||||||
|
_ANCESTOR_PATH_RES = [
|
||||||
|
re.compile(r"Auto-selected latest session:\s*(\S+\.jsonl)"),
|
||||||
|
re.compile(r"<file>([^<]+\.jsonl)</file>"),
|
||||||
|
re.compile(r"compact_session\.py[^\n\"]*?--file[= ]+(\S+\.jsonl)"),
|
||||||
|
]
|
||||||
|
_ANCESTOR_ID_RE = re.compile(r"compact_session\.py[^\n\"]*?--id[= ]+([0-9a-fA-F-]{36})")
|
||||||
|
|
||||||
|
|
||||||
|
def find_ancestor(path, visited):
|
||||||
|
"""Return the session log `path` was compacted from, or None.
|
||||||
|
|
||||||
|
Scans for references to another session and returns the first that resolves
|
||||||
|
to an existing file not already in `visited` (guards against cycles)."""
|
||||||
|
self_real = os.path.realpath(path)
|
||||||
|
try:
|
||||||
|
fh = open(path, encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
with fh:
|
||||||
|
for line in fh:
|
||||||
|
if "compact_session.py" not in line and "<file>" not in line and "Auto-selected" not in line:
|
||||||
|
continue
|
||||||
|
candidates = []
|
||||||
|
for pat in _ANCESTOR_PATH_RES:
|
||||||
|
candidates += pat.findall(line)
|
||||||
|
for sid in _ANCESTOR_ID_RE.findall(line):
|
||||||
|
candidates += glob.glob(os.path.join(PROJECTS_DIR, "**", f"{sid}.jsonl"), recursive=True)
|
||||||
|
for cand in candidates:
|
||||||
|
cand = os.path.expanduser(cand)
|
||||||
|
if not os.path.isfile(cand):
|
||||||
|
continue
|
||||||
|
real = os.path.realpath(cand)
|
||||||
|
if real == self_real or real in visited:
|
||||||
|
continue
|
||||||
|
return cand
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def resolve_file(args):
|
def resolve_file(args):
|
||||||
if args.latest:
|
if args.latest:
|
||||||
return resolve_latest()
|
return resolve_latest()
|
||||||
|
|
@ -187,12 +229,70 @@ def main():
|
||||||
ap.add_argument("--top-files", type=int, default=10, help="how many most-edited files")
|
ap.add_argument("--top-files", type=int, default=10, help="how many most-edited files")
|
||||||
ap.add_argument("--maxlen", type=int, default=800, help="max chars per quoted message")
|
ap.add_argument("--maxlen", type=int, default=800, help="max chars per quoted message")
|
||||||
ap.add_argument("--seed", type=int, default=1, help="seed for the middle random sample")
|
ap.add_argument("--seed", type=int, default=1, help="seed for the middle random sample")
|
||||||
|
ap.add_argument("--max-depth", type=int, default=10,
|
||||||
|
help="how many ancestor sessions to follow back through the "
|
||||||
|
"/clear -> cc-compact chain (0 = just this session)")
|
||||||
|
ap.add_argument("--decay", type=float, default=0.6,
|
||||||
|
help="per-depth shrink factor for every limit (tighter summaries deeper in the chain)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
if not (args.file or args.id or args.title):
|
if not (args.file or args.id or args.title):
|
||||||
args.latest = True
|
args.latest = True
|
||||||
|
|
||||||
path = resolve_file(args)
|
path = resolve_file(args)
|
||||||
|
|
||||||
|
out = sys.stdout.write
|
||||||
|
|
||||||
|
# Walk the lineage: the requested session at depth 0, then each older
|
||||||
|
# session it was compacted from, summarized ever more tightly.
|
||||||
|
visited = {os.path.realpath(path)}
|
||||||
|
chain = [path]
|
||||||
|
cur = path
|
||||||
|
for _ in range(max(0, args.max_depth)):
|
||||||
|
anc = find_ancestor(cur, visited)
|
||||||
|
if not anc:
|
||||||
|
break
|
||||||
|
chain.append(anc)
|
||||||
|
visited.add(os.path.realpath(anc))
|
||||||
|
cur = anc
|
||||||
|
|
||||||
|
out(f'<compacted-session-chain sessions="{len(chain)}" '
|
||||||
|
'note="depth 0 is the session you asked for; deeper entries are older '
|
||||||
|
'sessions it was compacted from, summarized more tightly">\n')
|
||||||
|
for depth, p in enumerate(chain):
|
||||||
|
params = scale_params(args, depth)
|
||||||
|
summarize(p, depth, params, out)
|
||||||
|
out("</compacted-session-chain>\n")
|
||||||
|
|
||||||
|
|
||||||
|
# Floors keep even the deepest summary useful without letting it grow.
|
||||||
|
_FLOORS = {"first": 1, "last": 2, "middle": 0, "top_files": 3, "maxlen": 200, "final": 300, "tools": 3}
|
||||||
|
_BASE_FINAL = 2000 # final-agent-message clip at depth 0
|
||||||
|
_BASE_TOOLS = 8 # last-tool-calls shown at depth 0
|
||||||
|
|
||||||
|
|
||||||
|
def scale_params(args, depth):
|
||||||
|
"""Depth-0 uses the CLI limits; each deeper level multiplies every limit by
|
||||||
|
args.decay ** depth, floored so summaries stay non-empty."""
|
||||||
|
f = args.decay ** depth
|
||||||
|
|
||||||
|
def s(base, floor):
|
||||||
|
return max(floor, int(round(base * f)))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"first": s(args.first, _FLOORS["first"]),
|
||||||
|
"last": s(args.last, _FLOORS["last"]),
|
||||||
|
"middle": s(args.middle, _FLOORS["middle"]),
|
||||||
|
"top_files": s(args.top_files, _FLOORS["top_files"]),
|
||||||
|
"maxlen": s(args.maxlen, _FLOORS["maxlen"]),
|
||||||
|
"final": s(_BASE_FINAL, _FLOORS["final"]),
|
||||||
|
"tools": s(_BASE_TOOLS, _FLOORS["tools"]),
|
||||||
|
"seed": args.seed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(path, depth, p, out):
|
||||||
|
"""Load one session log and emit its bounded, XML-tagged report under a
|
||||||
|
<session depth="N"> element. `p` holds the per-depth limits."""
|
||||||
turns = [] # conversational turns: {"prompt": str, "reply": [text,...]}
|
turns = [] # conversational turns: {"prompt": str, "reply": [text,...]}
|
||||||
edit_counts = {} # file path -> edit count
|
edit_counts = {} # file path -> edit count
|
||||||
last_assistant_text = [] # final text blocks
|
last_assistant_text = [] # final text blocks
|
||||||
|
|
@ -251,16 +351,17 @@ def main():
|
||||||
# sampled only from what's left between them. If the regions collide
|
# sampled only from what's left between them. If the regions collide
|
||||||
# (short session), the overlap is simply dropped.
|
# (short session), the overlap is simply dropped.
|
||||||
n = len(turns)
|
n = len(turns)
|
||||||
first_idx = list(range(min(args.first, n)))
|
first_idx = list(range(min(p["first"], n)))
|
||||||
last_start = max(len(first_idx), n - args.last)
|
last_start = max(len(first_idx), n - p["last"])
|
||||||
last_idx = list(range(last_start, n))
|
last_idx = list(range(last_start, n))
|
||||||
mid_pool = list(range(len(first_idx), last_start))
|
mid_pool = list(range(len(first_idx), last_start))
|
||||||
if args.seed is not None:
|
if p["seed"] is not None:
|
||||||
random.seed(args.seed)
|
random.seed(p["seed"])
|
||||||
mid_idx = sorted(random.sample(mid_pool, min(args.middle, len(mid_pool))))
|
mid_idx = sorted(random.sample(mid_pool, min(p["middle"], len(mid_pool))))
|
||||||
|
|
||||||
# ---- emit a bounded, XML-tagged report (newlines preserved) ----
|
# ---- emit a bounded, XML-tagged report (newlines preserved) ----
|
||||||
out = sys.stdout.write
|
role = "requested" if depth == 0 else "ancestor"
|
||||||
|
out(f'\n<session depth="{depth}" role="{role}">\n')
|
||||||
|
|
||||||
out("<session-summary>\n")
|
out("<session-summary>\n")
|
||||||
out(f" <file>{esc(path)}</file>\n")
|
out(f" <file>{esc(path)}</file>\n")
|
||||||
|
|
@ -279,9 +380,9 @@ def main():
|
||||||
t = turns[i]
|
t = turns[i]
|
||||||
reply = "\n".join(t["reply"]).strip()
|
reply = "\n".join(t["reply"]).strip()
|
||||||
out(f' <exchange n="{i + 1}">\n')
|
out(f' <exchange n="{i + 1}">\n')
|
||||||
out(f" <user>\n{clip(t['prompt'], args.maxlen)}\n </user>\n")
|
out(f" <user>\n{clip(t['prompt'], p['maxlen'])}\n </user>\n")
|
||||||
if reply:
|
if reply:
|
||||||
out(f" <agent>\n{clip(reply, args.maxlen)}\n </agent>\n")
|
out(f" <agent>\n{clip(reply, p['maxlen'])}\n </agent>\n")
|
||||||
else:
|
else:
|
||||||
out(" <agent note=\"no text reply — tool calls only\" />\n")
|
out(" <agent note=\"no text reply — tool calls only\" />\n")
|
||||||
out(" </exchange>\n")
|
out(" </exchange>\n")
|
||||||
|
|
@ -291,20 +392,22 @@ def main():
|
||||||
emit_exchanges("sampled-middle", mid_idx)
|
emit_exchanges("sampled-middle", mid_idx)
|
||||||
emit_exchanges("last", last_idx)
|
emit_exchanges("last", last_idx)
|
||||||
|
|
||||||
out(f'\n<most-edited-files top="{args.top_files}">\n')
|
out(f'\n<most-edited-files top="{p["top_files"]}">\n')
|
||||||
for fp, c in sorted(edit_counts.items(), key=lambda kv: -kv[1])[: args.top_files]:
|
for fp, c in sorted(edit_counts.items(), key=lambda kv: -kv[1])[: p["top_files"]]:
|
||||||
out(f' <file edits="{c}">{esc(fp)}</file>\n')
|
out(f' <file edits="{c}">{esc(fp)}</file>\n')
|
||||||
out("</most-edited-files>\n")
|
out("</most-edited-files>\n")
|
||||||
|
|
||||||
out("\n<last-tool-calls>\n")
|
out("\n<last-tool-calls>\n")
|
||||||
for name, arg in recent_tools[-8:]:
|
for name, arg in recent_tools[-p["tools"]:]:
|
||||||
out(f' <call tool="{esc(name)}">{esc(arg)}</call>\n')
|
out(f' <call tool="{esc(name)}">{esc(arg)}</call>\n')
|
||||||
out("</last-tool-calls>\n")
|
out("</last-tool-calls>\n")
|
||||||
|
|
||||||
out("\n<final-agent-message>\n")
|
out("\n<final-agent-message>\n")
|
||||||
out(clip("\n".join(last_assistant_text), 2000) + "\n")
|
out(clip("\n".join(last_assistant_text), p["final"]) + "\n")
|
||||||
out("</final-agent-message>\n")
|
out("</final-agent-message>\n")
|
||||||
|
|
||||||
|
out("</session>\n")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue