Release 0.2.0 #4

Merged
dguiducci merged 96 commits from main into release 2026-08-17 18:07:20 +01:00
26 changed files with 1151 additions and 499 deletions
Showing only changes of commit 165af19774 - Show all commits
+21 -3
View File
@@ -57,7 +57,7 @@ Domain words are allowed only in seed data, preset labels, UI copy and positioni
### Current state
`UserManager` (§11) is now **consumed**. Login exists (`crates/skald-core/src/auth/mod.rs`: `SessionStore``login`/`user_of`/`logout` plus `revoke_user`, the admin-side "drop every session of this user" used by `Skald::revoke_user_runtime`; the deny-by-default middleware is `src/frontend/api/guard.rs`, whose `require_auth` maps token → id and does **not** re-read the row, which is exactly why revocation must be pushed rather than polled; first admin created by `skald-setup`), and the per-user owner-bound runtime is `UserContext` (`crates/skald-core/src/skald/user_context.rs`) — resolved by `Skald::user_context` / the frontend's `require_context`, keyed off `UserManager::pool_of`, and carrying its **own `CancellationToken`** (a child of the instance one) so a single user's cron/hub/MCP loops can be stopped without touching anyone else's. The frontend owner call-sites (WS, sessions, inbox, approval-pending, projects, uploads, run-context, **cron**) route through the per-user pool; dev/stats read `llm_requests` — a *registry* table — from `system.db`, which is correct. The "owner-without-a-user" question resolved to **there isn't one**: every owner content belongs to a logged-in user (the admin included). The global owner-bound bundles (`Conversation`/`Tasks`: the "ownerless" `ChatSessionManager`, `ChatHub`, cron `TaskManager`, `TicManager`) are still constructed but **inert** — their loops never spawn and nothing consumes their accessors; removing them is pending follow-on work (kept for now because `RunContextManager` shares the `Conversation` bundle and *is* used, being registry-backed). See blueprint §19.
`UserManager` (§11) is now **consumed**. Login exists (`crates/skald-core/src/auth/mod.rs`: `SessionStore``login`/`user_of`/`logout` plus `revoke_user`, the admin-side "drop every session of this user" used by `Skald::revoke_user_runtime`; the deny-by-default middleware is `src/frontend/api/guard.rs`, whose `require_auth` maps token → id and does **not** re-read the row, which is exactly why revocation must be pushed rather than polled; first admin created by `skald-setup`), and the per-user owner-bound runtime is `UserContext` (`crates/skald-core/src/skald/user_context.rs`) — resolved by `Skald::user_context` / the frontend's `require_context`, keyed off `UserManager::pool_of`, and carrying its **own `CancellationToken`** (a child of the instance one) so a single user's cron/hub/MCP loops can be stopped without touching anyone else's. The frontend owner call-sites (WS, sessions, inbox, approval-pending, projects, uploads, run-context, **cron**) route through the per-user pool; dev/stats read `llm_requests` — a *registry* table — from `system.db`, which is correct. The "owner-without-a-user" question resolved to **there isn't one**: every owner content belongs to a logged-in user (the admin included). The global owner-bound bundles (`Conversation`/`Tasks`: the "ownerless" `ChatSessionManager`, `ChatHub`, cron `TaskManager`) are still constructed but **inert** — their loops never spawn and nothing consumes their accessors; removing them is pending follow-on work (kept for now because `RunContextManager` shares the `Conversation` bundle and *is* used, being registry-backed). See blueprint §19.
Direction of travel, decided but not yet executed: strip the **power-user surface** (self-rewriting, arbitrary shell, dev-agent suite, ticket system) and move to a **binary-first** layout — the app is built once and run from a compiled binary, not executed from its own source tree.
@@ -107,6 +107,7 @@ 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/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/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/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/clarification/` | `ClarificationManager`: background-session question/answer |
@@ -128,7 +129,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:
- **`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`, `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`, `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).
@@ -140,7 +141,7 @@ Schema is greenfield (no migrations, §0), but a purely **additive** column land
**Prompt substitutions**: an `AGENT.md` may carry `<!-- KEY -->` placeholders; `agents::resolve_includes` turns each into a `__KEY__` sentinel, replaced at request time. Two are resolved by the system-context source itself (`loop_adapters/system.rs`) from the session owner (`user_id`) + registry (`shared_pool`), so every source (WS, mobile, cron, sub-agents) gets them with no caller plumbing: `__SHARED_FOLDERS__` (the user's shared-folders table) and `__USER_PROFILE__` (the owner's directory profile: `Name`, `Date of birth` with age computed at build time, `Sex`, `Preferred language`, admin `Notes` — unset values render as explicit `unknown` / `not specified`, the `Notes` line is omitted when empty). Any other key comes from the per-call `SendMessageOptions::system_substitutions` map.
`system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` and the `mcp_events` lifecycle log (`SecretsStore` and the global `McpManager` are built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets (plus the residual global `mcp_events` log), not on call-site migration.
`system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` (`SecretsStore` is built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). The global runtime no longer writes `mcp_events` there: notification persistence is an explicit `McpManager::new` argument (`EventLog::{Persist,Discard}`), `Discard` for the ownerless global runtime and `Persist` for each per-user one, because an event belongs to whoever it happened to and its only reader (TIC) is per-user. Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets, not on call-site migration.
`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven conventions live in the free-form `roles.attrs` JSON — never new columns per attribute — parsed at a **single point** by the typed `db::roles::RoleAttrs` (`ui_mode`, `permission_groups`, `chat_agent`): `ui_mode` (see the frontend section) plus the role's **security-group set** (`roles.permission_group` = the default group, `attrs.permission_groups` = additional allowed groups; `Role::effective_groups()` = the union, `roles::role_allows_group()` gates it with `admin` short-circuiting to all). See the security-group picker in the frontend section. The role's **default entry (chat) agent** is `attrs.chat_agent` — the neutral `chat`-type agent members of the role land on (§0.1: data, not an enum). Resolved by `roles::default_chat_agent_for_user(registry_pool, user_id)` — the single seam behind both the per-user `ChatHub`'s `default_agent` (snapshotted at login in `UserContextFactory::build`, like fs/MCP access, so **every** session-creation path — explicit `provision_session`, lazy WS `get_or_create_session`, notify — honors it) and `provisioning_for_source`'s non-project branch. Falls back to `agents::DEFAULT_CHAT_AGENT` (`"assistant"`, the renamed former `main`) when unset. Seeded: `admin`/`member``assistant`, `children``kid` (Companion). A per-user override is future work, layering on top in the same resolver. The stack **root frame** is created with the session's own `agent_id` (not a literal) — `config.agent_id` (from the frame) drives which prompt runs, so a wrong id there silently runs the wrong agent. The admin-managed **directory profile** lives in three more additive columns — `birthdate` (ISO `YYYY-MM-DD`), `sex` (free text), `notes` (admin-authored) — edited only from the Users admin page (`set_directory_fields`; validation — real non-future date, length caps — lives in the `users_mgmt` API, not the db layer) and rendered into agent prompts by the `__USER_PROFILE__` substitution (see above). They are directory metadata written *by* the admin *about* the user, so the registry is their honest home under the §2 threat model.
@@ -215,6 +216,22 @@ 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.
## System agents (TIC)
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.
**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.
**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.
**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.
**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.
**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*.
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.
## Multimodal attachments
Uploads go through **one centralized seam**`ChatHub::save_upload` (behind `ChatHubApi::save_upload`, backed by `skald_core::uploads::save_to_home`) — so every surface persists identically and no two callers can drift on placement (the class of bug where the agent was handed a path it couldn't reach). The seam writes into the **caller's container home** under `uploads/{session_id}/` (agent path `uploads/{session}/{name}`, the `UPLOADS_SUBDIR` const in `core-api/user_fs.rs`), collision-dedupes the name, and prefers the sniffed magic-byte MIME over the client claim. The **web** handler (`POST /api/{source}/uploads`) buffers each field with a 256 MiB cap then calls the seam; the **Telegram** plugin downloads bytes then calls the same seam via `handle.chat_hub().save_upload("telegram", …)`. Because the file lands in the home (bind-mounted at `/root`), it is reachable by the fs-tools, `execute_cmd`, and the file viewer (`GET /api/file`, per-user via `resolve_view_path`) — there is **no** `/data` static route anymore (removed: it was `require_auth`-only, not ownership-scoped, and also exposed internal server state under `data/`). Attachment metadata travels as structured JSON in `chat_history.metadata` — never as persisted text.
@@ -375,6 +392,7 @@ The role editor (`roles-page.js`) sets the default group + an allowed-groups che
| `plugin-catalog.js` | `<plugin-catalog>` | `#plugin-catalog` — 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-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 |
| `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 |
| `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 + per-user access grants |
+8 -4
View File
@@ -2,6 +2,8 @@
You are **TIC**, an ephemeral background agent. You are not part of a user conversation. You run silently, in the background, as a periodic tick of the system.
You always run **for one specific user**. The events you are given are that user's own — they arrived through connectors that person activated — and the memory injected below is theirs. Everything you decide is on their behalf and reaches nobody else.
---
## Your purpose
@@ -20,7 +22,7 @@ You receive a batch of pending events collected from external sources (email, Wh
This is an **ephemeral session**. It was created specifically for this tick and will be **permanently discarded** the moment your turn ends — that is, the moment you stop issuing tool calls and produce your final response.
- There is no user waiting on the other end. Do not write conversational responses.
- Nothing you do here carries forward except what you explicitly write to `data/memory/`.
- Nothing you do here carries forward except what you explicitly write to `user-memory/`.
- Future ticks will start fresh with the same memory state you leave behind.
**Do not linger.** Reach a decision, act if needed, return.
@@ -54,7 +56,7 @@ Your job is strictly limited to **evaluating and notifying**. You must never:
- ❌ Create, update, or delete calendar events (no `mcp__gcal__create_event`, `mcp__gcal__update_event`, `mcp__gcal__delete_event`)
- ❌ Modify Gmail messages (no `mcp__gmail__modify_message`, `mcp__gmail__create_label`, etc.)
- ❌ Send WhatsApp messages (no `mcp__whatsapp__send_message`)
- ❌ Write or edit files in `data/memory/` or anywhere else
- ❌ Write or edit files in `user-memory/` or anywhere else
- ❌ Register MCP servers, toggle plugins, add cron jobs, or restart the app
You **must not** call any of these tools, even if they appear in your tool list. If an event requires any of these actions, call `notify()` and explain what needs to be done — the main agent will then ask the user and handle it.
@@ -63,7 +65,9 @@ You **must not** call any of these tools, even if they appear in your tool list.
### Step 1 — Read memory
The content of `data/memory/index.md` and `data/notifications.md` are already injected into your context below. Use the memory index to identify which memory files are relevant to the incoming events, then read those files silently before drawing conclusions. Use `data/notifications.md` as the authoritative source of the user's notification preferences — it overrides your default heuristics.
The content of `user-memory/index.md` is already injected into your context below. Use it to identify which of this user's memory notes are relevant to the incoming events, then read those notes silently before drawing conclusions. If the index points at a note holding their notification preferences, treat it as authoritative — it overrides your default heuristics.
`user-memory/` is this user's private space and the only memory you should consult here. Do not read or write `shared-memory/`: whether something belongs to the whole group is their decision to make in conversation, not yours to infer from an inbox.
Pay attention to:
- Known important contacts and their relevance
@@ -144,7 +148,7 @@ TIC reads memory primarily to evaluate relevance. Write to memory only when you
Your tool access is governed by your run context — only the tools you actually need are enabled.
- **File tools** (`read_file`, `list_files`, `write_file`, `edit_file`) — read memory files; write only to `data/memory/`
- **File tools** (`read_file`, `list_files`, `write_file`, `edit_file`) — read this user's memory notes; write only under `user-memory/`
- **`activate_tools(["name"])`** — load MCP tools for the servers you need. Call this first if you need to inspect event details via an MCP server.
- **`notify(...)`** — send one structured notification per relevant event (see "The notify tool")
+41
View File
@@ -29,6 +29,7 @@ pub mod scheduled_jobs;
pub mod scratchpad;
pub mod shared_folders;
pub mod sources;
pub mod system_agent_runs;
pub mod tool_permission_groups;
pub mod users;
@@ -895,6 +896,45 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
.execute(pool)
.await?;
// Execution log of the **system agents** — the background agents the instance
// runs on a user's behalf without being asked (TIC is the first and, today,
// the only one). The sibling of `job_runs`: same shape, but keyed on the agent
// instead of a scheduled job, because a system agent has no user-authored row
// to point at.
//
// Owner table, and that is the whole privacy story: a run of TIC summarises
// what landed in this user's inbox, so it belongs in *their* encrypted file
// and nowhere else. There is deliberately no `user_id` column — the file is
// the owner (§5.1). An admin reading `system.db` learns nothing about it.
//
// A user whose database is still locked is skipped by the scheduler and
// produces no row at all: the only file that could hold it is the one we
// cannot open. Hence no 'skipped' status — the skip is a log line (§9).
sqlx::query(
"CREATE TABLE IF NOT EXISTS system_agent_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
session_id INTEGER,
started_at TEXT NOT NULL,
completed_at TEXT,
duration_ms INTEGER,
status TEXT NOT NULL
CHECK(status IN ('running', 'completed', 'failed', 'cancelled')),
stats TEXT,
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_system_agent_runs_agent
ON system_agent_runs (agent_id, created_at DESC)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS mcp_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1098,6 +1138,7 @@ mod tests {
one("INSERT INTO scheduled_jobs (id, title, cron, prompt, session_id) VALUES (1, 't', '* * * * *', 'p', 1)")
.await.unwrap();
one("INSERT INTO job_runs (job_id, started_at, status) VALUES (1, 'now', 'completed')").await.unwrap();
one("INSERT INTO system_agent_runs (agent_id, started_at, status) VALUES ('tic', 'now', 'running')").await.unwrap();
// Owner table with a BARE `catalog_name` ref — proves it stands alone with
// FKs on (an owner→registry FK here would die on this INSERT).
one("INSERT INTO mcp_user_servers (name, catalog_name, source) VALUES ('u', 'whatsapp', 'local_script')").await.unwrap();
@@ -0,0 +1,123 @@
//! Execution log of the system agents (blueprint §13).
//!
//! One row per run, in the **user's own** database — a system agent runs on a
//! user's behalf, over their events, so its trace is theirs (see the table
//! comment in [`super::create_owner_tables`]). There is no `user_id` column
//! because the file is the owner.
//!
//! The write is split in two, unlike [`super::job_runs`]: [`start`] before the
//! agent runs, [`finish`] after. A run that never reaches `finish` — the process
//! died mid-turn — stays `running` and is swept to `failed` by the next [`start`]
//! for the same agent, which is safe because the scheduler is sequential and
//! single-instance: no live run can be in that state when a new one begins.
use anyhow::Result;
use sqlx::SqlitePool;
/// Terminal statuses. `running` is the transient one written by [`start`].
pub const STATUS_COMPLETED: &str = "completed";
pub const STATUS_FAILED: &str = "failed";
pub const STATUS_CANCELLED: &str = "cancelled";
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct SystemAgentRun {
pub id: i64,
pub agent_id: String,
pub session_id: Option<i64>,
pub started_at: String,
pub completed_at: Option<String>,
pub duration_ms: Option<i64>,
pub status: String,
/// Free-form JSON with the agent's own counters (TIC: events processed,
/// notifications emitted). Never the event contents.
pub stats: Option<String>,
pub error: Option<String>,
pub created_at: String,
}
/// Open a run: sweep any leftover `running` row for this agent, then insert.
pub async fn start(pool: &SqlitePool, agent_id: &str) -> Result<i64> {
sqlx::query(
"UPDATE system_agent_runs
SET status = 'failed', error = 'interrupted (server restarted)',
completed_at = datetime('now')
WHERE agent_id = ? AND status = 'running'",
)
.bind(agent_id)
.execute(pool)
.await?;
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO system_agent_runs (agent_id, started_at, status)
VALUES (?, datetime('now'), 'running')
RETURNING id",
)
.bind(agent_id)
.fetch_one(pool)
.await?;
Ok(id)
}
/// Close a run. `duration_ms` is computed by the caller, which holds the
/// `Instant` — `datetime('now')` has second resolution and a tick is often faster.
pub async fn finish(
pool: &SqlitePool,
run_id: i64,
status: &str,
session_id: Option<i64>,
duration_ms: i64,
stats: Option<&str>,
error: Option<&str>,
) -> Result<()> {
sqlx::query(
"UPDATE system_agent_runs
SET status = ?, session_id = ?, completed_at = datetime('now'),
duration_ms = ?, stats = ?, error = ?
WHERE id = ?",
)
.bind(status)
.bind(session_id)
.bind(duration_ms)
.bind(stats)
.bind(error)
.bind(run_id)
.execute(pool)
.await?;
Ok(())
}
/// Newest-first page of runs, optionally narrowed to one agent.
pub async fn list(
pool: &SqlitePool,
agent_id: Option<&str>,
limit: i64,
offset: i64,
) -> Result<Vec<SystemAgentRun>> {
let rows = sqlx::query_as::<_, SystemAgentRun>(
"SELECT id, agent_id, session_id, started_at, completed_at, duration_ms,
status, stats, error, created_at
FROM system_agent_runs
WHERE (? IS NULL OR agent_id = ?)
ORDER BY id DESC
LIMIT ? OFFSET ?",
)
.bind(agent_id)
.bind(agent_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Total matching [`list`]'s filter, for pagination.
pub async fn count(pool: &SqlitePool, agent_id: Option<&str>) -> Result<i64> {
let total = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM system_agent_runs WHERE (? IS NULL OR agent_id = ?)",
)
.bind(agent_id)
.bind(agent_id)
.fetch_one(pool)
.await?;
Ok(total)
}
+39 -2
View File
@@ -59,13 +59,50 @@ pub struct McpManager {
data_root: PathBuf,
}
/// Whether a runtime's server-pushed notifications are persisted to `mcp_events`.
///
/// `mcp_events` is an **owner** table and its only consumer is TIC, which is
/// per-user: an event is something that happened to *someone*. The global
/// runtime has no owner — its pool is `system.db` — so persisting there would
/// produce rows nobody can attribute and nobody will ever read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventLog {
/// Per-user runtime: notifications land in that user's `mcp_events`.
Persist,
/// Global runtime: notifications are dropped after the diagnostic log line.
Discard,
}
impl McpManager {
pub fn new(pool: Arc<SqlitePool>, shutdown: CancellationToken, data_root: impl Into<PathBuf>) -> Self {
pub fn new(
pool: Arc<SqlitePool>,
shutdown: CancellationToken,
data_root: impl Into<PathBuf>,
event_log: EventLog,
) -> Self {
let (notification_tx, notification_rx) = mpsc::unbounded_channel::<McpNotification>();
let (log_tx, log_rx) = mpsc::unbounded_channel::<McpLogLine>();
let pool_bg = pool.clone();
tokio::spawn(Self::notification_consumer(pool_bg, notification_rx, shutdown.clone()));
match event_log {
EventLog::Persist => {
tokio::spawn(Self::notification_consumer(pool_bg, notification_rx, shutdown.clone()));
}
// Still drain the channel: the senders are unbounded, but a receiver
// dropped here would make every `send` fail and log noise per event.
EventLog::Discard => {
let sd = shutdown.clone();
tokio::spawn(async move {
let mut rx = notification_rx;
loop {
tokio::select! {
_ = sd.cancelled() => break,
msg = rx.recv() => if msg.is_none() { break },
}
}
});
}
}
tokio::spawn(logs::log_consumer(log_rx, shutdown));
Self {
-2
View File
@@ -38,7 +38,6 @@ use crate::provider::ProviderRegistry;
use crate::run_context::RunContextManager;
use crate::secrets::SecretsStore;
use crate::session::manager::ChatSessionManager;
use crate::tic::TicManager;
use crate::tool_catalog::ToolCatalog;
use crate::tools::ToolRegistry;
use crate::transcribe::TranscribeManager;
@@ -293,7 +292,6 @@ impl Skald {
pub fn manager(&self) -> &Arc<ChatSessionManager> { &self.conversation.manager }
pub fn chat_hub(&self) -> &Arc<ChatHub> { &self.conversation.chat_hub }
pub fn run_context_manager(&self) -> &Arc<RunContextManager> { &self.conversation.run_context_manager }
pub fn tic_manager(&self) -> &Arc<TicManager> { &self.conversation.tic_manager }
// Interaction
pub fn approval(&self) -> &Arc<ApprovalManager> { &self.interaction.approval }
+14 -17
View File
@@ -35,7 +35,6 @@ use crate::run_context::RunContextManager;
use crate::secrets::SecretsStore;
use crate::session::handler::{DEFAULT_MAX_PARALLEL_SUBAGENTS, DEFAULT_MAX_TOOL_ROUNDS};
use crate::session::manager::ChatSessionManager;
use crate::tic::TicManager;
use crate::tool_catalog::ToolCatalog;
use crate::tool_discovery::ToolDiscovery;
use crate::tools::ToolRegistry;
@@ -160,7 +159,14 @@ impl Integrations {
/// after the elicitation handler is wired) and the plugin manager (plugins are
/// injected by `main.rs`; `start_enabled()` runs later, from `WebFrontend`).
pub(super) fn build(rt: &Runtime, plugins: Vec<Arc<dyn Plugin>>) -> Self {
let mcp = Arc::new(McpManager::new(Arc::clone(&rt.db), rt.shutdown_token.clone(), "data"));
// The global runtime has no owner, so its notifications are not persisted:
// `mcp_events` is per-user and its only reader (TIC) runs per-user.
let mcp = Arc::new(McpManager::new(
Arc::clone(&rt.db),
rt.shutdown_token.clone(),
"data",
crate::mcp::EventLog::Discard,
));
let mut plugin_manager = PluginManager::new(Arc::clone(&rt.db));
for plugin in plugins {
@@ -297,16 +303,12 @@ impl Interaction {
}
}
// ── Conversation: session manager + chat hub + run context + TIC ────────────
// ── Conversation: session manager + chat hub + run context ──────────────────
pub(super) struct Conversation {
pub(super) manager: Arc<ChatSessionManager>,
pub(super) chat_hub: Arc<ChatHub>,
pub(super) run_context_manager: Arc<RunContextManager>,
/// TIC lives here (rather than in `Tasks`) because it is constructed from and
/// drives the conversation stack (session manager + chat hub + run context);
/// this keeps every bundle a single-shot `build()` with no two-phase init.
pub(super) tic_manager: Arc<TicManager>,
}
impl Conversation {
@@ -395,17 +397,12 @@ impl Conversation {
chat_hub.register("web").await;
chat_hub.register("talk").await;
let tic_manager = TicManager::new(
Arc::clone(&rt.db),
Arc::clone(&manager),
Arc::clone(&chat_hub),
config.tic.clone(),
Arc::clone(&rt.config),
Arc::clone(&run_context_manager),
Arc::clone(&rt.system_bus),
);
// TIC is deliberately absent: it is a system agent that runs *per user*,
// over that user's own events, sessions and hub. Building it here would
// bind it to the ownerless stack above (§19) — which is precisely the bug
// that made it inert. It is constructed by `wiring::spawn_system_agents`.
Ok(Conversation { manager, chat_hub, run_context_manager, tic_manager })
Ok(Conversation { manager, chat_hub, run_context_manager })
}
}
+5 -1
View File
@@ -31,7 +31,7 @@ use bundles::{Conversation, Infra, Integrations, Interaction, Media, Models, Tas
use runtime::Runtime;
use user_context::{UserContextFactory, UserContextRegistry};
pub use user_context::UserContext;
use wiring::{spawn_background, spawn_user_lifecycle, wire};
use wiring::{spawn_background, spawn_system_agents, spawn_user_lifecycle, wire};
pub struct Skald {
rt: Runtime,
@@ -111,6 +111,10 @@ impl Skald {
// can only be spawned once the instance exists (blueprint §6).
spawn_user_lifecycle(&skald);
// Likewise the system-agent scheduler: it resolves a per-user runtime for
// each user it runs an agent for (blueprint §13).
spawn_system_agents(&skald, config.tic.clone());
Ok(skald)
}
@@ -241,6 +241,9 @@ impl UserContextFactory {
Arc::clone(&pool),
user_shutdown.clone(),
"data",
// This user's connectors push into this user's `mcp_events`, which is
// what TIC reads on their behalf.
crate::mcp::EventLog::Persist,
));
// NOTE: per-user MCP elicitation (interactive connector login, §15) is
// deferred — api-key connectors don't need it. Wire the user's
+116 -8
View File
@@ -2,19 +2,22 @@
//! spawns, each concentrated in one readable place instead of being scattered
//! through the constructor.
//!
//! Owner-bound background loops (cron, session-cancel, ticket-listener, tic) have
//! Owner-bound background loops (cron, session-cancel, ticket-listener) have
//! moved per-user into `UserContextFactory::build`. What remains here are the
//! instance-wide tasks: LLM-log cleanup on the registry pool, MCP server
//! initialization, and the user-lifecycle reconciler (which needs the finished
//! `Arc<Skald>` and is therefore spawned separately, after construction).
//! initialization, and the two that need the finished `Arc<Skald>` and are
//! therefore spawned separately, after construction — the user-lifecycle
//! reconciler and the system-agent scheduler.
use std::sync::Arc;
use std::time::Duration;
use core_api::system_bus::{RecvError, SystemEvent};
use tracing::{info, warn};
use crate::config::CoreConfig;
use crate::config::{CoreConfig, TicConfig};
use crate::elicitation::ElicitationBridge;
use crate::tic::{TicManager, TIC_INTERVAL_MINUTES_KEY};
use super::bundles::{Conversation, Integrations, Interaction, Tasks};
use super::runtime::Runtime;
@@ -41,10 +44,11 @@ pub(super) fn wire(
/// Spawns the instance-wide background tasks.
///
/// Owner-bound loops (cron, session-cancel, ticket-listener, tic) are **not**
/// spawned here — they run per-user inside `UserContext`. Session cancellation is
/// handled directly by the API handlers (which have `AuthUser` and resolve the
/// per-user context). TIC is deferred until connectors return (§13).
/// Owner-bound loops (cron, session-cancel, ticket-listener) are **not** spawned
/// here — they run per-user inside `UserContext`. Session cancellation is handled
/// directly by the API handlers (which have `AuthUser` and resolve the per-user
/// context). The system-agent scheduler needs the finished instance and lives in
/// [`spawn_system_agents`].
pub(super) fn spawn_background(
rt: &Runtime,
_tasks: &Tasks,
@@ -169,3 +173,107 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
info!("user-lifecycle: reconciler stopped");
});
}
/// Spawns the **system-agent scheduler** — the instance-wide timer that runs the
/// background agents nobody asked for (today: TIC).
///
/// One loop, not one per user. Every pass walks the user directory and runs the
/// agent for each user **sequentially**: a pass means N container round-trips and
/// N LLM calls, and doing them concurrently would spike the box every interval
/// for no gain — nobody is waiting on a background tick.
///
/// 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
/// has not logged in since the last restart has no readable events, no session
/// store, and no place to record the skip. It is logged at INFO and the pass
/// moves on; their events keep accumulating and are picked up by the first pass
/// after they log in.
///
/// Spawned after `Skald` is fully built, like [`spawn_user_lifecycle`] and for
/// the same reason: it resolves each user's runtime through `Skald::user_context`.
/// The back-reference is [`std::sync::Weak`].
pub(super) fn spawn_system_agents(skald: &Arc<super::Skald>, tic_config: TicConfig) {
let weak = Arc::downgrade(skald);
let shutdown = skald.rt.shutdown_token.clone();
let mut sys_rx = skald.rt.system_bus.subscribe();
let tic = TicManager::new(
tic_config,
Arc::clone(&skald.rt.config),
Arc::clone(&skald.rt.db),
);
skald.rt.supervisor.spawn("system-agents", async move {
info!("system-agents: scheduler started");
'outer: loop {
// Re-read the interval each pass so a Settings change lands without a
// 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);
tokio::pin!(deadline);
loop {
tokio::select! {
_ = shutdown.cancelled() => break 'outer,
_ = &mut deadline => break,
ev = sys_rx.recv() => match ev {
Ok(SystemEvent::ConfigKeyUpdated { key, .. })
if key == TIC_INTERVAL_MINUTES_KEY =>
{
info!("system-agents: interval changed, rescheduling");
continue 'outer;
}
Err(RecvError::Closed) => break 'outer,
_ => {}
},
}
}
let Some(skald) = weak.upgrade() else { break };
tic_pass(&skald, &tic).await;
}
info!("system-agents: scheduler stopped");
});
}
/// One TIC pass over the whole directory, one user at a time.
async fn tic_pass(skald: &Arc<super::Skald>, tic: &Arc<TicManager>) {
if !tic.is_enabled().await {
return;
}
let users = match skald.users().list().await {
Ok(u) => u,
Err(e) => {
warn!(error = %e, "system-agents: cannot list users, skipping this pass");
return;
}
};
for user in users.into_iter().filter(|u| u.active) {
if skald.rt.shutdown_token.is_cancelled() {
break;
}
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");
}
}
}
+241 -147
View File
@@ -1,51 +1,81 @@
//! TIC — the background event processor, and the first of the **system agents**.
//!
//! A system agent runs on a user's behalf without being asked. TIC's job is to
//! look at the events the user's connectors pushed since the last tick (new
//! mail, a calendar change, a WhatsApp message), decide which of them are worth
//! interrupting the user for, and `notify()` those.
//!
//! **It is per-user, and that is not an implementation detail.** The events it
//! 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
//! 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
//! for one user, and the instance-wide scheduler
//! (`skald::wiring::spawn_system_agents`) 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 trace of what TIC did for someone is readable by them and by nobody else.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
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 core_api::system_bus::{SystemEvent, SystemEventBus};
use crate::chat_hub::ChatHub;
use crate::config::TicConfig;
use crate::config_store::GlobalConfigManager;
use crate::db::mcp_events;
use crate::run_context::{RunContext, RunContextManager};
use crate::db::{mcp_events, system_agent_runs};
use crate::run_context::{self, RunContext};
use crate::session::manager::ChatSessionManager;
/// 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
/// conversation someone is reading.
const TIC_SOURCE: &str = "tic";
const TIC_AGENT: &str = "tic";
/// The agent id, in `agents/tic/`, and the `agent_id` of its `system_agent_runs` rows.
pub const TIC_AGENT: &str = "tic";
pub const TIC_ENABLED_KEY: &str = "tic.enabled";
pub const TIC_SECURITY_GROUP_KEY: &str = "tic.security_group";
pub const TIC_INTERVAL_MINUTES_KEY: &str = "tic.interval_minutes";
pub const TIC_ENABLED_KEY: &str = "tic.enabled";
pub const TIC_SECURITY_GROUP_KEY: &str = "tic.security_group";
pub const TIC_INTERVAL_MINUTES_KEY: &str = "tic.interval_minutes";
pub fn config_set() -> ConfigSet {
ConfigSet {
name: "TIC Agent".into(),
description: "TIC is a background agent that monitors all async events generated by connected MCP servers (new emails, calendar updates, WhatsApp messages, etc.). It reads your notification rules from data/notifications.md and your memory to decide — via an LLM call — which events are worth surfacing. Relevant notifications are forwarded to the home agent set via /sethome.".into(),
description: "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.".into(),
properties: vec![
ConfigProperty {
key: TIC_ENABLED_KEY.into(),
name: "Enabled".into(),
description: "Enable or disable the TIC agent. When disabled, no MCP events are processed.".into(),
description: "Enable or disable the TIC agent for the whole instance. When disabled, no events are processed for anyone.".into(),
property_type: PropertyType::Bool,
default_value: Some("true".into()),
},
ConfigProperty {
key: TIC_SECURITY_GROUP_KEY.into(),
name: "Security Group".into(),
description: "Tool permission group applied to each TIC agent session. Leave empty to use the default 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 {
key: TIC_INTERVAL_MINUTES_KEY.into(),
name: "Check Interval (minutes)".into(),
description: "How often TIC runs, in minutes. Leave empty to use the value from config.yml (tic.interval_secs).".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(),
property_type: PropertyType::Int,
default_value: Some("15".into()),
},
@@ -53,90 +83,51 @@ pub fn config_set() -> ConfigSet {
}
}
/// What one tick did, for the run log. Counters only — never event contents.
pub struct TicRun {
pub session_id: i64,
pub events_processed: 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 {
db: Arc<SqlitePool>,
session_mgr: Arc<ChatSessionManager>,
hub: Arc<ChatHub>,
config: TicConfig,
config_store: Arc<GlobalConfigManager>,
run_context_manager: Arc<RunContextManager>,
system_bus: Arc<SystemEventBus>,
/// Guards against concurrent ticks (e.g. if a tick takes longer than the interval).
running: AtomicBool,
config: TicConfig,
config_store: Arc<GlobalConfigManager>,
/// `system.db` — read to resolve each user's role when validating the
/// configured security group. Never written.
registry_pool: Arc<SqlitePool>,
}
impl TicManager {
pub fn new(
db: Arc<SqlitePool>,
session_mgr: Arc<ChatSessionManager>,
hub: Arc<ChatHub>,
config: TicConfig,
config_store: Arc<GlobalConfigManager>,
run_context_manager: Arc<RunContextManager>,
system_bus: Arc<SystemEventBus>,
config: TicConfig,
config_store: Arc<GlobalConfigManager>,
registry_pool: Arc<SqlitePool>,
) -> Arc<Self> {
Arc::new(Self {
db,
session_mgr,
hub,
config,
config_store,
run_context_manager,
system_bus,
running: AtomicBool::new(false),
})
Arc::new(Self { config, config_store, registry_pool })
}
/// Force a tick immediately, ignoring the running guard.
/// Intended for manual triggering (e.g. via the `/api/tic/trigger` endpoint).
pub async fn tick_now(self: Arc<Self>) {
if let Err(e) = self.run_tick().await {
warn!(error = %e, "TicManager: forced tick failed");
/// 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,
}
}
/// Spawn the background timer.
/// Subscribes to ConfigKeyUpdated so the interval can be changed at runtime.
pub fn start(self: Arc<Self>, shutdown: tokio_util::sync::CancellationToken) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut interval_secs = self.effective_interval_secs().await;
info!("TicManager started (interval={}s, batch={})", interval_secs, self.config.batch_size);
let mut timer = tokio::time::interval(Duration::from_secs(interval_secs));
timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut sys_rx = self.system_bus.subscribe();
loop {
tokio::select! {
_ = shutdown.cancelled() => {
info!("TicManager: stopping");
break;
}
res = sys_rx.recv() => {
if let Ok(SystemEvent::ConfigKeyUpdated { key, new_value, .. }) = res {
if key == TIC_INTERVAL_MINUTES_KEY {
if let Ok(mins) = new_value.parse::<u64>() {
let new_secs = mins.max(1) * 60;
if new_secs != interval_secs {
interval_secs = new_secs;
timer = tokio::time::interval(Duration::from_secs(interval_secs));
timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
info!(secs = interval_secs, "TicManager: interval updated");
}
}
}
}
}
_ = timer.tick() => {
self.tick().await;
}
}
}
})
}
async fn effective_interval_secs(&self) -> u64 {
/// 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 {
@@ -147,75 +138,178 @@ impl TicManager {
self.config.interval_secs
}
async fn is_enabled(&self) -> bool {
match self.config_store.get(TIC_ENABLED_KEY).await {
Ok(Some(v)) => v != "false",
_ => true,
}
}
async fn tick(&self) {
if !self.is_enabled().await {
return;
}
// Prevent concurrent ticks.
if self.running.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
warn!("TicManager: previous tick still running, skipping");
return;
}
let result = self.run_tick().await;
self.running.store(false, Ordering::SeqCst);
if let Err(e) = result {
warn!(error = %e, "TicManager: tick failed");
}
}
async fn run_tick(&self) -> anyhow::Result<()> {
// 1. Fetch the oldest N unprocessed events.
let events = mcp_events::pending_limited(&self.db, self.config.batch_size).await?;
/// One tick for one user, over that user's own runtime.
///
/// `Ok(None)` means there was nothing to do — no pending events — and
/// **nothing is written**: an idle tick must not leave a row behind, or the
/// 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(());
return Ok(None);
}
info!(count = events.len(), "TicManager: processing event batch");
let run_id = system_agent_runs::start(pool, TIC_AGENT).await?;
let started = Instant::now();
// 2. Mark as processed BEFORE running the agent — avoids double-processing
// if the process crashes mid-turn.
let ids: Vec<i64> = events.iter().map(|e| e.id).collect();
mcp_events::mark_processed(&self.db, &ids).await?;
// 3. Serialize events into the agent prompt.
let prompt = build_prompt(&events);
// 4. Create a fresh ephemeral session (agent_id = "tic", source = "tic").
// We bypass ChatHub entirely — TIC is not a user-facing source and should
// not appear in the sources table or consume a broadcast channel.
let (session_id, _) = self.session_mgr.create_session(TIC_AGENT, TIC_SOURCE, false, true, None).await?;
let handler = self.session_mgr.get_or_create_handler(session_id).await?;
handler.set_auto_deny_approvals();
// 5. Apply run context if configured in DB.
if let Ok(Some(rc_id)) = self.config_store.get(TIC_SECURITY_GROUP_KEY).await {
if !rc_id.is_empty() {
let rc = RunContext::with_security_group(Some(rc_id.clone()));
if let Err(e) = self.run_context_manager.set_session_run_context(session_id, Some(&rc)).await {
warn!(error = %e, rc_id, "TicManager: failed to set run context");
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)
}
}
// 6. Sink for session events — nobody subscribes; drop the receiver immediately
// so the channel is drained without buffering.
let (tx, _rx) = mpsc::channel(32);
let notify = crate::tools::notify::make_tool(Arc::clone(&self.hub), "TIC");
handler.handle_message(&prompt, None, None, None, None, vec![notify], std::collections::HashMap::new(), tx, true, None, None).await?;
info!(session_id, count = events.len(), "TicManager: tick complete");
Ok(())
}
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
// this batch rather than replaying it forever. The loss is visible: the run
// row closes as `failed` with the error.
let ids: Vec<i64> = events.iter().map(|e| e.id).collect();
mcp_events::mark_processed(pool, &ids).await?;
let prompt = build_prompt(&events);
let rc = self.run_context_for(user_id).await;
// A fresh ephemeral session per tick (agent_id = "tic", source = "tic").
// ChatHub is bypassed: TIC is not a user-facing source and must not take
// over the `sources` row of a conversation the user is having.
let (session_id, _) = sessions
.create_session(TIC_AGENT, TIC_SOURCE, false, true, rc.as_ref())
.await?;
let handler = sessions.get_or_create_handler(session_id).await?;
handler.set_auto_deny_approvals();
// 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 {
session_id,
events_processed: events.len(),
notifications_emitted: emitted.load(Ordering::Relaxed),
})
}
/// 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
/// tick actually produced, without the tool itself knowing it is being counted.
fn counting_notify(hub: Arc<ChatHub>) -> (InterfaceTool, Arc<AtomicUsize>) {
let inner = crate::tools::notify::make_tool(hub, "TIC");
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)
}
// ── Prompt builder ─────────────────────────────────────────────────────────────
+2 -1
View File
@@ -4,7 +4,7 @@ This folder is written for **you, the assistant**, not for the human directly. I
Keep answers grounded in what's actually enabled and configured for this instance — check with the relevant tool (e.g. list installed/enabled plugins) rather than assuming everything described here is turned on. A feature documented here may not be enabled on this particular instance.
This index will grow over time. Right now it covers memory, projects and plugins; more sections (agents, connectors, security groups, shared folders…) will be added later.
This index will grow over time. Right now it covers memory, projects, system agents and plugins; more sections (agents, connectors, security groups, shared folders…) will be added later.
## Features
@@ -12,6 +12,7 @@ This index will grow over time. Right now it covers memory, projects and plugins
| --- | --- |
| [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 |
| [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 |
| [settings.md](settings.md) | The admin's Config page: interface language, TIC agent, the compaction model picker, debug mode |
## Plugins
+4 -4
View File
@@ -10,11 +10,11 @@ Each setting is saved individually with its own **Save** button (a few, like the
## TIC Agent
TIC is a background agent that watches events from connected MCP servers (new emails, calendar updates, WhatsApp messages…) and decides which ones are worth surfacing as notifications.
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.
- **Enabled** — turn TIC on or off.
- **Security Group** — the tool permission group TIC's sessions run with; leave empty for the default group.
- **Check Interval (minutes)** — how often TIC runs; leave empty for the value from `config.yml`.
- **Enabled** — turn TIC on or off for the whole instance, for everyone.
- **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
+54
View File
@@ -0,0 +1,54 @@
# System agents
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**.
## What TIC does
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.
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 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.
## It runs per person, and only sees one person's things
This is the part worth being precise about, because people ask.
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.
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.
## 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.
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.
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 System agents page
Sidebar → **System agents**. One row per run, newest first:
- **Agent** — which system agent ran (`tic`).
- **Started** and **Duration**.
- **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.
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.
## What the admin can change
On the admin's Config page (see [settings.md](settings.md)), under **TIC Agent**:
- **Enabled** — turns TIC on or off for the whole instance, for everyone.
- **Check Interval (minutes)** — how often a pass over all users starts.
- **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.
There is no per-user on/off switch: if TIC is enabled, it runs for everyone who has logged in.
+3
View File
@@ -24,6 +24,7 @@ pub mod run_context;
pub mod sessions;
pub mod setup;
pub mod shared_folders;
pub mod system_agents;
pub mod transcribe_audio;
pub mod transcribe_models;
pub mod tts_models;
@@ -52,6 +53,8 @@ pub fn router() -> Router<Arc<Skald>> {
// Custom slash commands (file-based, read-only listing for autocomplete + /help)
.route("/commands", get(commands::list))
.route("/sessions", get(sessions::list_sessions).post(sessions::create))
// System agents (TIC …) — the caller's own run history
.route("/system-agents/runs", get(system_agents::list_runs))
// First-run setup
.route("/setup/status", get(setup::status))
.route("/setup/profiles", get(setup::profiles))
+79
View File
@@ -0,0 +1,79 @@
//! 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.
//!
//! **Scoped to the caller, with no admin override.** A run summarises what
//! 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
//! is deliberately no "all users" view: the admin sees their own runs and
//! nobody else's, which is the same promise the rest of the private pool makes
//! (§2/§3).
use std::sync::Arc;
use axum::{
Extension, Json,
extract::{Query, State},
};
use serde::Deserialize;
use serde_json::{Value, json};
use skald_core::db::system_agent_runs;
use skald_core::skald::Skald;
use super::guard::AuthUser;
use super::{ApiError, require_context};
#[derive(Deserialize)]
pub struct ListRunsQuery {
/// Narrow to one agent (`tic`). Omitted = every system agent.
pub agent_id: Option<String>,
#[serde(default = "default_page")]
pub page: i64,
#[serde(default = "default_per_page")]
pub per_page: i64,
}
fn default_page() -> i64 { 1 }
fn default_per_page() -> i64 { 20 }
/// `GET /api/system-agents/runs` — the caller's own run history, newest first.
pub async fn list_runs(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Query(q): Query<ListRunsQuery>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let per_page = q.per_page.clamp(1, 100);
let offset = (q.page.max(1) - 1) * per_page;
let agent = q.agent_id.as_deref().filter(|a| !a.is_empty());
let total = system_agent_runs::count(&ctx.pool, agent).await?;
let rows = system_agent_runs::list(&ctx.pool, agent, per_page, offset).await?;
let items: Vec<Value> = rows
.into_iter()
.map(|r| {
json!({
"id": r.id,
"agent_id": r.agent_id,
"session_id": r.session_id,
"started_at": r.started_at,
"completed_at": r.completed_at,
"duration_ms": r.duration_ms,
"status": r.status,
// Parsed here rather than in the browser: the column is the agent's
// own JSON, and the client should not have to know it is a string.
"stats": r.stats.as_deref()
.and_then(|s| serde_json::from_str::<Value>(s).ok()),
"error": r.error,
})
})
.collect();
Ok(Json(json!({
"items": items,
"total": total,
"page": q.page.max(1),
"per_page": per_page,
})))
}
+2 -2
View File
@@ -29,7 +29,7 @@ import { AgentInboxPage } from './components/agent-inbox.js';
import { LlmRequestsPage } from './components/llm-requests.js';
import { LlmRequestDetail } from './components/llm-request-detail.js';
import { SessionDetailPage } from './components/session-detail.js';
import { TicSessionsPage } from './components/tic-sessions.js';
import { SystemAgentsPage } from './components/system-agents.js';
import { ProjectsPage } from './components/projects/index.js';
import { FileViewerPage } from './components/file-viewer-page.js';
import { ToolDetailPage } from './components/tool-detail-page.js';
@@ -72,7 +72,7 @@ customElements.define('agent-inbox-page', AgentInboxPage);
customElements.define('llm-requests-page', LlmRequestsPage);
customElements.define('llm-request-detail', LlmRequestDetail);
customElements.define('session-detail-page', SessionDetailPage);
customElements.define('tic-sessions-page', TicSessionsPage);
customElements.define('system-agents-page', SystemAgentsPage);
customElements.define('projects-page', ProjectsPage);
customElements.define('file-viewer-page', FileViewerPage);
customElements.define('tool-detail-page', ToolDetailPage);
+1 -1
View File
@@ -78,7 +78,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
_pageFromHash() {
const m = location.hash.slice(1).match(/^([^/?]+)/);
const seg = m ? m[1] : '';
const known = ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer', 'tool_detail'];
const known = ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'system-agents', 'file_viewer', 'tool_detail'];
return known.includes(seg) ? seg : 'home';
}
+4 -2
View File
@@ -33,6 +33,9 @@ const NAV = [
{ id: 'connectors', group: 'extensions', priority: 10, icon: 'plug', labelKey: 'nav.connectors', aliases: ['connector'] },
{ id: 'plugins', group: 'extensions', priority: 20, icon: 'puzzle', labelKey: 'nav.plugins' },
{ id: 'agents', group: 'extensions', priority: 30, icon: 'people', labelKey: 'nav.agents' },
// The background agents the instance runs for you. Visible to everyone: the
// run log is the caller's own, so there is nothing here to gate on a role.
{ id: 'system-agents', group: 'extensions', priority: 40, icon: 'robot', labelKey: 'nav.system_agents' },
// Configurazione — rarely-touched setup. Every entry is admin-only today, so
// the section is admin-only in effect via the empty-section rule.
@@ -47,7 +50,6 @@ const NAV = [
// Sviluppo — debug surface, only with the debug flag on.
{ id: 'llm-requests', group: 'dev', priority: 10, icon: 'journal-code', labelKey: 'nav.llm_requests', debugOnly: true },
{ id: 'tic', group: 'dev', priority: 20, icon: 'bell', labelKey: 'nav.tic', debugOnly: true },
];
// Section order + which sections collapse. Configuration and Development are
@@ -227,7 +229,7 @@ export class AppSidebar extends I18nMixin(LightElement) {
return m ? `plugin/${m[1]}/${m[2]}` : 'home';
}
// `connector` (singular) is the per-connector detail page, `connectors` the list.
return ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'plugins', 'plugin-catalog', 'plugin-detail', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer', 'tool_detail'].includes(segment) ? segment : 'home';
return ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'plugins', 'plugin-catalog', 'plugin-detail', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'system-agents', 'file_viewer', 'tool_detail'].includes(segment) ? segment : 'home';
}
_tasksSectionFromHash() {
+308
View File
@@ -0,0 +1,308 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const PAGE_ID = 'system-agents';
const PER_PAGE = 20;
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
function formatDuration(ms) {
if (ms == null) return '—';
if (ms < 1000) return `${ms} ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(1)} s`;
const m = Math.floor(s / 60);
return `${m}m ${Math.round(s % 60)}s`;
}
const STATUS_ICON = {
running: 'bi-arrow-repeat',
completed: 'bi-check-circle',
failed: 'bi-exclamation-circle',
cancelled: 'bi-slash-circle',
};
export class SystemAgentsPage extends LightElement {
static properties = {
_open: { state: true },
_items: { state: true },
_total: { state: true },
_page: { state: true },
_loading: { state: true },
_error: { state: true },
};
constructor() {
super();
this._open = false;
this._items = [];
this._total = 0;
this._page = 1;
this._loading = false;
this._error = null;
}
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._fetch(this._page);
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _fetch(page) {
this._loading = true;
this._error = null;
try {
const params = new URLSearchParams({ page, per_page: PER_PAGE });
const res = await fetch(`/api/system-agents/runs?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this._items = data.items;
this._total = data.total;
this._page = data.page;
} catch (e) {
this._error = e.message;
} finally {
this._loading = false;
}
}
_openSession(id) {
if (id != null) window.location.hash = `session/${id}`;
}
get _totalPages() { return Math.max(1, Math.ceil(this._total / PER_PAGE)); }
/// The agent's own counters. Rendered generically so a second system agent
/// needs no change here: unknown keys fall back to the raw key name.
_renderStats(stats) {
if (!stats || typeof stats !== 'object') return '—';
const parts = Object.entries(stats)
.filter(([, v]) => v != null)
.map(([k, v]) => {
const label = t(`system_agents.stat.${k}`);
return `${v} ${label.startsWith('system_agents.') ? k.replace(/_/g, ' ') : label}`;
});
return parts.length ? parts.join(' · ') : '—';
}
_renderTable() {
if (this._loading) return html`
<div class="sa-state">
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
<span>${t('system_agents.loading')}</span>
</div>
`;
if (this._error) return html`
<div class="sa-state sa-state--error">
<i class="bi bi-exclamation-circle"></i>
<span>${this._error}</span>
</div>
`;
if (this._items.length === 0) return html`
<div class="sa-state sa-state--empty">
<i class="bi bi-robot"></i>
<span>${t('system_agents.empty')}</span>
<small>${t('system_agents.empty_hint')}</small>
</div>
`;
return html`
<div class="sa-table-wrap">
<table class="table table-sm sa-table">
<thead>
<tr>
<th>${t('system_agents.table.agent')}</th>
<th>${t('system_agents.table.started')}</th>
<th>${t('system_agents.table.status')}</th>
<th class="text-end">${t('system_agents.table.duration')}</th>
<th>${t('system_agents.table.result')}</th>
</tr>
</thead>
<tbody>
${this._items.map(r => html`
<tr class=${r.session_id != null ? 'sa-row--clickable' : ''}
@click=${() => this._openSession(r.session_id)}>
<td><span class="sa-agent">${r.agent_id}</span></td>
<td class="sa-date">${formatDate(r.started_at)}</td>
<td>
<span class="sa-status sa-status--${r.status}">
<i class="bi ${STATUS_ICON[r.status] ?? 'bi-question-circle'}"></i>
${t(`system_agents.status.${r.status}`)}
</span>
</td>
<td class="text-end sa-num">${formatDuration(r.duration_ms)}</td>
<td class="sa-result">
${r.error
? html`<span class="sa-error" title=${r.error}>${r.error}</span>`
: this._renderStats(r.stats)}
</td>
</tr>
`)}
</tbody>
</table>
</div>
`;
}
_renderPagination() {
if (this._totalPages <= 1) return nothing;
const pages = this._totalPages;
const cur = this._page;
return html`
<div class="sa-pagination">
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur <= 1}
@click=${() => this._fetch(cur - 1)}>
<i class="bi bi-chevron-left"></i>
</button>
<span class="sa-page-info">${t('system_agents.pagination', { cur, pages, total: this._total })}</span>
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages}
@click=${() => this._fetch(cur + 1)}>
<i class="bi bi-chevron-right"></i>
</button>
</div>
`;
}
render() {
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-header">
<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>
<button class="btn btn-sm btn-outline-secondary sa-refresh-btn"
?disabled=${this._loading}
@click=${() => this._fetch(this._page)}>
<i class="bi bi-arrow-clockwise"></i> ${t('system_agents.refresh')}
</button>
</div>
<p class="sa-subtitle">${t('system_agents.subtitle')}</p>
${this._renderTable()}
${this._renderPagination()}
</div>
`;
}
}
-255
View File
@@ -1,255 +0,0 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
const PAGE_ID = 'tic';
const PER_PAGE = 20;
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
function formatDateShort(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
export class TicSessionsPage extends LightElement {
static properties = {
_open: { state: true },
_items: { state: true },
_total: { state: true },
_page: { state: true },
_loading: { state: true },
_error: { state: true },
};
constructor() {
super();
this._open = false;
this._items = [];
this._total = 0;
this._page = 1;
this._loading = false;
this._error = null;
}
connectedCallback() {
super.connectedCallback();
this.__onLocaleChanged = () => this.requestUpdate();
window.addEventListener('locale-changed', this.__onLocaleChanged);
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none';
if (this._open && this._items.length === 0) this._fetch(1);
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _fetch(page) {
this._loading = true;
this._error = null;
try {
const params = new URLSearchParams({ source: 'tic', page, per_page: PER_PAGE });
const res = await fetch(`/api/sessions?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this._items = data.items;
this._total = data.total;
this._page = data.page;
} catch (e) {
this._error = e.message;
} finally {
this._loading = false;
}
}
_openSession(id) {
window.location.hash = `session/${id}`;
}
get _totalPages() { return Math.max(1, Math.ceil(this._total / PER_PAGE)); }
_renderTable() {
if (this._loading) return html`
<div class="tic-state">
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
<span>${t('tic.loading')}</span>
</div>
`;
if (this._error) return html`
<div class="tic-state tic-state--error">
<i class="bi bi-exclamation-circle"></i>
<span>${this._error}</span>
</div>
`;
if (this._items.length === 0) return html`
<div class="tic-state">
<i class="bi bi-inbox"></i>
<span>${t('tic.empty')}</span>
</div>
`;
return html`
<div class="tic-table-wrap">
<table class="table table-sm tic-table">
<thead>
<tr>
<th>#</th>
<th>${t('tic.table.agent')}</th>
<th>${t('tic.table.started')}</th>
<th class="text-end">${t('tic.table.messages')}</th>
<th>${t('tic.table.last_activity')}</th>
</tr>
</thead>
<tbody>
${this._items.map(r => html`
<tr class="tic-row--clickable" @click=${() => this._openSession(r.id)}>
<td class="tic-id">${r.id}</td>
<td><span class="tic-agent">${r.agent_id ?? '—'}</span></td>
<td class="tic-date">${formatDateShort(r.created_at)}</td>
<td class="text-end tic-num">${r.message_count}</td>
<td class="tic-date">${formatDate(r.last_message_at)}</td>
</tr>
`)}
</tbody>
</table>
</div>
`;
}
_renderPagination() {
if (this._totalPages <= 1) return nothing;
const pages = this._totalPages;
const cur = this._page;
return html`
<div class="tic-pagination">
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur <= 1}
@click=${() => this._fetch(cur - 1)}>
<i class="bi bi-chevron-left"></i>
</button>
<span class="tic-page-info">${t('tic.pagination', { cur, pages, total: this._total })}</span>
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages}
@click=${() => this._fetch(cur + 1)}>
<i class="bi bi-chevron-right"></i>
</button>
</div>
`;
}
render() {
return html`
<style>
.tic-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
padding: 1.5rem;
overflow-y: auto;
}
.tic-header {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.tic-title {
font-size: 1.2rem;
font-weight: 600;
margin: 0;
}
.tic-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;
}
.tic-refresh-btn {
margin-left: auto;
}
.tic-table-wrap {
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
overflow: hidden;
}
.tic-table {
margin-bottom: 0;
}
.tic-row--clickable {
cursor: pointer;
}
.tic-row--clickable:hover td {
background: var(--bs-tertiary-bg);
}
.tic-id {
font-family: monospace;
font-size: 0.82rem;
color: var(--bs-secondary-color);
width: 4rem;
}
.tic-agent {
font-family: monospace;
font-size: 0.82rem;
}
.tic-date {
font-size: 0.82rem;
color: var(--bs-secondary-color);
white-space: nowrap;
}
.tic-num {
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
}
.tic-state {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 3rem;
justify-content: center;
color: var(--bs-secondary-color);
font-size: 0.9rem;
}
.tic-state--error { color: var(--bs-danger); }
.tic-pagination {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
justify-content: center;
}
.tic-page-info {
font-size: 0.82rem;
color: var(--bs-secondary-color);
}
</style>
<div class="tic-page">
<div class="tic-header">
<h2 class="tic-title"><i class="bi bi-bell"></i> ${t('tic.title')}</h2>
<span class="tic-total-badge">${t('tic.total', { n: this._total })}</span>
<button class="btn btn-sm btn-outline-secondary tic-refresh-btn"
?disabled=${this._loading}
@click=${() => this._fetch(this._page)}>
<i class="bi bi-arrow-clockwise"></i> ${t('tic.refresh')}
</button>
</div>
${this._renderTable()}
${this._renderPagination()}
</div>
`;
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ project-board-section {
}
session-detail-page,
tic-sessions-page,
system-agents-page,
file-viewer-page,
tool-detail-page {
display: none; /* toggled by JS */
+27 -16
View File
@@ -25,7 +25,7 @@ export default {
'nav.catalog': 'Connectors Catalog',
'nav.config': 'Settings',
'nav.llm_requests': 'LLM Requests',
'nav.tic': 'TIC Sessions',
'nav.system_agents': 'System agents',
// ── Top bar ────────────────────────────────────────────────────────────────
'topbar.profile': 'Profile',
@@ -182,18 +182,18 @@ export default {
'config.set.interface.name': '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 monitors all async events generated by connected MCP servers (new emails, calendar updates, WhatsApp messages, etc.). It reads your notification rules from data/notifications.md and your memory to decide — via an LLM call — which events are worth surfacing. Relevant notifications are forwarded to the home agent set via /sethome.',
'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.desc': 'When a conversation grows too large, older messages are summarised by an LLM to keep the context within limits.',
'config.prop.ui_locale.name': 'Language',
'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.desc': 'Enable or disable the TIC agent. When disabled, no MCP events are processed.',
'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.desc': 'Tool permission group applied to each TIC agent session. Leave empty to use the default 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__interval_minutes.name': 'Check Interval (minutes)',
'config.prop.tic__interval_minutes.desc': 'How often TIC runs, in minutes. Leave empty to use the value from config.yml (tic.interval_secs).',
'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.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.',
@@ -950,19 +950,30 @@ export default {
'providers.confirm.delete': 'Delete provider "{name}"? All associated models will be deleted too.',
// ── TIC Sessions ───────────────────────────────────────────────────────────
'tic.title': 'TIC Sessions',
'tic.loading': 'Loading…',
'tic.empty': 'No TIC sessions found.',
'tic.total': '{n} total',
'tic.refresh': 'Refresh',
// ── 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.loading': 'Loading…',
'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.total': '{n} total',
'system_agents.refresh': 'Refresh',
'tic.table.agent': 'Agent',
'tic.table.started': 'Started',
'tic.table.messages': 'Messages',
'tic.table.last_activity':'Last activity',
'system_agents.table.agent': 'Agent',
'system_agents.table.started': 'Started',
'system_agents.table.status': 'Status',
'system_agents.table.duration': 'Duration',
'system_agents.table.result': 'Result',
'tic.pagination': 'Page {cur} of {pages} — {total} sessions',
'system_agents.status.running': 'Running',
'system_agents.status.completed': 'Completed',
'system_agents.status.failed': 'Failed',
'system_agents.status.cancelled': 'Cancelled',
'system_agents.stat.events_processed': 'events',
'system_agents.stat.notifications_emitted': 'notifications',
'system_agents.pagination': 'Page {cur} of {pages} — {total} runs',
// ── File viewer ─────────────────────────────────────────────────────────────
'fv.back': 'Back',
+27 -16
View File
@@ -25,7 +25,7 @@ export default {
'nav.catalog': 'Catalogue des connecteurs',
'nav.config': 'Paramètres',
'nav.llm_requests': 'Requêtes LLM',
'nav.tic': 'Sessions TIC',
'nav.system_agents': 'Agents système',
// ── Top bar ────────────────────────────────────────────────────────────────
'topbar.profile': 'Profil',
@@ -182,18 +182,18 @@ export default {
'config.set.interface.name': 'Interface',
'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 qui surveille tous les événements asynchrones générés par les serveurs MCP connectés (nouveaux e-mails, mises à jour du calendrier, messages WhatsApp, etc.). Il lit vos règles de notification dans data/notifications.md et votre mémoire pour décider — via un appel LLM — quels événements méritent d\'être signalés. Les notifications pertinentes sont transmises à l\'agent d\'accueil défini via /sethome.',
'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.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.prop.ui_locale.name': 'Langue',
'config.prop.ui_locale.desc': 'Langue d\'interface par défaut pour l\'ensemble de l\'instance. Chaque utilisateur peut la modifier dans son profil.',
'config.prop.tic__enabled.name': 'Activé',
'config.prop.tic__enabled.desc': 'Activer ou désactiver l\'agent TIC. Lorsqu\'il est désactivé, aucun événement MCP n\'est traité.',
'config.prop.tic__enabled.desc': 'Activer ou désactiver l\'agent TIC pour toute l\'instance. Lorsqu\'il est désactivé, aucun événement n\'est traité pour personne.',
'config.prop.tic__security_group.name': 'Groupe de sécurité',
'config.prop.tic__security_group.desc': 'Groupe de permissions d\'outils appliqué à chaque session de l\'agent TIC. Laissez vide pour utiliser le groupe par défaut.',
'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.desc': 'Fréquence d\'exécution de TIC, en minutes. Laissez vide pour utiliser la valeur de config.yml (tic.interval_secs).',
'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.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.',
@@ -940,19 +940,30 @@ export default {
'providers.confirm.delete': 'Supprimer le fournisseur "{name}" ? Tous les modèles associés seront également supprimés.',
// ── TIC Sessions ────────────────────────────────────────────────────────────
'tic.title': 'Sessions TIC',
'tic.loading': 'Chargement…',
'tic.empty': 'Aucune session TIC trouvée.',
'tic.total': '{n} total',
'tic.refresh': 'Actualiser',
// ── 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.loading': 'Chargement…',
'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.total': '{n} total',
'system_agents.refresh': 'Actualiser',
'tic.table.agent': 'Agent',
'tic.table.started': 'Démarrée',
'tic.table.messages': 'Messages',
'tic.table.last_activity':'Dernière activité',
'system_agents.table.agent': 'Agent',
'system_agents.table.started': 'Démarrée',
'system_agents.table.status': 'Statut',
'system_agents.table.duration': 'Durée',
'system_agents.table.result': 'Résultat',
'tic.pagination': 'Page {cur} sur {pages} — {total} sessions',
'system_agents.status.running': 'En cours',
'system_agents.status.completed': 'Terminée',
'system_agents.status.failed': 'Échouée',
'system_agents.status.cancelled': 'Annulée',
'system_agents.stat.events_processed': 'événements',
'system_agents.stat.notifications_emitted': 'notifications',
'system_agents.pagination': 'Page {cur} sur {pages} — {total} exécutions',
// ── File viewer ─────────────────────────────────────────────────────────────
'fv.back': 'Retour',
+27 -16
View File
@@ -25,7 +25,7 @@ export default {
'nav.catalog': 'Catalogo connettori',
'nav.config': 'Impostazioni',
'nav.llm_requests': 'Richieste LLM',
'nav.tic': 'Sessioni TIC',
'nav.system_agents': 'Agenti di sistema',
// ── Barra superiore ────────────────────────────────────────────────────────
'topbar.profile': 'Profilo',
@@ -206,18 +206,18 @@ export default {
'config.set.interface.name': 'Interfaccia',
'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 monitora tutti gli eventi asincroni generati dai server MCP connessi (nuove email, aggiornamenti del calendario, messaggi WhatsApp, ecc.). Legge le regole di notifica da data/notifications.md e la memoria per decidere — tramite una chiamata LLM — quali eventi vale la pena segnalare. Le notifiche rilevanti vengono inoltrate all\'agente predefinito impostato tramite /sethome.',
'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.desc': 'Quando una conversazione diventa troppo lunga, i messaggi più vecchi vengono riassunti da un LLM per mantenere il contesto entro i limiti.',
'config.prop.ui_locale.name': 'Lingua',
'config.prop.ui_locale.desc': 'Lingua predefinita per l\'intera istanza. Ogni utente può modificarla nel proprio profilo.',
'config.prop.tic__enabled.name': 'Attivo',
'config.prop.tic__enabled.desc': 'Attiva o disattiva l\'agente TIC. Quando disattivato, nessun evento MCP viene elaborato.',
'config.prop.tic__enabled.desc': 'Attiva o disattiva l\'agente TIC per l\'intera istanza. Quando è disattivato, non viene elaborato alcun evento per nessuno.',
'config.prop.tic__security_group.name': 'Gruppo di sicurezza',
'config.prop.tic__security_group.desc': 'Gruppo di permessi strumenti applicato a ogni sessione dell\'agente TIC. Lascia vuoto per usare il gruppo predefinito.',
'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.desc': 'Ogni quanto TIC viene eseguito, in minuti. Lascia vuoto per usare il valore da config.yml (tic.interval_secs).',
'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.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.',
@@ -940,19 +940,30 @@ export default {
'providers.confirm.delete': 'Eliminare il provider "{name}"? Tutti i modelli associati verranno eliminati.',
// ── TIC Sessions ────────────────────────────────────────────────────────────
'tic.title': 'Sessioni TIC',
'tic.loading': 'Caricamento…',
'tic.empty': 'Nessuna sessione TIC trovata.',
'tic.total': '{n} totale',
'tic.refresh': 'Aggiorna',
// ── 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.loading': 'Caricamento…',
'system_agents.empty': 'Nessuna esecuzione.',
'system_agents.empty_hint': 'Un\'esecuzione viene registrata solo quando ci sono nuovi eventi da esaminare.',
'system_agents.total': '{n} totale',
'system_agents.refresh': 'Aggiorna',
'tic.table.agent': 'Agente',
'tic.table.started': 'Iniziata',
'tic.table.messages': 'Messaggi',
'tic.table.last_activity':'Ultima attività',
'system_agents.table.agent': 'Agente',
'system_agents.table.started': 'Iniziata',
'system_agents.table.status': 'Esito',
'system_agents.table.duration': 'Durata',
'system_agents.table.result': 'Risultato',
'tic.pagination': 'Pagina {cur} di {pages} — {total} sessioni',
'system_agents.status.running': 'In corso',
'system_agents.status.completed': 'Completata',
'system_agents.status.failed': 'Fallita',
'system_agents.status.cancelled': 'Annullata',
'system_agents.stat.events_processed': 'eventi',
'system_agents.stat.notifications_emitted': 'notifiche',
'system_agents.pagination': 'Pagina {cur} di {pages} — {total} esecuzioni',
// ── File viewer ──────────────────────────────────────────────────────────────
'fv.back': 'Indietro',
+1 -1
View File
@@ -113,7 +113,7 @@
<agent-inbox-page></agent-inbox-page>
<llm-requests-page></llm-requests-page>
<session-detail-page style="display:none"></session-detail-page>
<tic-sessions-page style="display:none"></tic-sessions-page>
<system-agents-page style="display:none"></system-agents-page>
<projects-page style="display:none"></projects-page>
<file-viewer-page style="display:none"></file-viewer-page>
<tool-detail-page style="display:none"></tool-detail-page>