@ebowwa/channel-ssh 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.js +332 -0
  2. package/package.json +36 -0
  3. package/src/index.ts +438 -0
package/dist/index.js ADDED
@@ -0,0 +1,332 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/index.ts
5
+ import { execSync } from "child_process";
6
+ import { existsSync, readFileSync, writeFileSync } from "fs";
7
+ import { homedir } from "os";
8
+ import { join } from "path";
9
+ var SESSION_NAME = process.env.SSH_CHAT_SESSION || "ssh-chat";
10
+ var GLM_API_ENDPOINT = "https://api.z.ai/api/coding/paas/v4/chat/completions";
11
+ var MEMORY_FILE = process.env.SSH_MEMORY_FILE || join(homedir(), ".ssh-chat-memory.json");
12
+ var PROMPTS_FILE = process.env.PROMPTS_FILE || join(homedir(), ".ssh-chat-prompts.json");
13
+
14
+ class ConversationMemory {
15
+ file;
16
+ messages = [];
17
+ maxMessages;
18
+ constructor(file, maxMessages = 50) {
19
+ this.file = file;
20
+ this.maxMessages = maxMessages;
21
+ this.load();
22
+ }
23
+ load() {
24
+ try {
25
+ if (existsSync(this.file)) {
26
+ const data = JSON.parse(readFileSync(this.file, "utf-8"));
27
+ this.messages = data.messages || [];
28
+ }
29
+ } catch {
30
+ this.messages = [];
31
+ }
32
+ }
33
+ save() {
34
+ try {
35
+ writeFileSync(this.file, JSON.stringify({ messages: this.messages }, null, 2));
36
+ } catch (e) {
37
+ console.error("Failed to save memory:", e);
38
+ }
39
+ }
40
+ add(role, content) {
41
+ this.messages.push({ role, content, timestamp: Date.now() });
42
+ if (this.messages.length > this.maxMessages) {
43
+ this.messages = this.messages.slice(-this.maxMessages);
44
+ }
45
+ this.save();
46
+ }
47
+ getContext(limit = 20) {
48
+ return this.messages.slice(-limit);
49
+ }
50
+ clear() {
51
+ this.messages = [];
52
+ this.save();
53
+ }
54
+ }
55
+ var TOOLS = [
56
+ {
57
+ name: "read_file",
58
+ description: "Read a file from the filesystem.",
59
+ parameters: {
60
+ type: "object",
61
+ properties: { path: { type: "string", description: "File path to read" } },
62
+ required: ["path"]
63
+ },
64
+ handler: async (args) => {
65
+ const path = args.path;
66
+ try {
67
+ if (!existsSync(path))
68
+ return `File not found: ${path}`;
69
+ const content = readFileSync(path, "utf-8");
70
+ return content.length > 4000 ? content.slice(0, 4000) + `
71
+ ...[truncated]` : content;
72
+ } catch (e) {
73
+ return `Error: ${e.message}`;
74
+ }
75
+ }
76
+ },
77
+ {
78
+ name: "write_file",
79
+ description: "Write content to a file.",
80
+ parameters: {
81
+ type: "object",
82
+ properties: {
83
+ path: { type: "string" },
84
+ content: { type: "string" }
85
+ },
86
+ required: ["path", "content"]
87
+ },
88
+ handler: async (args) => {
89
+ try {
90
+ writeFileSync(args.path, args.content);
91
+ return `Wrote ${args.content.length} bytes to ${args.path}`;
92
+ } catch (e) {
93
+ return `Error: ${e.message}`;
94
+ }
95
+ }
96
+ },
97
+ {
98
+ name: "run_command",
99
+ description: "Execute a shell command.",
100
+ parameters: {
101
+ type: "object",
102
+ properties: {
103
+ command: { type: "string" },
104
+ cwd: { type: "string" }
105
+ },
106
+ required: ["command"]
107
+ },
108
+ handler: async (args) => {
109
+ const cmd = args.command;
110
+ const blocked = ["rm -rf", "mkfs", "dd if=", "> /dev/"];
111
+ if (blocked.some((b) => cmd.includes(b)))
112
+ return "Blocked: dangerous command";
113
+ try {
114
+ const result = execSync(cmd, { timeout: 1e4, cwd: args.cwd || process.cwd() });
115
+ return result.toString() || "(no output)";
116
+ } catch (e) {
117
+ return e.stdout?.toString() || e.message;
118
+ }
119
+ }
120
+ },
121
+ {
122
+ name: "git_status",
123
+ description: "Check git repository status.",
124
+ parameters: { type: "object", properties: { cwd: { type: "string" } } },
125
+ handler: async (args) => {
126
+ const cwd = args.cwd || process.cwd();
127
+ try {
128
+ const status = execSync("git status 2>&1", { cwd }).toString();
129
+ const branch = execSync("git branch --show-current 2>&1", { cwd }).toString();
130
+ return `Branch: ${branch}
131
+
132
+ ${status}`;
133
+ } catch (e) {
134
+ return `Error: ${e.message}`;
135
+ }
136
+ }
137
+ },
138
+ {
139
+ name: "system_info",
140
+ description: "Get system resource info.",
141
+ parameters: { type: "object", properties: {} },
142
+ handler: async () => {
143
+ try {
144
+ const cpu = execSync('nproc 2>/dev/null || echo "unknown"').toString().trim();
145
+ const mem = execSync('free -h 2>/dev/null | grep Mem || echo "unknown"').toString().trim();
146
+ const disk = execSync('df -h / 2>/dev/null | tail -1 || echo "unknown"').toString().trim();
147
+ return `CPU: ${cpu} cores
148
+ Memory: ${mem}
149
+ Disk: ${disk}`;
150
+ } catch (e) {
151
+ return `Error: ${e.message}`;
152
+ }
153
+ }
154
+ }
155
+ ];
156
+ function getGLMTools() {
157
+ return TOOLS.map((t) => ({
158
+ type: "function",
159
+ function: { name: t.name, description: t.description, parameters: t.parameters }
160
+ }));
161
+ }
162
+ async function executeTool(name, args) {
163
+ const tool = TOOLS.find((t) => t.name === name);
164
+ if (tool)
165
+ return tool.handler(args);
166
+ return `Unknown tool: ${name}`;
167
+ }
168
+ function getAPIKey() {
169
+ const envKey = process.env.ZAI_API_KEY || process.env.GLM_API_KEY;
170
+ if (envKey)
171
+ return envKey;
172
+ const keysJson = process.env.ZAI_API_KEYS;
173
+ if (keysJson) {
174
+ try {
175
+ const keys = JSON.parse(keysJson);
176
+ if (Array.isArray(keys) && keys.length > 0) {
177
+ return keys[Math.floor(Math.random() * keys.length)];
178
+ }
179
+ } catch {}
180
+ }
181
+ throw new Error("No API key found. Set ZAI_API_KEY or ZAI_API_KEYS env var.");
182
+ }
183
+ async function callGLM(messages, tools) {
184
+ const apiKey = getAPIKey();
185
+ const response = await fetch(GLM_API_ENDPOINT, {
186
+ method: "POST",
187
+ headers: {
188
+ "Content-Type": "application/json",
189
+ Authorization: `Bearer ${apiKey}`
190
+ },
191
+ body: JSON.stringify({
192
+ model: "glm-4-plus",
193
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
194
+ tools: getGLMTools(),
195
+ temperature: 0.7,
196
+ max_tokens: 4096
197
+ })
198
+ });
199
+ if (!response.ok) {
200
+ const text = await response.text();
201
+ throw new Error(`GLM API error: ${response.status} - ${text}`);
202
+ }
203
+ const data = await response.json();
204
+ const choice = data.choices?.[0];
205
+ if (!choice) {
206
+ throw new Error("No response from GLM");
207
+ }
208
+ if (choice.message?.tool_calls) {
209
+ const toolResults = [];
210
+ for (const tc of choice.message.tool_calls) {
211
+ const toolName = tc.function?.name;
212
+ const toolArgs = tc.function?.arguments ? JSON.parse(tc.function.arguments) : {};
213
+ const result = await executeTool(toolName, toolArgs);
214
+ toolResults.push(`[${toolName}]: ${result}`);
215
+ }
216
+ messages.push({ role: "assistant", content: "", timestamp: Date.now() });
217
+ messages.push({ role: "user", content: `Tool results:
218
+ ${toolResults.join(`
219
+ `)}`, timestamp: Date.now() });
220
+ return callGLM(messages, tools);
221
+ }
222
+ return choice.message?.content || "(no response)";
223
+ }
224
+ function tmux(args) {
225
+ try {
226
+ return execSync(`tmux ${args}`, { encoding: "utf-8" }).trim();
227
+ } catch (e) {
228
+ return e.stdout?.toString().trim() || "";
229
+ }
230
+ }
231
+ function sessionExists() {
232
+ const result = tmux(`has-session -t ${SESSION_NAME} 2>/dev/null`);
233
+ return !result.includes("no session");
234
+ }
235
+ function createSession() {
236
+ if (!sessionExists()) {
237
+ tmux(`new-session -d -s ${SESSION_NAME} -x 200 -y 50`);
238
+ tmux(`send-keys -t ${SESSION_NAME} '\uD83E\uDD16 SSH Chat Channel - Type your message and press Enter' Enter`);
239
+ tmux(`send-keys -t ${SESSION_NAME} '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501' Enter`);
240
+ tmux(`send-keys -t ${SESSION_NAME} '' Enter`);
241
+ console.log(`Created tmux session: ${SESSION_NAME}`);
242
+ }
243
+ }
244
+ function getPaneContent() {
245
+ return tmux(`capture-pane -t ${SESSION_NAME} -p -S -100`);
246
+ }
247
+ function sendToPane(text) {
248
+ const lines = text.split(`
249
+ `);
250
+ tmux(`send-keys -t ${SESSION_NAME} '' Enter`);
251
+ for (const line of lines) {
252
+ const escaped = line.replace(/["'$`\\]/g, "\\$&");
253
+ tmux(`send-keys -t ${SESSION_NAME} '${escaped}' Enter`);
254
+ }
255
+ tmux(`send-keys -t ${SESSION_NAME} '' Enter`);
256
+ tmux(`send-keys -t ${SESSION_NAME} '\uD83D\uDC64 You: '`);
257
+ }
258
+ var lastContent = "";
259
+ function detectNewInput() {
260
+ const currentContent = getPaneContent();
261
+ if (currentContent === lastContent) {
262
+ return null;
263
+ }
264
+ const lastLines = lastContent.split(`
265
+ `);
266
+ const currentLines = currentContent.split(`
267
+ `);
268
+ const newLines = [];
269
+ let foundLast = false;
270
+ for (const line of currentLines) {
271
+ if (!foundLast) {
272
+ if (line === lastLines[lastLines.length - 1]) {
273
+ foundLast = true;
274
+ }
275
+ } else {
276
+ if (!line.includes("\uD83D\uDC64 You:") && line.trim()) {
277
+ newLines.push(line.trim());
278
+ }
279
+ }
280
+ }
281
+ lastContent = currentContent;
282
+ const input = newLines.join(" ").trim();
283
+ return input || null;
284
+ }
285
+ async function main() {
286
+ console.log("\uD83E\uDD16 SSH Chat Channel starting...");
287
+ console.log(`Session: ${SESSION_NAME}`);
288
+ console.log(`Memory: ${MEMORY_FILE}`);
289
+ createSession();
290
+ const memory = new ConversationMemory(MEMORY_FILE);
291
+ memory.add("system", `You are an AI assistant accessible via SSH tmux session.
292
+ You are helpful, concise, and can execute tools to help the user.
293
+ This is a private SSH channel separate from any Telegram or other chat interfaces.`);
294
+ console.log("Ready. Monitoring tmux session for input...");
295
+ console.log(`Attach with: tmux attach -t ${SESSION_NAME}`);
296
+ while (true) {
297
+ try {
298
+ const input = detectNewInput();
299
+ if (input && input.length > 0) {
300
+ if (input.startsWith("/")) {
301
+ if (input === "/clear") {
302
+ memory.clear();
303
+ sendToPane("\uD83D\uDDD1\uFE0F Memory cleared.");
304
+ } else if (input === "/exit" || input === "/quit") {
305
+ sendToPane("\uD83D\uDC4B Goodbye!");
306
+ break;
307
+ } else {
308
+ sendToPane(`Unknown command: ${input}`);
309
+ }
310
+ continue;
311
+ }
312
+ console.log(`[${new Date().toISOString()}] Input: ${input.slice(0, 50)}...`);
313
+ memory.add("user", input);
314
+ const messages = memory.getContext(20);
315
+ const response = await callGLM(messages, TOOLS);
316
+ memory.add("assistant", response);
317
+ sendToPane(`\uD83E\uDD16 AI: ${response}`);
318
+ console.log(`[${new Date().toISOString()}] Response sent`);
319
+ }
320
+ await new Promise((r) => setTimeout(r, 500));
321
+ } catch (error) {
322
+ console.error("Error:", error);
323
+ await new Promise((r) => setTimeout(r, 2000));
324
+ }
325
+ }
326
+ }
327
+ process.on("SIGINT", () => {
328
+ console.log(`
329
+ Shutting down...`);
330
+ process.exit(0);
331
+ });
332
+ main().catch(console.error);
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@ebowwa/channel-ssh",
3
+ "version": "1.0.1",
4
+ "description": "SSH tmux channel for GLM Daemon - separate from Telegram",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "bin": {
9
+ "channel-ssh": "./dist/index.js"
10
+ },
11
+ "scripts": {
12
+ "build": "bun build src/index.ts --outdir dist --target bun --external '@ebowwa/*' --external 'node-telegram-bot-api'",
13
+ "dev": "bun run src/index.ts",
14
+ "prepublishOnly": "bun run build"
15
+ },
16
+ "dependencies": {
17
+ "@ebowwa/structured-prompts": "^0.3.2",
18
+ "@ebowwa/terminal": "^0.3.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/bun": "latest",
22
+ "typescript": "^5.9.3"
23
+ },
24
+ "keywords": [
25
+ "ssh",
26
+ "tmux",
27
+ "channel",
28
+ "glm",
29
+ "ai"
30
+ ],
31
+ "author": "Ebowwa Labs <labs@ebowwa.com>",
32
+ "license": "MIT",
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ }
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * SSH Channel for GLM Daemon
4
+ *
5
+ * Provides AI chat via SSH tmux session - completely separate from Telegram
6
+ *
7
+ * Usage:
8
+ * bun run src/index.ts
9
+ *
10
+ * Features:
11
+ * - Creates/attaches to tmux session "ssh-chat"
12
+ * - Monitors pane for user input (lines ending with Enter)
13
+ * - GLM-4.7 AI responses
14
+ * - Separate conversation memory from Telegram
15
+ * - Tool support (read_file, run_command, etc.)
16
+ */
17
+
18
+ import { execSync, spawn } from 'child_process';
19
+ import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'fs';
20
+ import { getStore } from '@ebowwa/structured-prompts';
21
+ import { homedir } from 'os';
22
+ import { join } from 'path';
23
+
24
+ const SESSION_NAME = process.env.SSH_CHAT_SESSION || 'ssh-chat';
25
+ const GLM_API_ENDPOINT = 'https://api.z.ai/api/coding/paas/v4/chat/completions';
26
+ const MEMORY_FILE = process.env.SSH_MEMORY_FILE || join(homedir(), '.ssh-chat-memory.json');
27
+ const PROMPTS_FILE = process.env.PROMPTS_FILE || join(homedir(), '.ssh-chat-prompts.json');
28
+
29
+ // ====================================================================
30
+ // Conversation Memory (Separate from Telegram)
31
+ // ====================================================================
32
+
33
+ interface Message {
34
+ role: 'user' | 'assistant' | 'system';
35
+ content: string;
36
+ timestamp: number;
37
+ }
38
+
39
+ class ConversationMemory {
40
+ private messages: Message[] = [];
41
+ private maxMessages: number;
42
+
43
+ constructor(private file: string, maxMessages = 50) {
44
+ this.maxMessages = maxMessages;
45
+ this.load();
46
+ }
47
+
48
+ private load(): void {
49
+ try {
50
+ if (existsSync(this.file)) {
51
+ const data = JSON.parse(readFileSync(this.file, 'utf-8'));
52
+ this.messages = data.messages || [];
53
+ }
54
+ } catch {
55
+ this.messages = [];
56
+ }
57
+ }
58
+
59
+ private save(): void {
60
+ try {
61
+ writeFileSync(this.file, JSON.stringify({ messages: this.messages }, null, 2));
62
+ } catch (e) {
63
+ console.error('Failed to save memory:', e);
64
+ }
65
+ }
66
+
67
+ add(role: 'user' | 'assistant' | 'system', content: string): void {
68
+ this.messages.push({ role, content, timestamp: Date.now() });
69
+ if (this.messages.length > this.maxMessages) {
70
+ this.messages = this.messages.slice(-this.maxMessages);
71
+ }
72
+ this.save();
73
+ }
74
+
75
+ getContext(limit = 20): Message[] {
76
+ return this.messages.slice(-limit);
77
+ }
78
+
79
+ clear(): void {
80
+ this.messages = [];
81
+ this.save();
82
+ }
83
+ }
84
+
85
+ // ====================================================================
86
+ // Tool Definitions
87
+ // ====================================================================
88
+
89
+ interface Tool {
90
+ name: string;
91
+ description: string;
92
+ parameters: Record<string, unknown>;
93
+ handler: (args: Record<string, unknown>) => Promise<string>;
94
+ }
95
+
96
+ const TOOLS: Tool[] = [
97
+ {
98
+ name: 'read_file',
99
+ description: 'Read a file from the filesystem.',
100
+ parameters: {
101
+ type: 'object',
102
+ properties: { path: { type: 'string', description: 'File path to read' } },
103
+ required: ['path']
104
+ },
105
+ handler: async (args) => {
106
+ const path = args.path as string;
107
+ try {
108
+ if (!existsSync(path)) return `File not found: ${path}`;
109
+ const content = readFileSync(path, 'utf-8');
110
+ return content.length > 4000 ? content.slice(0, 4000) + '\n...[truncated]' : content;
111
+ } catch (e) {
112
+ return `Error: ${(e as Error).message}`;
113
+ }
114
+ }
115
+ },
116
+ {
117
+ name: 'write_file',
118
+ description: 'Write content to a file.',
119
+ parameters: {
120
+ type: 'object',
121
+ properties: {
122
+ path: { type: 'string' },
123
+ content: { type: 'string' }
124
+ },
125
+ required: ['path', 'content']
126
+ },
127
+ handler: async (args) => {
128
+ try {
129
+ writeFileSync(args.path as string, args.content as string);
130
+ return `Wrote ${(args.content as string).length} bytes to ${args.path}`;
131
+ } catch (e) {
132
+ return `Error: ${(e as Error).message}`;
133
+ }
134
+ }
135
+ },
136
+ {
137
+ name: 'run_command',
138
+ description: 'Execute a shell command.',
139
+ parameters: {
140
+ type: 'object',
141
+ properties: {
142
+ command: { type: 'string' },
143
+ cwd: { type: 'string' }
144
+ },
145
+ required: ['command']
146
+ },
147
+ handler: async (args) => {
148
+ const cmd = args.command as string;
149
+ const blocked = ['rm -rf', 'mkfs', 'dd if=', '> /dev/'];
150
+ if (blocked.some(b => cmd.includes(b))) return 'Blocked: dangerous command';
151
+ try {
152
+ const result = execSync(cmd, { timeout: 10000, cwd: args.cwd as string || process.cwd() });
153
+ return result.toString() || '(no output)';
154
+ } catch (e: any) {
155
+ return e.stdout?.toString() || e.message;
156
+ }
157
+ }
158
+ },
159
+ {
160
+ name: 'git_status',
161
+ description: 'Check git repository status.',
162
+ parameters: { type: 'object', properties: { cwd: { type: 'string' } } },
163
+ handler: async (args) => {
164
+ const cwd = (args.cwd as string) || process.cwd();
165
+ try {
166
+ const status = execSync('git status 2>&1', { cwd }).toString();
167
+ const branch = execSync('git branch --show-current 2>&1', { cwd }).toString();
168
+ return `Branch: ${branch}\n\n${status}`;
169
+ } catch (e) {
170
+ return `Error: ${(e as Error).message}`;
171
+ }
172
+ }
173
+ },
174
+ {
175
+ name: 'system_info',
176
+ description: 'Get system resource info.',
177
+ parameters: { type: 'object', properties: {} },
178
+ handler: async () => {
179
+ try {
180
+ const cpu = execSync('nproc 2>/dev/null || echo "unknown"').toString().trim();
181
+ const mem = execSync('free -h 2>/dev/null | grep Mem || echo "unknown"').toString().trim();
182
+ const disk = execSync('df -h / 2>/dev/null | tail -1 || echo "unknown"').toString().trim();
183
+ return `CPU: ${cpu} cores\nMemory: ${mem}\nDisk: ${disk}`;
184
+ } catch (e) {
185
+ return `Error: ${(e as Error).message}`;
186
+ }
187
+ }
188
+ }
189
+ ];
190
+
191
+ function getGLMTools() {
192
+ return TOOLS.map(t => ({
193
+ type: 'function',
194
+ function: { name: t.name, description: t.description, parameters: t.parameters }
195
+ }));
196
+ }
197
+
198
+ async function executeTool(name: string, args: Record<string, unknown>): Promise<string> {
199
+ const tool = TOOLS.find(t => t.name === name);
200
+ if (tool) return tool.handler(args);
201
+ return `Unknown tool: ${name}`;
202
+ }
203
+
204
+ // ====================================================================
205
+ // GLM API Client
206
+ // ====================================================================
207
+
208
+ function getAPIKey(): string {
209
+ // Try environment variable first
210
+ const envKey = process.env.ZAI_API_KEY || process.env.GLM_API_KEY;
211
+ if (envKey) return envKey;
212
+
213
+ // Try rolling keys
214
+ const keysJson = process.env.ZAI_API_KEYS;
215
+ if (keysJson) {
216
+ try {
217
+ const keys = JSON.parse(keysJson);
218
+ if (Array.isArray(keys) && keys.length > 0) {
219
+ return keys[Math.floor(Math.random() * keys.length)];
220
+ }
221
+ } catch {}
222
+ }
223
+
224
+ throw new Error('No API key found. Set ZAI_API_KEY or ZAI_API_KEYS env var.');
225
+ }
226
+
227
+ async function callGLM(messages: Message[], tools: typeof TOOLS): Promise<string> {
228
+ const apiKey = getAPIKey();
229
+
230
+ const response = await fetch(GLM_API_ENDPOINT, {
231
+ method: 'POST',
232
+ headers: {
233
+ 'Content-Type': 'application/json',
234
+ 'Authorization': `Bearer ${apiKey}`
235
+ },
236
+ body: JSON.stringify({
237
+ model: 'glm-4-plus',
238
+ messages: messages.map(m => ({ role: m.role, content: m.content })),
239
+ tools: getGLMTools(),
240
+ temperature: 0.7,
241
+ max_tokens: 4096
242
+ })
243
+ });
244
+
245
+ if (!response.ok) {
246
+ const text = await response.text();
247
+ throw new Error(`GLM API error: ${response.status} - ${text}`);
248
+ }
249
+
250
+ const data = await response.json();
251
+ const choice = data.choices?.[0];
252
+
253
+ if (!choice) {
254
+ throw new Error('No response from GLM');
255
+ }
256
+
257
+ // Handle tool calls
258
+ if (choice.message?.tool_calls) {
259
+ const toolResults: string[] = [];
260
+
261
+ for (const tc of choice.message.tool_calls) {
262
+ const toolName = tc.function?.name;
263
+ const toolArgs = tc.function?.arguments ? JSON.parse(tc.function.arguments) : {};
264
+
265
+ const result = await executeTool(toolName, toolArgs);
266
+ toolResults.push(`[${toolName}]: ${result}`);
267
+ }
268
+
269
+ // Continue conversation with tool results
270
+ messages.push({ role: 'assistant', content: '', timestamp: Date.now() });
271
+ messages.push({ role: 'user', content: `Tool results:\n${toolResults.join('\n')}`, timestamp: Date.now() });
272
+
273
+ // Recursive call for final response
274
+ return callGLM(messages, tools);
275
+ }
276
+
277
+ return choice.message?.content || '(no response)';
278
+ }
279
+
280
+ // ====================================================================
281
+ // Tmux Interface
282
+ // ====================================================================
283
+
284
+ function tmux(args: string): string {
285
+ try {
286
+ return execSync(`tmux ${args}`, { encoding: 'utf-8' }).trim();
287
+ } catch (e: any) {
288
+ return e.stdout?.toString().trim() || '';
289
+ }
290
+ }
291
+
292
+ function sessionExists(): boolean {
293
+ const result = tmux(`has-session -t ${SESSION_NAME} 2>/dev/null`);
294
+ return !result.includes('no session');
295
+ }
296
+
297
+ function createSession(): void {
298
+ if (!sessionExists()) {
299
+ tmux(`new-session -d -s ${SESSION_NAME} -x 200 -y 50`);
300
+ tmux(`send-keys -t ${SESSION_NAME} '🤖 SSH Chat Channel - Type your message and press Enter' Enter`);
301
+ tmux(`send-keys -t ${SESSION_NAME} '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' Enter`);
302
+ tmux(`send-keys -t ${SESSION_NAME} '' Enter`);
303
+ console.log(`Created tmux session: ${SESSION_NAME}`);
304
+ }
305
+ }
306
+
307
+ function getPaneContent(): string {
308
+ return tmux(`capture-pane -t ${SESSION_NAME} -p -S -100`);
309
+ }
310
+
311
+ function sendToPane(text: string): void {
312
+ // Format and send response
313
+ const lines = text.split('\n');
314
+ tmux(`send-keys -t ${SESSION_NAME} '' Enter`);
315
+ for (const line of lines) {
316
+ // Escape special characters for tmux
317
+ const escaped = line.replace(/["'$`\\]/g, '\\$&');
318
+ tmux(`send-keys -t ${SESSION_NAME} '${escaped}' Enter`);
319
+ }
320
+ tmux(`send-keys -t ${SESSION_NAME} '' Enter`);
321
+ tmux(`send-keys -t ${SESSION_NAME} '👤 You: '`);
322
+ }
323
+
324
+ // Track last seen content to detect new input
325
+ let lastContent = '';
326
+
327
+ function detectNewInput(): string | null {
328
+ const currentContent = getPaneContent();
329
+
330
+ if (currentContent === lastContent) {
331
+ return null;
332
+ }
333
+
334
+ // Find new lines
335
+ const lastLines = lastContent.split('\n');
336
+ const currentLines = currentContent.split('\n');
337
+
338
+ // Get lines added after last check
339
+ const newLines: string[] = [];
340
+ let foundLast = false;
341
+
342
+ for (const line of currentLines) {
343
+ if (!foundLast) {
344
+ if (line === lastLines[lastLines.length - 1]) {
345
+ foundLast = true;
346
+ }
347
+ } else {
348
+ // Skip prompt line
349
+ if (!line.includes('👤 You:') && line.trim()) {
350
+ newLines.push(line.trim());
351
+ }
352
+ }
353
+ }
354
+
355
+ lastContent = currentContent;
356
+
357
+ // Combine new lines as input
358
+ const input = newLines.join(' ').trim();
359
+ return input || null;
360
+ }
361
+
362
+ // ====================================================================
363
+ // Main Loop
364
+ // ====================================================================
365
+
366
+ async function main() {
367
+ console.log('🤖 SSH Chat Channel starting...');
368
+ console.log(`Session: ${SESSION_NAME}`);
369
+ console.log(`Memory: ${MEMORY_FILE}`);
370
+
371
+ // Create tmux session
372
+ createSession();
373
+
374
+ // Initialize memory (separate from Telegram)
375
+ const memory = new ConversationMemory(MEMORY_FILE);
376
+
377
+ // Add system prompt
378
+ memory.add('system', `You are an AI assistant accessible via SSH tmux session.
379
+ You are helpful, concise, and can execute tools to help the user.
380
+ This is a private SSH channel separate from any Telegram or other chat interfaces.`);
381
+
382
+ console.log('Ready. Monitoring tmux session for input...');
383
+ console.log(`Attach with: tmux attach -t ${SESSION_NAME}`);
384
+
385
+ // Main loop
386
+ while (true) {
387
+ try {
388
+ const input = detectNewInput();
389
+
390
+ if (input && input.length > 0) {
391
+ // Skip commands
392
+ if (input.startsWith('/')) {
393
+ if (input === '/clear') {
394
+ memory.clear();
395
+ sendToPane('🗑️ Memory cleared.');
396
+ } else if (input === '/exit' || input === '/quit') {
397
+ sendToPane('👋 Goodbye!');
398
+ break;
399
+ } else {
400
+ sendToPane(`Unknown command: ${input}`);
401
+ }
402
+ continue;
403
+ }
404
+
405
+ console.log(`[${new Date().toISOString()}] Input: ${input.slice(0, 50)}...`);
406
+
407
+ // Add user message to memory
408
+ memory.add('user', input);
409
+
410
+ // Get AI response
411
+ const messages = memory.getContext(20);
412
+ const response = await callGLM(messages, TOOLS);
413
+
414
+ // Add response to memory
415
+ memory.add('assistant', response);
416
+
417
+ // Send to tmux
418
+ sendToPane(`🤖 AI: ${response}`);
419
+
420
+ console.log(`[${new Date().toISOString()}] Response sent`);
421
+ }
422
+
423
+ // Poll every 500ms
424
+ await new Promise(r => setTimeout(r, 500));
425
+ } catch (error) {
426
+ console.error('Error:', error);
427
+ await new Promise(r => setTimeout(r, 2000));
428
+ }
429
+ }
430
+ }
431
+
432
+ // Handle shutdown
433
+ process.on('SIGINT', () => {
434
+ console.log('\nShutting down...');
435
+ process.exit(0);
436
+ });
437
+
438
+ main().catch(console.error);