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.
Two "unknown tool (not in this turn's tool set)" failures, one disease: the
turn's tool set was rebuilt from a different recipe depending on which entry
point happened to drive it.
A sub-agent got `ask_user_clarification`, `execute_subtask` and `activate_tools`
and nothing else — while `agents/common/tools.md` and every reporting agent's
prompt tell it to register its output with `update_scratchpad`. The child could
see the scratchpad injected into its context but had no way to write to it. It
now gets the scratchpad and todos tools, on the parent's `scratchpad_sid`: one
blackboard per session, as the surrounding code already declared.
`show_file_to_user` was injected per message by the WS handler, while
`resume_session` and `resolve_pending_call` rebuilt the list with `execute_task`
alone. So approving a card, or reconnecting mid-turn, continued the *same*
conversation with the tool silently gone. There is now a single recipe,
`ChatHub::session_interface_tools`, used by all three paths and fed by a builder
the shell installs once through `Skald::set_interface_tools_builder`: the core
keeps owning the tool, the shell keeps owning the policy of who gets it —
Telegram still does not, since it cannot act on OpenFile.
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
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).
Replace the old session_mcp_grants/stack_mcp_grants table pair with
a single activated_tools table that anchors each activation at the
assistant message_id that triggered it. The durable write moves from
the activate_tools tool itself to the round loop (handle_tool_call),
which has the message_id the DTL serializer positions injected tool
blocks against.
Introduce DtlMode (None / AnthropicToolReference / KimiSystemTools),
resolved per model from capabilities (opt-in via tool_search)
combined with the provider's dtl_format(). The message builder inserts
Kimi system {tools} blocks at the activation position, or emits
Anthropic tool_reference markers on the tool result. The tool-def
surface (all_tool_defs) switches shape: Anthropic declares everything
deferred; Kimi omits activated tools from the top-level array (system
takes over); None keeps the old grant-set logic.
Anthropic client: accept structured system arrays (cache_control on
the static block when DTL is active), carry defer_loading through
conversion, emit tool_reference blocks on result messages. Prompt
caching enabled exactly when DTL is active (anthropic provider).
MCP server list in the prompt is now a static catalogue (not split
Available/Active) — the split invalidated the cache on every activation.
Groundwork for providers.yaml dtl: key; Moonshot/Kimi providers wired
with kimi_system_tools and the k3* enrich now adds tool_search.
Compactor re-anchors activations whose message was compacted away.
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.
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.