feat: conversation review — a nightly report on a supervised person's conversations
Nightly Build / build (push) Successful in 7m40s

The first AgentScope::PerSubject system agent, and the reason that scope
exists. Once a night, for each person with a supervision edge, it reads every
message that person and the assistant exchanged since the previous review —
across all their conversations — and writes one report for the people who
supervise them.

Schema (all registry except reports):
- supervision(subject_user_id, supervisor_user_id): the generic §0.1 edge,
  answering both 'whom does a background agent look at' and 'who may read
  what it produced', with real FKs so deleting a user cascades both ways
- system_agent_coverage(agent_id, subject_user_id, covered_through): the
  per-subject watermark that makes 'everything since last time' a window —
  neither system_agent_runs (history for humans) nor system_agent_state
  (advances before the work), and advanced only on a completed pass so a
  crash re-covers instead of skipping
- reports (owner schema, the second two-homes table after memory_docs):
  instance rows land in system.db, deliberately cleartext to the box owner,
  who is the intended reader (§2); the subject cannot see them structurally

The pass reads the subject's database inside a supervisor's runtime, so the
ephemeral session and run row land in the watcher's file; iteration is over
subjects, so two parents watching one child get one review; and the subject
need not be logged in when their space is unencrypted — via the new
UserManager::open_unencrypted, which refuses an encrypted user outright (no
key to be had) and never registers the pool as unlocked.

The agent declares the new AgentMeta flag allow_tools: false, so its turn
gets an empty tool registry — nothing for a prompt injection in the
transcript to call — and produces its report as its final assistant message,
read back from chat_history and parsed (NOTHING_TO_REPORT sentinel, no row on
quiet days). chat_history::conversation_window is the transcript query; its
four filters (non-ephemeral, depth 0, non-synthetic, non-empty) each guard a
specific way the review would otherwise be wrong, and tool calls are absent
by construction.

Cadence is Run at (hour) rather than Interval — 4am local by default — with
due-ness answered inside has_work against the coverage watermark, so a
machine off for three days covers the whole stretch in one pass. Reports
announce ReportCreated on the system bus (no subscriber yet). run_ephemeral_turn
gains a per-pass system_substitutions map, which the review uses to hand the
model the subject's profile under __SUBJECT_PROFILE__ — the system-context
substitutions describe the session owner, the wrong person here.

