KeiSeiKit-1.0/_primitives/_rust/kei-pet/src/memory.rs
Parfii-bot 0be354a920 KeiSeiKit-public — clean state
Single-commit clean baseline after security scrub of niche-tells,
project codenames, internal jargon, and contributor-email leaks.

Contents:
- 100 Rust crates (_primitives/_rust/)
- 37 agent manifests (_manifests/) + generated specs (_generated/)
- 67 user-invocable skills (skills/)
- 33 hooks (hooks/)
- Composition blocks (_blocks/)
- Documentation (docs/, README.md)
- TS adapter packages (_ts_packages/)
- Assembler (_assembler/)
- Roles (_roles/)
- Templates (_templates/)
- Forgejo CI (.forgejo/)

Author: Denis Parfionovich <info@greendragon.info>

License: see LICENSE.
2026-05-01 12:09:03 +08:00

167 lines
5 KiB
Rust

//! Persistent conversation memory indexed by (user_id, pet_name).
//!
//! Each row is a single message exchange turn (role = "user" | "assistant" |
//! caller-defined). Storage is SQLite. No FTS: `search` is a simple LIKE scan
//! scoped by the (user_id, pet_name) tuple.
//!
//! Scope boundary: this module does not open connections — the caller
//! supplies a `rusqlite::Connection` (on-disk or in-memory). That keeps the
//! module hermetically testable and lets the host choose the DB path.
use crate::injection_check;
use rusqlite::{params, Connection};
/// Conversation stream identity: one stream per (user, pet) pair.
#[derive(Debug, Clone)]
pub struct MemoryTag {
pub user_id: String,
pub pet_name: String,
}
/// A single recorded interaction row.
#[derive(Debug, Clone)]
pub struct Interaction {
pub id: i64,
pub role: String,
pub text: String,
pub ts: i64,
}
/// Errors surfaced by this module.
#[derive(Debug, thiserror::Error)]
pub enum MemoryError {
#[error(transparent)]
Sql(#[from] rusqlite::Error),
/// P2.1.b — content rejected by the injection check before persistence.
/// Mirrors `kei-memory::injection_guard` Block-tier rejections so a
/// malicious pet-conversation entry never lands in the SQLite table.
#[error("injection check blocked write: {0}")]
Blocked(String),
}
/// Create the `pet_conversations` table and its (user_id, pet_name, ts DESC)
/// index if they don't exist yet. Idempotent.
pub fn ensure_schema(conn: &Connection) -> Result<(), MemoryError> {
conn.execute(
"CREATE TABLE IF NOT EXISTS pet_conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
pet_name TEXT NOT NULL,
role TEXT NOT NULL,
text TEXT NOT NULL,
ts INTEGER NOT NULL
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pet_conv_tag_ts
ON pet_conversations (user_id, pet_name, ts DESC)",
[],
)?;
Ok(())
}
/// Insert one interaction row, returning its rowid.
///
/// P2.1.b — `text` is scanned via `injection_check::scan` before
/// persistence. Block-tier hits short-circuit with `MemoryError::Blocked`;
/// the row is NOT inserted and the SQLite autoincrement counter is not
/// advanced. The injected `role` is callee-controlled; we trust the
/// caller's role string and only sanitise the user-supplied `text`.
pub fn record_interaction(
conn: &Connection,
tag: &MemoryTag,
role: &str,
text: &str,
ts: i64,
) -> Result<i64, MemoryError> {
if let Err(finding) = injection_check::scan(text) {
return Err(MemoryError::Blocked(finding.to_string()));
}
conn.execute(
"INSERT INTO pet_conversations (user_id, pet_name, role, text, ts)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![tag.user_id, tag.pet_name, role, text, ts],
)?;
Ok(conn.last_insert_rowid())
}
/// Return up to `limit` most recent interactions for `tag`, newest first.
pub fn recent(
conn: &Connection,
tag: &MemoryTag,
limit: usize,
) -> Result<Vec<Interaction>, MemoryError> {
let mut stmt = conn.prepare(
"SELECT id, role, text, ts
FROM pet_conversations
WHERE user_id = ?1 AND pet_name = ?2
ORDER BY ts DESC, id DESC
LIMIT ?3",
)?;
let rows = stmt.query_map(
params![tag.user_id, tag.pet_name, limit as i64],
row_to_interaction,
)?;
collect_rows(rows)
}
/// Return up to `limit` interactions whose `text` contains `query` as a
/// literal substring (case-insensitive via LIKE), scoped to `tag`,
/// newest first.
pub fn search(
conn: &Connection,
tag: &MemoryTag,
query: &str,
limit: usize,
) -> Result<Vec<Interaction>, MemoryError> {
let pattern = format!("%{}%", escape_like(query));
let mut stmt = conn.prepare(
"SELECT id, role, text, ts
FROM pet_conversations
WHERE user_id = ?1 AND pet_name = ?2
AND text LIKE ?3 ESCAPE '\\'
ORDER BY ts DESC, id DESC
LIMIT ?4",
)?;
let rows = stmt.query_map(
params![tag.user_id, tag.pet_name, pattern, limit as i64],
row_to_interaction,
)?;
collect_rows(rows)
}
fn row_to_interaction(row: &rusqlite::Row<'_>) -> rusqlite::Result<Interaction> {
Ok(Interaction {
id: row.get(0)?,
role: row.get(1)?,
text: row.get(2)?,
ts: row.get(3)?,
})
}
fn collect_rows<I>(rows: I) -> Result<Vec<Interaction>, MemoryError>
where
I: Iterator<Item = rusqlite::Result<Interaction>>,
{
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Escape LIKE metacharacters (`%`, `_`, `\`) so callers can pass raw text.
fn escape_like(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' | '%' | '_' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}