config: JSONL format, journal as positionable group

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 <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-03-05 16:08:15 -05:00
parent f3492e70a0
commit 28b9784e1f
2 changed files with 106 additions and 94 deletions

View file

@ -1,17 +1,34 @@
// Configuration for poc-memory // 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. // 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::path::PathBuf;
use std::sync::OnceLock; use std::sync::OnceLock;
static CONFIG: OnceLock<Config> = OnceLock::new(); static CONFIG: OnceLock<Config> = OnceLock::new();
#[derive(Debug, Clone, PartialEq)]
pub enum ContextSource {
Store,
File,
Journal,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ContextGroup { pub struct ContextGroup {
pub label: String, pub label: String,
pub keys: Vec<String>, pub keys: Vec<String>,
pub source: ContextSource,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -46,7 +63,11 @@ impl Default for Config {
journal_days: 7, journal_days: 7,
journal_max: 20, journal_max: 20,
context_groups: vec![ 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) .map(PathBuf::from)
.unwrap_or_else(|_| { .unwrap_or_else(|_| {
PathBuf::from(std::env::var("HOME").expect("HOME not set")) 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(); let mut config = Config::default();
@ -67,85 +88,63 @@ impl Config {
return config; return config;
}; };
// Simple TOML parser: flat key=value pairs + [context.NAME] sections.
let mut context_groups: Vec<ContextGroup> = Vec::new(); let mut context_groups: Vec<ContextGroup> = Vec::new();
let mut current_section: Option<String> = None;
let mut current_label: Option<String> = None;
let mut current_keys: Vec<String> = Vec::new();
let mut saw_context = false;
for line in content.lines() { // Parse as a stream of JSON values (handles multi-line objects)
let line = line.trim(); let stream = serde_json::Deserializer::from_str(&content)
if line.is_empty() || line.starts_with('#') { .into_iter::<serde_json::Value>();
continue;
}
// Section header: [context.NAME] for result in stream {
if line.starts_with('[') && line.ends_with(']') { let Ok(obj) = result else { continue };
// Flush previous context section
if let Some(name) = current_section.take() { // Global config line
let label = current_label.take() if let Some(cfg) = obj.get("config") {
.unwrap_or_else(|| name.replace('_', " ")); if let Some(s) = cfg.get("user_name").and_then(|v| v.as_str()) {
context_groups.push(ContextGroup { label, keys: std::mem::take(&mut current_keys) }); config.user_name = s.to_string();
} }
if let Some(s) = cfg.get("assistant_name").and_then(|v| v.as_str()) {
let section = &line[1..line.len()-1]; config.assistant_name = s.to_string();
if let Some(name) = section.strip_prefix("context.") {
current_section = Some(name.to_string());
saw_context = true;
} }
continue; if let Some(s) = cfg.get("data_dir").and_then(|v| v.as_str()) {
} config.data_dir = expand_home(s);
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()),
_ => {}
} }
continue; if let Some(s) = cfg.get("projects_dir").and_then(|v| v.as_str()) {
} config.projects_dir = expand_home(s);
}
// Top-level keys if let Some(arr) = cfg.get("core_nodes").and_then(|v| v.as_array()) {
match key { config.core_nodes = arr.iter()
"user_name" => config.user_name = value.to_string(), .filter_map(|v| v.as_str().map(|s| s.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())
.collect(); .collect();
} }
"journal_days" => { if let Some(d) = cfg.get("journal_days").and_then(|v| v.as_u64()) {
if let Ok(d) = value.parse() { config.journal_days = d; } config.journal_days = d as u32;
} }
"journal_max" => { if let Some(m) = cfg.get("journal_max").and_then(|v| v.as_u64()) {
if let Ok(m) = value.parse() { config.journal_max = m; } 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 !context_groups.is_empty() {
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 {
config.context_groups = context_groups; config.context_groups = context_groups;
} }

View file

@ -1399,34 +1399,14 @@ fn cmd_journal_ts_migrate() -> Result<(), String> {
Ok(()) Ok(())
} }
fn cmd_load_context() -> Result<(), String> { fn render_journal(store: &store::Store, cfg: &config::Config) {
let cfg = config::get();
let store = store::Store::load()?;
let now = store::now_epoch(); let now = store::now_epoch();
let journal_window: i64 = cfg.journal_days as i64 * 24 * 3600; 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 cutoff_secs = now - journal_window;
let key_date_re = regex::Regex::new(r"j-(\d{4}-\d{2}-\d{2})").unwrap(); let key_date_re = regex::Regex::new(r"j-(\d{4}-\d{2}-\d{2})").unwrap();
let journal_ts = |n: &store::Node| -> i64 { let journal_ts = |n: &store::Node| -> i64 {
if n.created_at > 0 { return n.created_at; } 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) { if let Some(caps) = key_date_re.captures(&n.key) {
use chrono::{NaiveDate, TimeZone, Local}; use chrono::{NaiveDate, TimeZone, Local};
if let Ok(d) = NaiveDate::parse_from_str(&caps[1], "%Y-%m-%d") { 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)); journal_nodes.sort_by_key(|n| journal_ts(n));
if !journal_nodes.is_empty() { if !journal_nodes.is_empty() {
// Show most recent entries (last N by key order = chronological)
let max_journal = cfg.journal_max; let max_journal = cfg.journal_max;
let skip = if journal_nodes.len() > max_journal { let skip = journal_nodes.len().saturating_sub(max_journal);
journal_nodes.len() - max_journal
} else { 0 };
println!("--- recent journal entries (last {}/{}) ---", println!("--- recent journal entries (last {}/{}) ---",
journal_nodes.len().min(max_journal), journal_nodes.len()); journal_nodes.len().min(max_journal), journal_nodes.len());
for node in journal_nodes.iter().skip(skip) { for node in journal_nodes.iter().skip(skip) {
@ -1460,6 +1437,42 @@ fn cmd_load_context() -> Result<(), String> {
println!(); 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 ==="); println!("=== END MEMORY LOAD ===");
Ok(()) Ok(())