Commit graph

28 commits

Author SHA1 Message Date
patriceckhart
205e362ed4 feat: provider labels in login/logout, graceful unknown provider fallback, model picker filtering
Login and logout dialogs show descriptive labels (Anthropic Claude Pro/Max, OpenAI ChatGPT Plus/Pro). Unknown saved providers fall back to an available one instead of crashing. Model picker only shows models from logged-in providers.
2026-04-25 19:25:51 +02:00
patriceckhart
25b2bd4c96 feat: changelog on update, full-width highlights, @ file picker docs
Changelog dialog now shows only the changelog section from release notes with headings in accent color. Works for local 0.0.0 builds (fetches latest release). Full-width highlight bars fixed everywhere via erase-to-EOL and trailing ANSI preservation in truncateToWidth. Session ops dialog fixed. README documents the @ file picker.
2026-04-25 11:24:09 +02:00
patriceckhart
b245be02e5 feat(models): support user-defined models via models.json
Reads $ZOT_HOME/models.json at startup and merges user-defined models into the active catalog with highest precedence. Provider keys like openai-codex are normalized. Documented in README.
2026-04-23 23:09:32 +02:00
patriceckhart
fdcdeb5eb1 feat(ext): interactive extension panels + persistence
Add open_panel / panel_render / panel_close / panel_key to the extension protocol, expose extension_dir + data_dir in hello_ack, wire panel rendering and key routing through the interactive TUI, extend the Go SDK, and document the new capability. Also fix doubled user-message indent and redundant assistant wrap.
2026-04-22 08:53:21 +02:00
patriceckhart
9b063d871b fix(session): export the full running conversation, handle quoted paths
Two bugs in yesterday's /session export + import:

1. Quoted / tilde paths weren't normalised.
   Drag-drop paste in the tui auto-quotes dropped file paths so
   the shell-style `/session import 'foo bar.zotsession'` stays
   well-formed. But the /session handler's expandTilde checked
   for a leading '~' and the string's first char was a literal
   quote, so the tilde never expanded and stat failed with
   "no such file or directory".

   unquotePath helper now strips a matching pair of surrounding
   single or double quotes before expandTilde runs. Applies to
   both export (dst) and import (src).

2. Export was writing only the meta row when called mid-session.
   The tui's default persistence strategy writes agent messages
   to the session file lazily: WriteNewTranscript runs once
   when the tui exits, NOT after every turn. Meanwhile the
   running agent's messages live in a sync.Mutex-guarded slice
   in core.Agent.messages. /session export was reading the file
   bytes off disk, which at that point only contained the meta
   row plus whatever was there on startup.

   New FlushSession hook on InteractiveConfig: the cli wires it
   to WriteNewTranscript against the current agent, then
   advances sessBaselineMsgs so the tui's own exit-time flush
   doesn't double-write. /session export calls the hook right
   before ExportSession, so the file on disk reflects the full
   running transcript at the moment the user hit enter.

Tests:

- internal/core/session_portable_test.go was already exercising
  ExportSession/ImportSession against on-disk files; this fix
  lives in the cli/modes glue, not in core.

- internal/agent/modes ad-hoc TestUnquotePathThenExpandTilde
  (run locally, not committed) covered the 8 tilde+quote
  combinations.

Verified: create a fresh session, type "hello", reply, "foo",
reply, run /session export. Exported .zotsession now contains
the meta row + 2 user + 2 assistant + 1 usage row. Re-import
into a different cwd via /session import <path>, /sessions to
confirm it lands as a resumable entry.
2026-04-20 10:19:53 +02:00
patriceckhart
ef80f9cd80 feat(session): /session export + import with portable .zotsession file
Lets one user hand a conversation off to another machine or
user. New slash command:

  /session                    picker with export / import rows
  /session export             defaults to ~/Downloads/<name>.zotsession
  /session export ~/foo       writes ~/foo.zotsession
  /session export ~/bar/x.zs  writes to that exact path (ext added if missing)
  /session import <path>      loads and switches to it

Exported file is the same jsonl the live session writes, with
the meta row rewritten to strip the source user's cwd. The
importer rotates the id and cwd to claim the copy, so the
imported session becomes a first-class entry in the current
user's sessions/ directory and shows up in /sessions,
/jump, and on-disk summaries like any other.

core/session_portable.go (new)
  - ExportSession(src, dst) string  returns the resolved
    output path. dst can be a file, a directory, or a bare
    name missing the .zotsession ext; all three shapes land
    somewhere sensible.
  - ImportSession(src, root, cwd, version) string  returns
    the newly-created session file path, ready for
    OpenSession.
  - firstUserPrompt() + slugify() build descriptive
    "20260420-080305-3f268850-say-hello-in-one-sentence.zotsession"
    filenames when exporting into a directory.

core/session_portable_test.go (new)
  - Full round trip: write → export → import into a
    different cwd → OpenSession → message payloads match.
  - Verifies the exported meta drops the original cwd.
  - Verifies the .zotsession extension is appended when
    missing from dst.

modes/session_ops_dialog.go (new)
  - Tiny picker matching the telegramDialog / logoutDialog
    shape: arrow keys, enter, esc. Two rows (export / import)
    with muted hint text.

