jeo-code 0.12.6 → 0.12.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.ja.md +3 -1
  3. package/README.ko.md +3 -1
  4. package/README.md +5 -3
  5. package/README.zh.md +3 -1
  6. package/package.json +1 -1
  7. package/src/agent/compaction.ts +6 -7
  8. package/src/agent/engine.ts +6 -2
  9. package/src/agent/notify/discord-api.ts +125 -28
  10. package/src/agent/notify/discord-artifacts.ts +126 -0
  11. package/src/agent/notify/discord-attachments.ts +116 -0
  12. package/src/agent/notify/discord-daemon.ts +213 -39
  13. package/src/agent/notify/session-endpoint.ts +127 -5
  14. package/src/agent/session.ts +7 -5
  15. package/src/agent/tools.ts +1 -0
  16. package/src/ai/providers/anthropic.ts +1 -0
  17. package/src/ai/providers/openai-responses.ts +1 -0
  18. package/src/cli/runner.ts +9 -0
  19. package/src/cli.ts +4 -0
  20. package/src/commands/computer.ts +46 -15
  21. package/src/commands/launch/agy-turn.ts +20 -7
  22. package/src/commands/launch/confirm.ts +78 -0
  23. package/src/commands/launch/flags.ts +7 -0
  24. package/src/commands/launch/input.ts +18 -1
  25. package/src/commands/launch.ts +320 -40
  26. package/src/commands/messenger.ts +146 -0
  27. package/src/commands/notify.ts +3 -0
  28. package/src/messenger/activity.ts +30 -0
  29. package/src/messenger/artifacts.ts +167 -0
  30. package/src/messenger/config.ts +145 -0
  31. package/src/messenger/discord-setup.ts +568 -0
  32. package/src/messenger/discord.ts +707 -0
  33. package/src/messenger/gateway.ts +444 -0
  34. package/src/messenger/http.ts +88 -0
  35. package/src/messenger/service.ts +122 -0
  36. package/src/messenger/session-pool.ts +109 -0
  37. package/src/messenger/slack.ts +333 -0
  38. package/src/messenger/telegram.ts +241 -0
  39. package/src/messenger/types.ts +82 -0
  40. package/src/tui/components/slash.ts +1 -1
