grok-telegram-bot 2.3.1 → 2.5.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 (88) hide show
  1. package/.env.example +64 -2
  2. package/CHANGELOG.md +156 -1
  3. package/README.md +58 -15
  4. package/docs/GROUP.md +225 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +1 -1
  7. package/src/app/accounts.ts +84 -0
  8. package/src/app/instance-lock.ts +6 -0
  9. package/src/app/lifetime-flag.ts +20 -0
  10. package/src/app/settings-store.ts +47 -8
  11. package/src/app/types.ts +30 -2
  12. package/src/app/updater.ts +38 -6
  13. package/src/app/usage.ts +204 -7
  14. package/src/bot/account-rotator.ts +10 -0
  15. package/src/bot/auth.ts +96 -15
  16. package/src/bot/bot.ts +154 -11
  17. package/src/bot/chat-controller.ts +82 -13
  18. package/src/bot/commands.ts +69 -27
  19. package/src/bot/complexity-gate.ts +69 -0
  20. package/src/bot/deps.ts +22 -0
  21. package/src/bot/group-memory.ts +159 -0
  22. package/src/bot/handlers/accounts.ts +58 -1
  23. package/src/bot/handlers/control.ts +85 -32
  24. package/src/bot/handlers/document.ts +31 -4
  25. package/src/bot/handlers/forum.ts +207 -0
  26. package/src/bot/handlers/import-session.ts +290 -0
  27. package/src/bot/handlers/menu.ts +102 -61
  28. package/src/bot/handlers/message.ts +102 -21
  29. package/src/bot/handlers/photo.ts +123 -16
  30. package/src/bot/handlers/running.ts +172 -16
  31. package/src/bot/handlers/session-card.ts +20 -0
  32. package/src/bot/handlers/sessions.ts +76 -15
  33. package/src/bot/handlers/usage.ts +118 -16
  34. package/src/bot/handlers/voice.ts +52 -7
  35. package/src/bot/image-return.ts +8 -5
  36. package/src/bot/menu/ephemeral.ts +13 -3
  37. package/src/bot/menu/keyboard.ts +54 -14
  38. package/src/bot/menu/refresh.ts +3 -1
  39. package/src/bot/menu/status-panel.ts +25 -6
  40. package/src/bot/permission-service.ts +19 -0
  41. package/src/bot/prompt-anchor.ts +300 -0
  42. package/src/bot/prompt-content.ts +7 -0
  43. package/src/bot/registry.ts +94 -1
  44. package/src/bot/scope.ts +94 -0
  45. package/src/bot/session-fork.ts +11 -0
  46. package/src/bot/session-runtime.ts +1254 -83
  47. package/src/bot/suggestions.ts +489 -0
  48. package/src/bot/telegram-actions.ts +440 -0
  49. package/src/bot/telegram-bots.ts +495 -0
  50. package/src/bot/telegram-io.ts +94 -10
  51. package/src/cli.ts +2 -0
  52. package/src/config.ts +242 -2
  53. package/src/forum/bind-path.ts +146 -0
  54. package/src/forum/manager.ts +651 -0
  55. package/src/forum/project-icon.ts +142 -0
  56. package/src/forum/thread.ts +16 -0
  57. package/src/forum/topic-store.ts +114 -0
  58. package/src/forum/types.ts +29 -0
  59. package/src/grok/client.ts +214 -37
  60. package/src/grok/plan-approval.ts +72 -0
  61. package/src/grok/session-log.ts +16 -0
  62. package/src/grok/types.ts +21 -2
  63. package/src/import/build-import.ts +132 -0
  64. package/src/import/history-readers.ts +681 -0
  65. package/src/import/list-running.ts +100 -0
  66. package/src/import/sources.ts +78 -0
  67. package/src/index.ts +315 -30
  68. package/src/projects/manager.ts +16 -3
  69. package/src/render/chunk.ts +17 -10
  70. package/src/render/diff.ts +11 -2
  71. package/src/render/file-summary.ts +31 -1
  72. package/src/render/hashtags.ts +5 -1
  73. package/src/render/markdown.ts +293 -35
  74. package/src/render/plan.ts +127 -0
  75. package/src/render/session-comment.ts +318 -0
  76. package/src/render/telegram-bridge.ts +360 -0
  77. package/src/render/tool-call-detail.ts +400 -19
  78. package/src/render/tool-call-merge.ts +115 -0
  79. package/src/render/tool-call.ts +444 -162
  80. package/src/render/truncate.ts +85 -0
  81. package/src/service/platform.ts +44 -7
  82. package/src/service/windows.ts +30 -6
  83. package/src/sessions/history.ts +98 -0
  84. package/src/sessions/process.ts +7 -0
  85. package/src/sessions/store.ts +3 -0
  86. package/src/sessions/types.ts +5 -0
  87. package/src/stream/streamer.ts +90 -15
  88. package/src/tasks/runner.ts +4 -3
