@sayknow-cli/coding-agent 0.3.9 → 0.3.11

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 (59) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/types/cli/args.d.ts +1 -0
  3. package/dist/types/cli/notify-cli.d.ts +5 -0
  4. package/dist/types/cli.d.ts +3 -0
  5. package/dist/types/commands/launch.d.ts +3 -0
  6. package/dist/types/config/model-profile-activation.d.ts +7 -7
  7. package/dist/types/config/model-registry.d.ts +11 -3
  8. package/dist/types/config/model-resolver.d.ts +2 -2
  9. package/dist/types/config/settings-schema.d.ts +1 -1
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
  12. package/dist/types/modes/components/settings-selector.d.ts +2 -2
  13. package/dist/types/modes/components/thinking-selector.d.ts +6 -1
  14. package/dist/types/modes/types.d.ts +1 -0
  15. package/dist/types/notifications/config-commands.d.ts +29 -0
  16. package/dist/types/notifications/index.d.ts +12 -1
  17. package/dist/types/notifications/threaded-render.d.ts +2 -0
  18. package/dist/types/notifications/topic-registry.d.ts +4 -5
  19. package/dist/types/runtime-credential-selector.d.ts +7 -0
  20. package/dist/types/sdk.d.ts +7 -1
  21. package/dist/types/session/agent-session.d.ts +6 -3
  22. package/dist/types/tools/index.d.ts +1 -0
  23. package/dist/types/tools/skill-discovery.d.ts +40 -0
  24. package/package.json +7 -7
  25. package/src/cli/args.ts +8 -0
  26. package/src/cli/notify-cli.ts +27 -1
  27. package/src/cli/update-cli.ts +24 -63
  28. package/src/cli.ts +5 -0
  29. package/src/commands/launch.ts +5 -0
  30. package/src/commands/notify.ts +2 -2
  31. package/src/config/model-profile-activation.ts +23 -7
  32. package/src/config/model-registry.ts +107 -11
  33. package/src/config/model-resolver.ts +5 -16
  34. package/src/config/settings-schema.ts +3 -2
  35. package/src/config/settings.ts +24 -1
  36. package/src/extensibility/runtime-skill-discovery.ts +229 -0
  37. package/src/internal-urls/docs-index.generated.ts +1 -1
  38. package/src/main.ts +26 -3
  39. package/src/modes/components/settings-selector.ts +3 -3
  40. package/src/modes/components/thinking-selector.ts +79 -18
  41. package/src/modes/controllers/input-controller.ts +50 -9
  42. package/src/modes/controllers/selector-controller.ts +7 -5
  43. package/src/modes/types.ts +1 -0
  44. package/src/modes/utils/ui-helpers.ts +48 -15
  45. package/src/notifications/config-commands.ts +70 -0
  46. package/src/notifications/index.ts +156 -9
  47. package/src/notifications/rich-render.ts +1 -0
  48. package/src/notifications/telegram-daemon.ts +87 -4
  49. package/src/notifications/threaded-render.ts +14 -0
  50. package/src/notifications/topic-registry.ts +9 -8
  51. package/src/prompts/tools/skill-discovery.md +13 -0
  52. package/src/runtime-credential-selector.ts +35 -0
  53. package/src/sdk.ts +68 -5
  54. package/src/session/agent-session.ts +42 -18
  55. package/src/skc-runtime/psmux-detect.ts +17 -2
  56. package/src/system-prompt.ts +4 -6
  57. package/src/tools/index.ts +4 -0
  58. package/src/tools/skill-discovery.ts +73 -0
  59. package/src/tools/skill.ts +15 -2
@@ -1,53 +1,114 @@
1
1
  import { ThinkingLevel, type ThinkingLevel as ThinkingLevelValue } from "@sayknow-cli/agent-core";
