Skip to main content

sqlite_graphrag/commands/
ingest_opencode.rs

1//! OpenCode-curated ingest pipeline (v1.0.90, GAP-OPENCODE-002).
2//!
3//! Spawns `opencode run` per file to extract entities and relationships
4//! via LLM, then persists them alongside the memory body via `remember
5//! --graph-stdin --force-merge`.
6
7use 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    // GAP-001 (v1.1.04): `rt` is no longer hoisted here; each `block_on`
155    // site resolves the runtime via the canonical nested-runtime guard
156    // (`Handle::try_current` + `block_in_place`), so this command can be
157    // invoked from inside an existing tokio runtime without panicking.
158    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        // GAP-001 (v1.1.04): canonical nested-runtime guard.
206        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}