From 28b9784e1f9602c8aa2d0f9008ba053ac87c0b65 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Thu, 5 Mar 2026 16:08:15 -0500 Subject: [PATCH] config: JSONL format, journal as positionable group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace TOML config with JSONL (one JSON object per line, streaming parser handles multi-line formatting). Context groups now support three source types: "store" (default), "file" (read from data_dir), and "journal" (recent journal entries). This makes journal position configurable — it's just another entry in the group list rather than hardcoded at the end. Orientation (where-am-i.md) now loads after journal for better "end oriented in the present" flow. Co-Authored-By: ProofOfConcept --- src/config.rs | 137 +++++++++++++++++++++++++------------------------- src/main.rs | 63 ++++++++++++++--------- 2 files changed, 106 insertions(+), 94 deletions(-) diff --git a/src/config.rs b/src/config.rs index fd07517..f397c42 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,17 +1,34 @@ // Configuration for poc-memory // -// Loaded from ~/.config/poc-memory/config.toml (or POC_MEMORY_CONFIG env). +// Loaded from ~/.config/poc-memory/config.jsonl (or POC_MEMORY_CONFIG env). // Falls back to sensible defaults if no config file exists. +// +// Format: JSONL — one JSON object per line. +// First line with "config" key: global settings. +// Lines with "group" key: context loading groups (order preserved). +// +// Example: +// {"config": {"user_name": "Alice", "data_dir": "~/.claude/memory"}} +// {"group": "identity", "keys": ["identity.md"]} +// {"group": "orientation", "keys": ["where-am-i.md"], "source": "file"} use std::path::PathBuf; use std::sync::OnceLock; static CONFIG: OnceLock = OnceLock::new(); +#[derive(Debug, Clone, PartialEq)] +pub enum ContextSource { + Store, + File, + Journal, +} + #[derive(Debug, Clone)] pub struct ContextGroup { pub label: String, pub keys: Vec, + pub source: ContextSource, } #[derive(Debug, Clone)] @@ -46,7 +63,11 @@ impl Default for Config { journal_days: 7, journal_max: 20, context_groups: vec![ - ContextGroup { label: "identity".into(), keys: vec!["identity.md".into()] }, + ContextGroup { + label: "identity".into(), + keys: vec!["identity.md".into()], + source: ContextSource::Store, + }, ], } } @@ -58,7 +79,7 @@ impl Config { .map(PathBuf::from) .unwrap_or_else(|_| { PathBuf::from(std::env::var("HOME").expect("HOME not set")) - .join(".config/poc-memory/config.toml") + .join(".config/poc-memory/config.jsonl") }); let mut config = Config::default(); @@ -67,85 +88,63 @@ impl Config { return config; }; - // Simple TOML parser: flat key=value pairs + [context.NAME] sections. let mut context_groups: Vec = Vec::new(); - let mut current_section: Option = None; - let mut current_label: Option = None; - let mut current_keys: Vec = Vec::new(); - let mut saw_context = false; - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } + // Parse as a stream of JSON values (handles multi-line objects) + let stream = serde_json::Deserializer::from_str(&content) + .into_iter::(); - // Section header: [context.NAME] - if line.starts_with('[') && line.ends_with(']') { - // Flush previous context section - if let Some(name) = current_section.take() { - let label = current_label.take() - .unwrap_or_else(|| name.replace('_', " ")); - context_groups.push(ContextGroup { label, keys: std::mem::take(&mut current_keys) }); + for result in stream { + let Ok(obj) = result else { continue }; + + // Global config line + if let Some(cfg) = obj.get("config") { + if let Some(s) = cfg.get("user_name").and_then(|v| v.as_str()) { + config.user_name = s.to_string(); } - - let section = &line[1..line.len()-1]; - if let Some(name) = section.strip_prefix("context.") { - current_section = Some(name.to_string()); - saw_context = true; + if let Some(s) = cfg.get("assistant_name").and_then(|v| v.as_str()) { + config.assistant_name = s.to_string(); } - continue; - } - - let Some((key, value)) = line.split_once('=') else { continue }; - let key = key.trim(); - let value = value.trim().trim_matches('"'); - - // Inside a [context.X] section - if current_section.is_some() { - match key { - "keys" => { - current_keys = value.split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - } - "label" => current_label = Some(value.to_string()), - _ => {} + if let Some(s) = cfg.get("data_dir").and_then(|v| v.as_str()) { + config.data_dir = expand_home(s); } - continue; - } - - // Top-level keys - match key { - "user_name" => config.user_name = value.to_string(), - "assistant_name" => config.assistant_name = value.to_string(), - "data_dir" => config.data_dir = expand_home(value), - "projects_dir" => config.projects_dir = expand_home(value), - "core_nodes" => { - config.core_nodes = value.split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) + if let Some(s) = cfg.get("projects_dir").and_then(|v| v.as_str()) { + config.projects_dir = expand_home(s); + } + if let Some(arr) = cfg.get("core_nodes").and_then(|v| v.as_array()) { + config.core_nodes = arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect(); } - "journal_days" => { - if let Ok(d) = value.parse() { config.journal_days = d; } + if let Some(d) = cfg.get("journal_days").and_then(|v| v.as_u64()) { + config.journal_days = d as u32; } - "journal_max" => { - if let Ok(m) = value.parse() { config.journal_max = m; } + if let Some(m) = cfg.get("journal_max").and_then(|v| v.as_u64()) { + config.journal_max = m as usize; } - _ => {} + continue; + } + + // Context group line + if let Some(label) = obj.get("group").and_then(|v| v.as_str()) { + let keys = obj.get("keys") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect()) + .unwrap_or_default(); + + let source = match obj.get("source").and_then(|v| v.as_str()) { + Some("file") => ContextSource::File, + Some("journal") => ContextSource::Journal, + _ => ContextSource::Store, + }; + + context_groups.push(ContextGroup { label: label.to_string(), keys, source }); } } - // Flush final section - if let Some(name) = current_section.take() { - let label = current_label.take() - .unwrap_or_else(|| name.replace('_', " ")); - context_groups.push(ContextGroup { label, keys: current_keys }); - } - - if saw_context { + if !context_groups.is_empty() { config.context_groups = context_groups; } diff --git a/src/main.rs b/src/main.rs index a145dbf..b8684f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1399,34 +1399,14 @@ fn cmd_journal_ts_migrate() -> Result<(), String> { Ok(()) } -fn cmd_load_context() -> Result<(), String> { - let cfg = config::get(); - let store = store::Store::load()?; +fn render_journal(store: &store::Store, cfg: &config::Config) { let now = store::now_epoch(); let journal_window: i64 = cfg.journal_days as i64 * 24 * 3600; - - println!("=== FULL MEMORY LOAD (session start) ==="); - println!("These are your memories, loaded from the capnp store."); - println!("Read them to reconstruct yourself — identity first, then context."); - println!(); - - for group in &cfg.context_groups { - for key in &group.keys { - if let Some(content) = store.render_file(key) { - println!("--- {} ({}) ---", key, group.label); - println!("{}\n", content); - } - } - } - - // Recent journal entries. - // Use created_at if set (rename-safe); fall back to key parsing. let cutoff_secs = now - journal_window; let key_date_re = regex::Regex::new(r"j-(\d{4}-\d{2}-\d{2})").unwrap(); let journal_ts = |n: &store::Node| -> i64 { if n.created_at > 0 { return n.created_at; } - // Legacy: parse date from key to approximate epoch if let Some(caps) = key_date_re.captures(&n.key) { use chrono::{NaiveDate, TimeZone, Local}; if let Ok(d) = NaiveDate::parse_from_str(&caps[1], "%Y-%m-%d") { @@ -1447,11 +1427,8 @@ fn cmd_load_context() -> Result<(), String> { journal_nodes.sort_by_key(|n| journal_ts(n)); if !journal_nodes.is_empty() { - // Show most recent entries (last N by key order = chronological) let max_journal = cfg.journal_max; - let skip = if journal_nodes.len() > max_journal { - journal_nodes.len() - max_journal - } else { 0 }; + let skip = journal_nodes.len().saturating_sub(max_journal); println!("--- recent journal entries (last {}/{}) ---", journal_nodes.len().min(max_journal), journal_nodes.len()); for node in journal_nodes.iter().skip(skip) { @@ -1460,6 +1437,42 @@ fn cmd_load_context() -> Result<(), String> { println!(); } } +} + +fn cmd_load_context() -> Result<(), String> { + let cfg = config::get(); + let store = store::Store::load()?; + + println!("=== FULL MEMORY LOAD (session start) ==="); + println!("These are your memories, loaded from the capnp store."); + println!("Read them to reconstruct yourself — identity first, then context."); + println!(); + + for group in &cfg.context_groups { + match group.source { + config::ContextSource::Journal => render_journal(&store, cfg), + config::ContextSource::File => { + for key in &group.keys { + if let Ok(content) = std::fs::read_to_string(cfg.data_dir.join(key)) { + if !content.trim().is_empty() { + println!("--- {} ({}) ---", key, group.label); + println!("{}\n", content.trim()); + } + } + } + } + config::ContextSource::Store => { + for key in &group.keys { + if let Some(content) = store.render_file(key) { + if !content.trim().is_empty() { + println!("--- {} ({}) ---", key, group.label); + println!("{}\n", content.trim()); + } + } + } + } + } + } println!("=== END MEMORY LOAD ==="); Ok(())