1use 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 #[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 #[arg(long)]
31 pub name: Option<String>,
32 #[arg(long, conflicts_with_all = ["body_file", "body_stdin"])]
34 pub body: Option<String>,
35 #[arg(long, conflicts_with_all = ["body", "body_stdin"])]
37 pub body_file: Option<std::path::PathBuf>,
38 #[arg(long, conflicts_with_all = ["body", "body_file"])]
40 pub body_stdin: bool,
41 #[arg(long)]
43 pub description: Option<String>,
44 #[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 #[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 #[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 elapsed_ms: u64,
91 #[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 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 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 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 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 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}