Release 0.2.0 #4

Merged
dguiducci merged 96 commits from main into release 2026-08-17 18:07:20 +01:00
34 changed files with 2194 additions and 612 deletions
Showing only changes of commit 434e27d7c2 - Show all commits
+33 -11
View File
@@ -107,7 +107,8 @@ Two rules keep the boundary real, and both are enforced by the compiler:
| `crates/skald-core/src/mcp/` | MCP runtimes + the `McpProvider` seam (§7): the shared host **global** runtime and the per-user **container** runtimes, unioned per session as `UserMcpView`. See the MCP connectors section | | `crates/skald-core/src/mcp/` | MCP runtimes + the `McpProvider` seam (§7): the shared host **global** runtime and the per-user **container** runtimes, unioned per session as `UserMcpView`. See the MCP connectors section |
| `crates/skald-core/src/plugin/` | Plugin system: discovery, enable/disable, tool registration, per-user access grants + per-user config | | `crates/skald-core/src/plugin/` | Plugin system: discovery, enable/disable, tool registration, per-user access grants + per-user config |
| `crates/skald-core/src/cron/` | Scheduled job runner | | `crates/skald-core/src/cron/` | Scheduled job runner |
| `crates/skald-core/src/tic/` | `TicManager`: one tick of the TIC system agent for **one** user (`run_for`). No timer of its own — the instance-wide scheduler is `skald::wiring::spawn_system_agents`. See the system-agents section | | `crates/skald-core/src/system_agents/` | The `SystemAgent` trait + `run_and_record` + the shared ephemeral-turn/run-context machinery, plus `registry()` (the one enumeration of the agents) and `memory_lint.rs` (the two lint agents). See the system-agents section |
| `crates/skald-core/src/tic/` | `TicManager`: one pass of the TIC system agent for **one** user. No timer of its own — the instance-wide scheduler is `skald::wiring::spawn_system_agents` |
| `crates/skald-core/src/compactor.rs` | Context compaction **policy** — when to compact and with which model; the mechanics are `agent_loop::compaction`. Model for the summary call: the instance-wide Settings pick (`compaction_model`, a `PropertyType::LlmModel` config property declared by `compactor::config_set`) wins; else AUTO by `compaction.strength` (config.yml); a missing configured model degrades to the same AUTO path | | `crates/skald-core/src/compactor.rs` | Context compaction **policy** — when to compact and with which model; the mechanics are `agent_loop::compaction`. Model for the summary call: the instance-wide Settings pick (`compaction_model`, a `PropertyType::LlmModel` config property declared by `compactor::config_set`) wins; else AUTO by `compaction.strength` (config.yml); a missing configured model degrades to the same AUTO path |
| `crates/skald-core/src/approval/` | Approval rules engine | | `crates/skald-core/src/approval/` | Approval rules engine |
| `crates/skald-core/src/clarification/` | `ClarificationManager`: background-session question/answer | | `crates/skald-core/src/clarification/` | `ClarificationManager`: background-session question/answer |
@@ -129,7 +130,7 @@ Two rules keep the boundary real, and both are enforced by the compiler:
The schema is split into two buckets (§5.1), and the split is the point: The schema is split into two buckets (§5.1), and the split is the point:
- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `plugin_access` + `plugin_user_configs`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `mcp_catalog`, `mcp_global_servers` + `mcp_global_access`, `oauth_providers`, `role_capabilities`, `shared_folders` + `shared_folder_members`, `projects` + `project_members`. The MCP tables back the Connectors model (§7/§14/§15 — see its own section); `oauth_providers` (accessor `db/oauth_providers.rs`) holds one row per identity provider (Google…) — endpoints + `client_id`/`client_secret` + `redirect_uri`, admin-owned household secrets (§4/§15b), never a per-user token. The last two pairs are junction-backed membership: `shared_folder_members` (accessor `db/shared_folders.rs`) for the on-disk shared folders (§6), `project_members` (accessor `db/project_members.rs`) for projects (see the Projects section) — both let a member be read-only (`can_write`) and both drive the container mount topology + the fs routing. Their FKs are registry→registry (same file), which is allowed — unlike an owner→registry key. - **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `plugin_access` + `plugin_user_configs`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `mcp_catalog`, `mcp_global_servers` + `mcp_global_access`, `oauth_providers`, `role_capabilities`, `shared_folders` + `shared_folder_members`, `projects` + `project_members`. The MCP tables back the Connectors model (§7/§14/§15 — see its own section); `oauth_providers` (accessor `db/oauth_providers.rs`) holds one row per identity provider (Google…) — endpoints + `client_id`/`client_secret` + `redirect_uri`, admin-owned household secrets (§4/§15b), never a per-user token. The last two pairs are junction-backed membership: `shared_folder_members` (accessor `db/shared_folders.rs`) for the on-disk shared folders (§6), `project_members` (accessor `db/project_members.rs`) for projects (see the Projects section) — both let a member be read-only (`can_write`) and both drive the container mount topology + the fs routing. Their FKs are registry→registry (same file), which is allowed — unlike an owner→registry key.
- **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `system_agent_runs`, `mcp_user_servers`, `mcp_events`, `sources`, `secrets`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`). `mcp_user_servers` (a user's activated per-user connectors) carries `catalog_name` as a **bare `TEXT` snapshot** of `mcp_catalog.name`, never a FK — an owner→registry key would fail every INSERT; for an OAuth connector it also snapshots `oauth_provider` + `deliver_json`, and its `api_key` column holds the refresh token (in the SQLCipher-encrypted file, so no column crypto). Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below. (`projects`/`project_tickets` were owner tables in the single-user past: projects are shareable now, so `projects` + `project_members` are registry tables and `project_tickets` is gone.) - **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `system_agent_runs`, `system_agent_state`, `mcp_user_servers`, `mcp_events`, `sources`, `secrets`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`). `mcp_user_servers` (a user's activated per-user connectors) carries `catalog_name` as a **bare `TEXT` snapshot** of `mcp_catalog.name`, never a FK — an owner→registry key would fail every INSERT; for an OAuth connector it also snapshots `oauth_provider` + `deliver_json`, and its `api_key` column holds the refresh token (in the SQLCipher-encrypted file, so no column crypto). Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below. (`projects`/`project_tickets` were owner tables in the single-user past: projects are shareable now, so `projects` + `project_members` are registry tables and `project_tickets` is gone.)
Schema is greenfield (no migrations, §0), but a purely **additive** column lands on an existing DB in place: `db::ensure_column` runs `ALTER TABLE … ADD COLUMN` and swallows the "duplicate column" error, a no-op on a fresh DB where the `CREATE TABLE` already has the column. Used for the OAuth columns on `mcp_catalog` / `mcp_user_servers` so a dev box need not be wiped for an additive change (a full recreate is still valid). Schema is greenfield (no migrations, §0), but a purely **additive** column lands on an existing DB in place: `db::ensure_column` runs `ALTER TABLE … ADD COLUMN` and swallows the "duplicate column" error, a no-op on a fresh DB where the `CREATE TABLE` already has the column. Used for the OAuth columns on `mcp_catalog` / `mcp_user_servers` so a dev box need not be wiped for an additive change (a full recreate is still valid).
@@ -216,21 +217,41 @@ For a per-user connector whose credential is produced by **pairing** (`auth.type
**Deferred:** SSH and other §15 device kinds (would reuse the `login_status` contract), `deliver.as=file`, and non-Google OAuth providers are unimplemented paths that error clearly rather than half-work. No boot seed of catalog presets; the admin populates the catalog from the Marketplace. **Deferred:** SSH and other §15 device kinds (would reuse the `login_status` contract), `deliver.as=file`, and non-Google OAuth providers are unimplemented paths that error clearly rather than half-work. No boot seed of catalog presets; the admin populates the catalog from the Marketplace.
## System agents (TIC) ## System agents (TIC, memory lints)
A **system agent** runs on a user's behalf without being asked. TIC the background event processor — is the only one, and the surface is written so a second needs no new machinery. A **system agent** runs on a user's behalf without being asked. There are three — TIC (the background event processor) and the two memory lints — behind **one** scheduler, and the machinery is deliberately shaped so a fourth is a trait impl plus one line in a registry.
**It is per-user, and every part of the design falls out of that.** The events it reads (`mcp_events`) are in the caller's own encrypted database, pushed there by connectors running in the caller's container; the notification it emits goes to the caller's hub; the trace it leaves (`system_agent_runs`) is in that same file. `TicManager` (`crates/skald-core/src/tic/`) therefore owns **no timer and no user list**: it exposes `run_for(user_id, pool, sessions, hub)`, one tick for one user, over deps unpacked from that user's `UserContext`. Building it against the ownerless `Conversation` bundle was exactly what made the pre-multi-user version inert it wrote sessions into `system.db`, notified a hub with no subscribers, and resolved tool paths against a container that does not exist. **The unit of work is one agent for one user**, and every part of the design falls out of that. TIC's events (`mcp_events`) are in the caller's own encrypted database, pushed there by connectors in the caller's container; the notification goes to the caller's hub; the trace (`system_agent_runs`) is in that same file. So an agent owns **no timer and no user list**: it implements `SystemAgent` (`crates/skald-core/src/system_agents/`) — `has_work` + `run` over an `AgentRunCtx` unpacked from that user's `UserContext` — and `skald::wiring::spawn_system_agents` decides who and when. Building TIC against the ownerless `Conversation` bundle was exactly what made the pre-multi-user version inert: it wrote sessions into `system.db`, notified a hub with no subscribers, and resolved tool paths against a container that does not exist.
**One scheduler, sequential.** `skald::wiring::spawn_system_agents` is the instance-wide loop — spawned post-construction with a `Weak<Skald>`, like `spawn_user_lifecycle` and for the same reason (it resolves per-user runtimes through `Skald::user_context`). Each pass walks the directory and runs the agent for one user at a time: a pass is N container round-trips and N LLM calls, and nobody is waiting on a background tick, so concurrency would only spike the box every interval. A `ConfigKeyUpdated` on the interval key cuts the current wait short; the enabled flag is re-read per pass. **One loop for cadences three orders of magnitude apart.** TIC runs every few minutes, a lint weekly — the case that tempts a second loop. It stays one because the wake-up decides nothing: `base_tick` (min enabled interval, clamped to [60s, 15min]) only picks how often to *look*, and whether an agent runs for a given user is `system_agents::is_due` against persisted state. A second scheduler would be a fourth global bus in disguise.
**A locked user is skipped, and that is the normal case, not an error.** The pool is the unlock token (§9): a user who has not logged in since the last restart has no readable events, no session store — and no place to record the skip, since the only file that could hold it is the one we cannot open. Hence `system_agent_runs` has no `skipped` status: the skip is an INFO log line and nothing else. Their events keep accumulating and the first pass after they log in picks them up. **Due-ness is persisted, not counted from boot** — the new owner table `system_agent_state(agent_id, last_attempt_at)` (accessor `db/system_agent_state.rs`). It is deliberately **not** `system_agent_runs`: the run log is a history for the human and skips idle ticks, while scheduling needs *every* attempt, so reading due-ness off the log would re-run an idle agent every tick and never bring a weekly one due once its last productive run aged out. Persisting it is also what makes a long interval survive a restart — an in-memory deadline is fine at TIC's scale but a weekly agent on a box rebooted every few days would have it re-armed before it ever fired, and would simply never run. Side benefit: a user who logs in after a long absence is picked up on the next pass.
**The run log is theirs, not the admin's** (`db/system_agent_runs.rs`, owner table, no `user_id` column — the file is the owner). A run summarises what landed in someone's inbox, so `GET /api/system-agents/runs` is scoped through `require_context` with **no admin override**: everyone, admin included, sees their own runs. `stats` is a JSON blob of the agent's own counters (never event contents). The write is split `start`/`finish` (unlike `job_runs`, written once at the end) so a crash leaves a visible `running` row, swept to `failed` by the next `start` for that agent — safe precisely because the scheduler is sequential and single-instance. An **idle tick writes nothing**: a row only exists when there were events, or the log becomes a heartbeat instead of a history. **`run_and_record` orders the three steps, once, for everybody**: mark the attempt (always, even for an idle pass) → `has_work` (`false` writes nothing at all, or the run log becomes a heartbeat) → open the run row, then work. The `start`/`finish` split (unlike `job_runs`, written once at the end) leaves a visible `running` row when the process dies mid-pass, swept to `failed` by the next `start` for that agent — safe precisely because the scheduler is sequential and single-instance, at both levels (agents in order, then users in order).
**The configured security group is not applied verbatim.** `tic.security_group` is an instance-wide admin setting; handing it to a restricted member's run would give their background agent a tool set their role never granted. It goes through `run_context::reconcile_group_for_user` — the same seam a persisted group takes — degrading to the role default when the role disallows it. With nothing configured the run still starts from `role_default_run_context`, never `None`, because `None` means the catch-all group, which is *wider*. **A locked user is skipped, and that is the normal case, not an error.** The pool is the unlock token (§9): a user who has not logged in since the last restart has no readable events, no session store — and no place to record the skip, since the only file that could hold it is the one we cannot open. Hence `system_agent_runs` has no `skipped` status: the skip is an INFO log line and nothing else.
UI: `#system-agents` (`web/components/system-agents.js`, sidebar group `extensions`, visible to everyone — there is nothing to gate when the data is the caller's own). It replaced the old `#tic` "TIC Sessions" debug page, which listed `chat_sessions WHERE source='tic'` and so inferred runs from leftover ephemeral sessions rather than recording them. **`AgentScope::Instance` is the ownerless-work escape hatch, and there is exactly one user of it.** The shared memory store belongs to nobody, but a pass over it still has to run *somewhere*: an ownerless run would write its trace into `system.db`, which `GET /api/system-agents/runs` shows to nobody (scoped on the caller's own pool, by design), and its `notify()` would have no recipient. So `instance_pass` runs it as the **first active unlocked admin** (`users::list` order, so the choice is stable across passes), and the whole per-user surface keeps working unchanged. Cost: it needs an admin who has logged in since the restart.
**The run log is theirs, not the admin's** (`db/system_agent_runs.rs`, owner table, no `user_id` column — the file is the owner). `GET /api/system-agents/runs` is scoped through `require_context` with **no admin override**: everyone, admin included, sees their own runs. `stats` is a JSON blob of the agent's own counters, never contents.
**The configured security group is not applied verbatim.** `<agent>.security_group` is an instance-wide admin setting; handing it to a restricted member's run would give their background agent a tool set their role never granted. `system_agents::configured_run_context` puts it through `run_context::reconcile_group_for_user` — the same seam a persisted group takes — degrading to the role default when the role disallows it. With nothing configured the run still starts from `role_default_run_context`, never `None`, because `None` means the catch-all group, which is *wider*.
### The memory lints
`system_agents/memory_lint.rs` — one struct, two instances differing only by fields: `MemoryLintAgent::private` (`PerUser`, over `user-memory/` in the caller's pool) and `::shared` (`Instance`, over `shared-memory/` in the system pool — the same routing `classify_memory` gives the fs-tools). Prompts are two `AGENT.md`s sharing `agents/common/memory-lint.md`; the shared one additionally hunts **table-rule violations** and is told to report *which note and what kind of problem* without repeating the sensitive line, since restating it is the harm being flagged.
**Read-only, enforced twice.** The prompt says report-never-repair, and `shared-memory/*` writes are already `@fs_write require` — so an agent that tried to fix something would raise an approval card from an unattended pass, which `run_ephemeral_turn` auto-denies. Read-only is not a convention here, it is the only thing that works. `has_work` is "the store is non-empty", so a member who never uses memory collects no weekly row and no weekly notification.
**Interval units are per-agent**: TIC in minutes, the lints in days (`interval_from_config` takes the unit). Asking an admin to type `10080` for "weekly" would be a worse version of the same field.
### Where the settings live
`ConfigSet` gained `owner: Option<String>` (core-api): `None` renders on the general Config page, `Some(agent_id)` is claimed by the surface that owns it. Placement is **data on the set**, not a filter that knows set names, so a new owned set lands in the right place without touching either page. `system_agents::registry()` and `::config_sets()` are the single enumeration of the agents — `registry_and_config_sets_agree` is the test that stops the scheduler's list and the settings surface from drifting.
`/api/config` serves only owner-less sets and is now **admin-gated** (`caps::require_admin`), read *and* write: before this, both handlers ignored the caller entirely, so any authenticated session could read and change instance config — the sidebar hiding the page is presentation, not authorization. `GET /api/system-agents` lists the agents, with `config` resolved (via the shared `config::render_sets`) only for an admin and `Value::Null` for everyone else; writes still go through `PUT /api/config/{key}`, so the gate and the known-key check exist in one place.
UI: `#system-agents` (`web/components/system-agents.js`, sidebar group `extensions`, **visible to everyone** — the run log is the caller's own). **One tab per agent, plus "All"**, each tab holding that agent's description, its settings (admin only) and its runs — the tab is the agent, not the kind of information, because "why did this do nothing last night?" is half a schedule question and half a log question. The settings form is `web/components/shared/config-form.js` (`ConfigFormController`), shared with `config-page.js` so an owned set renders identically wherever it is edited. It replaced the old `#tic` "TIC Sessions" debug page, which listed `chat_sessions WHERE source='tic'` and so inferred runs from leftover ephemeral sessions rather than recording them.
## Multimodal attachments ## Multimodal attachments
@@ -391,7 +412,8 @@ The role editor (`roles-page.js`) sets the default group + an allowed-groups che
| `plugin-catalog.js` | `<plugin-catalog>` | `#plugins` — admin status board: one card per plugin (enable toggle + health dot + Configure → `#plugin-detail`) | | `plugin-catalog.js` | `<plugin-catalog>` | `#plugins` — admin status board: one card per plugin (enable toggle + health dot + Configure → `#plugin-detail`) |
| `plugin-detail.js` | `<plugin-detail>` | `#plugin-detail?id=<id>` — one plugin's admin page: instance-config form (`config_schema`) + per-user access checklist (plugin twin of `connector-detail.js`) | | `plugin-detail.js` | `<plugin-detail>` | `#plugin-detail?id=<id>` — one plugin's admin page: instance-config form (`config_schema`) + per-user access checklist (plugin twin of `connector-detail.js`) |
| `plugin-page-host.js` | `<plugin-page-host>` | Host for plugin-contributed pages (`#plugin/<plugin_id>/<page_id>`): dynamic-imports the fragment module, registers its element, mounts it with `plugin-id` | | `plugin-page-host.js` | `<plugin-page-host>` | Host for plugin-contributed pages (`#plugin/<plugin_id>/<page_id>`): dynamic-imports the fragment module, registers its element, mounts it with `plugin-id` |
| `system-agents.js` | `<system-agents-page>` | `#system-agents`the caller's own run history for the background system agents (TIC): agent, start, status, duration, counters; row → the run's session | | `system-agents.js` | `<system-agents-page>` | `#system-agents`one tab per background agent (plus "All"): its description, its settings (admin only) and the caller's own run history. Everyone sees the page; only an admin gets the config half |
| `shared/config-form.js` | `ConfigFormController` | The schema-driven settings form, shared by `config-page.js` and the System agents page — one renderer and one write path (`PUT /api/config/{key}`) for every `ConfigSet` |
| `shared-folders.js` | `<shared-folders-page>` | `#shared-folders` — admin-only CRUD for on-disk shared folders (§6): create/describe/delete + per-member read-only/read-write grants; description feeds the assistant's `__SHARED_FOLDERS__` context | | `shared-folders.js` | `<shared-folders-page>` | `#shared-folders` — admin-only CRUD for on-disk shared folders (§6): create/describe/delete + per-member read-only/read-write grants; description feeds the assistant's `__SHARED_FOLDERS__` context |
| `projects/` | `<projects-page>` | `#projects` — host + list + board; the board is tabbed (**Files** explorer with live watcher + write actions, **Sharing** members), deep-linked `#projects/{id}[/sharing]`. See the Projects section | | `projects/` | `<projects-page>` | `#projects` — host + list + board; the board is tabbed (**Files** explorer with live watcher + write actions, **Sharing** members), deep-linked `#projects/{id}[/sharing]`. See the Projects section |
| `connector-detail.js` | `<connector-detail-page>` | A connector's own page (`#connector?name=X`): env/secret form + Test, the **OAuth login panel** (sign in → paste code → complete, §15), global enable. Access grants live **only** on the Users page (`users-page.js` — the `#users/{id}` page's connectors section), so "who has what" has a single surface | | `connector-detail.js` | `<connector-detail-page>` | A connector's own page (`#connector?name=X`): env/secret form + Test, the **OAuth login panel** (sign in → paste code → complete, §15), global enable. Access grants live **only** on the Users page (`users-page.js` — the `#users/{id}` page's connectors section), so "who has what" has a single surface |
+8 -2
View File
@@ -39,12 +39,16 @@ Specialist **sub-agents** can be delegated a job — research, planning, writing
### 🧠 Two memories: yours and ours ### 🧠 Two memories: yours and ours
The assistant keeps notes like a personal wiki, in two clearly separated places: The assistant keeps notes in two clearly separated places:
- **Private memory** — what it learns about *you*: preferences, projects, context. Stored encrypted, for your assistant's eyes only. - **Private memory** — what it learns about *you*: preferences, projects, context. Stored encrypted, for your assistant's eyes only.
- **Shared memory** — the household's common notebook, readable by the whole family. Writes here need a human approval, so nobody's assistant quietly pushes personal things into the family space. - **Shared memory** — the household's common notebook, readable by the whole family. Writes here need a human approval, so nobody's assistant quietly pushes personal things into the family space.
Both are full-text searchable, and the assistant manages them on its own. Both are structured as a **maintained wiki** rather than an ever-growing pile of notes, following Andrej Karpathy's [LLM wiki](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f) pattern: notes cross-reference each other, an index says where everything lives, and an append-only log records every change — so you can reconstruct how memory reached its current state, and undo it if something goes wrong.
Because a wiki nobody prunes rots, a **weekly background pass** re-reads each store and reports what has drifted: facts whose date has gone by, questions nobody ever confirmed, notes the index lost track of, duplicates that have started to disagree — and, in the shared store, anything private written where everyone can read it. It only ever *reports*: an automated guess about notes several people wrote is not allowed to edit them.
Both stores are full-text searchable, and the assistant manages them on its own.
### 🔌 Connectors & the Marketplace ### 🔌 Connectors & the Marketplace
@@ -62,6 +66,8 @@ The trust model is deliberate: **only people decide what gets installed, never t
*"Remind me every morning at 8 if it's going to rain."* *"Every Sunday, help me plan the week's meals."* Scheduled jobs are created by simply asking — no crontab, no config files. *"Remind me every morning at 8 if it's going to rain."* *"Every Sunday, help me plan the week's meals."* Scheduled jobs are created by simply asking — no crontab, no config files.
Separately, **background agents** run on their own without being asked: one watches the events your connectors receive and pings you only when something is worth the interruption; two more keep memory healthy. Each works on your own data and reports to you alone — the run history is personal, and even the admin sees only their own.
### 🎨 Voice & images ### 🎨 Voice & images
Send a **voice message** (transcribed locally via whisper.cpp or in the cloud), let the assistant **talk back** (local Kokoro/Orpheus, or ElevenLabs/OpenAI), and **generate images** — locally via ComfyUI or through cloud providers. Send a **voice message** (transcribed locally via whisper.cpp or in the cloud), let the assistant **talk back** (local Kokoro/Orpheus, or ElevenLabs/OpenAI), and **generate images** — locally via ComfyUI or through cloud providers.
+49
View File
@@ -0,0 +1,49 @@
# The lint pass
You are running a **scheduled health pass** over a memory store. Nobody asked for it and nobody is waiting on the other end.
Memory is a wiki, not a scrapbook. A wiki nobody maintains rots quietly: contradictions stay pending, dates go by, notes lose the last line that pointed at them, the same fact ends up written in two places that slowly disagree. The Schema tells the assistant to lint "when it notices drift". You are what happens when nobody notices.
## You report. You do not repair.
**This is absolute, and it is not a matter of taste.**
- Never `write_file`, `edit_file`, `append_file`, `insert_at_line`, `replace_lines` or `delete` anything. Not to fix a typo, not to remove an obvious duplicate, not "just the index".
- You are one automated pass over a store built by several people over months. Your reading of an inconsistency is a guess, and a wrong guess here silently destroys something somebody meant. A human reading your report loses thirty seconds; a wrong edit can lose a fact nobody notices is gone until they need it.
- The rule holds even when the fix looks trivial and even when the note appears to invite it.
If you catch yourself composing an edit, stop: the edit *is* the report.
## Your lifecycle
This is an **ephemeral session**, created for this pass and discarded the moment your turn ends.
- There is no conversation here. Do not write a chat reply.
- Nothing you do carries forward except the notification you send.
- Do not linger: look, decide, report, return.
## What to look for
Read the store — start from `index.md`, then the notes it points at, then whatever it fails to point at.
| Drift | What it looks like |
| --- | --- |
| **Pending contradictions** | a `⚠ claimed changed` line, or a `CLAIM` in `log.md`, that has been sitting unresolved |
| **Expired facts** | a date that has passed: a plan that already happened, a renewal now due, a "starting next month" written months ago |
| **Orphans** | a note no line of `index.md` points to |
| **Broken index lines** | an `index.md` line pointing at a note that does not exist |
| **Duplicates** | two notes asserting the same thing, especially when they have started to disagree |
| **Stale index** | the index describes the store as it was, not as it is |
Judgement, not pattern-matching: a note that has not changed in a year is not stale if it is a passport number. A date in the past is not drift if the note is a record of what happened. Report what a careful person would want to look at, not everything that matches a rule.
## How to report
One `notify(...)` call for the whole pass — not one per finding. This is a periodic maintenance report; several separate pings for one scheduled pass is noise.
- `summary` is a **factual, third-person** account of what you found: which notes, what kind of drift, and what a person would need to decide. Two to five sentences. Plain prose.
- Name the notes by path so they can be opened.
- Suggest what the fix would be, in words. Never perform it.
- Order by what actually matters. A pending contradiction outranks a stale index line.
**If the store is healthy, send nothing.** Return without calling `notify`. A quiet pass is a successful pass, and a weekly "everything is fine" message trains people to ignore the channel — which costs you the one week it is not fine.
+51
View File
@@ -0,0 +1,51 @@
# Memory lint — private store
You are a background agent that keeps **one person's own memory** in good health.
You always run **for one specific user**, over `user-memory/` in their own encrypted database. Everything you read is theirs, the report you send reaches them and nobody else — not the admin, not other members.
<!-- INCLUDE: common/memory-lint.md -->
---
## Your store
**Read `user-memory/` and nothing else.**
Do not read `shared-memory/`. It is a different store with a different owner and its own pass; reading it here would only tempt you to report someone else's business into this person's notification.
Start with `user-memory/index.md`, follow it to the notes, then use `list_files` on `user-memory/` to find what the index does not mention. `user-memory/log.md` is the history — read it when you need to know how a note reached its current state, or how long a contradiction has been pending.
---
## What matters in a private store
This is someone's own space. They wrote it for themselves, and the bar for calling something "wrong" is high — an idiosyncratic note is not drift.
Weight your findings toward the ones with consequences:
- **Something with a date that has passed** and looks like it needed action — a renewal, an appointment, a deadline written down and never revisited.
- **A fact that has been superseded but never marked**, so the note now states two different things as current.
- **A contradiction still pending**, especially an old one: they were asked to confirm something and never did.
- **A note the index lost track of**, if its content looks like something they would want to find again.
Do not report on style, structure, or how they choose to organise their own notes.
---
## Tone of the report
The report goes to the person themselves. Be brief and concrete, name the notes, say what looks off and what they might want to do. No apology, no preamble, no encouragement.
---
## Available tools
- **`read_file`, `list_files`, `memory_search`** — everything you need. Reading is the whole job.
- **`notify(...)`** — one call, at the end, only if there is something worth their attention.
You have no reason to call anything else. If a write tool appears in your list, that is not permission.
<!-- INCLUDE: common/core_rules.md -->
<!-- INCLUDE: common/harness.md -->
+19
View File
@@ -0,0 +1,19 @@
{
"name": "Private memory lint",
"description": "Hidden background agent. Spawned periodically by the system-agent scheduler, for one user at a time. Reads that user's own `user-memory/` store and reports drift — pending contradictions, expired facts, orphan notes, broken index lines, duplicates — via notify(). Read-only: it never edits memory. Ephemeral: the session is discarded as soon as the turn ends.",
"friendly_description": "Weekly check-up of your private memory: flags facts that have gone out of date, questions left unanswered, and notes the index has lost track of. It only ever reports — it never changes your notes.",
"i18n": {
"it": {
"name": "Manutenzione memoria privata",
"friendly_description": "Controllo settimanale della tua memoria privata: segnala fatti ormai scaduti, domande rimaste in sospeso e note che l'indice ha perso di vista. Si limita a segnalare — non modifica mai le tue note."
},
"fr": {
"name": "Entretien de la mémoire privée",
"friendly_description": "Vérification hebdomadaire de votre mémoire privée : signale les faits périmés, les questions restées sans réponse et les notes que l'index a perdues de vue. Elle se contente de signaler — elle ne modifie jamais vos notes."
}
},
"type": "system",
"inject_skills": false,
"inject_memory": ["user-memory/index.md"],
"strength": "medium"
}
+62
View File
@@ -0,0 +1,62 @@
# Memory lint — shared store
You are a background agent that keeps the **group's shared memory** in good health.
The shared store belongs to nobody in particular, so this pass runs as the **admin** and the report goes to them. That is a practical choice about who can act on it, not a claim that the contents are private: everything in `shared-memory/` is already readable by every member.
<!-- INCLUDE: common/memory-lint.md -->
---
## Your store
**Read `shared-memory/` and nothing else.**
Never read `user-memory/`. It is a private store, this pass is not run on its owner's behalf, and there is no finding here worth that.
Start with `shared-memory/index.md`, follow it to the notes, then `list_files` on `shared-memory/` for what the index has lost. `shared-memory/log.md` is the history: who changed what, when, and which `CLAIM` lines are still unanswered.
---
## The defect that only exists here
Everything in the common list applies. But the shared store has one failure mode of its own, and it is the most important thing you look for:
> **A note that fails the table rule** — one person's private business sitting where every member can read it.
The rule, from the Schema: something belongs in `shared-memory/` only if you would say it out loud with **every member in the room**. So look for what should never have been written there:
- one person's health, school results, mood, worries or money
- one member's assessment or opinion of another
- anything that reads as though it was said in confidence
- anything that looks *inferred* about someone rather than stated by them in front of the others
**Report it without repeating it.** Name the note, say which category it falls into, and say that it looks like it belongs in a private store. Do **not** quote the sensitive line, summarise its content, or name the condition/amount/result involved. The finding is "this note is in the wrong place" — restating the contents in a notification would spread it further, which is the exact harm you are flagging. This overrides the usual instruction to be concrete.
Moving a note out afterwards does not un-tell it, so this is worth flagging early and plainly.
## Also specific to the shared store
- **Facts with no provenance** — a shared fact should carry `— name, YYYY-MM-DD`. One without it is a fact nobody can confirm or correct. Report them in aggregate ("four notes carry facts with no attribution"), not one by one.
- **Pending claims** — a `⚠ claimed changed` line under a fact, or a `CLAIM` in `log.md`, means someone tried to change a fact that was not theirs and it was correctly left alone. It is waiting on the person whose name is on the fact, or on the admin. An old one is the highest-value thing you can surface: it is a decision somebody owes.
- **Conflicts logged and never resolved** — a `CONFLICT` line in `log.md` with nothing after it.
- **Roster copies** — the member list is generated from the directory and must never be copied into a note. If you find a note listing who the members are, report it: a copy goes stale and can be talked into being edited.
---
## Tone of the report
The report goes to the admin, about a store the whole group shares. Be factual and neutral. You are describing the state of a document, never judging the people who wrote it — "this note looks private" is right, "X should not have written this" is not.
---
## Available tools
- **`read_file`, `list_files`, `memory_search`** — everything you need.
- **`notify(...)`** — one call, at the end, only if there is something to raise.
You have no reason to call anything else. If a write tool appears in your list, that is not permission — and in this store writes require human approval in any case, which nobody is here to give.
<!-- INCLUDE: common/core_rules.md -->
<!-- INCLUDE: common/harness.md -->
+19
View File
@@ -0,0 +1,19 @@
{
"name": "Shared memory lint",
"description": "Hidden background agent. Spawned periodically by the system-agent scheduler, once per instance, running as the admin. Reads the group's `shared-memory/` store and reports drift via notify(), with particular attention to notes that fail the table rule — private business written where every member can read it. Read-only: it never edits memory, and reports such a note without repeating its contents. Ephemeral: the session is discarded as soon as the turn ends.",
"friendly_description": "Weekly check-up of the group's shared memory: flags private things written in a place everyone can read, facts nobody is attached to, questions still waiting on someone, and notes that have gone out of date. It only ever reports — it never changes anything.",
"i18n": {
"it": {
"name": "Manutenzione memoria condivisa",
"friendly_description": "Controllo settimanale della memoria condivisa: segnala cose private finite dove tutti possono leggerle, fatti senza un nome accanto, domande ancora in attesa di risposta e note ormai scadute. Si limita a segnalare — non modifica mai nulla."
},
"fr": {
"name": "Entretien de la mémoire partagée",
"friendly_description": "Vérification hebdomadaire de la mémoire partagée : signale ce qui est privé mais écrit là où tout le monde peut le lire, les faits sans auteur, les questions encore en attente et les notes périmées. Elle se contente de signaler — elle ne modifie jamais rien."
}
},
"type": "system",
"inject_skills": false,
"inject_memory": ["shared-memory/index.md"],
"strength": "medium"
}
+21 -1
View File
@@ -43,10 +43,30 @@ pub struct ConfigProperty {
} }
/// A named group of related [`ConfigProperty`] items, shown as a distinct /// A named group of related [`ConfigProperty`] items, shown as a distinct
/// section in the Config UI. /// section of whichever page owns it.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigSet { pub struct ConfigSet {
pub name: String, pub name: String,
pub description: String, pub description: String,
pub properties: Vec<ConfigProperty>, pub properties: Vec<ConfigProperty>,
/// Who this set belongs to, and therefore **where it is edited**.
///
/// `None` is the general Config page. `Some(id)` hands the set to the
/// surface that owns `id` — today the System agents page, which shows an
/// agent's settings next to that same agent's run history, because "why did
/// it not run" is half a config question and half a log question.
///
/// Placement is deliberately **data on the set** rather than a filter that
/// knows set names: a page selects by owner, so a new owned set lands in the
/// right place without touching either page.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
}
impl ConfigSet {
/// Hand this set to the surface that owns `owner` (see [`ConfigSet::owner`]).
pub fn owned_by(mut self, owner: impl Into<String>) -> Self {
self.owner = Some(owner.into());
self
}
} }
+1
View File
@@ -70,6 +70,7 @@ pub fn config_set() -> ConfigSet {
default_value: None, default_value: None,
}, },
], ],
owner: None,
} }
} }
+28
View File
@@ -30,6 +30,7 @@ pub mod scratchpad;
pub mod shared_folders; pub mod shared_folders;
pub mod sources; pub mod sources;
pub mod system_agent_runs; pub mod system_agent_runs;
pub mod system_agent_state;
pub mod tool_permission_groups; pub mod tool_permission_groups;
pub mod users; pub mod users;
@@ -935,6 +936,33 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
.execute(pool) .execute(pool)
.await?; .await?;
// When each system agent last *attempted* a pass for this user — the
// scheduler's state, deliberately kept apart from `system_agent_runs`.
//
// The two answer different questions and conflating them breaks both. The run
// log is a history for the human: an idle tick writes nothing there, or it
// degenerates into a heartbeat. Scheduling needs the opposite — every attempt,
// productive or not — because "is this agent due?" is `now - last_attempt >=
// interval`. Reading due-ness off the run log would re-run an idle agent on
// every pass, and a weekly agent would never come due at all once its last
// productive run aged out.
//
// Persisting it is what makes a long interval survive a restart. An in-memory
// deadline is fine at TIC's scale — a few minutes, re-armed on boot — but a
// weekly agent on a machine rebooted every few days would have its deadline
// reset before it ever fired, and would simply never run.
//
// Owner table for the same reason as the run log: when an agent last ran for
// someone is that person's activity, not the registry's.
sqlx::query(
"CREATE TABLE IF NOT EXISTS system_agent_state (
agent_id TEXT PRIMARY KEY,
last_attempt_at TEXT NOT NULL
)",
)
.execute(pool)
.await?;
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS mcp_events ( "CREATE TABLE IF NOT EXISTS mcp_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -0,0 +1,97 @@
//! Scheduler state for the system agents: when each one last *attempted* a pass
//! for this user.
//!
//! Deliberately separate from [`super::system_agent_runs`], which is a history
//! written for the human and skips idle ticks. Due-ness needs every attempt, so
//! it needs its own row — see the table comment in [`super::create_owner_tables`]
//! for why conflating the two breaks both.
//!
//! Owner table, no `user_id` column: the file is the owner (§5.1).
use anyhow::Result;
use sqlx::SqlitePool;
/// When `agent_id` last attempted a pass here, as a SQLite `datetime('now')`
/// string, or `None` if it never has.
pub async fn last_attempt_at(pool: &SqlitePool, agent_id: &str) -> Result<Option<String>> {
let at = sqlx::query_scalar::<_, String>(
"SELECT last_attempt_at FROM system_agent_state WHERE agent_id = ?",
)
.bind(agent_id)
.fetch_optional(pool)
.await?;
Ok(at)
}
/// Record an attempt as of now. Called whether or not the pass had anything to
/// do — that is the whole point of this table.
pub async fn mark_attempt(pool: &SqlitePool, agent_id: &str) -> Result<()> {
sqlx::query(
"INSERT INTO system_agent_state (agent_id, last_attempt_at)
VALUES (?, datetime('now'))
ON CONFLICT(agent_id) DO UPDATE SET last_attempt_at = datetime('now')",
)
.bind(agent_id)
.execute(pool)
.await?;
Ok(())
}
/// Seconds since the last attempt, or `None` when there has never been one
/// (which every caller must read as "due now").
pub async fn seconds_since_attempt(pool: &SqlitePool, agent_id: &str) -> Result<Option<i64>> {
let secs = sqlx::query_scalar::<_, Option<i64>>(
"SELECT CAST(strftime('%s', 'now') AS INTEGER)
- CAST(strftime('%s', last_attempt_at) AS INTEGER)
FROM system_agent_state WHERE agent_id = ?",
)
.bind(agent_id)
.fetch_optional(pool)
.await?
.flatten();
Ok(secs)
}
#[cfg(test)]
mod tests {
use super::*;
async fn pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
crate::db::create_owner_tables(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn never_attempted_reads_as_due() {
let pool = pool().await;
assert!(last_attempt_at(&pool, "tic").await.unwrap().is_none());
assert!(seconds_since_attempt(&pool, "tic").await.unwrap().is_none());
}
#[tokio::test]
async fn an_attempt_is_recorded_and_then_overwritten() {
let pool = pool().await;
mark_attempt(&pool, "tic").await.unwrap();
let first = last_attempt_at(&pool, "tic").await.unwrap().unwrap();
// Fresh attempt: still one row for this agent, and the age is small.
mark_attempt(&pool, "tic").await.unwrap();
assert!(seconds_since_attempt(&pool, "tic").await.unwrap().unwrap() < 5);
assert!(!first.is_empty());
let rows = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM system_agent_state")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows, 1, "mark_attempt must upsert, not accumulate");
}
#[tokio::test]
async fn agents_do_not_share_a_row() {
let pool = pool().await;
mark_attempt(&pool, "tic").await.unwrap();
assert!(seconds_since_attempt(&pool, "tic").await.unwrap().is_some());
assert!(seconds_since_attempt(&pool, "memory-lint").await.unwrap().is_none());
}
}
+1
View File
@@ -172,6 +172,7 @@ pub fn config_set() -> ConfigSet {
default_value: Some("en".into()), default_value: Some("en".into()),
}, },
], ],
owner: None,
} }
} }
+1
View File
@@ -42,6 +42,7 @@ pub mod secrets;
pub mod service_manager; pub mod service_manager;
pub mod session; pub mod session;
pub mod setup; pub mod setup;
pub mod system_agents;
pub mod tic; pub mod tic;
pub mod tool_catalog; pub mod tool_catalog;
pub mod tool_discovery; pub mod tool_discovery;
+8 -3
View File
@@ -63,11 +63,16 @@ impl Runtime {
users, users,
sessions, sessions,
config, config,
config_properties: vec![ // Sets with no `owner` render on the general Config page; the owned
// ones are claimed by the surface that owns them — today the System
// agents page, one tab per agent.
config_properties: [
crate::i18n::config_set(), crate::i18n::config_set(),
crate::tic::config_set(),
crate::compactor::config_set(), crate::compactor::config_set(),
], ]
.into_iter()
.chain(crate::system_agents::config_sets())
.collect(),
system_bus, system_bus,
event_bus, event_bus,
global_tx, global_tx,
+161 -41
View File
@@ -17,7 +17,7 @@ use tracing::{info, warn};
use crate::config::{CoreConfig, TicConfig}; use crate::config::{CoreConfig, TicConfig};
use crate::elicitation::ElicitationBridge; use crate::elicitation::ElicitationBridge;
use crate::tic::{TicManager, TIC_INTERVAL_MINUTES_KEY}; use crate::system_agents::{self, AgentRunCtx, AgentScope, SystemAgent};
use super::bundles::{Conversation, Integrations, Interaction, Tasks}; use super::bundles::{Conversation, Integrations, Interaction, Tasks};
use super::runtime::Runtime; use super::runtime::Runtime;
@@ -174,13 +174,23 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
}); });
} }
/// Spawns the **system-agent scheduler** — the instance-wide timer that runs the /// Spawns the **system-agent scheduler** — the one instance-wide timer behind
/// background agents nobody asked for (today: TIC). /// every background agent nobody asked for (TIC, the two memory lints).
/// ///
/// One loop, not one per user. Every pass walks the user directory and runs the /// **One loop for all of them.** The agents differ by three orders of magnitude
/// agent for each user **sequentially**: a pass means N container round-trips and /// in cadence — TIC every few minutes, a lint every week — which is exactly the
/// N LLM calls, and doing them concurrently would spike the box every interval /// case that tempts a second loop. It stays one because the wake-up decides
/// for no gain — nobody is waiting on a background tick. /// nothing: [`base_tick`] only picks how often to *look*, and whether an agent
/// actually runs for a given user is [`system_agents::is_due`] against state in
/// that user's own database. Adding an agent therefore adds a registry entry,
/// never a task.
///
/// **Due-ness is persisted, not counted from boot.** An in-memory deadline is
/// fine at TIC's scale but silently breaks a weekly agent: every restart re-arms
/// it, so on a machine rebooted every few days it would never fire once. Reading
/// the last attempt from `system_agent_state` makes a long interval survive
/// restarts, and has the pleasant side effect that a user who logs in after a
/// long absence is picked up on the next pass rather than a week later.
/// ///
/// A user whose database is still locked is **skipped**, and that is the normal /// A user whose database is still locked is **skipped**, and that is the normal
/// case rather than an error: the pool is the unlock token (§9), so a user who /// case rather than an error: the pool is the unlock token (§9), so a user who
@@ -197,19 +207,24 @@ pub(super) fn spawn_system_agents(skald: &Arc<super::Skald>, tic_config: TicConf
let shutdown = skald.rt.shutdown_token.clone(); let shutdown = skald.rt.shutdown_token.clone();
let mut sys_rx = skald.rt.system_bus.subscribe(); let mut sys_rx = skald.rt.system_bus.subscribe();
let tic = TicManager::new( // Adding an agent is one line in `system_agents::registry` plus a
// `SystemAgent` impl — no loop of its own, which is the whole point: a second
// scheduler would be a fourth global bus in disguise.
let agents = system_agents::registry(
tic_config, tic_config,
Arc::clone(&skald.rt.config), Arc::clone(&skald.rt.config),
Arc::clone(&skald.rt.db), Arc::clone(&skald.rt.db),
); );
// Interval keys, so a change in the UI cuts the current wait short for
// whichever agent it belongs to.
let interval_keys: Vec<&'static str> = agents.iter().map(|a| a.interval_key()).collect();
skald.rt.supervisor.spawn("system-agents", async move { skald.rt.supervisor.spawn("system-agents", async move {
info!("system-agents: scheduler started"); info!(agents = agents.len(), "system-agents: scheduler started");
'outer: loop { 'outer: loop {
// Re-read the interval each pass so a Settings change lands without a let wait = base_tick(&agents).await;
// restart; a live change also cuts the current wait short.
let wait = Duration::from_secs(tic.interval_secs().await);
let deadline = tokio::time::sleep(wait); let deadline = tokio::time::sleep(wait);
tokio::pin!(deadline); tokio::pin!(deadline);
@@ -219,9 +234,9 @@ pub(super) fn spawn_system_agents(skald: &Arc<super::Skald>, tic_config: TicConf
_ = &mut deadline => break, _ = &mut deadline => break,
ev = sys_rx.recv() => match ev { ev = sys_rx.recv() => match ev {
Ok(SystemEvent::ConfigKeyUpdated { key, .. }) Ok(SystemEvent::ConfigKeyUpdated { key, .. })
if key == TIC_INTERVAL_MINUTES_KEY => if interval_keys.contains(&key.as_str()) =>
{ {
info!("system-agents: interval changed, rescheduling"); info!(%key, "system-agents: interval changed, rescheduling");
continue 'outer; continue 'outer;
} }
Err(RecvError::Closed) => break 'outer, Err(RecvError::Closed) => break 'outer,
@@ -231,49 +246,154 @@ pub(super) fn spawn_system_agents(skald: &Arc<super::Skald>, tic_config: TicConf
} }
let Some(skald) = weak.upgrade() else { break }; let Some(skald) = weak.upgrade() else { break };
tic_pass(&skald, &tic).await; agents_pass(&skald, &agents).await;
} }
info!("system-agents: scheduler stopped"); info!("system-agents: scheduler stopped");
}); });
} }
/// One TIC pass over the whole directory, one user at a time. /// How long to sleep between passes: the shortest interval any enabled agent
async fn tic_pass(skald: &Arc<super::Skald>, tic: &Arc<TicManager>) { /// asks for, clamped.
if !tic.is_enabled().await { ///
return; /// The wake-up itself decides nothing — every agent is gated per user by
} /// [`system_agents::is_due`] against persisted state — so this only has to be
/// fine-grained enough not to delay the most impatient agent, and coarse enough
/// not to spin. The floor keeps a misconfigured one-minute interval from turning
/// into a busy loop; the ceiling keeps a box that runs only weekly agents from
/// sleeping so long that a freshly changed setting takes hours to be noticed.
async fn base_tick(agents: &[Arc<dyn SystemAgent>]) -> Duration {
const FLOOR_SECS: u64 = 60;
const CEIL_SECS: u64 = 15 * 60;
let mut shortest = CEIL_SECS;
for agent in agents {
if agent.is_enabled().await {
shortest = shortest.min(agent.interval_secs().await);
}
}
Duration::from_secs(shortest.clamp(FLOOR_SECS, CEIL_SECS))
}
/// One pass over every agent, sequentially.
///
/// Sequential on purpose, and at two levels: agents one after another, and
/// within a per-user agent, users one after another. A pass is N container
/// round-trips and N LLM calls, nobody is waiting on it, and running them
/// concurrently would only spike the box every interval. It is also what makes
/// the `running` row of a crashed pass safe to sweep — no other run of the same
/// agent can be live.
async fn agents_pass(skald: &Arc<super::Skald>, agents: &[Arc<dyn SystemAgent>]) {
for agent in agents {
if skald.rt.shutdown_token.is_cancelled() {
return;
}
// Re-read per pass, so disabling an agent takes effect without a restart.
if !agent.is_enabled().await {
continue;
}
match agent.scope() {
AgentScope::PerUser => per_user_pass(skald, agent.as_ref()).await,
AgentScope::Instance => instance_pass(skald, agent.as_ref()).await,
}
}
}
/// Run `agent` for each active user whose database is unlocked and who is due.
async fn per_user_pass(skald: &Arc<super::Skald>, agent: &dyn SystemAgent) {
let users = match skald.users().list().await { let users = match skald.users().list().await {
Ok(u) => u, Ok(u) => u,
Err(e) => { Err(e) => {
warn!(error = %e, "system-agents: cannot list users, skipping this pass"); warn!(agent = agent.id(), error = %e,
"system-agents: cannot list users, skipping this pass");
return; return;
} }
}; };
for user in users.into_iter().filter(|u| u.active) { for user in users.into_iter().filter(|u| u.active) {
if skald.rt.shutdown_token.is_cancelled() { if skald.rt.shutdown_token.is_cancelled() {
break; return;
}
if !skald.users().is_unlocked(&user.id) {
info!(
user = %user.id, username = %user.username,
"TIC: skipped — the user's database is still encrypted (not logged in since the last restart)",
);
continue;
}
// Unlocked, so this resolves (and is normally already live from their login).
let Some(ctx) = skald.user_context(&user.id).await else {
warn!(user = %user.id, "TIC: skipped — could not resolve the user's runtime");
continue;
};
if let Err(e) = tic.run_for(&user.id, &ctx.pool, &ctx.sessions, &ctx.chat_hub).await {
// One user's failure must not end the pass for everyone after them.
warn!(user = %user.id, error = %e, "TIC: tick failed");
} }
run_one(skald, agent, &user.id, &user.username).await;
}
}
/// Run an instance-scoped `agent` once, as the admin.
///
/// The first active admin who is unlocked wins; ordering is `users::list`'s, so
/// the choice is stable across passes rather than racing between two admins. If
/// none has logged in since the last restart the pass is skipped exactly like a
/// locked user's — it settles at the next login.
async fn instance_pass(skald: &Arc<super::Skald>, agent: &dyn SystemAgent) {
let users = match skald.users().list().await {
Ok(u) => u,
Err(e) => {
warn!(agent = agent.id(), error = %e,
"system-agents: cannot list users, skipping this pass");
return;
}
};
let admins = users
.into_iter()
.filter(|u| u.active && u.role_id == crate::db::roles::ADMIN_ROLE_ID);
for admin in admins {
if skald.users().is_unlocked(&admin.id) {
run_one(skald, agent, &admin.id, &admin.username).await;
return;
}
}
info!(
agent = agent.id(),
"system-agents: skipped — no admin has logged in since the last restart, \
so the instance-wide pass has no runtime to run in",
);
}
/// The common tail: skip a locked user, resolve their runtime, check due-ness,
/// run and record.
async fn run_one(
skald: &Arc<super::Skald>,
agent: &dyn SystemAgent,
user_id: &str,
username: &str,
) {
// A locked user is the normal case, not an error: the pool is the unlock
// token (§9), so someone who has not logged in since the last restart has
// nothing readable — and no place to record the skip, since the only file
// that could hold it is the one we cannot open. Hence a log line and nothing
// else; their next login picks it up.
if !skald.users().is_unlocked(user_id) {
info!(
agent = agent.id(), user = %user_id, %username,
"system-agents: skipped — the user's database is still encrypted \
(not logged in since the last restart)",
);
return;
}
// Unlocked, so this resolves (and is normally already live from their login).
let Some(ctx) = skald.user_context(user_id).await else {
warn!(agent = agent.id(), user = %user_id,
"system-agents: skipped — could not resolve the user's runtime");
return;
};
if !system_agents::is_due(agent, &ctx.pool).await {
return;
}
let run_ctx = AgentRunCtx {
user_id,
pool: &ctx.pool,
sessions: &ctx.sessions,
hub: &ctx.chat_hub,
};
// One user'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 = %user_id, error = %e, "system-agents: pass failed");
} }
} }
@@ -0,0 +1,293 @@
//! The memory-lint agents — the weekly health pass over the two memory stores.
//!
//! Memory is a wiki, not a scrapbook (`agents/common/memory-wiki.md`), and a wiki
//! that nobody maintains rots: contradictions stay pending, dates go by, notes
//! lose their last inbound link, the same fact ends up written twice. The Lint
//! habit in the Schema covers "when you notice drift"; these agents are what
//! makes it happen when nobody notices.
//!
//! **There are two of them, and they are not the same job.** The private lint
//! runs for each user over their own store — their data, their notify, their run
//! log. The shared lint runs once over the group store, where the interesting
//! defect is different: a note that fails the table rule, i.e. one person's
//! private business sitting somewhere every member can read. They share the
//! wiki Schema through `agents/common/`, and diverge in their `AGENT.md`.
//!
//! **Both are read-only, and that is enforced twice.** The prompt says report,
//! never repair; and the approval rules already gate `shared-memory/*` writes as
//! `require` — so an agent that tried to fix something would raise an approval
//! card from an unattended pass, which [`super::run_ephemeral_turn`] auto-denies.
//! Read-only is therefore not a convention here, it is the only thing that works.
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use sqlx::SqlitePool;
use core_api::{ConfigProperty, ConfigSet, PropertyType};
use crate::config_store::GlobalConfigManager;
use crate::db::memory_docs;
use crate::tools::fs::{SHARED_MEMORY_ROOT, USER_MEMORY_ROOT};
use super::{
AgentOutcome, AgentRunCtx, AgentScope, SystemAgent, configured_run_context,
enabled_from_config, enabled_property, interval_from_config, run_ephemeral_turn,
security_group_property,
};
/// The chat `source` a lint pass runs under. Distinct from the user-facing
/// sources so a pass never lands in a conversation somebody is reading.
const LINT_SOURCE: &str = "memory-lint";
const DAY_SECS: u64 = 24 * 60 * 60;
/// A week, the default for both passes: long enough that a report is worth
/// reading, short enough that a contradiction does not sit for a month.
const DEFAULT_INTERVAL_SECS: u64 = 7 * DAY_SECS;
pub const PRIVATE_AGENT: &str = "memory-lint-private";
pub const SHARED_AGENT: &str = "memory-lint-shared";
pub const PRIVATE_ENABLED_KEY: &str = "memory_lint_private.enabled";
pub const PRIVATE_SECURITY_GROUP_KEY: &str = "memory_lint_private.security_group";
pub const PRIVATE_INTERVAL_DAYS_KEY: &str = "memory_lint_private.interval_days";
pub const SHARED_ENABLED_KEY: &str = "memory_lint_shared.enabled";
pub const SHARED_SECURITY_GROUP_KEY: &str = "memory_lint_shared.security_group";
pub const SHARED_INTERVAL_DAYS_KEY: &str = "memory_lint_shared.interval_days";
/// The interval property, in **days**.
///
/// The unit is per-agent on purpose. TIC is configured in minutes because it
/// runs in minutes; asking an admin to type `10080` for "weekly" would be a
/// worse form of the same field.
fn interval_days_property(key: &str, description: &str) -> ConfigProperty {
ConfigProperty {
key: key.into(),
name: "Interval (days)".into(),
description: description.into(),
property_type: PropertyType::Int,
default_value: Some("7".into()),
}
}
pub fn private_config_set() -> ConfigSet {
ConfigSet {
name: "Private memory lint".into(),
description: "A periodic health pass over each person's own memory store. For one user at \
a time it re-reads their notes and looks for drift: contradictions still \
pending, facts whose date has gone by, notes nothing links to, index lines \
pointing at nothing, and duplicates worth merging. It reports what it found \
as a notification and never edits anything itself. It reads only that \
user's private store, and the run is recorded on their own System agents \
page; a user who has not logged in since the last restart is skipped, \
because their database is still encrypted."
.into(),
properties: vec![
enabled_property(
PRIVATE_ENABLED_KEY,
"Enable the private memory lint for the whole instance. When disabled, nobody's \
private store is checked.",
),
security_group_property(PRIVATE_SECURITY_GROUP_KEY),
interval_days_property(
PRIVATE_INTERVAL_DAYS_KEY,
"How long between passes for each user. Counted per person from their own last \
pass, and it survives a restart, so a long interval is not reset by rebooting \
the machine.",
),
],
owner: Some(PRIVATE_AGENT.into()),
}
}
pub fn shared_config_set() -> ConfigSet {
ConfigSet {
name: "Shared memory lint".into(),
description: "A periodic health pass over the group's shared memory. It looks for the \
same drift as the private pass, plus the defect that only exists here: a \
note that fails the table rule — one person's private business sitting \
where every member can read it. It reports and never edits. The shared \
store belongs to nobody, so the pass runs as the admin and its report goes \
to them; it needs an admin who has logged in since the last restart."
.into(),
properties: vec![
enabled_property(
SHARED_ENABLED_KEY,
"Enable the shared memory lint for the whole instance.",
),
security_group_property(SHARED_SECURITY_GROUP_KEY),
interval_days_property(
SHARED_INTERVAL_DAYS_KEY,
"How long between passes over the shared store. It survives a restart, so a long \
interval is not reset by rebooting the machine.",
),
],
owner: Some(SHARED_AGENT.into()),
}
}
/// Shared by both agents: everything that differs is a field.
pub struct MemoryLintAgent {
id: &'static str,
scope: AgentScope,
/// The store this pass reads: `user-memory` or `shared-memory`.
root: &'static str,
enabled_key: &'static str,
group_key: &'static str,
interval_key: &'static str,
config_set: fn() -> ConfigSet,
config_store: Arc<GlobalConfigManager>,
/// `system.db` — the registry, read to reconcile the security group against
/// the user's role, and (for the shared pass) the store itself.
registry_pool: Arc<SqlitePool>,
}
impl MemoryLintAgent {
/// The per-user pass over `user-memory/`.
pub fn private(
config_store: Arc<GlobalConfigManager>,
registry_pool: Arc<SqlitePool>,
) -> Arc<Self> {
Arc::new(Self {
id: PRIVATE_AGENT,
scope: AgentScope::PerUser,
root: USER_MEMORY_ROOT,
enabled_key: PRIVATE_ENABLED_KEY,
group_key: PRIVATE_SECURITY_GROUP_KEY,
interval_key: PRIVATE_INTERVAL_DAYS_KEY,
config_set: private_config_set,
config_store,
registry_pool,
})
}
/// The instance pass over `shared-memory/`, run as the admin.
pub fn shared(
config_store: Arc<GlobalConfigManager>,
registry_pool: Arc<SqlitePool>,
) -> Arc<Self> {
Arc::new(Self {
id: SHARED_AGENT,
scope: AgentScope::Instance,
root: SHARED_MEMORY_ROOT,
enabled_key: SHARED_ENABLED_KEY,
group_key: SHARED_SECURITY_GROUP_KEY,
interval_key: SHARED_INTERVAL_DAYS_KEY,
config_set: shared_config_set,
config_store,
registry_pool,
})
}
/// Which pool holds the store this agent lints: the caller's own for the
/// private pass, `system.db` for the shared one (the same routing
/// `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,
}
}
}
#[async_trait]
impl SystemAgent for MemoryLintAgent {
fn id(&self) -> &'static str { self.id }
fn scope(&self) -> AgentScope { self.scope }
fn config_set(&self) -> ConfigSet { (self.config_set)() }
fn interval_key(&self) -> &'static str { self.interval_key }
async fn is_enabled(&self) -> bool {
enabled_from_config(&self.config_store, self.enabled_key).await
}
async fn interval_secs(&self) -> u64 {
interval_from_config(
&self.config_store,
self.interval_key,
DAY_SECS,
DEFAULT_INTERVAL_SECS,
)
.await
}
/// Nothing to lint in an empty store. Worth checking: without it, a member
/// who never uses memory would collect a weekly run row and a weekly
/// notification saying there was nothing to report.
async fn has_work(&self, ctx: &AgentRunCtx<'_>) -> Result<bool> {
let notes = memory_docs::list(self.store_pool(ctx), "").await?;
Ok(!notes.is_empty())
}
async fn run(&self, ctx: &AgentRunCtx<'_>) -> Result<AgentOutcome> {
let notes = memory_docs::list(self.store_pool(ctx), "").await?;
let rc = configured_run_context(
&self.config_store,
&self.registry_pool,
self.group_key,
ctx.user_id,
)
.await;
let (session_id, notified) = run_ephemeral_turn(
self.id,
LINT_SOURCE,
&build_prompt(self.root, notes.len()),
rc.as_ref(),
"Memory lint",
ctx,
)
.await?;
Ok(AgentOutcome {
session_id: Some(session_id),
stats: serde_json::json!({
"notes_examined": notes.len(),
"notifications_emitted": notified,
}),
})
}
}
/// The trigger message. Deliberately thin: *how* to lint is the agent's
/// `AGENT.md` plus the wiki Schema it includes from `agents/common/`, and
/// duplicating any of it here would give us two copies to keep in step.
fn build_prompt(root: &str, note_count: usize) -> String {
let today = chrono::Utc::now().format("%Y-%m-%d");
format!(
"[LINT] Scheduled health pass over `{root}/` — {today}\n\
The store currently holds {note_count} note(s).\n\n\
Read the store, find what has drifted, and report it. Change nothing."
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_prompt_names_the_store_and_forbids_editing() {
let p = build_prompt(SHARED_MEMORY_ROOT, 12);
assert!(p.contains("shared-memory/"));
assert!(p.contains("12 note(s)"));
assert!(p.contains("Change nothing."));
}
#[test]
fn the_two_agents_do_not_share_config_keys() {
let private: Vec<String> = private_config_set()
.properties.into_iter().map(|p| p.key).collect();
let shared: Vec<String> = shared_config_set()
.properties.into_iter().map(|p| p.key).collect();
// A shared key would make one agent's switch silently move the other's.
for key in &private {
assert!(!shared.contains(key), "`{key}` is claimed by both lint agents");
}
}
}
+472
View File
@@ -0,0 +1,472 @@
//! System agents — the background agents the instance runs on a user's behalf.
//!
//! A system agent runs without being asked. TIC was the first, and everything it
//! needed turned out to be general: an on/off switch, an interval, a security
//! group reconciled against the user's own role, an ephemeral session, and a run
//! recorded in the user's own database. This module is that shape, extracted, so
//! a second agent is a [`SystemAgent`] impl and nothing else — no timer of its
//! own, no bookkeeping of its own, no scheduler of its own.
//!
//! **The unit of work is one agent for one user.** The instance-wide scheduler
//! (`skald::wiring::spawn_system_agents`) decides who and when; an agent decides
//! only what. That split is what made TIC per-user correct, and it is why an
//! agent never sees the user list.
//!
//! ## Why the work is split in three
//!
//! [`run_and_record`] wraps every pass, and the order of its steps is
//! load-bearing:
//!
//! 1. **Mark the attempt** ([`db::system_agent_state`]) — always, before
//! anything else, so due-ness advances even for a pass that turns out to have
//! nothing to do. An agent that only recorded productive runs would be asked
//! again on every tick.
//! 2. **Ask [`SystemAgent::has_work`]** — a cheap look before any row is opened.
//! `false` writes nothing at all: an idle tick must not leave a trace, or the
//! run log stops being a history and becomes a heartbeat.
//! 3. **Open the run row, then work.** The `start`/`finish` split means a crash
//! mid-pass leaves a visible `running` row, swept to `failed` by the next
//! `start` for that agent — safe only because the scheduler is sequential and
//! single-instance.
pub mod memory_lint;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use anyhow::Result;
use async_trait::async_trait;
use sqlx::SqlitePool;
use tokio::sync::mpsc;
use tracing::{info, warn};
use core_api::interface_tool::{InterfaceTool, ToolFuture};
use core_api::{ConfigProperty, ConfigSet, PropertyType};
use crate::chat_hub::ChatHub;
use crate::config_store::GlobalConfigManager;
use crate::db::{system_agent_runs, system_agent_state};
use crate::run_context::{self, RunContext};
use crate::session::manager::ChatSessionManager;
/// Who a pass runs for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentScope {
/// One pass per user, over that user's own runtime. The default shape: the
/// data is theirs, the notification is theirs, the trace is theirs.
PerUser,
/// One pass for the whole instance, run inside the admin's runtime.
///
/// For work over something that has **no owner** — the shared memory store
/// being the case that forced this variant. Such a pass still has to run
/// *somewhere*: an ownerless run would write its trace into `system.db`,
/// which `GET /api/system-agents/runs` shows to nobody (scoped on the
/// caller's own pool, by design), and its `notify()` would have no
/// recipient. Attributing it to the admin keeps the whole per-user surface
/// working unchanged, at the price of needing an admin who has logged in
/// since the last restart.
Instance,
}
/// What one pass did, for the run log.
pub struct AgentOutcome {
/// The ephemeral session the pass ran in, so the UI can link to it.
pub session_id: Option<i64>,
/// The agent's own counters. Never the contents of what it read.
pub stats: serde_json::Value,
}
/// One user's runtime, unpacked from their `UserContext` by the scheduler.
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>,
}
#[async_trait]
pub trait SystemAgent: Send + Sync {
/// Directory name under `agents/`, and the `agent_id` of its rows.
fn id(&self) -> &'static str;
fn scope(&self) -> AgentScope;
/// The settings shown on this agent's tab of the System agents page. Must be
/// `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.
fn interval_key(&self) -> &'static str;
/// Instance-wide on/off switch, re-read every pass.
async fn is_enabled(&self) -> bool;
/// How long between passes **for one user**, in seconds.
async fn interval_secs(&self) -> u64;
/// Cheap look at whether this pass would do anything, before a run row is
/// opened. `false` means "nothing to do" and leaves no trace behind.
async fn has_work(&self, ctx: &AgentRunCtx<'_>) -> Result<bool>;
/// The pass itself. The run row is already open; returning `Err` closes it
/// as `failed` with the message.
async fn run(&self, ctx: &AgentRunCtx<'_>) -> Result<AgentOutcome>;
}
/// Every system agent the instance runs, in pass order.
///
/// The **one** place the set is enumerated. The scheduler takes this list, and
/// [`config_sets`] derives the settings surface from it, so an agent cannot exist
/// in one and be missing from the other — the failure that would otherwise look
/// like "the agent runs but has no settings" or "the settings page edits keys
/// nothing reads".
pub fn registry(
tic_config: crate::config::TicConfig,
config_store: Arc<GlobalConfigManager>,
registry_pool: Arc<SqlitePool>,
) -> Vec<Arc<dyn SystemAgent>> {
vec![
crate::tic::TicManager::new(
tic_config,
Arc::clone(&config_store),
Arc::clone(&registry_pool),
),
memory_lint::MemoryLintAgent::private(
Arc::clone(&config_store),
Arc::clone(&registry_pool),
),
memory_lint::MemoryLintAgent::shared(config_store, registry_pool),
]
}
/// The config sets of every system agent, in the same order as [`registry`].
///
/// A free function rather than `registry(..).map(|a| a.config_set())` because
/// `Runtime::bootstrap` needs the settings surface before it has the runtime
/// dependencies an agent is built from. `registry_and_config_sets_agree` is what
/// keeps the two honest.
pub fn config_sets() -> Vec<ConfigSet> {
vec![
crate::tic::config_set(),
memory_lint::private_config_set(),
memory_lint::shared_config_set(),
]
}
/// Is `agent` due for this user? `true` when it has never run here, or when the
/// last attempt is older than the configured interval.
///
/// Read from the database rather than an in-memory deadline, which is what makes
/// a weekly agent survive a restart — see the `system_agent_state` table comment.
pub async fn is_due(agent: &dyn SystemAgent, pool: &SqlitePool) -> bool {
let interval = agent.interval_secs().await as i64;
match system_agent_state::seconds_since_attempt(pool, agent.id()).await {
Ok(Some(elapsed)) => elapsed >= interval,
// Never attempted here — due now.
Ok(None) => true,
// Unreadable state: run it. A spurious pass is recoverable; an agent that
// silently stops running is not.
Err(e) => {
warn!(agent = agent.id(), error = %e, "system-agents: cannot read schedule state, running anyway");
true
}
}
}
/// Run one pass and record it. See the module docs for why the steps are ordered
/// the way they are.
///
/// `Ok(None)` means the pass had nothing to do and wrote no run row.
pub async fn run_and_record(
agent: &dyn SystemAgent,
ctx: &AgentRunCtx<'_>,
) -> 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");
}
// Step 2 — nothing to do leaves no trace.
if !agent.has_work(ctx).await? {
return Ok(None);
}
// Step 3 — open the row, then work.
let run_id = system_agent_runs::start(ctx.pool, agent.id()).await?;
let started = Instant::now();
match agent.run(ctx).await {
Ok(outcome) => {
system_agent_runs::finish(
ctx.pool,
run_id,
system_agent_runs::STATUS_COMPLETED,
outcome.session_id,
started.elapsed().as_millis() as i64,
Some(&outcome.stats.to_string()),
None,
)
.await?;
info!(agent = agent.id(), user = %ctx.user_id, stats = %outcome.stats,
"system-agents: pass complete");
Ok(Some(outcome))
}
Err(e) => {
// Best-effort: the pass already failed, and a failing log write must
// not mask the original error.
if let Err(log_err) = system_agent_runs::finish(
ctx.pool,
run_id,
system_agent_runs::STATUS_FAILED,
None,
started.elapsed().as_millis() as i64,
None,
Some(&e.to_string()),
)
.await
{
warn!(agent = agent.id(), user = %ctx.user_id, error = %log_err,
"system-agents: failed to record the failed pass");
}
Err(e)
}
}
}
// ── Shared machinery ───────────────────────────────────────────────────────────
/// Run one ephemeral turn of `agent_id` and return `(session_id, notifications
/// emitted)`.
///
/// Every system agent talks to its user the same way: a throwaway session that
/// `ChatHub` never sees, approvals auto-denied because nobody is watching, and
/// `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<'_>,
) -> 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
// `sources` row of a conversation the user is having.
let (session_id, _) = ctx
.sessions
.create_session(agent_id, source, false, true, run_context)
.await?;
let handler = ctx.sessions.get_or_create_handler(session_id).await?;
// Nobody is at the keyboard to answer an approval card, so anything the
// rules gate is denied rather than left hanging forever.
handler.set_auto_deny_approvals();
// The session's event stream has no subscriber, but the translator awaits its
// sends — a receiver merely dropped, or kept and never polled, wedges the
// turn at the channel's capacity. Drain it explicitly.
let (tx, mut rx) = mpsc::channel(32);
tokio::spawn(async move { while rx.recv().await.is_some() {} });
let (notify, emitted) = counting_notify(Arc::clone(ctx.hub), notify_label);
handler
.handle_message(
prompt,
None,
None,
None,
None,
vec![notify],
HashMap::new(),
tx,
true,
None,
None,
)
.await?;
Ok((session_id, emitted.load(Ordering::Relaxed)))
}
/// The security group for one user's pass.
///
/// The configured group is an instance-wide admin setting, so it cannot be
/// applied verbatim to somebody else's session: that would hand a restricted
/// member's background agent a tool set their role never granted. It goes
/// through the same seam a persisted group does —
/// [`run_context::reconcile_group_for_user`] — which degrades it to the user's
/// role default when their role does not allow it. With nothing configured we
/// still start from the role default rather than `None`, because `None` means
/// the catch-all group, which is *wider*.
pub async fn configured_run_context(
config_store: &GlobalConfigManager,
registry_pool: &SqlitePool,
key: &str,
user_id: &str,
) -> Option<RunContext> {
let configured = config_store
.get(key)
.await
.ok()
.flatten()
.filter(|g| !g.is_empty());
match configured {
Some(group) => {
let wanted = RunContext::with_security_group(Some(group));
run_context::reconcile_group_for_user(registry_pool, user_id, Some(wanted)).await
}
None => run_context::role_default_run_context(registry_pool, user_id).await,
}
}
/// Read an instance-wide boolean switch, defaulting to on.
pub async fn enabled_from_config(config_store: &GlobalConfigManager, key: &str) -> bool {
match config_store.get(key).await {
Ok(Some(v)) => v != "false",
_ => true,
}
}
/// Read an interval expressed in `unit_secs`-sized units, falling back to
/// `default_secs` when unset, unparseable or zero.
pub async fn interval_from_config(
config_store: &GlobalConfigManager,
key: &str,
unit_secs: u64,
default_secs: u64,
) -> u64 {
if let Ok(Some(val)) = config_store.get(key).await {
if let Ok(n) = val.trim().parse::<u64>() {
if n > 0 {
return n.saturating_mul(unit_secs);
}
}
}
default_secs
}
/// The on/off switch every system agent has.
pub fn enabled_property(key: &str, description: &str) -> ConfigProperty {
ConfigProperty {
key: key.into(),
name: "Enabled".into(),
description: description.into(),
property_type: PropertyType::Bool,
default_value: Some("true".into()),
}
}
/// The security-group picker every system agent has. The wording spells out the
/// per-user reconciliation, because an admin choosing a wide group here would
/// otherwise expect it to apply verbatim.
pub fn security_group_property(key: &str) -> ConfigProperty {
ConfigProperty {
key: key.into(),
name: "Security group".into(),
description: "Tool permission group applied to each run. It is re-checked against each \
user's own role: a user whose role does not allow this group runs under \
their role's default group instead. Leave empty to always use the role \
default."
.into(),
property_type: PropertyType::SecurityGroup,
default_value: None,
}
}
/// Wrap the `notify` tool so the run log can report how many notifications a
/// pass actually produced, without the tool itself knowing it is being counted.
fn counting_notify(hub: Arc<ChatHub>, label: &str) -> (InterfaceTool, Arc<AtomicUsize>) {
let inner = crate::tools::notify::make_tool(hub, label);
let counter = Arc::new(AtomicUsize::new(0));
let handler = {
let counter = Arc::clone(&counter);
let call = Arc::clone(&inner.handler);
Arc::new(move |args: serde_json::Value| {
let counter = Arc::clone(&counter);
let fut = call(args);
Box::pin(async move {
let out = fut.await;
if out.is_ok() {
counter.fetch_add(1, Ordering::Relaxed);
}
out
}) as ToolFuture
})
};
(InterfaceTool { definition: inner.definition, handler }, counter)
}
/// Every system agent's config set must be owned by the agent, or its settings
/// silently land on the general Config page instead of its own tab.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tic_config_set_is_owned_by_tic() {
let set = crate::tic::config_set();
assert_eq!(set.owner.as_deref(), Some(crate::tic::TIC_AGENT));
}
#[test]
fn lint_config_sets_are_owned_by_their_agents() {
assert_eq!(
memory_lint::private_config_set().owner.as_deref(),
Some(memory_lint::PRIVATE_AGENT),
);
assert_eq!(
memory_lint::shared_config_set().owner.as_deref(),
Some(memory_lint::SHARED_AGENT),
);
}
#[tokio::test]
async fn registry_and_config_sets_agree() {
// 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 scheduled: Vec<&str> =
registry(Default::default(), config, pool).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"))
.collect();
assert_eq!(
scheduled, configured,
"the scheduler's agents and the settings surface have drifted apart",
);
}
#[test]
fn every_agent_declares_its_interval_key_among_its_properties() {
// The scheduler watches `interval_key()` for live changes; a key that is
// not in the set is one nothing can ever edit.
for (set, key) in [
(crate::tic::config_set(), crate::tic::TIC_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),
] {
assert!(
set.properties.iter().any(|p| p.key == key),
"`{key}` is watched by the scheduler but is not an editable property",
);
}
}
}
+91 -214
View File
@@ -9,31 +9,35 @@
//! reads live in `mcp_events` inside the caller's own encrypted database, the //! reads live in `mcp_events` inside the caller's own encrypted database, the
//! connectors that produced them run inside the caller's container, and the //! connectors that produced them run inside the caller's container, and the
//! notification it emits goes to the caller's own hub. This manager therefore //! notification it emits goes to the caller's own hub. This manager therefore
//! owns no timer and no user list: it exposes [`TicManager::run_for`], one tick //! owns no timer and no user list: it implements
//! for one user, and the instance-wide scheduler //! [`SystemAgent`](crate::system_agents::SystemAgent), one pass for one user,
//! (`skald::wiring::spawn_system_agents`) decides who to run it for and when — //! and the instance-wide scheduler (`skald::wiring::spawn_system_agents`)
//! sequentially, skipping anyone whose database is still locked. //! decides who to run it for and when — sequentially, skipping anyone whose
//! database is still locked.
//! //!
//! The run is recorded in `system_agent_runs` in that same user's database, so //! The run is recorded in `system_agent_runs` in that same user's database, so
//! the trace of what TIC did for someone is readable by them and by nobody else. //! the trace of what TIC did for someone is readable by them and by nobody else.
//! Opening and closing that row is [`crate::system_agents::run_and_record`]'s
//! job, not TIC's: every agent needs it identically, and the ordering rules
//! around it are subtle enough that one copy is the only safe number.
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use anyhow::Result;
use async_trait::async_trait;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tokio::sync::mpsc; use tracing::info;
use tracing::{info, warn};
use core_api::interface_tool::{InterfaceTool, ToolFuture};
use core_api::{ConfigProperty, ConfigSet, PropertyType}; use core_api::{ConfigProperty, ConfigSet, PropertyType};
use crate::chat_hub::ChatHub;
use crate::config::TicConfig; use crate::config::TicConfig;
use crate::config_store::GlobalConfigManager; use crate::config_store::GlobalConfigManager;
use crate::db::{mcp_events, system_agent_runs}; use crate::db::mcp_events;
use crate::run_context::{self, RunContext}; use crate::system_agents::{
use crate::session::manager::ChatSessionManager; AgentOutcome, AgentRunCtx, AgentScope, SystemAgent, configured_run_context,
enabled_from_config, enabled_property, interval_from_config, run_ephemeral_turn,
security_group_property,
};
/// The chat `source` TIC's ephemeral sessions carry. Kept distinct from the /// The chat `source` TIC's ephemeral sessions carry. Kept distinct from the
/// user-facing sources (`web`, `talk`, `telegram`) so a tick never lands in a /// user-facing sources (`web`, `talk`, `telegram`) so a tick never lands in a
@@ -58,28 +62,24 @@ pub fn config_set() -> ConfigSet {
because their database is still encrypted. Each run is recorded on the System \ because their database is still encrypted. Each run is recorded on the System \
agents page, visible to the user it ran for.".into(), agents page, visible to the user it ran for.".into(),
properties: vec![ properties: vec![
ConfigProperty { enabled_property(
key: TIC_ENABLED_KEY.into(), TIC_ENABLED_KEY,
name: "Enabled".into(), "Enable or disable the TIC agent for the whole instance. When disabled, no events \
description: "Enable or disable the TIC agent for the whole instance. When disabled, no events are processed for anyone.".into(), are processed for anyone.",
property_type: PropertyType::Bool, ),
default_value: Some("true".into()), security_group_property(TIC_SECURITY_GROUP_KEY),
},
ConfigProperty {
key: TIC_SECURITY_GROUP_KEY.into(),
name: "Security Group".into(),
description: "Tool permission group applied to each TIC run. It is re-checked against each user's own role: a user whose role does not allow this group runs under their role's default group instead. Leave empty to always use the role default.".into(),
property_type: PropertyType::SecurityGroup,
default_value: None,
},
ConfigProperty { ConfigProperty {
key: TIC_INTERVAL_MINUTES_KEY.into(), key: TIC_INTERVAL_MINUTES_KEY.into(),
name: "Check Interval (minutes)".into(), name: "Check interval (minutes)".into(),
description: "How often TIC starts a pass over all users, in minutes. Leave empty to use the value from config.yml (tic.interval_secs).".into(), description: "How long between passes for each user, in minutes. Counted per \
person from their own last pass. Leave empty to use the value from \
config.yml (tic.interval_secs)."
.into(),
property_type: PropertyType::Int, property_type: PropertyType::Int,
default_value: Some("15".into()), default_value: Some("15".into()),
}, },
], ],
owner: Some(TIC_AGENT.into()),
} }
} }
@@ -90,16 +90,6 @@ pub struct TicRun {
pub notifications_emitted: usize, pub notifications_emitted: usize,
} }
impl TicRun {
fn stats_json(&self) -> String {
serde_json::json!({
"events_processed": self.events_processed,
"notifications_emitted": self.notifications_emitted,
})
.to_string()
}
}
pub struct TicManager { pub struct TicManager {
config: TicConfig, config: TicConfig,
config_store: Arc<GlobalConfigManager>, config_store: Arc<GlobalConfigManager>,
@@ -117,199 +107,86 @@ impl TicManager {
Arc::new(Self { config, config_store, registry_pool }) Arc::new(Self { config, config_store, registry_pool })
} }
/// Instance-wide on/off switch. Read fresh each pass, so toggling it in
/// Settings takes effect at the next pass with no restart.
pub async fn is_enabled(&self) -> bool {
match self.config_store.get(TIC_ENABLED_KEY).await {
Ok(Some(v)) => v != "false",
_ => true,
}
}
/// Seconds between passes: the Settings value wins, else `config.yml`.
pub async fn interval_secs(&self) -> u64 {
if let Ok(Some(val)) = self.config_store.get(TIC_INTERVAL_MINUTES_KEY).await {
if let Ok(mins) = val.parse::<u64>() {
if mins > 0 {
return mins * 60;
}
}
}
self.config.interval_secs
}
/// One tick for one user, over that user's own runtime. /// One tick for one user, over that user's own runtime.
/// async fn tick(&self, ctx: &AgentRunCtx<'_>) -> Result<TicRun> {
/// `Ok(None)` means there was nothing to do — no pending events — and let events = mcp_events::pending_limited(ctx.pool, self.config.batch_size).await?;
/// **nothing is written**: an idle tick must not leave a row behind, or the info!(user = %ctx.user_id, count = events.len(), "TIC: processing event batch");
/// run log becomes a heartbeat instead of a history. Any other outcome opens
/// a `system_agent_runs` row and closes it, failure included.
pub async fn run_for(
&self,
user_id: &str,
pool: &SqlitePool,
sessions: &Arc<ChatSessionManager>,
hub: &Arc<ChatHub>,
) -> anyhow::Result<Option<TicRun>> {
let events = mcp_events::pending_limited(pool, self.config.batch_size).await?;
if events.is_empty() {
return Ok(None);
}
let run_id = system_agent_runs::start(pool, TIC_AGENT).await?;
let started = Instant::now();
match self.tick(user_id, pool, sessions, hub, events).await {
Ok(run) => {
system_agent_runs::finish(
pool,
run_id,
system_agent_runs::STATUS_COMPLETED,
Some(run.session_id),
started.elapsed().as_millis() as i64,
Some(&run.stats_json()),
None,
)
.await?;
info!(
user = %user_id,
events = run.events_processed,
notifications = run.notifications_emitted,
"TIC: tick complete",
);
Ok(Some(run))
}
Err(e) => {
// Best-effort: the tick already failed, a failing log write must not
// mask the original error.
if let Err(log_err) = system_agent_runs::finish(
pool,
run_id,
system_agent_runs::STATUS_FAILED,
None,
started.elapsed().as_millis() as i64,
None,
Some(&e.to_string()),
)
.await
{
warn!(user = %user_id, error = %log_err, "TIC: failed to record the failed run");
}
Err(e)
}
}
}
async fn tick(
&self,
user_id: &str,
pool: &SqlitePool,
sessions: &Arc<ChatSessionManager>,
hub: &Arc<ChatHub>,
events: Vec<mcp_events::McpEvent>,
) -> anyhow::Result<TicRun> {
info!(user = %user_id, count = events.len(), "TIC: processing event batch");
// Mark as processed BEFORE running the agent — a crash mid-turn then costs // Mark as processed BEFORE running the agent — a crash mid-turn then costs
// this batch rather than replaying it forever. The loss is visible: the run // this batch rather than replaying it forever. The loss is visible: the run
// row closes as `failed` with the error. // row closes as `failed` with the error.
let ids: Vec<i64> = events.iter().map(|e| e.id).collect(); let ids: Vec<i64> = events.iter().map(|e| e.id).collect();
mcp_events::mark_processed(pool, &ids).await?; mcp_events::mark_processed(ctx.pool, &ids).await?;
let prompt = build_prompt(&events); let rc = configured_run_context(
let rc = self.run_context_for(user_id).await; &self.config_store,
&self.registry_pool,
TIC_SECURITY_GROUP_KEY,
ctx.user_id,
)
.await;
// A fresh ephemeral session per tick (agent_id = "tic", source = "tic"). let (session_id, notified) = run_ephemeral_turn(
// ChatHub is bypassed: TIC is not a user-facing source and must not take TIC_AGENT,
// over the `sources` row of a conversation the user is having. TIC_SOURCE,
let (session_id, _) = sessions &build_prompt(&events),
.create_session(TIC_AGENT, TIC_SOURCE, false, true, rc.as_ref()) rc.as_ref(),
.await?; "TIC",
let handler = sessions.get_or_create_handler(session_id).await?; ctx,
handler.set_auto_deny_approvals(); )
.await?;
// The session's event stream has no subscriber, but the translator awaits
// its sends — a receiver that is merely dropped, or kept and never polled,
// wedges the turn at the channel's capacity. Drain it explicitly.
let (tx, mut rx) = mpsc::channel(32);
tokio::spawn(async move { while rx.recv().await.is_some() {} });
let (notify, emitted) = counting_notify(Arc::clone(hub));
handler
.handle_message(
&prompt,
None,
None,
None,
None,
vec![notify],
std::collections::HashMap::new(),
tx,
true,
None,
None,
)
.await?;
Ok(TicRun { Ok(TicRun {
session_id, session_id,
events_processed: events.len(), events_processed: events.len(),
notifications_emitted: emitted.load(Ordering::Relaxed), notifications_emitted: notified,
}) })
} }
/// The security group for this user's tick.
///
/// The configured group is an instance-wide admin setting, so it cannot be
/// applied verbatim to somebody else's session: that would hand a restricted
/// member's TIC run a tool set their role never granted. It goes through the
/// same seam a persisted group does — [`run_context::reconcile_group_for_user`],
/// which degrades it to the user's role default when their role does not allow
/// it. With nothing configured we still start from the role default rather than
/// `None`, because `None` means the catch-all group, which is *wider*.
async fn run_context_for(&self, user_id: &str) -> Option<RunContext> {
let configured = self
.config_store
.get(TIC_SECURITY_GROUP_KEY)
.await
.ok()
.flatten()
.filter(|g| !g.is_empty());
match configured {
Some(group) => {
let wanted = RunContext::with_security_group(Some(group));
run_context::reconcile_group_for_user(&self.registry_pool, user_id, Some(wanted)).await
}
None => run_context::role_default_run_context(&self.registry_pool, user_id).await,
}
}
} }
/// Wrap the `notify` tool so the run log can report how many notifications the #[async_trait]
/// tick actually produced, without the tool itself knowing it is being counted. impl SystemAgent for TicManager {
fn counting_notify(hub: Arc<ChatHub>) -> (InterfaceTool, Arc<AtomicUsize>) { fn id(&self) -> &'static str { TIC_AGENT }
let inner = crate::tools::notify::make_tool(hub, "TIC");
let counter = Arc::new(AtomicUsize::new(0));
let handler = { fn scope(&self) -> AgentScope { AgentScope::PerUser }
let counter = Arc::clone(&counter);
let call = Arc::clone(&inner.handler); fn config_set(&self) -> ConfigSet { config_set() }
Arc::new(move |args: serde_json::Value| {
let counter = Arc::clone(&counter); fn interval_key(&self) -> &'static str { TIC_INTERVAL_MINUTES_KEY }
let fut = call(args);
Box::pin(async move { async fn is_enabled(&self) -> bool {
let out = fut.await; enabled_from_config(&self.config_store, TIC_ENABLED_KEY).await
if out.is_ok() { }
counter.fetch_add(1, Ordering::Relaxed);
} /// Seconds between passes: the Settings value (minutes) wins, else `config.yml`.
out async fn interval_secs(&self) -> u64 {
}) as ToolFuture interval_from_config(
&self.config_store,
TIC_INTERVAL_MINUTES_KEY,
60,
self.config.interval_secs,
)
.await
}
/// No pending events means no tick at all — and no row. The batch is re-read
/// in [`TicManager::tick`]; it is one indexed query on a small table, and
/// paying it twice is cheaper than a trait shaped around carrying the rows.
async fn has_work(&self, ctx: &AgentRunCtx<'_>) -> Result<bool> {
let events = mcp_events::pending_limited(ctx.pool, self.config.batch_size).await?;
Ok(!events.is_empty())
}
async fn run(&self, ctx: &AgentRunCtx<'_>) -> Result<AgentOutcome> {
let run = self.tick(ctx).await?;
Ok(AgentOutcome {
session_id: Some(run.session_id),
stats: serde_json::json!({
"events_processed": run.events_processed,
"notifications_emitted": run.notifications_emitted,
}),
}) })
}; }
(InterfaceTool { definition: inner.definition, handler }, counter)
} }
// ── Prompt builder ───────────────────────────────────────────────────────────── // ── Prompt builder ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -12,8 +12,8 @@ This index will grow over time. Right now it covers memory, projects, system age
| --- | --- | | --- | --- |
| [memory.md](memory.md) | Private and shared memory: what goes where, the indexes and history log, why some shared facts can't be changed on request | | [memory.md](memory.md) | Private and shared memory: what goes where, the indexes and history log, why some shared facts can't be changed on request |
| [projects.md](projects.md) | Projects: shared folders with their own assistant chat, a live file explorer, and member sharing | | [projects.md](projects.md) | Projects: shared folders with their own assistant chat, a live file explorer, and member sharing |
| [system-agents.md](system-agents.md) | Background agents that run on a schedule (TIC): what they watch, why they run per person, why a run can be skipped | | [system-agents.md](system-agents.md) | Background agents that run on a schedule (TIC, the two memory lints): what they watch, why they only ever report, why a run can be skipped, and their settings |
| [settings.md](settings.md) | The admin's Config page: interface language, TIC agent, the compaction model picker, debug mode | | [settings.md](settings.md) | The admin's Config page: interface language, the compaction model picker, debug mode |
## Plugins ## Plugins
+6
View File
@@ -27,6 +27,12 @@ If a user wants a shared fact changed and it is not theirs, tell them plainly wh
**The member list is not remembered — it is read.** Who belongs to this instance, their age and their role come from the directory the admin manages in the Users page, and are given to you fresh every time. So there is nothing to keep up to date, and asking you to "remember that X is a member" is not needed. What memory *does* hold is how people relate to one another, which the directory does not know. **The member list is not remembered — it is read.** Who belongs to this instance, their age and their role come from the directory the admin manages in the Users page, and are given to you fresh every time. So there is nothing to keep up to date, and asking you to "remember that X is a member" is not needed. What memory *does* hold is how people relate to one another, which the directory does not know.
## Memory is maintained, not just written to
Both stores are kept as a small wiki: notes cross-reference each other, `index.md` says where things are, and `log.md` records every change. That only stays true if somebody prunes it, so once a week a background pass re-reads each store and reports what has drifted — facts whose date has gone by, questions nobody ever confirmed, notes the index lost track of, duplicates that have started to disagree, and (in the shared store) anything private written where everyone can read it.
**Those passes never edit memory.** They report, and a person decides. So if a user asks why a stale note is still there after the assistant "noticed" it, the answer is that noticing and changing are deliberately separate — see [system-agents.md](system-agents.md).
## Related ## Related
- Notes are searchable full-text — you can find something without knowing which note holds it. - Notes are searchable full-text — you can find something without knowing which note holds it.
+3 -5
View File
@@ -8,13 +8,11 @@ Each setting is saved individually with its own **Save** button (a few, like the
- **Language** — the default interface language for the whole instance. Each user can override it on their own profile page. - **Language** — the default interface language for the whole instance. Each user can override it on their own profile page.
## TIC Agent ## Background agents — not here
TIC is a background agent that runs for each user in turn, reads the events that user's own connectors received (new emails, calendar updates, WhatsApp messages…) and decides which are worth surfacing as notifications to them. See [system-agents.md](system-agents.md) for how it works and why a user can be skipped. The settings for the background agents (TIC, the two memory lints) are **not** on this page. Each one is configured on its own tab of the **System agents** page, next to that agent's run history — see [system-agents.md](system-agents.md).
- **Enabled** — turn TIC on or off for the whole instance, for everyone. They are still admin-only, and still instance-wide. They simply live where their run log is, because "why did this agent do nothing last night?" is usually answered half by the schedule and half by the log.
- **Security Group** — the tool permission group a TIC run uses. It is re-checked against each user's own role: if their role doesn't allow that group, their run falls back to the role's default. Leave empty to always use the role default.
- **Check Interval (minutes)** — how often a pass over all users starts; leave empty for the value from `config.yml`.
## Compaction ## Compaction
+48 -20
View File
@@ -2,53 +2,81 @@
A **system agent** is an assistant that runs in the background on someone's behalf, without being asked. Nobody starts it and nobody is waiting for its answer: it wakes up on a schedule, looks at something, and gets in touch only if there is a reason to. A **system agent** is an assistant that runs in the background on someone's behalf, without being asked. Nobody starts it and nobody is waiting for its answer: it wakes up on a schedule, looks at something, and gets in touch only if there is a reason to.
Today there is exactly one: **TIC**. There are three:
## What TIC does | Agent | What it watches | How often |
| --- | --- | --- |
| **TIC** | events arriving from that person's connectors | every few minutes |
| **Private memory lint** | that person's own memory notes | weekly |
| **Shared memory lint** | the group's shared memory | weekly |
Connectors (Gmail, a calendar, WhatsApp…) push events into the system as they happen — a new message arrives, a meeting is moved. Those events pile up quietly; nothing interrupts the user. They share three habits worth stating once, because they explain most of what people ask:
- **They only read and report.** None of them changes anything. If something needs doing, they say so and the person decides.
- **An empty run is a correct run.** They are not supposed to find something every time, and they stay quiet when they don't.
- **They run per person, on that person's own things**, with one exception noted below.
## TIC
Connectors (Gmail, a calendar, WhatsApp…) push events into the system as they happen — a new message arrives, a meeting is moved. Those events pile up quietly; nothing interrupts anyone.
Every so often TIC wakes up and reads the batch that accumulated since last time. For each event it decides whether it is worth the interruption, using what it knows about that person from their private memory: who matters to them, what they are working on, what they have said they want to be told about. Events that pass become notifications in their Inbox. Events that don't are simply marked as seen — a newsletter or a group chat with nothing relevant in it produces nothing. Every so often TIC wakes up and reads the batch that accumulated since last time. For each event it decides whether it is worth the interruption, using what it knows about that person from their private memory: who matters to them, what they are working on, what they have said they want to be told about. Events that pass become notifications in their Inbox. Events that don't are simply marked as seen — a newsletter or a group chat with nothing relevant in it produces nothing.
An empty run is a correct run. TIC is not supposed to find something every time. TIC never replies to a message or moves a calendar event. If an event needs an action, it says so in the notification.
TIC only **reads and reports**. It never replies to a message, moves a calendar event, or changes anything — if an event needs an action, it says so in the notification and the user decides. ## The two memory lints
## It runs per person, and only sees one person's things Memory is kept as a small wiki rather than a pile of notes (see [memory.md](memory.md)): notes cross-reference each other, an `index.md` says where things are, and a `log.md` records every change. That works while somebody maintains it — and quietly rots when nobody does. Contradictions stay unresolved, dates go by, notes lose the last line that pointed at them, the same fact ends up written twice in two places that slowly disagree.
This is the part worth being precise about, because people ask. The lints are the scheduled maintenance pass. Once a week they re-read a store and report what has drifted:
TIC runs separately for each user. When it runs for someone, it reads only the events from **that person's own connectors**, consults only **their private memory**, and delivers notifications only to **them**. Two people on the same instance never see each other's events through TIC, and the admin does not see anyone's. - facts whose date has passed — a renewal now due, a plan that already happened
- questions somebody was asked to confirm and never did
- notes nothing links to any more, and index lines pointing at notes that no longer exist
- two notes saying the same thing, especially when they have started to disagree
The same applies to the record of what it did: each run is written into that user's own encrypted database, so the run history on the **System agents** page is personal — every user, admin included, sees their own and nobody else's. **They never fix anything.** They report, and a person decides. This is deliberate: an automated pass reading a store several people built over months is guessing, and a wrong guess destroys something somebody meant. Reading a report costs thirty seconds; a wrong edit can lose a fact nobody notices is gone until they need it.
There are two of them because the two stores are not the same job.
**Private memory lint** runs for each person over their own notes, and reports to them alone.
**Shared memory lint** runs once over the group's shared store, and looks for one extra thing that only exists there: **a note that fails the table rule** — one person's private business written somewhere every member can read. The rule is that something belongs in shared memory only if you would say it out loud with every member in the room; health, school results, money, worries and one member's opinion of another do not. When it finds one it says *which note* and *what kind of problem*, without repeating the sensitive content — restating it in a notification would spread it further, which is exactly the harm being flagged.
The shared store belongs to nobody in particular, so that pass runs **as the admin** and its report goes to them. That is about who can act on it, not about privacy: everything in shared memory is already readable by every member.
## Why a run can be missing ## Why a run can be missing
Users are handled one at a time, and a user is **skipped** if they have not logged in since the server last restarted. Users are handled one at a time, and a user is **skipped** if they have not logged in since the server last restarted.
This is not a fault, it is how the encryption works: a person's data is unreadable until they log in and their password unlocks it. Until that happens there is nothing for TIC to read and nowhere for it to write. Their events are not lost — they keep accumulating, and the first run after they log in picks up everything waiting. This is not a fault, it is how the encryption works: a person's data is unreadable until they log in and their password unlocks it. Until that happens there is nothing to read and nowhere to write. Nothing is lost — events keep accumulating, and the first run after they log in picks up everything waiting.
So if someone asks "why didn't it tell me about that email from this morning?", the first thing to check is whether they had logged in at the time. So if someone asks "why didn't it tell me about that email from this morning?", the first thing to check is whether they had logged in at the time. The same applies to the shared memory lint: it needs an admin who has logged in since the restart.
Schedules are counted **per person from their own last run**, and they survive a restart — so a weekly pass stays weekly even on a machine that gets rebooted every few days.
## The System agents page ## The System agents page
Sidebar → **System agents**. One row per run, newest first: Sidebar → **System agents**. There is one tab per agent, plus **All**. A tab holds that agent's description, its settings (admin only), and its run history — because "why did this do nothing last night?" is usually half a settings question and half a log question.
Each row is one run, newest first:
- **Agent** — which system agent ran (`tic`).
- **Started** and **Duration**. - **Started** and **Duration**.
- **Status** — completed, failed, or still running. - **Status** — completed, failed, or still running.
- **Result** — how many events it looked at and how many notifications it produced, or the error if it failed. - **Result** — the agent's own counters (events looked at, notes read, notifications sent), or the error if it failed.
Clicking a row opens the conversation the run happened in, for anyone who wants to see the reasoning. Clicking a row opens the conversation the run happened in, for anyone who wants to see the reasoning.
A run appears **only when there were events to look at**. Long gaps between rows mean quiet connectors, not a broken agent — if there is nothing new, TIC does no work and records nothing. A run appears **only when there was something to look at**. Long gaps mean quiet connectors or an untouched memory store, not a broken agent.
**The run history is personal.** Each run is written into that user's own encrypted database, so every user — the admin included — sees their own runs and nobody else's. There is no instance-wide view.
## What the admin can change ## What the admin can change
On the admin's Config page (see [settings.md](settings.md)), under **TIC Agent**: Each agent's tab carries the same three settings, visible only to an admin:
- **Enabled** — turns TIC on or off for the whole instance, for everyone. - **Enabled** — turns that agent on or off for the whole instance, for everyone.
- **Check Interval (minutes)** — how often a pass over all users starts. - **Interval** — how long between passes for each person. TIC is in minutes, the lints in days.
- **Security Group** — which tools TIC may use during a run. This is re-checked against each user's own role: if their role does not allow that group, their run uses their role's default group instead. Nobody's background agent gets more access than their role would give them. - **Security group** — which tools the agent may use during a run. It is re-checked against each user's own role: if their role does not allow that group, their run uses their role's default group instead. Nobody's background agent gets more access than their role would give them.
There is no per-user on/off switch: if TIC is enabled, it runs for everyone who has logged in. There is no per-user on/off switch: if an agent is enabled, it runs for everyone who has logged in.
+27 -1
View File
@@ -1,10 +1,36 @@
//! Shared role-capability gate for API handlers. //! Shared role-capability gate for API handlers.
use skald_core::db::role_capabilities; use skald_core::db::{role_capabilities, roles::ADMIN_ROLE_ID, users};
use skald_core::skald::Skald; use skald_core::skald::Skald;
use super::ApiError; use super::ApiError;
/// Fails with 403 unless the caller is an admin.
///
/// For instance-wide settings, which are admin-by-construction rather than
/// gated on a named capability: there is no meaningful role that should be able
/// to change the interface language or a background agent's schedule for
/// everybody without also being an admin.
///
/// Needed because the sidebar hiding a page is **not** access control — the
/// endpoints behind Config were reachable by any authenticated session.
pub async fn require_admin(skald: &Skald, user_id: &str) -> Result<(), ApiError> {
if is_admin(skald, user_id).await? {
Ok(())
} else {
Err(ApiError::forbidden("this setting is admin-only"))
}
}
/// Whether the caller is an admin. For handlers that serve everyone but reveal
/// more to an admin, rather than refusing outright.
pub async fn is_admin(skald: &Skald, user_id: &str) -> Result<bool, ApiError> {
let user = users::get(skald.db(), user_id)
.await?
.ok_or_else(|| ApiError::unauthorized("unknown user"))?;
Ok(user.role_id == ADMIN_ROLE_ID)
}
/// Fails with 403 unless the caller's role holds `cap` (admin holds everything). /// Fails with 403 unless the caller's role holds `cap` (admin holds everything).
pub async fn require_cap(skald: &Skald, user_id: &str, cap: &str) -> Result<(), ApiError> { pub async fn require_cap(skald: &Skald, user_id: &str, cap: &str) -> Result<(), ApiError> {
let user = skald_core::db::users::get(skald.db(), user_id).await? let user = skald_core::db::users::get(skald.db(), user_id).await?
+56 -10
View File
@@ -1,17 +1,32 @@
//! The instance's settings.
//!
//! **Admin-only, and enforced here.** Every key on this surface is instance-wide
//! — the interface language, which model summarises history, how often a
//! background agent runs for everybody — so there is no reading of it that makes
//! sense for a member. The sidebar has always hidden the page from non-admins,
//! which is presentation, not authorization: until these handlers took the
//! caller into account at all, any authenticated session could read *and write*
//! them.
//!
//! A set carrying a [`ConfigSet::owner`] is **not** served here: it belongs to
//! the page that owns it (see [`render_sets`], reused by that page so the two
//! render identically).
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
Json, Extension, Json,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use core_api::PropertyType; use core_api::{ConfigSet, PropertyType};
use skald_core::skald::Skald; use skald_core::skald::Skald;
use super::ApiError; use super::guard::AuthUser;
use super::{ApiError, caps};
// ── Response types ───────────────────────────────────────────────────────────── // ── Response types ─────────────────────────────────────────────────────────────
@@ -38,7 +53,7 @@ struct PropertyView {
} }
#[derive(Serialize)] #[derive(Serialize)]
struct ConfigSetView { pub struct ConfigSetView {
name: String, name: String,
description: String, description: String,
properties: Vec<PropertyView>, properties: Vec<PropertyView>,
@@ -47,8 +62,31 @@ struct ConfigSetView {
// ── GET /api/config ──────────────────────────────────────────────────────────── // ── GET /api/config ────────────────────────────────────────────────────────────
pub async fn list_properties( pub async fn list_properties(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
caps::require_admin(&skald, &auth.user_id).await?;
// Owned sets are edited on the surface that owns them, not here.
let sets: Vec<&ConfigSet> = skald
.config_properties()
.iter()
.filter(|s| s.owner.is_none())
.collect();
Ok(Json(json!({ "sets": render_sets(&skald, &sets).await? })))
}
/// Resolve every property in `sets` to its current value plus, for the dropdown
/// types, the choices the backend owns.
///
/// Shared with the System agents page so an owned set renders exactly like one
/// on the Config page — same types, same options, same defaults. The caller is
/// responsible for authorization: this function assumes it has already happened.
pub async fn render_sets(
skald: &Skald,
sets: &[&ConfigSet],
) -> Result<Vec<ConfigSetView>, ApiError> {
// Option sources for the dropdown-style property types. Each custom // Option sources for the dropdown-style property types. Each custom
// `PropertyType` that renders as a `<select>` computes its choices here and // `PropertyType` that renders as a `<select>` computes its choices here and
// ships them in `options`. To add a new one: build its `Vec<SelectOption>` // ships them in `options`. To add a new one: build its `Vec<SelectOption>`
@@ -75,8 +113,8 @@ pub async fn list_properties(
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut sets = Vec::with_capacity(skald.config_properties().len()); let mut views = Vec::with_capacity(sets.len());
for set in skald.config_properties() { for set in sets {
let mut props = Vec::with_capacity(set.properties.len()); let mut props = Vec::with_capacity(set.properties.len());
for prop in &set.properties { for prop in &set.properties {
let value = skald.config().get(&prop.key).await?; let value = skald.config().get(&prop.key).await?;
@@ -99,14 +137,14 @@ pub async fn list_properties(
options, options,
}); });
} }
sets.push(ConfigSetView { views.push(ConfigSetView {
name: set.name.clone(), name: set.name.clone(),
description: set.description.clone(), description: set.description.clone(),
properties: props, properties: props,
}); });
} }
Ok(Json(json!({ "sets": sets }))) Ok(views)
} }
// ── PUT /api/config/:key ──────────────────────────────────────────────────────── // ── PUT /api/config/:key ────────────────────────────────────────────────────────
@@ -121,11 +159,19 @@ pub struct KeyPath {
pub key: String, pub key: String,
} }
/// `PUT /api/config/{key}` — write one instance-wide setting.
///
/// The single write path for **every** config property, owned sets included: the
/// System agents page edits its tabs through this endpoint rather than one of
/// its own, so the admin gate and the known-key check exist in one place.
pub async fn set_property( pub async fn set_property(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<KeyPath>, Path(p): Path<KeyPath>,
Json(body): Json<SetPropertyBody>, Json(body): Json<SetPropertyBody>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
caps::require_admin(&skald, &auth.user_id).await?;
// Only allow keys that are registered as config properties. // Only allow keys that are registered as config properties.
let known = skald.config_properties().iter() let known = skald.config_properties().iter()
.flat_map(|s| &s.properties) .flat_map(|s| &s.properties)
+3 -1
View File
@@ -53,7 +53,9 @@ pub fn router() -> Router<Arc<Skald>> {
// Custom slash commands (file-based, read-only listing for autocomplete + /help) // Custom slash commands (file-based, read-only listing for autocomplete + /help)
.route("/commands", get(commands::list)) .route("/commands", get(commands::list))
.route("/sessions", get(sessions::list_sessions).post(sessions::create)) .route("/sessions", get(sessions::list_sessions).post(sessions::create))
// System agents (TIC) — the caller's own run history // System agents (TIC, memory lints) — the caller's own run history, plus
// the agent list (settings included only for an admin).
.route("/system-agents", get(system_agents::list_agents))
.route("/system-agents/runs", get(system_agents::list_runs)) .route("/system-agents/runs", get(system_agents::list_runs))
// First-run setup // First-run setup
.route("/setup/status", get(setup::status)) .route("/setup/status", get(setup::status))
+71 -8
View File
@@ -1,12 +1,22 @@
//! System agents — the background agents the instance runs on a user's behalf //! System agents — the background agents the instance runs on a user's behalf
//! (blueprint §13). Today that is TIC; the surface is written for more. //! (blueprint §13): TIC and the two memory lints.
//! //!
//! **Scoped to the caller, with no admin override.** A run summarises what //! **This page has two audiences, and the split is the whole design.**
//! arrived in someone's inbox, so it is stored in their own encrypted database //!
//! and read back through `require_context`, exactly like their sessions. There //! The run history is *the caller's own*, with no admin override: a run
//! is deliberately no "all users" view: the admin sees their own runs and //! summarises what arrived in someone's inbox, so it is stored in their own
//! nobody else's, which is the same promise the rest of the private pool makes //! encrypted database and read back through `require_context`, exactly like
//! (§2/§3). //! their sessions. There is deliberately no "all users" view — the admin sees
//! their own runs and nobody else's, the same promise the rest of the private
//! pool makes (§2/§3). That is why the page is visible to everyone.
//!
//! The *settings* are instance-wide and therefore admin-only. They live here
//! rather than on the Config page because an agent's schedule and its run log
//! answer the same question — "why did this not do anything last night?" — and
//! splitting them across two pages made the answer require both. [`list_agents`]
//! serves the config half only to an admin; a member gets the descriptions and
//! nothing else, and the write path is `PUT /api/config/{key}`, which gates
//! again on its own.
use std::sync::Arc; use std::sync::Arc;
@@ -21,7 +31,60 @@ use skald_core::db::system_agent_runs;
use skald_core::skald::Skald; use skald_core::skald::Skald;
use super::guard::AuthUser; use super::guard::AuthUser;
use super::{ApiError, require_context}; use super::{ApiError, caps, config, require_context};
/// `GET /api/system-agents` — the agents this instance runs, in pass order.
///
/// The list *is* the set of owned config sets: every system agent has one by
/// construction (`SystemAgent::config_set`), so there is no second registry to
/// keep in step with the scheduler.
pub async fn list_agents(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Value>, ApiError> {
let admin = caps::is_admin(&skald, &auth.user_id).await?;
let owned: Vec<&core_api::ConfigSet> = skald
.config_properties()
.iter()
.filter(|s| s.owner.is_some())
.collect();
// Values and options are resolved only for an admin. A member gets no
// settings at all rather than read-only ones: there is nothing on this page
// they could do with them, and shipping them would leak the instance's
// configuration to every session for the sake of a disabled form.
let items: Vec<Value> = if admin {
// `render_sets` preserves order, so zipping is safe.
let rendered = config::render_sets(&skald, &owned).await?;
owned
.iter()
.zip(rendered)
.map(|(set, view)| {
json!({
"id": set.owner,
"name": set.name,
"description": set.description,
"config": view,
})
})
.collect()
} else {
owned
.iter()
.map(|set| {
json!({
"id": set.owner,
"name": set.name,
"description": set.description,
"config": Value::Null,
})
})
.collect()
};
Ok(Json(json!({ "items": items, "can_configure": admin })))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ListRunsQuery { pub struct ListRunsQuery {
+19 -165
View File
@@ -1,32 +1,23 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js'; import { t } from '../lib/i18n.js';
import { ConfigFormController, maybeT, propKeyId } from './shared/config-form.js';
function _maybeT(key, fallback) { // Sets whose labels this page ships translations for. A set with no slug falls
const v = t(key); // back to the backend's own English text, which is also what happens to a newly
return v !== key ? v : fallback; // added one until it is translated.
}
function _configSetSlug(name) { function _configSetSlug(name) {
const slugs = { const slugs = {
'Interface': 'interface', 'Interface': 'interface',
'TIC Agent': 'tic_agent',
'Compaction': 'compaction', 'Compaction': 'compaction',
}; };
return slugs[name] ?? null; return slugs[name] ?? null;
} }
function _propKeyId(propKey) {
return propKey.replace(/\./g, '__');
}
export class ConfigPage extends LightElement { export class ConfigPage extends LightElement {
static properties = { static properties = {
_open: { state: true }, _open: { state: true },
_properties: { state: true }, _properties: { state: true },
_values: { state: true }, // { [key]: string }
_saving: { state: true }, // Set<key>
_saved: { state: true }, // Set<key> (brief flash)
_error: { state: true }, _error: { state: true },
_debugMode: { state: true }, _debugMode: { state: true },
_debugLoading: { state: true }, _debugLoading: { state: true },
@@ -36,12 +27,12 @@ export class ConfigPage extends LightElement {
super(); super();
this._open = false; this._open = false;
this._properties = []; this._properties = [];
this._values = {};
this._saving = new Set();
this._saved = new Set();
this._error = null; this._error = null;
this._debugMode = false; this._debugMode = false;
this._debugLoading = true; this._debugLoading = true;
// Values, in-flight saves and the saved-flash live in the shared controller,
// which also owns the write path (see `shared/config-form.js`).
this._form = new ConfigFormController(() => this.requestUpdate());
} }
connectedCallback() { connectedCallback() {
@@ -96,166 +87,29 @@ export class ConfigPage extends LightElement {
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
const data = await res.json(); const data = await res.json();
this._properties = data.sets ?? []; this._properties = data.sets ?? [];
const vals = {}; this._form.seedFromSets(this._properties);
for (const s of this._properties)
for (const p of s.properties) vals[p.key] = p.value ?? '';
this._values = vals;
} catch (e) { } catch (e) {
this._error = e.message; this._error = e.message;
} }
} }
_setValue(key, val) {
this._values = { ...this._values, [key]: val };
}
async _save(prop) {
const key = prop.key;
const value = this._values[key] ?? '';
this._saving = new Set([...this._saving, key]);
this.requestUpdate();
try {
const res = await fetch(`/api/config/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
});
if (!res.ok) throw new Error(await res.text());
this._saved = new Set([...this._saved, key]);
setTimeout(() => {
this._saved = new Set([...this._saved].filter(k => k !== key));
}, 1500);
} catch (e) {
alert(t('config.error_save', { name: prop.name, msg: e.message }));
} finally {
this._saving = new Set([...this._saving].filter(k => k !== key));
}
}
_renderInput(prop) {
const val = this._values[prop.key] ?? '';
if (prop.property_type === 'bool') {
const effective = val !== '' ? val : (prop.default_value ?? 'true');
const checked = effective !== 'false';
return html`
<div class="form-check form-switch config-bool-switch">
<input class="form-check-input" type="checkbox" role="switch"
id="cfg-${prop.key}"
.checked=${checked}
@change=${e => { this._setValue(prop.key, e.target.checked ? 'true' : 'false'); this._save(prop); }} />
<label class="form-check-label" for="cfg-${prop.key}">
${checked ? t('config.enabled') : t('config.disabled')}
</label>
</div>`;
}
if (prop.property_type === 'int') {
return html`
<input type="number" step="1" min="1"
class="form-control form-control-sm config-input"
.value=${val}
placeholder=${prop.default_value ?? ''}
@input=${e => this._setValue(prop.key, e.target.value)} />`;
}
// Dropdown-style property types. The backend ships the allowed values in
// `prop.options` (a list of {id, name}); we only decide how to frame them.
// Adding a new custom type from a config section? Give it a `property_type`
// on the backend, attach its `options`, and add a branch like these — a
// free-text box becomes a proper picker for the price of a few lines.
if (prop.property_type === 'security_group') {
// Nullable: the empty choice means "fall back to the instance default".
const groups = prop.options ?? [];
return html`
<select class="form-select form-select-sm config-input"
.value=${val}
@change=${e => this._setValue(prop.key, e.target.value)}>
<option value="">— default —</option>
${groups.map(g => html`
<option value=${g.id} ?selected=${val === g.id}>${g.name}</option>`)}
</select>`;
}
if (prop.property_type === 'locale') {
// Interface languages the instance supports; labels are native endonyms.
// Always a concrete pick (no empty option) — falls back to default_value.
const locales = prop.options ?? [];
const current = val || prop.default_value || 'en';
return html`
<select class="form-select form-select-sm config-input"
.value=${current}
@change=${e => { this._setValue(prop.key, e.target.value); this._save(prop); }}>
${locales.map(l => html`
<option value=${l.id} ?selected=${current === l.id}>${l.name}</option>`)}
</select>`;
}
if (prop.property_type === 'llm_model') {
// Configured LLM models, by name. Nullable: the empty choice means
// "auto-select" (the backend's own resolution order applies).
const models = prop.options ?? [];
return html`
<select class="form-select form-select-sm config-input"
.value=${val}
@change=${e => this._setValue(prop.key, e.target.value)}>
<option value="">— ${t('config.llm_model.auto')} —</option>
${models.map(m => html`
<option value=${m.id} ?selected=${val === m.id}>${m.name}</option>`)}
</select>`;
}
return html`
<input type="text"
class="form-control form-control-sm config-input"
.value=${val}
placeholder=${prop.default_value ?? ''}
@input=${e => this._setValue(prop.key, e.target.value)} />`;
}
_renderSet(set) { _renderSet(set) {
const slug = _configSetSlug(set.name); const slug = _configSetSlug(set.name);
const sName = slug ? _maybeT(`config.set.${slug}.name`, set.name) : set.name; const sName = slug ? maybeT(`config.set.${slug}.name`, set.name) : set.name;
const sDesc = slug ? _maybeT(`config.set.${slug}.desc`, set.description) : set.description; const sDesc = slug ? maybeT(`config.set.${slug}.desc`, set.description) : set.description;
return html` return html`
<div class="config-set"> <div class="config-set">
<div class="config-set-header"> <div class="config-set-header">
<div class="config-set-name">${sName}</div> <div class="config-set-name">${sName}</div>
<div class="config-set-desc">${sDesc}</div> <div class="config-set-desc">${sDesc}</div>
</div> </div>
<div class="config-rows"> ${this._form.renderRows(set.properties, p => {
${set.properties.map(p => this._renderRow(p))} const pk = propKeyId(p.key);
</div> return {
</div>`; name: maybeT(`config.prop.${pk}.name`, p.name),
} description: maybeT(`config.prop.${pk}.desc`, p.description),
};
_renderRow(prop) { })}
const saving = this._saving.has(prop.key);
const saved = this._saved.has(prop.key);
const pk = _propKeyId(prop.key);
const pName = _maybeT(`config.prop.${pk}.name`, prop.name);
const pDesc = _maybeT(`config.prop.${pk}.desc`, prop.description);
return html`
<div class="config-row">
<div class="config-row-meta">
<div class="config-row-name">${pName}</div>
<div class="config-row-desc">${pDesc}</div>
</div>
<div class="config-row-control">
${this._renderInput(prop)}
${!['bool', 'locale'].includes(prop.property_type) ? html`
<button class="btn btn-sm ${saved ? 'btn-success' : 'btn-primary'} config-save-btn"
?disabled=${saving}
@click=${() => this._save(prop)}>
${saving
? html`<span class="spinner-border spinner-border-sm"></span>`
: saved ? t('common.saved') : t('common.save')}
</button>` : nothing}
</div>
</div>`; </div>`;
} }
+194
View File
@@ -0,0 +1,194 @@
import { html, nothing } from 'lit';
import { t } from '../../lib/i18n.js';
/**
* The schema-driven settings form, shared by the Config page and the System
* agents page.
*
* A config property renders the same way wherever it is edited — the backend
* ships its type, its current value and, for the dropdown types, the choices it
* owns (`PropertyType` in `core-api`), and this decides how to frame them. Both
* pages write through the same `PUT /api/config/{key}`, so "where is this
* setting shown" stays a pure placement question with no second form to keep in
* step.
*
* Adding a property type is still the three-step recipe in `config_property.rs`:
* variant, backend mapping + options, and a branch in `_renderInput` here.
*/
export class ConfigFormController {
/** @param requestUpdate host callback, invoked whenever state changes. */
constructor(requestUpdate) {
this._requestUpdate = requestUpdate;
this._values = {};
this._saving = new Set();
this._saved = new Set();
}
/** Seed the editable values from freshly fetched sets. */
seedFromSets(sets) {
const vals = {};
for (const s of sets ?? [])
for (const p of s?.properties ?? []) vals[p.key] = p.value ?? '';
this._values = vals;
this._requestUpdate();
}
_setValue(key, val) {
this._values = { ...this._values, [key]: val };
this._requestUpdate();
}
async _save(prop) {
const key = prop.key;
const value = this._values[key] ?? '';
this._saving = new Set([...this._saving, key]);
this._requestUpdate();
try {
const res = await fetch(`/api/config/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
});
if (!res.ok) throw new Error(await res.text());
this._saved = new Set([...this._saved, key]);
this._requestUpdate();
setTimeout(() => {
this._saved = new Set([...this._saved].filter(k => k !== key));
this._requestUpdate();
}, 1500);
} catch (e) {
alert(t('config.error_save', { name: prop.name, msg: e.message }));
} finally {
this._saving = new Set([...this._saving].filter(k => k !== key));
this._requestUpdate();
}
}
_renderInput(prop) {
const val = this._values[prop.key] ?? '';
if (prop.property_type === 'bool') {
const effective = val !== '' ? val : (prop.default_value ?? 'true');
const checked = effective !== 'false';
return html`
<div class="form-check form-switch config-bool-switch">
<input class="form-check-input" type="checkbox" role="switch"
id="cfg-${prop.key}"
.checked=${checked}
@change=${e => { this._setValue(prop.key, e.target.checked ? 'true' : 'false'); this._save(prop); }} />
<label class="form-check-label" for="cfg-${prop.key}">
${checked ? t('config.enabled') : t('config.disabled')}
</label>
</div>`;
}
if (prop.property_type === 'int') {
return html`
<input type="number" step="1" min="1"
class="form-control form-control-sm config-input"
.value=${val}
placeholder=${prop.default_value ?? ''}
@input=${e => this._setValue(prop.key, e.target.value)} />`;
}
// Dropdown-style property types. The backend ships the allowed values in
// `prop.options` (a list of {id, name}); we only decide how to frame them.
if (prop.property_type === 'security_group') {
// Nullable: the empty choice means "fall back to the role default".
const groups = prop.options ?? [];
return html`
<select class="form-select form-select-sm config-input"
.value=${val}
@change=${e => this._setValue(prop.key, e.target.value)}>
<option value="">— default —</option>
${groups.map(g => html`
<option value=${g.id} ?selected=${val === g.id}>${g.name}</option>`)}
</select>`;
}
if (prop.property_type === 'locale') {
// Interface languages the instance supports; labels are native endonyms.
// Always a concrete pick (no empty option) — falls back to default_value.
const locales = prop.options ?? [];
const current = val || prop.default_value || 'en';
return html`
<select class="form-select form-select-sm config-input"
.value=${current}
@change=${e => { this._setValue(prop.key, e.target.value); this._save(prop); }}>
${locales.map(l => html`
<option value=${l.id} ?selected=${current === l.id}>${l.name}</option>`)}
</select>`;
}
if (prop.property_type === 'llm_model') {
// Configured LLM models, by name. Nullable: the empty choice means
// "auto-select" (the backend's own resolution order applies).
const models = prop.options ?? [];
return html`
<select class="form-select form-select-sm config-input"
.value=${val}
@change=${e => this._setValue(prop.key, e.target.value)}>
<option value="">— ${t('config.llm_model.auto')} —</option>
${models.map(m => html`
<option value=${m.id} ?selected=${val === m.id}>${m.name}</option>`)}
</select>`;
}
return html`
<input type="text"
class="form-control form-control-sm config-input"
.value=${val}
placeholder=${prop.default_value ?? ''}
@input=${e => this._setValue(prop.key, e.target.value)} />`;
}
/**
* One row per property. `labelFor(prop)` lets the host supply translated
* name/description; it returns `{ name, description }`.
*/
renderRows(properties, labelFor = p => p) {
return html`
<div class="config-rows">
${(properties ?? []).map(prop => {
const saving = this._saving.has(prop.key);
const saved = this._saved.has(prop.key);
const label = labelFor(prop);
// A switch and a language picker save on change; everything else needs
// an explicit commit, or every keystroke would be a write.
const needsButton = !['bool', 'locale'].includes(prop.property_type);
return html`
<div class="config-row">
<div class="config-row-meta">
<div class="config-row-name">${label.name}</div>
<div class="config-row-desc">${label.description}</div>
</div>
<div class="config-row-control">
${this._renderInput(prop)}
${needsButton ? html`
<button class="btn btn-sm ${saved ? 'btn-success' : 'btn-primary'} config-save-btn"
?disabled=${saving}
@click=${() => this._save(prop)}>
${saving
? html`<span class="spinner-border spinner-border-sm"></span>`
: saved ? t('common.saved') : t('common.save')}
</button>` : nothing}
</div>
</div>`;
})}
</div>`;
}
}
/** `t(key)` when a translation exists, otherwise the server-supplied text. */
export function maybeT(key, fallback) {
const v = t(key);
return v !== key ? v : fallback;
}
/** Config keys are dotted; i18n keys are not. */
export function propKeyId(propKey) {
return propKey.replace(/\./g, '__');
}
+120 -114
View File
@@ -1,10 +1,14 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js'; import { t } from '../lib/i18n.js';
import { ConfigFormController, maybeT, propKeyId } from './shared/config-form.js';
const PAGE_ID = 'system-agents'; const PAGE_ID = 'system-agents';
const PER_PAGE = 20; const PER_PAGE = 20;
/** The overview tab: every agent's runs, interleaved. */
const ALL_TAB = '__all__';
function formatDate(iso) { function formatDate(iso) {
if (!iso) return '—'; if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, { return new Date(iso).toLocaleString(undefined, {
@@ -29,9 +33,27 @@ const STATUS_ICON = {
cancelled: 'bi-slash-circle', cancelled: 'bi-slash-circle',
}; };
/**
* The background agents the instance runs, one tab per agent.
*
* **The tab is the agent, not the kind of information.** A tab holds an agent's
* settings *and* its run history, because the question people actually arrive
* with — "why did this do nothing last night?" — is answered half by the
* schedule and half by the log. Splitting them into a "runs" tab and a
* "settings" tab would put the two halves of every answer on opposite sides of
* the page.
*
* **Two audiences on one page.** The run history is the caller's own and is
* shown to everyone; the settings are instance-wide and shown only to an admin
* (`can_configure`). Hiding the form is presentation only — the backend gates
* both the listing and `PUT /api/config/{key}`.
*/
export class SystemAgentsPage extends LightElement { export class SystemAgentsPage extends LightElement {
static properties = { static properties = {
_open: { state: true }, _open: { state: true },
_agents: { state: true },
_canCfg: { state: true },
_tab: { state: true },
_items: { state: true }, _items: { state: true },
_total: { state: true }, _total: { state: true },
_page: { state: true }, _page: { state: true },
@@ -42,11 +64,15 @@ export class SystemAgentsPage extends LightElement {
constructor() { constructor() {
super(); super();
this._open = false; this._open = false;
this._agents = [];
this._canCfg = false;
this._tab = ALL_TAB;
this._items = []; this._items = [];
this._total = 0; this._total = 0;
this._page = 1; this._page = 1;
this._loading = false; this._loading = false;
this._error = null; this._error = null;
this._form = new ConfigFormController(() => this.requestUpdate());
} }
connectedCallback() { connectedCallback() {
@@ -56,7 +82,7 @@ export class SystemAgentsPage extends LightElement {
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === PAGE_ID; this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none'; this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._fetch(this._page); if (this._open) this._loadAll();
}); });
} }
@@ -65,12 +91,35 @@ export class SystemAgentsPage extends LightElement {
super.disconnectedCallback(); super.disconnectedCallback();
} }
async _loadAll() {
await this._fetchAgents();
await this._fetch(this._page);
}
async _fetchAgents() {
try {
const res = await fetch('/api/system-agents');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this._agents = data.items ?? [];
this._canCfg = !!data.can_configure;
// A member gets no `config` at all, so there is nothing to seed.
this._form.seedFromSets(this._agents.map(a => a.config).filter(Boolean));
} catch (e) {
// Non-fatal: without the agent list the page still shows the run log,
// which is the half everyone can see.
this._agents = [];
this._canCfg = false;
}
}
async _fetch(page) { async _fetch(page) {
this._loading = true; this._loading = true;
this._error = null; this._error = null;
try { try {
const params = new URLSearchParams({ page, per_page: PER_PAGE }); const params = new URLSearchParams({ page, per_page: PER_PAGE });
const res = await fetch(`/api/system-agents/runs?${params}`); if (this._tab !== ALL_TAB) params.set('agent_id', this._tab);
const res = await fetch(`/api/system-agents/runs?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json(); const data = await res.json();
this._items = data.items; this._items = data.items;
@@ -83,14 +132,33 @@ export class SystemAgentsPage extends LightElement {
} }
} }
_selectTab(id) {
if (this._tab === id) return;
this._tab = id;
this._page = 1;
this._fetch(1);
}
_openSession(id) { _openSession(id) {
if (id != null) window.location.hash = `session/${id}`; if (id != null) window.location.hash = `session/${id}`;
} }
get _totalPages() { return Math.max(1, Math.ceil(this._total / PER_PAGE)); } get _totalPages() { return Math.max(1, Math.ceil(this._total / PER_PAGE)); }
/// The agent's own counters. Rendered generically so a second system agent get _currentAgent() {
/// needs no change here: unknown keys fall back to the raw key name. return this._agents.find(a => a.id === this._tab) ?? null;
}
/** Server-supplied English unless the instance ships a translation. */
_agentLabel(agent) {
return {
name: maybeT(`system_agents.agent.${agent.id}.name`, agent.name),
description: maybeT(`system_agents.agent.${agent.id}.desc`, agent.description),
};
}
/// The agent's own counters. Rendered generically so a new system agent needs
/// no change here: unknown keys fall back to the raw key name.
_renderStats(stats) { _renderStats(stats) {
if (!stats || typeof stats !== 'object') return '—'; if (!stats || typeof stats !== 'object') return '—';
const parts = Object.entries(stats) const parts = Object.entries(stats)
@@ -102,6 +170,46 @@ export class SystemAgentsPage extends LightElement {
return parts.length ? parts.join(' · ') : '—'; return parts.length ? parts.join(' · ') : '—';
} }
_renderTabs() {
if (this._agents.length === 0) return nothing;
const tab = (id, label, icon) => html`
<button class="sa-tab ${this._tab === id ? 'sa-tab--active' : ''}"
@click=${() => this._selectTab(id)}>
${icon ? html`<i class="bi ${icon}"></i>` : nothing}${label}
</button>`;
return html`
<div class="sa-tab-bar">
${tab(ALL_TAB, t('system_agents.tab.all'), 'bi-collection')}
${this._agents.map(a => tab(a.id, this._agentLabel(a).name, null))}
</div>`;
}
/** The selected agent's description, plus its settings when the caller is an admin. */
_renderAgentPanel() {
const agent = this._currentAgent;
if (!agent) return nothing;
const label = this._agentLabel(agent);
return html`
<div class="sa-agent-panel">
<p class="sa-agent-desc">${label.description}</p>
${this._canCfg && agent.config ? html`
<div class="config-set sa-agent-config">
<div class="config-set-header">
<div class="config-set-name">${t('system_agents.settings')}</div>
</div>
${this._form.renderRows(agent.config.properties, p => {
const pk = propKeyId(p.key);
return {
name: maybeT(`config.prop.${pk}.name`, p.name),
description: maybeT(`config.prop.${pk}.desc`, p.description),
};
})}
</div>` : nothing}
</div>`;
}
_renderTable() { _renderTable() {
if (this._loading) return html` if (this._loading) return html`
<div class="sa-state"> <div class="sa-state">
@@ -123,12 +231,15 @@ export class SystemAgentsPage extends LightElement {
</div> </div>
`; `;
// The agent column is redundant once a single agent's tab is selected.
const showAgent = this._tab === ALL_TAB;
return html` return html`
<div class="sa-table-wrap"> <div class="sa-table-wrap">
<table class="table table-sm sa-table"> <table class="table table-sm sa-table">
<thead> <thead>
<tr> <tr>
<th>${t('system_agents.table.agent')}</th> ${showAgent ? html`<th>${t('system_agents.table.agent')}</th>` : nothing}
<th>${t('system_agents.table.started')}</th> <th>${t('system_agents.table.started')}</th>
<th>${t('system_agents.table.status')}</th> <th>${t('system_agents.table.status')}</th>
<th class="text-end">${t('system_agents.table.duration')}</th> <th class="text-end">${t('system_agents.table.duration')}</th>
@@ -139,7 +250,7 @@ export class SystemAgentsPage extends LightElement {
${this._items.map(r => html` ${this._items.map(r => html`
<tr class=${r.session_id != null ? 'sa-row--clickable' : ''} <tr class=${r.session_id != null ? 'sa-row--clickable' : ''}
@click=${() => this._openSession(r.session_id)}> @click=${() => this._openSession(r.session_id)}>
<td><span class="sa-agent">${r.agent_id}</span></td> ${showAgent ? html`<td><span class="sa-agent">${r.agent_id}</span></td>` : nothing}
<td class="sa-date">${formatDate(r.started_at)}</td> <td class="sa-date">${formatDate(r.started_at)}</td>
<td> <td>
<span class="sa-status sa-status--${r.status}"> <span class="sa-status sa-status--${r.status}">
@@ -182,124 +293,19 @@ export class SystemAgentsPage extends LightElement {
render() { render() {
return html` return html`
<style>
.sa-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
padding: 1.5rem;
overflow-y: auto;
}
.sa-header {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.35rem;
}
.sa-title {
font-size: 1.2rem;
font-weight: 600;
margin: 0;
}
.sa-total-badge {
font-size: 0.75rem;
color: var(--bs-secondary-color);
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 1rem;
padding: 0.1rem 0.6rem;
}
.sa-refresh-btn { margin-left: auto; }
.sa-subtitle {
font-size: 0.85rem;
color: var(--bs-secondary-color);
margin-bottom: 1.25rem;
max-width: 65ch;
}
.sa-table-wrap {
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
overflow-x: auto;
}
.sa-table { margin-bottom: 0; }
.sa-row--clickable { cursor: pointer; }
.sa-row--clickable:hover td { background: var(--bs-tertiary-bg); }
.sa-agent {
font-family: monospace;
font-size: 0.82rem;
}
.sa-date {
font-size: 0.82rem;
color: var(--bs-secondary-color);
white-space: nowrap;
}
.sa-num {
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
white-space: nowrap;
}
.sa-status {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
white-space: nowrap;
}
.sa-status--completed { color: var(--bs-success); }
.sa-status--failed { color: var(--bs-danger); }
.sa-status--running { color: var(--bs-secondary-color); }
.sa-status--cancelled { color: var(--bs-secondary-color); }
.sa-result {
font-size: 0.82rem;
color: var(--bs-secondary-color);
}
.sa-error {
color: var(--bs-danger);
display: inline-block;
max-width: 40ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.sa-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 3rem;
justify-content: center;
color: var(--bs-secondary-color);
font-size: 0.9rem;
}
.sa-state--empty i { font-size: 1.6rem; opacity: 0.6; }
.sa-state--error { color: var(--bs-danger); flex-direction: row; }
.sa-pagination {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
justify-content: center;
}
.sa-page-info {
font-size: 0.82rem;
color: var(--bs-secondary-color);
}
</style>
<div class="sa-page"> <div class="sa-page">
<div class="sa-header"> <div class="sa-header">
<h2 class="sa-title"><i class="bi bi-robot"></i> ${t('system_agents.title')}</h2> <h2 class="sa-title"><i class="bi bi-robot"></i> ${t('system_agents.title')}</h2>
<span class="sa-total-badge">${t('system_agents.total', { n: this._total })}</span> <span class="sa-total-badge">${t('system_agents.total', { n: this._total })}</span>
<button class="btn btn-sm btn-outline-secondary sa-refresh-btn" <button class="btn btn-sm btn-outline-secondary sa-refresh-btn"
?disabled=${this._loading} ?disabled=${this._loading}
@click=${() => this._fetch(this._page)}> @click=${() => this._loadAll()}>
<i class="bi bi-arrow-clockwise"></i> ${t('system_agents.refresh')} <i class="bi bi-arrow-clockwise"></i> ${t('system_agents.refresh')}
</button> </button>
</div> </div>
<p class="sa-subtitle">${t('system_agents.subtitle')}</p> <p class="sa-subtitle">${t('system_agents.subtitle')}</p>
${this._renderTabs()}
${this._renderAgentPanel()}
${this._renderTable()} ${this._renderTable()}
${this._renderPagination()} ${this._renderPagination()}
</div> </div>
+152
View File
@@ -0,0 +1,152 @@
/* System agents page — the background agents the instance runs.
One tab per agent; a tab holds that agent's settings and its run history.
The settings form reuses the `.config-*` classes from config.css, so an owned
config set looks identical wherever it is edited. */
.sa-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
padding: 1.5rem;
overflow-y: auto;
}
.sa-header {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.35rem;
}
.sa-title {
font-size: 1.2rem;
font-weight: 600;
margin: 0;
}
.sa-total-badge {
font-size: 0.75rem;
color: var(--bs-secondary-color);
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 1rem;
padding: 0.1rem 0.6rem;
}
.sa-refresh-btn { margin-left: auto; }
.sa-subtitle {
font-size: 0.85rem;
color: var(--bs-secondary-color);
margin-bottom: 1rem;
max-width: 65ch;
}
/* ── Tabs ──────────────────────────────────────────────────────────────────── */
.sa-tab-bar {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--bs-border-color);
margin-bottom: 1rem;
overflow-x: auto;
}
.sa-tab {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--bs-secondary-color);
font-size: 0.88rem;
padding: 0.5rem 0.85rem;
white-space: nowrap;
cursor: pointer;
}
.sa-tab:hover { color: var(--bs-body-color); }
.sa-tab--active {
color: var(--bs-body-color);
border-bottom-color: var(--bs-primary);
font-weight: 500;
}
/* ── Selected agent: description + settings ────────────────────────────────── */
.sa-agent-panel { margin-bottom: 1.25rem; }
.sa-agent-desc {
font-size: 0.85rem;
color: var(--bs-secondary-color);
max-width: 75ch;
margin-bottom: 1rem;
}
.sa-agent-config { margin-bottom: 0; }
/* ── Run table ─────────────────────────────────────────────────────────────── */
.sa-table-wrap {
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
overflow-x: auto;
}
.sa-table { margin-bottom: 0; }
.sa-row--clickable { cursor: pointer; }
.sa-row--clickable:hover td { background: var(--bs-tertiary-bg); }
.sa-agent {
font-family: monospace;
font-size: 0.82rem;
}
.sa-date {
font-size: 0.82rem;
color: var(--bs-secondary-color);
white-space: nowrap;
}
.sa-num {
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
white-space: nowrap;
}
.sa-status {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
white-space: nowrap;
}
.sa-status--completed { color: var(--bs-success); }
.sa-status--failed { color: var(--bs-danger); }
.sa-status--running { color: var(--bs-secondary-color); }
.sa-status--cancelled { color: var(--bs-secondary-color); }
.sa-result {
font-size: 0.82rem;
color: var(--bs-secondary-color);
}
.sa-error {
color: var(--bs-danger);
display: inline-block;
max-width: 40ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.sa-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 3rem;
justify-content: center;
color: var(--bs-secondary-color);
font-size: 0.9rem;
}
.sa-state--empty i { font-size: 1.6rem; opacity: 0.6; }
.sa-state--error { color: var(--bs-danger); flex-direction: row; }
.sa-pagination {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
justify-content: center;
}
.sa-page-info {
font-size: 0.82rem;
color: var(--bs-secondary-color);
}
+27 -6
View File
@@ -179,8 +179,6 @@ export default {
'config.set.interface.name': 'Interface', 'config.set.interface.name': 'Interface',
'config.set.interface.desc': 'Look and feel of the web interface.', 'config.set.interface.desc': 'Look and feel of the web interface.',
'config.set.tic_agent.name': 'TIC Agent',
'config.set.tic_agent.desc': 'TIC is a background agent that runs for every user, one at a time. For each user it reads the events their own connectors have pushed since the last run (new mail, calendar changes, incoming messages), decides — via an LLM call — which of them are worth surfacing, and sends those to that user as notifications. It reads only that user\'s events and writes only to their own conversation; a user who has not logged in since the last restart is skipped, because their database is still encrypted. Each run is recorded on the System agents page, visible to the user it ran for.',
'config.set.compaction.name': 'Compaction', 'config.set.compaction.name': 'Compaction',
'config.set.compaction.desc': 'When a conversation grows too large, older messages are summarised by an LLM to keep the context within limits.', 'config.set.compaction.desc': 'When a conversation grows too large, older messages are summarised by an LLM to keep the context within limits.',
@@ -188,10 +186,23 @@ export default {
'config.prop.ui_locale.desc': 'Default interface language for the whole instance. Each user can override it on their profile.', 'config.prop.ui_locale.desc': 'Default interface language for the whole instance. Each user can override it on their profile.',
'config.prop.tic__enabled.name': 'Enabled', 'config.prop.tic__enabled.name': 'Enabled',
'config.prop.tic__enabled.desc': 'Enable or disable the TIC agent for the whole instance. When disabled, no events are processed for anyone.', 'config.prop.tic__enabled.desc': 'Enable or disable the TIC agent for the whole instance. When disabled, no events are processed for anyone.',
'config.prop.tic__security_group.name': 'Security Group', 'config.prop.tic__security_group.name': 'Security group',
'config.prop.tic__security_group.desc': 'Tool permission group applied to each TIC run. It is re-checked against each user\'s own role: a user whose role does not allow this group runs under their role\'s default group instead. Leave empty to always use the role default.', 'config.prop.tic__security_group.desc': 'Tool permission group applied to each TIC run. It is re-checked against each user\'s own role: a user whose role does not allow this group runs under their role\'s default group instead. Leave empty to always use the role default.',
'config.prop.tic__interval_minutes.name': 'Check Interval (minutes)', 'config.prop.tic__interval_minutes.name': 'Check interval (minutes)',
'config.prop.tic__interval_minutes.desc': 'How often TIC starts a pass over all users, in minutes. Leave empty to use the value from config.yml (tic.interval_secs).', 'config.prop.tic__interval_minutes.desc': 'How long between passes for each user, in minutes. Counted per person from their own last pass. Leave empty to use the value from config.yml (tic.interval_secs).',
'config.prop.memory_lint_private__enabled.name': 'Enabled',
'config.prop.memory_lint_private__enabled.desc': 'Enable the private memory lint for the whole instance. When disabled, nobody\'s private store is checked.',
'config.prop.memory_lint_private__security_group.name': 'Security group',
'config.prop.memory_lint_private__security_group.desc': 'Tool permission group applied to each run. It is re-checked against each user\'s own role: a user whose role does not allow this group runs under their role\'s default group instead. Leave empty to always use the role default.',
'config.prop.memory_lint_private__interval_days.name': 'Interval (days)',
'config.prop.memory_lint_private__interval_days.desc': 'How long between passes for each user. Counted per person from their own last pass, and it survives a restart, so a long interval is not reset by rebooting the machine.',
'config.prop.memory_lint_shared__enabled.name': 'Enabled',
'config.prop.memory_lint_shared__enabled.desc': 'Enable the shared memory lint for the whole instance.',
'config.prop.memory_lint_shared__security_group.name': 'Security group',
'config.prop.memory_lint_shared__security_group.desc': 'Tool permission group applied to each run, re-checked against the admin\'s role. Leave empty to use the role default.',
'config.prop.memory_lint_shared__interval_days.name': 'Interval (days)',
'config.prop.memory_lint_shared__interval_days.desc': 'How long between passes over the shared store. It survives a restart, so a long interval is not reset by rebooting the machine.',
'config.prop.compaction_model.name': 'Compaction model', 'config.prop.compaction_model.name': 'Compaction model',
'config.prop.compaction_model.desc': 'Model used to summarise compacted conversations, for the whole instance. A cheap model is usually enough. Leave empty for automatic selection.', 'config.prop.compaction_model.desc': 'Model used to summarise compacted conversations, for the whole instance. A cheap model is usually enough. Leave empty for automatic selection.',
@@ -934,7 +945,7 @@ export default {
// ── System agents ─────────────────────────────────────────────────────────── // ── System agents ───────────────────────────────────────────────────────────
'system_agents.title': 'System agents', 'system_agents.title': 'System agents',
'system_agents.subtitle': 'Background agents the assistant runs for you on a schedule. They read the events your connectors receive and notify you when something looks worth your attention.', 'system_agents.subtitle': 'Background agents that run on a schedule, without being asked. Each one works on your own data and notifies you directly; the runs below are yours and nobody else sees them.',
'system_agents.loading': 'Loading…', 'system_agents.loading': 'Loading…',
'system_agents.empty': 'No runs yet.', 'system_agents.empty': 'No runs yet.',
'system_agents.empty_hint': 'A run is recorded only when there are new events to look at.', 'system_agents.empty_hint': 'A run is recorded only when there are new events to look at.',
@@ -954,8 +965,18 @@ export default {
'system_agents.stat.events_processed': 'events', 'system_agents.stat.events_processed': 'events',
'system_agents.stat.notifications_emitted': 'notifications', 'system_agents.stat.notifications_emitted': 'notifications',
'system_agents.stat.notes_examined': 'notes read',
'system_agents.pagination': 'Page {cur} of {pages} — {total} runs', 'system_agents.pagination': 'Page {cur} of {pages} — {total} runs',
'system_agents.tab.all': 'All',
'system_agents.settings': 'Settings',
'system_agents.agent.tic.name': 'TIC',
'system_agents.agent.tic.desc': 'Reads the events your connectors receive — new mail, calendar changes, incoming messages — decides which of them are worth your attention, and notifies you about those. It runs for one person at a time and reads only that person\'s events.',
'system_agents.agent.memory-lint-private.name': 'Private memory lint',
'system_agents.agent.memory-lint-private.desc': 'A periodic check-up of your own memory. It looks for facts whose date has gone by, questions you were asked and never answered, notes the index has lost track of, and duplicates worth merging — then tells you what it found. It never edits your notes.',
'system_agents.agent.memory-lint-shared.name': 'Shared memory lint',
'system_agents.agent.memory-lint-shared.desc': 'The same check-up over the group\'s shared memory, plus the problem that only exists there: something private written where every member can read it. The shared store belongs to nobody, so this runs as the admin and reports to them. It never edits anything.',
// ── File viewer ───────────────────────────────────────────────────────────── // ── File viewer ─────────────────────────────────────────────────────────────
'fv.back': 'Back', 'fv.back': 'Back',
+25 -4
View File
@@ -179,8 +179,6 @@ export default {
'config.set.interface.name': 'Interface', 'config.set.interface.name': 'Interface',
'config.set.interface.desc': 'Aspect et style de l\'interface web.', 'config.set.interface.desc': 'Aspect et style de l\'interface web.',
'config.set.tic_agent.name': 'Agent TIC',
'config.set.tic_agent.desc': 'TIC est un agent d\'arrière-plan exécuté pour chaque utilisateur, un à la fois. Pour chacun, il lit les événements reçus par ses propres connecteurs depuis la dernière exécution (nouveaux e-mails, changements d\'agenda, messages entrants), décide — via un appel LLM — lesquels méritent d\'être signalés, et les lui envoie sous forme de notifications. Il ne lit que les événements de cet utilisateur et n\'écrit que dans sa propre conversation ; un utilisateur qui ne s\'est pas connecté depuis le dernier redémarrage est ignoré, car sa base de données est encore chiffrée. Chaque exécution est enregistrée sur la page Agents système, visible par l\'utilisateur concerné.',
'config.set.compaction.name': 'Compaction', 'config.set.compaction.name': 'Compaction',
'config.set.compaction.desc': 'Lorsqu\'une conversation devient trop longue, les messages les plus anciens sont résumés par un LLM pour garder le contexte dans les limites.', 'config.set.compaction.desc': 'Lorsqu\'une conversation devient trop longue, les messages les plus anciens sont résumés par un LLM pour garder le contexte dans les limites.',
@@ -191,7 +189,20 @@ export default {
'config.prop.tic__security_group.name': 'Groupe de sécurité', 'config.prop.tic__security_group.name': 'Groupe de sécurité',
'config.prop.tic__security_group.desc': 'Groupe de permissions d\'outils appliqué à chaque exécution de TIC. Il est revérifié selon le rôle de chaque utilisateur : si son rôle n\'autorise pas ce groupe, l\'exécution utilise le groupe par défaut de son rôle. Laissez vide pour toujours utiliser celui du rôle.', 'config.prop.tic__security_group.desc': 'Groupe de permissions d\'outils appliqué à chaque exécution de TIC. Il est revérifié selon le rôle de chaque utilisateur : si son rôle n\'autorise pas ce groupe, l\'exécution utilise le groupe par défaut de son rôle. Laissez vide pour toujours utiliser celui du rôle.',
'config.prop.tic__interval_minutes.name': 'Intervalle de vérification (minutes)', 'config.prop.tic__interval_minutes.name': 'Intervalle de vérification (minutes)',
'config.prop.tic__interval_minutes.desc': 'Fréquence à laquelle TIC lance un passage sur tous les utilisateurs, en minutes. Laissez vide pour utiliser la valeur de config.yml (tic.interval_secs).', 'config.prop.tic__interval_minutes.desc': 'Temps écoulé entre deux passages pour chaque utilisateur, en minutes. Compté par personne depuis son propre dernier passage. Laissez vide pour utiliser la valeur de config.yml (tic.interval_secs).',
'config.prop.memory_lint_private__enabled.name': 'Activé',
'config.prop.memory_lint_private__enabled.desc': 'Activer l\'entretien de la mémoire privée pour toute l\'instance. Lorsqu\'il est désactivé, la mémoire privée de personne n\'est vérifiée.',
'config.prop.memory_lint_private__security_group.name': 'Groupe de sécurité',
'config.prop.memory_lint_private__security_group.desc': 'Groupe de permissions d\'outils appliqué à chaque exécution. Il est revérifié selon le rôle de chaque utilisateur : si son rôle n\'autorise pas ce groupe, l\'exécution utilise le groupe par défaut de son rôle. Laissez vide pour toujours utiliser celui du rôle.',
'config.prop.memory_lint_private__interval_days.name': 'Intervalle (jours)',
'config.prop.memory_lint_private__interval_days.desc': 'Temps écoulé entre deux passages pour chaque utilisateur. Compté par personne depuis son propre dernier passage, et conservé au redémarrage : redémarrer la machine ne réinitialise pas un intervalle long.',
'config.prop.memory_lint_shared__enabled.name': 'Activé',
'config.prop.memory_lint_shared__enabled.desc': 'Activer l\'entretien de la mémoire partagée pour toute l\'instance.',
'config.prop.memory_lint_shared__security_group.name': 'Groupe de sécurité',
'config.prop.memory_lint_shared__security_group.desc': 'Groupe de permissions d\'outils appliqué à chaque exécution, revérifié selon le rôle de l\'administrateur. Laissez vide pour utiliser celui du rôle.',
'config.prop.memory_lint_shared__interval_days.name': 'Intervalle (jours)',
'config.prop.memory_lint_shared__interval_days.desc': 'Temps écoulé entre deux passages sur la mémoire partagée. Conservé au redémarrage : redémarrer la machine ne réinitialise pas un intervalle long.',
'config.prop.compaction_model.name': 'Modèle de compaction', 'config.prop.compaction_model.name': 'Modèle de compaction',
'config.prop.compaction_model.desc': 'Modèle utilisé pour résumer les conversations compactées, pour toute l\'instance. Un modèle économique suffit généralement. Laissez vide pour une sélection automatique.', 'config.prop.compaction_model.desc': 'Modèle utilisé pour résumer les conversations compactées, pour toute l\'instance. Un modèle économique suffit généralement. Laissez vide pour une sélection automatique.',
@@ -924,7 +935,7 @@ export default {
// ── Agents système ────────────────────────────────────────────────────────── // ── Agents système ──────────────────────────────────────────────────────────
'system_agents.title': 'Agents système', 'system_agents.title': 'Agents système',
'system_agents.subtitle': 'Agents en arrière-plan que l\'assistant exécute pour vous à intervalles réguliers. Ils lisent les événements reçus par vos connecteurs et vous préviennent lorsque quelque chose mérite votre attention.', 'system_agents.subtitle': 'Agents en arrière-plan exécutés à intervalles réguliers, sans que vous ayez à le demander. Chacun travaille sur vos propres données et vous prévient directement ; les exécutions ci-dessous sont les vôtres et personne d\'autre ne les voit.',
'system_agents.loading': 'Chargement…', 'system_agents.loading': 'Chargement…',
'system_agents.empty': 'Aucune exécution.', 'system_agents.empty': 'Aucune exécution.',
'system_agents.empty_hint': 'Une exécution n\'est enregistrée que lorsqu\'il y a de nouveaux événements à examiner.', 'system_agents.empty_hint': 'Une exécution n\'est enregistrée que lorsqu\'il y a de nouveaux événements à examiner.',
@@ -944,8 +955,18 @@ export default {
'system_agents.stat.events_processed': 'événements', 'system_agents.stat.events_processed': 'événements',
'system_agents.stat.notifications_emitted': 'notifications', 'system_agents.stat.notifications_emitted': 'notifications',
'system_agents.stat.notes_examined': 'notes lues',
'system_agents.pagination': 'Page {cur} sur {pages} — {total} exécutions', 'system_agents.pagination': 'Page {cur} sur {pages} — {total} exécutions',
'system_agents.tab.all': 'Tous',
'system_agents.settings': 'Paramètres',
'system_agents.agent.tic.name': 'TIC',
'system_agents.agent.tic.desc': 'Lit les événements reçus par vos connecteurs — nouveaux e-mails, changements d\'agenda, messages entrants — décide lesquels méritent votre attention et ne vous signale que ceux-là. Il s\'exécute pour une personne à la fois et ne lit que les événements de cette personne.',
'system_agents.agent.memory-lint-private.name': 'Entretien de la mémoire privée',
'system_agents.agent.memory-lint-private.desc': 'Une vérification périodique de votre mémoire. Elle recherche les faits dont la date est passée, les questions qui vous ont été posées et restées sans réponse, les notes que l\'index a perdues de vue et les doublons à fusionner — puis vous dit ce qu\'elle a trouvé. Elle ne modifie jamais vos notes.',
'system_agents.agent.memory-lint-shared.name': 'Entretien de la mémoire partagée',
'system_agents.agent.memory-lint-shared.desc': 'La même vérification sur la mémoire partagée du groupe, plus le problème qui n\'existe que là : quelque chose de privé écrit là où tous les membres peuvent le lire. La mémoire partagée n\'appartient à personne, cet agent s\'exécute donc en tant qu\'administrateur et lui adresse son rapport. Il ne modifie jamais rien.',
// ── File viewer ───────────────────────────────────────────────────────────── // ── File viewer ─────────────────────────────────────────────────────────────
'fv.back': 'Retour', 'fv.back': 'Retour',
+25 -4
View File
@@ -203,8 +203,6 @@ export default {
'config.set.interface.name': 'Interfaccia', 'config.set.interface.name': 'Interfaccia',
'config.set.interface.desc': 'Aspetto e stile dell\'interfaccia web.', 'config.set.interface.desc': 'Aspetto e stile dell\'interfaccia web.',
'config.set.tic_agent.name': 'Agente TIC',
'config.set.tic_agent.desc': 'TIC è un agente in background che viene eseguito per ogni utente, uno alla volta. Per ciascun utente legge gli eventi che i suoi connettori hanno ricevuto dall\'ultima esecuzione (nuove email, modifiche al calendario, messaggi in arrivo), decide — tramite una chiamata LLM — quali meritano attenzione e glieli inoltra come notifiche. Legge solo gli eventi di quell\'utente e scrive solo nella sua conversazione; un utente che non ha effettuato l\'accesso dall\'ultimo riavvio viene saltato, perché il suo database è ancora cifrato. Ogni esecuzione viene registrata nella pagina Agenti di sistema, visibile all\'utente per cui è stata eseguita.',
'config.set.compaction.name': 'Compattazione', 'config.set.compaction.name': 'Compattazione',
'config.set.compaction.desc': 'Quando una conversazione diventa troppo lunga, i messaggi più vecchi vengono riassunti da un LLM per mantenere il contesto entro i limiti.', 'config.set.compaction.desc': 'Quando una conversazione diventa troppo lunga, i messaggi più vecchi vengono riassunti da un LLM per mantenere il contesto entro i limiti.',
@@ -215,7 +213,20 @@ export default {
'config.prop.tic__security_group.name': 'Gruppo di sicurezza', 'config.prop.tic__security_group.name': 'Gruppo di sicurezza',
'config.prop.tic__security_group.desc': 'Gruppo di permessi strumenti applicato a ogni esecuzione di TIC. Viene riverificato sul ruolo di ciascun utente: se il ruolo non consente questo gruppo, l\'esecuzione usa il gruppo predefinito del ruolo. Lascia vuoto per usare sempre il predefinito del ruolo.', 'config.prop.tic__security_group.desc': 'Gruppo di permessi strumenti applicato a ogni esecuzione di TIC. Viene riverificato sul ruolo di ciascun utente: se il ruolo non consente questo gruppo, l\'esecuzione usa il gruppo predefinito del ruolo. Lascia vuoto per usare sempre il predefinito del ruolo.',
'config.prop.tic__interval_minutes.name': 'Intervallo di controllo (minuti)', 'config.prop.tic__interval_minutes.name': 'Intervallo di controllo (minuti)',
'config.prop.tic__interval_minutes.desc': 'Ogni quanto TIC avvia un giro su tutti gli utenti, in minuti. Lascia vuoto per usare il valore da config.yml (tic.interval_secs).', 'config.prop.tic__interval_minutes.desc': 'Quanto tempo passa tra un giro e l\'altro per ciascun utente, in minuti. Conteggiato per persona a partire dal suo ultimo giro. Lascia vuoto per usare il valore da config.yml (tic.interval_secs).',
'config.prop.memory_lint_private__enabled.name': 'Attivo',
'config.prop.memory_lint_private__enabled.desc': 'Attiva la manutenzione della memoria privata per l\'intera istanza. Quando è disattivata, la memoria privata di nessuno viene controllata.',
'config.prop.memory_lint_private__security_group.name': 'Gruppo di sicurezza',
'config.prop.memory_lint_private__security_group.desc': 'Gruppo di permessi strumenti applicato a ogni esecuzione. Viene riverificato sul ruolo di ciascun utente: se il ruolo non consente questo gruppo, l\'esecuzione usa il gruppo predefinito del ruolo. Lascia vuoto per usare sempre il predefinito del ruolo.',
'config.prop.memory_lint_private__interval_days.name': 'Intervallo (giorni)',
'config.prop.memory_lint_private__interval_days.desc': 'Quanto tempo passa tra un giro e l\'altro per ciascun utente. Conteggiato per persona dal suo ultimo giro, e sopravvive al riavvio: riavviare la macchina non azzera un intervallo lungo.',
'config.prop.memory_lint_shared__enabled.name': 'Attivo',
'config.prop.memory_lint_shared__enabled.desc': 'Attiva la manutenzione della memoria condivisa per l\'intera istanza.',
'config.prop.memory_lint_shared__security_group.name': 'Gruppo di sicurezza',
'config.prop.memory_lint_shared__security_group.desc': 'Gruppo di permessi strumenti applicato a ogni esecuzione, riverificato sul ruolo dell\'amministratore. Lascia vuoto per usare il predefinito del ruolo.',
'config.prop.memory_lint_shared__interval_days.name': 'Intervallo (giorni)',
'config.prop.memory_lint_shared__interval_days.desc': 'Quanto tempo passa tra un giro e l\'altro sulla memoria condivisa. Sopravvive al riavvio: riavviare la macchina non azzera un intervallo lungo.',
'config.prop.compaction_model.name': 'Modello per la compattazione', 'config.prop.compaction_model.name': 'Modello per la compattazione',
'config.prop.compaction_model.desc': 'Modello usato per riassumere le conversazioni compattate, per tutta l\'istanza. Un modello economico di solito è sufficiente. Lascia vuoto per la selezione automatica.', 'config.prop.compaction_model.desc': 'Modello usato per riassumere le conversazioni compattate, per tutta l\'istanza. Un modello economico di solito è sufficiente. Lascia vuoto per la selezione automatica.',
@@ -924,7 +935,7 @@ export default {
// ── Agenti di sistema ─────────────────────────────────────────────────────── // ── Agenti di sistema ───────────────────────────────────────────────────────
'system_agents.title': 'Agenti di sistema', 'system_agents.title': 'Agenti di sistema',
'system_agents.subtitle': 'Agenti in background che l\'assistente esegue per te a intervalli regolari. Leggono gli eventi che arrivano dai tuoi connettori e ti avvisano quando c\'è qualcosa che merita attenzione.', 'system_agents.subtitle': 'Agenti in background che vengono eseguiti a intervalli regolari, senza che tu debba chiedere nulla. Ognuno lavora sui tuoi dati e avvisa te direttamente; le esecuzioni qui sotto sono le tue e nessun altro le vede.',
'system_agents.loading': 'Caricamento…', 'system_agents.loading': 'Caricamento…',
'system_agents.empty': 'Nessuna esecuzione.', 'system_agents.empty': 'Nessuna esecuzione.',
'system_agents.empty_hint': 'Un\'esecuzione viene registrata solo quando ci sono nuovi eventi da esaminare.', 'system_agents.empty_hint': 'Un\'esecuzione viene registrata solo quando ci sono nuovi eventi da esaminare.',
@@ -944,8 +955,18 @@ export default {
'system_agents.stat.events_processed': 'eventi', 'system_agents.stat.events_processed': 'eventi',
'system_agents.stat.notifications_emitted': 'notifiche', 'system_agents.stat.notifications_emitted': 'notifiche',
'system_agents.stat.notes_examined': 'note lette',
'system_agents.pagination': 'Pagina {cur} di {pages} — {total} esecuzioni', 'system_agents.pagination': 'Pagina {cur} di {pages} — {total} esecuzioni',
'system_agents.tab.all': 'Tutti',
'system_agents.settings': 'Impostazioni',
'system_agents.agent.tic.name': 'TIC',
'system_agents.agent.tic.desc': 'Legge gli eventi che arrivano dai tuoi connettori — nuove email, modifiche al calendario, messaggi in arrivo — decide quali meritano la tua attenzione e ti avvisa solo di quelli. Viene eseguito per una persona alla volta e legge solo gli eventi di quella persona.',
'system_agents.agent.memory-lint-private.name': 'Manutenzione memoria privata',
'system_agents.agent.memory-lint-private.desc': 'Un controllo periodico della tua memoria. Cerca fatti la cui data è ormai passata, domande che ti sono state poste e a cui non hai mai risposto, note di cui l\'indice ha perso traccia e duplicati da unire — poi ti dice cosa ha trovato. Non modifica mai le tue note.',
'system_agents.agent.memory-lint-shared.name': 'Manutenzione memoria condivisa',
'system_agents.agent.memory-lint-shared.desc': 'Lo stesso controllo sulla memoria condivisa del gruppo, più il problema che esiste solo lì: qualcosa di privato scritto dove tutti i membri possono leggerlo. La memoria condivisa non appartiene a nessuno, quindi questo agente viene eseguito come amministratore e segnala a lui. Non modifica mai nulla.',
// ── File viewer ────────────────────────────────────────────────────────────── // ── File viewer ──────────────────────────────────────────────────────────────
'fv.back': 'Indietro', 'fv.back': 'Indietro',
+1
View File
@@ -59,6 +59,7 @@
<link rel="stylesheet" href="css/approval-rules.css" /> <link rel="stylesheet" href="css/approval-rules.css" />
<link rel="stylesheet" href="css/inbox-cards.css" /> <link rel="stylesheet" href="css/inbox-cards.css" />
<link rel="stylesheet" href="css/config.css" /> <link rel="stylesheet" href="css/config.css" />
<link rel="stylesheet" href="css/system-agents.css" />
<link rel="stylesheet" href="css/agent-inbox.css" /> <link rel="stylesheet" href="css/agent-inbox.css" />
<link rel="stylesheet" href="css/home.css" /> <link rel="stylesheet" href="css/home.css" />
<link rel="stylesheet" href="css/llm-requests.css" /> <link rel="stylesheet" href="css/llm-requests.css" />