tangram-ai 0.0.1

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 (72) hide show
  1. package/.github/workflows/ci.yml +34 -0
  2. package/.github/workflows/release.yml +49 -0
  3. package/README.md +302 -0
  4. package/TODO.md +5 -0
  5. package/config.example.json +66 -0
  6. package/dist/channels/telegram.js +214 -0
  7. package/dist/config/load.js +62 -0
  8. package/dist/config/schema.js +147 -0
  9. package/dist/deploy/githubRelease.js +34 -0
  10. package/dist/deploy/paths.js +16 -0
  11. package/dist/deploy/systemdUser.js +134 -0
  12. package/dist/deploy/upgrade.js +144 -0
  13. package/dist/graph/agentGraph.js +351 -0
  14. package/dist/index.js +267 -0
  15. package/dist/memory/store.js +168 -0
  16. package/dist/onboard/files.js +100 -0
  17. package/dist/onboard/prompts.js +77 -0
  18. package/dist/onboard/run.js +47 -0
  19. package/dist/onboard/templates.js +94 -0
  20. package/dist/providers/anthropicMessages.js +148 -0
  21. package/dist/providers/openaiResponses.js +172 -0
  22. package/dist/providers/registry.js +20 -0
  23. package/dist/providers/types.js +1 -0
  24. package/dist/scheduler/cronRunner.js +100 -0
  25. package/dist/scheduler/cronStore.js +167 -0
  26. package/dist/scheduler/heartbeat.js +77 -0
  27. package/dist/scheduler/timezone.js +134 -0
  28. package/dist/session/locks.js +14 -0
  29. package/dist/skills/catalog.js +137 -0
  30. package/dist/skills/runtime.js +251 -0
  31. package/dist/tools/bashTool.js +152 -0
  32. package/dist/tools/cronTools.js +345 -0
  33. package/dist/tools/fileTools.js +257 -0
  34. package/dist/tools/memoryTools.js +88 -0
  35. package/dist/utils/logger.js +48 -0
  36. package/dist/utils/path.js +11 -0
  37. package/dist/utils/telegram.js +25 -0
  38. package/package.json +44 -0
  39. package/scripts/bump-version.mjs +44 -0
  40. package/scripts/prepare-release.mjs +35 -0
  41. package/src/channels/telegram.ts +258 -0
  42. package/src/config/load.ts +77 -0
  43. package/src/config/schema.ts +154 -0
  44. package/src/deploy/paths.ts +27 -0
  45. package/src/deploy/systemdUser.ts +169 -0
  46. package/src/deploy/upgrade.ts +189 -0
  47. package/src/graph/agentGraph.ts +471 -0
  48. package/src/index.ts +335 -0
  49. package/src/memory/store.ts +190 -0
  50. package/src/onboard/files.ts +127 -0
  51. package/src/onboard/prompts.ts +123 -0
  52. package/src/onboard/run.ts +57 -0
  53. package/src/onboard/templates.ts +105 -0
  54. package/src/providers/anthropicMessages.ts +189 -0
  55. package/src/providers/openaiResponses.ts +194 -0
  56. package/src/providers/registry.ts +24 -0
  57. package/src/providers/types.ts +46 -0
  58. package/src/scheduler/cronRunner.ts +117 -0
  59. package/src/scheduler/cronStore.ts +222 -0
  60. package/src/scheduler/heartbeat.ts +92 -0
  61. package/src/scheduler/timezone.ts +186 -0
  62. package/src/session/locks.ts +16 -0
  63. package/src/skills/catalog.ts +148 -0
  64. package/src/skills/runtime.ts +294 -0
  65. package/src/tools/bashTool.ts +180 -0
  66. package/src/tools/cronTools.ts +415 -0
  67. package/src/tools/fileTools.ts +280 -0
  68. package/src/tools/memoryTools.ts +99 -0
  69. package/src/utils/logger.ts +56 -0
  70. package/src/utils/path.ts +9 -0
  71. package/src/utils/telegram.ts +26 -0
  72. package/tsconfig.json +19 -0
