40663373d4ccc4a58c60a57386ba7b127de1f9dc
23
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
78cdcf4cc7 |
feat: let one source carry several chats, and open them with a +
Nightly Build / build (push) Successful in 7m49s
A source had exactly one live conversation, so the copilot could only ever replace a chat, never add one: the trash button reset the source and the old conversation was left orphaned. Working on two things at once meant losing one. The tab bar now holds two kinds of tab. A primary tab is a source — it shows whatever `web` or `project-7` currently points at, which is where background delivery lands (notify, a finished async task, an inbound Telegram message) and what a reset moves to a fresh row. A secondary tab, opened with `+`, is one specific conversation: its source points elsewhere, so it is unreachable by source name and is addressed by id throughout — REST, WebSocket, event filtering. `POST /api/sessions/new` creates one without touching `sources`, which is the whole difference from a reset; its agent and run-context still come from the source, so an extra project tab is the coordinator with the project's context. Project "Open chat" is untouched and still resumes the project's own. The load-bearing half is in ChatHub: the input queue and the model pin are now keyed by session, not by source. Two tabs on one source would otherwise serialize into a single queue and a single turn, and share a `/model` pin — the odd one out, since the security group was already per-session and persisted. The source-taking methods survive as one-line resolvers, so Telegram, mobile and cron are untouched. Because queues now grow with conversations rather than with the handful of sources, a reset retires the queue it replaces instead of leaving a consumer task parked forever. Events are filtered per conversation, so anything a chat must see has to carry a session id: `show_file_to_user`'s OpenFile and the security-group revalidation were emitting untagged and would have reached nobody. A primary connection additionally follows NewSession for its source, so a second window does not keep talking to a conversation another window just reset. Tabs can be renamed by double-click — `chat_sessions.title` existed and was dead until now. An empty name stores NULL, so the box is also the undo. |
||
|
|
8f5c5382c8 |
feat: keep the chat tabs you left open, and keep them with you
Nightly Build / build (push) Successful in 7m39s
Reopening the app closed every project tab: the copilot's tab bar lived in
RAM, so a reload dropped it and each conversation had to be found again from
its project board.
The set of open tabs is now a column on the session row, `chat_sessions.is_open`
(additive, `ensure_column`), restored by `GET /api/sessions/open` and written by
`PUT /api/sessions/{id}/open`. Not localStorage: that store is per-origin, so on
a shared laptop one member's tabs would greet the next, whereas the owner table
sits in their own encrypted file and follows them to another device. Which tab
is *selected* stays in sessionStorage — that one is genuinely per window, and a
shared value would have two windows fighting over it.
`is_open` defaults to 0 and `chat_sessions::create` never sets it: every `/new`
leaves its predecessor behind and every system-agent pass mints a row, so the
opposite default would restore a bar full of conversations nobody opened. Only
the copilot writes the column, at the moment it opens the tab. A reset moves the
flag rather than copying it — `POST /api/sessions` now returns the new id and
`new_session` carries it, and the old row is closed as the new one opens, or the
source would restore twice and a later close would clear the stale row.
Closing a tab clears the flag and nothing else: the conversation is kept and
comes back with its history when the project is reopened.
|
||
|
|
01b8a187b5 |
feat: let a background task ask the chat that started it, not just the Inbox
Nightly Build / build (push) Successful in 7m42s
An async sub-agent runs in a session of its own, so the rich per-session events
that draw the inline approval card never reach the chat's socket — only the
id-only inbox lifecycle ones do. A task blocked on an approval was therefore
invisible in the conversation that started it, and the only way to unblock it
was to notice the sidebar badge and go to the Inbox.
The chat already shows what it handed off. This asks the same question of the
pending items: `GET /{source}/inbox` joins them against the sessions of this
conversation's running async jobs, so "whose is this" has one answer, in the
same place `/{source}/tasks` answers it for a task. The client is left with a
list to render, not a correlation to guess. The live path adds no event — the
existing `approval_requested` / `clarification_*` broadcasts already reach every
socket of the user, and re-reading the endpoint turns a nudge into something
renderable and survives a reload for free.
The card sits above the task strip rather than in the transcript: the task that
is asking may have been started twenty messages ago, and a card that scrolls
away is a card that gets missed. One at a time, with a count of what is behind
it — a blocked task stays blocked whether or not its card is on screen, so
stacking them would trade a readable chat for a queue nobody asked to see. And
it closes: the ✕ hides the card without resolving anything, leaving the item in
the Inbox, because a panel that cannot be moved takes the chat hostage.
`InboxCardsMixin` is the cards and their resolve calls, split out of
`InboxMixin` so the chat and the Inbox render the same approval rather than two
drifting copies of it; `_afterInboxResolve` is the only thing they disagree on.
Elicitations are left out: `PendingElicitationInfo` carries no `session_id`, so
there is nothing to attribute one to a task with.
Also: an async task's context label said "CronJob:", which sends whoever reads
the approval looking on the wrong page — and now says so next to the task's
real name.
|
||
|
|
efb5b1dc33 |
feat: let an agent ask what its connectors are, instead of guessing
Nightly Build / build (push) Successful in 7m44s
An agent that wanted to know which MCP servers it had called `list_mcp_servers` — a tool that has never existed anywhere in this repo — and got "unknown tool". It was not a random hallucination: the prompt block says "the system prompt shows available servers", and `render_mcp_list` returned an empty string when nothing was connected. The model read a promise, found no table, and invented the discovery tool the text implied. The `mcp` kinds of `list_items`/`toggle_item` had been removed to close the §14 RCE vector, which was right for the write half and left no read half at all. So `list_items` gains `type: "mcp"` and returns the whole picture in one call, split into four buckets that each answer a different question: what is already loaded (call its tools directly), what is ready for `activate_tools`, what is installed but unusable and why, and what the user could still activate. Conflating the first two is what produced the original failure, so they stay apart. Every entry carries a derived note and a next step; when the step is a human one, it says so and names the UI page, because there is no tool for it. Read-only, and structurally so: `toggle_item` deliberately gains nothing, and the new `McpDirectory` trait exposes exactly one method. Enabling a connector from a tool is the thing §14 removed, and a wider seam here is how it would come back. Deny-by-default survives the report — an ungranted connector is not named at all, since a listing of what to ask for is itself a leak — except for a catalogue manager, who cannot administer what they cannot see. Three sources answer three questions and none is redundant: the registry says what exists and who may have it, the owner database says what was activated, and the live runtimes say what is connected right now — a row can read `ready` while its process is dead. The live half reaches the tool through the turn's extension map, alongside the pool and the fs view; with no live view the durable picture still renders, so freshness is an improvement and never a precondition. The static `__MCP_LIST__` table stays as it was, because it is frozen per conversation for prompt-cache stability. Its empty case now says so out loud and points at the tool. |
||
|
|
daaceff6ba |
feat: show a conversation its own background tasks, and give it back every outcome
Nightly Build / build (push) Successful in 7m34s
An `execute_task mode="async"` was invisible from the chat that started it.
The only trace was the receipt in the transcript and a row on the Tasks page
— which does not say *which* of those rows the assistant just spawned — so
"is it still going?" had no answer where the question is asked.
Worse, a task that did not simply succeed never came back at all. `run_job`
branched on `Ok`/`Err` first and routed by `job.kind` only inside the `Ok`
arm, so a failure or a kill left through the `Err` arm's unconditional
`hub.notify` — the home source (`/sethome`), worded "Cron job … failed" —
while the parent conversation sat waiting for a `task_completed` that would
never arrive. The wrong chat, and a wedged one.
The fix is a shape, not a branch: one `JobOutcome` classification, then one
`match job.kind` delivery site for every ending. An async task now ends in
its parent conversation whatever happened to it. The sink has a single
channel deliberately — to the model reading it, "it broke" is a result like
any other and must not be overlookable — so a failure is delivered as prose,
carrying whatever partial output the run produced, which is usually the only
clue about why. A cron job keeps the home notification: it belongs to nobody's
conversation. Cancellation becomes a third outcome rather than a flavour of
failure (`job_runs.status` has always had `'cancelled'` in its CHECK and
nothing ever wrote it), classified off the new typed `TurnCancelled` error so
nothing keys on a message string.
The strip above the composer is the visible half. `ServerEvent::TaskUpdate`
announces state to the source of the parent conversation only; the list is
`renderTaskStrip` (shared by the desktop copilot and the mobile chat), fed by
state on `ChatSession`. Each row links to `#session/{id}` — the page that
already shows, live, what a background agent is doing, and without which
"a task is running" is a fact you can do nothing with. Stopping is the
existing kill endpoint. A finished row clears itself after 20 s (its result
is in the conversation by then); a failed one stays until dismissed, and the
dismissal is remembered across reloads.
`GET /api/{source}/tasks` is what makes the strip survive a browser refresh:
the event is a broadcast with no replay, so without a load-time read a reload
would empty a chat that still has work running under it. It answers with the
running tasks plus failures from the last 30 minutes — the two states a person
can still act on. Successes are absent on purpose. Its window compares through
`datetime()` on both sides: `completed_at` is RFC 3339 and the cutoff is
SQLite-shaped, and `'T' > ' '` would let every same-day row through a window
meant to exclude it.
Not addressed, and worth doing next: a cron job's result should go where its
creator says, not always to the home chat.
|
||
|
|
e29dc40202 |
fix: say why the microphone is unavailable, instead of freezing the button
`navigator.mediaDevices` only exists in a secure context — HTTPS, or localhost. Over plain http on a LAN address the property is undefined, so `_startRecording` threw on its first line, the catch wrote one console line and returned, and `_recording` stayed false: the button sat there unchanged with nothing to read anywhere a user would look. The unavailable cases are now named before the attempt rather than guessed at afterwards — insecure context, unsupported browser, denied permission, anything else — and surfaced in the chat through `_pushError`, which every chat surface already shares. The button is deliberately still rendered when the context is insecure: hiding it would read as "transcription is not configured", which is the wrong diagnosis to hand someone. Adds docs/voice.md, since "why doesn't the microphone work" is a question the assistant will be asked and the answer is entirely outside Skald. |
||
|
|
85536755ee |
feat: a "Run now" button for the memory lints — one pass, for whoever asked
Nightly Build / build (push) Successful in 7m33s
The two memory lints run weekly, which is right for maintenance and wrong for the moment somebody has just reorganised their notes and wants to know what the lint makes of them. Each agent's tab now carries a button that starts one pass immediately, for the caller. It runs as the caller — their pool, their sessions, their hub — so the report lands with the person who asked. The shared lint is the interesting case: its scheduled pass runs as the admin because the shared store belongs to nobody, but a member pressing the button reads the same store and gets the report themselves, which is coherent with shared memory being readable by every member anyway. Two settings are treated differently on purpose. Due-ness is skipped, exactly as manual /compact skips the compactor's token threshold: the interval answers *when*, and a human asking is a good enough answer to that. The Enabled switch is honoured: it answers *whether*, and that one is the admin's. The conversation review gets no button (AgentScope::PerSubject): it is about somebody else and picks its own subjects, so "run it for me" has no meaning. The frontend reads that from the agent's scope, not from a list of ids. A second starter breaks an invariant the scheduler used to hold for free. system_agent_runs::start sweeps any leftover `running` row of the same agent to `failed` before inserting, which was safe only because one sequential loop was the only thing that ever started a pass; a manual run overlapping a scheduled one would have marked a healthy run as interrupted and duplicated its work. So the agent list moves out of the scheduler and onto Skald as SystemAgents, which holds the registry plus an in-flight guard both paths claim through — keyed on what the pass is *about*, so an instance-wide agent is one slot no matter who runs it, and a per-subject review is keyed on the subject rather than on the supervisor lending the runtime. has_work is answered synchronously, before anything is spawned: it leaves no run row, so without that the button would say "started" over a log that never gains a row. Everything after it is spawned — a pass is an LLM turn, and no HTTP request should be held open for one. The run row exists before the browser is answered, so the log itself is the progress surface; the page polls it quietly until the pass leaves `running`. |
||
|
|
baf68878e4 |
fix: stop shrinking conversations behind the user's back — both automatic context guards ship off
Nightly Build / build (push) Successful in 7m35s
The shipped default combined a sliding history window with no compaction, which is the worse of the two available trades in both directions it is measured on. `max_history_messages: 30` is a sliding tail window (`projection::window` — `drain(..len - max)`). Past 30 messages it drops from the head on *every* turn, so the prompt prefix changes on every single request and every provider that caches one (Anthropic breakpoints, OpenAI automatic prefix caching) misses every time. It also drops those messages with no summary standing in for them: silent amnesia, not just a cold cache. Compaction rewrites the prefix once per compaction and leaves a summary behind — yet it was the half that was commented out, while the window's own doc-comment already said the two were exclusive. Both are now `Option` and both ship unset, so nothing shrinks a conversation unless a human types `/compact`. Which surfaced the real bug: `/compact` did not work either. The compactor was `Option<Arc<ContextCompactor>>` keyed on the config section existing, so commenting out `compaction:` disabled the manual command too — `force_compact` returned `Ok(false)` and the chat answered "compaction disabled". Manual compaction is a command a user types; it cannot depend on an admin having filled in a token threshold. The compactor is now built unconditionally and `threshold_tokens: Option<u32>` arms only the automatic pass; `try_compact` early-returns without it, `force_compact` deliberately never consults it. The projection accordingly yields to the *automatic* pass rather than to the compactor's existence (`LoopConfig.auto_compaction_enabled`), so a configured message cap is not silently voided by `/compact` merely being available. `CompactionConfig::Default` is hand-written for the same reason `RoleAttrs`'s is: a derived one gives `keep_recent: 0`, which would compact away every recent message on any box omitting the section — now the default. Also fixes two documentation bugs in the same file: `event_triage` was documented nested under `llm:`, where it parses fine and is then silently ignored (it is a top-level field), and `datetime` was documented twice with conflicting examples. A new test asserts the shipped default actually deserializes and that both guards are off — a field the default omits must be genuinely optional, or a brand-new install fails to boot. Automatic compaction returns later, triggered off the resolved model's own context window instead of a hand-tuned token count that cannot know which model is answering. |
||
|
|
4f10528368 |
feat: conversation review — a nightly report on a supervised person's conversations
Nightly Build / build (push) Successful in 7m40s
The first AgentScope::PerSubject system agent, and the reason that scope exists. Once a night, for each person with a supervision edge, it reads every message that person and the assistant exchanged since the previous review — across all their conversations — and writes one report for the people who supervise them. Schema (all registry except reports): - supervision(subject_user_id, supervisor_user_id): the generic §0.1 edge, answering both 'whom does a background agent look at' and 'who may read what it produced', with real FKs so deleting a user cascades both ways - system_agent_coverage(agent_id, subject_user_id, covered_through): the per-subject watermark that makes 'everything since last time' a window — neither system_agent_runs (history for humans) nor system_agent_state (advances before the work), and advanced only on a completed pass so a crash re-covers instead of skipping - reports (owner schema, the second two-homes table after memory_docs): instance rows land in system.db, deliberately cleartext to the box owner, who is the intended reader (§2); the subject cannot see them structurally The pass reads the subject's database inside a supervisor's runtime, so the ephemeral session and run row land in the watcher's file; iteration is over subjects, so two parents watching one child get one review; and the subject need not be logged in when their space is unencrypted — via the new UserManager::open_unencrypted, which refuses an encrypted user outright (no key to be had) and never registers the pool as unlocked. The agent declares the new AgentMeta flag allow_tools: false, so its turn gets an empty tool registry — nothing for a prompt injection in the transcript to call — and produces its report as its final assistant message, read back from chat_history and parsed (NOTHING_TO_REPORT sentinel, no row on quiet days). chat_history::conversation_window is the transcript query; its four filters (non-ephemeral, depth 0, non-synthetic, non-empty) each guard a specific way the review would otherwise be wrong, and tool calls are absent by construction. Cadence is Run at (hour) rather than Interval — 4am local by default — with due-ness answered inside has_work against the coverage watermark, so a machine off for three days covers the whole stretch in one pass. Reports announce ReportCreated on the system bus (no subscriber yet). run_ephemeral_turn gains a per-pass system_substitutions map, which the review uses to hand the model the subject's profile under __SUBJECT_PROFILE__ — the system-context substitutions describe the session owner, the wrong person here. docs/system-agents.md gains the conversation review section; CLAUDE.md documents the scope, the tables and the tool-less design. |
||
|
|
e6818408cb |
feat: grant a new plugin or connector to everyone by default — the admin's job is now removal, not distribution
Nightly Build / build (push) Successful in 7m23s
The grant junctions (plugin_access, mcp_global_access, mcp_catalog_access)
stay deny-by-default internally, but the rows are written for you at two
moments and never again:
— an object is CREATED: PluginManager::update_config (first toggle —
the plugins row's birth), mcp::catalog_upsert, marketplace install,
mcp::global_enable
— a user is CREATED: UserManager::register_user
Who is included is the role attrs.auto_grant flag (default true, so every
role predating the attribute behaves like an adult member). The seeded
Children preset sets it to false, which is the whole reason the attribute
exists. Admins are skipped because they hold everything implicitly. The
role editor now exposes the switch as a checkbox.
New crate module: db::access_defaults (seed_new_object, seed_new_user,
set_grant_by_default). Additive columns: grant_by_default on plugins,
mcp_catalog, mcp_global_servers (INTEGER NOT NULL DEFAULT 1).
On the frontend the Roles page gets a "New extensions" column and
checklist; the user's plugin/connector rosters are unchanged. i18n:
en, fr, it.
Docs: new docs/access.md for the assistant, plus index.md cross-link.
CLAUDE.md updated with a full default-access section.
|
||
|
|
da8a835d70 |
move per-user plugin grants to the user's page
Nightly Build / build (push) Successful in 7m16s
Granting was a checklist of every user on each plugin's page, so "what may
this person use?" meant opening every plugin in turn — and the answer lived
on N pages while the connector half of it already lived on one. Both grant
sections now sit together on #users/{id}: same row list, same disabled chip,
same replace-the-whole-set save. The plugin's own page keeps a read-only
roster of who holds it, linking back to each person.
- db: plugin_access::set_for_user, the per-user twin of set_for_user on
mcp_catalog_access; set_access stays as the inverse read model
- PluginManager: list_grants_for_user / set_grants_for_user, which omit and
reject manages_own_access plugins (a box that controls nothing is worse
than no box)
- GET/PUT /api/users/{id}/plugins, mounted next to /users/{id}/connectors;
PUT /api/plugins/{id}/access is gone, GET remains as the roster
No push after the write, unlike a connector grant: that one gates a runtime
snapshotted at login, while a plugin grant is re-read from plugin_access on
every request that depends on it (sidebar pages, /plugins/mine, and each
inbound channel message), so a revoke lands with no bus event.
Docs updated with where access is granted, and why mobile-connector is
absent from that list.
|
||
|
|
046f060fcd |
rename the TIC system agent to event triage
Nightly Build / build (push) Successful in 7m16s
TIC said nothing about what the agent does, and named the wrong thing: the tick belongs to the scheduler, which is generic and lives outside it. The agent's only decision is whether an incoming event deserves an interruption — it sorts, it never acts — so it is now event-triage, matching the functional naming of the two memory lints. - agents/tic/ -> agents/event-triage/, module tic/ -> event_triage/, TicManager -> EventTriageManager, TicConfig -> EventTriageConfig - agent id and chat source: "tic" -> "event-triage" - config keys: tic.* -> event_triage.*, and the config.yml section tic: -> event_triage: (greenfield: previously set values fall back to defaults) - i18n en/it/fr: Event triage / Triage eventi / Tri des evenements; dropped the stale "TIC sessions" mention from the debug-pages description - docs/system-agents.md, docs/index.md, docs/settings.md, CLAUDE.md, SKALD.md |
||
|
|
434e27d7c2 |
system agents: generalise the scheduler and add the two memory lints
Nightly Build / build (push) Successful in 7m14s
Memory is kept as a maintained wiki, and a wiki nobody prunes rots. This adds
the scheduled maintenance pass, and generalises the machinery TIC had grown so
that a background agent is a trait impl rather than a loop of its own.
Two lint agents, not one. The private pass runs per user over `user-memory/`
and reports to them; the shared pass runs once over `shared-memory/`, where the
interesting defect is different — a note failing the table rule, i.e. private
business written where every member can read it. It names the note and the
category without repeating the content, since restating it spreads the very
thing being flagged. Both share `agents/common/memory-lint.md`.
Both are read-only, and that is enforced twice: the prompt says report-never-
repair, and `shared-memory/*` writes are already `@fs_write require`, so an
agent that tried to fix something would raise an approval card from an
unattended pass, which is auto-denied. Read-only is the only design that works
here, not merely the safe one.
One scheduler for cadences three orders of magnitude apart. TIC runs every few
minutes, a lint weekly — the case that tempts a second loop. It stays one
because the wake-up decides nothing: `base_tick` picks only how often to look,
and whether an agent runs for a user is `is_due` against persisted state.
Due-ness moves out of the run log into a new owner table, `system_agent_state`.
The two answer different questions: the run log skips idle ticks so it stays a
history rather than a heartbeat, while scheduling needs every attempt. Reading
due-ness off the log would re-run an idle agent on every tick and never bring a
weekly one due once its last productive run aged out. Persisting it is also
what makes a long interval survive a restart — an in-memory deadline is fine at
TIC's scale, but a weekly agent on a box rebooted every few days would have it
re-armed before it ever fired.
The shared store belongs to nobody, so `AgentScope::Instance` runs that pass as
the first unlocked admin. An ownerless run would write its trace into system.db,
which the runs endpoint shows to nobody by design, and its notify() would have
no recipient; attributing it to a user keeps the whole per-user surface working
unchanged.
Settings move to where the run log is. `ConfigSet` gains `owner`, so placement
is data on the set rather than a page that knows set names; the System agents
page grows one tab per agent holding its description, its settings (admin only)
and its runs — "why did this do nothing last night?" is half a schedule
question and half a log question. The form is shared with the Config page, and
writes still go through PUT /api/config/{key}.
Fixes an authorization gap found on the way: neither /api/config handler took
the caller into account, so any authenticated session could read and write
instance-wide config. The sidebar hiding the page is presentation, not access
control. Both are now admin-gated.
|
||
|
|
4b1affa600 |
plugins: merge the user Plugins page into per-plugin sidebar pages
Nightly Build / build (push) Successful in 7m1s
The generic per-user #plugins page is gone: a plugin with per-user settings hosts them in its own web_pages() sidebar page instead (Telegram's pairing page is new; Honcho's opt-in page already existed). The admin catalog moves from #plugin-catalog to #plugins (old hash redirected), and user_config_schema is removed from the Plugin trait, the API DTOs and both plugins — the my-config endpoint, the plugin_user_configs store and the update_user_config hook stay, now driven by each plugin's own page fragment. |
||
|
|
50e1333d99 |
mobile-connector: merge pairing+devices into one self-service Mobile App page
Nightly Build / build (push) Successful in 7m3s
The two admin-only console pages become a single "Mobile App" page visible to every logged-in user: connection status (with the last connection error for troubleshooting), the device list (admin sees all, others only their own), a pairing dialog with the QR, and — admin-only — a settings dialog hosting the plugin config, including a relay picker (official grayed out, test, custom URL). The generic plugin-detail config form defers to it via the new Plugin::config_in_detail_page flag. Pairing is now self-service: any user opens a window and the device auto-binds to them; revocation is admin-for-anyone, owner-for-self; (re)binding to another user stays admin-only. Binding-managed plugins (manages_own_access) now expose their non-admin pages to all users and self-scope per caller (web_pages_for). The relay client records the error that ends a WS session and clears it on reconnect. |
||
|
|
165af19774 |
tic: run per-user under a system-agent scheduler, with a run log
Nightly Build / build (push) Successful in 6m58s
Reframe TIC from an ownerless global loop into a per-user system agent.
The events it reads live in each user's own encrypted mcp_events, the
connectors that produced them run in that user's container, and the
notifications go to that user's hub — so the previous design (built
against the ownerless Conversation bundle, writing into system.db and
notifying a hub with no subscribers) was inert by construction.
Core changes
- TicManager owns no timer and no user list. It now exposes
run_for(user_id, pool, sessions, hub): one tick for one user, over
deps unpacked from that user's UserContext. Removed from the
Conversation bundle; Skald::tic_manager() is gone.
- New spawn_system_agents in wiring.rs: one instance-wide loop, spawned
post-construction with a Weak<Skald> (like spawn_user_lifecycle).
Each pass walks the directory and runs TIC for one user at a time —
sequential, because a pass is N container round-trips and N LLM calls
nobody is waiting on. A ConfigKeyUpdated on the interval key cuts the
current wait short; enabled is re-read per pass.
- A user whose database is still locked is skipped (normal, not an
error): the pool is the unlock token, so a user who hasn't logged in
since restart has no readable events and nowhere to record a skip.
- The configured tic.security_group is re-checked per user through
run_context::reconcile_group_for_user — a restricted member never
gets a tool set their role wouldn't grant; unconfigured starts from
role_default_run_context, never None (None = catch-all = wider).
- New system_agent_runs owner table (no user_id column — the file is
the owner): start/finish split so a crash leaves a visible 'running'
row, swept to 'failed' by the next start; safe because the scheduler
is sequential and single-instance. An idle tick writes nothing.
- counting_notify wraps the notify tool so the run log can report
notifications emitted without the tool knowing it's counted.
- The session's event channel is drained by a spawned task instead of
a dropped receiver — the translator awaits its sends and would wedge
at capacity.
EventLog::{Persist,Discard} on McpManager::new
- mcp_events is an owner table and its only reader (TIC) is per-user,
so an event is something that happened to someone. The per-user
runtime gets Persist; the ownerless global runtime gets Discard (its
pool is system.db, rows would be unattributable and unread).
API + UI
- GET /api/system-agents/runs: the caller's own run history, scoped
through require_context with no admin override (same promise as the
rest of the private pool).
- web/components/system-agents.js replaces tic-sessions.js. The old
#tic debug page inferred runs from leftover ephemeral sessions; the
new #system-agents page (sidebar group 'extensions', visible to
everyone — the data is the caller's own) reads the real run log.
- i18n: tic.* keys replaced with system_agents.* in en/it/fr.
Docs
- New docs/system-agents.md (user-facing: what TIC does, why it runs
per person, why a run can be missing). Updated docs/settings.md and
docs/index.md.
- agents/tic/AGENT.md reframed per-user: events are that person's,
memory is user-memory/ (private) — never shared-memory/.
- CLAUDE.md records the system-agents design and the EventLog seam.
|
||
|
|
a3e1b0add0 |
memory: reshape the two stores into a maintained wiki
Nightly Build / build (push) Successful in 6m50s
Memory was a scrapbook: notes accumulated, nothing kept them consistent, and shared memory had no rule saying what belonged in it. This adopts the LLM-wiki pattern — the assistant maintains an evolving artifact rather than re-deriving knowledge each session. The schema (agents/common/memory-wiki.md, included by the three type:chat agents) adds an append-only log.md beside each index.md, names Ingest / Recall / Lint as habits, and states the rule for shared memory: write it there only if you would say it out loud with every member in the room. One person's health, results or another member's opinion of them stays private — that is what shared folders, not shared memory, are for. Tampering is the reason the rules are shaped this way. Shared facts carry provenance and are superseded rather than erased, and a member who contradicts a fact they did not write gets a logged CLAIM instead of an overwrite: only the originator or an admin can turn it into a change. The approval gate cannot enforce this — it asks the caller, who is the same person pushing — so a per-role write permission on shared memory is still the real boundary. The prompt is the etiquette, not the fence. append_file is a new fs tool because the log needs a write that cannot shorten a file. On memory paths it is one SQL statement, so concurrent appends (parallel tool batches, two sessions of one user) cannot lose a line — a dropped line in an audit trail is worse than a failed write. It is auto-allowed on shared-memory/log.md at a lower priority than the shared write rule: gating the trail would be friction with no safety, and a rejected log write yields an unlogged change. memory::scaffold seeds index.md / log.md / user.md so the schema does not describe files that do not exist. Seeded empty rather than left absent: a missing note resolves to nothing at injection, so the model cannot tell "nothing recorded yet" from "this mechanism is not running". __MEMBERS__ renders the roster from users + roles instead of a note the model maintains. A remembered copy drifts and can be talked into being edited; this one bypasses the model entirely. users.notes are excluded — they are the admin's private notes about a person and this block is visible to every member. Still open: a UI to browse memory (list_dir does not classify memory paths yet), a tool-written revisions table (log lines are still typed by the model), and the periodic lint pass. |
||
|
|
5081ec2afe |
llm: drop model/agent scope matching; add instance-wide compaction model picker
Nightly Build / build (push) Successful in 6m50s
Remove the scope system end-to-end (llm_models.scope column, agent meta scope field, scope-based tier in model selection, UI checkboxes/pills): it was only a soft ranking hint, had drifted (6 UI scopes vs 3 used by agents, 'general' not even selectable) and duplicated what strength already decides. Strength stays the single AUTO-selection axis. Compaction: the summary model is now pickable from the Settings page via a new PropertyType::LlmModel config property (registry key compaction_model), instance-wide and live (no restart). Fallback chain: explicit pick -> compaction.strength from config.yml -> priority order; a deleted configured model degrades to AUTO. ContextCompactor reads the key at compact time through GlobalConfigManager. |
||
|
|
3c52587dee |
file viewer: edit Markdown with optimistic-lock conflict detection
Nightly Build / build (push) Successful in 6m47s
- GET /api/file returns ETag (mtime+size) + X-Writable on disk files; PUT /api/file accepts optional if_match -> 409 Conflict on stale version (last-write-wins preserved when omitted), echoes the new ETag - FileViewerBase: View | Edit tabs for .md when the caller can write; source textarea with Save/Cancel, live preview while editing - Watcher no longer clobbers the buffer mid-edit: while editing with unsaved changes it probes the server ETag and only raises a conflict when the remote actually moved on (own-save echo is ignored) - Conflict banner: Reload remote | Copy mine, then reload | Overwrite - i18n (en/it/fr) + CSS; docs/projects.md updated |
||
|
|
e70c4a90f3 |
file viewer, docs, ws: add image/media preview path, projects doc, ws wiring
Nightly Build / build (push) Successful in 6m44s
Show file gains image and video display for capable agents. Docs add projects.md and update index. Wire ws file-watch in project-board. Minor fs tool and CLAUDE.md updates. |
||
|
|
9224245f6f |
docs overhaul: agent-facing doc bundle, read-only docs mount in containers
Nightly Build / build (push) Successful in 6m38s
- Strip ~55 stale upstream docs (dev docs never meant for the agents)
- Write new slim index.md as an agent-facing guide to the app's features
- Add new plugin docs: comfyui, elevenlabs, kokoro_tts, orpheus_tts_3b,
remote_connectivity, whisper_local (replacing old names)
- Add docs_host to UserFs: docs/… and ~/docs/… resolve to {WD}/docs,
mounted read-only at /root/docs in every user's container
- Instruct assistant/kid/project-coordinator agents to read docs/index.md
when users ask how the software works
|
||
|
|
fb3eeeeec6 |
Refactor: remove desktop/Tauri bundle, add i18n, CI/CD pipeline
Nightly Build / build (push) Failing after 6s
- Remove desktop (Tauri) bundle: docs/desktop.md, icons/, tauri.conf.json, src/desktop/mod.rs, gen/schemas/ - Remove build.rs (no longer needed) - Add i18n system (crates/core-api, plugin-mobile-connector, web) - Refactor config system (src/config.rs, boot_format.rs) - Add mobile connector features (app, router, device pairing) - Plugin system improvements (skald-core) - Update dependencies (Cargo.lock, Cargo.toml) - CI/CD: Gitea Actions workflows (nightly + release), package.sh, verify-version.sh, builds.skaldagent.net config |
||
|
|
38494a85a9 | First Version |