1use crate::commands::ingest::IngestArgs;
8use crate::commands::opencode_runner;
9use crate::errors::AppError;
10use crate::parsers::normalize_entity_name;
11use serde::{Deserialize, Serialize};
12use std::io::Write;
13use std::path::{Path, PathBuf};
14
15const EXTRACTION_SCHEMA: &str = r#"Return ONLY a valid JSON object with this exact structure (no markdown, no explanation):
16{
17 "entities": [
18 {"name": "entity-name-in-kebab-case", "entity_type": "concept|project|tool|person|file|incident|decision|organization|location|date"}
19 ],
20 "relationships": [
21 {"source": "entity-a", "target": "entity-b", "relation": "applies-to|uses|depends-on|causes|fixes|contradicts|supports|follows|related|replaces|tracked-in", "strength": 0.7}
22 ]
23}"#;
24
25#[derive(Debug, Deserialize, Serialize)]
26pub struct ExtractionResult {
27 #[serde(default)]
28 pub entities: Vec<ExtractedEntity>,
29 #[serde(default)]
30 pub relationships: Vec<ExtractedRelationship>,
31}
32
33#[derive(Debug, Deserialize, Serialize, Clone)]
34pub struct ExtractedEntity {
35 pub name: String,
36 pub entity_type: String,
37}
38
39#[derive(Debug, Deserialize, Serialize, Clone)]
40pub struct ExtractedRelationship {
41 pub source: String,
42 pub target: String,
43 pub relation: String,
44 #[serde(default = "default_strength")]
45 pub strength: f64,
46}
47
48fn default_strength() -> f64 {
49 0.5
50}
51
52pub async fn extract_with_opencode(
53 binary: &Path,
54 model: &str,
55 body: &str,
56 memory_name: &str,
57 timeout_secs: u64,
58) -> Result<(ExtractionResult, f64, u64), AppError> {
59 let prompt = format!(
60 "Analyze the following document and extract domain-specific entities and their relationships.\n\
61 Memory name: {memory_name}\n\n\
62 {EXTRACTION_SCHEMA}\n\n\
63 Document content:\n{body}"
64 );
65
66 opencode_runner::call_opencode::<ExtractionResult>(binary, model, &prompt, timeout_secs).await
67}
68
69fn emit_json(value: &serde_json::Value) {
70 let _ = writeln!(
71 std::io::stdout(),
72 "{}",
73 serde_json::to_string(value).unwrap_or_default()
74 );
75 let _ = std::io::stdout().flush();
76}
77
78pub fn run_opencode_ingest(args: &IngestArgs) -> Result<(), AppError> {
79 let started = std::time::Instant::now();
80
81 if !args.dir.exists() {
82 return Err(AppError::Validation(format!(
83 "directory not found: {}",
84 args.dir.display()
85 )));
86 }
87
88 let binary =
89 opencode_runner::find_opencode_binary_with_override(args.opencode_binary.as_deref())?;
90 let version = opencode_runner::validate_opencode_version(&binary)?;
91 let model = opencode_runner::resolve_opencode_model(args.opencode_model.as_deref());
92 let timeout = opencode_runner::resolve_opencode_timeout(if args.opencode_timeout != 300 {
93 Some(args.opencode_timeout)
94 } else {
95 None
96 });
97
98 emit_json(&serde_json::json!({
99 "phase": "validate",
100 "opencode_path": binary.display().to_string(),
101 "version": format!("{}.{}.{}", version.0, version.1, version.2),
102 "model": &model,
103 }));
104
105 let mut files: Vec<PathBuf> = Vec::new();
106 super::ingest::collect_files(&args.dir, &args.pattern, args.recursive, &mut files)?;
107
108 if files.len() > args.max_files {
109 return Err(AppError::Validation(format!(
110 "found {} files exceeding --max-files cap of {}; aborting (all-or-nothing)",
111 files.len(),
112 args.max_files
113 )));
114 }
115
116 files.sort();
117
118 emit_json(&serde_json::json!({
119 "phase": "scan",
120 "dir": args.dir.display().to_string(),
121 "files_total": files.len(),
122 "files_new": files.len(),
123 "files_existing": 0,
124 }));
125
126 if args.dry_run {
127 for (idx, file) in files.iter().enumerate() {
128 let (name, truncated, orig) =
129 super::ingest::derive_kebab_name(file, args.max_name_length);
130 emit_json(&serde_json::json!({
131 "file": file.display().to_string(),
132 "name": name,
133 "status": "preview",
134 "index": idx + 1,
135 "total": files.len(),
136 "truncated": truncated,
137 "original_name": orig,
138 }));
139 }
140 emit_json(&serde_json::json!({
141 "summary": true,
142 "files_total": files.len(),
143 "completed": 0,
144 "failed": 0,
145 "skipped": 0,
146 "entities_total": 0,
147 "rels_total": 0,
148 "cost_usd": 0.0,
149 "elapsed_ms": started.elapsed().as_millis() as u64,
150 }));
151 return Ok(());
152 }
153
154 let ns = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
159 let app_paths = crate::paths::AppPaths::resolve(args.db.as_deref())?;
160
161 let mut completed = 0usize;
162 let mut failed = 0usize;
163 let mut skipped = 0usize;
164 let mut entities_total = 0usize;
165 let mut rels_total = 0usize;
166 let mut cost_total: f64 = 0.0;
167
168 for (idx, file) in files.iter().enumerate() {
169 let (name, truncated, orig) = super::ingest::derive_kebab_name(file, args.max_name_length);
170
171 let body = match std::fs::read_to_string(file) {
172 Ok(b) => b,
173 Err(e) => {
174 emit_json(&serde_json::json!({
175 "file": file.display().to_string(),
176 "name": name,
177 "status": "failed",
178 "error": format!("read error: {e}"),
179 "index": idx + 1,
180 "total": files.len(),
181 }));
182 failed += 1;
183 if args.fail_fast {
184 break;
185 }
186 continue;
187 }
188 };
189
190 if body.len() > 512_000 {
191 emit_json(&serde_json::json!({
192 "file": file.display().to_string(),
193 "name": name,
194 "status": "skipped",
195 "error": format!("file exceeds 512KB limit ({} bytes)", body.len()),
196 "index": idx + 1,
197 "total": files.len(),
198 }));
199 skipped += 1;
200 continue;
201 }
202
203 let file_started = std::time::Instant::now();
204
205 let fut = extract_with_opencode(&binary, &model, &body, &name, timeout);
207 let extraction = match tokio::runtime::Handle::try_current() {
208 Ok(handle) => tokio::task::block_in_place(|| handle.block_on(fut)),
209 Err(_) => crate::embedder::shared_runtime()?.block_on(fut),
210 };
211
212 match extraction {
213 Ok((result, cost, _tokens)) => {
214 let ent_count = result.entities.len();
215 let rel_count = result.relationships.len();
216
217 let graph_payload = serde_json::json!({
218 "body": body,
219 "entities": result.entities.iter().map(|e| {
220 serde_json::json!({"name": e.name, "entity_type": e.entity_type})
221 }).collect::<Vec<_>>(),
222 "relationships": result.relationships.iter().map(|r| {
223 serde_json::json!({
224 "source": r.source,
225 "target": r.target,
226 "relation": r.relation,
227 "strength": r.strength
228 })
229 }).collect::<Vec<_>>(),
230 });
231
232 let remember_result = persist_memory_with_graph(
233 &app_paths.db,
234 &ns,
235 &name,
236 &format!("{:?}", args.r#type).to_lowercase(),
237 &format!("ingested from {} via opencode", file.display()),
238 &graph_payload,
239 );
240
241 match remember_result {
242 Ok(memory_id) => {
243 entities_total += ent_count;
244 rels_total += rel_count;
245 cost_total += cost;
246 completed += 1;
247
248 emit_json(&serde_json::json!({
249 "file": file.display().to_string(),
250 "name": name,
251 "status": "done",
252 "memory_id": memory_id,
253 "entities": ent_count,
254 "rels": rel_count,
255 "cost_usd": cost,
256 "elapsed_ms": file_started.elapsed().as_millis() as u64,
257 "index": idx + 1,
258 "total": files.len(),
259 "truncated": truncated,
260 "original_name": orig,
261 }));
262 }
263 Err(e) => {
264 failed += 1;
265 emit_json(&serde_json::json!({
266 "file": file.display().to_string(),
267 "name": name,
268 "status": "failed",
269 "error": format!("persist error: {e}"),
270 "elapsed_ms": file_started.elapsed().as_millis() as u64,
271 "index": idx + 1,
272 "total": files.len(),
273 }));
274 if args.fail_fast {
275 break;
276 }
277 }
278 }
279 }
280 Err(e) => {
281 failed += 1;
282 emit_json(&serde_json::json!({
283 "file": file.display().to_string(),
284 "name": name,
285 "status": "failed",
286 "error": format!("extraction error: {e}"),
287 "elapsed_ms": file_started.elapsed().as_millis() as u64,
288 "index": idx + 1,
289 "total": files.len(),
290 }));
291 if args.fail_fast {
292 break;
293 }
294 }
295 }
296 }
297
298 emit_json(&serde_json::json!({
299 "summary": true,
300 "files_total": files.len(),
301 "completed": completed,
302 "failed": failed,
303 "skipped": skipped,
304 "entities_total": entities_total,
305 "rels_total": rels_total,
306 "cost_usd": cost_total,
307 "elapsed_ms": started.elapsed().as_millis() as u64,
308 }));
309
310 Ok(())
311}
312
313fn persist_memory_with_graph(
314 db_path: &Path,
315 namespace: &str,
316 name: &str,
317 memory_type: &str,
318 description: &str,
319 graph_payload: &serde_json::Value,
320) -> Result<i64, AppError> {
321 let conn = crate::storage::connection::open_rw(db_path)?;
322
323 let existing = conn
324 .query_row(
325 "SELECT id FROM memories WHERE name = ?1 AND namespace = ?2",
326 rusqlite::params![name, namespace],
327 |row| row.get::<_, i64>(0),
328 )
329 .ok();
330
331 let body = graph_payload
332 .get("body")
333 .and_then(|b| b.as_str())
334 .unwrap_or("");
335 let body_hash = blake3::hash(body.as_bytes()).to_hex().to_string();
336
337 let memory_id = if let Some(id) = existing {
338 conn.execute(
339 "UPDATE memories SET body = ?1, description = ?2, type = ?3, body_hash = ?4, updated_at = strftime('%s','now') WHERE id = ?5",
340 rusqlite::params![body, description, memory_type, body_hash, id],
341 )
342 .map_err(AppError::Database)?;
343 id
344 } else {
345 conn.execute(
346 "INSERT INTO memories (name, namespace, type, description, body, body_hash, created_at, updated_at) \
347 VALUES (?1, ?2, ?3, ?4, ?5, ?6, strftime('%s','now'), strftime('%s','now'))",
348 rusqlite::params![name, namespace, memory_type, description, body, body_hash],
349 )
350 .map_err(AppError::Database)?;
351 conn.last_insert_rowid()
352 };
353
354 if let Some(entities) = graph_payload.get("entities").and_then(|e| e.as_array()) {
355 for ent in entities {
356 let ent_name = ent.get("name").and_then(|n| n.as_str()).unwrap_or("");
357 let ent_type = ent
358 .get("entity_type")
359 .and_then(|t| t.as_str())
360 .unwrap_or("concept");
361 if ent_name.len() < 2 {
362 continue;
363 }
364 let normalized = normalize_entity_name(ent_name);
365 conn.execute(
366 "INSERT OR IGNORE INTO entities (name, type, namespace) VALUES (?1, ?2, ?3)",
367 rusqlite::params![normalized, ent_type, namespace],
368 )
369 .map_err(AppError::Database)?;
370
371 let entity_id: i64 = conn
372 .query_row(
373 "SELECT id FROM entities WHERE name = ?1 AND namespace = ?2",
374 rusqlite::params![normalized, namespace],
375 |row| row.get(0),
376 )
377 .map_err(AppError::Database)?;
378
379 conn.execute(
380 "INSERT OR IGNORE INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
381 rusqlite::params![memory_id, entity_id],
382 )
383 .map_err(AppError::Database)?;
384 }
385 }
386
387 if let Some(rels) = graph_payload
388 .get("relationships")
389 .and_then(|r| r.as_array())
390 {
391 for rel in rels {
392 let source = rel.get("source").and_then(|s| s.as_str()).unwrap_or("");
393 let target = rel.get("target").and_then(|t| t.as_str()).unwrap_or("");
394 let relation = rel
395 .get("relation")
396 .and_then(|r| r.as_str())
397 .unwrap_or("related");
398 let strength = rel.get("strength").and_then(|s| s.as_f64()).unwrap_or(0.5);
399
400 if source.len() < 2 || target.len() < 2 {
401 continue;
402 }
403
404 let src_norm = normalize_entity_name(source);
405 let tgt_norm = normalize_entity_name(target);
406
407 for name_val in [&src_norm, &tgt_norm] {
408 conn.execute(
409 "INSERT OR IGNORE INTO entities (name, type, namespace) VALUES (?1, 'concept', ?2)",
410 rusqlite::params![name_val, namespace],
411 )
412 .map_err(AppError::Database)?;
413 }
414
415 let src_id: i64 = conn
416 .query_row(
417 "SELECT id FROM entities WHERE name = ?1 AND namespace = ?2",
418 rusqlite::params![src_norm, namespace],
419 |row| row.get(0),
420 )
421 .map_err(AppError::Database)?;
422
423 let tgt_id: i64 = conn
424 .query_row(
425 "SELECT id FROM entities WHERE name = ?1 AND namespace = ?2",
426 rusqlite::params![tgt_norm, namespace],
427 |row| row.get(0),
428 )
429 .map_err(AppError::Database)?;
430
431 let rel_normalized = relation.replace('-', "_");
432 conn.execute(
433 "INSERT OR IGNORE INTO relationships (source_id, target_id, relation, weight, namespace) \
434 VALUES (?1, ?2, ?3, ?4, ?5)",
435 rusqlite::params![src_id, tgt_id, rel_normalized, strength, namespace],
436 )
437 .map_err(AppError::Database)?;
438 }
439 }
440
441 Ok(memory_id)
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn extraction_result_deserializes_empty() {
450 let json = r#"{"entities":[],"relationships":[]}"#;
451 let result: ExtractionResult = serde_json::from_str(json).unwrap();
452 assert!(result.entities.is_empty());
453 assert!(result.relationships.is_empty());
454 }
455
456 #[test]
457 fn extraction_result_deserializes_with_data() {
458 let json = r#"{
459 "entities": [
460 {"name": "sqlite-graphrag", "entity_type": "project"},
461 {"name": "opencode", "entity_type": "tool"}
462 ],
463 "relationships": [
464 {"source": "sqlite-graphrag", "target": "opencode", "relation": "uses", "strength": 0.8}
465 ]
466 }"#;
467 let result: ExtractionResult = serde_json::from_str(json).unwrap();
468 assert_eq!(result.entities.len(), 2);
469 assert_eq!(result.relationships.len(), 1);
470 assert_eq!(result.relationships[0].strength, 0.8);
471 }
472
473 #[test]
474 fn extraction_result_default_strength() {
475 let json = r#"{
476 "entities": [],
477 "relationships": [
478 {"source": "a", "target": "b", "relation": "related"}
479 ]
480 }"#;
481 let result: ExtractionResult = serde_json::from_str(json).unwrap();
482 assert_eq!(result.relationships[0].strength, 0.5);
483 }
484}