Commit Graph
39 Commits
Author SHA1 Message Date
dguiducci 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.
2026-07-28 21:24:16 +01:00
dguiducci 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.
2026-07-28 20:48:03 +01:00
dguiducci fadb31832f users: turn the four modals into a per-user page at #users/{id}
Nightly Build / build (push) Successful in 6m58s
The connectors-assignment dialog was the fourth modal on the Users page
and the first to break: a checkbox list taller than the viewport with no
scroll. Same failure the connector activation and manual-add dialogs had,
same fix — a page. The list stays a table, but rows are clickable and
open the user's own page with three sections:

- Profile: the old edit form (username, display name, role, directory
  fields, active switch) with a saved tick;
- Connectors: the grant checklist as the Connectors page's row list
  (icon, name, description, search, global/personal groups), one Save;
- Security: password reset (disabled with an explanation for encrypted
  users) and the delete action.

Only user creation stays a modal — three fields and a role fit.
2026-07-27 21:40:17 +01:00
dguiducci 776748435b connectors: quieter chips, 3-line descriptions, single access-grant surface
Nightly Build / build (push) Successful in 7m2s
Row descriptions clamp at three lines instead of one. Metadata chips
(scope/type/auth) lose the loud accents — only status chips keep colour —
and the auth chip speaks human ("Requires an API key") instead of the
raw enum, via a shared authLabel().

