@ebowwa/stack 0.1.3 → 0.2.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 +26 -11
- package/dist/index.js +39 -136
- package/package.json +7 -10
- package/src/index.ts +66 -199
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# @ebowwa/stack
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cross-channel AI stack with shared memory.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
7
|
+
- **Channels**: SSH, Telegram (both optional)
|
|
8
|
+
- **Cross-Channel Memory**: Shared context between channels with permission controls
|
|
9
|
+
- **AI Brain**: GLM-powered message handling with tool execution
|
|
10
|
+
- **Minimal API**: Status and health endpoints
|
|
11
11
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
@@ -18,18 +18,33 @@ bun add @ebowwa/stack
|
|
|
18
18
|
## Usage
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
#
|
|
22
|
-
bun run @ebowwa/stack
|
|
21
|
+
# Telegram only
|
|
22
|
+
TELEGRAM_BOT_TOKEN=xxx bun run @ebowwa/stack
|
|
23
23
|
|
|
24
|
-
#
|
|
24
|
+
# SSH only
|
|
25
|
+
SSH_CHAT_DIR=/root/.ssh-chat bun run @ebowwa/stack
|
|
26
|
+
|
|
27
|
+
# Both channels
|
|
25
28
|
SSH_CHAT_DIR=/root/.ssh-chat TELEGRAM_BOT_TOKEN=xxx bun run @ebowwa/stack
|
|
26
29
|
```
|
|
27
30
|
|
|
31
|
+
## Environment Variables
|
|
32
|
+
|
|
33
|
+
| Variable | Description |
|
|
34
|
+
|----------|-------------|
|
|
35
|
+
| `SSH_CHAT_DIR` | SSH chat directory (enables SSH channel) |
|
|
36
|
+
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram) |
|
|
37
|
+
| `TELEGRAM_CHAT_ID` | Allowed Telegram chat ID |
|
|
38
|
+
| `API_PORT` | API port (default: 8911) |
|
|
39
|
+
| `NODE_NAME` | Node name (default: stack) |
|
|
40
|
+
|
|
41
|
+
## API Endpoints
|
|
42
|
+
|
|
43
|
+
- `GET /api/status` - Stack status
|
|
44
|
+
- `GET /health` - Health check
|
|
45
|
+
|
|
28
46
|
## Commands
|
|
29
47
|
|
|
30
|
-
- `/ralph start <prompt>` - Start a Ralph loop
|
|
31
|
-
- `/ralph list` - List running loops
|
|
32
|
-
- `/ralph stop <id>` - Stop a loop
|
|
33
48
|
- `/status` - Node status
|
|
34
49
|
- `/memory <cmd>` - Memory management (grant, revoke, list, clear)
|
|
35
50
|
|
package/dist/index.js
CHANGED
|
@@ -57410,15 +57410,13 @@ class Stack {
|
|
|
57410
57410
|
this.config = {
|
|
57411
57411
|
...config,
|
|
57412
57412
|
api: config.api ?? { port: 8911 },
|
|
57413
|
-
ralph: config.ralph ?? { worktreesDir: "/root/worktrees", repoUrl: "" },
|
|
57414
57413
|
ai: config.ai ?? { model: "GLM-4.7", temperature: 0.7, maxTokens: 4096 },
|
|
57415
57414
|
node: config.node ?? { name: "stack", hostname: "localhost" }
|
|
57416
57415
|
};
|
|
57417
57416
|
this.state = {
|
|
57418
57417
|
started: new Date,
|
|
57419
57418
|
channels: { ssh: false, telegram: false },
|
|
57420
|
-
api: { enabled: !!this.config.api, port: this.config.api?.port }
|
|
57421
|
-
ralphLoops: new Map
|
|
57419
|
+
api: { enabled: !!this.config.api, port: this.config.api?.port }
|
|
57422
57420
|
};
|
|
57423
57421
|
const memoryChannels = {};
|
|
57424
57422
|
const permissions = {};
|
|
@@ -57431,10 +57429,6 @@ class Stack {
|
|
|
57431
57429
|
memoryChannels.telegram = { memoryFile: "/root/.telegram-memory.json", maxMessages: 50 };
|
|
57432
57430
|
enabledChannels.push("telegram");
|
|
57433
57431
|
}
|
|
57434
|
-
if (this.config.api) {
|
|
57435
|
-
memoryChannels.api = { memoryFile: "/root/.api-memory.json", maxMessages: 100 };
|
|
57436
|
-
enabledChannels.push("api");
|
|
57437
|
-
}
|
|
57438
57432
|
for (const channel of enabledChannels) {
|
|
57439
57433
|
permissions[channel] = { canRead: enabledChannels.filter((c) => c !== channel) };
|
|
57440
57434
|
}
|
|
@@ -57447,7 +57441,7 @@ class Stack {
|
|
|
57447
57441
|
serverName: this.config.node.name,
|
|
57448
57442
|
hostname: this.config.node.hostname,
|
|
57449
57443
|
packageName: "@ebowwa/stack",
|
|
57450
|
-
version: "0.
|
|
57444
|
+
version: "0.2.0"
|
|
57451
57445
|
}
|
|
57452
57446
|
});
|
|
57453
57447
|
this.client = new GLMClient;
|
|
@@ -57488,17 +57482,28 @@ class Stack {
|
|
|
57488
57482
|
const channel = channelId.platform;
|
|
57489
57483
|
const text = message.text;
|
|
57490
57484
|
console.log(`[${channel}] ${text.slice(0, 50)}...`);
|
|
57485
|
+
const telegramChannel = this.channels.get("telegram");
|
|
57486
|
+
const chatId = channelId.metadata?.chatId;
|
|
57487
|
+
if (channel === "telegram" && telegramChannel?.startTypingIndicator && chatId) {
|
|
57488
|
+
telegramChannel.startTypingIndicator(chatId);
|
|
57489
|
+
}
|
|
57490
|
+
const stopTyping = () => {
|
|
57491
|
+
if (channel === "telegram" && telegramChannel?.stopTypingIndicator && chatId) {
|
|
57492
|
+
telegramChannel.stopTypingIndicator(chatId);
|
|
57493
|
+
}
|
|
57494
|
+
};
|
|
57491
57495
|
const cmdResult = parseMemoryCommand(this.memory, channel, text);
|
|
57492
57496
|
if (cmdResult.handled) {
|
|
57497
|
+
stopTyping();
|
|
57493
57498
|
return {
|
|
57494
57499
|
content: { text: cmdResult.response || "Done" },
|
|
57495
57500
|
replyTo: { messageId: message.messageId, channelId: message.channelId }
|
|
57496
57501
|
};
|
|
57497
57502
|
}
|
|
57498
|
-
|
|
57499
|
-
|
|
57503
|
+
if (text.trim().toLowerCase() === "/status") {
|
|
57504
|
+
stopTyping();
|
|
57500
57505
|
return {
|
|
57501
|
-
content: { text:
|
|
57506
|
+
content: { text: this.getStatus() },
|
|
57502
57507
|
replyTo: { messageId: message.messageId, channelId: message.channelId }
|
|
57503
57508
|
};
|
|
57504
57509
|
}
|
|
@@ -57512,12 +57517,14 @@ class Stack {
|
|
|
57512
57517
|
maxTokens: this.config.ai.maxTokens
|
|
57513
57518
|
});
|
|
57514
57519
|
this.memory.addMessage(channel, { role: "assistant", content: result.content });
|
|
57520
|
+
stopTyping();
|
|
57515
57521
|
return {
|
|
57516
57522
|
content: { text: result.content },
|
|
57517
57523
|
replyTo: { messageId: message.messageId, channelId: message.channelId }
|
|
57518
57524
|
};
|
|
57519
57525
|
} catch (error) {
|
|
57520
57526
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
57527
|
+
stopTyping();
|
|
57521
57528
|
return {
|
|
57522
57529
|
content: { text: `Error: ${errorMsg}` },
|
|
57523
57530
|
replyTo: { messageId: message.messageId, channelId: message.channelId }
|
|
@@ -57525,91 +57532,27 @@ class Stack {
|
|
|
57525
57532
|
}
|
|
57526
57533
|
}
|
|
57527
57534
|
buildSystemPrompt() {
|
|
57528
|
-
|
|
57535
|
+
const channelList = Object.entries(this.state.channels).filter(([, v]) => v).map(([k]) => k).join(", ") || "none";
|
|
57536
|
+
return `You are **${this.config.node.name}** \u2014 a 24/7 AI assistant.
|
|
57529
57537
|
|
|
57530
|
-
##
|
|
57531
|
-
-
|
|
57532
|
-
-
|
|
57533
|
-
-
|
|
57534
|
-
- **Cross-Channel Memory**: Shared context between SSH, Telegram, and API
|
|
57538
|
+
## Capabilities
|
|
57539
|
+
- Cross-channel memory (shared context between channels)
|
|
57540
|
+
- Tool execution via MCP
|
|
57541
|
+
- Node monitoring and management
|
|
57535
57542
|
|
|
57536
57543
|
## Commands
|
|
57537
|
-
- \`/ralph start <prompt>\` \u2014 Start a Ralph loop
|
|
57538
|
-
- \`/ralph list\` \u2014 List running loops
|
|
57539
|
-
- \`/ralph stop <id>\` \u2014 Stop a loop
|
|
57540
57544
|
- \`/status\` \u2014 Node status
|
|
57541
57545
|
- \`/memory <cmd>\` \u2014 Memory management (grant, revoke, list, clear)
|
|
57542
57546
|
|
|
57543
|
-
##
|
|
57547
|
+
## Info
|
|
57544
57548
|
- Node: ${this.config.node.name}
|
|
57545
|
-
- Channels: ${
|
|
57549
|
+
- Channels: ${channelList}
|
|
57546
57550
|
- API: :${this.config.api?.port ?? 8911}`;
|
|
57547
57551
|
}
|
|
57548
|
-
async handleRalphCommand(channel, text) {
|
|
57549
|
-
const parts = text.trim().split(/\s+/);
|
|
57550
|
-
const cmd = parts[0].toLowerCase();
|
|
57551
|
-
if (cmd === "/ralph" || cmd === "/ralph") {
|
|
57552
|
-
const subCmd = parts[1]?.toLowerCase();
|
|
57553
|
-
switch (subCmd) {
|
|
57554
|
-
case "start": {
|
|
57555
|
-
const prompt = parts.slice(2).join(" ");
|
|
57556
|
-
if (!prompt)
|
|
57557
|
-
return "Usage: /ralph start <prompt>";
|
|
57558
|
-
return await this.startRalphLoop(prompt);
|
|
57559
|
-
}
|
|
57560
|
-
case "list":
|
|
57561
|
-
return this.listRalphLoops();
|
|
57562
|
-
case "stop": {
|
|
57563
|
-
const id = parts[2];
|
|
57564
|
-
if (!id)
|
|
57565
|
-
return "Usage: /ralph stop <id>";
|
|
57566
|
-
return await this.stopRalphLoop(id);
|
|
57567
|
-
}
|
|
57568
|
-
default:
|
|
57569
|
-
return "Ralph commands: start, list, stop";
|
|
57570
|
-
}
|
|
57571
|
-
}
|
|
57572
|
-
if (cmd === "/status") {
|
|
57573
|
-
return this.getStatus();
|
|
57574
|
-
}
|
|
57575
|
-
return null;
|
|
57576
|
-
}
|
|
57577
|
-
async startRalphLoop(prompt) {
|
|
57578
|
-
const id = `ralph-${Date.now()}`;
|
|
57579
|
-
this.state.ralphLoops.set(id, {
|
|
57580
|
-
id,
|
|
57581
|
-
worktree: `${this.config.ralph.worktreesDir}/${id}`,
|
|
57582
|
-
prompt,
|
|
57583
|
-
status: "running",
|
|
57584
|
-
iterations: 0,
|
|
57585
|
-
started: new Date
|
|
57586
|
-
});
|
|
57587
|
-
return `Started Ralph loop: ${id}
|
|
57588
|
-
Prompt: ${prompt.slice(0, 100)}...`;
|
|
57589
|
-
}
|
|
57590
|
-
listRalphLoops() {
|
|
57591
|
-
if (this.state.ralphLoops.size === 0) {
|
|
57592
|
-
return "No Ralph loops running";
|
|
57593
|
-
}
|
|
57594
|
-
const lines = ["Ralph Loops:"];
|
|
57595
|
-
for (const [id, info] of this.state.ralphLoops) {
|
|
57596
|
-
lines.push(` ${id}: ${info.status} (${info.iterations} iters)`);
|
|
57597
|
-
}
|
|
57598
|
-
return lines.join(`
|
|
57599
|
-
`);
|
|
57600
|
-
}
|
|
57601
|
-
async stopRalphLoop(id) {
|
|
57602
|
-
const info = this.state.ralphLoops.get(id);
|
|
57603
|
-
if (!info)
|
|
57604
|
-
return `Ralph loop not found: ${id}`;
|
|
57605
|
-
info.status = "paused";
|
|
57606
|
-
return `Stopped Ralph loop: ${id}`;
|
|
57607
|
-
}
|
|
57608
57552
|
getStatus() {
|
|
57609
57553
|
const lines = [
|
|
57610
57554
|
`**${this.config.node.name} Status**`,
|
|
57611
57555
|
`Channels: SSH=${this.state.channels.ssh}, Telegram=${this.state.channels.telegram}`,
|
|
57612
|
-
`Ralph Loops: ${this.state.ralphLoops.size}`,
|
|
57613
57556
|
`Uptime: ${Math.floor((Date.now() - this.state.started.getTime()) / 1000)}s`
|
|
57614
57557
|
];
|
|
57615
57558
|
return lines.join(`
|
|
@@ -57620,7 +57563,7 @@ Prompt: ${prompt.slice(0, 100)}...`;
|
|
|
57620
57563
|
return;
|
|
57621
57564
|
const port = this.config.api.port ?? 8911;
|
|
57622
57565
|
const host = this.config.api.host ?? "0.0.0.0";
|
|
57623
|
-
|
|
57566
|
+
Bun.serve({
|
|
57624
57567
|
port,
|
|
57625
57568
|
host,
|
|
57626
57569
|
fetch: async (req) => {
|
|
@@ -57628,63 +57571,27 @@ Prompt: ${prompt.slice(0, 100)}...`;
|
|
|
57628
57571
|
const path = url.pathname;
|
|
57629
57572
|
const corsHeaders = {
|
|
57630
57573
|
"Access-Control-Allow-Origin": "*",
|
|
57631
|
-
"Access-Control-Allow-Methods": "GET,
|
|
57574
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
57632
57575
|
"Access-Control-Allow-Headers": "Content-Type"
|
|
57633
57576
|
};
|
|
57634
57577
|
if (req.method === "OPTIONS") {
|
|
57635
57578
|
return new Response(null, { headers: corsHeaders });
|
|
57636
57579
|
}
|
|
57637
|
-
|
|
57638
|
-
|
|
57639
|
-
|
|
57640
|
-
|
|
57641
|
-
|
|
57642
|
-
|
|
57643
|
-
|
|
57644
|
-
|
|
57645
|
-
|
|
57646
|
-
const prompt = body.prompt;
|
|
57647
|
-
if (!prompt) {
|
|
57648
|
-
return Response.json({ error: "Missing prompt" }, { status: 400, headers: corsHeaders });
|
|
57649
|
-
}
|
|
57650
|
-
const result = await this.startRalphLoop(prompt);
|
|
57651
|
-
return Response.json({ message: result }, { headers: corsHeaders });
|
|
57652
|
-
}
|
|
57653
|
-
const match = path.match(/^\/api\/ralph-loops\/(.+)$/);
|
|
57654
|
-
if (match && req.method === "DELETE") {
|
|
57655
|
-
const result = await this.stopRalphLoop(match[1]);
|
|
57656
|
-
return Response.json({ message: result }, { headers: corsHeaders });
|
|
57657
|
-
}
|
|
57658
|
-
if (path === "/health") {
|
|
57659
|
-
return Response.json({ status: "ok" }, { headers: corsHeaders });
|
|
57660
|
-
}
|
|
57661
|
-
return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });
|
|
57662
|
-
} catch (error) {
|
|
57663
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
57664
|
-
return Response.json({ error: errorMsg }, { status: 500, headers: corsHeaders });
|
|
57580
|
+
if (path === "/api/status" && req.method === "GET") {
|
|
57581
|
+
return Response.json({
|
|
57582
|
+
node: this.config.node.name,
|
|
57583
|
+
channels: this.state.channels,
|
|
57584
|
+
uptime: Math.floor((Date.now() - this.state.started.getTime()) / 1000)
|
|
57585
|
+
}, { headers: corsHeaders });
|
|
57586
|
+
}
|
|
57587
|
+
if (path === "/health") {
|
|
57588
|
+
return Response.json({ status: "ok" }, { headers: corsHeaders });
|
|
57665
57589
|
}
|
|
57590
|
+
return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });
|
|
57666
57591
|
}
|
|
57667
57592
|
});
|
|
57668
57593
|
console.log(`[Stack] API running on http://${host}:${port}`);
|
|
57669
57594
|
}
|
|
57670
|
-
getStatusJSON() {
|
|
57671
|
-
return {
|
|
57672
|
-
node: this.config.node.name,
|
|
57673
|
-
channels: {
|
|
57674
|
-
ssh: this.state.channels.ssh,
|
|
57675
|
-
telegram: this.state.channels.telegram
|
|
57676
|
-
},
|
|
57677
|
-
api: {
|
|
57678
|
-
enabled: this.state.api.enabled,
|
|
57679
|
-
port: this.state.api.port
|
|
57680
|
-
},
|
|
57681
|
-
ralphLoops: this.state.ralphLoops.size,
|
|
57682
|
-
uptime: Math.floor((Date.now() - this.state.started.getTime()) / 1000)
|
|
57683
|
-
};
|
|
57684
|
-
}
|
|
57685
|
-
listRalphLoopsJSON() {
|
|
57686
|
-
return Array.from(this.state.ralphLoops.values());
|
|
57687
|
-
}
|
|
57688
57595
|
async start() {
|
|
57689
57596
|
console.log(`[Stack] Starting ${this.config.node.name}...`);
|
|
57690
57597
|
this.abortController = new AbortController;
|
|
@@ -57703,7 +57610,7 @@ Prompt: ${prompt.slice(0, 100)}...`;
|
|
|
57703
57610
|
if (this.state.api.enabled) {
|
|
57704
57611
|
console.log(` - API: :${this.state.api.port}`);
|
|
57705
57612
|
}
|
|
57706
|
-
console.log(" - Commands: /
|
|
57613
|
+
console.log(" - Commands: /status, /memory <cmd>");
|
|
57707
57614
|
await new Promise(() => {});
|
|
57708
57615
|
}
|
|
57709
57616
|
async stop() {
|
|
@@ -57735,10 +57642,6 @@ async function main() {
|
|
|
57735
57642
|
port: parseInt(process.env.API_PORT || "8911", 10),
|
|
57736
57643
|
host: process.env.API_HOST
|
|
57737
57644
|
},
|
|
57738
|
-
ralph: {
|
|
57739
|
-
worktreesDir: process.env.WORKTREES_DIR || "/root/worktrees",
|
|
57740
|
-
repoUrl: process.env.REPO_URL || ""
|
|
57741
|
-
},
|
|
57742
57645
|
node: {
|
|
57743
57646
|
name: process.env.NODE_NAME || "stack",
|
|
57744
57647
|
hostname: process.env.HOSTNAME || "localhost"
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ebowwa/stack",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Cross-channel AI stack with shared memory (SSH + Telegram)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
},
|
|
21
21
|
"keywords": [
|
|
22
22
|
"stack",
|
|
23
|
-
"
|
|
24
|
-
"daemon",
|
|
25
|
-
"ralph",
|
|
23
|
+
"channel",
|
|
26
24
|
"telegram",
|
|
27
25
|
"ssh",
|
|
28
|
-
"ai"
|
|
26
|
+
"ai",
|
|
27
|
+
"cross-channel",
|
|
28
|
+
"memory"
|
|
29
29
|
],
|
|
30
30
|
"author": "Ebowwa Labs <labs@ebowwa.com>",
|
|
31
31
|
"license": "MIT",
|
|
@@ -40,10 +40,7 @@
|
|
|
40
40
|
"@ebowwa/channel-ssh": "^2.1.1",
|
|
41
41
|
"@ebowwa/channel-telegram": "^1.14.2",
|
|
42
42
|
"@ebowwa/channel-types": "^0.2.1",
|
|
43
|
-
"@ebowwa/codespaces-types": "^1.6.1"
|
|
44
|
-
"@ebowwa/daemons": "^0.5.0",
|
|
45
|
-
"@ebowwa/node-agent": "^0.6.4",
|
|
46
|
-
"@ebowwa/rolling-keys": "^0.1.1"
|
|
43
|
+
"@ebowwa/codespaces-types": "^1.6.1"
|
|
47
44
|
},
|
|
48
45
|
"devDependencies": {
|
|
49
46
|
"@types/bun": "latest"
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* @ebowwa/stack -
|
|
3
|
+
* @ebowwa/stack - Cross-Channel AI Stack
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Features:
|
|
6
6
|
* - Unified Router: Cross-channel communication (SSH + Telegram)
|
|
7
|
-
* -
|
|
7
|
+
* - Cross-channel memory with permission controls
|
|
8
|
+
* - AI-powered message handling
|
|
8
9
|
*
|
|
9
10
|
* Architecture:
|
|
10
11
|
* ┌──────────────────────────────────────────────────────────────┐
|
|
11
12
|
* │ STACK │
|
|
12
13
|
* │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
|
|
13
|
-
* │ │
|
|
14
|
-
* │ │
|
|
14
|
+
* │ │ SSH Channel │ │Telegram Channel│ │ HTTP API │ │
|
|
15
|
+
* │ │ (optional) │ │ (optional) │ │ (optional) │ │
|
|
15
16
|
* │ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │
|
|
16
17
|
* │ │ │ │ │
|
|
17
18
|
* │ └───────────────────┴───────────────────┘ │
|
|
@@ -19,6 +20,10 @@
|
|
|
19
20
|
* │ ┌─────────▼─────────┐ │
|
|
20
21
|
* │ │ Shared Memory │ │
|
|
21
22
|
* │ │ (Cross-Context) │ │
|
|
23
|
+
* │ └─────────┬─────────┘ │
|
|
24
|
+
* │ │ │
|
|
25
|
+
* │ ┌─────────▼─────────┐ │
|
|
26
|
+
* │ │ AI Brain (GLM) │ │
|
|
22
27
|
* │ └───────────────────┘ │
|
|
23
28
|
* └──────────────────────────────────────────────────────────────┘
|
|
24
29
|
*/
|
|
@@ -43,17 +48,11 @@ export interface StackConfig {
|
|
|
43
48
|
botToken?: string;
|
|
44
49
|
allowedChats?: number[];
|
|
45
50
|
};
|
|
46
|
-
/** Enable HTTP API
|
|
51
|
+
/** Enable HTTP API */
|
|
47
52
|
api?: {
|
|
48
53
|
port?: number;
|
|
49
54
|
host?: string;
|
|
50
55
|
};
|
|
51
|
-
/** Ralph loop configuration */
|
|
52
|
-
ralph?: {
|
|
53
|
-
worktreesDir: string;
|
|
54
|
-
repoUrl: string;
|
|
55
|
-
baseBranch?: string;
|
|
56
|
-
};
|
|
57
56
|
/** AI configuration */
|
|
58
57
|
ai?: {
|
|
59
58
|
model?: string;
|
|
@@ -77,16 +76,6 @@ export interface StackState {
|
|
|
77
76
|
enabled: boolean;
|
|
78
77
|
port?: number;
|
|
79
78
|
};
|
|
80
|
-
ralphLoops: Map<string, RalphLoopInfo>;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
interface RalphLoopInfo {
|
|
84
|
-
id: string;
|
|
85
|
-
worktree: string;
|
|
86
|
-
prompt: string;
|
|
87
|
-
status: "running" | "paused" | "completed" | "error";
|
|
88
|
-
iterations: number;
|
|
89
|
-
started: Date;
|
|
90
79
|
}
|
|
91
80
|
|
|
92
81
|
// ============================================================
|
|
@@ -104,21 +93,17 @@ export class Stack {
|
|
|
104
93
|
private abortController: AbortController | null = null;
|
|
105
94
|
|
|
106
95
|
constructor(config: StackConfig) {
|
|
107
|
-
// Only set defaults for non-channel config
|
|
108
96
|
this.config = {
|
|
109
97
|
...config,
|
|
110
98
|
api: config.api ?? { port: 8911 },
|
|
111
|
-
ralph: config.ralph ?? { worktreesDir: "/root/worktrees", repoUrl: "" },
|
|
112
99
|
ai: config.ai ?? { model: "GLM-4.7", temperature: 0.7, maxTokens: 4096 },
|
|
113
100
|
node: config.node ?? { name: "stack", hostname: "localhost" },
|
|
114
|
-
// ssh and telegram remain undefined if not provided
|
|
115
101
|
};
|
|
116
102
|
|
|
117
103
|
this.state = {
|
|
118
104
|
started: new Date(),
|
|
119
105
|
channels: { ssh: false, telegram: false },
|
|
120
106
|
api: { enabled: !!this.config.api, port: this.config.api?.port },
|
|
121
|
-
ralphLoops: new Map(),
|
|
122
107
|
};
|
|
123
108
|
|
|
124
109
|
// Build memory channels dynamically based on enabled channels
|
|
@@ -134,17 +119,13 @@ export class Stack {
|
|
|
134
119
|
memoryChannels.telegram = { memoryFile: "/root/.telegram-memory.json", maxMessages: 50 };
|
|
135
120
|
enabledChannels.push("telegram");
|
|
136
121
|
}
|
|
137
|
-
if (this.config.api) {
|
|
138
|
-
memoryChannels.api = { memoryFile: "/root/.api-memory.json", maxMessages: 100 };
|
|
139
|
-
enabledChannels.push("api");
|
|
140
|
-
}
|
|
141
122
|
|
|
142
|
-
// Set up cross-channel permissions
|
|
123
|
+
// Set up cross-channel permissions
|
|
143
124
|
for (const channel of enabledChannels) {
|
|
144
125
|
permissions[channel] = { canRead: enabledChannels.filter(c => c !== channel) };
|
|
145
126
|
}
|
|
146
127
|
|
|
147
|
-
// Initialize shared memory
|
|
128
|
+
// Initialize shared memory
|
|
148
129
|
this.memory = createPermissionMemory({
|
|
149
130
|
channels: memoryChannels,
|
|
150
131
|
permissions,
|
|
@@ -156,7 +137,7 @@ export class Stack {
|
|
|
156
137
|
serverName: this.config.node.name,
|
|
157
138
|
hostname: this.config.node.hostname,
|
|
158
139
|
packageName: "@ebowwa/stack",
|
|
159
|
-
version: "0.
|
|
140
|
+
version: "0.2.0",
|
|
160
141
|
},
|
|
161
142
|
});
|
|
162
143
|
|
|
@@ -208,25 +189,39 @@ export class Stack {
|
|
|
208
189
|
|
|
209
190
|
private async handleMessage(routed: { message: ChannelMessage; channelId: ChannelId }): Promise<ChannelResponse> {
|
|
210
191
|
const { message, channelId } = routed;
|
|
211
|
-
const channel = channelId.platform as "ssh" | "telegram"
|
|
192
|
+
const channel = channelId.platform as "ssh" | "telegram";
|
|
212
193
|
const text = message.text;
|
|
213
194
|
|
|
214
195
|
console.log(`[${channel}] ${text.slice(0, 50)}...`);
|
|
215
196
|
|
|
197
|
+
// Start typing indicator for Telegram
|
|
198
|
+
const telegramChannel = this.channels.get("telegram") as { startTypingIndicator?: (chatId: string) => void; stopTypingIndicator?: (chatId: string) => void } | undefined;
|
|
199
|
+
const chatId = channelId.metadata?.chatId as string | undefined;
|
|
200
|
+
if (channel === "telegram" && telegramChannel?.startTypingIndicator && chatId) {
|
|
201
|
+
telegramChannel.startTypingIndicator(chatId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const stopTyping = () => {
|
|
205
|
+
if (channel === "telegram" && telegramChannel?.stopTypingIndicator && chatId) {
|
|
206
|
+
telegramChannel.stopTypingIndicator(chatId);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
216
210
|
// Check for memory commands
|
|
217
211
|
const cmdResult = parseMemoryCommand(this.memory, channel, text);
|
|
218
212
|
if (cmdResult.handled) {
|
|
213
|
+
stopTyping();
|
|
219
214
|
return {
|
|
220
215
|
content: { text: cmdResult.response || "Done" },
|
|
221
216
|
replyTo: { messageId: message.messageId, channelId: message.channelId },
|
|
222
217
|
};
|
|
223
218
|
}
|
|
224
219
|
|
|
225
|
-
// Check for
|
|
226
|
-
|
|
227
|
-
|
|
220
|
+
// Check for status command
|
|
221
|
+
if (text.trim().toLowerCase() === "/status") {
|
|
222
|
+
stopTyping();
|
|
228
223
|
return {
|
|
229
|
-
content: { text:
|
|
224
|
+
content: { text: this.getStatus() },
|
|
230
225
|
replyTo: { messageId: message.messageId, channelId: message.channelId },
|
|
231
226
|
};
|
|
232
227
|
}
|
|
@@ -252,6 +247,7 @@ export class Stack {
|
|
|
252
247
|
});
|
|
253
248
|
|
|
254
249
|
this.memory.addMessage(channel, { role: "assistant", content: result.content });
|
|
250
|
+
stopTyping();
|
|
255
251
|
|
|
256
252
|
return {
|
|
257
253
|
content: { text: result.content },
|
|
@@ -259,6 +255,7 @@ export class Stack {
|
|
|
259
255
|
};
|
|
260
256
|
} catch (error) {
|
|
261
257
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
258
|
+
stopTyping();
|
|
262
259
|
return {
|
|
263
260
|
content: { text: `Error: ${errorMsg}` },
|
|
264
261
|
replyTo: { messageId: message.messageId, channelId: message.channelId },
|
|
@@ -267,107 +264,39 @@ export class Stack {
|
|
|
267
264
|
}
|
|
268
265
|
|
|
269
266
|
private buildSystemPrompt(): string {
|
|
270
|
-
|
|
267
|
+
const channelList = Object.entries(this.state.channels)
|
|
268
|
+
.filter(([, v]) => v)
|
|
269
|
+
.map(([k]) => k)
|
|
270
|
+
.join(", ") || "none";
|
|
271
|
+
|
|
272
|
+
return `You are **${this.config.node.name}** — a 24/7 AI assistant.
|
|
271
273
|
|
|
272
|
-
##
|
|
273
|
-
-
|
|
274
|
-
-
|
|
275
|
-
-
|
|
276
|
-
- **Cross-Channel Memory**: Shared context between SSH, Telegram, and API
|
|
274
|
+
## Capabilities
|
|
275
|
+
- Cross-channel memory (shared context between channels)
|
|
276
|
+
- Tool execution via MCP
|
|
277
|
+
- Node monitoring and management
|
|
277
278
|
|
|
278
279
|
## Commands
|
|
279
|
-
- \`/ralph start <prompt>\` — Start a Ralph loop
|
|
280
|
-
- \`/ralph list\` — List running loops
|
|
281
|
-
- \`/ralph stop <id>\` — Stop a loop
|
|
282
280
|
- \`/status\` — Node status
|
|
283
281
|
- \`/memory <cmd>\` — Memory management (grant, revoke, list, clear)
|
|
284
282
|
|
|
285
|
-
##
|
|
283
|
+
## Info
|
|
286
284
|
- Node: ${this.config.node.name}
|
|
287
|
-
- Channels: ${
|
|
285
|
+
- Channels: ${channelList}
|
|
288
286
|
- API: :${this.config.api?.port ?? 8911}`;
|
|
289
287
|
}
|
|
290
288
|
|
|
291
|
-
// ============================================================
|
|
292
|
-
// Ralph Loop Management (delegates to node-agent)
|
|
293
|
-
// ============================================================
|
|
294
|
-
|
|
295
|
-
private async handleRalphCommand(channel: string, text: string): Promise<string | null> {
|
|
296
|
-
const parts = text.trim().split(/\s+/);
|
|
297
|
-
const cmd = parts[0].toLowerCase();
|
|
298
|
-
|
|
299
|
-
if (cmd === "/ralph" || cmd === "/ralph") {
|
|
300
|
-
const subCmd = parts[1]?.toLowerCase();
|
|
301
|
-
|
|
302
|
-
switch (subCmd) {
|
|
303
|
-
case "start": {
|
|
304
|
-
const prompt = parts.slice(2).join(" ");
|
|
305
|
-
if (!prompt) return "Usage: /ralph start <prompt>";
|
|
306
|
-
return await this.startRalphLoop(prompt);
|
|
307
|
-
}
|
|
308
|
-
case "list":
|
|
309
|
-
return this.listRalphLoops();
|
|
310
|
-
case "stop": {
|
|
311
|
-
const id = parts[2];
|
|
312
|
-
if (!id) return "Usage: /ralph stop <id>";
|
|
313
|
-
return await this.stopRalphLoop(id);
|
|
314
|
-
}
|
|
315
|
-
default:
|
|
316
|
-
return "Ralph commands: start, list, stop";
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
if (cmd === "/status") {
|
|
321
|
-
return this.getStatus();
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
return null;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
private async startRalphLoop(prompt: string): Promise<string> {
|
|
328
|
-
// TODO: Delegate to @ebowwa/node-agent RalphService
|
|
329
|
-
const id = `ralph-${Date.now()}`;
|
|
330
|
-
this.state.ralphLoops.set(id, {
|
|
331
|
-
id,
|
|
332
|
-
worktree: `${this.config.ralph.worktreesDir}/${id}`,
|
|
333
|
-
prompt,
|
|
334
|
-
status: "running",
|
|
335
|
-
iterations: 0,
|
|
336
|
-
started: new Date(),
|
|
337
|
-
});
|
|
338
|
-
return `Started Ralph loop: ${id}\nPrompt: ${prompt.slice(0, 100)}...`;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
private listRalphLoops(): string {
|
|
342
|
-
if (this.state.ralphLoops.size === 0) {
|
|
343
|
-
return "No Ralph loops running";
|
|
344
|
-
}
|
|
345
|
-
const lines = ["Ralph Loops:"];
|
|
346
|
-
for (const [id, info] of this.state.ralphLoops) {
|
|
347
|
-
lines.push(` ${id}: ${info.status} (${info.iterations} iters)`);
|
|
348
|
-
}
|
|
349
|
-
return lines.join("\n");
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
private async stopRalphLoop(id: string): Promise<string> {
|
|
353
|
-
const info = this.state.ralphLoops.get(id);
|
|
354
|
-
if (!info) return `Ralph loop not found: ${id}`;
|
|
355
|
-
info.status = "paused";
|
|
356
|
-
return `Stopped Ralph loop: ${id}`;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
289
|
private getStatus(): string {
|
|
360
290
|
const lines = [
|
|
361
291
|
`**${this.config.node.name} Status**`,
|
|
362
292
|
`Channels: SSH=${this.state.channels.ssh}, Telegram=${this.state.channels.telegram}`,
|
|
363
|
-
`Ralph Loops: ${this.state.ralphLoops.size}`,
|
|
364
293
|
`Uptime: ${Math.floor((Date.now() - this.state.started.getTime()) / 1000)}s`,
|
|
365
294
|
];
|
|
366
295
|
return lines.join("\n");
|
|
367
296
|
}
|
|
368
297
|
|
|
369
298
|
// ============================================================
|
|
370
|
-
// HTTP API
|
|
299
|
+
// HTTP API (minimal)
|
|
371
300
|
// ============================================================
|
|
372
301
|
|
|
373
302
|
private startAPI(): void {
|
|
@@ -376,17 +305,16 @@ export class Stack {
|
|
|
376
305
|
const port = this.config.api.port ?? 8911;
|
|
377
306
|
const host = this.config.api.host ?? "0.0.0.0";
|
|
378
307
|
|
|
379
|
-
|
|
308
|
+
Bun.serve({
|
|
380
309
|
port,
|
|
381
310
|
host,
|
|
382
311
|
fetch: async (req) => {
|
|
383
312
|
const url = new URL(req.url);
|
|
384
313
|
const path = url.pathname;
|
|
385
314
|
|
|
386
|
-
// CORS headers
|
|
387
315
|
const corsHeaders = {
|
|
388
316
|
"Access-Control-Allow-Origin": "*",
|
|
389
|
-
"Access-Control-Allow-Methods": "GET,
|
|
317
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
390
318
|
"Access-Control-Allow-Headers": "Content-Type",
|
|
391
319
|
};
|
|
392
320
|
|
|
@@ -394,69 +322,25 @@ export class Stack {
|
|
|
394
322
|
return new Response(null, { headers: corsHeaders });
|
|
395
323
|
}
|
|
396
324
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (path === "/api/ralph-loops" && req.method === "GET") {
|
|
405
|
-
return Response.json(this.listRalphLoopsJSON(), { headers: corsHeaders });
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// POST /api/ralph-loops
|
|
409
|
-
if (path === "/api/ralph-loops" && req.method === "POST") {
|
|
410
|
-
const body = await req.json();
|
|
411
|
-
const prompt = body.prompt;
|
|
412
|
-
if (!prompt) {
|
|
413
|
-
return Response.json({ error: "Missing prompt" }, { status: 400, headers: corsHeaders });
|
|
414
|
-
}
|
|
415
|
-
const result = await this.startRalphLoop(prompt);
|
|
416
|
-
return Response.json({ message: result }, { headers: corsHeaders });
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// DELETE /api/ralph-loops/:id
|
|
420
|
-
const match = path.match(/^\/api\/ralph-loops\/(.+)$/);
|
|
421
|
-
if (match && req.method === "DELETE") {
|
|
422
|
-
const result = await this.stopRalphLoop(match[1]);
|
|
423
|
-
return Response.json({ message: result }, { headers: corsHeaders });
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// GET /health
|
|
427
|
-
if (path === "/health") {
|
|
428
|
-
return Response.json({ status: "ok" }, { headers: corsHeaders });
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });
|
|
432
|
-
} catch (error) {
|
|
433
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
434
|
-
return Response.json({ error: errorMsg }, { status: 500, headers: corsHeaders });
|
|
325
|
+
// GET /api/status
|
|
326
|
+
if (path === "/api/status" && req.method === "GET") {
|
|
327
|
+
return Response.json({
|
|
328
|
+
node: this.config.node.name,
|
|
329
|
+
channels: this.state.channels,
|
|
330
|
+
uptime: Math.floor((Date.now() - this.state.started.getTime()) / 1000),
|
|
331
|
+
}, { headers: corsHeaders });
|
|
435
332
|
}
|
|
436
|
-
},
|
|
437
|
-
});
|
|
438
333
|
|
|
439
|
-
|
|
440
|
-
|
|
334
|
+
// GET /health
|
|
335
|
+
if (path === "/health") {
|
|
336
|
+
return Response.json({ status: "ok" }, { headers: corsHeaders });
|
|
337
|
+
}
|
|
441
338
|
|
|
442
|
-
|
|
443
|
-
return {
|
|
444
|
-
node: this.config.node.name,
|
|
445
|
-
channels: {
|
|
446
|
-
ssh: this.state.channels.ssh,
|
|
447
|
-
telegram: this.state.channels.telegram,
|
|
448
|
-
},
|
|
449
|
-
api: {
|
|
450
|
-
enabled: this.state.api.enabled,
|
|
451
|
-
port: this.state.api.port,
|
|
339
|
+
return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });
|
|
452
340
|
},
|
|
453
|
-
|
|
454
|
-
uptime: Math.floor((Date.now() - this.state.started.getTime()) / 1000),
|
|
455
|
-
};
|
|
456
|
-
}
|
|
341
|
+
});
|
|
457
342
|
|
|
458
|
-
|
|
459
|
-
return Array.from(this.state.ralphLoops.values());
|
|
343
|
+
console.log(`[Stack] API running on http://${host}:${port}`);
|
|
460
344
|
}
|
|
461
345
|
|
|
462
346
|
// ============================================================
|
|
@@ -468,17 +352,12 @@ export class Stack {
|
|
|
468
352
|
|
|
469
353
|
this.abortController = new AbortController();
|
|
470
354
|
|
|
471
|
-
// Register channels (only if configured)
|
|
472
355
|
await this.registerSSH();
|
|
473
356
|
await this.registerTelegram();
|
|
474
357
|
|
|
475
|
-
// Set handler
|
|
476
358
|
this.router.setHandler((routed) => this.handleMessage(routed));
|
|
477
|
-
|
|
478
|
-
// Start router
|
|
479
359
|
await this.router.start();
|
|
480
360
|
|
|
481
|
-
// Start API (optional)
|
|
482
361
|
this.startAPI();
|
|
483
362
|
|
|
484
363
|
console.log("[Stack] Running!");
|
|
@@ -491,10 +370,9 @@ export class Stack {
|
|
|
491
370
|
if (this.state.api.enabled) {
|
|
492
371
|
console.log(` - API: :${this.state.api.port}`);
|
|
493
372
|
}
|
|
494
|
-
console.log(" - Commands: /
|
|
373
|
+
console.log(" - Commands: /status, /memory <cmd>");
|
|
495
374
|
|
|
496
|
-
|
|
497
|
-
await new Promise(() => {}); // Run forever
|
|
375
|
+
await new Promise(() => {});
|
|
498
376
|
}
|
|
499
377
|
|
|
500
378
|
async stop(): Promise<void> {
|
|
@@ -502,7 +380,6 @@ export class Stack {
|
|
|
502
380
|
this.abortController?.abort();
|
|
503
381
|
await this.router.stop();
|
|
504
382
|
|
|
505
|
-
// Stop all channels
|
|
506
383
|
for (const [name, channel] of this.channels) {
|
|
507
384
|
console.log(`[Stack] Stopping ${name}...`);
|
|
508
385
|
await channel.stop();
|
|
@@ -517,31 +394,23 @@ export class Stack {
|
|
|
517
394
|
// ============================================================
|
|
518
395
|
|
|
519
396
|
async function main() {
|
|
520
|
-
// Build config - only enable channels when explicitly configured
|
|
521
397
|
const config: StackConfig = {
|
|
522
|
-
// SSH only enabled if SSH_CHAT_DIR is set
|
|
523
398
|
...(process.env.SSH_CHAT_DIR ? {
|
|
524
399
|
ssh: {
|
|
525
400
|
chatDir: process.env.SSH_CHAT_DIR,
|
|
526
401
|
pollInterval: parseInt(process.env.SSH_POLL_INTERVAL || "500", 10),
|
|
527
402
|
}
|
|
528
403
|
} : {}),
|
|
529
|
-
// Telegram only enabled if bot token is set
|
|
530
404
|
...(process.env.TELEGRAM_BOT_TOKEN ? {
|
|
531
405
|
telegram: {
|
|
532
406
|
botToken: process.env.TELEGRAM_BOT_TOKEN,
|
|
533
407
|
allowedChats: process.env.TELEGRAM_CHAT_ID ? [parseInt(process.env.TELEGRAM_CHAT_ID, 10)] : undefined,
|
|
534
408
|
}
|
|
535
409
|
} : {}),
|
|
536
|
-
// API enabled by default
|
|
537
410
|
api: {
|
|
538
411
|
port: parseInt(process.env.API_PORT || "8911", 10),
|
|
539
412
|
host: process.env.API_HOST,
|
|
540
413
|
},
|
|
541
|
-
ralph: {
|
|
542
|
-
worktreesDir: process.env.WORKTREES_DIR || "/root/worktrees",
|
|
543
|
-
repoUrl: process.env.REPO_URL || "",
|
|
544
|
-
},
|
|
545
414
|
node: {
|
|
546
415
|
name: process.env.NODE_NAME || "stack",
|
|
547
416
|
hostname: process.env.HOSTNAME || "localhost",
|
|
@@ -550,7 +419,6 @@ async function main() {
|
|
|
550
419
|
|
|
551
420
|
const stack = new Stack(config);
|
|
552
421
|
|
|
553
|
-
// Handle shutdown
|
|
554
422
|
process.on("SIGINT", async () => {
|
|
555
423
|
await stack.stop();
|
|
556
424
|
process.exit(0);
|
|
@@ -564,7 +432,6 @@ async function main() {
|
|
|
564
432
|
await stack.start();
|
|
565
433
|
}
|
|
566
434
|
|
|
567
|
-
// Run if executed directly
|
|
568
435
|
if (import.meta.main) {
|
|
569
436
|
main().catch((error) => {
|
|
570
437
|
console.error("[Stack] Fatal error:", error);
|