@@ -0,0 +1,34 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ pull_request:
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - name: Checkout
15
+ uses: actions/checkout@v4
16
+
17
+ - name: Setup Node.js
18
+ uses: actions/setup-node@v4
19
+ with:
20
+ node-version: 22
21
+ cache: npm
22
+
23
+ - name: Install dependencies
24
+ run: npm ci
25
+
26
+ - name: Lint
27
+ run: npm run lint
28
+
29
+ - name: Test
30
+ run: npm test
31
+
32
+ - name: Build
33
+ run: npm run build
34
+
@@ -0,0 +1,49 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+ workflow_dispatch:
8
+ inputs:
9
+ tag:
10
+ description: "Existing git tag to release (example: v1.2.3)"
11
+ required: true
12
+ type: string
13
+
14
+ permissions:
15
+ contents: write
16
+
17
+ jobs:
18
+ release:
19
+ runs-on: ubuntu-latest
20
+ env:
21
+ RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
22
+
23
+ steps:
24
+ - name: Checkout
25
+ uses: actions/checkout@v4
26
+
27
+ - name: Setup Node.js
28
+ uses: actions/setup-node@v4
29
+ with:
30
+ node-version: 22
31
+ cache: npm
32
+
33
+ - name: Install dependencies
34
+ run: npm ci
35
+
36
+ - name: Build
37
+ run: npm run build
38
+
39
+ - name: Pack artifact
40
+ run: |
41
+ tar -czf tangram-ai-${RELEASE_TAG}.tar.gz dist package.json package-lock.json README.md
42
+
43
+ - name: Create GitHub Release
44
+ uses: softprops/action-gh-release@v2
45
+ with:
46
+ tag_name: ${{ env.RELEASE_TAG }}
47
+ files: |
48
+ tangram-ai-${{ env.RELEASE_TAG }}.tar.gz
49
+ generate_release_notes: true
package/README.md ADDED
@@ -0,0 +1,302 @@
1
+ # tangram2 (MVP)
2
+
3
+ Minimal Telegram chatbot built with **TypeScript + LangGraph**, with **multi-provider config** and **OpenAI Responses API** as the default provider.
4
+
5
+ ## Quick Start
6
+
7
+ 1) Install deps
8
+
9
+ ```bash
10
+ npm i
11
+ ```
12
+
13
+ 2) Create config
14
+
15
+ ```bash
16
+ mkdir -p ~/.tangram2 && cp config.example.json ~/.tangram2/config.json
17
+ ```
18
+
19
+ Edit `~/.tangram2/config.json` and set:
20
+ - `channels.telegram.token`
21
+ - `providers.<yourProviderKey>.apiKey`
22
+ - optionally `providers.<yourProviderKey>.baseUrl`
23
+
24
+ Supported providers:
25
+ - `openai` (Responses API)
26
+ - `anthropic` (Messages API, supports custom `baseUrl`)
27
+
28
+ 3) Run
29
+
30
+ ```bash
31
+ npm run gateway -- --verbose
32
+ npm run onboard
33
+ npm run gateway -- status
34
+ ```
35
+
36
+ ## Deploy & Upgrade
37
+
38
+ Deployment bootstrap is part of `onboard`.
39
+
40
+ ```bash
41
+ npm run onboard
42
+ ```
43
+
44
+ During onboarding, the wizard can optionally install/start a user-level `systemd` service.
45
+
46
+ Gateway service operations:
47
+
48
+ ```bash
49
+ npm run gateway -- status
50
+ npm run gateway -- stop
51
+ npm run gateway -- restart
52
+ ```
53
+
54
+ Upgrade and rollback:
55
+
56
+ ```bash
57
+ npm run upgrade -- --dry-run
58
+ npm run upgrade -- --version v0.0.1
59
+ npm run rollback -- --to v0.0.1
60
+ ```
61
+
62
+ Notes:
63
+ - `upgrade` uses global npm install (`npm install -g tangram-ai@...`) and auto-restarts service
64
+ - use `--no-restart` to skip restart
65
+ - if `systemd --user` is unavailable, run foreground mode: `npm run gateway -- --verbose`
66
+
67
+ ## Release Workflow
68
+
69
+ This repo includes a baseline release pipeline:
70
+
71
+ - CI workflow: `.github/workflows/ci.yml`
72
+ - runs on push/PR
73
+ - executes `npm ci`, `npm run lint`, `npm test`, `npm run build`
74
+ - Release workflow: `.github/workflows/release.yml`
75
+ - triggers on tag push `v*`
76
+ - builds project and uploads tarball asset to GitHub Release
77
+
78
+ ### Local release commands
79
+
80
+ - Bump version only:
81
+
82
+ ```bash
83
+ npm run release:patch
84
+ npm run release:minor
85
+ npm run release:major
86
+ ```
87
+
88
+ - Prepare a full release (bump version + build + commit + tag):
89
+
90
+ ```bash
91
+ npm run release:prepare:patch
92
+ npm run release:prepare:minor
93
+ npm run release:prepare:major
94
+ ```
95
+
96
+ After `release:prepare:*` completes, push branch and tag:
97
+
98
+ ```bash
99
+ git push origin master
100
+ git push origin vX.Y.Z
101
+ ```
102
+
103
+ Pushing the tag triggers GitHub Actions release creation automatically.
104
+
105
+ ## Onboard Wizard
106
+
107
+ Run `npm run onboard` for an interactive setup that:
108
+ - asks for provider/API/Telegram settings
109
+ - applies developer-default permissions (shell enabled but restricted)
110
+ - initializes `~/.tangram2` directories and baseline files
111
+ - initializes runtime directories under `~/.tangram2/app`
112
+ - can install/start user-level `systemd` service
113
+ - handles existing files one by one (`overwrite` / `skip` / `backup then overwrite`)
114
+
115
+ ## Memory (Shared)
116
+
117
+ Shared memory lives under the configured workspace directory (default: `~/.tangram2/workspace`):
118
+ - Long-term memory: `memory/memory.md`
119
+ - Daily notes: `memory/YYYY-MM-DD.md`
120
+
121
+ Telegram commands:
122
+ - `/memory` show memory context
123
+ - `/remember <text>` append to today's daily memory
124
+ - `/remember_long <text>` append to long-term memory
125
+
126
+ Telegram UX behaviors:
127
+ - bot sends `typing` action while processing
128
+ - during tool-calling loops, progress hints may be sent as temporary `⏳ ...` updates (controlled by `channels.telegram.progressUpdates`, default `true`)
129
+
130
+ ## Memory Tools (LLM)
131
+
132
+ The agent exposes function tools to the model (via OpenAI Responses API):
133
+ - `memory_search` search shared memory files
134
+ - `memory_write` append to shared memory files
135
+ - `file_read` read local skill/content files from allowed roots
136
+ - `file_write` write local files under allowed roots
137
+ - `file_edit` edit files by targeted text replacement
138
+ - `bash` execute CLI commands when `agents.defaults.shell.enabled=true`
139
+ - `cron_schedule` schedule one-time/repeating callbacks
140
+ - `cron_list` list scheduled callbacks
141
+ - `cron_cancel` cancel scheduled callbacks
142
+
143
+ The LangGraph workflow also runs a post-reply "memory reflection" node that can automatically summarize the latest turn into memory using a strict JSON format prompt.
144
+
145
+ ## Skills Metadata
146
+
147
+ The runtime discovers local skills and injects a compact skills list into the model instructions, so the model can decide which skill to open/use.
148
+
149
+ By default it scans:
150
+ - `~/.tangram2/skills`
151
+
152
+ You can customize via `agents.defaults.skills`:
153
+
154
+ ```json
155
+ {
156
+ "agents": {
157
+ "defaults": {
158
+ "skills": {
159
+ "enabled": true,
160
+ "roots": [
161
+ "~/.tangram2/skills"
162
+ ],
163
+ "maxSkills": 40,
164
+ "hotReload": {
165
+ "enabled": true,
166
+ "debounceMs": 800,
167
+ "logDiff": true
168
+ }
169
+ }
170
+ }
171
+ }
172
+ }
173
+ ```
174
+
175
+ Hot reload behavior:
176
+ - skill directory/file changes are detected with filesystem watchers
177
+ - reload is debounced (`hotReload.debounceMs`) to avoid noisy rapid rescans
178
+ - updates apply globally to the next LLM execution without restarting gateway
179
+ - when `hotReload.logDiff=true`, gateway logs added/removed/changed skills
180
+
181
+ `file_read` / `file_write` / `file_edit` are path-restricted to these resolved skill roots.
182
+
183
+ ## Shell Tool (Optional)
184
+
185
+ Enable shell execution only when needed:
186
+
187
+ ```json
188
+ {
189
+ "agents": {
190
+ "defaults": {
191
+ "shell": {
192
+ "enabled": true,
193
+ "fullAccess": false,
194
+ "roots": ["~/.tangram2"],
195
+ "defaultCwd": "~/.tangram2/workspace",
196
+ "timeoutMs": 120000,
197
+ "maxOutputChars": 12000
198
+ }
199
+ }
200
+ }
201
+ }
202
+ ```
203
+
204
+ When enabled, the model can call a `bash` tool with argv form commands (e.g. `['bash','-lc','ls -la']`), constrained to allowed roots.
205
+
206
+ Set `shell.fullAccess=true` to disable cwd root restrictions and allow any local path.
207
+
208
+ ## Heartbeat (Optional)
209
+
210
+ Heartbeat periodically reads `HEARTBEAT.md` and triggers a model run with that content.
211
+
212
+ ```json
213
+ {
214
+ "agents": {
215
+ "defaults": {
216
+ "heartbeat": {
217
+ "enabled": true,
218
+ "intervalSeconds": 300,
219
+ "filePath": "~/.tangram2/workspace/HEARTBEAT.md",
220
+ "threadId": "heartbeat"
221
+ }
222
+ }
223
+ }
224
+ }
225
+ ```
226
+
227
+ ## Cron Scheduler
228
+
229
+ Cron scheduler runs due tasks and sends their payload to the model at the scheduled time.
230
+
231
+ ```json
232
+ {
233
+ "agents": {
234
+ "defaults": {
235
+ "cron": {
236
+ "enabled": true,
237
+ "tickSeconds": 15,
238
+ "storePath": "~/.tangram2/workspace/cron-tasks.json",
239
+ "defaultThreadId": "cron"
240
+ }
241
+ }
242
+ }
243
+ }
244
+ ```
245
+
246
+ Model-facing cron tools:
247
+ - `cron_schedule` set run time, repeat mode, and `callbackPrompt` (sent to model when due, not directly to user)
248
+ - `cron_schedule_local` set local timezone schedules (e.g. daily 09:00 Asia/Shanghai) and `callbackPrompt`
249
+ - `cron_list` inspect pending tasks
250
+ - `cron_cancel` remove a task by id
251
+
252
+ Compatibility note:
253
+ - old `message` field is still accepted for backward compatibility, but `callbackPrompt` is recommended
254
+
255
+ ## Config
256
+
257
+ This project supports **multiple provider instances**. Example:
258
+
259
+ ```json
260
+ {
261
+ "providers": {
262
+ "openai": {
263
+ "type": "openai",
264
+ "apiKey": "sk-...",
265
+ "baseUrl": "https://api.openai.com/v1",
266
+ "defaultModel": "gpt-4.1-mini"
267
+ },
268
+ "anthropic": {
269
+ "type": "anthropic",
270
+ "apiKey": "sk-ant-...",
271
+ "baseUrl": "https://api.anthropic.com",
272
+ "defaultModel": "claude-3-5-sonnet-latest"
273
+ },
274
+ "local": {
275
+ "type": "openai",
276
+ "apiKey": "dummy",
277
+ "baseUrl": "http://localhost:8000/v1",
278
+ "defaultModel": "meta-llama/Llama-3.1-8B-Instruct"
279
+ }
280
+ },
281
+ "agents": {
282
+ "defaults": {
283
+ "provider": "openai",
284
+ "temperature": 0.7,
285
+ "systemPrompt": "You are a helpful assistant."
286
+ }
287
+ },
288
+ "channels": {
289
+ "telegram": {
290
+ "enabled": true,
291
+ "token": "123456:ABCDEF...",
292
+ "allowFrom": []
293
+ }
294
+ }
295
+ }
296
+ ```
297
+
298
+ Config lookup order:
299
+ - `--config <path>`
300
+ - `TANGRAM2_CONFIG`
301
+ - `~/.tangram2/config.json`
302
+ - `./config.json` (legacy fallback)
package/TODO.md ADDED
@@ -0,0 +1,5 @@
1
+ # TODO
2
+
3
+ - [ ] 设计并实现 `onboard` 命令,用于初始化 `~/.tangram2`(配置、workspace、skills 等)。
4
+ - [ ] 在设计 `onboard` 时参考 `openclaw` / `nanobot` 的初始化与引导流程,抽取适配 tangram2 的最小可用方案。
5
+
@@ -0,0 +1,66 @@
1
+ {
2
+ "providers": {
3
+ "openai": {
4
+ "type": "openai",
5
+ "apiKey": "sk-...",
6
+ "baseUrl": "https://api.openai.com/v1",
7
+ "defaultModel": "gpt-4.1-mini"
8
+ },
9
+ "anthropic": {
10
+ "type": "anthropic",
11
+ "apiKey": "sk-ant-...",
12
+ "baseUrl": "https://api.anthropic.com",
13
+ "defaultModel": "claude-3-5-sonnet-latest"
14
+ }
15
+ },
16
+ "agents": {
17
+ "defaults": {
18
+ "provider": "openai",
19
+ "workspace": "~/.tangram2/workspace",
20
+ "skills": {
21
+ "enabled": true,
22
+ "roots": [
23
+ "~/.tangram2/skills"
24
+ ],
25
+ "maxSkills": 40,
26
+ "hotReload": {
27
+ "enabled": true,
28
+ "debounceMs": 800,
29
+ "logDiff": true
30
+ }
31
+ },
32
+ "shell": {
33
+ "enabled": false,
34
+ "fullAccess": false,
35
+ "roots": [
36
+ "~/.tangram2"
37
+ ],
38
+ "defaultCwd": "~/.tangram2/workspace",
39
+ "timeoutMs": 120000,
40
+ "maxOutputChars": 12000
41
+ },
42
+ "heartbeat": {
43
+ "enabled": false,
44
+ "intervalSeconds": 300,
45
+ "filePath": "~/.tangram2/workspace/HEARTBEAT.md",
46
+ "threadId": "heartbeat"
47
+ },
48
+ "cron": {
49
+ "enabled": true,
50
+ "tickSeconds": 15,
51
+ "storePath": "~/.tangram2/workspace/cron-tasks.json",
52
+ "defaultThreadId": "cron"
53
+ },
54
+ "temperature": 0.7,
55
+ "systemPrompt": "You are a helpful assistant. Keep replies concise."
56
+ }
57
+ },
58
+ "channels": {
59
+ "telegram": {
60
+ "enabled": true,
61
+ "token": "123456:ABCDEF...",
62
+ "progressUpdates": true,
63
+ "allowFrom": []
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,214 @@
1
+ import { Telegraf } from "telegraf";
2
+ import { randomUUID } from "node:crypto";
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import { splitTelegramMessage } from "../utils/telegram.js";
5
+ import { withKeyLock } from "../session/locks.js";
6
+ function createTypingLoop(ctx, chatId) {
7
+ let stopped = false;
8
+ const run = async () => {
9
+ while (!stopped) {
10
+ try {
11
+ await ctx.telegram.sendChatAction(chatId, "typing");
12
+ }
13
+ catch {
14
+ }
15
+ await sleep(3500);
16
+ }
17
+ };
18
+ void run();
19
+ return () => {
20
+ stopped = true;
21
+ };
22
+ }
23
+ function resolveChatId(rawThreadId, fallbackThreadId) {
24
+ const aliases = new Set(["current", "current_thread", "this_thread", "this"]);
25
+ let effective = rawThreadId.trim();
26
+ if (aliases.has(effective) && fallbackThreadId) {
27
+ effective = fallbackThreadId;
28
+ }
29
+ if (/^-?\d+$/.test(effective)) {
30
+ const asNum = Number(effective);
31
+ if (Number.isSafeInteger(asNum)) {
32
+ return asNum;
33
+ }
34
+ }
35
+ return effective;
36
+ }
37
+ export async function startTelegramGateway(config, invoke, memory, logger) {
38
+ const tg = config.channels.telegram;
39
+ if (!tg?.enabled) {
40
+ throw new Error("Telegram channel is not enabled in config.channels.telegram.enabled");
41
+ }
42
+ if (!tg.token) {
43
+ throw new Error("Telegram token is required when channels.telegram.enabled=true");
44
+ }
45
+ const bot = new Telegraf(tg.token);
46
+ let lastSeenChatId;
47
+ logger?.info("Telegram gateway starting", {
48
+ allowFromCount: Array.isArray(tg.allowFrom) ? tg.allowFrom.length : 0,
49
+ progressUpdates: tg.progressUpdates !== false,
50
+ });
51
+ const replyText = async (ctx, text) => {
52
+ const safeText = text && text.length > 0 ? text : "(empty reply)";
53
+ // Use a safety margin below Telegram's hard 4096-char limit.
54
+ const parts = splitTelegramMessage(safeText, 3800);
55
+ for (const part of parts) {
56
+ await ctx.reply(part, { link_preview_options: { is_disabled: true } });
57
+ }
58
+ };
59
+ const sendToThread = async ({ threadId, text }) => {
60
+ const safeText = text && text.length > 0 ? text : "(empty reply)";
61
+ const chatId = resolveChatId(threadId, lastSeenChatId);
62
+ const parts = splitTelegramMessage(safeText, 3800);
63
+ for (const part of parts) {
64
+ await bot.telegram.sendMessage(chatId, part, { link_preview_options: { is_disabled: true } });
65
+ }
66
+ };
67
+ bot.start(async (ctx) => {
68
+ await replyText(ctx, "Connected. Send me a message.");
69
+ });
70
+ bot.command("memory", async (ctx) => {
71
+ logger?.debug("Command /memory", {
72
+ chatId: String(ctx.chat?.id ?? ""),
73
+ userId: String(ctx.from?.id ?? ""),
74
+ });
75
+ const userId = ctx.from?.id != null ? String(ctx.from.id) : "";
76
+ if (Array.isArray(tg.allowFrom) && tg.allowFrom.length > 0) {
77
+ if (!userId || !tg.allowFrom.includes(userId)) {
78
+ await replyText(ctx, "Not allowed.");
79
+ return;
80
+ }
81
+ }
82
+ const text = await withKeyLock("memory", async () => memory.getMemoryContext());
83
+ if (!text) {
84
+ await replyText(ctx, "(memory is empty)");
85
+ return;
86
+ }
87
+ // Avoid spamming too many messages if memory grows large.
88
+ const maxChars = 20000;
89
+ const trimmed = text.length > maxChars ? text.slice(-maxChars) : text;
90
+ const note = text.length > maxChars ? "(showing last 20000 chars)\n\n" : "";
91
+ await replyText(ctx, note + trimmed);
92
+ });
93
+ bot.command("remember", async (ctx) => {
94
+ logger?.debug("Command /remember", {
95
+ chatId: String(ctx.chat?.id ?? ""),
96
+ userId: String(ctx.from?.id ?? ""),
97
+ });
98
+ const userId = ctx.from?.id != null ? String(ctx.from.id) : "";
99
+ if (Array.isArray(tg.allowFrom) && tg.allowFrom.length > 0) {
100
+ if (!userId || !tg.allowFrom.includes(userId)) {
101
+ await replyText(ctx, "Not allowed.");
102
+ return;
103
+ }
104
+ }
105
+ const raw = ctx.message?.text;
106
+ const payload = raw?.replace(/^\/remember\s*/i, "").trim() ?? "";
107
+ if (!payload) {
108
+ await replyText(ctx, "Usage: /remember <text>");
109
+ return;
110
+ }
111
+ await withKeyLock("memory", async () => memory.appendToday(payload));
112
+ await replyText(ctx, "Saved to today's memory.");
113
+ });
114
+ bot.command("remember_long", async (ctx) => {
115
+ logger?.debug("Command /remember_long", {
116
+ chatId: String(ctx.chat?.id ?? ""),
117
+ userId: String(ctx.from?.id ?? ""),
118
+ });
119
+ const userId = ctx.from?.id != null ? String(ctx.from.id) : "";
120
+ if (Array.isArray(tg.allowFrom) && tg.allowFrom.length > 0) {
121
+ if (!userId || !tg.allowFrom.includes(userId)) {
122
+ await replyText(ctx, "Not allowed.");
123
+ return;
124
+ }
125
+ }
126
+ const raw = ctx.message?.text;
127
+ const payload = raw?.replace(/^\/remember_long\s*/i, "").trim() ?? "";
128
+ if (!payload) {
129
+ await replyText(ctx, "Usage: /remember_long <text>");
130
+ return;
131
+ }
132
+ await withKeyLock("memory", async () => memory.appendLongTerm(payload));
133
+ await replyText(ctx, "Saved to long-term memory.");
134
+ });
135
+ bot.on("text", async (ctx) => {
136
+ const chatId = String(ctx.chat.id);
137
+ lastSeenChatId = chatId;
138
+ const userId = ctx.from?.id != null ? String(ctx.from.id) : "";
139
+ const text = ctx.message?.text;
140
+ if (!text)
141
+ return;
142
+ logger?.debug("Incoming text", {
143
+ chatId,
144
+ userId,
145
+ length: text.length,
146
+ });
147
+ if (Array.isArray(tg.allowFrom) && tg.allowFrom.length > 0) {
148
+ if (!userId || !tg.allowFrom.includes(userId)) {
149
+ await replyText(ctx, "Not allowed.");
150
+ return;
151
+ }
152
+ }
153
+ try {
154
+ const stopTyping = createTypingLoop(ctx, chatId);
155
+ const progressThrottleMs = 1200;
156
+ let lastProgressAt = 0;
157
+ const progressEnabled = tg.progressUpdates !== false;
158
+ const onProgress = async (event) => {
159
+ if (event.kind === "tool_progress" && !progressEnabled)
160
+ return;
161
+ const now = Date.now();
162
+ if (now - lastProgressAt < progressThrottleMs)
163
+ return;
164
+ lastProgressAt = now;
165
+ if (event.kind === "assistant_explanation") {
166
+ await replyText(ctx, `💬 ${event.message}`);
167
+ return;
168
+ }
169
+ await replyText(ctx, `⏳ ${event.message}`);
170
+ };
171
+ // Prevent concurrent invokes within a chat to keep ordering and memory sane.
172
+ try {
173
+ const reply = await withKeyLock(chatId, async () => invoke({ threadId: chatId, text, onProgress }));
174
+ logger?.debug("Outgoing reply", { chatId, length: reply.length });
175
+ await replyText(ctx, reply);
176
+ }
177
+ finally {
178
+ stopTyping();
179
+ }
180
+ }
181
+ catch (err) {
182
+ // Avoid echoing huge payloads back to Telegram (which can recurse into the same error).
183
+ // Log full error locally.
184
+ const errorId = randomUUID().slice(0, 8);
185
+ // eslint-disable-next-line no-console
186
+ console.error(`[telegram][${errorId}]`, err);
187
+ logger?.error("Invoke failed", { errorId, chatId, userId, message: err?.message });
188
+ // User-facing error should be short and never include provider payloads.
189
+ const safe = `Provider error (${errorId}). Check server logs.`;
190
+ try {
191
+ await replyText(ctx, safe);
192
+ }
193
+ catch (inner) {
194
+ // eslint-disable-next-line no-console
195
+ console.error("Failed to send error message", inner);
196
+ }
197
+ }
198
+ });
199
+ void bot
200
+ .launch()
201
+ .then(() => {
202
+ logger?.info("Telegram bot launched");
203
+ })
204
+ .catch((err) => {
205
+ logger?.error("Telegram bot launch failed", {
206
+ message: err?.message,
207
+ });
208
+ });
209
+ // Graceful shutdown.
210
+ const stop = () => bot.stop("SIGTERM");
211
+ process.once("SIGINT", stop);
212
+ process.once("SIGTERM", stop);
213
+ return { sendToThread };
214
+ }