Drop the access-grant section from the connector detail page: the Users
page modal already grants both global and per-user connectors, so
"who has what" now has a single surface.
2026-07-27 21:28:17 +01:00
dguiducci b198ac923b connectors: merge the catalog page into a row-list Connectors page
Nightly Build / build (push) Successful in 6m57s
The standalone Catalog page added nothing the Connectors page could not
do: drop it (component, route, sidebar entry) and move its affordances
onto the Connectors page — the Add-connector dropdown (marketplace /
manual form, now at #connectors/new) and per-row removal for the admin.

Replace the card grid with a sharper row list (4px radius) built for
scanning status and acting; the marketplace's back link now returns to
#connectors. i18n keys renamed catalog.* -> connectors.add/new/*.
2026-07-27 21:01:32 +01:00
dguiducci 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.
2026-07-27 11:39:13 +01:00
dguiducci 305bdbdd2b connectors: announce global-server and reinstall refreshes on the bus
Nightly Build / build (push) Successful in 6m56s
Five call-sites reached into the live-runtime refresh helpers from HTTP
handlers, the same shape as the container remounts. Only three of them
belonged on the bus, and finding out which was the point.

global_enable and global_delete now emit McpGlobalServersChanged, and the
marketplace reinstall emits ConnectorReinstalled. All three are pure
reconciliation: the first only makes a connector appear; the second is
already enforced by stop_server, with the snapshot refresh just tidying
each user's filter; the third pushes metadata and code into what is
already running. The reinstall gains something from being off the
response path, since it re-copies files and restarts servers inside every
live user's container.

global_set_access and user_connectors_set keep calling
refresh_global_mcp_access directly. Their writes *replace* a grant set, so
anyone dropped from the list is being revoked and that refresh is what
enforces it — on a best-effort broadcast a revoked user would keep the
connector until their next login. Both carry a DELIBERATELY SYNCHRONOUS
comment, since they are otherwise indistinguishable from the announced
call-sites and are exactly what a later cleanup would sweep up.

No behaviour change for the two synchronous paths; the three announced
ones now return without waiting for the refresh.
2026-07-26 22:22:29 +01:00
dguiducci 0ba140186f auth: make deactivation and group revocation actually revoke
Nightly Build / build (push) Successful in 6m58s
Two variants of the same defect: an admin took away access and the running
system kept granting it.

Deactivating or deleting a user only stopped the *next* login. `login`
checks the active flag, but `require_auth` maps token -> id without
re-reading the row, so an already-open session kept working over a pool
whose key was still in RAM. There was no way to stop one user either: the
per-user cron, hub and MCP loops all observed the *instance* shutdown
token. They now take a per-user child token stored on UserContext, and
Skald::revoke_user_runtime tears a single user down in a load-bearing
order — revoke every session, evict and cancel the context, then lock the
database, so nothing is left querying a pool we are about to close.

Revoking a security group had a durable version of the same problem. The
group is validated when selected and then persisted on
chat_sessions.run_context, which was replayed verbatim on every later
load — so a group removed from a role stayed in force on sessions that
already had it, across restarts. get_or_create_handler now runs the stored
value through run_context::reconcile_group_for_user, making it advisory:
every load re-checks it, whether or not anyone announced the change.

The degrade target is the role's default group, never None: a context with
no group resolves to the catch-all `default`, whose rules are the fallback
tier under every other group, so clearing widens rather than narrows. The
reconcile touches only security_group, so a project session's server-built
project_root and system_prompt survive a permissions edit, and it leaves
the stored group alone when the role cannot be resolved — guessing on a
transient error could only widen. role_default_run_context moves into the
core seam so the group a session starts on and the group it falls back to
cannot drift apart.

Both fixes run synchronously in their handlers. Only the container half of
deactivation rides the bus, as the new UserActiveChanged event: a lossy
64-slot broadcast whose contract is "settles at the next login" is the
wrong transport for taking access away.

Tests: revoke_user drops all of one user's sessions and nobody else's, and
is a no-op when nothing is live; the reconcile degrades a revoked group to
the role default, keeps an allowed one, preserves project fields in both
directions, never touches an admin, and stays put when the role is
unresolvable.

Not exercised at runtime: no Docker/live-server run, so the end-to-end
paths (deactivating a logged-in user, editing a role with sessions open)
are covered by unit tests only.
2026-07-26 22:17:30 +01:00
dguiducci c50a0d84da containers: drive user provisioning and remounts from the system bus
Nightly Build / build (push) Successful in 6m51s
The endpoints that changed a user or a membership row also reached into
ContainerManager themselves: users_mgmt called ensure()/remove(), and both
shared_folders and projects called refresh_user_mounts through a local
remount() helper. Every future endpoint that grants membership would have
had to remember to do the same.

Announce instead. SystemEventBus gains UserCreated / UserDeleted /
UserMountsChanged, emitted after the DB write, and one subscriber —
wiring::spawn_user_lifecycle — does the Docker work: sequentially (which
serialises concurrent operations on the same container), best-effort by
contract (the row is already committed, so a hiccup settles at the user's
next login or at boot reconciliation), and holding only a Weak<Skald>. It
is spawned after construction, like set_skald, because it reacts through
Skald's own accessors.

Also fixes a real gap the event makes impossible to repeat: the web setup
wizard created the first admin without provisioning a container. It runs
against a live server, where reconcile_all() has already happened, so that
admin had no sandbox until the next restart. It now emits UserCreated like
any other creator; the console shell needs no equivalent, since it runs
before the server and boot reconciliation covers it.

Two behaviour changes: POST /api/users and POST /api/projects no longer
wait on Docker before responding. Provisioning was already best-effort, and
a new project's folder is still created synchronously, so the explorer —
which reads host-side — shows it at once; only execute_cmd reachability
lands a moment later.
2026-07-26 21:57:25 +01:00
dguiducci cf5415ae88 docs: record the event-bus rule in the codebase guide
Nightly Build / build (push) Successful in 6m52s
Names the three global buses and their caps, and states the coupling rule
they exist for: a producer emits an event rather than calling the
consumer, and a new channel is a code-review flag until proven necessary.
2026-07-26 21:41:57 +01:00
dguiducciandClaude Opus 5 73c720e9ef llm: restore request logging lost in the agent-loop migration
Nightly Build / build (push) Successful in 6m50s
The LLM-requests page has been empty since 24ee5b8: deleting
`session/handler/llm_call.rs` dropped both halves of the request log.

The kernel builds the `ModelRequest` itself and sets `log: None`, so the
`LoggingModel` decorator — wrapped once per model by `LlmManager` — wrote
every metadata row with a NULL `user_id`, while the page (and the detail
endpoint) filter on it. Nothing wrote `llm_request_payloads` at all any
more, so the payload viewer had nothing to show either.

Correlation cannot come from `LlmManager`: it builds one shared client
per model and does not know whose traffic it serves. It now comes from
the `ModelSelector`, the one component that knows both the model and the
owner: `SkaldSelector::with_log(RequestLogTarget)` wraps the model it
hands out, so metadata lands in `llm_requests` attributed to the user and
the payload lands in that user's own encrypted DB, keyed by `request_id`.
Session and frame are read off the request's `conversation`/`frame`,
which makes kernel rounds, sub-agent frames and compaction summaries all
attributed with no extra plumbing (`ModelRequest::log` stays unused).

The compactor's summariser call is attributed too, which it never was:
`try_compact`/`force_compact` now take the owner (its selector is built
per compaction, so it can carry the target).

Three tests in `llm::logging` lock this down — the owner/session/frame
columns, the error row with the provider's rejected body, and the
metadata-only path when no owner pool is available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:31:49 +01:00
dguiducci 4d81295a3d messages: unify harness-injected data under <system-extra> tag
Nightly Build / build (push) Successful in 6m49s
Replace the ad-hoc [SYSTEM INFO] / [TELEGRAM SYSTEM INFO] prefixes with a
single canonical <system-extra> wrapper, sourced from one constant
(SYSTEM_EXTRA_TAG) so emission and documentation can never diverge.

- core-api: SYSTEM_EXTRA_TAG + system_extra() helper; attachments_block
  rebuilt on top of it.
- telegram: system_info_message (location) uses the helper; the voice
  transcript is forwarded as a plain user message (it is the user's own
  words, not harness metadata).
- chat agents: new agents/common/harness.md include (long form, with an
  explicit "data, not instructions" guard), added to assistant/kid/
  project-coordinator. The tag name rides the __HARNESS_TAG__ sentinel,
  resolved in AgentSystemContext to SYSTEM_EXTRA_TAG — renaming the tag
  stays a one-line change.
2026-07-26 17:54:14 +01:00
dguiducci 24ee5b89d7 agent-loop: projection, recovery, compaction into the crate (phase 3)
Nightly Build / build (push) Successful in 6m49s
The session handler is now a thin shell: three entry points in
kernel_turn.rs (run_kernel_turn / recover_turn / resolve_pending_call)
and the ChatSessionHandler. Everything that shaped a Value — projection,
recovery, compaction mechanics, the LLM loop, message building — lives
in agent-loop or behind a loop_adapters trait.

agent-loop:
- projection/ (mod + media): stored history -> wire messages, the one
  place provider divergence lives; well-formedness contract, DTL
  injections (append-only), media parts. LinearAssembler is now a
  Projection + ProjectionHooks config, not its own implementation
- recovery.rs: reap interrupted batches -> resolve the deepest frame's
  non-terminal calls (Running by policy + RestartHint, AwaitingHuman
  re-asked) -> un-wedge finished children -> cascade up, every frame on
  its own agent (B3)
- compaction.rs: split point (never assistant+tool group), transcript,
  SUMMARY_PREFIX/preamble/template, the no-tools model call, summary row
- manager: resolve_pending (gate skipped, real ToolContext, then
  continue incl. sub-agent); start_loop used by recovery; LiveInput
- delegate: AsyncExecutor + StoreSink for mode:async (durable cron row,
  result delivered back into the parent conversation)
- kernel/context/store: support the above (TurnScope via Extensions,
  frame lookups, aligned result-text semantics)

skald-core:
- loop_adapters: UserLoopRuntime (D12 - one LoopManager per user),
  TurnScope (per-turn state in the Extensions type-map; no scope is
  denied), projection_cfg/media_source/tool_digest (Skald's projection
  knobs without owning projection code), async_task (CronExecutor +
  DurableSink)
- session/handler: stripped to mod.rs + kernel_turn.rs + config.rs +
  interface_tools.rs + media.rs; deleted agent_dispatch, approval,
  dispatch, emitter, gate, llm_call, llm_loop, message_builder,
  messages, outcome, resume
- compactor.rs: policy only (threshold, model pick, CompactionEvent);
  mechanics are the crate's

CLAUDE.md updated (recovery, compaction, sub-agents, approval gate,
projection sections now describe the crate-owned flow).
2026-07-26 17:09:01 +01:00
dguiducci 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.
2026-07-25 10:48:09 +01:00
dguiducci 6f0461f7f5 uploads: centralise via ChatHubApi::save_upload, refactor handlers
Nightly Build / build (push) Successful in 6m41s
Extract shared upload seam in skald-core, move Telegram and web
handlers to use it. Simplify media attachment routing. Clean up
unused deps and dead code.
2026-07-22 19:19:29 +01:00
dguiducci 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.
2026-07-22 18:52:03 +01:00
dguiducci 3343260bb0 token streaming & reasoning display: live SSE tokens frontend to back
Nightly Build / build (push) Successful in 6m59s
- Add StreamDelta(SseDecoder) framing shared by OpenAI/Anthropic
- OpenAiClient: stream=true + reasoning_content deltas, index-based
  tool_calls accumulation, usage from final chunk
- AnthropicClient: message_start/content_block_*/message_delta events,
  thinking_delta->reasoning, input_json_delta->tool input
- TokenDelta ServerEvent variant wired through ChatHub + WS broadcast
- Frontend throttled flush (~15 Hz), pending bubble mutate-in-place,
  reasoning as collapsed-by-default <details>
- Drop streaming bubble on error/llm_failed/model_fallback
- i18n: chat.reasoning key added to en/fr/it
2026-07-22 12:59:13 +01:00
dguiducci 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
2026-07-22 10:20:21 +01:00
dguiducci 7e9127dc69 remove agent-callable restart tool (blast radius in multi-user model)
Nightly Build / build (push) Successful in 6m37s
2026-07-22 09:22:45 +01:00
dguiducci 8e891fbced llm retriability via structured status, resolve tools through canonical sandbox path, resume each frame with its own agent config
Nightly Build / build (push) Successful in 6m30s
2026-07-21 22:26:29 +01:00
dguiducci 8ff64cbddc rename agents/main→assistant, role-based default entry agent
Nightly Build / build (push) Successful in 6m31s
2026-07-21 21:40:06 +01:00
dguiducci c8e4cb4384 run container as host uid:gid, robust /stop, project paths as full agent paths
Nightly Build / build (push) Successful in 6m31s
2026-07-21 10:47:12 +01:00
dguiducci 780524a765 install: polish install/uninstall scripts with Docker setup, dep checks, and first-run flow
Nightly Build / build (push) Failing after 2m58s
2026-07-20 14:36:05 +01:00
dguiducci 44dc67cda0 Setup: utente admin via web, ruoli e run-context con security-group, onboarding install/uninstall script
Nightly Build / build (push) Failing after 6m12s
2026-07-20 12:54:56 +01:00
dguiducci 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
2026-07-19 22:35:06 +01:00
dguiducci ba911ae8cb feat(plugins): plugin pages, per-user config, capabilities gate, mobile/telegram refactors
- Plugin HTTP routes + web pages (plugin-page-host, plugin-catalog, plugin-detail)
- Plugin access grants + per-user config (DB tables + API + frontend forms)
- Capabilities-based guard (caps.rs) replacing role-id checks
- Mobile connector: message routing, payload types, router refactor
- Telegram bot: auth flow, event handling improvements
- Honcho plugin: substantial rework
- Sidebar: plugin pages integration, role-driven visibility
- i18n: new strings for plugins, connectors, capabilities
- Remove unused mascot asset
2026-07-19 20:47:09 +01:00
dguiducci f85876350e feat(mcp): WhatsApp connector, archivable catalog, MCP connector config endpoint 2026-07-19 10:55:44 +01:00
dguiducci c389962e3c Profilo utente (birthdate/sex/notes), kid agent, i18n, shared folders, UX 2026-07-19 08:45:17 +01:00
dguiducci 126886e309 Major rebranding, i18n, dashboard, shared folders, role capabilities
- Rebrand: new app/agent icons, SKALD.md, warm "paper" CSS palette
  (terracotta accent, --radius tokens, WCAG contrast, reduced-motion),
  updated favicon, tray icon, skaldkonur asset
- i18n: backend crate (i18n.rs, locale column, ui_locale config),
  frontend library (web/lib/i18n.js, I18nMixin, t(key)),
  translation files (web/i18n/), every component wired
- Dashboard: <dashboard-page> replaces old home-page content;
  <app-copilot> becomes the landing page (full/dock layout modes)
- Shared folders: API endpoints (shared_folders.rs), frontend page,
  can_write membership, container mount topology, user_fs routing
- Role capabilities: new db table & authorization seam (data not enums),
  roles.attrs JSON for ui_mode / interface select
- Setup: skald-setup prompts for language + password, sets ui_locale
- General: components migrated to CSS variables, Lit conventions cleanup,
  connectors/catalog/marketplace/approval refactoring
2026-07-18 21:38:42 +01:00
dguiducci 2b35312abd feat(media): multimodal attachments — image/video inlining per model capabilities
Adds media.rs for per-turn attachment routing, message_builder
partitioning by resolved model capabilities, controller endpoints
for uploads, and server routing for /data/* behind session auth.

See CLAUDE.md §Multimodal attachments for full design.
2026-07-18 18:02:45 +01:00
dguiducci 4fea04c57f feat(providers): OpenAI-compatible provider types as runtime data (providers.yaml)
Replace the five copy-paste OpenAI-compatible provider structs (moonshot,
moonshot_code, deepseek, zai, lm_studio) with one DeclaredProvider engine
driven by a providers.yaml catalog loaded at boot from the cwd — edit and
restart, no rebuild. The YAML carries identity, endpoints, per-model JSON
field mapping, id-glob enrichment rules and the reasoning knob (effort /
thinking request kinds); capability_flags keep vision and future input
modalities declarative. Anthropic, Ollama, OpenAI and OpenRouter stay
native (different wire protocols or bespoke parsing) and register
alongside; colliding declared ids are skipped. The shipped catalog is
validated by a unit test.
2026-07-18 16:19:05 +01:00
dguiducci e6c4e202a4 feat(mcp): OAuth per-user connectors (§15) — providers, PKCE copy-paste flow, env credential delivery
- oauth_providers registry table (per-provider client creds) + db/oauth_providers.rs
- mcp/oauth.rs: authorization-code + PKCE S256, RAM-only TTL'd flow store, copy-paste consent
- mcp/install.rs + verify.rs: connector file install + manifest verification
- activate persists a pending row (needs_oauth); /mcp/oauth/start + /complete exchange code for refresh token
- credential delivery via env var on docker exec (google_authorized_user JSON), never on disk
- mcp_catalog/mcp_user_servers: additive OAuth columns (ensure_column), catalog_name/oauth_provider/deliver_json bare TEXT snapshots
- frontend: connector-detail.js (OAuth login panel), shared/connector-common.js, connectors.js admin Sign-in providers modal
- API: /mcp/providers (admin OAuth creds), /mcp/oauth/start|complete
- .gitignore: add /homes/ (instance data), /connectors/, /reset.sh; drop stale /secrets/
2026-07-17 21:47:51 +01:00
dguiducci 6d299472e3 feat(mcp): Connectors — catalog + global vs per-user runtimes (§7/§14/§15)
Re-architects MCP from one owner table + agent-written registration into an
admin-curated catalog with two runtimes unioned per session, surfaced in the UI
as "Connectors" (mcp/schema stays neutral, §0.1).

Two runtimes behind one seam (§7):
- Global runtime: shared, stateless connectors (web-search, Tavily…) on the
  HOST, connected at boot from mcp_global_servers, access-filtered per user via
  mcp_global_access.
- Per-user runtime: a user's activated connectors run INSIDE their container,
  started at first login from mcp_user_servers and living until restart (§9);
  docker exec -i children die via kill_on_drop when the UserContext drops.
- McpProvider trait (mcp/provider.rs): the session round-loop never learns which
  runtime owns a server. McpManager implements it directly (inert ownerless
  bundle); UserMcpView implements global ∪ user with an accessible_global
  snapshot. Both share McpManager::connect_all; McpServerSpec +
  global_row_spec/user_row_spec turn a DB row into a connectable spec.
- mcp-client: McpServerConfig.launch_in runs a stdio command inside a container
  via docker exec -i (set at runtime, never parsed from config).

Authorization is a capability on the role, not `if role==admin` (§0.1/§14):
role_capabilities table + db/role_capabilities.rs — register_remote and
register_local_from_catalog are self-service (seeded on every new role), while
register_local_script and manage_catalog are admin-only. admin holds every
capability by construction. This removes the agent-facing register_mcp/delete_mcp
tools and the mcp kinds of list_items/toggle_item, closing the §14 RCE vector.

Schema:
- Registry: mcp_catalog (vetted templates — schema only, no live creds),
  mcp_global_servers + mcp_global_access, role_capabilities.
- Owner: mcp_user_servers (per-user activations; api_key encrypted at rest,
  catalog_name a bare TEXT snapshot, never an owner→registry FK).
- Drops the old owner table mcp_servers.

API + UI: src/frontend/api/mcp.rs (admin catalog/global/access + user
available/activate/activated, all capability-gated via require_cap);
web/components/connectors.js (<connectors-page>) renders the user view always
and the admin view for role_id === 'admin'.

Deferred: interactive per-user auth (OAuth callback / QR / SSH elicitation, §15)
— only none/api_key wired; no boot seed of catalog presets; per-(user, session)
MCP grant model still open.
2026-07-16 16:44:12 +01:00
dguiducci 8dac783878 feat(container): per-user Docker sandbox + mapped per-user filesystem
Realizes blueprint §6: each user gets a permanent Docker container
(skald-{userid}, our own skald-runtime image with python+node) as their
execution sandbox. Docker is now a hard requirement — a missing daemon fails
Skald::new and the process exits at boot.

- ContainerManager (crates/skald-core/src/container/): docker availability
  check, builds skald-runtime from the embedded Dockerfile, reconciles one
  running container per active user at boot, stops them at shutdown, and
  ensure/remove on user create/delete. Shells the docker CLI (no client crate).
- UserFs (core-api): pure value type carried in ToolContext, mapping the agent's
  single namespace — ~/ → homes/{userid}, shared/{X}/ → shared/{X} (membership),
  user-memory/ + shared-memory/ → SQLite — to host and container paths.
- execute_cmd now runs inside the caller's container via `docker exec`.
- fs-tools resolve every physical path through UserFs to the per-user host
  workspace, host-side, with fail-closed symlink/`..` containment
  (resolve_host_path: canonicalize + prefix-check). grep_files resolves its root
  the same way but stays disk-only.
- shared_folders + shared_folder_members (registry, junction table with
  can_write) back the shared-folder membership that drives both the container
  mounts and the shared/{X} routing.
- Threading: UserContext.fs → ChatSessionManager → handler → ToolContext.fs.

Per-user MCP servers do not yet run in the container (next round).
2026-07-11 15:54:21 +01:00
dguiducci 2c54778116 feat(mobile): per-user device bindings, multi-user Inbox routing, and admin-mediated authorization
- Device→user bindings persisted in config table (auth.rs), loaded at plugin start
- RelayApp now routes Inbox responses per-user via UserChannelApi, never globally
- New mobile_bind_device LLM tool for admin-mediated device→user assignment
- Per-user event forwarders (events.rs) with per-user debounced notifiers
- Config listener (auth::config_listener) refreshes bindings cache reactively
- Reconcile loop catches users who unlock after boot
- Hello/Logout treated as device-registry ops (no user resolution needed)
- Unbound device payloads are silently dropped
- RelayAgent::authorize_client → bind_device (atomic bind + authorize)
- Approval rules seed mobile_bind_device/revoke_device as require
2026-07-11 11:18:35 +01:00
dguiducci a847dda88f feat(memory): dual-pool memory namespace, FTS search, and prompt injection
Add a virtual memory namespace backed by SQLite, surfaced through the
fs-tools, with private (per-user) and shared (system) stores.

Storage
- `memory_docs` owner table + external-content FTS5 index with sync triggers.
- `db/memory_docs.rs` accessor: get / upsert / list / search (bm25+snippet) / delete.

Routing (tools/fs)
- `classify_memory` splits paths on the raw first component; `..` clamps inside
  the store, never escaping to disk.
- read/write/list/edit/insert/replace/search_file route `user-memory/` to the
  owner pool and `shared-memory/` to the system pool (a singleton captured in
  `register_all`); every other path stays on disk. Each tool extracts a pure
  transform shared between its disk and memory paths.
- New `memory_search` tool over the FTS index (scope private/shared/all),
  with a sanitised FTS5 query. grep_files stays disk-only.

Approval
- `user-memory/*` allow (read+write); `shared-memory/*` reads allow,
  writes require approval so the agent can't silently push one person's data
  into shared memory. `memory_search` allowed via a path-less rule.
- migrate away the old `memory/*` and blanket `shared-memory/*` rows.

Prompt injection
- `MessageBuilder::load_inject_memory` reads `user-memory/` (owner pool) and
  `shared-memory/` (system pool) inject entries from SQLite; disk paths
  unchanged. The system pool is threaded ChatSessionManager -> handler ->
  MessageBuilder.
- main and project-coordinator inject `user-memory/index.md` +
  `shared-memory/index.md`; common/memory.md rewritten for the two stores.
2026-07-11 02:11:00 +01:00
dguiducci 7dd77d4ef4 feat(auth): login, roles, user mgmt, setup wizard, and session guard
- New skald-setup crate: interactive first-run wizard that creates the
  admin user, prompts for encryption choice and password
- Auth system: session-based login/logout with cookie, guard middleware
- Roles API: CRUD for data-driven roles, seeded on first boot
- Users management API: create, list, edit, delete users
- Setup state API: check if first admin has been created
- Frontend: login-page, setup-page, users-page, roles-page, profile-page
  components with corresponding CSS
- Topbar: avatar dropdown with profile link and logout
- Sidebar: nav entries for Users and Roles (admin only)
- Page shell CSS: layout support for the new pages
- build.sh: builds both skald and skald-setup binaries
- run.sh: runs skald-setup before the server loop
- CLAUDE.md: updated workspace layout and build/run docs
2026-07-10 19:19:25 +01:00
dguiducci 178a38357e feat(users): UserManager with per-user SQLCipher, and extract skald-core crate
Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.

## UserManager + per-user encryption (§9/§11)

New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.

New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).

- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
  <0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
  the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
  the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
  `create_owner_tables` (one owner's content, identical in every file). No FK in
  the owner bucket may reach the registry — enforced by a standalone test.
  Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
  key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
  so a crash leaves an orphan file, never a user without a database. `open_db`
  never creates: a missing file is an error, not a silent empty database.

Not consumed yet: no login, call sites still use the shared system.db pool.

## Extract crates/skald-core

The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:

- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
  (sibling of `http_router`), so the core no longer downcasts to
  `MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
  teardown-and-respawn; the core defaults to the supervisor exit code. The core
  loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
  only emits tracing events.

All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
2026-07-10 16:48:51 +01:00
dguiducci 38494a85a9 First Version 2026-07-10 15:02:09 +01:00