grok-telegram-bot 2.2.4 → 2.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.
@@ -7,9 +7,10 @@
7
7
  import { basename } from "node:path";
8
8
  import { type Api, InlineKeyboard } from "grammy";
9
9
  import {
10
- type GrokClient,
11
- isAccountRotationError,
10
+ type GrokClient,
11
+ isAccountRotationError,
12
12
  isContextExhaustedError,
13
+ isSessionLifecycleError,
13
14
  isTransientError,
14
15
  type SessionMetadata,
15
16
  } from "../grok/client.js";
@@ -31,7 +32,8 @@ import { type FileOp, fileOpFromUpdate, mergeFileOp, summarizeFileOps, summarize
31
32
  import { isActiveStatus, renderSubagentTransition, statusKey } from "../render/subagent.js";
32
33
  import type { PendingStage, SubagentInfo } from "../grok/types.js";
33
34
  import { ResponseStreamer } from "../stream/streamer.js";
34
- import { extractImagePaths, sendImages } from "./image-return.js";
35
+ import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
36
+ import { collectTurnImagePaths, sendImages } from "./image-return.js";
35
37
  import { buildContentBlocks, mergeInputs } from "./prompt-content.js";
36
38
  import {
37
39
  backoffSchedule,
@@ -99,10 +101,10 @@ export class SessionRuntime {
99
101
  private turnCount = 0;
100
102
  /** Telegram message id of the current turn's prompt, so replies thread to it. */
101
103
  private turnReplyTo: number | undefined;
102
- private imageScanText = "";
103
- private sentImagesThisTurn = new Set<string>();
104
- /** Monotonic count used to reject ACP "success" responses with no turn updates. */
105
- private sessionUpdateCount = 0;
104
+ private imageScanText = "";
105
+ private sentImagesThisTurn = new Set<string>();
106
+ /** Monotonic count used to reject ACP "success" responses with no turn updates. */
107
+ private sessionUpdateCount = 0;
106
108
  private readonly listener: (sessionId: string, update: SessionUpdate) => void;
107
109
  private primingContext: string | undefined;
108
110
  private watcher: TailWatcher | undefined;
@@ -216,8 +218,11 @@ export class SessionRuntime {
216
218
  this.typing.stop();
217
219
  this.stopWatch();
218
220
  if (this.streamer) {
219
- await this.streamer.finalize().catch(() => {});
221
+ // Finalize off the critical path so project/session switches never wait
222
+ // on Telegram edits of the previous live stream.
223
+ const prev = this.streamer;
220
224
  this.streamer = undefined;
225
+ void prev.finalize().catch(() => {});
221
226
  }
222
227
  }
223
228
  this.changed();
@@ -252,6 +257,7 @@ export class SessionRuntime {
252
257
  // в”Ђв”Ђ sessions в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
253
258
 
254
259
  async startNewSession(cwd: string, projectName?: string): Promise<void> {
260
+ await this.accountRotator?.waitForIdle();
255
261
  if (this.busy) await this.cancel();
256
262
  await this.bindNewSession(cwd, projectName);
257
263
  }
@@ -426,6 +432,9 @@ export class SessionRuntime {
426
432
  }
427
433
 
428
434
  private async ensureSession(): Promise<void> {
435
+ // Account rotation restarts the process globally. Do not bind a new chat
436
+ // to a candidate account until the owner has finished probing it.
437
+ await this.accountRotator?.waitForIdle();
429
438
  if (this.rebindPending && this.sessionId) {
430
439
  // The ACP process is frequently mid-restart the first time we re-bind
431
440
  // (auto-restart after a crash, or a fresh bot boot), so a single attempt
@@ -514,14 +523,17 @@ export class SessionRuntime {
514
523
  const content = buildContentBlocks(input, {
515
524
  reasoning: reasoningDirective(this.reasoning),
516
525
  priming: this.primingContext,
526
+ imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
517
527
  progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
518
528
  });
519
529
  this.primingContext = undefined;
520
530
 
521
531
  try {
522
532
  const outcome = await this.runPromptWithRetries(content);
523
- const recovered = await this.maybeAutoFork(input, outcome);
524
- let final = recovered ?? outcome;
533
+ const rebound = await this.maybeRecoverAgentSession(input, outcome);
534
+ let final = rebound ?? outcome;
535
+ const recovered = await this.maybeAutoFork(input, final);
536
+ final = recovered ?? final;
525
537
  // Last resort: if the turn still failed, rotate through other saved
526
538
  // accounts (once) and retry on each until one works.
527
539
  const rotated = await this.maybeRotateAccount(input, final);
@@ -624,6 +636,51 @@ export class SessionRuntime {
624
636
  return pct !== undefined && pct >= threshold;
625
637
  }
626
638
 
639
+ /** A shared-process restart invalidates this runtime's ACP session binding,
640
+ * but says nothing about account health. Wait for any account probe to
641
+ * settle, re-bind/fork this chat on the selected account, and retry once. */
642
+ private async maybeRecoverAgentSession(
643
+ input: PromptInput,
644
+ outcome: { result?: PromptResult; error?: Error; attempts: number },
645
+ ): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
646
+ if (
647
+ !outcome.error ||
648
+ !isSessionLifecycleError(outcome.error) ||
649
+ this.cancelled ||
650
+ (this.streamer?.hasOutput ?? false)
651
+ ) {
652
+ return undefined;
653
+ }
654
+ try {
655
+ await this.accountRotator?.waitForIdle();
656
+ if (this.cancelled) return outcome;
657
+ const previousId = this.sessionId;
658
+ this.sessionLive = false;
659
+ this.rebindPending = Boolean(previousId);
660
+ await this.ensureSession();
661
+ this.shownToolIds = new Set();
662
+ this.subagentShown = new Map();
663
+ this.streamer?.setFooter(this.hashtags());
664
+ const retryContent = buildContentBlocks(input, {
665
+ reasoning: reasoningDirective(this.reasoning),
666
+ priming: this.primingContext,
667
+ imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
668
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
669
+ });
670
+ this.primingContext = undefined;
671
+ log.info(
672
+ `chat ${this.chatId} recovered lifecycle error on the active account` +
673
+ (previousId && this.sessionId !== previousId
674
+ ? ` with fresh session ${this.sessionId?.slice(0, 8)}`
675
+ : " by re-binding its session"),
676
+ );
677
+ return this.runPromptWithRetries(retryContent);
678
+ } catch (error) {
679
+ log.warn(`chat ${this.chatId} session recovery failed: ${(error as Error).message}`);
680
+ return { error: error as Error, attempts: outcome.attempts };
681
+ }
682
+ }
683
+
627
684
  /**
628
685
  * Auto-fork-on-error recovery. When a turn fails with a *transient* error (or
629
686
  * a context-exhaustion error) and nothing was streamed to the user, the
@@ -670,6 +727,7 @@ export class SessionRuntime {
670
727
  const forkContent = buildContentBlocks(input, {
671
728
  reasoning: reasoningDirective(this.reasoning),
672
729
  priming: transcript ? buildPriming(transcript) : undefined,
730
+ imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
673
731
  progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
674
732
  });
675
733
  return this.runPromptWithRetries(forkContent);
@@ -692,27 +750,59 @@ export class SessionRuntime {
692
750
  input: PromptInput,
693
751
  final: { result?: PromptResult; error?: Error; attempts: number },
694
752
  ): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
695
- const rotator = this.accountRotator;
696
- if (!rotator?.enabled() || !final.error || this.cancelled) return undefined;
697
- // A quota-exhausted or access-denied response cannot be recovered by retrying
698
- // this login. Quarantine it before choosing targets, so later rotations do not
699
- // cycle back to a known-bad account. This intentionally happens before the
700
- // partial-stream guard: we must not retry/rotate a partial reply, but its
701
- // account still needs to be skipped during a future rotation.
702
- if (isAccountRotationError(final.error)) {
703
- await rotator.markFailed(undefined, final.error.message);
704
- }
705
- if (this.streamer?.hasOutput ?? false) return undefined;
706
- const targets = await rotator.targets().catch(() => [] as { id: string; label: string }[]);
753
+ const rotator = this.accountRotator;
754
+ if (!rotator?.enabled() || !final.error || this.cancelled) return undefined;
755
+ if (isSessionLifecycleError(final.error)) return undefined;
756
+ const originalError = final.error;
757
+ const observed = rotator.state();
758
+ return rotator.withRotationLock(observed, async (changed) => {
759
+ if (this.cancelled) return final;
760
+ if (changed) {
761
+ if (this.streamer?.hasOutput ?? false) return undefined;
762
+ const current = rotator.state();
763
+ const transcript = this.sessionId ? recentTranscript(this.cfg.sessionsDir, this.sessionId) : undefined;
764
+ if (this.foreground) {
765
+ await this.notify(
766
+ `\u{1F504} Reusing ${current.activeLabel ?? "the account selected by another chat"} with a fresh session…`,
767
+ { replyTo: this.turnReplyTo },
768
+ );
769
+ }
770
+ log.info(`chat ${this.chatId} reusing account generation ${current.generation} selected by another chat`);
771
+ try {
772
+ await this.bindNewSession(this.cwd, this.projectName);
773
+ } catch (error) {
774
+ return { error: error as Error, attempts: final.attempts };
775
+ }
776
+ this.shownToolIds = new Set();
777
+ this.subagentShown = new Map();
778
+ this.streamer?.setFooter(this.hashtags());
779
+ const content = buildContentBlocks(input, {
780
+ reasoning: reasoningDirective(this.reasoning),
781
+ priming: transcript ? buildPriming(transcript) : undefined,
782
+ imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
783
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
784
+ });
785
+ return this.runPromptWithRetries(content);
786
+ }
787
+ // A quota-exhausted or access-denied response cannot be recovered by retrying
788
+ // this login. Quarantine it before choosing targets, so later rotations do not
789
+ // cycle back to a known-bad account. This intentionally happens before the
790
+ // partial-stream guard: we must not retry/rotate a partial reply, but its
791
+ // account still needs to be skipped during a future rotation.
792
+ if (isAccountRotationError(originalError)) {
793
+ await rotator.markFailed(observed.activeId, originalError.message);
794
+ }
795
+ if (this.streamer?.hasOutput ?? false) return undefined;
796
+ const targets = await rotator.targets().catch(() => [] as { id: string; label: string }[]);
707
797
  if (targets.length === 0) return undefined;
708
798
 
709
799
  const transcript = this.sessionId ? recentTranscript(this.cfg.sessionsDir, this.sessionId) : undefined;
710
- const errors: string[] = [`\u2022 previous: ${final.error.message}`];
800
+ const errors: string[] = [`\u2022 previous: ${originalError.message}`];
711
801
  let last = final;
712
802
 
713
803
  for (const t of targets) {
714
804
  if (this.cancelled) return last;
715
- const failReason = last.error ?? final.error;
805
+ const failReason = last.error ?? originalError;
716
806
  if (this.foreground) {
717
807
  await this.notify(formatAccountSwitchNotice(t.label, failReason), { replyTo: this.turnReplyTo });
718
808
  }
@@ -736,30 +826,32 @@ export class SessionRuntime {
736
826
  const content = buildContentBlocks(input, {
737
827
  reasoning: reasoningDirective(this.reasoning),
738
828
  priming: transcript ? buildPriming(transcript) : undefined,
739
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
829
+ imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
830
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
740
831
  });
741
832
  log.info(
742
833
  `chat ${this.chatId} auto-rotating to account ${t.label}` +
743
- (isAccountRotationError(failReason) ? " (previous account unavailable)" : ""),
834
+ (isAccountRotationError(failReason) ? " (previous account unavailable)" : ""),
744
835
  );
745
836
  // runPromptWithRetries already skips backoff for 402 / balance exhausted.
746
837
  last = await this.runPromptWithRetries(content);
747
- if (last.result && !this.cancelled) {
838
+ if (last.result && !this.cancelled) {
748
839
  if (this.foreground) {
749
840
  await this.notify(`\u2705 Recovered on ${t.label}.`, { replyTo: this.turnReplyTo });
750
841
  }
751
842
  return last;
752
- }
753
- if (last.error && isAccountRotationError(last.error)) {
754
- await rotator.markFailed(t.id, last.error.message);
755
- }
756
- if (this.cancelled || (this.streamer?.hasOutput ?? false)) return last;
757
- errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
843
+ }
844
+ if (last.error && isAccountRotationError(last.error)) {
845
+ await rotator.markFailed(t.id, last.error.message);
846
+ }
847
+ if (this.cancelled || (this.streamer?.hasOutput ?? false)) return last;
848
+ errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
758
849
  }
759
850
 
760
851
  // One full cycle done and still failing — stop with a combined report.
761
852
  const combined = new Error(`Tried ${targets.length + 1} account(s), all failed:\n${errors.join("\n")}`);
762
853
  return { error: combined, attempts: last.attempts };
854
+ });
763
855
  }
764
856
 
765
857
  /**
@@ -776,21 +868,21 @@ export class SessionRuntime {
776
868
  ): Promise<{ result?: PromptResult; error?: Error; attempts: number }> {
777
869
  const delays = this.cfg.promptRetryAttempts > 0 ? backoffSchedule(this.cfg.promptRetryAttempts) : [];
778
870
  const totalAttempts = delays.length + 1;
779
- let attempt = 0;
780
- for (;;) {
781
- attempt++;
782
- try {
783
- const updatesBeforePrompt = this.sessionUpdateCount;
784
- const result = await this.acp.prompt(this.sessionId!, content);
785
- // A healthy ACP turn emits at least one session/update (text, thought,
786
- // or tool event) before resolving session/prompt. Grok can otherwise
787
- // report a successful end-turn after an upstream model failure; never
788
- // present that as a completed user request.
789
- await sleep(0);
790
- if (this.sessionUpdateCount === updatesBeforePrompt) {
791
- throw new Error("Empty agent response — Grok ended the turn without any output or tool activity");
792
- }
793
- return { result, attempts: attempt };
871
+ let attempt = 0;
872
+ for (;;) {
873
+ attempt++;
874
+ try {
875
+ const updatesBeforePrompt = this.sessionUpdateCount;
876
+ const result = await this.acp.prompt(this.sessionId!, content);
877
+ // A healthy ACP turn emits at least one session/update (text, thought,
878
+ // or tool event) before resolving session/prompt. Grok can otherwise
879
+ // report a successful end-turn after an upstream model failure; never
880
+ // present that as a completed user request.
881
+ await sleep(0);
882
+ if (this.sessionUpdateCount === updatesBeforePrompt) {
883
+ throw new Error("Empty agent response — Grok ended the turn without any output or tool activity");
884
+ }
885
+ return { result, attempts: attempt };
794
886
  } catch (err) {
795
887
  const error = err as Error;
796
888
  const canRecover = !this.cancelled && !(this.streamer?.hasOutput ?? false);
@@ -803,7 +895,7 @@ export class SessionRuntime {
803
895
  attempt <= delays.length &&
804
896
  canRecover &&
805
897
  !forkInstead &&
806
- !isAccountRotationError(error) &&
898
+ !isAccountRotationError(error) &&
807
899
  isTransientError(error);
808
900
  if (!willRetry) return { error, attempts: attempt };
809
901
  const waitMs = delays[attempt - 1]!;
@@ -859,6 +951,7 @@ export class SessionRuntime {
859
951
  const delays = this.cfg.promptRetryAttempts > 0 ? backoffSchedule(this.cfg.promptRetryAttempts) : [RETRY_BASE_MS];
860
952
  const resumeContent = buildContentBlocks(textPrompt(RESUME_INSTRUCTION), {
861
953
  reasoning: reasoningDirective(this.reasoning),
954
+ imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
862
955
  progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
863
956
  });
864
957
 
@@ -888,17 +981,26 @@ export class SessionRuntime {
888
981
  return last;
889
982
  }
890
983
 
891
- /** Send any fresh images the agent produced this turn (screenshots, etc.). */
984
+ /** Send any fresh images the agent produced this turn (Imagine, screenshots…). */
892
985
  private async sendTurnImages(): Promise<void> {
893
- if (!this.cfg.sendAgentImages || !this.imageScanText) return;
894
- const paths = extractImagePaths(this.imageScanText, this.cwd);
986
+ if (!this.cfg.sendAgentImages) return;
987
+ // Always check session images/ + assets/ even when the agent never named a
988
+ // path in text — image_gen writes under ~/.grok/sessions/.../images/.
989
+ const paths = collectTurnImagePaths({
990
+ scanText: this.imageScanText,
991
+ cwd: this.cwd,
992
+ sessionId: this.sessionId,
993
+ since: this.turnStartedAt,
994
+ });
895
995
  if (paths.length === 0) return;
896
996
  try {
897
- await sendImages(this.api, this.chatId, paths, {
997
+ const n = await sendImages(this.api, this.chatId, paths, {
898
998
  since: this.turnStartedAt,
899
999
  already: this.sentImagesThisTurn,
900
1000
  max: this.cfg.agentImagesMax,
1001
+ replyTo: this.turnReplyTo,
901
1002
  });
1003
+ if (n > 0) log.info(`chat ${this.chatId}: sent ${n} agent image file(s)`);
902
1004
  } catch {
903
1005
  /* non-fatal */
904
1006
  }
@@ -980,10 +1082,10 @@ export class SessionRuntime {
980
1082
  void this.runTurn(batch);
981
1083
  }
982
1084
 
983
- private onUpdate(sessionId: string, update: SessionUpdate): void {
984
- if (!this.busy || sessionId !== this.sessionId) return;
985
- this.sessionUpdateCount++;
986
- const kind = update.sessionUpdate;
1085
+ private onUpdate(sessionId: string, update: SessionUpdate): void {
1086
+ if (!this.busy || sessionId !== this.sessionId) return;
1087
+ this.sessionUpdateCount++;
1088
+ const kind = update.sessionUpdate;
987
1089
 
988
1090
  // Accumulate the turn's file-change summary + image-scan text even when this
989
1091
  // session is in the background (its output isn't streamed here, but the
@@ -991,6 +1093,12 @@ export class SessionRuntime {
991
1093
  if (kind === "tool_call" || kind === "tool_call_update") {
992
1094
  if (update.rawInput) this.imageScanText += " " + JSON.stringify(update.rawInput);
993
1095
  if (update.title) this.imageScanText += " " + update.title;
1096
+ // Tool results often carry the saved path only in content_blocks (Imagine).
1097
+ if (Array.isArray(update.content_blocks)) {
1098
+ this.imageScanText += " " + JSON.stringify(update.content_blocks);
1099
+ }
1100
+ // Some agents put free-form result text on `content`.
1101
+ if (update.content?.text) this.imageScanText += " " + update.content.text;
994
1102
  const fo = fileOpFromUpdate(update);
995
1103
  if (fo) this.fileOps.set(fo.path, mergeFileOp(this.fileOps.get(fo.path), fo.op));
996
1104
  } else if (kind === "agent_message_chunk") {
@@ -15,6 +15,7 @@ import { EventEmitter } from "node:events";
15
15
  import { createLogger } from "../logger.js";
16
16
  import { hasLogin } from "../app/grok-credentials.js";
17
17
  import { contextWindowFor, DEFAULT_MODEL, KNOWN_MODELS } from "./models.js";
18
+ import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
18
19
  import { PROGRESS_DIRECTIVE } from "../render/progress.js";
19
20
  import { SessionLog } from "./session-log.js";
20
21
  import { JsonRpcTransport } from "./transport.js";
@@ -58,6 +59,11 @@ const ACCOUNT_EXHAUSTED_RE =
58
59
  * saved login may be permitted, while same-account retries cannot help. */
59
60
  const ACCOUNT_ACCESS_DENIED_RE =
60
61
  /\b403\b|forbidden|access denied/i;
62
+ /** Process/session lifecycle failures are not evidence that the active login
63
+ * is bad. They require a session re-bind on the current process generation,
64
+ * never account rotation. */
65
+ const SESSION_LIFECYCLE_RE =
66
+ /unknown session id|grok agent is restarting|grok agent stdio exited|grok agent (?:is )?not running|agent connection (?:is )?closed|authentication required.{0,80}no auth method id provided/i;
61
67
 
62
68
  export class GrokError extends Error {
63
69
  constructor(
@@ -122,7 +128,14 @@ export function isAccountRotationError(err: Error): boolean {
122
128
  return false;
123
129
  }
124
130
 
131
+ export function isSessionLifecycleError(err: Error): boolean {
132
+ return SESSION_LIFECYCLE_RE.test(err.message);
133
+ }
134
+
125
135
  export function isTransientError(err: Error): boolean {
136
+ // Retrying the same stale session cannot recover a process-generation
137
+ // mismatch. SessionRuntime owns the immediate re-bind + one safe retry.
138
+ if (isSessionLifecycleError(err)) return false;
126
139
  // Quota exhaustion and access denial are permanent for this login — rotate,
127
140
  // never back off and retry the same credentials.
128
141
  if (isAccountRotationError(err)) return false;
@@ -264,9 +277,10 @@ export class GrokClient extends EventEmitter {
264
277
  this.availableModels = KNOWN_MODELS.map((m) => ({ modelId: m.modelId, name: m.name, description: m.description }));
265
278
  }
266
279
 
267
- async start(): Promise<void> {
280
+ async start(notifyRestarted = false): Promise<void> {
268
281
  this.stopped = false;
269
282
  await this.connect();
283
+ if (notifyRestarted) this.emit("restarted");
270
284
  }
271
285
 
272
286
  private async connect(): Promise<void> {
@@ -528,9 +542,7 @@ export class GrokClient extends EventEmitter {
528
542
  this.stopped = true;
529
543
  this.restartAttempts = 0;
530
544
  await this.killCurrent();
531
- this.stopped = false;
532
- await this.connect();
533
- this.emit("restarted");
545
+ await this.start(true);
534
546
  }
535
547
 
536
548
  private killCurrent(): Promise<void> {
@@ -728,8 +740,12 @@ export class GrokClient extends EventEmitter {
728
740
  * leading reasoning directive, fork/priming preamble) removed, for a clean log. */
729
741
  private cleanUserText(content: ContentBlock[]): string {
730
742
  let t = this.visibleText(content);
743
+ // Strip bot-injected appendices (image rules first, then progress — progress
744
+ // is always last when both are present).
731
745
  const pi = t.indexOf(PROGRESS_DIRECTIVE);
732
746
  if (pi !== -1) t = t.slice(0, pi).trimEnd();
747
+ const ii = t.indexOf(IMAGE_OUTPUT_DIRECTIVE);
748
+ if (ii !== -1) t = t.slice(0, ii).trimEnd();
733
749
  const marker = "User's new message:\n";
734
750
  const mi = t.lastIndexOf(marker);
735
751
  if (mi !== -1) t = t.slice(mi + marker.length);
package/src/grok/types.ts CHANGED
@@ -26,12 +26,23 @@ export interface JsonRpcNotification {
26
26
 
27
27
  export type JsonRpcMessage = JsonRpcResponse & JsonRpcNotification & { method?: string };
28
28
 
29
- /** A content block in a prompt or message. */
29
+ /** A content block in a prompt or message (ACP ContentBlock subset). */
30
30
  export interface ContentBlock {
31
- type: "text" | "image" | "resource";
31
+ type: "text" | "image" | "audio" | "resource" | "resource_link";
32
32
  text?: string;
33
33
  data?: string;
34
34
  mimeType?: string;
35
+ /** resource_link */
36
+ uri?: string;
37
+ name?: string;
38
+ size?: number;
39
+ /** embedded resource */
40
+ resource?: {
41
+ uri: string;
42
+ mimeType?: string;
43
+ text?: string;
44
+ blob?: string;
45
+ };
35
46
  [k: string]: unknown;
36
47
  }
37
48
 
@@ -47,7 +58,11 @@ export interface InitializeResult {
47
58
  authMethods?: AuthMethod[];
48
59
  agentCapabilities?: {
49
60
  loadSession?: boolean;
50
- promptCapabilities?: { image?: boolean };
61
+ promptCapabilities?: {
62
+ image?: boolean;
63
+ audio?: boolean;
64
+ embeddedContext?: boolean;
65
+ };
51
66
  };
52
67
  agentInfo?: { name?: string; version?: string };
53
68
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Prompt appendix so the agent keeps generated images in the session media
3
+ * folder and mentions absolute paths (the bot delivers those as Telegram files).
4
+ *
5
+ * Keep tidy-idempotent (no trailing spaces / 3+ blank lines) so
6
+ * `cleanStoredText` can strip it by exact match after extractProgress/tidy.
7
+ */
8
+ export const IMAGE_OUTPUT_DIRECTIVE = [
9
+ "IMAGE OUTPUT RULES:",
10
+ "When generating images (image_gen / image_edit) or saving image files, write them under the current Grok session media folder (session images/ or assets/) or the project images/ directory — not random temp locations.",
11
+ "Always mention the absolute path of each image file you create in your reply so the client can deliver it as a downloadable Telegram file.",
12
+ ].join("\n");
@@ -3,6 +3,7 @@
3
3
  * Reads only the tail of large logs to stay fast.
4
4
  */
5
5
  import { closeSync, openSync, readSync, statSync } from "node:fs";
6
+ import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
6
7
  import { extractProgress, PROGRESS_DIRECTIVE } from "../render/progress.js";
7
8
  import type { HistoryEntry, HistoryRole } from "./types.js";
8
9
 
@@ -161,6 +162,7 @@ function cleanStoredText(text: string): string {
161
162
  if (!text) return text;
162
163
  let t = extractProgress(text).cleaned;
163
164
  if (t.includes(PROGRESS_DIRECTIVE)) t = t.split(PROGRESS_DIRECTIVE).join("").trim();
165
+ if (t.includes(IMAGE_OUTPUT_DIRECTIVE)) t = t.split(IMAGE_OUTPUT_DIRECTIVE).join("").trim();
164
166
  return t;
165
167
  }
166
168