2
- import { Container, type SelectItem, SelectList } from "@sayknow-cli/tui";
3
- import { getSelectListTheme } from "../../modes/theme/theme";
2
+ import { Container, type SelectItem, SelectList, Text } from "@sayknow-cli/tui";
3
+ import { getSelectListTheme, theme } from "../../modes/theme/theme";
4
4
  import { getThinkingLevelMetadata, type ThinkingLevelValue as ThinkingMetadataValue } from "../../thinking-metadata";
5
5
  import { DynamicBorder } from "./dynamic-border";
6
6
 
7
+ const SCOPE_ITEMS = [
8
+ {
9
+ value: "session",
10
+ label: "Apply for this session",
11
+ description: "Use this effort until the session changes it again",
12
+ },
13
+ {
14
+ value: "default",
15
+ label: "Set as default",
16
+ description: "Save this effort for future sessions",
17
+ },
18
+ ] as const satisfies readonly SelectItem[];
19
+
20
+ export interface ThinkingSelectorSelection {
21
+ level: ThinkingLevelValue;
22
+ persistDefault: boolean;
23
+ }
24
+
7
25
  /**
8
26
  * Component that renders a thinking level selector with borders
9
27
  */
10
28
  export class ThinkingSelectorComponent extends Container {
11
29
  #selectList: SelectList;
30
+ #levelList: SelectList;
31
+ #selectedLevel: ThinkingLevelValue | undefined;
32
+ readonly #onSelect: (selection: ThinkingSelectorSelection) => void;
33
+ readonly #onCancel: () => void;
12
34
 
13
35
  constructor(
14
36
  currentLevel: ThinkingLevelValue | undefined,
15
37
  availableLevels: ThinkingLevelValue[],
16
- onSelect: (level: ThinkingLevelValue) => void,
38
+ onSelect: (selection: ThinkingSelectorSelection) => void,
17
39
  onCancel: () => void,
18
40
  ) {
19
41
  super();
20
42
 
21
- const thinkingLevels: SelectItem[] = availableLevels.map(level =>
22
- getThinkingLevelMetadata(level as ThinkingMetadataValue),
23
- );
43
+ this.#onSelect = onSelect;
44
+ this.#onCancel = onCancel;
24
45
 
25
- // Add top border
26
- this.addChild(new DynamicBorder());
46
+ const currentValue = currentLevel ?? ThinkingLevel.Off;
47
+ const thinkingLevels: SelectItem[] = availableLevels.map(level => {
48
+ const metadata = getThinkingLevelMetadata(level as ThinkingMetadataValue);
49
+ return level === currentValue
50
+ ? { ...metadata, label: metadata.label + theme.fg("muted", " (current)") }
51
+ : metadata;
52
+ });
27
53
 
28
- // Create selector
29
- this.#selectList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme());
54
+ this.#levelList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme());
55
+ this.#selectList = this.#levelList;
30
56
 
31
57
  // Preselect current level
32
- const currentIndex = thinkingLevels.findIndex(item => item.value === (currentLevel ?? ThinkingLevel.Off));
58
+ const currentIndex = thinkingLevels.findIndex(item => item.value === currentValue);
33
59
  if (currentIndex !== -1) {
34
- this.#selectList.setSelectedIndex(currentIndex);
60
+ this.#levelList.setSelectedIndex(currentIndex);
35
61
  }
36
62
 
37
- this.#selectList.onSelect = item => {
38
- onSelect(item.value as ThinkingLevelValue);
63
+ this.#levelList.onSelect = item => {
64
+ this.#selectedLevel = item.value as ThinkingLevelValue;
65
+ this.#renderScopeList();
39
66
  };
40
67
 
41
- this.#selectList.onCancel = () => {
42
- onCancel();
68
+ this.#levelList.onCancel = () => {
69
+ this.#onCancel();
43
70
  };
44
71
 