modes/interactive.go
  - sessionOpsDialog field + constructor + key dispatch +
    render selector, identical boilerplate to the other small
    dialogs.
  - openSessionOpsDialog, doSessionOp, doSessionExport,
    doSessionImport. Export uses CurrentSessionPath (new
    config hook); import calls core.ImportSession then routes
    through the existing LoadSession so the agent switches to
    the new file.
  - defaultExportDir (~/Downloads → ~ → /tmp fallback),
    expandTilde, friendlyPath helpers.

cli.go
  - CurrentSessionPath: sess.Path getter wired into the
    interactive config.

slash_suggest.go + README
  - /session listed in the slash catalog and the README
    commands table, with a short description of the two
    direct forms.

Not wired into the session_dialog.go picker (which stays
resume-only); a later change could add "export this one"
directly from the picker rows if that's useful.
2026-04-20 10:04:33 +02:00
patriceckhart
e1c1e0e609 fix(cli): load extensions in print and json modes too
runPrintMode and runJSONMode never constructed the extension
manager, so --ext and installed extensions were silently ignored in
non-interactive flows. Only the interactive TUI and rpc mode were
loading them. The symptom: 'zot -e ~/path/to/weather -p "..."'
would spawn nothing, no log, and the model had no weather tool.

Added shared helpers used by both print and json:
  - setupNonInteractiveExtensions: same --ext + Discover sequence
    as interactive, plus the session_start event and MergeExtensionTools.
  - wireNonInteractiveAgentExtHooks: same BeforeToolExecute /
    BeforeTurn / BeforeAssistantMessage / OnEvent plumbing so guard
    extensions, event interceptors, and extension-contributed tools
    work identically in one-shot runs.
  - nonInteractiveExtHooks: minimal HostHooks impl. Notify goes to
    stderr so extensions can still log; Submit / Insert / Display
    are no-ops because there's no TUI to steer.

Verified end-to-end:

  zot -e ~/Developer/zot/examples/extensions/weather \
      -p 'use the weather tool for Berlin'
  -> 'Berlin: 16°C, fog. (deterministic demo)'

Before the fix, the same command silently fell back to bash/curl
suggestions because no tool was ever registered.
2026-04-19 20:00:36 +02:00
patriceckhart
e2f2092478 fix(no-yolo): don't auto-refuse tool calls in non-interactive modes
Previously --no-yolo in -p / --json / rpc modes auto-refused every
tool call. That made the flag dangerous to pass to scripts: a
single --no-yolo in a shell config or wrapper script would silently
break any tool-using prompt.

New behaviour:
  - Default: every mode is yolo (tools run freely, no prompts).
  - --no-yolo + interactive TUI: confirm dialog before each tool.
  - --no-yolo + -p / --json / rpc: stderr warning and ignore the
    flag. Tools run freely; scripts keep working.

The TUI confirm dialog and /yolo runtime toggle still work as
before. Also removed the unused wireNoYoloAutoRefuse helper and
simplified core.NewConfirmGate's doc comment.
2026-04-19 19:17:05 +02:00
patriceckhart
ac6d556f0a feat(tool-gate): --no-yolo flag, confirm dialog, /yolo runtime toggle
Adds a per-tool-call confirmation gate. Default stays yolo mode
(tools run freely, same as today). Pass --no-yolo to require
explicit user approval before each tool invocation.

Interactive TUI:
  A dialog appears before every tool call. Shows the tool name and
  a one-line preview of its args (command / path / url / etc.)
  with four choices, selectable by arrow keys or numeric shortcut:

    1. yes                     (run this call)
    2. yes, always this tool   (skip prompts for this tool,
                                session-scoped)
    3. yes, always             (skip prompts for every tool,
                                session-scoped)
    4. no                      (refuse and let the model try
                                something else)

  Esc/ctrl+c refuses the current prompt. Esc during a running turn
  both cancels the turn AND drains any pending confirm so the
  agent goroutine doesn't deadlock. Multiple pending confirms are
  queued and answered one at a time with a count visible in the
  header.

  Type /yolo to disable the gate for the rest of the session
  (equivalent to the "yes, always" choice but without needing a
  pending prompt). Any currently-open confirm auto-allows so the
  agent keeps moving.

Print / JSON / RPC modes:
  No interactive prompt is available, so every tool call is
  auto-refused with a reason the model can learn from:
  "tool call refused: --no-yolo is active and there is no
  interactive prompt in this mode; ask the user what to do
  instead". Observed behaviour: the model pivots to asking the
  user directly instead of looping on the same tool.

Implementation:
  internal/core/confirm.go
    - ConfirmDecision, Confirmer interface
    - ConfirmGate with session-scoped memory for "always this tool"
      and "always everything" decisions, both concurrency-safe
    - BuildPreview: turns {"command":"ls"} into "ls", etc.
    - Lives in core to avoid a modes -> agent import cycle
  internal/core/confirm_test.go
    - Tests: nil gate allows, nil-inner refuses with reason, one-
      shot allow doesn't remember, remember-tool short-circuits
      only same tool, remember-all short-circuits everything,
      refusal reasons surface, empty-reason gets a default,
      runtime AllowAll works, BuildPreview handles each field
  internal/agent/modes/confirm_dialog.go
    - Queue-based dialog, HandleKey wiring, CancelAll and
      AllowAllPending for the two exit cases
  internal/agent/modes/interactive.go
    - InteractiveConfig gains NoYolo + ConfirmGate fields
    - Interactive implements core.Confirmer via a response channel
    - Confirm dialog dispatched FIRST in the key-handler chain so
      keys never leak to other dialogs while the agent is blocked
    - Esc-while-busy also calls confirmDialog.CancelAll so the
      agent unblocks
    - /yolo slash command handled in runSlash
  internal/agent/cli.go
    - Constructs the ConfirmGate when args.NoYolo is set,
      BeforeToolExecute calls it first, extensions only see calls
      the user already approved
    - After iv is built, SetConfirmer(iv) wires the gate's inner
      so interactive + gate share the same struct
    - wireNoYoloAutoRefuse() for print / json modes
  internal/agent/args.go
    - --no-yolo flag and help text
  internal/agent/modes/slash_suggest.go
    - /yolo added to slashCatalog

