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:
parent
f3492e70a0
commit
28b9784e1f
2 changed files with 106 additions and 94 deletions
137
src/config.rs
137
src/config.rs
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
63
src/main.rs
63
src/main.rs
|
|
@ -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(())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue