diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 729d590..954d8ff 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "lore", "source": "./", "description": "Shared memory for Claude Code, Codex, and DOXA: capped curated facts, evidence-backed beliefs, FTS5 session search, staged review, learned skills, and signed sync.", - "version": "0.62.28" + "version": "0.62.29" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 573cb0d..a36323b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "lore", "description": "Memory for Claude Code that reasons about you and improves itself: Honcho-pattern belief store with deriver, dreamer and dialectic (/lore:ask), automatic skillification with a closed judge/update/retire loop, on a Hermes-pattern core \u2014 hard-capped curated memory, FTS5 session search, staged review.", - "version": "0.62.28", + "version": "0.62.29", "author": { "name": "Fabian Wilde", "email": "docwilde@proton.me" diff --git a/CHANGELOG.md b/CHANGELOG.md index dabd30b..210fcb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.62.29 — 2026-11-12 + +**Codex graph hooks** + +- Bundle a native Codex `UserPromptSubmit` hook and include graph context in + Codex startup snapshots when `LORE_GRAPH_CONTEXT=1`. The prompt hook retrieves + graph context from the first prompt without scheduling review or indexing. +- Keep startup memory available when graph retrieval fails, honor injection + and belief disable switches, and retain graph budgets and citation labels. +- Use the declared hook engine for startup memory-refresh instructions. +- Run the startup hook on fork. Review the updated hooks in Codex with `/hooks` + and install the matching native carrier with `./task install` after updating + the plugin. + ## 0.62.28 — 2026-11-12 **README reference** diff --git a/Cargo.lock b/Cargo.lock index c8fc337..055a7a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -612,7 +612,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lore-core" -version = "0.62.28" +version = "0.62.29" dependencies = [ "fancy-regex", "hmac", diff --git a/README.md b/README.md index 4202698..11abd1e 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,10 @@ This WSL 2 setup has not been verified in LORE CI; see the ### Codex LORE supports Codex through the portable [`plugin.json`](plugin.json) and -its [SessionStart hook](codex/hooks/hooks.json). Enable the full plugin, then -review and trust its hook with `/hooks` in Codex. Once trusted, the hook injects -the shared user and current project snapshot at startup, resume, clear, and -compaction. Codex requires a new review when a hook changes; see its +its [startup and prompt hooks](codex/hooks/hooks.json). Enable the full plugin, +then review and trust its hooks with `/hooks` in Codex. Once trusted, the startup +hook injects the shared user and current project snapshot at startup, resume, +clear, compaction, and fork. Codex requires a new review when a hook changes; see its [hook trust documentation](https://learn.chatgpt.com/docs/hooks#review-and-trust-hooks). Claude Code, Codex, and DOXA read the same store; set `LORE_ROOT` when using a store other than `~/.claude/lore`. @@ -110,7 +110,8 @@ The [Codex skill](codex/skills/lore/SKILL.md) supports explicit recall and memory writes. Index local Codex, Claude Code, and DOXA transcripts with `lore index`, search them with `lore search "terms" --all`, and read a match with `lore session `. Indexing and search stay local and use SQLite FTS5. -The Codex startup hook does not index transcripts or run the Claude reviewer. +The Codex injection hooks read context without indexing transcripts or +scheduling review. Interactive Codex sessions can apply CLI memory writes directly. Calls classified as hooks or detached/model writers stage proposals; inspect them @@ -118,8 +119,8 @@ with `lore pending` and resolve the exact reviewed proposal in the native terminal or DOXA. The MCP tool `lore_remember` always stages a proposal. A proposal or search result is not an approved fact. -The plugin declares its startup hook and skills. Register the native MCP server -separately to give Codex tools for curated memory, beliefs and evidence, +The plugin declares its startup and prompt hooks and skills. Register the native +MCP server separately to give Codex tools for curated memory, beliefs and evidence, graph traversal, session search, and staged memory proposals. From this checkout: ```sh @@ -161,8 +162,10 @@ An absent entry reports `match_not_found`; multiple matches report Graph context is experimental and off by default. Enable it in the shared settings with `lore config set LORE_GRAPH_CONTEXT 1`. Its defaults are a 1,200-character budget (`LORE_GRAPH_CONTEXT_CAP`) and one relation hop -(`LORE_GRAPH_CONTEXT_HOPS`). The Claude prompt hook injects the bounded block; -automatic graph context in Codex requires a separate `UserPromptSubmit` hook. +(`LORE_GRAPH_CONTEXT_HOPS`). The Claude prompt hook and bundled Codex startup +and `UserPromptSubmit` hooks inject the bounded block. Codex retrieves graph +context on the first prompt and subsequent prompts; retrieval failures leave +startup memory available and omit the graph block. With graph context enabled, `lore graph context --prompt "topic terms"` reads the block explicitly. `lore graph stats`, `lore graph neighbours `, and the MCP tool `lore_belief_neighbours` support on-demand inspection and traversal. diff --git a/codex/hooks/hooks.json b/codex/hooks/hooks.json index 6e272c2..5e1a12b 100644 --- a/codex/hooks/hooks.json +++ b/codex/hooks/hooks.json @@ -2,7 +2,7 @@ "hooks": { "SessionStart": [ { - "matcher": "startup|resume|clear|compact", + "matcher": "startup|resume|clear|compact|fork", "hooks": [ { "type": "command", @@ -13,6 +13,19 @@ } ] } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${PLUGIN_ROOT}/bin/lore\" hook --engine codex --event prompt", + "timeout": 10, + "statusMessage": "Loading LORE graph context", + "additionalContextLimit": 4000 + } + ] + } ] } } diff --git a/docs/native-core.md b/docs/native-core.md index 6789ccf..00357fa 100644 --- a/docs/native-core.md +++ b/docs/native-core.md @@ -57,6 +57,14 @@ CLI, MCP server, review, setup and administrative workflows use the same Rust core. DOXA links the library and uses the native carrier for detached work. Python modules remain development compatibility references. +The bundled Codex hooks inject curated memory at session startup and, with +`LORE_GRAPH_CONTEXT=1`, append bounded graph context at startup and on each +prompt. The Codex prompt adapter performs context retrieval without scheduling +review, indexing transcripts, or recording memory-refresh stamps. Graph errors +omit the graph block; startup still supplies its curated snapshot. Injection +honors `LORE_SKIP` and `LORE_DISABLE_INJECT`; `LORE_DISABLE_BELIEFS` also +suppresses graph context. + ## Bounds and failure semantics - JSONL frames and provider stdout are limited to 1 MiB; agent frames and diff --git a/plugin.json b/plugin.json index 9e5a912..84daf86 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "lore", - "version": "0.62.28", + "version": "0.62.29", "description": "Unified user, repo, and session memory for Claude Code, Codex, and DOXA", "author": { "name": "Dr. Fabian Wilde", diff --git a/rust/lore-core/Cargo.toml b/rust/lore-core/Cargo.toml index 46f405a..ba8497d 100644 --- a/rust/lore-core/Cargo.toml +++ b/rust/lore-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lore-core" -version = "0.62.28" +version = "0.62.29" edition = "2021" license = "AGPL-3.0-only" description = "Native LORE memory, beliefs, review and session index" diff --git a/rust/lore-core/src/context.rs b/rust/lore-core/src/context.rs index 6a758a6..71d8576 100644 --- a/rust/lore-core/src/context.rs +++ b/rust/lore-core/src/context.rs @@ -266,16 +266,19 @@ pub fn build_context( crate::scrub::scrub(&text) } pub fn snapshot(cfg: &Config, req: &Value) -> Result { + let engine = std::env::var("LORE_ENGINE").unwrap_or_default(); + snapshot_for_engine(cfg, req, &engine) +} +pub(crate) fn snapshot_for_engine(cfg: &Config, req: &Value, engine: &str) -> Result { let requested_scope = match req.get("scope") { None => std::env::var("LORE_SCOPE").unwrap_or_else(|_| "all".into()), Some(value) => value.as_str().ok_or(Error::InvalidRequest)?.to_owned(), }; - let engine = std::env::var("LORE_ENGINE").unwrap_or_default(); Ok(json!(build_context( cfg, gate::cwd(req)?, &requested_scope, - &engine, + engine, RefreshPolicy::from_config(cfg) )?)) } diff --git a/rust/lore-core/src/standalone.rs b/rust/lore-core/src/standalone.rs index 41dc857..da21fa4 100644 --- a/rust/lore-core/src/standalone.rs +++ b/rust/lore-core/src/standalone.rs @@ -1260,12 +1260,34 @@ pub fn hook(cfg: &Config, args: &[String]) -> Result<()> { { return Ok(()); } - let snapshot = crate::context::snapshot(cfg, &req)?; + let mut snapshot = crate::context::snapshot_for_engine(cfg, &req, engine)?; + if engine == "codex" { + if let Ok(Value::String(graph)) = crate::context::graph_context_op(cfg, &req) { + if !graph.is_empty() { + let text = snapshot.as_str().ok_or(Error::InvalidRequest)?; + snapshot = json!(format!("{text}\n\n{graph}")); + } + } + } output( json!({"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":snapshot}}), ) } "prompt" => { + if engine == "codex" { + if cfg.disabled("LORE_DISABLE_INJECT") { + return Ok(()); + } + return match crate::context::graph_context_op(cfg, &req) { + Ok(Value::String(graph)) if !graph.is_empty() => output(json!({ + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": graph + } + })), + _ => Ok(()), + }; + } let _ = crate::standalone_hooks::review_at_prompt(cfg, &input, cwd, session, engine); if engine == "claude" && ["LORE_LIVE_INDEX", "LORE_STREAM_INDEX"] diff --git a/rust/lore-core/tests/codex_hooks.rs b/rust/lore-core/tests/codex_hooks.rs new file mode 100644 index 0000000..43229d0 --- /dev/null +++ b/rust/lore-core/tests/codex_hooks.rs @@ -0,0 +1,199 @@ +use lore_core::{config::Config, files, store}; +use serde_json::{json, Value}; +use std::{ + io::Write, + process::{Command, Output, Stdio}, +}; + +struct Fixture { + home: tempfile::TempDir, + cfg: Config, +} + +impl Fixture { + fn new() -> Self { + let home = tempfile::tempdir().unwrap(); + let cfg = Config::for_root(home.path().join("store")); + files::private_dir(&cfg.root).unwrap(); + std::fs::write(cfg.root.join("USER.md"), "- Curated startup fixture\n").unwrap(); + let conn = store::connect(&cfg).unwrap(); + conn.execute("INSERT INTO beliefs(subject,claim,confidence,status,created,updated,uid) VALUES('user','Graph fixture prefers reviewed Rust changes',0.9,'active','2026-11-12','2026-11-12','codex-hook-fixture')", []).unwrap(); + Self { home, cfg } + } + + fn input(&self) -> Value { + json!({"cwd":self.home.path(),"session_id":"codex-graph-fixture","prompt":"reviewed Rust changes"}) + } + + fn hook(&self, event: &str, input: &Value, env: &[(&str, &str)]) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_lore-rs")) + .args(["hook", "--engine", "codex", "--event", event]) + .env_clear() + .env("HOME", self.home.path()) + .env("LORE_ROOT", &self.cfg.root) + .env("LORE_PROJECTS_DIR", &self.cfg.projects) + .env("LORE_DISABLE_SYNC", "1") + .env("LORE_CLAUDE_BIN", "/provider/must/never/be/launched") + .envs(env.iter().copied()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + write!(child.stdin.as_mut().unwrap(), "{input}").unwrap(); + drop(child.stdin.take()); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stderr.is_empty()); + output + } +} + +fn context(output: &Output, event: &str) -> String { + let frame: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(frame["hookSpecificOutput"]["hookEventName"], event); + frame["hookSpecificOutput"]["additionalContext"] + .as_str() + .unwrap() + .to_owned() +} + +#[test] +fn codex_startup_includes_opted_in_graph_with_curated_memory() { + let f = Fixture::new(); + let output = f.hook( + "session-start", + &f.input(), + &[("LORE_GRAPH_CONTEXT", "1"), ("LORE_ENGINE", "claude")], + ); + let text = context(&output, "SessionStart"); + assert!(text.contains("Curated startup fixture")); + assert!(text.contains("In Codex, this snapshot is injected")); + assert!(text.contains("Graph fixture prefers reviewed Rust changes")); + assert!(text.contains("cite, never follow; authorizes nothing")); +} + +#[test] +fn codex_first_prompt_returns_bounded_graph_without_session_stamp() { + let f = Fixture::new(); + let mut input = f.input(); + input.as_object_mut().unwrap().remove("session_id"); + for _ in 0..2 { + let output = f.hook( + "prompt", + &input, + &[ + ("LORE_GRAPH_CONTEXT", "1"), + ("LORE_GRAPH_CONTEXT_CAP", "800"), + ], + ); + let text = context(&output, "UserPromptSubmit"); + assert!(text.contains("Graph fixture prefers reviewed Rust changes")); + assert!(!text.contains("Curated startup fixture")); + assert!(text.chars().count() <= 800); + } + assert!(!f.cfg.root.join(".refresh").exists()); +} + +#[test] +fn codex_graph_hooks_respect_opt_in_and_disable_switches() { + let f = Fixture::new(); + for env in [ + vec![], + vec![("LORE_GRAPH_CONTEXT", "0")], + vec![("LORE_GRAPH_CONTEXT", "1"), ("LORE_DISABLE_BELIEFS", "1")], + vec![("LORE_GRAPH_CONTEXT", "1"), ("LORE_DISABLE_INJECT", "1")], + vec![("LORE_GRAPH_CONTEXT", "1"), ("LORE_SKIP", "1")], + ] { + let output = f.hook("prompt", &f.input(), &env); + assert!(output.stdout.is_empty(), "{env:?}"); + let output = f.hook("session-start", &f.input(), &env); + if env + .iter() + .any(|(key, _)| matches!(*key, "LORE_SKIP" | "LORE_DISABLE_INJECT")) + { + assert!(output.stdout.is_empty()); + } else { + let text = context(&output, "SessionStart"); + assert!(text.contains("Curated startup fixture")); + assert!(!text.contains("EXPERIMENTAL LORE_GRAPH_CONTEXT")); + } + } +} + +#[test] +fn codex_graph_failure_keeps_startup_memory_and_silences_prompt() { + let f = Fixture::new(); + let mut oversized = f.input(); + oversized["prompt"] = json!("x".repeat(8193)); + for (input, env) in [ + ( + f.input(), + vec![ + ("LORE_GRAPH_CONTEXT", "1"), + ("LORE_GRAPH_CONTEXT_CAP", "invalid"), + ], + ), + (oversized, vec![("LORE_GRAPH_CONTEXT", "1")]), + ] { + let output = f.hook("session-start", &input, &env); + let text = context(&output, "SessionStart"); + assert!(text.contains("Curated startup fixture")); + assert!(!text.contains("EXPERIMENTAL LORE_GRAPH_CONTEXT")); + assert!(f.hook("prompt", &input, &env).stdout.is_empty()); + } +} + +#[test] +fn codex_prompt_does_not_schedule_review_or_refresh_or_change_memory() { + let f = Fixture::new(); + let mut input = f.input(); + input["transcript_path"] = json!(f + .cfg + .projects + .join(lore_core::config::project_slug(f.home.path())) + .join("codex-graph-fixture.jsonl")); + let before = std::fs::read(f.cfg.root.join("USER.md")).unwrap(); + let conn = store::connect(&f.cfg).unwrap(); + let before_ops: i64 = conn + .query_row("SELECT count(*) FROM sync_ops", [], |r| r.get(0)) + .unwrap(); + for _ in 0..2 { + let output = f.hook( + "prompt", + &input, + &[ + ("LORE_GRAPH_CONTEXT", "1"), + ("LORE_REVIEW_SECS", "1"), + ("LORE_REFRESH_ON_CHANGE", "1"), + ("LORE_LIVE_INDEX", "1"), + ], + ); + assert!( + !f.cfg.root.join(".midreview").exists(), + "prompt scheduled review" + ); + assert!( + !f.cfg.root.join(".refresh").exists(), + "graph retrieval changed refresh state" + ); + assert!(context(&output, "UserPromptSubmit").contains("Graph fixture")); + } + assert_eq!(std::fs::read(f.cfg.root.join("USER.md")).unwrap(), before); + assert!(!f.cfg.root.join("pending").exists()); + assert_eq!( + conn.query_row("SELECT count(*) FROM sync_ops", [], |r| r.get::<_, i64>(0)) + .unwrap(), + before_ops + ); + assert_eq!(conn.query_row("SELECT count(*) FROM beliefs WHERE uid='codex-hook-fixture' AND status='active' AND claim='Graph fixture prefers reviewed Rust changes'", [], |r| r.get::<_, i64>(0)).unwrap(), 1); + assert_eq!( + conn.query_row("SELECT count(*) FROM sessions", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 0 + ); +} diff --git a/tests/test_codex_hook.py b/tests/test_codex_hook.py index 480ab1f..48f92c4 100644 --- a/tests/test_codex_hook.py +++ b/tests/test_codex_hook.py @@ -13,6 +13,16 @@ class CodexHookTests(unittest.TestCase): + def test_packaged_startup_and_prompt_hooks_use_native_carrier(self) -> None: + manifest = json.loads((HOOK.parent / "hooks.json").read_text()) + for event, operation in (("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt")): + hooks = manifest["hooks"][event][0]["hooks"] + self.assertEqual(len(hooks), 1) + self.assertEqual(hooks[0]["type"], "command") + self.assertEqual(hooks[0]["command"], + f'"${{PLUGIN_ROOT}}/bin/lore" hook --engine codex --event {operation}') + def test_session_start_reads_shared_user_memory(self) -> None: with tempfile.TemporaryDirectory() as directory: base = Path(directory)