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