@@ -0,0 +1,489 @@
1
+ /**
2
+ * Post-turn follow-up suggestions + gated self-recheck helpers.
3
+ *
4
+ * Self-recheck (once per real user turn, when enabled):
5
+ * 1. Hard-skip if no files were modified.
6
+ * 2. Quiet meta ask: AI refuses (simple / pure build / nothing to re-verify)
7
+ * or writes a complete recheck brief (bugs + production gaps + finish-all +
8
+ * per-bug checklist).
9
+ * 3. That prompt is queued as the one-shot SELF-RECHECK turn (compose always
10
+ * injects finish-all / per-bug rules when the body omitted them).
11
+ * 4. Then Done + suggestions.
12
+ *
13
+ * After Done, the bot quietly asks for 1–3 short next steps as JSON with a
14
+ * "need" score (0–100). Suggestions appear as inline buttons; those at/above
15
+ * SUGGESTIONS_AUTO_APPROVE_PCT are merged into **one** auto-queued prompt
16
+ * (`1) …\n2) …`) so they run as a single turn.
17
+ */
18
+ import { InlineKeyboard } from "grammy";
19
+
20
+ /** Max suggestions shown / accepted. */
21
+ export const SUGGESTION_MAX = 3;
22
+ /** Button label budget (Telegram ~64 chars). */
23
+ const BTN_MAX = 56;
24
+
25
+ /** Marker for the automatic post-turn self-recheck prompt (once per user turn). */
26
+ export const SELF_RECHECK_MARKER = "SELF-RECHECK (automatic quality pass";
27
+
28
+ /**
29
+ * Default self-recheck body (user can override via SELF_RECHECK_PROMPT).
30
+ * Placeholders: {{USER}} = user's request, {{DONE}} = first-turn summary.
31
+ */
32
+ export const DEFAULT_SELF_RECHECK_PROMPT = [
33
+ `${SELF_RECHECK_MARKER} — once only).`,
34
+ "Do a rigorous self-review of the work just completed for the user request below.",
35
+ "You are a bugs/logic finder AND a production-completeness pass for the SAME feature.",
36
+ "",
37
+ "A) Bugs & regressions",
38
+ "1) Incomplete steps, wrong assumptions, missing verification, edge cases, broken paths.",
39
+ "2) Logic mismatches (UI vs backend, state vs render, happy path vs error path).",
40
+ "",
41
+ "B) Production-related functionality (same domain only — not a new product idea)",
42
+ "Continue development toward an ideal production version of WHAT WAS BUILT:",
43
+ "- Game with water → waves/physics/collision where missing; entity that can die → death state; etc.",
44
+ "- Auth form → rate limits, lockout, CSRF, secure tokens, validation, error handling.",
45
+ "- API write → input validation, authz, idempotency/errors, logging where expected.",
46
+ "- UI control → empty/loading/error states, accessibility basics tied to the control.",
47
+ "Only add what a solid implementation of THIS feature should already include.",
48
+ "",
49
+ "C) Finish-all — no honest leftovers",
50
+ "- Do NOT leave half-done work with 'still need…', 'remaining…', 'TODO for user',",
51
+ " incomplete honest footers, or progress stuck low because work is unfinished.",
52
+ "- Finish critical gaps now so Done does not rely on follow-up suggestion buttons",
53
+ " for unfinished must-have work from this request.",
54
+ "- Do NOT ask the user questions. Do NOT call enter_plan_mode unless truly necessary.",
55
+ "",
56
+ "D) Per-bug recheck checklist (REQUIRED at the end of this pass)",
57
+ "1) List every plausible bug/edge case for THESE changes (race, null, authz, wrong path,",
58
+ " regression, missing test, off-by-one, bad default, security hole, etc.).",
59
+ "2) For each item: verify with tools or fix it. Briefly note only real findings.",
60
+ "3) Prefer {progress: 100%} only when this checklist is done and nothing critical remains.",
61
+ "",
62
+ "Rules:",
63
+ "- Prefer fixing real problems with tools; do not invent unrelated features.",
64
+ "- If everything checks out, briefly confirm what you verified (no long essay).",
65
+ "- End with an honest {progress: N%} marker for this recheck pass.",
66
+ "",
67
+ "USER'S REQUEST:",
68
+ "{{USER}}",
69
+ "",
70
+ "WHAT WAS JUST DONE (summary):",
71
+ "{{DONE}}",
72
+ ].join("\n");
73
+
74
+ /** Shared rules appended when wrapping a short AI-written recheck body. */
75
+ export const SELF_RECHECK_COMPOSE_RULES = [
76
+ "Rules:",
77
+ "- Fix real bugs and logic mismatches with tools when needed; no unrelated features.",
78
+ "- Production-related completeness for the SAME feature (e.g. water→waves, auth→rate limits,",
79
+ " API writes→validation/errors). Close gaps a solid production build of this feature needs.",
80
+ "- Finish-all: do NOT leave 'still need…', 'remaining…', incomplete honest footers, or",
81
+ " half-done work that would force critical follow-up buttons. Finish those gaps now.",
82
+ "- End with a per-bug recheck: list plausible bugs for these changes and verify/fix each.",
83
+ "- Do NOT ask the user questions. Do NOT call enter_plan_mode unless truly necessary.",
84
+ "- End with an honest {progress: N%} marker for this recheck pass (100 only when checklist done).",
85
+ ].join("\n");
86
+
87
+ export interface Suggestion {
88
+ /** Plain follow-up the user would type / the bot will submit. */
89
+ text: string;
90
+ /** How needed/critical relative to the last user prompt (0–100). */
91
+ need: number;
92
+ }
93
+
94
+ /**
95
+ * Quiet meta-prompt after a successful turn. Must produce JSON only.
96
+ * Percentage rules enforce honesty: unrelated ideas cannot score above 60.
97
+ */
98
+ export function buildSuggestionsPrompt(userText: string, assistantSnippet: string): string {
99
+ const user = clamp(userText.replace(/\s+/g, " ").trim(), 500) || "(empty)";
100
+ const did = clamp(assistantSnippet.replace(/\s+/g, " ").trim(), 600) || "(no assistant text)";
101
+ return [
102
+ "FOLLOW-UP SUGGESTIONS (meta only). Do NOT use tools. Do NOT write code. Do NOT continue the task.",
103
+ "Based ONLY on the user's last request and what was just done, propose 1 to 3 short next steps.",
104
+ "",
105
+ "USER'S LAST PROMPT:",
106
+ user,
107
+ "",
108
+ "WHAT WAS JUST DONE (summary):",
109
+ did,
110
+ "",
111
+ "For each suggestion set need = integer 0–100 = how needed/critical it is for completing or properly finishing THAT user prompt:",
112
+ "- High need (70–100): tightly related, critical next step for the same request (fix a gap, verify, finish unfinished part).",
113
+ "- Medium (40–69): related polish or natural continuation of the same task.",
114
+ "- Low (1–39): optional or weakly related.",
115
+ "- HARD RULE: if a suggestion is NOT clearly related to the user's last prompt, need MUST be ≤ 60 (never higher).",
116
+ "- Do not invent unrelated new features just to fill slots; prefer fewer high-quality items.",
117
+ "- Prefer the highest need for the single most critical related follow-up.",
118
+ "",
119
+ "Reply with ONLY a JSON array (no markdown fences, no keys other than text/need, no commentary):",
120
+ '[{"text":"short imperative follow-up the user would send","need":85}]',
121
+ "Constraints: 1–3 items; text ≤ 120 chars; plain language; no quotes wrapping the whole array.",
122
+ ].join("\n");
123
+ }
124
+
125
+ /** Parse model output into 0–3 validated suggestions. */
126
+ export function parseSuggestions(raw: string): Suggestion[] {
127
+ if (!raw?.trim()) return [];
128
+ let t = raw.trim();
129
+ // Strip accidental code fences / progress markers.
130
+ t = t.replace(/\{[\s]*progress[\s]*:[\s]*\d{1,3}\s*%?[\s]*\}/gi, "").trim();
131
+ const fence = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
132
+ if (fence) t = fence[1]!.trim();
133
+ // Extract first JSON array if prose sneaks in.
134
+ const arrMatch = /\[[\s\S]*\]/.exec(t);
135
+ if (arrMatch) t = arrMatch[0]!;
136
+
137
+ let parsed: unknown;
138
+ try {
139
+ parsed = JSON.parse(t);
140
+ } catch {
141
+ // Try line-wise objects.
142
+ const objs = [...t.matchAll(/\{[^{}]*"text"[^{}]*\}/g)].map((m) => {
143
+ try {
144
+ return JSON.parse(m[0]!) as unknown;
145
+ } catch {
146
+ return undefined;
147
+ }
148
+ });
149
+ parsed = objs.filter(Boolean);
150
+ }
151
+
152
+ const list = Array.isArray(parsed) ? parsed : [];
153
+ const out: Suggestion[] = [];
154
+ for (const item of list) {
155
+ if (!item || typeof item !== "object") continue;
156
+ const rec = item as Record<string, unknown>;
157
+ const text = String(rec.text ?? rec.suggestion ?? rec.prompt ?? "").replace(/\s+/g, " ").trim();
158
+ if (!text || text.length < 2) continue;
159
+ let need = Number(rec.need ?? rec.pct ?? rec.percent ?? rec.score ?? 0);
160
+ if (!Number.isFinite(need)) need = 0;
161
+ need = Math.max(0, Math.min(100, Math.round(need)));
162
+ out.push({ text: text.slice(0, 120), need });
163
+ if (out.length >= SUGGESTION_MAX) break;
164
+ }
165
+ // Highest need first for auto-approve + button order.
166
+ out.sort((a, b) => b.need - a.need);
167
+ return out;
168
+ }
169
+
170
+ /** Inline keyboard for Done: one row per suggestion + optional extra rows. */
171
+ export function suggestionsKeyboard(
172
+ batchId: number,
173
+ suggestions: Suggestion[],
174
+ extra?: InlineKeyboard,
175
+ ): InlineKeyboard {
176
+ const kb = new InlineKeyboard();
177
+ suggestions.forEach((s, i) => {
178
+ const label = clamp(`${s.need}% · ${s.text}`, BTN_MAX);
179
+ kb.text(label, `sug:${batchId}:${i}`).row();
180
+ });
181
+ if (extra) {
182
+ // Append extra keyboard rows (e.g. Switch session).
183
+ const rows = (extra as unknown as { inline_keyboard?: Array<Array<{ text: string; callback_data: string }>> })
184
+ .inline_keyboard;
185
+ if (rows) {
186
+ for (const row of rows) {
187
+ for (let i = 0; i < row.length; i++) {
188
+ const b = row[i]!;
189
+ if (i === row.length - 1) kb.text(b.text, b.callback_data).row();
190
+ else kb.text(b.text, b.callback_data);
191
+ }
192
+ }
193
+ }
194
+ }
195
+ return kb;
196
+ }
197
+
198
+ /** Suggestions at/above the auto-approve threshold (need >= pct). */
199
+ export function autoApproveSuggestions(suggestions: Suggestion[], thresholdPct: number): Suggestion[] {
200
+ if (thresholdPct <= 0) return [];
201
+ const thr = Math.max(0, Math.min(100, Math.round(thresholdPct)));
202
+ return suggestions.filter((s) => s.need >= thr);
203
+ }
204
+
205
+ /**
206
+ * Merge auto-approved suggestions into a single multi-step prompt so they run
207
+ * as one agent turn: `1) …\n2) …\n3) …` (already sorted highest need first).
208
+ */
209
+ export function formatBatchedSuggestionsPrompt(suggestions: Suggestion[]): string {
210
+ if (suggestions.length === 0) return "";
211
+ if (suggestions.length === 1) return suggestions[0]!.text;
212
+ return suggestions.map((s, i) => `${i + 1}) ${s.text}`).join("\n");
213
+ }
214
+
215
+ /** Detect the quiet suggestions meta-prompt (history strip / title guard). */
216
+ export function isSuggestionsMetaPrompt(text: string): boolean {
217
+ return /^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(text.trim());
218
+ }
219
+
220
+ /** Marker for the quiet "should we recheck?" meta-prompt. */
221
+ export const SELF_RECHECK_DECISION_MARKER = "SELF-RECHECK DECISION (meta only)";
222
+
223
+ /** Detect the quiet self-recheck decision meta-prompt. */
224
+ export function isSelfRecheckDecisionPrompt(text: string): boolean {
225
+ return text.trim().startsWith(SELF_RECHECK_DECISION_MARKER);
226
+ }
227
+
228
+ /** Detect the automatic self-recheck pass (not a normal user message). */
229
+ export function isSelfRecheckPrompt(text: string): boolean {
230
+ return text.trim().startsWith(SELF_RECHECK_MARKER);
231
+ }
232
+
233
+ /** Outcome of the quiet recheck-decision turn. */
234
+ export type SelfRecheckDecision =
235
+ | { needed: false; reason?: string }
236
+ | { needed: true; prompt: string };
237
+
238
+ /**
239
+ * Quiet meta-prompt: AI either refuses recheck (simple / no value) or writes
240
+ * the focused recheck instructions that will be submitted as the next turn.
241
+ */
242
+ export function buildSelfRecheckDecisionPrompt(
243
+ userText: string,
244
+ assistantSnippet: string,
245
+ filesSummary: string,
246
+ ): string {
247
+ const user = clamp(userText.replace(/\s+/g, " ").trim(), 700) || "(empty)";
248
+ const did = clamp(assistantSnippet.replace(/\s+/g, " ").trim(), 900) || "(no assistant text)";
249
+ const files = clamp(filesSummary.replace(/\s+/g, " ").trim(), 400) || "(none)";
250
+ return [
251
+ `${SELF_RECHECK_DECISION_MARKER}. Do NOT use tools. Do NOT write code. Do NOT continue the task.`,
252
+ "Decide whether a second automatic quality pass is worth running for the work just completed.",
253
+ "",
254
+ "Set needed=false (skip recheck) when ANY of these apply:",
255
+ "- Simple task: Q&A, explanation, status, one-liner, or trivial change.",
256
+ "- Pure build / install / run / package with no non-trivial logic to re-audit.",
257
+ "- Work is clearly complete and low-risk; a re-verify would add little value.",
258
+ "- No plausible bugs, incomplete steps, or tightly related security/ops gaps.",
259
+ "",
260
+ "Set needed=true when:",
261
+ "- Non-trivial code/config changed and edge cases, regressions, or incomplete",
262
+ " follow-through are plausible (auth, multi-file logic, concurrency, data paths).",
263
+ "- A focused second pass with tools could catch real bugs or finish related gaps.",
264
+ "- The first pass left honest leftovers ('still need…', unfinished production logic,",
265
+ " incomplete feature pieces) that a recheck should finish instead of the user.",
266
+ "",
267
+ "If needed=true, write prompt = a COMPLETE recheck brief for a one-shot agent pass.",
268
+ "The prompt MUST include ALL of the following sections (imperative, concrete, domain-specific):",
269
+ "1) Bugs/logic: what to re-read, verify, and fix for THIS change set.",
270
+ "2) Production-related functionality: continue to ideal production for the SAME feature",
271
+ " (examples: game water→waves; auth→rate limits/lockout/CSRF; API→validation/errors;",
272
+ " UI→empty/loading/error). Not unrelated product ideas.",
273
+ "3) Finish-all: explicitly order the agent to finish incomplete work and NOT leave",
274
+ " 'still need…', 'remaining…', incomplete honest footers, or half-done bottoms.",
275
+ "4) Per-bug recheck (at the BOTTOM of the prompt): list every plausible bug/edge case",
276
+ " for these changes and instruct the agent to verify or fix EACH one with tools.",
277
+ "Do not invent unrelated features. Keep the prompt actionable (not vague).",
278
+ "If needed=false, give a short reason.",
279
+ "",
280
+ "USER'S REQUEST:",
281
+ user,
282
+ "",
283
+ "WHAT WAS JUST DONE (summary):",
284
+ did,
285
+ "",
286
+ "FILES MODIFIED THIS TURN:",
287
+ files,
288
+ "",
289
+ "Reply with ONLY one JSON object (no markdown fences, no commentary):",
290
+ '{"needed":false,"reason":"short reason"}',
291
+ "or",
292
+ '{"needed":true,"prompt":"complete recheck brief with bugs + production gaps + finish-all + per-bug checklist"}',
293
+ ].join("\n");
294
+ }
295
+
296
+ /**
297
+ * Parse quiet recheck-decision JSON. Defaults to skip on empty/invalid output
298
+ * so a bad meta reply never blocks Done — except when the model clearly wrote a
299
+ * recheck body without wrapping JSON (treated as needed=true).
300
+ */
301
+ export function parseSelfRecheckDecision(raw: string): SelfRecheckDecision {
302
+ if (!raw?.trim()) return { needed: false, reason: "empty decision" };
303
+ let t = raw.trim();
304
+ // Strip trailing progress markers only (avoid eating prompt text mid-JSON).
305
+ t = t.replace(/\n?\s*\{[\s]*progress[\s]*:[\s]*\d{1,3}\s*%?[\s]*\}\s*$/gi, "").trim();
306
+ t = t.replace(/\{[\s]*progress[\s]*:[\s]*\d{1,3}\s*%?[\s]*\}/gi, "").trim();
307
+ const fence = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
308
+ if (fence) t = fence[1]!.trim();
309
+
310
+ // Soft refuse phrases (JSON or prose).
311
+ if (/\b(not needed|no recheck|skip recheck|unnecessary|not necessary|no need to recheck)\b/i.test(t)
312
+ && !/"needed"\s*:\s*true/i.test(t)) {
313
+ // Only force-skip when JSON does not explicitly set needed:true.
314
+ if (!/"needed"\s*:\s*true/i.test(raw) && !/"recheck"\s*:\s*true/i.test(raw)) {
315
+ const asJson = tryParseDecisionObject(t);
316
+ if (!asJson || asJson.needed !== true) {
317
+ return { needed: false, reason: "refused in prose" };
318
+ }
319
+ }
320
+ }
321
+
322
+ const asJson = tryParseDecisionObject(t);
323
+ if (asJson) return asJson;
324
+
325
+ // Plain imperative body (model forgot JSON) → treat as recheck prompt.
326
+ const prose = t.replace(/\s+/g, " ").trim();
327
+ if (prose.length >= 24 && !/^(ok|done|none|n\/a|skip)\b/i.test(prose)) {
328
+ return { needed: true, prompt: prose.slice(0, 4000) };
329
+ }
330
+ return { needed: false, reason: "unparseable decision" };
331
+ }
332
+
333
+ /** Best-effort extract/parse of a decision object from model text. */
334
+ function tryParseDecisionObject(t: string): SelfRecheckDecision | undefined {
335
+ // Prefer balanced-ish first object; fall back to greedy match.
336
+ let candidate = t;
337
+ const objMatch = /\{[\s\S]*\}/.exec(t);
338
+ if (objMatch) candidate = objMatch[0]!;
339
+
340
+ let parsed: unknown;
341
+ try {
342
+ parsed = JSON.parse(candidate);
343
+ } catch {
344
+ // Try smaller object if trailing junk broke parse.
345
+ const m = /\{[^{}]*"needed"[^{}]*\}/i.exec(t)
346
+ || /\{[^{}]*"recheck"[^{}]*\}/i.exec(t)
347
+ || /\{[^{}]*"prompt"[^{}]*\}/i.exec(t);
348
+ if (!m) return undefined;
349
+ try {
350
+ parsed = JSON.parse(m[0]!);
351
+ } catch {
352
+ return undefined;
353
+ }
354
+ }
355
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
356
+ const rec = parsed as Record<string, unknown>;
357
+
358
+ // Do not treat suggestion-style "need" (0–100 score) as the needed flag.
359
+ // Only needed / recheck / required (boolean-ish) control the gate.
360
+ const neededRaw = rec.needed ?? rec.recheck ?? rec.required;
361
+ const prompt = String(rec.prompt ?? rec.text ?? rec.instructions ?? rec.recheck_prompt ?? "")
362
+ .replace(/\r\n/g, "\n")
363
+ .trim();
364
+
365
+ let needed: boolean | undefined;
366
+ if (typeof neededRaw === "boolean") needed = neededRaw;
367
+ else if (typeof neededRaw === "string") {
368
+ const s = neededRaw.trim().toLowerCase();
369
+ if (/^(1|true|yes|y|needed|recheck)$/i.test(s)) needed = true;
370
+ else if (/^(0|false|no|n|skip|none)$/i.test(s)) needed = false;
371
+ } else if (typeof neededRaw === "number") {
372
+ needed = neededRaw > 0;
373
+ }
374
+
375
+ // Explicit skip / refuse keys always win.
376
+ if (rec.skip === true || rec.refuse === true) needed = false;
377
+
378
+ // Prompt-only object: model wrote instructions without a needed flag → run.
379
+ if (needed === undefined && prompt.length >= 3) needed = true;
380
+ if (needed === undefined) needed = false;
381
+
382
+ if (!needed) {
383
+ const reason = String(rec.reason ?? rec.why ?? rec.message ?? "").replace(/\s+/g, " ").trim();
384
+ return { needed: false, reason: reason || undefined };
385
+ }
386
+
387
+ // needed=true but no usable prompt → empty body; caller fills default template.
388
+ if (!prompt || prompt.length < 3) {
389
+ return { needed: true, prompt: "" };
390
+ }
391
+ return { needed: true, prompt: prompt.slice(0, 4000) };
392
+ }
393
+
394
+ /**
395
+ * Build the one-shot self-recheck turn text from an optional env template.
396
+ * Placeholders: {{USER}}, {{DONE}} (also {USER}/{DONE} for convenience).
397
+ */
398
+ export function buildSelfRecheckPrompt(
399
+ userText: string,
400
+ assistantSnippet: string,
401
+ template?: string,
402
+ ): string {
403
+ const user = clamp(userText.replace(/\s+/g, " ").trim(), 700) || "(empty)";
404
+ const did = clamp(assistantSnippet.replace(/\s+/g, " ").trim(), 900) || "(no assistant text)";
405
+ const tpl = (template?.trim() || DEFAULT_SELF_RECHECK_PROMPT).trim();
406
+ let out = tpl
407
+ .replace(/\{\{\s*USER\s*\}\}/gi, user)
408
+ .replace(/\{\{\s*DONE\s*\}\}/gi, did)
409
+ .replace(/\{USER\}/gi, user)
410
+ .replace(/\{DONE\}/gi, did);
411
+ // Ensure the marker is present so isSelfRecheckPrompt / one-shot guard work
412
+ // even if the user customized SELF_RECHECK_PROMPT and dropped it.
413
+ return ensureSelfRecheckMarker(out);
414
+ }
415
+
416
+ /**
417
+ * True when the recheck body already encodes finish-all + per-bug expectations
418
+ * (default template or a strong AI brief). Used to avoid double-appending rules.
419
+ */
420
+ export function hasSelfRecheckFinishRules(text: string): boolean {
421
+ const t = text.toLowerCase();
422
+ const finish =
423
+ t.includes("still need") || t.includes("finish-all") || t.includes("finish all");
424
+ const bugs = t.includes("per-bug") || t.includes("plausible bug");
425
+ return finish && bugs;
426
+ }
427
+
428
+ /** Append SHARED compose rules when the body omitted finish-all / per-bug language. */
429
+ export function ensureSelfRecheckComposeRules(text: string): string {
430
+ const body = text.trim();
431
+ if (!body) return SELF_RECHECK_COMPOSE_RULES;
432
+ if (hasSelfRecheckFinishRules(body)) return body;
433
+ return `${body}\n\n${SELF_RECHECK_COMPOSE_RULES}`;
434
+ }
435
+
436
+ /**
437
+ * Turn an AI-written recheck body into a full one-shot turn (marker + context).
438
+ */
439
+ export function composeSelfRecheckTurn(
440
+ agentPrompt: string,
441
+ userText: string,
442
+ assistantSnippet: string,
443
+ ): string {
444
+ const user = clamp(userText.replace(/\s+/g, " ").trim(), 700) || "(empty)";
445
+ const did = clamp(assistantSnippet.replace(/\s+/g, " ").trim(), 900) || "(no assistant text)";
446
+ const body =
447
+ agentPrompt.trim() ||
448
+ DEFAULT_SELF_RECHECK_PROMPT
449
+ .replace(/\{\{\s*USER\s*\}\}/gi, user)
450
+ .replace(/\{\{\s*DONE\s*\}\}/gi, did)
451
+ .replace(/\{USER\}/gi, user)
452
+ .replace(/\{DONE\}/gi, did);
453
+ // Full template already has context — don't double-append USER/DONE sections.
454
+ // Only skip wrapping when the body looks like a complete recheck brief (marker
455
+ // or both a request header and a done header), not a casual mention of the words.
456
+ const looksComplete =
457
+ isSelfRecheckPrompt(body) ||
458
+ (/USER'S REQUEST:/i.test(body) && /WHAT WAS JUST DONE/i.test(body));
459
+ if (looksComplete) {
460
+ // Still inject finish-all / per-bug rules if a "complete-looking" AI brief
461
+ // only copied headers and omitted production/finish-all/per-bug sections.
462
+ return ensureSelfRecheckMarker(ensureSelfRecheckComposeRules(body));
463
+ }
464
+ return ensureSelfRecheckMarker(
465
+ ensureSelfRecheckComposeRules(
466
+ [
467
+ body,
468
+ "",
469
+ "USER'S REQUEST:",
470
+ user,
471
+ "",
472
+ "WHAT WAS JUST DONE (summary):",
473
+ did,
474
+ ].join("\n"),
475
+ ),
476
+ );
477
+ }
478
+
479
+ /** Prepend the self-recheck marker when missing. */
480
+ export function ensureSelfRecheckMarker(text: string): string {
481
+ const t = text.trim();
482
+ if (t.startsWith(SELF_RECHECK_MARKER)) return t;
483
+ return `${SELF_RECHECK_MARKER} — once only).\n\n${t}`;
484
+ }
485
+
486
+ function clamp(s: string, max: number): string {
487
+ if (s.length <= max) return s;
488
+ return s.slice(0, max - 1) + "\u2026";
489
+ }