Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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 Cargo.lock

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

125 changes: 122 additions & 3 deletions crates/jcode-app-core/src/tool/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ struct MemoryInput {
/// For recall action: retrieval mode
#[serde(default)]
mode: Option<String>,
/// For remember: repo-relative source path the fact was drawn from.
/// With source_start_line/source_end_line the tool hashes the exact
/// span at bank time and attaches a citation (fail-soft: uncited when
/// the span is unreadable).
#[serde(default)]
source_path: Option<String>,
/// For remember: 0-based start line of the cited span (inclusive).
#[serde(default)]
source_start_line: Option<usize>,
/// For remember: 0-based end line of the cited span (exclusive).
#[serde(default)]
source_end_line: Option<usize>,
}

#[async_trait]
Expand Down Expand Up @@ -119,7 +131,10 @@ impl Tool for MemoryTool {
"scope": { "type": "string", "enum": ["project", "global", "all"] },
"from_id": { "type": "string" },
"to_id": { "type": "string" },
"limit": { "type": "integer", "minimum": 0, "description": "Max results for recall, search, or list. Zero returns no results. Recall defaults to 10." }
"limit": { "type": "integer", "minimum": 0, "description": "Max results for recall, search, or list. Zero returns no results. Recall defaults to 10." },
"source_path": { "type": "string", "description": "For remember: repo-relative source path to cite." },
"source_start_line": { "type": "integer", "description": "For remember: 0-based start line of the cited span." },
"source_end_line": { "type": "integer", "description": "For remember: 0-based end line of the cited span." }
},
"required": ["action"]
})
Expand Down Expand Up @@ -155,6 +170,29 @@ impl Tool for MemoryTool {
if let Some(tags) = input.tags {
entry = entry.with_tags(tags);
}
// Cite only exact, bank-time-verified spans. Anything else
// (no path, unreadable span, escape) stores uncited and says
// so, so the verification path never covers fuzzy provenance.
let mut citation_note = String::new();
if let (Some(path), Some(start), Some(end)) =
(input.source_path, input.source_start_line, input.source_end_line)
{
let anchor = ctx
Comment thread
greptile-apps[bot] marked this conversation as resolved.
.working_dir
.as_deref()
.map(crate::memory_types::find_repo_root)
.unwrap_or_else(|| std::path::PathBuf::from("."));
match crate::memory_types::SourceCitation::bank(&anchor, &path, start, end) {
Some(citation) => {
entry = entry.with_citation(citation);
}
None => {
citation_note = format!(
" (no citation attached: unreadable span {path}:{start}-{end})"
);
}
}
}
let id = if scope == "global" {
manager.remember_global(entry)?
} else {
Expand All @@ -175,8 +213,8 @@ impl Tool for MemoryTool {
});
memory::set_state(MemoryState::Idle);
Ok(ToolOutput::new(format!(
"Remembered {} ({}): \"{}\" [id: {}]",
category, scope, content, id
"Remembered {} ({}): \"{}\" [id: {}]{}",
category, scope, content, id, citation_note
)))
}
"recall" => {
Expand Down Expand Up @@ -489,6 +527,9 @@ mod tests {
assert!(!props.contains_key("weight"));
assert!(!props.contains_key("depth"));
assert!(!props.contains_key("mode"));
assert!(props.contains_key("source_path"));
assert!(props.contains_key("source_start_line"));
assert!(props.contains_key("source_end_line"));
}

fn test_ctx(working_dir: Option<std::path::PathBuf>) -> ToolContext {
Expand Down Expand Up @@ -684,6 +725,84 @@ mod tests {
/// Issue #491 regression: project-scoped remember followed by list must
/// round-trip through the real (non-test-mode) manager when the tool
/// context carries a working dir.
#[tokio::test]
async fn remember_attaches_citation_for_exact_span() {
let _guard = crate::storage::lock_test_env();
let home = tempfile::tempdir().expect("home");
let project = tempfile::tempdir().expect("project");
let prev_home = std::env::var_os("JCODE_HOME");
crate::env::set_var("JCODE_HOME", home.path());
std::fs::write(project.path().join("src.rs"), "fn cited() {}\nlet x = 1;\n").unwrap();

let tool = MemoryTool::new();
let out = tool
.execute(
json!({
"action": "remember",
"content": "cited() exists",
"scope": "project",
"source_path": "src.rs",
"source_start_line": 0,
"source_end_line": 1
}),
test_ctx(Some(project.path().to_path_buf())),
)
.await
.expect("remember should succeed");
assert!(!out.output.contains("no citation attached"), "{out:?}");

// Recall the entry from the store and prove the citation persisted.
let manager =
MemoryTool::new().scoped_manager(&test_ctx(Some(project.path().to_path_buf())));
let found = manager
.list_all_scoped(crate::memory_types::MemoryScope::Project)
.expect("list")
.into_iter()
.find(|e| e.content == "cited() exists")
.expect("entry stored");
let citation = found.citation.expect("citation attached");
assert_eq!(citation.path, "src.rs");
assert_eq!((citation.start_line, citation.end_line), (0, 1));

if let Some(prev) = prev_home {
crate::env::set_var("JCODE_HOME", prev);
} else {
crate::env::remove_var("JCODE_HOME");
}
}

#[tokio::test]
async fn remember_reports_unreadable_span_without_failing() {
let _guard = crate::storage::lock_test_env();
let home = tempfile::tempdir().expect("home");
let project = tempfile::tempdir().expect("project");
let prev_home = std::env::var_os("JCODE_HOME");
crate::env::set_var("JCODE_HOME", home.path());

let tool = MemoryTool::new();
let out = tool
.execute(
json!({
"action": "remember",
"content": "ghost fact",
"scope": "project",
"source_path": "gone.rs",
"source_start_line": 0,
"source_end_line": 1
}),
test_ctx(Some(project.path().to_path_buf())),
)
.await
.expect("remember stores uncited, never fails");
assert!(out.output.contains("no citation attached"), "{out:?}");

if let Some(prev) = prev_home {
crate::env::set_var("JCODE_HOME", prev);
} else {
crate::env::remove_var("JCODE_HOME");
}
}

#[tokio::test]
async fn project_scope_round_trips_with_working_dir() {
let _guard = crate::storage::lock_test_env();
Expand Down
16 changes: 13 additions & 3 deletions crates/jcode-base/src/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ mod pending;
mod prompt_support;

pub use crate::memory_types::{
MemoryCategory, MemoryEntry, MemoryScope, MemoryStore, Reinforcement, TrustLevel,
format_relevant_display_prompt, format_relevant_prompt,
CitationStatus, MemoryCategory, MemoryEntry, MemoryScope, MemoryStore, Reinforcement,
SourceCitation, TrustLevel, citation_stale_mark, format_relevant_display_prompt,
format_relevant_prompt, format_relevant_prompt_verified,
};
use crate::memory_types::{
collect_skill_query_terms, format_entries_for_prompt, memory_matches_search,
Expand Down Expand Up @@ -1188,7 +1189,16 @@ impl MemoryManager {
StepStatus::Pending
};
});
let prompt = format_relevant_prompt(&relevant, 5);
// Citation verification rides here (not in the agent): the Jev
// refactor builds the prompt inside the manager, so the repo root
// comes from the manager's own project dir. Unverifiable entries
// (no root, legacy) fall back to the unverified prompt — recall
// never withholds a memory.
let prompt = match self.get_project_dir() {
Some(root) => format_relevant_prompt_verified(&relevant, 5, &root)
.or_else(|| format_relevant_prompt(&relevant, 5)),
None => format_relevant_prompt(&relevant, 5),
};
let display = format_relevant_display_prompt(&relevant, 5);
set_state(if count == 0 {
MemoryState::Idle
Expand Down
4 changes: 4 additions & 0 deletions crates/jcode-memory-types/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ chrono = { version = "0.4", features = ["serde"] }
rand = "0.9.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"

[dev-dependencies]
tempfile = "3"
Loading