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
//
// 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<Config> = OnceLock::new();
#[derive(Debug, Clone, PartialEq)]
pub enum ContextSource {
Store,
File,
Journal,
}
#[derive(Debug, Clone)]
pub struct ContextGroup {
pub label: String,
pub keys: Vec<String>,
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<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() {
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::<serde_json::Value>();
// 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;
}