docs/system-agents.md gains the conversation review section; CLAUDE.md
documents the scope, the tables and the tool-less design.
This commit is contained in:
2026-08-02 20:30:27 +01:00
parent e6818408cb
commit 4f10528368
19 changed files with 2394 additions and 35 deletions
+17
View File
@@ -104,6 +104,23 @@ pub enum SystemEvent {
ConnectorReinstalled {
catalog_name: String,
},
// ── Reports (blueprint §13) ───────────────────────────────────────────────
/// A background agent filed a report. Announced by whoever wrote the row,
/// never delivered by it: *who* should hear about a report — the people
/// supervising its subject, an unread badge, a future digest — is a question
/// the producer has no business answering, and answering it there would make
/// every new recipient a change to every agent that writes one.
///
/// Best-effort like everything on this bus, which is the right promise here: a
/// missed announcement costs a notification, not the report, and the row is
/// already durable by the time this is sent. `subject_user_id` is `None` for a
/// report about nobody in particular.
ReportCreated {
report_id: i64,
kind: String,
subject_user_id: Option<String>,
},
}
// ── Bus ───────────────────────────────────────────────────────────────────────
+17
View File
@@ -68,6 +68,8 @@ struct RawMeta {
inject_skills: bool,
#[serde(default)]
icon: Option<String>,
#[serde(default = "default_true")]
allow_tools: bool,
}
/// Serde default for boolean fields that should be `true` when the key is absent.
@@ -121,6 +123,19 @@ pub struct AgentMeta {
/// Defaults to None if no icon is configured.
#[serde(default)]
pub icon: Option<String>,
/// Whether this agent is offered any tools at all. True unless stated
/// otherwise, which is every agent that does anything.
///
/// `false` empties the turn's tool set — built-ins, MCP, plugin and interface
/// tools alike, `notify` included. For an agent whose whole job is to read
/// what it was handed and answer in prose, that is a stronger and simpler
/// guarantee than any permission group: a group governs *whether a call is
/// allowed*, this governs *whether there is anything to call*. Nothing to
/// gate, nothing to approve, nothing to reach — and no way for a prompt
/// injection carried in the material it reads to act, because the round it
/// would act in has no tools in it.
#[serde(default = "default_true")]
pub allow_tools: bool,
}
impl AgentMeta {
@@ -195,6 +210,7 @@ pub fn discover() -> Result<Vec<AgentMeta>> {
agent_type: raw.agent_type,
inject_skills: raw.inject_skills,
icon: raw.icon,
allow_tools: raw.allow_tools,
};
trace!(agent_id = %meta.id, client = ?meta.client, strength = ?meta.strength, "agent meta loaded");
debug!(agent_id = %meta.id, name = %meta.name, "agent discovered");
@@ -227,6 +243,7 @@ pub fn load_meta(agent_id: &str) -> Result<AgentMeta> {
agent_type: raw.agent_type,
inject_skills: raw.inject_skills,
icon: raw.icon,
allow_tools: raw.allow_tools,
})
}
+271
View File
@@ -213,6 +213,136 @@ pub async fn for_stack_since(
rows.into_iter().map(row_to_message).collect()
}
/// One line of a cross-session transcript: a message with the conversation it
/// belongs to. See [`conversation_window`].
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct TranscriptLine {
pub session_id: i64,
pub session_title: Option<String>,
pub source: String,
pub agent_id: String,
pub role: String,
pub content: String,
pub created_at: String,
}
/// Every message this database's owner exchanged with an assistant between
/// `since` (inclusive) and `until` (exclusive), across **all** their
/// conversations, oldest first.
///
/// The window is half-open so consecutive calls tile without overlapping or
/// skipping: today's `until` is tomorrow's `since`. Both bounds are UTC
/// `'YYYY-MM-DD HH:MM:SS'`, the shape `datetime('now')` writes, so they compare
/// as plain strings against `created_at`.
///
/// **Four filters, and each one exists because of a specific way the result would
/// otherwise be wrong:**
///
/// - `is_ephemeral = 0` — a background agent's own throwaway sessions live in the
/// same table. Without this, a pass that reads conversations would read the
/// transcript its *previous* pass was given, and report on itself.
/// - `depth = 0` — only the root frame. Deeper frames are sub-agents talking to
/// each other: machine-to-machine chatter that nobody typed.
/// - `is_synthetic = 0` — turns the machinery injected as if they were the user
/// (notification briefings, job results). Attributing those to the person would
/// be a lie about who said what.
/// - `content <> ''` — an assistant row whose whole content was a tool call.
///
/// **Tool calls and their results are not here at all**, and that is by
/// construction rather than by filter: they live in `chat_llm_tools`, keyed to a
/// message id. So this returns what was *said*, never what was *done* — a web
/// search the assistant ran is invisible, including its query.
pub async fn conversation_window(
pool: &SqlitePool,
since: &str,
until: &str,
limit: i64,
) -> anyhow::Result<Vec<TranscriptLine>> {
// Newest-first with a LIMIT, then reversed: over budget, the window that
// matters is the recent end, not whatever happened to come first.
let mut rows = sqlx::query_as::<_, TranscriptLine>(
"SELECT s.id AS session_id,
s.title AS session_title,
s.source AS source,
s.agent_id AS agent_id,
h.role AS role,
h.content AS content,
h.created_at AS created_at
FROM chat_history h
JOIN chat_sessions_stack st ON st.id = h.session_stack_id
JOIN chat_sessions s ON s.id = st.session_id
WHERE h.created_at >= ? AND h.created_at < ?
AND h.status = 'ok'
AND h.is_synthetic = 0
AND h.content <> ''
AND h.role IN ('user', 'assistant')
AND st.depth = 0
AND s.is_ephemeral = 0
ORDER BY h.created_at DESC, h.id DESC
LIMIT ?",
)
.bind(since)
.bind(until)
.bind(limit)
.fetch_all(pool)
.await?;
rows.reverse();
Ok(rows)
}
/// How many messages [`conversation_window`] would return, without loading them.
/// The cheap look a scheduler takes before deciding a pass is worth opening.
pub async fn conversation_window_count(
pool: &SqlitePool,
since: &str,
until: &str,
) -> anyhow::Result<i64> {
let n = sqlx::query_scalar::<_, i64>(
"SELECT count(*)
FROM chat_history h
JOIN chat_sessions_stack st ON st.id = h.session_stack_id
JOIN chat_sessions s ON s.id = st.session_id
WHERE h.created_at >= ? AND h.created_at < ?
AND h.status = 'ok'
AND h.is_synthetic = 0
AND h.content <> ''
AND h.role IN ('user', 'assistant')
AND st.depth = 0
AND s.is_ephemeral = 0",
)
.bind(since)
.bind(until)
.fetch_one(pool)
.await?;
Ok(n)
}
/// The last thing the assistant said in a session's **root** frame.
///
/// For a caller whose agent produces a document rather than a side effect: the
/// turn's answer is the deliverable, and it has to be read back from the store
/// because `handle_message` returns nothing. Root frame only — the deepest
/// sub-agent's last words are not the session's answer.
pub async fn last_assistant_for_session(
pool: &SqlitePool,
session_id: i64,
) -> anyhow::Result<Option<String>> {
let content = sqlx::query_scalar::<_, String>(
"SELECT h.content
FROM chat_history h
JOIN chat_sessions_stack st ON st.id = h.session_stack_id
WHERE st.session_id = ? AND st.depth = 0
AND h.role = 'assistant' AND h.status = 'ok' AND h.content <> ''
ORDER BY h.id DESC
LIMIT 1",
)
.bind(session_id)
.fetch_optional(pool)
.await?;
Ok(content)
}
/// Returns the most recent ok message for a stack frame, or `None` if empty.
/// Used by Telegram's `/context` command to show last turn's token usage.
pub async fn last_message_for_stack(
@@ -274,3 +404,144 @@ pub async fn estimate_tokens_for_stack(
Ok((total_chars / 4).max(0) as u32)
}
#[cfg(test)]
mod tests {
use super::*;
/// A standalone owner-schema database with one ordinary conversation and one
/// of every thing the window must leave out.
async fn seeded() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
crate::db::create_owner_tables(&pool).await.unwrap();
let q = |sql: &'static str| sqlx::query(sql).execute(&pool);
// A real conversation, and a second one the same day.
q("INSERT INTO chat_sessions (id, title, source, agent_id, is_ephemeral) VALUES (1, 'Homework', 'web', 'kid', 0)").await.unwrap();
q("INSERT INTO chat_sessions (id, title, source, agent_id, is_ephemeral) VALUES (2, NULL, 'telegram', 'kid', 0)").await.unwrap();
// A background agent's throwaway session — the one that would make a
// review read its own previous pass.
q("INSERT INTO chat_sessions (id, title, source, agent_id, is_ephemeral) VALUES (3, 'review', 'conversation-review', 'conversation-review', 1)").await.unwrap();
q("INSERT INTO chat_sessions_stack (id, session_id, depth) VALUES (1, 1, 0)").await.unwrap();
q("INSERT INTO chat_sessions_stack (id, session_id, depth) VALUES (2, 2, 0)").await.unwrap();
q("INSERT INTO chat_sessions_stack (id, session_id, depth) VALUES (3, 3, 0)").await.unwrap();
// A sub-agent frame of the real conversation.
q("INSERT INTO chat_sessions_stack (id, session_id, depth) VALUES (4, 1, 1)").await.unwrap();
let msg = |stack: i64, role: &'static str, content: &'static str, at: &'static str,
synthetic: i64, status: &'static str| {
sqlx::query(
"INSERT INTO chat_history (session_stack_id, role, content, created_at, is_synthetic, status)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(stack).bind(role).bind(content).bind(at).bind(synthetic).bind(status)
.execute(&pool)
};
msg(1, "user", "kept: in window", "2026-07-28 21:00:00", 0, "ok").await.unwrap();
msg(1, "assistant", "kept: the reply", "2026-07-28 21:00:30", 0, "ok").await.unwrap();
msg(2, "user", "kept: other session", "2026-07-29 02:00:00", 0, "ok").await.unwrap();
msg(1, "user", "dropped: before", "2026-07-27 10:00:00", 0, "ok").await.unwrap();
msg(1, "user", "dropped: after", "2026-07-30 10:00:00", 0, "ok").await.unwrap();
msg(3, "user", "dropped: ephemeral", "2026-07-28 22:00:00", 0, "ok").await.unwrap();
msg(4, "assistant", "dropped: sub-agent", "2026-07-28 22:00:00", 0, "ok").await.unwrap();
msg(1, "user", "dropped: synthetic", "2026-07-28 22:00:00", 1, "ok").await.unwrap();
msg(1, "assistant", "dropped: failed", "2026-07-28 22:00:00", 0, "failed").await.unwrap();
msg(1, "assistant", "", "2026-07-28 22:00:00", 0, "ok").await.unwrap();
msg(1, "agent", "dropped: agent role", "2026-07-28 22:00:00", 0, "ok").await.unwrap();
pool
}
const SINCE: &str = "2026-07-28 04:00:00";
const UNTIL: &str = "2026-07-29 04:00:00";
/// Each exclusion is a way the review would otherwise be wrong; assert them
/// together, because it is the *set* that defines "what was said".
#[tokio::test]
async fn the_window_keeps_only_what_was_said_in_it() {
let pool = seeded().await;
let lines = conversation_window(&pool, SINCE, UNTIL, 100).await.unwrap();
let kept: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
assert_eq!(kept, vec![
"kept: in window",
"kept: the reply",
"kept: other session",
], "everything else is a way the transcript would lie");
// The count is the same question, asked cheaply.
assert_eq!(conversation_window_count(&pool, SINCE, UNTIL).await.unwrap(), 3);
// Oldest first, and each line carries the conversation it belongs to.
assert_eq!(lines[0].session_id, 1);
assert_eq!(lines[0].session_title.as_deref(), Some("Homework"));
assert_eq!(lines[2].session_id, 2);
assert_eq!(lines[2].source, "telegram");
assert!(lines[2].session_title.is_none());
}
/// Half-open, so consecutive windows tile: a message exactly on the boundary
/// belongs to the later window, never to both and never to neither.
#[tokio::test]
async fn the_window_is_half_open() {
let pool = seeded().await;
sqlx::query(
"INSERT INTO chat_history (session_stack_id, role, content, created_at)
VALUES (1, 'user', 'exactly on the edge', ?)",
)
.bind(UNTIL)
.execute(&pool).await.unwrap();
let before = conversation_window(&pool, SINCE, UNTIL, 100).await.unwrap();
assert!(!before.iter().any(|l| l.content == "exactly on the edge"),
"`until` is exclusive");
let after = conversation_window(&pool, UNTIL, "2026-07-30 04:00:00", 100).await.unwrap();
assert!(after.iter().any(|l| l.content == "exactly on the edge"),
"`since` is inclusive, so nothing falls between two windows");
}
/// Over budget, the recent end is what survives — a truncated review of last
/// night beats a complete review of last month.
#[tokio::test]
async fn a_capped_window_keeps_the_most_recent_messages() {
let pool = seeded().await;
let lines = conversation_window(&pool, SINCE, UNTIL, 2).await.unwrap();
let kept: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
assert_eq!(kept, vec!["kept: the reply", "kept: other session"]);
// The count ignores the cap, which is how the caller knows it truncated.
assert_eq!(conversation_window_count(&pool, SINCE, UNTIL).await.unwrap(), 3);
}
#[tokio::test]
async fn the_last_assistant_message_comes_from_the_root_frame() {
let pool = seeded().await;
// Session 1's newest root-frame assistant line, not the sub-agent's.
sqlx::query(
"INSERT INTO chat_history (session_stack_id, role, content, created_at)
VALUES (1, 'assistant', 'the answer', '2026-07-29 03:00:00')",
)
.execute(&pool).await.unwrap();
sqlx::query(
"INSERT INTO chat_history (session_stack_id, role, content, created_at)
VALUES (4, 'assistant', 'sub-agent chatter', '2026-07-29 03:30:00')",
)
.execute(&pool).await.unwrap();
assert_eq!(
last_assistant_for_session(&pool, 1).await.unwrap().as_deref(),
Some("the answer"),
);
// A session that never got an answer says so rather than inventing one.
assert!(last_assistant_for_session(&pool, 2).await.unwrap().is_none());
assert!(last_assistant_for_session(&pool, 99).await.unwrap().is_none());
}
}
+140
View File
@@ -24,12 +24,15 @@ pub mod oauth_providers;
pub mod plugins;
pub mod plugin_access;
pub mod plugin_user_configs;
pub mod reports;
pub mod role_capabilities;
pub mod roles;
pub mod scheduled_jobs;
pub mod scratchpad;
pub mod shared_folders;
pub mod sources;
pub mod supervision;
pub mod system_agent_coverage;
pub mod system_agent_runs;
pub mod system_agent_state;
pub mod tool_permission_groups;
@@ -679,6 +682,76 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
.execute(pool)
.await?;
// The supervision edge (§0.1): one person's activity may be read on another's
// behalf. **A generic edge between two users, and nothing more** — the domain
// reading of it ("a parent watches a child") lives in the seed data and the UI
// copy, never here, so a pivot to a mentor watching a trainee, or a care worker
// watching a resident, renames nothing.
//
// It answers two questions with one table, which is why it is an edge and not a
// per-agent list of subjects: *whom does a background agent look at* (the
// distinct subjects) and *who may read what it produced* (the supervisors of a
// given subject). The second is what the reports' `audience = 'supervisors'`
// resolves against.
//
// Both FKs are registry→registry (same file), so they are allowed and the
// cascade is real: deleting a user takes their edges with them, in both
// directions.
sqlx::query(
"CREATE TABLE IF NOT EXISTS supervision (
subject_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
supervisor_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (subject_user_id, supervisor_user_id)
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_supervision_supervisor
ON supervision(supervisor_user_id)",
)
.execute(pool)
.await?;
// How far a background agent has *processed* a subject — the watermark that
// makes "everything since last time" a well-defined window.
//
// **Not `system_agent_runs`, and not `system_agent_state`**, though it sits
// between them and the difference is the whole reason it exists:
//
// `system_agent_state` when an agent last *attempted* a pass. Advances on
// every tick, including idle ones, and is marked
// *before* the work — so it can never delimit the
// window the work is about.
// this table how far the work actually got. Advances **only on a
// completed pass**, so a crash mid-pass re-covers the
// same stretch next time. For a review, a duplicate
// report is a nuisance and a skipped window is a blind
// spot: at-least-once is the only acceptable direction.
//
// The obvious alternative — deriving the watermark from the last report's
// `period_end` — fails on a single ordinary action: a supervisor deleting an
// old report would move the scheduler's window back and regenerate the very
// report they discarded. A document is the user's to delete; scheduler state is
// not, so they cannot be the same row.
//
// Registry, not owner, for a reason specific to how these passes run: the pass
// executes inside *some* supervisor's runtime, and which one depends on who is
// logged in tonight. A watermark in the acting user's file would give one
// subject two unsynchronised clocks.
sqlx::query(
"CREATE TABLE IF NOT EXISTS system_agent_coverage (
agent_id TEXT NOT NULL,
subject_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
covered_through TEXT NOT NULL, -- UTC 'YYYY-MM-DD HH:MM:SS'
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (agent_id, subject_user_id)
)",
)
.execute(pool)
.await?;
Ok(())
}
@@ -1128,6 +1201,68 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
sqlx::query(trigger).execute(pool).await?;
}
// Reports — the documents system agents write about a stretch of time
// (blueprint §13). Like `memory_docs` above, one owner schema backs **two
// homes**, and which file a row lands in *is* its audience:
//
// `{userid}.db` a report that belongs to that user, about that user —
// their weekly "what you struggled to get done" digest.
// Behind SQLCipher: nobody else can read it, admin included.
// `system.db` an instance report, written about someone *for* the
// people who supervise them. Cleartext to whoever owns the
// box, deliberately — they are the intended reader (§2).
//
// That split is why nothing here filters by reader: a report's subject can
// never see an instance report about them, because their tools only ever
// touch their own pool. The invisibility is structural, not a rule someone
// has to remember in each query.
//
// The producer's scope decides the file with no extra concept:
// `AgentScope::PerUser` writes into `ctx.pool`, `AgentScope::Instance` into
// the registry pool the agent already holds.
//
// `subject_user_id` / `producer_user_id` / `run_id` are **bare** columns, not
// foreign keys: `users` lives in the registry (an owner→registry FK would
// fail every INSERT), and for an instance row the `system_agent_runs` trace
// sits in the *acting* user's file. They are snapshots, and a deleted user
// leaves them dangling on purpose — the report outlives the account.
//
// `kind` is free-form producer-declared text, never an enum (§0.1). Rows are
// immutable once written: the only UPDATE is the read acknowledgement.
sqlx::query(
"CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL, -- producer-declared type, not an enum
title TEXT NOT NULL,
summary TEXT, -- one line: lists + notification text
body TEXT NOT NULL DEFAULT '', -- markdown
severity TEXT NOT NULL DEFAULT 'info', -- 'info' | 'notice' | 'alert'
subject_user_id TEXT, -- who it is about (bare snapshot)
audience TEXT NOT NULL DEFAULT 'owner', -- 'owner' | 'admins' | 'supervisors'
period_start TEXT, -- the window it covers
period_end TEXT,
produced_by TEXT NOT NULL, -- system agent id
producer_user_id TEXT, -- whose runtime ran the pass
run_id INTEGER, -- system_agent_runs.id (bare snapshot)
metadata TEXT, -- JSON counters; never contents
read_at TEXT, -- shared acknowledgement: first reader wins
read_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
// Listing is always newest-first, optionally narrowed to one subject. `kind`
// is deliberately unindexed: a handful of rows a week means a scan is
// cheaper than the index it would need.
for index in [
"CREATE INDEX IF NOT EXISTS idx_reports_created ON reports(created_at DESC, id DESC)",
"CREATE INDEX IF NOT EXISTS idx_reports_subject ON reports(subject_user_id, created_at DESC)",
] {
sqlx::query(index).execute(pool).await?;
}
Ok(())
}
@@ -1186,6 +1321,11 @@ mod tests {
one("INSERT INTO llm_request_payloads (request_id, request_json) VALUES ('r1', '{}')").await.unwrap();
// Fires the AFTER INSERT trigger into the external-content FTS5 table.
one("INSERT INTO memory_docs (path, content) VALUES ('notes/x.md', 'hello world')").await.unwrap();
// Bare `subject_user_id` / `producer_user_id` (registry `users`) and a
// bare `run_id` that points at no row in this file — an FK on any of the
// three would die right here.
one("INSERT INTO reports (kind, title, body, produced_by, subject_user_id, producer_user_id, run_id)
VALUES ('conversation-review', 't', 'b', 'agent', 'u-absent', 'u-also-absent', 4242)").await.unwrap();
// ...and the FTS index actually answers a MATCH.
let (hits,): (i64,) = sqlx::query_as(
+435
View File
@@ -0,0 +1,435 @@
//! Accessor for `reports` — the documents system agents write about a stretch
//! of time (blueprint §13).
//!
//! **The pool is the audience.** Like [`super::memory_docs`], one owner schema
//! backs two homes and the file a row lands in decides who may read it: a user's
//! own encrypted database holds the reports that belong to them, `system.db`
//! holds the instance ones — written about someone, for the people who supervise
//! them. Nothing in here filters by reader, because there is nothing to filter:
//! a subject's tools only ever reach their own pool. The separation is
//! structural, not a predicate someone has to remember to add.
//!
//! Which file a producer writes into falls out of its own scope with no new
//! concept: `AgentScope::PerUser` passes `ctx.pool`, `AgentScope::Instance`
//! passes the registry pool it already holds.
//!
//! **A report is immutable.** It is a snapshot of a window that has closed, so
//! there is no `update`: the only write after [`create`] is [`mark_read`], and
//! even that is once — see its "first reader wins" note.
use anyhow::Result;
use sqlx::SqlitePool;
/// Severity, in ascending order of "someone should look at this". Free text in
/// the column; these are the vocabulary the UI knows how to render.
pub const SEVERITY_INFO: &str = "info";
pub const SEVERITY_NOTICE: &str = "notice";
pub const SEVERITY_ALERT: &str = "alert";
/// The report belongs to whoever owns the file it is in — the default, and the
/// only meaningful value inside a `{userid}.db`.
pub const AUDIENCE_OWNER: &str = "owner";
/// An instance report (`system.db`) for the admins.
pub const AUDIENCE_ADMINS: &str = "admins";
/// An instance report for whoever holds a [`super::supervision`] edge over its
/// `subject_user_id` — the audience that is *computed*, not enumerated, so adding
/// a second parent to the edge widens the readership of every past report at once.
pub const AUDIENCE_SUPERVISORS: &str = "supervisors";
/// A report with its body. Use [`ReportSummary`] for listings — the body is the
/// bulk of the row and a list never renders it.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct Report {
pub id: i64,
pub kind: String,
pub title: String,
pub summary: Option<String>,
pub body: String,
pub severity: String,
pub subject_user_id: Option<String>,
pub audience: String,
pub period_start: Option<String>,
pub period_end: Option<String>,
pub produced_by: String,
pub producer_user_id: Option<String>,
pub run_id: Option<i64>,
pub metadata: Option<String>,
pub read_at: Option<String>,
pub read_by: Option<String>,
pub created_at: String,
}
/// A listing row: everything but `body`.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct ReportSummary {
pub id: i64,
pub kind: String,
pub title: String,
pub summary: Option<String>,
pub severity: String,
pub subject_user_id: Option<String>,
pub audience: String,
pub period_start: Option<String>,
pub period_end: Option<String>,
pub produced_by: String,
pub producer_user_id: Option<String>,
pub run_id: Option<i64>,
pub metadata: Option<String>,
pub read_at: Option<String>,
pub read_by: Option<String>,
pub created_at: String,
}
/// The fields a producer supplies. `kind`, `title`, `body` and `produced_by` are
/// the ones with no sensible default; everything else has one.
#[derive(Debug, Clone)]
pub struct NewReport<'a> {
/// Producer-declared type — data, never an enum (§0.1). Groups the UI.
pub kind: &'a str,
pub title: &'a str,
/// One line for lists and for the notification that announces it.
pub summary: Option<&'a str>,
/// Markdown.
pub body: &'a str,
pub severity: &'a str,
/// Who the report is about. `None` for a report about nobody in particular.
pub subject_user_id: Option<&'a str>,
pub audience: &'a str,
/// The window covered, ISO-8601. Both `None` for a point-in-time report.
pub period_start: Option<&'a str>,
pub period_end: Option<&'a str>,
/// The system agent's id.
pub produced_by: &'a str,
/// Whose runtime ran the pass — for an instance report, not the subject.
pub producer_user_id: Option<&'a str>,
/// `system_agent_runs.id`. A bare snapshot: for an instance report that row
/// lives in the acting user's file, not this one.
pub run_id: Option<i64>,
/// JSON counters. Never contents — the body is the only place text belongs.
pub metadata: Option<&'a str>,
}
/// Hand-written, not derived, for the same reason `RoleAttrs`'s is: a derived
/// `Default` would leave `severity` and `audience` empty strings, and both are
/// `NOT NULL` columns whose value the UI dispatches on. The defaults are the
/// quiet, narrow ones — informational, and readable only by the file's owner.
impl Default for NewReport<'_> {
fn default() -> Self {
Self {
kind: "",
title: "",
summary: None,
body: "",
severity: SEVERITY_INFO,
subject_user_id: None,
audience: AUDIENCE_OWNER,
period_start: None,
period_end: None,
produced_by: "",
producer_user_id: None,
run_id: None,
metadata: None,
}
}
}
/// How to narrow a [`list`]. All-`None` lists everything, newest first.
#[derive(Debug, Clone, Default)]
pub struct ListFilter<'a> {
pub kind: Option<&'a str>,
pub subject_user_id: Option<&'a str>,
/// Only reports nobody has acknowledged yet.
pub unread_only: bool,
/// Only reports created at or after this ISO timestamp.
pub since: Option<&'a str>,
pub limit: Option<i64>,
}
const SUMMARY_COLS: &str = "id, kind, title, summary, severity, subject_user_id, audience, \
period_start, period_end, produced_by, producer_user_id, run_id, metadata, \
read_at, read_by, created_at";
/// Write a report. Returns its id.
pub async fn create(pool: &SqlitePool, report: &NewReport<'_>) -> Result<i64> {
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO reports
(kind, title, summary, body, severity, subject_user_id, audience,
period_start, period_end, produced_by, producer_user_id, run_id, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id",
)
.bind(report.kind)
.bind(report.title)
.bind(report.summary)
.bind(report.body)
.bind(report.severity)
.bind(report.subject_user_id)
.bind(report.audience)
.bind(report.period_start)
.bind(report.period_end)
.bind(report.produced_by)
.bind(report.producer_user_id)
.bind(report.run_id)
.bind(report.metadata)
.fetch_one(pool)
.await?;
Ok(id)
}
/// Fetch one report, body included.
pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<Report>> {
let row = sqlx::query_as::<_, Report>(
"SELECT id, kind, title, summary, body, severity, subject_user_id, audience,
period_start, period_end, produced_by, producer_user_id, run_id, metadata,
read_at, read_by, created_at
FROM reports WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row)
}
/// List reports newest first, without their bodies.
///
/// `id DESC` breaks ties: `created_at` has second resolution, and two reports of
/// the same pass land inside one tick often enough that the order would
/// otherwise be whatever SQLite felt like.
pub async fn list(pool: &SqlitePool, filter: &ListFilter<'_>) -> Result<Vec<ReportSummary>> {
// The SQL text is assembled only from these literals — every caller-supplied
// value goes through a bind, in the same order the predicates were pushed.
let mut predicates: Vec<&str> = Vec::new();
if filter.kind.is_some() { predicates.push("kind = ?"); }
if filter.subject_user_id.is_some() { predicates.push("subject_user_id = ?"); }
if filter.unread_only { predicates.push("read_at IS NULL"); }
if filter.since.is_some() { predicates.push("created_at >= ?"); }
let mut sql = format!("SELECT {SUMMARY_COLS} FROM reports");
if !predicates.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&predicates.join(" AND "));
}
sql.push_str(" ORDER BY created_at DESC, id DESC");
if filter.limit.is_some() {
sql.push_str(" LIMIT ?");
}
let mut query = sqlx::query_as::<_, ReportSummary>(sqlx::AssertSqlSafe(sql));
if let Some(kind) = filter.kind { query = query.bind(kind); }
if let Some(subject) = filter.subject_user_id { query = query.bind(subject); }
if let Some(since) = filter.since { query = query.bind(since); }
if let Some(limit) = filter.limit { query = query.bind(limit); }
Ok(query.fetch_all(pool).await?)
}
/// How many reports nobody has acknowledged — the badge count.
pub async fn unread_count(pool: &SqlitePool) -> Result<i64> {
let n = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM reports WHERE read_at IS NULL")
.fetch_one(pool)
.await?;
Ok(n)
}
/// Acknowledge a report on behalf of `user_id`. Returns whether this call is the
/// one that marked it.
///
/// **First reader wins, and that is the semantics, not an optimisation.** An
/// instance report can have several readers (two admins); an alert about the
/// same evening is one thing to deal with, dealt with once. The `read_at IS
/// NULL` guard makes the write idempotent and keeps `read_by` pointing at
/// whoever actually took it, instead of whoever opened it last.
pub async fn mark_read(pool: &SqlitePool, id: i64, user_id: &str) -> Result<bool> {
let n = sqlx::query(
"UPDATE reports SET read_at = datetime('now'), read_by = ?
WHERE id = ? AND read_at IS NULL",
)
.bind(user_id)
.bind(id)
.execute(pool)
.await?
.rows_affected();
Ok(n > 0)
}
/// Delete a report. Returns whether a row was removed.
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<bool> {
let n = sqlx::query("DELETE FROM reports WHERE id = ?")
.bind(id)
.execute(pool)
.await?
.rows_affected();
Ok(n > 0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
/// A standalone owner-schema database in a throwaway temp dir, as in
/// `memory_docs`: `tag` plus a counter keep parallel tests off one file.
async fn owner_pool(tag: &str) -> (SqlitePool, PathBuf) {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir()
.join(format!("skald-reports-{}-{tag}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let pool = crate::db::create_user_pool(&dir.join("owner.db"), None).await.unwrap();
(pool, dir)
}
#[tokio::test]
async fn create_stores_every_field_and_defaults_the_rest() {
let (pool, dir) = owner_pool("create").await;
// The minimum a producer must supply: the defaults fill the rest.
let bare = create(&pool, &NewReport {
kind: "usage-digest",
title: "Your week with the assistant",
body: "You asked for a calendar three times and got nowhere.",
produced_by: "usage-digest",
..Default::default()
}).await.unwrap();
let got = get(&pool, bare).await.unwrap().unwrap();
assert_eq!(got.severity, SEVERITY_INFO, "a bare report is informational");
assert_eq!(got.audience, AUDIENCE_OWNER, "...and readable only by its file's owner");
assert!(got.subject_user_id.is_none());
assert!(got.read_at.is_none(), "a fresh report is unread");
assert!(!got.created_at.is_empty());
// A full instance report: subject and run_id are bare snapshots, so
// neither has to exist anywhere in this file.
let full = create(&pool, &NewReport {
kind: "conversation-review",
title: "Something to look at",
summary: Some("one line for the notification"),
body: "# Detail\n\nnarrated, not quoted.",
severity: SEVERITY_ALERT,
subject_user_id: Some("u-nobody"),
audience: AUDIENCE_ADMINS,
period_start: Some("2026-07-28T00:00:00Z"),
period_end: Some("2026-07-29T00:00:00Z"),
produced_by: "conversation-review",
producer_user_id: Some("u-someone-else"),
run_id: Some(4242),
metadata: Some(r#"{"sessions_scanned":7}"#),
}).await.unwrap();
let got = get(&pool, full).await.unwrap().unwrap();
assert_eq!(got.severity, SEVERITY_ALERT);
assert_eq!(got.audience, AUDIENCE_ADMINS);
assert_eq!(got.subject_user_id.as_deref(), Some("u-nobody"));
assert_eq!(got.producer_user_id.as_deref(), Some("u-someone-else"));
assert_eq!(got.run_id, Some(4242));
assert_eq!(got.period_end.as_deref(), Some("2026-07-29T00:00:00Z"));
assert!(got.body.starts_with("# Detail"));
assert!(get(&pool, 9999).await.unwrap().is_none());
pool.close().await;
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn list_narrows_and_orders_newest_first() {
let (pool, dir) = owner_pool("list").await;
let mk = |kind: &'static str, subject: Option<&'static str>| {
let pool = pool.clone();
async move {
create(&pool, &NewReport {
kind,
title: "t",
body: "b",
subject_user_id: subject,
produced_by: "agent",
..Default::default()
}).await.unwrap()
}
};
let first = mk("usage-digest", None).await;
let second = mk("conversation-review", Some("u-kid")).await;
let third = mk("conversation-review", Some("u-other")).await;
// Newest first, with `id` breaking the same-second tie.
let all = list(&pool, &ListFilter::default()).await.unwrap();
assert_eq!(all.iter().map(|r| r.id).collect::<Vec<_>>(), vec![third, second, first]);
let by_kind = list(&pool, &ListFilter {
kind: Some("conversation-review"), ..Default::default()
}).await.unwrap();
assert_eq!(by_kind.iter().map(|r| r.id).collect::<Vec<_>>(), vec![third, second]);
let by_subject = list(&pool, &ListFilter {
subject_user_id: Some("u-kid"), ..Default::default()
}).await.unwrap();
assert_eq!(by_subject.len(), 1);
assert_eq!(by_subject[0].id, second);
// Two filters compose, and `limit` applies after the ordering.
let both = list(&pool, &ListFilter {
kind: Some("conversation-review"),
subject_user_id: Some("u-other"),
..Default::default()
}).await.unwrap();
assert_eq!(both.len(), 1);
assert_eq!(both[0].id, third);
let capped = list(&pool, &ListFilter { limit: Some(2), ..Default::default() }).await.unwrap();
assert_eq!(capped.iter().map(|r| r.id).collect::<Vec<_>>(), vec![third, second]);
// `since` is inclusive, and a future timestamp excludes everything.
assert!(list(&pool, &ListFilter {
since: Some("2999-01-01T00:00:00Z"), ..Default::default()
}).await.unwrap().is_empty());
pool.close().await;
let _ = std::fs::remove_dir_all(&dir);
}
/// Several admins share one instance report; whoever gets there first is the
/// one who took it, and the count reflects the household, not each reader.
#[tokio::test]
async fn acknowledgement_is_shared_and_first_writer_wins() {
let (pool, dir) = owner_pool("read").await;
let id = create(&pool, &NewReport {
kind: "conversation-review", title: "t", body: "b",
audience: AUDIENCE_ADMINS, produced_by: "agent", ..Default::default()
}).await.unwrap();
let other = create(&pool, &NewReport {
kind: "usage-digest", title: "t2", body: "b", produced_by: "agent", ..Default::default()
}).await.unwrap();
assert_eq!(unread_count(&pool).await.unwrap(), 2);
assert_eq!(list(&pool, &ListFilter { unread_only: true, ..Default::default() })
.await.unwrap().len(), 2);
assert!(mark_read(&pool, id, "u-anna").await.unwrap(), "the first reader takes it");
assert!(!mark_read(&pool, id, "u-bruno").await.unwrap(), "the second changes nothing");
let got = get(&pool, id).await.unwrap().unwrap();
assert_eq!(got.read_by.as_deref(), Some("u-anna"), "read_by keeps whoever took it");
assert!(got.read_at.is_some());
assert_eq!(unread_count(&pool).await.unwrap(), 1);
let unread = list(&pool, &ListFilter { unread_only: true, ..Default::default() })
.await.unwrap();
assert_eq!(unread.len(), 1);
assert_eq!(unread[0].id, other);
assert!(!mark_read(&pool, 9999, "u-anna").await.unwrap(), "an absent report marks nothing");
assert!(delete(&pool, id).await.unwrap());
assert!(!delete(&pool, id).await.unwrap(), "a second delete is a no-op");
assert!(get(&pool, id).await.unwrap().is_none());
pool.close().await;
let _ = std::fs::remove_dir_all(&dir);
}
}
+183
View File
@@ -0,0 +1,183 @@
//! Accessor for `supervision` — the edge that says one person's activity may be
//! read on another's behalf (§0.1).
//!
//! Deliberately anaemic: an edge, two directions, no attributes. It carries no
//! notion of *what* the supervisor may see, because that belongs to whatever
//! reads it — today one background agent, tomorrow a read gate on the reports it
//! writes. Putting "may read conversations" / "may read memory" on the row here
//! would be inventing a permission model before anything asks for one.
//!
//! Registry table, so both foreign keys are real and the cascade is too: deleting
//! either user removes the edge.
use anyhow::Result;
use sqlx::SqlitePool;
/// One edge.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct SupervisionEdge {
pub subject_user_id: String,
pub supervisor_user_id: String,
pub created_at: String,
}
/// Every user somebody supervises, in a stable order.
///
/// The order matters more than it looks: it is the order a background pass walks
/// its subjects in, and a stable one makes a partial pass (the process died
/// halfway) resume predictably instead of favouring whoever sorts first by
/// accident.
pub async fn subjects(pool: &SqlitePool) -> Result<Vec<String>> {
let rows = sqlx::query_scalar::<_, String>(
"SELECT DISTINCT subject_user_id FROM supervision ORDER BY subject_user_id",
)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Who supervises `subject`, in a stable order.
pub async fn supervisors_of(pool: &SqlitePool, subject: &str) -> Result<Vec<String>> {
let rows = sqlx::query_scalar::<_, String>(
"SELECT supervisor_user_id FROM supervision
WHERE subject_user_id = ?
ORDER BY supervisor_user_id",
)
.bind(subject)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Whom `supervisor` watches, in a stable order.
pub async fn subjects_of(pool: &SqlitePool, supervisor: &str) -> Result<Vec<String>> {
let rows = sqlx::query_scalar::<_, String>(
"SELECT subject_user_id FROM supervision
WHERE supervisor_user_id = ?
ORDER BY subject_user_id",
)
.bind(supervisor)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Is this edge present? The question a future read gate on a report asks.
pub async fn supervises(pool: &SqlitePool, supervisor: &str, subject: &str) -> Result<bool> {
let n = sqlx::query_scalar::<_, i64>(
"SELECT count(*) FROM supervision
WHERE supervisor_user_id = ? AND subject_user_id = ?",
)
.bind(supervisor)
.bind(subject)
.fetch_one(pool)
.await?;
Ok(n > 0)
}
/// Every edge, for an admin listing.
pub async fn list(pool: &SqlitePool) -> Result<Vec<SupervisionEdge>> {
let rows = sqlx::query_as::<_, SupervisionEdge>(
"SELECT subject_user_id, supervisor_user_id, created_at FROM supervision
ORDER BY subject_user_id, supervisor_user_id",
)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Add an edge. Idempotent on the primary key.
///
/// Refuses a self-edge: supervising yourself would make every subject their own
/// supervisor, which is not a special case anyone wants — it is the pass reading
/// its own runtime's data and reporting it to itself.
pub async fn add(pool: &SqlitePool, subject: &str, supervisor: &str) -> Result<()> {
if subject == supervisor {
anyhow::bail!("a user cannot supervise themselves");
}
sqlx::query(
"INSERT INTO supervision (subject_user_id, supervisor_user_id)
VALUES (?, ?)
ON CONFLICT(subject_user_id, supervisor_user_id) DO NOTHING",
)
.bind(subject)
.bind(supervisor)
.execute(pool)
.await?;
Ok(())
}
/// Remove an edge. Returns whether one was there.
pub async fn remove(pool: &SqlitePool, subject: &str, supervisor: &str) -> Result<bool> {
let n = sqlx::query(
"DELETE FROM supervision WHERE subject_user_id = ? AND supervisor_user_id = ?",
)
.bind(subject)
.bind(supervisor)
.execute(pool)
.await?
.rows_affected();
Ok(n > 0)
}
#[cfg(test)]
mod tests {
use super::*;
/// A registry pool with two users to hang edges off — the FKs are enforced,
/// so the rows have to exist.
async fn registry() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
crate::db::create_registry_tables(&pool).await.unwrap();
sqlx::query("INSERT INTO roles (id, label, permission_group) VALUES ('member', 'Member', 'default')")
.execute(&pool).await.unwrap();
for (id, name) in [("u-anna", "anna"), ("u-bruno", "bruno"), ("u-kid", "kid")] {
sqlx::query(
"INSERT INTO users (id, username, display_name, role_id, encrypted)
VALUES (?, ?, ?, 'member', 0)",
)
.bind(id).bind(name).bind(name)
.execute(&pool).await.unwrap();
}
pool
}
#[tokio::test]
async fn an_edge_reads_from_both_ends() {
let pool = registry().await;
add(&pool, "u-kid", "u-anna").await.unwrap();
add(&pool, "u-kid", "u-bruno").await.unwrap();
add(&pool, "u-kid", "u-anna").await.unwrap(); // idempotent
assert_eq!(subjects(&pool).await.unwrap(), vec!["u-kid"]);
assert_eq!(supervisors_of(&pool, "u-kid").await.unwrap(), vec!["u-anna", "u-bruno"]);
assert_eq!(subjects_of(&pool, "u-anna").await.unwrap(), vec!["u-kid"]);
assert!(supervises(&pool, "u-anna", "u-kid").await.unwrap());
assert!(!supervises(&pool, "u-kid", "u-anna").await.unwrap(), "the edge is directed");
assert_eq!(list(&pool).await.unwrap().len(), 2);
assert!(remove(&pool, "u-kid", "u-anna").await.unwrap());
assert!(!remove(&pool, "u-kid", "u-anna").await.unwrap());
assert_eq!(supervisors_of(&pool, "u-kid").await.unwrap(), vec!["u-bruno"]);
// One supervisor left, so the subject is still watched.
assert_eq!(subjects(&pool).await.unwrap(), vec!["u-kid"]);
}
#[tokio::test]
async fn a_self_edge_is_refused() {
let pool = registry().await;
assert!(add(&pool, "u-anna", "u-anna").await.is_err());
}
#[tokio::test]
async fn deleting_a_user_takes_their_edges_from_both_directions() {
let pool = registry().await;
add(&pool, "u-kid", "u-anna").await.unwrap();
add(&pool, "u-bruno", "u-anna").await.unwrap();
// The supervisor goes: both edges they were on go with them.
sqlx::query("DELETE FROM users WHERE id = 'u-anna'").execute(&pool).await.unwrap();
assert!(list(&pool).await.unwrap().is_empty(), "cascade must clear both directions");
}
}
@@ -0,0 +1,167 @@
//! Accessor for `system_agent_coverage` — how far a background agent has
//! processed a given subject.
//!
//! This is the watermark that turns "everything since last time" into a
//! well-defined window `[covered_through, now)`. Two properties carry the whole
//! design, and both are the opposite of [`super::system_agent_state`]:
//!
//! - **It advances only on a completed pass.** A crash halfway leaves the mark
//! where it was, so the next pass re-covers that stretch. For a review, a
//! duplicate is a nuisance and a gap is a blind spot.
//! - **It is written after the work, not before.** `mark_attempt` is deliberately
//! the first thing `run_and_record` does, which is exactly why it can never
//! delimit the window the work is about.
//!
//! Timestamps are UTC `'YYYY-MM-DD HH:MM:SS'` — the format SQLite's
//! `datetime('now')` produces — so they compare as strings against the
//! `created_at` columns they are used to filter.
use anyhow::Result;
use sqlx::SqlitePool;
/// Format an instant the way SQLite's `datetime('now')` does, so the two are
/// string-comparable. The one place that knows the format.
pub fn stamp(at: chrono::DateTime<chrono::Utc>) -> String {
at.format("%Y-%m-%d %H:%M:%S").to_string()
}
/// Now, in that format.
pub fn now_stamp() -> String {
stamp(chrono::Utc::now())
}
/// How far `agent_id` has processed `subject`, or `None` if it never has.
pub async fn covered_through(
pool: &SqlitePool,
agent_id: &str,
subject: &str,
) -> Result<Option<String>> {
let at = sqlx::query_scalar::<_, String>(
"SELECT covered_through FROM system_agent_coverage
WHERE agent_id = ? AND subject_user_id = ?",
)
.bind(agent_id)
.bind(subject)
.fetch_optional(pool)
.await?;
Ok(at)
}
/// Move the watermark forward to `through`.
///
/// **Monotonic**: an older value than the one stored is ignored rather than
/// applied. Two passes for the same subject cannot run concurrently today (the
/// scheduler is sequential and single-instance), so this is not a race guard — it
/// is a guard against a caller computing a window start and writing *that* back
/// instead of the window end, which would silently make the agent re-read the
/// same stretch forever.
pub async fn advance(
pool: &SqlitePool,
agent_id: &str,
subject: &str,
through: &str,
) -> Result<()> {
sqlx::query(
"INSERT INTO system_agent_coverage (agent_id, subject_user_id, covered_through)
VALUES (?, ?, ?)
ON CONFLICT(agent_id, subject_user_id) DO UPDATE SET
covered_through = MAX(system_agent_coverage.covered_through, excluded.covered_through),
updated_at = datetime('now')",
)
.bind(agent_id)
.bind(subject)
.bind(through)
.execute(pool)
.await?;
Ok(())
}
/// Forget a subject's watermark, so the next pass starts from scratch. For an
/// admin-side "review this person again from the beginning".
pub async fn clear(pool: &SqlitePool, agent_id: &str, subject: &str) -> Result<bool> {
let n = sqlx::query(
"DELETE FROM system_agent_coverage WHERE agent_id = ? AND subject_user_id = ?",
)
.bind(agent_id)
.bind(subject)
.execute(pool)
.await?
.rows_affected();
Ok(n > 0)
}
#[cfg(test)]
mod tests {
use super::*;
async fn registry() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
crate::db::create_registry_tables(&pool).await.unwrap();
sqlx::query("INSERT INTO roles (id, label, permission_group) VALUES ('member', 'Member', 'default')")
.execute(&pool).await.unwrap();
sqlx::query(
"INSERT INTO users (id, username, role_id, encrypted)
VALUES ('u-kid', 'kid', 'member', 0)",
)
.execute(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn never_covered_reads_as_none_then_advances() {
let pool = registry().await;
assert!(covered_through(&pool, "conversation-review", "u-kid").await.unwrap().is_none());
advance(&pool, "conversation-review", "u-kid", "2026-07-28 04:00:00").await.unwrap();
assert_eq!(
covered_through(&pool, "conversation-review", "u-kid").await.unwrap().unwrap(),
"2026-07-28 04:00:00",
);
advance(&pool, "conversation-review", "u-kid", "2026-07-29 04:00:00").await.unwrap();
assert_eq!(
covered_through(&pool, "conversation-review", "u-kid").await.unwrap().unwrap(),
"2026-07-29 04:00:00",
);
}
/// The guard that stops a caller from writing the window *start* back.
#[tokio::test]
async fn the_watermark_never_moves_backwards() {
let pool = registry().await;
advance(&pool, "conversation-review", "u-kid", "2026-07-29 04:00:00").await.unwrap();
advance(&pool, "conversation-review", "u-kid", "2026-07-01 04:00:00").await.unwrap();
assert_eq!(
covered_through(&pool, "conversation-review", "u-kid").await.unwrap().unwrap(),
"2026-07-29 04:00:00",
"an older value must not rewind the watermark",
);
}
#[tokio::test]
async fn agents_and_subjects_do_not_share_a_row() {
let pool = registry().await;
sqlx::query(
"INSERT INTO users (id, username, role_id, encrypted)
VALUES ('u-two', 'two', 'member', 0)",
)
.execute(&pool).await.unwrap();
advance(&pool, "conversation-review", "u-kid", "2026-07-29 04:00:00").await.unwrap();
assert!(covered_through(&pool, "conversation-review", "u-two").await.unwrap().is_none());
assert!(covered_through(&pool, "weekly-digest", "u-kid").await.unwrap().is_none());
assert!(clear(&pool, "conversation-review", "u-kid").await.unwrap());
assert!(!clear(&pool, "conversation-review", "u-kid").await.unwrap());
assert!(covered_through(&pool, "conversation-review", "u-kid").await.unwrap().is_none());
}
#[test]
fn the_stamp_matches_sqlite_datetime_shape() {
let s = now_stamp();
assert_eq!(s.len(), 19, "'YYYY-MM-DD HH:MM:SS'");
assert_eq!(&s[4..5], "-");
assert_eq!(&s[10..11], " ");
assert_eq!(&s[13..14], ":");
}
}
@@ -136,6 +136,7 @@ impl EventTriageManager {
&build_prompt(&events),
rc.as_ref(),
"Event triage",
std::collections::HashMap::new(),
ctx,
)
.await?;
+16 -2
View File
@@ -244,8 +244,22 @@ impl UserLoopRuntime {
datetime: self.config.datetime.clone(),
});
// The agent's own declarations. Loaded once here and used twice below —
// for the tool set and for the selector's strength floor.
let meta = crate::agents::load_meta(&frame_agent).ok();
// ── Tool set: the native tools, then the surface's legacy ones ──
let tools = self.build_toolset(&scope, config);
//
// Unless the agent declares it gets none. An empty set is not the same as
// a restrictive permission group: a group decides whether a call is
// allowed, this decides whether the model is shown anything to call. For
// an agent that reads material and answers in prose — a review, a
// summariser — that is the difference between gating an action and there
// being no action available.
let tools = match meta.as_ref() {
Some(m) if !m.allow_tools => Arc::new(agent_loop::tool::ToolRegistry::new()) as Arc<dyn ToolSet>,
_ => self.build_toolset(&scope, config),
};
// ── Assembler: the shared projection, scoped to this session's DTL ──
let assembler = Arc::new(skald_assembler(
@@ -270,7 +284,7 @@ impl UserLoopRuntime {
extensions.insert(scope.clone());
// ── Selector: this agent's strength (D14) + the owner's request log ──
let strength = crate::agents::load_meta(&frame_agent).ok().and_then(|m| m.strength);
let strength = meta.and_then(|m| m.strength);
let selector: Arc<dyn ModelSelector> = Arc::new(
SkaldSelector::new(self.llm_manager.clone(), strength).with_log(self.log_target()),
);
+104 -2
View File
@@ -214,6 +214,7 @@ pub(super) fn spawn_system_agents(skald: &Arc<super::Skald>, event_triage_config
event_triage_config,
Arc::clone(&skald.rt.config),
Arc::clone(&skald.rt.db),
Arc::clone(&skald.rt.system_bus),
);
// Interval keys, so a change in the UI cuts the current wait short for
@@ -293,8 +294,9 @@ async fn agents_pass(skald: &Arc<super::Skald>, agents: &[Arc<dyn SystemAgent>])
continue;
}
match agent.scope() {
AgentScope::PerUser => per_user_pass(skald, agent.as_ref()).await,
AgentScope::Instance => instance_pass(skald, agent.as_ref()).await,
AgentScope::PerUser => per_user_pass(skald, agent.as_ref()).await,
AgentScope::Instance => instance_pass(skald, agent.as_ref()).await,
AgentScope::PerSubject => subject_pass(skald, agent.as_ref()).await,
}
}
}
@@ -352,6 +354,104 @@ async fn instance_pass(skald: &Arc<super::Skald>, agent: &dyn SystemAgent) {
);
}
/// Run `agent` once per supervised subject, each pass inside a supervisor's
/// runtime.
///
/// Three properties, and each one is a decision rather than a detail:
///
/// - **The iteration is over subjects, not supervisors.** Two parents watching
/// the same child must produce one review of that child, not two. Whichever of
/// them is available lends their runtime; the report is filed against the
/// subject and every supervisor reads the same row.
/// - **The subject does not need to be logged in.** `open_unencrypted` opens
/// their file directly when it has no key, which is what makes a 4am pass
/// possible at all — nobody is at a keyboard then. An encrypted subject has no
/// such door and is reviewed only while their own session is live.
/// - **Due-ness is not checked here.** Unlike the other two passes, it is per
/// subject and lives in `system_agent_coverage`; the agent answers it inside
/// `has_work`. See [`AgentScope::PerSubject`].
async fn subject_pass(skald: &Arc<super::Skald>, agent: &dyn SystemAgent) {
let subjects = match crate::db::supervision::subjects(&skald.rt.db).await {
Ok(s) => s,
Err(e) => {
warn!(agent = agent.id(), error = %e,
"system-agents: cannot read the supervision edges, skipping this pass");
return;
}
};
for subject_id in subjects {
if skald.rt.shutdown_token.is_cancelled() {
return;
}
let subject = match skald.users().get(&subject_id).await {
Ok(Some(u)) if u.active => u,
Ok(_) => continue, // deleted or deactivated: nothing to review
Err(e) => {
warn!(agent = agent.id(), user = %subject_id, error = %e,
"system-agents: cannot read the subject, skipping them");
continue;
}
};
// Their database, without asking them to be present — as long as it has
// no key. A refusal here is the honest case, not a failure: an encrypted
// person cannot be read while they are away, by anyone.
let subject_pool = match skald.users().open_unencrypted(&subject_id).await {
Ok(p) => p,
Err(e) => {
info!(agent = agent.id(), user = %subject_id, reason = %e,
"system-agents: skipped — the subject's database cannot be read right now");
continue;
}
};
// Somebody entitled to the result has to lend a runtime for the work to
// happen in. First unlocked supervisor wins, in the edge's stable order.
let Some(host) = first_unlocked_supervisor(skald, &subject_id).await else {
info!(agent = agent.id(), user = %subject_id,
"system-agents: skipped — none of this person's supervisors has logged in \
since the last restart, so the pass has no runtime to run in");
continue;
};
let Some(ctx) = skald.user_context(&host).await else {
warn!(agent = agent.id(), supervisor = %host,
"system-agents: skipped — could not resolve the supervisor's runtime");
continue;
};
let run_ctx = AgentRunCtx {
user_id: &host,
pool: &ctx.pool,
sessions: &ctx.sessions,
hub: &ctx.chat_hub,
subject: Some(system_agents::AgentSubject {
user_id: &subject_id,
username: &subject.username,
pool: &subject_pool,
}),
run_id: None,
};
// One subject's failure must not end the pass for everyone after them.
if let Err(e) = system_agents::run_and_record(agent, &run_ctx).await {
warn!(agent = agent.id(), user = %subject_id, error = %e,
"system-agents: pass failed");
}
}
}
/// The first supervisor of `subject` whose runtime is live, in the edge's stable
/// order — so the same one is picked pass after pass rather than alternating.
async fn first_unlocked_supervisor(skald: &Arc<super::Skald>, subject: &str) -> Option<String> {
let supervisors = crate::db::supervision::supervisors_of(&skald.rt.db, subject)
.await
.unwrap_or_default();
supervisors.into_iter().find(|s| skald.users().is_unlocked(s))
}
/// The common tail: skip a locked user, resolve their runtime, check due-ness,
/// run and record.
async fn run_one(
@@ -390,6 +490,8 @@ async fn run_one(
pool: &ctx.pool,
sessions: &ctx.sessions,
hub: &ctx.chat_hub,
subject: None,
run_id: None,
};
// One user's failure must not end the pass for everyone after them.
@@ -0,0 +1,709 @@
//! The conversation review — a nightly read of what a supervised person and the
//! assistant said to each other, turned into one report.
//!
//! The first [`AgentScope::PerSubject`] agent, and the reason that scope exists.
//! Everything it reads belongs to the subject; everything it leaves behind — the
//! ephemeral session, the run row — belongs to the supervisor whose runtime it
//! borrowed; and the one thing that crosses between them is the report, in
//! `system.db`, where the people entitled to it can read it.
//!
//! ## One report per person, never one per conversation
//!
//! A day's activity is spread over however many sessions somebody happened to
//! open, and reviewing them one at a time would produce a stack of fragments
//! nobody can act on — the useful signal is often *across* conversations (the
//! same subject raised twice, in two places, hours apart). So a pass takes the
//! whole window at once: every session, in one transcript, one turn, one report.
//!
//! ## What the model is shown, and what it is not
//!
//! Only what was **said** — see [`chat_history::conversation_window`] for the
//! four exclusions and why each one exists. Tool calls and their results are not
//! filtered out so much as absent by construction: they live in a different
//! table. The consequence is real and the prompt says so plainly, because a model
//! shown a gap will otherwise narrate over it — a web search the assistant ran is
//! invisible, query included.
//!
//! ## Why the report is the turn's own answer
//!
//! There is no `save_report` tool. The final assistant message *is* the body, and
//! this module writes the row. A tool would have to be whitelisted past the
//! approval gate — an unattended pass auto-denies anything gated — and would add
//! a way for the pass to silently produce nothing at all. The cost of not having
//! one is that the model cannot set a severity; see [`REPORT_SEVERITY`].
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use chrono::{DateTime, Duration, Local, TimeZone, Utc};
use sqlx::SqlitePool;
use tracing::warn;
use core_api::system_bus::{SystemEvent, SystemEventBus};
use core_api::{ConfigProperty, ConfigSet, PropertyType};
use crate::config_store::GlobalConfigManager;
use crate::db::chat_history::{self, TranscriptLine};
use crate::db::{reports, system_agent_coverage};
use super::{
AgentOutcome, AgentRunCtx, AgentScope, SystemAgent, configured_run_context,
enabled_from_config, enabled_property, run_ephemeral_turn, security_group_property,
};
pub const CONVERSATION_REVIEW_AGENT: &str = "conversation-review";
/// The chat `source` a pass runs under, and the `kind` of the reports it writes.
/// Same string on purpose: one name to grep for when tracing where a report came
/// from.
const REVIEW_SOURCE: &str = "conversation-review";
pub const ENABLED_KEY: &str = "conversation_review.enabled";
pub const SECURITY_GROUP_KEY: &str = "conversation_review.security_group";
pub const RUN_AT_HOUR_KEY: &str = "conversation_review.run_at_hour";
/// 4am: late enough that the day is over, early enough that the report is waiting
/// when somebody wakes up.
const DEFAULT_RUN_AT_HOUR: u32 = 4;
const DAY_SECS: u64 = 24 * 60 * 60;
/// How far back the very first pass for a subject looks. Their history may go
/// back months; opening with a report on all of it would be expensive, mostly
/// stale, and unlike every report after it.
const FIRST_WINDOW_HOURS: i64 = 24;
/// Caps on what one turn is shown. A day of chatter is normally far below these;
/// they exist so that an outlier costs a truncated report rather than a refused
/// request.
const MAX_MESSAGES: i64 = 600;
const MAX_MESSAGE_CHARS: usize = 2_000;
/// What the agent answers when the window holds nothing worth writing up.
///
/// A sentinel rather than a judgement call in the parser: "did the model mean
/// there was nothing?" is not a question worth asking of prose, and getting it
/// wrong in the lenient direction files an empty report every single night.
pub const NOTHING_TO_REPORT: &str = "NOTHING_TO_REPORT";
/// Every report this agent files carries the same severity.
///
/// Not laziness — a consequence of the report being the turn's own answer: there
/// is no structured channel for the model to grade its own finding on, and
/// inferring one from prose would be a guess presented as a fact. `notice` is the
/// honest middle: this was worth writing down, and a human decides how much it
/// matters. A grade would come from giving the agent a structured hand-off, which
/// is a change to make deliberately rather than by parsing.
const REPORT_SEVERITY: &str = reports::SEVERITY_NOTICE;
pub fn config_set() -> ConfigSet {
ConfigSet {
name: "Conversation review".into(),
description: "A daily read of the conversations of the people someone supervises. For one \
subject at a time it reads everything they and the assistant said to each \
other since the previous review, and writes a single report about the whole \
stretch — not one per conversation. The report is stored for the people who \
supervise that person; the subject does not see it. Tool calls are not \
included, so what a connector did on their behalf is outside what it can \
see. Nobody is reviewed unless a supervision link says so."
.into(),
properties: vec![
enabled_property(
ENABLED_KEY,
"Enable the conversation review for the whole instance. When disabled, nobody is \
reviewed, whatever the supervision links say.",
),
security_group_property(SECURITY_GROUP_KEY),
ConfigProperty {
key: RUN_AT_HOUR_KEY.into(),
name: "Run at (hour)".into(),
description: "Hour of the day, 023 in this machine's local time, after which \
the review runs. It runs once per day per person; if the machine \
was off at that hour, the next start catches up and the report \
covers the whole stretch that was missed."
.into(),
property_type: PropertyType::Int,
default_value: Some(DEFAULT_RUN_AT_HOUR.to_string()),
},
],
owner: Some(CONVERSATION_REVIEW_AGENT.into()),
}
}
pub struct ConversationReviewAgent {
config_store: Arc<GlobalConfigManager>,
/// `system.db` — the supervision edges, the coverage watermarks and the
/// reports all live here.
registry_pool: Arc<SqlitePool>,
system_bus: Arc<SystemEventBus>,
}
impl ConversationReviewAgent {
pub fn new(
config_store: Arc<GlobalConfigManager>,
registry_pool: Arc<SqlitePool>,
system_bus: Arc<SystemEventBus>,
) -> Arc<Self> {
Arc::new(Self { config_store, registry_pool, system_bus })
}
async fn run_at_hour(&self) -> u32 {
match self.config_store.get(RUN_AT_HOUR_KEY).await {
Ok(Some(v)) => v.trim().parse::<u32>().ok().filter(|h| *h <= 23).unwrap_or(DEFAULT_RUN_AT_HOUR),
_ => DEFAULT_RUN_AT_HOUR,
}
}
/// The window this pass would cover, or `None` when the subject is not due.
///
/// Both halves of scheduling live here, together, because they are one
/// question: *is there a stretch of time we have not looked at yet, ending
/// after today's hour?* Splitting them across `is_due` and `has_work` was what
/// made the first sketch wrong — the attempt marker moves before the work, so
/// by the time the agent ran, the window it was meant to cover had already
/// been marked as covered.
async fn window_for(
&self,
subject: &str,
now: DateTime<Utc>,
) -> Result<Option<(String, String)>> {
let covered = system_agent_coverage::covered_through(
&self.registry_pool,
CONVERSATION_REVIEW_AGENT,
subject,
)
.await?;
let start = covered.unwrap_or_else(|| {
system_agent_coverage::stamp(now - Duration::hours(FIRST_WINDOW_HOURS))
});
// Due when the covered stretch stops before the most recent occurrence of
// the configured hour. That single comparison is what makes the schedule
// survive downtime: a machine off for three days simply finds a watermark
// three days old, and covers all of it in one pass.
let boundary = system_agent_coverage::stamp(
most_recent_occurrence(&Local, self.run_at_hour().await, now),
);
if start >= boundary {
return Ok(None);
}
Ok(Some((start, system_agent_coverage::stamp(now))))
}
}
#[async_trait]
impl SystemAgent for ConversationReviewAgent {
fn id(&self) -> &'static str { CONVERSATION_REVIEW_AGENT }
fn scope(&self) -> AgentScope { AgentScope::PerSubject }
fn config_set(&self) -> ConfigSet { config_set() }
fn interval_key(&self) -> &'static str { RUN_AT_HOUR_KEY }
async fn is_enabled(&self) -> bool {
enabled_from_config(&self.config_store, ENABLED_KEY).await
}
/// Daily. Only feeds the scheduler's sleep computation — the actual cadence is
/// the hour-of-day check in [`Self::window_for`], and the tick is clamped well
/// below a day regardless.
async fn interval_secs(&self) -> u64 { DAY_SECS }
async fn has_work(&self, ctx: &AgentRunCtx<'_>) -> Result<bool> {
let Some(subject) = ctx.subject else {
warn!(agent = CONVERSATION_REVIEW_AGENT, "no subject on the run context; skipping");
return Ok(false);
};
let Some((since, until)) = self.window_for(subject.user_id, Utc::now()).await? else {
return Ok(false);
};
// Due, but the stretch may still be empty — somebody who did not open the
// assistant yesterday should collect no run row and no report.
let n = chat_history::conversation_window_count(subject.pool, &since, &until).await?;
Ok(n > 0)
}
async fn run(&self, ctx: &AgentRunCtx<'_>) -> Result<AgentOutcome> {
let subject = ctx.subject.ok_or_else(|| anyhow::anyhow!("no subject on the run context"))?;
let now = Utc::now();
let Some((since, until)) = self.window_for(subject.user_id, now).await? else {
// `has_work` said yes a moment ago; only a concurrent pass could land
// here, and the scheduler is single-instance. Treat it as a no-op
// rather than an error.
return Ok(AgentOutcome {
session_id: None,
stats: serde_json::json!({ "skipped": "not due" }),
});
};
let total = chat_history::conversation_window_count(subject.pool, &since, &until).await?;
let lines = chat_history::conversation_window(subject.pool, &since, &until, MAX_MESSAGES).await?;
let dropped = (total - lines.len() as i64).max(0) as usize;
let sessions = distinct_sessions(&lines);
let transcript = build_transcript(subject.username, &lines, dropped);
let prompt = build_prompt(subject.username, &since, &until, &transcript);
// The security group is the **acting** user's business: the pass runs in
// the supervisor's runtime, on their permissions, and reconciling against
// the subject's role would hand a restricted account's tool set to the
// person reviewing it.
let rc = configured_run_context(
&self.config_store,
&self.registry_pool,
SECURITY_GROUP_KEY,
ctx.user_id,
)
.await;
// Who the report is about, in the system prompt rather than the trigger
// message: an age, a name and a sex change what counts as worth reporting
// — the same sentence reads differently from a nine-year-old and from a
// seventeen-year-old — so the model must have it before it reads a word of
// the transcript. It cannot come from `__USER_PROFILE__`, which resolves
// the session owner, and the session belongs to the supervisor.
let mut substitutions = std::collections::HashMap::new();
substitutions.insert(
"SUBJECT_PROFILE".to_string(),
crate::loop_adapters::system::render_user_profile_section(
&self.registry_pool,
subject.user_id,
)
.await
.unwrap_or_else(|e| {
warn!(user = %subject.user_id, error = %e, "conversation-review: no subject profile");
"unknown".to_string()
}),
);
let (session_id, _) = run_ephemeral_turn(
CONVERSATION_REVIEW_AGENT,
REVIEW_SOURCE,
&prompt,
rc.as_ref(),
"Conversation review",
substitutions,
ctx,
)
.await?;
// The turn ran in the supervisor's runtime, so its answer is in their file.
let answer = chat_history::last_assistant_for_session(ctx.pool, session_id)
.await?
.unwrap_or_default();
let report_id = match parse_report(&answer) {
None => None,
Some(ParsedReport { title, summary, body }) => {
let title = title.unwrap_or_else(|| {
format!("Conversation review — {}{}", subject.username, &until[..10])
});
let id = reports::create(&self.registry_pool, &reports::NewReport {
kind: CONVERSATION_REVIEW_AGENT,
title: &title,
summary: summary.as_deref(),
body: &body,
severity: REPORT_SEVERITY,
subject_user_id: Some(subject.user_id),
audience: reports::AUDIENCE_SUPERVISORS,
period_start: Some(&since),
period_end: Some(&until),
produced_by: CONVERSATION_REVIEW_AGENT,
producer_user_id: Some(ctx.user_id),
run_id: ctx.run_id,
metadata: Some(&serde_json::json!({
"messages_examined": lines.len(),
"messages_dropped": dropped,
"sessions": sessions,
}).to_string()),
})
.await?;
// Announced, not delivered. Who should hear about a new report —
// the supervisors, a badge, a future digest — is not this agent's
// business, and wiring it here would make every new recipient a
// change to the reviewer.
let _ = self.system_bus.send(SystemEvent::ReportCreated {
report_id: id,
kind: CONVERSATION_REVIEW_AGENT.to_string(),
subject_user_id: Some(subject.user_id.to_string()),
});
Some(id)
}
};
// Only now, and only here: the watermark moves because the stretch was
// actually looked at. A pass that failed above never reaches this line, so
// the same window is offered again next time — a duplicate report being a
// nuisance and a missed window being a blind spot.
system_agent_coverage::advance(
&self.registry_pool,
CONVERSATION_REVIEW_AGENT,
subject.user_id,
&until,
)
.await?;
Ok(AgentOutcome {
session_id: Some(session_id),
stats: serde_json::json!({
"subject": subject.user_id,
"window_start": since,
"window_end": until,
"messages_examined": lines.len(),
"messages_dropped": dropped,
"sessions": sessions,
"report_id": report_id,
}),
})
}
}
// ── Transcript ────────────────────────────────────────────────────────────────
fn distinct_sessions(lines: &[TranscriptLine]) -> usize {
let mut seen: Vec<i64> = Vec::new();
for l in lines {
if !seen.contains(&l.session_id) {
seen.push(l.session_id);
}
}
seen.len()
}
/// Render the window as a readable transcript, grouped by conversation.
///
/// **Prose, not JSON**, and the choice is about what the model does with it: a
/// dialogue read as a dialogue is what these models are best at, JSON spends
/// tokens on syntax, and — the deciding argument — nothing machine-readable comes
/// back this way. The structured artefact is the report, on the other end.
///
/// Grouped by session rather than strictly chronological because the question
/// "what was this conversation about" is answered by contiguity; sessions are
/// ordered by when each one was first spoken in, so the day still reads forwards.
fn build_transcript(subject_label: &str, lines: &[TranscriptLine], dropped: usize) -> String {
if lines.is_empty() {
return "(no messages in this window)".to_string();
}
let mut out = String::new();
if dropped > 0 {
out.push_str(&format!(
"> Note: {dropped} older message(s) in this window were left out to fit. What follows \
is the most recent part of the stretch.\n\n",
));
}
let mut order: Vec<i64> = Vec::new();
for l in lines {
if !order.contains(&l.session_id) {
order.push(l.session_id);
}
}
for session_id in order {
let head = lines.iter().find(|l| l.session_id == session_id).expect("session came from lines");
let title = head.session_title.as_deref().filter(|t| !t.is_empty()).unwrap_or("untitled");
out.push_str(&format!(
"\n## Conversation {session_id}\"{title}\" (via {}, assistant: {})\n\n",
head.source, head.agent_id,
));
for line in lines.iter().filter(|l| l.session_id == session_id) {
let who = if line.role == "user" { subject_label } else { "assistant" };
out.push_str(&format!(
"[{}] {who}: {}\n\n",
line.created_at,
truncate(&line.content, MAX_MESSAGE_CHARS),
));
}
}
out
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let kept: String = s.chars().take(max).collect();
format!("{kept}… [truncated]")
}
/// The trigger message. Thin on purpose: *how* to review is the agent's
/// `AGENT.md`, and a second copy of it here would be one to keep in step.
fn build_prompt(subject_label: &str, since: &str, until: &str, transcript: &str) -> String {
format!(
"[REVIEW] Scheduled review of {subject_label}'s conversations\n\
Window: {since}{until} (UTC)\n\n\
Below is everything {subject_label} and the assistant said to each other in that window, \
grouped by conversation. Tool calls and their results are not included.\n\n\
Read it, and write the report. If there is nothing worth reporting, answer with \
`{NOTHING_TO_REPORT}` and nothing else.\n\n\
---\n\n{transcript}"
)
}
// ── The answer ────────────────────────────────────────────────────────────────
struct ParsedReport {
title: Option<String>,
summary: Option<String>,
body: String,
}
/// Turn the turn's answer into a report, or `None` for "nothing to report".
///
/// Deliberately shallow, and it only works because the report's shape is fixed
/// by the prompt: a leading heading, then one summary paragraph, then sections.
/// So the heading becomes the title — a document's first heading *is* its title —
/// and the opening paragraph becomes the summary, whole rather than by its first
/// line, because a paragraph written to be the summary is exactly what
/// `reports.summary` is for. Anything more would be parsing prose, which is how a
/// report ends up filed under half a sentence.
fn parse_report(answer: &str) -> Option<ParsedReport> {
let answer = answer.trim();
if answer.is_empty() {
return None;
}
// Lenient on the sentinel: a model that adds a sentence after it still means
// the same thing, and the alternative is filing that sentence as a report.
if answer.lines().next().is_some_and(|l| l.trim().starts_with(NOTHING_TO_REPORT)) {
return None;
}
let mut lines = answer.lines().peekable();
let mut title = None;
if let Some(first) = lines.peek() {
if let Some(heading) = first.trim().strip_prefix("# ") {
let heading = heading.trim();
if !heading.is_empty() {
title = Some(heading.to_string());
lines.next();
}
}
}
let body: String = lines.collect::<Vec<_>>().join("\n").trim().to_string();
let body = if body.is_empty() { answer.to_string() } else { body };
Some(ParsedReport { title, summary: leading_paragraph(&body), body })
}
/// The first paragraph of prose: everything from the first ordinary line up to
/// the blank line that ends it, flattened onto one line.
///
/// Headings and rules are skipped on the way in, so a body that opens with a
/// `## Summary` heading yields the paragraph under it rather than the word
/// "Summary".
fn leading_paragraph(body: &str) -> Option<String> {
let mut para: Vec<&str> = Vec::new();
for line in body.lines().map(str::trim) {
let skippable = line.is_empty() || line.starts_with('#') || line.starts_with("---");
match (skippable, para.is_empty()) {
(true, true) => continue, // still looking for the paragraph
(true, false) => break, // it just ended
(false, _) => para.push(line),
}
}
(!para.is_empty()).then(|| truncate(&para.join(" "), 400))
}
/// The most recent moment at which the local clock read `hour:00`, at or before
/// `now`.
///
/// Generic over the timezone so it can be tested without depending on where the
/// machine is. Resolution goes through the timezone rather than arithmetic on
/// UTC, so an hour that a DST jump skipped is handled instead of silently landing
/// an hour out: today's candidate and yesterday's are both resolved, and the
/// latest one that exists and has already passed wins.
fn most_recent_occurrence<Tz: TimeZone>(tz: &Tz, hour: u32, now: DateTime<Utc>) -> DateTime<Utc> {
let local_now = now.with_timezone(tz);
let today = local_now.date_naive();
let mut best: Option<DateTime<Utc>> = None;
for back in 0..=1 {
let Some(day) = today.checked_sub_days(chrono::Days::new(back)) else { continue };
let Some(naive) = day.and_hms_opt(hour.min(23), 0, 0) else { continue };
// `.earliest()` is `None` inside a DST gap — that wall-clock time did not
// happen on that day, so there is nothing to pick.
let Some(candidate) = tz.from_local_datetime(&naive).earliest() else { continue };
let candidate = candidate.with_timezone(&Utc);
if candidate <= now && best.is_none_or(|b| candidate > b) {
best = Some(candidate);
}
}
// Neither candidate resolved (a DST gap on both days, which no real zone does):
// fall back to a full day back, which is never later than the true answer.
best.unwrap_or(now - Duration::days(1))
}
#[cfg(test)]
mod tests {
use super::*;
fn line(session_id: i64, title: &str, role: &str, content: &str, at: &str) -> TranscriptLine {
TranscriptLine {
session_id,
session_title: Some(title.to_string()),
source: "web".into(),
agent_id: "kid".into(),
role: role.into(),
content: content.into(),
created_at: at.into(),
}
}
#[test]
fn the_transcript_groups_by_conversation_and_names_the_person() {
let lines = vec![
line(12, "Homework", "user", "help me with history", "2026-07-28 21:04:00"),
line(12, "Homework", "assistant", "sure", "2026-07-28 21:04:30"),
line(15, "", "user", "are you awake", "2026-07-29 02:31:00"),
line(12, "Homework", "user", "one more thing", "2026-07-29 07:00:00"),
];
let t = build_transcript("luca", &lines, 0);
// Two conversations, in the order they were first spoken in.
assert_eq!(t.matches("## Conversation").count(), 2);
assert!(t.find("Conversation 12").unwrap() < t.find("Conversation 15").unwrap());
// A session with no title still reads as something.
assert!(t.contains("\"untitled\""));
// The person is named; the machine is not named after them.
assert!(t.contains("luca: help me with history"));
assert!(t.contains("assistant: sure"));
// Later messages of an earlier conversation stay with it.
let block12 = &t[t.find("Conversation 12").unwrap()..t.find("Conversation 15").unwrap()];
assert!(block12.contains("one more thing"));
// Timestamps survive: "at 2am" is half the finding.
assert!(t.contains("[2026-07-29 02:31:00]"));
}
#[test]
fn dropped_messages_are_declared_not_hidden() {
let lines = vec![line(1, "t", "user", "hi", "2026-07-28 21:04:00")];
let t = build_transcript("luca", &lines, 42);
assert!(t.contains("42 older message(s)"), "a truncated window must say so");
assert!(build_transcript("luca", &[], 0).contains("no messages"));
}
#[test]
fn long_messages_are_truncated_with_a_marker() {
let long = "x".repeat(MAX_MESSAGE_CHARS + 500);
let t = build_transcript("luca", &[line(1, "t", "user", &long, "2026-07-28 21:04:00")], 0);
assert!(t.contains("[truncated]"));
assert!(t.len() < long.len() + 500);
}
#[test]
fn the_sentinel_files_nothing() {
assert!(parse_report(NOTHING_TO_REPORT).is_none());
assert!(parse_report(" NOTHING_TO_REPORT \n").is_none());
assert!(parse_report("NOTHING_TO_REPORT — quiet day").is_none(),
"a model that explains itself still means nothing to report");
assert!(parse_report("").is_none());
assert!(parse_report(" \n ").is_none());
}
/// The shape the prompt asks for: heading, summary paragraph, then sections.
#[test]
fn the_report_shape_maps_onto_the_row() {
let answer = "# Late-night messages\n\
\n\
Three conversations after midnight, all about the same worry.\n\
Nothing was said that needs acting on tonight.\n\
\n\
## What happened\n\
\n\
Detail follows.\n\
\n\
## Worth knowing\n\
\n\
More detail.";
let parsed = parse_report(answer).expect("this is a report");
assert_eq!(parsed.title.as_deref(), Some("Late-night messages"));
assert!(!parsed.body.starts_with('#'), "the title is not repeated in the body");
assert!(parsed.body.contains("## What happened"), "the sections stay in the body");
// The whole opening paragraph, on one line — not just its first sentence.
assert_eq!(
parsed.summary.as_deref(),
Some("Three conversations after midnight, all about the same worry. \
Nothing was said that needs acting on tonight."),
);
}
#[test]
fn a_summary_under_its_own_heading_is_still_found() {
let parsed = parse_report("# Title\n\n## Summary\n\nThe paragraph that matters.\n\n## Detail\n\nx")
.expect("this is a report");
assert_eq!(parsed.summary.as_deref(), Some("The paragraph that matters."),
"a heading must not be mistaken for the paragraph it introduces");
}
#[test]
fn a_report_without_a_heading_keeps_its_whole_body() {
let parsed = parse_report("Nothing structural, just prose.\n\nMore prose.")
.expect("this is a report");
assert!(parsed.title.is_none(), "the caller supplies a title when the model gives none");
assert!(parsed.body.starts_with("Nothing structural"));
assert_eq!(parsed.summary.as_deref(), Some("Nothing structural, just prose."));
// A `#` that is not a heading (no space) is body, not a title.
let parsed = parse_report("#hashtag not a heading").expect("this is a report");
assert!(parsed.title.is_none());
assert_eq!(parsed.body, "#hashtag not a heading");
}
#[test]
fn the_daily_boundary_is_the_most_recent_occurrence_of_the_hour() {
let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
// Later the same day: today's 04:00.
assert_eq!(
most_recent_occurrence(&Utc, 4, at("2026-07-29T09:00:00Z")),
at("2026-07-29T04:00:00Z"),
);
// Before it: yesterday's.
assert_eq!(
most_recent_occurrence(&Utc, 4, at("2026-07-29T02:00:00Z")),
at("2026-07-28T04:00:00Z"),
);
// Exactly on the hour counts as passed, so the pass fires at 04:00 sharp.
assert_eq!(
most_recent_occurrence(&Utc, 4, at("2026-07-29T04:00:00Z")),
at("2026-07-29T04:00:00Z"),
);
// Midnight is an hour like any other.
assert_eq!(
most_recent_occurrence(&Utc, 0, at("2026-07-29T00:30:00Z")),
at("2026-07-29T00:00:00Z"),
);
// A machine that was off for days still gets one boundary, not none: what
// makes the missed window recoverable is that the watermark is older than
// this, not that the boundary moved.
assert_eq!(
most_recent_occurrence(&Utc, 4, at("2026-08-02T05:00:00Z")),
at("2026-08-02T04:00:00Z"),
);
}
#[test]
fn the_prompt_states_the_window_and_the_tool_blind_spot() {
let p = build_prompt("luca", "2026-07-28 04:00:00", "2026-07-29 04:00:00", "");
assert!(p.contains("luca"));
assert!(p.contains("2026-07-28 04:00:00"));
assert!(p.contains("Tool calls and their results are not included"));
assert!(p.contains(NOTHING_TO_REPORT));
}
}
@@ -186,8 +186,10 @@ impl MemoryLintAgent {
/// `classify_memory` gives the fs-tools).
fn store_pool<'a>(&'a self, ctx: &'a AgentRunCtx<'_>) -> &'a SqlitePool {
match self.scope {
AgentScope::PerUser => ctx.pool,
AgentScope::Instance => &self.registry_pool,
// A lint is never per-subject; anything but the instance store is the
// caller's own.
_ => ctx.pool,
}
}
}
@@ -240,6 +242,7 @@ impl SystemAgent for MemoryLintAgent {
&build_prompt(self.root, notes.len()),
rc.as_ref(),
"Memory lint",
std::collections::HashMap::new(),
ctx,
)
.await?;
+90 -20
View File
@@ -29,6 +29,7 @@
//! `start` for that agent — safe only because the scheduler is sequential and
//! single-instance.
pub mod conversation_review;
pub mod memory_lint;
use std::collections::HashMap;
@@ -68,6 +69,26 @@ pub enum AgentScope {
/// working unchanged, at the price of needing an admin who has logged in
/// since the last restart.
Instance,
/// One pass per **supervised subject**, run inside a supervisor's runtime.
///
/// For work done *about* one person *for* another (`crate::db::supervision`).
/// The two halves come apart here in a way neither other variant needs: the
/// data read is the subject's, while the runtime doing the reading — the
/// ephemeral session, the LLM turn, the run log — belongs to a supervisor.
/// Which is the point: everything the pass leaves behind lands in the
/// watcher's file, not the watched one's.
///
/// Two consequences worth knowing before writing one:
///
/// - **Due-ness is per subject and does not go through [`is_due`].** That
/// helper keys scheduler state by agent within one file, which would
/// collapse every subject sharing a supervisor into a single clock. These
/// agents answer scheduling themselves inside [`SystemAgent::has_work`],
/// against `crate::db::system_agent_coverage`.
/// - **The subject need not be logged in**, as long as their database is not
/// encrypted (`UserManager::open_unencrypted`). An encrypted subject is
/// readable only while their own session is live — no key, no pass.
PerSubject,
}
/// What one pass did, for the run log.
@@ -78,13 +99,37 @@ pub struct AgentOutcome {
pub stats: serde_json::Value,
}
/// Who a [`AgentScope::PerSubject`] pass is *about*, when that is not the person
/// whose runtime it is running in.
#[derive(Clone, Copy)]
pub struct AgentSubject<'a> {
pub user_id: &'a str,
pub username: &'a str,
/// The subject's database, opened for reading. Not necessarily an unlocked
/// session's pool — see `UserManager::open_unencrypted`.
pub pool: &'a SqlitePool,
}
/// One user's runtime, unpacked from their `UserContext` by the scheduler.
///
/// The four leading fields always describe the runtime **the pass executes in**,
/// which for every scope but [`AgentScope::PerSubject`] is also whom the pass is
/// about. Keeping that meaning fixed is what lets `run_ephemeral_turn` stay
/// unaware of the distinction: it always writes into the acting runtime.
#[derive(Clone, Copy)]
pub struct AgentRunCtx<'a> {
pub user_id: &'a str,
/// The user's own (unlocked) database.
pub pool: &'a SqlitePool,
pub sessions: &'a Arc<ChatSessionManager>,
pub hub: &'a Arc<ChatHub>,
/// Set only for [`AgentScope::PerSubject`]: the person being looked at.
pub subject: Option<AgentSubject<'a>>,
/// The `system_agent_runs` row this pass is being recorded under, filled in by
/// [`run_and_record`] before it calls [`SystemAgent::run`]. Lets an agent that
/// produces a durable artefact point back at the run that made it — across
/// files, where a foreign key cannot reach.
pub run_id: Option<i64>,
}
#[async_trait]
@@ -98,8 +143,11 @@ pub trait SystemAgent: Send + Sync {
/// `owned_by(self.id())`, or it lands on the general Config page instead.
fn config_set(&self) -> ConfigSet;
/// The config key holding the interval. The scheduler watches it so a change
/// in the UI reschedules without a restart.
/// The config key that governs this agent's cadence. The scheduler watches it
/// so a change in the UI reschedules without a restart.
///
/// Usually the interval itself; for an agent that runs at a fixed time of day
/// it is the hour, which is the key that moves the next pass just the same.
fn interval_key(&self) -> &'static str;
/// Instance-wide on/off switch, re-read every pass.
@@ -128,6 +176,7 @@ pub fn registry(
event_triage_config: crate::config::EventTriageConfig,
config_store: Arc<GlobalConfigManager>,
registry_pool: Arc<SqlitePool>,
system_bus: Arc<core_api::system_bus::SystemEventBus>,
) -> Vec<Arc<dyn SystemAgent>> {
vec![
crate::event_triage::EventTriageManager::new(
@@ -139,7 +188,11 @@ pub fn registry(
Arc::clone(&config_store),
Arc::clone(&registry_pool),
),
memory_lint::MemoryLintAgent::shared(config_store, registry_pool),
memory_lint::MemoryLintAgent::shared(
Arc::clone(&config_store),
Arc::clone(&registry_pool),
),
conversation_review::ConversationReviewAgent::new(config_store, registry_pool, system_bus),
]
}
@@ -154,6 +207,7 @@ pub fn config_sets() -> Vec<ConfigSet> {
crate::event_triage::config_set(),
memory_lint::private_config_set(),
memory_lint::shared_config_set(),
conversation_review::config_set(),
]
}
@@ -187,9 +241,17 @@ pub async fn run_and_record(
) -> Result<Option<AgentOutcome>> {
// Step 1 — the attempt counts even if there is nothing to do, or an idle
// agent is asked again on every single tick.
if let Err(e) = system_agent_state::mark_attempt(ctx.pool, agent.id()).await {
warn!(agent = agent.id(), user = %ctx.user_id, error = %e,
"system-agents: could not record the attempt");
//
// Skipped for a per-subject pass, and not as an optimisation: that state is
// keyed by agent inside one file, so several subjects sharing a supervisor
// would overwrite each other's row and the first subject of the evening would
// silently stand for all of them. Those agents keep their own per-subject
// watermark (`db::system_agent_coverage`) and are gated by `has_work` alone.
if agent.scope() != AgentScope::PerSubject {
if let Err(e) = system_agent_state::mark_attempt(ctx.pool, agent.id()).await {
warn!(agent = agent.id(), user = %ctx.user_id, error = %e,
"system-agents: could not record the attempt");
}
}
// Step 2 — nothing to do leaves no trace.
@@ -197,9 +259,11 @@ pub async fn run_and_record(
return Ok(None);
}
// Step 3 — open the row, then work.
// Step 3 — open the row, then work. The pass runs with the row's id in hand,
// so whatever it produces can name the run that produced it.
let run_id = system_agent_runs::start(ctx.pool, agent.id()).await?;
let started = Instant::now();
let ctx = &AgentRunCtx { run_id: Some(run_id), ..*ctx };
match agent.run(ctx).await {
Ok(outcome) => {
@@ -249,12 +313,19 @@ pub async fn run_and_record(
/// `notify()` as the only way out. Sharing it is what keeps a new agent from
/// re-deriving the two subtleties below.
pub async fn run_ephemeral_turn(
agent_id: &str,
source: &str,
prompt: &str,
run_context: Option<&RunContext>,
notify_label: &str,
ctx: &AgentRunCtx<'_>,
agent_id: &str,
source: &str,
prompt: &str,
run_context: Option<&RunContext>,
notify_label: &str,
// `<!-- KEY -->` placeholders in the agent's `AGENT.md`, resolved for this
// pass. The two the system context resolves by itself (`__USER_PROFILE__`,
// `__SHARED_FOLDERS__`) describe the *session owner*, which for a pass about
// somebody else is the wrong person — so an agent that needs the subject's
// details supplies them here, under its own key, rather than being handed a
// profile that silently means the runtime's owner.
substitutions: HashMap<String, String>,
ctx: &AgentRunCtx<'_>,
) -> Result<(i64, usize)> {
// A fresh ephemeral session per pass. ChatHub is bypassed on purpose: a
// system agent is not a user-facing source and must not take over the
@@ -285,7 +356,7 @@ pub async fn run_ephemeral_turn(
None,
None,
vec![notify],
HashMap::new(),
substitutions,
tx,
true,
None,
@@ -436,13 +507,11 @@ mod tests {
// Constructing the agents touches no table — the pool is only a handle
// they hold on to — so an empty database is enough here.
let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap());
let config = Arc::new(GlobalConfigManager::new(
Arc::clone(&pool),
Arc::new(core_api::system_bus::SystemEventBus::new()),
));
let bus = Arc::new(core_api::system_bus::SystemEventBus::new());
let config = Arc::new(GlobalConfigManager::new(Arc::clone(&pool), Arc::clone(&bus)));
let scheduled: Vec<&str> =
registry(Default::default(), config, pool).iter().map(|a| a.id()).collect();
let scheduled: Vec<&str> = registry(Default::default(), config, pool, bus)
.iter().map(|a| a.id()).collect();
let configured: Vec<String> = config_sets()
.into_iter()
.map(|s| s.owner.expect("a system agent's config set must be owned by it"))
@@ -462,6 +531,7 @@ mod tests {
(crate::event_triage::config_set(), crate::event_triage::EVENT_TRIAGE_INTERVAL_MINUTES_KEY),
(memory_lint::private_config_set(), memory_lint::PRIVATE_INTERVAL_DAYS_KEY),
(memory_lint::shared_config_set(), memory_lint::SHARED_INTERVAL_DAYS_KEY),
(conversation_review::config_set(), conversation_review::RUN_AT_HOUR_KEY),
] {
assert!(
set.properties.iter().any(|p| p.key == key),
+48
View File
@@ -162,6 +162,54 @@ impl UserManager {
self.unlocked.read().map(|m| m.contains_key(id)).unwrap_or(false)
}
/// Open the database of a user whose file is **not encrypted**, without their
/// credentials — for work done *about* them by someone entitled to it.
///
/// For an unencrypted user the password guards the *session*, not the data:
/// the file has no key, so any code in this process can already open it. This
/// makes that explicit and puts the one honest limit in a single place —
/// **an encrypted user is refused**, and not as policy: without their password
/// there is no key to be had, and there must never be a second way to get one.
/// The rule a caller inherits from that is neutral by construction: work over
/// somebody else's history runs unattended for a user who is not encrypted,
/// and only while they are logged in for one who is.
///
/// **Authorization is the caller's**, exactly as for [`Self::open_db`] with a
/// credential-less user — this checks entitlement to a *key*, never
/// entitlement to the *data*. Call it only behind an explicit relation
/// (a `supervision` edge), never behind a role check.
///
/// The pool is **not** registered as unlocked: putting it in that map would
/// make the person look logged in to everything that iterates unlocked users,
/// and would keep their file open for the life of the process. A caller that
/// opened one here owns it and should close it. When the user *is* already
/// unlocked their live pool is returned instead, so a reader never opens a
/// second connection alongside their session.
pub async fn open_unencrypted(&self, id: &str) -> Result<SqlitePool, AuthError> {
if let Some(pool) = self.pool_of(id) {
return Ok(pool);
}
let user = db::users::get(&self.system, id)
.await
.map_err(AuthError::Internal)?
.ok_or(AuthError::UnknownUser)?;
if user.is_encrypted() {
return Err(AuthError::PasswordRequired);
}
if !user.active {
return Err(AuthError::Inactive);
}
let path = self.path_of(id);
if !path.exists() {
return Err(AuthError::MissingDatabase(path));
}
db::open_user_pool(&path, None).await.map_err(AuthError::Internal)
}
/// Login and unlock in one operation.
///
/// For an encrypted user a single Argon2id pass answers both questions: the