Verified end-to-end: fresh zot --no-yolo -p "read sample.ts" now
returns "I can't read files in this mode (--no-yolo without an
interactive prompt). How would you like to proceed" instead of
actually reading.
2026-04-19 19:12:45 +02:00
patriceckhart
99c9ba8062 feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext
Clears every deferred extension todo in one push:

1) Interception expands to three events: tool_call (already shipped),
   turn_start (gate the turn before the model call, e.g. rate-limit /
   business-hour), and assistant_message (suppress or rewrite the
   user-visible text while keeping the model's original output in
   the transcript).

2) Tool-call args can now be rewritten mid-flight. An interceptor
   returning modified_args replaces the JSON the tool actually
   receives, without the model seeing the rewrite. Chains: each
   subscriber sees the previous one's output, letting guards
   successively redact / patch / augment. Invalid JSON is dropped
   safely.

3) /reload-ext hot-reloads every extension without restarting zot.
   The manager gracefully shuts down all running subprocesses,
   re-reads extension.json from disk, respawns (including --ext
   paths remembered from startup), and the host rebuilds the agent's
   tool registry in-place so freshly-registered tools are callable
   immediately.

Wire-format changes (extproto):
- EventInterceptResponseFromExt gains modified_args and replace_text
  fields (both optional, ignored when block=true).
- EventInterceptFromHost gains Step (for turn_start) and Text (for
  assistant_message) alongside the existing tool_call payload.

Core agent changes:
- BeforeToolExecute signature now returns (allowed, reason,
  modifiedArgs json.RawMessage). Non-nil+valid JSON args replace
  tc.Arguments before Tool.Execute runs.
- New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking
  cancels the turn with an EvTurnEnd{StopError} carrying the reason.
- New BeforeAssistantMessage hook, invoked after finalMsg is
  assembled but before the EvAssistantMessage emit. Supports
  suppress (block=true) and text rewrite (replace_text). Transcript
  always gets the original; UI gets the rewritten text.
- New SetTools(reg) so /reload-ext can swap the registry on the
  live agent under the agent mutex.

Manager changes:
- InterceptToolCall now returns InterceptResult (Block, Reason,
  ModifiedArgs, ReplaceText), with a chain that folds rewrites.
- New InterceptTurnStart and InterceptAssistantMessage.
- New Reload(ctx, grace) tears down and respawns everything,
  returning ReloadStats{Stopped, Loaded, Ready, Errors}.
- New SetOnReload(fn) callback the host uses to rebuild the agent
  tool registry after a reload.
- LoadExplicit remembers --ext paths so Reload respawns them.
- subscribe accepts "tool_call", "turn_start", "assistant_message"
  under "intercept".

SDK (pkg/zotext):
- New handler types: ToolCallHandler, TurnStartHandler,
  AssistantMessageHandler, and their decision structs
  (ToolCallDecision with ModifiedArgs, AssistantMessageDecision
  with ReplaceText).
- New registration methods: InterceptToolCallX (rich variant of
  the existing InterceptToolCall), InterceptTurnStart,
  InterceptAssistantMessage.
- dispatchIntercept routes per-event with panic recovery and
  always emits exactly one event_intercept_response.

TUI:
- /reload-ext slash command registered in slashCatalog and
  runSlash. Added to slashCancelsTurn so it waits for idle like
  /compact does.
- runReloadExt shows a "reloading extensions..." status, runs the
  Manager.Reload on a goroutine, and reports the resulting stats.

Tests:
- internal/core/intercept_test.go: verifies args are actually
  rewritten on the way to Tool.Execute, malformed JSON is ignored,
  and block surfaces the reason as an error ToolResult.
- internal/agent/extensions/intercept_test.go: end-to-end with a
  bash extension subprocess that blocks rm -rf, rewrites other bash
  args to "echo GUARDED:", passes through read calls, allows
  turn_start, and redacts SECRET in assistant messages. Second test
  verifies Reload respawns the subprocess, re-registers its command,
  and fires the onReload callback.

Docs:
- docs/extensions.md: rewrote the intercept section to cover all
  three events, added a table of event_intercept_response fields,
  documented the /reload-ext hot-reload command, expanded the SDK
  section with examples of every handler, moved the old "future"
  items into a shipped Phase 4.
- README.md: extensions summary mentions intercept beyond tool_call,
  /reload-ext added to the slash-commands table and to the
  turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
patriceckhart
b9e7517149 feat(tui): show github release notes once after upgrading
The first time a user launches a newer zot binary, the tui pops
a dismissible overlay with the release notes for that version.
Press any key to close; the version goes into config.json's
last_changelog_shown so the same notes never reappear.

