feat(memory): add selective branch pick support#193
feat(memory): add selective branch pick support#193gouhongshen wants to merge 14 commits intomatrixorigin:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a new selective “branch pick” capability that applies a subset of changes from one memory branch into another (defaulting to main), wiring it through MCP tooling, the RemoteClient, and the REST API, with E2E coverage for success and conflict cases.
Changes:
- Introduces
memory_pickMCP tool + GitForDataService support for picking bykey_list,snapshot_range, orretrieveselectors with conflict strategies. - Adds REST endpoint
POST /v1/branches/:name/pickand RemoteClient forwarding formemory_pick. - Adds MCP/API/remote E2E tests covering happy paths, validation, and conflict handling.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| memoria/crates/memoria-mcp/tests/branch_e2e.rs | Adds MCP E2E coverage for key_list/snapshot_range/retrieve pick and conflict strategies |
| memoria/crates/memoria-mcp/src/remote.rs | Forwards memory_pick to the new REST pick endpoint |
| memoria/crates/memoria-mcp/src/git_tools.rs | Implements memory_pick tool parsing, selection logic, and pick execution |
| memoria/crates/memoria-git/src/service.rs | Adds MatrixOne DDL helpers for data branch pick (keys + snapshot range) |
| memoria/crates/memoria-api/tests/api_e2e.rs | Adds REST + remote E2E tests for pick, validation, and conflicts |
| memoria/crates/memoria-api/src/routes/snapshots.rs | Adds REST handler that calls MCP git_tools pick and maps conflicts to HTTP 409 |
| memoria/crates/memoria-api/src/routes/mcp.rs | Marks memory_pick as dirtying FULL metrics state |
| memoria/crates/memoria-api/src/models.rs | Adds PickRequest and PickSelector request models |
| memoria/crates/memoria-api/src/lib.rs | Adds /v1/branches/:name/pick route and metrics dirty-path handling |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
dc5812a to
a8ecfdf
Compare
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let diff_clause = pickable_diff_clause("s_to", "s_from"); | ||
| let mut qb: QueryBuilder<MySql> = QueryBuilder::new(format!( | ||
| "SELECT s_to.memory_id, COALESCE(s_to.content, '') AS content, \ | ||
| COALESCE(s_to.memory_type, 'semantic') AS memory_type, \ | ||
| CASE WHEN t.memory_id IS NULL THEN 0 ELSE 1 END AS target_exists \ | ||
| FROM {source_table} {{SNAPSHOT = '" | ||
| )); | ||
| qb.push(to_snapshot); | ||
| qb.push("'}} s_to "); | ||
| qb.push(format!( | ||
| "LEFT JOIN {source_table} {{SNAPSHOT = '{from_snapshot}'}} s_from ON s_from.memory_id = s_to.memory_id " | ||
| )); | ||
| qb.push(format!( | ||
| "LEFT JOIN {target_table} t ON t.memory_id = s_to.memory_id WHERE s_to.user_id = " | ||
| )); | ||
| qb.push_bind(user_id); | ||
| qb.push(" AND ("); | ||
| qb.push(&diff_clause); | ||
| qb.push(") ORDER BY s_to.updated_at DESC, s_to.created_at DESC"); |
There was a problem hiding this comment.
snapshot_range dry-run candidate query only checks differences between to_snapshot and from_snapshot (s_to vs s_from) and does not filter out rows that are already identical in the target branch. This can inflate candidate_count and mark target_exists rows as conflicts even when picking would be a no-op for that memory_id in the target. Consider also applying the same “pickable vs target” diff predicate (s_to vs target alias) so the preview reflects what would actually change in the target branch.
| let message = err.to_string(); | ||
| if message.to_lowercase().contains("conflict") { | ||
| let selection_detail = selected | ||
| .map(|count| format!(" for {count} selected change(s)")) | ||
| .unwrap_or_default(); | ||
| return Err(MemoriaError::Validation(format!( | ||
| "Conflict: pick from branch '{source}' into '{target}' aborted{selection_detail}: {message}" | ||
| ))); | ||
| } |
There was a problem hiding this comment.
map_pick_conflict treats any error whose message contains the substring "conflict" as a pick conflict. Because the SQL statement itself contains "when conflict ...", unrelated failures (notably SQL parser errors on older MatrixOne versions) will be misclassified and returned as MemoriaError::Validation("Conflict: ..."), which the REST layer maps to HTTP 409. Suggest tightening the detection to only match real pick-conflict errors (e.g., by checking specific MatrixOne error codes / a more specific phrase) and letting parser/DDL errors surface as internal errors instead of 409.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
key_list,snapshot_range, andretrieveselectors, optional target branch, andfail|skip|acceptconflict strategiesretrieve.min_scoreanddry_runpreview/output shaping formemory_pickInterface
MCP
Tool:
memory_pickInputs:
source: source branch nametarget: optional target branch, defaults tomainstrategy: optionalfail | skip | accept, defaults tofailselector:{"type":"key_list","keys":["", "..."]}{"type":"snapshot_range","from_snapshot":"snap_a","to_snapshot":"snap_b"}{"type":"retrieve","query":"alpha query","top_k":5,"min_score":0.82}(top_kandmin_scoreonly apply toretrieve)dry_run(optional preview mode; no database mutation):{"limit":10,"offset":0,"include_content_preview":true,"include_scores":true}limit/offsetonly shape preview output; they do not change the selected candidate setOutputs:
content[0].textdry_runreturns preview JSON rendered incontent[0].text, including:resultsummarypagecandidatessession_idREST API
POST /v1/branches/:source/pickRequest body:
target: optional target branch, defaults tomainstrategy: optionalfail | skip | accept, defaults tofailselector: same shape as MCPdry_run: same shape as MCP; when present, returns a structured preview instead of applying changesSuccess response:
{"result":"Picked ..."}dry_runpreview:{ "dry_run": true, "result": "Previewed ...", "summary": { "candidate_count": 2, "shown_count": 1, "would_apply": 2, "would_skip": 0, "conflict_count": 0, "would_abort": false }, "page": { "limit": 1, "offset": 0, "has_more": true }, "candidates": [ { "memory_id": "019...", "content_preview": "alpha branch memory", "score": 0.93, "status": "would_apply", "reason": "new_or_updated_row" } ] }Error mapping:
422: validation error404: source/target branch or snapshot not found409: pick conflict whenstrategy=failTesting
cargo check -p memoria-mcp -p memoria-apicargo test -p memoria-mcp --test branch_e2e --no-runcargo test -p memoria-api --test api_e2e --no-runpicke2e currently still depends on a ready MatrixOne test DB; on this machine the existing multi-db harness times out before tests start