@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.
- package/dist/index.js +332 -0
- package/package.json +36 -0
- 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);
|