Lifecycle:
  - dev builds (version "" / "dev" / "0.0.0"): no fetch ever
  - first-ever launch (no LastChangelogShown stored): seed it
    silently with the current version so fresh installs don't
    get release notes dumped at them
  - subsequent launches with the same version: skipped (config
    already records that version was shown)
  - launch with a different version: fetch the release page from
    https://api.github.com/repos/patriceckhart/zot/releases/tags/v<ver>
    and open the dialog if the body is non-empty
  - dismiss writes LastChangelogShown so it never repeats

Components:
  - internal/agent/changelog.go: FetchChangelog/Async, and the
    Should/Mark/Seed helpers around config.LastChangelogShown.
    Honours $GITHUB_TOKEN exactly like the install scripts and
    the existing update check, so private-repo fetches work
    with auth.
  - internal/agent/modes/changelog_dialog.go: the overlay.
    Markdown body via the existing RenderMarkdown pipeline,
    scrollable with up/down/pgup/pgdn, any other key dismisses.
  - internal/agent/modes/interactive.go: new ChangelogChan and
    OnChangelogDismiss config fields, single-shot select case
    in Run() that opens the dialog when a payload arrives.
  - internal/agent/cli.go: spawns the fetch goroutine, gates it
    on ShouldShowChangelog, wires OnChangelogDismiss to
    MarkChangelogShown so the version is persisted.

Best-effort: timeouts at 4s, missing tag => silent skip, network
failure => silent skip + retry on next launch (no
LastChangelogShown update if we never showed anything).

Documented in the README under the SYSTEM.md note.
2026-04-19 16:12:13 +02:00
patriceckhart
fbad128c4c feat(skills): user skills now opt-in via --with-skills
Behaviour change: a fresh zot run now loads only the built-in
skills compiled into the binary. User-installed SKILL.md files
under $ZOT_HOME/skills/, .zot/skills/, .claude/skills/, or
.agents/skills/ stay dormant until the user explicitly opts in
with --with-skills.

Three discrete modes:

  zot                       built-ins only (default)
  zot --with-skills         built-ins + user skills
  zot --no-skill            nothing (no skill tool, no manifest)

Rationale: a fresh install should have a deterministic skill
set, regardless of what's already lying around in $ZOT_HOME from
old experiments. Built-ins ship with the binary so they're
auditable; user skills are loaded only when the user explicitly
asks for them.

Discover() gained an includeUser bool (was 3 args, now 4). The
in-tree caller updated; the test that exercised the old signature
gets includeUser=true so its existing assertions still hold.
scanUserSkills() split out so the includeUser=false path is a
cheap no-op.

End-to-end verified live:
  default              -> write-zot-extension (only)
  --with-skills        -> write-zot-extension + code-review + test-fix
  --no-skill           -> no skill tool, no manifest at all
2026-04-19 16:03:26 +02:00
patriceckhart
e9ffc74442 feat(skills): --no-skill flag to disable all skills (including built-ins)
Mirrors --no-ext: one flag that skips skill discovery + the
`skill` tool registration entirely for one run. Useful for
clean-room runs where you want zero extra context biasing the
model.

Defaults are unchanged:
  - user-installed skills under $ZOT_HOME/skills/, .zot/skills/,
    .claude/skills/, .agents/skills/ load normally
  - built-in skills compiled into the binary (currently the
    write-zot-extension authoring guide) load normally
  - both groups appear in the system-prompt manifest and are
    loadable via the `skill` tool

With --no-skill (or --no-skills):
  - skills.Discover() is not called
  - the `skill` tool is not registered
  - no "Available skills" addendum is appended to the system
    prompt
  - /skills picker is empty

Wired into both interactive and rpc modes via the existing
SkillSnapshot wiring (returns nil when args.NoSkill is set, so
the picker also stays empty).

End-to-end verified live:
  default            : tool list includes "skill"
  --no-skill         : tool list is read, write, edit, bash only
                       (no skill, no extra context manifest)
2026-04-19 15:58:38 +02:00
patriceckhart
2cffe048c9 feat(skills): built-in extension-author skill, hidden from /skills
The model now ships with a `write-zot-extension` skill compiled
into the binary. When the user asks for help authoring a zot
extension (slash command, LLM tool, audit hook, permission gate)
the model sees the skill in its system-prompt manifest, calls
the `skill` tool to load the body on demand, and walks the user
through the right answer with the wire format, manifest shape,
SDK examples (Go + TS + Python), and dev workflow already in
context. No need for the user to be in the zot repo or to ask
the model to read docs/extensions.md first.

Built-in skills:
  - shipped via //go:embed at internal/skills/builtin/
  - merged into Discover()'s output AFTER user skills, so a
    user-installed skill with the same name shadows the built-in
    (drop your own SKILL.md at $ZOT_HOME/skills/write-zot-extension/
    to customise)
  - marked Builtin: true on the Skill struct
  - hidden from user-facing surfaces: VisibleSkills() filters them
    so /skills only shows skills the user actually installed or
    shipped in their project

The model side stays unchanged: system-prompt manifest still lists
built-ins (so the model knows they exist), the `skill` tool still
loads them on demand. Only the picker is filtered.

