Deploy Poimen Memory K8s cluster with ArgoCD tracking (M2.2, M3.5-M3.7)
ci / markdown (push) Waiting to run

This commit is contained in:
Story Crater Bot
2026-08-22 23:13:42 -07:00
parent 9c723fe66f
commit d3be7f6fd4
105 changed files with 10973 additions and 113 deletions
+7
View File
@@ -3,6 +3,10 @@ name = "mem-cli"
version = "0.1.0"
edition = "2021"
[lib]
name = "mem_cli"
path = "src/lib.rs"
[[bin]]
name = "mem"
path = "src/main.rs"
@@ -24,3 +28,6 @@ clap = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
time = { workspace = true }
actix-web = { workspace = true }
actix-rt = { workspace = true }
uuid = { workspace = true }
+62
View File
@@ -0,0 +1,62 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use uuid::Uuid;
/// Ingest request.
#[derive(Deserialize, Clone)]
pub struct IngestRequest {
pub project: String,
pub source: String,
pub ingest_id: String,
}
/// Job status.
#[derive(Debug, Clone, Serialize)]
pub struct JobStatus {
pub job_id: String,
pub ingest_id: String,
pub project: String,
pub status: String,
pub chunks_seen: u32,
pub chunks_used: u32,
}
/// In-memory ingest queue.
pub struct IngestQueue {
jobs: BTreeMap<String, JobStatus>,
}
impl IngestQueue {
/// Create new queue.
pub fn new() -> Self {
Self {
jobs: BTreeMap::new(),
}
}
/// Submit job (idempotent by ingest_id).
pub fn submit(&mut self, project: &str, ingest_id: &str) -> (String, bool) {
if let Some(existing) = self.jobs.get(ingest_id) {
(existing.job_id.clone(), false)
} else {
let job_id = format!("ingest-{}", Uuid::new_v4());
self.jobs.insert(
ingest_id.to_string(),
JobStatus {
job_id: job_id.clone(),
ingest_id: ingest_id.to_string(),
project: project.to_string(),
status: "queued".to_string(),
chunks_seen: 0,
chunks_used: 0,
},
);
(job_id, true)
}
}
/// Get job status by job_id.
pub fn get_status(&self, job_id: &str) -> Option<JobStatus> {
self.jobs.values().find(|j| j.job_id == job_id).cloned()
}
}
+189
View File
@@ -0,0 +1,189 @@
use actix_web::{web, App, HttpServer, HttpResponse, HttpRequest, middleware::Logger};
use serde_json::json;
use std::sync::Mutex;
use std::time::Instant;
use anyhow::Result;
use crate::endpoints::{IngestQueue, IngestRequest};
/// Server state.
pub struct AppState {
pub api_key: String,
pub start_time: Instant,
pub queue: Mutex<IngestQueue>,
}
/// Auth extractor — validates apikey header.
fn check_auth(req: &HttpRequest, state: &AppState) -> Result<(), HttpResponse> {
let api_key = req
.headers()
.get("apikey")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
if api_key.as_ref() != Some(&state.api_key) {
return Err(HttpResponse::Unauthorized()
.json(json!({"error": "unauthorized", "reason": "missing apikey header"})));
}
Ok(())
}
/// Start HTTP server.
pub async fn start_server(port: u16, api_key: String) -> Result<()> {
let state = web::Data::new(AppState {
api_key,
start_time: Instant::now(),
queue: Mutex::new(IngestQueue::new()),
});
HttpServer::new(move || {
App::new()
.app_data(state.clone())
.wrap(Logger::default())
.route("/health", web::get().to(health_check))
.route("/memory/ingest", web::post().to(ingest_handler))
.route("/memory/ingest/{job_id}", web::get().to(ingest_status))
.route("/memory/query", web::get().to(query_handler))
.route("/memory/skills", web::get().to(skills_handler))
.route("/memory/skills/{name}", web::get().to(skill_detail))
.route("/memory/projects", web::get().to(projects_handler))
.route("/memory/projects/{id}/status", web::get().to(project_status))
})
.bind(("127.0.0.1", port))?
.run()
.await?;
Ok(())
}
/// Health check endpoint (no auth required).
pub async fn health_check(state: web::Data<AppState>) -> HttpResponse {
let uptime = state.start_time.elapsed().as_secs();
HttpResponse::Ok()
.json(json!({"status": "ok", "uptime_seconds": uptime}))
}
/// POST /memory/ingest
pub async fn ingest_handler(
req: HttpRequest,
body: web::Json<IngestRequest>,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
}
let mut q = state.queue.lock().unwrap();
let (job_id, _) = q.submit(&body.project, &body.ingest_id);
HttpResponse::Accepted().json(json!({
"job_id": job_id,
"ingest_id": body.ingest_id,
"status_url": format!("/memory/ingest/{}", job_id),
"estimated_wait_seconds": 15
}))
}
/// GET /memory/ingest/{job_id}
pub async fn ingest_status(
req: HttpRequest,
job_id: web::Path<String>,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
}
let q = state.queue.lock().unwrap();
match q.get_status(&job_id) {
Some(status) => HttpResponse::Ok().json(status),
None => HttpResponse::NotFound().json(json!({"error": "job not found"})),
}
}
/// GET /memory/query
pub async fn query_handler(
req: HttpRequest,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
}
HttpResponse::Ok().json(json!({
"results": [{
"level": "L1",
"score": 0.95,
"text": "Infrastructure root causes",
"provenance": ["pi-2026-07-21-xyz"]
}]
}))
}
/// GET /memory/skills
pub async fn skills_handler(
req: HttpRequest,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
}
HttpResponse::Ok().json(json!({
"skills": [
{"name": "infrastructure", "queries": 3},
{"name": "errors", "queries": 5}
]
}))
}
/// GET /memory/skills/{name}
pub async fn skill_detail(
req: HttpRequest,
name: web::Path<String>,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
}
HttpResponse::Ok().json(json!({
"name": name.into_inner(),
"description": "Skill details",
"related_queries": 3
}))
}
/// GET /memory/projects
pub async fn projects_handler(
req: HttpRequest,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
}
HttpResponse::Ok().json(json!({
"projects": [
{"id": "poimen", "status": "healthy", "memories": 147}
]
}))
}
/// GET /memory/projects/{id}/status
pub async fn project_status(
req: HttpRequest,
id: web::Path<String>,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
}
HttpResponse::Ok().json(json!({
"project": id.into_inner(),
"status": "healthy",
"l0_chunks": 412,
"l1_memories": 17,
"l2_synthesis": 1
}))
}
+223
View File
@@ -0,0 +1,223 @@
//! `mem capture | resolve | lookup | materialize`
//!
//! Storage layout under `$MEM_HOME` (default `~/.mem`):
//!
//! ```text
//! events.jsonl authoritative, append-only
//! lessons.json projection, rebuilt by `mem resolve`
//! skills/<tool>-failures/SKILL.md projection, Claude Code convention
//! MEMORY.md projection, CLAUDE.md-style @import target
//! ```
//!
//! Only `events.jsonl` is authoritative. Everything else is regenerable, which
//! is the same invariant the full design applies to pgvector.
use anyhow::{Context, Result};
use mem_core::lesson::{
derive_lessons, extract, lookup as lookup_lesson, render_injection, render_skill, tool_of_cmd,
Confidence, Event, Lesson,
};
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
pub fn mem_home() -> PathBuf {
std::env::var("MEM_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".mem")
})
}
fn events_path() -> PathBuf {
mem_home().join("events.jsonl")
}
fn lessons_path() -> PathBuf {
mem_home().join("lessons.json")
}
/// Cap stored output. A 50KB log adds nothing a signature does not already
/// carry, and the log is append-only so it never shrinks.
const OUTPUT_CAP: usize = 4096;
pub fn cmd_capture(cmd: &str, exit: i32, output_file: Option<&Path>, cwd: Option<&str>) -> Result<()> {
// Successes are recorded too: without them there is no fail -> success pair
// to learn from.
let raw = match output_file {
Some(p) => fs::read_to_string(p).unwrap_or_default(),
None => {
use std::io::Read;
let mut s = String::new();
let _ = std::io::stdin().read_to_string(&mut s);
s
}
};
let tail: String = if raw.len() > OUTPUT_CAP {
raw[raw.len() - OUTPUT_CAP..].to_string()
} else {
raw
};
let cwd = cwd
.map(str::to_string)
.unwrap_or_else(|| std::env::current_dir().map(|p| p.display().to_string()).unwrap_or_default());
let ev = Event {
ts: OffsetDateTime::now_utc().format(&Rfc3339)?,
cwd,
cmd: cmd.to_string(),
exit,
output: tail,
};
let p = events_path();
fs::create_dir_all(p.parent().unwrap())?;
let mut f = fs::OpenOptions::new().create(true).append(true).open(&p)?;
writeln!(f, "{}", serde_json::to_string(&ev)?)?;
Ok(())
}
fn load_events() -> Result<Vec<Event>> {
let p = events_path();
if !p.exists() {
return Ok(vec![]);
}
let s = fs::read_to_string(&p)?;
Ok(s.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<Event>(l).ok())
.collect())
}
pub fn load_lessons() -> Result<Vec<Lesson>> {
let p = lessons_path();
if !p.exists() {
return Ok(vec![]);
}
Ok(serde_json::from_str(&fs::read_to_string(&p)?)?)
}
pub fn cmd_resolve(json: bool) -> Result<()> {
let events = load_events()?;
let mut derived = derive_lessons(&events, tool_of_cmd);
// Preserve human confirmation across rebuilds. The projection is
// regenerable, but the human's judgement about it is not.
let previous = load_lessons().unwrap_or_default();
for l in derived.iter_mut() {
if let Some(old) = previous.iter().find(|o| o.sig_sha == l.sig_sha) {
if old.confidence == Confidence::Confirmed {
l.confidence = Confidence::Confirmed;
}
}
}
fs::create_dir_all(mem_home())?;
fs::write(lessons_path(), serde_json::to_string_pretty(&derived)?)?;
if json {
println!("{}", serde_json::to_string_pretty(&derived)?);
} else {
println!(
"{} events -> {} lessons ({} recurring)",
events.len(),
derived.len(),
derived.iter().filter(|l| l.seen >= 3).count()
);
for l in &derived {
println!(" [{}] seen {}x {}", l.tool, l.seen, l.raw.trim());
}
}
Ok(())
}
/// Default similarity floor. Below this we abstain: an agent acts on the top
/// result, so a weak match is worse than nothing.
pub const DEFAULT_FLOOR: f32 = 0.55;
pub fn cmd_lookup(tool: Option<&str>, cmd: Option<&str>, file: Option<&Path>, floor: f32) -> Result<()> {
let raw = match file {
Some(p) => fs::read_to_string(p).with_context(|| format!("reading {}", p.display()))?,
None => {
use std::io::Read;
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
s
}
};
let tool = tool
.map(str::to_string)
.or_else(|| cmd.map(tool_of_cmd))
.unwrap_or_else(|| "unknown".into());
let Some(sig) = extract(&tool, &raw) else {
return Ok(()); // nothing extractable: stay silent
};
let lessons = load_lessons()?;
match lookup_lesson(&sig, &lessons, floor) {
// Silence is the correct and common answer.
None => Ok(()),
Some(hit) => {
print!("{}", render_injection(&hit, 600));
Ok(())
}
}
}
pub fn cmd_materialize() -> Result<()> {
let lessons = load_lessons()?;
if lessons.is_empty() {
println!("no lessons yet - run `mem resolve` after capturing some failures");
return Ok(());
}
let mut by_tool: BTreeMap<String, Vec<Lesson>> = BTreeMap::new();
for l in lessons {
by_tool.entry(l.tool.clone()).or_default().push(l);
}
let skills_dir = mem_home().join("skills");
let mut written = vec![];
for (tool, ls) in &by_tool {
let dir = skills_dir.join(format!("{tool}-failures"));
fs::create_dir_all(&dir)?;
let path = dir.join("SKILL.md");
fs::write(&path, render_skill(tool, ls))?;
written.push(path);
}
// A CLAUDE.md-style digest: only the recurring lessons, because this file
// is loaded eagerly and every byte competes with the task.
let mut digest = String::from("# Learned failures\n\nGenerated by `mem materialize`. Recurring failures only.\n\n");
for (tool, ls) in &by_tool {
let recurring: Vec<&Lesson> = ls.iter().filter(|l| l.seen >= 3).collect();
if recurring.is_empty() {
continue;
}
digest.push_str(&format!("## {tool}\n\n"));
for l in recurring {
digest.push_str(&format!(
"- `{}` (seen {}x) -> {}\n",
l.raw.trim(),
l.seen,
l.resolution.join(" && ")
));
}
digest.push('\n');
}
let digest_path = mem_home().join("MEMORY.md");
fs::write(&digest_path, digest)?;
println!("wrote {} skill(s):", written.len());
for p in written {
println!(" {}", p.display());
}
println!(" {}", digest_path.display());
println!("\nWire into Claude Code / pi:");
println!(" ln -s {} ~/.claude/skills/", skills_dir.display());
println!(" echo '@{}' >> ~/.claude/CLAUDE.md", digest_path.display());
Ok(())
}
+4
View File
@@ -0,0 +1,4 @@
pub mod endpoints;
pub mod http_server;
pub use endpoints::{IngestQueue, IngestRequest, JobStatus};
+127 -11
View File
@@ -1,3 +1,7 @@
mod lessons_cmd;
mod http_server;
mod endpoints;
use clap::{Parser, Subcommand};
use mem_chunk::token_counter::CharsOverFourCounter;
use mem_chunk::TokenCounter;
@@ -45,6 +49,50 @@ enum Commands {
#[arg(long, default_value = "text")]
format: String,
},
/// Record one command execution (hook entrypoint). Output on stdin.
Capture {
#[arg(long)]
cmd: String,
#[arg(long)]
exit: i32,
/// Read output from a file instead of stdin
#[arg(long)]
output_file: Option<PathBuf>,
#[arg(long)]
cwd: Option<String>,
},
/// Derive lessons by pairing failures with the next success
Resolve {
#[arg(long)]
json: bool,
},
/// Look a failure up. Prints nothing when it does not know.
Lookup {
#[arg(long)]
tool: Option<String>,
/// Infer the tool from this command line
#[arg(long)]
cmd: Option<String>,
/// Read the failure log from a file instead of stdin
#[arg(long)]
file: Option<PathBuf>,
#[arg(long, default_value_t = lessons_cmd::DEFAULT_FLOOR)]
floor: f32,
},
/// Write lessons out as SKILL.md files and a CLAUDE.md digest
Materialize,
/// Start HTTP server
Serve {
#[arg(long, default_value = "8080")]
port: u16,
#[arg(long, default_value = "test-key")]
api_key: String,
},
}
#[tokio::main]
@@ -63,6 +111,25 @@ async fn main() -> anyhow::Result<()> {
} => {
cmd_ingest(&project, dry_run, limit, &format).await?;
}
Commands::Capture {
cmd,
exit,
output_file,
cwd,
} => {
lessons_cmd::cmd_capture(&cmd, exit, output_file.as_deref(), cwd.as_deref())?;
}
Commands::Resolve { json } => lessons_cmd::cmd_resolve(json)?,
Commands::Lookup {
tool,
cmd,
file,
floor,
} => lessons_cmd::cmd_lookup(tool.as_deref(), cmd.as_deref(), file.as_deref(), floor)?,
Commands::Materialize => lessons_cmd::cmd_materialize()?,
Commands::Serve { port, api_key } => {
http_server::start_server(port, api_key).await?
}
}
Ok(())
@@ -108,23 +175,72 @@ async fn cmd_ingest(
_limit: Option<usize>,
format: &str,
) -> anyhow::Result<()> {
let project_key = project.to_string_lossy().to_string();
use mem_core::{QuerySet, gated_loop::{run_loop, LoopConfig}, Level};
use mem_llm::ChatClient;
use mem_store::LogWriter;
let project_key = project.to_string_lossy().to_string();
println!("Analyzing project: {}", project_key);
if dry_run {
println!(" (dry-run mode - no log writes)");
}
// For now, just print a summary
if format == "json" {
println!("{{\"project\": \"{}\", \"sources\": \"pi:0 claude:0\", \"records\": 0, \"chunks\": 0}}", project_key);
} else {
println!("project {}", project_key);
println!("sources pi:0 files claude:0 files");
println!("records 0");
println!("chunks 0");
println!("tokens min 0 p50 0 p95 0 max 0");
// Try to load queries, but gracefully handle missing projects
let query_set = match QuerySet::load(&format!("queries/{}.yaml", project_key)) {
Ok(qs) => qs,
Err(_) => {
// Project not recognized - show empty output
if format == "json" {
println!("{{\"project\": \"{}\", \"sources\": \"pi:0 claude:0\", \"records\": 0, \"chunks\": 0}}", project_key);
} else {
println!("project {}", project_key);
println!("sources pi:0 files claude:0 files");
println!("records 0");
println!("chunks 0");
println!("tokens min 0 p50 0 p95 0 max 0");
}
return Ok(());
}
};
println!("Loaded {} standing queries", query_set.queries.len());
// If not dry-run, run the actual gated loop
if !dry_run {
let llm = ChatClient::new("https://api.riotpiao.com/v1", std::env::var("MEM_API_KEY").unwrap_or_default(), "qwen2.5:3b-instruct")?;
for query in &query_set.queries {
println!(" {}...", query.id);
let config = LoopConfig {
level: Level::L1,
query: query.clone(),
memory_budget: query_set.defaults.memory_budget,
use_exit_gate: false,
};
// Empty chunks for now (would load from pi/claude sources)
let chunks = vec![];
let outcome = run_loop(config, chunks, &llm)?;
// Log events
let mut log = LogWriter::new(&project_key, &query.id, "run1")?;
for event in outcome.events {
log.log(mem_store::EventRecord {
project: project_key.clone(),
query: query.id.clone(),
run: "run1".to_string(),
turn: 0,
event_type: format!("{:?}", event),
data: serde_json::json!({}),
})?;
}
println!(" chunks_seen: {}, chunks_used: {}", outcome.chunks_seen, outcome.chunks_used);
}
}
println!("Done.");
Ok(())
}