diff --git a/Cargo.toml b/Cargo.toml index f0a20e4..af27492 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,4 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" anyhow = "1.0" dirs = "5.0" +chrono = "0.4.44" diff --git a/docs/DOCS.md b/docs/DOCS.md index 09aa560..e7267b9 100644 --- a/docs/DOCS.md +++ b/docs/DOCS.md @@ -2,32 +2,72 @@ ## Overview -MCP server for AI memory. Reads/writes core.md and memory.md. Nothing more. +MCP server for AI memory. Reads/writes core.json and memory/*.json in atproto lexicon record format. ## Design - AI decides, tool records - File I/O only, no database - 4 MCP tools: read_core, read_memory, save_memory, compress -- Simple, unbreakable, long-lasting +- Storage format: atproto getRecord JSON ## MCP Tools | Tool | Args | Description | |------|------|-------------| -| read_core | none | Returns core.md content | -| read_memory | none | Returns memory.md content | -| save_memory | content: string | Overwrites memory.md | -| compress | conversation: string | Reads memory.md + conversation, writes compressed result to memory.md | +| read_core | none | Returns core.json record | +| read_memory | none | Returns latest memory record | +| save_memory | content: string | Creates new memory record (version increments) | +| compress | conversation: string | Same as save_memory (AI compresses before calling) | compress note: AI decides what to keep/discard. Tool just writes. ## Data ``` -~/.config/aigpt/ -├── core.md ← read only (identity, settings) -└── memory.md ← read/write (memories, grows over time) +~/Library/Application Support/ai.syui.gpt/ (macOS) +~/.local/share/ai.syui.gpt/ (Linux) +├── core.json ← read only, rkey: self +└── memory/ + ├── {tid1}.json ← version 1 + ├── {tid2}.json ← version 2 + └── {tid3}.json ← version 3 (latest) +``` + +## Record Format + +core (single record, rkey: self): +```json +{ + "uri": "at://{did}/ai.syui.gpt.core/self", + "value": { + "$type": "ai.syui.gpt.core", + "did": "did:plc:xxx", + "handle": "ai.syui.ai", + "content": { + "$type": "ai.syui.gpt.core#markdown", + "text": "personality and instructions" + }, + "createdAt": "2025-01-01T00:00:00Z" + } +} +``` + +memory (multiple records, rkey: tid): +```json +{ + "uri": "at://{did}/ai.syui.gpt.memory/{tid}", + "value": { + "$type": "ai.syui.gpt.memory", + "did": "did:plc:xxx", + "content": { + "$type": "ai.syui.gpt.memory#markdown", + "text": "# Memory\n\n## ..." + }, + "version": 5, + "createdAt": "2026-03-01T12:00:00Z" + } +} ``` ## Architecture @@ -35,8 +75,8 @@ compress note: AI decides what to keep/discard. Tool just writes. ``` src/ ├── mcp/server.rs ← JSON-RPC over stdio -├── core/reader.rs ← read core.md, memory.md -├── core/writer.rs ← write memory.md +├── core/reader.rs ← read core.json, memory/*.json +├── core/writer.rs ← write memory/{tid}.json └── main.rs ← CLI + MCP server ``` @@ -46,20 +86,20 @@ When compress is called, AI should: - Keep facts and decisions - Discard procedures and processes - Resolve contradictions (keep newer) -- Don't duplicate core.md content +- Don't duplicate core.json content ## Usage ```bash -aigpt serve # start MCP server -aigpt read-core # CLI: read core.md -aigpt read-memory # CLI: read memory.md -aigpt save-memory "content" # CLI: write memory.md +aigpt server # start MCP server +aigpt read-core # CLI: read core.json +aigpt read-memory # CLI: read latest memory +aigpt save-memory "..." # CLI: create new memory record ``` ## Tech -- Rust, MCP (JSON-RPC over stdio), file I/O only +- Rust, MCP (JSON-RPC over stdio), atproto record format, file I/O only ## History diff --git a/src/core/config.rs b/src/core/config.rs new file mode 100644 index 0000000..4e1c8ec --- /dev/null +++ b/src/core/config.rs @@ -0,0 +1,117 @@ +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::fs; +use std::path::PathBuf; + +use chrono::Utc; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Config { + pub path: Option, + pub did: Option, + pub handle: Option, +} + +pub fn config_file() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("ai.syui.gpt") + .join("config.json") +} + +pub fn load() -> Config { + let path = config_file(); + match fs::read_to_string(&path) { + Ok(content) => serde_json::from_str(&content).unwrap_or(defaults()), + Err(_) => defaults(), + } +} + +fn defaults() -> Config { + Config { + path: None, + did: None, + handle: None, + } +} + +pub fn init() { + let cfg_path = config_file(); + if !cfg_path.exists() { + if let Some(parent) = cfg_path.parent() { + let _ = fs::create_dir_all(parent); + } + let default_cfg = json!({ + "path": null, + "did": null, + "handle": null + }); + let _ = fs::write(&cfg_path, serde_json::to_string_pretty(&default_cfg).unwrap()); + } + + let cfg = load(); + let core_path = record_path(&cfg, "ai.syui.gpt.core", "self"); + if !core_path.exists() { + if let Some(parent) = core_path.parent() { + let _ = fs::create_dir_all(parent); + } + let did = cfg.did.clone().unwrap_or_else(|| "self".to_string()); + let handle = cfg.handle.clone().unwrap_or_else(|| "self".to_string()); + let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let core_record = json!({ + "uri": format!("at://{}/ai.syui.gpt.core/self", did), + "value": { + "$type": "ai.syui.gpt.core", + "did": did, + "handle": handle, + "content": { + "$type": "ai.syui.gpt.core#markdown", + "text": "" + }, + "createdAt": now + } + }); + let _ = fs::write(&core_path, serde_json::to_string_pretty(&core_record).unwrap()); + } + + let memory_dir = collection_dir(&cfg, "ai.syui.gpt.memory"); + let _ = fs::create_dir_all(&memory_dir); +} + +pub fn base_dir(cfg: &Config) -> PathBuf { + match &cfg.path { + Some(p) => { + if p.starts_with("~/") { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(&p[2..]) + } else { + PathBuf::from(p) + } + } + None => dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("ai.syui.gpt"), + } +} + +pub fn identity(cfg: &Config) -> String { + if cfg!(windows) { + cfg.handle.clone().unwrap_or_else(|| "self".to_string()) + } else { + cfg.did.clone().unwrap_or_else(|| "self".to_string()) + } +} + +/// $cfg/{did|handle}/{collection}/{rkey}.json +pub fn record_path(cfg: &Config, collection: &str, rkey: &str) -> PathBuf { + base_dir(cfg) + .join(identity(cfg)) + .join(collection) + .join(format!("{}.json", rkey)) +} + +/// $cfg/{did|handle}/{collection}/ +pub fn collection_dir(cfg: &Config, collection: &str) -> PathBuf { + base_dir(cfg).join(identity(cfg)).join(collection) +} diff --git a/src/core/mod.rs b/src/core/mod.rs index c9134a0..301670f 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,2 +1,3 @@ +pub mod config; pub mod reader; pub mod writer; diff --git a/src/core/reader.rs b/src/core/reader.rs index ba156b4..c179df5 100644 --- a/src/core/reader.rs +++ b/src/core/reader.rs @@ -1,24 +1,37 @@ use anyhow::{Context, Result}; +use serde_json::Value; use std::fs; -use std::path::PathBuf; -fn config_dir() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("ai.syui.gpt") +use crate::core::config; + +pub fn read_core() -> Result { + let cfg = config::load(); + let path = config::record_path(&cfg, "ai.syui.gpt.core", "self"); + let content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))?; + let record: Value = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", path.display()))?; + Ok(record) } -pub fn read_core() -> Result { - let path = config_dir().join("core.md"); - fs::read_to_string(&path) - .with_context(|| format!("Failed to read {}", path.display())) -} - -pub fn read_memory() -> Result { - let path = config_dir().join("memory.md"); - match fs::read_to_string(&path) { - Ok(content) => Ok(content), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), - Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())), +pub fn read_memory() -> Result> { + let cfg = config::load(); + let dir = config::collection_dir(&cfg, "ai.syui.gpt.memory"); + if !dir.exists() { + return Ok(None); } + let mut files: Vec<_> = fs::read_dir(&dir)? + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "json")) + .collect(); + if files.is_empty() { + return Ok(None); + } + files.sort_by_key(|e| e.file_name()); + let latest = files.last().unwrap().path(); + let content = fs::read_to_string(&latest) + .with_context(|| format!("Failed to read {}", latest.display()))?; + let record: Value = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", latest.display()))?; + Ok(Some(record)) } diff --git a/src/core/writer.rs b/src/core/writer.rs index a08cb9f..a5e2d65 100644 --- a/src/core/writer.rs +++ b/src/core/writer.rs @@ -1,18 +1,59 @@ use anyhow::{Context, Result}; +use chrono::Utc; +use serde_json::json; use std::fs; -use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; -fn config_dir() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("ai.syui.gpt") +use crate::core::{config, reader}; + +fn generate_tid() -> String { + const CHARSET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz"; + let micros = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_micros() as u64; + let mut tid = [0u8; 13]; + let mut v = micros; + for i in (0..13).rev() { + tid[i] = CHARSET[(v & 0x1f) as usize]; + v >>= 5; + } + String::from_utf8(tid.to_vec()).unwrap() +} + +fn next_version() -> u64 { + match reader::read_memory() { + Ok(Some(record)) => record["value"]["version"].as_u64().unwrap_or(0) + 1, + _ => 1, + } } pub fn save_memory(content: &str) -> Result<()> { - let dir = config_dir(); + let cfg = config::load(); + let did = cfg.did.clone().unwrap_or_else(|| "self".to_string()); + let tid = generate_tid(); + let version = next_version(); + let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + + let record = json!({ + "uri": format!("at://{}/ai.syui.gpt.memory/{}", did, tid), + "value": { + "$type": "ai.syui.gpt.memory", + "did": did, + "content": { + "$type": "ai.syui.gpt.memory#markdown", + "text": content + }, + "version": version, + "createdAt": now + } + }); + + let dir = config::collection_dir(&cfg, "ai.syui.gpt.memory"); fs::create_dir_all(&dir) .with_context(|| format!("Failed to create {}", dir.display()))?; - let path = dir.join("memory.md"); - fs::write(&path, content) + let path = dir.join(format!("{}.json", tid)); + let json_str = serde_json::to_string_pretty(&record)?; + fs::write(&path, json_str) .with_context(|| format!("Failed to write {}", path.display())) } diff --git a/src/main.rs b/src/main.rs index d21e70b..30624dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,16 @@ use anyhow::Result; use clap::{Parser, Subcommand}; -use aigpt::core::{reader, writer}; +use aigpt::core::{config, reader, writer}; use aigpt::mcp::MCPServer; #[derive(Parser)] #[command(name = "aigpt")] -#[command(about = "AI memory MCP server - read/write core.md and memory.md")] +#[command(about = "AI memory MCP server")] #[command(version)] struct Cli { #[command(subcommand)] - command: Commands, + command: Option, } #[derive(Subcommand)] @@ -18,13 +18,13 @@ enum Commands { /// Start MCP server (JSON-RPC over stdio) Server, - /// Read core.md + /// Read core.json ReadCore, - /// Read memory.md + /// Read latest memory record ReadMemory, - /// Save content to memory.md + /// Create new memory record SaveMemory { /// Content to write content: String, @@ -32,29 +32,70 @@ enum Commands { } fn main() -> Result<()> { + config::init(); let cli = Cli::parse(); match cli.command { - Commands::Server => { + None => { + print_status(); + } + + Some(Commands::Server) => { let server = MCPServer::new(); server.run()?; } - Commands::ReadCore => { - let content = reader::read_core()?; - print!("{}", content); + Some(Commands::ReadCore) => { + let record = reader::read_core()?; + println!("{}", serde_json::to_string_pretty(&record)?); } - Commands::ReadMemory => { - let content = reader::read_memory()?; - print!("{}", content); + Some(Commands::ReadMemory) => { + match reader::read_memory()? { + Some(record) => println!("{}", serde_json::to_string_pretty(&record)?), + None => println!("No memory records found"), + } } - Commands::SaveMemory { content } => { + Some(Commands::SaveMemory { content }) => { writer::save_memory(&content)?; - println!("Saved to memory.md"); + println!("Saved."); } } Ok(()) } + +fn print_status() { + let cfg = config::load(); + let did = cfg.did.clone().unwrap_or_else(|| "self".to_string()); + let handle = cfg.handle.clone().unwrap_or_else(|| "self".to_string()); + let base = config::base_dir(&cfg); + let id = config::identity(&cfg); + + let memory_dir = config::collection_dir(&cfg, "ai.syui.gpt.memory"); + let memory_count = std::fs::read_dir(&memory_dir) + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "json")) + .count() + }) + .unwrap_or(0); + + let latest_version = match reader::read_memory() { + Ok(Some(record)) => record["value"]["version"].as_u64().unwrap_or(0), + _ => 0, + }; + + println!("aigpt - AI memory MCP server\n"); + println!("config: {}", config::config_file().display()); + println!("did: {}", did); + println!("handle: {}", handle); + println!(); + println!("path: {}/", base.display()); + println!(" {}/{}", id, "ai.syui.gpt.core/self.json"); + println!(" {}/{}", id, "ai.syui.gpt.memory/*.json"); + println!(); + println!("memory: {} records (version: {})", memory_count, latest_version); +} diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 0ec16a3..7b59b38 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -162,14 +162,15 @@ impl MCPServer { fn tool_read_core(&self) -> Value { match reader::read_core() { - Ok(content) => json!({ "content": content }), + Ok(record) => record, Err(e) => json!({ "error": e.to_string() }), } } fn tool_read_memory(&self) -> Value { match reader::read_memory() { - Ok(content) => json!({ "content": content }), + Ok(Some(record)) => record, + Ok(None) => json!({ "content": "" }), Err(e) => json!({ "error": e.to_string() }), } }