Verified live:
  prompt: "List the names of the skills you have available"
  -> code-review, test-fix, write-zot-extension

  prompt: "I want to write a zot extension that adds a slash
           command /pwd which inserts the current directory path
           into the editor. What language should I use, and what
           files do I need to create?"
  -> [tool_call] skill({"name":"write-zot-extension"})
  -> body returned
  -> the model produces a complete extension with the right
     manifest, the right hello/register/ready frames, action:
     insert correctly chosen, and a remark about cwd capture.

The picker filter has its own unit test
(TestVisibleSkillsHidesBuiltins) and the existing Discover test
was updated to expect the built-in count without hardcoding it.
2026-04-19 15:55:25 +02:00
patriceckhart
84dbd86aee feat(extensions): --no-ext to skip discovery for one run
Useful for clean-room runs ("does this prompt work without my
extensions interfering?") and for reproducing issues without
auto-loaded guardrails.

  zot --no-ext              # zero extensions
  zot --no-ext --ext ./x    # only x; nothing else gets discovered

Explicit --ext paths still load on top, so --no-ext is "skip the
implicit scan", not "block all extensions". Lets you run with
exactly one extension without uninstalling the rest.

Wired into both interactive (cli.go) and rpc (rpc.go) modes.
Help text updated.

The "── extensions ────" divider in the slash autocomplete
already hides itself when no extension commands are registered
(empty s.extra short-circuits allCatalog before the header
insertion path), so --no-ext naturally produces a clean popup
with no orphan rule. pruneOrphanHeaders also drops the rule
when filter input narrows the extension group to zero matches.

End-to-end verified live:
  default                  : agent sees weather + read_notes
  --no-ext                 : agent sees only the 5 built-ins
  --no-ext --ext scratchpad: agent sees built-ins + read_notes
2026-04-19 15:42:34 +02:00
patriceckhart
7e94b0776b feat(extensions): --ext PATH (short -e) for ad-hoc loading
Loads an extension from any directory for one zot session without
needing to copy / install it under $ZOT_HOME. Repeatable. Resolved
to absolute before spawn so paths like "." survive a later cwd
change. Loaded BEFORE the installed-extension scan so explicit
paths win on name conflicts, letting you shadow an installed copy
with a work-in-progress version.

  zot --ext ./my-extension        # one extension
  zot -e ./a -e ./b               # multiple
  zot --ext .                     # the cwd is itself an extension

Manager.LoadExplicit is the public entry point. Spawns happen in
parallel like the regular Discover path, with per-path errors
returned so a typo in one --ext doesn't break the others.

Wired into both interactive (cli.go) and rpc (rpc.go) modes.
Help text + docs/extensions.md updated.

Verified end-to-end: disabling the installed scratchpad,
running `zot rpc --ext .` from its directory, and asking the
model to list its tools shows read_notes available again.
2026-04-19 15:20:56 +02:00
patriceckhart
5dbbcb9040 feat(extensions): typescript example + path-aware exec resolution
examples/extensions/scratchpad: real .ts (not .js) extension, no
build step, no SDK. Runs via `npx --yes tsx index.ts` so authors
can use TypeScript without forcing a global install. Demonstrates:

  /note <text>   slash command (typed CommandResponse)
  /notes         slash command (display action)
  /clear-notes   slash command
  read_notes     LLM-callable tool (typed ToolResult)

Plus a typed wire-format subset inline so the file shows what the
protocol actually looks like from the consumer side. Pure node +
tsx, zero npm deps beyond tsx itself (~5 MB cached on first call).

Manager fix: extension exec paths are now resolved by shape:

  absolute               used as-is
  starts with ./ or ../  joined to ext.Dir
  contains a separator   joined to ext.Dir (other relative form)
  bare name (no sep)     left as-is so $PATH lookup works

Before this, "exec": "npx" was being looked up at
extensions/scratchpad/npx and failing with a "no such file or
directory" error. With the fix, "node", "npx", "python3", "tsx",
etc. resolve via $PATH like users intuitively expect.

Bumped WaitForReady grace from 500ms to 3s so slow runtimes
(npx tsx cold-start ≈ 1.4s) get their register_tool frames
in before the agent's tool registry is built. Extensions that
send ready quickly still release the wait immediately; the
extra grace only applies to laggards.

Verified end-to-end live against anthropic:
  prompt: "Use the read_notes tool now and tell me what's in the
           scratchpad"
  -> [tool_call] read_notes({})
  -> [tool_result] (scratchpad is empty)
  -> "The scratchpad is empty."
2026-04-19 15:06:00 +02:00
patriceckhart
83ae236571 feat(extensions): phase 3 — event subscriptions + tool-call interception
Two new capabilities, both ride on the existing subprocess
protocol with a couple of new frame types.

Event subscriptions (one-way notifications):

  ext  -> host: subscribe {events: [...], intercept: [...]}
  host -> ext:  event {event, ...payload}

  Recognised events: session_start, turn_start, turn_end,
  tool_call, assistant_message. Subscribers get fire-and-forget
  notifications on each. Useful for telemetry, audit logs, custom
  state widgets that follow live agent activity.

Tool-call interception (round-trip, can refuse):

  host -> ext:  event_intercept {id, event:"tool_call", tool_name, tool_args}
  ext  -> host: event_intercept_response {id, block?, reason?}

  When at least one extension subscribed to "tool_call" intercept,
  zot asks each one in turn before running every tool call. First
  blocker wins; reason becomes the tool-result error text the model
  sees. Per-extension 5s timeout treats unresponsive interceptors
  as "allow" so a wedged extension never stalls the agent.

Wire format additions (internal/extproto):
  ext -> host: SubscribeFromExt, EventInterceptResponseFromExt
  host -> ext: EventFromHost, EventInterceptFromHost

Manager (internal/agent/extensions):
  - per-extension eventSubs / interceptSubs sets, populated by the
    subscribe frame
  - EmitEvent fans out to every subscribed extension on its own
    goroutine (won't block the agent on slow stdin writes)
  - InterceptToolCall walks subscribers serially, returning the
    first refusal; 5s timeout per subscriber (allow on timeout)
  - readLoop handles event_intercept_response correlations the
    same way it handles command/tool responses

Core (internal/core/agent.go):
  - Agent.BeforeToolExecute hook called from runOneTool right
    before tool.Execute. Returning (allowed=false, reason)
    short-circuits with an IsError tool result containing reason.
  - Agent.OnEvent observer fires for every emitted AgentEvent;
    composed transparently with the per-Prompt sink via wrapSink
    so neither the existing TUI nor the rpc loop need changes.

Wiring (internal/agent/cli.go, rpc.go):
  - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and
    OnEvent -> fanoutAgentEvent for every freshly-built agent
    (initial, login rebuild, model swap)
  - fanoutAgentEvent translates core AgentEvent kinds into
    extproto.EventFromHost. Internal-only events (text_delta,
    tool_progress) are dropped to keep the per-extension stream
    sane.
  - session_start emitted once after extensions come up

SDK (pkg/zotext):
  - On(name, EventHandler) registers per-event observers
  - InterceptToolCall(InterceptHandler) registers a single
    intercept callback
  - Run() now also sends a subscribe frame before the ready
    sentinel, with the union of subscribed events + intercept
  - Frame loop handles "event" and "event_intercept" frames,
    runs the handlers (intercepts on a goroutine to avoid
    head-of-line blocking)
  - Capabilities advertised: commands + tools + events

Example (examples/extensions/guard):
  - subscribes to session_start / turn_start / tool_call / turn_end
    and writes one-line audit entries
  - intercepts every bash call; refuses commands matching
    rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777
  - end-to-end verified live: agent -> bash("rm -rf /tmp/foo")
    -> guard refuses -> model sees the refusal text and surfaces
    it in its reply ("the guard blocked it, as expected — the
    pattern \brm\s+-rf\b matched")

Docs/extensions.md updated with all five new frame types and the
guard example.
2026-04-19 14:57:03 +02:00
patriceckhart
74709a0bd9 feat(extensions): phase 2 — extension-defined tools
Extensions can now register tools the LLM calls directly. The model
sees them in its tool list alongside the built-ins (read, write,
edit, bash, skill); when it invokes one, zot routes the tool_call
to the owning extension subprocess and feeds the tool_result back.

Wire format additions (internal/extproto):
  ext -> host:
    register_tool {name, description, schema}
    ready                                       # all initial regs flushed
    tool_result {id, content[], is_error}       # reply to a tool_call
  host -> ext:
    tool_call {id, name, args}                  # raw json args from the model

Manager (internal/agent/extensions):
  - tracks per-extension RegisterToolFromExt frames
  - validates schemas parse as JSON before registering (bad schema
    skipped + logged, doesn't crash zot)
  - toolIndex map for O(1) lookup
  - WaitForReady(grace): blocks per extension on its readyCh until
    a ready frame arrives or the grace expires; called once after
    Discover so the agent's tool registry is built against the
    final set
  - Tools() / HasTool() / InvokeTool() public surface
  - readLoop closes readyCh on stdout EOF so a wedged extension
    doesn't permanently block WaitForReady

extensionTool (internal/agent/extensions/tool.go):
  implements core.Tool. Execute() round-trips through
  Manager.InvokeTool with a 60s default timeout, decodes
  base64 image blocks, surfaces extension+tool name in
  ToolResult.Details for the renderer.

internal/agent/build.go:
  - new ExtensionToolSource interface (declared here to avoid the
    build->extensions->core import cycle) + ExtensionToolInfo
    mirror of extensions.ToolInfo
  - Resolved.MergeExtensionTools(): folds extension tools into
    ToolRegistry, re-renders the system prompt's tool summary
    with both built-in and extension tools listed
  - Resolved gains private bookkeeping fields so the rebuild
    works without re-running Resolve

internal/agent/cli.go:
  extension manager built BEFORE the agent in interactive mode
  so MergeExtensionTools can fire before NewAgent. Same in
  buildAgent + buildAgentFor closures so login / model-switch
  rebuilds also include extension tools. extToolAdapter bridges
  *extensions.Manager to ExtensionToolSource.

internal/agent/rpc.go:
  extension lifecycle now also runs in `zot rpc` mode. Notify and
  Display from extensions surface as `ext_notify` / `ext_display`
  events on the rpc stream so any consumer can react.

pkg/zotext (Go SDK):
  - ToolHandler, ToolResult, ToolContent types
  - Tool(name, desc, schema, fn) registration method
  - TextResult / TextErrorResult / Image / ImageBytes constructors
  - Run() now also flushes register_tool frames + a final ready
    sentinel after the last registration

examples/extensions/weather: working Go example registering one
tool. Deterministic fake weather (sha1 of city -> temp + cond) so
the demo is repeatable. Plus README explaining how to install.

Tests:
  internal/agent/extensions/tool_test.go: spawns a mock /bin/sh
  extension that registers a tool, sends ready, and echoes tool
  calls. Verifies registration timing, lookup via HasTool/Tools,
  invoke roundtrip via InvokeTool.

End-to-end verified against live anthropic backend:
  prompt: "What is the weather in Berlin?"
  -> [tool_call] weather({"city":"Berlin"})
  -> [tool_result] Berlin: 16°C, fog (deterministic fake)
  -> reply: "Berlin is 16°C."

Docs/extensions.md updated with phase 2 wire format, the new SDK
tool API, and the weather example.
2026-04-19 14:46:32 +02:00
patriceckhart
222a62c70f feat: skills — reusable instructions discovered from SKILL.md files
A skill is a single SKILL.md file with a YAML frontmatter header,
discovered from well-known directories at startup. Two integration
points:

  1. The system prompt gains a short manifest listing each skill's
     name + one-line description. Cheap (a few dozen tokens).
  2. A built-in `skill` tool lets the model load any one skill's
     full body on demand and follow the instructions there.

The on-demand-load model keeps token usage cheap: only the
manifest goes into every request; the body is fetched as a tool
result the one or two turns the model actually needs it.

Discovery (priority order — first match wins per name):
  ./.zot/skills/<name>/SKILL.md            project (native)
  $ZOT_HOME/skills/<name>/SKILL.md         global (native)
  ./.claude/skills/<name>/SKILL.md         project (claude-compat)
  ~/.claude/skills/<name>/SKILL.md         global (claude-compat)
  ./.agents/skills/<name>/SKILL.md         project (agent-compat)
  ~/.agents/skills/<name>/SKILL.md         global (agent-compat)

Compat paths are deliberate: any SKILL.md written for a related
ecosystem works in zot unchanged.

Frontmatter fields:
  name           optional; defaults to directory name
  description    required; shown in the system prompt
  allowed-tools  optional list; informational (no enforcement)
  permissions    optional per-tool patterns; informational

allowed-tools and permissions are parsed but not enforced this
version. They render in the body so the model can self-regulate.

What landed:

- internal/skills: discovery + frontmatter parsing (no yaml dep —
  hand-rolled subset for the limited shape skills use), the on-
  demand `skill` tool implementing core.Tool, system-prompt
  addendum, FindByName lookup helper. Real unit tests cover all
  five locations + dedup priority + parser corner cases.

- internal/agent/build.go: Resolve discovers skills, registers the
  skill tool when at least one was found, appends the manifest to
  the system prompt's append list. Resolved gains a SkillTool
  field so the tui can read the live set.

- internal/agent/modes/skills_dialog.go: /skills picker with two
  modes — list view (cursor + paging) and body view (markdown-
  rendered with scroll). Refreshes its snapshot each open via
  cfg.SkillSnapshot so edits to a SKILL.md during a session are
  reflected immediately.

- /skills slash command + entry in slashCatalog.

- examples/skills/code-review and examples/skills/test-fix as
  starter skills demonstrating procedural style + frontmatter.

- docs/skills.md: full reference covering discovery, frontmatter,
  inspection, authoring tips, and ecosystem compat.

End-to-end verified against the live anthropic backend:

  prompt: "What skills do you have available?"
  -> "- code-review\n- test-fix"

  prompt: "Use the skill tool to load the code-review skill,
           then summarize step 1."
  -> [tool_call] skill({"name":"code-review"})
  -> [tool_result] body returned
  -> "Step 1 is to establish what changed by running git status..."
2026-04-19 14:32:30 +02:00
patriceckhart
0c92d6e914 feat: extension system (subprocess + json-rpc, any language)
Phase 1: extensions can register slash commands and push chat
notifications. Tools and event subscriptions land in later phases.

Architecture: each extension is its own subprocess. Zot launches
it on startup, completes a hello/hello_ack handshake over its
stdin/stdout, then routes slash commands the extension registered.
Crash isolation, language agnostic, works with any executable
that can read/write json lines.

What lands here:

- internal/extproto: shared wire-format types (Frame, HelloFromExt,
  RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt,
  HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...).
  Both the host and the SDK marshal/unmarshal the same types.

- internal/agent/extensions: discovery + lifecycle manager.
  - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions
    (project-local first, global second; first wins for duplicates)
  - Spawns each enabled extension, captures stderr to
    $ZOT_HOME/logs/ext-<name>.log
  - Reads frames in a goroutine, dispatches register_command and
    notify, correlates command_response by id
  - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL
  - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display)

- Interactive bridge: extensions slot into the slash dispatcher
  *after* the built-in catalog, so built-ins always win on conflict.
  Extension-registered commands also flow into the autocomplete
  popup and /help via slashSuggester.SetExtra. NotifyFromExt frames
  render as muted [ext-name] notes above the editor.

- internal/agent/extcmd: `zot ext` CLI.
    list / install <path|git-url> / remove / enable / disable / logs

- pkg/zotext: public Go SDK. Construct an Extension, register
  Command(name, desc, fn), call Run(). Fn returns a Response built
  with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr
  via Logf() so stdout stays clean for the protocol.

- examples/extensions/hello: working Go example registering /hello
  and /summon, plus README + extension.json.

- docs/extensions.md: full protocol reference, including a
  ~30-line raw-Python example for users who don't want the SDK.

Tests: internal/agent/extensions/manager_test.go spawns a mock
extension via /bin/sh and exercises the full handshake -> register
-> invoke -> response cycle. Verifies the hello frame ordering,
correlation-by-id, and graceful shutdown.

Verified manually: built and installed the example, drove it via
stdin pipes, confirmed clean handshake + correct frame ordering
and shutdown_ack. Builds vet-clean on darwin / linux / windows.

Editor.Insert exported (was Editor.insert) so the extension hooks
can drop text into the input.
2026-04-19 14:09:43 +02:00
patriceckhart
a670442c9e feat: zotcore SDK + zot rpc subprocess protocol
two new ways to embed the zot agent runtime in third-party apps:

1. pkg/zotcore - public Go SDK
   - Runtime type: New(Config), Prompt(ctx,text,imgs)->chan Event,
     Cancel, Compact, SetModel, State, Messages, Cost, ListModels,
     Close. Concurrent-safe; one prompt at a time per Runtime,
     ErrBusy if you try to overlap. Spawn multiple Runtimes for
     multiple projects.
   - Public types mirror the JSON-RPC wire schema 1:1 so consumers
     can share parsing code with the out-of-process clients.
   - Internal core/agent/provider stay internal; SDK is a thin
     facade that exposes only what's stable.

2. zot rpc subcommand - newline-delimited JSON on stdin/stdout
   - 'zot rpc' (or 'zot --rpc') turns the agent runtime into a
     subprocess that any language can drive via pipes.
   - Commands: hello, prompt, abort, compact, get_state,
     get_messages, clear, set_model, get_models, ping. Each
     optionally carries an id; the matching response echoes it.
   - Stream notifications: turn_start, user_message,
     assistant_start, text_delta, tool_call, tool_progress,
     tool_result, assistant_message, usage, turn_end, done,
     error, compact_done. Same shape as the existing --json mode
     events (modes.EventToJSON / ContentToJSON were exported
     for reuse).
   - Auth: optional ZOTCORE_RPC_TOKEN env var; first command
     must be hello {token: ...} when set. Without the env var
     the spawning process is implicitly trusted.
   - Concurrency: one prompt or compact at a time per process,
     enforced by a turnMu mutex. abort fires immediately
     regardless. Stdin close exits the process.

3. docs/rpc.md - full schema reference
4. examples/rpc/{python,node,shell,go} - reference clients
5. examples/sdk - in-process Go embedding example
6. README updated with a new modes entry and an embedding section
2026-04-19 12:26:48 +02:00
patriceckhart
fdef8ac614 core: drop empty session files instead of writing meta-only stubs
every zot launch was creating a session file with just a meta line,
even when the user exited without prompting. /sessions and ls -la
ended up showing dozens of empty entries.

now Session tracks messagesAppended and freshFile (true for
NewSession, false for OpenSession). Close() removes the file when
both conditions hold: this process created it AND no messages
were ever appended. resumed sessions are never auto-deleted even
if the resume run added nothing, since the prior content is real.

PruneEmptySessions sweeps existing meta-only stubs from the cwd's
session dir on each interactive launch. cheap (only reads enough
of each file to find a 'message' line) and fixes the existing
backlog automatically the first time you reopen zot in a project.
2026-04-19 11:38:57 +02:00
patriceckhart
8e546bde70 tui: show 'update available' banner at top of chat
- internal/agent/update.go: check github releases api for a newer
  tag than the compiled-in version, cached in $ZOT_HOME/update-check.json
  with a 12h ttl so startup never hits the network twice. honours
  $GITHUB_TOKEN for the window while the repo is private; falls back
  to silent no-op on any failure. skipped entirely on dev builds
  (version = 0.0.0 or dev)
- internal/agent/modes/update_banner.go: yellow-framed block with
  the new version, the current version, the one-liner install
  command appropriate for the platform, and a link to the release
  page. rendered above the welcome / transcript so it's the first
  thing the user sees
- wired through via InteractiveConfig.UpdateInfoChan to avoid an
  import cycle (modes -> agent). cli.go kicks off the check async
  and feeds the result in

note: no 'dismiss' key yet \u2014 the banner stays until you update to
the shown version. if the nagging gets annoying we can add a
per-version dismiss cache later.
2026-04-18 11:49:22 +02:00
patriceckhart
2158c272af fix ci: portable syscall.Select via x/sys/unix; gofmt pass
- rewrite resize_unix.go on top of golang.org/x/sys/unix so the
  peek-stdin helper compiles on linux (Select returns (int, error),
  Timeval.Usec is int64) as well as darwin (int32, error-only)
- promote golang.org/x/sys to a direct dep
- gofmt -w . (11 files of alignment drift from recent edits)
- install.sh / install.ps1: accept $GITHUB_TOKEN so the installers
  work against the repo while it's private; no-op on public repos
- README: document the private-repo install paths (PAT for curl|bash
  and powershell, GOPRIVATE for go install)
2026-04-18 10:55:42 +02:00
patriceckhart
a4e6cde56f fix code highlighting 2026-04-18 10:16:06 +02:00
patriceckhart
d8a0cba4fc add telegram bot bridge 2026-04-18 09:15:46 +02:00
patriceckhart
6cced27476 initial commit 2026-04-17 20:36:38 +02:00