45
- this.addChild(this.#selectList);
72
+ this.#renderLevelList();
73
+ }
74
+
75
+ #renderLevelList(): void {
76
+ this.detachAll();
77
+ this.#selectedLevel = undefined;
78
+ this.#selectList = this.#levelList;
79
+ this.addChild(new DynamicBorder());
80
+ this.addChild(this.#levelList);
81
+ this.addChild(new DynamicBorder());
82
+ }
83
+
84
+ #renderScopeList(): void {
85
+ const level = this.#selectedLevel;
86
+ if (!level) return;
87
+
88
+ const scopeList = new SelectList(SCOPE_ITEMS, SCOPE_ITEMS.length, getSelectListTheme());
89
+ scopeList.onSelect = item => {
90
+ this.#onSelect({
91
+ level,
92
+ persistDefault: item.value === "default",
93
+ });
94
+ };
95
+ scopeList.onCancel = () => {
96
+ this.#renderLevelList();
97
+ };
46
98
 
47
- // Add bottom border
99
+ const metadata = getThinkingLevelMetadata(level as ThinkingMetadataValue);
100
+ this.detachAll();
101
+ this.#selectList = scopeList;
102
+ this.addChild(new DynamicBorder());
103
+ this.addChild(new Text(theme.fg("muted", ` Reasoning effort: ${metadata.label}`), 0, 0));
104
+ this.addChild(scopeList);
48
105
  this.addChild(new DynamicBorder());
49
106
  }
50
107
 
108
+ handleInput(data: string): void {
109
+ this.#selectList.handleInput(data);
110
+ }
111
+
51
112
  getSelectList(): SelectList {
52
113
  return this.#selectList;
53
114
  }
@@ -27,6 +27,8 @@ interface Expandable {
27
27
  }
28
28
 
29
29
  const INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS = 5_000;
30
+ const IMAGE_PLACEHOLDER_PATTERN = /\[image ([1-9]\d*)\]/g;
31
+ const IMAGE_PLACEHOLDER_PRESENT_PATTERN = /\[image [1-9]\d*\]/;
30
32
 
