use std::collections::HashMap;
use std::process::ExitCode;
use dejadb_cal::{CalExecutor, CalExecutorConfig, DejaDbFacade};
use dejadb_core::error::Hash;
use dejadb_core::types::{Fact, Grain};
use dejadb_store::DejaDB;
const USAGE: &str = "\
deja — embedded memory engine for AI agents (OMS + CAL on Turso)
USAGE:
deja <command> [--db <memory.db>] [options] (-d = --db)
deja --version | -V print the version and exit
deja help | --help | -h show this help
COMMANDS:
add <subject> <relation> <object> store a fact (positional)
[--subject S --relation R --object O] [--ns NS] [--confidence C]
[--idempotent] no-op if this exact value is already the head
recall <subject> | --subject S [--relation R] [--ns NS] [-k N]
[--render sml|toon|markdown|plain|json] [--budget TOKENS]
cal <QUERY> [--ns NS] execute a CAL statement
search --query TEXT [--subject S] [-k N] hybrid recall (BM25 + structural, RRF)
history --subject S --relation R [--ns NS]
provenance <source-hash> grains distilled from a source (reverse)
forks open forks (>1 head for a subject+relation)
merge --subject S --relation R --object O close a fork with a resolved value
novelty --text T [--subject S] [--relation R] [-k N] nearest existing grains
(paraphrase check; needs --embed-cmd)
log [--since OP] [--limit N] op-log (change feed)
bundle --out FILE [--since OP] incremental backup (git-shaped)
import --bundle FILE apply a bundle (fast-forward)
migrate --from SRC --file PATH [--history PATH] import another system's
export: mem0 | mem0-history | langgraph | letta | letta-archival |
zep | basic-memory (PATH = vault dir) | jsonl (generic
{subject,relation,object}|{content} lines). Re-runs skip what is
already imported. See docs/migrate.md for per-source dump commands.
reindex backfill + rebuild the BM25 text index
(e.g. after --index-text true on a file
written with indexing off)
stream --to DIR [--interval-ms N] [--once] continuous op-log shipping
restore --from DIR [--until-hlc T] rebuild from stream dir (PITR)
follow --from DIR [--interval-ms N] [--once] subscribe: apply new segments
(org/category distribution)
verify integrity + content-address recheck
stats store counters
serve --mcp [--ns NS] [--mount alias=path,...] [--no-destructive-ops] [--lock-ns NS] MCP server on stdio
(--mount adds read-only files for
cross-file ASSEMBLE; ns \"alias.inner\")
repl [--ns NS] interactive CAL console in the terminal
remember --content TEXT [--facts JSON] [--observer ID]
hook claude-code print settings.json hook snippet
(auto recall-before-prompt + capture-on-stop)
memtool '<COMMAND-JSON>' Anthropic memory-tool ops on grains
ui [--addr HOST:PORT] [--allow-remote] [--token-env VAR] [--no-destructive-ops] web console (default 127.0.0.1:7437)
Namespace defaults to \"shared\". Exit code 0 on success.
--db is optional for one-shot commands: it falls back to $DEJADB_DB, then
~/.dejadb/default.db. `serve` and `ui` require an explicit --db (or $DEJADB_DB).
Files carry their own settings (text index, entity relations, embedding
provenance) in an internal meta table; a bare open honors them.
--index-text true|false explicitly re-stamps the file's declaration.
Encryption at rest: add --passphrase-env <VAR> to any command to derive an
AES-256 key (Argon2id) from the passphrase in environment variable VAR. The
non-secret salt is kept in a <memory.db>.kdf sidecar — back it up with the db.
Vector recall: add --embed-cmd 'CMD' [--embed-model NAME] to any command to
install a command embedder — CMD gets the text on stdin and must print a JSON
array of numbers. Turns on the vector leg for search/serve, and embeds grains
written by add/remember/migrate.";
fn flag(args: &HashMap<String, String>, k: &str) -> Option<String> {
args.get(k).cloned()
}
fn short_flag(a: &str) -> Option<&'static str> {
match a {
"-d" => Some("db"),
"-k" => Some("k"),
_ => None,
}
}
fn need(args: &HashMap<String, String>, k: &str) -> Result<String, String> {
flag(args, k).ok_or_else(|| format!("missing required --{k}"))
}
fn resolve_db(args: &HashMap<String, String>, require_explicit: bool) -> Result<String, String> {
if let Some(p) = flag(args, "db") {
return Ok(p);
}
if let Ok(p) = std::env::var("DEJADB_DB") {
if !p.trim().is_empty() {
return Ok(p);
}
}
if require_explicit {
return Err(
"this command needs an explicit memory file — pass --db <file> (or -d), \
or set $DEJADB_DB. It will not fall back to the personal default memory \
(~/.dejadb/default.db), to avoid serving the wrong file."
.to_string(),
);
}
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| "no --db given, and neither $DEJADB_DB nor $HOME is set".to_string())?;
let path = format!("{home}/.dejadb/default.db");
if let Some(parent) = std::path::Path::new(&path).parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
}
eprintln!("deja: using default memory {path} (override with -d/--db or $DEJADB_DB)");
Ok(path)
}
fn addr_is_loopback(addr: &str) -> bool {
let a = addr.trim();
if let Some(rest) = a.strip_prefix('[') {
let host = rest.split(']').next().unwrap_or("");
return host == "::1" || host.eq_ignore_ascii_case("localhost");
}
let host = a.rsplit_once(':').map(|(h, _)| h).unwrap_or(a);
if host.eq_ignore_ascii_case("localhost") {
return true;
}
match host.parse::<std::net::IpAddr>() {
Ok(ip) => ip.is_loopback(),
Err(_) => false,
}
}
fn parse_args(rest: &[String]) -> (HashMap<String, String>, Vec<String>) {
let mut flags = HashMap::new();
let mut positional = Vec::new();
let mut i = 0;
while i < rest.len() {
let a = &rest[i];
if let Some(name) = a.strip_prefix("--") {
if i + 1 < rest.len() && !rest[i + 1].starts_with('-') {
flags.insert(name.to_string(), rest[i + 1].clone());
i += 2;
} else {
flags.insert(name.to_string(), "true".to_string());
i += 1;
}
} else if let Some(long) = short_flag(a) {
if i + 1 < rest.len() && !rest[i + 1].starts_with('-') {
flags.insert(long.to_string(), rest[i + 1].clone());
i += 2;
} else {
flags.insert(long.to_string(), "true".to_string());
i += 1;
}
} else {
positional.push(a.clone());
i += 1;
}
}
(flags, positional)
}
fn render_transcript_content(content: &serde_json::Value) -> String {
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let head: String = s.chars().take(max).collect();
format!("{head}… [{}+ chars truncated]", s.chars().count() - max)
}
}
fn tool_result_body(content: &serde_json::Value) -> String {
match content {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(blocks) => blocks
.iter()
.filter_map(|b| b["text"].as_str().or_else(|| b.as_str()))
.collect::<Vec<_>>()
.join("\n"),
other if other.is_object() => other["text"].as_str().unwrap_or("").to_string(),
_ => String::new(),
}
}
match content {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(blocks) => blocks
.iter()
.filter_map(|b| match b["type"].as_str() {
Some("text") => b["text"].as_str().map(str::to_string),
Some("tool_use") => {
let name = b["name"].as_str().unwrap_or("tool");
Some(format!("[tool_use {name}] {}", truncate(&b["input"].to_string(), 500)))
}
Some("tool_result") => {
let flag = if b["is_error"].as_bool().unwrap_or(false) {
" ERROR"
} else {
""
};
Some(format!("[tool_result{flag}] {}", truncate(&tool_result_body(&b["content"]), 800)))
}
_ => b["text"].as_str().map(str::to_string),
})
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
fn run_migrate(
m: &mut DejaDB,
ns: &str,
from: &str,
file: &str,
history: Option<String>,
) -> Result<dejadb_store::migrate::MigrateReport, String> {
use dejadb_store::migrate as mig;
let read = |p: &str| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"));
let parse = |p: &str, s: &str| {
serde_json::from_str::<serde_json::Value>(s).map_err(|e| format!("{p}: bad JSON: {e}"))
};
let rep = match from {
"mem0" => {
let export = parse(file, &read(file)?)?;
let history = match &history {
Some(h) => Some(parse(h, &read(h)?)?),
None => None,
};
mig::migrate_mem0(m, ns, Some(&export), history.as_ref())
}
"mem0-history" => {
let h = parse(file, &read(file)?)?;
mig::migrate_mem0(m, ns, None, Some(&h))
}
"langgraph" | "langmem" => mig::migrate_langgraph(m, ns, &read(file)?),
"letta" => {
let af = parse(file, &read(file)?)?;
mig::migrate_letta(m, ns, &af)
}
"letta-archival" => mig::migrate_letta_archival(m, ns, &read(file)?),
"zep" | "graphiti" => {
let v = parse(file, &read(file)?)?;
mig::migrate_zep(m, ns, &v)
}
"jsonl" => mig::migrate_jsonl(m, ns, &read(file)?),
"basic-memory" => {
let root = std::path::PathBuf::from(file);
if !root.is_dir() {
return Err(format!(
"--from basic-memory expects --file to be the vault directory (got {file})"
));
}
let mut notes: Vec<std::path::PathBuf> = Vec::new();
let mut stack = vec![root.clone()];
while let Some(d) = stack.pop() {
let entries = std::fs::read_dir(&d).map_err(|e| format!("{}: {e}", d.display()))?;
for entry in entries {
let path = entry.map_err(|e| e.to_string())?.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|x| x == "md") {
notes.push(path);
}
}
}
notes.sort();
let mut rep = mig::MigrateReport::default();
for path in notes {
let rel = path
.strip_prefix(&root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let md = read(&path.to_string_lossy())?;
let mtime_ms = std::fs::metadata(&path)
.and_then(|md| md.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64);
mig::migrate_basic_memory_note(m, ns, &rel, &md, mtime_ms, &mut rep)
.map_err(|e| e.to_string())?;
}
Ok(rep)
}
other => {
return Err(format!(
"unknown --from '{other}' — sources: mem0, mem0-history, langgraph, letta, \
letta-archival, zep, basic-memory, jsonl"
))
}
};
rep.map_err(|e| e.to_string())
}
fn run() -> Result<(), String> {
let argv: Vec<String> = std::env::args().skip(1).collect();
let cmd = match argv.first() {
Some(c) => c.clone(),
None => {
println!("{USAGE}");
return Ok(());
}
};
if cmd == "help" || cmd == "--help" || cmd == "-h" {
println!("{USAGE}");
return Ok(());
}
if cmd == "version" || cmd == "--version" || cmd == "-V" {
println!("deja {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let (flags, positional) = parse_args(&argv[1..]);
let db = resolve_db(&flags, matches!(cmd.as_str(), "serve" | "ui"))?;
let ns = flag(&flags, "ns").unwrap_or_else(|| "shared".to_string());
if cmd == "hook" {
let target = positional.first().map(String::as_str).unwrap_or("claude-code");
if target != "claude-code" {
return Err(format!("unknown hook target '{target}'"));
}
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "deja".into());
println!(
r#"Add to ~/.claude/settings.json (hooks section) to close the learning
loop automatically — inject relevant memory before each prompt, and capture
each exchange (with tool outcomes) when a turn ends:
{{
"hooks": {{
"UserPromptSubmit": [{{ "hooks": [{{
"type": "command",
"command": "{exe} recall-hook --db {db} --ns {ns}"
}}] }}],
"Stop": [{{ "hooks": [{{
"type": "command",
"command": "{exe} capture-stop --db {db} --ns {ns}"
}}] }}]
}}
}}
recall-hook reads the prompt and prints matching memories to stdout, which
Claude Code injects as context — so retrieval no longer depends on the model
choosing to call a tool. For on-demand reads/writes by the model itself, also
register the MCP server:
claude mcp add deja -- {exe} serve --mcp --db {db} --ns {ns}
Nothing was written — apply the snippet yourself (or rerun with your own paths)."#
);
return Ok(());
}
let enc_key = match flag(&flags, "passphrase-env") {
Some(var) => {
let pass = zeroize::Zeroizing::new(std::env::var(&var).map_err(|_| {
format!("--passphrase-env {var}: environment variable is not set")
})?);
if pass.trim().is_empty() {
return Err(format!("--passphrase-env {var}: passphrase is empty"));
}
Some(DejaDB::derive_key_for(&db, pass.as_str()).map_err(|e| e.to_string())?)
}
None => None,
};
let explicit_index = flag(&flags, "index-text");
let mut m = if explicit_index.is_some() || enc_key.is_some() {
let mut o = dejadb_store::DejaDbOptions::default();
if let Some(v) = &explicit_index {
o.index_text = !matches!(v.as_str(), "false" | "0" | "off" | "no");
}
if let Some(key) = &enc_key {
o.encryption_key = Some(**key);
}
DejaDB::open_with(&db, o)
} else {
DejaDB::open(&db)
}
.map_err(|e| e.to_string())?;
for w in m.open_warnings() {
eprintln!("deja: warning: {w}");
}
if let Some(cmd_line) = flag(&flags, "embed-cmd") {
let model = flag(&flags, "embed-model");
let seen = m.open_warnings().len();
let ce = dejadb_store::CommandEmbed::new(&cmd_line, model.as_deref())
.map_err(|e| e.to_string())?;
m.set_embedder(Box::new(ce));
for w in &m.open_warnings()[seen..] {
eprintln!("deja: warning: {w}");
}
}
match cmd.as_str() {
"add" => {
let (s, r, o) = match (
flag(&flags, "subject"),
flag(&flags, "relation"),
flag(&flags, "object"),
) {
(Some(s), Some(r), Some(o)) => (s, r, o),
_ if positional.len() >= 3 => (
positional[0].clone(),
positional[1].clone(),
positional[2].clone(),
),
_ => {
return Err("usage: deja add <subject> <relation> <object> \
(or --subject S --relation R --object O)"
.to_string())
}
};
for (name, v) in [("subject", &s), ("relation", &r), ("object", &o)] {
if v.trim().is_empty() {
return Err(format!("{name} must not be empty"));
}
}
let conf: f64 = flag(&flags, "confidence")
.map(|c| c.parse().map_err(|_| "bad --confidence".to_string()))
.transpose()?
.unwrap_or(0.9);
let mut f = Fact::new(&s, &r, &o).confidence(conf);
f.common.namespace = Some(ns);
if flags.contains_key("idempotent") {
let (h, inserted) = m.add_if_novel(&f).map_err(|e| e.to_string())?;
println!("{h}");
if !inserted {
eprintln!("(unchanged — value already current, no new grain)");
}
} else {
let h = m.add(&f).map_err(|e| e.to_string())?;
println!("{h}");
}
}
"recall" => {
let s = flag(&flags, "subject")
.or_else(|| positional.first().cloned())
.ok_or_else(|| "usage: deja recall <subject> (or --subject S)".to_string())?;
let rel = flag(&flags, "relation");
let k: usize = flag(&flags, "k").and_then(|v| v.parse().ok()).unwrap_or(16);
let grains = m
.recall(&ns, &s, rel.as_deref(), k)
.map_err(|e| e.to_string())?;
match flag(&flags, "render") {
None => {
for g in grains {
let line = serde_json::json!({
"hash": g.hash.to_hex(),
"type": format!("{:?}", g.grain_type).to_lowercase(),
"fields": g.fields,
});
println!("{line}");
}
}
Some(render) => {
use dejadb_context::{ContextAssembler, FormatPolicy, OutputFormat};
let mut policy = match render.as_str() {
"sml" => FormatPolicy::claude(),
"markdown" => FormatPolicy::gpt4(),
"toon" => FormatPolicy::new(OutputFormat::Toon),
"plain" => FormatPolicy::new(OutputFormat::PlainText),
"json" => FormatPolicy::json_api(),
other => return Err(format!("unknown --render '{other}'")),
};
if let Some(b) = flag(&flags, "budget").and_then(|v| v.parse().ok()) {
policy.token_budget = Some(b);
}
let hits: Vec<dejadb_cal::store_types::SearchHit> = grains
.into_iter()
.map(|grain| {
let hash = grain.hash;
dejadb_cal::store_types::SearchHit {
grain,
score: 1.0,
hash,
score_breakdown: None,
explanation: None,
scope_depth: None,
source_namespace: None,
relative_time: None,
conflict_status: None,
supersession_status: None,
superseded_by_hash: None,
recall_source: None,
}
})
.collect();
let ctx = ContextAssembler::new().format(&hits, &policy);
println!("{}", ctx.text);
eprintln!(
"-- {} grains, ~{} tokens{}",
ctx.included_count,
ctx.estimated_tokens,
if ctx.truncated { " (truncated to budget)" } else { "" }
);
}
}
}
"search" => {
let q = need(&flags, "query")?;
let subject = flag(&flags, "subject");
let k: usize = flag(&flags, "k").and_then(|v| v.parse().ok()).unwrap_or(10);
let grains = m
.recall_hybrid(&ns, subject.as_deref(), None, Some(&q), k, None)
.map_err(|e| e.to_string())?;
for g in grains {
println!("{}", serde_json::json!({
"hash": g.hash.to_hex(),
"type": format!("{:?}", g.grain_type).to_lowercase(),
"fields": g.fields,
}));
}
}
"cal" => {
let query = positional
.first()
.ok_or_else(|| "usage: deja cal '<QUERY>' --db <file>".to_string())?
.clone();
let facade = DejaDbFacade::with_session(m, Some(ns), None);
let ex = CalExecutor::new(CalExecutorConfig {
allow_destructive_ops: !flags.contains_key("no-destructive-ops"),
..CalExecutorConfig::default()
});
let res = ex.execute(&query, &facade).map_err(|e| e.to_string())?;
let payload = serde_json::to_string_pretty(&res.result).map_err(|e| e.to_string())?;
println!("{payload}");
for w in res.warnings {
eprintln!("warning: {w}");
}
}
"history" => {
let s = need(&flags, "subject")?;
let r = need(&flags, "relation")?;
let versions = m.history(&ns, &s, &r).map_err(|e| e.to_string())?;
for v in versions {
println!(
"{}",
serde_json::json!({
"hash": v.hash.to_hex(),
"object": v.object,
"created_at": v.created_at,
"confidence": v.confidence,
"superseded_by": v.superseded_by.map(|h| h.to_hex()),
})
);
}
}
"log" => {
let since: i64 = flag(&flags, "since").and_then(|v| v.parse().ok()).unwrap_or(0);
let limit: usize = flag(&flags, "limit").and_then(|v| v.parse().ok()).unwrap_or(50);
for op in m.changes_since(since, limit).map_err(|e| e.to_string())? {
let kind = match op.op {
dejadb_store::OP_ADD => "add",
dejadb_store::OP_SUPERSEDE => "supersede",
dejadb_store::OP_FORGET => "forget",
_ => "?",
};
println!("{:>6} {:>20} {:<9} {}", op.op_seq, op.hlc, kind, op.hash.to_hex());
}
}
"stream" => {
let to = need(&flags, "to")?;
let interval: u64 = flag(&flags, "interval-ms").and_then(|v| v.parse().ok()).unwrap_or(500);
let once = flags.contains_key("once");
std::fs::create_dir_all(&to).map_err(|e| e.to_string())?;
let cursor_path = format!("{to}/CURSOR");
let (gen_id, cursor) = match std::fs::read_to_string(&cursor_path) {
Ok(s) => {
let mut parts = s.trim().splitn(2, ' ');
let g = parts.next().unwrap_or("").to_string();
let c: i64 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
(g, c)
}
Err(_) => (String::new(), 0),
};
let (gen_id, mut cursor) = if gen_id.is_empty() {
let g = format!("{:016x}", std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() as u64);
std::fs::create_dir_all(format!("{to}/gen-{g}")).map_err(|e| e.to_string())?;
(g, 0i64)
} else {
(gen_id, cursor)
};
let mut seg: u32 = std::fs::read_dir(format!("{to}/gen-{gen_id}"))
.map(|d| d.count() as u32)
.unwrap_or(0);
loop {
let ops_now = m.changes_since(cursor, 1).map_err(|e| e.to_string())?;
if !ops_now.is_empty() {
let seg_path = format!("{to}/gen-{gen_id}/segment-{seg:08}.mgb");
let st = m.bundle_since(cursor, &seg_path).map_err(|e| e.to_string())?;
cursor = st.last_op_seq;
seg += 1;
std::fs::write(&cursor_path, format!("{gen_id} {cursor}")).map_err(|e| e.to_string())?;
eprintln!("shipped {} ops → {seg_path}", st.ops);
}
if once {
break;
}
std::thread::sleep(std::time::Duration::from_millis(interval));
}
}
"follow" => {
let from = need(&flags, "from")?;
let interval: u64 = flag(&flags, "interval-ms").and_then(|v| v.parse().ok()).unwrap_or(1000);
let once = flags.contains_key("once");
let fcur_path = format!("{db}.follow");
loop {
let cursor = std::fs::read_to_string(format!("{from}/CURSOR")).unwrap_or_default();
let gen_id = cursor.trim().split(' ').next().unwrap_or("").to_string();
if !gen_id.is_empty() {
let (fgen, fseg) = match std::fs::read_to_string(&fcur_path) {
Ok(s) => {
let mut it = s.trim().splitn(2, ' ');
(it.next().unwrap_or("").to_string(),
it.next().and_then(|v| v.parse::<u32>().ok()).unwrap_or(0))
}
Err(_) => (String::new(), 0),
};
let mut fseg = if fgen != gen_id { 0 } else { fseg };
loop {
let seg_path = format!("{from}/gen-{gen_id}/segment-{fseg:08}.mgb");
if !std::path::Path::new(&seg_path).exists() {
break;
}
let st = m.import_bundle(&seg_path).map_err(|e| e.to_string())?;
eprintln!("applied segment {fseg} ({} ops, {} skipped)", st.applied, st.skipped);
fseg += 1;
std::fs::write(&fcur_path, format!("{gen_id} {fseg}")).map_err(|e| e.to_string())?;
}
}
if once {
break;
}
std::thread::sleep(std::time::Duration::from_millis(interval));
}
}
"restore" => {
let from = need(&flags, "from")?;
let until: Option<i64> = flag(&flags, "until-hlc").and_then(|v| v.parse().ok());
let cursor = std::fs::read_to_string(format!("{from}/CURSOR"))
.map_err(|_| "no CURSOR in stream dir".to_string())?;
let gen_id = cursor.trim().split(' ').next().unwrap_or("").to_string();
let dir = format!("{from}/gen-{gen_id}");
let mut segs: Vec<String> = std::fs::read_dir(&dir)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok().map(|e| e.path().display().to_string()))
.filter(|p| p.ends_with(".mgb"))
.collect();
segs.sort();
let mut applied = 0usize;
for s in &segs {
let st = m.import_bundle_until(s, until).map_err(|e| e.to_string())?;
applied += st.applied;
}
println!("restored {} ops from {} segments (gen {gen_id})", applied, segs.len());
}
"bundle" => {
let out = need(&flags, "out")?;
let since: i64 = flag(&flags, "since").and_then(|v| v.parse().ok()).unwrap_or(0);
let st = m.bundle_since(since, &out).map_err(|e| e.to_string())?;
println!(
"bundled {} ops ({} bytes) → {} (next --since {})",
st.ops, st.bytes, out, st.last_op_seq
);
}
"import" => {
let bundle = need(&flags, "bundle")?;
let st = m.import_bundle(&bundle).map_err(|e| e.to_string())?;
println!("applied {} ops, skipped {}", st.applied, st.skipped);
}
"migrate" => {
let from = need(&flags, "from")?;
let file = need(&flags, "file")?;
let deferred = m.defer_text_index().map_err(|e| e.to_string())?;
let rep = run_migrate(&mut m, &ns, &from, &file, flag(&flags, "history"));
if deferred {
m.rebuild_text_index().map_err(|e| e.to_string())?;
}
let rep = rep?;
for n in &rep.notes {
eprintln!("deja: migrate: {n}");
}
println!("{}", rep.to_json());
}
"reindex" => {
let n = m.rebuild_text_index().map_err(|e| e.to_string())?;
println!("text index rebuilt ({n} rows backfilled)");
}
"verify" => {
let rep = m.verify().map_err(|e| e.to_string())?;
println!(
"integrity: {} | grains: {} | hash mismatches: {} | undecodable: {}",
rep.integrity, rep.grains, rep.hash_mismatches, rep.undecodable
);
if rep.integrity != "ok" || rep.hash_mismatches > 0 || rep.undecodable > 0 {
return Err("verification FAILED".to_string());
}
}
"stats" => {
let s = m.stats().map_err(|e| e.to_string())?;
println!(
"grains: {} ({} current) | triples: {} | terms: {} | ops: {} | thread-indexed events: {}",
s.grains, s.current, s.triples, s.terms, s.ops, s.events_indexed
);
}
"serve" => {
if !flags.contains_key("mcp") {
return Err("only --mcp transport is available (deja serve --mcp)".to_string());
}
let mut facade = dejadb_cal::DejaDbFacade::with_session(m, Some(ns), None);
if let Some(spec) = flag(&flags, "mount") {
for entry in spec.split(',').map(str::trim).filter(|e| !e.is_empty()) {
let (alias, path) = entry
.split_once('=')
.map(|(a, p)| (a.trim(), p.trim()))
.filter(|(a, p)| !a.is_empty() && !p.is_empty())
.ok_or_else(|| format!("--mount expects alias=path, got '{entry}'"))?;
let store =
DejaDB::open(path).map_err(|e| format!("mount '{alias}' ({path}): {e}"))?;
eprintln!("deja: mounted '{alias}' (read-only) → {path}");
facade.mount(alias, store);
}
}
let mut server = dejadb_mcp::McpServer::new(facade, None);
if flags.contains_key("no-destructive-ops") {
server = server.allow_destructive_ops(false);
eprintln!("deja: destructive operations disabled (read-only session)");
}
if let Some(lock) = flag(&flags, "lock-ns") {
server = server.lock_namespace(lock.clone());
eprintln!("deja: namespace locked to '{lock}' (per-call namespace ignored)");
}
server.serve_stdio().map_err(|e| e.to_string())?;
}
"capture-stop" => {
use std::io::Read as IoRead;
let mut input = String::new();
std::io::stdin().read_to_string(&mut input).map_err(|e| e.to_string())?;
let hook: serde_json::Value =
serde_json::from_str(&input).map_err(|e| format!("bad hook json: {e}"))?;
let session = hook["session_id"].as_str().unwrap_or("unknown-session").to_string();
let tpath = hook["transcript_path"]
.as_str()
.ok_or("hook json missing transcript_path")?;
let transcript = std::fs::read_to_string(tpath).map_err(|e| e.to_string())?;
let mut last: std::collections::HashMap<String, String> = Default::default();
for line in transcript.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
let role = v["message"]["role"].as_str().unwrap_or("");
if role != "user" && role != "assistant" {
continue;
}
let text = render_transcript_content(&v["message"]["content"]);
if !text.trim().is_empty() {
last.insert(role.to_string(), text);
}
}
let mut stored = 0;
for role in ["user", "assistant"] {
if let Some(text) = last.get(role) {
let mut e = dejadb_core::types::Event::new(text);
e.common.namespace = Some(ns.clone());
e.session_id = Some(session.clone());
e.role = dejadb_core::types::Role::from_str(role);
m.add(&e).map_err(|e| e.to_string())?;
stored += 1;
}
}
println!("captured {stored} events for session {session}");
}
"recall-hook" => {
use std::io::Read as IoRead;
let mut input = String::new();
std::io::stdin().read_to_string(&mut input).map_err(|e| e.to_string())?;
let hook: serde_json::Value =
serde_json::from_str(&input).unwrap_or(serde_json::Value::Null);
let query = hook["prompt"].as_str().unwrap_or("").trim().to_string();
if query.is_empty() {
return Ok(());
}
let k: usize = flag(&flags, "k").and_then(|v| v.parse().ok()).unwrap_or(5);
let grains = m
.recall_hybrid(&ns, None, None, Some(&query), k, None)
.map_err(|e| e.to_string())?;
if grains.is_empty() {
return Ok(());
}
use dejadb_context::{ContextAssembler, FormatPolicy};
let mut policy = FormatPolicy::claude();
policy.token_budget =
Some(flag(&flags, "budget").and_then(|v| v.parse().ok()).unwrap_or(400));
let hits: Vec<dejadb_cal::store_types::SearchHit> = grains
.into_iter()
.map(|grain| {
let hash = grain.hash;
dejadb_cal::store_types::SearchHit {
grain,
score: 1.0,
hash,
score_breakdown: None,
explanation: None,
scope_depth: None,
source_namespace: None,
relative_time: None,
conflict_status: None,
supersession_status: None,
superseded_by_hash: None,
recall_source: None,
}
})
.collect();
let ctx = ContextAssembler::new().format(&hits, &policy);
if !ctx.text.trim().is_empty() {
println!("Relevant memory from DejaDB:\n{}", ctx.text);
}
}
"remember" => {
let content = need(&flags, "content")?;
let observer = flag(&flags, "observer").unwrap_or_else(|| "cli".to_string());
let drafts: Vec<dejadb_store::FactDraft> = match flag(&flags, "facts") {
Some(j) => {
let arr: Vec<serde_json::Value> =
serde_json::from_str(&j).map_err(|e| format!("bad --facts: {e}"))?;
arr.iter()
.map(|v| dejadb_store::FactDraft {
subject: v["subject"].as_str().unwrap_or("").to_string(),
relation: v["relation"].as_str().unwrap_or("").to_string(),
object: v["object"].as_str().unwrap_or("").to_string(),
confidence: v["confidence"].as_f64().unwrap_or(0.8),
})
.collect()
}
None => Vec::new(),
};
let extractor = move |_c: &str| drafts.clone();
let res = m
.remember(&ns, &content, &observer, Some(&extractor))
.map_err(|e| e.to_string())?;
println!(
"{}",
serde_json::json!({
"observation": res.observation.to_hex(),
"facts": res.facts.iter().map(|h| h.to_hex()).collect::<Vec<_>>(),
})
);
}
"memtool" => {
let cmd = positional
.first()
.ok_or_else(|| "usage: deja memtool '<json>' --db <file>".to_string())?;
let cmd: serde_json::Value =
serde_json::from_str(cmd).map_err(|e| format!("bad command json: {e}"))?;
let mut t = dejadb_store::memory_tool::MemoryTool::new(&mut m, &ns);
println!("{}", t.execute(&cmd).map_err(|e| e.to_string())?);
}
"repl" => {
use std::io::{BufRead, Write as IoWrite};
let facade = dejadb_cal::DejaDbFacade::with_session(m, Some(ns.clone()), None);
let ex = CalExecutor::new(CalExecutorConfig {
allow_destructive_ops: !flags.contains_key("no-destructive-ops"),
..CalExecutorConfig::default()
});
eprintln!("deja repl — namespace '{ns}' · CAL statements, or .stats .log .help .quit");
let stdin = std::io::stdin();
let mut lines = stdin.lock().lines();
loop {
eprint!("cal> ");
std::io::stderr().flush().ok();
let line = match lines.next() {
Some(Ok(l)) => l,
_ => break,
};
let line = line.trim();
match line {
"" => continue,
".quit" | ".exit" => break,
".help" => eprintln!(
"CAL: RECALL / ASSEMBLE / EXISTS / HISTORY / ADD / SUPERSEDE / DESCRIBE / | COUNT\n\
dot: .stats .log .verify .quit"
),
".stats" => match facade.with_store(|st| st.stats()) {
Ok(s) => eprintln!(
"grains {} ({} current) · triples {} · ops {}",
s.grains, s.current, s.triples, s.ops
),
Err(e) => eprintln!("error: {e}"),
},
".log" => match facade.with_store(|st| st.changes_since(0, 20)) {
Ok(ops) => {
for o in ops {
eprintln!("{:>5} {:<9} {}", o.op_seq, o.op, o.hash.to_hex());
}
}
Err(e) => eprintln!("error: {e}"),
},
".verify" => match facade.with_store(|st| st.verify()) {
Ok(r) => eprintln!(
"integrity {} · {} grains · {} mismatches",
r.integrity, r.grains, r.hash_mismatches
),
Err(e) => eprintln!("error: {e}"),
},
q => match ex.execute(q, &facade) {
Ok(res) => {
println!(
"{}",
serde_json::to_string_pretty(&res.result).unwrap_or_default()
);
for w in res.warnings {
eprintln!("warning: {w}");
}
}
Err(e) => eprintln!("error: {e}"),
},
}
}
}
"ui" => {
let addr = flag(&flags, "addr").unwrap_or_else(|| "127.0.0.1:7437".to_string());
let allow_remote = flag(&flags, "allow-remote").is_some();
let auth_token = match flag(&flags, "token-env") {
Some(var) => {
let t = std::env::var(&var).map_err(|_| {
format!("--token-env {var}: environment variable is not set")
})?;
if t.trim().is_empty() {
return Err(format!("--token-env {var}: token is empty"));
}
Some(t)
}
None => None,
};
let has_auth = auth_token.is_some();
if !addr_is_loopback(&addr) && !allow_remote {
let why = if has_auth {
"It is authenticated, but still plaintext HTTP — the token and all \
memory cross the wire in the clear. Terminate TLS in front of it, \
or pass --allow-remote to accept that risk."
} else {
"With no --token-env it is an UNAUTHENTICATED, writable console over \
plaintext HTTP — anyone who can reach it could read or modify your \
memory. Add --token-env <VAR>, keep it on loopback, put it behind a \
TLS-terminating proxy, or pass --allow-remote to override."
};
return Err(format!(
"refusing to bind the console to a non-loopback address ({addr}). {why}"
));
}
let facade = dejadb_cal::DejaDbFacade::with_session(m, Some(ns), None);
let mut server = dejadb_server::UiServer::new(facade, db.clone());
if allow_remote {
server = server.allow_remote(true);
}
if flags.contains_key("no-destructive-ops") {
server = server.allow_destructive_ops(false);
eprintln!("deja: destructive operations disabled (read-only console)");
}
if let Some(tok) = auth_token {
server = server.with_auth(tok);
eprintln!(
"deja: console authentication ENABLED — HTTP Basic \
(any username, password = the token)"
);
} else {
eprintln!("deja: console is UNAUTHENTICATED (enable with --token-env <VAR>)");
}
let listener = dejadb_server::UiServer::bind(&addr).map_err(|e| e.to_string())?;
if !addr_is_loopback(&addr) {
eprintln!(
"deja: WARNING — bound to non-loopback {addr} over plaintext HTTP; {}",
if has_auth {
"the token crosses the wire in the clear — use a TLS-terminating proxy."
} else {
"UNAUTHENTICATED — anyone who can reach it can read/modify memory."
}
);
}
eprintln!(
"deja console → http://{} (Ctrl-C to stop)",
listener.local_addr().map_err(|e| e.to_string())?
);
server.serve(listener).map_err(|e| e.to_string())?;
}
"get" => {
let h = positional
.first()
.ok_or_else(|| "usage: deja get <hash> --db <file>".to_string())?;
let hash = Hash::from_hex(h).map_err(|e| e.to_string())?;
let g = m.get(&hash).map_err(|e| e.to_string())?;
println!(
"{}",
serde_json::json!({
"hash": g.hash.to_hex(),
"type": format!("{:?}", g.grain_type).to_lowercase(),
"fields": g.fields,
})
);
}
"provenance" => {
let h = flag(&flags, "of")
.or_else(|| positional.first().cloned())
.ok_or_else(|| {
"usage: deja provenance <source-hash> --db <file> \
(lists grains whose derived_from is that hash)"
.to_string()
})?;
let h = h.strip_prefix("sha256:").unwrap_or(&h);
let parent = Hash::from_hex(h).map_err(|e| e.to_string())?;
let kids = m.grains_derived_from(&parent).map_err(|e| e.to_string())?;
for g in kids {
println!(
"{}",
serde_json::json!({
"hash": g.hash.to_hex(),
"type": format!("{:?}", g.grain_type).to_lowercase(),
"subject": g.get_str("subject"),
"relation": g.get_str("relation"),
"object": g.get_str("object"),
})
);
}
}
"forks" => {
let forks = m.open_forks().map_err(|e| e.to_string())?;
if forks.is_empty() {
eprintln!("no open forks");
}
for f in forks {
println!(
"{}",
serde_json::json!({
"namespace": f.namespace,
"subject": f.subject,
"relation": f.relation,
"heads": f.heads.iter().map(|h| h.to_hex()).collect::<Vec<_>>(),
})
);
}
}
"merge" => {
let s = need(&flags, "subject")?;
let r = need(&flags, "relation")?;
let o = need(&flags, "object")?;
let conf: f64 = flag(&flags, "confidence")
.map(|c| c.parse().map_err(|_| "bad --confidence".to_string()))
.transpose()?
.unwrap_or(0.9);
let mut merged = Fact::new(&s, &r, &o).confidence(conf);
merged.common.namespace = Some(ns.clone());
let h = m
.merge_heads(&ns, &s, &r, &mut merged)
.map_err(|e| e.to_string())?;
println!("{h}");
}
"novelty" => {
let text = need(&flags, "text")?;
let subject = flag(&flags, "subject");
let relation = flag(&flags, "relation");
let k: usize = flag(&flags, "k").and_then(|v| v.parse().ok()).unwrap_or(5);
let matches = m
.nearest_semantic(&ns, subject.as_deref(), relation.as_deref(), &text, k)
.map_err(|e| e.to_string())?;
for (h, sim) in matches {
let object = m.get(&h).ok().and_then(|g| g.get_str("object").map(String::from));
println!(
"{}",
serde_json::json!({
"hash": h.to_hex(),
"similarity": (sim * 1000.0).round() / 1000.0,
"object": object,
})
);
}
}
other => return Err(format!("unknown command '{other}' — try `deja help`")),
}
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("deja: {e}");
ExitCode::FAILURE
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args(a: &[&str]) -> (HashMap<String, String>, Vec<String>) {
parse_args(&a.iter().map(|s| s.to_string()).collect::<Vec<_>>())
}
#[test]
fn valueless_long_flag_does_not_swallow_short_flag() {
let (flags, pos) = args(&["--mcp", "-d", "mem.db", "--ns", "caller"]);
assert_eq!(flags.get("mcp").map(String::as_str), Some("true"));
assert_eq!(flags.get("db").map(String::as_str), Some("mem.db"));
assert_eq!(flags.get("ns").map(String::as_str), Some("caller"));
assert!(pos.is_empty(), "no stray positionals: {pos:?}");
}
#[test]
fn db_flag_equivalent_across_form_and_order() {
for a in [
&["--mcp", "-d", "mem.db"][..],
&["-d", "mem.db", "--mcp"][..],
&["--mcp", "--db", "mem.db"][..],
&["--db", "mem.db", "--mcp"][..],
] {
let (flags, _) = args(a);
assert_eq!(flags.get("db").map(String::as_str), Some("mem.db"), "args: {a:?}");
assert_eq!(flags.get("mcp").map(String::as_str), Some("true"), "args: {a:?}");
}
}
#[test]
fn adjacent_valueless_flags_stay_true() {
let (flags, _) = args(&["--mcp", "--no-destructive-ops"]);
assert_eq!(flags.get("mcp").map(String::as_str), Some("true"));
assert_eq!(flags.get("no-destructive-ops").map(String::as_str), Some("true"));
}
}