grok-telegram-bot 2.5.0 → 2.7.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/.env.example +13 -0
- package/CHANGELOG.md +106 -0
- package/README.md +20 -5
- package/docs/GROUP.md +39 -4
- package/docs/INSTALL.md +2 -0
- package/package.json +4 -4
- package/scripts/setup.mjs +20 -3
- package/src/app/instance.ts +223 -0
- package/src/app/types.ts +34 -1
- package/src/bot/ask-user-service.ts +226 -0
- package/src/bot/auth.ts +5 -1
- package/src/bot/bot.ts +105 -4
- package/src/bot/chat-controller.ts +129 -0
- package/src/bot/commands.ts +22 -2
- package/src/bot/group-memory.ts +192 -12
- package/src/bot/handlers/forum.ts +16 -6
- package/src/bot/handlers/grok-slash.ts +336 -0
- package/src/bot/handlers/message.ts +165 -25
- package/src/bot/handlers/photo.ts +4 -1
- package/src/bot/handlers/system.ts +63 -1
- package/src/bot/image-return.ts +4 -1
- package/src/bot/manager-context.ts +208 -0
- package/src/bot/manager-jobs.ts +142 -0
- package/src/bot/menu/ephemeral.ts +4 -1
- package/src/bot/plan-exit-service.ts +169 -0
- package/src/bot/prompt-anchor.ts +2 -3
- package/src/bot/prompt-content.ts +5 -0
- package/src/bot/registry.ts +11 -2
- package/src/bot/scope.ts +9 -8
- package/src/bot/session-runtime.ts +665 -55
- package/src/bot/telegram-actions.ts +728 -38
- package/src/bot/telegram-bots.ts +2 -1
- package/src/bot/telegram-io.ts +4 -1
- package/src/cli.ts +43 -7
- package/src/config.ts +35 -25
- package/src/forum/manager.ts +2 -1
- package/src/forum/thread.ts +33 -0
- package/src/grok/client.ts +29 -5
- package/src/grok/plan-approval.ts +8 -0
- package/src/index.ts +4 -0
- package/src/render/manager-directive.ts +137 -0
- package/src/render/session-comment.ts +10 -0
- package/src/render/telegram-bridge.ts +118 -14
- package/src/service/linux.ts +21 -15
- package/src/service/macos.ts +20 -15
- package/src/service/platform.ts +12 -3
- package/src/service/types.ts +6 -0
- package/src/service/windows.ts +31 -22
- package/src/sessions/history.ts +18 -0
- package/src/stream/streamer.ts +46 -10
|
@@ -8,22 +8,27 @@
|
|
|
8
8
|
* rapid consecutive text messages per chat within a short debounce window
|
|
9
9
|
* (`MESSAGE_BATCH_MS`) into a single prompt — one submission, one confirmation.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* the chat shows immediate life while CLI/ACP starts
|
|
13
|
-
* to that anchor with the same searchable tag.
|
|
11
|
+
* Project topics: user messages are replaced by a bot-owned prompt anchor
|
|
12
|
+
* (`#prompt_<id>`) so the chat shows immediate life while CLI/ACP starts.
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* General (manager): user messages are KEPT; AI replies thread to the user
|
|
15
|
+
* message. Each message is a NEW session (parallel), unless the user replies
|
|
16
|
+
* to a bot message — then that session continues.
|
|
18
17
|
*/
|
|
19
18
|
import type { Bot } from "grammy";
|
|
20
19
|
import { textPrompt } from "../../app/types.js";
|
|
21
20
|
import { createLogger } from "../../logger.js";
|
|
22
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
batchKey,
|
|
23
|
+
forumThreadId,
|
|
24
|
+
isGeneralThread,
|
|
25
|
+
outboundThreadExtra,
|
|
26
|
+
} from "../../forum/thread.js";
|
|
23
27
|
import type { BotDeps } from "../deps.js";
|
|
24
|
-
import { adoptUserPrompt } from "../prompt-anchor.js";
|
|
28
|
+
import { adoptUserPrompt, newPromptId } from "../prompt-anchor.js";
|
|
25
29
|
import { extractReplyContext } from "../reply-context.js";
|
|
26
30
|
import { resolveForumRuntime } from "./forum.js";
|
|
31
|
+
import type { SessionRuntime } from "../session-runtime.js";
|
|
27
32
|
|
|
28
33
|
const log = createLogger("message");
|
|
29
34
|
|
|
@@ -35,6 +40,10 @@ interface TextBatch {
|
|
|
35
40
|
threadId?: number;
|
|
36
41
|
/** Reference content if the burst began as a reply to another message. */
|
|
37
42
|
quoted?: string;
|
|
43
|
+
/** Telegram message_id the user is replying to (for session follow-up). */
|
|
44
|
+
replyToMessageId?: number;
|
|
45
|
+
/** Text of the replied-to message (for #sess_ recovery). */
|
|
46
|
+
replyToText?: string;
|
|
38
47
|
timer: NodeJS.Timeout;
|
|
39
48
|
}
|
|
40
49
|
|
|
@@ -49,7 +58,6 @@ export function registerMessages(bot: Bot, deps: BotDeps): void {
|
|
|
49
58
|
const text = ctx.message.text;
|
|
50
59
|
if (!text.trim()) return;
|
|
51
60
|
// Slash commands are handled by bot.command / menu — never batch as agent prompts.
|
|
52
|
-
// (Otherwise /forum_setup and /help would also hit the debounce → agent path.)
|
|
53
61
|
if (!text.includes("\n") && text.startsWith("/")) return;
|
|
54
62
|
|
|
55
63
|
const chatId = ctx.chat.id;
|
|
@@ -58,7 +66,14 @@ export function registerMessages(bot: Bot, deps: BotDeps): void {
|
|
|
58
66
|
const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
|
|
59
67
|
const threadId = isForum ? forumThreadId(rawThreadId) : rawThreadId;
|
|
60
68
|
const quoted = extractReplyContext(ctx);
|
|
61
|
-
const
|
|
69
|
+
const replyToMessageId = ctx.message.reply_to_message?.message_id;
|
|
70
|
+
const replyToText =
|
|
71
|
+
ctx.message.reply_to_message?.text ?? ctx.message.reply_to_message?.caption;
|
|
72
|
+
// General: do not coalesce independent messages (parallel sessions).
|
|
73
|
+
// Still coalesce multi-part 4096 splits when reply chain is empty and rapid.
|
|
74
|
+
const key = isGeneralThread(threadId) && isForum
|
|
75
|
+
? `${chatId}:${threadId ?? 1}:m${id}`
|
|
76
|
+
: batchKey(chatId, rawThreadId, isForum);
|
|
62
77
|
|
|
63
78
|
const batch = batches.get(key);
|
|
64
79
|
if (batch) {
|
|
@@ -66,6 +81,10 @@ export function registerMessages(bot: Bot, deps: BotDeps): void {
|
|
|
66
81
|
batch.parts.push(text);
|
|
67
82
|
batch.ids.push(id);
|
|
68
83
|
if (quoted && !batch.quoted) batch.quoted = quoted;
|
|
84
|
+
if (replyToMessageId !== undefined && batch.replyToMessageId === undefined) {
|
|
85
|
+
batch.replyToMessageId = replyToMessageId;
|
|
86
|
+
batch.replyToText = replyToText;
|
|
87
|
+
}
|
|
69
88
|
batch.timer = arm(key);
|
|
70
89
|
return;
|
|
71
90
|
}
|
|
@@ -74,6 +93,8 @@ export function registerMessages(bot: Bot, deps: BotDeps): void {
|
|
|
74
93
|
ids: [id],
|
|
75
94
|
threadId,
|
|
76
95
|
quoted,
|
|
96
|
+
replyToMessageId,
|
|
97
|
+
replyToText,
|
|
77
98
|
timer: arm(key),
|
|
78
99
|
});
|
|
79
100
|
});
|
|
@@ -85,8 +106,6 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
|
|
|
85
106
|
if (!batch) return;
|
|
86
107
|
batches.delete(key);
|
|
87
108
|
|
|
88
|
-
// Telegram splits at 4096 chars, almost always on a line boundary, so
|
|
89
|
-
// rejoining with a newline reconstructs the original text faithfully.
|
|
90
109
|
const combined = batch.parts.join("\n").trim();
|
|
91
110
|
if (!combined) return;
|
|
92
111
|
|
|
@@ -94,14 +113,16 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
|
|
|
94
113
|
const chatId = Number(chatIdStr);
|
|
95
114
|
const threadId = batch.threadId;
|
|
96
115
|
|
|
97
|
-
// Defense-in-depth: never submit slash-only lines as agent prompts.
|
|
98
116
|
if (batch.parts.length === 1 && !combined.includes("\n") && combined.startsWith("/")) {
|
|
99
117
|
return;
|
|
100
118
|
}
|
|
101
119
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
120
|
+
const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
|
|
121
|
+
const isGeneral = isForum && isGeneralThread(threadId);
|
|
122
|
+
|
|
123
|
+
let rt: SessionRuntime = deps.registry.get(chatId);
|
|
124
|
+
|
|
125
|
+
if (isForum && deps.forum) {
|
|
105
126
|
const resolved = await resolveForumRuntime(
|
|
106
127
|
deps,
|
|
107
128
|
deps.forum,
|
|
@@ -111,10 +132,98 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
|
|
|
111
132
|
batch.ids[0]!,
|
|
112
133
|
);
|
|
113
134
|
if (resolved === "handled" || resolved === "ignore") return;
|
|
135
|
+
|
|
136
|
+
if (isGeneral) {
|
|
137
|
+
// ── General manager path ─────────────────────────────────────────
|
|
138
|
+
const controller = deps.registry.forumController(
|
|
139
|
+
chatId,
|
|
140
|
+
forumThreadId(threadId),
|
|
141
|
+
resolved.rt.cwd,
|
|
142
|
+
resolved.rt.projectName ?? "General",
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Reply → continue same session (map, #sess_ on controlled runtimes, or disk).
|
|
146
|
+
// Fresh message → new parallel session (does not queue behind other General work).
|
|
147
|
+
const continueRt = await controller.resolveContinueFromReply({
|
|
148
|
+
replyToMessageId: batch.replyToMessageId,
|
|
149
|
+
replyToText: batch.replyToText,
|
|
150
|
+
cwd: resolved.rt.cwd,
|
|
151
|
+
projectName: resolved.rt.projectName ?? "General",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const userMsgId = batch.ids[0]!;
|
|
155
|
+
const replyTo = userMsgId;
|
|
156
|
+
// Keep user message; reply to it. No overwrite / adopt delete.
|
|
157
|
+
|
|
158
|
+
// New session: post "Starting…" FIRST (before ACP session/new) so the user
|
|
159
|
+
// sees life immediately. runTurn later edits it to Thinking… then streams.
|
|
160
|
+
// Follow-up on existing session: skip Starting (runTurn posts Thinking…).
|
|
161
|
+
let seedMessageId: number | undefined;
|
|
162
|
+
if (!continueRt) {
|
|
163
|
+
seedMessageId = await sendStatus(
|
|
164
|
+
deps,
|
|
165
|
+
chatId,
|
|
166
|
+
"Starting\u2026",
|
|
167
|
+
threadId,
|
|
168
|
+
replyTo,
|
|
169
|
+
);
|
|
170
|
+
rt = await controller.addParallel(
|
|
171
|
+
resolved.rt.cwd,
|
|
172
|
+
resolved.rt.projectName ?? "General",
|
|
173
|
+
);
|
|
174
|
+
if (seedMessageId !== undefined && rt.sessionId) {
|
|
175
|
+
controller.bindTelegramMessage(seedMessageId, rt.sessionId);
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
rt = continueRt;
|
|
179
|
+
// FG for status panel; manager setForeground keeps busy siblings streaming.
|
|
180
|
+
if (rt.sessionId) await controller.switchTo(rt.sessionId).catch(() => {});
|
|
181
|
+
}
|
|
182
|
+
if (rt.sessionId) controller.bindTelegramMessage(userMsgId, rt.sessionId);
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const outcome = await rt.submit(
|
|
186
|
+
textPrompt(combined, replyTo, batch.quoted, {
|
|
187
|
+
promptId: newPromptId(),
|
|
188
|
+
seedMessageId,
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
if (rt.sessionId) {
|
|
192
|
+
controller.bindTelegramMessage(userMsgId, rt.sessionId);
|
|
193
|
+
if (seedMessageId !== undefined) {
|
|
194
|
+
controller.bindTelegramMessage(seedMessageId, rt.sessionId);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Never show "queued" spam for new parallel sessions; only if continuing
|
|
198
|
+
// the same session that is already busy.
|
|
199
|
+
if (outcome === "queued" && continueRt) {
|
|
200
|
+
await send(
|
|
201
|
+
deps,
|
|
202
|
+
chatId,
|
|
203
|
+
`\u{1F4E5} Got it — queued as a follow-up on that thread.`,
|
|
204
|
+
threadId,
|
|
205
|
+
replyTo,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
} catch (err) {
|
|
209
|
+
log.warn(`general submit failed chat ${chatId}: ${(err as Error).message}`);
|
|
210
|
+
if (seedMessageId !== undefined) {
|
|
211
|
+
await editStatus(deps, chatId, seedMessageId, `\u274C Couldn't start: ${(err as Error).message}`);
|
|
212
|
+
} else {
|
|
213
|
+
await send(
|
|
214
|
+
deps,
|
|
215
|
+
chatId,
|
|
216
|
+
`\u274C Couldn't start: ${(err as Error).message}`,
|
|
217
|
+
threadId,
|
|
218
|
+
replyTo,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Project / AI Chat topics (existing behavior) ─────────────────
|
|
114
226
|
rt = resolved.rt;
|
|
115
|
-
// If nothing is selected / no session yet, ensure a new session is created
|
|
116
|
-
// on first message (ensureSession inside submit). If FG has no sessionId
|
|
117
|
-
// after a closed session, start a fresh one for this topic.
|
|
118
227
|
if (!rt.sessionId && !rt.isBusy) {
|
|
119
228
|
try {
|
|
120
229
|
await rt.startNewSession(rt.cwd, rt.projectName);
|
|
@@ -125,11 +234,8 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
|
|
|
125
234
|
}
|
|
126
235
|
|
|
127
236
|
const note = batch.parts.length > 1 ? ` (combined ${batch.parts.length} messages)` : "";
|
|
128
|
-
// Hoisted so a submit failure after a successful adopt can still thread the error.
|
|
129
237
|
let replyTo: number | undefined = batch.ids[0];
|
|
130
238
|
try {
|
|
131
|
-
// Instant bot anchor: user sees the prompt adopted immediately while CLI warms.
|
|
132
|
-
// All AI output + Done reply to this message and carry #prompt_<id>.
|
|
133
239
|
const anchor = await adoptUserPrompt(deps.api, {
|
|
134
240
|
chatId,
|
|
135
241
|
text: combined,
|
|
@@ -151,7 +257,6 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
|
|
|
151
257
|
replyTo,
|
|
152
258
|
);
|
|
153
259
|
}
|
|
154
|
-
// "ran": turn started; complexity is steered silently by the agent.
|
|
155
260
|
} catch (err) {
|
|
156
261
|
log.warn(`submit failed for chat ${chatId}: ${(err as Error).message}`);
|
|
157
262
|
await send(
|
|
@@ -172,8 +277,7 @@ async function send(
|
|
|
172
277
|
replyTo?: number,
|
|
173
278
|
): Promise<void> {
|
|
174
279
|
try {
|
|
175
|
-
const extra: Record<string, unknown> = {};
|
|
176
|
-
if (threadId !== undefined) extra.message_thread_id = threadId;
|
|
280
|
+
const extra: Record<string, unknown> = { ...outboundThreadExtra(threadId) };
|
|
177
281
|
if (replyTo !== undefined) {
|
|
178
282
|
extra.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
|
|
179
283
|
}
|
|
@@ -182,3 +286,39 @@ async function send(
|
|
|
182
286
|
/* non-fatal */
|
|
183
287
|
}
|
|
184
288
|
}
|
|
289
|
+
|
|
290
|
+
/** Post status bubble; returns message_id for later edit (Starting… → Thinking…). */
|
|
291
|
+
async function sendStatus(
|
|
292
|
+
deps: BotDeps,
|
|
293
|
+
chatId: number,
|
|
294
|
+
text: string,
|
|
295
|
+
threadId?: number,
|
|
296
|
+
replyTo?: number,
|
|
297
|
+
): Promise<number | undefined> {
|
|
298
|
+
try {
|
|
299
|
+
const extra: Record<string, unknown> = {
|
|
300
|
+
disable_notification: true,
|
|
301
|
+
...outboundThreadExtra(threadId),
|
|
302
|
+
};
|
|
303
|
+
if (replyTo !== undefined) {
|
|
304
|
+
extra.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
|
|
305
|
+
}
|
|
306
|
+
const msg = await deps.api.sendMessage(chatId, text, extra);
|
|
307
|
+
return msg.message_id;
|
|
308
|
+
} catch {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function editStatus(
|
|
314
|
+
deps: BotDeps,
|
|
315
|
+
chatId: number,
|
|
316
|
+
messageId: number,
|
|
317
|
+
text: string,
|
|
318
|
+
): Promise<void> {
|
|
319
|
+
try {
|
|
320
|
+
await deps.api.editMessageText(chatId, messageId, text);
|
|
321
|
+
} catch {
|
|
322
|
+
/* non-fatal */
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -193,8 +193,11 @@ async function submit(
|
|
|
193
193
|
quotedText: quoted,
|
|
194
194
|
});
|
|
195
195
|
if (outcome === "queued") {
|
|
196
|
+
// Omit General (1) — Bot API rejects message_thread_id=1.
|
|
196
197
|
const extra: Record<string, unknown> =
|
|
197
|
-
threadId !== undefined
|
|
198
|
+
threadId !== undefined && threadId !== 1
|
|
199
|
+
? { message_thread_id: threadId }
|
|
200
|
+
: {};
|
|
198
201
|
if (anchor?.replyTo !== undefined) {
|
|
199
202
|
extra.reply_parameters = {
|
|
200
203
|
message_id: anchor.replyTo,
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* System commands: /queue /clearqueue /model /restart.
|
|
2
|
+
* System commands: /queue /clearqueue /model /restart /sandbox.
|
|
3
3
|
*/
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
5
|
import type { Bot } from "grammy";
|
|
6
|
+
import { InlineKeyboard } from "grammy";
|
|
7
|
+
import { ENV_PATH } from "../../config.js";
|
|
5
8
|
import type { BotDeps } from "../deps.js";
|
|
6
9
|
|
|
10
|
+
const SANDBOX_PROFILES = ["workspace-safe", "workspace", "strict", "read-only", "off"] as const;
|
|
11
|
+
|
|
7
12
|
export function registerSystem(bot: Bot, deps: BotDeps): void {
|
|
8
13
|
bot.command("queue", async (ctx) => {
|
|
9
14
|
const rt = deps.registry.get(ctx.chat.id);
|
|
@@ -48,4 +53,61 @@ export function registerSystem(bot: Bot, deps: BotDeps): void {
|
|
|
48
53
|
await ctx.reply(`\u274C Restart failed: ${(err as Error).message}`);
|
|
49
54
|
}
|
|
50
55
|
});
|
|
56
|
+
|
|
57
|
+
bot.command("sandbox", async (ctx) => {
|
|
58
|
+
const arg = (ctx.match || "").toString().trim();
|
|
59
|
+
const current = deps.cfg.sandboxProfile || process.env.GROK_SANDBOX || "(from ~/.grok/config.toml)";
|
|
60
|
+
if (!arg) {
|
|
61
|
+
const kb = new InlineKeyboard();
|
|
62
|
+
for (const p of SANDBOX_PROFILES) kb.text(p, `sbx:${p}`).row();
|
|
63
|
+
await ctx.reply(
|
|
64
|
+
`\u{1F6E1} Sandbox profile now: ${current}\n` +
|
|
65
|
+
`Grok reads GROK_SANDBOX. Pick one, then /restart.\n` +
|
|
66
|
+
`Or: /sandbox workspace-safe`,
|
|
67
|
+
{ reply_markup: kb },
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!(SANDBOX_PROFILES as readonly string[]).includes(arg)) {
|
|
72
|
+
await ctx.reply(`Unknown profile. Use: ${SANDBOX_PROFILES.join(", ")}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
upsertEnv("GROK_SANDBOX", arg);
|
|
77
|
+
deps.cfg.sandboxProfile = arg;
|
|
78
|
+
process.env.GROK_SANDBOX = arg;
|
|
79
|
+
deps.acp.setAgentOptions({ sandboxProfile: arg });
|
|
80
|
+
await ctx.reply(`\u2705 GROK_SANDBOX=${arg} written. Send /restart to apply.`);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
await ctx.reply(`\u274C Could not write .env: ${(e as Error).message}`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
bot.callbackQuery(/^sbx:([\w-]+)$/, async (ctx) => {
|
|
87
|
+
const profile = ctx.match![1]!;
|
|
88
|
+
if (!(SANDBOX_PROFILES as readonly string[]).includes(profile)) {
|
|
89
|
+
await ctx.answerCallbackQuery({ text: "Unknown profile" });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
upsertEnv("GROK_SANDBOX", profile);
|
|
94
|
+
deps.cfg.sandboxProfile = profile;
|
|
95
|
+
process.env.GROK_SANDBOX = profile;
|
|
96
|
+
deps.acp.setAgentOptions({ sandboxProfile: profile });
|
|
97
|
+
await ctx.answerCallbackQuery({ text: profile });
|
|
98
|
+
await ctx.editMessageText(`\u2705 GROK_SANDBOX=${profile}. Send /restart to apply.`).catch(() => {});
|
|
99
|
+
} catch (e) {
|
|
100
|
+
await ctx.answerCallbackQuery({ text: (e as Error).message.slice(0, 40) });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Set KEY=value in the instance .env without dumping secrets. */
|
|
106
|
+
function upsertEnv(key: string, value: string): void {
|
|
107
|
+
if (!existsSync(ENV_PATH)) throw new Error(".env not found");
|
|
108
|
+
let body = readFileSync(ENV_PATH, "utf-8");
|
|
109
|
+
const re = new RegExp(`^${key}=.*$`, "m");
|
|
110
|
+
if (re.test(body)) body = body.replace(re, `${key}=${value}`);
|
|
111
|
+
else body = body.replace(/\s*$/, `\n${key}=${value}\n`);
|
|
112
|
+
writeFileSync(ENV_PATH, body, "utf-8");
|
|
51
113
|
}
|
package/src/bot/image-return.ts
CHANGED
|
@@ -145,7 +145,10 @@ export async function sendImages(
|
|
|
145
145
|
if (opts.replyTo !== undefined) {
|
|
146
146
|
extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
|
|
147
147
|
}
|
|
148
|
-
|
|
148
|
+
// Omit General (1) — Bot API rejects message_thread_id=1.
|
|
149
|
+
if (opts.messageThreadId !== undefined && opts.messageThreadId !== 1) {
|
|
150
|
+
extra.message_thread_id = opts.messageThreadId;
|
|
151
|
+
}
|
|
149
152
|
for (const path of paths) {
|
|
150
153
|
if (sent >= opts.max) break;
|
|
151
154
|
if (opts.already.has(path)) continue;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-injected context for General manager turns: topic catalog, memory hits,
|
|
3
|
+
* recent General chat history, and active dispatch jobs.
|
|
4
|
+
*/
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { ForumManager } from "../forum/manager.js";
|
|
7
|
+
import { readHistory } from "../sessions/history.js";
|
|
8
|
+
import type { SessionStore } from "../sessions/store.js";
|
|
9
|
+
import { searchGroupMemory } from "./group-memory.js";
|
|
10
|
+
import { listActiveManagerJobs, listRecentManagerJobs, type ManagerJob } from "./manager-jobs.js";
|
|
11
|
+
|
|
12
|
+
export const MANAGER_CONTEXT_MARKER = "MANAGER CONTEXT (auto — use before dispatching work):";
|
|
13
|
+
|
|
14
|
+
const CONTEXT_MAX = 6500;
|
|
15
|
+
|
|
16
|
+
export interface ManagerContextOpts {
|
|
17
|
+
userText: string;
|
|
18
|
+
sessionsDir: string;
|
|
19
|
+
store: SessionStore;
|
|
20
|
+
forum?: ForumManager;
|
|
21
|
+
/** Override jobs list (tests). */
|
|
22
|
+
jobs?: ManagerJob[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Build a capped context block for manager prompts. */
|
|
26
|
+
export function buildManagerContextBlock(opts: ManagerContextOpts): string {
|
|
27
|
+
const lines: string[] = [MANAGER_CONTEXT_MARKER, ""];
|
|
28
|
+
|
|
29
|
+
const topics = opts.forum?.isReady ? opts.forum.store.all() : [];
|
|
30
|
+
lines.push("## Topics");
|
|
31
|
+
if (topics.length === 0) {
|
|
32
|
+
lines.push("(no forum topics mapped)");
|
|
33
|
+
} else {
|
|
34
|
+
const topicCap = 40;
|
|
35
|
+
for (const t of topics.slice(0, topicCap)) {
|
|
36
|
+
const path = t.projectPath ?? "(unbound)";
|
|
37
|
+
lines.push(`- **${t.name}** #${t.threadId} [${t.kind}] \`${path}\``);
|
|
38
|
+
}
|
|
39
|
+
if (topics.length > topicCap) {
|
|
40
|
+
lines.push(`… +${topics.length - topicCap} more (use list_topics)`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const workspace =
|
|
45
|
+
topics.find((t) => t.kind === "general")?.projectPath ||
|
|
46
|
+
topics.find((t) => t.kind === "ai_chat")?.projectPath ||
|
|
47
|
+
undefined;
|
|
48
|
+
const preferPaths = [
|
|
49
|
+
workspace,
|
|
50
|
+
...topics
|
|
51
|
+
.filter((t) => t.kind === "project" && t.projectPath)
|
|
52
|
+
.map((t) => t.projectPath!)
|
|
53
|
+
.slice(0, 12),
|
|
54
|
+
].filter(Boolean) as string[];
|
|
55
|
+
|
|
56
|
+
// Always surface recent General chat so the manager "remembers" this room.
|
|
57
|
+
lines.push("", "## Recent General chat (always available)");
|
|
58
|
+
const generalSnips = recentGeneralHistory(opts, workspace, 10);
|
|
59
|
+
if (generalSnips.length === 0) {
|
|
60
|
+
lines.push("(no prior General history on disk yet)");
|
|
61
|
+
} else {
|
|
62
|
+
for (const s of generalSnips) lines.push(`- ${s}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
lines.push(
|
|
66
|
+
"",
|
|
67
|
+
"## Memory hits (ranked: relevance + recency — newest work first, not git)",
|
|
68
|
+
);
|
|
69
|
+
const hits = searchGroupMemory({
|
|
70
|
+
query: opts.userText,
|
|
71
|
+
limit: 14,
|
|
72
|
+
sessionsDir: opts.sessionsDir,
|
|
73
|
+
store: opts.store,
|
|
74
|
+
topics: topics.length ? topics : undefined,
|
|
75
|
+
preferPaths,
|
|
76
|
+
preferGeneral: true,
|
|
77
|
+
maxSessions: 32,
|
|
78
|
+
});
|
|
79
|
+
if (hits.length === 0) {
|
|
80
|
+
lines.push(
|
|
81
|
+
"(no hits — call search_memory; do NOT run git until memory is exhausted)",
|
|
82
|
+
);
|
|
83
|
+
} else {
|
|
84
|
+
for (const h of hits) {
|
|
85
|
+
const where =
|
|
86
|
+
h.threadId !== undefined
|
|
87
|
+
? ` #${h.threadId}`
|
|
88
|
+
: h.sessionId
|
|
89
|
+
? ` session=${h.sessionId.slice(0, 8)}`
|
|
90
|
+
: "";
|
|
91
|
+
lines.push(`- [${h.kind}] ${h.title}${where}: ${h.snippet}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
lines.push(
|
|
96
|
+
"",
|
|
97
|
+
"Rules: for \"last modifications / last work\" trust hits with [Xm/h/d ago] stamps — prefer the newest session for that project path.",
|
|
98
|
+
"Ignore older OmniRoute-style notes if a newer session for the same path exists.",
|
|
99
|
+
"Do not use git log/status as the first step unless the user asked for git.",
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const jobs = opts.jobs ?? [...listActiveManagerJobs(8), ...listRecentManagerJobs(4)];
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
const uniq: ManagerJob[] = [];
|
|
105
|
+
for (const j of jobs) {
|
|
106
|
+
if (seen.has(j.id)) continue;
|
|
107
|
+
seen.add(j.id);
|
|
108
|
+
uniq.push(j);
|
|
109
|
+
if (uniq.length >= 10) break;
|
|
110
|
+
}
|
|
111
|
+
lines.push("", "## Manager jobs (this process)");
|
|
112
|
+
if (uniq.length === 0) {
|
|
113
|
+
lines.push("(none)");
|
|
114
|
+
} else {
|
|
115
|
+
for (const j of uniq) {
|
|
116
|
+
const ageMin = Math.max(0, Math.round((Date.now() - j.createdAt) / 60_000));
|
|
117
|
+
lines.push(
|
|
118
|
+
`- ${j.status} **${j.targetName}** #${j.targetThreadId} (${ageMin}m ago) job=${j.id}` +
|
|
119
|
+
(j.childSessionId ? ` session=${j.childSessionId.slice(0, 8)}` : ""),
|
|
120
|
+
);
|
|
121
|
+
if (j.userAskPreview) lines.push(` ask: ${clamp(j.userAskPreview, 160)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
lines.push(
|
|
126
|
+
"",
|
|
127
|
+
"Use this context to pick the right topic and write a strong send_prompt.",
|
|
128
|
+
"When a hit shows session=XXXXXXXX and the user wants follow-up there, pass that exact",
|
|
129
|
+
"prefix as session_id on send_prompt (do NOT rely on the topic's currently open session).",
|
|
130
|
+
"Prefer search_memory again if you need deeper history before dispatching.",
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
let block = lines.join("\n");
|
|
134
|
+
if (block.length > CONTEXT_MAX) {
|
|
135
|
+
block = block.slice(0, CONTEXT_MAX - 1) + "\u2026";
|
|
136
|
+
}
|
|
137
|
+
return block;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Last user/assistant lines from General-named or workspace sessions. */
|
|
141
|
+
function recentGeneralHistory(
|
|
142
|
+
opts: ManagerContextOpts,
|
|
143
|
+
workspace: string | undefined,
|
|
144
|
+
limit: number,
|
|
145
|
+
): string[] {
|
|
146
|
+
const out: string[] = [];
|
|
147
|
+
let metas;
|
|
148
|
+
try {
|
|
149
|
+
metas = opts.store.list(40);
|
|
150
|
+
} catch {
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
const ws = workspace?.replace(/\\/g, "/").toLowerCase();
|
|
154
|
+
const ranked = metas
|
|
155
|
+
.map((m) => {
|
|
156
|
+
const title = (m.title || "").toLowerCase();
|
|
157
|
+
const cwd = (m.cwd || "").replace(/\\/g, "/").toLowerCase();
|
|
158
|
+
let rank = 0;
|
|
159
|
+
// Prefer sessions explicitly labeled General — never treat every project
|
|
160
|
+
// under the workspace root as "General chat" (path prefix trap).
|
|
161
|
+
if (title === "general") rank += 12;
|
|
162
|
+
else if (/\bgeneral\b/.test(title)) rank += 6;
|
|
163
|
+
// Exact workspace cwd only (General/AI Chat bind), not child project paths.
|
|
164
|
+
if (ws && cwd === ws) rank += 4;
|
|
165
|
+
return { m, rank };
|
|
166
|
+
})
|
|
167
|
+
.filter((x) => x.rank > 0)
|
|
168
|
+
.sort(
|
|
169
|
+
(a, b) =>
|
|
170
|
+
b.rank - a.rank ||
|
|
171
|
+
String(b.m.updatedAt || "").localeCompare(String(a.m.updatedAt || "")),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
for (const { m } of ranked.slice(0, 4)) {
|
|
175
|
+
try {
|
|
176
|
+
const path = join(opts.sessionsDir, `${m.sessionId}.jsonl`);
|
|
177
|
+
const entries = readHistory(path, 8);
|
|
178
|
+
for (const e of entries) {
|
|
179
|
+
if (!e.text?.trim()) continue;
|
|
180
|
+
// Skip huge manager context dumps in history.
|
|
181
|
+
if (e.text.includes(MANAGER_CONTEXT_MARKER)) continue;
|
|
182
|
+
if (e.text.startsWith("MANAGER MODE")) continue;
|
|
183
|
+
const role = e.role === "user" ? "user" : e.role === "assistant" ? "bot" : e.role;
|
|
184
|
+
out.push(
|
|
185
|
+
`${role} · ${m.sessionId.slice(0, 8)}: ${clamp(e.text.replace(/\s+/g, " "), 180)}`,
|
|
186
|
+
);
|
|
187
|
+
if (out.length >= limit) return out;
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
/* skip */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Prepend context block to a user/manager prompt body (idempotent). */
|
|
197
|
+
export function injectManagerContext(text: string, contextBlock: string): string {
|
|
198
|
+
const body = text.trim();
|
|
199
|
+
if (!contextBlock.trim()) return body;
|
|
200
|
+
if (body.includes(MANAGER_CONTEXT_MARKER)) return body;
|
|
201
|
+
return `${contextBlock}\n\n---\n\n${body}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function clamp(s: string, max: number): string {
|
|
205
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
206
|
+
if (t.length <= max) return t;
|
|
207
|
+
return t.slice(0, max - 1) + "\u2026";
|
|
208
|
+
}
|