talon-agent 5.25.0 → 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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/backend/agy/one-shot.ts +34 -3
- package/src/backend/codex/auth.ts +31 -2
- package/src/backend/codex/constants.ts +21 -7
- package/src/backend/codex/discovery.ts +32 -1
- package/src/backend/codex/factory.ts +3 -5
- package/src/backend/codex/handler/message.ts +7 -7
- package/src/backend/codex/models.ts +53 -11
- package/src/backend/codex/one-shot.ts +139 -37
- package/src/backend/codex/state.ts +11 -0
- package/src/core/agents/abort-reason.ts +46 -0
- package/src/core/agents/registry.ts +3 -2
- package/src/core/background/dream/index.ts +2 -2
- package/src/core/background/heartbeat/agent.ts +1 -1
- package/src/core/background/isolated-agent.ts +1 -1
- package/src/core/backup/index.ts +1 -0
- package/src/core/backup/status.ts +30 -1
- package/src/core/config/index.ts +8 -0
- package/src/core/engine/gateway.ts +5 -0
- package/src/core/mesh/credentials/store.ts +60 -9
- package/src/core/tools/bridge.ts +58 -4
- package/src/frontend/discord/callbacks/components/effort.ts +4 -4
- package/src/frontend/discord/callbacks/components/index.ts +2 -0
- package/src/frontend/discord/commands/backup-panel.ts +219 -0
- package/src/frontend/discord/commands/backup.ts +25 -34
- package/src/frontend/discord/commands/info.ts +17 -5
- package/src/frontend/discord/commands/settings.ts +12 -9
- package/src/frontend/discord/render.ts +1 -1
- package/src/frontend/native/bridge/credentials/claims.ts +5 -5
- package/src/frontend/native/bridge/routes/chats.ts +40 -20
- package/src/frontend/native/bridge/routes/host.ts +15 -2
- package/src/frontend/native/bridge/routes/table.ts +4 -0
- package/src/frontend/native/commands/admin.ts +64 -0
- package/src/frontend/native/commands/backup.ts +191 -0
- package/src/frontend/native/commands/definitions.ts +113 -0
- package/src/frontend/native/commands/format.ts +24 -0
- package/src/frontend/native/commands/index.ts +106 -0
- package/src/frontend/native/commands/info.ts +97 -0
- package/src/frontend/native/commands/session.ts +130 -0
- package/src/frontend/native/commands/types.ts +26 -0
- package/src/frontend/native/protocol.ts +18 -0
- package/src/frontend/native/surface/handlers.ts +14 -1
- package/src/frontend/native/surface/status.ts +9 -1
- package/src/frontend/native/turn/emit.ts +32 -1
- package/src/frontend/presentation/backup-panel.ts +425 -0
- package/src/frontend/presentation/memory-report.ts +109 -0
- package/src/frontend/presentation/text-commands.ts +243 -0
- package/src/frontend/telegram/admin/sessions.ts +20 -5
- package/src/frontend/telegram/admin.ts +9 -2
- package/src/frontend/telegram/callbacks/backup.ts +153 -22
- package/src/frontend/telegram/callbacks/effort.ts +5 -5
- package/src/frontend/telegram/callbacks/index.ts +2 -2
- package/src/frontend/telegram/callbacks/settings.ts +3 -40
- package/src/frontend/telegram/commands/admin.ts +8 -7
- package/src/frontend/telegram/commands/backup.ts +56 -46
- package/src/frontend/telegram/commands/index.ts +3 -2
- package/src/frontend/telegram/commands/info.ts +45 -26
- package/src/frontend/telegram/commands/memory.ts +8 -92
- package/src/frontend/telegram/commands/settings.ts +13 -45
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +19 -15
- package/src/frontend/telegram/render/backup-panel.ts +47 -0
- package/src/frontend/telegram/render/menu.ts +21 -30
- package/src/frontend/terminal/builtins/model.ts +20 -11
- package/src/frontend/whatsapp/commands.ts +16 -216
- 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
|
-
|
|
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;
|