march-cli 0.1.32 → 0.1.33
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/platform/open-file.mjs +9 -10
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createTelegramPlatformAdapter } from "./platforms/telegram.mjs";
|
|
2
|
+
|
|
3
|
+
export class GatewayPlatformRegistry {
|
|
4
|
+
#factories = new Map();
|
|
5
|
+
|
|
6
|
+
register(id, factory) {
|
|
7
|
+
const key = normalizePlatformId(id);
|
|
8
|
+
if (typeof factory !== "function") throw new Error(`Gateway platform factory must be a function: ${key}`);
|
|
9
|
+
this.#factories.set(key, factory);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
has(id) {
|
|
13
|
+
return this.#factories.has(normalizePlatformId(id));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
list() {
|
|
17
|
+
return [...this.#factories.keys()].sort();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
create(id, options = {}) {
|
|
21
|
+
const key = normalizePlatformId(id);
|
|
22
|
+
const factory = this.#factories.get(key);
|
|
23
|
+
if (!factory) throw new Error(`Unsupported gateway platform: ${key}`);
|
|
24
|
+
return factory(options);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createDefaultGatewayPlatformRegistry(options = {}) {
|
|
29
|
+
const registry = new GatewayPlatformRegistry();
|
|
30
|
+
registry.register("telegram", (platformOptions = {}) => createTelegramPlatformAdapter({ ...options, ...platformOptions }));
|
|
31
|
+
return registry;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizePlatformId(id) {
|
|
35
|
+
const value = String(id ?? "").trim().toLowerCase();
|
|
36
|
+
if (!/^[a-z0-9_-]+$/.test(value)) throw new Error(`Invalid gateway platform id: ${id}`);
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
4
|
+
const MAX_TELEGRAM_TEXT = 3900;
|
|
5
|
+
|
|
6
|
+
export function createTelegramPlatformAdapter({
|
|
7
|
+
config = {},
|
|
8
|
+
fetchImpl = globalThis.fetch,
|
|
9
|
+
env = process.env,
|
|
10
|
+
sleep = defaultSleep,
|
|
11
|
+
logger = console,
|
|
12
|
+
} = {}) {
|
|
13
|
+
return new TelegramPlatformAdapter({ config, fetchImpl, env, sleep, logger });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class TelegramPlatformAdapter {
|
|
17
|
+
#config;
|
|
18
|
+
#fetch;
|
|
19
|
+
#env;
|
|
20
|
+
#sleep;
|
|
21
|
+
#logger;
|
|
22
|
+
#offset = 0;
|
|
23
|
+
#running = false;
|
|
24
|
+
|
|
25
|
+
constructor({ config, fetchImpl, env, sleep, logger }) {
|
|
26
|
+
this.id = "telegram";
|
|
27
|
+
this.#config = config ?? {};
|
|
28
|
+
this.#fetch = fetchImpl;
|
|
29
|
+
this.#env = env ?? {};
|
|
30
|
+
this.#sleep = sleep;
|
|
31
|
+
this.#logger = logger;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get configured() {
|
|
35
|
+
return Boolean(this.#token());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async start({ handleMessage, signal } = {}) {
|
|
39
|
+
if (typeof handleMessage !== "function") throw new Error("Telegram gateway requires a message handler");
|
|
40
|
+
this.#assertReady();
|
|
41
|
+
this.#running = true;
|
|
42
|
+
await this.#api("deleteWebhook", { drop_pending_updates: false });
|
|
43
|
+
this.#logger.info?.("[gateway:telegram] polling started");
|
|
44
|
+
while (this.#running && !signal?.aborted) {
|
|
45
|
+
try {
|
|
46
|
+
await this.pollOnce({ handleMessage });
|
|
47
|
+
} catch (err) {
|
|
48
|
+
this.#logger.warn?.(`[gateway:telegram] polling error: ${err.message}`);
|
|
49
|
+
await this.#sleep(this.#retryDelayMs());
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
stop() {
|
|
55
|
+
this.#running = false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async pollOnce({ handleMessage } = {}) {
|
|
59
|
+
if (typeof handleMessage !== "function") throw new Error("Telegram gateway requires a message handler");
|
|
60
|
+
this.#assertReady();
|
|
61
|
+
const response = await this.#api("getUpdates", {
|
|
62
|
+
offset: this.#offset || undefined,
|
|
63
|
+
timeout: this.#pollTimeoutSeconds(),
|
|
64
|
+
allowed_updates: ["message"],
|
|
65
|
+
});
|
|
66
|
+
const updates = Array.isArray(response.result) ? response.result : [];
|
|
67
|
+
for (const update of updates) {
|
|
68
|
+
if (Number.isInteger(update.update_id)) this.#offset = Math.max(this.#offset, update.update_id + 1);
|
|
69
|
+
const message = normalizeTelegramUpdate(update, { allowedUsers: this.#allowedUsers(), dmOnly: this.#dmOnly() });
|
|
70
|
+
if (!message) continue;
|
|
71
|
+
const result = await handleMessage(message);
|
|
72
|
+
await this.send({ chatId: message.chatId, lines: result?.lines ?? [], replyToMessageId: message.messageId });
|
|
73
|
+
}
|
|
74
|
+
return updates.length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async send({ chatId, lines, replyToMessageId = null }) {
|
|
78
|
+
const chunks = splitTelegramLines(lines);
|
|
79
|
+
for (const text of chunks) {
|
|
80
|
+
await this.#api("sendMessage", {
|
|
81
|
+
chat_id: chatId,
|
|
82
|
+
text,
|
|
83
|
+
disable_web_page_preview: true,
|
|
84
|
+
...replyOptions(replyToMessageId),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async sendBinary({ chatId, binary, replyToMessageId = null }) {
|
|
90
|
+
const method = telegramBinaryMethod(binary?.type);
|
|
91
|
+
const field = telegramBinaryField(binary?.type);
|
|
92
|
+
const payload = {
|
|
93
|
+
chat_id: chatId,
|
|
94
|
+
...replyOptions(replyToMessageId),
|
|
95
|
+
...(binary.caption ? { caption: binary.caption } : {}),
|
|
96
|
+
};
|
|
97
|
+
if (binary.url) {
|
|
98
|
+
await this.#api(method, { ...payload, [field]: binary.url });
|
|
99
|
+
return { target: "telegram", method, source: "url" };
|
|
100
|
+
}
|
|
101
|
+
if (!binary.path) throw new Error("Telegram binary send requires path or url");
|
|
102
|
+
const form = new FormData();
|
|
103
|
+
for (const [key, value] of Object.entries(payload)) form.append(key, String(value));
|
|
104
|
+
const data = readFileSync(binary.path);
|
|
105
|
+
const blob = new Blob([data], { type: binary.mimeType || "application/octet-stream" });
|
|
106
|
+
form.append(field, blob, binary.filename || binary.path.split(/[\\/]/).at(-1) || "media.bin");
|
|
107
|
+
await this.#apiForm(method, form);
|
|
108
|
+
return { target: "telegram", method, source: "path" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#assertReady() {
|
|
112
|
+
if (!this.#token()) throw new Error("Telegram gateway requires a bot token. Set TELEGRAM_BOT_TOKEN or gateway.platforms.telegram.botTokenEnv.");
|
|
113
|
+
if (typeof this.#fetch !== "function") throw new Error("Telegram gateway requires fetch support");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async #api(method, payload) {
|
|
117
|
+
return this.#apiRequest(method, {
|
|
118
|
+
headers: { "content-type": "application/json" },
|
|
119
|
+
body: JSON.stringify(payload ?? {}),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async #apiForm(method, form) {
|
|
124
|
+
return this.#apiRequest(method, { body: form });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async #apiRequest(method, init) {
|
|
128
|
+
const response = await this.#fetch(`${this.#apiBase()}/bot${this.#token()}/${method}`, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
...init,
|
|
131
|
+
});
|
|
132
|
+
const data = await response.json().catch(() => null);
|
|
133
|
+
if (!response.ok || data?.ok !== true) {
|
|
134
|
+
const description = data?.description || response.statusText || `HTTP ${response.status}`;
|
|
135
|
+
throw new Error(`Telegram ${method} failed: ${description}`);
|
|
136
|
+
}
|
|
137
|
+
return data;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#token() {
|
|
141
|
+
const tokenEnv = this.#config.botTokenEnv ?? this.#config.bot_token_env ?? this.#config.tokenEnv ?? "TELEGRAM_BOT_TOKEN";
|
|
142
|
+
return cleanString(this.#config.botToken ?? this.#config.bot_token ?? this.#config.token ?? this.#env[tokenEnv]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#allowedUsers() {
|
|
146
|
+
const raw = this.#config.allowedUsers ?? this.#config.allowed_users ?? this.#env.MARCH_TELEGRAM_ALLOWED_USERS ?? this.#env.TELEGRAM_ALLOWED_USERS ?? "";
|
|
147
|
+
if (Array.isArray(raw)) return new Set(raw.map((value) => String(value).trim()).filter(Boolean));
|
|
148
|
+
return new Set(String(raw).split(",").map((value) => value.trim()).filter(Boolean));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#dmOnly() {
|
|
152
|
+
return this.#config.dmOnly ?? this.#config.dm_only ?? true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#apiBase() {
|
|
156
|
+
return cleanString(this.#config.apiBase ?? this.#config.api_base) ?? TELEGRAM_API_BASE;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#pollTimeoutSeconds() {
|
|
160
|
+
return positiveInteger(this.#config.pollTimeoutSeconds ?? this.#config.poll_timeout_seconds, 30);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#retryDelayMs() {
|
|
164
|
+
return positiveInteger(this.#config.retryDelayMs ?? this.#config.retry_delay_ms, 2000);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function normalizeTelegramUpdate(update, { allowedUsers = new Set(), dmOnly = true } = {}) {
|
|
169
|
+
const rawMessage = update?.message;
|
|
170
|
+
const text = typeof rawMessage?.text === "string" ? rawMessage.text.trim() : "";
|
|
171
|
+
if (!rawMessage || !text) return null;
|
|
172
|
+
|
|
173
|
+
const userId = cleanString(rawMessage.from?.id);
|
|
174
|
+
const chatId = cleanString(rawMessage.chat?.id);
|
|
175
|
+
if (!userId || !chatId) return null;
|
|
176
|
+
if (!isAllowedTelegramUser(userId, allowedUsers)) return null;
|
|
177
|
+
if (dmOnly && rawMessage.chat?.type !== "private") return null;
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
platform: "telegram",
|
|
181
|
+
chatId,
|
|
182
|
+
userId,
|
|
183
|
+
messageId: cleanString(rawMessage.message_id),
|
|
184
|
+
text,
|
|
185
|
+
receivedAt: rawMessage.date ? new Date(rawMessage.date * 1000).toISOString() : new Date().toISOString(),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function isAllowedTelegramUser(userId, allowedUsers) {
|
|
190
|
+
if (!(allowedUsers instanceof Set) || allowedUsers.size === 0) return false;
|
|
191
|
+
return allowedUsers.has("*") || allowedUsers.has(String(userId));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function telegramBinaryMethod(type) {
|
|
195
|
+
if (type === "image") return "sendPhoto";
|
|
196
|
+
if (type === "video") return "sendVideo";
|
|
197
|
+
if (type === "audio") return "sendAudio";
|
|
198
|
+
if (type === "file") return "sendDocument";
|
|
199
|
+
throw new Error(`Unsupported Telegram binary type: ${type}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function telegramBinaryField(type) {
|
|
203
|
+
if (type === "image") return "photo";
|
|
204
|
+
if (type === "video") return "video";
|
|
205
|
+
if (type === "audio") return "audio";
|
|
206
|
+
if (type === "file") return "document";
|
|
207
|
+
throw new Error(`Unsupported Telegram binary type: ${type}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function splitTelegramLines(lines) {
|
|
211
|
+
const text = (Array.isArray(lines) ? lines : [lines])
|
|
212
|
+
.map((line) => String(line ?? "").trimEnd())
|
|
213
|
+
.filter(Boolean)
|
|
214
|
+
.join("\n");
|
|
215
|
+
if (!text) return [];
|
|
216
|
+
|
|
217
|
+
const chunks = [];
|
|
218
|
+
for (let index = 0; index < text.length; index += MAX_TELEGRAM_TEXT) {
|
|
219
|
+
chunks.push(text.slice(index, index + MAX_TELEGRAM_TEXT));
|
|
220
|
+
}
|
|
221
|
+
return chunks;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function replyOptions(replyToMessageId) {
|
|
225
|
+
return replyToMessageId ? { reply_to_message_id: Number(replyToMessageId), allow_sending_without_reply: true } : {};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function cleanString(value) {
|
|
229
|
+
if (value == null) return null;
|
|
230
|
+
const clean = String(value).trim();
|
|
231
|
+
return clean || null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function positiveInteger(value, fallback) {
|
|
235
|
+
const parsed = Number.parseInt(value, 10);
|
|
236
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function defaultSleep(ms) {
|
|
240
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
241
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { normalize } from "node:path";
|
|
2
|
+
|
|
3
|
+
export function createGatewayRunnerBridge({ runner, cwd }) {
|
|
4
|
+
if (!runner) throw new Error("Gateway runner bridge requires a runner");
|
|
5
|
+
const root = normalize(cwd);
|
|
6
|
+
let activeGatewaySessionKey = null;
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
async getRunner(session) {
|
|
10
|
+
if (normalize(session.workspaceRoot) !== root) {
|
|
11
|
+
throw new Error(`Gateway workspace '${session.workspaceAlias}' is not served by this process: ${session.workspaceRoot}`);
|
|
12
|
+
}
|
|
13
|
+
await activateGatewaySession({ runner, session, activeGatewaySessionKey });
|
|
14
|
+
activeGatewaySessionKey = session.key;
|
|
15
|
+
return wrapRunnerForGatewaySession(runner, session, () => { activeGatewaySessionKey = session.key; });
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function activateGatewaySession({ runner, session, activeGatewaySessionKey }) {
|
|
21
|
+
const stats = runner.getSessionStats?.() ?? {};
|
|
22
|
+
if (!session.piSessionFile) {
|
|
23
|
+
if (!activeGatewaySessionKey) {
|
|
24
|
+
bindSessionFile(session, stats);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const result = await runner.startNewSession();
|
|
28
|
+
bindSessionFile(session, result ?? runner.getSessionStats?.() ?? {});
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (stats.sessionFile !== session.piSessionFile) {
|
|
32
|
+
await runner.switchPiSession(session.piSessionFile);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function wrapRunnerForGatewaySession(runner, session, markActive) {
|
|
37
|
+
return new Proxy(runner, {
|
|
38
|
+
get(target, property, receiver) {
|
|
39
|
+
if (property === "startNewSession") {
|
|
40
|
+
return async (...args) => {
|
|
41
|
+
const result = await target.startNewSession(...args);
|
|
42
|
+
bindSessionFile(session, result ?? target.getSessionStats?.() ?? {});
|
|
43
|
+
markActive();
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return Reflect.get(target, property, receiver);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function bindSessionFile(session, stats) {
|
|
53
|
+
if (stats?.sessionFile) session.piSessionFile = stats.sessionFile;
|
|
54
|
+
if (stats?.sessionId) session.piSessionId = stats.sessionId;
|
|
55
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export function createGatewayMessageQueue({ handleMessage, send, logger = console } = {}) {
|
|
2
|
+
if (typeof handleMessage !== "function") throw new Error("Gateway queue requires a message handler");
|
|
3
|
+
if (typeof send !== "function") throw new Error("Gateway queue requires a send function");
|
|
4
|
+
|
|
5
|
+
const pending = [];
|
|
6
|
+
let processing = false;
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
enqueue(message) {
|
|
10
|
+
const ahead = pending.length + (processing ? 1 : 0);
|
|
11
|
+
pending.push(message);
|
|
12
|
+
drainSoon();
|
|
13
|
+
if (ahead <= 0) return { type: "queued", lines: [] };
|
|
14
|
+
return { type: "queued", lines: [`Queued: ${ahead} message${ahead === 1 ? "" : "s"} ahead.`] };
|
|
15
|
+
},
|
|
16
|
+
getStats() {
|
|
17
|
+
return { processing, pending: pending.length };
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function drainSoon() {
|
|
22
|
+
if (processing) return;
|
|
23
|
+
processing = true;
|
|
24
|
+
queueMicrotask(() => {
|
|
25
|
+
void drain();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function drain() {
|
|
30
|
+
try {
|
|
31
|
+
while (pending.length > 0) {
|
|
32
|
+
const message = pending.shift();
|
|
33
|
+
try {
|
|
34
|
+
const result = await handleMessage(message);
|
|
35
|
+
await send(message, result);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
logger.warn?.(`[gateway:queue] message failed: ${err.message}`);
|
|
38
|
+
await send(message, { type: "error", lines: [`Error: ${err.message}`] });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} finally {
|
|
42
|
+
processing = false;
|
|
43
|
+
if (pending.length > 0) drainSoon();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createModeState, MODES } from "../cli/input/mode-state.mjs";
|
|
2
|
+
import { gatewaySessionKey } from "./message.mjs";
|
|
3
|
+
import { resolveGatewayWorkspace } from "./config.mjs";
|
|
4
|
+
|
|
5
|
+
export class GatewaySessionStore {
|
|
6
|
+
#gatewayConfig;
|
|
7
|
+
#sessions = new Map();
|
|
8
|
+
|
|
9
|
+
constructor({ gatewayConfig }) {
|
|
10
|
+
this.#gatewayConfig = gatewayConfig;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
getOrCreate(message) {
|
|
14
|
+
const key = gatewaySessionKey(message);
|
|
15
|
+
const existing = this.#sessions.get(key);
|
|
16
|
+
if (existing) return existing;
|
|
17
|
+
|
|
18
|
+
const workspaceAlias = this.#gatewayConfig?.defaultWorkspace ?? null;
|
|
19
|
+
const workspace = resolveGatewayWorkspace(this.#gatewayConfig, workspaceAlias);
|
|
20
|
+
const session = {
|
|
21
|
+
key,
|
|
22
|
+
platform: message.platform,
|
|
23
|
+
chatId: message.chatId,
|
|
24
|
+
threadId: message.threadId,
|
|
25
|
+
// Remote social entrypoints default to safe discussion mode.
|
|
26
|
+
modeState: createModeState({ initial: MODES.DISCUSS }),
|
|
27
|
+
workspaceAlias,
|
|
28
|
+
workspaceRoot: workspace?.root ?? null,
|
|
29
|
+
marchSessionId: `gateway:${key}`,
|
|
30
|
+
};
|
|
31
|
+
this.#sessions.set(key, session);
|
|
32
|
+
return session;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setWorkspace(session, alias) {
|
|
36
|
+
const workspace = resolveGatewayWorkspace(this.#gatewayConfig, alias);
|
|
37
|
+
if (!workspace) throw new Error(`Unknown gateway workspace: ${alias}`);
|
|
38
|
+
session.workspaceAlias = workspace.alias;
|
|
39
|
+
session.workspaceRoot = workspace.root;
|
|
40
|
+
return session;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
listWorkspaces() {
|
|
44
|
+
return Object.values(this.#gatewayConfig?.workspaces ?? {});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { readConfigJson, writeConfigJson } from "../../config/config-json.mjs";
|
|
5
|
+
import { selectWithKeyboard } from "../../cli/input/select-with-keyboard.mjs";
|
|
6
|
+
|
|
7
|
+
const TELEGRAM_PLATFORM = { id: "telegram", label: "Telegram", tokenEnv: "TELEGRAM_BOT_TOKEN" };
|
|
8
|
+
|
|
9
|
+
export async function runGatewaySetupCommand({
|
|
10
|
+
cwd = process.cwd(),
|
|
11
|
+
input = process.stdin,
|
|
12
|
+
output = process.stdout,
|
|
13
|
+
select = selectWithKeyboard,
|
|
14
|
+
readSecret = readLine,
|
|
15
|
+
readText = readLine,
|
|
16
|
+
} = {}) {
|
|
17
|
+
output.write("Gateway setup\n\n");
|
|
18
|
+
const platform = await select({
|
|
19
|
+
input,
|
|
20
|
+
output,
|
|
21
|
+
message: "Choose gateway platform",
|
|
22
|
+
items: [{ label: TELEGRAM_PLATFORM.label, value: TELEGRAM_PLATFORM }],
|
|
23
|
+
});
|
|
24
|
+
if (!platform) {
|
|
25
|
+
output.write("Gateway setup cancelled.\n");
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const token = String(await readSecret({ input, output, prompt: "Telegram bot token: " }) ?? "").trim();
|
|
30
|
+
if (!token) {
|
|
31
|
+
output.write("Telegram bot token is required.\n");
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const userId = String(await readText({ input, output, prompt: "Allowed Telegram user id: " }) ?? "").trim();
|
|
36
|
+
if (!userId) {
|
|
37
|
+
output.write("Allowed Telegram user id is required.\n");
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const workspaceAlias = normalizeAlias(String(await readText({ input, output, prompt: "Workspace alias [current]: " }) ?? "").trim() || "current");
|
|
42
|
+
if (!workspaceAlias) {
|
|
43
|
+
output.write("Workspace alias must contain only letters, numbers, _ or -.\n");
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const configPath = projectGatewayConfigJsonPath(cwd);
|
|
48
|
+
upsertGatewayProjectConfig({
|
|
49
|
+
path: configPath,
|
|
50
|
+
workspaceAlias,
|
|
51
|
+
workspaceRoot: ".",
|
|
52
|
+
platformId: platform.id,
|
|
53
|
+
tokenEnv: platform.tokenEnv,
|
|
54
|
+
allowedUsers: [userId],
|
|
55
|
+
});
|
|
56
|
+
const envPath = projectEnvPath(cwd);
|
|
57
|
+
upsertEnvFile({ path: envPath, key: platform.tokenEnv, value: token });
|
|
58
|
+
|
|
59
|
+
output.write("\nGateway configured.\n");
|
|
60
|
+
output.write(`Config: ${relative(cwd, configPath) || configPath}\n`);
|
|
61
|
+
output.write(`Secret env: ${relative(cwd, envPath) || envPath}\n`);
|
|
62
|
+
output.write("\nRun:\n march gateway run\n");
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function projectGatewayConfigJsonPath(cwd) {
|
|
67
|
+
return join(cwd, ".march", "config.json");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function projectEnvPath(cwd) {
|
|
71
|
+
return join(cwd, ".env");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function upsertGatewayProjectConfig({ path, workspaceAlias, workspaceRoot = ".", platformId = "telegram", tokenEnv = "TELEGRAM_BOT_TOKEN", allowedUsers = [] }) {
|
|
75
|
+
const config = readConfigJson(path);
|
|
76
|
+
const gateway = config.gateway && typeof config.gateway === "object" && !Array.isArray(config.gateway) ? config.gateway : {};
|
|
77
|
+
const workspaces = gateway.workspaces && typeof gateway.workspaces === "object" && !Array.isArray(gateway.workspaces) ? gateway.workspaces : {};
|
|
78
|
+
const platforms = gateway.platforms && typeof gateway.platforms === "object" && !Array.isArray(gateway.platforms) ? gateway.platforms : {};
|
|
79
|
+
const platform = platforms[platformId] && typeof platforms[platformId] === "object" && !Array.isArray(platforms[platformId]) ? platforms[platformId] : {};
|
|
80
|
+
|
|
81
|
+
config.gateway = {
|
|
82
|
+
...gateway,
|
|
83
|
+
enabled: true,
|
|
84
|
+
defaultWorkspace: workspaceAlias,
|
|
85
|
+
workspaces: {
|
|
86
|
+
...workspaces,
|
|
87
|
+
[workspaceAlias]: workspaceRoot,
|
|
88
|
+
},
|
|
89
|
+
platforms: {
|
|
90
|
+
...platforms,
|
|
91
|
+
[platformId]: {
|
|
92
|
+
...platform,
|
|
93
|
+
enabled: true,
|
|
94
|
+
botTokenEnv: tokenEnv,
|
|
95
|
+
allowedUsers: mergeUniqueStrings(platform.allowedUsers ?? platform.allowed_users, allowedUsers),
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
writeConfigJson(path, config);
|
|
100
|
+
return config;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function upsertEnvFile({ path, key, value }) {
|
|
104
|
+
const lines = existsSync(path) ? readFileSync(path, "utf8").split(/\r?\n/) : [];
|
|
105
|
+
const nextLine = `${key}=${escapeEnvValue(value)}`;
|
|
106
|
+
let replaced = false;
|
|
107
|
+
const next = lines.map((line) => {
|
|
108
|
+
const parsed = parseEnvLine(line);
|
|
109
|
+
if (parsed?.key !== key) return line;
|
|
110
|
+
replaced = true;
|
|
111
|
+
return nextLine;
|
|
112
|
+
});
|
|
113
|
+
if (!replaced) {
|
|
114
|
+
if (next.length && next[next.length - 1] !== "") next.push("");
|
|
115
|
+
next.push(nextLine);
|
|
116
|
+
}
|
|
117
|
+
while (next.length > 1 && next[next.length - 1] === "" && next[next.length - 2] === "") next.pop();
|
|
118
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
119
|
+
writeFileSync(path, `${next.join("\n").replace(/\n*$/, "")}\n`, "utf8");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeAlias(value) {
|
|
123
|
+
return /^[a-zA-Z0-9_-]+$/.test(value) ? value : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function mergeUniqueStrings(current, additions) {
|
|
127
|
+
const values = Array.isArray(current) ? current : String(current ?? "").split(",");
|
|
128
|
+
return [...new Set([...values, ...additions].map((value) => String(value).trim()).filter(Boolean))];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseEnvLine(line) {
|
|
132
|
+
const trimmed = String(line ?? "").trim();
|
|
133
|
+
if (!trimmed || trimmed.startsWith("#")) return null;
|
|
134
|
+
const eq = trimmed.indexOf("=");
|
|
135
|
+
if (eq < 1) return null;
|
|
136
|
+
return { key: trimmed.slice(0, eq).trim() };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function escapeEnvValue(value) {
|
|
140
|
+
const raw = String(value ?? "");
|
|
141
|
+
return /^[A-Za-z0-9_./:@-]+$/.test(raw) ? raw : JSON.stringify(raw);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readLine({ input = process.stdin, output = process.stdout, prompt }) {
|
|
145
|
+
const rl = createInterface({ input, output });
|
|
146
|
+
return new Promise((resolve) => rl.question(prompt, (answer) => {
|
|
147
|
+
rl.close();
|
|
148
|
+
resolve(answer);
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function parseWorkspaceCommand(input) {
|
|
2
|
+
const trimmed = String(input ?? "").trim();
|
|
3
|
+
if (trimmed === "/workspace") return { type: "show" };
|
|
4
|
+
if (trimmed === "/workspaces") return { type: "list" };
|
|
5
|
+
const match = trimmed.match(/^\/workspace\s+set\s+(\S+)$/);
|
|
6
|
+
if (match) return { type: "set", alias: match[1] };
|
|
7
|
+
if (trimmed.startsWith("/workspace ")) return { type: "error", message: "Usage: /workspace, /workspaces, or /workspace set <alias>" };
|
|
8
|
+
return { type: "none" };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function handleWorkspaceCommand(command, { session, sessionStore }) {
|
|
12
|
+
if (command.type === "error") return [`Error: ${command.message}`];
|
|
13
|
+
if (command.type === "show") return [formatCurrentWorkspace(session)];
|
|
14
|
+
if (command.type === "list") return formatWorkspaceList(sessionStore.listWorkspaces(), session.workspaceAlias);
|
|
15
|
+
if (command.type === "set") {
|
|
16
|
+
try {
|
|
17
|
+
sessionStore.setWorkspace(session, command.alias);
|
|
18
|
+
return [`Workspace: ${session.workspaceAlias} (${session.workspaceRoot})`];
|
|
19
|
+
} catch (err) {
|
|
20
|
+
return [`Error: ${err.message}`];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatCurrentWorkspace(session) {
|
|
27
|
+
if (!session.workspaceAlias || !session.workspaceRoot) return "Workspace: not configured";
|
|
28
|
+
return `Workspace: ${session.workspaceAlias} (${session.workspaceRoot})`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatWorkspaceList(workspaces, currentAlias) {
|
|
32
|
+
if (workspaces.length === 0) return ["No gateway workspaces configured."];
|
|
33
|
+
return [
|
|
34
|
+
"Gateway workspaces:",
|
|
35
|
+
...workspaces.map((workspace) => {
|
|
36
|
+
const marker = workspace.alias === currentAlias ? "*" : " ";
|
|
37
|
+
return `${marker} ${workspace.alias}: ${workspace.root}`;
|
|
38
|
+
}),
|
|
39
|
+
];
|
|
40
|
+
}
|
package/src/image-gen/tool.mjs
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { sendBinaryOutput } from "../agent/output/binary-output-sink.mjs";
|
|
3
5
|
import { toolText } from "../agent/tool-result.mjs";
|
|
4
|
-
import { openFileWithDefaultApp } from "../platform/open-file.mjs";
|
|
5
6
|
import { generateImage } from "./provider.mjs";
|
|
6
7
|
|
|
7
8
|
export function createImageGenTool({
|
|
8
9
|
authStorage,
|
|
9
10
|
projectMarchDir,
|
|
10
11
|
generateImageImpl = generateImage,
|
|
11
|
-
|
|
12
|
+
sendBinary = sendBinaryOutput,
|
|
12
13
|
}) {
|
|
13
14
|
return defineTool({
|
|
14
15
|
name: "image_generate",
|
|
@@ -48,7 +49,7 @@ export function createImageGenTool({
|
|
|
48
49
|
try {
|
|
49
50
|
const { prompt, quality = "medium", aspectRatio = "1:1", auto_open: autoOpen = true } = params;
|
|
50
51
|
const image = await generateImageImpl({ prompt, quality, aspectRatio, authStorage, projectMarchDir });
|
|
51
|
-
const
|
|
52
|
+
const outputResult = autoOpen ? await deliverGeneratedImage(image, sendBinary) : { opened: false, delivered: false };
|
|
52
53
|
return toolJson({
|
|
53
54
|
success: true,
|
|
54
55
|
image: image.marker,
|
|
@@ -57,8 +58,8 @@ export function createImageGenTool({
|
|
|
57
58
|
prompt,
|
|
58
59
|
aspectRatio,
|
|
59
60
|
quality,
|
|
60
|
-
...
|
|
61
|
-
}, { ...image, ...
|
|
61
|
+
...outputResult,
|
|
62
|
+
}, { ...image, ...outputResult });
|
|
62
63
|
} catch (err) {
|
|
63
64
|
return toolJson({
|
|
64
65
|
success: false,
|
|
@@ -70,12 +71,18 @@ export function createImageGenTool({
|
|
|
70
71
|
});
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
async function
|
|
74
|
+
async function deliverGeneratedImage(image, sendBinary) {
|
|
75
|
+
const binary = {
|
|
76
|
+
type: "image",
|
|
77
|
+
path: image.filePath,
|
|
78
|
+
filename: basename(image.filePath),
|
|
79
|
+
mimeType: image.mimeType,
|
|
80
|
+
};
|
|
74
81
|
try {
|
|
75
|
-
await
|
|
76
|
-
return { opened: true };
|
|
82
|
+
const sink = await sendBinary(binary);
|
|
83
|
+
return { opened: sink?.opened === true, delivered: true, sink };
|
|
77
84
|
} catch (err) {
|
|
78
|
-
return { opened: false, openError: err.message };
|
|
85
|
+
return { opened: false, delivered: false, openError: err.message };
|
|
79
86
|
}
|
|
80
87
|
}
|
|
81
88
|
|