feat: conversation review — a nightly report on a supervised person's conversations
Nightly Build / build (push) Successful in 7m40s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user