Release 0.3.0 #5

Merged
dguiducci merged 18 commits from main into release 2026-08-24 22:01:48 +01:00
Owner

Version bump to 0.3.0 and the CHANGELOG section closed as ## [0.3.0] - 2026-08-24.

Merging publishes v0.3.0: the release workflow builds amd64 + arm64, deploys the tarballs to builds.skaldagent.net/releases/v0.3.0, flips releases/LATEST and republishes install.sh.

Version bump to 0.3.0 and the CHANGELOG section closed as `## [0.3.0] - 2026-08-24`. Merging publishes v0.3.0: the `release` workflow builds amd64 + arm64, deploys the tarballs to builds.skaldagent.net/releases/v0.3.0, flips `releases/LATEST` and republishes `install.sh`.
dguiducci added 18 commits 2026-08-24 21:58:23 +01:00
image_generate wrote the file into the server's own data/images/ and handed
that host path to the model. It is a path in nobody's vocabulary: not the
caller's home, not their container. Telegram's send_attachment therefore
resolved it under the user's home and answered "file not found", and
read_file, execute_cmd and the viewer could not reach it either. The web URL
was the only surface that worked, which is why the failure only ever showed
on Telegram -- and why the model there, having no working way to hand the
file over, started inventing send_photo and send_media.

Placement moves to the tool, the one place holding a ToolContext:

- The manager returns bytes (generate_bytes) and no longer knows where an
  image goes. It has no UserFs and no session, so it never could have.

- run_with saves through uploads::save_to_home into uploads/{session}/. The
  returned path is agent vocabulary, so every consumer resolves it, and that
  is the one directory the media inliner is authorized to read from -- a
  vision model can be shown the image it just made. execute_async, the
  context-free path, now fails loudly rather than writing somewhere nobody
  can read; same shape as execute_cmd.

- The extension is sniffed rather than assumed png: it is what decides
  whether Telegram sends the picture inline or as an anonymous document, and
  providers return jpeg and webp too. The file is named after the prompt, so
  it reads as something in the explorer and in Telegram.

The result still carries a url, since the chat renders Markdown images and
![](url) beats naming a file the user then has to open. It points at
/api/file?path=..., which resolves through the caller's own UserFs. The old
/api/images/{id} route is removed: it had no writer left once placement
moved, and it addressed one instance-wide directory behind require_auth
alone, with no notion of who owned the image -- the same shape as the /data
static mount removed before it. That leaves data_root unused, so the manager
no longer knows about the server's filesystem at all.

Docs: the Telegram page explains send_attachment as the channel's equivalent
of show_file_to_user; the ComfyUI page says where a generated image lands and
which of the two handles to use where.

Also introduces CHANGELOG.md and the standing rule for it in CLAUDE.md.
feat(agents): learn and reuse the user's writing style
Nightly Build / build (push) Successful in 9s
1b709a880f
The chat agents now treat a stated preference about how something should be
written — or a correction to a draft they produced — as durable, and record it
in a `## Writing style` section of `user-memory/user.md`: preferred wording,
how emails open and close, what changes between the formal and the informal
register, and contacts written to differently from everyone else.

The section is capped at 10 lines, because it shares `user.md`'s own 40-line
budget and per-recipient detail is the half that grows without bound; past
that, the whole section moves into its own note and leaves a pointer behind.
A rule that turns out to be wrong is corrected in place rather than joined by
a second bullet contradicting it.