@@ -0,0 +1,146 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import * as path from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { Writable } from "node:stream";
6
+ import { loadMessengerConfig, validateMessengerEnvironment } from "../messenger/config";
7
+ import { createDiscordAdapter } from "../messenger/discord";
8
+ import { setupDiscordMessenger } from "../messenger/discord-setup";
9
+ import { messengerArtifactPrompt } from "../messenger/artifacts";
10
+ import { createMessengerGateway, messengerSessionRoute } from "../messenger/gateway";
11
+ import { createMessengerSessionPool } from "../messenger/session-pool";
12
+ import { createSlackAdapter } from "../messenger/slack";
13
+ import { createTelegramAdapter } from "../messenger/telegram";
14
+ import type { MessengerAdapter, MessengerConfig, MessengerReply } from "../messenger/types";
15
+ import { createSession } from "../agent/session";
16
+
17
+ type Flags = { config?: string; channel: string[]; user: string[]; tools?: string; artifacts: boolean; service: boolean; noDm: boolean };
18
+
19
+ function parse(args: string[]): { action: "setup" | "check" | "run"; flags: Flags } {
20
+ const action = args.shift();
21
+ if (action !== "setup" && action !== "check" && action !== "run") throw new Error("Usage: jeo messenger setup|check|run --config <file>");
22
+ const flags: Flags = { channel: [], user: [], artifacts: false, service: false, noDm: false };
23
+ for (let index = 0; index < args.length; index++) {
24
+ const arg = args[index]!;
25
+ if (arg === "--artifacts") flags.artifacts = true;
26
+ else if (arg === "--service") flags.service = true;
27
+ else if (arg === "--no-dm") flags.noDm = true;
28
+ else if (["--config", "--channel", "--user", "--tools"].includes(arg)) {
29
+ const value = args[++index];
30
+ if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`);
31
+ if (arg === "--config") flags.config = value;
32
+ else if (arg === "--tools") flags.tools = value;
33
+ else flags[arg === "--channel" ? "channel" : "user"].push(...value.split(",").filter(Boolean));
34
+ } else throw new Error(`Unknown messenger option: ${arg}`);
35
+ }
36
+ if (!flags.config) throw new Error("Messenger requires --config <file>");
37
+ return { action, flags };
38
+ }
39
+
40
+ function terminalIO() {
41
+ let muted = false;
42
+ const output = new Writable({ write(chunk, _encoding, callback) { if (!muted) process.stdout.write(chunk); callback(); } });
43
+ const rl = createInterface({ input: process.stdin, output, terminal: Boolean(process.stdin.isTTY && process.stdout.isTTY) });
44
+ const ask = async (question: string, secret = false) => {
45
+ process.stdout.write(question);
46
+ muted = secret;
47
+ try { return (await rl.question("")).trim(); }
48
+ finally { muted = false; if (secret) process.stdout.write("\n"); }
49
+ };
50
+ return {
51
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
52
+ say: (line: string) => process.stdout.write(`${line}\n`),
53
+ ask: (question: string) => ask(question),
54
+ askSecret: (question: string) => ask(question, true),
55
+ close: () => rl.close(),
56
+ };
57
+ }
58
+
59
+ function routeUuid(config: MessengerConfig, message: Parameters<MessengerReply>[0]): string {
60
+ const hex = messengerSessionRoute(config.cwd, message);
61
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
62
+ }
63
+
64
+ /** Run each conversation in its own reusable agent process. */
65
+ export function createMessengerCliReply(config: MessengerConfig, spawn: typeof Bun.spawn = Bun.spawn): MessengerReply & { close(): Promise<void> } {
66
+ const pool = createMessengerSessionPool(spawn);
67
+ const reply: MessengerReply = async (message, signal, onActivity) => {
68
+ if (signal.aborted) throw new Error("Messenger request aborted");
69
+ const id = routeUuid(config, message);
70
+ await fs.mkdir(config.sessionDir, { recursive: true, mode: 0o700 });
71
+ const target = path.join(config.sessionDir, `${id}.jsonl`);
72
+ const legacy = path.join(config.cwd, ".jeo", "sessions", `${id}.jsonl`);
73
+ try {
74
+ await fs.access(target);
75
+ } catch (error) {
76
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
77
+ let old;
78
+ try { old = await fs.open(legacy, constants.O_RDONLY | constants.O_NOFOLLOW); }
79
+ catch (openError) { if ((openError as NodeJS.ErrnoException).code !== "ENOENT") throw openError; }
80
+ try {
81
+ if (old) {
82
+ if (!(await old.stat()).isFile()) throw new Error("Invalid legacy messenger session");
83
+ const contents = await old.readFile();
84
+ const temporary = `${target}.${crypto.randomUUID()}.tmp`;
85
+ try {
86
+ await fs.writeFile(temporary, contents, { flag: "wx", mode: 0o600 });
87
+ await fs.link(temporary, target);
88
+ } finally { await fs.unlink(temporary).catch(() => {}); }
89
+ } else {
90
+ await createSession(config.cwd, id, undefined, config.sessionDir, true);
91
+ }
92
+ } catch (createError) {
93
+ if ((createError as NodeJS.ErrnoException).code !== "EEXIST") throw createError;
94
+ } finally { await old?.close(); }
95
+ }
96
+ const artifactPrompt = await messengerArtifactPrompt(config.platforms[message.platform]?.artifactDir, message, signal);
97
+ const executable = process.execPath;
98
+ const sourceEntry = process.argv[1]?.endsWith(".ts") ? process.argv[1] : undefined;
99
+ const tools = [...new Set(config.toolNames.map(name => name === "grep" ? "search" : name === "glob" ? "find" : name))];
100
+ const toolFlags = tools.length ? ["--tools", tools.join(",")] : ["--no-tools"];
101
+ const command = [executable, ...(sourceEntry ? ["--no-env-file", sourceEntry] : []), "launch", "--print", "--no-tui", "--no-skills", "--literal-worker", ...toolFlags, "--resume", id, "--", "worker"];
102
+ const secretNames = new Set<string>();
103
+ for (const platform of Object.values(config.platforms)) {
104
+ if (!platform) continue;
105
+ secretNames.add(platform.tokenEnv);
106
+ if (platform.appTokenEnv) secretNames.add(platform.appTokenEnv);
107
+ for (const name of [platform.tokenEnv, platform.appTokenEnv]) {
108
+ if (name?.startsWith("JEO_")) secretNames.add(`JEOPI_${name.slice(4)}`);
109
+ if (name?.startsWith("JEOPI_")) secretNames.add(`JEO_${name.slice(6)}`);
110
+ }
111
+ }
112
+ const env = Object.fromEntries(Object.entries(process.env).filter(([name, value]) => value !== undefined && !secretNames.has(name))) as Record<string, string>;
113
+ for (const name of secretNames) env[name] = "";
114
+ env.JEO_SESSION_DIR = config.sessionDir;
115
+ return pool.turn(id, command, config.cwd, env, [message.text, artifactPrompt].filter(Boolean).join("\n\n"), signal, onActivity);
116
+ };
117
+ return Object.assign(reply, { close: pool.close });
118
+ }
119
+
120
+ export async function runMessengerCommand(args: string[]): Promise<void> {
121
+ const { action, flags } = parse([...args]);
122
+ if (action === "setup") {
123
+ const io = terminalIO();
124
+ try {
125
+ const result = await setupDiscordMessenger({ configPath: path.resolve(flags.config!), channelIds: flags.channel.length ? flags.channel : undefined, userIds: flags.user.length ? flags.user : undefined, toolNames: flags.tools?.split(",").filter(Boolean), artifacts: flags.artifacts, service: flags.service, dmTest: !flags.noDm }, io);
126
+ process.stdout.write(`Discord messenger ready: ${result.configPath}\nStart: ${result.runScript}\n`);
127
+ } finally { io.close(); }
128
+ return;
129
+ }
130
+ let config: MessengerConfig;
131
+ try { config = await loadMessengerConfig(flags.config!); validateMessengerEnvironment(config); }
132
+ catch { throw new Error("Messenger configuration or required environment is invalid"); }
133
+ const platforms = Object.keys(config.platforms);
134
+ if (action === "check") { process.stdout.write(`Messenger configuration valid: ${platforms.join(", ")}; ${config.toolNames.length} tools enabled. No network checks performed.\n`); return; }
135
+ if (config.toolNames.length) process.stderr.write("Warning: messenger tools grant authorized remote users local machine authority; this is not a sandbox.\n");
136
+ const adapters: MessengerAdapter[] = [];
137
+ if (config.platforms.discord) adapters.push(createDiscordAdapter(config.platforms.discord));
138
+ if (config.platforms.telegram) adapters.push(createTelegramAdapter(config.platforms.telegram));
139
+ if (config.platforms.slack) adapters.push(createSlackAdapter(config.platforms.slack));
140
+ const controller = new AbortController();
141
+ const stop = () => controller.abort();
142
+ process.once("SIGINT", stop); process.once("SIGTERM", stop);
143
+ const reply = createMessengerCliReply(config);
144
+ try { process.stdout.write(`Starting messenger: ${platforms.join(", ")}. Press Ctrl+C to stop.\n`); await createMessengerGateway(config, adapters, reply, { concurrency: 4 }).run(controller.signal); }
145
+ finally { controller.abort(); await reply.close(); process.off("SIGINT", stop); process.off("SIGTERM", stop); }
146
+ }
@@ -107,6 +107,7 @@ async function runSetup(flags: Flags, opts: NotifyOptions): Promise<void> {
107
107
  console.log(`Slack bot and channel verified; channelId=${channelId}. Notifications enabled. Setup did not send a message.`);
108
108
  console.log("Socket Mode URL verified; app-token ownership and socket connectivity are checked by the daemon handshake. Token rotation requires an explicit user allowlist.");
109
109
  } else if (flags.provider === "discord") {
110
+ console.log("Discord setup: enable Message Content Intent in the Developer Portal, invite the bot with View Channel, Send Messages, Read Message History, and Send Messages in Threads, then copy IDs with Discord Developer Mode.");
110
111
  const channelId = flags.channelId ?? existing?.discord?.channelId ?? await question("Discord channel ID: ", opts);
111
112
  const sameDestination = existing?.discord?.botToken === token && existing.discord.channelId === channelId;
112
113
  if (!allowedUserIds?.length && (!sameDestination || !existing?.discord?.allowedUserIds?.length)) allowedUserIds = (await question("Allowed Discord user IDs (comma separated; required after token/destination changes): ", opts)).split(",").map(id => id.trim());
@@ -121,6 +122,7 @@ async function runSetup(flags: Flags, opts: NotifyOptions): Promise<void> {
121
122
  const unchanged = previous?.botToken === token && previous.channelId === channelId;
122
123
  const owners = allowedUserIds ?? (unchanged ? previous.allowedUserIds : undefined);
123
124
  if (!owners?.length || !owners.every(id => /^[1-9]\d{0,19}$/.test(id))) throw new Error("Discord requires explicit allowed user IDs; repeat --allowed-user-ids after token or destination changes");
125
+ if ([10, 11, 12].includes(channel.type) && (!channel.owner_id || !owners.includes(channel.owner_id))) throw new Error("Discord thread owner must be an allowed user");
124
126
  return { notifications: { ...raw.notifications, enabled: true, discord: { ...(unchanged ? previous : {}), botToken: token, channelId, allowedUserIds: owners } } };
125
127
  });
126
128
  console.log(`Discord bot verified; channelId=${channelId}. Notifications enabled.`);
@@ -209,6 +211,7 @@ async function runHealth(provider: Provider, send: boolean, opts: NotifyOptions)
209
211
  const me = await api.getMe();
210
212
  const channel = await api.getChannel(cfg.channelId);
211
213
  if (!me.bot || channel.id !== cfg.channelId || ![0, 1, 5, 10, 11, 12].includes(channel.type)) throw new Error("Discord identity/channel validation failed");
214
+ if ([10, 11, 12].includes(channel.type) && (!channel.owner_id || !cfg.allowedUserIds.includes(channel.owner_id))) throw new Error("Discord thread owner must be an allowed user");
212
215
  if (send) await api.sendMessage(cfg.channelId, "jeo notification test");
213
216
  } else {
214
217
  const cfg = n?.telegram;
@@ -0,0 +1,30 @@
1
+ import type { AgentLoopEvents, ToolInvocation } from "../agent/engine";
2
+ import type { MessengerActivityReporter } from "./types";
3
+
4
+ /**
5
+ * Derive one-line progress from session events: the model's declared tool intent (falling back to the
6
+ * tool name) and long-running lifecycle notices. Raw tool arguments, results and errors never surface.
7
+ */
8
+ export function createMessengerActivityEvents(report: MessengerActivityReporter): AgentLoopEvents {
9
+ let step = 0;
10
+ return {
11
+ onAssistant(_raw: string, invocation: ToolInvocation | null) {
12
+ if (!invocation || invocation.tool === "done") return;
13
+ step++;
14
+ const intent =
15
+ invocation.arguments &&
16
+ typeof invocation.arguments.i === "string" &&
17
+ invocation.arguments.i.trim()
18
+ ? invocation.arguments.i.trim()
19
+ : `Using ${invocation.tool}`;
20
+ report(`Step ${step} · ${intent}`);
21
+ },
22
+ onNotice(message: string) {
23
+ if (/compact/i.test(message)) {
24
+ report("Compacting context");
25
+ } else if (/retry|rate limit/i.test(message)) {
26
+ report("Retrying after a provider error");
27
+ }
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,167 @@
1
+ import * as fsConstants from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { messengerConversationKey } from "./gateway";
5
+ import type { MessengerMessage } from "./types";
6
+
7
+ const MAX_ARTIFACT_SIZE = 8 * 1024 * 1024;
8
+ const CDN_HOSTS: Record<string, true> = { "cdn.discordapp.com": true, "media.discordapp.net": true };
9
+ const MAX_ARTIFACTS = 10;
10
+
11
+ export function messengerArtifactDirectory(root: string, message: MessengerMessage): string {
12
+ const route = new Bun.CryptoHasher("sha256").update(messengerConversationKey(message)).digest("hex");
13
+ return path.join(root, route);
14
+ }
15
+
16
+ export async function prepareMessengerArtifactDirectory(root: string, message: MessengerMessage): Promise<string> {
17
+ const rootStat = await fs.lstat(root);
18
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("Invalid artifact directory");
19
+ const directory = messengerArtifactDirectory(root, message);
20
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
21
+ const stat = await fs.lstat(directory);
22
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Invalid artifact directory");
23
+ return directory;
24
+ }
25
+
26
+ async function withAbort<T>(operation: () => Promise<T>, signal: AbortSignal): Promise<T> {
27
+ if (signal.aborted) throw new Error("Attachment download aborted");
28
+ const stopped = Promise.withResolvers<never>();
29
+ const abort = (): void => stopped.reject(new Error("Attachment download aborted"));
30
+ signal.addEventListener("abort", abort, { once: true });
31
+ try {
32
+ return await Promise.race([operation(), stopped.promise]);
33
+ } finally {
34
+ signal.removeEventListener("abort", abort);
35
+ }
36
+ }
37
+
38
+ export async function downloadMessengerAttachments(
39
+ directory: string,
40
+ message: MessengerMessage,
41
+ signal: AbortSignal,
42
+ fetchImpl: typeof fetch = fetch,
43
+ timeoutMs = 30_000,
44
+ ): Promise<string[]> {
45
+ const attachments = message.attachments ?? [];
46
+ if (attachments.length > MAX_ARTIFACTS) throw new Error("Too many attachments");
47
+ const paths: string[] = [];
48
+ for (const [index, attachment] of attachments.entries()) {
49
+ const url = new URL(attachment.url);
50
+ if (
51
+ !Object.hasOwn(CDN_HOSTS, url.hostname) ||
52
+ url.protocol !== "https:" ||
53
+ url.port ||
54
+ url.username ||
55
+ url.password ||
56
+ !url.pathname.startsWith("/attachments/")
57
+ )
58
+ throw new Error("Invalid attachment URL");
59
+ if (!Number.isSafeInteger(attachment.size) || attachment.size < 0 || attachment.size > MAX_ARTIFACT_SIZE)
60
+ throw new Error("Invalid attachment size");
61
+ const downloadSignal = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
62
+ const response = await withAbort(
63
+ () => fetchImpl(url, { signal: downloadSignal, redirect: "error" }),
64
+ downloadSignal,
65
+ );
66
+ if (!response.ok || !response.body) throw new Error("Attachment download failed");
67
+ const reader = response.body.getReader();
68
+ const chunks: Uint8Array[] = [];
69
+ let size = 0;
70
+ try {
71
+ while (true) {
72
+ const { done, value } = await withAbort(() => reader.read(), downloadSignal);
73
+ if (done) break;
74
+ size += value.byteLength;
75
+ if (size > MAX_ARTIFACT_SIZE || size > attachment.size) throw new Error("Attachment exceeds declared size");
76
+ chunks.push(value);
77
+ }
78
+ } finally {
79
+ void reader.cancel().catch(() => {});
80
+ }
81
+ if (size !== attachment.size) throw new Error("Attachment size mismatch");
82
+ const safeName =
83
+ path
84
+ .basename(attachment.filename)
85
+ .replace(/[^a-zA-Z0-9._-]/gu, "_")
86
+ .slice(0, 100) || "attachment";
87
+ const id = new Bun.CryptoHasher("sha256").update(message.messageId).digest("hex");
88
+ const file = path.join(directory, `input-${id}-${index}-${safeName}`);
89
+ await fs.writeFile(file, Buffer.concat(chunks), { flag: "wx", mode: 0o600 });
90
+ paths.push(file);
91
+ }
92
+ return paths;
93
+ }
94
+
95
+ export async function messengerArtifactPrompt(
96
+ root: string | undefined,
97
+ message: MessengerMessage,
98
+ signal: AbortSignal,
99
+ ): Promise<string> {
100
+ if (!root) {
101
+ if (message.attachments?.length) throw new Error("Discord attachments are not enabled");
102
+ return "";
103
+ }
104
+ const directory = await prepareMessengerArtifactDirectory(root, message);
105
+ const inputs = await downloadMessengerAttachments(directory, message, signal);
106
+ const base = `To deliver a requested file, write it inside ${directory} and put [artifact:relative/path] on its own line in your final response. Only files in that directory can be uploaded; each must be at most 8 MiB. Never include credentials or secrets in output files.`;
107
+ return inputs.length ? `${base}\nIncoming files: ${inputs.join(", ")}` : base;
108
+ }
109
+
110
+ export async function readMessengerArtifacts(
111
+ root: string,
112
+ message: MessengerMessage,
113
+ text: string,
114
+ ): Promise<{
115
+ text: string;
116
+ files: { name: string; data: Uint8Array }[];
117
+ }> {
118
+ const names: string[] = [];
119
+ const cleaned = text
120
+ .replace(/^\s*\[artifact:([^\]\r\n]+)\]\s*$/gmu, (_match, name: string) => {
121
+ names.push(name);
122
+ return "";
123
+ })
124
+ .trim();
125
+ if (names.length > MAX_ARTIFACTS) throw new Error("Too many output files");
126
+ if (!names.length) return { text, files: [] };
127
+ const directory = await prepareMessengerArtifactDirectory(root, message);
128
+ const files: { name: string; data: Uint8Array }[] = [];
129
+ for (const name of names) {
130
+ if (
131
+ !name ||
132
+ path.isAbsolute(name) ||
133
+ name.includes("\\") ||
134
+ name.split("/").some(part => !part || part === "." || part === "..")
135
+ ) {
136
+ throw new Error("Invalid output file path");
137
+ }
138
+ let current = path.resolve(directory);
139
+ for (const part of name.split("/")) {
140
+ current = path.join(current, part);
141
+ const entry = await fs.lstat(current);
142
+ if (entry.isSymbolicLink()) throw new Error("Output file symlinks are not allowed");
143
+ }
144
+ if (!(await fs.lstat(current)).isFile()) throw new Error("Output file is not a regular file");
145
+ const handle = await fs.open(
146
+ current,
147
+ fsConstants.constants.O_RDONLY | fsConstants.constants.O_NOFOLLOW | fsConstants.constants.O_NONBLOCK,
148
+ );
149
+ try {
150
+ const stat = await handle.stat();
151
+ if (!stat.isFile() || stat.size > MAX_ARTIFACT_SIZE)
152
+ throw new Error("Output file is invalid or exceeds 8 MiB");
153
+ const data = Buffer.alloc(stat.size + 1);
154
+ let offset = 0;
155
+ while (offset < data.length) {
156
+ const { bytesRead } = await handle.read(data, offset, data.length - offset, offset);
157
+ if (!bytesRead) break;
158
+ offset += bytesRead;
159
+ }
160
+ if (offset !== stat.size) throw new Error("Output file changed while being read");
161
+ files.push({ name: path.basename(name), data: data.subarray(0, offset) });
162
+ } finally {
163
+ await handle.close();
164
+ }
165
+ }
166
+ return { text: cleaned, files };
167
+ }
@@ -0,0 +1,145 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import type { MessengerConfig, MessengerPlatform, MessengerPlatformConfig } from "./types";
4
+
5
+ const PLATFORMS: MessengerPlatform[] = ["discord", "telegram", "slack"];
6
+ const TOOLS: Record<string, true> = {
7
+ read: true,
8
+ grep: true,
9
+ glob: true,
10
+ find: true,
11
+ search: true,
12
+ bash: true,
13
+ edit: true,
14
+ write: true,
15
+ };
16
+
17
+ function object(value: unknown, label: string): Record<string, unknown> {
18
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
19
+ throw new Error(`${label} must be an object`);
20
+ }
21
+ return value as Record<string, unknown>;
22
+ }
23
+
24
+ function keys(value: Record<string, unknown>, allowed: string[], label: string): void {
25
+ if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error(`${label} contains an unknown setting`);
26
+ }
27
+
28
+ function string(value: unknown, label: string): string {
29
+ if (typeof value !== "string" || value.trim() !== value || !value || /[\u0000-\u001f\u007f]/u.test(value)) {
30
+ throw new Error(`${label} must be a nonempty string without surrounding whitespace or control characters`);
31
+ }
32
+ return value;
33
+ }
34
+
35
+ function ids(value: unknown, label: string): string[] {
36
+ if (!Array.isArray(value) || value.length === 0) throw new Error(`${label} must be a nonempty list of exact IDs`);
37
+ const result = value.map(item => string(item, label));
38
+ if (result.some(id => /[\s*]/u.test(id)) || new Set(result).size !== result.length) {
39
+ throw new Error(`${label} must contain unique exact IDs, without whitespace or wildcards`);
40
+ }
41
+ return result;
42
+ }
43
+
44
+ function envName(value: unknown, fallback: string, label: string): string {
45
+ const result = value === undefined ? fallback : string(value, label);
46
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(result)) throw new Error(`${label} must name an environment variable`);
47
+ return result;
48
+ }
49
+
50
+ /** Resolve a platform credential from `env`, honoring JEO_<X> <-> JEOPI_<X> aliases when using default names. */
51
+ export function resolveMessengerToken(
52
+ name: string | undefined,
53
+ env: Record<string, string | undefined> = process.env,
54
+ ): string | undefined {
55
+ if (!name) return undefined;
56
+ const direct = env[name];
57
+ if (direct !== undefined) return direct;
58
+ if (name.startsWith("JEO_")) return env[`JEOPI_${name.slice(4)}`];
59
+ if (name.startsWith("JEOPI_")) return env[`JEO_${name.slice(6)}`];
60
+ return undefined;
61
+ }
62
+
63
+ export function parseMessengerConfig(value: unknown, baseDir: string): MessengerConfig {
64
+ const input = object(value, "Messenger config");
65
+ keys(input, ["cwd", "sessionDir", "toolNames", "platforms"], "Messenger config");
66
+ const platformsInput = object(input.platforms, "platforms");
67
+ keys(platformsInput, PLATFORMS, "platforms");
68
+ if (Object.keys(platformsInput).length === 0) throw new Error("At least one messenger platform is required");
69
+ const platforms: MessengerConfig["platforms"] = {};
70
+ for (const platform of PLATFORMS) {
71
+ if (!Object.hasOwn(platformsInput, platform)) continue;
72
+ const item = object(platformsInput[platform], `${platform} config`);
73
+ keys(
74
+ item,
75
+ platform === "slack"
76
+ ? ["tokenEnv", "appTokenEnv", "allowedUserIds", "allowedChannelIds"]
77
+ : platform === "discord"
78
+ ? ["tokenEnv", "allowedUserIds", "allowedChannelIds", "artifactDir"]
79
+ : ["tokenEnv", "allowedUserIds", "allowedChannelIds"],
80
+ `${platform} config`,
81
+ );
82
+ const config: MessengerPlatformConfig = {
83
+ tokenEnv: envName(item.tokenEnv, `JEO_${platform.toUpperCase()}_BOT_TOKEN`, `${platform}.tokenEnv`),
84
+ allowedUserIds: ids(item.allowedUserIds, `${platform}.allowedUserIds`),
85
+ };
86
+ if (platform === "slack")
87
+ config.appTokenEnv = envName(item.appTokenEnv, "JEO_SLACK_APP_TOKEN", "slack.appTokenEnv");
88
+ if (item.allowedChannelIds !== undefined)
89
+ config.allowedChannelIds = ids(item.allowedChannelIds, `${platform}.allowedChannelIds`);
90
+ if (platform === "discord" && item.artifactDir !== undefined)
91
+ config.artifactDir = path.resolve(baseDir, string(item.artifactDir, "discord.artifactDir"));
92
+ platforms[platform] = config;
93
+ }
94
+ const toolNames = input.toolNames === undefined ? [] : input.toolNames;
95
+ if (
96
+ !Array.isArray(toolNames) ||
97
+ toolNames.some(name => typeof name !== "string" || !Object.hasOwn(TOOLS, name)) ||
98
+ new Set(toolNames).size !== toolNames.length
99
+ ) {
100
+ throw new Error("toolNames must be a unique list drawn from read, grep, glob, bash, edit, write");
101
+ }
102
+ return {
103
+ cwd: path.resolve(baseDir, input.cwd === undefined ? "." : string(input.cwd, "cwd")),
104
+ sessionDir: path.resolve(
105
+ baseDir,
106
+ input.sessionDir === undefined ? ".jeo/messenger-sessions" : string(input.sessionDir, "sessionDir"),
107
+ ),
108
+ toolNames: [...toolNames] as string[],
109
+ platforms,
110
+ };
111
+ }
112
+
113
+ export async function loadMessengerConfig(file: string): Promise<MessengerConfig> {
114
+ let value: unknown;
115
+ try {
116
+ value = await Bun.file(file).json();
117
+ } catch {
118
+ throw new Error("Cannot read messenger configuration as JSON");
119
+ }
120
+ const config = parseMessengerConfig(value, path.dirname(path.resolve(file)));
121
+ const artifactDir = config.platforms.discord?.artifactDir;
122
+ if (artifactDir) {
123
+ const stat = await fs.lstat(artifactDir).catch(() => undefined);
124
+ if (!stat?.isDirectory() || stat.isSymbolicLink())
125
+ throw new Error("discord.artifactDir must be an existing directory, not a symlink");
126
+ }
127
+ return config;
128
+ }
129
+
130
+ export function validateMessengerEnvironment(
131
+ config: MessengerConfig,
132
+ env: Record<string, string | undefined> = process.env,
133
+ ): void {
134
+ for (const platform of PLATFORMS) {
135
+ const item = config.platforms[platform];
136
+ if (!item) continue;
137
+ for (const name of [item.tokenEnv, item.appTokenEnv]) {
138
+ if (name === undefined) continue;
139
+ const token = resolveMessengerToken(name, env);
140
+ if (!token?.trim() || token !== token.trim() || /[\s\u0000-\u001f\u007f]/u.test(token)) {
141
+ throw new Error(`${platform} requires a nonempty credential in ${name}`);
142
+ }
143
+ }
144
+ }
145
+ }