@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.
- package/CHANGELOG.md +12 -1
- package/README.md +7 -8
- package/package.json +3 -3
- package/scripts/apply-pi-natives-patch.js +82 -56
- package/src/adapters/telegram/bot.ts +41 -1
- package/src/adapters/telegram/bridge.ts +186 -0
- package/src/adapters/telegram/handler.ts +138 -13
- package/src/adapters/telegram/index.ts +12 -2
- package/src/agents/__tests__/config.test.ts +79 -0
- package/src/agents/__tests__/resilience.test.ts +119 -0
- package/src/agents/__tests__/strategies.test.ts +83 -0
- package/src/agents/config.ts +367 -0
- package/src/agents/ensemble.ts +224 -0
- package/src/agents/resilience.ts +332 -0
- package/src/agents/strategies/best-of-n.ts +108 -0
- package/src/agents/strategies/consensus.ts +105 -0
- package/src/agents/strategies/critic.ts +131 -0
- package/src/agents/strategies/types.ts +68 -0
- package/src/async/__tests__/task-runner.test.ts +162 -0
- package/src/async/__tests__/task-store.test.ts +146 -0
- package/src/async/index.ts +28 -1
- package/src/async/notifier.ts +133 -0
- package/src/async/task-runner.ts +175 -0
- package/src/async/task-store.ts +170 -0
- package/src/async/types.ts +70 -0
- package/src/cli/entry.ts +3 -1
- package/src/cli/index.ts +69 -54
- package/src/config/index.ts +1 -1
- package/src/debug/index.ts +1 -1
- package/src/modes/components/welcome.ts +13 -6
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/setup-wizard/scenes/splash.ts +1 -1
- package/src/session/agent-session.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +63 -0
- package/src/slash-commands/helpers/task.ts +181 -0
- package/src/tbm/__tests__/tbm.test.ts +660 -0
- package/src/tbm/comm-modes.ts +165 -0
- package/src/tbm/config.ts +136 -0
- package/src/tbm/context-delta.ts +146 -0
- package/src/tbm/context-pyramid.ts +202 -0
- package/src/tbm/dashboard.ts +131 -0
- package/src/tbm/index.ts +247 -0
- package/src/tbm/lazy-skills.ts +182 -0
- package/src/tbm/response-cache.ts +220 -0
- package/src/tbm/tombstone.ts +189 -0
- package/src/tbm/tool-compress.ts +230 -0
- package/src/tools/ask.ts +1 -1
- package/src/tui/chat-blocks.ts +205 -0
- package/src/tui/chat-ui.ts +270 -0
- package/src/tui/code-cell.ts +90 -1
- package/src/tui/command-palette.ts +189 -0
- package/src/tui/index.ts +4 -0
- package/src/tui/splash.ts +130 -0
- package/src/ui/banner.ts +69 -29
- package/src/ui/colors.ts +24 -0
- package/src/ui/error-display.ts +130 -0
- package/src/ui/gradient.ts +104 -0
- package/src/ui/index.ts +15 -0
- package/src/ui/memory-toast.ts +102 -0
- package/src/ui/status-bar.ts +36 -30
- 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
|
|
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,
|
|
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.
|
|
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
|
-
| **
|
|
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
|
-
| **
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 ||
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
//
|
|
35
|
-
if (src.includes("JS-only
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
//
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
\t
|
|
68
|
-
\t
|
|
69
|
-
\t
|
|
70
|
-
\t
|
|
71
|
-
\
|
|
72
|
-
\t\
|
|
73
|
-
\t\
|
|
74
|
-
\t\
|
|
75
|
-
\t\t\
|
|
76
|
-
\t\t\
|
|
77
|
-
\t\t
|
|
78
|
-
\t\t\t
|
|
79
|
-
\t\t}
|
|
80
|
-
|
|
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 =
|
|
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
|
+
}
|