Skip to main content

sqlite_graphrag/commands/
edit.rs

1//! Handler for the `edit` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::i18n::errors_msg;
5use crate::output;
6use crate::paths::AppPaths;
7use crate::storage::connection::open_rw;
8use crate::storage::{memories, versions};
9use serde::Serialize;
10
11#[derive(clap::Args)]
12#[command(after_long_help = "EXAMPLES:\n  \
13    # Edit body inline\n  \
14    sqlite-graphrag edit onboarding --body \"updated content\"\n\n  \
15    # Edit body from a file\n  \
16    sqlite-graphrag edit onboarding --body-file ./updated.md\n\n  \
17    # Edit body from stdin (pipe)\n  \
18    cat updated.md | sqlite-graphrag edit onboarding --body-stdin\n\n  \
19    # Update only the description\n  \
20    sqlite-graphrag edit onboarding --description \"new short description\"")]
21pub struct EditArgs {
22    /// Memory name as a positional argument. Alternative to `--name`.
23    #[arg(
24        value_name = "NAME",
25        conflicts_with = "name",
26        help = "Memory name to edit; alternative to --name"
27    )]
28    pub name_positional: Option<String>,
29    /// Memory name to edit. Soft-deleted memories are not editable; use `restore` first.
30    #[arg(long)]
31    pub name: Option<String>,
32    /// New inline body content. Mutually exclusive with --body-file and --body-stdin.
33    #[arg(long, conflicts_with_all = ["body_file", "body_stdin"])]
34    pub body: Option<String>,
35    /// Read new body from a file. Mutually exclusive with --body and --body-stdin.
36    #[arg(long, conflicts_with_all = ["body", "body_stdin"])]
37    pub body_file: Option<std::path::PathBuf>,
38    /// Read new body from stdin until EOF. Mutually exclusive with --body and --body-file.
39    #[arg(long, conflicts_with_all = ["body", "body_file"])]
40    pub body_stdin: bool,
41    /// New description (≤500 chars) replacing the existing one.
42    #[arg(long)]
43    pub description: Option<String>,
44    /// Change the memory type (e.g. note, skill, decision).
45    #[arg(long, value_enum, visible_alias = "type", help = "Change memory type")]
46    pub memory_type: Option<crate::cli::MemoryType>,
47    #[arg(
48        long,
49        value_name = "EPOCH_OR_RFC3339",
50        value_parser = crate::parsers::parse_expected_updated_at,
51        long_help = "Optimistic lock: reject if updated_at does not match. \
52Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
53    )]
54    pub expected_updated_at: Option<i64>,
55    #[arg(
56        long,
57        help = "Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global)"
58    )]
59    pub namespace: Option<String>,
60    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
61    pub json: bool,
62    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
63    pub db: Option<String>,
64    /// G42/S9 (v1.0.79): regenerate the embedding even when the body is
65    /// unchanged. This is the supported way to re-embed a memory (the
66    /// pre-v1.0.79 docs suggested `edit --description "<same>"`, which
67    /// is a no-op and never re-embeds).
68    #[arg(
69        long,
70        default_value_t = false,
71        help = "Regenerate the embedding even when the body is unchanged (G42/S9)"
72    )]
73    pub force_reembed: bool,
74    /// G42/S3 (v1.0.79): maximum simultaneous LLM embedding subprocesses.
75    /// Only relevant for future multi-item edit paths; a single-body edit
76    /// performs one LLM call regardless.
77    #[arg(long, default_value_t = 4, value_name = "N",
78          value_parser = clap::value_parser!(u64).range(1..=32),
79          help = "Maximum simultaneous LLM embedding subprocesses (default: 4, clamp [1,32])")]
80    pub llm_parallelism: u64,
81}
82
83#[derive(Serialize)]
84struct EditResponse {
85    memory_id: i64,
86    name: String,
87    action: String,
88    version: i64,
89    /// Total execution time in milliseconds from handler start to serialisation.
90    elapsed_ms: u64,
91    /// v1.0.84 (ADR-0042): discriminador do backend LLM que efetivamente
92    /// executou o re-embedding do body editado. `"claude" | "codex" | "none"`.
93    /// Absent on the wire when `None` (kept for happy-path envelope cleanliness,
94    /// ou quando body não mudou e re-embedding não foi invocado).
95    #[serde(skip_serializing_if = "Option::is_none")]
96    backend_invoked: Option<&'static str>,
97}
98
99pub fn run(
100    args: EditArgs,
101    llm_backend: crate::cli::LlmBackendChoice,
102    embedding_backend: crate::cli::EmbeddingBackendChoice,
103) -> Result<(), AppError> {
104    use crate::constants::*;
105
106    let inicio = std::time::Instant::now();
107    tracing::debug!(target: "edit", name = ?args.name_positional.as_deref().or(args.name.as_deref()), "updating memory");
108    // Resolve name from positional or --name flag; both are optional, at least one is required.
109    let name = args.name_positional.or(args.name).ok_or_else(|| {
110        AppError::Validation("name required: pass as positional argument or via --name".to_string())
111    })?;
112    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
113
114    let paths = AppPaths::resolve(args.db.as_deref())?;
115    crate::storage::connection::ensure_db_ready(&paths)?;
116    let mut conn = open_rw(&paths.db)?;
117
118    let (memory_id, current_updated_at, _current_version) =
119        memories::find_by_name(&conn, &namespace, &name)?
120            .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
121
122    if let Some(expected) = args.expected_updated_at {
123        if expected != current_updated_at {
124            return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
125                expected,
126                current_updated_at,
127            )));
128        }
129    }
130
131    let mut raw_body: Option<String> = None;
132    if args.body.is_some() || args.body_file.is_some() || args.body_stdin {
133        let b = if let Some(b) = args.body {
134            b
135        } else if let Some(path) = &args.body_file {
136            let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
137            if file_size > MAX_MEMORY_BODY_LEN as u64 {
138                return Err(AppError::LimitExceeded(
139                    crate::i18n::validation::body_exceeds(MAX_MEMORY_BODY_LEN),
140                ));
141            }
142            std::fs::read_to_string(path).map_err(AppError::Io)?
143        } else {
144            crate::stdin_helper::read_stdin_with_timeout(60)?
145        };
146        if b.len() > MAX_MEMORY_BODY_LEN {
147            return Err(AppError::LimitExceeded(
148                crate::i18n::validation::body_exceeds(MAX_MEMORY_BODY_LEN),
149            ));
150        }
151        raw_body = Some(b);
152    }
153
154    if let Some(ref desc) = args.description {
155        if desc.len() > MAX_MEMORY_DESCRIPTION_LEN {
156            return Err(AppError::Validation(
157                crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
158            ));
159        }
160    }
161
162    let row = memories::read_by_name(&conn, &namespace, &name)?
163        .ok_or_else(|| AppError::Internal(anyhow::anyhow!("memory row not found after check")))?;
164
165    let body_changed = raw_body.is_some();
166    let new_body = raw_body.unwrap_or(row.body.clone());
167    let new_description = args.description.unwrap_or(row.description.clone());
168    let new_hash = blake3::hash(new_body.as_bytes()).to_hex().to_string();
169    // Skip re-embedding when body content is identical to the stored version.
170    let body_changed = body_changed && new_hash != row.body_hash;
171    let memory_type = args
172        .memory_type
173        .map(|t| t.as_str().to_string())
174        .unwrap_or_else(|| row.memory_type.clone());
175    let type_changed = memory_type != row.memory_type;
176    let metadata = row.metadata.clone();
177
178    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
179
180    let affected = if let Some(ts) = args.expected_updated_at {
181        tx.execute(
182            "UPDATE memories SET description=?2, body=?3, body_hash=?4, type=?5
183             WHERE id=?1 AND updated_at=?6 AND deleted_at IS NULL",
184            rusqlite::params![
185                memory_id,
186                new_description,
187                new_body,
188                new_hash,
189                memory_type,
190                ts
191            ],
192        )?
193    } else {
194        tx.execute(
195            "UPDATE memories SET description=?2, body=?3, body_hash=?4, type=?5
196             WHERE id=?1 AND deleted_at IS NULL",
197            rusqlite::params![memory_id, new_description, new_body, new_hash, memory_type],
198        )?
199    };
200
201    if affected == 0 {
202        return Err(AppError::Conflict(
203            "optimistic lock conflict: memory was modified by another process".to_string(),
204        ));
205    }
206
207    // v1.0.84 (ADR-0042): backend discriminator for the JSON envelope.
208    // Populated only when re-embedding actually ran; stays None for
209    // description-only or metadata-only edits.
210    let mut backend_invoked: Option<&'static str> = None;
211
212    if body_changed || type_changed || args.force_reembed {
213        output::emit_progress_i18n(
214            "Re-computing embedding for edited body...",
215            crate::i18n::validation::runtime_pt::edit_recomputing_embedding(),
216        );
217        // v1.0.82 (GAP-003): forward --llm-backend to embed_with_fallback.
218        // v1.0.84 (ADR-0042): tuple (Vec<f32>, LlmBackendKind) — extrai o
219        // backend que efetivamente rodou para popular `backend_invoked`.
220        let skip_embed = crate::embedder::should_skip_embedding_on_failure();
221        let embedding: Option<(Vec<f32>, &'static str)> =
222            match crate::embedder::embed_passage_with_embedding_choice(
223                &paths.models,
224                &new_body,
225                embedding_backend,
226                llm_backend,
227            ) {
228                Ok((emb, kind)) => Some((emb, kind.as_str())),
229                Err(AppError::Validation(msg)) => return Err(AppError::Validation(msg)),
230                Err(e) if skip_embed => {
231                    tracing::warn!(error = %e, "edit: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
232                    None
233                }
234                Err(e) => return Err(e),
235            };
236        if let Some((ref emb, kind)) = embedding {
237            backend_invoked = Some(kind);
238            let snippet: String = new_body.chars().take(300).collect();
239            memories::upsert_vec(
240                &tx,
241                memory_id,
242                &namespace,
243                &memory_type,
244                emb,
245                &name,
246                &snippet,
247            )?;
248        }
249    }
250
251    let next_v = versions::next_version(&tx, memory_id)?;
252
253    versions::insert_version(
254        &tx,
255        memory_id,
256        next_v,
257        &name,
258        &memory_type,
259        &new_description,
260        &new_body,
261        &metadata,
262        None,
263        "edit",
264    )?;
265
266    memories::sync_fts_after_update(
267        &tx,
268        memory_id,
269        &row.name,
270        &row.description,
271        &row.body,
272        &row.name,
273        &new_description,
274        &new_body,
275    )?;
276
277    tx.commit()?;
278
279    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
280
281    output::emit_json(&EditResponse {
282        memory_id,
283        name,
284        action: "updated".to_string(),
285        version: next_v,
286        elapsed_ms: inicio.elapsed().as_millis() as u64,
287        backend_invoked,
288    })?;
289
290    Ok(())
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[derive(clap::Parser)]
298    struct TestCli {
299        #[command(flatten)]
300        args: EditArgs,
301    }
302
303    #[test]
304    fn type_flag_is_a_visible_alias_of_memory_type() {
305        // G47: COOKBOOK, README and llms.txt promise `edit --type`; the flag
306        // was only reachable as --memory-type, breaking the documented CLI.
307        use clap::Parser;
308        let cli = TestCli::try_parse_from(["edit", "--name", "m", "--type", "decision"])
309            .expect("--type must parse as an alias of --memory-type");
310        assert!(cli.args.memory_type.is_some());
311        let cli = TestCli::try_parse_from(["edit", "--name", "m", "--memory-type", "decision"])
312            .expect("--memory-type must keep working");
313        assert!(cli.args.memory_type.is_some());
314    }
315
316    #[test]
317    fn edit_response_serializes_all_fields() {
318        let resp = EditResponse {
319            memory_id: 42,
320            name: "my-memory".to_string(),
321            action: "updated".to_string(),
322            version: 3,
323            elapsed_ms: 7,
324            backend_invoked: None,
325        };
326        let json = serde_json::to_value(&resp).expect("serialization failed");
327        assert_eq!(json["memory_id"], 42i64);
328        assert_eq!(json["name"], "my-memory");
329        assert_eq!(json["action"], "updated");
330        assert_eq!(json["version"], 3i64);
331        assert!(json["elapsed_ms"].is_number());
332    }
333
334    #[test]
335    fn edit_response_action_contains_updated() {
336        let resp = EditResponse {
337            memory_id: 1,
338            name: "n".to_string(),
339            action: "updated".to_string(),
340            version: 1,
341            elapsed_ms: 0,
342            backend_invoked: None,
343        };
344        assert_eq!(
345            resp.action, "updated",
346            "action must be 'updated' for successful edits"
347        );
348    }
349
350    #[test]
351    fn edit_body_exceeds_limit_returns_error() {
352        let limit = crate::constants::MAX_MEMORY_BODY_LEN;
353        let large_body: String = "a".repeat(limit + 1);
354        assert!(
355            large_body.len() > limit,
356            "body above limit must have length > MAX_MEMORY_BODY_LEN"
357        );
358    }
359
360    #[test]
361    fn edit_description_exceeds_limit_returns_error() {
362        let limit = crate::constants::MAX_MEMORY_DESCRIPTION_LEN;
363        let large_desc: String = "d".repeat(limit + 1);
364        assert!(
365            large_desc.len() > limit,
366            "description above limit must have length > MAX_MEMORY_DESCRIPTION_LEN"
367        );
368    }
369}