From d651da79b70e2470ab9ee8538a753933b3bd0bdd Mon Sep 17 00:00:00 2001 From: Jason Irish Date: Mon, 6 Jul 2026 14:06:29 -0500 Subject: [PATCH 1/2] feat(#9): v0.6.0 - inline post-compaction recovery instead of a pointer session-onboard.sh's source=compact path previously printed only a pointer ("your buffer survived at , read it"), costing the model a tool call it might not make right where post-compaction recall is weakest. Now inlines the last 30 lines of the current session's buffer directly into the SessionStart block (fenced), including any trailing compaction-boundary marker, which lands there naturally since no captured action can occur between PreCompact stamping it and this SessionStart firing. The full-file pointer is kept for sessions longer than the window. Bounded output: each buffer line is length-clamped at capture time, so a fixed line count keeps this bounded regardless of session length. startup/resume/clear are unaffected - only compact inlines buffer content. Verified: full 150-assertion suite (7 new) plus shellcheck under both Homebrew and an apt-get install shellcheck Ubuntu container. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q4iokjvAbBLgrRJYwCVtqb --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 24 ++++++++++++++++++++++++ hooks/session-onboard.sh | 17 ++++++++++++++--- tests/run.sh | 26 +++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 50dcf8d..a6f75bf 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "throughline", "description": "Continuous, state-aware session memory for Claude Code. Captures what you did and what is (commands, file changes, decisions, and live git/PR state), then hands it off with judgment at session wrap-up. Readable, committable artifacts; binds to Claude's native memory.", - "version": "0.5.2", + "version": "0.6.0", "author": { "name": "Dynamic Agency", "email": "support@dynamicagency.com" diff --git a/CHANGELOG.md b/CHANGELOG.md index bedc4d7..c006d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,30 @@ All notable changes to throughline are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/); this project uses semantic versioning. +## [0.6.0] + +Issue #9 from the v0.4.0 audit (docs/AUDIT-v0.4.0.md, P2): inline post-compaction +recovery content instead of a pointer. Feature bump - onboard's compact-path +output now carries actual buffer content, not just a filename. Verified by the +full 150-assertion suite (7 new) plus shellcheck under both Homebrew and an +`apt-get install shellcheck` Ubuntu container. + +### Added +- **Inlined post-compaction recovery** (`session-onboard.sh`): on + `source=compact`, the last 30 lines of the current session's action + buffer - including any trailing `compaction-boundary` marker, which lands + there naturally since no captured action can land between PreCompact + stamping it and this SessionStart firing - are now printed directly into + the SessionStart block, fenced in a code block. Previously this path only + printed a pointer ("your buffer survived at ``, read it"), which cost + the model a tool call it might not make, at exactly the point where + post-compaction recall is weakest. The pointer to the full file is kept + alongside the inlined tail for sessions that ran longer than the window. + Bounded output: each buffer line is itself length-clamped at capture time, + so a fixed line count keeps the inlined block's size bounded regardless of + session length. `startup`/`resume`/`clear` sources are unaffected - only + `compact` inlines buffer content. + ## [0.5.2] Hot-path perf batch plus issue #15, which turned out to have a second, diff --git a/hooks/session-onboard.sh b/hooks/session-onboard.sh index de127b5..143e133 100755 --- a/hooks/session-onboard.sh +++ b/hooks/session-onboard.sh @@ -132,11 +132,22 @@ case "$data" in ;; esac -# Post-compaction recovery: the conversation was just summarized, but this -# session's buffer is intact on disk. Point Claude at it explicitly. +# Post-compaction recovery (issue #9): the conversation was just summarized, +# but this session's buffer is intact on disk. Inline its TAIL directly into +# this SessionStart block instead of only pointing at the file - a bare +# pointer costs the model a tool call it may not make, right where +# post-compaction recall is weakest. Bounded to the last N lines so a long +# session's buffer can't balloon this block (each record line is itself +# clamp()-bounded at capture time, so N lines is also bounded in bytes); the +# full-file pointer is kept for anything older than the tail. +TL_COMPACT_TAIL_LINES=30 if [ "$src" = "compact" ] && [ -n "$sid" ] && [ -f "$bufdir/session-$sid.md" ]; then + buf="$bufdir/session-$sid.md" echo - echo "🧷 Context was just compacted. This session's action buffer survived at \`${bufdir#"$root"/}/session-$sid.md\` - read it to recover what you did before the compaction. The raw actions persist even though the conversation summary dropped detail." + echo "🧷 Context was just compacted. The last $TL_COMPACT_TAIL_LINES line(s) of this session's action buffer are inlined below to recover what you did before the compaction, without an extra read - the raw actions persist even though the conversation summary dropped detail. Full history (if the session ran longer than this tail) is at \`${bufdir#"$root"/}/session-$sid.md\`." + echo '```' + tail -n "$TL_COMPACT_TAIL_LINES" "$buf" 2>/dev/null + echo '```' fi # Surface unconsumed buffers from OTHER sessions. Exclude the current session's diff --git a/tests/run.sh b/tests/run.sh index 59bccc7..5ce83a5 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -280,10 +280,34 @@ cap '{"session_id":"T","tool_name":"Bash","tool_input":{"description":"x","comma printf '%s' '{"session_id":"T","trigger":"manual"}' | sh "$H/session-precompact.sh" has "PreCompact writes a compaction-boundary marker" "$(cat "$BUF/session-T.md")" '` marker they're embedded in. (The jq `clean` def in -# tl_jq_redact_defs below duplicates this rule rather than calling it: the -# capture-side hooks apply control-char -# AND backtick stripping together, per-field, inside one jq pipeline that also -# does redaction — pulling just the control-char half out to a separate shell -# call there would mean an extra process per field for no real safety gain.) +# Replace control characters (including newlines) AND backticks with a space. +# Shared by session-flush.sh and session-precompact.sh so their reason/trigger +# fields can't break either delimiter they get embedded in: the `` +# HTML comment (control chars), and - since session-onboard.sh's post-compact +# inline-tail feature (issue #9) started wrapping raw buffer content in a +# markdown ``` fence - a run of 3+ backticks in a stamped line could also +# prematurely close that fence. Both trigger/reason are fixed enum strings +# from the harness today (auto/manual; clear/logout/prompt_input_exit/other), +# so this is currently unreachable, but it is cheap to close at the source +# rather than assume every future caller and every future delimiter stays +# safe. (The jq `clean` def in tl_jq_redact_defs below duplicates this rule +# rather than calling it: the capture-side hooks apply control-char and +# backtick stripping together, per-field, inside one jq pipeline that also +# does redaction — pulling just this half out to a separate shell call there +# would mean an extra process per field for no real safety gain.) tl_clean_ctrl() { - printf '%s' "$1" | tr '[:cntrl:]' ' ' + printf '%s' "$1" | tr '[:cntrl:]`' ' ' } # Single source for the on-disk timestamp format, used by every record line and diff --git a/hooks/session-onboard.sh b/hooks/session-onboard.sh index 143e133..797a9f6 100755 --- a/hooks/session-onboard.sh +++ b/hooks/session-onboard.sh @@ -136,17 +136,27 @@ esac # but this session's buffer is intact on disk. Inline its TAIL directly into # this SessionStart block instead of only pointing at the file - a bare # pointer costs the model a tool call it may not make, right where -# post-compaction recall is weakest. Bounded to the last N lines so a long -# session's buffer can't balloon this block (each record line is itself -# clamp()-bounded at capture time, so N lines is also bounded in bytes); the -# full-file pointer is kept for anything older than the tail. +# post-compaction recall is weakest. Bounded to the last N lines, EACH also +# capped at TL_COMPACT_TAIL_LINE_CHARS characters (via the awk pass below) - +# not just line count. A record's Bash `description` and Edit/Write/ +# NotebookEdit `file_path` fields are never length-clamped in +# session-capture.sh (only `command` and the other free-text fields are), so +# without this hook's OWN cap an unusually long one of those would still +# inline verbatim; capping here, at the point this block's own bounded-size +# claim is made, holds regardless of what any capture-side branch does or +# later stops doing. The full-file pointer is kept for anything older than +# the tail. TL_COMPACT_TAIL_LINES=30 +TL_COMPACT_TAIL_LINE_CHARS=300 if [ "$src" = "compact" ] && [ -n "$sid" ] && [ -f "$bufdir/session-$sid.md" ]; then buf="$bufdir/session-$sid.md" echo echo "🧷 Context was just compacted. The last $TL_COMPACT_TAIL_LINES line(s) of this session's action buffer are inlined below to recover what you did before the compaction, without an extra read - the raw actions persist even though the conversation summary dropped detail. Full history (if the session ran longer than this tail) is at \`${bufdir#"$root"/}/session-$sid.md\`." echo '```' - tail -n "$TL_COMPACT_TAIL_LINES" "$buf" 2>/dev/null + tail -n "$TL_COMPACT_TAIL_LINES" "$buf" 2>/dev/null | awk -v max="$TL_COMPACT_TAIL_LINE_CHARS" ' + { if (length($0) > max) print substr($0, 1, max) " …[line truncated]" + else print + }' echo '```' fi diff --git a/tests/run.sh b/tests/run.sh index 5ce83a5..da14c1f 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -254,6 +254,13 @@ eq "tl_safe_sid rejects '.'" "$(tl_safe_sid '.')" "" eq "tl_safe_sid rejects '..'" "$(tl_safe_sid '..')" "" eq "tl_safe_sid rejects empty" "$(tl_safe_sid '')" "" +# 5a2. tl_clean_ctrl unit tests: control chars AND backticks both become a +# space (issue #9 review finding: a stamped trigger/reason containing a +# run of backticks could otherwise break the markdown fence the new +# inline-tail feature wraps buffer content in). +eq "tl_clean_ctrl replaces control chars with space" "$(tl_clean_ctrl "$(printf 'a\tb')")" "a b" +eq "tl_clean_ctrl replaces backticks with space" "$(tl_clean_ctrl 'trig```ger')" "trig ger" + # 5c. tl_data_dir honors an absolute THROUGHLINE_DATA_DIR override ABS_DIR="$WORK/abs-data" eq "tl_data_dir honors absolute THROUGHLINE_DATA_DIR" \ @@ -309,6 +316,18 @@ hasnt "onboard(compact) tail excludes a line beyond the bound" "$O_TAIL" 'cmd10` has "onboard(compact) tail includes the first line within the bound" "$O_TAIL" 'cmd11`' has "onboard(compact) tail includes the most recent line" "$O_TAIL" 'cmd40`' +# 7c. a single OVERSIZED line within the tail window is truncated per-line +# (review finding: session-capture.sh never length-clamps a Bash +# description or an Edit/Write/NotebookEdit file_path, so this hook's own +# per-line cap - not an assumption about capture-side clamping - is what +# actually keeps the inlined block bounded). +reset_buf +LONGDESC=$(awk 'BEGIN{for(i=0;i<3000;i++) printf "x"}') +printf -- '- `t` **bash** %s - `ls`\n' "$LONGDESC" > "$BUF/session-T.md" +O_LONG=$(printf '%s' '{"source":"compact","session_id":"T"}' | sh "$H/session-onboard.sh") +hasnt "onboard(compact) does not inline an oversized field verbatim" "$O_LONG" "$LONGDESC" +has "onboard(compact) marks a truncated oversized line" "$O_LONG" '…[line truncated]' + # 8. flush stamps ended once, anchored printf '%s' '{"session_id":"T","reason":"clear"}' | sh "$H/session-flush.sh" printf '%s' '{"session_id":"T","reason":"clear"}' | sh "$H/session-flush.sh"