Skip to content

Commit e1f5807

Browse files
Shahinyanmclaude
andcommitted
feat(v0.10.3): search & pack quality fixes from user feedback
Five bugs surfaced after a month of real Task Journal use: 1. CRITICAL — task_search "OPS-306" crashed with "no such column: 306" because FTS5 read `-` as column-prefix syntax. Affects every hyphenated ID, path, colon-prefixed token, and glob. New tj_core::fts::sanitize_query phrase-quotes any query containing `-` `:` `*` `(` `)` `"` `/`; multi-word queries pass through untouched so default AND-tokens semantics survive. Applied at both MCP task_search and CLI Search call sites (incl. --all-projects). 2. HIGH — search missed terms living only in event body when the unicode61 tokenizer split source text differently from the query. When sanitized FTS5 returns 0 hits, fall back to LIKE %query% against search_fts.text. Same fallback in MCP + CLI. 3. HIGH — pack truncation dropped the NEWEST decision (most-important summary) instead of the oldest. render_active_decisions / render_evidence / render_rejected now ORDER BY ... DESC so end-of-pack truncation hits older rows. Also bumped FULL_BUDGET 10K → 24K — real tasks accumulate 50–100 events with detailed text and 10K was clipping the summary even after the DESC reshuffle. 4. MEDIUM — added --type / event_type filter on search. `task-journal search "switching" --type decision` and the matching MCP param restrict matches to one EventType. SQL adds `AND type = ?2`. Composes with the LIKE fallback. 5. LOW — two PreCompact firings within 60 s used to double-write the "Conversation compacted at T" boundary marker (multi-plugin race, retried hook). Dedup check inspects the most recent decision event for the active task and skips the append if the text already starts with the marker prefix AND the prior write was within DEDUP_WINDOW_SECS = 60. Added: tj_core::fts module (sanitize_query, like_pattern) + 6 integration tests covering each bug. No schema changes — pack cache invalidates on next event so the new ordering surfaces organically without a forced rebuild. bd: claude-memory-3wg (epic), claude-memory-jrd, claude-memory-2pi, claude-memory-bo1, claude-memory-5e0, claude-memory-jaq. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f50d211 commit e1f5807

13 files changed

Lines changed: 715 additions & 39 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.10.3] - 2026-06-06
11+
12+
**Search & pack quality fixes from real user feedback.** Five bugs hit
13+
during a month-long session: FTS5 query crashes on hyphenated
14+
identifiers (`OPS-306``no such column: 306`), event-body search
15+
missing hits the tokenizer split differently than the query, pack
16+
truncation cutting the **newest** decision (most important) instead
17+
of the oldest, no way to filter search by event type, and duplicate
18+
"Conversation compacted" markers when PreCompact fires twice within
19+
the same second.
20+
21+
### Added
22+
- `tj_core::fts::sanitize_query` — phrase-quotes FTS5 metacharacters
23+
(`-` `:` `*` `(` `)` `"` `/`) so identifiers like `OPS-306`, paths
24+
like `src/main.rs`, and tokens like `ttl:30s` stop crashing the
25+
`search_fts MATCH` planner. Multi-word queries pass through
26+
unchanged so default AND semantics are preserved.
27+
- `tj_core::fts::like_pattern` — wraps a query as `%query%` for the
28+
LIKE-fallback path described below.
29+
- `--type <event_type>` flag on `task-journal search` and matching
30+
`event_type` field on the MCP `task_search` tool. Restricts hits
31+
to a single event class (`decision`, `evidence`, `finding`, ...).
32+
- LIKE fallback in both CLI `search` and MCP `task_search`: when the
33+
sanitized FTS5 phrase returns zero hits, the same query is rerun
34+
against `search_fts.text LIKE %query%`. Recovers cases where the
35+
unicode61 tokenizer split the source text differently from the
36+
query string.
37+
38+
### Changed
39+
- `render_active_decisions`, `render_evidence`, `render_rejected`
40+
now `ORDER BY ... DESC` (newest-first). The summary/final decision
41+
the agent records just before close lives at the *top* of its
42+
section so end-of-pack truncation drops the **oldest** rows, not
43+
the newest.
44+
- `FULL_BUDGET` bumped 10 KiB → 24 KiB. Real long-running tasks
45+
(50–100 events with detailed decision text) blew past 10 KiB after
46+
a couple of weeks and the budget was the binding constraint on
47+
what survived. 24 KiB still fits comfortably inside any modern
48+
LLM context window.
49+
50+
### Fixed
51+
- B1 (CRITICAL): `task_search "OPS-306"` no longer crashes with
52+
`MCP error -32603: no such column: 306`. Same fix covers all
53+
paths/colon-prefixed tokens/glob-shaped queries.
54+
- B2 (HIGH): event-body terms now reach the user via the LIKE
55+
fallback when an FTS5 token-split mismatch otherwise hides them.
56+
- B3 (HIGH): the **newest** decision is now the first line of the
57+
Active decisions section and survives truncation; the user's
58+
"final summary" pattern no longer gets clipped.
59+
- B5 (LOW): two `PreCompact` hook firings within 60 s no longer
60+
double-write the boundary marker. The dedup check inspects the
61+
most-recent decision event for the active task and skips the
62+
append if it already looks like a recent compaction marker.
63+
64+
### Migration
65+
- No schema changes. Existing tasks pick up the new ordering on the
66+
next pack render (cache is keyed by mode, not order, so callers
67+
may need to clear `task_pack_cache` once for visible effect — or
68+
wait for the next event to invalidate it organically).
69+
70+
### CLI / MCP API
71+
- CLI: `task-journal search <query> [--type TYPE]` is additive.
72+
- MCP `task_search`: new optional `event_type: Option<String>`
73+
parameter. Existing callers that omit it see no behavior change
74+
besides the FTS5 crash fix.
75+
1076
## [0.10.2] - 2026-06-02
1177

