@sns-myagent/cli 0.2.0 → 0.3.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/CHANGELOG.md +12 -1
- package/README.md +7 -8
- package/bin/.gitkeep +0 -0
- package/bin/snscoder.js +7 -96
- package/dist/cli.js +22026 -0
- package/package.json +4 -3
- package/scripts/apply-pi-natives-patch.js +82 -56
- package/scripts/fetch-binary.mjs +106 -217
- package/scripts/smoke-test.sh +92 -0
- package/src/adapters/telegram/bot.ts +41 -1
- package/src/adapters/telegram/bridge.ts +186 -0
- package/src/adapters/telegram/handler.ts +138 -13
- package/src/adapters/telegram/index.ts +12 -2
- package/src/agents/__tests__/config.test.ts +79 -0
- package/src/agents/__tests__/resilience.test.ts +119 -0
- package/src/agents/__tests__/strategies.test.ts +83 -0
- package/src/agents/config.ts +367 -0
- package/src/agents/ensemble.ts +230 -0
- package/src/agents/resilience.ts +332 -0
- package/src/agents/strategies/best-of-n.ts +108 -0
- package/src/agents/strategies/consensus.ts +104 -0
- package/src/agents/strategies/critic.ts +131 -0
- package/src/agents/strategies/index.ts +12 -0
- package/src/agents/strategies/types.ts +68 -0
- package/src/async/__tests__/task-runner.test.ts +162 -0
- package/src/async/__tests__/task-store.test.ts +146 -0
- package/src/async/index.ts +28 -1
- package/src/async/notifier.ts +133 -0
- package/src/async/task-runner.ts +175 -0
- package/src/async/task-store.ts +170 -0
- package/src/async/types.ts +70 -0
- package/src/cli/entry.ts +3 -1
- package/src/cli/index.ts +74 -55
- package/src/config/index.ts +1 -1
- package/src/debug/index.ts +1 -1
- package/src/internal-urls/docs-index.generated.txt +0 -2
- package/src/modes/components/welcome.ts +13 -6
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/setup-wizard/scenes/splash.ts +1 -1
- package/src/session/agent-session.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +63 -0
- package/src/slash-commands/helpers/task.ts +181 -0
- package/src/tbm/__tests__/tbm.test.ts +660 -0
- package/src/tbm/comm-modes.ts +165 -0
- package/src/tbm/config.ts +136 -0
- package/src/tbm/context-delta.ts +146 -0
- package/src/tbm/context-pyramid.ts +202 -0
- package/src/tbm/dashboard.ts +131 -0
- package/src/tbm/index.ts +247 -0
- package/src/tbm/lazy-skills.ts +182 -0
- package/src/tbm/response-cache.ts +220 -0
- package/src/tbm/tombstone.ts +189 -0
- package/src/tbm/tool-compress.ts +230 -0
- package/src/tools/ask.ts +1 -1
- package/src/tui/chat-blocks.ts +205 -0
- package/src/tui/chat-ui.ts +270 -0
- package/src/tui/code-cell.ts +90 -1
- package/src/tui/command-palette.ts +189 -0
- package/src/tui/index.ts +4 -0
- package/src/tui/splash.ts +130 -0
- package/src/ui/banner.ts +69 -29
- package/src/ui/colors.ts +24 -0
- package/src/ui/error-display.ts +130 -0
- package/src/ui/gradient.ts +104 -0
- package/src/ui/index.ts +15 -0
- package/src/ui/memory-toast.ts +102 -0
- package/src/ui/status-bar.ts +36 -30
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram ↔ Agent bridge.
|
|
3
|
+
*
|
|
4
|
+
* Creates per-chat agent sessions via the SDK `createAgentSession()` API.
|
|
5
|
+
* Each chat ID gets its own persistent session (auto-created on first message).
|
|
6
|
+
*
|
|
7
|
+
* The `createForwardToAgent()` factory returns a `ForwardToAgent` function
|
|
8
|
+
* that the TelegramBot handler calls for every `/chat` and freeform message.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createAgentSession, type CreateAgentSessionResult } from "../../sdk.js";
|
|
12
|
+
import type { AgentSession } from "../../session/agent-session.js";
|
|
13
|
+
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
|
14
|
+
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
15
|
+
import { loadConfig } from "../../config/loader.js";
|
|
16
|
+
import { initializeExtensions } from "../../modes/runtime-init.js";
|
|
17
|
+
|
|
18
|
+
/** Internal session record. */
|
|
19
|
+
interface ChatRecord {
|
|
20
|
+
result: CreateAgentSessionResult;
|
|
21
|
+
session: AgentSession;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** ForwardToAgent signature — matches handler.ts ForwardToAgent. */
|
|
26
|
+
export type ForwardToAgentFn = (
|
|
27
|
+
chatId: string,
|
|
28
|
+
userId: string,
|
|
29
|
+
text: string,
|
|
30
|
+
) => Promise<string>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* TTL cache: chatId → ChatRecord.
|
|
34
|
+
* Sessions auto-expire after `maxIdleMs` of inactivity.
|
|
35
|
+
*/
|
|
36
|
+
class SessionCache {
|
|
37
|
+
readonly #cache = new Map<string, ChatRecord>();
|
|
38
|
+
readonly #maxIdleMs: number;
|
|
39
|
+
|
|
40
|
+
constructor(maxIdleMs = 30 * 60 * 1000) {
|
|
41
|
+
this.#maxIdleMs = maxIdleMs;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get(chatId: string): ChatRecord | undefined {
|
|
45
|
+
const rec = this.#cache.get(chatId);
|
|
46
|
+
if (!rec) return undefined;
|
|
47
|
+
// Check idle expiry
|
|
48
|
+
if (Date.now() - rec.createdAt > this.#maxIdleMs) {
|
|
49
|
+
this.#cache.delete(chatId);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return rec;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
set(chatId: string, record: ChatRecord): void {
|
|
56
|
+
this.#cache.set(chatId, record);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
delete(chatId: string): void {
|
|
60
|
+
this.#cache.delete(chatId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
clear(): void {
|
|
64
|
+
this.#cache.clear();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get size(): number {
|
|
68
|
+
return this.#cache.size;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
keys(): IterableIterator<string> {
|
|
72
|
+
return this.#cache.keys();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sessionCache = new SessionCache();
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extract the assistant's text response from a session after prompt().
|
|
80
|
+
*/
|
|
81
|
+
function extractAssistantText(session: AgentSession): string {
|
|
82
|
+
const msgs = session.state.messages;
|
|
83
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
84
|
+
const msg = msgs[i];
|
|
85
|
+
if (msg && msg.role === "assistant") {
|
|
86
|
+
const asst = msg as AssistantMessage;
|
|
87
|
+
if (asst.stopReason === "error" || asst.stopReason === "aborted") {
|
|
88
|
+
return `⚠️ ${sanitizeText(asst.errorMessage || `Request ${asst.stopReason}`)}`;
|
|
89
|
+
}
|
|
90
|
+
const parts: string[] = [];
|
|
91
|
+
for (const c of asst.content) {
|
|
92
|
+
if (c.type === "text") parts.push(sanitizeText(c.text));
|
|
93
|
+
}
|
|
94
|
+
if (parts.length > 0) return parts.join("\n");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return "⚠️ No response from agent.";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create or retrieve an agent session for a chatId.
|
|
102
|
+
*/
|
|
103
|
+
async function getOrCreateSession(chatId: string): Promise<AgentSession> {
|
|
104
|
+
const existing = sessionCache.get(chatId);
|
|
105
|
+
if (existing) return existing.session;
|
|
106
|
+
|
|
107
|
+
const cfg = loadConfig();
|
|
108
|
+
const cwd = process.cwd();
|
|
109
|
+
|
|
110
|
+
const result = await createAgentSession({
|
|
111
|
+
cwd,
|
|
112
|
+
autoApprove: true,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const session = result.session;
|
|
116
|
+
|
|
117
|
+
// Subscribe for persistence (required by print-mode pattern)
|
|
118
|
+
session.subscribe(() => {});
|
|
119
|
+
|
|
120
|
+
// Initialize extensions (lightweight, no TUI)
|
|
121
|
+
await initializeExtensions(session, {
|
|
122
|
+
reportSendError: (_action, err) => {
|
|
123
|
+
console.error(`[telegram-bridge] extension error: ${err.message}`);
|
|
124
|
+
},
|
|
125
|
+
reportRuntimeError: (err) => {
|
|
126
|
+
console.error(`[telegram-bridge] runtime error: ${err.error}`);
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
sessionCache.set(chatId, {
|
|
131
|
+
result,
|
|
132
|
+
session,
|
|
133
|
+
createdAt: Date.now(),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
console.error(`[telegram-bridge] created session for chat=${chatId} (cache=${sessionCache.size})`);
|
|
137
|
+
return session;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create the forwardToAgent function that the TelegramBot handler calls.
|
|
142
|
+
*
|
|
143
|
+
* Usage in CLI:
|
|
144
|
+
* const forwardToAgent = createForwardToAgent();
|
|
145
|
+
* startTelegramAdapter(token, { autostart: true, forwardToAgent });
|
|
146
|
+
*/
|
|
147
|
+
export function createForwardToAgent(): ForwardToAgentFn {
|
|
148
|
+
return async (chatId, _userId, text): Promise<string> => {
|
|
149
|
+
try {
|
|
150
|
+
const session = await getOrCreateSession(chatId);
|
|
151
|
+
|
|
152
|
+
// session.prompt() returns true (agent processed) or false (slash-command consumed)
|
|
153
|
+
const handled = await session.prompt(text);
|
|
154
|
+
|
|
155
|
+
if (!handled) {
|
|
156
|
+
// Slash command consumed it — no LLM response to return
|
|
157
|
+
return "✅ Command processed.";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return extractAssistantText(session);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
163
|
+
console.error(`[telegram-bridge] forwardToAgent error: ${msg}`);
|
|
164
|
+
return `⚠️ Agent error: ${msg}`;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Reset a specific chat session (for /reset command).
|
|
171
|
+
*/
|
|
172
|
+
export function resetChatSession(chatId: string): boolean {
|
|
173
|
+
const had = sessionCache.get(chatId) !== undefined;
|
|
174
|
+
sessionCache.delete(chatId);
|
|
175
|
+
return had;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get bridge stats (for /status).
|
|
180
|
+
*/
|
|
181
|
+
export function getBridgeStats(): { activeSessions: number; chatIds: string[] } {
|
|
182
|
+
return {
|
|
183
|
+
activeSessions: sessionCache.size,
|
|
184
|
+
chatIds: [...sessionCache.keys()],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -12,12 +12,18 @@ import { logger } from "@oh-my-pi/pi-utils";
|
|
|
12
12
|
export type ParsedMessage =
|
|
13
13
|
| { kind: "command"; command: TelegramCommand; args: string; raw: string; userId: number; chatId: number; messageId: number }
|
|
14
14
|
| { kind: "chat"; text: string; userId: number; chatId: number; messageId: number }
|
|
15
|
+
| { kind: "file"; fileId: string; fileName: string; mimeType: string; caption: string; userId: number; chatId: number; messageId: number }
|
|
15
16
|
| { kind: "empty"; userId: number; chatId: number; messageId: number };
|
|
16
17
|
|
|
17
|
-
export type TelegramCommand =
|
|
18
|
+
export type TelegramCommand =
|
|
19
|
+
| "start" | "help" | "chat" | "reset" | "status"
|
|
20
|
+
| "memory" | "cron" | "model" | "code" | "review" | "task";
|
|
18
21
|
|
|
19
22
|
/** Commands we recognise, mapped to their canonical name. */
|
|
20
|
-
const COMMANDS: ReadonlySet<string> = new Set([
|
|
23
|
+
const COMMANDS: ReadonlySet<string> = new Set([
|
|
24
|
+
"start", "help", "chat", "reset", "status",
|
|
25
|
+
"memory", "cron", "model", "code", "review", "task",
|
|
26
|
+
]);
|
|
21
27
|
|
|
22
28
|
/**
|
|
23
29
|
* Parse a grammY `Message` into a `ParsedMessage`. Returns `kind: "empty"`
|
|
@@ -29,6 +35,59 @@ export function parseMessage(msg: Message): ParsedMessage {
|
|
|
29
35
|
const messageId = msg.message_id;
|
|
30
36
|
const text = (msg.text ?? msg.caption ?? "").trim();
|
|
31
37
|
|
|
38
|
+
// Detect file attachments (document, photo, video, audio, voice)
|
|
39
|
+
if (msg.document) {
|
|
40
|
+
return {
|
|
41
|
+
kind: "file",
|
|
42
|
+
fileId: msg.document.file_id,
|
|
43
|
+
fileName: msg.document.file_name ?? "file",
|
|
44
|
+
mimeType: msg.document.mime_type ?? "application/octet-stream",
|
|
45
|
+
caption: text,
|
|
46
|
+
userId, chatId, messageId,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (msg.photo && msg.photo.length > 0) {
|
|
50
|
+
const largest = msg.photo[msg.photo.length - 1]!;
|
|
51
|
+
return {
|
|
52
|
+
kind: "file",
|
|
53
|
+
fileId: largest.file_id,
|
|
54
|
+
fileName: `photo_${messageId}.jpg`,
|
|
55
|
+
mimeType: "image/jpeg",
|
|
56
|
+
caption: text,
|
|
57
|
+
userId, chatId, messageId,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (msg.video) {
|
|
61
|
+
return {
|
|
62
|
+
kind: "file",
|
|
63
|
+
fileId: msg.video.file_id,
|
|
64
|
+
fileName: msg.video.file_name ?? `video_${messageId}.mp4`,
|
|
65
|
+
mimeType: msg.video.mime_type ?? "video/mp4",
|
|
66
|
+
caption: text,
|
|
67
|
+
userId, chatId, messageId,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (msg.voice) {
|
|
71
|
+
return {
|
|
72
|
+
kind: "file",
|
|
73
|
+
fileId: msg.voice.file_id,
|
|
74
|
+
fileName: `voice_${messageId}.ogg`,
|
|
75
|
+
mimeType: msg.voice.mime_type ?? "audio/ogg",
|
|
76
|
+
caption: text,
|
|
77
|
+
userId, chatId, messageId,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (msg.audio) {
|
|
81
|
+
return {
|
|
82
|
+
kind: "file",
|
|
83
|
+
fileId: msg.audio.file_id,
|
|
84
|
+
fileName: msg.audio.file_name ?? `audio_${messageId}.mp3`,
|
|
85
|
+
mimeType: msg.audio.mime_type ?? "audio/mpeg",
|
|
86
|
+
caption: text,
|
|
87
|
+
userId, chatId, messageId,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
32
91
|
if (text.length === 0) {
|
|
33
92
|
return { kind: "empty", userId, chatId, messageId };
|
|
34
93
|
}
|
|
@@ -73,15 +132,22 @@ export const COMMAND_REPLIES: Record<TelegramCommand, string> = {
|
|
|
73
132
|
"/help — list commands (this message)\n" +
|
|
74
133
|
"/chat \\<message\\> — send a prompt to the agent explicitly\n" +
|
|
75
134
|
"/reset — clear the per\\-chat conversation context\n" +
|
|
76
|
-
"/status — show adapter health\n
|
|
135
|
+
"/status — show adapter health + bridge stats\n" +
|
|
136
|
+
"/memory — show agent memory usage\n" +
|
|
137
|
+
"/cron — show cron jobs\n" +
|
|
138
|
+
"/model — show current model\n" +
|
|
139
|
+
"/code \\<task\\> — send a coding task to the agent\n" +
|
|
140
|
+
"/review \\<task\\> — ask the agent to review code\n\n" +
|
|
77
141
|
"Anything that isn't a command is also forwarded to the agent.",
|
|
78
142
|
chat: "", // computed dynamically when invoked
|
|
79
|
-
reset: "
|
|
80
|
-
status:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
143
|
+
reset: "", // computed dynamically when invoked
|
|
144
|
+
status: "", // computed dynamically when invoked
|
|
145
|
+
memory: "", // forwarded to agent
|
|
146
|
+
cron: "", // forwarded to agent
|
|
147
|
+
model: "", // forwarded to agent
|
|
148
|
+
code: "", // forwarded to agent
|
|
149
|
+
review: "", // forwarded to agent
|
|
150
|
+
task: "", // forwarded to agent
|
|
85
151
|
};
|
|
86
152
|
|
|
87
153
|
export interface HandleContext {
|
|
@@ -89,6 +155,12 @@ export interface HandleContext {
|
|
|
89
155
|
readonly sessionKey: string;
|
|
90
156
|
/** Optional callback that the real agent integration replaces. */
|
|
91
157
|
forwardToAgent?: (text: string, sessionKey: string) => Promise<string>;
|
|
158
|
+
/** Optional callback to reset a chat session (bridge). */
|
|
159
|
+
resetChatSession?: (chatId: string) => boolean;
|
|
160
|
+
/** Optional callback to get bridge stats. */
|
|
161
|
+
getBridgeStats?: () => { activeSessions: number; chatIds: string[] };
|
|
162
|
+
/** Optional callback to download a Telegram file to local path. */
|
|
163
|
+
downloadFile?: (fileId: string, fileName: string) => Promise<string>;
|
|
92
164
|
}
|
|
93
165
|
|
|
94
166
|
/**
|
|
@@ -100,24 +172,77 @@ export async function resolveReply(parsed: ParsedMessage, ctx: HandleContext): P
|
|
|
100
172
|
switch (parsed.kind) {
|
|
101
173
|
case "empty":
|
|
102
174
|
return "I can only read text right now. Send a message and I'll forward it to the agent.";
|
|
175
|
+
|
|
103
176
|
case "command": {
|
|
104
177
|
switch (parsed.command) {
|
|
105
178
|
case "start":
|
|
106
179
|
case "help":
|
|
107
|
-
case "reset":
|
|
108
|
-
case "status":
|
|
109
180
|
return COMMAND_REPLIES[parsed.command];
|
|
181
|
+
|
|
182
|
+
case "reset": {
|
|
183
|
+
if (ctx.resetChatSession) {
|
|
184
|
+
const had = ctx.resetChatSession(ctx.sessionKey);
|
|
185
|
+
return had
|
|
186
|
+
? "Context cleared for this chat. Next message starts a fresh thread."
|
|
187
|
+
: "No active session for this chat. Next message will start one.";
|
|
188
|
+
}
|
|
189
|
+
return "Context cleared for this chat. Next message starts a fresh thread.";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
case "status": {
|
|
193
|
+
const stats = ctx.getBridgeStats?.();
|
|
194
|
+
const sessions = stats ? stats.activeSessions : 0;
|
|
195
|
+
return (
|
|
196
|
+
"*Adapter status*\n\n" +
|
|
197
|
+
"• transport: polling\n" +
|
|
198
|
+
"• backend: grammY v1\n" +
|
|
199
|
+
`• agent sessions: ${sessions}\n` +
|
|
200
|
+
"• bridge: wired ✓"
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
110
204
|
case "chat": {
|
|
111
205
|
const text = parsed.args.length > 0 ? parsed.args : "(empty)";
|
|
112
206
|
if (ctx.forwardToAgent) return ctx.forwardToAgent(text, ctx.sessionKey);
|
|
113
207
|
logger.debug("telegram: forwardToAgent not wired; echoing", { sessionKey: ctx.sessionKey });
|
|
114
208
|
return `[stub\\-agent echo] you said: ${escapeForPlain(text)}`;
|
|
115
209
|
}
|
|
210
|
+
|
|
211
|
+
case "memory":
|
|
212
|
+
case "cron":
|
|
213
|
+
case "model":
|
|
214
|
+
case "code":
|
|
215
|
+
case "review":
|
|
216
|
+
case "task": {
|
|
217
|
+
// Route through agent — the slash command will be recognized by the session
|
|
218
|
+
const cmdText = `/${parsed.command} ${parsed.args}`.trim();
|
|
219
|
+
if (ctx.forwardToAgent) return ctx.forwardToAgent(cmdText, ctx.sessionKey);
|
|
220
|
+
return `⚠️ Agent not wired. Use /help for available commands.`;
|
|
221
|
+
}
|
|
116
222
|
}
|
|
117
223
|
}
|
|
224
|
+
|
|
118
225
|
case "chat": {
|
|
119
|
-
|
|
120
|
-
|
|
226
|
+
const chatText = (parsed as { kind: "chat"; text: string }).text;
|
|
227
|
+
if (ctx.forwardToAgent) return ctx.forwardToAgent(chatText, ctx.sessionKey);
|
|
228
|
+
return `[stub\\-agent echo] you said: ${escapeForPlain(chatText)}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
case "file": {
|
|
232
|
+
const f = parsed as Extract<ParsedMessage, { kind: "file" }>;
|
|
233
|
+
if (!ctx.downloadFile) {
|
|
234
|
+
return "⚠️ File handling not configured. Send text instead.";
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const localPath = await ctx.downloadFile(f.fileId, f.fileName);
|
|
238
|
+
const fileInfo = `📎 File uploaded: ${f.fileName} (${f.mimeType})\nSaved to: ${localPath}`;
|
|
239
|
+
const prompt = f.caption ? `${fileInfo}\n\nUser caption: ${f.caption}` : fileInfo;
|
|
240
|
+
if (ctx.forwardToAgent) return ctx.forwardToAgent(prompt, ctx.sessionKey);
|
|
241
|
+
return `File saved to ${localPath}. Agent not wired — cannot process.`;
|
|
242
|
+
} catch (err) {
|
|
243
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
244
|
+
return `⚠️ File download failed: ${escapeForPlain(msg)}`;
|
|
245
|
+
}
|
|
121
246
|
}
|
|
122
247
|
}
|
|
123
248
|
}
|
|
@@ -36,14 +36,24 @@ let shuttingDown = false;
|
|
|
36
36
|
*/
|
|
37
37
|
export function startTelegramAdapter(
|
|
38
38
|
token: string | undefined = process.env.SNS_TELEGRAM_BOT_TOKEN,
|
|
39
|
-
opts: {
|
|
39
|
+
opts: {
|
|
40
|
+
autostart?: boolean;
|
|
41
|
+
forwardToAgent?: (text: string, sessionKey: string) => Promise<string>;
|
|
42
|
+
resetChatSession?: (chatId: string) => boolean;
|
|
43
|
+
getBridgeStats?: () => { activeSessions: number; chatIds: string[] };
|
|
44
|
+
} = {},
|
|
40
45
|
): TelegramBot | undefined {
|
|
41
46
|
if (activeBot) return activeBot;
|
|
42
47
|
if (!token) return undefined;
|
|
43
48
|
const autostart = opts.autostart ?? process.env.SNS_TELEGRAM_AUTOSTART !== "0";
|
|
44
49
|
if (!autostart) return undefined;
|
|
45
50
|
|
|
46
|
-
const bot = new TelegramBot({
|
|
51
|
+
const bot = new TelegramBot({
|
|
52
|
+
token,
|
|
53
|
+
forwardToAgent: opts.forwardToAgent,
|
|
54
|
+
resetChatSession: opts.resetChatSession,
|
|
55
|
+
getBridgeStats: opts.getBridgeStats,
|
|
56
|
+
});
|
|
47
57
|
activeBot = bot;
|
|
48
58
|
|
|
49
59
|
bot.start().catch((error) => {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents.yaml config tests — SNS-MyAgent Phase 5.2
|
|
3
|
+
*
|
|
4
|
+
* Tests YAML parsing + config validation without touching real fs paths
|
|
5
|
+
* (uses tmp dirs and cleans up after).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { parseYamlSimple, DEFAULT_CONFIG, AgentsConfigManager } from "../config.js";
|
|
14
|
+
|
|
15
|
+
describe("parseYamlSimple", () => {
|
|
16
|
+
it("parses simple key:value pairs", () => {
|
|
17
|
+
const out = parseYamlSimple("model: gpt-4o\nprovider: openai");
|
|
18
|
+
expect(out.model).toBe("gpt-4o");
|
|
19
|
+
expect(out.provider).toBe("openai");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns empty object on empty input", () => {
|
|
23
|
+
expect(parseYamlSimple("")).toEqual({});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("does not throw on indented map", () => {
|
|
27
|
+
expect(() =>
|
|
28
|
+
parseYamlSimple(`agents:
|
|
29
|
+
coder:
|
|
30
|
+
model: gpt-4o
|
|
31
|
+
tools: bash
|
|
32
|
+
`),
|
|
33
|
+
).not.toThrow();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("AgentsConfigManager", () => {
|
|
38
|
+
let tmpDir: string;
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
tmpDir = mkdtempSync(join(tmpdir(), "agents-config-"));
|
|
41
|
+
mkdirSync(join(tmpDir, ".sns-myagent"), { recursive: true });
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("loads bundled defaults when no custom config", () => {
|
|
48
|
+
const mgr = new AgentsConfigManager();
|
|
49
|
+
const cfg = mgr.load();
|
|
50
|
+
expect(cfg.agents).toBeDefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("loads project config from disk", () => {
|
|
54
|
+
const yaml = `agents:
|
|
55
|
+
reviewer:
|
|
56
|
+
model: gpt-4o-mini
|
|
57
|
+
tools:
|
|
58
|
+
- read
|
|
59
|
+
`;
|
|
60
|
+
writeFileSync(join(tmpDir, ".sns-myagent", "agents.yaml"), yaml);
|
|
61
|
+
const prevCwd = process.cwd();
|
|
62
|
+
process.chdir(tmpDir);
|
|
63
|
+
try {
|
|
64
|
+
const mgr = new AgentsConfigManager();
|
|
65
|
+
mgr.load();
|
|
66
|
+
expect(mgr.config.agents).toBeDefined();
|
|
67
|
+
} finally {
|
|
68
|
+
process.chdir(prevCwd);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("DEFAULT_CONFIG", () => {
|
|
74
|
+
it("has ensembles + defaults", () => {
|
|
75
|
+
expect(DEFAULT_CONFIG.ensembles).toBeDefined();
|
|
76
|
+
expect(DEFAULT_CONFIG.default_agent).toBe("task");
|
|
77
|
+
expect(typeof DEFAULT_CONFIG.max_concurrency).toBe("number");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resilience module smoke tests — SNS-MyAgent Phase 5.4
|
|
3
|
+
*
|
|
4
|
+
* Pure logic tests for retry, timeout, circuit breaker, fallback.
|
|
5
|
+
* No DB, no LLM, no async intervals.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
withRetry,
|
|
11
|
+
withTimeout,
|
|
12
|
+
withFallback,
|
|
13
|
+
CircuitBreaker,
|
|
14
|
+
TimeoutError,
|
|
15
|
+
CircuitBreakerOpenError,
|
|
16
|
+
} from "../resilience.js";
|
|
17
|
+
|
|
18
|
+
describe("withRetry", () => {
|
|
19
|
+
it("returns result on first success", async () => {
|
|
20
|
+
let calls = 0;
|
|
21
|
+
const out = await withRetry(
|
|
22
|
+
async () => {
|
|
23
|
+
calls++;
|
|
24
|
+
return 42;
|
|
25
|
+
},
|
|
26
|
+
{ maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 1, backoffMultiplier: 1, jitterFactor: 0 },
|
|
27
|
+
);
|
|
28
|
+
expect(out.success).toBe(true);
|
|
29
|
+
expect(out.result).toBe(42);
|
|
30
|
+
expect(calls).toBe(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("retries until success", async () => {
|
|
34
|
+
let calls = 0;
|
|
35
|
+
const out = await withRetry(
|
|
36
|
+
async () => {
|
|
37
|
+
calls++;
|
|
38
|
+
if (calls < 3) throw new Error("transient");
|
|
39
|
+
return "ok";
|
|
40
|
+
},
|
|
41
|
+
{ maxAttempts: 5, baseDelayMs: 1, maxDelayMs: 1, backoffMultiplier: 1, jitterFactor: 0 },
|
|
42
|
+
);
|
|
43
|
+
expect(out.success).toBe(true);
|
|
44
|
+
expect(out.result).toBe("ok");
|
|
45
|
+
expect(calls).toBe(3);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("fails after maxAttempts", async () => {
|
|
49
|
+
let calls = 0;
|
|
50
|
+
const out = await withRetry(
|
|
51
|
+
async () => {
|
|
52
|
+
calls++;
|
|
53
|
+
throw new Error("permanent");
|
|
54
|
+
},
|
|
55
|
+
{ maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1, backoffMultiplier: 1, jitterFactor: 0 },
|
|
56
|
+
);
|
|
57
|
+
expect(out.success).toBe(false);
|
|
58
|
+
expect(out.error?.message).toBe("permanent");
|
|
59
|
+
expect(calls).toBe(2);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("withTimeout", () => {
|
|
64
|
+
it("resolves when fn finishes fast", async () => {
|
|
65
|
+
const out = await withTimeout(async () => "fast", 100);
|
|
66
|
+
expect(out).toBe("fast");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rejects with TimeoutError on slow fn", async () => {
|
|
70
|
+
await expect(
|
|
71
|
+
withTimeout(async () => {
|
|
72
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
73
|
+
return "slow";
|
|
74
|
+
}, 20),
|
|
75
|
+
).rejects.toBeInstanceOf(TimeoutError);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("withFallback", () => {
|
|
80
|
+
it("returns primary result when it succeeds", async () => {
|
|
81
|
+
const out = await withFallback([
|
|
82
|
+
async () => "primary",
|
|
83
|
+
async () => "fallback",
|
|
84
|
+
]);
|
|
85
|
+
expect(out.success).toBe(true);
|
|
86
|
+
expect(out.result).toBe("primary");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("falls back when primary throws", async () => {
|
|
90
|
+
const out = await withFallback([
|
|
91
|
+
async () => {
|
|
92
|
+
throw new Error("primary down");
|
|
93
|
+
},
|
|
94
|
+
async () => "fallback",
|
|
95
|
+
]);
|
|
96
|
+
expect(out.success).toBe(true);
|
|
97
|
+
expect(out.result).toBe("fallback");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("CircuitBreaker", () => {
|
|
102
|
+
it("opens after threshold failures", async () => {
|
|
103
|
+
const cb = new CircuitBreaker("test1", { failureThreshold: 2, resetTimeoutMs: 60_000 });
|
|
104
|
+
const failing = async () => {
|
|
105
|
+
throw new Error("boom");
|
|
106
|
+
};
|
|
107
|
+
await expect(cb.execute(failing)).rejects.toThrow();
|
|
108
|
+
await expect(cb.execute(failing)).rejects.toThrow();
|
|
109
|
+
await expect(cb.execute(failing)).rejects.toBeInstanceOf(CircuitBreakerOpenError);
|
|
110
|
+
expect(cb.state).toBe("open");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("closes after successful execution in closed state", async () => {
|
|
114
|
+
const cb = new CircuitBreaker("test2", { failureThreshold: 5 });
|
|
115
|
+
const out = await cb.execute(async () => "ok");
|
|
116
|
+
expect(out).toBe("ok");
|
|
117
|
+
expect(cb.state).toBe("closed");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensemble strategy smoke tests — SNS-MyAgent Phase 5
|
|
3
|
+
*
|
|
4
|
+
* Pure logic tests (no DB, no LLM). Verifies deterministic
|
|
5
|
+
* consensus/critic/best-of-N behavior with mock executors.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from "bun:test";
|
|
9
|
+
import { ConsensusStrategy } from "../strategies/consensus.js";
|
|
10
|
+
import { BestOfNStrategy } from "../strategies/best-of-n.js";
|
|
11
|
+
import { CriticStrategy } from "../strategies/critic.js";
|
|
12
|
+
import { aggregateTokens } from "../strategies/types.js";
|
|
13
|
+
import type { AgentResponse } from "../strategies/types.js";
|
|
14
|
+
|
|
15
|
+
function mockResp(role: string, content: string, tokens = { input: 10, output: 20 }): AgentResponse {
|
|
16
|
+
return { role, model: "test", content, tokens, timeMs: 5 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("aggregateTokens", () => {
|
|
20
|
+
it("sums input/output across responses", () => {
|
|
21
|
+
const out = aggregateTokens([
|
|
22
|
+
mockResp("a", "x", { input: 5, output: 7 }),
|
|
23
|
+
mockResp("b", "y", { input: 3, output: 2 }),
|
|
24
|
+
]);
|
|
25
|
+
expect(out).toEqual({ input: 8, output: 9 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("treats missing tokens as zero", () => {
|
|
29
|
+
const out = aggregateTokens([{ role: "x", model: "m", content: "c", timeMs: 0 }]);
|
|
30
|
+
expect(out).toEqual({ input: 0, output: 0 });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("ConsensusStrategy", () => {
|
|
35
|
+
it("picks majority when threshold met", async () => {
|
|
36
|
+
const strat = new ConsensusStrategy({ n: 3, threshold: 0.5 });
|
|
37
|
+
const exec = async (role: string) => mockResp(role, role === "a" ? "YES" : "NO");
|
|
38
|
+
const result = await strat.execute("q?", ["a", "b"], exec);
|
|
39
|
+
expect(result.strategy).toBe("consensus");
|
|
40
|
+
expect(result.responses.length).toBe(3);
|
|
41
|
+
expect(["YES", "NO"]).toContain(result.final);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("throws when no agents provided", async () => {
|
|
45
|
+
const strat = new ConsensusStrategy();
|
|
46
|
+
await expect(strat.execute("q?", [], async () => mockResp("x", "x"))).rejects.toThrow(
|
|
47
|
+
/at least 1 agent/,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("BestOfNStrategy", () => {
|
|
53
|
+
it("executes and returns a ranked result", async () => {
|
|
54
|
+
const strat = new BestOfNStrategy({ n: 2 });
|
|
55
|
+
const exec = async (role: string, prompt: string) => {
|
|
56
|
+
// Scorer prompt format → respond with a number
|
|
57
|
+
if (prompt.includes("Rate")) return mockResp(role, "0.8");
|
|
58
|
+
return mockResp(role, `out-${role}`);
|
|
59
|
+
};
|
|
60
|
+
const result = await strat.execute("q?", ["a", "b"], exec);
|
|
61
|
+
expect(result.strategy).toBe("best_of_n");
|
|
62
|
+
expect(result.responses.length).toBeGreaterThan(0);
|
|
63
|
+
expect(result.final).toMatch(/out-[ab]/);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("CriticStrategy", () => {
|
|
68
|
+
it("iterates generator→critic until accept or max rounds", async () => {
|
|
69
|
+
const strat = new CriticStrategy({ maxRounds: 2 });
|
|
70
|
+
let round = 0;
|
|
71
|
+
const exec = async (_role: string, prompt: string) => {
|
|
72
|
+
round++;
|
|
73
|
+
if (prompt.includes("[critic review")) {
|
|
74
|
+
return mockResp("critic", "accepted", { input: 5, output: 10 });
|
|
75
|
+
}
|
|
76
|
+
return mockResp("gen", `draft-v${round}`, { input: 1, output: 5 });
|
|
77
|
+
};
|
|
78
|
+
const result = await strat.execute("write essay", ["gen", "critic"], exec);
|
|
79
|
+
expect(result.strategy).toBe("critic");
|
|
80
|
+
expect(result.rounds).toBeGreaterThanOrEqual(1);
|
|
81
|
+
expect(result.final).toContain("draft");
|
|
82
|
+
});
|
|
83
|
+
});
|