use std::path::{Path, PathBuf};
use std::sync::Mutex;
use async_trait::async_trait;
use chrono::Utc;
use tracing::{debug, warn};
pub const SOUL_JOURNAL_OBSERVE_META_KEY: &str = "soul_journal_observe";
pub const SOUL_JOURNAL_SUBSTRATE_PREFIX: &str = "substrate/_derived/soul_journal";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DriftSignal {
UserCorrection {
matched: String,
},
Explicit {
source: String,
},
Synthetic,
}
impl DriftSignal {
pub fn label(&self) -> &'static str {
match self {
DriftSignal::UserCorrection { .. } => "user_correction",
DriftSignal::Explicit { .. } => "explicit",
DriftSignal::Synthetic => "synthetic",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DriftObservation {
pub entry_id: String,
pub summary: String,
pub content: String,
pub ts: String,
pub conv_id: String,
pub signal: DriftSignal,
}
impl DriftObservation {
pub fn synthetic(summary: impl Into<String>, content: impl Into<String>) -> Self {
Self {
entry_id: String::new(),
summary: summary.into(),
content: content.into(),
ts: String::new(),
conv_id: String::new(),
signal: DriftSignal::Synthetic,
}
}
pub fn finalized(&self) -> Self {
let mut out = self.clone();
if out.ts.is_empty() {
out.ts = Utc::now().to_rfc3339();
}
if out.entry_id.is_empty() {
let nanos = Utc::now().timestamp_nanos_opt().unwrap_or(0);
out.entry_id = format!("obs-{nanos:x}");
}
out
}
pub fn to_markdown(&self) -> String {
let o = self.finalized();
let signal_detail = match &o.signal {
DriftSignal::UserCorrection { matched } => format!("user_correction ({matched})"),
DriftSignal::Explicit { source } => format!("explicit ({source})"),
DriftSignal::Synthetic => "synthetic".to_string(),
};
let conv = if o.conv_id.is_empty() {
"unknown".to_string()
} else {
o.conv_id.clone()
};
format!(
"## {} [conversation {conv}]\n\
**Observation:** {}\n\
**Suggested update:** {}\n\
**Source:** {signal_detail}\n\
**Entry-id:** {}\n\n",
o.ts, o.summary, o.content, o.entry_id
)
}
pub fn to_substrate_value(&self) -> serde_json::Value {
let o = self.finalized();
serde_json::json!({
"summary": o.summary,
"content": o.content,
"ts": o.ts,
"conv_id": o.conv_id,
"signal": o.signal.label(),
})
}
}
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
pub trait SoulJournal: Send + Sync + 'static {
async fn append(&self, observation: DriftObservation) -> Result<(), String>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingJournalEntry {
pub entry_id: String,
pub summary: String,
pub content: String,
pub ts: String,
pub conv_id: String,
pub signal: String,
}
impl PendingJournalEntry {
pub fn from_substrate_value(entry_id: impl Into<String>, value: &serde_json::Value) -> Self {
let entry_id = entry_id.into();
let content = value
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let summary = value
.get("summary")
.and_then(|v| v.as_str())
.map(str::to_string)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
let first = content.lines().next().unwrap_or("").trim();
if first.len() > 80 {
format!("{}…", &first[..80])
} else if first.is_empty() {
entry_id.clone()
} else {
first.to_string()
}
});
Self {
entry_id,
summary,
content,
ts: value
.get("ts")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
conv_id: value
.get("conv_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
signal: value
.get("signal")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
}
}
pub fn from_observation(obs: &DriftObservation) -> Self {
let o = obs.finalized();
Self {
entry_id: o.entry_id,
summary: o.summary,
content: o.content,
ts: o.ts,
conv_id: o.conv_id,
signal: o.signal.label().to_string(),
}
}
}
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
pub trait SoulJournalReader: Send + Sync + 'static {
async fn list_pending(&self) -> Result<Vec<PendingJournalEntry>, String>;
}
#[derive(Debug, Default)]
pub struct InMemorySoulJournal {
entries: Mutex<Vec<DriftObservation>>,
}
impl InMemorySoulJournal {
pub fn new() -> Self {
Self::default()
}
pub fn entries(&self) -> Vec<DriftObservation> {
self.entries
.lock()
.map(|g| g.iter().map(|e| e.finalized()).collect())
.unwrap_or_default()
}
}
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
impl SoulJournal for InMemorySoulJournal {
async fn append(&self, observation: DriftObservation) -> Result<(), String> {
let finalized = observation.finalized();
self.entries
.lock()
.map_err(|e| format!("soul journal mutex poisoned: {e}"))?
.push(finalized);
Ok(())
}
}
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
impl SoulJournalReader for InMemorySoulJournal {
async fn list_pending(&self) -> Result<Vec<PendingJournalEntry>, String> {
Ok(self
.entries()
.into_iter()
.map(|o| PendingJournalEntry::from_observation(&o))
.collect())
}
}
pub struct FileSoulJournal {
journal_path: PathBuf,
}
impl FileSoulJournal {
pub fn for_workspace(workspace: impl Into<PathBuf>) -> Self {
let workspace = workspace.into();
Self {
journal_path: workspace.join(".clawft").join("SOUL.journal.md"),
}
}
pub fn with_path(path: impl Into<PathBuf>) -> Self {
Self {
journal_path: path.into(),
}
}
pub fn path(&self) -> &Path {
&self.journal_path
}
}
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
impl SoulJournal for FileSoulJournal {
async fn append(&self, observation: DriftObservation) -> Result<(), String> {
let block = observation.to_markdown();
if let Some(parent) = self.journal_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create journal dir {}: {e}", parent.display()))?;
}
if !self.journal_path.exists() {
let header = "# SOUL.journal\n\n\
Append-only journal of identity-drift observations. \
Promote via `weaver soul promote`.\n\n";
std::fs::write(&self.journal_path, header)
.map_err(|e| format!("seed journal {}: {e}", self.journal_path.display()))?;
}
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.journal_path)
.map_err(|e| format!("open journal {}: {e}", self.journal_path.display()))?;
file.write_all(block.as_bytes())
.map_err(|e| format!("append journal {}: {e}", self.journal_path.display()))?;
debug!(
path = %self.journal_path.display(),
entry_id = %observation.finalized().entry_id,
"soul journal: appended file entry"
);
Ok(())
}
}
pub struct CompositeSoulJournal {
primary: std::sync::Arc<dyn SoulJournal>,
mirror: Option<std::sync::Arc<dyn SoulJournal>>,
}
impl CompositeSoulJournal {
pub fn new(primary: std::sync::Arc<dyn SoulJournal>) -> Self {
Self {
primary,
mirror: None,
}
}
pub fn with_mirror(
primary: std::sync::Arc<dyn SoulJournal>,
mirror: std::sync::Arc<dyn SoulJournal>,
) -> Self {
Self {
primary,
mirror: Some(mirror),
}
}
}
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
impl SoulJournal for CompositeSoulJournal {
async fn append(&self, observation: DriftObservation) -> Result<(), String> {
let observation = observation.finalized();
let primary_result = self.primary.append(observation.clone()).await;
if let Some(ref mirror) = self.mirror
&& let Err(e) = mirror.append(observation).await
{
warn!(error = %e, "soul journal: mirror append failed (non-fatal)");
}
primary_result
}
}
const DRIFT_CUES: &[&str] = &[
"i would rather",
"please remember",
"going forward",
"from now on",
"stop doing",
"rather than",
"instead of",
"i'd rather",
"too verbose",
"too terse",
"please be",
"you should",
"i prefer",
"do not",
"prefer ",
"don't",
];
pub fn detect_drift_signal(
user_content: &str,
assistant_content: &str,
metadata: &std::collections::HashMap<String, serde_json::Value>,
) -> Option<(DriftSignal, String /* content body */)> {
if let Some(v) = metadata.get(SOUL_JOURNAL_OBSERVE_META_KEY) {
if let Some(body) = v.as_str() {
let body = body.trim();
if !body.is_empty() {
return Some((
DriftSignal::Explicit {
source: "metadata".into(),
},
body.to_string(),
));
}
} else if let Some(obj) = v.as_object() {
if let Some(body) = obj.get("content").and_then(|c| c.as_str()) {
let body = body.trim();
if !body.is_empty() {
return Some((
DriftSignal::Explicit {
source: "metadata".into(),
},
body.to_string(),
));
}
}
}
}
if assistant_content.trim().is_empty() {
return None;
}
let lower = user_content.to_ascii_lowercase();
for cue in DRIFT_CUES {
if lower.contains(cue) {
return Some((
DriftSignal::UserCorrection {
matched: (*cue).trim().to_string(),
},
format!(
"User correction cue `{cue}` in conversation. \
User said: {}\n\nAssistant replied: {}",
truncate(user_content, 400),
truncate(assistant_content, 400)
),
));
}
}
None
}
pub fn observation_from_signal(
signal: DriftSignal,
content: String,
conv_id: &str,
) -> DriftObservation {
let summary = summarize_content(&content);
DriftObservation {
entry_id: String::new(),
summary,
content,
ts: String::new(),
conv_id: conv_id.to_string(),
signal,
}
}
fn summarize_content(content: &str) -> String {
let first = content.lines().next().unwrap_or("").trim();
if first.len() > 80 {
format!("{}…", &first[..80])
} else if first.is_empty() {
"identity drift observation".into()
} else {
first.to_string()
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let truncated: String = s.chars().take(max).collect();
format!("{truncated}…")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Arc;
#[test]
fn heuristic_fires_on_prefer_cue() {
let meta = HashMap::new();
let hit = detect_drift_signal(
"Please don't use bullet lists; I prefer narrative paragraphs.",
"Got it — I'll stick to prose.",
&meta,
);
assert!(hit.is_some());
let (sig, body) = hit.unwrap();
match sig {
DriftSignal::UserCorrection { matched } => {
assert!(
matched == "don't" || matched == "i prefer" || matched == "prefer",
"unexpected cue: {matched}"
);
}
other => panic!("expected UserCorrection, got {other:?}"),
}
assert!(body.contains("User correction cue"));
}
#[test]
fn heuristic_skips_when_assistant_empty() {
let meta = HashMap::new();
let hit = detect_drift_signal("I prefer short answers.", "", &meta);
assert!(hit.is_none());
}
#[test]
fn heuristic_skips_neutral_user_message() {
let meta = HashMap::new();
let hit = detect_drift_signal("What is the weather?", "Sunny and 72°F.", &meta);
assert!(hit.is_none());
}
#[test]
fn explicit_metadata_overrides_heuristic() {
let mut meta = HashMap::new();
meta.insert(
SOUL_JOURNAL_OBSERVE_META_KEY.to_string(),
serde_json::json!("noticed bias toward over-apologizing"),
);
let hit = detect_drift_signal("hello", "hi there", &meta).expect("explicit");
assert_eq!(
hit.0,
DriftSignal::Explicit {
source: "metadata".into()
}
);
assert_eq!(hit.1, "noticed bias toward over-apologizing");
}
#[tokio::test]
async fn in_memory_journal_records_entries() {
let j = InMemorySoulJournal::new();
j.append(DriftObservation::synthetic(
"terse preference",
"user wants shorter answers",
))
.await
.unwrap();
let entries = j.entries();
assert_eq!(entries.len(), 1);
assert!(!entries[0].entry_id.is_empty());
assert!(!entries[0].ts.is_empty());
assert_eq!(entries[0].summary, "terse preference");
}
#[tokio::test]
async fn in_memory_journal_reader_lists_pending() {
let j = InMemorySoulJournal::new();
j.append(DriftObservation::synthetic("a", "body-a"))
.await
.unwrap();
j.append(DriftObservation::synthetic("b", "body-b"))
.await
.unwrap();
let pending = SoulJournalReader::list_pending(&j).await.unwrap();
assert_eq!(pending.len(), 2);
assert_eq!(pending[0].summary, "a");
assert_eq!(pending[1].content, "body-b");
assert_eq!(pending[0].signal, "synthetic");
}
#[test]
fn pending_entry_from_substrate_value_round_trip() {
let obs = DriftObservation::synthetic("sum", "full content body");
let value = obs.to_substrate_value();
let pending = PendingJournalEntry::from_substrate_value("01HZXTEST", &value);
assert_eq!(pending.entry_id, "01HZXTEST");
assert_eq!(pending.summary, "sum");
assert_eq!(pending.content, "full content body");
assert_eq!(pending.signal, "synthetic");
assert!(!pending.ts.is_empty());
}
#[test]
fn pending_entry_from_substrate_value_tolerates_sparse_payload() {
let value = serde_json::json!({ "content": "only content present" });
let pending = PendingJournalEntry::from_substrate_value("sparse-1", &value);
assert_eq!(pending.content, "only content present");
assert_eq!(pending.summary, "only content present");
assert!(pending.ts.is_empty());
assert!(pending.signal.is_empty());
}
#[tokio::test]
async fn file_journal_appends_markdown() {
let tmp = tempfile::tempdir().unwrap();
let j = FileSoulJournal::for_workspace(tmp.path());
let mut obs = DriftObservation::synthetic(
"noticed bias toward verbose answers",
"prefer narrative paragraphs over bullet lists",
);
obs.conv_id = "conv-test".into();
j.append(obs).await.unwrap();
let body = std::fs::read_to_string(j.path()).unwrap();
assert!(body.contains("# SOUL.journal"));
assert!(body.contains("noticed bias toward verbose answers"));
assert!(body.contains("**Entry-id:**"));
assert!(body.contains("conversation conv-test"));
}
#[tokio::test]
async fn composite_writes_primary_and_mirror() {
let primary = Arc::new(InMemorySoulJournal::new());
let mirror = Arc::new(InMemorySoulJournal::new());
let composite = CompositeSoulJournal::with_mirror(primary.clone(), mirror.clone());
composite
.append(DriftObservation::synthetic("s", "c"))
.await
.unwrap();
assert_eq!(primary.entries().len(), 1);
assert_eq!(mirror.entries().len(), 1);
assert_eq!(
primary.entries()[0].entry_id,
mirror.entries()[0].entry_id,
"composite must share finalized entry_id across writers"
);
}
#[test]
fn substrate_value_shape_matches_promote_decoder() {
let mut obs = DriftObservation::synthetic("summary line", "full body content");
obs.ts = "2026-04-27T12:00:00Z".into();
let v = obs.to_substrate_value();
assert_eq!(v["summary"], "summary line");
assert_eq!(v["content"], "full body content");
assert_eq!(v["ts"], "2026-04-27T12:00:00Z");
assert_eq!(v["signal"], "synthetic");
}
}