diff --git a/Cargo.lock b/Cargo.lock index 5b3f2c2..1e89c27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,7 +415,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "herdr-context" -version = "0.19.3" +version = "0.19.4" dependencies = [ "cap-fs-ext", "cap-primitives", diff --git a/Cargo.toml b/Cargo.toml index 47c4d18..76c4ce5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "herdr-context" -version = "0.19.3" +version = "0.19.4" edition = "2024" rust-version = "1.97.1" autobenches = false diff --git a/README.md b/README.md index 6cf12ab..eaabaed 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ test suite. ## Release status -Version `0.19.3` is the current release line. Tag `v0.19.3`, Cargo +Version `0.19.4` is the current release line. Tag `v0.19.4`, Cargo metadata, both manifests, and the minimum Herdr version are validated together; pushing a `v*` tag runs formatting, Clippy, the full test suite, and the contract checks, then publishes generated GitHub release notes. diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 63d4506..0848bf9 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "herdr-context" name = "herdr-context" -version = "0.19.3" +version = "0.19.4" min_herdr_version = "0.8.0" description = "Per-tab project context dock" platforms = ["linux", "macos"] diff --git a/src/conversations/sources/claude_code.rs b/src/conversations/sources/claude_code.rs index 1c32bab..025199f 100644 --- a/src/conversations/sources/claude_code.rs +++ b/src/conversations/sources/claude_code.rs @@ -7,8 +7,8 @@ use serde::de::IgnoredAny; use super::known_stores::{ EntryKind, FormatFailure, KnownFormat, KnownJsonlSource, KnownStore, MAX_CANDIDATE_PATHS, ParseOutcome, ParsedMetadata, PendingMetadata, canonical_cwd, claude_project_directory, - parse_rfc3339, push_inventory_error, push_listing_error, push_shape_error, - validate_tool_version, validate_uuid, + normalize_metadata_title, parse_rfc3339, push_inventory_error, push_listing_error, + push_shape_error, validate_tool_version, validate_uuid, }; use super::{ ConversationCandidate, ConversationSource, ConversationSourceError, DiscoveryBatch, @@ -114,9 +114,9 @@ impl KnownFormat for ClaudeCodeFormat { } fn adapter_revision(&self) -> u32 { - // 3: current and future auxiliary metadata plus descendant cwd changes - // are accepted, so cached rejections from revision 2 must be revisited. - 3 + // 4: Claude titles are now extracted, so cached title-less metadata + // from revision 3 must be revisited. + 4 } fn list_candidates( @@ -260,6 +260,9 @@ impl KnownFormat for ClaudeCodeFormat { validate_uuid(expected_id, 4)?; let mut session_id = previous.map(|metadata| metadata.session_id.clone()); + let mut title = previous + .and_then(|metadata| metadata.title.clone()) + .or_else(|| previous_pending.and_then(|metadata| metadata.title.clone())); let mut created_at = previous .map(|metadata| metadata.created_at) .or_else(|| previous_pending.and_then(|metadata| metadata.created_at)); @@ -460,10 +463,12 @@ impl KnownFormat for ClaudeCodeFormat { } "ai-title" => { validate_claude_session(&record, expected_id)?; - if record.ai_title.is_none() { - return Err(FormatFailure::unsupported( - "Claude ai-title record is missing its title", - )); + if title.is_none() { + title = record + .ai_title + .as_ref() + .and_then(serde_json::Value::as_str) + .and_then(normalize_metadata_title); } } "queue-operation" => { @@ -484,17 +489,24 @@ impl KnownFormat for ClaudeCodeFormat { updated_at = Some(updated_at.map_or(timestamp, |current| current.max(timestamp))); } - "mode" | "custom-title" | "agent-name" | "agent-setting" => { + "custom-title" => { + validate_claude_session(&record, expected_id)?; + if let Some(native_title) = record + .custom_title + .as_ref() + .and_then(serde_json::Value::as_str) + .and_then(normalize_metadata_title) + { + title = Some(native_title); + } + } + "mode" | "agent-name" | "agent-setting" => { validate_claude_session(&record, expected_id)?; let (present, message) = match record.kind.as_str() { "mode" => ( record.mode.is_some(), "Claude mode record is missing its mode", ), - "custom-title" => ( - record.custom_title.is_some(), - "Claude custom-title record is missing its title", - ), "agent-name" => ( record.agent_name.is_some(), "Claude agent-name record is missing its name", @@ -532,6 +544,7 @@ impl KnownFormat for ClaudeCodeFormat { let Some(session_id) = session_id else { return Ok(ParseOutcome::IdentityPending(PendingMetadata { + title, created_at, updated_at, record_count, @@ -539,7 +552,7 @@ impl KnownFormat for ClaudeCodeFormat { }; Ok(ParseOutcome::Metadata(ParsedMetadata { session_id, - title: None, + title, created_at: created_at.ok_or_else(|| { FormatFailure::unsupported("Claude JSONL contains no current timestamp") })?, @@ -584,10 +597,10 @@ struct ClaudeRecord { #[serde(rename = "permissionMode")] permission_mode: Option, #[serde(rename = "aiTitle")] - ai_title: Option, + ai_title: Option, mode: Option, #[serde(rename = "customTitle")] - custom_title: Option, + custom_title: Option, #[serde(rename = "agentName")] agent_name: Option, #[serde(rename = "agentSetting")] diff --git a/src/conversations/sources/known_stores.rs b/src/conversations/sources/known_stores.rs index d1035a3..1ec7fc0 100644 --- a/src/conversations/sources/known_stores.rs +++ b/src/conversations/sources/known_stores.rs @@ -37,6 +37,15 @@ const MAX_RECORDS: usize = 32_768; const MAX_WATERMARK_BYTES: usize = 4 * 1024 * 1024; const MAX_METADATA_TITLE_BYTES: usize = 256; +const fn valid_metadata_title(value: &str) -> bool { + value.len() <= MAX_METADATA_TITLE_BYTES +} + +pub(super) fn normalize_metadata_title(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty() && valid_metadata_title(value)).then(|| value.to_owned()) +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct KnownStoreRoots { claude_code: PathBuf, @@ -1104,6 +1113,7 @@ pub(super) struct ParsedMetadata { #[derive(Clone, Debug)] pub(super) struct PendingMetadata { + pub(super) title: Option, pub(super) created_at: Option, pub(super) updated_at: Option, pub(super) record_count: u64, @@ -1201,6 +1211,8 @@ impl StoredRejection { #[derive(Clone, Debug, Deserialize, Serialize)] struct StoredPendingMetadata { + #[serde(default)] + title: Option, created_at: Option<(bool, u64, u32)>, updated_at: Option<(bool, u64, u32)>, record_count: u64, @@ -1209,6 +1221,7 @@ struct StoredPendingMetadata { impl StoredPendingMetadata { fn from_pending(metadata: &PendingMetadata) -> Self { Self { + title: metadata.title.clone(), created_at: metadata.created_at.map(system_time_parts), updated_at: metadata.updated_at.map(system_time_parts), record_count: metadata.record_count, @@ -1217,6 +1230,10 @@ impl StoredPendingMetadata { fn to_pending(&self) -> Option { if self.record_count == 0 + || self + .title + .as_deref() + .is_some_and(|title| !valid_metadata_title(title)) || self .created_at .is_some_and(|(_, _, nanos)| nanos >= 1_000_000_000) @@ -1236,6 +1253,7 @@ impl StoredPendingMetadata { return None; } Some(PendingMetadata { + title: self.title.clone(), created_at, updated_at, record_count: self.record_count, @@ -1279,8 +1297,8 @@ impl StoredMetadata { || chain_updated_at.2 >= 1_000_000_000 || self .title - .as_ref() - .is_some_and(|title| title.len() > MAX_METADATA_TITLE_BYTES) + .as_deref() + .is_some_and(|title| !valid_metadata_title(title)) { return None; } diff --git a/tests/conversation_external_discovery.rs b/tests/conversation_external_discovery.rs index f8906f2..1830d54 100644 --- a/tests/conversation_external_discovery.rs +++ b/tests/conversation_external_discovery.rs @@ -830,6 +830,79 @@ fn claude_adapter_revision_revisits_revision_two_watermarks() { assert!(refreshed.errors().is_empty(), "{:?}", refreshed.errors()); } +#[test] +fn claude_title_revision_revisits_cached_titleless_metadata() { + let (_project_dir, project) = project(); + let home = TempDir::new().expect("home"); + let session_id = "11111111-1111-4111-8111-111111111111"; + let destination = home + .path() + .join(".claude/projects/custom-project-key") + .join(format!("{session_id}.jsonl")); + fs::create_dir_all(destination.parent().expect("fixture parent")).expect("store"); + let transcript = format!( + "{}\n{}", + serde_json::json!({ + "type": "ai-title", + "aiTitle": "generated cached title", + "sessionId": session_id, + }), + fixture_text( + "claude-code/-workspace-project/11111111-1111-4111-8111-111111111111.jsonl", + &project, + ) + ); + fs::write(destination, transcript).expect("Claude fixture"); + let source = ClaudeCodeSource::new(project.clone(), home.path().join(".claude/projects")) + .expect("Claude source"); + let first = source + .discover( + &project, + None, + DiscoveryLimit::new(8).expect("discovery limit"), + ) + .expect("initial discovery"); + let mut revision_three = serde_json::from_str::( + first.next_watermark().expect("watermark").token(), + ) + .expect("watermark JSON"); + for entry in revision_three + .as_object_mut() + .expect("watermark map") + .values_mut() + { + let entry = entry.as_object_mut().expect("watermark entry"); + entry.insert("adapter_revision".to_owned(), serde_json::json!(3)); + entry + .get_mut("summary") + .and_then(serde_json::Value::as_object_mut) + .expect("stored summary") + .insert("title".to_owned(), serde_json::Value::Null); + } + let revision_three = SourceWatermark::new( + source.source_id().clone(), + serde_json::to_string(&revision_three).expect("revision three watermark"), + ) + .expect("source watermark"); + + let refreshed = source + .discover( + &project, + Some(&revision_three), + DiscoveryLimit::new(8).expect("discovery limit"), + ) + .expect("refreshed discovery"); + + assert_eq!(refreshed.candidates().len(), 1); + let conversation = source + .extract_metadata( + &refreshed.candidates()[0], + MetadataBudget::new(512 * 1024).expect("metadata budget"), + ) + .expect("refreshed metadata"); + assert_eq!(conversation.title(), Some("generated cached title")); +} + #[test] fn global_store_overrides_resolve_without_home_scans() { let home = TempDir::new().expect("home"); @@ -890,7 +963,22 @@ fn claude_accepts_current_auxiliary_record_shapes() { }), serde_json::json!({ "type": "custom-title", - "customTitle": "sanitized title", + "customTitle": 42, + "sessionId": session_id, + }), + serde_json::json!({ + "type": "ai-title", + "aiTitle": " ", + "sessionId": session_id, + }), + serde_json::json!({ + "type": "ai-title", + "aiTitle": "generated fallback title", + "sessionId": session_id, + }), + serde_json::json!({ + "type": "custom-title", + "customTitle": "custom conversation title", "sessionId": session_id, }), serde_json::json!({ @@ -936,7 +1024,7 @@ fn claude_accepts_current_auxiliary_record_shapes() { }), serde_json::json!({ "type": "ai-title", - "aiTitle": "sanitized title", + "aiTitle": "generated conversation title", "sessionId": session_id, }), serde_json::json!({ @@ -966,10 +1054,9 @@ fn claude_accepts_current_auxiliary_record_shapes() { let source = ClaudeCodeSource::new(project.clone(), home.path().join(".claude/projects")) .expect("Claude source"); - assert_eq!( - discover_one(&source, &project).session_reference().id(), - session_id - ); + let conversation = discover_one(&source, &project); + assert_eq!(conversation.session_reference().id(), session_id); + assert_eq!(conversation.title(), Some("custom conversation title")); } #[test] @@ -1030,6 +1117,14 @@ fn claude_resumes_after_a_page_of_unknown_auxiliary_records() { "attachment": {"type": "goal_status", "met": false}, }) )); + transcript.push_str(&format!( + "{}\n", + serde_json::json!({ + "type": "ai-title", + "aiTitle": "generated paginated title", + "sessionId": session_id, + }) + )); while transcript.len() <= 512 * 1024 { transcript.push_str(&auxiliary); } @@ -1071,6 +1166,7 @@ fn claude_resumes_after_a_page_of_unknown_auxiliary_records() { conversation.created_at(), Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_767_139_200)) ); + assert_eq!(conversation.title(), Some("generated paginated title")); } #[test] diff --git a/tests/test_release.py b/tests/test_release.py index ccba7ab..c742db4 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -19,15 +19,15 @@ class ReleaseContractTests(unittest.TestCase): def test_repository_contract_is_release_ready(self) -> None: contract = release.validate_repository(ROOT) - self.assertEqual(contract.version, "0.19.3") + self.assertEqual(contract.version, "0.19.4") self.assertEqual(contract.min_herdr_version, "0.8.0") self.assertEqual(len(contract.performance_metrics), 12) def test_trigger_tag_must_exactly_match_cargo_version(self) -> None: contract = release.validate_repository(ROOT) - release.validate_trigger_tag(contract, "v0.19.3") - with self.assertRaisesRegex(release.ReleaseError, "exactly v0.19.3"): + release.validate_trigger_tag(contract, "v0.19.4") + with self.assertRaisesRegex(release.ReleaseError, "exactly v0.19.4"): release.validate_trigger_tag(contract, "vtest") def test_failed_budget_requires_complete_risk_acceptance(self) -> None: