Commit Graph
122 Commits
Author SHA1 Message Date
dguiducci 080ea736e4 feat: signpost the virtual memory roots inside the container, instead of leaving them absent
Nightly Build / build (push) Successful in 7m38s
`user-memory/` and `shared-memory/` live in SQLite, so nothing of them existed on
disk — and that nothing was worse than it looks. `cat user-memory/x.md` returned a
bare ENOENT, which a model reads as "the note is missing" rather than "wrong door";
and `mkdir -p user-memory && echo … > user-memory/x.md` *succeeded*, writing a real
file into the home that no reader ever visits (every reader goes to `memory_docs`)
and that the next `ls` then confirms as if it had worked.

Each root now gets a read-only bind mount holding a README that names the tools to
use instead. Read-only as a mount rather than as a mode: the container user has
passwordless sudo, so a chmod would be a suggestion, while `:ro` holds — remounting
needs CAP_SYS_ADMIN. Verified in a scratch container: write, sudo write, sudo chmod,
sudo mount -o remount,rw and sudo rm all fail. And a README rather than an empty
directory, because "Permission denied" is an error, not an instruction — models
answer it by reaching for sudo; the README puts the correction in the same directory
the failing command just named.

The mounts are deliberately not part of `UserFs`: they back no agent path and the
host-side fs-tools must never resolve into them. They reach existing containers as a
fourth self-heal axis in `reusable()`, not as an IMAGE_TAG bump — the image is
unchanged, and a bump would make every installation rebuild it to fix a mount.

The matching half is in `classify_memory`, which now strips the home spellings
(`./`, `~/`, `/root/`) before matching the root. Without it `~/user-memory/x.md`
missed the match and fell through to the disk router — becoming exactly the
invisible physical file the signpost exists to prevent.

`agents/common/memory.md` says the rule outright: the stores are reachable only
through the file tools and `memory_search`, never through `execute_cmd`.
2026-08-02 22:34:24 +01:00
dguiducci da0830aefa feat: an hour-precision clock that says so, and a cron tool that names the real timezone
Nightly Build / build (push) Successful in 7m30s
The datetime block claimed second precision it never had. It is built once per
request and a turn can run for minutes, so `17:54:31` is a lie by the time the
model reads it — and the model, having no way to know, wrote cron expressions
from it.

Rounding was already there but configurable (`round_minutes`, shipped at 60)
and justified by the prompt cache. That justification was false: the block is
the LAST system message, after the whole conversation, so the cached prefix is
identical from turn to turn whatever the timestamp says. Rounding buys nothing
for caching today.

So the knob goes and the granularity becomes part of the contract: always
truncated to the hour, stated in words, with a pointer to `date` for the cases
that need the minute. `DatetimeConfig` keeps only `enabled`.

- truncation happens in the DISPLAYED zone, not on the UTC epoch: +05:30 zones
  would otherwise render 20:30 — an hour off and not on an hour boundary, which
  reads as precise again.
- the weekday is spelled out. "next Tuesday" is a far more common ask than the
  minute, and weekday-from-date is exactly the arithmetic models get wrong.

Also fixes a real bug found on the way: `execute_task` told the model, twice,
that cron expressions are evaluated in Europe/London — hardcoded, while
TaskManager uses the configured timezone. On a non-UK box every scheduled job
was written against the wrong clock. The description now names the zone the
scheduler actually uses (`TaskManager::timezone_name`), and the assistant's
AGENT.md stops repeating the literal.

