config file, install command, scrub personal references
Add ~/.config/poc-memory/config.toml for user_name, assistant_name, data_dir, projects_dir, and core_nodes. All agent prompts and transcript parsing now use configured names instead of hardcoded personal references. `poc-memory daemon install` writes the systemd user service and installs the memory-search hook into Claude's settings.json. Scrubbed hardcoded names from code and docs. Authors: ProofOfConcept <poc@bcachefs.org> and Kent Overstreet
This commit is contained in:
parent
ed641ec95f
commit
a8aaadb0ad
11 changed files with 256 additions and 41 deletions
|
|
@ -37,7 +37,7 @@ fn main() {
|
|||
}
|
||||
|
||||
// Skip system/idle prompts
|
||||
for prefix in &["Kent is AFK", "You're on your own", "IRC mention"] {
|
||||
for prefix in &["is AFK", "You're on your own", "IRC mention"] {
|
||||
if prompt.starts_with(prefix) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
93
src/config.rs
Normal file
93
src/config.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// Configuration for poc-memory
|
||||
//
|
||||
// Loaded from ~/.config/poc-memory/config.toml (or POC_MEMORY_CONFIG env).
|
||||
// Falls back to sensible defaults if no config file exists.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static CONFIG: OnceLock<Config> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// Display name for the human user in transcripts/prompts.
|
||||
pub user_name: String,
|
||||
/// Display name for the AI assistant.
|
||||
pub assistant_name: String,
|
||||
/// Base directory for memory data (store, logs, status).
|
||||
pub data_dir: PathBuf,
|
||||
/// Directory containing Claude session transcripts.
|
||||
pub projects_dir: PathBuf,
|
||||
/// Core node keys that should never be decayed/deleted.
|
||||
pub core_nodes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let home = PathBuf::from(std::env::var("HOME").expect("HOME not set"));
|
||||
Self {
|
||||
user_name: "User".to_string(),
|
||||
assistant_name: "Assistant".to_string(),
|
||||
data_dir: home.join(".claude/memory"),
|
||||
projects_dir: home.join(".claude/projects"),
|
||||
core_nodes: vec!["identity.md".to_string()],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn load_from_file() -> Self {
|
||||
let path = std::env::var("POC_MEMORY_CONFIG")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
PathBuf::from(std::env::var("HOME").expect("HOME not set"))
|
||||
.join(".config/poc-memory/config.toml")
|
||||
});
|
||||
|
||||
let mut config = Config::default();
|
||||
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
return config;
|
||||
};
|
||||
|
||||
// Simple TOML parser — we only need flat key = "value" pairs.
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else { continue };
|
||||
let key = key.trim();
|
||||
let value = value.trim().trim_matches('"');
|
||||
|
||||
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())
|
||||
.collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_home(path: &str) -> PathBuf {
|
||||
if let Some(rest) = path.strip_prefix("~/") {
|
||||
PathBuf::from(std::env::var("HOME").expect("HOME not set")).join(rest)
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the global config (loaded once on first access).
|
||||
pub fn get() -> &'static Config {
|
||||
CONFIG.get_or_init(Config::load_from_file)
|
||||
}
|
||||
123
src/daemon.rs
123
src/daemon.rs
|
|
@ -24,23 +24,19 @@ use std::time::{Duration, SystemTime};
|
|||
const SESSION_STALE_SECS: u64 = 600; // 10 minutes
|
||||
const SCHEDULER_INTERVAL: Duration = Duration::from_secs(60);
|
||||
const HEALTH_INTERVAL: Duration = Duration::from_secs(3600);
|
||||
const STATUS_FILE: &str = ".claude/memory/daemon-status.json";
|
||||
const LOG_FILE: &str = ".claude/memory/daemon.log";
|
||||
|
||||
fn home_dir() -> PathBuf {
|
||||
PathBuf::from(std::env::var("HOME").expect("HOME not set"))
|
||||
}
|
||||
fn status_file() -> &'static str { "daemon-status.json" }
|
||||
fn log_file() -> &'static str { "daemon.log" }
|
||||
|
||||
fn status_path() -> PathBuf {
|
||||
home_dir().join(STATUS_FILE)
|
||||
crate::config::get().data_dir.join(status_file())
|
||||
}
|
||||
|
||||
fn log_path() -> PathBuf {
|
||||
home_dir().join(LOG_FILE)
|
||||
crate::config::get().data_dir.join(log_file())
|
||||
}
|
||||
|
||||
fn projects_dir() -> PathBuf {
|
||||
home_dir().join(".claude/projects")
|
||||
crate::config::get().projects_dir.clone()
|
||||
}
|
||||
|
||||
// --- Logging ---
|
||||
|
|
@ -600,6 +596,115 @@ pub fn show_status() -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn install_service() -> Result<(), String> {
|
||||
let exe = std::env::current_exe()
|
||||
.map_err(|e| format!("current_exe: {}", e))?;
|
||||
let home = std::env::var("HOME").map_err(|e| format!("HOME: {}", e))?;
|
||||
|
||||
let unit_dir = PathBuf::from(&home).join(".config/systemd/user");
|
||||
fs::create_dir_all(&unit_dir)
|
||||
.map_err(|e| format!("create {}: {}", unit_dir.display(), e))?;
|
||||
|
||||
let unit = format!(
|
||||
r#"[Unit]
|
||||
Description=poc-memory daemon — background memory maintenance
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={exe} daemon
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
Environment=HOME={home}
|
||||
Environment=PATH={home}/.cargo/bin:{home}/.local/bin:{home}/bin:/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
"#, exe = exe.display(), home = home);
|
||||
|
||||
let unit_path = unit_dir.join("poc-memory.service");
|
||||
fs::write(&unit_path, &unit)
|
||||
.map_err(|e| format!("write {}: {}", unit_path.display(), e))?;
|
||||
eprintln!("Wrote {}", unit_path.display());
|
||||
|
||||
let status = std::process::Command::new("systemctl")
|
||||
.args(["--user", "daemon-reload"])
|
||||
.status()
|
||||
.map_err(|e| format!("systemctl daemon-reload: {}", e))?;
|
||||
if !status.success() {
|
||||
return Err("systemctl daemon-reload failed".into());
|
||||
}
|
||||
|
||||
let status = std::process::Command::new("systemctl")
|
||||
.args(["--user", "enable", "--now", "poc-memory"])
|
||||
.status()
|
||||
.map_err(|e| format!("systemctl enable: {}", e))?;
|
||||
if !status.success() {
|
||||
return Err("systemctl enable --now failed".into());
|
||||
}
|
||||
|
||||
eprintln!("Service enabled and started");
|
||||
|
||||
// Install memory-search hook into Claude settings
|
||||
install_hook(&home, &exe)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_hook(home: &str, exe: &Path) -> Result<(), String> {
|
||||
let settings_path = PathBuf::from(home).join(".claude/settings.json");
|
||||
let hook_binary = exe.with_file_name("memory-search");
|
||||
|
||||
if !hook_binary.exists() {
|
||||
eprintln!("Warning: {} not found — hook not installed", hook_binary.display());
|
||||
eprintln!(" Build with: cargo install --path .");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut settings: serde_json::Value = if settings_path.exists() {
|
||||
let content = fs::read_to_string(&settings_path)
|
||||
.map_err(|e| format!("read settings: {}", e))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("parse settings: {}", e))?
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
|
||||
let hook_command = hook_binary.to_string_lossy().to_string();
|
||||
|
||||
// Check if hook already exists
|
||||
let hooks = settings
|
||||
.as_object_mut().ok_or("settings not an object")?
|
||||
.entry("hooks")
|
||||
.or_insert_with(|| serde_json::json!({}))
|
||||
.as_object_mut().ok_or("hooks not an object")?
|
||||
.entry("UserPromptSubmit")
|
||||
.or_insert_with(|| serde_json::json!([]))
|
||||
.as_array_mut().ok_or("UserPromptSubmit not an array")?;
|
||||
|
||||
let already_installed = hooks.iter().any(|h| {
|
||||
h.get("command").and_then(|c| c.as_str())
|
||||
.is_some_and(|c| c.contains("memory-search"))
|
||||
});
|
||||
|
||||
if already_installed {
|
||||
eprintln!("Hook already installed in {}", settings_path.display());
|
||||
} else {
|
||||
hooks.push(serde_json::json!({
|
||||
"type": "command",
|
||||
"command": hook_command,
|
||||
"timeout": 10
|
||||
}));
|
||||
let json = serde_json::to_string_pretty(&settings)
|
||||
.map_err(|e| format!("serialize settings: {}", e))?;
|
||||
fs::write(&settings_path, json)
|
||||
.map_err(|e| format!("write settings: {}", e))?;
|
||||
eprintln!("Hook installed: {}", hook_command);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn show_log(job_filter: Option<&str>, lines: usize) -> Result<(), String> {
|
||||
let path = log_path();
|
||||
if !path.exists() {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
//
|
||||
// Uses Haiku (not Sonnet) for cost efficiency on high-volume extraction.
|
||||
|
||||
use crate::config;
|
||||
use crate::llm;
|
||||
use crate::store::{self, Provenance};
|
||||
|
||||
|
|
@ -19,7 +20,13 @@ const OVERLAP_TOKENS: usize = 200;
|
|||
const WINDOW_CHARS: usize = WINDOW_TOKENS * CHARS_PER_TOKEN;
|
||||
const OVERLAP_CHARS: usize = OVERLAP_TOKENS * CHARS_PER_TOKEN;
|
||||
|
||||
const EXTRACTION_PROMPT: &str = r#"Extract atomic factual claims from this conversation excerpt.
|
||||
fn extraction_prompt() -> String {
|
||||
let cfg = config::get();
|
||||
format!(
|
||||
r#"Extract atomic factual claims from this conversation excerpt.
|
||||
|
||||
Speakers are labeled [{user}] and [{assistant}] in the transcript.
|
||||
Use their proper names in claims — not "the user" or "the assistant."
|
||||
|
||||
Each claim should be:
|
||||
- A single verifiable statement
|
||||
|
|
@ -29,7 +36,7 @@ Each claim should be:
|
|||
linux/kernel, memory/design, identity/personal)
|
||||
- Tagged with confidence: "stated" (explicitly said), "implied" (logically follows),
|
||||
or "speculative" (hypothesis, not confirmed)
|
||||
- Include which speaker said it (Kent, PoC/ProofOfConcept, or Unknown)
|
||||
- Include which speaker said it ("{user}", "{assistant}", or "Unknown")
|
||||
|
||||
Do NOT extract:
|
||||
- Opinions or subjective assessments
|
||||
|
|
@ -37,20 +44,21 @@ Do NOT extract:
|
|||
- Things that are obviously common knowledge
|
||||
- Restatements of the same fact (pick the clearest version)
|
||||
- System messages, tool outputs, or error logs (extract what was LEARNED from them)
|
||||
- Anything about the conversation itself ("Kent and PoC discussed...")
|
||||
- Anything about the conversation itself ("{user} and {assistant} discussed...")
|
||||
|
||||
Output as a JSON array. Each element:
|
||||
{
|
||||
{{
|
||||
"claim": "the exact factual statement",
|
||||
"domain": "category/subcategory",
|
||||
"confidence": "stated|implied|speculative",
|
||||
"speaker": "Kent|PoC|Unknown"
|
||||
}
|
||||
"speaker": "{user}|{assistant}|Unknown"
|
||||
}}
|
||||
|
||||
If the excerpt contains no extractable facts, output an empty array: []
|
||||
|
||||
--- CONVERSATION EXCERPT ---
|
||||
"#;
|
||||
"#, user = cfg.user_name, assistant = cfg.assistant_name)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Fact {
|
||||
|
|
@ -74,6 +82,7 @@ struct Message {
|
|||
|
||||
/// Extract user/assistant text messages from a JSONL transcript.
|
||||
fn extract_conversation(path: &Path) -> Vec<Message> {
|
||||
let cfg = config::get();
|
||||
let Ok(content) = fs::read_to_string(path) else { return Vec::new() };
|
||||
let mut messages = Vec::new();
|
||||
|
||||
|
|
@ -119,7 +128,11 @@ fn extract_conversation(path: &Path) -> Vec<Message> {
|
|||
continue;
|
||||
}
|
||||
|
||||
let role = if msg_type == "user" { "Kent" } else { "PoC" }.to_string();
|
||||
let role = if msg_type == "user" {
|
||||
cfg.user_name.clone()
|
||||
} else {
|
||||
cfg.assistant_name.clone()
|
||||
};
|
||||
messages.push(Message { role, text, timestamp });
|
||||
}
|
||||
|
||||
|
|
@ -229,11 +242,12 @@ pub fn mine_transcript(path: &Path, dry_run: bool) -> Result<Vec<Fact>, String>
|
|||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let prompt_prefix = extraction_prompt();
|
||||
let mut all_facts = Vec::new();
|
||||
for (i, (_offset, chunk)) in chunks.iter().enumerate() {
|
||||
eprint!(" Chunk {}/{} ({} chars)...", i + 1, chunks.len(), chunk.len());
|
||||
|
||||
let prompt = format!("{}{}", EXTRACTION_PROMPT, chunk);
|
||||
let prompt = format!("{}{}", prompt_prefix, chunk);
|
||||
let response = match llm::call_haiku(&prompt) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ fn extract_conversation_text(path: &Path, max_chars: usize) -> String {
|
|||
let text = strip_system_tags(&text);
|
||||
if text.starts_with("[Request interrupted") { continue; }
|
||||
if text.len() > 5 {
|
||||
fragments.push(format!("**Kent:** {}", text));
|
||||
fragments.push(format!("**{}:** {}", crate::config::get().user_name, text));
|
||||
total += text.len();
|
||||
}
|
||||
}
|
||||
|
|
@ -377,7 +377,7 @@ fn extract_conversation_text(path: &Path, max_chars: usize) -> String {
|
|||
if let Some(text) = extract_text_content(&obj) {
|
||||
let text = strip_system_tags(&text);
|
||||
if text.len() > 10 {
|
||||
fragments.push(format!("**PoC:** {}", text));
|
||||
fragments.push(format!("**{}:** {}", crate::config::get().assistant_name, text));
|
||||
total += text.len();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
#![allow(dead_code)]
|
||||
// poc-memory: graph-structured memory with append-only Cap'n Proto storage
|
||||
// poc-memory: graph-structured memory for AI assistants
|
||||
//
|
||||
// Authors: ProofOfConcept <poc@bcachefs.org> and Kent Overstreet
|
||||
// License: MIT OR Apache-2.0
|
||||
//
|
||||
// Architecture:
|
||||
// nodes.capnp - append-only content node log
|
||||
|
|
@ -13,6 +16,7 @@
|
|||
// Neuroscience-inspired: spaced repetition replay, emotional gating,
|
||||
// interference detection, schema assimilation, reconsolidation.
|
||||
|
||||
mod config;
|
||||
mod store;
|
||||
mod util;
|
||||
mod llm;
|
||||
|
|
@ -1850,8 +1854,9 @@ fn cmd_daemon(args: &[String]) -> Result<(), String> {
|
|||
};
|
||||
daemon::show_log(job, lines)
|
||||
}
|
||||
"install" => daemon::install_service(),
|
||||
_ => {
|
||||
eprintln!("Usage: poc-memory daemon [status|log [JOB] [LINES]]");
|
||||
eprintln!("Usage: poc-memory daemon [status|log|install]");
|
||||
Err("unknown daemon subcommand".into())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ impl Store {
|
|||
/// Bulk recategorize nodes using rule-based logic.
|
||||
/// Returns (changed, unchanged) counts.
|
||||
pub fn fix_categories(&mut self) -> Result<(usize, usize), String> {
|
||||
let core_files = ["identity.md", "kent.md"];
|
||||
let cfg = crate::config::get();
|
||||
let core_files: Vec<&str> = cfg.core_nodes.iter().map(|s| s.as_str()).collect();
|
||||
let tech_files = [
|
||||
"language-theory.md", "zoom-navigation.md",
|
||||
"rust-conversion.md", "poc-architecture.md",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ use serde::{Deserialize, Serialize};
|
|||
use uuid::Uuid;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -86,10 +85,8 @@ macro_rules! capnp_message {
|
|||
};
|
||||
}
|
||||
|
||||
// Data dir: ~/.claude/memory/
|
||||
pub fn memory_dir() -> PathBuf {
|
||||
PathBuf::from(env::var("HOME").expect("HOME not set"))
|
||||
.join(".claude/memory")
|
||||
crate::config::get().data_dir.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn nodes_path() -> PathBuf { memory_dir().join("nodes.capnp") }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue