@sns-myagent/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/README.md +7 -8
  3. package/package.json +3 -3
  4. package/scripts/apply-pi-natives-patch.js +82 -56
  5. package/src/adapters/telegram/bot.ts +41 -1
  6. package/src/adapters/telegram/bridge.ts +186 -0
  7. package/src/adapters/telegram/handler.ts +138 -13
  8. package/src/adapters/telegram/index.ts +12 -2
  9. package/src/agents/__tests__/config.test.ts +79 -0
  10. package/src/agents/__tests__/resilience.test.ts +119 -0
  11. package/src/agents/__tests__/strategies.test.ts +83 -0
  12. package/src/agents/config.ts +367 -0
  13. package/src/agents/ensemble.ts +224 -0
  14. package/src/agents/resilience.ts +332 -0
  15. package/src/agents/strategies/best-of-n.ts +108 -0
  16. package/src/agents/strategies/consensus.ts +105 -0
  17. package/src/agents/strategies/critic.ts +131 -0
  18. package/src/agents/strategies/types.ts +68 -0
  19. package/src/async/__tests__/task-runner.test.ts +162 -0
  20. package/src/async/__tests__/task-store.test.ts +146 -0
  21. package/src/async/index.ts +28 -1
  22. package/src/async/notifier.ts +133 -0
  23. package/src/async/task-runner.ts +175 -0
  24. package/src/async/task-store.ts +170 -0
  25. package/src/async/types.ts +70 -0
  26. package/src/cli/entry.ts +3 -1
  27. package/src/cli/index.ts +69 -54
  28. package/src/config/index.ts +1 -1
  29. package/src/debug/index.ts +1 -1
  30. package/src/modes/components/welcome.ts +13 -6
  31. package/src/modes/controllers/event-controller.ts +1 -1
  32. package/src/modes/setup-wizard/scenes/splash.ts +1 -1
  33. package/src/session/agent-session.ts +1 -1
  34. package/src/slash-commands/builtin-registry.ts +63 -0
  35. package/src/slash-commands/helpers/task.ts +181 -0
  36. package/src/tbm/__tests__/tbm.test.ts +660 -0
  37. package/src/tbm/comm-modes.ts +165 -0
  38. package/src/tbm/config.ts +136 -0
  39. package/src/tbm/context-delta.ts +146 -0
  40. package/src/tbm/context-pyramid.ts +202 -0
  41. package/src/tbm/dashboard.ts +131 -0
  42. package/src/tbm/index.ts +247 -0
  43. package/src/tbm/lazy-skills.ts +182 -0
  44. package/src/tbm/response-cache.ts +220 -0
  45. package/src/tbm/tombstone.ts +189 -0
  46. package/src/tbm/tool-compress.ts +230 -0
  47. package/src/tools/ask.ts +1 -1
  48. package/src/tui/chat-blocks.ts +205 -0
  49. package/src/tui/chat-ui.ts +270 -0
  50. package/src/tui/code-cell.ts +90 -1
  51. package/src/tui/command-palette.ts +189 -0
  52. package/src/tui/index.ts +4 -0
  53. package/src/tui/splash.ts +130 -0
  54. package/src/ui/banner.ts +69 -29
  55. package/src/ui/colors.ts +24 -0
  56. package/src/ui/error-display.ts +130 -0
  57. package/src/ui/gradient.ts +104 -0
  58. package/src/ui/index.ts +15 -0
  59. package/src/ui/memory-toast.ts +102 -0
  60. package/src/ui/status-bar.ts +36 -30
  61. package/bin/snscoder.js +0 -98
package/CHANGELOG.md CHANGED
@@ -10,10 +10,21 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
10
10
  ## [Unreleased]
11
11
 
12
12
  ### Added
13
+ - **TBM (Token Budget Manager) fully implemented** — 7 core modules:
14
+ - Context Delta Cache: static/dynamic message splitting, ~40% token savings
15
+ - Context Pyramid: 5 compression levels (L0–L4), auto-demotion based on budget pressure
16
+ - Lazy Skill Loading: index-on-demand instead of bulk inject, ~85% savings
17
+ - Tool Output Compression: per-tool budgets, auto-truncation, JSON/HTML/markdown reducers
18
+ - Conversation Tombstoning: old messages → 10-line summaries, ~85% reduction
19
+ - Response Cache: exact + semantic (Jaccard bigram) match, configurable TTL
20
+ - Communication Modes: caveman/normal/verbose/auto with system prompt directives
21
+ - `/tokens` command: full TBM dashboard (session stats, cache hits, compression, pyramid level, cost estimate)
22
+ - `/mode` command: set caveman/normal/verbose/auto communication mode
23
+ - `TbmManager` singleton class coordinating all subsystems
13
24
  - Phase 1 scaffold complete: forked from Pi Agent, rebranded as `@sns-myagent/cli` with `snscoder` binary (commit `d1480eb`)
14
25
  - GitHub Actions CI/CD pipeline (`.github/workflows/ci.yml`) with staged verify → install → typecheck → lint → build → diagnose jobs
15
26
  - BYOK provider config system with `.sns-myagent/config.yaml` + env overrides
16
- - Real source-derived inventory of 58 built-in slash commands and 70+ tools documented in README
27
+ - Real source-derived inventory of 58 built-in slash commands and 30 built-in tools documented in README
17
28
  - `docs/memory.md` covering mnemopi / mnemosyne / mem0 / lcm backends
18
29
  - `docs/tbm.md` Token Budget Manager reference
19
30
  - `SECURITY.md` vulnerability reporting + security model
package/README.md CHANGED
@@ -18,12 +18,12 @@
18
18
  </p>
19
19
 
20
20
  <p align="center">
21
- <strong>BYOK coding agent CLI — 30 built-in tools, 58 slash commands, multi-provider LLM, memory, MCP, Telegram.</strong>
21
+ <strong>BYOK coding agent CLI — 30 built-in tools, 61 slash commands, multi-provider LLM, memory, MCP, Telegram.</strong>
22
22
  </p>
23
23
 
24
24
  <p align="center">
25
25
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License: MIT"></a>
26
- <img src="https://img.shields.io/badge/version-0.1.0-yellow?style=flat-square" alt="Version 0.1.0">
26
+ <img src="https://img.shields.io/badge/version-0.2.0-yellow?style=flat-square" alt="Version 0.2.0">
27
27
  <img src="https://img.shields.io/badge/bun-%3E%3D1.3.14-efbbf4?style=flat-square&logo=bun&logoColor=black" alt="Bun >= 1.3.14">
28
28
  <img src="https://img.shields.io/badge/typescript-5.x-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.x">
29
29
  </p>
@@ -60,9 +60,9 @@
60
60
  | Feature | Source |
61
61
  |---------|--------|
62
62
  | **30 built-in tools** | `src/tools/builtin-names.ts` |
63
- | **58 slash commands** | `src/slash-commands/builtin-registry.ts` |
63
+ | **61 slash commands** | `src/slash-commands/builtin-registry.ts` |
64
64
  | **Multi-provider LLM** | OpenAI, Anthropic, Ollama, custom endpoints via `@oh-my-pi/pi-ai` |
65
- | **4 memory backends** | mnemopi (default), hindsight, local, off — `src/memory-backend/resolve.ts` |
65
+ | **7 memory backends** | mnemopi (default), hindsight, mnemosyne, mem0, lcm, local, off — `src/memory-backend/resolve.ts` |
66
66
  | **MCP integration** | 22 source files in `src/mcp/` |
67
67
  | **Plan mode** | `src/plan-mode/` — agent plans before executing |
68
68
  | **Goal mode** | Autonomous objective with token budget and lifecycle |
@@ -117,11 +117,10 @@ All tool names come from `src/tools/builtin-names.ts`.
117
117
  | `debug` | Debugging assistance |
118
118
 
119
119
  ### Web & Browser
120
-
120
+ ### Web & Browser
121
121
  | Tool | Description |
122
122
  |------|-------------|
123
123
  | `web_search` | Web search via configured provider |
124
- | `search` | File/content search |
125
124
  | `search_tool_bm25` | BM25-based search tool |
126
125
  | `browser` | Headless browser automation (Puppeteer) |
127
126
  | `inspect_image` | Image analysis |
@@ -159,7 +158,7 @@ All tool names come from `src/tools/builtin-names.ts`.
159
158
 
160
159
  ## Slash Commands
161
160
 
162
- 58 built-in commands from `src/slash-commands/builtin-registry.ts`. Most useful:
161
+ 61 built-in commands from `src/slash-commands/builtin-registry.ts`. Most useful:
163
162
 
164
163
  ### Session & Navigation
165
164
 
@@ -401,7 +400,7 @@ Settings use dot-separated paths. Full schema in `src/config/settings-schema.ts`
401
400
  "eval.jl": true,
402
401
 
403
402
  // Memory
404
- "memory.backend": "mnemopi", // "mnemopi" | "hindsight" | "local" | "off"
403
+ "memory.backend": "mnemopi", // "mnemopi" | "hindsight" | "mnemosyne" | "mem0" | "lcm" | "local" | "off"
405
404
  "hindsight.scoping": "...",
406
405
 
407
406
  // MCP
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sns-myagent/cli",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "description": "SNS-MyAgent — Pi Agent size, full features. BYOK coding agent CLI.",
6
6
  "homepage": "https://github.com/Reihantt6/sns-myagent",
7
7
  "author": "Reihantt6",
@@ -33,7 +33,7 @@
33
33
  "scripts": {
34
34
  "fetch-binary": "node scripts/fetch-binary.mjs",
35
35
  "postinstall": "node scripts/fetch-binary.mjs && node scripts/apply-pi-natives-patch.js",
36
- "build": "bun build --compile --define process.env.PI_COMPILED='\"true\"' --define process.env.PKG_VERSION='\"0.1.0\"' --no-compile-autoload-bunfig --no-compile-autoload-dotenv --no-compile-autoload-tsconfig --no-compile-autoload-package-json --keep-names --outfile bin/snscoder-linux-x64 ./src/cli/entry.ts && cp bin/snscoder-linux-x64 bin/snscoder",
36
+ "build": "bun build --compile --define process.env.PI_COMPILED='\"true\"' --define process.env.PKG_VERSION='\"0.3.0\"' --no-compile-autoload-bunfig --no-compile-autoload-dotenv --no-compile-autoload-tsconfig --no-compile-autoload-package-json --keep-names --outfile bin/snscoder-linux-x64 ./src/cli/entry.ts && cp bin/snscoder-linux-x64 bin/snscoder",
37
37
  "check": "biome check . && bun run check:types",
38
38
  "check:types": "tsgo -p tsconfig.json --noEmit",
39
39
  "lint": "biome lint .",
@@ -42,7 +42,7 @@
42
42
  "fmt": "biome format --write . && bun run format-prompts",
43
43
  "format-prompts": "bun scripts/format-prompts.ts",
44
44
  "generate-docs-index": "bun scripts/generate-docs-index.ts --generate",
45
- "prepack": "bun scripts/generate-docs-index.ts --generate && (bun --cwd=../collab-web run build:tool-views 2>/dev/null || echo 'collab-web not found, skipping') && bun scripts/bundle-dist.ts || ( bun scripts/generate-docs-index.ts --reset; exit 1 )",
45
+ "prepack": "(bun scripts/generate-docs-index.ts --generate 2>/dev/null || echo 'docs index skipped') && (bun --cwd=../collab-web run build:tool-views 2>/dev/null || echo 'collab-web not found, skipping') && (bun scripts/bundle-dist.ts 2>/dev/null || echo 'bundle-dist skipped')",
46
46
  "postpack": "bun scripts/generate-docs-index.ts --reset",
47
47
  "bench:guard": "bun scripts/bench-guard.ts"
48
48
  },
@@ -6,6 +6,13 @@
6
6
  * .node addon is unavailable in a compiled Bun binary (no embed:native step).
7
7
  * This lets the binary start in "JS-only mode" instead of crashing.
8
8
  *
9
+ * The patch REPLACES the trailing `throw new Error("Failed to load ...")` block
10
+ * with an `if (ctx.isCompiledBinary) return noopProxy; else throw;` block. The
11
+ * earlier version of this patch only APPENDED a fallback after the throw — but
12
+ * `throw` is unconditional and aborts the function, so the fallback was dead
13
+ * code. This rewrite uses string replacement to convert the throw into a
14
+ * conditional re-throw.
15
+ *
9
16
  * Re-run: node scripts/apply-pi-natives-patch.js
10
17
  */
11
18
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
@@ -15,72 +22,91 @@ import { fileURLToPath } from "node:url";
15
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
23
  const root = join(__dirname, "..");
17
24
 
18
- const target = join(
19
- root,
20
- "node_modules",
21
- "@oh-my-pi",
22
- "pi-natives",
23
- "native",
24
- "loader-state.js",
25
- );
25
+ const target = join(root, "node_modules", "@oh-my-pi", "pi-natives", "native", "loader-state.js");
26
26
 
27
27
  if (!existsSync(target)) {
28
- console.warn("[patch] pi-natives not installed, skipping.");
29
- process.exit(0);
28
+ console.warn("[patch] pi-natives not installed, skipping.");
29
+ process.exit(0);
30
30
  }
31
31
 
32
32
  const src = readFileSync(target, "utf8");
33
33
 