CLAUDE.md: record that the instance is in production. The greenfield licence has
expired — schema changes need a versioning mechanism, and per-user SQLCipher
files mean it cannot be a boot-time sweep.
2026-08-02 22:20:52 +01:00
dguiducci 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`.
2026-08-02 21:40:21 +01:00
dguiducci 11f4ba8ed2 fix: replace the six Italian user-facing strings with the English wording already used elsewhere
Nightly Build / build (push) Successful in 7m33s
None of them was an isolated slip — each already had an English twin somewhere
else in the system, so this is alignment rather than translation.

The four in `ws.rs` are the slash-command replies (/sethome, /cost x2,
/compact), and the Telegram plugin — the same command set, reached through a
different surface — has said them in English all along. The two surfaces could
answer the same command in two languages. Adopted Telegram's wording verbatim,
and made the /compact "nothing to summarise" line match its twin's spelling
while there.

The two in `skald-relay-server` are the APNs alert body. That one is worth not
re-deriving: it is the *fallback* shown only when the notification service
extension cannot run, and the iOS app localises the same message under the
English key "Action required" (Localizable.xcstrings, en + it). English was
already the canonical form on the other side of the wire — the hardcoded Italian
only ever surfaced in the one case where no locale is negotiated with anyone.
2026-08-02 21:24:38 +01:00
dguiducci 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.
2026-08-02 21:21:14 +01:00
dguiducciandClaude Opus 5 d4b34e6130 feat: give the sandbox a real shell toolbelt — and make an image bump reach existing users
Nightly Build / build (push) Successful in 7m32s
The per-user container shipped python+node and little else, so an agent asking
for `unzip`, `ffprobe` or even `ps` found nothing and had to `sudo apt-get
install` mid-task. That fallback works, but it re-runs on **every container
recreate**, inside the task, where it costs latency and can fail — while the
image is **one, shared by every container**, so preinstalling costs its size
once for the whole box. Anything an agent reaches for repeatedly is therefore
cheaper baked in.

Added on that rule: jq, ripgrep, zip/unzip, xz-utils, sqlite3, wget,
openssh-client, procps, less, file, tzdata, dnsutils, iputils-ping, ffmpeg
(+ffprobe), imagemagick, poppler-utils and tesseract — with the ita/fra
language packs, matching the app's supported UI locales (eng and osd arrive as
hard deps). Deliberately left out: build-essential/python3-dev (~270 MB, only
for a pip package with no wheel) and pandoc (~216 MB) are big *and*
self-recoverable, so they stay on demand. 687 MB -> 1.3 GB, ffmpeg being most
of it.

The image tag goes v2 -> v3, which alone would have equipped nobody: a
container pins the image it was created from, so `ensure()` would have rebuilt
v3 and then happily reused every existing v2 container — the new tools would
have reached only users created from here on. `reusable()` now compares
`.Config.Image` too, turning a tag bump into a recreate, safe for the same
reason the `--user`/`--init` self-heal already is: the container holds no
durable state, everything lives in the bind mounts. An unreadable inspect
answers true, so a docker hiccup never churns a working container.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:04:20 +01:00
dguiducci 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.
2026-08-02 20:30:27 +01:00
dguiducci 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.
2026-07-29 15:53:51 +01:00
dguiducci 0ed94225b2 web: unify page headers into one shared page-header bar
Nightly Build / build (push) Successful in 7m17s
Every full-page view now renders the same sticky top bar (back button,
title, right-side actions) from the new web/css/page-header.css,
replacing a dozen per-page duplicates (.page-panel-header,
.project-page-header, .task-page-header, .llm-page-header, .um-header,
.apr-header, .llmr-header, .pv-header, .sa-header, .config-page-header,
.agents-page-header). Pages that padded the whole container (config,
agents, system-agents, llm-requests, models-hub) move that padding into
a body wrapper so the bar sits flush and stays pinned on scroll.
Back buttons are standardized to the icon-only .page-header-back.
2026-07-29 12:36:24 +01:00
dguiducci 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.
2026-07-29 11:36:47 +01:00
dguiducciandClaude Opus 5 8bcf09a67e chore: delete scripts/ and cut requirements.txt down to its real consumers
Nightly Build / build (push) Successful in 7m13s
scripts/ held the pre-marketplace MCP servers (gmail, gcal, gmaps, ssh, weather,
google_trends, whatsapp, serpapi_flights). Nothing referenced them any more:
connectors are admin-curated and installed into connectors/ from the
marketplace, and ci/package.sh never shipped scripts/ in the first place — so on
every installed box requirements.txt was pulling google-auth, googlemaps,
paramiko, trendspyg and friends for files that did not exist there.

requirements.txt now states what it is actually for: the two TTS plugins, which
spawn a bare `python3` on an embedded server script and so have no dependency
reconciler of their own. A connector's deps stay with the connector —
`ensure_installed` puts them in .pydeps/node_modules inside the user's
container, `ensure_installed_host` beside the files for a global one.

The venv itself stays load-bearing for those two plugins and for the host pip
that installs a global connector's deps, so the run/install/update scripts keep
creating it — but their "Python MCP servers will be unavailable" warning was
naming the one thing that no longer depends on it, and now says what really
breaks.

CONNECTOR_MANIFEST_GUIDE.md moves to the repo root: it was the one thing in
scripts/ still referenced (CLAUDE.md), and being under a gitignored directory it
had never been committed at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 22:19:01 +01:00
dguiducciandClaude Opus 5 70f6a927bc fix: memory-lint agents were missing from the agents page
Both metas declared "strength": "medium", which is not an LlmStrength
(very_low | low | average | high | very_high). `discover()` warns and skips a
meta.json it cannot deserialize — deliberately, so one bad file does not blank
the whole roster — so the two agents never reached /api/agents and the page's
"system" section only ever showed event-triage.

The skip is right; its silence is not. `agents::tests::every_shipped_agent_meta_parses`
deserializes every shipped meta.json, so a typo'd field now fails the build
instead of quietly costing an agent its place in the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 22:18:51 +01:00
dguiducci 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
2026-07-28 21:59:37 +01:00
dguiducci 0b793d56ae feat: system agent icons — spider (TIC), firefly (private lint), bee (shared lint)
Nightly Build / build (push) Successful in 7m13s
System agents now form an insect family, visually distinct from chat agents:
- TIC: Cat → Spider 🕷️ (new icon replaces old)
- Private Memory Lint: Firefly  (new icon + meta.json field)
- Shared Memory Lint: Bee 🐝 (new icon + meta.json field)

Updated agents/README.md and SKALD.md.
2026-07-28 21:43:01 +01:00
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 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.
2026-07-27 23:49:50 +01:00
dguiducci a78259551e README: add clone origin, website, binary downloads, and iOS app section
Nightly Build / build (push) Successful in 7m13s
2026-07-27 22:11:47 +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
dguiducci 6f35c53d93 activate_tools: diagnose a group instead of pretending it activated
A group name that resolved to no running MCP server was granted anyway,
persisted in `activated_tools`, and reported as a success with "registered
but not yet running — tools will appear after reconnect". Every part of
that was false: nothing registers connectors anymore (the agent-facing
`register_mcp` went away with §14), no reconnect will ever produce the
tools, and the model — believing it had succeeded — called `mcp__x__…` a
round later and failed there instead of here. The junk grant row stayed in
the session forever, resolving to zero tool defs on every projection.

`SkaldToolActivator` now resolves first and acts only on what resolved,
walking the connector states in order: the built-in `config` group, then
the servers running in this user's view, then `mcp_user_servers` (owner
pool), then `mcp_global_servers` + `mcp_global_access`, then `mcp_catalog`
+ `mcp_catalog_access` (registry pool), then unknown. Only `activated`
touches the in-memory grant set and the DB; everything else leaves no
trace at all.

The result is a JSON object keyed by group name — `status` (activated /
needs_login / not_activated / not_authorized / unavailable / unknown),
`tool_prefix`, `tool_count`, `description`, `message` — with the same
shape whether the call succeeded or not, so the model parses one thing
rather than prose. `message` is written to be relayed to a non-technical
user and says who can fix it: the user in Connectors, or the admin. When
no group at all activates, the tool fails with that same JSON, so the
model reports the diagnosis instead of proceeding. A diagnosis query that
errors is logged and falls through to the next candidate — a broken lookup
must never become a false claim about a connector.

The activator needs the registry pool, the user id and the config defs;
both construction sites are updated, so a sub-agent gets the same
diagnosis as the root agent.

Removes `tools/activate_tools.rs` and its interface-tool registration: it
was unreachable (`SkaldToolSet::find` prefers natives, and `NATIVE_NAMES`
explicitly drops the legacy interface tool of that name) but carried the
same wrong string, waiting to be fixed twice.
2026-07-26 21:41:53 +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 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.
2026-07-26 19:03: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 3fca7867fa agent-loop: drop leftover empty wiring module (phase 2) 2026-07-26 12:18:42 +01:00
dguiducci 0297fe71bd agent-loop: root turn driven by the library kernel (phase 2)
ChatSessionHandler now runs the root turn on the agent-loop kernel
instead of run_agent_turn; sub-agents follow on the same kernel via
DelegateTool. The old loop stays for resume/recovery until phase 3.

agent-loop:
- DelegateTool + AgentCatalog/AgentProfile (full toolset override,
  per-child selector/assembler, frame-scoped get), StaticCatalog,
  FilteredToolSet; sync flow with sticky child_token; batch via the
  generic fan-out
- manager: start_loop skips the registry (children are not
  double-driving; they ride the parent's token tree); LoopParams gains
  selector/token overrides
- store: get_frame, get_call, set_call_extras; HistoryStore result text
  aligned to raw-stored semantics (projection formats)
- events: ApprovalRequired.request_id, AgentSpawned/Finished parent
  info; AskUserTool with_name + suggested_answers alias + Question.frame

skald-core (loop_adapters + handler):
- SkaldAssembler (byte-parity port of MessageBuilder's projection:
  scratchpad/summary/window, DTL Kimi/Anthropic injection, media, user
  coalescing, reasoning echo) + AgentSystemContext (prompt layers,
  substitutions, MCP list, shared folders, user profile)
- SkaldAgentCatalog (build_sub_agent_config port), SkaldHumanChannel,
  scratchpad/todos tools, execute_task sync/async alias,
  LegacyInterfaceTool, PendingLiveInput
- ApprovalGate: PendingWrite diffs via LoopEvent::Host (memory/disk
  routed like the fs-tools); SkaldWritePreviewHook for executed-write
  diffs; EventTranslator LoopEvent→ServerEvent (display meta, preview,
  FileChanged, AgentStart/Done, root-only Done/Truncated/Cancelled)
- handle_message: builds TurnParams and drives the kernel; resume of
  pending tools runs first (results belong to the previous turn);
  ChatEvent publication stays handler-side; /stop cancels the live loop
- ToolRegistry.get_tool/all_tools; def builders made pub(crate)

Full workspace suite green (179 skald-core, 34 agent-loop, adapters
incl.); two pre-existing doc-test failures fixed along the way.
2026-07-26 12:15:53 +01:00
dguiducci d50abbb0fa agent-loop: Skald adapters behind the crate traits (phase 1)
New skald-core::loop_adapters module — implements the agent-loop trait
surface over existing infrastructure, unused by the current loop (wired
in phase 2):

- SqliteHistory: HistoryStore over chat_sessions_stack/chat_history/
  chat_llm_tools/chat_summaries, no schema change; CallState maps 1:1 on
  the existing status strings; wire call ids synthesized as tc_{id}
- SkaldSelector: ModelSelector over LlmManager with the agent's strength
  captured per-turn (D14); DtlMode → ToolRendering mapping (D15)
- SkaldActivationSource + SkaldToolActivator: DTL catalog + persistence
  (activated_tools, anchored at the triggering message) behind the
  crate's protocol traits; unifies the grants/persistence split
- ApprovalGate: port of run_approval_gate (pre-approved, engine, fs
  fast-path, auto-deny, AwaitingHuman + block on human); a closed human
  channel maps to the new GateDecision::Suspend in agent-loop
- SkaldToolSet + CoreToolBridge/McpToolBridge: core-api and MCP tools
  run inside the crate's kernel (execution bridged, execute_cmd keeps
  its teardown; D7 MarkInterrupted for shell)
- agent-loop: re-export async_trait at root; EventSink::new made public

17 adapter tests green (temp-DB integration); full workspace suite green
(pre-existing honcho-client doc-test failure untouched: missing dev-deps).
2026-07-26 07:15:36 +01:00
dguiducci 882a8c9cb9 llm: switch Skald to agent-loop Model clients; drop llm-client (phase 1, D13)
The LLM call path now runs on the agent-loop crate's clients and trait:

- core-api: BuiltLlmClient.client is Arc<dyn agent_loop::model::Model>;
  chatbot.rs (ChatbotClient + wire types) deleted; APP_NAME re-exported
  from agent-loop
- providers (openai/anthropic/ollama/openrouter/requesty/declared) build
  OpenAiModel/AnthropicModel/OllamaModel with the model's wire id
- LoggingModel decorator (llm/logging.rs) replaces LoggingChatbotClient;
  per-request correlation (session/stack/user) travels in the new
  ModelRequest.log field, never sent to providers
- llm_call/llm_loop/compactor speak Model::complete + ModelResponse;
  retriability via Model::is_retriable (structured status, B6 rule now
  the crate's default); payload persistence reads RawMeta off
  ModelResponse/ModelError
- crates/llm-client and skald-core/src/chatbot deleted

Full workspace test suite green (incl. 162 skald-core + 32 agent-loop).
2026-07-25 23:55:17 +01:00
dguiducci b8cc6d263b agent-loop: new crate — LLM loop kernel + Model clients (phase 0)
Extract the LLM agent loop into a standalone workspace crate with zero
deps on skald-core/core-api (blueprint project-loop.md, D13-D15):

- kernel: round loop, model fallback with rebuild, parallel tool fan-out
  (ordered id alloc / bounded concurrent exec / ordered record), streaming
  deltas drained before outcomes, sticky cancellation
- models: OpenAiModel/AnthropicModel/OllamaModel/LmStudioModel ported from
  llm-client onto the Model trait; ModelError carries the HTTP status;
  is_retriable default = the 401/403/404/422 rule
- DTL as crate protocol (ToolRendering Inline/DeferredToolReference/
  SystemToolBlock; Anthropic conversions + Kimi system+tools passthrough),
  host catalog behind ActivationSource/ToolActivator
- HistoryStore durability contract + InMemoryStore; LinearAssembler with
  well-formed projection (incl. DTL injection, summary, crash survivors)
- LoopManager singleton (broadcast bus + live registry), one live loop
  per conversation, orphan-marking on start_turn
- 32 tests green (kernel §13 suite, assembler DTL, SSE/Anthropic ports),
  clippy clean
2026-07-25 23:40:41 +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 9dafc4bfaa fs-tools: show agent path, not host path, in tool messages
Nightly Build / build (push) Successful in 6m49s
rewrite_to_host overwrote args["path"] with the resolved absolute host
path, which then leaked into every message the on-disk execute returned to
the agent (e.g. edit_file's "Text not found in /home/.../SKALD.md"). The
agent must only ever see its virtual namespace.

rewrite_to_host now stashes the agent-visible path under a private key while
keeping the host path in args["path"] for I/O; each execute renders messages
from the stashed path. The key is never persisted (tool args are logged from
call.arguments before run_with rewrites them) nor sent to the LLM.

Fixed across edit_file, write_file, insert_at_line, replace_lines,
search_file and grep_files. read_file and list_files were already correct.

Added a regression test asserting no host path component appears in the
output of a physical-path write/edit/grep.
2026-07-25 00:52:21 +01:00
dguiducci f6665ae49d mobile: show login screen for stock browsers when not authenticated
Nightly Build / build (push) Successful in 6m57s
Add an inline auth gate to mobile.html that checks /api/auth/me on load.
Skipped when ?native=true (the iOS shell handles auth in background).
Includes a vanilla JS login form (no Lit) reusing the existing login styles.

Bump version to 0.1.2.
2026-07-24 23:52:54 +01:00
dguiducci ccd6e4fbea llm-requests: render DTL payloads (Kimi system-tools, Anthropic tool-reference)
Nightly Build / build (push) Successful in 6m51s
2026-07-24 21:22:49 +01:00
dguiducci db6e395c11 chat: stick-to-bottom auto-scroll with jump-to-latest button
Nightly Build / build (push) Successful in 6m51s
Auto-scroll now yields when the reader scrolls away from the bottom, so a
fast-streaming reply no longer fights someone reading the start. Stickiness is
a flag driven by a passive scroll listener (not a per-flush distance check,
which breaks when one flush adds more than the threshold of content); scrolling
back within the band re-arms it.

Centralised in chat-session.js (_scrollToBottom(force) + _forceScrollToBottom
+ _messagesContainer hook), removing the duplicated overrides in copilot.js /
chat-page.js. Force-scroll is used where the latest must be shown: history
load, chat reopen, approval/clarification prompts, and message send.

A sticky 'jump to latest' affordance appears only while scrolled up.
2026-07-24 21:06:04 +01:00
dguiducci d1d0a2af26 llm: add dynamic tool loading (DTL) — Kimi system-tools + Anthropic tool-reference
Nightly Build / build (push) Successful in 6m51s
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.
2026-07-24 20:48:04 +01:00
dguiducci 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
2026-07-23 22:03:43 +01:00
dguiducci 798e55951b ui: shape simplified interface — hide reasoning, show projects
Nightly Build / build (push) Successful in 6m46s
Release / verify-version (pull_request) Successful in 3s
Release / release (pull_request) Has been skipped
2026-07-23 18:03:28 +01:00
dguiducci f1bae3e84f ci: include commands/ in packaged tarball
Nightly Build / build (push) Successful in 6m48s
2026-07-23 17:51:11 +01:00
dguiducci 42c0eaf2ec Live-refresh connectors after marketplace reinstall
Nightly Build / build (push) Successful in 6m45s
After a reinstall the catalog entry carries new llm_short_description,
icon and code. Previously the running servers (global + per-user) kept
their old metadata and code until the next login.

- Add refresh_connector_after_reinstall on Skald: re-snapshots the
  description from the catalog, reconciles local files on per-user
  connectors, and restarts both the global and per-user servers
- Add set_description db accessor for mcp_global_servers
- user_row_spec_resolved now injects the live catalog description
  (over the bare name) so user-runtime connectors show the right blurb
- marketplace install() fetches a fresh feed instead of the browse
  cache, so a reinstall reflects the changed manifest immediately
2026-07-22 23:21:03 +01:00
dguiducci aa4f31ec64 Fix memory-docs line-count SQL edge case, remove stale tmp-referencing test
Nightly Build / build (push) Successful in 6m46s
- memory_docs list_with_metadata: use SQL substr to detect trailing
  newline instead of counting newlines and adding 1 unconditionally
- list_files: remove meta_smoke tests that referenced a non-existent
  scratchpad directory
2026-07-22 22:34:09 +01:00
dguiducci 7769b6689d list_files with_metadata mode, deeper JSON outline, MCP connector descriptions in prompt
Nightly Build / build (push) Has been cancelled
- list_files: new with_metadata parameter returns {path, line_count?, size}
  per entry (both disk and memory-docs) so the agent can spot large files
  worth outlining before reading
- ast_outline: replaced flat tree-sitter JSON walker with a recursive one
  that shows nested keys at every depth, with inline scalar values and
  container summaries
- message_builder: format active MCP connectors as a table with description
  instead of a bare bullet list
- read_file description now hints to use get_ast_outline first
- tools.md: agent guidance to outline before reading
2026-07-22 22:32:43 +01:00
dguiducci e71990347d inbox: improve pending items display and live updates
Nightly Build / build (push) Successful in 6m54s
2026-07-22 20:58:04 +01:00
dguiducci 0156bf6814 llm: add Requesty provider, improve message builder, wire bundles
Nightly Build / build (push) Successful in 6m43s
2026-07-22 20:50:31 +01:00