Shipped as the shared fragment `common/writing-style.md`, included by the
three `type: chat` agents. Only `assistant` injects `user.md` automatically,
so the fragment closes by telling the other two to read it before drafting.
fix(event-triage): never notify about a filtered event
Nightly Build / build (push) Successful in 40s
934726a75d
Triage was applying the user's notification preferences correctly and then
calling notify() anyway, with the filtering itself as the summary ("filtered
as generic marketing per user preferences") — the interruption the rule was
written to prevent, delivered with an explanation attached.

Nothing in the prompt said that notify() *is* the interruption, so using it as
a record of the decision looked coherent. Say it plainly instead, in the three
places the model passes through: notify delivers immediately and has no silent
variant, a rule that filters a category means no call at all, and a summary
that mentions filtering is the tell that the rule is about to be broken. The
tool description carries the same statement at the call site.
feat(files): a Files section over the caller's whole space
Nightly Build / build (push) Successful in 5m36s
488c702517
Until now file browsing existed only inside a project, and the two memory
stores were reachable only by the agent's tools. `#files` is the general
surface: home, both memory stores, the shared folders and projects the
caller belongs to, plus the read-only skills and docs trees.

The root is virtual, and that is the design. Anchoring at `~` is wrong:
the explorer reads host-side, while `shared/`, `projects/`, `skills/` and
`docs/` are bind mounts inside the container — a page rooted at the home
would show less than the user has with no way to reach the rest, and on
native Linux would show Docker's empty mountpoint stubs, a door that
appears to work and leads nowhere. So level 0 is a synthetic list from the
new `GET /api/files/roots`, serialized from the caller's `UserFs` plus the
two virtual memory roots. It sends `kind`, never a label: labels are copy
and get translated.

`GET /api/files/dir` now answers `{ path, can_write, entries }`, and a
memory path is classified before `resolve_view_path` (which refuses one)
and listed from `memory_docs`: one level derived from the flat key space
by the pure `memory_docs::immediate_children`, over a single query whose
unslashed prefix also spots an exact note as "not a directory". Memory is
read-only from the page — every writer routes through `resolve_view_path`,
and `shared-memory/*` is `@fs_write require` for the agent, so a button
that walks past that rule is a decision of its own.

The explorer moves out of projects into `shared/file-explorer.js`, taking
`root` + `rootLabel` and reading `can_write` from the listing rather than
from its host: writability changes per branch and comes from the same
`UserFs::can_write_to` the server rejects writes with, so the buttons
offered and the writes accepted cannot disagree. Deep-linking needed it
steerable without a two-way binding, hence `rel` in and
`explorer-navigate` out — the event fires only for a click, never for a
`rel` the host set, so echoing it back is a no-op.

The URL carries the agent path of the open folder in one parameter, the
same vocabulary the assistant uses, so a link is shareable and pasteable
into a conversation; which root it belongs to is derived, not stored.

docs/: a new files.md, plus two pages this made false — shared-folders.md
claimed in three places that a shared folder has no explorer, and
memory.md never said a user can now read their own notes.
An eye next to the paperclip shares what the user has open with their next
message: the page, the folder being browsed, the file open in the viewer and
any highlighted passage (line numbers where a source view exists), plus which
entity a detail page is about. The bag is client-authored {label, value} pairs
in English — the backend only clamps (chars, never bytes), neutralizes the
harness tag and renders one <system-extra> block per message, deduped
consecutively so it appears exactly when the view changed. On by default,
per-device toggle, hover/tap to preview, a chip on every sent message;
docs/view-context.md for users, an updated harness.md clause for the model.
fix(view-context): tell the chat agents when not to use it
Nightly Build / build (push) Successful in 40s
8361a238c7
With the eye on, the snapshot of what the user is looking at was read as
part of the question: asked something unrelated to the open page, the
assistant went investigating the folder with a run of tool calls. The
transport was right; the prompt never said the block can be irrelevant.

New fragment agents/common/view-context.md, included only by the three
type: chat agents. It could have gone in harness.md — which all three
already include — but harness.md is also included by the two memory-lint
agents, which never receive a view. Hence the split, and hence moving the
snapshot bullet there too: both halves of the rule now live in one file.

The rule names the observed behaviour ("never open, list, search or
otherwise investigate ... just because it is there") and keeps the deictic
examples, so curing the over-use does not create the under-use.

docs/ gains the same rule where the in-app assistant reads it; the
CHANGELOG entry for the feature is extended, not duplicated.
The sent-message proof was a collapsed <details> under the user bubble: a
row of height in every bubble that carried view context, for something that
is evidence about the message rather than part of it. It also rendered a
blank line, since the bubble is white-space: pre-wrap and the template left
a newline before the element.

Now a faint eye in the bubble's bottom-right corner, revealing the pairs on
hover (on focus for keyboard and touch). Deliberately not expandable, so
scrolling back through a conversation cannot grow a second layout, and the
popover opens downwards rather than over the message it belongs to.

The i18n key stays alive as the mark's aria-label; docs and changelog wording
updated to match.
docs: the chat window, the Inbox and security groups
Nightly Build / build (push) Successful in 9s
72fa40708a
Three of the surfaces a user asks about most had nothing in `docs/`, so the
assistant answered from guesswork: the chat itself (its two layouts, the tab
bar and what lands on which tab, every control around the composer, the slash
commands it must never forward to the model), the Inbox (why background work
asks there rather than in the chat, and that an unanswered card is a stopped
job, not a slow one), and security groups — the honest answer to "why does it
keep asking me for permission?", including what a group is *not*: not a mode,
not a data boundary, and not advisory.

Each page states the misreadings users actually arrive with, since correcting
those is most of the work; the tables list what the person is looking at, not
what the code does.
fix(mcp): a failed handshake must not strand the child process
Nightly Build / build (push) Successful in 4m5s
67fc1455c5
An MCP server whose `initialize` answer is an error — a broken or version-
mismatched connector — starts fine and then never exits. `McpServer::start`
returned `Err` correctly, but the `Child` lives in the read-loop task rather
than in the returned value, so `kill_on_drop` followed a task nothing ever
drops. Every retry therefore left a live process holding three pipes and a
pidfd, and the supervisor's retry ceiling is deliberately not permanent.

The end state was not a dead connector but a dead instance: the process hit
its 1024-descriptor limit, `accept()` began failing with EMFILE, and incoming
connections queued on a socket nobody could accept from — while the process,
the port and every other connector still looked healthy. Observed in
production at ~5h from the first bad handshake to unreachable, with 229
orphaned interpreters.

`stop_server`/`stop_all` had the same hole from the other side: they document
the dropped handle as killing the process, but the task holds its end of
stdin, so the child stayed blocked on a read that would never return.

Both close with one seam. `McpServer` now owns a oneshot sender whose receiver
the read-loop selects on; nothing ever sends, so the drop is the message. That
covers a deliberate stop, the last `Arc` going away, and a `?` in `start()`
unwinding past the local binding before it was ever returned — including the
caller's `timeout`, which drops the same future. The loop then kills and, as
importantly, reaps: an unreaped child trades the orphan for a zombie holding
the same pipes.

Both leaks are covered by tests that fail without the fix.

Also raise LimitNOFILE to 65536: the installers write it, and update.sh heals
an existing unit additively, leaving an admin's own value alone. The leak is
the bug, but 1024 for a process sharing descriptors between the listener,
every user's SQLite handles and three pipes per connector is thin regardless.
The file existed here and at the root of ~/projects/marketplace, byte
for byte identical — two copies of one contract, which is a drift
waiting to happen. The one an author reads is the one sitting next to
the connectors, so this repo keeps a pointer instead of a copy: CLAUDE.md
now names the marketplace checkout and says that CONNECTOR_MANIFEST_GUIDE.md
there is the file to consult, and to edit, if the specification changes.
docs: split CLAUDE.md into an always-loaded core plus dev-docs/
Nightly Build / build (push) Successful in 10s
902f47ecd8
CLAUDE.md had grown to 152 KB (~21k words, ~40k tokens) and is loaded into
every coding-agent session. The cost is not the cache read, it is attention:
the rules that are genuinely invariant were drowning in the mechanics of
subsystems that most tasks never touch.

The split criterion is blast radius, not importance. A rule a change anywhere
could violate stays in CLAUDE.md — the commit rule, the production/schema
constraint, domain neutrality, the event-bus rule, the crate boundaries, and
the module map. The mechanism of one subsystem moves to dev-docs/, opened on
entry to that subsystem via a routing table at the top of CLAUDE.md.

Nothing was rewritten: every section was moved verbatim by line range and
verified line-by-line against the original. The only edits are cross-reference
repairs ("see the DB section" -> a link), the promotion of headings in the
extracted files, and a condensed "Current state" whose full text now lives in
dev-docs/users-auth-and-boot.md.

CLAUDE.md: 152 KB -> 31 KB. Twelve subsystem files plus an index under
dev-docs/, which now carries the same standing rule as docs/ and CHANGELOG.md:
a change to a subsystem updates its dev-doc in the same change.

No CHANGELOG entry: this is documentation for coding agents with no observable
effect on the application.
docs: name the sibling repositories in CLAUDE.md
Nightly Build / build (push) Successful in 9s
9c9ad5dd44
The marketplace, the iOS client and the Android client are checked out beside
this repo and are invisible from inside it, so a change here could break one of
them with nothing in context to say so. CLAUDE.md now lists all three by
relative path, states what each is, and — the part that matters — names the
coupling: plugin-mobile-connector for the two clients, the manifest format for
the marketplace.

The marketplace row repeats the existing rule rather than softening it: the
authoring spec is CONNECTOR_MANIFEST_GUIDE.md in that repo, edited there and
never restated here. The mcp-connectors dev-doc now uses the same relative
path instead of an absolute one under a home directory.
Four gaps off the coverage map, written for the in-app assistant:

- file-viewer.md — what each kind renders to, the live reload, editing a
  Markdown file and the conflict banner, git history mode, and why a `.tex`
  must be shown instead of a PDF built from it.
- tasks-page.md — the four sections, disable-vs-delete, where each kind's
  result lands, and that there is no "new task" button because tasks are
  created in conversation.
- profile.md — display name, language, password, and what an encrypted
  account means when the password is forgotten.
- users.md — creating a member and the irreversible encryption choice, the
  directory profile that feeds the agents' prompt, deactivating vs deleting,
  and the per-person event-triage interval.

Indexed in docs/index.md, cross-linked from files.md, tasks.md and access.md.
fix(web): a page host sized by its content renders narrow
Nightly Build / build (push) Successful in 9s
e5e8ccc92f
Models → TTS rendered as a 45px strip in the middle of an empty
workspace. The cause was not inside the page: `models-tts-section` had
no CSS rule at all, and an unknown custom element is `display: inline`.
Its host is switched to `display: flex` by JS, so the section became a
content-sized flex item in a row container. Its three siblings escaped
only because they were named in a sizing block that TTS was never added
to.

Replace that enumeration with `tasks-page > *, projects-page > *,
models-hub-page > *`. The three multiplexers render exactly one section
at a time as their only child, so a new section now inherits the sizing
by existing — the list of names was itself the bug, and forgetting it
was silent: no console error, no failed build, just a narrow page.

Also give every remaining page host the two properties that made this
survivable elsewhere: `flex-direction: column` on agent-inbox,
approval-rules, approval-groups, llm-providers and models-hub (in the
default `row` the width depends on the inner div remembering
`width: 100%`), and `min-width: 0` on agents, config and dashboard so a
wide table cannot push the workspace past the viewport.

Measured in headless Chrome against the real stylesheets: the TTS
section goes from 45px to 589px in a 590px host, matching its siblings.

Record the trap in dev-docs/frontend.md, which had no page-shell section
at all — and add the models-tts row missing from its component table.
The routing table said "before you touch one of these areas, open its
file", and the closing line explained why a pointer is not a summary.
Neither survives contact with a change that looks trivial: the diagnosis
feels complete after a grep, the edit is one line, and the file never
gets opened. That is how the narrow-page bug in the previous commit was
nearly shipped as a one-line addition to the very enumeration that was
the defect.

State the missing half. "The fix is obvious" is what triggers the rule,
not what excuses you from it, because a dev-doc is not a description of
the code — it is the rules and traps the code cannot state about itself,
and grepping the source finds what the code does, never what you must
not do to it. Add the two consequences that make it cheap to comply: the
same-change update rule means the file has to be opened regardless, so
opening it first is free and is the only moment it can still change what
gets built; and the file is read whole, since the paragraph that saves
you is not the one matching the grep.

Give the write-side standing rule its read half explicitly, where it
was only ever phrased as an obligation to type into the file.
feat(plugin-honcho): show what Honcho remembers about you
Nightly Build / build (push) Successful in 2m24s
9feaaaff29
The opt-in page gains a debug panel, once the user's saved flag is on:
service status (reachability, latency, the caller's own processing
queue, with the specific Honcho error when something is wrong), a full
overview (peer card, derived facts with ids, summary) and one text
field with two actions — search (raw ranked facts) and ask (Honcho's
server-side LLM answers), plus an in-page mini-guide.

Every endpoint gates on the per-user opt-in server-side, fail closed,
and derives the peer from the authenticated Caller — never from the
request body — since the workspace is shared. Honcho 404s are
translated per-endpoint as 'no memory yet' rather than failures.
chore(release): 0.3.0
Nightly Build / build (push) Successful in 2m35s
Release / verify-version (pull_request) Successful in 2s
Release / release (pull_request) Skipped
c1a8227e11
Closes the `Unreleased` section as `## [0.3.0] - 2026-08-24` and bumps the
workspace version, the two edits `ci/verify-version.sh` gates a release PR on:
the check fails the PR if `releases/v{version}` already exists on the build
host, so the bump is what makes the merge to `release` publishable.

The closed section carries a handful of items that shipped in `0.2.0` — the
Docker-daemon restart fix, per-user event-triage tuning, the file viewer's
syntax highlighting. CHANGELOG.md was written after that release and backfilled
them, and `0.2.0` has no section of its own to move them into, so they are
recorded here rather than nowhere.
dguiducci merged commit f1f7af601a into release 2026-08-24 22:01:48 +01:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dguiducci/Skald-Circle#5