mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
fix(extensions): route through manager from runSlash default branch
extension commands appeared in the autocomplete popup but invoking them produced "unknown command: /summon". The submit-handler path already tried the extension manager before erroring, but the popup- enter path (suggest.Selection -> runSlash) bypassed that check and fell straight into runSlash's switch, where the default case bailed with the generic error. Fix: runSlash's default branch now also consults cfg.Extensions.HasCommand and dispatches via invokeExtensionCommand when matched. Both UI paths (typed-and-enter, popup-enter) now route identically. Built-in cases above default still always win on conflict. Also adds examples/extensions/clock — a node extension demonstrating the wire protocol from a non-Go runtime. Pure stdlib (readline + process), no npm install. Registers /now (display) and /uptime (prompt). Documented in its README; the protocol works the same from any language.
This commit is contained in:
parent
13607ed6be
commit
1c7488285b
4 changed files with 240 additions and 0 deletions
53
examples/extensions/clock/README.md
Normal file
53
examples/extensions/clock/README.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# clock — example zot extension (Node, no dependencies)
|
||||
|
||||
A minimal TypeScript-style extension showing the wire protocol from
|
||||
the Node side without any SDK. Pure stdlib (`readline`, `process`).
|
||||
|
||||
## Requirements
|
||||
|
||||
Node 18 or newer (uses ESM). No `npm install` step.
|
||||
|
||||
## Install
|
||||
|
||||
From this directory:
|
||||
|
||||
```bash
|
||||
zot ext install .
|
||||
```
|
||||
|
||||
This copies the manifest + script into `$ZOT_HOME/extensions/clock/`.
|
||||
zot picks it up the next time you launch the TUI.
|
||||
|
||||
## Use
|
||||
|
||||
In zot:
|
||||
|
||||
- `/now` — extension pushes a styled note showing local
|
||||
and ISO time (no model call)
|
||||
- `/uptime` — extension asks the agent to comment on how
|
||||
long the clock extension has been running
|
||||
- `/uptime caching` — same, but the agent's comment is steered by
|
||||
the trailing args
|
||||
|
||||
## Why JavaScript and not TypeScript
|
||||
|
||||
The file uses JSDoc types (`@typedef`, `@param`) so it type-checks
|
||||
under `tsc --checkJs` without a build step. Authentic TypeScript
|
||||
authoring works too — rename `index.js` → `index.ts`, install
|
||||
[`tsx`](https://www.npmjs.com/package/tsx), and update
|
||||
`extension.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"exec": "tsx",
|
||||
"args": ["index.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
zot doesn't care which one you use; it just spawns whatever `exec`
|
||||
points at and reads/writes JSON lines on its stdio.
|
||||
|
||||
## See also
|
||||
|
||||
- `examples/extensions/hello` — Go version using the `pkg/zotext` SDK
|
||||
- `docs/extensions.md` — full protocol reference
|
||||
9
examples/extensions/clock/extension.json
Normal file
9
examples/extensions/clock/extension.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "clock",
|
||||
"version": "1.0.0",
|
||||
"exec": "node",
|
||||
"args": ["index.js"],
|
||||
"language": "typescript",
|
||||
"description": "registers /now and /uptime — a tiny time toolkit",
|
||||
"enabled": true
|
||||
}
|
||||
164
examples/extensions/clock/index.js
Normal file
164
examples/extensions/clock/index.js
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// clock — a zot extension written in plain Node (no dependencies).
|
||||
//
|
||||
// Registers two slash commands:
|
||||
// /now — pushes the current local time into the chat as
|
||||
// a one-shot note (no model call, no transcript)
|
||||
// /uptime — submits a prompt asking the agent to comment on
|
||||
// how long this extension has been running
|
||||
//
|
||||
// Why .js and not .ts: this file uses JSDoc types so it can be
|
||||
// type-checked by tsc / tsserver without a build step. Renaming to
|
||||
// .ts and updating extension.json's args to ["--import","tsx",
|
||||
// "index.ts"] (with tsx installed) works too. The extension protocol
|
||||
// itself is language-agnostic; what matters is that `exec` produces
|
||||
// a process that reads JSON lines from stdin and writes them to
|
||||
// stdout.
|
||||
//
|
||||
// Install:
|
||||
// zot ext install /path/to/this/dir
|
||||
//
|
||||
// Then in zot:
|
||||
// /now
|
||||
// /uptime
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { stdin, stdout, stderr } from "node:process";
|
||||
|
||||
const NAME = "clock";
|
||||
const VERSION = "1.0.0";
|
||||
const STARTED_AT = Date.now();
|
||||
|
||||
/** @typedef {{type: string, id?: string, [k: string]: unknown}} Frame */
|
||||
|
||||
/**
|
||||
* Send a frame to zot. One JSON object per line; flush immediately
|
||||
* so the host doesn't sit waiting on a buffer.
|
||||
* @param {Frame} obj
|
||||
*/
|
||||
function send(obj) {
|
||||
stdout.write(JSON.stringify(obj) + "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* stderr is captured by zot to $ZOT_HOME/logs/ext-clock.log; perfect
|
||||
* for debug output. Anything written to stdout would corrupt the
|
||||
* protocol stream.
|
||||
* @param {string} msg
|
||||
*/
|
||||
function log(msg) {
|
||||
stderr.write(`[${NAME}] ${msg}\n`);
|
||||
}
|
||||
|
||||
// 1. Hello first.
|
||||
send({
|
||||
type: "hello",
|
||||
name: NAME,
|
||||
version: VERSION,
|
||||
capabilities: ["commands"],
|
||||
});
|
||||
|
||||
// 2. Register every command we can handle.
|
||||
send({
|
||||
type: "register_command",
|
||||
name: "now",
|
||||
description: "show the current local time (no model call)",
|
||||
});
|
||||
send({
|
||||
type: "register_command",
|
||||
name: "uptime",
|
||||
description: "ask the agent to riff on how long the clock ext has run",
|
||||
});
|
||||
|
||||
// 3. Read frames until stdin closes (zot shuts us down).
|
||||
const rl = createInterface({ input: stdin, crlfDelay: Infinity });
|
||||
|
||||
rl.on("line", (line) => {
|
||||
/** @type {Frame} */
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.parse(line);
|
||||
} catch (err) {
|
||||
log(`malformed frame: ${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (frame.type) {
|
||||
case "hello_ack":
|
||||
log(
|
||||
`connected to zot ${frame.zot_version} (${frame.provider}/${frame.model})`,
|
||||
);
|
||||
break;
|
||||
|
||||
case "command_invoked":
|
||||
handleCommand(frame);
|
||||
break;
|
||||
|
||||
case "shutdown":
|
||||
send({ type: "shutdown_ack" });
|
||||
rl.close();
|
||||
break;
|
||||
|
||||
default:
|
||||
log(`unknown frame type: ${frame.type}`);
|
||||
}
|
||||
});
|
||||
|
||||
rl.on("close", () => {
|
||||
log("read loop closed; exiting");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {Frame & {name?: string, args?: string}} frame
|
||||
*/
|
||||
function handleCommand(frame) {
|
||||
const name = String(frame.name ?? "");
|
||||
const args = String(frame.args ?? "").trim();
|
||||
const id = String(frame.id ?? "");
|
||||
|
||||
switch (name) {
|
||||
case "now": {
|
||||
const now = new Date();
|
||||
const human = now.toLocaleString(undefined, {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
const iso = now.toISOString();
|
||||
send({
|
||||
type: "command_response",
|
||||
id,
|
||||
action: "display",
|
||||
display: `local: ${human}\niso : ${iso}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "uptime": {
|
||||
const ms = Date.now() - STARTED_AT;
|
||||
const seconds = Math.round(ms / 1000);
|
||||
const focus = args ? `Focus on the topic: ${args}.` : "";
|
||||
send({
|
||||
type: "command_response",
|
||||
id,
|
||||
action: "prompt",
|
||||
prompt:
|
||||
`The clock extension has been running for ${seconds}s in this zot session. ` +
|
||||
`Riff on that briefly in one short sentence — be a little dramatic. ${focus}`.trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
send({
|
||||
type: "command_response",
|
||||
id,
|
||||
action: "noop",
|
||||
error: `clock: unknown command /${name}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1135,6 +1135,20 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
|
|||
i.statusErr = ""
|
||||
i.mu.Unlock()
|
||||
default:
|
||||
// Last-resort fallback: try the extension manager. Built-in
|
||||
// cases above always win; this branch only fires for slash
|
||||
// commands the extension manager registered. Same routing as
|
||||
// the editor's submit-handler dispatch path so the autocomplete
|
||||
// "enter on highlighted suggestion" flow also works.
|
||||
extName := strings.TrimPrefix(parts[0], "/")
|
||||
if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
|
||||
rest := ""
|
||||
if len(parts) > 1 {
|
||||
rest = strings.Join(parts[1:], " ")
|
||||
}
|
||||
go i.invokeExtensionCommand(ctx, extName, rest)
|
||||
return false
|
||||
}
|
||||
i.mu.Lock()
|
||||
i.statusErr = "unknown command: " + parts[0]
|
||||
i.mu.Unlock()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue