Skip to main content

roboticus_db/
hippocampus.rs

1use crate::{Database, DbResultExt};
2use roboticus_core::{RoboticusError, Result};
3use rusqlite::OptionalExtension;
4use serde::{Deserialize, Serialize};
5
6/// A schema map entry describing a table in the database.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SchemaEntry {
9    pub table_name: String,
10    pub description: String,
11    pub columns: Vec<ColumnDef>,
12    pub created_by: String,
13    pub agent_owned: bool,
14    pub created_at: String,
15    pub updated_at: String,
16    pub access_level: String,
17    pub row_count: i64,
18}
19
20/// Column definition within a schema entry.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ColumnDef {
23    pub name: String,
24    pub col_type: String,
25    pub nullable: bool,
26    pub description: Option<String>,
27}
28
29/// Register a table in the hippocampus.
30#[allow(clippy::too_many_arguments)]
31pub fn register_table(
32    db: &Database,
33    table_name: &str,
34    description: &str,
35    columns: &[ColumnDef],
36    created_by: &str,
37    agent_owned: bool,
38    access_level: &str,
39    row_count: i64,
40) -> Result<()> {
41    let conn = db.conn();
42    let columns_json = serde_json::to_string(columns).db_err()?;
43
44    conn.execute(
45        "INSERT OR REPLACE INTO hippocampus \
46         (table_name, description, columns_json, created_by, agent_owned, access_level, row_count, updated_at) \
47         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'))",
48        rusqlite::params![
49            table_name,
50            description,
51            columns_json,
52            created_by,
53            agent_owned as i32,
54            access_level,
55            row_count
56        ],
57    )
58    .db_err()?;
59
60    Ok(())
61}
62
63const SELECT_COLS: &str = "table_name, description, columns_json, created_by, agent_owned, \
64                           created_at, updated_at, access_level, row_count";
65
66fn row_to_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result<SchemaEntry> {
67    let columns_json: String = row.get(2)?;
68    let columns: Vec<ColumnDef> = serde_json::from_str(&columns_json).unwrap_or_else(|e| {
69        tracing::warn!(error = %e, "failed to deserialize column definitions, using empty list");
70        Vec::new()
71    });
72    Ok(SchemaEntry {
73        table_name: row.get(0)?,
74        description: row.get(1)?,
75        columns,
76        created_by: row.get(3)?,
77        agent_owned: row.get::<_, i32>(4)? != 0,
78        created_at: row.get(5)?,
79        updated_at: row.get(6)?,
80        access_level: row
81            .get::<_, Option<String>>(7)?
82            .unwrap_or_else(|| "internal".into()),
83        row_count: row.get::<_, Option<i64>>(8)?.unwrap_or(0),
84    })
85}
86
87/// Look up a table's schema entry.
88pub fn get_table(db: &Database, table_name: &str) -> Result<Option<SchemaEntry>> {
89    let conn = db.conn();
90    conn.query_row(
91        &format!("SELECT {SELECT_COLS} FROM hippocampus WHERE table_name = ?1"),
92        [table_name],
93        row_to_entry,
94    )
95    .optional()
96    .db_err()
97}
98
99/// List all tables in the hippocampus.
100pub fn list_tables(db: &Database) -> Result<Vec<SchemaEntry>> {
101    let conn = db.conn();
102    let mut stmt = conn
103        .prepare(&format!(
104            "SELECT {SELECT_COLS} FROM hippocampus ORDER BY table_name"
105        ))
106        .db_err()?;
107
108    let rows = stmt.query_map([], row_to_entry).db_err()?;
109
110    rows.collect::<std::result::Result<Vec<_>, _>>().db_err()
111}
112
113/// List only agent-created tables.
114pub fn list_agent_tables(db: &Database, agent_id: &str) -> Result<Vec<SchemaEntry>> {
115    let conn = db.conn();
116    let mut stmt = conn
117        .prepare(&format!(
118            "SELECT {SELECT_COLS} FROM hippocampus WHERE agent_owned = 1 AND created_by = ?1 ORDER BY table_name"
119        ))
120        .db_err()?;
121
122    let rows = stmt.query_map([agent_id], row_to_entry).db_err()?;
123
124    rows.collect::<std::result::Result<Vec<_>, _>>().db_err()
125}
126
127fn validate_identifier(s: &str) -> Result<()> {
128    if s.is_empty()
129        || s.chars().next().is_some_and(|c| c.is_ascii_digit())
130        || !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
131    {
132        return Err(RoboticusError::Database(format!(
133            "invalid SQL identifier: {s}"
134        )));
135    }
136    Ok(())
137}
138
139/// Create an agent-owned table with the given columns.
140/// Table names are prefixed with the agent ID for isolation.
141pub fn create_agent_table(
142    db: &Database,
143    agent_id: &str,
144    table_suffix: &str,
145    description: &str,
146    columns: &[ColumnDef],
147) -> Result<String> {
148    let table_name = format!("{agent_id}_{table_suffix}");
149
150    if !table_name
151        .chars()
152        .all(|c| c.is_ascii_alphanumeric() || c == '_')
153    {
154        return Err(RoboticusError::Database(
155            "table name contains invalid characters".into(),
156        ));
157    }
158
159    for col in columns {
160        validate_identifier(&col.name)?;
161        validate_identifier(&col.col_type)?;
162    }
163
164    let col_defs: Vec<String> = columns
165        .iter()
166        .map(|c| {
167            let null = if c.nullable { "" } else { " NOT NULL" };
168            format!("{} {}{}", c.name, c.col_type, null)
169        })
170        .collect();
171
172    let middle = if col_defs.is_empty() {
173        String::new()
174    } else {
175        format!(", {}", col_defs.join(", "))
176    };
177
178    let create_sql = format!(
179        "CREATE TABLE IF NOT EXISTS \"{}\" (id TEXT PRIMARY KEY{}, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
180        table_name, middle
181    );
182
183    {
184        let conn = db.conn();
185        conn.execute(&create_sql, []).db_err()?;
186    }
187
188    register_table(
189        db,
190        &table_name,
191        description,
192        columns,
193        agent_id,
194        true,
195        "readwrite",
196        0,
197    )?;
198
199    Ok(table_name)
200}
201
202/// Drop an agent-owned table. Only tables created by the specified agent can be dropped.
203/// Auth check + DROP + registry DELETE are performed in a single transaction to prevent TOCTOU.
204pub fn drop_agent_table(db: &Database, agent_id: &str, table_name: &str) -> Result<()> {
205    validate_identifier(table_name)?;
206
207    let conn = db.conn();
208    let tx = conn.unchecked_transaction().db_err()?;
209
210    // Atomic check: verify ownership inside the transaction
211    let owned: bool = tx
212        .query_row(
213            "SELECT agent_owned AND created_by = ?2 FROM hippocampus WHERE table_name = ?1",
214            rusqlite::params![table_name, agent_id],
215            |row| row.get(0),
216        )
217        .map_err(|e| match e {
218            rusqlite::Error::QueryReturnedNoRows => {
219                RoboticusError::Database(format!("table {table_name} not found in hippocampus"))
220            }
221            other => RoboticusError::Database(other.to_string()),
222        })?;
223
224    if !owned {
225        return Err(RoboticusError::Database(
226            "cannot drop: table not owned by this agent".into(),
227        ));
228    }
229
230    tx.execute(&format!("DROP TABLE IF EXISTS \"{}\"", table_name), [])
231        .db_err()?;
232    tx.execute(
233        "DELETE FROM hippocampus WHERE table_name = ?1",
234        [table_name],
235    )
236    .db_err()?;
237
238    tx.commit().db_err()?;
239
240    Ok(())
241}
242
243/// Generate a schema map summary for injection into the agent's context.
244pub fn schema_summary(db: &Database) -> Result<String> {
245    let tables = list_tables(db)?;
246    if tables.is_empty() {
247        return Ok("No tables registered in hippocampus.".into());
248    }
249
250    let mut summary = String::from("## Database Schema Map\n\n");
251    for entry in &tables {
252        let owner = if entry.agent_owned {
253            format!(" (owned by: {})", entry.created_by)
254        } else {
255            " (system)".to_string()
256        };
257        summary.push_str(&format!(
258            "### {}{} [{}, {} rows]\n",
259            entry.table_name, owner, entry.access_level, entry.row_count
260        ));
261        summary.push_str(&format!("{}\n", entry.description));
262        for col in &entry.columns {
263            let null_str = if col.nullable { ", nullable" } else { "" };
264            let desc = col.description.as_deref().unwrap_or("");
265            summary.push_str(&format!(
266                "- `{}` ({}{}){}\n",
267                col.name,
268                col.col_type,
269                null_str,
270                if desc.is_empty() {
271                    String::new()
272                } else {
273                    format!(" — {desc}")
274                }
275            ));
276        }
277        summary.push('\n');
278    }
279    Ok(summary)
280}
281
282/// Compact hippocampus summary for context injection (~200 tokens).
283///
284/// Prioritizes agent-owned tables (fully listed), then a concise list of
285/// system table names. Fits within ~800 chars.
286pub fn compact_summary(db: &Database) -> Result<String> {
287    let tables = list_tables(db)?;
288    if tables.is_empty() {
289        return Ok(String::new());
290    }
291
292    let mut system_names = Vec::new();
293    let mut agent_lines = Vec::new();
294
295    for entry in &tables {
296        if entry.agent_owned {
297            agent_lines.push(format!(
298                "- {} ({} rows) — {}",
299                entry.table_name, entry.row_count, entry.description
300            ));
301        } else {
302            system_names.push(entry.table_name.as_str());
303        }
304    }
305
306    let mut summary = String::from("[Database]\n");
307
308    if !agent_lines.is_empty() {
309        summary.push_str("Your tables:\n");
310        for line in &agent_lines {
311            summary.push_str(line);
312            summary.push('\n');
313        }
314    }
315
316    if !system_names.is_empty() {
317        summary.push_str(&format!(
318            "System tables ({}): {}\n",
319            system_names.len(),
320            system_names.join(", ")
321        ));
322    }
323
324    summary.push_str("Use create_table/alter_table/drop_table tools to manage your tables. ");
325    summary.push_str("Use get_runtime_context for full schema details.");
326
327    // Hard truncation safety net
328    if summary.len() > 1000 {
329        summary.truncate(1000);
330        if let Some(last_nl) = summary.rfind('\n') {
331            summary.truncate(last_nl);
332        }
333        summary.push_str("\n...(use introspection tools for details)\n");
334    }
335
336    Ok(summary)
337}
338
339/// Return (description, access_level) for known system tables.
340fn system_table_metadata(table_name: &str) -> (&'static str, &'static str) {
341    match table_name {
342        "schema_version" => ("Schema migration version tracking", "internal"),
343        "sessions" => ("User conversation sessions", "read"),
344        "session_messages" => ("Messages within sessions", "read"),
345        "turns" => ("Conversation turn tracking", "internal"),
346        "tool_calls" => ("Tool invocation log", "read"),
347        "policy_decisions" => ("Policy evaluation results", "internal"),
348        "working_memory" => ("Session-scoped working memory", "read"),
349        "episodic_memory" => ("Long-term event memory", "read"),
350        "semantic_memory" => ("Factual knowledge store", "read"),
351        "procedural_memory" => ("Learned procedure memory", "read"),
352        "relationship_memory" => ("Entity relationship memory", "read"),
353        "tasks" => ("Task queue for agent work items", "read"),
354        "cron_jobs" => ("Scheduled cron jobs", "read"),
355        "cron_runs" => ("Cron job execution history", "read"),
356        "transactions" => ("Wallet transaction log", "internal"),
357        "inference_costs" => ("LLM inference cost tracking", "internal"),
358        "semantic_cache" => ("Semantic response cache", "internal"),
359        "identity" => ("Agent identity and credentials", "internal"),
360        "os_personality_history" => ("OS personality evolution log", "internal"),
361        "metric_snapshots" => ("System metric snapshots", "internal"),
362        "discovered_agents" => ("Discovered peer agents", "read"),
363        "skills" => ("Registered agent skills", "read"),
364        "delivery_queue" => ("Durable message delivery queue", "internal"),
365        "approval_requests" => ("Pending human approval requests", "read"),
366        "plugins" => ("Installed plugins", "read"),
367        "embeddings" => ("Vector embeddings store", "internal"),
368        "sub_agents" => ("Spawned sub-agent registry", "read"),
369        "context_checkpoints" => ("Context checkpoint snapshots", "internal"),
370        "hippocampus" => ("Schema map (this table)", "internal"),
371        "learned_skills" => ("Skills synthesized from successful tool sequences", "read"),
372        _ => ("Agent-managed table", "readwrite"),
373    }
374}
375
376/// Introspect columns of a table via `PRAGMA table_info`.
377fn introspect_columns(
378    conn: &rusqlite::Connection,
379    table_name: &str,
380) -> std::result::Result<Vec<ColumnDef>, rusqlite::Error> {
381    let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table_name))?;
382    let cols = stmt.query_map([], |row| {
383        let name: String = row.get(1)?;
384        let col_type: String = row.get(2)?;
385        let notnull: i32 = row.get(3)?;
386        Ok(ColumnDef {
387            name,
388            col_type,
389            nullable: notnull == 0,
390            description: None,
391        })
392    })?;
393    cols.collect()
394}
395
396/// Bootstrap the hippocampus by auto-discovering all tables in the database,
397/// introspecting their columns, and registering them. Also runs a consistency
398/// check to remove stale entries for tables that no longer exist.
399pub fn bootstrap_hippocampus(db: &Database) -> Result<()> {
400    // Phase 1: Discover all tables and collect metadata
401    let table_data: Vec<(String, Vec<ColumnDef>, i64)> = {
402        let conn = db.conn();
403        let mut stmt = conn
404            .prepare(
405                "SELECT name FROM sqlite_master \
406                 WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
407                 ORDER BY name",
408            )
409            .db_err()?;
410
411        let table_names: Vec<String> = stmt
412            .query_map([], |row| row.get(0))
413            .db_err()?
414            .collect::<std::result::Result<Vec<_>, _>>()
415            .db_err()?;
416
417        let mut data = Vec::with_capacity(table_names.len());
418        for name in table_names {
419            let columns = introspect_columns(&conn, &name).db_err()?;
420
421            let row_count: i64 = conn
422                .query_row(&format!("SELECT COUNT(*) FROM \"{}\"", name), [], |row| {
423                    row.get(0)
424                })
425                .unwrap_or(0);
426
427            data.push((name, columns, row_count));
428        }
429        data
430    };
431
432    // Phase 2: Register each table (connection released, register_table acquires its own)
433    for (name, columns, row_count) in &table_data {
434        let (description, access_level) = system_table_metadata(name);
435
436        // Preserve existing agent-owned entries — only upsert system tables
437        if let Some(existing) = get_table(db, name)?
438            && existing.agent_owned
439        {
440            // Update row_count only for agent-owned tables, keep their metadata
441            register_table(
442                db,
443                name,
444                &existing.description,
445                columns,
446                &existing.created_by,
447                true,
448                &existing.access_level,
449                *row_count,
450            )?;
451            continue;
452        }
453
454        register_table(
455            db,
456            name,
457            description,
458            columns,
459            "system",
460            false,
461            access_level,
462            *row_count,
463        )?;
464    }
465
466    // Phase 3: Consistency check — remove stale entries for non-existent tables
467    let registered = list_tables(db)?;
468    let existing_names: std::collections::HashSet<&str> =
469        table_data.iter().map(|(n, _, _)| n.as_str()).collect();
470
471    for entry in &registered {
472        if !existing_names.contains(entry.table_name.as_str()) {
473            tracing::warn!(
474                table = %entry.table_name,
475                "hippocampus entry for missing table, removing"
476            );
477            let conn = db.conn();
478            conn.execute(
479                "DELETE FROM hippocampus WHERE table_name = ?1",
480                [&entry.table_name],
481            )
482            .db_err()?;
483        }
484    }
485
486    tracing::info!(
487        tables = table_data.len(),
488        "hippocampus bootstrapped with schema map"
489    );
490    Ok(())
491}
492
493/// Seed the hippocampus with entries for core system tables (legacy helper).
494pub fn seed_system_tables(db: &Database) -> Result<()> {
495    let system_tables = vec![
496        (
497            "sessions",
498            "User conversation sessions",
499            vec![
500                ColumnDef {
501                    name: "id".into(),
502                    col_type: "TEXT".into(),
503                    nullable: false,
504                    description: Some("Primary key".into()),
505                },
506                ColumnDef {
507                    name: "agent_id".into(),
508                    col_type: "TEXT".into(),
509                    nullable: false,
510                    description: Some("Owning agent".into()),
511                },
512                ColumnDef {
513                    name: "scope_key".into(),
514                    col_type: "TEXT".into(),
515                    nullable: true,
516                    description: Some("Session scope identifier".into()),
517                },
518                ColumnDef {
519                    name: "status".into(),
520                    col_type: "TEXT".into(),
521                    nullable: false,
522                    description: Some("active/archived/expired".into()),
523                },
524            ],
525        ),
526        (
527            "episodic_memory",
528            "Long-term event memory",
529            vec![
530                ColumnDef {
531                    name: "id".into(),
532                    col_type: "TEXT".into(),
533                    nullable: false,
534                    description: Some("Primary key".into()),
535                },
536                ColumnDef {
537                    name: "classification".into(),
538                    col_type: "TEXT".into(),
539                    nullable: false,
540                    description: Some("Memory category".into()),
541                },
542                ColumnDef {
543                    name: "content".into(),
544                    col_type: "TEXT".into(),
545                    nullable: false,
546                    description: Some("Memory content".into()),
547                },
548                ColumnDef {
549                    name: "importance".into(),
550                    col_type: "INTEGER".into(),
551                    nullable: false,
552                    description: Some("1-10 importance score".into()),
553                },
554            ],
555        ),
556        (
557            "semantic_memory",
558            "Factual knowledge store",
559            vec![
560                ColumnDef {
561                    name: "id".into(),
562                    col_type: "TEXT".into(),
563                    nullable: false,
564                    description: Some("Primary key".into()),
565                },
566                ColumnDef {
567                    name: "category".into(),
568                    col_type: "TEXT".into(),
569                    nullable: false,
570                    description: Some("Knowledge category".into()),
571                },
572                ColumnDef {
573                    name: "key".into(),
574                    col_type: "TEXT".into(),
575                    nullable: false,
576                    description: Some("Fact key".into()),
577                },
578                ColumnDef {
579                    name: "value".into(),
580                    col_type: "TEXT".into(),
581                    nullable: false,
582                    description: Some("Fact value".into()),
583                },
584            ],
585        ),
586        (
587            "working_memory",
588            "Session-scoped working memory",
589            vec![
590                ColumnDef {
591                    name: "id".into(),
592                    col_type: "TEXT".into(),
593                    nullable: false,
594                    description: Some("Primary key".into()),
595                },
596                ColumnDef {
597                    name: "session_id".into(),
598                    col_type: "TEXT".into(),
599                    nullable: false,
600                    description: Some("Associated session".into()),
601                },
602                ColumnDef {
603                    name: "entry_type".into(),
604                    col_type: "TEXT".into(),
605                    nullable: false,
606                    description: Some("Type of entry".into()),
607                },
608                ColumnDef {
609                    name: "content".into(),
610                    col_type: "TEXT".into(),
611                    nullable: false,
612                    description: Some("Entry content".into()),
613                },
614            ],
615        ),
616    ];
617
618    for (name, desc, cols) in system_tables {
619        register_table(db, name, desc, &cols, "system", false, "read", 0)?;
620    }
621
622    Ok(())
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    fn test_db() -> Database {
630        Database::new(":memory:").unwrap()
631    }
632
633    #[test]
634    fn register_and_get_table() {
635        let db = test_db();
636        let cols = vec![
637            ColumnDef {
638                name: "name".into(),
639                col_type: "TEXT".into(),
640                nullable: false,
641                description: Some("User name".into()),
642            },
643            ColumnDef {
644                name: "age".into(),
645                col_type: "INTEGER".into(),
646                nullable: true,
647                description: None,
648            },
649        ];
650        register_table(
651            &db,
652            "users",
653            "User records",
654            &cols,
655            "system",
656            false,
657            "read",
658            0,
659        )
660        .unwrap();
661
662        let entry = get_table(&db, "users").unwrap().unwrap();
663        assert_eq!(entry.table_name, "users");
664        assert_eq!(entry.description, "User records");
665        assert_eq!(entry.columns.len(), 2);
666        assert!(!entry.agent_owned);
667        assert_eq!(entry.access_level, "read");
668        assert_eq!(entry.row_count, 0);
669    }
670
671    #[test]
672    fn get_table_not_found() {
673        let db = test_db();
674        assert!(get_table(&db, "nonexistent").unwrap().is_none());
675    }
676
677    #[test]
678    fn list_tables_includes_bootstrap() {
679        let db = test_db();
680        // Database::new runs bootstrap_hippocampus, so system tables are already registered
681        let tables = list_tables(&db).unwrap();
682        assert!(
683            tables.len() >= 20,
684            "bootstrap should register system tables, got {}",
685            tables.len()
686        );
687    }
688
689    #[test]
690    fn list_tables_grows_with_registration() {
691        let db = test_db();
692        let before = list_tables(&db).unwrap().len();
693        register_table(
694            &db,
695            "custom_a",
696            "Table A",
697            &[],
698            "test",
699            false,
700            "internal",
701            0,
702        )
703        .unwrap();
704        register_table(
705            &db,
706            "custom_b",
707            "Table B",
708            &[],
709            "test",
710            false,
711            "internal",
712            0,
713        )
714        .unwrap();
715        let after = list_tables(&db).unwrap().len();
716        assert_eq!(after, before + 2);
717    }
718
719    #[test]
720    fn create_agent_table_success() {
721        let db = test_db();
722        let cols = vec![
723            ColumnDef {
724                name: "key".into(),
725                col_type: "TEXT".into(),
726                nullable: false,
727                description: None,
728            },
729            ColumnDef {
730                name: "value".into(),
731                col_type: "TEXT".into(),
732                nullable: true,
733                description: None,
734            },
735        ];
736        let table_name = create_agent_table(&db, "agent42", "notes", "Agent notes", &cols).unwrap();
737        assert_eq!(table_name, "agent42_notes");
738
739        let entry = get_table(&db, "agent42_notes").unwrap().unwrap();
740        assert!(entry.agent_owned);
741        assert_eq!(entry.created_by, "agent42");
742        assert_eq!(entry.access_level, "readwrite");
743    }
744
745    #[test]
746    fn create_agent_table_invalid_chars() {
747        let db = test_db();
748        let result = create_agent_table(&db, "agent", "bad;name", "test", &[]);
749        assert!(result.is_err());
750    }
751
752    #[test]
753    fn drop_agent_table_success() {
754        let db = test_db();
755        create_agent_table(&db, "agent1", "temp", "temp table", &[]).unwrap();
756        drop_agent_table(&db, "agent1", "agent1_temp").unwrap();
757        assert!(get_table(&db, "agent1_temp").unwrap().is_none());
758    }
759
760    #[test]
761    fn drop_agent_table_wrong_owner() {
762        let db = test_db();
763        create_agent_table(&db, "agent1", "data", "data", &[]).unwrap();
764        let result = drop_agent_table(&db, "agent2", "agent1_data");
765        assert!(result.is_err());
766    }
767
768    #[test]
769    fn drop_system_table_fails() {
770        let db = test_db();
771        register_table(&db, "sessions", "Sessions", &[], "system", false, "read", 0).unwrap();
772        let result = drop_agent_table(&db, "agent1", "sessions");
773        assert!(result.is_err());
774    }
775
776    #[test]
777    fn list_agent_tables_filters() {
778        let db = test_db();
779        register_table(&db, "sessions", "System", &[], "system", false, "read", 0).unwrap();
780        create_agent_table(&db, "agent1", "notes", "Notes", &[]).unwrap();
781        create_agent_table(&db, "agent2", "data", "Data", &[]).unwrap();
782
783        let agent1_tables = list_agent_tables(&db, "agent1").unwrap();
784        assert_eq!(agent1_tables.len(), 1);
785        assert_eq!(agent1_tables[0].table_name, "agent1_notes");
786    }
787
788    #[test]
789    fn schema_summary_after_bootstrap() {
790        let db = test_db();
791        let summary = schema_summary(&db).unwrap();
792        // Bootstrap runs at init, so summary is never empty
793        assert!(summary.contains("## Database Schema Map"));
794        assert!(summary.contains("sessions"));
795    }
796
797    #[test]
798    fn compact_summary_includes_system_and_agent_tables() {
799        let db = test_db();
800        // Create an agent-owned table
801        create_agent_table(
802            &db,
803            "agent1",
804            "notes",
805            "Agent scratchpad",
806            &[ColumnDef {
807                name: "body".into(),
808                col_type: "TEXT".into(),
809                nullable: true,
810                description: None,
811            }],
812        )
813        .unwrap();
814
815        let summary = compact_summary(&db).unwrap();
816        assert!(summary.contains("[Database]"), "missing header");
817        assert!(summary.contains("System tables ("), "missing system count");
818        assert!(summary.contains("Your tables:"), "missing agent section");
819        assert!(summary.contains("agent1_notes"), "missing agent table");
820    }
821
822    #[test]
823    fn compact_summary_fits_token_budget() {
824        let db = test_db();
825        let summary = compact_summary(&db).unwrap();
826        // ~250 tokens ≈ ~1000 chars
827        assert!(
828            summary.len() <= 1100,
829            "compact_summary too long: {} chars",
830            summary.len()
831        );
832    }
833
834    #[test]
835    fn schema_summary_with_tables() {
836        let db = test_db();
837        seed_system_tables(&db).unwrap();
838        let summary = schema_summary(&db).unwrap();
839        assert!(summary.contains("sessions"));
840        assert!(summary.contains("episodic_memory"));
841        assert!(summary.contains("(system)"));
842        assert!(summary.contains("[read, 0 rows]"));
843    }
844
845    #[test]
846    fn seed_system_tables_upserts_over_bootstrap() {
847        let db = test_db();
848        let before = list_tables(&db).unwrap().len();
849        seed_system_tables(&db).unwrap();
850        let after = list_tables(&db).unwrap().len();
851        // seed_system_tables covers 4 tables already registered by bootstrap — no new entries
852        assert_eq!(before, after, "seed should upsert, not add duplicates");
853    }
854
855    #[test]
856    fn register_table_upsert() {
857        let db = test_db();
858        register_table(
859            &db,
860            "test",
861            "Version 1",
862            &[],
863            "system",
864            false,
865            "internal",
866            0,
867        )
868        .unwrap();
869        register_table(&db, "test", "Version 2", &[], "system", false, "read", 42).unwrap();
870
871        let entry = get_table(&db, "test").unwrap().unwrap();
872        assert_eq!(entry.description, "Version 2");
873        assert_eq!(entry.access_level, "read");
874        assert_eq!(entry.row_count, 42);
875    }
876
877    #[test]
878    fn column_def_serialization() {
879        let col = ColumnDef {
880            name: "test_col".into(),
881            col_type: "TEXT".into(),
882            nullable: true,
883            description: Some("A test column".into()),
884        };
885        let json = serde_json::to_string(&col).unwrap();
886        let decoded: ColumnDef = serde_json::from_str(&json).unwrap();
887        assert_eq!(decoded.name, "test_col");
888        assert!(decoded.nullable);
889    }
890
891    #[test]
892    fn validate_identifier_valid() {
893        validate_identifier("hello").unwrap();
894        validate_identifier("my_table").unwrap();
895        validate_identifier("col123").unwrap();
896        validate_identifier("A").unwrap();
897    }
898
899    #[test]
900    fn validate_identifier_empty_fails() {
901        assert!(validate_identifier("").is_err());
902    }
903
904    #[test]
905    fn validate_identifier_special_chars_fail() {
906        assert!(validate_identifier("name;drop").is_err());
907        assert!(validate_identifier("col name").is_err());
908        assert!(validate_identifier("table-name").is_err());
909        assert!(validate_identifier("col.name").is_err());
910    }
911
912    #[test]
913    fn create_agent_table_empty_columns() {
914        let db = test_db();
915        let name = create_agent_table(&db, "agent", "empty", "No columns", &[]).unwrap();
916        assert_eq!(name, "agent_empty");
917
918        // Table should have at least id and created_at
919        let conn = db.conn();
920        conn.execute("INSERT INTO \"agent_empty\" (id) VALUES ('row1')", [])
921            .unwrap();
922        let count: i64 = conn
923            .query_row("SELECT COUNT(*) FROM \"agent_empty\"", [], |row| row.get(0))
924            .unwrap();
925        assert_eq!(count, 1);
926    }
927
928    #[test]
929    fn create_agent_table_invalid_column_name() {
930        let db = test_db();
931        let cols = vec![ColumnDef {
932            name: "bad;col".into(),
933            col_type: "TEXT".into(),
934            nullable: false,
935            description: None,
936        }];
937        let result = create_agent_table(&db, "agent", "badcol", "test", &cols);
938        assert!(result.is_err());
939    }
940
941    #[test]
942    fn create_agent_table_invalid_column_type() {
943        let db = test_db();
944        let cols = vec![ColumnDef {
945            name: "good_col".into(),
946            col_type: "TEXT;DROP".into(),
947            nullable: false,
948            description: None,
949        }];
950        let result = create_agent_table(&db, "agent", "badtype", "test", &cols);
951        assert!(result.is_err());
952    }
953
954    #[test]
955    fn drop_agent_table_nonexistent() {
956        let db = test_db();
957        let result = drop_agent_table(&db, "agent", "nonexistent");
958        assert!(result.is_err());
959    }
960
961    #[test]
962    fn schema_summary_with_agent_owned_table() {
963        let db = test_db();
964        let cols = vec![
965            ColumnDef {
966                name: "note".into(),
967                col_type: "TEXT".into(),
968                nullable: false,
969                description: Some("The note content".into()),
970            },
971            ColumnDef {
972                name: "priority".into(),
973                col_type: "INTEGER".into(),
974                nullable: true,
975                description: None,
976            },
977        ];
978        create_agent_table(&db, "agent1", "notes", "Agent notes storage", &cols).unwrap();
979
980        let summary = schema_summary(&db).unwrap();
981        assert!(
982            summary.contains("(owned by: agent1)"),
983            "summary should show agent owner"
984        );
985        assert!(
986            summary.contains("note"),
987            "summary should include column names"
988        );
989        assert!(
990            summary.contains("nullable"),
991            "nullable columns should be marked"
992        );
993        assert!(
994            summary.contains("The note content"),
995            "column descriptions should appear"
996        );
997        assert!(
998            summary.contains("[readwrite, 0 rows]"),
999            "summary should show access level and row count"
1000        );
1001    }
1002
1003    #[test]
1004    fn schema_summary_column_without_description() {
1005        let db = test_db();
1006        let cols = vec![ColumnDef {
1007            name: "val".into(),
1008            col_type: "REAL".into(),
1009            nullable: false,
1010            description: None,
1011        }];
1012        register_table(
1013            &db,
1014            "metrics",
1015            "Metric values",
1016            &cols,
1017            "system",
1018            false,
1019            "internal",
1020            0,
1021        )
1022        .unwrap();
1023
1024        let summary = schema_summary(&db).unwrap();
1025        assert!(summary.contains("`val` (REAL)"));
1026    }
1027
1028    #[test]
1029    fn list_agent_tables_empty_for_unknown_agent() {
1030        let db = test_db();
1031        create_agent_table(&db, "agent1", "data", "Data", &[]).unwrap();
1032        let tables = list_agent_tables(&db, "agent_unknown").unwrap();
1033        assert!(tables.is_empty());
1034    }
1035
1036    #[test]
1037    fn seed_system_tables_idempotent() {
1038        let db = test_db();
1039        let baseline = list_tables(&db).unwrap().len();
1040        seed_system_tables(&db).unwrap();
1041        seed_system_tables(&db).unwrap();
1042        let after = list_tables(&db).unwrap().len();
1043        assert_eq!(
1044            baseline, after,
1045            "seeding twice should not create duplicates"
1046        );
1047    }
1048
1049    #[test]
1050    fn bootstrap_discovers_all_system_tables() {
1051        let db = test_db();
1052        bootstrap_hippocampus(&db).unwrap();
1053
1054        let tables = list_tables(&db).unwrap();
1055        // The :memory: database created via Database::new runs initialize_db which creates
1056        // all system tables. bootstrap_hippocampus should discover them all.
1057        assert!(
1058            tables.len() >= 20,
1059            "expected at least 20 system tables, got {}",
1060            tables.len()
1061        );
1062
1063        // Check specific known tables
1064        let names: Vec<&str> = tables.iter().map(|t| t.table_name.as_str()).collect();
1065        assert!(names.contains(&"sessions"), "missing sessions table");
1066        assert!(
1067            names.contains(&"inference_costs"),
1068            "missing inference_costs table"
1069        );
1070        assert!(
1071            names.contains(&"hippocampus"),
1072            "missing hippocampus table itself"
1073        );
1074    }
1075
1076    #[test]
1077    fn bootstrap_introspects_columns() {
1078        let db = test_db();
1079        bootstrap_hippocampus(&db).unwrap();
1080
1081        let entry = get_table(&db, "sessions").unwrap().unwrap();
1082        assert!(
1083            !entry.columns.is_empty(),
1084            "sessions should have introspected columns"
1085        );
1086        let col_names: Vec<&str> = entry.columns.iter().map(|c| c.name.as_str()).collect();
1087        assert!(col_names.contains(&"id"), "sessions should have id column");
1088        assert!(
1089            col_names.contains(&"agent_id"),
1090            "sessions should have agent_id column"
1091        );
1092    }
1093
1094    #[test]
1095    fn bootstrap_sets_access_levels() {
1096        let db = test_db();
1097        bootstrap_hippocampus(&db).unwrap();
1098
1099        let sessions = get_table(&db, "sessions").unwrap().unwrap();
1100        assert_eq!(sessions.access_level, "read");
1101
1102        let inference = get_table(&db, "inference_costs").unwrap().unwrap();
1103        assert_eq!(inference.access_level, "internal");
1104    }
1105
1106    #[test]
1107    fn bootstrap_preserves_agent_tables() {
1108        let db = test_db();
1109        create_agent_table(&db, "agent1", "notes", "My notes", &[]).unwrap();
1110        bootstrap_hippocampus(&db).unwrap();
1111
1112        let entry = get_table(&db, "agent1_notes").unwrap().unwrap();
1113        assert!(entry.agent_owned);
1114        assert_eq!(entry.created_by, "agent1");
1115        assert_eq!(entry.description, "My notes");
1116        assert_eq!(entry.access_level, "readwrite");
1117    }
1118
1119    #[test]
1120    fn bootstrap_idempotent() {
1121        let db = test_db();
1122        bootstrap_hippocampus(&db).unwrap();
1123        let count1 = list_tables(&db).unwrap().len();
1124        bootstrap_hippocampus(&db).unwrap();
1125        let count2 = list_tables(&db).unwrap().len();
1126        assert_eq!(count1, count2, "bootstrap should be idempotent");
1127    }
1128
1129    #[test]
1130    fn bootstrap_consistency_removes_stale_entries() {
1131        let db = test_db();
1132        // Register a fake table that doesn't actually exist
1133        register_table(
1134            &db,
1135            "phantom_table",
1136            "Does not exist",
1137            &[],
1138            "system",
1139            false,
1140            "internal",
1141            0,
1142        )
1143        .unwrap();
1144        assert!(get_table(&db, "phantom_table").unwrap().is_some());
1145
1146        // Bootstrap should remove it
1147        bootstrap_hippocampus(&db).unwrap();
1148        assert!(
1149            get_table(&db, "phantom_table").unwrap().is_none(),
1150            "stale entry should be removed by consistency check"
1151        );
1152    }
1153
1154    #[test]
1155    fn bootstrap_counts_rows() {
1156        let db = test_db();
1157
1158        // Insert some data into sessions (unique on agent_id + scope_key)
1159        {
1160            let conn = db.conn();
1161            conn.execute(
1162                "INSERT INTO sessions (id, agent_id, scope_key, status) VALUES ('s1', 'test', 'scope_a', 'active')",
1163                [],
1164            )
1165            .unwrap();
1166            conn.execute(
1167                "INSERT INTO sessions (id, agent_id, scope_key, status) VALUES ('s2', 'test', 'scope_b', 'active')",
1168                [],
1169            )
1170            .unwrap();
1171        }
1172
1173        bootstrap_hippocampus(&db).unwrap();
1174
1175        let entry = get_table(&db, "sessions").unwrap().unwrap();
1176        assert_eq!(entry.row_count, 2, "should count existing rows");
1177    }
1178
1179    #[test]
1180    fn system_table_metadata_known_tables() {
1181        let (desc, level) = system_table_metadata("sessions");
1182        assert_eq!(desc, "User conversation sessions");
1183        assert_eq!(level, "read");
1184
1185        let (desc, level) = system_table_metadata("inference_costs");
1186        assert_eq!(desc, "LLM inference cost tracking");
1187        assert_eq!(level, "internal");
1188    }
1189
1190    #[test]
1191    fn system_table_metadata_unknown_table() {
1192        let (desc, level) = system_table_metadata("unknown_custom_table");
1193        assert_eq!(desc, "Agent-managed table");
1194        assert_eq!(level, "readwrite");
1195    }
1196
1197    #[test]
1198    fn access_level_and_row_count_round_trip() {
1199        let db = test_db();
1200        register_table(&db, "test", "Test", &[], "system", false, "read", 99).unwrap();
1201
1202        let entry = get_table(&db, "test").unwrap().unwrap();
1203        assert_eq!(entry.access_level, "read");
1204        assert_eq!(entry.row_count, 99);
1205    }
1206}