Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ jobs:
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo build --workspace
- run: cargo test --workspace --lib --no-fail-fast
- name: Persist and recover authored memory through the CLI
run: cargo test --locked -p terraphim_agent --test memory_capture_cli_tests --test memory_apply_cli_tests --test memory_rubric_cli_tests
# Exercise the released grep command against real files and exported
# synonym graphs, including Linux-only hostile filesystem names.
- run: cargo test --locked -p terraphim_grep --test synonym_retrieval --test answer_request --test path_guard_hostile_tree
Expand Down
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ members = [
]

[workspace.package]
version = "1.21.19"
version = "1.21.20"
edition = "2024"
authors = ["Terraphim Team <team@terraphim.ai>"]
documentation = "https://terraphim.ai"
Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ Consumes upstream crates (core, config-persistence, service, agents, kg-agents)

## Memory Lifecycle

The `terraphim-agent memory` CLI namespace implements the eight-stage agentic memory lifecycle:
The `terraphim-agent memory` namespace saves authored lessons, inspects the
evolution store and retrieves memories through a configured role's knowledge
graph. See [capture and store isolation](docs/memory-capture.md) for a complete
example and the required input change.

```
terraphim-agent memory capture # Write a memory item with provenance tags
terraphim-agent memory distill # Compile learnings into KG entries
terraphim-agent memory capture --content 'Use the project validation command.'
terraphim-agent memory scope # Show role/project KG boundaries
terraphim-agent memory provenance # Search session history
terraphim-agent memory retrieve # Search memory items
terraphim-agent memory apply # Show hook injection effects
terraphim-agent memory validate # Score items against reliability rubric
Expand All @@ -30,4 +31,11 @@ terraphim-agent memory show # Inspect a specific item
terraphim-agent memory export # Dump as JSON or markdown
```

The full eight-stage lifecycle is not qualified by these commands. In the
current implementation, `memory distill` and `memory provenance` only report
status; `memory scope --check` does not verify confidentiality. Use explicit
`learn compile` / `learn export-kg` for the separate reviewed-learning store.
`memory apply` previews context; it does not execute a command. Rubric and
validation results use the named `heuristic-v1` scorer.

