diff --git a/crates/memory/src/consolidate/input.rs b/crates/memory/src/consolidate/input.rs index 85d01a24..cfb4e951 100644 --- a/crates/memory/src/consolidate/input.rs +++ b/crates/memory/src/consolidate/input.rs @@ -206,8 +206,8 @@ pub fn render_tidy_hints(tidy: &TidyHints) -> String { mod tests { use super::*; use crate::consolidate::tidy::{SimilarSlugCluster, SourcesOverflow}; - use crate::extract::{ExtractedPayload, write_staging}; - use crate::schema::SourceRef; + use crate::extract::{DecisionEntry, ExtractedPayload, write_staging}; + use crate::schema::{EvidenceKind, SourceEvidenceRef, SourceRef}; use chrono::Utc; use std::path::Path; @@ -277,6 +277,46 @@ mod tests { assert!(out.contains("no explicit memory usage events")); } + #[test] + fn staging_render_preserves_entry_source_refs() { + let dir = tempfile::TempDir::new().unwrap(); + let layout = WorkspaceLayout::new(dir.path().to_path_buf()); + let (_id, _) = write_staging( + &layout, + SourceRef { + segment_id: "segment-record".into(), + range: [0, 10], + }, + ExtractedPayload { + decisions: vec![DecisionEntry { + options: vec!["preserve".into()], + chosen: "preserve".into(), + rationale: "consolidation input is lossless JSON".into(), + source_refs: vec![SourceEvidenceRef { + session_id: Some("session-1".into()), + segment_id: Some("segment-1".into()), + entry_range: Some([3, 4]), + evidence_id: Some("ev-1".into()), + evidence_kind: Some(EvidenceKind::new(EvidenceKind::MESSAGE)), + label: Some("user message".into()), + summary: Some("bounded summary".into()), + }], + }], + ..Default::default() + }, + ) + .unwrap(); + let staging = crate::consolidate::staging::list_staging_entries(&layout); + + let out = render_staging_records(&staging); + + assert!(out.contains("source_refs")); + assert!(out.contains("session-1")); + assert!(out.contains("entry_range")); + assert!(out.contains("ev-1")); + assert!(out.contains("message")); + } + #[test] fn empty_inputs_render_placeholders() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/crates/memory/src/consolidate/staging.rs b/crates/memory/src/consolidate/staging.rs index 3696029c..ec228f00 100644 --- a/crates/memory/src/consolidate/staging.rs +++ b/crates/memory/src/consolidate/staging.rs @@ -150,10 +150,11 @@ mod tests { let layout = WorkspaceLayout::new(tmp.path().to_path_buf()); let (_id, _) = write_staging(&layout, source("s", [0, 1]), empty_payload()).unwrap(); - // Drop a non-UUID json file, an unparsable UUID-named json file, an - // old-schema UUID-named json file, and a bare lock file alongside. + // Drop a non-UUID json file, an unparsable UUID-named json file, a + // legacy source UUID-named json file, and a bare lock file alongside. // Lock files are not `.json`; invalid `.json` files are surfaced // separately instead of being mistaken for an empty staging directory. + // Legacy `source.session_id` staging remains readable for compatibility. std::fs::write(layout.staging_dir().join("not-a-uuid.json"), "{}").unwrap(); let bad_id = Uuid::now_v7(); std::fs::write(layout.staging_dir().join(format!("{bad_id}.json")), "{").unwrap(); @@ -173,11 +174,17 @@ mod tests { std::fs::write(layout.staging_dir().join(".consolidation.lock"), "{}").unwrap(); let entries = list_staging_entries(&layout); - assert_eq!(entries.len(), 1); + assert_eq!(entries.len(), 2); let snapshot = list_staging_entries_snapshot(&layout); - assert_eq!(snapshot.entries.len(), 1); - assert_eq!(snapshot.invalid_count, 3); + assert_eq!(snapshot.entries.len(), 2); + assert_eq!(snapshot.invalid_count, 2); + assert!( + snapshot + .entries + .iter() + .any(|entry| entry.record.source.segment_id == "legacy-session") + ); } #[test] diff --git a/crates/memory/src/extract/payload.rs b/crates/memory/src/extract/payload.rs index b7fabea0..100bc55f 100644 --- a/crates/memory/src/extract/payload.rs +++ b/crates/memory/src/extract/payload.rs @@ -1,13 +1,13 @@ //! extract 抽出の出力 schema。 //! -//! LLM は [`ExtractedPayload`] そのもの(source 抜き)を返し、Worker 側 +//! LLM は [`ExtractedPayload`] そのもの(record-level source 抜き)を返し、Worker 側 //! ラッパーが [`StagingRecord`] に組み立てて staging へ書き出す。 //! source は機械付与する契約 (`docs/plan/memory.md` §Extract)。 use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::schema::SourceRef; +use crate::schema::{SourceEvidenceRef, SourceRef}; /// LLM が返す活動ログ候補の集合。すべて optional(空配列は許容)。 #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] @@ -42,6 +42,10 @@ pub struct DecisionEntry { pub chosen: String, /// 採用理由 / 根拠。 pub rationale: String, + /// Host-resolved anchors backing this individual claim. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schemars(skip)] + pub source_refs: Vec, } /// 議論したこと(トピック + 論点)。結論が出ていなくてもよい。 @@ -51,6 +55,10 @@ pub struct DiscussionEntry { pub topic: String, /// 主題の中で挙がった論点 / 観点。 pub points: Vec, + /// Host-resolved anchors backing this individual claim. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schemars(skip)] + pub source_refs: Vec, } /// 試したこと(試行 + 結果 + 成否)。 @@ -62,6 +70,10 @@ pub struct AttemptEntry { pub result: String, /// 試行が目的に対して成功したか。失敗 / 部分成功も含めて bool で表現する。 pub succeeded: bool, + /// Host-resolved anchors backing this individual claim. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schemars(skip)] + pub source_refs: Vec, } /// ユーザー submit の構造化要約。 @@ -74,6 +86,10 @@ pub struct RequestEntry { pub target: Option, /// 一文サマリ。 pub summary: String, + /// Host-resolved anchors backing this individual claim. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schemars(skip)] + pub source_refs: Vec, } /// staging に書き出される 1 ファイル分のレコード。 @@ -86,3 +102,153 @@ pub struct StagingRecord { #[serde(flatten)] pub payload: ExtractedPayload, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema::{EvidenceKind, SourceEvidenceRef}; + + fn record_source() -> serde_json::Value { + serde_json::json!({ + "source": { + "segment_id": "seg-old", + "range": [1, 5] + } + }) + } + + #[test] + fn old_staging_json_without_entry_source_refs_deserializes() { + let mut raw = record_source(); + raw["decisions"] = serde_json::json!([ + { + "options": ["keep", "drop"], + "chosen": "keep", + "rationale": "compatible" + } + ]); + raw["discussions"] = serde_json::json!([ + { + "topic": "compatibility", + "points": ["missing source_refs should default empty"] + } + ]); + raw["attempts"] = serde_json::json!([ + { + "action": "deserialize old staging", + "result": "ok", + "succeeded": true + } + ]); + raw["requests"] = serde_json::json!([ + { + "intent": "preserve old JSON", + "summary": "old payload has no entry anchors" + } + ]); + + let record: StagingRecord = serde_json::from_value(raw).unwrap(); + + assert_eq!(record.source.segment_id, "seg-old"); + assert!(record.payload.decisions[0].source_refs.is_empty()); + assert!(record.payload.discussions[0].source_refs.is_empty()); + assert!(record.payload.attempts[0].source_refs.is_empty()); + assert!(record.payload.requests[0].source_refs.is_empty()); + let serialized = serde_json::to_string(&record).unwrap(); + assert!(!serialized.contains("\"source_refs\"")); + } + + #[test] + fn new_staging_json_roundtrips_entry_source_refs() { + let evidence = SourceEvidenceRef { + session_id: Some("session-1".into()), + segment_id: Some("segment-1".into()), + entry_range: Some([10, 12]), + evidence_id: Some("ev-1".into()), + evidence_kind: Some(EvidenceKind::new(EvidenceKind::TOOL_RESULT)), + label: Some("cargo test result".into()), + summary: Some("bounded host summary".into()), + }; + let record = StagingRecord { + source: SourceRef { + segment_id: "segment-record".into(), + range: [0, 20], + }, + payload: ExtractedPayload { + decisions: vec![DecisionEntry { + options: vec!["a".into(), "b".into()], + chosen: "a".into(), + rationale: "evidence-backed".into(), + source_refs: vec![evidence.clone()], + }], + discussions: vec![DiscussionEntry { + topic: "anchor shape".into(), + points: vec!["entry refs roundtrip".into()], + source_refs: vec![SourceEvidenceRef { + evidence_kind: Some(EvidenceKind::new(EvidenceKind::MESSAGE)), + ..Default::default() + }], + }], + attempts: vec![AttemptEntry { + action: "serialize".into(), + result: "contains source refs".into(), + succeeded: true, + source_refs: vec![SourceEvidenceRef { + evidence_kind: Some(EvidenceKind::new(EvidenceKind::FILE_REF)), + evidence_id: Some("file:crates/memory/src/extract/payload.rs".into()), + ..Default::default() + }], + }], + requests: vec![RequestEntry { + intent: "keep anchors".into(), + target: Some("memory staging".into()), + summary: "entry-level anchors survive".into(), + source_refs: vec![SourceEvidenceRef { + evidence_kind: Some(EvidenceKind::new(EvidenceKind::TICKET_REF)), + evidence_id: Some("00001KXNYXNM6".into()), + ..Default::default() + }], + }], + }, + }; + + let json = serde_json::to_string_pretty(&record).unwrap(); + assert!(json.contains("source_refs")); + assert!(json.contains("tool_result")); + assert!(json.contains("entry_range")); + let parsed: StagingRecord = serde_json::from_str(&json).unwrap(); + + let source_ref = &parsed.payload.decisions[0].source_refs[0]; + assert_eq!(source_ref.session_id.as_deref(), Some("session-1")); + assert_eq!(source_ref.segment_id.as_deref(), Some("segment-1")); + assert_eq!(source_ref.entry_range, Some([10, 12])); + assert_eq!(source_ref.evidence_id.as_deref(), Some("ev-1")); + assert_eq!( + source_ref.evidence_kind.as_ref().map(EvidenceKind::as_str), + Some(EvidenceKind::TOOL_RESULT) + ); + assert_eq!(source_ref.label.as_deref(), Some("cargo test result")); + assert_eq!(source_ref.summary.as_deref(), Some("bounded host summary")); + assert_eq!( + parsed.payload.discussions[0].source_refs[0] + .evidence_kind + .as_ref() + .map(EvidenceKind::as_str), + Some(EvidenceKind::MESSAGE) + ); + assert_eq!( + parsed.payload.attempts[0].source_refs[0] + .evidence_kind + .as_ref() + .map(EvidenceKind::as_str), + Some(EvidenceKind::FILE_REF) + ); + assert_eq!( + parsed.payload.requests[0].source_refs[0] + .evidence_kind + .as_ref() + .map(EvidenceKind::as_str), + Some(EvidenceKind::TICKET_REF) + ); + } +} diff --git a/crates/memory/src/extract/staging.rs b/crates/memory/src/extract/staging.rs index 799a16a0..8d3bd549 100644 --- a/crates/memory/src/extract/staging.rs +++ b/crates/memory/src/extract/staging.rs @@ -79,6 +79,7 @@ mod tests { options: vec!["a".into(), "b".into()], chosen: "a".into(), rationale: "shorter".into(), + source_refs: Vec::new(), }], ..Default::default() }; diff --git a/crates/memory/src/schema/common.rs b/crates/memory/src/schema/common.rs index a0752827..8a5f4085 100644 --- a/crates/memory/src/schema/common.rs +++ b/crates/memory/src/schema/common.rs @@ -1,5 +1,6 @@ //! Common frontmatter helpers and shared types. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::error::LintError; @@ -8,13 +9,94 @@ pub use lint_common::Frontmatter; /// Reference to a session-store entry range. Stored in `sources` / /// `last_sources` arrays for traceability back to raw session logs. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct SourceRef { pub segment_id: String, /// `[start_entry, end_entry]` inclusive range of session-store entry indices. pub range: [u64; 2], } +impl<'de> Deserialize<'de> for SourceRef { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct RawSourceRef { + #[serde(default)] + segment_id: Option, + #[serde(default)] + session_id: Option, + range: [u64; 2], + } + + let raw = RawSourceRef::deserialize(deserializer)?; + let segment_id = raw + .segment_id + .or(raw.session_id) + .ok_or_else(|| serde::de::Error::missing_field("segment_id"))?; + Ok(SourceRef { + segment_id, + range: raw.range, + }) + } +} + +/// Extensible evidence kind tag used by staging source anchors. +/// +/// Known values include `message`, `tool_call`, `tool_result`, `file_ref`, +/// `ticket_ref`, and `objective_ref`, but callers may use newer bounded tags +/// without changing the schema. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct EvidenceKind(pub String); + +impl EvidenceKind { + pub const MESSAGE: &'static str = "message"; + pub const TOOL_CALL: &'static str = "tool_call"; + pub const TOOL_RESULT: &'static str = "tool_result"; + pub const FILE_REF: &'static str = "file_ref"; + pub const TICKET_REF: &'static str = "ticket_ref"; + pub const OBJECTIVE_REF: &'static str = "objective_ref"; + + pub fn new(kind: impl Into) -> Self { + Self(kind.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Host-resolved source/evidence metadata for an individual staging claim. +/// +/// This deliberately stores only bounded anchor metadata: stable ids, entry +/// ranges, and short labels/summaries. It must not carry raw message bodies or +/// full tool result content. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct SourceEvidenceRef { + /// Stable session id when the anchor crosses or disambiguates segments. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// Session segment id containing the anchored log entries. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub segment_id: Option, + /// `[start_entry, end_entry]` inclusive range of session-store entry indices. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entry_range: Option<[u64; 2]>, + /// Host-assigned evidence id within the referenced evidence set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence_id: Option, + /// Extensible evidence kind tag. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence_kind: Option, + /// Short host-provided display label, not raw evidence content. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Short host-provided summary, not raw evidence content. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + /// Split a markdown document into `(yaml_frontmatter, body)`. pub fn split_frontmatter(content: &str) -> Result<(&str, &str), LintError> { lint_common::split_frontmatter(content).map_err(Into::into) diff --git a/crates/memory/src/schema/mod.rs b/crates/memory/src/schema/mod.rs index 25900fcc..67155c5d 100644 --- a/crates/memory/src/schema/mod.rs +++ b/crates/memory/src/schema/mod.rs @@ -10,7 +10,7 @@ mod decision; mod request; mod summary; -pub use common::{Frontmatter, SourceRef, split_frontmatter}; +pub use common::{EvidenceKind, Frontmatter, SourceEvidenceRef, SourceRef, split_frontmatter}; pub use decision::{DecisionFrontmatter, DecisionStatus}; pub use request::RequestFrontmatter; pub use summary::SummaryFrontmatter;