Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.11.1] - 2026-06-08

**Fix: `pack` panicked on multibyte UTF-8.** Pack truncation sliced the
rendered text at a raw byte index, panicking ("byte index N is not a char
boundary") whenever the budget cutoff landed inside a multibyte character —
i.e. on Cyrillic/CJK/emoji-heavy journals that exceed the pack budget.
ASCII-only content was unaffected, so it stayed latent. Truncation now cuts
at a UTF-8 char boundary.

### Fixed
- `tj_core::pack` truncation is now char-boundary-safe (`truncate_to_budget`);
packs with non-ASCII text exceeding the budget no longer panic.

## [0.11.0] - 2026-06-08

**Live `session_id` on emitted events (additive, opt-in).** The journal now
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
]

[workspace.package]
version = "0.11.0"
version = "0.11.1"
edition = "2021"
rust-version = "1.88"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion crates/tj-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ name = "task-journal"
path = "src/main.rs"

[dependencies]
tj-core = { package = "task-journal-core", version = "0.11.0", path = "../tj-core" }
tj-core = { package = "task-journal-core", version = "0.11.1", path = "../tj-core" }
anyhow = { workspace = true }
clap = { workspace = true }
tracing = { workspace = true }
Expand Down
44 changes: 41 additions & 3 deletions crates/tj-core/src/pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,23 @@ fn render_lifecycle(conn: &Connection, task_id: &str) -> anyhow::Result<String>
Ok(out)
}

/// Truncate `text` to at most `budget` bytes, cutting at a UTF-8 char
/// boundary and preferring the last newline within the kept prefix, then
/// append `marker`. Char-boundary-safe: a raw `text[..budget]` byte slice
/// panics when `budget` lands inside a multibyte char (Cyrillic/CJK/emoji).
fn truncate_to_budget(text: &mut String, budget: usize, marker: &str) {
if text.len() <= budget {
return;
}
let mut end = budget;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
let cutoff = text[..end].rfind('\n').unwrap_or(end);
text.truncate(cutoff);
text.push_str(marker);
}

pub fn assemble(conn: &Connection, task_id: &str, mode: PackMode) -> anyhow::Result<TaskPack> {
let mode_str = match mode {
PackMode::Compact => "compact",
Expand Down Expand Up @@ -334,9 +351,7 @@ pub fn assemble(conn: &Connection, task_id: &str, mode: PackMode) -> anyhow::Res
};
let truncated = text.len() > budget;
if truncated {
let cutoff = text[..budget].rfind('\n').unwrap_or(budget);
text.truncate(cutoff);
text.push_str(TRUNC_MARKER);
truncate_to_budget(&mut text, budget, TRUNC_MARKER);
}

let generated_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
Expand Down Expand Up @@ -585,6 +600,29 @@ mod tests {
assert!(pack.text.contains("truncated to fit pack budget"));
}

#[test]
fn truncate_to_budget_handles_multibyte_boundary() {
// 1 ASCII byte shifts every 'я' (2 bytes) start to an ODD offset, so an
// EVEN budget lands INSIDE a char — a raw text[..budget] slice would panic.
let marker = "\n[cut]";
let mut s = String::from("x");
s.push_str(&"я".repeat(2000)); // total = 1 + 4000 = 4001 bytes
let budget = 100usize; // even → mid-char given the odd char starts
assert!(!s.is_char_boundary(budget), "precondition: budget must be mid-char");
truncate_to_budget(&mut s, budget, marker); // must NOT panic
assert!(s.ends_with(marker));
assert!(s.len() <= budget + marker.len());
assert!(std::str::from_utf8(s.as_bytes()).is_ok(), "result must be valid UTF-8");
}

#[test]
fn truncate_to_budget_noop_under_budget() {
let mut s = String::from("маленький текст");
let before = s.clone();
truncate_to_budget(&mut s, 10_000, "\n[cut]");
assert_eq!(s, before);
}

#[test]
fn corrected_events_appear_with_correction_event_type() {
use crate::db;
Expand Down
2 changes: 1 addition & 1 deletion crates/tj-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ name = "task-journal-mcp"
path = "src/main.rs"

[dependencies]
tj-core = { package = "task-journal-core", version = "0.11.0", path = "../tj-core" }
tj-core = { package = "task-journal-core", version = "0.11.1", path = "../tj-core" }
anyhow = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
Expand Down
Loading