sqlite_graphrag/commands/
edit.rs1use 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 {
23 #[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 #[arg(long)]
32 pub name: Option<String>,
33 #[arg(long, conflicts_with_all = ["body_file", "body_stdin"])]
35 pub body: Option<String>,
36 #[arg(long, conflicts_with_all = ["body", "body_stdin"])]
38 pub body_file: Option<std::path::PathBuf>,
39 #[arg(long, conflicts_with_all = ["body", "body_file"])]
41 pub body_stdin: bool,
42 #[arg(long)]
44 pub description: Option<String>,
45 #[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 pub expected_updated_at: Option<i64>,
57 #[arg(
58 long,
59 help = "Namespace (flag / XDG namespace.default / global)"
60 )]
61 pub namespace: Option<String>,
63 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
65 pub json: bool,
66 #[arg(long)]
68 pub db: Option<String>,
69 #[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 #[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 elapsed_ms: u64,
96 #[serde(skip_serializing_if = "Option::is_none")]
101 backend_invoked: Option<&'static str>,
102}
103
104pub 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 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 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 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 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 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 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 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}