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.
Files changed (50) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +106 -0
  3. package/README.md +20 -5
  4. package/docs/GROUP.md +39 -4
  5. package/docs/INSTALL.md +2 -0
  6. package/package.json +4 -4
  7. package/scripts/setup.mjs +20 -3
  8. package/src/app/instance.ts +223 -0
  9. package/src/app/types.ts +34 -1
  10. package/src/bot/ask-user-service.ts +226 -0
  11. package/src/bot/auth.ts +5 -1
  12. package/src/bot/bot.ts +105 -4
  13. package/src/bot/chat-controller.ts +129 -0
  14. package/src/bot/commands.ts +22 -2
  15. package/src/bot/group-memory.ts +192 -12
  16. package/src/bot/handlers/forum.ts +16 -6
  17. package/src/bot/handlers/grok-slash.ts +336 -0
  18. package/src/bot/handlers/message.ts +165 -25
  19. package/src/bot/handlers/photo.ts +4 -1
  20. package/src/bot/handlers/system.ts +63 -1
  21. package/src/bot/image-return.ts +4 -1
  22. package/src/bot/manager-context.ts +208 -0
  23. package/src/bot/manager-jobs.ts +142 -0
  24. package/src/bot/menu/ephemeral.ts +4 -1
  25. package/src/bot/plan-exit-service.ts +169 -0
  26. package/src/bot/prompt-anchor.ts +2 -3
  27. package/src/bot/prompt-content.ts +5 -0
  28. package/src/bot/registry.ts +11 -2
  29. package/src/bot/scope.ts +9 -8
  30. package/src/bot/session-runtime.ts +665 -55
  31. package/src/bot/telegram-actions.ts +728 -38
  32. package/src/bot/telegram-bots.ts +2 -1
  33. package/src/bot/telegram-io.ts +4 -1
  34. package/src/cli.ts +43 -7
  35. package/src/config.ts +35 -25
  36. package/src/forum/manager.ts +2 -1
  37. package/src/forum/thread.ts +33 -0
  38. package/src/grok/client.ts +29 -5
  39. package/src/grok/plan-approval.ts +8 -0
  40. package/src/index.ts +4 -0
  41. package/src/render/manager-directive.ts +137 -0
  42. package/src/render/session-comment.ts +10 -0
  43. package/src/render/telegram-bridge.ts +118 -14
  44. package/src/service/linux.ts +21 -15
  45. package/src/service/macos.ts +20 -15
  46. package/src/service/platform.ts +12 -3
  47. package/src/service/types.ts +6 -0
  48. package/src/service/windows.ts +31 -22
  49. package/src/sessions/history.ts +18 -0
  50. 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
- * User messages are replaced by a bot-owned prompt anchor (`#prompt_<id>`) so
12
- * the chat shows immediate life while CLI/ACP starts, and all AI replies thread
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
- * While a turn is running, the combined message is queued and runs
16
- * automatically when the current turn finishes.
17
- * (Wizard input and menu-button text are intercepted by earlier handlers.)
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 { batchKey, forumThreadId } from "../../forum/thread.js";
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 key = batchKey(chatId, rawThreadId, isForum);
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
- let rt = deps.registry.get(chatId);
103
- // Forum group: topic-scoped multi-session controller (model/reasoning/running).
104
- if (deps.forum?.isActiveForumChat(chatId)) {
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 ? { message_thread_id: threadId } : {};
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
  }
@@ -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
- if (opts.messageThreadId !== undefined) extra.message_thread_id = opts.messageThreadId;
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
+ }