@ebowwa/channel-ssh 1.1.3 → 2.1.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/dist/index.d.ts +102 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +324 -364
- package/dist/index.js.map +1 -0
- package/dist/plugin.d.ts +16 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +136 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +29 -7
- package/src/index.ts +333 -398
- package/src/plugin.ts +179 -0
package/src/index.ts
CHANGED
|
@@ -1,101 +1,85 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
1
|
/**
|
|
3
|
-
*
|
|
2
|
+
* @ebowwa/channel-ssh
|
|
4
3
|
*
|
|
5
|
-
*
|
|
4
|
+
* SSH channel adapter implementing ChannelConnector.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* This package handles:
|
|
7
|
+
* - File-based IPC (reads from ~/.ssh-chat/in, writes to ~/.ssh-chat/out)
|
|
8
|
+
* - Status tracking (~/.ssh-chat/status)
|
|
9
|
+
* - Message normalization to ChannelMessage format
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* STATUS_FILE: ~/.ssh-chat/status (processing/idle)
|
|
11
|
+
* Intelligence (GLM, tools, memory) can be provided by:
|
|
12
|
+
* 1. This package (standalone mode with built-in echo handler)
|
|
13
|
+
* 2. External daemon/consumer (adapter mode via onMessage handler)
|
|
15
14
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* - Tool support (read_file, run_command, etc.)
|
|
15
|
+
* Usage:
|
|
16
|
+
* Write message: echo "your message" > ~/.ssh-chat/in
|
|
17
|
+
* Read response: cat ~/.ssh-chat/out
|
|
18
|
+
* Check status: cat ~/.ssh-chat/status
|
|
21
19
|
*/
|
|
22
20
|
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
21
|
+
import { existsSync, readFileSync, watch, writeFileSync, mkdirSync } from "fs";
|
|
22
|
+
import { homedir } from "os";
|
|
23
|
+
import { join } from "path";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
type ChannelId,
|
|
27
|
+
type ChannelMessage,
|
|
28
|
+
type ChannelResponse,
|
|
29
|
+
type ChannelCapabilities,
|
|
30
|
+
type MessageSender,
|
|
31
|
+
type MessageContext,
|
|
32
|
+
type MessageHandler,
|
|
33
|
+
createChannelId,
|
|
34
|
+
} from "@ebowwa/channel-types";
|
|
35
|
+
|
|
36
|
+
// ============================================================
|
|
37
|
+
// CONFIG
|
|
38
|
+
// ============================================================
|
|
39
|
+
|
|
40
|
+
export interface SSHConfig {
|
|
41
|
+
/** Chat directory for IPC files (default: ~/.ssh-chat) */
|
|
42
|
+
chatDir?: string;
|
|
43
|
+
/** Polling interval in ms (default: 500) */
|
|
44
|
+
pollInterval?: number;
|
|
45
|
+
/** Memory limit - max messages to keep (default: 100) */
|
|
46
|
+
memoryLimit?: number;
|
|
47
|
+
/** Context limit for LLM (default: 20) */
|
|
48
|
+
contextLimit?: number;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
function
|
|
45
|
-
return
|
|
51
|
+
export function createSSHConfigFromEnv(): SSHConfig {
|
|
52
|
+
return {
|
|
53
|
+
chatDir: process.env.SSH_CHAT_DIR,
|
|
54
|
+
pollInterval: process.env.SSH_POLL_INTERVAL
|
|
55
|
+
? parseInt(process.env.SSH_POLL_INTERVAL, 10)
|
|
56
|
+
: undefined,
|
|
57
|
+
memoryLimit: process.env.SSH_MEMORY_LIMIT
|
|
58
|
+
? parseInt(process.env.SSH_MEMORY_LIMIT, 10)
|
|
59
|
+
: undefined,
|
|
60
|
+
contextLimit: process.env.SSH_CONTEXT_LIMIT
|
|
61
|
+
? parseInt(process.env.SSH_CONTEXT_LIMIT, 10)
|
|
62
|
+
: undefined,
|
|
63
|
+
};
|
|
46
64
|
}
|
|
47
65
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
maxRetries: requireEnvInt('GLM_MAX_RETRIES'),
|
|
52
|
-
timeout: requireEnvInt('GLM_TIMEOUT_MS'),
|
|
53
|
-
temperature: requireEnvFloat('GLM_TEMPERATURE'),
|
|
54
|
-
maxTokens: requireEnvInt('GLM_MAX_TOKENS'),
|
|
55
|
-
pollInterval: requireEnvInt('SSH_CHAT_POLL_MS'),
|
|
56
|
-
memoryLimit: requireEnvInt('SSH_CHAT_MEMORY_LIMIT'),
|
|
57
|
-
contextLimit: requireEnvInt('SSH_CHAT_CONTEXT_LIMIT'),
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const IN_FILE = join(CONFIG.chatDir, 'in');
|
|
61
|
-
const OUT_FILE = join(CONFIG.chatDir, 'out');
|
|
62
|
-
const STATUS_FILE = join(CONFIG.chatDir, 'status');
|
|
63
|
-
const MEMORY_FILE = join(CONFIG.chatDir, 'memory.json');
|
|
64
|
-
|
|
65
|
-
// ====================================================================
|
|
66
|
-
// Setup
|
|
67
|
-
// ====================================================================
|
|
68
|
-
|
|
69
|
-
function ensureDir(): void {
|
|
70
|
-
if (!existsSync(CONFIG.chatDir)) {
|
|
71
|
-
mkdirSync(CONFIG.chatDir, { recursive: true });
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function setStatus(status: 'idle' | 'processing' | 'error' | 'retrying'): void {
|
|
76
|
-
writeFileSync(STATUS_FILE, JSON.stringify({ status, timestamp: Date.now() }));
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function writeOutput(text: string): void {
|
|
80
|
-
const timestamp = new Date().toISOString();
|
|
81
|
-
writeFileSync(OUT_FILE, `[${timestamp}]\n${text}\n`);
|
|
82
|
-
}
|
|
66
|
+
// ============================================================
|
|
67
|
+
// CONVERSATION MEMORY
|
|
68
|
+
// ============================================================
|
|
83
69
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// ====================================================================
|
|
87
|
-
|
|
88
|
-
interface Message {
|
|
89
|
-
role: 'user' | 'assistant' | 'system';
|
|
70
|
+
interface StoredMessage {
|
|
71
|
+
role: "user" | "assistant" | "system";
|
|
90
72
|
content: string;
|
|
91
73
|
timestamp: number;
|
|
92
74
|
}
|
|
93
75
|
|
|
94
|
-
class ConversationMemory {
|
|
95
|
-
private messages:
|
|
76
|
+
export class ConversationMemory {
|
|
77
|
+
private messages: StoredMessage[] = [];
|
|
96
78
|
private maxMessages: number;
|
|
79
|
+
private file: string;
|
|
97
80
|
|
|
98
|
-
constructor(
|
|
81
|
+
constructor(file: string, maxMessages = 100) {
|
|
82
|
+
this.file = file;
|
|
99
83
|
this.maxMessages = maxMessages;
|
|
100
84
|
this.load();
|
|
101
85
|
}
|
|
@@ -103,7 +87,7 @@ class ConversationMemory {
|
|
|
103
87
|
private load(): void {
|
|
104
88
|
try {
|
|
105
89
|
if (existsSync(this.file)) {
|
|
106
|
-
const data = JSON.parse(readFileSync(this.file,
|
|
90
|
+
const data = JSON.parse(readFileSync(this.file, "utf-8"));
|
|
107
91
|
this.messages = data.messages || [];
|
|
108
92
|
}
|
|
109
93
|
} catch {
|
|
@@ -115,11 +99,11 @@ class ConversationMemory {
|
|
|
115
99
|
try {
|
|
116
100
|
writeFileSync(this.file, JSON.stringify({ messages: this.messages }, null, 2));
|
|
117
101
|
} catch (e) {
|
|
118
|
-
console.error(
|
|
102
|
+
console.error("Failed to save memory:", e);
|
|
119
103
|
}
|
|
120
104
|
}
|
|
121
105
|
|
|
122
|
-
add(role:
|
|
106
|
+
add(role: "user" | "assistant" | "system", content: string): void {
|
|
123
107
|
this.messages.push({ role, content, timestamp: Date.now() });
|
|
124
108
|
if (this.messages.length > this.maxMessages) {
|
|
125
109
|
this.messages = this.messages.slice(-this.maxMessages);
|
|
@@ -127,7 +111,7 @@ class ConversationMemory {
|
|
|
127
111
|
this.save();
|
|
128
112
|
}
|
|
129
113
|
|
|
130
|
-
getContext(limit = 20):
|
|
114
|
+
getContext(limit = 20): StoredMessage[] {
|
|
131
115
|
return this.messages.slice(-limit);
|
|
132
116
|
}
|
|
133
117
|
|
|
@@ -137,368 +121,319 @@ class ConversationMemory {
|
|
|
137
121
|
}
|
|
138
122
|
}
|
|
139
123
|
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
type: 'object',
|
|
157
|
-
properties: { path: { type: 'string', description: 'File path to read' } },
|
|
158
|
-
required: ['path']
|
|
159
|
-
},
|
|
160
|
-
handler: async (args) => {
|
|
161
|
-
const path = args.path as string;
|
|
162
|
-
try {
|
|
163
|
-
if (!existsSync(path)) return `File not found: ${path}`;
|
|
164
|
-
const content = readFileSync(path, 'utf-8');
|
|
165
|
-
return content.length > 4000 ? content.slice(0, 4000) + '\n...[truncated]' : content;
|
|
166
|
-
} catch (e) {
|
|
167
|
-
return `Error: ${(e as Error).message}`;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
},
|
|
171
|
-
{
|
|
172
|
-
name: 'write_file',
|
|
173
|
-
description: 'Write content to a file.',
|
|
174
|
-
parameters: {
|
|
175
|
-
type: 'object',
|
|
176
|
-
properties: {
|
|
177
|
-
path: { type: 'string' },
|
|
178
|
-
content: { type: 'string' }
|
|
179
|
-
},
|
|
180
|
-
required: ['path', 'content']
|
|
124
|
+
// ============================================================
|
|
125
|
+
// SSH CHANNEL
|
|
126
|
+
// ============================================================
|
|
127
|
+
|
|
128
|
+
export class SSHChannel {
|
|
129
|
+
readonly id: ChannelId;
|
|
130
|
+
readonly label = "SSH";
|
|
131
|
+
readonly capabilities: ChannelCapabilities = {
|
|
132
|
+
supports: {
|
|
133
|
+
text: true,
|
|
134
|
+
media: false,
|
|
135
|
+
replies: false,
|
|
136
|
+
threads: false,
|
|
137
|
+
reactions: false,
|
|
138
|
+
editing: false,
|
|
139
|
+
streaming: false,
|
|
181
140
|
},
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return `Wrote ${(args.content as string).length} bytes to ${args.path}`;
|
|
186
|
-
} catch (e) {
|
|
187
|
-
return `Error: ${(e as Error).message}`;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
name: 'run_command',
|
|
193
|
-
description: 'Execute a shell command.',
|
|
194
|
-
parameters: {
|
|
195
|
-
type: 'object',
|
|
196
|
-
properties: {
|
|
197
|
-
command: { type: 'string' },
|
|
198
|
-
cwd: { type: 'string' }
|
|
199
|
-
},
|
|
200
|
-
required: ['command']
|
|
141
|
+
rateLimits: {
|
|
142
|
+
messagesPerMinute: 120,
|
|
143
|
+
charactersPerMessage: 100000,
|
|
201
144
|
},
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const cpu = execSync('nproc 2>/dev/null || echo "unknown"').toString().trim();
|
|
236
|
-
const mem = execSync('free -h 2>/dev/null | grep Mem || echo "unknown"').toString().trim();
|
|
237
|
-
const disk = execSync('df -h / 2>/dev/null | tail -1 || echo "unknown"').toString().trim();
|
|
238
|
-
return `CPU: ${cpu} cores\nMemory: ${mem}\nDisk: ${disk}`;
|
|
239
|
-
} catch (e) {
|
|
240
|
-
return `Error: ${(e as Error).message}`;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
private config: Required<SSHConfig>;
|
|
148
|
+
private memory: ConversationMemory;
|
|
149
|
+
private messageHandler?: MessageHandler;
|
|
150
|
+
private connected = false;
|
|
151
|
+
private watcher?: ReturnType<typeof watch>;
|
|
152
|
+
private pollInterval?: ReturnType<typeof setInterval>;
|
|
153
|
+
private lastContent = "";
|
|
154
|
+
private messageCounter = 0;
|
|
155
|
+
|
|
156
|
+
// File paths
|
|
157
|
+
private readonly IN_FILE: string;
|
|
158
|
+
private readonly OUT_FILE: string;
|
|
159
|
+
private readonly STATUS_FILE: string;
|
|
160
|
+
private readonly MEMORY_FILE: string;
|
|
161
|
+
|
|
162
|
+
constructor(config: SSHConfig = {}) {
|
|
163
|
+
this.config = {
|
|
164
|
+
chatDir: config.chatDir ?? join(homedir(), ".ssh-chat"),
|
|
165
|
+
pollInterval: config.pollInterval ?? 500,
|
|
166
|
+
memoryLimit: config.memoryLimit ?? 100,
|
|
167
|
+
contextLimit: config.contextLimit ?? 20,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
this.id = createChannelId("ssh", "default");
|
|
171
|
+
|
|
172
|
+
this.IN_FILE = join(this.config.chatDir, "in");
|
|
173
|
+
this.OUT_FILE = join(this.config.chatDir, "out");
|
|
174
|
+
this.STATUS_FILE = join(this.config.chatDir, "status");
|
|
175
|
+
this.MEMORY_FILE = join(this.config.chatDir, "memory.json");
|
|
176
|
+
|
|
177
|
+
this.memory = new ConversationMemory(this.MEMORY_FILE, this.config.memoryLimit);
|
|
243
178
|
}
|
|
244
|
-
];
|
|
245
179
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
function: { name: t.name, description: t.description, parameters: t.parameters }
|
|
250
|
-
}));
|
|
251
|
-
}
|
|
180
|
+
// ============================================================
|
|
181
|
+
// ChannelConnector Implementation
|
|
182
|
+
// ============================================================
|
|
252
183
|
|
|
253
|
-
async
|
|
254
|
-
|
|
255
|
-
if (tool) return tool.handler(args);
|
|
256
|
-
return `Unknown tool: ${name}`;
|
|
257
|
-
}
|
|
184
|
+
async start(): Promise<void> {
|
|
185
|
+
console.log("[SSHChannel] Starting...");
|
|
258
186
|
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
// ====================================================================
|
|
187
|
+
// Ensure directory exists
|
|
188
|
+
this.ensureDir();
|
|
262
189
|
|
|
263
|
-
|
|
190
|
+
// Create empty files if they don't exist
|
|
191
|
+
if (!existsSync(this.IN_FILE)) writeFileSync(this.IN_FILE, "");
|
|
192
|
+
if (!existsSync(this.OUT_FILE)) writeFileSync(this.OUT_FILE, "Ready. Send a message.\n");
|
|
264
193
|
|
|
265
|
-
|
|
266
|
-
const envKey = process.env.ZAI_API_KEY || process.env.Z_AI_API_KEY || process.env.GLM_API_KEY;
|
|
267
|
-
if (envKey) return envKey;
|
|
194
|
+
this.setStatus("idle");
|
|
268
195
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (Array.isArray(keys) && keys.length > 0) {
|
|
274
|
-
return keys[Math.floor(Math.random() * keys.length)];
|
|
196
|
+
// Setup file watcher
|
|
197
|
+
this.watcher = watch(this.config.chatDir, (eventType, filename) => {
|
|
198
|
+
if (filename === "in" && eventType === "change") {
|
|
199
|
+
this.processIncoming();
|
|
275
200
|
}
|
|
276
|
-
} catch {}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
throw new Error('No API key found. Set ZAI_API_KEY env var.');
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function sleep(ms: number): Promise<void> {
|
|
283
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function calculateBackoff(retryCount: number): number {
|
|
287
|
-
return Math.min(1000 * Math.pow(2, retryCount), 10000);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
async function callGLM(messages: Message[], retryCount = 0): Promise<string> {
|
|
291
|
-
const apiKey = getAPIKey();
|
|
292
|
-
|
|
293
|
-
try {
|
|
294
|
-
const controller = new AbortController();
|
|
295
|
-
const timeoutId = setTimeout(() => controller.abort(), CONFIG.timeout);
|
|
296
|
-
|
|
297
|
-
const response = await fetch(GLM_API_ENDPOINT, {
|
|
298
|
-
method: 'POST',
|
|
299
|
-
headers: {
|
|
300
|
-
'Content-Type': 'application/json',
|
|
301
|
-
'Authorization': `Bearer ${apiKey}`
|
|
302
|
-
},
|
|
303
|
-
signal: controller.signal,
|
|
304
|
-
body: JSON.stringify({
|
|
305
|
-
model: CONFIG.model,
|
|
306
|
-
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
|
307
|
-
tools: getGLMTools(),
|
|
308
|
-
temperature: CONFIG.temperature,
|
|
309
|
-
max_tokens: CONFIG.maxTokens
|
|
310
|
-
})
|
|
311
201
|
});
|
|
312
202
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
203
|
+
// Setup polling as backup (watch can be unreliable)
|
|
204
|
+
this.pollInterval = setInterval(() => {
|
|
205
|
+
try {
|
|
206
|
+
const content = readFileSync(this.IN_FILE, "utf-8").trim();
|
|
207
|
+
if (content && content !== this.lastContent) {
|
|
208
|
+
this.processIncoming();
|
|
209
|
+
}
|
|
210
|
+
} catch {}
|
|
211
|
+
}, this.config.pollInterval);
|
|
212
|
+
|
|
213
|
+
this.connected = true;
|
|
214
|
+
console.log("[SSHChannel] Started");
|
|
215
|
+
console.log(` IN_FILE: ${this.IN_FILE}`);
|
|
216
|
+
console.log(` OUT_FILE: ${this.OUT_FILE}`);
|
|
217
|
+
console.log(` STATUS: ${this.STATUS_FILE}`);
|
|
218
|
+
}
|
|
317
219
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const backoff = calculateBackoff(retryCount);
|
|
321
|
-
console.log(`GLM API error ${response.status}, retrying in ${backoff}ms (${retryCount + 1}/${CONFIG.maxRetries})`);
|
|
322
|
-
await sleep(backoff);
|
|
323
|
-
return callGLM(messages, retryCount + 1);
|
|
324
|
-
}
|
|
220
|
+
async stop(): Promise<void> {
|
|
221
|
+
console.log("[SSHChannel] Stopping...");
|
|
325
222
|
|
|
326
|
-
|
|
223
|
+
if (this.watcher) {
|
|
224
|
+
this.watcher.close();
|
|
225
|
+
this.watcher = undefined;
|
|
327
226
|
}
|
|
328
227
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (!choice) {
|
|
333
|
-
throw new Error('No response from GLM');
|
|
228
|
+
if (this.pollInterval) {
|
|
229
|
+
clearInterval(this.pollInterval);
|
|
230
|
+
this.pollInterval = undefined;
|
|
334
231
|
}
|
|
335
232
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const toolResults: string[] = [];
|
|
339
|
-
|
|
340
|
-
for (const tc of choice.message.tool_calls) {
|
|
341
|
-
const toolName = tc.function?.name;
|
|
342
|
-
const toolArgs = tc.function?.arguments ? JSON.parse(tc.function.arguments) : {};
|
|
343
|
-
const result = await executeTool(toolName, toolArgs);
|
|
344
|
-
toolResults.push(`[${toolName}]: ${result}`);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Add assistant message with tool calls and user message with results
|
|
348
|
-
const updatedMessages = [
|
|
349
|
-
...messages,
|
|
350
|
-
{ role: 'assistant' as const, content: choice.message.content || '', timestamp: Date.now() },
|
|
351
|
-
{ role: 'user' as const, content: `Tool results:\n${toolResults.join('\n')}`, timestamp: Date.now() }
|
|
352
|
-
];
|
|
353
|
-
|
|
354
|
-
// Continue conversation with tool results
|
|
355
|
-
return callGLM(updatedMessages, 0);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
return choice.message?.content || '(no response)';
|
|
359
|
-
|
|
360
|
-
} catch (error) {
|
|
361
|
-
// Retry on network errors or timeout
|
|
362
|
-
if ((error instanceof Error && (error.name === 'AbortError' || error.message.includes('fetch'))) && retryCount < CONFIG.maxRetries) {
|
|
363
|
-
const backoff = calculateBackoff(retryCount);
|
|
364
|
-
console.log(`Network error, retrying in ${backoff}ms (${retryCount + 1}/${CONFIG.maxRetries})`);
|
|
365
|
-
await sleep(backoff);
|
|
366
|
-
return callGLM(messages, retryCount + 1);
|
|
367
|
-
}
|
|
368
|
-
throw error;
|
|
233
|
+
this.connected = false;
|
|
234
|
+
console.log("[SSHChannel] Stopped");
|
|
369
235
|
}
|
|
370
|
-
}
|
|
371
236
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Set the message handler. The daemon/consumer provides intelligence.
|
|
239
|
+
*/
|
|
240
|
+
onMessage(handler: MessageHandler): void {
|
|
241
|
+
this.messageHandler = handler;
|
|
242
|
+
}
|
|
375
243
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
}
|
|
383
|
-
if (input === '/help') {
|
|
384
|
-
return `Commands:
|
|
385
|
-
/clear - Clear conversation memory
|
|
386
|
-
/help - Show this help
|
|
387
|
-
/status - Show system status
|
|
244
|
+
/**
|
|
245
|
+
* Send a response back to SSH channel (writes to OUT_FILE).
|
|
246
|
+
*/
|
|
247
|
+
async send(response: ChannelResponse): Promise<void> {
|
|
248
|
+
const text = response.content.text;
|
|
249
|
+
const timestamp = new Date().toISOString();
|
|
250
|
+
writeFileSync(this.OUT_FILE, `[${timestamp}]\n${text}\n`);
|
|
388
251
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
if (input === '/status') {
|
|
392
|
-
return `Status: running
|
|
393
|
-
Memory file: ${MEMORY_FILE}
|
|
394
|
-
Chat dir: ${CONFIG.chatDir}`;
|
|
395
|
-
}
|
|
396
|
-
return `Unknown command: ${input}. Type /help for available commands.`;
|
|
252
|
+
// Store in memory
|
|
253
|
+
this.memory.add("assistant", text);
|
|
397
254
|
}
|
|
398
255
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
return await callGLM(messages);
|
|
403
|
-
}
|
|
256
|
+
isConnected(): boolean {
|
|
257
|
+
return this.connected;
|
|
258
|
+
}
|
|
404
259
|
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
260
|
+
// ============================================================
|
|
261
|
+
// Public Helpers
|
|
262
|
+
// ============================================================
|
|
408
263
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
console.log('Usage:');
|
|
415
|
-
console.log(` Write message: echo "your message" > ${IN_FILE}`);
|
|
416
|
-
console.log(` Read response: cat ${OUT_FILE}`);
|
|
417
|
-
console.log('');
|
|
418
|
-
|
|
419
|
-
// Ensure directories exist
|
|
420
|
-
ensureDir();
|
|
421
|
-
|
|
422
|
-
// Verify API key is configured
|
|
423
|
-
try {
|
|
424
|
-
getAPIKey();
|
|
425
|
-
console.log('GLM client initialized with retry support');
|
|
426
|
-
} catch (e) {
|
|
427
|
-
console.error('Failed to initialize GLM client:', (e as Error).message);
|
|
428
|
-
process.exit(1);
|
|
264
|
+
/**
|
|
265
|
+
* Get the conversation memory.
|
|
266
|
+
*/
|
|
267
|
+
getMemory(): ConversationMemory {
|
|
268
|
+
return this.memory;
|
|
429
269
|
}
|
|
430
270
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
// Create empty files if they don't exist
|
|
438
|
-
if (!existsSync(IN_FILE)) writeFileSync(IN_FILE, '');
|
|
439
|
-
if (!existsSync(OUT_FILE)) writeFileSync(OUT_FILE, 'Ready. Send a message.\n');
|
|
271
|
+
/**
|
|
272
|
+
* Get the input file path.
|
|
273
|
+
*/
|
|
274
|
+
getInFile(): string {
|
|
275
|
+
return this.IN_FILE;
|
|
276
|
+
}
|
|
440
277
|
|
|
441
|
-
|
|
278
|
+
/**
|
|
279
|
+
* Get the output file path.
|
|
280
|
+
*/
|
|
281
|
+
getOutFile(): string {
|
|
282
|
+
return this.OUT_FILE;
|
|
283
|
+
}
|
|
442
284
|
|
|
443
|
-
|
|
444
|
-
|
|
285
|
+
/**
|
|
286
|
+
* Get the status file path.
|
|
287
|
+
*/
|
|
288
|
+
getStatusFile(): string {
|
|
289
|
+
return this.STATUS_FILE;
|
|
290
|
+
}
|
|
445
291
|
|
|
446
|
-
|
|
292
|
+
// ============================================================
|
|
293
|
+
// Internal Methods
|
|
294
|
+
// ============================================================
|
|
447
295
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
processIncoming();
|
|
296
|
+
private ensureDir(): void {
|
|
297
|
+
if (!existsSync(this.config.chatDir)) {
|
|
298
|
+
mkdirSync(this.config.chatDir, { recursive: true });
|
|
452
299
|
}
|
|
453
|
-
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private setStatus(status: "idle" | "processing" | "error"): void {
|
|
303
|
+
writeFileSync(this.STATUS_FILE, JSON.stringify({ status, timestamp: Date.now() }));
|
|
304
|
+
}
|
|
454
305
|
|
|
455
|
-
async
|
|
306
|
+
private async processIncoming(): Promise<void> {
|
|
456
307
|
try {
|
|
457
|
-
const content = readFileSync(IN_FILE,
|
|
308
|
+
const content = readFileSync(this.IN_FILE, "utf-8").trim();
|
|
458
309
|
|
|
459
310
|
// Skip if same as last or empty
|
|
460
|
-
if (!content || content === lastContent) return;
|
|
311
|
+
if (!content || content === this.lastContent) return;
|
|
461
312
|
|
|
462
|
-
lastContent = content;
|
|
463
|
-
setStatus(
|
|
313
|
+
this.lastContent = content;
|
|
314
|
+
this.setStatus("processing");
|
|
464
315
|
|
|
465
|
-
console.log(`[
|
|
316
|
+
console.log(`[SSHChannel] Processing: ${content.slice(0, 50)}...`);
|
|
466
317
|
|
|
467
318
|
// Clear input file after reading
|
|
468
|
-
writeFileSync(IN_FILE,
|
|
469
|
-
|
|
470
|
-
//
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
319
|
+
writeFileSync(this.IN_FILE, "");
|
|
320
|
+
|
|
321
|
+
// Create normalized message
|
|
322
|
+
const message = this.createChannelMessage(content);
|
|
323
|
+
|
|
324
|
+
// Store in memory
|
|
325
|
+
this.memory.add("user", content);
|
|
326
|
+
|
|
327
|
+
// Route to handler if set
|
|
328
|
+
if (this.messageHandler) {
|
|
329
|
+
try {
|
|
330
|
+
const response = await this.messageHandler(message);
|
|
331
|
+
if (response) {
|
|
332
|
+
await this.send(response);
|
|
333
|
+
}
|
|
334
|
+
} catch (error) {
|
|
335
|
+
console.error("[SSHChannel] Handler error:", error);
|
|
336
|
+
writeFileSync(
|
|
337
|
+
this.OUT_FILE,
|
|
338
|
+
`[${new Date().toISOString()}]\nError: ${(error as Error).message}\n`
|
|
339
|
+
);
|
|
340
|
+
this.setStatus("error");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
// No handler - echo mode
|
|
345
|
+
await this.send({
|
|
346
|
+
content: { text: `Echo: ${content}` },
|
|
347
|
+
replyTo: { messageId: message.messageId, channelId: message.channelId },
|
|
348
|
+
});
|
|
349
|
+
}
|
|
476
350
|
|
|
477
|
-
setStatus(
|
|
478
|
-
console.log(
|
|
351
|
+
this.setStatus("idle");
|
|
352
|
+
console.log("[SSHChannel] Response sent");
|
|
479
353
|
} catch (error) {
|
|
480
|
-
console.error(
|
|
481
|
-
setStatus(
|
|
482
|
-
writeOutput(`Error: ${(error as Error).message}`);
|
|
354
|
+
console.error("[SSHChannel] Error:", error);
|
|
355
|
+
this.setStatus("error");
|
|
483
356
|
}
|
|
484
357
|
}
|
|
485
358
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
359
|
+
/**
|
|
360
|
+
* Normalize SSH input to ChannelMessage format.
|
|
361
|
+
*/
|
|
362
|
+
private createChannelMessage(text: string): ChannelMessage {
|
|
363
|
+
this.messageCounter++;
|
|
364
|
+
const messageId = `ssh-${Date.now()}-${this.messageCounter}`;
|
|
365
|
+
|
|
366
|
+
const sender: MessageSender = {
|
|
367
|
+
id: "ssh-user",
|
|
368
|
+
username: "ssh",
|
|
369
|
+
displayName: "SSH User",
|
|
370
|
+
isBot: false,
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const context: MessageContext = {
|
|
374
|
+
isDM: true,
|
|
375
|
+
metadata: {
|
|
376
|
+
source: "file-ipc",
|
|
377
|
+
chatDir: this.config.chatDir,
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
messageId,
|
|
383
|
+
channelId: this.id,
|
|
384
|
+
timestamp: new Date(),
|
|
385
|
+
sender,
|
|
386
|
+
text,
|
|
387
|
+
context,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ============================================================
|
|
393
|
+
// FACTORY
|
|
394
|
+
// ============================================================
|
|
495
395
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
396
|
+
export function createSSHChannel(config?: SSHConfig): SSHChannel {
|
|
397
|
+
return new SSHChannel(config);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ============================================================
|
|
401
|
+
// MAIN ENTRY POINT (for standalone testing)
|
|
402
|
+
// ============================================================
|
|
403
|
+
|
|
404
|
+
async function main() {
|
|
405
|
+
const config = createSSHConfigFromEnv();
|
|
406
|
+
const channel = createSSHChannel(config);
|
|
407
|
+
|
|
408
|
+
// Example: Echo handler for testing
|
|
409
|
+
channel.onMessage(async (msg) => {
|
|
410
|
+
console.log(`Received: ${msg.text}`);
|
|
411
|
+
return {
|
|
412
|
+
content: { text: `Echo: ${msg.text}` },
|
|
413
|
+
replyTo: { messageId: msg.messageId, channelId: msg.channelId },
|
|
414
|
+
};
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
process.on("SIGINT", async () => {
|
|
418
|
+
await channel.stop();
|
|
419
|
+
process.exit(0);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
process.on("SIGTERM", async () => {
|
|
423
|
+
await channel.stop();
|
|
500
424
|
process.exit(0);
|
|
501
425
|
});
|
|
426
|
+
|
|
427
|
+
await channel.start();
|
|
502
428
|
}
|
|
503
429
|
|
|
504
|
-
main()
|
|
430
|
+
// Only run main when executed directly (not when imported)
|
|
431
|
+
if (import.meta.main) {
|
|
432
|
+
main().catch(console.error);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ============================================================
|
|
436
|
+
// PLUGIN SYSTEM
|
|
437
|
+
// ============================================================
|
|
438
|
+
|
|
439
|
+
export { createSSHPlugin, sshPlugin, type ResolvedSSHAccount } from "./plugin.js";
|