1278
**`watchPaths` + FileChanged → auto-evidence on marker file edits.** X4

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.10.2"
10+
version = "0.10.3"
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
@@ -16,7 +16,7 @@ name = "task-journal"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
tj-core = { package = "task-journal-core", version = "0.10.2", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.10.3", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
clap = { workspace = true }
2222
tracing = { workspace = true }

crates/tj-cli/src/main.rs

Lines changed: 128 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,10 @@ enum Commands {
700700
/// Search across all projects on this machine, not just the cwd one.
701701
#[arg(long)]
702702
all_projects: bool,
703+
/// v0.10.3+: restrict matches to a single event type
704+
/// (`decision`, `evidence`, `finding`, `rejection`, ...).
705+
#[arg(long = "type", value_name = "TYPE")]
706+
event_type: Option<String>,
703707
},
704708
/// Append a correction event referencing an earlier event_id.
705709
EventCorrect {
@@ -1738,6 +1742,45 @@ fn main() -> Result<()> {
17381742

17391743
// (2) Boundary marker.
17401744
let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1745+
1746+
// v0.10.3: dedupe near-duplicate markers. Two PreCompact
1747+
// hook firings within DEDUP_WINDOW_SECS — caused by
1748+
// multi-plugin race, rapid compact-then-restore, or a
1749+
// retried hook — both append "Conversation compacted at
1750+
// T" events with the same wall-clock second. Skip if
1751+
// the most recent decision event already carries this
1752+
// marker text and was written under a minute ago.
1753+
const DEDUP_WINDOW_SECS: i64 = 60;
1754+
let last_marker: Option<(String, String)> = conn
1755+
.query_row(
1756+
"SELECT ei.timestamp, COALESCE(sf.text, '') \
1757+
FROM events_index ei \
1758+
LEFT JOIN search_fts sf ON sf.event_id = ei.event_id \
1759+
WHERE ei.task_id = ?1 AND ei.type = 'decision' \
1760+
ORDER BY ei.timestamp DESC LIMIT 1",
1761+
rusqlite::params![&tc.task_id],
1762+
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
1763+
)
1764+
.ok();
1765+
if let Some((ts, text)) = last_marker {
1766+
if text.starts_with("Conversation compacted at") {
1767+
if let Ok(prev) = chrono::DateTime::parse_from_rfc3339(&ts) {
1768+
let delta = (chrono::Utc::now()
1769+
.signed_duration_since(prev.with_timezone(&chrono::Utc)))
1770+
.num_seconds();
1771+
if delta.abs() < DEDUP_WINDOW_SECS {
1772+
// Marker recently appended — skip the
1773+
// second one. Still print SOMETHING so
1774+
// hook callers see a stable exit shape;
1775+
// emit the previous event_id we'd have
1776+
// duplicated would not be available here
1777+
// without an extra query, so emit empty.
1778+
return Ok(());
1779+
}
1780+
}
1781+
}
1782+
}
1783+
17411784
let marker_text = format!(
17421785
"Conversation compacted at {now}; preceding events should be treated as a single reasoning unit."
17431786
);
@@ -2266,7 +2309,12 @@ fn main() -> Result<()> {
22662309
query,
22672310
limit,
22682311
all_projects,
2312+
event_type,
22692313
} => {
2314+
// v0.10.3: sanitize FTS5 query so hyphenated IDs / paths /
2315+
// colons no longer crash with "no such column" mid-search.
2316+
let fts_query = tj_core::fts::sanitize_query(&query);
2317+
let like_query = tj_core::fts::like_pattern(&query);
22702318
if all_projects {
22712319
let state_dir = tj_core::paths::state_dir()?;
22722320
let hashes = tj_core::db::list_all_projects(&state_dir)?;
@@ -2276,19 +2324,17 @@ fn main() -> Result<()> {
22762324
Ok(c) => c,
22772325
Err(_) => continue,
22782326
};
2279-
let mut stmt = match conn.prepare(
2280-
"SELECT DISTINCT task_id FROM search_fts WHERE search_fts MATCH ?1 LIMIT ?2"
2327+
let ids = match run_search(
2328+
&conn,
2329+
&fts_query,
2330+
&like_query,
2331+
event_type.as_deref(),
2332+
limit,
22812333
) {
2282-
Ok(s) => s,
2334+
Ok(v) => v,
22832335
Err(_) => continue,
22842336
};
2285-
let rows = match stmt.query_map(rusqlite::params![&query, limit as i64], |r| {
2286-
r.get::<_, String>(0)
2287-
}) {
2288-
Ok(r) => r,
2289-
Err(_) => continue,
2290-
};
2291-
for id in rows.flatten() {
2337+
for id in ids {
22922338
println!("{hash}\t{id}");
22932339
}
22942340
}
@@ -2304,14 +2350,7 @@ fn main() -> Result<()> {
23042350
if events_path.exists() {
23052351
tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?;
23062352
}
2307-
let mut stmt = conn.prepare(
2308-
"SELECT DISTINCT task_id FROM search_fts WHERE search_fts MATCH ?1 LIMIT ?2",
2309-
)?;
2310-
let ids: Vec<String> = stmt
2311-
.query_map(rusqlite::params![query, limit as i64], |r| {
2312-
r.get::<_, String>(0)
2313-
})?
2314-
.collect::<Result<_, _>>()?;
2353+
let ids = run_search(&conn, &fts_query, &like_query, event_type.as_deref(), limit)?;
23152354
for id in ids {
23162355
println!("{id}");
23172356
}
@@ -2634,6 +2673,77 @@ fn topic_is_fts_safe(topic: &str) -> bool {
26342673
.any(|c| matches!(c, '-' | '"' | '*' | ':' | '(' | ')'))
26352674
}
26362675

2676+
/// v0.10.3: shared search helper used by `Commands::Search` for both
2677+
/// the cwd and `--all-projects` paths. Runs the sanitized FTS5 MATCH
2678+
/// first; on zero hits, scans `search_fts.text` via `LIKE` so
2679+
/// hyphenated identifiers (e.g. `OPS-306`) and substrings missed by
2680+
/// the unicode61 tokenizer still surface.
2681+
fn run_search(
2682+
conn: &rusqlite::Connection,
2683+
fts_query: &str,
2684+
like_query: &str,
2685+
event_type: Option<&str>,
2686+
limit: usize,
2687+
) -> Result<Vec<String>> {
2688+
let (fts_sql, fts_uses_type) = match event_type {
2689+
Some(_) => (
2690+
"SELECT DISTINCT task_id FROM search_fts \
2691+
WHERE search_fts MATCH ?1 AND type = ?2 LIMIT ?3",
2692+
true,
2693+
),
2694+
None => (
2695+
"SELECT DISTINCT task_id FROM search_fts \
2696+
WHERE search_fts MATCH ?1 LIMIT ?2",
2697+
false,
2698+
),
2699+
};
2700+
let mut stmt = conn.prepare(fts_sql)?;
2701+
let ids: Vec<String> = if fts_uses_type {
2702+
let ty = event_type.unwrap();
2703+
stmt.query_map(rusqlite::params![fts_query, ty, limit as i64], |r| {
2704+
r.get::<_, String>(0)
2705+
})?
2706+
.collect::<rusqlite::Result<_>>()?
2707+
} else {
2708+
stmt.query_map(rusqlite::params![fts_query, limit as i64], |r| {
2709+
r.get::<_, String>(0)
2710+
})?
2711+
.collect::<rusqlite::Result<_>>()?
2712+
};
2713+
if !ids.is_empty() {
2714+
return Ok(ids);
2715+
}
2716+
2717+
let (like_sql, like_uses_type) = match event_type {
2718+
Some(_) => (
2719+
"SELECT DISTINCT task_id FROM search_fts \
2720+
WHERE text LIKE ?1 AND type = ?2 LIMIT ?3",
2721+
true,
2722+
),
2723+
None => (
2724+
"SELECT DISTINCT task_id FROM search_fts \
2725+
WHERE text LIKE ?1 LIMIT ?2",
2726+
false,
2727+
),
2728+
};
2729+
let mut stmt_like = conn.prepare(like_sql)?;
2730+
let ids_like: Vec<String> = if like_uses_type {
2731+
let ty = event_type.unwrap();
2732+
stmt_like
2733+
.query_map(rusqlite::params![like_query, ty, limit as i64], |r| {
2734+
r.get::<_, String>(0)
2735+
})?
2736+
.collect::<rusqlite::Result<_>>()?
2737+
} else {
2738+
stmt_like
2739+
.query_map(rusqlite::params![like_query, limit as i64], |r| {
2740+
r.get::<_, String>(0)
2741+
})?
2742+
.collect::<rusqlite::Result<_>>()?
2743+
};
2744+
Ok(ids_like)
2745+
}
2746+
26372747
fn run_rejected(topic: &str, all_projects: bool, limit: usize, since: Option<i64>) -> Result<()> {
26382748
let cutoff: Option<String> = since.map(|d| {
26392749
(chrono::Utc::now() - chrono::Duration::days(d))

0 commit comments

Comments
 (0)