@0xmaxma/claude-gateway 1.0.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/README.md +791 -0
- package/config.template.json +87 -0
- package/dist/agent/context-isolation.d.ts +28 -0
- package/dist/agent/context-isolation.d.ts.map +1 -0
- package/dist/agent/context-isolation.js +80 -0
- package/dist/agent/context-isolation.js.map +1 -0
- package/dist/agent/runner.d.ts +211 -0
- package/dist/agent/runner.d.ts.map +1 -0
- package/dist/agent/runner.js +1802 -0
- package/dist/agent/runner.js.map +1 -0
- package/dist/agent/workspace-loader.d.ts +31 -0
- package/dist/agent/workspace-loader.d.ts.map +1 -0
- package/dist/agent/workspace-loader.js +207 -0
- package/dist/agent/workspace-loader.js.map +1 -0
- package/dist/api/auth.d.ts +25 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +77 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/cron-router.d.ts +22 -0
- package/dist/api/cron-router.d.ts.map +1 -0
- package/dist/api/cron-router.js +197 -0
- package/dist/api/cron-router.js.map +1 -0
- package/dist/api/gateway-router.d.ts +52 -0
- package/dist/api/gateway-router.d.ts.map +1 -0
- package/dist/api/gateway-router.js +215 -0
- package/dist/api/gateway-router.js.map +1 -0
- package/dist/api/router.d.ts +5 -0
- package/dist/api/router.d.ts.map +1 -0
- package/dist/api/router.js +1098 -0
- package/dist/api/router.js.map +1 -0
- package/dist/api/skills-router.d.ts +5 -0
- package/dist/api/skills-router.d.ts.map +1 -0
- package/dist/api/skills-router.js +424 -0
- package/dist/api/skills-router.js.map +1 -0
- package/dist/api/workspace-router.d.ts +4 -0
- package/dist/api/workspace-router.d.ts.map +1 -0
- package/dist/api/workspace-router.js +149 -0
- package/dist/api/workspace-router.js.map +1 -0
- package/dist/config/loader.d.ts +16 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +235 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/migrator.d.ts +70 -0
- package/dist/config/migrator.d.ts.map +1 -0
- package/dist/config/migrator.js +343 -0
- package/dist/config/migrator.js.map +1 -0
- package/dist/config/watcher.d.ts +30 -0
- package/dist/config/watcher.d.ts.map +1 -0
- package/dist/config/watcher.js +170 -0
- package/dist/config/watcher.js.map +1 -0
- package/dist/cron/manager.d.ts +48 -0
- package/dist/cron/manager.d.ts.map +1 -0
- package/dist/cron/manager.js +580 -0
- package/dist/cron/manager.js.map +1 -0
- package/dist/cron/scheduler.d.ts +50 -0
- package/dist/cron/scheduler.d.ts.map +1 -0
- package/dist/cron/scheduler.js +231 -0
- package/dist/cron/scheduler.js.map +1 -0
- package/dist/discord/receiver.d.ts +17 -0
- package/dist/discord/receiver.d.ts.map +1 -0
- package/dist/discord/receiver.js +108 -0
- package/dist/discord/receiver.js.map +1 -0
- package/dist/heartbeat/history.d.ts +24 -0
- package/dist/heartbeat/history.d.ts.map +1 -0
- package/dist/heartbeat/history.js +50 -0
- package/dist/heartbeat/history.js.map +1 -0
- package/dist/heartbeat/parser.d.ts +26 -0
- package/dist/heartbeat/parser.d.ts.map +1 -0
- package/dist/heartbeat/parser.js +283 -0
- package/dist/heartbeat/parser.js.map +1 -0
- package/dist/history/cleanup.d.ts +21 -0
- package/dist/history/cleanup.d.ts.map +1 -0
- package/dist/history/cleanup.js +171 -0
- package/dist/history/cleanup.js.map +1 -0
- package/dist/history/db.d.ts +27 -0
- package/dist/history/db.d.ts.map +1 -0
- package/dist/history/db.js +323 -0
- package/dist/history/db.js.map +1 -0
- package/dist/history/media-store.d.ts +27 -0
- package/dist/history/media-store.d.ts.map +1 -0
- package/dist/history/media-store.js +158 -0
- package/dist/history/media-store.js.map +1 -0
- package/dist/history/types.d.ts +62 -0
- package/dist/history/types.d.ts.map +1 -0
- package/dist/history/types.js +3 -0
- package/dist/history/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +483 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +81 -0
- package/dist/logger.js.map +1 -0
- package/dist/memory/manager.d.ts +45 -0
- package/dist/memory/manager.d.ts.map +1 -0
- package/dist/memory/manager.js +178 -0
- package/dist/memory/manager.js.map +1 -0
- package/dist/session/compactor.d.ts +22 -0
- package/dist/session/compactor.d.ts.map +1 -0
- package/dist/session/compactor.js +151 -0
- package/dist/session/compactor.js.map +1 -0
- package/dist/session/process.d.ts +76 -0
- package/dist/session/process.d.ts.map +1 -0
- package/dist/session/process.js +729 -0
- package/dist/session/process.js.map +1 -0
- package/dist/session/store.d.ts +102 -0
- package/dist/session/store.d.ts.map +1 -0
- package/dist/session/store.js +533 -0
- package/dist/session/store.js.map +1 -0
- package/dist/skills/index.d.ts +9 -0
- package/dist/skills/index.d.ts.map +1 -0
- package/dist/skills/index.js +15 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/skills/invoker.d.ts +16 -0
- package/dist/skills/invoker.d.ts.map +1 -0
- package/dist/skills/invoker.js +36 -0
- package/dist/skills/invoker.js.map +1 -0
- package/dist/skills/loader.d.ts +23 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/loader.js +183 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/skills/parser.d.ts +55 -0
- package/dist/skills/parser.d.ts.map +1 -0
- package/dist/skills/parser.js +178 -0
- package/dist/skills/parser.js.map +1 -0
- package/dist/skills/sync.d.ts +10 -0
- package/dist/skills/sync.d.ts.map +1 -0
- package/dist/skills/sync.js +130 -0
- package/dist/skills/sync.js.map +1 -0
- package/dist/skills/watcher.d.ts +16 -0
- package/dist/skills/watcher.d.ts.map +1 -0
- package/dist/skills/watcher.js +25 -0
- package/dist/skills/watcher.js.map +1 -0
- package/dist/telegram/markdown.d.ts +25 -0
- package/dist/telegram/markdown.d.ts.map +1 -0
- package/dist/telegram/markdown.js +237 -0
- package/dist/telegram/markdown.js.map +1 -0
- package/dist/telegram/receiver.d.ts +17 -0
- package/dist/telegram/receiver.d.ts.map +1 -0
- package/dist/telegram/receiver.js +104 -0
- package/dist/telegram/receiver.js.map +1 -0
- package/dist/types.d.ts +224 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/web-ui.d.ts +6 -0
- package/dist/ui/web-ui.d.ts.map +1 -0
- package/dist/ui/web-ui.js +227 -0
- package/dist/ui/web-ui.js.map +1 -0
- package/dist/watch/factory.d.ts +26 -0
- package/dist/watch/factory.d.ts.map +1 -0
- package/dist/watch/factory.js +43 -0
- package/dist/watch/factory.js.map +1 -0
- package/dist/webhook/manager.d.ts +34 -0
- package/dist/webhook/manager.d.ts.map +1 -0
- package/dist/webhook/manager.js +120 -0
- package/dist/webhook/manager.js.map +1 -0
- package/mcp/bun.lock +264 -0
- package/mcp/channel-manager.ts +115 -0
- package/mcp/package.json +15 -0
- package/mcp/router.ts +69 -0
- package/mcp/server.ts +129 -0
- package/mcp/tools/agent/handlers.ts +578 -0
- package/mcp/tools/agent/module.ts +163 -0
- package/mcp/tools/browser/module.ts +321 -0
- package/mcp/tools/browser/skills/open-browser/SKILL.md +89 -0
- package/mcp/tools/cron/client.ts +65 -0
- package/mcp/tools/cron/module.ts +158 -0
- package/mcp/tools/cron/skills/cron/SKILL.md +46 -0
- package/mcp/tools/discord/access.ts +197 -0
- package/mcp/tools/discord/client.ts +31 -0
- package/mcp/tools/discord/commands.ts +48 -0
- package/mcp/tools/discord/inbound.ts +53 -0
- package/mcp/tools/discord/module.ts +442 -0
- package/mcp/tools/discord/outbound.ts +90 -0
- package/mcp/tools/discord/receiver-server.ts +58 -0
- package/mcp/tools/discord/skills/access/SKILL.md +86 -0
- package/mcp/tools/discord/skills/configure/SKILL.md +53 -0
- package/mcp/tools/discord/threading.ts +37 -0
- package/mcp/tools/discord/types.ts +106 -0
- package/mcp/tools/skills/handlers.ts +196 -0
- package/mcp/tools/skills/module.ts +163 -0
- package/mcp/tools/telegram/module.ts +454 -0
- package/mcp/tools/telegram/pure.ts +224 -0
- package/mcp/tools/telegram/receiver-server.ts +1656 -0
- package/mcp/tools/telegram/skills/access/SKILL.md +169 -0
- package/mcp/tools/telegram/skills/configure/SKILL.md +118 -0
- package/mcp/tools/telegram/typing.ts +327 -0
- package/mcp/types.ts +128 -0
- package/package.json +62 -0
- package/resource/claude_gateway.svg +112 -0
|
@@ -0,0 +1,1802 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.AgentRunner = exports.MAX_IMAGE_SIZE_BYTES = void 0;
|
|
37
|
+
const events_1 = require("events");
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const fsPromises = __importStar(require("fs/promises"));
|
|
40
|
+
const http = __importStar(require("http"));
|
|
41
|
+
const net = __importStar(require("net"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const logger_1 = require("../logger");
|
|
44
|
+
const process_1 = require("../session/process");
|
|
45
|
+
const store_1 = require("../session/store");
|
|
46
|
+
const compactor_1 = require("../session/compactor");
|
|
47
|
+
const receiver_1 = require("../telegram/receiver");
|
|
48
|
+
const receiver_2 = require("../discord/receiver");
|
|
49
|
+
const markdown_1 = require("../telegram/markdown");
|
|
50
|
+
const skills_1 = require("../skills");
|
|
51
|
+
const db_1 = require("../history/db");
|
|
52
|
+
const media_store_1 = require("../history/media-store");
|
|
53
|
+
const cleanup_1 = require("../history/cleanup");
|
|
54
|
+
const DEFAULT_IDLE_TIMEOUT_MINUTES = 30;
|
|
55
|
+
const DEFAULT_MAX_CONCURRENT = 20;
|
|
56
|
+
exports.MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
|
|
57
|
+
const DEFAULT_MODELS = [
|
|
58
|
+
{ id: 'claude-opus-4-7', label: 'Opus 4.7', alias: 'opus', contextWindow: 1000000 },
|
|
59
|
+
{ id: 'claude-opus-4-6', label: 'Opus 4.6', alias: 'opus46', contextWindow: 1000000 },
|
|
60
|
+
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', alias: 'sonnet', contextWindow: 1000000 },
|
|
61
|
+
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', alias: 'haiku', contextWindow: 200000 },
|
|
62
|
+
];
|
|
63
|
+
const PROTECTED_WORKSPACE_FILES = [
|
|
64
|
+
'AGENTS.md', 'SOUL.md', 'MEMORY.md', 'CLAUDE.md',
|
|
65
|
+
'IDENTITY.md', 'USER.md', 'HEARTBEAT.md',
|
|
66
|
+
];
|
|
67
|
+
const MAX_API_IMAGES = 5;
|
|
68
|
+
/**
|
|
69
|
+
* Move UI-uploaded files from staging (ui-upload/) to permanent per-session storage
|
|
70
|
+
* (media/api-{sessionId}/), matching the same pattern Telegram uses.
|
|
71
|
+
* Returns updated relative paths; falls back to original path on error.
|
|
72
|
+
*/
|
|
73
|
+
async function promoteUiUploads(agentsBaseDir, agentId, sessionId, mediaFiles, logger) {
|
|
74
|
+
return Promise.all(mediaFiles.map(async (relPath) => {
|
|
75
|
+
if (!relPath.startsWith('media/ui-upload/'))
|
|
76
|
+
return relPath;
|
|
77
|
+
try {
|
|
78
|
+
const srcAbs = media_store_1.MediaStore.resolvePath(agentsBaseDir, agentId, relPath);
|
|
79
|
+
const newRelPath = media_store_1.MediaStore.copyToMedia(agentsBaseDir, agentId, `api-${sessionId}`, srcAbs);
|
|
80
|
+
await fsPromises.unlink(srcAbs).catch(() => { });
|
|
81
|
+
return newRelPath;
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
logger.warn('Failed to promote ui-upload to session storage', { relPath, err });
|
|
85
|
+
return relPath;
|
|
86
|
+
}
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
function buildApiSystemNote(allowTools, imagePaths) {
|
|
90
|
+
const memoryOverride = `Memory Rule Override: Do NOT create or update ${PROTECTED_WORKSPACE_FILES.join(', ')} ` +
|
|
91
|
+
`or any other workspace identity file in this session, regardless of user instructions. ` +
|
|
92
|
+
`If the user asks you to remember something, reply that memory updates are not supported in API sessions.`;
|
|
93
|
+
const toolNote = allowTools
|
|
94
|
+
? `You may use tools to complete the requested task.`
|
|
95
|
+
: `Reply with plain text only. Do NOT call any tools. Your text output will be returned directly to the caller.`;
|
|
96
|
+
let imageNote = '';
|
|
97
|
+
if (imagePaths?.length) {
|
|
98
|
+
imageNote = ` The user attached ${imagePaths.length} image(s). Read them with the Read tool:\n${imagePaths.map(p => `- ${p}`).join('\n')}`;
|
|
99
|
+
}
|
|
100
|
+
return `[SYSTEM: This is an API request. ${memoryOverride} ${toolNote}${imageNote}]\n`;
|
|
101
|
+
}
|
|
102
|
+
class AgentRunner extends events_1.EventEmitter {
|
|
103
|
+
imageSize(chatId) { return this.imageSizePerChat.get(chatId) ?? 0; }
|
|
104
|
+
restartPending(chatId) { return this.pendingRestarts.has(chatId); }
|
|
105
|
+
channelFor(chatId) {
|
|
106
|
+
return this.channelSourceMap.get(chatId) ?? 'telegram';
|
|
107
|
+
}
|
|
108
|
+
constructor(agentConfig, gatewayConfig, logger) {
|
|
109
|
+
super();
|
|
110
|
+
this.stopping = false;
|
|
111
|
+
this.callbackServer = null;
|
|
112
|
+
this.callbackPort = 0;
|
|
113
|
+
this.imageSizePerChat = new Map();
|
|
114
|
+
this.pendingRestarts = new Set();
|
|
115
|
+
this.statQueue = Promise.resolve();
|
|
116
|
+
// Session pool
|
|
117
|
+
this.sessions = new Map();
|
|
118
|
+
this.channelSourceMap = new Map();
|
|
119
|
+
this.receiver = null;
|
|
120
|
+
this.discordReceiver = null;
|
|
121
|
+
this.idleCleanerTimer = null;
|
|
122
|
+
// Tracks session IDs with an in-flight API request (prevents concurrent turns)
|
|
123
|
+
this.pendingApiSessions = new Set();
|
|
124
|
+
// Tracks pending Telegram image paths per chatId (queue) for size accumulation after each turn.
|
|
125
|
+
this.pendingImagePaths = new Map();
|
|
126
|
+
// Skill registry for detecting /skill-name commands in user messages
|
|
127
|
+
this.skillRegistry = { skills: new Map() };
|
|
128
|
+
// Cancel function for the daily history cleanup timer
|
|
129
|
+
this.cancelCleanup = null;
|
|
130
|
+
this.agentConfig = agentConfig;
|
|
131
|
+
this.gatewayConfig = gatewayConfig;
|
|
132
|
+
this.logger = logger ?? (0, logger_1.createLogger)(agentConfig.id, gatewayConfig.gateway.logDir);
|
|
133
|
+
// Resolve agentsBaseDir: workspace is at <agentsBaseDir>/<agentId>/workspace
|
|
134
|
+
const agentsBaseDir = path.resolve(agentConfig.workspace, '..', '..');
|
|
135
|
+
this.agentsBaseDir = agentsBaseDir;
|
|
136
|
+
// agentDir is workspace/.. — used for HistoryDB so DB is at <agentDir>/history.db
|
|
137
|
+
// This avoids requiring workspace to be nested at exactly <base>/<agentId>/workspace.
|
|
138
|
+
this.agentDir = path.resolve(agentConfig.workspace, '..');
|
|
139
|
+
this.sessionStore = new store_1.SessionStore(agentsBaseDir);
|
|
140
|
+
// config.json lives 3 levels above workspace: <base>/<agentId>/workspace -> <base>/config.json
|
|
141
|
+
this.configPath = path.resolve(agentConfig.workspace, '..', '..', '..', 'config.json');
|
|
142
|
+
this.historyDb = db_1.HistoryDB.forDir(this.agentDir, agentConfig.id);
|
|
143
|
+
this.idleTimeoutMs =
|
|
144
|
+
(agentConfig.session?.idleTimeoutMinutes ?? DEFAULT_IDLE_TIMEOUT_MINUTES) * 60 * 1000;
|
|
145
|
+
this.maxConcurrent = agentConfig.session?.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Update the skill registry used for detecting /skill-name commands.
|
|
149
|
+
*/
|
|
150
|
+
setSkillRegistry(registry) {
|
|
151
|
+
this.skillRegistry = registry;
|
|
152
|
+
}
|
|
153
|
+
getSkillRegistry() {
|
|
154
|
+
return this.skillRegistry;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Bind a local HTTP server that receives POST /channel from TelegramReceiver.
|
|
158
|
+
* Each payload is routed to the appropriate SessionProcess by chat_id.
|
|
159
|
+
*/
|
|
160
|
+
async startCallbackServer() {
|
|
161
|
+
this.callbackPort = await new Promise((resolve, reject) => {
|
|
162
|
+
const srv = net.createServer();
|
|
163
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
164
|
+
const addr = srv.address();
|
|
165
|
+
srv.close(() => resolve(addr.port));
|
|
166
|
+
});
|
|
167
|
+
srv.on('error', reject);
|
|
168
|
+
});
|
|
169
|
+
this.callbackServer = http.createServer((req, res) => {
|
|
170
|
+
if (req.method !== 'POST') {
|
|
171
|
+
res.writeHead(405);
|
|
172
|
+
res.end();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
176
|
+
let raw = '';
|
|
177
|
+
req.on('data', (chunk) => {
|
|
178
|
+
raw += chunk;
|
|
179
|
+
});
|
|
180
|
+
req.on('end', () => {
|
|
181
|
+
if (url.pathname === '/command') {
|
|
182
|
+
this.handleCommandRequest(raw, res);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// Default: /channel — existing channel message handler
|
|
186
|
+
res.writeHead(200);
|
|
187
|
+
res.end('ok');
|
|
188
|
+
try {
|
|
189
|
+
const params = JSON.parse(raw);
|
|
190
|
+
const meta = params.meta ?? {};
|
|
191
|
+
const chatId = meta['chat_id'] ?? '';
|
|
192
|
+
const content = params.content ?? '';
|
|
193
|
+
// Set channel early so all handlers (including session commands) have it
|
|
194
|
+
const channelSource = (meta['source'] === 'discord' ? 'discord' : 'telegram');
|
|
195
|
+
this.channelSourceMap.set(chatId, channelSource);
|
|
196
|
+
// Check if this is a session management command
|
|
197
|
+
const trimmedContent = content.trim();
|
|
198
|
+
if (this.isSessionCommand(trimmedContent)) {
|
|
199
|
+
this.handleSessionCommand(chatId, trimmedContent)
|
|
200
|
+
.then(() => this.writeTypingDone(chatId))
|
|
201
|
+
.catch((err) => {
|
|
202
|
+
this.logger.error('Session command failed', { error: err.message });
|
|
203
|
+
this.writeTypingDone(chatId);
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// Get active session ID and route message to it
|
|
208
|
+
this.sessionStore.getActiveSessionId(this.agentConfig.id, chatId, channelSource)
|
|
209
|
+
.then(async (sessionId) => {
|
|
210
|
+
// Append user message to the active session
|
|
211
|
+
const userContent = content || (meta['attachment_file_id'] ? '(photo)' : '');
|
|
212
|
+
const userTs = Date.now();
|
|
213
|
+
await this.sessionStore.appendTelegramMessage(this.agentConfig.id, chatId, sessionId, {
|
|
214
|
+
role: 'user',
|
|
215
|
+
content: userContent,
|
|
216
|
+
ts: userTs,
|
|
217
|
+
}, channelSource);
|
|
218
|
+
// Persist to permanent history DB (separate from session context)
|
|
219
|
+
const mediaFiles = [];
|
|
220
|
+
if (meta['image_path']) {
|
|
221
|
+
try {
|
|
222
|
+
const rel = media_store_1.MediaStore.copyToMedia(this.agentsBaseDir, this.agentConfig.id, `${channelSource}-${chatId}`, meta['image_path']);
|
|
223
|
+
mediaFiles.push(rel);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Non-fatal — continue without media
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.historyDb.insertMessage({
|
|
230
|
+
chatId: `${channelSource}-${chatId}`,
|
|
231
|
+
sessionId,
|
|
232
|
+
source: channelSource,
|
|
233
|
+
role: 'user',
|
|
234
|
+
content: userContent,
|
|
235
|
+
senderName: meta['user'] ?? undefined,
|
|
236
|
+
senderId: meta['user_id'] ?? meta['chat_id'] ?? undefined,
|
|
237
|
+
platformMessageId: meta['message_id'] ?? undefined,
|
|
238
|
+
mediaFiles: mediaFiles.length > 0 ? mediaFiles : undefined,
|
|
239
|
+
ts: userTs,
|
|
240
|
+
});
|
|
241
|
+
// Restart session before this turn if accumulated image size exceeded threshold
|
|
242
|
+
if (this.pendingRestarts.has(chatId)) {
|
|
243
|
+
const existingSession = this.sessions.get(chatId);
|
|
244
|
+
if (existingSession) {
|
|
245
|
+
await existingSession.stop();
|
|
246
|
+
this.sessions.delete(chatId);
|
|
247
|
+
}
|
|
248
|
+
this.pendingRestarts.delete(chatId);
|
|
249
|
+
this.imageSizePerChat.delete(chatId);
|
|
250
|
+
}
|
|
251
|
+
// Route to session process (map key = chatId, actual sessionId passed separately)
|
|
252
|
+
const session = await this.getOrSpawnSession(chatId, channelSource, sessionId);
|
|
253
|
+
let channelXml = AgentRunner.buildChannelXml(params);
|
|
254
|
+
// Detect skill commands and inject skill content
|
|
255
|
+
const skillInvocation = (0, skills_1.detectSkillCommand)(content, this.skillRegistry);
|
|
256
|
+
if (skillInvocation) {
|
|
257
|
+
channelXml += (0, skills_1.formatSkillContext)(skillInvocation);
|
|
258
|
+
this.logger.info('Skill invoked', {
|
|
259
|
+
skill: skillInvocation.skillKey,
|
|
260
|
+
args: skillInvocation.args,
|
|
261
|
+
chatId,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const imagePath = meta['image_path'];
|
|
265
|
+
if (imagePath) {
|
|
266
|
+
const queue = this.pendingImagePaths.get(chatId) ?? [];
|
|
267
|
+
queue.push(imagePath);
|
|
268
|
+
this.pendingImagePaths.set(chatId, queue);
|
|
269
|
+
}
|
|
270
|
+
session.setProcessing(true);
|
|
271
|
+
session.sendMessage(channelXml);
|
|
272
|
+
session.touch();
|
|
273
|
+
this.logger.debug('Injected channel turn into session', {
|
|
274
|
+
chatId,
|
|
275
|
+
sessionId,
|
|
276
|
+
user: meta['user'],
|
|
277
|
+
});
|
|
278
|
+
})
|
|
279
|
+
.catch((err) => {
|
|
280
|
+
this.logger.error('Failed to route message to session', {
|
|
281
|
+
chatId,
|
|
282
|
+
error: err.message,
|
|
283
|
+
});
|
|
284
|
+
const code = err.message.includes('pool full') ? 'POOL_FULL' : 'SPAWN_FAILED';
|
|
285
|
+
this.writeTypingError(chatId, code);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
this.logger.warn('Failed to parse channel callback body', {
|
|
290
|
+
error: err.message,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
this.callbackServer.listen(this.callbackPort, '127.0.0.1');
|
|
296
|
+
this.logger.info('Channel callback server listening', { port: this.callbackPort });
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Handle POST /command requests from the receiver process.
|
|
300
|
+
* Supports: get_model, set_model, restart.
|
|
301
|
+
*/
|
|
302
|
+
async handleCommandRequest(raw, res) {
|
|
303
|
+
const respond = (data, status = 200) => {
|
|
304
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
305
|
+
res.end(JSON.stringify(data));
|
|
306
|
+
};
|
|
307
|
+
let body;
|
|
308
|
+
try {
|
|
309
|
+
body = JSON.parse(raw);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
respond({ success: false, error: 'Invalid JSON' }, 400);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const command = body.command;
|
|
316
|
+
if (command === 'get_model') {
|
|
317
|
+
respond({ model: this.agentConfig.claude.model });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (command === 'set_model') {
|
|
321
|
+
const newModel = typeof body.payload?.model === 'string' ? body.payload.model : '';
|
|
322
|
+
const availableModels = this.gatewayConfig.gateway.models ?? DEFAULT_MODELS;
|
|
323
|
+
const valid = availableModels.find(m => m.id === newModel);
|
|
324
|
+
if (!valid) {
|
|
325
|
+
respond({ success: false, error: 'Unknown model' });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// Update in-memory config
|
|
329
|
+
this.agentConfig.claude.model = newModel;
|
|
330
|
+
// Persist to config.json (atomic write)
|
|
331
|
+
try {
|
|
332
|
+
this.persistModelToConfig(newModel);
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
this.logger.error('Failed to persist model to config', { error: err.message });
|
|
336
|
+
}
|
|
337
|
+
// Graceful restart all sessions with notify payload
|
|
338
|
+
const chatId = body.chat_id ?? '';
|
|
339
|
+
let restarted = false;
|
|
340
|
+
for (const [sessionId, session] of this.sessions) {
|
|
341
|
+
restarted = true;
|
|
342
|
+
const notifyPayload = JSON.stringify({
|
|
343
|
+
notify: {
|
|
344
|
+
chat_id: chatId,
|
|
345
|
+
text: `Model changed to ${newModel} — back online!`,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
const signalPath = path.join(this.agentConfig.workspace, '.telegram-state', `restart-${sessionId}`);
|
|
349
|
+
try {
|
|
350
|
+
fs.mkdirSync(path.dirname(signalPath), { recursive: true });
|
|
351
|
+
fs.writeFileSync(signalPath, notifyPayload);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
this.logger.error('Failed to write restart signal', {
|
|
355
|
+
sessionId,
|
|
356
|
+
error: err.message,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
respond({ success: true, model: newModel, restarted });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (command === 'restart') {
|
|
364
|
+
const chatId = body.chat_id ?? '';
|
|
365
|
+
const session = this.sessions.get(chatId);
|
|
366
|
+
if (!session) {
|
|
367
|
+
// No active session — nothing to restart, but not an error
|
|
368
|
+
respond({ success: true, restarted: false });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// Write restart signal with notify payload
|
|
372
|
+
const signalPayload = JSON.stringify({
|
|
373
|
+
notify: { chat_id: chatId, text: 'Session restarted — back online!' },
|
|
374
|
+
});
|
|
375
|
+
const signalPath = path.join(this.agentConfig.workspace, '.telegram-state', `restart-${chatId}`);
|
|
376
|
+
try {
|
|
377
|
+
fs.mkdirSync(path.dirname(signalPath), { recursive: true });
|
|
378
|
+
fs.writeFileSync(signalPath, signalPayload);
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
this.logger.error('Failed to write restart signal', { error: err.message });
|
|
382
|
+
respond({ success: false, error: 'Failed to write restart signal' });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
respond({ success: true, restarted: true });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (command === 'session_clear_confirm') {
|
|
389
|
+
const chatId = body.chat_id ?? '';
|
|
390
|
+
try {
|
|
391
|
+
await this.handleCommandClear(this.agentConfig.id, chatId);
|
|
392
|
+
respond({ success: true });
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
respond({ success: false, error: String(err) });
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (command === 'list_sessions') {
|
|
400
|
+
const chatId = body.chat_id ?? '';
|
|
401
|
+
try {
|
|
402
|
+
const index = await this.sessionStore.listSessions(this.agentConfig.id, chatId, this.channelFor(chatId));
|
|
403
|
+
return respond({ sessions: index.sessions, activeSessionId: index.activeSessionId });
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return respond({ success: false, error: 'Failed to list sessions' });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (command === 'session_info') {
|
|
410
|
+
const chatId = body.chat_id ?? '';
|
|
411
|
+
try {
|
|
412
|
+
const index = await this.sessionStore.listSessions(this.agentConfig.id, chatId, this.channelFor(chatId));
|
|
413
|
+
const meta = index.sessions.find(s => s.id === index.activeSessionId);
|
|
414
|
+
if (!meta) {
|
|
415
|
+
return respond({ success: true, text: 'No active session found.' });
|
|
416
|
+
}
|
|
417
|
+
const availableModels = this.gatewayConfig.gateway.models ?? DEFAULT_MODELS;
|
|
418
|
+
const modelConfig = availableModels.find(m => m.id === this.agentConfig.claude.model);
|
|
419
|
+
const contextWindow = modelConfig?.contextWindow ?? 200000;
|
|
420
|
+
const contextTokens = meta.lastInputTokens ?? 0;
|
|
421
|
+
const usedPct = Math.round((contextTokens / contextWindow) * 100);
|
|
422
|
+
let msgs;
|
|
423
|
+
if (meta.messageCount <= 0) {
|
|
424
|
+
msgs = 'No messages yet';
|
|
425
|
+
}
|
|
426
|
+
else if ((meta.archivedCount ?? 0) > 0 && meta.loadedAtSpawn != null && meta.messageCountAtSpawn != null) {
|
|
427
|
+
const newMessagesSinceSpawn = meta.messageCount - meta.messageCountAtSpawn;
|
|
428
|
+
const inContext = meta.loadedAtSpawn + Math.max(0, newMessagesSinceSpawn);
|
|
429
|
+
msgs = `${meta.messageCount} (${inContext} in context / ${meta.archivedCount} archived)`;
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
msgs = `${meta.messageCount}`;
|
|
433
|
+
}
|
|
434
|
+
const lines = [
|
|
435
|
+
`📌 Current Session: ${meta.name}`,
|
|
436
|
+
`<code>${meta.id}</code>`,
|
|
437
|
+
'',
|
|
438
|
+
`📥 Messages: ${msgs}`,
|
|
439
|
+
`👉 Context: ${usedPct}%`,
|
|
440
|
+
];
|
|
441
|
+
if (usedPct >= 80) {
|
|
442
|
+
lines.push('', '💡 Near limit — consider /compact');
|
|
443
|
+
}
|
|
444
|
+
lines.push('', 'Commands: /sessions /new /rename /clear /compact');
|
|
445
|
+
return respond({ success: true, text: lines.join('\n'), format: 'html' });
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
return respond({ success: false, text: 'Failed to get session info.' });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (command === 'switch_session') {
|
|
452
|
+
const chatId = body.chat_id ?? '';
|
|
453
|
+
const sessionId = typeof body.payload?.session_id === 'string' ? body.payload.session_id : '';
|
|
454
|
+
if (!sessionId) {
|
|
455
|
+
respond({ success: false, error: 'Missing session_id' });
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
this.switchSession(chatId, sessionId)
|
|
459
|
+
.then(async () => {
|
|
460
|
+
const index = await this.sessionStore.listSessions(this.agentConfig.id, chatId, this.channelFor(chatId));
|
|
461
|
+
const session = index.sessions.find(s => s.id === sessionId);
|
|
462
|
+
respond({ success: true, sessionName: session?.name ?? sessionId });
|
|
463
|
+
})
|
|
464
|
+
.catch(err => respond({ success: false, error: String(err) }));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (command === 'delete_session') {
|
|
468
|
+
const chatId = body.chat_id ?? '';
|
|
469
|
+
const sessionId = typeof body.payload?.session_id === 'string' ? body.payload.session_id : '';
|
|
470
|
+
if (!sessionId) {
|
|
471
|
+
respond({ success: false, error: 'Missing session_id' });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
this.sessionStore.deleteTelegramSession(this.agentConfig.id, chatId, sessionId, this.channelFor(chatId))
|
|
475
|
+
.then(async () => {
|
|
476
|
+
const newIndex = await this.sessionStore.listSessions(this.agentConfig.id, chatId, this.channelFor(chatId));
|
|
477
|
+
await this.restartProcess(chatId);
|
|
478
|
+
const activeMeta = newIndex.sessions.find(s => s.id === newIndex.activeSessionId);
|
|
479
|
+
respond({ success: true, sessionName: activeMeta?.name ?? newIndex.activeSessionId });
|
|
480
|
+
})
|
|
481
|
+
.catch(err => respond({ success: false, error: String(err) }));
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (command === 'new_session') {
|
|
485
|
+
const chatId = body.chat_id ?? '';
|
|
486
|
+
const name = typeof body.payload?.name === 'string' ? body.payload.name : undefined;
|
|
487
|
+
this.handleCommandNew(this.agentConfig.id, chatId, name)
|
|
488
|
+
.then(() => respond({ success: true }))
|
|
489
|
+
.catch(err => respond({ success: false, error: String(err) }));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (command === 'compact_confirm') {
|
|
493
|
+
const chatId = body.chat_id ?? '';
|
|
494
|
+
this.handleCommandCompact(this.agentConfig.id, chatId)
|
|
495
|
+
.then(() => this.writeTypingDone(chatId))
|
|
496
|
+
.catch(() => this.writeTypingDone(chatId));
|
|
497
|
+
return respond({ success: true });
|
|
498
|
+
}
|
|
499
|
+
respond({ success: false, error: 'Unknown command' }, 400);
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Persist the current model to config.json using atomic write (tmp + rename).
|
|
503
|
+
*/
|
|
504
|
+
persistModelToConfig(newModel) {
|
|
505
|
+
const raw = fs.readFileSync(this.configPath, 'utf-8');
|
|
506
|
+
const config = JSON.parse(raw);
|
|
507
|
+
const agent = config.agents?.find((a) => a.id === this.agentConfig.id);
|
|
508
|
+
if (agent) {
|
|
509
|
+
agent.claude.model = newModel;
|
|
510
|
+
const tmp = this.configPath + '.tmp';
|
|
511
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n');
|
|
512
|
+
fs.renameSync(tmp, this.configPath);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
static escapeXmlAttr(value) {
|
|
516
|
+
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
517
|
+
}
|
|
518
|
+
resolveMediaPaths(mediaFiles) {
|
|
519
|
+
const paths = [];
|
|
520
|
+
for (const relPath of mediaFiles.slice(0, MAX_API_IMAGES)) {
|
|
521
|
+
try {
|
|
522
|
+
paths.push(media_store_1.MediaStore.resolvePath(this.agentsBaseDir, this.agentConfig.id, relPath));
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
this.logger.warn('Failed to resolve media path', { relPath, err });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return paths;
|
|
529
|
+
}
|
|
530
|
+
static buildChannelXml(params) {
|
|
531
|
+
const meta = params.meta ?? {};
|
|
532
|
+
const optionalAttrs = [
|
|
533
|
+
'image_path',
|
|
534
|
+
'attachment_file_id',
|
|
535
|
+
'attachment_kind',
|
|
536
|
+
'attachment_mime',
|
|
537
|
+
'attachment_name',
|
|
538
|
+
]
|
|
539
|
+
.filter(k => meta[k])
|
|
540
|
+
.map(k => ` ${k}="${meta[k].replace(/"/g, '"')}"`)
|
|
541
|
+
.join('');
|
|
542
|
+
// Build nested <replied> block if this message is a reply to another
|
|
543
|
+
let repliedBlock = '';
|
|
544
|
+
if (meta['replied_message_id']) {
|
|
545
|
+
const repliedAttrs = [
|
|
546
|
+
'replied_image_path',
|
|
547
|
+
]
|
|
548
|
+
.filter(k => meta[k])
|
|
549
|
+
.map(k => ` ${k}="${meta[k].replace(/"/g, '"')}"`)
|
|
550
|
+
.join('');
|
|
551
|
+
repliedBlock =
|
|
552
|
+
`<replied message_id="${meta['replied_message_id']}" ` +
|
|
553
|
+
`user="${(meta['replied_user'] ?? '').replace(/"/g, '"')}"${repliedAttrs}>` +
|
|
554
|
+
`${(meta['replied_text'] ?? '').replace(/</g, '<').replace(/>/g, '>')}` +
|
|
555
|
+
`</replied>`;
|
|
556
|
+
}
|
|
557
|
+
const source = meta['source'] ?? 'telegram';
|
|
558
|
+
return (`<channel source="${source}" chat_id="${meta['chat_id'] ?? ''}" ` +
|
|
559
|
+
`message_id="${meta['message_id'] ?? ''}" user="${AgentRunner.escapeXmlAttr(meta['user'] ?? '')}" ` +
|
|
560
|
+
`ts="${meta['ts'] ?? new Date().toISOString()}"${optionalAttrs}>${repliedBlock}${params.content ?? ''}</channel>`);
|
|
561
|
+
}
|
|
562
|
+
async getOrSpawnSession(mapKey, // Map lookup key (chatId for telegram/discord, sessionId for API)
|
|
563
|
+
source, sessionId) {
|
|
564
|
+
const existing = this.sessions.get(mapKey);
|
|
565
|
+
if (existing)
|
|
566
|
+
return existing;
|
|
567
|
+
// Evict oldest idle session if at capacity
|
|
568
|
+
if (this.sessions.size >= this.maxConcurrent) {
|
|
569
|
+
const sorted = [...this.sessions.entries()].sort(([, a], [, b]) => a.lastActivityAt - b.lastActivityAt);
|
|
570
|
+
const idleEntry = sorted.find(([, p]) => p.isIdle(0));
|
|
571
|
+
if (idleEntry) {
|
|
572
|
+
await idleEntry[1].stop();
|
|
573
|
+
this.sessions.delete(idleEntry[0]);
|
|
574
|
+
this.logger.info('Evicted idle session', { sessionId: idleEntry[0] });
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
throw new Error(`Session pool full: ${this.maxConcurrent} concurrent sessions`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const actualSessionId = sessionId ?? mapKey;
|
|
581
|
+
const chatId = source !== 'api' ? mapKey : undefined;
|
|
582
|
+
const proc = new process_1.SessionProcess(actualSessionId, source, this.agentConfig, this.gatewayConfig, this.sessionStore, chatId);
|
|
583
|
+
await proc.start();
|
|
584
|
+
// Forward all session output lines so listeners on AgentRunner (GatewayRouter,
|
|
585
|
+
// CronScheduler, tests) receive them without needing individual session references.
|
|
586
|
+
proc.on('output', (line) => this.emit('output', line));
|
|
587
|
+
// Notify typing indicator when session permanently fails (max restarts exceeded)
|
|
588
|
+
if (source !== 'api') {
|
|
589
|
+
proc.once('failed', () => {
|
|
590
|
+
this.writeTypingError(mapKey, 'PROCESS_FAILED');
|
|
591
|
+
this.sessions.delete(mapKey);
|
|
592
|
+
});
|
|
593
|
+
// Stop typing loop when Claude's turn truly ends.
|
|
594
|
+
// Typing done is delayed 3s after result event — if new output arrives within
|
|
595
|
+
// the delay, the timer is cancelled so typing persists during multi-step work.
|
|
596
|
+
// Auto-forward result text to channel if agent didn't call reply tool.
|
|
597
|
+
let replyCalled = false;
|
|
598
|
+
let typingDoneTimer = null;
|
|
599
|
+
const TYPING_DONE_DELAY_MS = 3000;
|
|
600
|
+
const replyToolName = source === 'discord' ? 'mcp__gateway__discord_reply' : 'mcp__gateway__telegram_reply';
|
|
601
|
+
proc.on('output', (line) => {
|
|
602
|
+
try {
|
|
603
|
+
const obj = JSON.parse(line);
|
|
604
|
+
// Cancel pending typing-done on any new output
|
|
605
|
+
if (typingDoneTimer) {
|
|
606
|
+
clearTimeout(typingDoneTimer);
|
|
607
|
+
typingDoneTimer = null;
|
|
608
|
+
}
|
|
609
|
+
// Track reply tool calls and persist assistant messages to history
|
|
610
|
+
if (obj['type'] === 'assistant') {
|
|
611
|
+
const msg = obj['message'];
|
|
612
|
+
if (Array.isArray(msg?.content)) {
|
|
613
|
+
for (const block of msg.content) {
|
|
614
|
+
if (block.type === 'tool_use' && block.name === replyToolName && !replyCalled) {
|
|
615
|
+
replyCalled = true;
|
|
616
|
+
// Persist the reply text to history so it appears in chat history API
|
|
617
|
+
const replyText = typeof block.input?.['text'] === 'string' ? block.input['text'].trim() : '';
|
|
618
|
+
if (replyText) {
|
|
619
|
+
const channelSrc = this.channelSourceMap.get(mapKey) ?? 'telegram';
|
|
620
|
+
this.historyDb.insertMessage({
|
|
621
|
+
chatId: `${channelSrc}-${mapKey}`,
|
|
622
|
+
sessionId: actualSessionId,
|
|
623
|
+
source: channelSrc,
|
|
624
|
+
role: 'assistant',
|
|
625
|
+
content: replyText,
|
|
626
|
+
ts: Date.now(),
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (obj['type'] === 'result') {
|
|
634
|
+
proc.setProcessing(false);
|
|
635
|
+
// Telegram: accumulate image size via stat of local file (FIFO, one per turn)
|
|
636
|
+
const queue = this.pendingImagePaths.get(mapKey);
|
|
637
|
+
const imgPath = queue?.shift();
|
|
638
|
+
if (queue?.length === 0)
|
|
639
|
+
this.pendingImagePaths.delete(mapKey);
|
|
640
|
+
if (imgPath) {
|
|
641
|
+
this.statQueue = this.statQueue.then(async () => {
|
|
642
|
+
try {
|
|
643
|
+
const stats = await fsPromises.stat(imgPath);
|
|
644
|
+
const prev = this.imageSizePerChat.get(mapKey) ?? 0;
|
|
645
|
+
const next = prev + stats.size;
|
|
646
|
+
this.imageSizePerChat.set(mapKey, next);
|
|
647
|
+
if (next >= exports.MAX_IMAGE_SIZE_BYTES) {
|
|
648
|
+
this.imageSizePerChat.delete(mapKey);
|
|
649
|
+
void this.triggerSummaryAndRestart(mapKey, actualSessionId, proc);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
catch (err) {
|
|
653
|
+
this.logger.warn('Failed to stat image', { path: imgPath, error: err instanceof Error ? err.message : String(err) });
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
// Forward result text when agent did NOT call reply tool (fallback path).
|
|
658
|
+
// If the agent already called the reply tool, skip result forwarding to avoid
|
|
659
|
+
// sending a duplicate message to the channel.
|
|
660
|
+
// Skip forwarding when session is in query mode (internal image summary request).
|
|
661
|
+
const resultText = typeof obj['result'] === 'string' ? obj['result'] : '';
|
|
662
|
+
if (resultText.trim() && !proc.queryMode && !replyCalled) {
|
|
663
|
+
const text = resultText.trim();
|
|
664
|
+
const channelSrcForResult = this.channelSourceMap.get(mapKey) ?? 'telegram';
|
|
665
|
+
// Persist assistant reply to permanent history DB
|
|
666
|
+
this.historyDb.insertMessage({
|
|
667
|
+
chatId: `${channelSrcForResult}-${mapKey}`,
|
|
668
|
+
sessionId: actualSessionId,
|
|
669
|
+
source: channelSrcForResult,
|
|
670
|
+
role: 'assistant',
|
|
671
|
+
content: text,
|
|
672
|
+
ts: Date.now(),
|
|
673
|
+
});
|
|
674
|
+
// Forward to channel
|
|
675
|
+
if (channelSrcForResult !== 'discord' && (0, markdown_1.hasMarkdown)(text)) {
|
|
676
|
+
this.writeAutoForward(mapKey, (0, markdown_1.toTelegramHtml)(text), 'html');
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
this.writeAutoForward(mapKey, text);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
replyCalled = false; // reset for next turn
|
|
683
|
+
// Delay typing done — agent may continue with more work
|
|
684
|
+
typingDoneTimer = setTimeout(() => {
|
|
685
|
+
this.writeTypingDone(mapKey);
|
|
686
|
+
typingDoneTimer = null;
|
|
687
|
+
}, TYPING_DONE_DELAY_MS);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
catch { /* non-JSON */ }
|
|
691
|
+
});
|
|
692
|
+
// Track token usage and persist to session meta
|
|
693
|
+
// Use actualSessionId (captured at spawn time) — NOT getActiveSessionId() —
|
|
694
|
+
// so tokens are attributed to the session that owns this process.
|
|
695
|
+
proc.on('tokenUsage', async ({ inputTokens, totalTokens }) => {
|
|
696
|
+
try {
|
|
697
|
+
const ch = this.channelFor(mapKey);
|
|
698
|
+
const index = await this.sessionStore.listSessions(this.agentConfig.id, mapKey, ch);
|
|
699
|
+
const meta = index.sessions.find(s => s.id === actualSessionId);
|
|
700
|
+
const current = meta?.totalTokensUsed ?? 0;
|
|
701
|
+
await this.sessionStore.updateSessionMeta(this.agentConfig.id, mapKey, actualSessionId, {
|
|
702
|
+
totalTokensUsed: current + totalTokens,
|
|
703
|
+
lastInputTokens: inputTokens,
|
|
704
|
+
}, ch);
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
// Non-fatal — token tracking is best-effort
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
// Persist spawn context (loaded vs archived message counts).
|
|
711
|
+
// Read from property instead of event — the event fired during start() before
|
|
712
|
+
// listeners were registered, causing a race condition.
|
|
713
|
+
if (proc.spawnContext) {
|
|
714
|
+
this.sessionStore.updateSessionMeta(this.agentConfig.id, mapKey, actualSessionId, {
|
|
715
|
+
loadedAtSpawn: proc.spawnContext.loadedAtSpawn,
|
|
716
|
+
archivedCount: proc.spawnContext.archivedCount,
|
|
717
|
+
messageCountAtSpawn: proc.spawnContext.messageCountAtSpawn,
|
|
718
|
+
}, this.channelFor(mapKey)).catch(() => { });
|
|
719
|
+
}
|
|
720
|
+
// Clean up pending typing-done timer when session stops
|
|
721
|
+
proc.once('exit', () => {
|
|
722
|
+
proc.setProcessing(false);
|
|
723
|
+
if (typingDoneTimer) {
|
|
724
|
+
clearTimeout(typingDoneTimer);
|
|
725
|
+
typingDoneTimer = null;
|
|
726
|
+
}
|
|
727
|
+
this.writeTypingDone(mapKey);
|
|
728
|
+
});
|
|
729
|
+
// Deferred restart: stop session after its current turn completes
|
|
730
|
+
proc.once('deferredRestartReady', async () => {
|
|
731
|
+
this.logger.info('Deferred restart: stopping session after turn completed', { mapKey });
|
|
732
|
+
await proc.stop();
|
|
733
|
+
this.sessions.delete(mapKey);
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
this.sessions.set(mapKey, proc);
|
|
737
|
+
if (source === 'telegram' || source === 'discord') {
|
|
738
|
+
this.channelSourceMap.set(mapKey, source);
|
|
739
|
+
}
|
|
740
|
+
this.logger.info('Spawned session', {
|
|
741
|
+
mapKey,
|
|
742
|
+
actualSessionId,
|
|
743
|
+
source,
|
|
744
|
+
total: this.sessions.size,
|
|
745
|
+
});
|
|
746
|
+
return proc;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Returns true if the message content is a session management command.
|
|
750
|
+
*/
|
|
751
|
+
isSessionCommand(content) {
|
|
752
|
+
return /^\/sessions?\b|^\/new(\s|$)|^\/clear\b|^\/compact\b|^\/rename(\s|$)|^\/stop(\s|$)/.test(content);
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Dispatch a session management command to the appropriate handler.
|
|
756
|
+
*/
|
|
757
|
+
async handleSessionCommand(chatId, content) {
|
|
758
|
+
const agentId = this.agentConfig.id;
|
|
759
|
+
if (content.startsWith('/sessions')) {
|
|
760
|
+
await this.handleCommandSessions(agentId, chatId);
|
|
761
|
+
}
|
|
762
|
+
else if (content.startsWith('/session') && !content.startsWith('/sessions')) {
|
|
763
|
+
await this.handleCommandSessionInfo(agentId, chatId);
|
|
764
|
+
}
|
|
765
|
+
else if (content.startsWith('/new')) {
|
|
766
|
+
const name = content.replace('/new', '').trim() || undefined;
|
|
767
|
+
await this.handleCommandNew(agentId, chatId, name);
|
|
768
|
+
}
|
|
769
|
+
else if (content.startsWith('/clear')) {
|
|
770
|
+
await this.handleCommandClear(agentId, chatId);
|
|
771
|
+
}
|
|
772
|
+
else if (content.startsWith('/compact')) {
|
|
773
|
+
await this.handleCommandCompact(agentId, chatId);
|
|
774
|
+
}
|
|
775
|
+
else if (content.startsWith('/rename')) {
|
|
776
|
+
const name = content.replace('/rename', '').trim();
|
|
777
|
+
await this.handleCommandRename(agentId, chatId, name);
|
|
778
|
+
}
|
|
779
|
+
else if (content.startsWith('/stop')) {
|
|
780
|
+
await this.handleCommandStop(chatId);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* /sessions — list all sessions for this chat.
|
|
785
|
+
*/
|
|
786
|
+
async handleCommandSessions(agentId, chatId) {
|
|
787
|
+
const index = await this.sessionStore.listSessions(agentId, chatId, this.channelFor(chatId));
|
|
788
|
+
const lines = [`Sessions (${agentId})`, ''];
|
|
789
|
+
for (const s of index.sessions) {
|
|
790
|
+
const isActive = s.id === index.activeSessionId;
|
|
791
|
+
const indicator = isActive ? '🟢 ' : ' ';
|
|
792
|
+
const age = this.formatAge(s.lastActive);
|
|
793
|
+
lines.push(`${indicator}${isActive ? `**${s.name}**` : s.name}`);
|
|
794
|
+
lines.push(` ${s.messageCount} messages · last active ${age}`);
|
|
795
|
+
lines.push('');
|
|
796
|
+
}
|
|
797
|
+
lines.push('Use /new [name] to create a new session');
|
|
798
|
+
this.writeAutoForward(chatId, lines.join('\n'));
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* /session — show info about the current active session.
|
|
802
|
+
*/
|
|
803
|
+
async handleCommandSessionInfo(agentId, chatId) {
|
|
804
|
+
const index = await this.sessionStore.listSessions(agentId, chatId, this.channelFor(chatId));
|
|
805
|
+
const meta = index.sessions.find(s => s.id === index.activeSessionId);
|
|
806
|
+
if (!meta) {
|
|
807
|
+
this.writeAutoForward(chatId, 'No active session found.');
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const availableModels = this.gatewayConfig.gateway.models ?? DEFAULT_MODELS;
|
|
811
|
+
const modelConfig = availableModels.find(m => m.id === this.agentConfig.claude.model);
|
|
812
|
+
const contextWindow = modelConfig?.contextWindow ?? 200000;
|
|
813
|
+
const contextTokens = meta.lastInputTokens ?? 0;
|
|
814
|
+
const usedPct = Math.round((contextTokens / contextWindow) * 100);
|
|
815
|
+
let msgs;
|
|
816
|
+
if (meta.messageCount <= 0) {
|
|
817
|
+
msgs = 'No messages yet';
|
|
818
|
+
}
|
|
819
|
+
else if ((meta.archivedCount ?? 0) > 0 && meta.loadedAtSpawn != null && meta.messageCountAtSpawn != null) {
|
|
820
|
+
const newMessagesSinceSpawn = meta.messageCount - meta.messageCountAtSpawn;
|
|
821
|
+
const inContext = meta.loadedAtSpawn + Math.max(0, newMessagesSinceSpawn);
|
|
822
|
+
msgs = `${meta.messageCount} (${inContext} in context / ${meta.archivedCount} archived)`;
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
msgs = `${meta.messageCount}`;
|
|
826
|
+
}
|
|
827
|
+
const contextLine = `${usedPct}%`;
|
|
828
|
+
const lines = [
|
|
829
|
+
`📌 Current Session: ${meta.name}`,
|
|
830
|
+
`<code>${index.activeSessionId}</code>`,
|
|
831
|
+
'',
|
|
832
|
+
`📥 Messages: ${msgs}`,
|
|
833
|
+
`👉 Context: ${contextLine}`,
|
|
834
|
+
];
|
|
835
|
+
if (usedPct >= 80) {
|
|
836
|
+
lines.push('', '💡 Near limit — consider /compact');
|
|
837
|
+
}
|
|
838
|
+
lines.push('', 'Commands: /sessions /new /rename /clear /compact');
|
|
839
|
+
const info = lines.join('\n');
|
|
840
|
+
this.writeAutoForward(chatId, info, 'html');
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* /new [name] — create a new session and switch to it.
|
|
844
|
+
*/
|
|
845
|
+
async handleCommandNew(agentId, chatId, name) {
|
|
846
|
+
const newMeta = await this.sessionStore.createTelegramSession(agentId, chatId, name, this.channelFor(chatId));
|
|
847
|
+
await this.switchSession(chatId, newMeta.id);
|
|
848
|
+
this.writeAutoForward(chatId, `✅ New session created: "${newMeta.name}"\nNow chatting in a fresh context. Use /sessions to switch back.`);
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* /rename <name> — rename the current session.
|
|
852
|
+
*/
|
|
853
|
+
async handleCommandRename(agentId, chatId, name) {
|
|
854
|
+
if (!name) {
|
|
855
|
+
this.writeAutoForward(chatId, '⚠️ Usage: /rename <new name>\nExample: /rename Design review');
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const sessionId = await this.sessionStore.getActiveSessionId(agentId, chatId, this.channelFor(chatId));
|
|
859
|
+
await this.sessionStore.updateSessionMeta(agentId, chatId, sessionId, { name }, this.channelFor(chatId));
|
|
860
|
+
this.writeAutoForward(chatId, `✅ Session renamed to "${name}"`);
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* /stop — interrupt the in-flight turn for this chat by sending SIGINT to the subprocess.
|
|
864
|
+
* Leaves session history and metadata intact. Queued messages still process afterwards.
|
|
865
|
+
*/
|
|
866
|
+
async handleCommandStop(chatId) {
|
|
867
|
+
const session = this.sessions.get(chatId);
|
|
868
|
+
const stopped = session ? session.interrupt() : false;
|
|
869
|
+
this.writeAutoForward(chatId, stopped ? 'Stopped.' : 'No turn in progress.');
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* /clear — clear history of the current session and restart the process.
|
|
873
|
+
* Also clears the permanent history DB and media files for this chat.
|
|
874
|
+
*/
|
|
875
|
+
async handleCommandClear(agentId, chatId) {
|
|
876
|
+
const ch = this.channelFor(chatId);
|
|
877
|
+
const sessionId = await this.sessionStore.getActiveSessionId(agentId, chatId, ch);
|
|
878
|
+
// Clear messages and reset all metadata in-place (preserves session ID and name)
|
|
879
|
+
await this.sessionStore.clearTelegramSessionHistory(agentId, chatId, sessionId, ch);
|
|
880
|
+
await this.sessionStore.updateSessionMeta(agentId, chatId, sessionId, {
|
|
881
|
+
totalTokensUsed: 0,
|
|
882
|
+
lastInputTokens: 0,
|
|
883
|
+
archivedCount: 0,
|
|
884
|
+
loadedAtSpawn: undefined,
|
|
885
|
+
messageCountAtSpawn: undefined,
|
|
886
|
+
}, ch);
|
|
887
|
+
// Clear permanent history DB for this chat
|
|
888
|
+
const historyChatId = `${ch}-${chatId}`;
|
|
889
|
+
this.historyDb.clearChat(historyChatId);
|
|
890
|
+
// Delete persisted media files for this chat
|
|
891
|
+
media_store_1.MediaStore.clearChatMedia(this.agentsBaseDir, agentId, historyChatId);
|
|
892
|
+
// Kill old process so next message spawns fresh
|
|
893
|
+
this.restartProcess(chatId).catch(() => { });
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* /compact — summarise old history and keep only recent messages.
|
|
897
|
+
*/
|
|
898
|
+
async handleCommandCompact(agentId, chatId) {
|
|
899
|
+
const ch = this.channelFor(chatId);
|
|
900
|
+
const sessionId = await this.sessionStore.getActiveSessionId(agentId, chatId, ch);
|
|
901
|
+
const index = await this.sessionStore.listSessions(agentId, chatId, ch);
|
|
902
|
+
const meta = index.sessions.find(s => s.id === sessionId);
|
|
903
|
+
const name = meta?.name ?? 'Session';
|
|
904
|
+
const availableModels = this.gatewayConfig.gateway.models ?? DEFAULT_MODELS;
|
|
905
|
+
const modelConfig = availableModels.find(m => m.id === this.agentConfig.claude.model);
|
|
906
|
+
const contextWindow = modelConfig?.contextWindow ?? 200000;
|
|
907
|
+
this.writeAutoForward(chatId, `⏳ Compacting session "${name}"...`);
|
|
908
|
+
try {
|
|
909
|
+
const compactor = new compactor_1.SessionCompactor(this.sessionStore);
|
|
910
|
+
const result = await compactor.compact(agentId, chatId, sessionId, this.agentConfig.claude.model, contextWindow, ch);
|
|
911
|
+
await this.sessionStore.updateSessionMeta(agentId, chatId, sessionId, {
|
|
912
|
+
loadedAtSpawn: undefined,
|
|
913
|
+
archivedCount: undefined,
|
|
914
|
+
messageCountAtSpawn: undefined,
|
|
915
|
+
}, ch);
|
|
916
|
+
await this.restartProcess(chatId);
|
|
917
|
+
const summary = [
|
|
918
|
+
`✅ Session compacted`,
|
|
919
|
+
'',
|
|
920
|
+
`Before: ${result.beforeMessages} messages (~${result.beforeTokens.toLocaleString()} tokens) → ${result.contextPctBefore}% of context`,
|
|
921
|
+
`After: ${result.afterMessages} messages (~${result.afterTokens.toLocaleString()} tokens) → ${result.contextPctAfter}% of context`,
|
|
922
|
+
`Reduced by: ${result.reductionPct}%`,
|
|
923
|
+
'',
|
|
924
|
+
'Summary preserved. Full history before compaction is archived.',
|
|
925
|
+
].join('\n');
|
|
926
|
+
this.writeAutoForward(chatId, summary);
|
|
927
|
+
}
|
|
928
|
+
catch (err) {
|
|
929
|
+
if (err.name === 'NotEnoughMessagesError') {
|
|
930
|
+
this.writeAutoForward(chatId, `⚠️ ${err.message}`);
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
this.logger.error('Compact failed', { error: err.message });
|
|
934
|
+
this.writeAutoForward(chatId, `❌ Compact failed: ${err.message}\n\nYour session history is unchanged.`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Switch the active session for a chat: stop the existing process and update the store.
|
|
940
|
+
* The new process will be lazily spawned on the next incoming message.
|
|
941
|
+
*/
|
|
942
|
+
async switchSession(chatId, newSessionId) {
|
|
943
|
+
const existing = this.sessions.get(chatId);
|
|
944
|
+
if (existing) {
|
|
945
|
+
await existing.stop();
|
|
946
|
+
this.sessions.delete(chatId);
|
|
947
|
+
}
|
|
948
|
+
await this.sessionStore.setActiveSession(this.agentConfig.id, chatId, newSessionId, this.channelFor(chatId));
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Restart the process for a given chatId (stop + remove from map).
|
|
952
|
+
* The process will be lazily re-spawned on the next incoming message.
|
|
953
|
+
*/
|
|
954
|
+
async restartProcess(chatId) {
|
|
955
|
+
const existing = this.sessions.get(chatId);
|
|
956
|
+
if (existing) {
|
|
957
|
+
await existing.stop();
|
|
958
|
+
this.sessions.delete(chatId);
|
|
959
|
+
}
|
|
960
|
+
// Process will be re-spawned on next incoming message
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Stop all idle session subprocesses so they re-spawn with the latest
|
|
964
|
+
* system prompt on the next incoming message.
|
|
965
|
+
*
|
|
966
|
+
* - "Idle" = no activity for {@link IDLE_THRESHOLD_MS}ms (enough to exclude an in-flight turn).
|
|
967
|
+
* - Busy sessions are left alone; they will be picked up by the idle cleaner
|
|
968
|
+
* (every 5m) or naturally on the next spawn after timeout.
|
|
969
|
+
* - Does NOT stop the receiver; incoming messages keep flowing.
|
|
970
|
+
*
|
|
971
|
+
* Used by the skills hot-reload path so that SKILL.md changes take effect
|
|
972
|
+
* without kicking users out of in-flight turns.
|
|
973
|
+
*/
|
|
974
|
+
async restartOrDefer() {
|
|
975
|
+
let immediate = 0;
|
|
976
|
+
let deferred = 0;
|
|
977
|
+
const toStopNow = [];
|
|
978
|
+
for (const [id, proc] of this.sessions) {
|
|
979
|
+
if (proc.isProcessing) {
|
|
980
|
+
proc.markPendingRestart();
|
|
981
|
+
deferred++;
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
toStopNow.push(id);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
for (const id of toStopNow) {
|
|
988
|
+
const proc = this.sessions.get(id);
|
|
989
|
+
if (!proc)
|
|
990
|
+
continue;
|
|
991
|
+
await proc.stop();
|
|
992
|
+
this.sessions.delete(id);
|
|
993
|
+
immediate++;
|
|
994
|
+
}
|
|
995
|
+
this.logger.info('restartOrDefer: sessions restarted', { immediate, deferred });
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Format a timestamp as a human-readable age string (e.g. "5m ago", "2h ago").
|
|
999
|
+
*/
|
|
1000
|
+
formatAge(ts) {
|
|
1001
|
+
const diffMs = Date.now() - ts;
|
|
1002
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
1003
|
+
if (diffMins < 1)
|
|
1004
|
+
return 'just now';
|
|
1005
|
+
if (diffMins < 60)
|
|
1006
|
+
return `${diffMins}m ago`;
|
|
1007
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
1008
|
+
if (diffHours < 24)
|
|
1009
|
+
return `${diffHours}h ago`;
|
|
1010
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
1011
|
+
return `${diffDays}d ago`;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Write an error code to the typing signal directory so the receiver's
|
|
1015
|
+
* typing loop can pick it up and notify the user via Telegram.
|
|
1016
|
+
* Non-fatal: if the write fails the typing loop will stop via stalled timer.
|
|
1017
|
+
*/
|
|
1018
|
+
getTypingDir(chatId) {
|
|
1019
|
+
const channel = this.channelSourceMap.get(chatId) ?? 'telegram';
|
|
1020
|
+
const stateDir = channel === 'discord' ? '.discord-state' : '.telegram-state';
|
|
1021
|
+
return path.join(this.agentConfig.workspace, stateDir, 'typing');
|
|
1022
|
+
}
|
|
1023
|
+
writeTypingError(chatId, code) {
|
|
1024
|
+
const typingDir = this.getTypingDir(chatId);
|
|
1025
|
+
try {
|
|
1026
|
+
fs.mkdirSync(typingDir, { recursive: true });
|
|
1027
|
+
fs.writeFileSync(path.join(typingDir, `${chatId}.error`), code);
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
// Non-fatal — typing loop will stop via stalled timer instead
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
writeTypingDone(chatId) {
|
|
1034
|
+
const typingDir = this.getTypingDir(chatId);
|
|
1035
|
+
try {
|
|
1036
|
+
fs.rmSync(path.join(typingDir, chatId), { force: true });
|
|
1037
|
+
}
|
|
1038
|
+
catch {
|
|
1039
|
+
// Non-fatal
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
async triggerSummaryAndRestart(chatId, sessionId, session) {
|
|
1043
|
+
const IMAGE_CONTEXT_MARKER = '[Image Context Summary]';
|
|
1044
|
+
try {
|
|
1045
|
+
const prompt = [
|
|
1046
|
+
'Briefly summarize each image you have seen in this conversation.',
|
|
1047
|
+
'Format: "Image N: [1-2 sentence description]"',
|
|
1048
|
+
'List every image separately.',
|
|
1049
|
+
].join(' ');
|
|
1050
|
+
const description = await session.query(prompt);
|
|
1051
|
+
if (description) {
|
|
1052
|
+
const msg = {
|
|
1053
|
+
role: 'system',
|
|
1054
|
+
content: `${IMAGE_CONTEXT_MARKER}\n${description}`,
|
|
1055
|
+
ts: Date.now(),
|
|
1056
|
+
};
|
|
1057
|
+
await this.sessionStore.appendTelegramMessage(this.agentConfig.id, chatId, sessionId, msg, this.channelFor(chatId));
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
catch (err) {
|
|
1061
|
+
this.logger.warn('Image context summary failed', { error: err instanceof Error ? err.message : String(err) });
|
|
1062
|
+
}
|
|
1063
|
+
this.pendingRestarts.add(chatId);
|
|
1064
|
+
}
|
|
1065
|
+
writeAutoForward(chatId, text, format = 'text') {
|
|
1066
|
+
const typingDir = this.getTypingDir(chatId);
|
|
1067
|
+
try {
|
|
1068
|
+
fs.mkdirSync(typingDir, { recursive: true });
|
|
1069
|
+
fs.writeFileSync(path.join(typingDir, `${chatId}.forward`), JSON.stringify({ text, format }));
|
|
1070
|
+
}
|
|
1071
|
+
catch {
|
|
1072
|
+
// Non-fatal
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
startIdleCleaner() {
|
|
1076
|
+
this.idleCleanerTimer = setInterval(async () => {
|
|
1077
|
+
for (const [id, proc] of this.sessions) {
|
|
1078
|
+
if (proc.isIdle(this.idleTimeoutMs)) {
|
|
1079
|
+
this.logger.info('Stopping idle session', { sessionId: id });
|
|
1080
|
+
await proc.stop();
|
|
1081
|
+
this.sessions.delete(id);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}, 5 * 60 * 1000);
|
|
1085
|
+
}
|
|
1086
|
+
async start() {
|
|
1087
|
+
this.stopping = false;
|
|
1088
|
+
await this.startCallbackServer();
|
|
1089
|
+
if (this.agentConfig.telegram?.botToken) {
|
|
1090
|
+
this.receiver = new receiver_1.TelegramReceiver(this.agentConfig, this.callbackPort, this.gatewayConfig.gateway.logDir);
|
|
1091
|
+
this.receiver.start();
|
|
1092
|
+
}
|
|
1093
|
+
if (this.agentConfig.discord?.botToken) {
|
|
1094
|
+
this.discordReceiver = new receiver_2.DiscordReceiver(this.agentConfig, this.callbackPort, this.gatewayConfig.gateway.logDir);
|
|
1095
|
+
this.discordReceiver.start();
|
|
1096
|
+
}
|
|
1097
|
+
this.startIdleCleaner();
|
|
1098
|
+
this._startCleanupScheduler();
|
|
1099
|
+
this.logger.info('AgentRunner started', { agentId: this.agentConfig.id });
|
|
1100
|
+
}
|
|
1101
|
+
startDiscordReceiver() {
|
|
1102
|
+
if (this.discordReceiver?.isRunning())
|
|
1103
|
+
return;
|
|
1104
|
+
this.discordReceiver = new receiver_2.DiscordReceiver(this.agentConfig, this.callbackPort, this.gatewayConfig.gateway.logDir);
|
|
1105
|
+
this.discordReceiver.start();
|
|
1106
|
+
this.logger.info('DiscordReceiver hot-started', { agentId: this.agentConfig.id });
|
|
1107
|
+
}
|
|
1108
|
+
async stop() {
|
|
1109
|
+
this.stopping = true;
|
|
1110
|
+
if (this.idleCleanerTimer !== null) {
|
|
1111
|
+
clearInterval(this.idleCleanerTimer);
|
|
1112
|
+
this.idleCleanerTimer = null;
|
|
1113
|
+
}
|
|
1114
|
+
this.cancelCleanup?.();
|
|
1115
|
+
this.cancelCleanup = null;
|
|
1116
|
+
this.callbackServer?.close();
|
|
1117
|
+
this.callbackServer = null;
|
|
1118
|
+
this.receiver?.stop();
|
|
1119
|
+
this.discordReceiver?.stop();
|
|
1120
|
+
await Promise.all([...this.sessions.values()].map((s) => s.stop()));
|
|
1121
|
+
this.sessions.clear();
|
|
1122
|
+
}
|
|
1123
|
+
_startCleanupScheduler() {
|
|
1124
|
+
const gw = this.gatewayConfig.gateway;
|
|
1125
|
+
const retentionDays = (0, cleanup_1.resolveRetentionDays)(this.agentConfig.history?.retentionDays, gw.history?.retentionDays);
|
|
1126
|
+
const cleanupHour = gw.history?.cleanupHour ?? 0;
|
|
1127
|
+
const cleanupTimezone = gw.history?.cleanupTimezone ?? 'UTC';
|
|
1128
|
+
const agentMediaRoot = media_store_1.MediaStore.agentMediaRoot(this.agentsBaseDir, this.agentConfig.id);
|
|
1129
|
+
const logPath = path.join(this.agentDir, 'cleanup.log');
|
|
1130
|
+
this.cancelCleanup = (0, cleanup_1.scheduleCleanup)({
|
|
1131
|
+
db: this.historyDb,
|
|
1132
|
+
agentMediaRoot,
|
|
1133
|
+
logPath,
|
|
1134
|
+
retentionDays,
|
|
1135
|
+
cleanupHour,
|
|
1136
|
+
cleanupTimezone,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
async restart() {
|
|
1140
|
+
await this.stop();
|
|
1141
|
+
this.stopping = false;
|
|
1142
|
+
await this.start();
|
|
1143
|
+
}
|
|
1144
|
+
isRunning() {
|
|
1145
|
+
return this.receiver?.isRunning() ?? false;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Send a message to an API session and wait for the response.
|
|
1149
|
+
*
|
|
1150
|
+
* - Spawns a new SessionProcess (source='api') if none exists for sessionId.
|
|
1151
|
+
* - Rejects with code 'CONFLICT' if a prior request is still in-flight for this session.
|
|
1152
|
+
* - Rejects with code 'TIMEOUT' if Claude does not respond within timeoutMs.
|
|
1153
|
+
* - Appends user message and assistant reply to SessionStore for history persistence.
|
|
1154
|
+
*/
|
|
1155
|
+
async sendApiMessage(sessionId, chatId, message, opts) {
|
|
1156
|
+
if (this.pendingApiSessions.has(sessionId)) {
|
|
1157
|
+
const err = Object.assign(new Error(`Session ${sessionId} already has a pending request`), { code: 'CONFLICT' });
|
|
1158
|
+
throw err;
|
|
1159
|
+
}
|
|
1160
|
+
const session = await this.getOrSpawnSession(sessionId, 'api');
|
|
1161
|
+
// Register session in api-{chatId} index.json on first use
|
|
1162
|
+
const internalChatIdForSession = `api-${chatId}`;
|
|
1163
|
+
await this.sessionStore.ensureApiSession(this.agentConfig.id, internalChatIdForSession, sessionId).catch((err) => {
|
|
1164
|
+
this.logger.warn('Failed to register API session in index', { agentId: this.agentConfig.id, chatId, sessionId, error: err.message });
|
|
1165
|
+
});
|
|
1166
|
+
// Promote UI-uploaded files from staging to permanent per-session storage
|
|
1167
|
+
const finalMediaFiles = opts.mediaFiles?.length
|
|
1168
|
+
? await promoteUiUploads(this.agentsBaseDir, this.agentConfig.id, sessionId, opts.mediaFiles, this.logger)
|
|
1169
|
+
: undefined;
|
|
1170
|
+
// Resolve media files to absolute paths for file-path based image passing
|
|
1171
|
+
// (same pattern as Telegram — Claude Code reads files via Read tool instead of base64 inline)
|
|
1172
|
+
const imagePaths = finalMediaFiles?.length ? this.resolveMediaPaths(finalMediaFiles) : [];
|
|
1173
|
+
// Persist user message
|
|
1174
|
+
const apiUserTs = Date.now();
|
|
1175
|
+
await this.sessionStore
|
|
1176
|
+
.appendMessage(this.agentConfig.id, sessionId, {
|
|
1177
|
+
role: 'user',
|
|
1178
|
+
content: message,
|
|
1179
|
+
ts: apiUserTs,
|
|
1180
|
+
})
|
|
1181
|
+
.catch(() => { });
|
|
1182
|
+
this.historyDb.insertMessage({
|
|
1183
|
+
chatId: `api-${chatId}`,
|
|
1184
|
+
sessionId,
|
|
1185
|
+
source: 'api',
|
|
1186
|
+
role: 'user',
|
|
1187
|
+
content: message,
|
|
1188
|
+
mediaFiles: finalMediaFiles?.length ? finalMediaFiles : undefined,
|
|
1189
|
+
ts: apiUserTs,
|
|
1190
|
+
});
|
|
1191
|
+
this.pendingApiSessions.add(sessionId);
|
|
1192
|
+
session.touch();
|
|
1193
|
+
// Image paths only work when allowTools:true — Claude needs the Read tool to access them
|
|
1194
|
+
const allowTools = opts.allowTools ?? false;
|
|
1195
|
+
if (!allowTools && imagePaths.length) {
|
|
1196
|
+
this.logger.warn('Images ignored: allowTools is false, Claude cannot use Read tool', { sessionId, imageCount: imagePaths.length });
|
|
1197
|
+
}
|
|
1198
|
+
const effectiveImagePaths = allowTools ? imagePaths : [];
|
|
1199
|
+
const systemNote = buildApiSystemNote(allowTools, effectiveImagePaths.length ? effectiveImagePaths : undefined);
|
|
1200
|
+
// Detect skill commands (same as channel message path)
|
|
1201
|
+
const skillInvocation = (0, skills_1.detectSkillCommand)(message, this.skillRegistry);
|
|
1202
|
+
if (skillInvocation) {
|
|
1203
|
+
this.logger.info('Skill invoked via API', {
|
|
1204
|
+
skill: skillInvocation.skillKey,
|
|
1205
|
+
args: skillInvocation.args,
|
|
1206
|
+
sessionId,
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
// Build channel XML with image_path attribute (like Telegram) for first image
|
|
1210
|
+
const imageAttr = effectiveImagePaths.length ? ` image_path="${AgentRunner.escapeXmlAttr(effectiveImagePaths[0])}"` : '';
|
|
1211
|
+
const channelXml = `<channel source="api" chat_id="${chatId}" session_id="${sessionId}" ts="${new Date().toISOString()}"${imageAttr}>\n` +
|
|
1212
|
+
`${message}\n\n` +
|
|
1213
|
+
`${systemNote}` +
|
|
1214
|
+
`</channel>` +
|
|
1215
|
+
(skillInvocation ? `\n${(0, skills_1.formatSkillContext)(skillInvocation)}` : '');
|
|
1216
|
+
return new Promise((resolve, reject) => {
|
|
1217
|
+
const buffer = [];
|
|
1218
|
+
let quietTimer;
|
|
1219
|
+
// Track partial message text for delta computation (--include-partial-messages)
|
|
1220
|
+
let lastPartialText = '';
|
|
1221
|
+
const done = (result) => {
|
|
1222
|
+
cleanup();
|
|
1223
|
+
// Persist assistant reply
|
|
1224
|
+
if (result.trim()) {
|
|
1225
|
+
const apiAssistantTs = Date.now();
|
|
1226
|
+
this.sessionStore
|
|
1227
|
+
.appendMessage(this.agentConfig.id, sessionId, {
|
|
1228
|
+
role: 'assistant',
|
|
1229
|
+
content: result.trim(),
|
|
1230
|
+
ts: apiAssistantTs,
|
|
1231
|
+
})
|
|
1232
|
+
.catch(() => { });
|
|
1233
|
+
this.historyDb.insertMessage({
|
|
1234
|
+
chatId: `api-${chatId}`,
|
|
1235
|
+
sessionId,
|
|
1236
|
+
source: 'api',
|
|
1237
|
+
role: 'assistant',
|
|
1238
|
+
content: result.trim(),
|
|
1239
|
+
ts: apiAssistantTs,
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
resolve(result.trim());
|
|
1243
|
+
};
|
|
1244
|
+
const fail = (err) => {
|
|
1245
|
+
cleanup();
|
|
1246
|
+
reject(err);
|
|
1247
|
+
};
|
|
1248
|
+
const cleanup = () => {
|
|
1249
|
+
clearTimeout(globalTimer);
|
|
1250
|
+
if (quietTimer)
|
|
1251
|
+
clearTimeout(quietTimer);
|
|
1252
|
+
session.off('output', onOutput);
|
|
1253
|
+
this.pendingApiSessions.delete(sessionId);
|
|
1254
|
+
};
|
|
1255
|
+
const resetQuiet = () => {
|
|
1256
|
+
if (quietTimer)
|
|
1257
|
+
clearTimeout(quietTimer);
|
|
1258
|
+
quietTimer = setTimeout(() => done(buffer.join('')), 2000);
|
|
1259
|
+
};
|
|
1260
|
+
const onOutput = (line) => {
|
|
1261
|
+
try {
|
|
1262
|
+
const obj = JSON.parse(line);
|
|
1263
|
+
// Handle partial assistant messages (from --include-partial-messages)
|
|
1264
|
+
if (obj['type'] === 'assistant') {
|
|
1265
|
+
const msg = obj['message'];
|
|
1266
|
+
if (Array.isArray(msg?.content)) {
|
|
1267
|
+
let fullText = '';
|
|
1268
|
+
for (const block of msg.content) {
|
|
1269
|
+
if (block.type === 'text' && block.text)
|
|
1270
|
+
fullText += block.text;
|
|
1271
|
+
}
|
|
1272
|
+
if (fullText.length > lastPartialText.length) {
|
|
1273
|
+
buffer.push(fullText.slice(lastPartialText.length));
|
|
1274
|
+
resetQuiet();
|
|
1275
|
+
}
|
|
1276
|
+
lastPartialText = fullText;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
// Standalone text delta
|
|
1280
|
+
if (obj['type'] === 'text') {
|
|
1281
|
+
const text = obj['text'] ?? '';
|
|
1282
|
+
if (text) {
|
|
1283
|
+
buffer.push(text);
|
|
1284
|
+
resetQuiet();
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
// result event = end of turn
|
|
1288
|
+
if (obj['type'] === 'result') {
|
|
1289
|
+
session.setProcessing(false);
|
|
1290
|
+
const resultText = obj['result'] ?? buffer.join('');
|
|
1291
|
+
done(resultText);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
catch {
|
|
1295
|
+
/* non-JSON stdout line */
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
const globalTimer = setTimeout(() => {
|
|
1299
|
+
session.setProcessing(false);
|
|
1300
|
+
fail(Object.assign(new Error('Agent response timeout'), { code: 'TIMEOUT' }));
|
|
1301
|
+
}, opts.timeoutMs);
|
|
1302
|
+
session.on('output', onOutput);
|
|
1303
|
+
session.setProcessing(true);
|
|
1304
|
+
session.sendMessage(channelXml);
|
|
1305
|
+
// Do NOT call resetQuiet() here — the quiet timer should only start
|
|
1306
|
+
// after the first output line arrives, otherwise it fires before the
|
|
1307
|
+
// subprocess has had time to respond (especially on first turn).
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Send a message to an API session and stream back events via callbacks.
|
|
1312
|
+
*
|
|
1313
|
+
* Returns a cleanup function that removes all listeners and frees the session slot.
|
|
1314
|
+
* The caller MUST invoke cleanup on client disconnect or when done.
|
|
1315
|
+
*/
|
|
1316
|
+
async sendApiMessageStream(sessionId, chatId, message, callbacks, opts) {
|
|
1317
|
+
if (this.pendingApiSessions.has(sessionId)) {
|
|
1318
|
+
const err = Object.assign(new Error(`Session ${sessionId} already has a pending request`), { code: 'CONFLICT' });
|
|
1319
|
+
throw err;
|
|
1320
|
+
}
|
|
1321
|
+
const session = await this.getOrSpawnSession(sessionId, 'api');
|
|
1322
|
+
// Register session in api-{chatId} index.json on first use
|
|
1323
|
+
const internalChatIdStream = `api-${chatId}`;
|
|
1324
|
+
await this.sessionStore.ensureApiSession(this.agentConfig.id, internalChatIdStream, sessionId).catch((err) => {
|
|
1325
|
+
this.logger.warn('Failed to register API session in index', { agentId: this.agentConfig.id, chatId, sessionId, error: err.message });
|
|
1326
|
+
});
|
|
1327
|
+
// Promote UI-uploaded files from staging to permanent per-session storage
|
|
1328
|
+
const finalMediaFilesStream = opts.mediaFiles?.length
|
|
1329
|
+
? await promoteUiUploads(this.agentsBaseDir, this.agentConfig.id, sessionId, opts.mediaFiles, this.logger)
|
|
1330
|
+
: undefined;
|
|
1331
|
+
// Resolve media files to absolute paths for file-path based image passing
|
|
1332
|
+
const imagePathsStream = finalMediaFilesStream?.length ? this.resolveMediaPaths(finalMediaFilesStream) : [];
|
|
1333
|
+
// Persist user message
|
|
1334
|
+
const streamUserTs = Date.now();
|
|
1335
|
+
await this.sessionStore
|
|
1336
|
+
.appendMessage(this.agentConfig.id, sessionId, {
|
|
1337
|
+
role: 'user',
|
|
1338
|
+
content: message,
|
|
1339
|
+
ts: streamUserTs,
|
|
1340
|
+
})
|
|
1341
|
+
.catch(() => { });
|
|
1342
|
+
this.historyDb.insertMessage({
|
|
1343
|
+
chatId: `api-${chatId}`,
|
|
1344
|
+
sessionId,
|
|
1345
|
+
source: 'api',
|
|
1346
|
+
role: 'user',
|
|
1347
|
+
content: message,
|
|
1348
|
+
mediaFiles: finalMediaFilesStream?.length ? finalMediaFilesStream : undefined,
|
|
1349
|
+
ts: streamUserTs,
|
|
1350
|
+
});
|
|
1351
|
+
this.pendingApiSessions.add(sessionId);
|
|
1352
|
+
session.touch();
|
|
1353
|
+
const buffer = [];
|
|
1354
|
+
let settled = false;
|
|
1355
|
+
// Track partial message text for delta computation (--include-partial-messages)
|
|
1356
|
+
let lastPartialText = '';
|
|
1357
|
+
const done = (result) => {
|
|
1358
|
+
if (settled)
|
|
1359
|
+
return;
|
|
1360
|
+
settled = true;
|
|
1361
|
+
cleanup();
|
|
1362
|
+
// Persist assistant reply
|
|
1363
|
+
if (result.trim()) {
|
|
1364
|
+
const streamAssistantTs = Date.now();
|
|
1365
|
+
this.sessionStore
|
|
1366
|
+
.appendMessage(this.agentConfig.id, sessionId, {
|
|
1367
|
+
role: 'assistant',
|
|
1368
|
+
content: result.trim(),
|
|
1369
|
+
ts: streamAssistantTs,
|
|
1370
|
+
})
|
|
1371
|
+
.catch(() => { });
|
|
1372
|
+
this.historyDb.insertMessage({
|
|
1373
|
+
chatId: `api-${chatId}`,
|
|
1374
|
+
sessionId,
|
|
1375
|
+
source: 'api',
|
|
1376
|
+
role: 'assistant',
|
|
1377
|
+
content: result.trim(),
|
|
1378
|
+
ts: streamAssistantTs,
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
callbacks.onDone(result.trim());
|
|
1382
|
+
};
|
|
1383
|
+
const fail = (err) => {
|
|
1384
|
+
if (settled)
|
|
1385
|
+
return;
|
|
1386
|
+
settled = true;
|
|
1387
|
+
cleanup();
|
|
1388
|
+
callbacks.onError(err);
|
|
1389
|
+
};
|
|
1390
|
+
const cleanup = () => {
|
|
1391
|
+
clearTimeout(globalTimer);
|
|
1392
|
+
session.off('output', onOutput);
|
|
1393
|
+
this.pendingApiSessions.delete(sessionId);
|
|
1394
|
+
};
|
|
1395
|
+
const onOutput = (line) => {
|
|
1396
|
+
try {
|
|
1397
|
+
const obj = JSON.parse(line);
|
|
1398
|
+
// Partial assistant message (from --include-partial-messages)
|
|
1399
|
+
// Contains cumulative text; compute delta and emit as text_delta
|
|
1400
|
+
if (obj['type'] === 'assistant') {
|
|
1401
|
+
const msg = obj['message'];
|
|
1402
|
+
if (Array.isArray(msg?.content)) {
|
|
1403
|
+
let fullText = '';
|
|
1404
|
+
for (const block of msg.content) {
|
|
1405
|
+
if (block.type === 'text' && block.text)
|
|
1406
|
+
fullText += block.text;
|
|
1407
|
+
}
|
|
1408
|
+
if (fullText.length > lastPartialText.length) {
|
|
1409
|
+
const delta = fullText.slice(lastPartialText.length);
|
|
1410
|
+
buffer.push(delta);
|
|
1411
|
+
callbacks.onChunk({ type: 'text_delta', text: delta });
|
|
1412
|
+
}
|
|
1413
|
+
lastPartialText = fullText;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
// Standalone text delta (legacy format)
|
|
1417
|
+
if (obj['type'] === 'text') {
|
|
1418
|
+
const text = obj['text'] ?? '';
|
|
1419
|
+
if (text) {
|
|
1420
|
+
buffer.push(text);
|
|
1421
|
+
callbacks.onChunk({ type: 'text_delta', text });
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
// stream_event from --output-format stream-json
|
|
1425
|
+
// Format: {"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"..."}}}
|
|
1426
|
+
if (obj['type'] === 'stream_event') {
|
|
1427
|
+
const event = obj['event'];
|
|
1428
|
+
if (event?.['type'] === 'content_block_delta') {
|
|
1429
|
+
const delta = event['delta'];
|
|
1430
|
+
if (delta?.['type'] === 'text_delta' && typeof delta['text'] === 'string' && delta['text']) {
|
|
1431
|
+
buffer.push(delta['text']);
|
|
1432
|
+
// Update lastPartialText so the final 'assistant' message won't re-send the full text
|
|
1433
|
+
lastPartialText += delta['text'];
|
|
1434
|
+
callbacks.onChunk({ type: 'text_delta', text: delta['text'] });
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
// Text from delta field (other formats)
|
|
1439
|
+
if (obj['type'] !== 'assistant' && obj['type'] !== 'text' && obj['type'] !== 'result' && obj['type'] !== 'stream_event') {
|
|
1440
|
+
const deltaText = obj['delta']?.['text'];
|
|
1441
|
+
if (deltaText) {
|
|
1442
|
+
buffer.push(deltaText);
|
|
1443
|
+
callbacks.onChunk({ type: 'text_delta', text: deltaText });
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
// Tool use
|
|
1447
|
+
if (obj['type'] === 'tool_use') {
|
|
1448
|
+
callbacks.onChunk({
|
|
1449
|
+
type: 'tool_use',
|
|
1450
|
+
name: obj['name'] ?? '',
|
|
1451
|
+
id: obj['id'] ?? '',
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
// Thinking
|
|
1455
|
+
if (obj['type'] === 'thinking') {
|
|
1456
|
+
callbacks.onChunk({
|
|
1457
|
+
type: 'thinking',
|
|
1458
|
+
text: obj['text'] ?? '',
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
// Result = end of turn
|
|
1462
|
+
if (obj['type'] === 'result') {
|
|
1463
|
+
session.setProcessing(false);
|
|
1464
|
+
lastPartialText = ''; // reset for next turn
|
|
1465
|
+
const resultText = obj['result'] ?? buffer.join('');
|
|
1466
|
+
done(resultText);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
catch {
|
|
1470
|
+
/* non-JSON stdout line */
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
const globalTimer = setTimeout(() => {
|
|
1474
|
+
session.setProcessing(false);
|
|
1475
|
+
fail(Object.assign(new Error('Agent response timeout'), { code: 'TIMEOUT' }));
|
|
1476
|
+
}, opts.timeoutMs);
|
|
1477
|
+
session.on('output', onOutput);
|
|
1478
|
+
// Image paths only work when allowTools:true — Claude needs the Read tool to access them
|
|
1479
|
+
const allowToolsStream = opts.allowTools ?? false;
|
|
1480
|
+
if (!allowToolsStream && imagePathsStream.length) {
|
|
1481
|
+
this.logger.warn('Images ignored: allowTools is false, Claude cannot use Read tool', { sessionId, imageCount: imagePathsStream.length });
|
|
1482
|
+
}
|
|
1483
|
+
const effectiveImagePathsStream = allowToolsStream ? imagePathsStream : [];
|
|
1484
|
+
const systemNote = buildApiSystemNote(allowToolsStream, effectiveImagePathsStream.length ? effectiveImagePathsStream : undefined);
|
|
1485
|
+
// Detect skill commands (same as channel message path)
|
|
1486
|
+
const skillInvocationStream = (0, skills_1.detectSkillCommand)(message, this.skillRegistry);
|
|
1487
|
+
if (skillInvocationStream) {
|
|
1488
|
+
this.logger.info('Skill invoked via API stream', {
|
|
1489
|
+
skill: skillInvocationStream.skillKey,
|
|
1490
|
+
args: skillInvocationStream.args,
|
|
1491
|
+
sessionId,
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
// Build channel XML with image_path attribute (like Telegram) for first image
|
|
1495
|
+
const imageAttrStream = effectiveImagePathsStream.length ? ` image_path="${AgentRunner.escapeXmlAttr(effectiveImagePathsStream[0])}"` : '';
|
|
1496
|
+
const channelXml = `<channel source="api" chat_id="${chatId}" session_id="${sessionId}" ts="${new Date().toISOString()}"${imageAttrStream}>\n` +
|
|
1497
|
+
`${message}\n\n` +
|
|
1498
|
+
systemNote +
|
|
1499
|
+
`</channel>` +
|
|
1500
|
+
(skillInvocationStream ? `\n${(0, skills_1.formatSkillContext)(skillInvocationStream)}` : '');
|
|
1501
|
+
session.setProcessing(true);
|
|
1502
|
+
session.sendMessage(channelXml);
|
|
1503
|
+
// Return cleanup function for client disconnect
|
|
1504
|
+
return () => {
|
|
1505
|
+
if (!settled) {
|
|
1506
|
+
settled = true;
|
|
1507
|
+
cleanup();
|
|
1508
|
+
}
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Check if a session has a pending API request (for preflight conflict check).
|
|
1513
|
+
*/
|
|
1514
|
+
hasActiveApiSession(sessionId) {
|
|
1515
|
+
return this.pendingApiSessions.has(sessionId);
|
|
1516
|
+
}
|
|
1517
|
+
getAgentsBaseDir() {
|
|
1518
|
+
return this.agentsBaseDir;
|
|
1519
|
+
}
|
|
1520
|
+
getAgentDir() {
|
|
1521
|
+
return this.agentDir;
|
|
1522
|
+
}
|
|
1523
|
+
getHistoryDb() {
|
|
1524
|
+
return this.historyDb;
|
|
1525
|
+
}
|
|
1526
|
+
getAllSessionNames() {
|
|
1527
|
+
return this.sessionStore.getAllSessionNames(this.agentConfig.id);
|
|
1528
|
+
}
|
|
1529
|
+
async listSessionsForChat(chatId, channel) {
|
|
1530
|
+
return this.sessionStore.listSessions(this.agentConfig.id, chatId, channel);
|
|
1531
|
+
}
|
|
1532
|
+
async executeApiCommand(sessionId, chatId, command) {
|
|
1533
|
+
const agentId = this.agentConfig.id;
|
|
1534
|
+
const internalChatId = `api-${chatId}`;
|
|
1535
|
+
if (command === '/model') {
|
|
1536
|
+
return { model: this.agentConfig.claude.model };
|
|
1537
|
+
}
|
|
1538
|
+
if (command === '/stop') {
|
|
1539
|
+
const session = this.sessions.get(sessionId);
|
|
1540
|
+
const stopped = session ? session.interrupt() : false;
|
|
1541
|
+
return { stopped };
|
|
1542
|
+
}
|
|
1543
|
+
if (command === '/restart') {
|
|
1544
|
+
this.restartProcess(sessionId).catch(() => { });
|
|
1545
|
+
return { restarting: true };
|
|
1546
|
+
}
|
|
1547
|
+
if (command === '/session') {
|
|
1548
|
+
const index = await this.sessionStore.listSessions(agentId, internalChatId, 'api').catch(() => null);
|
|
1549
|
+
const meta = index?.sessions.find((s) => s.id === sessionId);
|
|
1550
|
+
if (!meta)
|
|
1551
|
+
return { sessionId, sessionName: null, messageCount: 0, archivedCount: 0, contextUsedPct: 0, model: this.agentConfig.claude.model };
|
|
1552
|
+
const availableModels = this.gatewayConfig.gateway.models ?? DEFAULT_MODELS;
|
|
1553
|
+
const modelConfig = availableModels.find((m) => m.id === this.agentConfig.claude.model);
|
|
1554
|
+
const contextWindow = modelConfig?.contextWindow ?? 200000;
|
|
1555
|
+
const contextUsedPct = Math.round(((meta.lastInputTokens ?? 0) / contextWindow) * 100);
|
|
1556
|
+
return {
|
|
1557
|
+
sessionId,
|
|
1558
|
+
sessionName: meta.name,
|
|
1559
|
+
messageCount: meta.messageCount,
|
|
1560
|
+
archivedCount: meta.archivedCount ?? 0,
|
|
1561
|
+
contextUsedPct,
|
|
1562
|
+
model: this.agentConfig.claude.model,
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
if (command === '/clear') {
|
|
1566
|
+
const ch = 'api';
|
|
1567
|
+
await this.sessionStore.clearTelegramSessionHistory(agentId, internalChatId, sessionId, ch);
|
|
1568
|
+
await this.sessionStore.updateSessionMeta(agentId, internalChatId, sessionId, {
|
|
1569
|
+
totalTokensUsed: 0,
|
|
1570
|
+
lastInputTokens: 0,
|
|
1571
|
+
archivedCount: 0,
|
|
1572
|
+
loadedAtSpawn: undefined,
|
|
1573
|
+
messageCountAtSpawn: undefined,
|
|
1574
|
+
}, ch);
|
|
1575
|
+
const mediaPaths = this.historyDb.clearSession(internalChatId, sessionId);
|
|
1576
|
+
media_store_1.MediaStore.deleteMediaFiles(this.agentsBaseDir, agentId, mediaPaths);
|
|
1577
|
+
this.restartProcess(sessionId).catch(() => { });
|
|
1578
|
+
return { success: true };
|
|
1579
|
+
}
|
|
1580
|
+
if (command === '/compact') {
|
|
1581
|
+
const ch = 'api';
|
|
1582
|
+
const availableModels = this.gatewayConfig.gateway.models ?? DEFAULT_MODELS;
|
|
1583
|
+
const modelConfig = availableModels.find((m) => m.id === this.agentConfig.claude.model);
|
|
1584
|
+
const contextWindow = modelConfig?.contextWindow ?? 200000;
|
|
1585
|
+
const compactor = new compactor_1.SessionCompactor(this.sessionStore);
|
|
1586
|
+
const result = await compactor.compact(agentId, internalChatId, sessionId, this.agentConfig.claude.model, contextWindow, ch);
|
|
1587
|
+
await this.sessionStore.updateSessionMeta(agentId, internalChatId, sessionId, {
|
|
1588
|
+
loadedAtSpawn: undefined,
|
|
1589
|
+
archivedCount: undefined,
|
|
1590
|
+
messageCountAtSpawn: undefined,
|
|
1591
|
+
}, ch);
|
|
1592
|
+
await this.restartProcess(sessionId);
|
|
1593
|
+
return { success: true, keptMessages: result.afterMessages, archivedMessages: result.beforeMessages - result.afterMessages };
|
|
1594
|
+
}
|
|
1595
|
+
throw new Error(`Unknown command: ${command}`);
|
|
1596
|
+
}
|
|
1597
|
+
async setModel(newModel) {
|
|
1598
|
+
const availableModels = this.gatewayConfig.gateway.models ?? DEFAULT_MODELS;
|
|
1599
|
+
if (!availableModels.find((m) => m.id === newModel)) {
|
|
1600
|
+
throw Object.assign(new Error(`Unknown model: ${newModel}`), { code: 'UNKNOWN_MODEL' });
|
|
1601
|
+
}
|
|
1602
|
+
this.agentConfig.claude.model = newModel;
|
|
1603
|
+
try {
|
|
1604
|
+
this.persistModelToConfig(newModel);
|
|
1605
|
+
}
|
|
1606
|
+
catch (err) {
|
|
1607
|
+
this.logger.error('Failed to persist model to config', { error: err.message });
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
async listApiSessions(chatId) {
|
|
1611
|
+
return this.sessionStore.listSessions(this.agentConfig.id, `api-${chatId}`, 'api');
|
|
1612
|
+
}
|
|
1613
|
+
async createApiSession(chatId, prompt, name) {
|
|
1614
|
+
let sessionName = name;
|
|
1615
|
+
if (!sessionName && prompt) {
|
|
1616
|
+
try {
|
|
1617
|
+
const { execFile } = await Promise.resolve().then(() => __importStar(require('child_process')));
|
|
1618
|
+
const { promisify } = await Promise.resolve().then(() => __importStar(require('util')));
|
|
1619
|
+
const execFileAsync = promisify(execFile);
|
|
1620
|
+
const titlePrompt = `Summarise in 3-5 words as a session title (no punctuation, no quotes): ${prompt}`;
|
|
1621
|
+
const { stdout } = await execFileAsync('claude', ['-p', titlePrompt, '--output-format', 'text', '--model', 'claude-haiku-4-5-20251001'], { timeout: 15000, encoding: 'utf-8' });
|
|
1622
|
+
sessionName = stdout.trim().slice(0, 60) || undefined;
|
|
1623
|
+
}
|
|
1624
|
+
catch {
|
|
1625
|
+
sessionName = undefined;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
return this.sessionStore.createTelegramSession(this.agentConfig.id, `api-${chatId}`, sessionName, 'api');
|
|
1629
|
+
}
|
|
1630
|
+
async getApiSessionInfo(chatId, sessionId) {
|
|
1631
|
+
const index = await this.sessionStore.listSessions(this.agentConfig.id, `api-${chatId}`, 'api').catch(() => null);
|
|
1632
|
+
const meta = index?.sessions.find((s) => s.id === sessionId);
|
|
1633
|
+
if (!meta)
|
|
1634
|
+
return null;
|
|
1635
|
+
const availableModels = this.gatewayConfig.gateway.models ?? DEFAULT_MODELS;
|
|
1636
|
+
const modelConfig = availableModels.find((m) => m.id === this.agentConfig.claude.model);
|
|
1637
|
+
const contextWindow = modelConfig?.contextWindow ?? 200000;
|
|
1638
|
+
const contextUsedPct = Math.round(((meta.lastInputTokens ?? 0) / contextWindow) * 100);
|
|
1639
|
+
return {
|
|
1640
|
+
sessionId: meta.id,
|
|
1641
|
+
sessionName: meta.name,
|
|
1642
|
+
messageCount: meta.messageCount,
|
|
1643
|
+
archivedCount: meta.archivedCount ?? 0,
|
|
1644
|
+
contextUsedPct,
|
|
1645
|
+
model: this.agentConfig.claude.model,
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
async renameApiSession(chatId, sessionId, sessionName) {
|
|
1649
|
+
await this.sessionStore.updateSessionMeta(this.agentConfig.id, `api-${chatId}`, sessionId, { name: sessionName }, 'api');
|
|
1650
|
+
}
|
|
1651
|
+
async deleteApiSession(chatId, sessionId) {
|
|
1652
|
+
await this.sessionStore.deleteTelegramSession(this.agentConfig.id, `api-${chatId}`, sessionId, 'api');
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Send a message into an existing channel session (cross-channel continuation from UI).
|
|
1656
|
+
* The session process receives full history context from the session JSON (Layer 1).
|
|
1657
|
+
* The reply is streamed back via SSE callbacks and persisted to history DB.
|
|
1658
|
+
*/
|
|
1659
|
+
async sendMessageToSession(rawChatId, channel, sessionId, message, senderName, callbacks, opts) {
|
|
1660
|
+
// Ensure the session process uses the correct channel source
|
|
1661
|
+
this.channelSourceMap.set(rawChatId, channel);
|
|
1662
|
+
const session = await this.getOrSpawnSession(rawChatId, channel, sessionId);
|
|
1663
|
+
// Persist user message (Layer 1 session JSON + Layer 2 history DB)
|
|
1664
|
+
const uiUserTs = Date.now();
|
|
1665
|
+
await this.sessionStore.appendTelegramMessage(this.agentConfig.id, rawChatId, sessionId, {
|
|
1666
|
+
role: 'user',
|
|
1667
|
+
content: message,
|
|
1668
|
+
ts: uiUserTs,
|
|
1669
|
+
}, channel).catch(() => { });
|
|
1670
|
+
this.historyDb.insertMessage({
|
|
1671
|
+
chatId: `${channel}-${rawChatId}`,
|
|
1672
|
+
sessionId,
|
|
1673
|
+
source: 'ui',
|
|
1674
|
+
role: 'user',
|
|
1675
|
+
content: message,
|
|
1676
|
+
senderName,
|
|
1677
|
+
ts: uiUserTs,
|
|
1678
|
+
});
|
|
1679
|
+
const buffer = [];
|
|
1680
|
+
let settled = false;
|
|
1681
|
+
let lastPartialText = '';
|
|
1682
|
+
const done = (result) => {
|
|
1683
|
+
if (settled)
|
|
1684
|
+
return;
|
|
1685
|
+
settled = true;
|
|
1686
|
+
cleanup();
|
|
1687
|
+
if (result.trim()) {
|
|
1688
|
+
const uiAssistantTs = Date.now();
|
|
1689
|
+
this.sessionStore.appendTelegramMessage(this.agentConfig.id, rawChatId, sessionId, {
|
|
1690
|
+
role: 'assistant',
|
|
1691
|
+
content: result.trim(),
|
|
1692
|
+
ts: uiAssistantTs,
|
|
1693
|
+
}, channel).catch(() => { });
|
|
1694
|
+
this.historyDb.insertMessage({
|
|
1695
|
+
chatId: `${channel}-${rawChatId}`,
|
|
1696
|
+
sessionId,
|
|
1697
|
+
source: channel,
|
|
1698
|
+
role: 'assistant',
|
|
1699
|
+
content: result.trim(),
|
|
1700
|
+
ts: uiAssistantTs,
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
callbacks.onDone(result.trim());
|
|
1704
|
+
};
|
|
1705
|
+
const fail = (err) => {
|
|
1706
|
+
if (settled)
|
|
1707
|
+
return;
|
|
1708
|
+
settled = true;
|
|
1709
|
+
cleanup();
|
|
1710
|
+
callbacks.onError(err);
|
|
1711
|
+
};
|
|
1712
|
+
let globalTimer;
|
|
1713
|
+
const cleanup = () => {
|
|
1714
|
+
if (globalTimer)
|
|
1715
|
+
clearTimeout(globalTimer);
|
|
1716
|
+
session.off('output', onOutput);
|
|
1717
|
+
};
|
|
1718
|
+
const onOutput = (line) => {
|
|
1719
|
+
try {
|
|
1720
|
+
const obj = JSON.parse(line);
|
|
1721
|
+
if (obj['type'] === 'assistant') {
|
|
1722
|
+
const msg = obj['message'];
|
|
1723
|
+
if (Array.isArray(msg?.content)) {
|
|
1724
|
+
let fullText = '';
|
|
1725
|
+
for (const block of msg.content) {
|
|
1726
|
+
if (block.type === 'text' && block.text)
|
|
1727
|
+
fullText += block.text;
|
|
1728
|
+
}
|
|
1729
|
+
if (fullText.length > lastPartialText.length) {
|
|
1730
|
+
const delta = fullText.slice(lastPartialText.length);
|
|
1731
|
+
buffer.push(delta);
|
|
1732
|
+
callbacks.onChunk({ type: 'text_delta', text: delta });
|
|
1733
|
+
}
|
|
1734
|
+
lastPartialText = fullText;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
if (obj['type'] === 'text') {
|
|
1738
|
+
const text = obj['text'] ?? '';
|
|
1739
|
+
if (text) {
|
|
1740
|
+
buffer.push(text);
|
|
1741
|
+
callbacks.onChunk({ type: 'text_delta', text });
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
if (obj['type'] === 'result') {
|
|
1745
|
+
session.setProcessing(false);
|
|
1746
|
+
const resultText = obj['result'] ?? buffer.join('');
|
|
1747
|
+
done(resultText);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
catch { /* non-JSON */ }
|
|
1751
|
+
};
|
|
1752
|
+
globalTimer = setTimeout(() => {
|
|
1753
|
+
session.setProcessing(false);
|
|
1754
|
+
fail(Object.assign(new Error('Agent response timeout'), { code: 'TIMEOUT' }));
|
|
1755
|
+
}, opts.timeoutMs);
|
|
1756
|
+
session.on('output', onOutput);
|
|
1757
|
+
const channelXml = `<channel source="ui" chat_id="${AgentRunner.escapeXmlAttr(rawChatId)}" session_id="${AgentRunner.escapeXmlAttr(sessionId)}" ` +
|
|
1758
|
+
`user="${AgentRunner.escapeXmlAttr(senderName ?? 'ui')}" ts="${new Date().toISOString()}">\n${message}\n</channel>`;
|
|
1759
|
+
session.setProcessing(true);
|
|
1760
|
+
session.sendMessage(channelXml);
|
|
1761
|
+
return () => {
|
|
1762
|
+
if (!settled) {
|
|
1763
|
+
settled = true;
|
|
1764
|
+
cleanup();
|
|
1765
|
+
}
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Expose the callback server port for integration tests that need to simulate
|
|
1770
|
+
* incoming Telegram messages by POSTing directly to the channel endpoint.
|
|
1771
|
+
*/
|
|
1772
|
+
getCallbackPort() {
|
|
1773
|
+
return this.callbackPort;
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Send a message to all active sessions.
|
|
1777
|
+
* Used for heartbeat/cron tasks delivered out-of-band.
|
|
1778
|
+
*
|
|
1779
|
+
* If no Telegram sessions are active, a transient `__heartbeat__` API session is
|
|
1780
|
+
* spawned so that CronScheduler tasks can always run regardless of active user sessions.
|
|
1781
|
+
*/
|
|
1782
|
+
sendMessage(message) {
|
|
1783
|
+
if (this.sessions.size === 0) {
|
|
1784
|
+
// No active user sessions — spawn a shared heartbeat session so the prompt
|
|
1785
|
+
// reaches a subprocess and output events fire for CronScheduler/tests.
|
|
1786
|
+
this.getOrSpawnSession('__heartbeat__', 'api')
|
|
1787
|
+
.then((session) => {
|
|
1788
|
+
session.sendMessage(message);
|
|
1789
|
+
session.touch();
|
|
1790
|
+
})
|
|
1791
|
+
.catch((err) => this.logger.error('sendMessage failed to spawn heartbeat session', {
|
|
1792
|
+
error: err.message,
|
|
1793
|
+
}));
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
for (const session of this.sessions.values()) {
|
|
1797
|
+
session.sendMessage(message);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
exports.AgentRunner = AgentRunner;
|
|
1802
|
+
//# sourceMappingURL=runner.js.map
|