march-cli 0.1.32 → 0.1.34
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/package.json +1 -1
- package/src/agent/output/binary-output-sink.mjs +17 -0
- package/src/agent/output/send-binary-tool.mjs +84 -0
- package/src/agent/tools.mjs +3 -0
- package/src/cli/args.mjs +3 -1
- package/src/cli/commands/help-command.mjs +1 -1
- package/src/cli/commands/mode-command.mjs +21 -0
- package/src/cli/repl-loop.mjs +1 -0
- package/src/cli/slash-commands.mjs +8 -0
- package/src/cli/startup/configured-command.mjs +17 -0
- package/src/cli/startup/gateway-daemon-command.mjs +21 -0
- package/src/config/loader.mjs +31 -0
- package/src/gateway/command-router.mjs +44 -0
- package/src/gateway/command.mjs +107 -0
- package/src/gateway/config.mjs +62 -0
- package/src/gateway/daemon.mjs +41 -0
- package/src/gateway/handler.mjs +29 -0
- package/src/gateway/message.mjs +37 -0
- package/src/gateway/platform-registry.mjs +38 -0
- package/src/gateway/platforms/telegram.mjs +241 -0
- package/src/gateway/runner-bridge.mjs +55 -0
- package/src/gateway/runtime/queue.mjs +46 -0
- package/src/gateway/session-store.mjs +46 -0
- package/src/gateway/setup/command.mjs +150 -0
- package/src/gateway/workspace-command.mjs +40 -0
- package/src/image-gen/tool.mjs +16 -9
- package/src/lsp/client.mjs +1 -0
- package/src/lsp/managed-node-server.mjs +1 -0
- package/src/lsp/server-definitions.mjs +8 -0
- package/src/main.mjs +6 -9
- package/src/memory/markdown/memory-id.mjs +36 -0
- package/src/memory/markdown-store.mjs +17 -6
- package/src/memory/markdown-tools.mjs +3 -2
- package/src/platform/open-file.mjs +9 -10
package/package.json
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { openFileWithDefaultApp } from "../../platform/open-file.mjs";
|
|
3
|
+
|
|
4
|
+
const sinkStorage = new AsyncLocalStorage();
|
|
5
|
+
|
|
6
|
+
export function withBinaryOutputSink(sink, fn) {
|
|
7
|
+
return sinkStorage.run(sink, fn);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function sendBinaryOutput(binary, { openFile = openFileWithDefaultApp } = {}) {
|
|
11
|
+
const sink = sinkStorage.getStore();
|
|
12
|
+
if (sink?.sendBinary) return sink.sendBinary(binary);
|
|
13
|
+
if (!binary.path && !binary.url) throw new Error("send_binary requires a path or url");
|
|
14
|
+
if (binary.url) throw new Error("send_binary url output is only supported by gateway sinks");
|
|
15
|
+
await openFile(binary.path);
|
|
16
|
+
return { target: "local", opened: true };
|
|
17
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { basename, extname } from "node:path";
|
|
3
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { toolText } from "../tool-result.mjs";
|
|
6
|
+
import { sendBinaryOutput } from "./binary-output-sink.mjs";
|
|
7
|
+
|
|
8
|
+
const SUPPORTED_TYPES = new Set(["image", "video", "audio", "file"]);
|
|
9
|
+
const MIME_BY_EXT = new Map([
|
|
10
|
+
[".png", "image/png"], [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"], [".webp", "image/webp"], [".gif", "image/gif"],
|
|
11
|
+
[".mp4", "video/mp4"], [".mov", "video/quicktime"], [".webm", "video/webm"],
|
|
12
|
+
[".mp3", "audio/mpeg"], [".wav", "audio/wav"], [".ogg", "audio/ogg"], [".opus", "audio/ogg"],
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export function createSendBinaryTool({ engine, sendBinary = sendBinaryOutput } = {}) {
|
|
16
|
+
return defineTool({
|
|
17
|
+
name: "send_binary",
|
|
18
|
+
label: "Send Binary",
|
|
19
|
+
description: "Send or display an existing binary artifact. In the TUI this opens local files with the system default app; in a gateway it sends image/video/audio/file media through the platform.",
|
|
20
|
+
promptSnippet: "send_binary(type, path|url, caption?, mimeType?) - Send/display an existing image, video, audio, or file artifact.",
|
|
21
|
+
promptGuidelines: [
|
|
22
|
+
"Use send_binary when the user asks you to send, show, open, or deliver an existing media/file artifact.",
|
|
23
|
+
"Do not use send_binary to generate media; first create or locate the artifact, then send it.",
|
|
24
|
+
],
|
|
25
|
+
parameters: Type.Object({
|
|
26
|
+
type: Type.String({ enum: [...SUPPORTED_TYPES], description: "Binary type: image, video, audio, or file" }),
|
|
27
|
+
path: Type.Optional(Type.String({ description: "Local file path, absolute or relative to the workspace" })),
|
|
28
|
+
url: Type.Optional(Type.String({ description: "Remote URL to send through gateway platforms" })),
|
|
29
|
+
caption: Type.Optional(Type.String({ description: "Optional caption to send with the media" })),
|
|
30
|
+
mimeType: Type.Optional(Type.String({ description: "Optional MIME type override" })),
|
|
31
|
+
}),
|
|
32
|
+
execute: async (_toolCallId, params) => {
|
|
33
|
+
try {
|
|
34
|
+
const binary = normalizeBinaryOutput(params, { engine });
|
|
35
|
+
const sinkResult = await sendBinary(binary);
|
|
36
|
+
return toolJson({ success: true, ...binary, sink: sinkResult }, { binary, sink: sinkResult });
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return toolJson({ success: false, error: err.message }, { error: true });
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function normalizeBinaryOutput(params, { engine } = {}) {
|
|
45
|
+
const type = clean(params.type);
|
|
46
|
+
if (!SUPPORTED_TYPES.has(type)) throw new Error("send_binary type must be one of: image, video, audio, file");
|
|
47
|
+
const rawPath = clean(params.path);
|
|
48
|
+
const url = clean(params.url);
|
|
49
|
+
if (rawPath && url) throw new Error("send_binary accepts either path or url, not both");
|
|
50
|
+
if (!rawPath && !url) throw new Error("send_binary requires path or url");
|
|
51
|
+
|
|
52
|
+
if (url) {
|
|
53
|
+
assertHttpUrl(url);
|
|
54
|
+
return { type, url, caption: clean(params.caption), mimeType: clean(params.mimeType) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const path = engine?.resolvePath ? engine.resolvePath(rawPath) : rawPath;
|
|
58
|
+
const stat = statSync(path);
|
|
59
|
+
if (!stat.isFile()) throw new Error(`send_binary path is not a file: ${path}`);
|
|
60
|
+
return {
|
|
61
|
+
type,
|
|
62
|
+
path,
|
|
63
|
+
filename: basename(path),
|
|
64
|
+
caption: clean(params.caption),
|
|
65
|
+
mimeType: clean(params.mimeType) ?? MIME_BY_EXT.get(extname(path).toLowerCase()) ?? "application/octet-stream",
|
|
66
|
+
sizeBytes: stat.size,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assertHttpUrl(value) {
|
|
71
|
+
let parsed;
|
|
72
|
+
try { parsed = new URL(value); } catch { throw new Error(`send_binary url is invalid: ${value}`); }
|
|
73
|
+
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("send_binary url must use http or https");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function clean(value) {
|
|
77
|
+
if (value == null) return null;
|
|
78
|
+
const text = String(value).trim();
|
|
79
|
+
return text || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toolJson(payload, details = {}) {
|
|
83
|
+
return toolText(JSON.stringify(payload, null, 2), details);
|
|
84
|
+
}
|
package/src/agent/tools.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { createContextStatsTool } from "./context-stats-tool.mjs";
|
|
|
3
3
|
import { createEditFileTool } from "./file-edit-tool.mjs";
|
|
4
4
|
import { createReadFileTool } from "./file-tools/read-file-tool.mjs";
|
|
5
5
|
import { createReadImageTool } from "./file-tools/read-image-tool.mjs";
|
|
6
|
+
import { createSendBinaryTool } from "./output/send-binary-tool.mjs";
|
|
6
7
|
import { createScreenTool } from "./screen-tools/screen-tool.mjs";
|
|
7
8
|
import { createListWindowsTool } from "./screen-tools/list-windows-tool.mjs";
|
|
8
9
|
import { toolText } from "./tool-result.mjs";
|
|
@@ -17,12 +18,14 @@ export function createMarchCustomTools({ cwd, engine, ui, memoryTools = [], shel
|
|
|
17
18
|
const editFileTool = createEditFileTool({ engine, ui, lspService });
|
|
18
19
|
const readFileTool = createReadFileTool({ engine });
|
|
19
20
|
const readImageTool = createReadImageTool({ engine, getCurrentModel });
|
|
21
|
+
const sendBinaryTool = createSendBinaryTool({ engine });
|
|
20
22
|
const screenTool = createScreenTool({ getCurrentModel });
|
|
21
23
|
const listWindowsTool = createListWindowsTool();
|
|
22
24
|
|
|
23
25
|
const tools = [
|
|
24
26
|
readFileTool,
|
|
25
27
|
readImageTool,
|
|
28
|
+
sendBinaryTool,
|
|
26
29
|
screenTool,
|
|
27
30
|
listWindowsTool,
|
|
28
31
|
contextStatsTool,
|
package/src/cli/args.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export function parseCliArgs(argv) {
|
|
|
28
28
|
allowPositionals: true,
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
const commandName = ["login", "provider", "websearch", "memory", "browser"].includes(positionals[0]) ? positionals[0] : null;
|
|
31
|
+
const commandName = ["login", "provider", "websearch", "memory", "browser", "gateway"].includes(positionals[0]) ? positionals[0] : null;
|
|
32
32
|
|
|
33
33
|
return {
|
|
34
34
|
command: commandName ? { name: commandName, args: positionals.slice(1) } : null,
|
|
@@ -73,6 +73,8 @@ Usage:
|
|
|
73
73
|
march browser install Install the developer browser extension
|
|
74
74
|
march browser status Show browser daemon/extension status
|
|
75
75
|
march browser restart Restart the browser daemon
|
|
76
|
+
march gateway setup Configure gateway interactively
|
|
77
|
+
march gateway status Show gateway configuration status
|
|
76
78
|
|
|
77
79
|
Options:
|
|
78
80
|
-m, --model <id> Initial model ID override
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export function formatHelpLines() {
|
|
2
2
|
return [
|
|
3
|
-
"Commands: /new, /exit, /help, /hotkeys, /templates, /export jsonl, /export html, /export gist <jsonl|html>, /settings, /extensions, /providers, /providers <name>, /model, /models, /session, /status, /shell, /shell spawn [name], /save, /name, /copy",
|
|
3
|
+
"Commands: /new, /exit, /help, /hotkeys, /templates, /do, /discuss, /mode, /export jsonl, /export html, /export gist <jsonl|html>, /settings, /extensions, /providers, /providers <name>, /model, /models, /session, /status, /shell, /shell spawn [name], /save, /name, /copy",
|
|
4
4
|
"Sessions: /session opens previous sessions and restores the selected one.",
|
|
5
5
|
"Shortcuts: Tab = toggle Do/Discuss, Esc = abort turn, Ctrl+C = abort turn / press twice to exit when idle, Ctrl+O = toggle tool output, Alt+S = shell pane, Alt+N = next shell, Alt+K/J = shell scroll, PageUp/PageDown = output scroll, Ctrl+G = external editor, Shift+Tab = thinking selector, Ctrl+T = thinking selector, Ctrl+L = model selector",
|
|
6
6
|
];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { MODES, formatModeLabel } from "../input/mode-state.mjs";
|
|
2
|
+
|
|
3
|
+
export function parseModeCommand(input) {
|
|
4
|
+
const trimmed = String(input ?? "").trim();
|
|
5
|
+
if (trimmed === "/mode") return { type: "show" };
|
|
6
|
+
if (trimmed === "/do") return { type: "set", mode: MODES.DO };
|
|
7
|
+
if (trimmed === "/discuss") return { type: "set", mode: MODES.DISCUSS };
|
|
8
|
+
return { type: "none" };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function handleModeCommand(command, { modeState } = {}) {
|
|
12
|
+
if (!modeState || typeof modeState.get !== "function") {
|
|
13
|
+
return ["Mode switching is unavailable in this runtime."];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (command.type === "set") {
|
|
17
|
+
modeState.set(command.mode);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return [`Mode: ${formatModeLabel(modeState.get())}`];
|
|
21
|
+
}
|
package/src/cli/repl-loop.mjs
CHANGED
|
@@ -87,6 +87,7 @@ export async function runInteractiveRepl({
|
|
|
87
87
|
keybindingDiagnostics: keybindingConfig.diagnostics,
|
|
88
88
|
promptTemplates: promptTemplateConfig.templates,
|
|
89
89
|
promptTemplateDiagnostics: promptTemplateConfig.diagnostics,
|
|
90
|
+
modeState,
|
|
90
91
|
renderStartupBanner,
|
|
91
92
|
configHomeDir,
|
|
92
93
|
});
|
|
@@ -11,6 +11,7 @@ import { handleSettingsCommand, parseSettingsCommand } from "../config/settings-
|
|
|
11
11
|
import { handleSessionNameCommand, parseSessionNameCommand } from "./session/session-name-command.mjs";
|
|
12
12
|
import { handleShellCommand, parseShellCommand } from "./shell/shell-command.mjs";
|
|
13
13
|
import { handleProviderCommand, parseProviderCommand } from "./commands/provider-command.mjs";
|
|
14
|
+
import { handleModeCommand, parseModeCommand } from "./commands/mode-command.mjs";
|
|
14
15
|
import { formatHelpLines } from "./commands/help-command.mjs";
|
|
15
16
|
|
|
16
17
|
export async function handleSlashCommand(trimmed, {
|
|
@@ -25,6 +26,7 @@ export async function handleSlashCommand(trimmed, {
|
|
|
25
26
|
keybindingDiagnostics = [],
|
|
26
27
|
promptTemplates = [],
|
|
27
28
|
promptTemplateDiagnostics = [],
|
|
29
|
+
modeState = null,
|
|
28
30
|
renderStartupBanner = null,
|
|
29
31
|
settingsHomeDir,
|
|
30
32
|
configHomeDir = settingsHomeDir,
|
|
@@ -66,6 +68,12 @@ export async function handleSlashCommand(trimmed, {
|
|
|
66
68
|
return { handled: true };
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
const modeCommand = parseModeCommand(trimmed);
|
|
72
|
+
if (modeCommand.type !== "none") {
|
|
73
|
+
for (const line of handleModeCommand(modeCommand, { modeState })) ui.writeln(line);
|
|
74
|
+
return { handled: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
69
77
|
if (trimmed === "/hotkeys") {
|
|
70
78
|
for (const line of formatHotkeysPanel(keybindings, keybindingDiagnostics)) ui.writeln(line);
|
|
71
79
|
return { handled: true };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { runBrowserCommand } from "../../browser/cli/command.mjs";
|
|
2
|
+
import { runGatewayCommand } from "../../gateway/command.mjs";
|
|
3
|
+
|
|
4
|
+
export async function runConfiguredCliCommand(args, { config, cwd, stateRoot }) {
|
|
5
|
+
if (args.command?.name === "browser") {
|
|
6
|
+
try {
|
|
7
|
+
return { handled: true, code: await runBrowserCommand(args, { stateRoot }) };
|
|
8
|
+
} catch (err) {
|
|
9
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
10
|
+
return { handled: true, code: 1 };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
if (args.command?.name === "gateway" && args.command.args?.[0] !== "run") {
|
|
14
|
+
return { handled: true, code: await runGatewayCommand(args, { config, cwd }) };
|
|
15
|
+
}
|
|
16
|
+
return { handled: false, code: null };
|
|
17
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { runGatewayCommand } from "../../gateway/command.mjs";
|
|
2
|
+
import { createGatewayRunnerBridge } from "../../gateway/runner-bridge.mjs";
|
|
3
|
+
import { closeMarchRuntime } from "./runtime-close.mjs";
|
|
4
|
+
|
|
5
|
+
export async function maybeRunGatewayDaemonCommand(args, {
|
|
6
|
+
config,
|
|
7
|
+
cwd,
|
|
8
|
+
runner,
|
|
9
|
+
currentProject,
|
|
10
|
+
memoryStore,
|
|
11
|
+
ui,
|
|
12
|
+
logger,
|
|
13
|
+
} = {}) {
|
|
14
|
+
if (args.command?.name !== "gateway" || args.command.args?.[0] !== "run") return { handled: false, code: null };
|
|
15
|
+
const bridge = createGatewayRunnerBridge({ runner, cwd });
|
|
16
|
+
try {
|
|
17
|
+
return { handled: true, code: await runGatewayCommand(args, { config, cwd, getRunner: bridge.getRunner, currentProject }) };
|
|
18
|
+
} finally {
|
|
19
|
+
await closeMarchRuntime({ runner, memoryStore, ui, logger });
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/config/loader.mjs
CHANGED
|
@@ -59,6 +59,7 @@ function mergeLayers(layers) {
|
|
|
59
59
|
trimBatch: null,
|
|
60
60
|
memoryRoot: null,
|
|
61
61
|
notifications: { turnEnd: true, desktop: true, bell: false, command: null, minDurationMs: 0, sound: true },
|
|
62
|
+
gateway: { enabled: false, defaultWorkspace: null, workspaces: {}, platforms: {} },
|
|
62
63
|
remoteMemories: [],
|
|
63
64
|
};
|
|
64
65
|
for (const layer of layers) {
|
|
@@ -88,6 +89,9 @@ function mergeLayers(layers) {
|
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
if (layer.memoryRoot) result.memoryRoot = layer.memoryRoot;
|
|
92
|
+
if (layer.gateway && typeof layer.gateway === "object" && !Array.isArray(layer.gateway)) {
|
|
93
|
+
result.gateway = mergeGateway(result.gateway, layer.gateway);
|
|
94
|
+
}
|
|
91
95
|
if (Array.isArray(layer.remoteMemories)) result.remoteMemories = mergeRemoteMemories(result.remoteMemories, layer.remoteMemories);
|
|
92
96
|
}
|
|
93
97
|
return result;
|
|
@@ -127,6 +131,33 @@ function mergeHostedTools(current, next) {
|
|
|
127
131
|
return merged;
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
function mergeGateway(current, next) {
|
|
135
|
+
const merged = {
|
|
136
|
+
...current,
|
|
137
|
+
workspaces: { ...(current.workspaces ?? {}) },
|
|
138
|
+
platforms: { ...(current.platforms ?? {}) },
|
|
139
|
+
};
|
|
140
|
+
if (typeof next.enabled === "boolean") merged.enabled = next.enabled;
|
|
141
|
+
if (next.defaultWorkspace != null) merged.defaultWorkspace = next.defaultWorkspace;
|
|
142
|
+
if (next.default_workspace != null) merged.defaultWorkspace = next.default_workspace;
|
|
143
|
+
if (next.workspaces && typeof next.workspaces === "object" && !Array.isArray(next.workspaces)) {
|
|
144
|
+
merged.workspaces = { ...merged.workspaces, ...next.workspaces };
|
|
145
|
+
}
|
|
146
|
+
if (next.platforms && typeof next.platforms === "object" && !Array.isArray(next.platforms)) {
|
|
147
|
+
merged.platforms = mergeGatewayPlatforms(merged.platforms, next.platforms);
|
|
148
|
+
}
|
|
149
|
+
return merged;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function mergeGatewayPlatforms(current, next) {
|
|
153
|
+
const merged = { ...current };
|
|
154
|
+
for (const [id, profile] of Object.entries(next)) {
|
|
155
|
+
if (!profile || typeof profile !== "object" || Array.isArray(profile)) continue;
|
|
156
|
+
merged[id] = { ...(merged[id] ?? {}), ...profile };
|
|
157
|
+
}
|
|
158
|
+
return merged;
|
|
159
|
+
}
|
|
160
|
+
|
|
130
161
|
function mergeProviders(current, next) {
|
|
131
162
|
const merged = { ...current };
|
|
132
163
|
for (const [id, profile] of Object.entries(next)) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { handleSlashCommand } from "../cli/slash-commands.mjs";
|
|
2
|
+
import { parseWorkspaceCommand, handleWorkspaceCommand } from "./workspace-command.mjs";
|
|
3
|
+
|
|
4
|
+
export async function handleGatewaySlashCommand(input, {
|
|
5
|
+
runner,
|
|
6
|
+
session,
|
|
7
|
+
sessionStore,
|
|
8
|
+
slashCommandHandler = handleSlashCommand,
|
|
9
|
+
} = {}) {
|
|
10
|
+
const trimmed = String(input ?? "").trim();
|
|
11
|
+
if (!trimmed.startsWith("/")) return { handled: false, lines: [] };
|
|
12
|
+
|
|
13
|
+
const workspaceCommand = parseWorkspaceCommand(trimmed);
|
|
14
|
+
if (workspaceCommand.type !== "none") {
|
|
15
|
+
return {
|
|
16
|
+
handled: true,
|
|
17
|
+
lines: handleWorkspaceCommand(workspaceCommand, { session, sessionStore }),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ui = createCollectingUi();
|
|
22
|
+
const result = await slashCommandHandler(trimmed, {
|
|
23
|
+
ui,
|
|
24
|
+
runner,
|
|
25
|
+
modeState: session.modeState,
|
|
26
|
+
sessionState: { sessionId: session.marchSessionId },
|
|
27
|
+
sessionSource: "gateway",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...result,
|
|
32
|
+
handled: Boolean(result?.handled),
|
|
33
|
+
lines: ui.lines,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createCollectingUi() {
|
|
38
|
+
const lines = [];
|
|
39
|
+
return {
|
|
40
|
+
lines,
|
|
41
|
+
writeln(line = "") { lines.push(String(line)); },
|
|
42
|
+
clearOutput() {},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { normalizeGatewayConfig } from "./config.mjs";
|
|
2
|
+
import { runGatewayDaemon } from "./daemon.mjs";
|
|
3
|
+
import { createDefaultGatewayPlatformRegistry } from "./platform-registry.mjs";
|
|
4
|
+
import { runGatewaySetupCommand } from "./setup/command.mjs";
|
|
5
|
+
|
|
6
|
+
export async function runGatewayCommand(args, {
|
|
7
|
+
config,
|
|
8
|
+
cwd = process.cwd(),
|
|
9
|
+
input = process.stdin,
|
|
10
|
+
stdout = process.stdout,
|
|
11
|
+
stderr = process.stderr,
|
|
12
|
+
platformRegistry = createDefaultGatewayPlatformRegistry(),
|
|
13
|
+
getRunner,
|
|
14
|
+
currentProject = "",
|
|
15
|
+
} = {}) {
|
|
16
|
+
const [subcommand = "status", ...rest] = args.command?.args ?? [];
|
|
17
|
+
const gatewayConfig = normalizeGatewayConfig(config, { cwd });
|
|
18
|
+
|
|
19
|
+
if (subcommand === "setup") {
|
|
20
|
+
return await runGatewaySetupCommand({ cwd, input, output: stdout });
|
|
21
|
+
}
|
|
22
|
+
if (subcommand === "status") {
|
|
23
|
+
writeLines(stdout, formatGatewayStatus(gatewayConfig, platformRegistry));
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
if (subcommand === "workspaces") {
|
|
27
|
+
writeLines(stdout, formatGatewayWorkspaces(gatewayConfig));
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
if (subcommand === "platforms") {
|
|
31
|
+
writeLines(stdout, formatGatewayPlatforms(gatewayConfig, platformRegistry));
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
if (subcommand === "run") {
|
|
35
|
+
const platform = rest[0] ?? firstEnabledPlatform(gatewayConfig);
|
|
36
|
+
if (!platform) {
|
|
37
|
+
stderr.write("Error: no gateway platform configured.\n");
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
if (gatewayConfig.platforms[platform]?.enabled !== true) {
|
|
41
|
+
stderr.write(`Error: gateway platform '${platform}' is not enabled in config.\n`);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
if (!platformRegistry.has(platform)) {
|
|
45
|
+
stderr.write(`Error: gateway platform '${platform}' is not implemented in this build.\n`);
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
if (typeof getRunner !== "function") {
|
|
49
|
+
stderr.write("Error: gateway runner bridge is not wired for this command path yet.\n");
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
await runGatewayDaemon({ platform, platformRegistry, gatewayConfig, getRunner, currentProject });
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
stderr.write("Usage: march gateway [setup|status|workspaces|platforms|run [platform]]\n");
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatGatewayStatus(gatewayConfig, platformRegistry) {
|
|
62
|
+
return [
|
|
63
|
+
`Gateway: ${gatewayConfig.enabled ? "enabled" : "disabled"}`,
|
|
64
|
+
`Default workspace: ${gatewayConfig.defaultWorkspace ?? "not configured"}`,
|
|
65
|
+
`Configured workspaces: ${Object.keys(gatewayConfig.workspaces).length}`,
|
|
66
|
+
`Configured platforms: ${Object.keys(gatewayConfig.platforms).length}`,
|
|
67
|
+
`Implemented platforms: ${platformRegistry.list().join(", ") || "none"}`,
|
|
68
|
+
...formatDiagnostics(gatewayConfig.diagnostics),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatGatewayWorkspaces(gatewayConfig) {
|
|
73
|
+
const workspaces = Object.values(gatewayConfig.workspaces);
|
|
74
|
+
if (workspaces.length === 0) return ["No gateway workspaces configured."];
|
|
75
|
+
return [
|
|
76
|
+
"Gateway workspaces:",
|
|
77
|
+
...workspaces.map((workspace) => {
|
|
78
|
+
const marker = workspace.alias === gatewayConfig.defaultWorkspace ? "*" : " ";
|
|
79
|
+
return `${marker} ${workspace.alias}: ${workspace.root}`;
|
|
80
|
+
}),
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatGatewayPlatforms(gatewayConfig, platformRegistry) {
|
|
85
|
+
const platformIds = Object.keys(gatewayConfig.platforms).sort();
|
|
86
|
+
if (platformIds.length === 0) return ["No gateway platforms configured."];
|
|
87
|
+
return [
|
|
88
|
+
"Gateway platforms:",
|
|
89
|
+
...platformIds.map((id) => {
|
|
90
|
+
const enabled = gatewayConfig.platforms[id]?.enabled === true ? "enabled" : "disabled";
|
|
91
|
+
const implemented = platformRegistry.has(id) ? "implemented" : "not implemented";
|
|
92
|
+
return `- ${id}: ${enabled}, ${implemented}`;
|
|
93
|
+
}),
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function firstEnabledPlatform(gatewayConfig) {
|
|
98
|
+
return Object.entries(gatewayConfig.platforms).find(([, value]) => value?.enabled === true)?.[0] ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatDiagnostics(diagnostics = []) {
|
|
102
|
+
return diagnostics.map((entry) => `${entry.type}: ${entry.message}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function writeLines(stream, lines) {
|
|
106
|
+
for (const line of lines) stream.write(`${line}\n`);
|
|
107
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
export function normalizeGatewayConfig(config = {}, { cwd = process.cwd() } = {}) {
|
|
4
|
+
const raw = config.gateway && typeof config.gateway === "object" && !Array.isArray(config.gateway)
|
|
5
|
+
? config.gateway
|
|
6
|
+
: {};
|
|
7
|
+
const diagnostics = [];
|
|
8
|
+
const workspaces = normalizeWorkspaces(raw.workspaces, { cwd, diagnostics });
|
|
9
|
+
const defaultWorkspace = raw.defaultWorkspace ?? raw.default_workspace ?? null;
|
|
10
|
+
const defaultWorkspaceAlias = resolveDefaultWorkspace(defaultWorkspace, { cwd, workspaces, diagnostics });
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
enabled: raw.enabled === true,
|
|
14
|
+
defaultWorkspace: defaultWorkspaceAlias,
|
|
15
|
+
workspaces,
|
|
16
|
+
platforms: raw.platforms && typeof raw.platforms === "object" && !Array.isArray(raw.platforms) ? { ...raw.platforms } : {},
|
|
17
|
+
diagnostics,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveGatewayWorkspace(gatewayConfig, alias = null) {
|
|
22
|
+
const workspaceAlias = alias ?? gatewayConfig?.defaultWorkspace ?? null;
|
|
23
|
+
if (!workspaceAlias) return null;
|
|
24
|
+
return gatewayConfig?.workspaces?.[workspaceAlias] ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeWorkspaces(rawWorkspaces, { cwd, diagnostics }) {
|
|
28
|
+
const workspaces = {};
|
|
29
|
+
if (!rawWorkspaces || typeof rawWorkspaces !== "object" || Array.isArray(rawWorkspaces)) return workspaces;
|
|
30
|
+
for (const [alias, value] of Object.entries(rawWorkspaces)) {
|
|
31
|
+
const cleanAlias = normalizeAlias(alias);
|
|
32
|
+
if (!cleanAlias) {
|
|
33
|
+
diagnostics.push({ type: "warning", message: `Ignored gateway workspace with invalid alias: ${alias}` });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const root = typeof value === "string" ? value : value?.root;
|
|
37
|
+
if (typeof root !== "string" || root.trim() === "") {
|
|
38
|
+
diagnostics.push({ type: "warning", message: `Ignored gateway workspace without root: ${alias}` });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
workspaces[cleanAlias] = { alias: cleanAlias, root: resolve(cwd, root) };
|
|
42
|
+
}
|
|
43
|
+
return workspaces;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveDefaultWorkspace(defaultWorkspace, { cwd, workspaces, diagnostics }) {
|
|
47
|
+
if (typeof defaultWorkspace !== "string" || defaultWorkspace.trim() === "") return null;
|
|
48
|
+
const value = defaultWorkspace.trim();
|
|
49
|
+
const alias = normalizeAlias(value);
|
|
50
|
+
if (alias && workspaces[alias]) return alias;
|
|
51
|
+
|
|
52
|
+
// Explicit default paths are accepted, but future /workspace set remains alias-only.
|
|
53
|
+
const defaultAlias = "default";
|
|
54
|
+
workspaces[defaultAlias] = { alias: defaultAlias, root: resolve(cwd, value) };
|
|
55
|
+
diagnostics.push({ type: "info", message: "Gateway defaultWorkspace used an explicit path; registered it as workspace alias 'default'." });
|
|
56
|
+
return defaultAlias;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeAlias(alias) {
|
|
60
|
+
const value = String(alias ?? "").trim();
|
|
61
|
+
return /^[a-zA-Z0-9_-]+$/.test(value) ? value : null;
|
|
62
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createGatewayMessageHandler } from "./handler.mjs";
|
|
2
|
+
import { createGatewayMessageQueue } from "./runtime/queue.mjs";
|
|
3
|
+
import { GatewaySessionStore } from "./session-store.mjs";
|
|
4
|
+
|
|
5
|
+
export async function runGatewayDaemon({
|
|
6
|
+
platform,
|
|
7
|
+
platformRegistry,
|
|
8
|
+
gatewayConfig,
|
|
9
|
+
getRunner,
|
|
10
|
+
currentProject = "",
|
|
11
|
+
signal,
|
|
12
|
+
logger = console,
|
|
13
|
+
} = {}) {
|
|
14
|
+
if (!platform) throw new Error("Gateway daemon requires a platform id");
|
|
15
|
+
if (!platformRegistry) throw new Error("Gateway daemon requires a platform registry");
|
|
16
|
+
if (typeof getRunner !== "function") throw new Error("Gateway daemon requires a runner factory");
|
|
17
|
+
|
|
18
|
+
const platformConfig = gatewayConfig?.platforms?.[platform] ?? {};
|
|
19
|
+
const adapter = platformRegistry.create(platform, { config: platformConfig });
|
|
20
|
+
if (typeof adapter.send !== "function") throw new Error(`Gateway platform '${platform}' requires a send function`);
|
|
21
|
+
if (typeof adapter.sendBinary !== "function") throw new Error(`Gateway platform '${platform}' requires a sendBinary function`);
|
|
22
|
+
const sessionStore = new GatewaySessionStore({ gatewayConfig });
|
|
23
|
+
const handleMessage = createGatewayMessageHandler({
|
|
24
|
+
sessionStore,
|
|
25
|
+
getRunner,
|
|
26
|
+
currentProject,
|
|
27
|
+
outputSinkForMessage: (message) => ({
|
|
28
|
+
sendBinary: (binary) => adapter.sendBinary({ chatId: message.chatId, binary, replyToMessageId: message.messageId }),
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
const queue = createGatewayMessageQueue({
|
|
32
|
+
handleMessage,
|
|
33
|
+
logger,
|
|
34
|
+
send: async (message, result) => {
|
|
35
|
+
await adapter.send({ chatId: message.chatId, lines: result?.lines ?? [], replyToMessageId: message.messageId });
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
logger.info?.(`[gateway] starting ${platform}`);
|
|
40
|
+
await adapter.start({ handleMessage: (message) => queue.enqueue(message), signal });
|
|
41
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { appendModeReminder } from "../cli/input/mode-state.mjs";
|
|
2
|
+
import { withBinaryOutputSink } from "../agent/output/binary-output-sink.mjs";
|
|
3
|
+
import { normalizeGatewayMessage } from "./message.mjs";
|
|
4
|
+
import { handleGatewaySlashCommand } from "./command-router.mjs";
|
|
5
|
+
|
|
6
|
+
export function createGatewayMessageHandler({ sessionStore, getRunner, currentProject = "", outputSinkForMessage = null }) {
|
|
7
|
+
return async function handleGatewayMessage(input) {
|
|
8
|
+
const message = normalizeGatewayMessage(input);
|
|
9
|
+
const session = sessionStore.getOrCreate(message);
|
|
10
|
+
const runner = await getRunner(session);
|
|
11
|
+
|
|
12
|
+
if (message.text.startsWith("/")) {
|
|
13
|
+
const commandResult = await handleGatewaySlashCommand(message.text, { runner, session, sessionStore });
|
|
14
|
+
if (commandResult.handled) {
|
|
15
|
+
return { type: "command", session, lines: commandResult.lines };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!session.workspaceRoot) {
|
|
20
|
+
return { type: "error", session, lines: ["Error: no gateway workspace configured for this chat."] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const prompt = appendModeReminder(message.text, session.modeState.get());
|
|
24
|
+
const sink = outputSinkForMessage?.(message, session);
|
|
25
|
+
const runTurn = () => runner.runTurn(prompt, message.text, { currentProject });
|
|
26
|
+
const result = sink ? await withBinaryOutputSink(sink, runTurn) : await runTurn();
|
|
27
|
+
return { type: "turn", session, result, lines: result?.draft ? [result.draft] : [] };
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function normalizeGatewayMessage(input) {
|
|
2
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
3
|
+
throw new Error("Gateway message must be an object");
|
|
4
|
+
}
|
|
5
|
+
const platform = cleanRequiredString(input.platform, "platform");
|
|
6
|
+
const chatId = cleanRequiredString(input.chatId ?? input.chat_id, "chatId");
|
|
7
|
+
const userId = cleanRequiredString(input.userId ?? input.user_id, "userId");
|
|
8
|
+
const text = typeof input.text === "string" ? input.text.trim() : "";
|
|
9
|
+
if (!text) throw new Error("Gateway message text is required");
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
platform,
|
|
13
|
+
chatId,
|
|
14
|
+
userId,
|
|
15
|
+
threadId: cleanOptionalString(input.threadId ?? input.thread_id),
|
|
16
|
+
messageId: cleanOptionalString(input.messageId ?? input.message_id),
|
|
17
|
+
text,
|
|
18
|
+
receivedAt: input.receivedAt ?? input.received_at ?? new Date().toISOString(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function gatewaySessionKey(message) {
|
|
23
|
+
const thread = message.threadId ? `:thread:${message.threadId}` : "";
|
|
24
|
+
return `${message.platform}:chat:${message.chatId}${thread}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function cleanRequiredString(value, field) {
|
|
28
|
+
const clean = cleanOptionalString(value);
|
|
29
|
+
if (!clean) throw new Error(`Gateway message ${field} is required`);
|
|
30
|
+
return clean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cleanOptionalString(value) {
|
|
34
|
+
if (value == null) return null;
|
|
35
|
+
const clean = String(value).trim();
|
|
36
|
+
return clean || null;
|
|
37
|
+
}
|