31
33
  function isExpandable(obj: unknown): obj is Expandable {
32
34
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
@@ -383,6 +385,7 @@ export class InputController {
383
385
  this.ctx.isBashMode = trimmed.startsWith("!");
384
386
  this.ctx.isBashNoContext = trimmed.startsWith("!!");
385
387
  this.ctx.isPythonMode = trimmed.startsWith("$") && !trimmed.startsWith("${");
388
+ this.#clearPendingImagesIfPlaceholdersRemoved(text);
386
389
  if (
387
390
  wasBashMode !== this.ctx.isBashMode ||
388
391
  wasBashNoContext !== this.ctx.isBashNoContext ||
@@ -418,7 +421,7 @@ export class InputController {
418
421
  }
419
422
 
420
423
  const runner = this.ctx.session.extensionRunner;
421
- let inputImages = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
424
+ let inputImages = this.#visiblePendingImagesForText(text);
422
425
 
423
426
  if (runner?.hasHandlers("input")) {
424
427
  const result = await runner.emitInput(text, inputImages, "interactive");
@@ -498,7 +501,7 @@ export class InputController {
498
501
 
499
502
  // Queue input during compaction
500
503
  if (this.ctx.session.isCompacting) {
501
- if (this.ctx.pendingImages.length > 0) {
504
+ if ((inputImages?.length ?? 0) > 0) {
502
505
  this.ctx.showStatus("Compaction in progress. Retry after it completes to send images.");
503
506
  return;
504
507
  }
@@ -520,11 +523,13 @@ export class InputController {
520
523
  // typed since queuing intact. Same protection as #783, applied to
521
524
  // the streaming/queue path.
522
525
  const streamingBehavior = this.#busyStreamingBehavior();
523
- await this.ctx.withLocalSubmission(
524
- text,
525
- () => this.ctx.session.prompt(text, { streamingBehavior, images }),
526
- { imageCount: images?.length ?? 0 },
527
- );
526
+ const promptOptions =
527
+ streamingBehavior === "followUp"
528
+ ? { streamingBehavior, images, followUpQueuePolicy: "sequential" as const }
529
+ : { streamingBehavior, images };
530
+ await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, promptOptions), {
531
+ imageCount: images?.length ?? 0,
532
+ });
528
533
  this.ctx.updatePendingMessagesDisplay();
529
534
  this.ctx.ui.requestRender();
530
535
  return;
@@ -869,7 +874,9 @@ export class InputController {
869
874
  details,
870
875
  attribution: "user",
871
876
  },
872
- { streamingBehavior },
877
+ streamingBehavior === "followUp"
878
+ ? { streamingBehavior, followUpQueuePolicy: "sequential" }
879
+ : { streamingBehavior },
873
880
  );
874
881
  }
875
882
  if (this.ctx.session.isStreaming) {
@@ -911,7 +918,10 @@ export class InputController {
911
918
  this.ctx.editor.addToHistory(text);
912
919
  this.ctx.editor.setText("");
913
920
  await this.ctx.withLocalSubmission(text, () =>
914
- this.ctx.session.prompt(text, { streamingBehavior: "followUp" }),
921
+ this.ctx.session.prompt(text, {
922
+ streamingBehavior: "followUp",
923
+ followUpQueuePolicy: "sequential",
924
+ }),
915
925
  );
916
926
  this.ctx.updatePendingMessagesDisplay();
917
927
  this.ctx.ui.requestRender();
@@ -1104,6 +1114,37 @@ export class InputController {
1104
1114
  return `[image ${this.ctx.pendingImages.length}]`;
1105
1115
  }
1106
1116
 
1117
+ #visiblePendingImagesForText(text: string): InteractiveModeContext["pendingImages"] | undefined {
1118
+ if (this.ctx.pendingImages.length === 0) {
1119
+ return undefined;
1120
+ }
1121
+
1122
+ const images: InteractiveModeContext["pendingImages"] = [];
1123
+ const seenImageIndexes = new Set<number>();
1124
+ for (const match of text.matchAll(IMAGE_PLACEHOLDER_PATTERN)) {
1125
+ const placeholderNumberText = match[1];
1126
+ if (!placeholderNumberText) continue;
1127
+ const placeholderNumber = Number.parseInt(placeholderNumberText, 10);
1128
+ const imageIndex = placeholderNumber - 1;
1129
+ if (imageIndex < 0 || imageIndex >= this.ctx.pendingImages.length || seenImageIndexes.has(imageIndex)) {
1130
+ continue;
1131
+ }
1132
+ const image = this.ctx.pendingImages[imageIndex];
1133
+ if (!image) continue;
1134
+ images.push(image);
1135
+ seenImageIndexes.add(imageIndex);
1136
+ }
1137
+
1138
+ return images.length > 0 ? images : undefined;
1139
+ }
1140
+
1141
+ #clearPendingImagesIfPlaceholdersRemoved(text: string): void {
1142
+ if (this.ctx.pendingImages.length === 0 || IMAGE_PLACEHOLDER_PRESENT_PATTERN.test(text)) {
1143
+ return;
1144
+ }
1145
+ this.ctx.pendingImages = [];
1146
+ }
1147
+
1107
1148
  async handleImagePaste(): Promise<boolean> {
1108
1149
  try {
1109
1150
  const image = await readImageFromClipboard();
@@ -340,12 +340,13 @@ export class SelectorController {
340
340
  const selector = new ThinkingSelectorComponent(
341
341
  this.ctx.session.thinkingLevel,
342
342
  availableLevels,
343
- level => {
343
+ selection => {
344
344
  done();
345
345
 
346
+ const { level, persistDefault } = selection;
346
347
  const configuredDefault = this.ctx.settings.get("defaultThinkingLevel");
347
348
  const levelToApply = level === ThinkingLevel.Inherit ? configuredDefault : level;
348
- this.ctx.session.setThinkingLevel(levelToApply, false);
349
+ this.ctx.session.setThinkingLevel(levelToApply, persistDefault);
349
350
  const effectiveLevel = this.ctx.session.thinkingLevel ?? ThinkingLevel.Off;
350
351
  const requestedLabel =
351
352
  level === ThinkingLevel.Inherit ? `${level} (configured default: ${configuredDefault})` : level;
@@ -355,9 +356,11 @@ export class SelectorController {
355
356
  this.ctx.statusLine.invalidate();
356
357
  this.ctx.updateEditorBorderColor();
357
358
  this.ctx.updateEditorTopBorder();
359
+ if (persistDefault) void this.ctx.notifyConfigChanged?.();
358
360
  this.ctx.ui.requestRender();
361
+ const scopeLabel = persistDefault ? "Default reasoning effort" : "Reasoning effort";
359
362
  this.ctx.showStatus(
360
- `Reasoning effort set to ${requestedLabel}. Effective effort: ${effectiveLevel}.${clampedSuffix}`,
363
+ `${scopeLabel} set to ${requestedLabel}. Effective effort: ${effectiveLevel}.${clampedSuffix}`,
361
364
  );
362
365
  },
363
366
  () => {
@@ -365,10 +368,9 @@ export class SelectorController {
365
368
  this.ctx.ui.requestRender();
366
369
  },
367
370
  );
368
- return { component: selector, focus: selector.getSelectList() };
371
+ return { component: selector, focus: selector };
369
372
  });
370
373
  }
371
-
372
374
  showSettingsSelector(): void {
373
375
  getAvailableThemes().then(availableThemes => {
374
376
  this.showSelector(done => {
@@ -34,6 +34,7 @@ import type { Theme } from "./theme/theme";
34
34
  export type CompactionQueuedMessage = {
35
35
  text: string;
36
36
  mode: "steer" | "followUp";
37
+ followUpQueuePolicy?: "sequential";
37
38
  };
38
39
 
39
40
  export type SubmittedUserInput = {
@@ -37,17 +37,27 @@ interface RenderInitialMessagesOptions {
37
37
  preserveExistingChat?: boolean;
38
38
  }
39
39
 
40
+ function cloneRenderArgs(args: Record<string, unknown>): Record<string, unknown> {
41
+ try {
42
+ return structuredClone(args);
43
+ } catch {
44
+ return { ...args };
45
+ }
46
+ }
47
+
40
48
  export function argsWithPartialJson(args: unknown, partialJson: unknown): unknown {
41
49
  if (typeof partialJson !== "string" || !args || typeof args !== "object" || Array.isArray(args)) return args;
42
- // Non-enumerable so the transient streaming buffer reaches renderers via direct
43
- // property read but never serializes into persisted/wire message content.
44
- Object.defineProperty(args, "__partialJson", {
50
+ // Keep the transient streaming buffer on a renderer-only snapshot. The live
51
+ // assistant message args are later validated/executed, so UI-only metadata or
52
+ // renderer mutations must never share that object reference.
53
+ const renderArgs = cloneRenderArgs(args as Record<string, unknown>);
54
+ Object.defineProperty(renderArgs, "__partialJson", {
45
55
  value: partialJson,
46
56
  enumerable: false,
47
57
  configurable: true,
48
58
  writable: true,
49
59
  });
50
- return args;
60
+ return renderArgs;
51
61
  }
52
62
 
53
63
  type QueuedMessages = {
@@ -625,7 +635,11 @@ export class UiHelpers {
625
635
  }
626
636
 
627
637
  queueCompactionMessage(text: string, mode: "steer" | "followUp"): void {
628
- this.ctx.compactionQueuedMessages.push({ text, mode } as CompactionQueuedMessage);
638
+ const entry: CompactionQueuedMessage = { text, mode };
639
+ if (mode === "followUp") {
640
+ entry.followUpQueuePolicy = "sequential";
641
+ }
642
+ this.ctx.compactionQueuedMessages.push(entry);
629
643
  this.ctx.editor.addToHistory(text);
630
644
  this.ctx.editor.setText("");
631
645
  this.ctx.updatePendingMessagesDisplay();
@@ -639,6 +653,11 @@ export class UiHelpers {
639
653
  return this.#hasSkillInvocations(text) || this.isKnownSlashCommand(text);
640
654
  }
641
655
 
656
+ #compactionFollowUpQueuePolicy(message: CompactionQueuedMessage): "sequential" | undefined {
657
+ if (message.mode !== "followUp") return undefined;
658
+ return message.followUpQueuePolicy ?? "sequential";
659
+ }
660
+
642
661
  async #deliverQueuedSkillMessage(message: CompactionQueuedMessage): Promise<boolean> {
643
662
  const invocations = parseSkillInvocations(message.text, this.ctx.skillCommands ?? new Map());
644
663
  if (invocations.length === 0) {
@@ -681,6 +700,13 @@ export class UiHelpers {
681
700
  continue;
682
701
  }
683
702
 
703
+ const promptOptions =
704
+ message.mode === "followUp"
705
+ ? {
706
+ streamingBehavior: message.mode,
707
+ followUpQueuePolicy: this.#compactionFollowUpQueuePolicy(message),
708
+ }
709
+ : { streamingBehavior: message.mode };
684
710
  await this.ctx.session.promptCustomMessage(
685
711
  {
686
712
  customType: SKILL_PROMPT_MESSAGE_TYPE,
@@ -689,7 +715,7 @@ export class UiHelpers {
689
715
  details,
690
716
  attribution: "user",
691
717
  },
692
- { streamingBehavior: message.mode },
718
+ promptOptions,
693
719
  );
694
720
  }
695
721
 
@@ -709,7 +735,11 @@ export class UiHelpers {
709
735
  return;
710
736
  }
711
737
  await this.ctx.withLocalSubmission(message.text, () =>
712
- message.mode === "followUp" ? this.ctx.session.followUp(message.text) : this.ctx.session.steer(message.text),
738
+ message.mode === "followUp"
739
+ ? this.ctx.session.followUp(message.text, undefined, {
740
+ followUpQueuePolicy: this.#compactionFollowUpQueuePolicy(message),
741
+ })
742
+ : this.ctx.session.steer(message.text),
713
743
  );
714
744
  }
715
745
 
@@ -800,14 +830,17 @@ export class UiHelpers {
800
830
  // `restoreQueue` rather than rethrown, so we use the primitive
801
831
  // recordLocalSubmission and dispose manually in the catch.
802
832
  const disposeFirstPrompt = this.ctx.recordLocalSubmission(firstPrompt.text);
803
- const promptPromise = this.ctx.session
804
- .prompt(firstPrompt.text, {
805
- streamingBehavior: firstPrompt.mode === "followUp" ? "followUp" : "steer",
806
- })
807
- .catch((error: unknown) => {
808
- disposeFirstPrompt();
809
- restoreQueue(error);
810
- });
833
+ const firstPromptOptions =
834
+ firstPrompt.mode === "followUp"
835
+ ? {
836
+ streamingBehavior: "followUp" as const,
837
+ followUpQueuePolicy: this.#compactionFollowUpQueuePolicy(firstPrompt),
838
+ }
839
+ : { streamingBehavior: "steer" as const };
840
+ const promptPromise = this.ctx.session.prompt(firstPrompt.text, firstPromptOptions).catch((error: unknown) => {
841
+ disposeFirstPrompt();
842
+ restoreQueue(error);
843
+ });
811
844
 
812
845
  for (const message of rest) {
813
846
  await this.#deliverQueuedMessage(message);
@@ -20,6 +20,76 @@ export interface ConfigCommandChange {
20
20
  redact?: boolean;
21
21
  }
22
22
 
23
+ export type TelegramControlCommandName = "reasoning" | "usage" | "context" | "compact";
24
+
25
+ export type TelegramControlCommand =
26
+ | { name: "reasoning"; action: "cycle" | "status" | "set"; level?: string }
27
+ | { name: "usage" }
28
+ | { name: "context" }
29
+ | { name: "compact"; instructions?: string };
30
+
31
+ export type TelegramControlCommandParseResult =
32
+ | { kind: "none" }
33
+ | { kind: "ignored"; commandName: TelegramControlCommandName }
34
+ | { kind: "command"; command: TelegramControlCommand }
35
+ | { kind: "invalid"; commandName: TelegramControlCommandName; usage: string };
36
+
37
+ const TELEGRAM_CONTROL_COMMANDS = new Set<TelegramControlCommandName>(["reasoning", "usage", "context", "compact"]);
38
+ const TELEGRAM_REASONING_LEVELS = new Set(["inherit", "off", "minimal", "low", "medium", "high", "xhigh", "max"]);
39
+
40
+ function splitTelegramBotSuffix(rawCommand: string): { name: string; suffix?: string } {
41
+ const [name, suffix] = rawCommand.toLowerCase().split("@", 2);
42
+ return suffix ? { name, suffix } : { name };
43
+ }
44
+
45
+ export function telegramControlCommandUsage(commandName: TelegramControlCommandName): string {
46
+ switch (commandName) {
47
+ case "reasoning":
48
+ return "Usage: /reasoning [cycle|inherit|off|minimal|low|medium|high|xhigh|max]";
49
+ case "usage":
50
+ return "Usage: /usage";
51
+ case "context":
52
+ return "Usage: /context";
53
+ case "compact":
54
+ return "Usage: /compact [instructions]";
55
+ }
56
+ }
57
+
58
+ /** Parse deterministic Telegram session-control commands. Recognised roots fail closed. */
59
+ export function parseTelegramControlCommand(text: string, botUsername?: string): TelegramControlCommandParseResult {
60
+ const trimmed = text.trim();
61
+ if (!trimmed.startsWith("/")) return { kind: "none" };
62
+ const [rawRoot, ...rest] = trimmed.slice(1).split(/\s+/);
63
+ if (!rawRoot) return { kind: "none" };
64
+ const { name: root, suffix } = splitTelegramBotSuffix(rawRoot);
65
+ if (!TELEGRAM_CONTROL_COMMANDS.has(root as TelegramControlCommandName)) return { kind: "none" };
66
+ const commandName = root as TelegramControlCommandName;
67
+ if (suffix && (!botUsername || suffix !== botUsername.toLowerCase())) return { kind: "ignored", commandName };
68
+ const usage = telegramControlCommandUsage(commandName);
69
+
70
+ switch (commandName) {
71
+ case "usage":
72
+ case "context":
73
+ return rest.length === 0
74
+ ? { kind: "command", command: { name: commandName } }
75
+ : { kind: "invalid", commandName, usage };
76
+ case "compact": {
77
+ const instructions = trimmed.slice(rawRoot.length + 1).trim();
78
+ return { kind: "command", command: instructions ? { name: "compact", instructions } : { name: "compact" } };
79
+ }
80
+ case "reasoning": {
81
+ if (rest.length === 0) return { kind: "command", command: { name: "reasoning", action: "status" } };
82
+ if (rest.length !== 1) return { kind: "invalid", commandName, usage };
83
+ const levelOrAction = rest[0]!.toLowerCase();
84
+ if (levelOrAction === "cycle") return { kind: "command", command: { name: "reasoning", action: "cycle" } };
85
+ if (TELEGRAM_REASONING_LEVELS.has(levelOrAction)) {
86
+ return { kind: "command", command: { name: "reasoning", action: "set", level: levelOrAction } };
87
+ }
88
+ return { kind: "invalid", commandName, usage };
89
+ }
90
+ }
91
+ }
92
+
23
93
  /**
24
94
  * Parse an in-thread config command. Returns the requested change, or
25
95
  * `undefined` when the text is not a recognised config command (so the daemon