34
- // Already patched?
35
- if (src.includes("JS-only fallback")) {
36
- console.log("[patch] loader-state.js already patched, skipping.");
37
- process.exit(0);
34
+ // Idempotent: already fully patched with the conditional version
35
+ if (src.includes("JS-only mode: native addon unavailable")) {
36
+ console.log("[patch] loader-state.js already fully patched, skipping.");
37
+ process.exit(0);
38
38
  }
39
39
 
40
- const marker = "If you need support for this platform, please open an issue.";
41
- const idx = src.indexOf(marker);
42
- if (idx === -1) {
43
- console.error("[patch] Could not locate injection point in loader-state.js");
44
- process.exit(1);
40
+ // Idempotent: old incomplete patch applied (has dead code after throw)
41
+ // restore by removing the dead append, then re-apply below.
42
+ let working = src;
43
+ const deadBlockMarker = "\t// --- JS-only fallback: return a no-op stub proxy so the binary can ---";
44
+ const deadIdx = working.indexOf(deadBlockMarker);
45
+ if (deadIdx !== -1) {
46
+ // Remove everything from the dead block marker to end of file or to next
47
+ // standalone `}\n\n}` (end of function). Conservative: remove marker + 60 lines.
48
+ const lines = working.split("\n");
49
+ const markerLine = lines.findIndex((l) => l.includes(deadBlockMarker));
50
+ // Find the trailing closing brace of the loadNative function (single `}\n` at col 0
51
+ // or `\n}` at start of line preceded by blank line)
52
+ let endLine = lines.length;
53
+ for (let i = markerLine + 1; i < lines.length; i++) {
54
+ if (lines[i] === "}") {
55
+ endLine = i + 1;
56
+ break;
57
+ }
58
+ }
59
+ lines.splice(markerLine, endLine - markerLine);
60
+ working = lines.join("\n");
61
+ console.log("[patch] Removed dead JS-only fallback block from prior incomplete patch.");
45
62
  }
46
63
 
47
- // Find the end of the "Unsupported platform" throw block (next closing brace + newline)
48
- const afterMarker = src.indexOf("\n\t);", idx) + 4; // after the );\n
49
-
50
- const patch = `
51
- \t// --- JS-only fallback: return a no-op stub proxy so the binary can ---
52
- \t// --- start up even when pi_natives .node files are unavailable. ---
53
- \tif (ctx.isCompiledBinary) {
54
- \t\tconst details = errors.map(error => \`- \${error}\`).join("\\n");
55
- \t\tstartupMarker(\`native:loadNative:jsOnlyFallback:\${details}\`);
56
- \t\ttry {
57
- \t\t\tfs.writeSync(2, \`[pi-natives] JS-only mode: native addon unavailable for \${ctx.addonLabel}\\n\`);
58
- \t\t\tfs.writeSync(2, \`[pi-natives] Tried:\\n\${details}\\n\`);
59
- \t\t\tfs.writeSync(2, \`[pi-natives] Native features (grep, pty, shell, clipboard, syntax highlighting, etc.) are disabled.\\n\`);
60
- \t\t} catch {
61
- \t\t\t// stderr unavailable
62
- \t\t}
64
+ // Locate the trailing throw new Error("Failed to load pi_natives...") block.
65
+ const throwStart = 'throw new Error(\n\t\t`Failed to load pi_natives native addon';
66
+ const throwIdx = working.indexOf(throwStart);
67
+ if (throwIdx === -1) {
68
+ console.error("[patch] Could not locate `throw new Error` block to replace.");
69
+ process.exit(1);
70
+ }
71
+ const throwEnd = working.indexOf("\n\t);", throwIdx) + 4;
72
+ const oldThrowBlock = working.substring(throwIdx, throwEnd);
63
73
 
64
- \t\tconst noop = new Proxy(function () {}, {
65
- \t\t\tget(_target, prop) {
66
- \t\t\t\tif (prop === Symbol.toPrimitive) return () => "";
67
- \t\t\t\tif (prop === Symbol.toStringTag) return "NativeStub";
68
- \t\t\t\tif (prop === "then") return undefined;
69
- \t\t\t\tif (prop === Symbol.iterator) return function* () {};
70
- \t\t\t\tif (prop === "length") return 0;
71
- \t\t\t\treturn noop;
72
- \t\t\t},
73
- \t\t\tapply(_target, _thisArg, args) {
74
- \t\t\t\treturn noop;
75
- \t\t\t},
76
- \t\t\tconstruct(_target, _args) {
77
- \t\t\t\treturn noop;
78
- \t\t\t},
79
- \t\t});
80
- \t\treturn noop;
81
- \t}
82
- `;
74
+ // New conditional block: when compiled binary, return noop Proxy stub;
75
+ // otherwise re-throw the original error.
76
+ const replacement = [
77
+ "\t// --- JS-only fallback (patched by scripts/apply-pi-natives-patch.js): ---",
78
+ "\t// --- when compiled binary can't load native .node, return a no-op ---",
79
+ "\t// --- Proxy stub so the binary still starts. Plain Node mode keeps ---",
80
+ "\t// --- the original throw so missing-native is a hard failure there. ---",
81
+ "\tif (ctx.isCompiledBinary) {",
82
+ "\t\tconst fallbackDetails = errors.map(error => `- ${error}`).join(\"\\n\");",
83
+ "\t\tstartupMarker(`native:loadNative:jsOnlyFallback:${fallbackDetails}`);",
84
+ "\t\ttry {",
85
+ "\t\t\tfs.writeSync(2, `[pi-natives] JS-only mode: native addon unavailable for ${ctx.addonLabel}\\n`);",
86
+ "\t\t\tfs.writeSync(2, `[pi-natives] Native features (grep, pty, shell, clipboard, syntax highlighting, etc.) are disabled.\\n`);",
87
+ "\t\t} catch {",
88
+ "\t\t\t// stderr unavailable",
89
+ "\t\t}",
90
+ "",
91
+ "\t\tconst noop = new Proxy(function () {}, {",
92
+ "\t\t\tget(_target, prop) {",
93
+ "\t\t\t\tif (prop === Symbol.toPrimitive) return () => \"\";",
94
+ "\t\t\t\tif (prop === Symbol.toStringTag) return \"NativeStub\";",
95
+ "\t\t\t\tif (prop === \"then\") return undefined;",
96
+ "\t\t\t\tif (prop === Symbol.iterator) return function* () {};",
97
+ "\t\t\t\tif (prop === \"length\") return 0;",
98
+ "\t\t\t\treturn noop;",
99
+ "\t\t\t},",
100
+ "\t\t\tapply(_target, _thisArg, _args) { return noop; },",
101
+ "\t\t\tconstruct(_target, _args) { return noop; },",
102
+ "\t\t});",
103
+ "\t\treturn noop;",
104
+ "\t}",
105
+ "\tthrow new Error(",
106
+ "\t\t`Failed to load pi_natives native addon for ${ctx.addonLabel}.\\n\\nTried:\\n${details}\\n\\n${buildHelpMessage(ctx)}`,",
107
+ "\t);",
108
+ ].join("\n");
83
109
 
84
- const patched = src.slice(0, afterMarker) + patch + src.slice(afterMarker);
110
+ const patched = working.replace(oldThrowBlock, replacement);
85
111
  writeFileSync(target, patched, "utf8");
86
- console.log("[patch] Applied pi-natives JS-only fallback patch.");
112
+ console.log("[patch] Applied pi-natives JS-only fallback patch (conditional version).");
@@ -10,8 +10,11 @@
10
10
  * drives an `AgentRegistry` session — see `handler.ts` for the stub.
11
11
  */
12
12
 
13
- import { Bot, type Context } from "grammy";
13
+ import { Bot, type Context, InputFile } from "grammy";
14
14
  import { logger } from "@oh-my-pi/pi-utils";
15
+ import * as fs from "node:fs/promises";
16
+ import * as path from "node:path";
17
+ import * as os from "node:os";
15
18
  import { markdownToTelegram } from "./format";
16
19
  import { parseMessage, resolveReply, type HandleContext } from "./handler";
17
20
 
@@ -20,6 +23,10 @@ export interface TelegramBotOptions {
20
23
  readonly token: string;
21
24
  /** Optional agent hook. When omitted, the handler echoes with a marker. */
22
25
  forwardToAgent?: (text: string, sessionKey: string) => Promise<string>;
26
+ /** Reset per-chat session (bridge). */
27
+ resetChatSession?: (chatId: string) => boolean;
28
+ /** Get bridge stats. */
29
+ getBridgeStats?: () => { activeSessions: number; chatIds: string[] };
23
30
  /** Per\\-chat session store. Injectable for tests; defaults to a Map. */
24
31
  sessionStore?: Map<string, number>;
25
32
  /** Logger overrides. */
@@ -119,6 +126,36 @@ export class TelegramBot {
119
126
  return sent.message_id;
120
127
  }
121
128
 
129
+ /** Send a file/document to a chat. */
130
+ async sendDocument(chatId: number, filePath: string, caption?: string): Promise<number> {
131
+ const input = new InputFile(filePath);
132
+ const sent = await this.#bot.api.sendDocument(chatId, input, {
133
+ caption,
134
+ ...(caption ? { parse_mode: "MarkdownV2" as const } : {}),
135
+ });
136
+ return sent.message_id;
137
+ }
138
+
139
+ /**
140
+ * Download a Telegram file to local temp dir. Returns the local path.
141
+ * Used by handler.ts `case "file"` via the HandleContext callback.
142
+ */
143
+ async downloadFile(fileId: string, fileName: string): Promise<string> {
144
+ const downloadDir = path.join(os.tmpdir(), "sns-telegram-files");
145
+ await fs.mkdir(downloadDir, { recursive: true });
146
+ const file = await this.#bot.api.getFile(fileId);
147
+ if (!file.file_path) throw new Error("Telegram returned no file_path");
148
+ const fileUrl = `https://api.telegram.org/file/bot${this.#bot.token}/${file.file_path}`;
149
+ const resp = await fetch(fileUrl);
150
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} downloading file`);
151
+ const buffer = Buffer.from(await resp.arrayBuffer());
152
+ const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
153
+ const localPath = path.join(downloadDir, `${Date.now()}_${safeName}`);
154
+ await fs.writeFile(localPath, buffer);
155
+ logger.debug("telegram: file downloaded", { fileId, localPath, size: buffer.length });
156
+ return localPath;
157
+ }
158
+
122
159
  /** Test\\-only: simulate an inbound update without going through Telegram. */
123
160
  async __handleUpdateForTests(ctx: Context): Promise<void> {
124
161
  await this.#onMessage(ctx);
@@ -143,6 +180,9 @@ export class TelegramBot {
143
180
  const handleCtx: HandleContext = {
144
181
  sessionKey,
145
182
  forwardToAgent: this.#opts.forwardToAgent,
183
+ resetChatSession: this.#opts.resetChatSession,
184
+ getBridgeStats: this.#opts.getBridgeStats,
185
+ downloadFile: (fileId: string, fileName: string) => this.downloadFile(fileId, fileName),
146
186
  };
147
187
  try {
148
188
  const reply = await resolveReply(parsed, handleCtx);
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Telegram ↔ Agent bridge.
3
+ *
4
+ * Creates per-chat agent sessions via the SDK `createAgentSession()` API.
5
+ * Each chat ID gets its own persistent session (auto-created on first message).
6
+ *
7
+ * The `createForwardToAgent()` factory returns a `ForwardToAgent` function
8
+ * that the TelegramBot handler calls for every `/chat` and freeform message.
9
+ */
10
+
11
+ import { createAgentSession, type CreateAgentSessionResult } from "../../sdk.js";
12
+ import type { AgentSession } from "../../session/agent-session.js";
13
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
14
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
15
+ import { loadConfig } from "../../config/loader.js";
16
+ import { initializeExtensions } from "../../modes/runtime-init.js";
17
+
18
+ /** Internal session record. */
19
+ interface ChatRecord {
20
+ result: CreateAgentSessionResult;
21
+ session: AgentSession;
22
+ createdAt: number;
23
+ }
24
+
25
+ /** ForwardToAgent signature — matches handler.ts ForwardToAgent. */
26
+ export type ForwardToAgentFn = (
27
+ chatId: string,
28
+ userId: string,
29
+ text: string,
30
+ ) => Promise<string>;
31
+
32
+ /**
33
+ * TTL cache: chatId → ChatRecord.
34
+ * Sessions auto-expire after `maxIdleMs` of inactivity.
35
+ */
36
+ class SessionCache {
37
+ readonly #cache = new Map<string, ChatRecord>();
38
+ readonly #maxIdleMs: number;
39
+
40
+ constructor(maxIdleMs = 30 * 60 * 1000) {
41
+ this.#maxIdleMs = maxIdleMs;
42
+ }
43
+
44
+ get(chatId: string): ChatRecord | undefined {
45
+ const rec = this.#cache.get(chatId);
46
+ if (!rec) return undefined;
47
+ // Check idle expiry
48
+ if (Date.now() - rec.createdAt > this.#maxIdleMs) {
49
+ this.#cache.delete(chatId);
50
+ return undefined;
51
+ }
52
+ return rec;
53
+ }
54
+
55
+ set(chatId: string, record: ChatRecord): void {
56
+ this.#cache.set(chatId, record);
57
+ }
58
+
59
+ delete(chatId: string): void {
60
+ this.#cache.delete(chatId);
61
+ }
62
+
63
+ clear(): void {
64
+ this.#cache.clear();
65
+ }
66
+
67
+ get size(): number {
68
+ return this.#cache.size;
69
+ }
70
+
71
+ keys(): IterableIterator<string> {
72
+ return this.#cache.keys();
73
+ }
74
+ }
75
+
76
+ const sessionCache = new SessionCache();
77
+
78
+ /**
79
+ * Extract the assistant's text response from a session after prompt().
80
+ */
81
+ function extractAssistantText(session: AgentSession): string {
82
+ const msgs = session.state.messages;
83
+ for (let i = msgs.length - 1; i >= 0; i--) {
84
+ const msg = msgs[i];
85
+ if (msg && msg.role === "assistant") {
86
+ const asst = msg as AssistantMessage;
87
+ if (asst.stopReason === "error" || asst.stopReason === "aborted") {
88
+ return `⚠️ ${sanitizeText(asst.errorMessage || `Request ${asst.stopReason}`)}`;
89
+ }
90
+ const parts: string[] = [];
91
+ for (const c of asst.content) {
92
+ if (c.type === "text") parts.push(sanitizeText(c.text));
93
+ }
94
+ if (parts.length > 0) return parts.join("\n");
95
+ }
96
+ }
97
+ return "⚠️ No response from agent.";
98
+ }
99
+
100
+ /**
101
+ * Create or retrieve an agent session for a chatId.
102
+ */
103
+ async function getOrCreateSession(chatId: string): Promise<AgentSession> {
104
+ const existing = sessionCache.get(chatId);
105
+ if (existing) return existing.session;
106
+
107
+ const cfg = loadConfig();
108
+ const cwd = process.cwd();
109
+
110
+ const result = await createAgentSession({
111
+ cwd,
112
+ autoApprove: true,
113
+ });
114
+
115
+ const session = result.session;
116
+
117
+ // Subscribe for persistence (required by print-mode pattern)
118
+ session.subscribe(() => {});
119
+
120
+ // Initialize extensions (lightweight, no TUI)
121
+ await initializeExtensions(session, {
122
+ reportSendError: (_action, err) => {
123
+ console.error(`[telegram-bridge] extension error: ${err.message}`);
124
+ },
125
+ reportRuntimeError: (err) => {
126
+ console.error(`[telegram-bridge] runtime error: ${err.error}`);
127
+ },
128
+ });
129
+
130
+ sessionCache.set(chatId, {
131
+ result,
132
+ session,
133
+ createdAt: Date.now(),
134
+ });
135
+
136
+ console.error(`[telegram-bridge] created session for chat=${chatId} (cache=${sessionCache.size})`);
137
+ return session;
138
+ }
139
+
140
+ /**
141
+ * Create the forwardToAgent function that the TelegramBot handler calls.
142
+ *
143
+ * Usage in CLI:
144
+ * const forwardToAgent = createForwardToAgent();
145
+ * startTelegramAdapter(token, { autostart: true, forwardToAgent });
146
+ */
147
+ export function createForwardToAgent(): ForwardToAgentFn {
148
+ return async (chatId, _userId, text): Promise<string> => {
149
+ try {
150
+ const session = await getOrCreateSession(chatId);
151
+
152
+ // session.prompt() returns true (agent processed) or false (slash-command consumed)
153
+ const handled = await session.prompt(text);
154
+
155
+ if (!handled) {
156
+ // Slash command consumed it — no LLM response to return
157
+ return "✅ Command processed.";
158
+ }
159
+
160
+ return extractAssistantText(session);
161
+ } catch (err) {
162
+ const msg = err instanceof Error ? err.message : String(err);
163
+ console.error(`[telegram-bridge] forwardToAgent error: ${msg}`);
164
+ return `⚠️ Agent error: ${msg}`;
165
+ }
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Reset a specific chat session (for /reset command).
171
+ */
172
+ export function resetChatSession(chatId: string): boolean {
173
+ const had = sessionCache.get(chatId) !== undefined;
174
+ sessionCache.delete(chatId);
175
+ return had;
176
+ }
177
+
178
+ /**
179
+ * Get bridge stats (for /status).
180
+ */
181
+ export function getBridgeStats(): { activeSessions: number; chatIds: string[] } {
182
+ return {
183
+ activeSessions: sessionCache.size,
184
+ chatIds: [...sessionCache.keys()],
185
+ };
186
+ }