diff --git a/bin/lousho.js b/bin/lousho.js index 78320490..d23b4b24 100755 --- a/bin/lousho.js +++ b/bin/lousho.js @@ -11,6 +11,7 @@ const USAGE = [ '', 'Commands:', ' lousho init [dir] [--provider P] [--template T] [--yes] [--no-install] [--no-git] [--package-manager PM] [--force] [--sdk-path ]', + ' [--bot-name N --bot-token T --bot-username U --allowed-chats IDS --persona TXT] (telegram template only)', ' Scaffold a new agent project (what `npm create lousho-agent` runs).', ' lousho dev [--port N] [--host H] [--allowed-hosts a,b] [--no-schedules] [--traces[=dir]]', ' Serve an agent with a local chat UI and HTTP API; reloads on file changes.', diff --git a/src/cli/init.ts b/src/cli/init.ts index a114f09f..45bab784 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -16,6 +16,7 @@ import { PACKAGE_MANAGERS, PROVIDER_NAMES, TEMPLATES, + botFlagsUsed, detectPackageManager, expectOneOf, parseInitArgs, @@ -161,6 +162,11 @@ async function scaffold(options: InitOptions, environment: InitEnvironment): Pro const dir = path.resolve(environment.cwd, choices.dir); assertWritable(dir, options.force); + const botFlags = botFlagsUsed(options); + if (choices.template !== 'telegram' && botFlags.length) { + throw new InitUsageError(`lousho init: ${botFlags.join(', ')} only work with --template telegram.`); + } + const sdkDependency = resolveSdkDependency(environment.sdk.version, dir, options.sdkPath); const files = renderProject({ name: packageNameFor(dir), @@ -169,6 +175,13 @@ async function scaffold(options: InitOptions, environment: InitEnvironment): Pro packageManager: choices.packageManager, sdkDependency, sdk: environment.sdk, + bot: { + name: options.botName, + token: options.botToken, + username: options.botUsername, + allowedChats: options.allowedChats, + persona: options.persona, + }, }); environment.write(`Creating ${choices.template} ${choices.provider} agent in ${dir}\n`); writeFiles(dir, files); diff --git a/src/cli/init/__snapshots__/templates.test.ts.snap b/src/cli/init/__snapshots__/templates.test.ts.snap index e82d34a4..72a7ac3b 100644 --- a/src/cli/init/__snapshots__/templates.test.ts.snap +++ b/src/cli/init/__snapshots__/templates.test.ts.snap @@ -37,6 +37,50 @@ export function buildAgent(provider?: LLMProvider) { " `; +exports[`renderProject > snapshot: anthropic / telegram agent.ts 1`] = ` +"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +const BOT = process.env.BOT_NAME ?? "Bot"; +const persona = "a helpful assistant that lives on Telegram"; + +const instructions = [ + \`You are \${BOT}, \${persona}.\`, + 'You talk over Telegram — short, natural messages, plain text the way people actually text. Markdown renders for you: bold, italic, code spans, bullets, links. Never say you are an AI. The owner is whoever claimed this bot first; other chats may reach you in groups or through the whitelist — answer them, but approvals and dangerous tools are the owner\\'s call.', +].join('\\n\\n'); + +const tools = [currentTime]; +// Caps each reply so a first run works on a low-credit key (providers such as OpenRouter reserve the +// model's full output limit up front). Raise it when you need longer answers. +const modelSettings = { maxTokens: 4096 }; + +/** + * The agent's options. The default export is what \`lousho dev src/agent.ts\` + * (and \`lousho chat\`, \`mcp\`, \`acp\`, \`build\`) serve. + */ +const config: CreateAgentConfig = { model: 'anthropic/claude-sonnet-4-5', instructions, tools, modelSettings }; +export default config; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider ? createAgent({ provider, instructions, tools, modelSettings }) : createAgent(config); +} +" +`; + exports[`renderProject > snapshot: anthropic / tools agent.ts 1`] = ` "import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent'; import { z } from 'zod'; @@ -127,6 +171,50 @@ export function buildAgent(provider?: LLMProvider) { " `; +exports[`renderProject > snapshot: google / telegram agent.ts 1`] = ` +"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +const BOT = process.env.BOT_NAME ?? "Bot"; +const persona = "a helpful assistant that lives on Telegram"; + +const instructions = [ + \`You are \${BOT}, \${persona}.\`, + 'You talk over Telegram — short, natural messages, plain text the way people actually text. Markdown renders for you: bold, italic, code spans, bullets, links. Never say you are an AI. The owner is whoever claimed this bot first; other chats may reach you in groups or through the whitelist — answer them, but approvals and dangerous tools are the owner\\'s call.', +].join('\\n\\n'); + +const tools = [currentTime]; +// Caps each reply so a first run works on a low-credit key (providers such as OpenRouter reserve the +// model's full output limit up front). Raise it when you need longer answers. +const modelSettings = { maxTokens: 4096 }; + +/** + * The agent's options. The default export is what \`lousho dev src/agent.ts\` + * (and \`lousho chat\`, \`mcp\`, \`acp\`, \`build\`) serve. + */ +const config: CreateAgentConfig = { model: 'google/gemini-2.5-flash', instructions, tools, modelSettings }; +export default config; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider ? createAgent({ provider, instructions, tools, modelSettings }) : createAgent(config); +} +" +`; + exports[`renderProject > snapshot: google / tools agent.ts 1`] = ` "import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent'; import { z } from 'zod'; @@ -252,6 +340,50 @@ export function buildAgent(provider?: LLMProvider) { " `; +exports[`renderProject > snapshot: ollama / telegram agent.ts 1`] = ` +"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +const BOT = process.env.BOT_NAME ?? "Bot"; +const persona = "a helpful assistant that lives on Telegram"; + +const instructions = [ + \`You are \${BOT}, \${persona}.\`, + 'You talk over Telegram — short, natural messages, plain text the way people actually text. Markdown renders for you: bold, italic, code spans, bullets, links. Never say you are an AI. The owner is whoever claimed this bot first; other chats may reach you in groups or through the whitelist — answer them, but approvals and dangerous tools are the owner\\'s call.', +].join('\\n\\n'); + +const tools = [currentTime]; +// Caps each reply so a first run works on a low-credit key (providers such as OpenRouter reserve the +// model's full output limit up front). Raise it when you need longer answers. +const modelSettings = { maxTokens: 4096 }; + +/** + * The agent's options. The default export is what \`lousho dev src/agent.ts\` + * (and \`lousho chat\`, \`mcp\`, \`acp\`, \`build\`) serve. + */ +const config: CreateAgentConfig = { model: 'ollama/llama3', instructions, tools, modelSettings }; +export default config; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider ? createAgent({ provider, instructions, tools, modelSettings }) : createAgent(config); +} +" +`; + exports[`renderProject > snapshot: ollama / tools agent.ts 1`] = ` "import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent'; import { z } from 'zod'; @@ -342,6 +474,50 @@ export function buildAgent(provider?: LLMProvider) { " `; +exports[`renderProject > snapshot: openai / telegram agent.ts 1`] = ` +"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +const BOT = process.env.BOT_NAME ?? "Bot"; +const persona = "a helpful assistant that lives on Telegram"; + +const instructions = [ + \`You are \${BOT}, \${persona}.\`, + 'You talk over Telegram — short, natural messages, plain text the way people actually text. Markdown renders for you: bold, italic, code spans, bullets, links. Never say you are an AI. The owner is whoever claimed this bot first; other chats may reach you in groups or through the whitelist — answer them, but approvals and dangerous tools are the owner\\'s call.', +].join('\\n\\n'); + +const tools = [currentTime]; +// Caps each reply so a first run works on a low-credit key (providers such as OpenRouter reserve the +// model's full output limit up front). Raise it when you need longer answers. +const modelSettings = { maxTokens: 4096 }; + +/** + * The agent's options. The default export is what \`lousho dev src/agent.ts\` + * (and \`lousho chat\`, \`mcp\`, \`acp\`, \`build\`) serve. + */ +const config: CreateAgentConfig = { model: 'openai/gpt-4.1-mini', instructions, tools, modelSettings }; +export default config; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider ? createAgent({ provider, instructions, tools, modelSettings }) : createAgent(config); +} +" +`; + exports[`renderProject > snapshot: openai / tools agent.ts 1`] = ` "import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent'; import { z } from 'zod'; @@ -432,6 +608,50 @@ export function buildAgent(provider?: LLMProvider) { " `; +exports[`renderProject > snapshot: openrouter / telegram agent.ts 1`] = ` +"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +const BOT = process.env.BOT_NAME ?? "Bot"; +const persona = "a helpful assistant that lives on Telegram"; + +const instructions = [ + \`You are \${BOT}, \${persona}.\`, + 'You talk over Telegram — short, natural messages, plain text the way people actually text. Markdown renders for you: bold, italic, code spans, bullets, links. Never say you are an AI. The owner is whoever claimed this bot first; other chats may reach you in groups or through the whitelist — answer them, but approvals and dangerous tools are the owner\\'s call.', +].join('\\n\\n'); + +const tools = [currentTime]; +// Caps each reply so a first run works on a low-credit key (providers such as OpenRouter reserve the +// model's full output limit up front). Raise it when you need longer answers. +const modelSettings = { maxTokens: 4096 }; + +/** + * The agent's options. The default export is what \`lousho dev src/agent.ts\` + * (and \`lousho chat\`, \`mcp\`, \`acp\`, \`build\`) serve. + */ +const config: CreateAgentConfig = { model: 'openrouter/openai/gpt-4.1-mini', instructions, tools, modelSettings }; +export default config; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider ? createAgent({ provider, instructions, tools, modelSettings }) : createAgent(config); +} +" +`; + exports[`renderProject > snapshot: openrouter / tools agent.ts 1`] = ` "import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent'; import { z } from 'zod'; @@ -522,6 +742,50 @@ export function buildAgent(provider?: LLMProvider) { " `; +exports[`renderProject > snapshot: pi / telegram agent.ts 1`] = ` +"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +const BOT = process.env.BOT_NAME ?? "Bot"; +const persona = "a helpful assistant that lives on Telegram"; + +const instructions = [ + \`You are \${BOT}, \${persona}.\`, + 'You talk over Telegram — short, natural messages, plain text the way people actually text. Markdown renders for you: bold, italic, code spans, bullets, links. Never say you are an AI. The owner is whoever claimed this bot first; other chats may reach you in groups or through the whitelist — answer them, but approvals and dangerous tools are the owner\\'s call.', +].join('\\n\\n'); + +const tools = [currentTime]; +// Caps each reply so a first run works on a low-credit key (providers such as OpenRouter reserve the +// model's full output limit up front). Raise it when you need longer answers. +const modelSettings = { maxTokens: 4096 }; + +/** + * The agent's options. The default export is what \`lousho dev src/agent.ts\` + * (and \`lousho chat\`, \`mcp\`, \`acp\`, \`build\`) serve. + */ +const config: CreateAgentConfig = { model: 'pi/openrouter/openai/gpt-4.1-mini', instructions, tools, modelSettings }; +export default config; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider ? createAgent({ provider, instructions, tools, modelSettings }) : createAgent(config); +} +" +`; + exports[`renderProject > snapshot: pi / tools agent.ts 1`] = ` "import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent'; import { z } from 'zod'; @@ -575,6 +839,43 @@ export function buildAgent(provider?: LLMProvider) { " `; +exports[`renderProject > snapshot: telegram package.json 1`] = ` +"{ + "name": "demo-agent", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx src/index.ts", + "dev:ui": "lousho dev src/agent.ts", + "telegram": "tsx src/index.ts --telegram", + "task": "tsx src/index.ts --task-file", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "doctor": "lousho doctor src/agent.ts", + "build": "lousho build src/agent.ts --target=node-server" + }, + "dependencies": { + "@ai-sdk/openai": "^4.0.0", + "@lousho/build-ai-agent": "^1.0.0-alpha.8", + "ai": "^7.0.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsup": "^8.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vite": "^8.0.0", + "vitest": "^5.0.3" + }, + "engines": { + "node": ">=22.19.0" + } +} +" +`; + exports[`renderProject > snapshot: tools package.json 1`] = ` "{ "name": "demo-agent", diff --git a/src/cli/init/options.ts b/src/cli/init/options.ts index 9d9e4268..3a0e09be 100644 --- a/src/cli/init/options.ts +++ b/src/cli/init/options.ts @@ -4,7 +4,7 @@ import { parseArgs } from 'node:util'; import { listProviders } from '../../providers/providerSpec'; -export const TEMPLATES = ['minimal', 'tools', 'yaml'] as const; +export const TEMPLATES = ['minimal', 'tools', 'yaml', 'telegram'] as const; export type Template = (typeof TEMPLATES)[number]; export const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'] as const; @@ -28,6 +28,13 @@ Options: --sdk-path Local development: depend on a checkout or packed .tgz of the SDK instead of the published version (env: LOUSHO_SDK_PATH). See docs/installation.md#installing-from-a-local-build + +Telegram template options (only with --template telegram): + --bot-name The bot's persona name (env BOT_NAME; e.g. Ghost) + --bot-token BotFather token, written to .env as TELEGRAM_BOT_TOKEN + --bot-username Bot's @username without @ (env BOT_USERNAME) + --allowed-chats Comma-separated chat IDs allowed besides the owner (env ALLOWED_CHATS) + --persona One-paragraph persona baked into the agent's instructions --help, -h Show this help`; export interface InitOptions { @@ -41,6 +48,12 @@ export interface InitOptions { git: boolean; force: boolean; sdkPath?: string; + /** Telegram template only. */ + botName?: string; + botToken?: string; + botUsername?: string; + allowedChats?: string; + persona?: string; } /** Thrown for any bad flag or value; the message is printed as-is. */ @@ -64,6 +77,11 @@ export function parseInitArgs(argv: readonly string[], env: NodeJS.ProcessEnv = template: { type: 'string' }, 'package-manager': { type: 'string' }, 'sdk-path': { type: 'string' }, + 'bot-name': { type: 'string' }, + 'bot-token': { type: 'string' }, + 'bot-username': { type: 'string' }, + 'allowed-chats': { type: 'string' }, + persona: { type: 'string' }, }, }); } catch (error) { @@ -84,9 +102,29 @@ export function parseInitArgs(argv: readonly string[], env: NodeJS.ProcessEnv = git: values['no-git'] !== true, force: values.force === true, sdkPath: (values['sdk-path'] as string | undefined) ?? (env.LOUSHO_SDK_PATH || undefined), + botName: values['bot-name'] as string | undefined, + botToken: values['bot-token'] as string | undefined, + botUsername: values['bot-username'] as string | undefined, + allowedChats: values['allowed-chats'] as string | undefined, + persona: values.persona as string | undefined, }; } +/** The telegram-template flags → the InitOptions field each sets; used to reject them on other templates. */ +const BOT_FLAG_FIELDS = { + '--bot-name': 'botName', + '--bot-token': 'botToken', + '--bot-username': 'botUsername', + '--allowed-chats': 'allowedChats', + '--persona': 'persona', +} as const satisfies Record; + +const BOT_FLAGS = Object.keys(BOT_FLAG_FIELDS) as Array; + +export function botFlagsUsed(options: InitOptions): string[] { + return BOT_FLAGS.filter((flag) => options[BOT_FLAG_FIELDS[flag]] !== undefined); +} + /** Returns `value` if it is one of `allowed`, otherwise throws naming the flag and the allowed set. */ export function expectOneOf(flag: string, value: string, allowed: readonly T[]): T { if ((allowed as readonly string[]).includes(value)) return value as T; diff --git a/src/cli/init/projectConfig.ts b/src/cli/init/projectConfig.ts new file mode 100644 index 00000000..756aac63 --- /dev/null +++ b/src/cli/init/projectConfig.ts @@ -0,0 +1,38 @@ +/** + * The shared init-project contract: the shape `renderProject` consumes and the + * provider resolver both `templates.ts` and `telegramTemplate.ts` need. Kept in + * its own module so template files stay acyclic (telegramTemplate -> here, + * templates -> telegramTemplate). + */ +import { listProviders } from '../../providers/providerSpec'; +import { ConfigurationError } from '../../execution/errors'; +import type { PackageManager, Template } from './options'; +import type { SdkManifest } from './sdkDependency'; + +/** Bot options collected from `--bot-*`/`--persona` flags. */ +export interface BotOptions { + name?: string; + token?: string; + username?: string; + allowedChats?: string; + persona?: string; +} + +export interface ProjectConfig { + /** npm package name (also the directory's base name). */ + name: string; + provider: string; + template: Template; + packageManager: PackageManager; + /** `dependencies["@lousho/build-ai-agent"]`: a range, or `file:./`. */ + sdkDependency: string; + sdk: SdkManifest; + /** Telegram template only: --bot-* / --persona flag values baked into .env and the persona. */ + bot?: BotOptions; +} + +export function providerInfo(provider: string) { + const info = listProviders().find((candidate) => candidate.name === provider); + if (!info) throw new ConfigurationError(`lousho init: unknown provider '${provider}'.`, 'provider', 'LOUSHO_PROVIDER_UNKNOWN'); + return info; +} diff --git a/src/cli/init/prompts.ts b/src/cli/init/prompts.ts index b052f876..48cdd0f2 100644 --- a/src/cli/init/prompts.ts +++ b/src/cli/init/prompts.ts @@ -20,6 +20,7 @@ const TEMPLATE_HINTS: Record = { minimal: 'one agent, one example tool, an offline test', tools: 'several example tools', yaml: 'an agent.yaml spec, run with `lousho dev`', + telegram: 'a Telegram bot: polling bridge, owner lock, approvals', }; function choices(values: readonly string[], hints: Record = {}) { diff --git a/src/cli/init/telegramTemplate.ts b/src/cli/init/telegramTemplate.ts new file mode 100644 index 00000000..6cc6061c --- /dev/null +++ b/src/cli/init/telegramTemplate.ts @@ -0,0 +1,781 @@ +/** + * File contents for the `telegram` init template: a long-polling Telegram + * bridge around the generated agent — owner lock, an allowed-chats + * whitelist, approval buttons, markdown→Telegram-HTML rendering, photo/file + * intake, terminal and `--task-file` one-shot modes. + */ +import { providerInfo, type BotOptions, type ProjectConfig } from './projectConfig'; + +export type { BotOptions } from './projectConfig'; + +const MD_TS = [ + '/**', + ' * Markdown-ish → Telegram HTML. Escapes raw HTML first, then rewrites', + ' * the common markers the model emits. Output is always well-formed;', + ' * senders still fall back to plain text if Telegram rejects it.', + ' */', + 'export function mdToTelegramHtml(src: string): string {', + " let s = src.replace(/&/g, '&').replace(//g, '>');", + " s = s.replace(/```[^\\n]*\\n?([\\s\\S]*?)```/g, (_m, code: string) => `
${code.trim()}
`);", + " s = s.replace(/`([^`\\n]+)`/g, '$1');", + " s = s.replace(/\\*\\*([^*\\n]+)\\*\\*/g, '$1');", + " s = s.replace(/__([^_\\n]+)__/g, '$1');", + " s = s.replace(/(^|[\\s(>])\\*([^*\\n]+)\\*(?![\\w*])/g, '$1$2');", + " s = s.replace(/(^|[\\s(>])_([^_\\n]+)_(?![\\w_])/g, '$1$2');", + " s = s.replace(/~~([^~\\n]+)~~/g, '$1');", + " s = s.replace(/^#{1,6}\\s*(.+)$/gm, '$1');", + ' s = s.replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^)\\s]+)\\)/g, \'$1\');', + " s = s.replace(/^\\s*[-*]\\s+/gm, '• ');", + ' return s;', + '}', + '', +].join('\n'); + +const API_TS = [ + "import { readFileSync } from 'node:fs';", + "import { basename } from 'node:path';", + "import { mdToTelegramHtml } from './md.js';", + '', + '// Read per call: .env is loaded by index.ts after this module is imported.', + 'const token = () => {', + ' const t = process.env.TELEGRAM_BOT_TOKEN;', + " if (!t) throw new Error('TELEGRAM_BOT_TOKEN is not set — put it in .env');", + ' return t;', + '};', + 'const api = (method: string) => `https://api.telegram.org/bot${token()}/${method}`;', + '', + 'export async function tgCall(method: string, body?: Record): Promise {', + ' const res = await fetch(api(method), {', + " method: 'POST',", + " headers: { 'content-type': 'application/json' },", + ' body: JSON.stringify(body ?? {}),', + ' });', + ' const data = (await res.json()) as { ok: boolean; result: T; description?: string };', + ' if (!data.ok) throw new Error(`telegram ${method} failed: ${data.description ?? res.status}`);', + ' return data.result;', + '}', + '', + 'export interface TgMessage {', + ' message_id: number;', + ' from?: { id: number; is_bot?: boolean; first_name?: string; username?: string };', + ' chat: { id: number; type: string };', + ' text?: string;', + ' caption?: string;', + ' photo?: { file_id: string }[];', + ' document?: { file_id: string; file_name?: string };', + ' voice?: { file_id: string; duration?: number };', + ' audio?: { file_id: string; duration?: number; file_name?: string };', + ' video?: { file_id: string; duration?: number; thumb?: { file_id: string } };', + ' video_note?: { file_id: string; duration?: number };', + ' sticker?: { file_id: string; emoji?: string };', + ' location?: { latitude: number; longitude: number; live_period?: number };', + ' venue?: { title?: string; address?: string; location: { latitude: number; longitude: number } };', + ' media_group_id?: string;', + ' reply_to_message?: TgMessage;', + '}', + 'export interface TgCallbackQuery {', + ' id: string;', + ' from: { id: number };', + ' data?: string;', + ' message?: TgMessage;', + '}', + 'export interface TgUpdate {', + ' update_id: number;', + ' message?: TgMessage;', + ' callback_query?: TgCallbackQuery;', + '}', + '', + 'export const getUpdates = (offset: number) =>', + " tgCall('getUpdates', { offset, timeout: 30, allowed_updates: ['message', 'callback_query'] });", + '', + 'const chunk = (text: string, size = 4000) => {', + ' const parts: string[] = [];', + ' for (let i = 0; i < text.length; i += size) parts.push(text.slice(i, i + size));', + ' return parts;', + '};', + '', + 'export async function sendMessage(chatId: number, text: string, extra: Record = {}): Promise {', + ' let lastId: number | undefined;', + ' for (const part of chunk(text)) {', + ' try {', + " const msg = await tgCall<{ message_id: number }>('sendMessage', {", + ' chat_id: chatId,', + ' text: mdToTelegramHtml(part),', + " parse_mode: 'HTML',", + ' ...extra,', + ' });', + ' lastId = msg?.message_id;', + ' } catch {', + " const msg = await tgCall<{ message_id: number }>('sendMessage', { chat_id: chatId, text: part, ...extra });", + ' lastId = msg?.message_id;', + ' }', + ' }', + ' return lastId;', + '}', + '', + "export const sendChatAction = (chatId: number, action: 'typing' | 'upload_document' | 'upload_photo' | 'record_voice') =>", + " tgCall('sendChatAction', { chat_id: chatId, action });", + 'export const answerCallbackQuery = (id: string, text?: string) => tgCall(\'answerCallbackQuery\', { callback_query_id: id, text });', + 'export const editMessageMarkup = (chatId: number, messageId: number) =>', + " tgCall('editMessageReplyMarkup', { chat_id: chatId, message_id: messageId, reply_markup: { inline_keyboard: [] } });", + '', + 'export const sendDocument = (chatId: number, filePath: string, caption?: string) =>', + " sendBinary('sendDocument', 'document', chatId, filePath, caption);", + 'export const sendPhoto = (chatId: number, filePath: string, caption?: string) =>', + " sendBinary('sendPhoto', 'photo', chatId, filePath, caption);", + '', + 'async function sendBinary(', + " method: 'sendDocument' | 'sendPhoto' | 'sendAudio' | 'sendVideo',", + " field: 'document' | 'photo' | 'audio' | 'video',", + ' chatId: number,', + ' filePath: string,', + ' caption?: string,', + '): Promise {', + ' const form = new FormData();', + " form.set('chat_id', String(chatId));", + ' if (caption) {', + " form.set('caption', mdToTelegramHtml(caption.slice(0, 1024)));", + " form.set('parse_mode', 'HTML');", + ' }', + ' form.set(field, new Blob([readFileSync(filePath)]), basename(filePath));', + " const res = await fetch(api(method), { method: 'POST', body: form });", + ' const data = (await res.json()) as { ok: boolean; description?: string };', + ' if (!data.ok) throw new Error(`telegram ${method} failed: ${data.description ?? res.status}`);', + '}', + '', + '/** Downloads a Telegram file by file_id. Returns the bytes and the path Telegram reports. */', + 'export async function downloadFile(fileId: string): Promise<{ bytes: Uint8Array; path: string }> {', + " const file = await tgCall<{ file_path: string }>('getFile', { file_id: fileId });", + ' const res = await fetch(`https://api.telegram.org/file/bot${token()}/${file.file_path}`);', + ' if (!res.ok) throw new Error(`telegram file download failed: ${res.status}`);', + ' return { bytes: new Uint8Array(await res.arrayBuffer()), path: file.file_path };', + '}', + '', +].join('\n'); + +const APPROVALS_TS = [ + '/**', + ' * "Always allow" rules: when the bot asks approval for a tool call, a saved', + ' * matching rule resolves it without pinging. Stored in .lousho/approvals.json.', + ' */', + "import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';", + "import { dirname } from 'node:path';", + '', + 'export interface ApprovalRule {', + ' tool: string;', + ' /** Optional substring an arg must contain (e.g. a command prefix). */', + ' scope?: string;', + '}', + '', + "const FILE = '.lousho/approvals.json';", + '', + 'export function loadRules(): ApprovalRule[] {', + ' try {', + " return JSON.parse(readFileSync(FILE, 'utf8'));", + ' } catch {', + ' return [];', + ' }', + '}', + '', + 'export function saveRules(rules: ApprovalRule[]): void {', + ' mkdirSync(dirname(FILE), { recursive: true });', + ' writeFileSync(FILE, JSON.stringify(rules, null, 2));', + '}', + '', + '/** True when a saved rule covers this tool call — auto-approve without asking. */', + 'export function findMatch(tool: string, args: Record): boolean {', + ' return loadRules().some((r) => {', + ' if (r.tool !== tool) return false;', + ' if (!r.scope) return true;', + ' return JSON.stringify(args).includes(r.scope);', + ' });', + '}', + '', + 'export function addRule(tool: string, args?: Record): ApprovalRule {', + " const scope = typeof args?.cmd === 'string' ? String(args.cmd).split(' ')[0] : undefined;", + ' const rule: ApprovalRule = { tool, ...(scope ? { scope } : {}) };', + ' saveRules([...loadRules(), rule]);', + ' return rule;', + '}', + '', + 'export function removeRule(index: number): ApprovalRule | undefined {', + ' const rules = loadRules();', + ' if (index < 0 || index >= rules.length) return undefined;', + ' const [removed] = rules.splice(index, 1);', + ' saveRules(rules);', + ' return removed;', + '}', + '', + 'export const describeRule = (r: ApprovalRule, i: number) => `${i + 1}. ${r.tool}${r.scope ? ` when args contain "${r.scope}"` : \' (all calls)\'}`;', + '', +].join('\n'); + +const BRIDGE_TS = [ + '/**', + ' * Long-polling Telegram bridge. One process per bot token — a second poller', + ' * makes Telegram return "Conflict: terminated by other getUpdates request".', + ' *', + ' * Access model:', + ' * - the FIRST chat that messages the bot becomes the owner (persisted),', + ' * - ALLOWED_CHATS="id1,id2" whitelists other chats,', + ' * - in groups (ALLOW_GROUPS=true) it answers @mentions and replies only,', + ' * - everyone else is logged and ignored — this bot can run tools on your', + ' * machine, strangers must not get turns.', + ' */', + "import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';", + "import { basename, dirname, join } from 'node:path';", + "import type { Agent, Session } from '@lousho/build-ai-agent';", + 'import {', + ' answerCallbackQuery,', + ' downloadFile,', + ' editMessageMarkup,', + ' getUpdates,', + ' sendChatAction,', + ' sendMessage,', + ' type TgCallbackQuery,', + ' type TgMessage,', + ' type TgUpdate,', + "} from './api.js';", + "import { addRule, describeRule, findMatch, loadRules, removeRule } from '../approvals.js';", + '', + "const STATE_FILE = '.lousho/state.json';", + "const INBOX = '.lousho/inbox';", + 'const ensureDir = (p: string) => (mkdirSync(p, { recursive: true }), p);', + 'const STARTED_AT = Date.now();', + '', + 'interface BridgeState {', + ' ownerChatId?: number;', + ' ownerUserId?: number;', + '}', + 'const loadState = (): BridgeState => {', + ' try {', + " return JSON.parse(readFileSync(STATE_FILE, 'utf8'));", + ' } catch {', + ' return {};', + ' }', + '};', + 'const saveState = (patch: BridgeState) => {', + ' ensureDir(dirname(STATE_FILE));', + ' writeFileSync(STATE_FILE, JSON.stringify({ ...loadState(), ...patch }));', + '};', + '', + "const botUsername = () => process.env.BOT_USERNAME ?? '';", + "const groupsAllowed = () => /^true$/i.test(process.env.ALLOW_GROUPS ?? '');", + 'const allowedChats = () =>', + " (process.env.ALLOWED_CHATS ?? '')", + " .split(',')", + ' .map((s) => Number(s.trim()))', + ' .filter((n) => Number.isFinite(n) && n !== 0);', + '', + '/** Who may talk in DMs: the owner or a whitelisted chat ID. */', + 'const chatAllowed = (chatId: number) => {', + ' const { ownerChatId } = loadState();', + ' return chatId === ownerChatId || allowedChats().includes(chatId);', + '};', + '', + '/** In groups the bot only speaks when @mentioned or replied to. */', + 'function groupTrigger(msg: TgMessage): boolean {', + " const text = msg.text ?? msg.caption ?? '';", + ' const name = botUsername();', + ' return (name.length > 0 && text.includes(`@${name}`)) || msg.reply_to_message?.from?.username === name;', + '}', + '', + 'const sessions = new Map();', + 'const sessionFor = (agent: Agent, chatId: number) => {', + ' let s = sessions.get(chatId);', + ' if (!s) {', + ' s = agent.session({ id: `tg-${chatId}` });', + ' sessions.set(chatId, s);', + ' }', + ' return s;', + '};', + '', + '// Serializes turns per chat — sessions are stateful, two concurrent turns corrupt order.', + 'const queues = new Map>();', + 'function enqueue(chatId: number, work: () => Promise) {', + ' const prev = queues.get(chatId) ?? Promise.resolve();', + ' const next = prev.then(work, work);', + ' queues.set(chatId, next.catch(() => {}));', + ' void next;', + '}', + '', + 'const pendingApprovals = new Map(); // chatId → approvalId', + '', + "async function resolvePending(agent: Agent, chatId: number, verdict: 'allow' | 'deny' | 'always'): Promise {", + ' const id = pendingApprovals.get(chatId);', + ' if (!id) return false;', + ' const pending = await agent.approvals.get(id);', + " if (verdict === 'always' && pending) {", + ' const rule = addRule(pending.toolName, pending.args as Record);', + ' await sendMessage(chatId, `got it — auto-allowing ${describeRule(rule, 0).slice(3)} from now on. /approvals manages these.`);', + ' }', + ' pendingApprovals.delete(chatId);', + " const result = await agent.approvals.resolve({ id, approved: verdict !== 'deny' });", + ' if (result?.text) await sendMessage(chatId, result.text);', + ' return true;', + '}', + '', + 'function approvalPrompt(toolName: string, args: Record): string {', + " const show = (v: unknown) => String(v ?? '').slice(0, 300);", + ' const bits = Object.entries(args).map(([k, v]) => `${k}: ${show(v)}`).join(\'\\n\');', + ' return `i want to run ${toolName}${bits ? `:\\n\\n${bits}` : \'\'}`;', + '}', + '', + "async function finishTurn(agent: Agent, chatId: number, result: { text?: string | null; finishReason?: string; approvalId?: string }) {", + ' if (result.text?.trim()) await sendMessage(chatId, result.text);', + " if (result.finishReason === 'awaiting-approval' && result.approvalId) {", + ' const pending = await agent.approvals.get(result.approvalId);', + " const toolName = pending?.toolName ?? '?';", + ' const args = (pending?.args ?? {}) as Record;', + ' if (findMatch(toolName, args)) {', + ' const r = await agent.approvals.resolve({ id: result.approvalId, approved: true });', + ' if (r?.text) await sendMessage(chatId, r.text);', + ' return;', + ' }', + ' pendingApprovals.set(chatId, result.approvalId);', + ' const id = result.approvalId;', + ' await sendMessage(chatId, `${approvalPrompt(toolName, args)}\\n\\nallow or deny?`, {', + ' reply_markup: {', + ' inline_keyboard: [[', + " { text: 'allow', callback_data: `ap:${id}:a` },", + " { text: 'deny', callback_data: `ap:${id}:d` },", + " { text: 'always', callback_data: `ap:${id}:w` },", + ' ]],', + ' },', + ' });', + ' }', + '}', + '', + 'async function runTurn(agent: Agent, chatId: number, input: Parameters[0]) {', + " void sendChatAction(chatId, 'typing').catch(() => {});", + ' return sessionFor(agent, chatId).send(input);', + '}', + '', + 'async function handleCommand(agent: Agent, chatId: number, text: string): Promise {', + ' const [cmd] = text.split(/\\s/, 1);', + ' switch (cmd) {', + " case '/start':", + ' await sendMessage(chatId, "online. first chat locks as owner — that\'s you.");', + ' return true;', + " case '/status': {", + ' const up = Math.round((Date.now() - STARTED_AT) / 60_000);', + ' const { ownerChatId } = loadState();', + ' await sendMessage(chatId, [', + " 'status: up',", + ' `up: ${up < 60 ? `${up} min` : `${Math.floor(up / 60)}h ${up % 60}m`}`,', + " `owner chat: ${ownerChatId ?? 'not locked yet'}`,", + " `allowed chats: ${allowedChats().join(', ') || 'none'}`,", + ' `pending approvals: ${pendingApprovals.size}`,', + " ].join('\\n'));", + ' return true;', + ' }', + " case '/approvals': {", + ' const rules = loadRules();', + ' await sendMessage(', + ' chatId,', + ' rules.length', + ' ? `auto-allow rules:\\n${rules.map(describeRule).join(\'\\n\')}\\n\\nreply /unallow to remove one`', + ' : \'no auto-allow rules yet — reply "always" when i ask for permission and i\\\'ll save one\',', + ' );', + ' return true;', + ' }', + " case '/unallow': {", + ' const n = Number(text.split(/\\s+/)[1]);', + ' const removed = Number.isFinite(n) ? removeRule(n - 1) : undefined;', + ' await sendMessage(chatId, removed ? `removed auto-allow for ${removed.tool}` : \'usage: /unallow \');', + ' return true;', + ' }', + " case '/help':", + " await sendMessage(chatId, '/start /status /approvals /unallow — otherwise just talk. Send photos and files, i can see them.');", + ' return true;', + ' default:', + ' return false;', + ' }', + '}', + '', + 'async function handleMessage(agent: Agent, msg: TgMessage): Promise {', + ' const chatId = msg.chat.id;', + " const isGroup = msg.chat.type === 'group' || msg.chat.type === 'supergroup';", + ' const { ownerChatId } = loadState();', + '', + ' // Owner lock: the first private chat claims the bot. Everyone else needs', + ' // ALLOWED_CHATS (DMs) or a group trigger.', + ' if (isGroup) {', + ' if (!groupsAllowed() || !groupTrigger(msg)) return;', + ' } else if (!ownerChatId) {', + ' saveState({ ownerChatId: chatId, ...(msg.from?.id ? { ownerUserId: msg.from.id } : {}) });', + ' } else if (!chatAllowed(chatId)) {', + ' console.log(`[bridge] ignored message from non-owner chat ${chatId}`);', + ' return;', + ' }', + '', + ' const session = sessionFor(agent, chatId);', + " const name = msg.from?.first_name ?? msg.from?.username ?? 'there';", + ' const from = isGroup ? `[${name} in a group chat]: ` : \'\';', + '', + ' try {', + ' if (msg.photo?.length) {', + ' const photo = msg.photo[msg.photo.length - 1]!;', + ' const { bytes, path } = await downloadFile(photo.file_id);', + ' const file = join(ensureDir(INBOX), `tg-${msg.message_id}-${basename(path)}`);', + ' writeFileSync(file, bytes);', + ' const result = await runTurn(agent, chatId, [', + " { type: 'text', text: `${from}${name} sent a photo (saved as ${basename(file)}). ${msg.caption ?? ''}` },", + " { type: 'image', image: bytes, mimeType: 'image/jpeg' },", + ' ]);', + ' await finishTurn(agent, chatId, result);', + ' return;', + ' }', + '', + ' const media = msg.document ?? msg.voice ?? msg.audio ?? msg.video ?? msg.video_note;', + ' if (media) {', + ' const { bytes, path } = await downloadFile(media.file_id);', + " const named = 'file_name' in media ? media.file_name : undefined;", + ' const file = join(ensureDir(INBOX), `${msg.message_id}-${basename(named ?? path)}`);', + ' writeFileSync(file, bytes);', + " const kind = msg.document ? 'a file' : msg.video ? 'a video' : msg.video_note ? 'a video note' : 'a voice message';", + ' const result = await runTurn(', + ' agent,', + ' chatId,', + ' `${from}${name} sent ${kind} "${basename(file)}" saved at ${file}. Caption: ${msg.caption ?? \'none\'}. Read it from disk if useful.`,', + ' );', + ' await finishTurn(agent, chatId, result);', + ' return;', + ' }', + '', + ' if (msg.location || msg.venue) {', + ' const lat = msg.location?.latitude ?? msg.venue!.location.latitude;', + ' const lng = msg.location?.longitude ?? msg.venue!.location.longitude;', + ' const result = await runTurn(agent, chatId, `${from}${name} shared a location: lat ${lat}, lng ${lng}.`);', + ' await finishTurn(agent, chatId, result);', + ' return;', + ' }', + '', + ' if (msg.text) {', + " if (msg.text.startsWith('/') && (await handleCommand(agent, chatId, msg.text))) return;", + ' const verdict = /^\\s*(allow|deny|always)\\s*$/i.exec(msg.text)?.[1]?.toLowerCase();', + " if (verdict && (await resolvePending(agent, chatId, verdict as 'allow' | 'deny' | 'always'))) return;", + ' enqueue(chatId, async () => {', + ' try {', + ' const result = await runTurn(agent, chatId, `${from}${msg.text!}`);', + ' await finishTurn(agent, chatId, result);', + ' } catch (error) {', + ' console.error(`[bridge] turn failed for chat ${chatId}: ${error instanceof Error ? error.message : error}`);', + " await sendMessage(chatId, 'something glitched on my end — resend?').catch(() => {});", + ' }', + ' });', + ' return;', + ' }', + ' } catch (error) {', + ' console.error(`[bridge] handler failed for chat ${chatId}: ${error instanceof Error ? error.message : error}`);', + " await sendMessage(chatId, 'something glitched on my end — resend?').catch(() => {});", + ' }', + '}', + '', + 'async function handleCallback(agent: Agent, cq: TgCallbackQuery): Promise {', + " const m = /^ap:([^:]+):([adw])$/.exec(cq.data ?? '');", + ' const chatId = cq.message?.chat.id;', + ' if (!m || !chatId) return;', + ' const [, approvalId, code] = m;', + ' const ownerUid = loadState().ownerUserId;', + ' if (ownerUid && cq.from.id !== ownerUid) {', + " await answerCallbackQuery(cq.id, 'not yours to decide').catch(() => {});", + ' return;', + ' }', + ' await answerCallbackQuery(cq.id).catch(() => {});', + ' await editMessageMarkup(chatId, cq.message!.message_id).catch(() => {});', + " const verdict = code === 'a' ? 'allow' : code === 'd' ? 'deny' : 'always';", + ' pendingApprovals.set(chatId, approvalId!);', + ' enqueue(chatId, async () => {', + ' try {', + ' await resolvePending(agent, chatId, verdict);', + ' } catch (error) {', + ' console.error(`[bridge] approval failed: ${error instanceof Error ? error.message : error}`);', + ' }', + ' });', + '}', + '', + 'export async function runBridge(agent: Agent): Promise {', + ' let offset = 0;', + ' const name = botUsername();', + " console.log(`[bridge] polling Telegram${name ? ` for @${name}` : ''} updates…`);", + ' for (;;) {', + ' let updates: TgUpdate[] = [];', + ' try {', + ' updates = await getUpdates(offset);', + ' } catch (error) {', + ' console.error(`[bridge] getUpdates failed: ${error instanceof Error ? error.message : error} — retrying in 5s`);', + ' await new Promise((r) => setTimeout(r, 5_000));', + ' continue;', + ' }', + ' for (const update of updates) {', + ' offset = Math.max(offset, update.update_id + 1);', + ' if (update.callback_query) void handleCallback(agent, update.callback_query);', + ' else if (update.message) void handleMessage(agent, update.message);', + ' }', + ' }', + '}', + '', +].join('\n'); + +const INDEX_TS = [ + "import { readFileSync } from 'node:fs';", + "import { createInterface } from 'node:readline/promises';", + "import { parseEnv } from 'node:util';", + "import { buildAgent } from './agent.js';", + "import { runBridge } from './telegram/bridge.js';", + '', + '// Load .env when there is one; variables already set in the environment win. The byte order mark', + '// some editors write (PowerShell\'s Set-Content -Encoding utf8) is dropped, or the first key is lost.', + 'try {', + " const vars = parseEnv(readFileSync('.env', 'utf8').replace(/^\\uFEFF/, ''));", + ' for (const [name, value] of Object.entries(vars)) {', + ' if (process.env[name] === undefined) process.env[name] = value;', + ' }', + '} catch (error) {', + " if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;", + '}', + '', + 'const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));', + "const telegram = process.argv.includes('--telegram');", + "const BOT = process.env.BOT_NAME ?? 'the bot';", + '', + 'const agent = buildAgent();', + '', + '// One-shot delegation: `tsx src/index.ts --task-file ` sends one message', + '// and exits. Approvals auto-resolve: the delegation itself was approved upstream.', + "const tfIdx = process.argv.indexOf('--task-file');", + 'if (tfIdx > -1) {', + " const task = readFileSync(process.argv[tfIdx + 1]!, 'utf8').trim();", + " const session = agent.session({ id: 'cli' });", + ' let result = await session.send(task);', + " while (result.finishReason === 'awaiting-approval' && result.approvalId) {", + ' result = await agent.approvals.resolve({ id: result.approvalId, approved: true });', + ' }', + ' console.log(`agent> ${result.text}`);', + ' process.exit(0);', + '}', + '', + 'if (telegram) {', + ' await runBridge(agent); // never returns — one poller per bot token', + '} else {', + ' const rl = createInterface({ input: process.stdin, output: process.stdout });', + " rl.on('close', () => process.exit(0));", + " const session = agent.session({ id: 'terminal' });", + ' console.log(`Chatting with ${BOT} (terminal mode; npm run telegram for the bot). Ctrl+C to quit.`);', + ' for (;;) {', + " const message = (await rl.question('you> ')).trim();", + ' if (!message) continue;', + ' try {', + ' const result = await session.send(message);', + ' console.log(`agent> ${result.text}`);', + " if (result.finishReason === 'awaiting-approval') {", + " console.log('(a tool call needs approval — auto-approved in terminal mode)');", + ' await agent.approvals.resolve({ id: result.approvalId!, approved: true });', + ' }', + ' } catch (error) {', + ' console.error(`error: ${errorText(error)}`);', + ' }', + ' }', + '}', + '', +].join('\n'); + +const MD_TEST_TS = [ + "import { describe, expect, it } from 'vitest';", + "import { mdToTelegramHtml } from './md.js';", + '', + "describe('mdToTelegramHtml', () => {", + " it('converts bold, italic, code, bullets and links', () => {", + " expect(mdToTelegramHtml('**bold** and *it* and `code`')).toBe('bold and it and code');", + " expect(mdToTelegramHtml('- item\\n* item2')).toBe('• item\\n• item2');", + " expect(mdToTelegramHtml('[x](https://y.co)')).toBe('x');", + ' });', + '', + " it('escapes raw HTML before marking up', () => {", + " expect(mdToTelegramHtml('a < b & **c**')).toBe('a < b & c');", + " expect(mdToTelegramHtml('