See [MEMORY_POLICY.md](MEMORY_POLICY.md) for the public commons vs permissioned memory boundary.
9 changes: 7 additions & 2 deletions crates/terraphim_agent/src/cli_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -782,9 +782,14 @@ pub(crate) enum RobotSub {

#[derive(Subcommand, Debug)]
pub(crate) enum MemorySub {
/// Capture a command or session event as an agentic memory item
/// (writes to evolution store with provenance metadata)
/// Save authored lesson text to the evolution store with provenance metadata
Capture {
/// Exact lesson text to save (mutually exclusive with --file)
#[arg(long, required_unless_present = "file", conflicts_with = "file")]
content: Option<String>,
/// Read the lesson from a UTF-8 file (mutually exclusive with --content)
#[arg(long, required_unless_present = "content", conflicts_with = "content")]
file: Option<std::path::PathBuf>,
/// Provenance tag for traceability (session ID, commit SHA)
#[arg(long)]
provenance_tag: Option<String>,
Expand Down
6 changes: 5 additions & 1 deletion crates/terraphim_agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2505,7 +2505,11 @@ async fn run_cache_command(sub: &CacheSub) -> Result<()> {
}

fn evolution_path() -> std::path::PathBuf {
dirs::config_dir()
// Use the same explicit data root as learning capture. Existing users
// without an override retain the historical config-directory location.
std::env::var_os("TERRAPHIM_DEFAULT_DATA_PATH")
.map(std::path::PathBuf::from)
.or_else(dirs::config_dir)
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("terraphim")
.join("evolution")
Expand Down
26 changes: 20 additions & 6 deletions crates/terraphim_agent/src/memory_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//! `compute_risk`) that only this command uses. Extracted verbatim; only the
//! imports are new.

use anyhow::Result;
use anyhow::{Context, Result};

use terraphim_agent::service::TuiService;

Expand All @@ -20,14 +20,28 @@ pub(crate) async fn run_memory_command(
config_path: Option<String>,
) -> Result<()> {
match sub {
MemorySub::Capture { provenance_tag } => {
MemorySub::Capture {
content,
file,
provenance_tag,
} => {
use terraphim_agent_evolution::{ImportanceLevel, MemoryItem, MemoryItemType};

let content = match (content, file) {
(Some(content), None) => content,
(None, Some(path)) => std::fs::read_to_string(&path).with_context(|| {
format!("Cannot read memory lesson from {}", path.display())
})?,
_ => anyhow::bail!("Provide exactly one of --content or --file"),
};
anyhow::ensure!(
!content.trim().is_empty(),
"Memory lesson must not be blank"
);
// Validate before loading or saving the store. The evolution crate
// enforces the same byte limit when adding the MemoryItem.
anyhow::ensure!(content.len() <= 1_000_000, "Memory lesson exceeds 1 MB");
let mut evolution = load_evolution();
let content = provenance_tag
.as_ref()
.map(|tag| format!("Memory item captured via CLI with provenance: {}", tag))
.unwrap_or_else(|| "Memory item captured via CLI".to_string());
let tags: Vec<String> = provenance_tag
.clone()
.map(|tag| vec![format!("provenance:{}", tag)])
Expand Down
6 changes: 4 additions & 2 deletions crates/terraphim_agent/tests/memory_apply_cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
//! at `tests/test_kg/`) and the evolution store lives under a temp `HOME`.
//! No mocks: the store file is the one the binary writes.
//!
//! `memory capture` writes fixed content with no knowledge-graph term in it,
//! so a captured item alone is never retrieved and the injected size is zero.
//! The baseline captures content with no knowledge-graph term in it,
//! so that item is not retrieved and the injected size is zero.
//! The non-zero case adds an item whose content names the `trash` concept
//! twice (`rm -rf` and `rm -r`), through the real `MemoryState::add_memory`,
//! into the store the CLI created.
Expand Down Expand Up @@ -53,6 +53,8 @@ fn capture_item(root: &Path, tag: &str) -> String {
"json",
"memory",
"capture",
"--content",
"A baseline note with no configured concepts.",
"--provenance-tag",
tag,
],
Expand Down
134 changes: 134 additions & 0 deletions crates/terraphim_agent/tests/memory_capture_cli_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//! Exercise authored memory through the actual CLI and persisted store.

use std::path::Path;
use std::process::{Command, Output};

fn run(root: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_terraphim-agent"))
.args(["--format", "json"])
.args(args)
.env("TERRAPHIM_DEFAULT_DATA_PATH", root)
.current_dir(root)
.output()
.expect("run real agent")
}

fn json(output: Output) -> serde_json::Value {
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).expect("machine-readable result")
}

#[test]
fn capture_preserves_authored_content_across_processes_and_stores() {
let root = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
let content = "Validate the public garden brief with python3 validate.py --brief brief.md.\nKeep the source decisions intact.";
let captured = json(run(
root.path(),
&[
"memory",
"capture",
"--content",
content,
"--provenance-tag",
"public-fixture-v1",
],
));
let id = captured["memory_id"].as_str().unwrap();
let shown = json(run(root.path(), &["memory", "show", id]));
assert_eq!(shown["memory_item"]["content"], content);
assert_eq!(
shown["memory_item"]["tags"][0],
"provenance:public-fixture-v1"
);
let store = root.path().join("terraphim/evolution/cli-agent.json");
assert!(
store.is_file(),
"explicit data root owns the persisted store"
);
let listed = json(run(root.path(), &["memory", "list"]));
assert!(listed.to_string().contains(id));
let exported = json(run(root.path(), &["memory", "export", "--format", "json"]));
assert!(
exported
.to_string()
.contains("Keep the source decisions intact.")
);
let isolated = json(run(other.path(), &["memory", "list"]));
assert!(!isolated.to_string().contains(id));
assert!(
!other
.path()
.join("terraphim/evolution/cli-agent.json")
.exists()
);
}

#[test]
fn capture_reads_utf8_file_without_replacing_content_with_provenance() {
let root = tempfile::tempdir().unwrap();
let input = root.path().join("lesson.md");
let content = "# Validation lesson\nUse the café fixture — keep Ελληνικά intact.\n";
std::fs::write(&input, content).unwrap();
let captured = json(run(
root.path(),
&["memory", "capture", "--file", input.to_str().unwrap()],
));
let shown = json(run(
root.path(),
&["memory", "show", captured["memory_id"].as_str().unwrap()],
));
assert_eq!(shown["memory_item"]["content"], content);
}

#[test]
fn capture_refuses_missing_blank_conflicting_and_unreadable_input() {
for args in [
vec!["memory", "capture"],
vec!["memory", "capture", "--content", " \n\t"],
vec!["memory", "capture", "--file", "missing-lesson.md"],
vec![
"memory",
"capture",
"--content",
"lesson",
"--file",
"lesson.md",
],
] {
let root = tempfile::tempdir().unwrap();
let output = run(root.path(), &args);
assert!(!output.status.success(), "must reject {args:?}");
assert!(
!root
.path()
.join("terraphim/evolution/cli-agent.json")
.exists()
);
}
}

#[test]
fn invalid_files_leave_existing_memories_unchanged() {
let root = tempfile::tempdir().unwrap();
json(run(
root.path(),
&["memory", "capture", "--content", "Keep this authored note."],
));
let store = root.path().join("terraphim/evolution/cli-agent.json");
let before = std::fs::read(&store).unwrap();
for bytes in [vec![0xff, 0xfe], vec![b'a'; 1_000_001], b" \n\t".to_vec()] {
let input = root.path().join("invalid-lesson.md");
std::fs::write(&input, bytes).unwrap();
let output = run(
root.path(),
&["memory", "capture", "--file", input.to_str().unwrap()],
);
assert!(!output.status.success());
assert_eq!(std::fs::read(&store).unwrap(), before);
}
}
2 changes: 2 additions & 0 deletions crates/terraphim_agent/tests/memory_rubric_cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ fn capture_medium_item(home: &Path) -> String {
"json",
"memory",
"capture",
"--content",
"A baseline note for rubric inspection.",
"--provenance-tag",
"rubric-cli-test",
],
Expand Down
41 changes: 41 additions & 0 deletions docs/memory-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Save a lesson and recover it in another process

`memory capture` requires the actual lesson, either as `--content` or a UTF-8
`--file`. A provenance tag identifies the source; it is not a substitute for
the lesson. Blank, unreadable and conflicting inputs fail without writing a
memory. Content is preserved exactly, up to the evolution crate's 1 MB limit.

```sh
export TERRAPHIM_DEFAULT_DATA_PATH="$PWD/.terraphim-demo-data"
terraphim-agent --format json memory capture \
--content 'Validate the garden brief with python3 validate.py --brief brief.md.' \
--provenance-tag public-brief-v1
terraphim-agent --format json memory list
terraphim-agent --format json memory show MEMORY_ID_FROM_CAPTURE
terraphim-agent memory export --format json --output memories.json
```

These commands each start a new process. With the explicit data root, the
evolution store is `.terraphim-demo-data/terraphim/evolution/cli-agent.json`.
Without it, the historical platform configuration-directory path is retained;
existing stores are not moved or imported. Use a different data root for a
separate project store. This is file-location isolation, not an access-control
system or automatic secret redaction.

To save a multiline note, use `memory capture --file lesson.md`. Text and file
input are mutually exclusive. Earlier versions accepted capture with only a
provenance tag and saved placeholder text; callers must now supply content.

Retrieval is a separate operation using the selected role's knowledge graph:

```sh
terraphim-agent --config role-config.json memory retrieve 'validate brief' \
--role 'Brief Reviewer' --format json
```

Configure that role and its graph before retrieval. Saving a lesson does not
create terminology or copy it into the separate `learn` store. Graph retrieval
requires concept co-occurrence in the lesson; a role that does not recognise
the query returns no memories rather than a lexical fallback. A role is a
retrieval configuration, not a confidentiality boundary. `memory apply` is a
preview of proposed context and does not execute commands.
Loading
Loading