talon-agent 5.25.1 → 5.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/backend/agy/one-shot.ts +34 -3
  4. package/src/backend/codex/auth.ts +31 -2
  5. package/src/backend/codex/constants.ts +21 -7
  6. package/src/backend/codex/discovery.ts +32 -1
  7. package/src/backend/codex/factory.ts +3 -5
  8. package/src/backend/codex/handler/message.ts +7 -7
  9. package/src/backend/codex/models.ts +53 -11
  10. package/src/backend/codex/one-shot.ts +139 -37
  11. package/src/backend/codex/state.ts +11 -0
  12. package/src/core/agents/abort-reason.ts +46 -0
  13. package/src/core/agents/registry.ts +3 -2
  14. package/src/core/background/dream/index.ts +2 -2
  15. package/src/core/background/heartbeat/agent.ts +1 -1
  16. package/src/core/background/isolated-agent.ts +1 -1
  17. package/src/core/backup/index.ts +1 -0
  18. package/src/core/backup/status.ts +30 -1
  19. package/src/core/config/index.ts +8 -0
  20. package/src/core/engine/gateway.ts +5 -0
  21. package/src/core/mesh/credentials/store.ts +60 -9
  22. package/src/core/tools/bridge.ts +58 -4
  23. package/src/frontend/discord/callbacks/components/effort.ts +4 -4
  24. package/src/frontend/discord/callbacks/components/index.ts +2 -0
  25. package/src/frontend/discord/commands/backup-panel.ts +219 -0
  26. package/src/frontend/discord/commands/backup.ts +25 -34
  27. package/src/frontend/discord/commands/info.ts +17 -5
  28. package/src/frontend/discord/commands/settings.ts +12 -9
  29. package/src/frontend/discord/render.ts +1 -1
  30. package/src/frontend/native/bridge/credentials/claims.ts +5 -5
  31. package/src/frontend/native/bridge/routes/chats.ts +40 -20
  32. package/src/frontend/native/bridge/routes/host.ts +15 -2
  33. package/src/frontend/native/bridge/routes/table.ts +4 -0
  34. package/src/frontend/native/commands/admin.ts +64 -0
  35. package/src/frontend/native/commands/backup.ts +191 -0
  36. package/src/frontend/native/commands/definitions.ts +113 -0
  37. package/src/frontend/native/commands/format.ts +24 -0
  38. package/src/frontend/native/commands/index.ts +106 -0
  39. package/src/frontend/native/commands/info.ts +97 -0
  40. package/src/frontend/native/commands/session.ts +130 -0
  41. package/src/frontend/native/commands/types.ts +26 -0
  42. package/src/frontend/native/protocol.ts +18 -0
  43. package/src/frontend/native/surface/handlers.ts +14 -1
  44. package/src/frontend/native/surface/status.ts +9 -1
  45. package/src/frontend/native/turn/emit.ts +32 -1
  46. package/src/frontend/presentation/backup-panel.ts +425 -0
  47. package/src/frontend/presentation/memory-report.ts +109 -0
  48. package/src/frontend/presentation/text-commands.ts +243 -0
  49. package/src/frontend/telegram/admin/sessions.ts +20 -5
  50. package/src/frontend/telegram/admin.ts +9 -2
  51. package/src/frontend/telegram/callbacks/backup.ts +153 -22
  52. package/src/frontend/telegram/callbacks/effort.ts +5 -5
  53. package/src/frontend/telegram/callbacks/index.ts +2 -2
  54. package/src/frontend/telegram/callbacks/settings.ts +3 -40
  55. package/src/frontend/telegram/commands/admin.ts +8 -7
  56. package/src/frontend/telegram/commands/backup.ts +56 -46
  57. package/src/frontend/telegram/commands/index.ts +3 -2
  58. package/src/frontend/telegram/commands/info.ts +45 -26
  59. package/src/frontend/telegram/commands/memory.ts +8 -92
  60. package/src/frontend/telegram/commands/settings.ts +13 -45
  61. package/src/frontend/telegram/commands/whatsapp-pairing.ts +19 -15
  62. package/src/frontend/telegram/render/backup-panel.ts +47 -0
  63. package/src/frontend/telegram/render/menu.ts +21 -30
  64. package/src/frontend/terminal/builtins/model.ts +20 -11
  65. package/src/frontend/whatsapp/commands.ts +16 -216
  66. package/src/frontend/whatsapp/messages/inbound.ts +33 -2
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The native bridge's slash commands — the single list `/help` prints,
3
+ * `GET /commands` serves for client autocomplete, and the parser matches
4
+ * against. A name not in this list is never intercepted: `/etc/hosts`,
5
+ * `/shrug` or a typo reaches the model as ordinary text.
6
+ *
7
+ * `admin` marks what needs the `operator` scope — the same scope the
8
+ * bridge demands for `POST /control` and `POST /config` — so a
9
+ * client-only credential cannot restart the daemon or restore a snapshot
10
+ * by typing what it could not POST.
11
+ */
12
+
13
+ import type { ClientCommand } from "../protocol.js";
14
+
15
+ export const NATIVE_COMMANDS = [
16
+ {
17
+ name: "help",
18
+ description: "All commands",
19
+ },
20
+ {
21
+ name: "status",
22
+ description: "Session info, context usage and stats",
23
+ },
24
+ {
25
+ name: "settings",
26
+ description: "This chat's model, backend, effort and pulse",
27
+ },
28
+ {
29
+ name: "model",
30
+ description: "List models; pick one or switch backend",
31
+ args: "[<n>|<id>|<backend>|default|backend default]",
32
+ },
33
+ {
34
+ name: "effort",
35
+ description: "Show or set thinking effort",
36
+ args: "[off|low|medium|high|max|adaptive]",
37
+ },
38
+ {
39
+ name: "pulse",
40
+ description: "Periodic check-ins: on, off, or an interval",
41
+ args: "[on|off|<interval>]",
42
+ },
43
+ {
44
+ name: "stop",
45
+ description: "Stop the current response",
46
+ },
47
+ {
48
+ name: "reset",
49
+ description: "Clear the session and start fresh",
50
+ },
51
+ {
52
+ name: "ping",
53
+ description: "Health check",
54
+ },
55
+ {
56
+ name: "usage",
57
+ description: "Plan limits across every backend",
58
+ },
59
+ {
60
+ name: "mesh",
61
+ description: "Ping and list mesh devices",
62
+ },
63
+ {
64
+ name: "plugins",
65
+ description: "List loaded plugins",
66
+ },
67
+ {
68
+ name: "memory",
69
+ description: "What Talon remembers — list, search, why <id>",
70
+ args: "[<query>|why <id>|kind <kind>]",
71
+ },
72
+ {
73
+ name: "metrics",
74
+ description: "Aggregate performance metrics",
75
+ args: "[all]",
76
+ admin: true,
77
+ },
78
+ {
79
+ name: "doctor",
80
+ description: "Environment and native-module health",
81
+ admin: true,
82
+ },
83
+ {
84
+ name: "dream",
85
+ description: "Force memory consolidation",
86
+ admin: true,
87
+ },
88
+ {
89
+ name: "restart",
90
+ description: "Restart the daemon",
91
+ admin: true,
92
+ },
93
+ {
94
+ name: "backup",
95
+ description: "Snapshots and checkpoints; restore <id>",
96
+ args: "[status|now|checkpoint <label>|list|show <id>|pin <id>|unpin <id>|restore <id>]",
97
+ admin: true,
98
+ },
99
+ ] as const satisfies readonly ClientCommand[];
100
+
101
+ export type NativeCommandName = (typeof NATIVE_COMMANDS)[number]["name"];
102
+
103
+ /** The command list as the wire carries it (a fresh copy per call). */
104
+ export function listNativeCommands(): ClientCommand[] {
105
+ return NATIVE_COMMANDS.map((c) => ({ ...c }));
106
+ }
107
+
108
+ /** The definition for a name, when it is one of ours. */
109
+ export function findNativeCommand(name: string): ClientCommand | undefined {
110
+ return (NATIVE_COMMANDS as readonly ClientCommand[]).find(
111
+ (c) => c.name === name,
112
+ );
113
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The native bridge's report dialect: Markdown, which every client
3
+ * renders, with no escaping (nothing is parsed server-side) and no
4
+ * per-message cap — a bridge message is a JSON string, not a platform
5
+ * bubble, so a report is one reply however long it grows.
6
+ */
7
+
8
+ import type { ReportFormatter } from "../../presentation/reports.js";
9
+
10
+ export const NATIVE_REPORTS: ReportFormatter = {
11
+ bold: (s) => `**${s}**`,
12
+ italic: (s) => `_${s}_`,
13
+ emphasis: (s) => `*${s}*`,
14
+ code: (s) => `\`${s}\``,
15
+ escape: (s) => s,
16
+ lineLimit: 100_000,
17
+ metricLabelMax: 80,
18
+ pulseLabel: "🔔 Pulse:",
19
+ };
20
+
21
+ /** Wrap preformatted text (status tables, listings) in a code fence. */
22
+ export function fence(text: string): string {
23
+ return `\`\`\`\n${text.replace(/```/g, "ˋˋˋ")}\n\`\`\``;
24
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Slash commands on the native bridge.
3
+ *
4
+ * `/send` hands every message here first. Text that names one of the
5
+ * commands in definitions.ts is answered by the daemon — the command and
6
+ * its reply land in the chat as ordinary messages, persisted like
7
+ * WhatsApp's so a reload keeps them — and never reaches the model. Any
8
+ * other text, including `/something` that is not ours, returns false and
9
+ * runs a turn exactly as before.
10
+ *
11
+ * Commands run even while a turn is in flight (that is what `/stop` is
12
+ * for) and are never queued behind one.
13
+ */
14
+
15
+ import { logError } from "../../../util/log.js";
16
+ import type { ChatEntry } from "../chats/chats.js";
17
+ import type { NativeRuntime } from "../runtime.js";
18
+ import { emitNotice, emitUser } from "../turn/emit.js";
19
+ import { adminCommands } from "./admin.js";
20
+ import { backupCommand } from "./backup.js";
21
+ import { findNativeCommand, type NativeCommandName } from "./definitions.js";
22
+ import { infoCommands } from "./info.js";
23
+ import { sessionCommands } from "./session.js";
24
+ import type { NativeCommandContext, NativeCommandHandler } from "./types.js";
25
+
26
+ export { listNativeCommands } from "./definitions.js";
27
+
28
+ const HANDLERS: Record<NativeCommandName, NativeCommandHandler> = {
29
+ ...infoCommands,
30
+ ...sessionCommands,
31
+ ...adminCommands,
32
+ backup: backupCommand,
33
+ };
34
+
35
+ export type ParsedNativeCommand = { name: NativeCommandName; arg: string };
36
+
37
+ /**
38
+ * Parse a slash command out of message text. Null for anything that is
39
+ * not one of ours — a path (`/etc/hosts`), a fraction, an unknown `/foo`.
40
+ */
41
+ export function parseNativeCommand(text: string): ParsedNativeCommand | null {
42
+ const match = /^\/([a-zA-Z]+)(?:\s+([\s\S]*))?$/.exec(text.trim());
43
+ if (!match) return null;
44
+ const def = findNativeCommand(match[1]!.toLowerCase());
45
+ if (!def) return null;
46
+ return {
47
+ name: def.name as NativeCommandName,
48
+ arg: (match[2] ?? "").trim(),
49
+ };
50
+ }
51
+
52
+ /** Who sent the command, as far as authorisation is concerned. */
53
+ export type NativeCommandCaller = {
54
+ /** The bridge credential holds the `operator` scope. */
55
+ operator: boolean;
56
+ };
57
+
58
+ async function runCommand(
59
+ runtime: NativeRuntime,
60
+ entry: ChatEntry,
61
+ cmd: ParsedNativeCommand,
62
+ caller: NativeCommandCaller,
63
+ ): Promise<void> {
64
+ const ctx: NativeCommandContext = {
65
+ runtime,
66
+ entry,
67
+ arg: cmd.arg,
68
+ operator: caller.operator,
69
+ deps: { config: runtime.config, gateway: runtime.gateway },
70
+ reply: (text) => {
71
+ emitNotice(runtime, entry, text);
72
+ },
73
+ };
74
+ if (findNativeCommand(cmd.name)?.admin && !caller.operator) {
75
+ ctx.reply(
76
+ `Not authorized — \`/${cmd.name}\` needs an operator credential, and this device's credential does not have that scope.`,
77
+ );
78
+ return;
79
+ }
80
+ try {
81
+ await HANDLERS[cmd.name](ctx);
82
+ } catch (err) {
83
+ logError("native", `/${cmd.name} failed`, err);
84
+ ctx.reply(
85
+ `⚠️ /${cmd.name} failed: ${err instanceof Error ? err.message : String(err)}`,
86
+ );
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Handle `text` as a slash command if it is one. True when it was — the
92
+ * caller then skips the agent turn. The command runs in the background:
93
+ * `/send` is fire-and-forget, and replies stream back as `message` events.
94
+ */
95
+ export function handleNativeCommand(
96
+ runtime: NativeRuntime,
97
+ entry: ChatEntry,
98
+ text: string,
99
+ caller: NativeCommandCaller,
100
+ ): boolean {
101
+ const cmd = parseNativeCommand(text);
102
+ if (!cmd) return false;
103
+ emitUser(runtime, entry, text.trim(), [], { autoTitle: false });
104
+ void runCommand(runtime, entry, cmd, caller);
105
+ return true;
106
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Read-only commands — `/help`, `/ping`, `/usage`, `/mesh`, `/plugins`,
3
+ * `/memory`. Each report is the one Telegram and Discord print, in the
4
+ * native Markdown dialect (presentation/reports.ts, memory-report.ts).
5
+ *
6
+ * `/memory` is not operator-gated here, unlike Telegram's admin-only
7
+ * `/memory`: the bridge already serves the same rows on `GET /memory` to
8
+ * any `client` credential, and a native chat is always a private one.
9
+ */
10
+
11
+ import { getLoadedPlugins } from "../../../core/plugin/index.js";
12
+ import { collectPlanUsage } from "../../presentation/plan-usage-report.js";
13
+ import { renderMemoryReport } from "../../presentation/memory-report.js";
14
+ import {
15
+ renderMeshReport,
16
+ renderUsageMessage,
17
+ } from "../../presentation/reports.js";
18
+ import { formatDuration } from "../../presentation/format.js";
19
+ import { NATIVE_COMMANDS } from "./definitions.js";
20
+ import { NATIVE_REPORTS } from "./format.js";
21
+ import type { NativeCommandContext, NativeCommandHandler } from "./types.js";
22
+
23
+ /** `/help` — generated from the definitions, so it can never drift. */
24
+ function renderNativeHelp(operator: boolean): string {
25
+ const lines = ["**Commands**"];
26
+ for (const cmd of NATIVE_COMMANDS) {
27
+ const args = "args" in cmd ? ` ${cmd.args}` : "";
28
+ const admin =
29
+ "admin" in cmd
30
+ ? operator
31
+ ? " (admin)"
32
+ : " (admin — not available to this device)"
33
+ : "";
34
+ lines.push(`\`/${cmd.name}${args}\` — ${cmd.description}${admin}`);
35
+ }
36
+ lines.push(
37
+ "",
38
+ "Anything else starting with a slash goes to the model as a normal message.",
39
+ );
40
+ return lines.join("\n");
41
+ }
42
+
43
+ async function help(ctx: NativeCommandContext): Promise<void> {
44
+ ctx.reply(renderNativeHelp(ctx.operator));
45
+ }
46
+
47
+ async function ping(ctx: NativeCommandContext): Promise<void> {
48
+ const uptime = formatDuration(process.uptime() * 1000);
49
+ ctx.reply(
50
+ `Pong! Bridge ✓ · ${ctx.runtime.chats.count()} chat(s) · uptime ${uptime}`,
51
+ );
52
+ }
53
+
54
+ async function usage(ctx: NativeCommandContext): Promise<void> {
55
+ const entries = await collectPlanUsage(ctx.runtime.config);
56
+ ctx.reply(renderUsageMessage(entries, NATIVE_REPORTS));
57
+ }
58
+
59
+ async function mesh(ctx: NativeCommandContext): Promise<void> {
60
+ try {
61
+ const results = await ctx.runtime.mesh.pingAll();
62
+ ctx.reply(renderMeshReport(results, NATIVE_REPORTS));
63
+ } catch {
64
+ ctx.reply("Could not reach the mesh service.");
65
+ }
66
+ }
67
+
68
+ async function plugins(ctx: NativeCommandContext): Promise<void> {
69
+ const loaded = getLoadedPlugins();
70
+ if (loaded.length === 0) {
71
+ ctx.reply("No plugins loaded.");
72
+ return;
73
+ }
74
+ const lines = loaded.map((p) => {
75
+ const ver = p.plugin.version ? ` v${p.plugin.version}` : "";
76
+ const desc = p.plugin.description ? ` — ${p.plugin.description}` : "";
77
+ const mcp = p.plugin.mcpServerPath ? " [MCP]" : "";
78
+ const fe = p.plugin.frontends?.length
79
+ ? ` (${p.plugin.frontends.join(", ")})`
80
+ : "";
81
+ return `• **${p.plugin.name}**${ver}${mcp}${fe}${desc}`;
82
+ });
83
+ ctx.reply(`**Plugins (${loaded.length})**\n\n${lines.join("\n")}`);
84
+ }
85
+
86
+ async function memory(ctx: NativeCommandContext): Promise<void> {
87
+ ctx.reply(renderMemoryReport(ctx.arg, NATIVE_REPORTS));
88
+ }
89
+
90
+ export const infoCommands = {
91
+ help,
92
+ ping,
93
+ usage,
94
+ mesh,
95
+ plugins,
96
+ memory,
97
+ } satisfies Record<string, NativeCommandHandler>;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Per-chat commands — `/model`, `/effort`, `/settings`, `/status`,
3
+ * `/pulse`, `/stop`, `/reset`.
4
+ *
5
+ * The listings and pickers are the ones WhatsApp prints
6
+ * (presentation/text-commands.ts). What is native here is the state the
7
+ * bridge keeps beside the shared stores: a backend switch goes through
8
+ * the bridge's own `setBackend` (it also drops turn meta, the cached
9
+ * context readout and the queued follow-up), `/reset` through the same
10
+ * `resetChat` as `POST /chats/reset`, and every change ends with a
11
+ * `chat_updated` so every connected client's header follows.
12
+ */
13
+
14
+ import {
15
+ disablePulse,
16
+ enablePulse,
17
+ } from "../../../core/background/pulse/pulse.js";
18
+ import {
19
+ getChatSettings,
20
+ setChatPulseInterval,
21
+ } from "../../../storage/chat-settings.js";
22
+ import { resetChatBackend } from "../../presentation/model-commands.js";
23
+ import { formatDuration, parseInterval } from "../../presentation/format.js";
24
+ import {
25
+ effortCommandReply,
26
+ modelCommandReply,
27
+ settingsCommandReply,
28
+ statusCommandReply,
29
+ } from "../../presentation/text-commands.js";
30
+ import { broadcastChatUpdated } from "../chats/chat-wire.js";
31
+ import { resetChat, wipeChatConversation } from "../chats/reset.js";
32
+ import { setBackend } from "../surface/models.js";
33
+ import { broadcastStatus } from "../surface/status.js";
34
+ import { interruptTurn } from "../turn/turn.js";
35
+ import type { NativeCommandContext, NativeCommandHandler } from "./types.js";
36
+
37
+ /** Pulse intervals below this would have the chat check in constantly. */
38
+ const MIN_PULSE_INTERVAL_MS = 5 * 60 * 1000;
39
+
40
+ async function model(ctx: NativeCommandContext): Promise<void> {
41
+ const { runtime, entry, deps } = ctx;
42
+ const text = await modelCommandReply(entry.id, ctx.arg, deps, {
43
+ switchBackend: async (target) => {
44
+ const result = await setBackend(runtime, entry.id, target.id);
45
+ return result.ok
46
+ ? `Backend: ${target.label} (\`${target.id}\`).`
47
+ : `Could not switch to ${target.label}: ${result.error ?? "rebind failed"}`;
48
+ },
49
+ resetBackend: async () => {
50
+ const outcome = await resetChatBackend(entry.id, deps);
51
+ wipeChatConversation(runtime, entry.id);
52
+ broadcastStatus(runtime);
53
+ return outcome.text;
54
+ },
55
+ });
56
+ if (ctx.arg) broadcastChatUpdated(runtime, entry);
57
+ ctx.reply(text);
58
+ }
59
+
60
+ async function effort(ctx: NativeCommandContext): Promise<void> {
61
+ const text = await effortCommandReply(ctx.entry.id, ctx.arg, ctx.deps);
62
+ if (ctx.arg) broadcastChatUpdated(ctx.runtime, ctx.entry);
63
+ ctx.reply(text);
64
+ }
65
+
66
+ async function settings(ctx: NativeCommandContext): Promise<void> {
67
+ ctx.reply(await settingsCommandReply(ctx.entry.id, ctx.deps));
68
+ }
69
+
70
+ async function status(ctx: NativeCommandContext): Promise<void> {
71
+ ctx.reply(await statusCommandReply(ctx.entry.id, ctx.deps));
72
+ }
73
+
74
+ /** The reply for a pulse argument, applying the change it names. */
75
+ function applyPulse(chatId: string, arg: string): string {
76
+ const lower = arg.toLowerCase();
77
+ if (!lower || lower === "status") {
78
+ const sets = getChatSettings(chatId);
79
+ return (
80
+ `**🔔 Pulse:** ${sets.pulse === true ? "on" : "off"}` +
81
+ (sets.pulseIntervalMs
82
+ ? ` (every ${formatDuration(sets.pulseIntervalMs)})`
83
+ : "") +
84
+ "\n\nReads along every few minutes and jumps in when there's something to add. " +
85
+ "`/pulse on`, `/pulse off`, `/pulse 30m`."
86
+ );
87
+ }
88
+ if (lower === "on" || lower === "enable") {
89
+ enablePulse(chatId);
90
+ return "🔔 Pulse enabled.";
91
+ }
92
+ if (lower === "off" || lower === "disable") {
93
+ disablePulse(chatId);
94
+ return "🔔 Pulse disabled.";
95
+ }
96
+ const intervalMs = parseInterval(lower);
97
+ if (!intervalMs)
98
+ return "Use: `/pulse on`, `/pulse off`, `/pulse 30m`, `/pulse 2h`";
99
+ if (intervalMs < MIN_PULSE_INTERVAL_MS)
100
+ return "Minimum interval is 5 minutes.";
101
+ setChatPulseInterval(chatId, intervalMs);
102
+ enablePulse(chatId);
103
+ return `🔔 Pulse cooldown set to **${formatDuration(intervalMs)}**`;
104
+ }
105
+
106
+ async function pulse(ctx: NativeCommandContext): Promise<void> {
107
+ const text = applyPulse(ctx.entry.id, ctx.arg);
108
+ if (ctx.arg) broadcastChatUpdated(ctx.runtime, ctx.entry);
109
+ ctx.reply(text);
110
+ }
111
+
112
+ async function stop(ctx: NativeCommandContext): Promise<void> {
113
+ const stopped = await interruptTurn(ctx.runtime, ctx.entry.id);
114
+ ctx.reply(stopped ? "⏹ Stopped." : "Nothing is running.");
115
+ }
116
+
117
+ /** `resetChat` posts its own notice — a second "cleared" would be noise. */
118
+ async function reset(ctx: NativeCommandContext): Promise<void> {
119
+ resetChat(ctx.runtime, ctx.entry.id);
120
+ }
121
+
122
+ export const sessionCommands = {
123
+ model,
124
+ effort,
125
+ settings,
126
+ status,
127
+ pulse,
128
+ stop,
129
+ reset,
130
+ } satisfies Record<string, NativeCommandHandler>;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * What a native slash-command handler receives. Handlers answer through
3
+ * `reply` rather than a return value, so a command that has more to say
4
+ * later (a snapshot finishing, a restart about to happen) says it in
5
+ * order, and one with nothing to add (`/reset` — the reset emits its own
6
+ * notice) simply says nothing.
7
+ */
8
+
9
+ import type { ModelCommandDeps } from "../../presentation/model-commands.js";
10
+ import type { ChatEntry } from "../chats/chats.js";
11
+ import type { NativeRuntime } from "../runtime.js";
12
+
13
+ export type NativeCommandContext = {
14
+ runtime: NativeRuntime;
15
+ entry: ChatEntry;
16
+ /** Everything after the command name, trimmed. */
17
+ arg: string;
18
+ /** The caller holds the bridge's `operator` scope. */
19
+ operator: boolean;
20
+ /** The shared model-command dependencies for this runtime. */
21
+ deps: ModelCommandDeps;
22
+ /** Post a Markdown reply into the chat. */
23
+ reply(text: string): void;
24
+ };
25
+
26
+ export type NativeCommandHandler = (ctx: NativeCommandContext) => Promise<void>;
@@ -216,6 +216,24 @@ export type LogEntry = {
216
216
  stack?: string;
217
217
  };
218
218
 
219
+ /**
220
+ * One slash command the daemon answers itself in a native chat, as
221
+ * `GET /commands` lists it — so a client can offer autocomplete. Additive
222
+ * in v1 behind the `commands` capability. A `/send` whose text starts with
223
+ * a listed name is handled by the daemon (the command and its reply land
224
+ * in the chat as ordinary messages); any other text reaches the model.
225
+ */
226
+ export type ClientCommand = {
227
+ /** Without the leading slash, e.g. `"backup"`. */
228
+ name: string;
229
+ /** One line, for the suggestion list. */
230
+ description: string;
231
+ /** Argument synopsis, e.g. `"[list|now|restore <id>]"`; absent = none. */
232
+ args?: string;
233
+ /** Needs the `operator` scope; other callers get a refusal. */
234
+ admin?: boolean;
235
+ };
236
+
219
237
  /** A selectable model for the picker. */
220
238
  export type ModelOption = {
221
239
  id: string;
@@ -10,6 +10,7 @@ import { getPooledBackend } from "../../../core/engine/backend-controller/index.
10
10
  import { createChat, deleteChat, renameChat } from "../chats/chat-lifecycle.js";
11
11
  import { broadcastChatUpdated, toClientChat } from "../chats/chat-wire.js";
12
12
  import { control } from "./control.js";
13
+ import { handleNativeCommand, listNativeCommands } from "../commands/index.js";
13
14
  import {
14
15
  pluginItems,
15
16
  skillItems,
@@ -108,11 +109,22 @@ export function buildBridgeHandlers(
108
109
  // straight delegations to the read-only half of the store.
109
110
  listMemory,
110
111
  memoryWhy,
111
- send: (id, text, opts) => {
112
+ send: (id, text, opts, caller) => {
112
113
  const entry = chats.get(id) ?? chats.ensure(id);
113
114
  // Resolve the client's references into the records this daemon minted
114
115
  // at upload time — dropping anything it can't account for.
115
116
  const attachments = resolveAttachments(runtime, opts);
117
+ // A slash command the daemon answers itself never reaches the model,
118
+ // and never waits behind a running turn (`/stop` is for exactly
119
+ // that). A message with files attached is always the model's.
120
+ if (
121
+ !attachments.length &&
122
+ handleNativeCommand(runtime, entry, text, {
123
+ operator: caller?.operator === true,
124
+ })
125
+ ) {
126
+ return;
127
+ }
116
128
  // A turn is already running for this chat — don't interrupt it. Park the
117
129
  // message as the single queued follow-up (synced to every client); it
118
130
  // auto-sends when the running turn ends. `isBusy` reads `liveTurns`,
@@ -188,6 +200,7 @@ export function buildBridgeHandlers(
188
200
  setSkillEnabled: (name, enabled) =>
189
201
  toggleSkill(config, getPooledBackend(config.backend), name, enabled),
190
202
  control,
203
+ listCommands: () => listNativeCommands(),
191
204
  logs: ({ lines, minLevel, component }) =>
192
205
  readLogEntries(files.log, { limit: lines, minLevel, component }),
193
206
  liveTurnEvents: () => liveTurnEvents(runtime),
@@ -14,7 +14,15 @@ export function bridgeStatus(runtime: NativeRuntime): BridgeStatus {
14
14
  protocol: BRIDGE_PROTOCOL_VERSION,
15
15
  // "attachments": /upload streams any file type and /send takes an
16
16
  // `attachments` list, so a client can stage several files per message.
17
- capabilities: ["mesh", "mesh-commands", "plugins-skills", "attachments"],
17
+ // "commands": `/send` answers slash commands itself and `GET /commands`
18
+ // lists them for autocomplete.
19
+ capabilities: [
20
+ "mesh",
21
+ "mesh-commands",
22
+ "plugins-skills",
23
+ "attachments",
24
+ "commands",
25
+ ],
18
26
  botName: runtime.botName,
19
27
  backend: config.backend,
20
28
  model: resolveModel(config.model)?.displayName ?? config.model,
@@ -67,6 +67,36 @@ export function emitAssistant(
67
67
  return id;
68
68
  }
69
69
 
70
+ /**
71
+ * Persist + broadcast an assistant message that is not a turn's reply —
72
+ * a slash command's answer. Unlike `emitAssistant` it leaves
73
+ * `lastAssistantId` alone: a turn still running beside the command must
74
+ * attach its tool timeline to its own reply, not to this one.
75
+ */
76
+ export function emitNotice(
77
+ runtime: NativeRuntime,
78
+ entry: ChatEntry,
79
+ text: string,
80
+ ): number {
81
+ const id = runtime.nextId();
82
+ const ts = Date.now();
83
+ pushMessage(entry.id, {
84
+ msgId: id,
85
+ senderId: BOT_SENDER_ID,
86
+ senderName: runtime.botName,
87
+ text,
88
+ timestamp: ts,
89
+ });
90
+ runtime.chats.touch(entry.id, text);
91
+ runtime.broadcast({
92
+ kind: "message",
93
+ chatId: entry.id,
94
+ message: { id: String(id), chatId: entry.id, role: "assistant", text, ts },
95
+ });
96
+ broadcastChatUpdated(runtime, entry);
97
+ return id;
98
+ }
99
+
70
100
  /** Persist + broadcast an assistant photo message (image + optional caption). */
71
101
  export function emitPhoto(
72
102
  runtime: NativeRuntime,
@@ -122,6 +152,7 @@ export function emitUser(
122
152
  entry: ChatEntry,
123
153
  text: string,
124
154
  attachments: ClientAttachment[] = [],
155
+ opts: { autoTitle?: boolean } = {},
125
156
  ): number {
126
157
  const id = runtime.nextId();
127
158
  const ts = Date.now();
@@ -160,7 +191,7 @@ export function emitUser(
160
191
  entry.id,
161
192
  attachments.length ? text || attachmentPreview(attachments) : text,
162
193
  );
163
- maybeAutoTitle(runtime, entry, text);
194
+ if (opts.autoTitle !== false) maybeAutoTitle(runtime, entry, text);
164
195
  runtime.broadcast({ kind: "message", chatId: entry.id, message });
165
196
  broadcastChatUpdated(runtime, entry);
166
197
  return id;