Skip to content

Commit 552ae5b

Browse files
authored
Merge pull request #40 from Digital-Threads/fix/complete-enrich-chunking
fix: chunk enrich transcript so complete works on large sessions (0.22.1)
2 parents b2e6141 + 13fef42 commit 552ae5b

8 files changed

Lines changed: 156 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.22.1] - 2026-06-13
11+
12+
### Fixed
13+
- **`complete` no longer fails on large sessions.** The enrich pass fed a whole
14+
session transcript to the model in one call; a big multi-session task blew the
15+
~200k-token context limit and `claude -p` returned HTTP 400 ("Prompt is too
16+
long"). The transcript is now split into line-aligned chunks under a safe
17+
budget and the recovered events are merged (and deduped), so finalize works on
18+
any session size. (`--quick` was unaffected — it skips enrich.)
19+
- **Legible `claude -p` errors.** A non-zero `claude -p` exit now surfaces the
20+
JSON error it prints on **stdout** (with `--output-format json` the real cause
21+
— invalid model, usage limit, context overflow — goes there, not stderr), so a
22+
failure reads as "Prompt is too long · ~220310 tokens" instead of a bare
23+
"exit status 1".
24+
1025
## [0.22.0] - 2026-06-13
1126

1227
### Added

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = [
77
]
88

99
[workspace.package]
10-
version = "0.22.0"
10+
version = "0.22.1"
1111
edition = "2021"
1212
rust-version = "1.88"
1313
license = "MIT"

crates/tj-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ default = ["embed"]
2323
embed = ["tj-core/embed"]
2424

2525
[dependencies]
26-
tj-core = { package = "task-journal-core", version = "0.22.0", path = "../tj-core", default-features = false }
26+
tj-core = { package = "task-journal-core", version = "0.22.1", path = "../tj-core", default-features = false }
2727
anyhow = { workspace = true }
2828
clap = { workspace = true }
2929
tracing = { workspace = true }

crates/tj-core/src/classifier/agent_sdk.rs

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,46 @@ fn base_claude_command(model: &str) -> Command {
6464
/// very journal — out of the classification subprocess).
6565
pub struct ClaudeBinaryRunner;
6666

67+
/// Build the error for a non-zero `claude -p` exit. With `--output-format
68+
/// json` claude reports the real cause (invalid model, usage limit, auth) as
69+
/// JSON on **stdout**, not stderr — so surface both, capped, or the user just
70+
/// sees a bare "exit status 1".
71+
fn claude_exit_error(
72+
status: std::process::ExitStatus,
73+
stdout: &[u8],
74+
stderr: &[u8],
75+
) -> anyhow::Error {
76+
let cap = |b: &[u8]| {
77+
let s = String::from_utf8_lossy(b);
78+
let s = s.trim().to_string();
79+
if s.chars().count() > 600 {
80+
format!("{}…", s.chars().take(600).collect::<String>())
81+
} else {
82+
s
83+
}
84+
};
85+
let out = cap(stdout);
86+
let err = cap(stderr);
87+
let detail = match (out.is_empty(), err.is_empty()) {
88+
(true, true) => "(no output)".to_string(),
89+
(false, true) => out,
90+
(true, false) => err,
91+
(false, false) => format!("{err} | stdout: {out}"),
92+
};
93+
anyhow!("`claude -p` exited with {status}: {detail}")
94+
}
95+
6796
impl CommandRunner for ClaudeBinaryRunner {
6897
fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String> {
6998
let output = base_claude_command(model)
7099
.arg(prompt)
71100
.output()
72101
.context("failed to spawn `claude` (is Claude Code installed and on PATH?)")?;
73102
if !output.status.success() {
74-
let stderr = String::from_utf8_lossy(&output.stderr);
75-
return Err(anyhow!(
76-
"`claude -p` exited with {}: {}",
103+
return Err(claude_exit_error(
77104
output.status,
78-
stderr.trim()
105+
&output.stdout,
106+
&output.stderr,
79107
));
80108
}
81109
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
@@ -111,11 +139,10 @@ impl CommandRunner for ClaudeBinaryStdinRunner {
111139
.wait_with_output()
112140
.context("failed to wait for `claude`")?;
113141
if !output.status.success() {
114-
let stderr = String::from_utf8_lossy(&output.stderr);
115-
return Err(anyhow!(
116-
"`claude -p` exited with {}: {}",
142+
return Err(claude_exit_error(
117143
output.status,
118-
stderr.trim()
144+
&output.stdout,
145+
&output.stderr,
119146
));
120147
}
121148
Ok(String::from_utf8_lossy(&output.stdout).into_owned())

crates/tj-core/src/dream/llm_backend.rs

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,57 @@ impl LlmDreamBackend {
2424
}
2525
}
2626

27+
/// Max transcript characters fed to the model in one call. The hard wall is
28+
/// the ~200k-token context limit (a real session hit ~220k tokens and `claude
29+
/// -p` returned HTTP 400). We stay well under it and split oversized
30+
/// transcripts across several calls, merging the events (run_dream dedups).
31+
const TRANSCRIPT_CHAR_BUDGET: usize = 360_000;
32+
2733
impl DreamBackend for LlmDreamBackend {
2834
fn backfill(&self, input: &BackfillInput) -> anyhow::Result<Vec<BackfillEvent>> {
29-
let prompt = crate::dream::prompt::build_prompt(input);
30-
let text = self.llm.complete(&prompt, 1024)?;
31-
parse_backfill_json(&text)
35+
let mut out = Vec::new();
36+
for chunk in chunk_transcript(&input.transcript, TRANSCRIPT_CHAR_BUDGET) {
37+
let chunk_input = BackfillInput {
38+
tasks: input.tasks.clone(),
39+
transcript: chunk,
40+
};
41+
let prompt = crate::dream::prompt::build_prompt(&chunk_input);
42+
let text = self.llm.complete(&prompt, 1024)?;
43+
out.extend(parse_backfill_json(&text)?);
44+
}
45+
Ok(out)
46+
}
47+
}
48+
49+
/// Split a transcript into chunks of at most `budget` bytes, breaking on line
50+
/// boundaries where possible (a lone oversized line is hard-split on char
51+
/// boundaries). Always returns at least one chunk so an empty transcript still
52+
/// yields a single call.
53+
fn chunk_transcript(transcript: &str, budget: usize) -> Vec<String> {
54+
if transcript.len() <= budget {
55+
return vec![transcript.to_string()];
56+
}
57+
let mut chunks = Vec::new();
58+
let mut cur = String::new();
59+
for line in transcript.split_inclusive('\n') {
60+
if !cur.is_empty() && cur.len() + line.len() > budget {
61+
chunks.push(std::mem::take(&mut cur));
62+
}
63+
if line.len() > budget {
64+
for ch in line.chars() {
65+
if !cur.is_empty() && cur.len() + ch.len_utf8() > budget {
66+
chunks.push(std::mem::take(&mut cur));
67+
}
68+
cur.push(ch);
69+
}
70+
} else {
71+
cur.push_str(line);
72+
}
73+
}
74+
if !cur.is_empty() {
75+
chunks.push(cur);
3276
}
77+
chunks
3378
}
3479

3580
/// Parse the model's reply (a JSON array of `BackfillEvent`, possibly wrapped in
@@ -66,6 +111,57 @@ mod tests {
66111
assert!(parse_backfill_json("[]").unwrap().is_empty());
67112
}
68113

114+
#[test]
115+
fn small_transcript_is_one_chunk() {
116+
let c = chunk_transcript("a\nb\nc\n", 100);
117+
assert_eq!(c.len(), 1);
118+
assert_eq!(c[0], "a\nb\nc\n");
119+
}
120+
121+
#[test]
122+
fn big_transcript_splits_on_lines_and_preserves_content() {
123+
// 10 lines of 20 chars; budget 50 → multiple chunks, no loss.
124+
let transcript: String = (0..10).map(|i| format!("line{i:015}\n")).collect();
125+
let chunks = chunk_transcript(&transcript, 50);
126+
assert!(chunks.len() > 1, "must split");
127+
assert!(chunks.iter().all(|c| c.len() <= 50));
128+
assert_eq!(chunks.concat(), transcript, "no content lost");
129+
}
130+
131+
#[test]
132+
fn oversized_single_line_is_hard_split() {
133+
let line = "x".repeat(250);
134+
let chunks = chunk_transcript(&line, 100);
135+
assert!(chunks.len() >= 3);
136+
assert!(chunks.iter().all(|c| c.len() <= 100));
137+
assert_eq!(chunks.concat(), line);
138+
}
139+
140+
#[test]
141+
fn backfill_chunks_large_transcript_into_multiple_calls() {
142+
use std::sync::atomic::{AtomicUsize, Ordering};
143+
struct CountingLlm(AtomicUsize);
144+
impl LlmBackend for CountingLlm {
145+
fn complete(&self, _prompt: &str, _max: u32) -> anyhow::Result<String> {
146+
self.0.fetch_add(1, Ordering::SeqCst);
147+
Ok("[]".to_string())
148+
}
149+
fn name(&self) -> &'static str {
150+
"counting"
151+
}
152+
}
153+
let llm = Box::new(CountingLlm(AtomicUsize::new(0)));
154+
// Build a transcript larger than the budget so it must split.
155+
let transcript = "y\n".repeat(TRANSCRIPT_CHAR_BUDGET);
156+
let b = LlmDreamBackend::new(llm);
157+
let input = BackfillInput {
158+
tasks: vec![],
159+
transcript,
160+
};
161+
let evs = b.backfill(&input).unwrap();
162+
assert!(evs.is_empty());
163+
}
164+
69165
#[test]
70166
fn llm_dream_backend_runs_and_parses() {
71167
struct FakeLlm;

crates/tj-mcp/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ path = "src/main.rs"
1717

1818
[dependencies]
1919
# Lean: the MCP server doesn't embed yet, so it skips the model2vec backend.
20-
tj-core = { package = "task-journal-core", version = "0.22.0", path = "../tj-core", default-features = false }
20+
tj-core = { package = "task-journal-core", version = "0.22.1", path = "../tj-core", default-features = false }
2121
anyhow = { workspace = true }
2222
tokio = { workspace = true }
2323
tracing = { workspace = true }

plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "task-journal",
3-
"version": "0.22.0",
3+
"version": "0.22.1",
44
"description": "Append-only journal of AI-coding task reasoning chains: hypotheses, decisions, rejections, evidence. Renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
55
"author": {
66
"name": "Mher Shahinyan"

0 commit comments

Comments
 (0)