@sayknow-cli/coding-agent 0.3.8 → 0.3.10

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 (93) hide show
  1. package/CHANGELOG.md +13 -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 +15 -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 +7 -2
  14. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  15. package/dist/types/modes/interactive-mode.d.ts +2 -0
  16. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  17. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  18. package/dist/types/modes/types.d.ts +3 -0
  19. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  20. package/dist/types/notifications/config-commands.d.ts +38 -0
  21. package/dist/types/notifications/config.d.ts +16 -0
  22. package/dist/types/notifications/index.d.ts +14 -1
  23. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  24. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  25. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  26. package/dist/types/notifications/rich-draft.d.ts +68 -0
  27. package/dist/types/notifications/rich-render.d.ts +90 -0
  28. package/dist/types/notifications/telegram-daemon.d.ts +22 -0
  29. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  30. package/dist/types/notifications/threaded-render.d.ts +10 -0
  31. package/dist/types/notifications/topic-registry.d.ts +4 -5
  32. package/dist/types/runtime-credential-selector.d.ts +7 -0
  33. package/dist/types/sdk.d.ts +7 -1
  34. package/dist/types/session/agent-session.d.ts +6 -3
  35. package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
  36. package/dist/types/tools/index.d.ts +1 -0
  37. package/dist/types/tools/skill-discovery.d.ts +40 -0
  38. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  39. package/package.json +7 -7
  40. package/src/cli/args.ts +8 -0
  41. package/src/cli/notify-cli.ts +38 -10
  42. package/src/cli.ts +5 -0
  43. package/src/commands/launch.ts +5 -0
  44. package/src/commands/notify.ts +2 -2
  45. package/src/config/keybindings.ts +1 -1
  46. package/src/config/model-profile-activation.ts +23 -7
  47. package/src/config/model-registry.ts +107 -11
  48. package/src/config/model-resolver.ts +5 -16
  49. package/src/config/settings-schema.ts +11 -2
  50. package/src/config/settings.ts +24 -1
  51. package/src/extensibility/runtime-skill-discovery.ts +229 -0
  52. package/src/internal-urls/docs-index.generated.ts +4 -4
  53. package/src/lsp/client.ts +1 -0
  54. package/src/main.ts +26 -3
  55. package/src/modes/components/footer.ts +7 -2
  56. package/src/modes/components/settings-selector.ts +3 -3
  57. package/src/modes/components/thinking-selector.ts +83 -20
  58. package/src/modes/controllers/event-controller.ts +11 -2
  59. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  60. package/src/modes/controllers/input-controller.ts +62 -26
  61. package/src/modes/controllers/selector-controller.ts +43 -0
  62. package/src/modes/interactive-mode.ts +5 -0
  63. package/src/modes/rpc/rpc-mode.ts +3 -9
  64. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  65. package/src/modes/types.ts +3 -0
  66. package/src/modes/utils/injected-user-submission.ts +94 -0
  67. package/src/modes/utils/ui-helpers.ts +48 -15
  68. package/src/notifications/config-commands.ts +90 -0
  69. package/src/notifications/config.ts +27 -0
  70. package/src/notifications/index.ts +184 -14
  71. package/src/notifications/lifecycle-commands.ts +37 -16
  72. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  73. package/src/notifications/reply-sent-store.ts +134 -0
  74. package/src/notifications/rich-draft.ts +107 -0
  75. package/src/notifications/rich-render.ts +143 -0
  76. package/src/notifications/telegram-daemon.ts +431 -91
  77. package/src/notifications/telegram-reference.ts +17 -0
  78. package/src/notifications/threaded-render.ts +42 -1
  79. package/src/notifications/topic-registry.ts +9 -8
  80. package/src/prompts/tools/search-tool-bm25.md +5 -0
  81. package/src/prompts/tools/skill-discovery.md +13 -0
  82. package/src/runtime-credential-selector.ts +35 -0
  83. package/src/sdk.ts +104 -13
  84. package/src/session/agent-session.ts +48 -22
  85. package/src/skc-runtime/psmux-detect.ts +17 -2
  86. package/src/skc-runtime/tmux-common.ts +1 -0
  87. package/src/skc-runtime/ultragoal-runtime.ts +10 -1
  88. package/src/slash-commands/builtin-registry.ts +78 -1
  89. package/src/system-prompt.ts +4 -6
  90. package/src/tools/index.ts +4 -0
  91. package/src/tools/skill-discovery.ts +73 -0
  92. package/src/tools/skill.ts +15 -2
  93. package/src/utils/pasted-image-path.ts +170 -0
@@ -0,0 +1,94 @@
1
+ import type { ImageContent, TextContent } from "@sayknow-cli/ai";
2
+ import type { InteractiveModeContext } from "../types";
3
+
4
+ /**
5
+ * Normalize the content passed to an extension `sendUserMessage` call into a
6
+ * plain text string plus its image attachments. Mirrors the normalization in
7
+ * `AgentSession.sendUserMessage` (text parts joined with "\n") so the resulting
8
+ * text matches the eventual user `message_start` payload.
9
+ */
10
+ export function normalizeInjectedUserContent(content: string | (TextContent | ImageContent)[]): {
11
+ text: string;
12
+ images: ImageContent[];
13
+ imageCount: number;
14
+ } {
15
+ if (typeof content === "string") {
16
+ return { text: content, images: [], imageCount: 0 };
17
+ }
18
+ const textParts: string[] = [];
19
+ const images: ImageContent[] = [];
20
+ for (const part of content) {
21
+ if (part.type === "text") textParts.push(part.text);
22
+ else images.push(part);
23
+ }
24
+ const text = textParts.join("\n");
25
+ return { text, images, imageCount: images.length };
26
+ }
27
+
28
+ /**
29
+ * Record a remotely/programmatically injected user message (e.g. Telegram
30
+ * inbound routed through the extension API) into the interactive TUI, so it is
31
+ * captured in prompt history and shown immediately instead of only appearing
32
+ * once the eventual `message_start` event lands.
33
+ *
34
+ * Local TUI submissions never reach this path (they go through
35
+ * `session.prompt(...)` / `startPendingSubmission`), so this cannot double-add
36
+ * local prompt history.
37
+ *
38
+ * - Always adds the injected text to editor prompt history.
39
+ * - Idle injections optimistically render the user message and record a pending
40
+ * injected optimistic signature (a counting Map, so multiple idle injections
41
+ * before the first `message_start` do not clobber each other); the later user
42
+ * `message_start` consumes one count and skips both the duplicate chat add and
43
+ * the defensive editor clear (so a locally typed draft is preserved).
44
+ * - Busy/queued injections refresh the pending-message display, which the
45
+ * caller has already populated by invoking `session.sendUserMessage(...)`
46
+ * before this helper.
47
+ *
48
+ * This helper never clears the editor text.
49
+ */
50
+ export function applyInjectedUserSubmission(
51
+ ctx: InteractiveModeContext,
52
+ input: { content: string | (TextContent | ImageContent)[]; queued: boolean },
53
+ ): void {
54
+ const { text, images, imageCount } = normalizeInjectedUserContent(input.content);
55
+ ctx.editor.addToHistory(text);
56
+
57
+ if (input.queued) {
58
+ ctx.updatePendingMessagesDisplay();
59
+ ctx.ui.requestRender();
60
+ return;
61
+ }
62
+
63
+ incrementInjectedOptimisticSignature(ctx, `${text}\u0000${imageCount}`);
64
+ ctx.addMessageToChat({
65
+ role: "user",
66
+ content: [{ type: "text", text }, ...images],
67
+ attribution: "user",
68
+ timestamp: Date.now(),
69
+ });
70
+ ctx.ui.requestRender();
71
+ }
72
+
73
+ /**
74
+ * Record one pending optimistic render for an injected user message.
75
+ *
76
+ * Injected sends are fire-and-forget and multiple idle injections can be
77
+ * rendered before the first `message_start` arrives, so a counting Map (not a
78
+ * single slot) tracks how many optimistic renders are outstanding per signature.
79
+ */
80
+ export function incrementInjectedOptimisticSignature(ctx: InteractiveModeContext, signature: string): void {
81
+ ctx.optimisticInjectedSignatures.set(signature, (ctx.optimisticInjectedSignatures.get(signature) ?? 0) + 1);
82
+ }
83
+
84
+ /**
85
+ * Consume one pending injected optimistic render for `signature`. Decrements the
86
+ * count (deleting the key at zero) and returns whether one was outstanding.
87
+ */
88
+ export function consumeInjectedOptimisticSignature(ctx: InteractiveModeContext, signature: string): boolean {
89
+ const count = ctx.optimisticInjectedSignatures.get(signature) ?? 0;
90
+ if (count <= 0) return false;
91
+ if (count === 1) ctx.optimisticInjectedSignatures.delete(signature);
92
+ else ctx.optimisticInjectedSignatures.set(signature, count - 1);
93
+ return true;
94
+ }
@@ -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
@@ -48,3 +118,23 @@ export function parseInThreadConfigCommand(text: string): ConfigCommandChange |
48
118
  return undefined;
49
119
  }
50
120
  }
121
+
122
+ /**
123
+ * Parse a `/rich on|off` toggle. Returns `true`/`false` for a recognised
124
+ * on/off argument, or `undefined` otherwise (not a `/rich` command, or `/rich`
125
+ * with a missing/invalid argument). This is intentionally SEPARATE from
126
+ * `parseInThreadConfigCommand`: `/verbose`/`/redact` are producer/session config
127
+ * forwarded over the WS, whereas rich is Telegram-daemon delivery policy handled
128
+ * daemon-locally, so it never becomes a `config_command` frame or a user turn.
129
+ */
130
+ export function parseRichToggleCommand(text: string): boolean | undefined {
131
+ const trimmed = text.trim();
132
+ if (!trimmed.startsWith("/")) return undefined;
133
+ const [rawCommand, ...rest] = trimmed.slice(1).split(/\s+/);
134
+ // Accept the "/rich@botname" form Telegram appends in group chats.
135
+ if (rawCommand?.toLowerCase().split("@")[0] !== "rich") return undefined;
136
+ const arg = rest[0]?.toLowerCase();
137
+ if (arg === "on" || arg === "true" || arg === "1") return true;
138
+ if (arg === "off" || arg === "false" || arg === "0") return false;
139
+ return undefined;
140
+ }
@@ -16,6 +16,12 @@ export interface NotificationConfig {
16
16
  redact: boolean;
17
17
  verbosity: "lean" | "verbose";
18
18
  idleTimeoutMs: number;
19
+ rich: {
20
+ enabled: boolean;
21
+ };
22
+ richDraft: {
23
+ enabled: boolean;
24
+ };
19
25
  }
20
26
 
21
27
  /** Read typed config from Settings. */
@@ -35,6 +41,12 @@ export function getNotificationConfig(settings: Settings): NotificationConfig {
35
41
  redact: settings.get("notifications.redact"),
36
42
  verbosity: settings.get("notifications.verbosity") === "verbose" ? "verbose" : "lean",
37
43
  idleTimeoutMs: settings.get("notifications.daemon.idleTimeoutMs"),
44
+ rich: {
45
+ enabled: settings.get("notifications.telegram.rich.enabled"),
46
+ },
47
+ richDraft: {
48
+ enabled: settings.get("notifications.telegram.richDraft.enabled"),
49
+ },
38
50
  };
39
51
  }
40
52
 
@@ -59,6 +71,20 @@ export function isGloballyConfigured(cfg: NotificationConfig): boolean {
59
71
  );
60
72
  }
61
73
 
74
+ /**
75
+ * Per-run opt-out for completion notifications, honored before settings lookups.
76
+ *
77
+ * `SKC_NOTIFY=off` (also `0` / `false`, case-insensitive) suppresses the
78
+ * completion notification surface for this process only. `config.yml` is
79
+ * untouched and child processes inherit the env var, which lets non-interactive
80
+ * fleet runs (`skc -p --no-session`) stay silent even when a user-level/global
81
+ * completion notification configuration is enabled.
82
+ */
83
+ export function completionNotifyDisabledByEnv(env: NodeJS.ProcessEnv): boolean {
84
+ const v = env.SKC_NOTIFY?.trim().toLowerCase();
85
+ return v === "off" || v === "0" || v === "false";
86
+ }
87
+
62
88
  /** Resolve whether the notifications extension should be registered at SDK startup. */
63
89
  export function shouldRegisterNotificationsExtension(input: {
64
90
  env: NodeJS.ProcessEnv;
@@ -71,6 +97,7 @@ export function shouldRegisterNotificationsExtension(input: {
71
97
  currentAgentType?: string;
72
98
  }): boolean {
73
99
  if ((input.taskDepth ?? 0) > 0 || input.parentTaskPrefix || input.currentAgentType) return false;
100
+ if (completionNotifyDisabledByEnv(input.env)) return false;
74
101
  if (input.env.SKC_NOTIFICATIONS === "0") return false;
75
102
  if (input.env.SKC_NOTIFICATIONS === "1" || input.env.SKC_NOTIFICATIONS_TOKEN) return true;
76
103
  return input.cfg ? isGloballyConfigured(input.cfg) : false;
@@ -24,11 +24,13 @@ import * as fs from "node:fs";
24
24
  import * as os from "node:os";
25
25
  import * as path from "node:path";
26
26
  import { promisify } from "node:util";
27
+ import { ThinkingLevel } from "@sayknow-cli/agent-core";
27
28
  import type { ImageContent, TextContent } from "@sayknow-cli/ai";
28
29
  import { NotificationServer } from "@sayknow-cli/natives";
29
30
  import { logger, postmortem } from "@sayknow-cli/utils";
30
31
  import { Settings } from "../config/settings";
31
32
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../extensibility/extensions";
33
+ import { parseThinkingLevel } from "../thinking";
32
34
  import { registerAskAnswerSource } from "../tools/ask-answer-registry";
33
35
  import { registerTelegramFileSink } from "./attachment-registry";
34
36
  import {
@@ -90,6 +92,8 @@ export interface SessionCreateFrame {
90
92
  target: SessionCreateTarget;
91
93
  /** Reference to the daemon-written, once-consumed startup-prompt file. */
92
94
  startupPromptRef?: string;
95
+ /** Model profile preset to activate for the spawned session (--mpreset). */
96
+ modelPreset?: string;
93
97
  }
94
98
 
95
99
  /** Close (hard-kill, history preserved) a session. */
@@ -356,6 +360,9 @@ interface SessionRuntime {
356
360
  liveRef?: string;
357
361
  lastLiveAt?: number;
358
362
  lastLiveText?: string;
363
+ /** True between turn_end and the next turn_start: drops late async message_update
364
+ * frames so a stale live edit can never be emitted after the finalized turn. */
365
+ turnClosed?: boolean;
359
366
  /** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
360
367
  cancelPostmortemCleanup: () => void;
361
368
  }
@@ -383,6 +390,8 @@ const defaultConfig: NotificationConfig = {
383
390
  redact: false,
384
391
  verbosity: "lean",
385
392
  idleTimeoutMs: 60_000,
393
+ rich: { enabled: true },
394
+ richDraft: { enabled: false },
386
395
  };
387
396
 
388
397
  export function notificationsEnabled(): boolean {
@@ -400,20 +409,18 @@ function streamIntervalMs(): number {
400
409
  return Math.max(200, Number(process.env.SKC_NOTIFICATIONS_STREAM_INTERVAL_MS) || 500);
401
410
  }
402
411
  // Max chars of a turn's assistant text carried by the FINALIZED turn_stream (and
403
- // the pre-ask capture). Default 3500 keeps the mirror a glanceable per-turn
404
- // summary; a client that splits long messages (the Telegram daemon does so via
405
- // splitTelegramHtml, scheduling each chunk through the shared rate-limit pool so
406
- // the fan-out never bypasses the per-chat limit) can raise it with
407
- // SKC_NOTIFICATIONS_TURN_MAX to deliver full turns. The value is clamped to a
408
- // finite [280, TURN_TEXT_MAX_CEILING] range: a non-finite or non-positive env
409
- // (unset, NaN, Infinity, <= 0) falls back to the default, so the cap can never
410
- // be unbounded. Live frames are intentionally NOT raised — they stay one
412
+ // the pre-ask capture). Finalized turns default to the bounded full-turn ceiling
413
+ // because split-capable clients such as the Telegram daemon schedule each
414
+ // splitTelegramHtml chunk through the shared rate-limit pool. Operators who want
415
+ // glanceable summaries can lower this with SKC_NOTIFICATIONS_TURN_MAX. The value
416
+ // is always clamped to a finite [280, TURN_TEXT_MAX_CEILING] range so the cap can
417
+ // never be unbounded. Live frames are intentionally NOT raised — they stay one
411
418
  // editable preview message rather than fanning a long in-progress turn across
412
419
  // sends.
413
420
  const TURN_TEXT_MAX_CEILING = 40_000;
414
421
  function turnTextMax(): number {
415
422
  const raw = Number(process.env.SKC_NOTIFICATIONS_TURN_MAX);
416
- if (!Number.isFinite(raw) || raw <= 0) return 3500;
423
+ if (!Number.isFinite(raw) || raw <= 0) return TURN_TEXT_MAX_CEILING;
417
424
  return Math.min(TURN_TEXT_MAX_CEILING, Math.max(280, raw));
418
425
  }
419
426
  function resolveSettings(settingsOverride?: Settings): ResolvedSettings {
@@ -480,6 +487,121 @@ function mapAnswerToGate(
480
487
  return { selected: [] };
481
488
  }
482
489
 
490
+ interface NotificationControlCommandPayload {
491
+ name?: unknown;
492
+ action?: unknown;
493
+ level?: unknown;
494
+ instructions?: unknown;
495
+ }
496
+
497
+ function parseControlCommandPayload(json: string | undefined): NotificationControlCommandPayload | undefined {
498
+ if (!json) return undefined;
499
+ try {
500
+ const parsed = JSON.parse(json) as unknown;
501
+ return parsed && typeof parsed === "object" ? (parsed as NotificationControlCommandPayload) : undefined;
502
+ } catch {
503
+ return undefined;
504
+ }
505
+ }
506
+
507
+ function formatCompactTokenCount(value: number | null | undefined): string {
508
+ if (value == null) return "unknown";
509
+ if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1))}m`;
510
+ if (value >= 1_000) return `${Number((value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1))}k`;
511
+ return value.toLocaleString();
512
+ }
513
+
514
+ function formatContextUsageLine(ctx: ExtensionContext): string {
515
+ const usage = ctx.getContextUsage();
516
+ if (!usage) return "Context usage unavailable.";
517
+ const tokens = formatCompactTokenCount(usage.tokens);
518
+ const window = formatCompactTokenCount(usage.contextWindow);
519
+ const pct = usage.percent == null ? "unknown" : `${usage.percent.toFixed(1)}%`;
520
+ return `Context: ${tokens}/${window} ${pct}`;
521
+ }
522
+
523
+ function formatLocalUsage(ctx: ExtensionContext): string {
524
+ const stats = ctx.sessionManager.getUsageStatistics();
525
+ return [
526
+ "Usage",
527
+ `Input tokens: ${stats.input}`,
528
+ `Output tokens: ${stats.output}`,
529
+ `Cache read tokens: ${stats.cacheRead}`,
530
+ `Cache write tokens: ${stats.cacheWrite}`,
531
+ `Premium requests: ${stats.premiumRequests}`,
532
+ `Cost: $${stats.cost.toFixed(6)}`,
533
+ ].join("\n");
534
+ }
535
+
536
+ function cycleTelegramThinking(api: ExtensionAPI): ThinkingLevel | undefined {
537
+ const levels = [
538
+ ThinkingLevel.Off,
539
+ ThinkingLevel.Minimal,
540
+ ThinkingLevel.Low,
541
+ ThinkingLevel.Medium,
542
+ ThinkingLevel.High,
543
+ ThinkingLevel.XHigh,
544
+ ThinkingLevel.Max,
545
+ ];
546
+ const current = api.getThinkingLevel() ?? ThinkingLevel.Off;
547
+ const currentIndex = levels.indexOf(current as (typeof levels)[number]);
548
+ const next = levels[(currentIndex + 1) % levels.length];
549
+ if (!next) return undefined;
550
+ api.setThinkingLevel(next);
551
+ return api.getThinkingLevel() ?? next;
552
+ }
553
+
554
+ export async function executeNotificationControlCommand(
555
+ command: NotificationControlCommandPayload | undefined,
556
+ ctx: ExtensionContext,
557
+ api: ExtensionAPI,
558
+ ): Promise<{ status: "ok" | "error" | "unavailable"; message: string }> {
559
+ if (!command || typeof command.name !== "string") return { status: "error", message: "Invalid control command." };
560
+ switch (command.name) {
561
+ case "reasoning": {
562
+ const current = api.getThinkingLevel() ?? ThinkingLevel.Off;
563
+ if (command.action === "status") return { status: "ok", message: `Reasoning effort: ${current}` };
564
+ if (command.action === "cycle") {
565
+ const next = cycleTelegramThinking(api);
566
+ return next
567
+ ? { status: "ok", message: `Reasoning effort set to ${next}.` }
568
+ : { status: "unavailable", message: "Reasoning effort unavailable for this session." };
569
+ }
570
+ if (command.action === "set" && typeof command.level === "string") {
571
+ const parsed = parseThinkingLevel(command.level);
572
+ if (!parsed) return { status: "error", message: "Invalid reasoning effort." };
573
+ api.setThinkingLevel(parsed);
574
+ return { status: "ok", message: `Reasoning effort set to ${api.getThinkingLevel() ?? ThinkingLevel.Off}.` };
575
+ }
576
+ return { status: "error", message: "Invalid reasoning command." };
577
+ }
578
+ case "usage":
579
+ return { status: "ok", message: formatLocalUsage(ctx) };
580
+ case "context":
581
+ return { status: "ok", message: formatContextUsageLine(ctx) };
582
+ case "compact": {
583
+ const before = ctx.getContextUsage()?.tokens;
584
+ try {
585
+ await ctx.compact(typeof command.instructions === "string" ? command.instructions : undefined);
586
+ } catch (err) {
587
+ return {
588
+ status: "error",
589
+ message: `Compaction failed: ${err instanceof Error ? err.message : String(err)}`,
590
+ };
591
+ }
592
+ const after = ctx.getContextUsage()?.tokens;
593
+ if (before != null && after != null)
594
+ return {
595
+ status: "ok",
596
+ message: `Compaction complete. Tokens: ${before} -> ${after} (saved ${before - after}).`,
597
+ };
598
+ return { status: "ok", message: "Compaction complete." };
599
+ }
600
+ default:
601
+ return { status: "error", message: "Unknown control command." };
602
+ }
603
+ }
604
+
483
605
  /** Register the interactive `ask` answer source for a session (the ask tool
484
606
  * races the local UI against a remote reply). Returns the deregister disposer. */
485
607
  function registerInteractiveAnswerSource(
@@ -696,6 +818,38 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
696
818
  }
697
819
  }
698
820
  }
821
+ if (inbound.kind === "control_command") {
822
+ if (!runtime || !inbound.requestId) return;
823
+ void executeNotificationControlCommand(parseControlCommandPayload(inbound.commandJson), ctx, api)
824
+ .then(result => {
825
+ runtime?.server.pushFrame(
826
+ JSON.stringify({
827
+ type: "control_command_result",
828
+ sessionId: id,
829
+ requestId: inbound.requestId,
830
+ updateId: inbound.updateId,
831
+ status: result.status,
832
+ message: result.message,
833
+ }),
834
+ );
835
+ })
836
+ .catch(err => {
837
+ try {
838
+ runtime?.server.pushFrame(
839
+ JSON.stringify({
840
+ type: "control_command_result",
841
+ sessionId: id,
842
+ requestId: inbound.requestId,
843
+ updateId: inbound.updateId,
844
+ status: "error",
845
+ message: `Control command failed: ${err instanceof Error ? err.message : String(err)}`,
846
+ }),
847
+ );
848
+ } catch (pushErr) {
849
+ logger.warn(`notifications: control_command_result failed: ${String(pushErr)}`);
850
+ }
851
+ });
852
+ }
699
853
  });
700
854
 
701
855
  try {
@@ -997,7 +1151,10 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
997
1151
  api.on("turn_start", (_event, ctx) => {
998
1152
  const id = sessionId(ctx);
999
1153
  const rt = runtimes.get(id);
1000
- if (!rt || rt.pendingInbound.size === 0) return;
1154
+ if (!rt) return;
1155
+ // A new turn is live: re-open the live-stream window (see turnClosed).
1156
+ rt.turnClosed = false;
1157
+ if (rt.pendingInbound.size === 0) return;
1001
1158
  for (const updateId of rt.pendingInbound) {
1002
1159
  try {
1003
1160
  rt.server.pushFrame(JSON.stringify({ type: "inbound_ack", sessionId: id, updateId, state: "consumed" }));
@@ -1093,15 +1250,25 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
1093
1250
  // rate-limit pool before sending to Telegram.
1094
1251
  // Push the in-flight turn's assistant text as a finalized turn_stream, deduped
1095
1252
  // against what was already flushed for this turn (the pre-ask lead-in).
1096
- const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined): void => {
1253
+ const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): void => {
1097
1254
  if (!text || text === rt.preAskFlushedText) return;
1098
1255
  rt.preAskFlushedText = text;
1256
+ // Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
1257
+ // live message, never a fresh (rich-promotable) send. If live frames were
1258
+ // async-queued and none landed before this flush, allocate the per-turn ref
1259
+ // now so the finalized frame always carries a messageRef → the daemon keeps it
1260
+ // editable (HTML edit) and never rich-promotes a streamed final.
1261
+ if (finalAnswer && rt.stream && rt.liveRef === undefined) {
1262
+ rt.turnSeq = (rt.turnSeq ?? 0) + 1;
1263
+ rt.liveRef = String(rt.turnSeq);
1264
+ }
1099
1265
  try {
1100
1266
  rt.server.pushFrame(
1101
1267
  JSON.stringify({
1102
1268
  type: "turn_stream",
1103
1269
  sessionId: id,
1104
1270
  phase: "finalized",
1271
+ finalAnswer,
1105
1272
  text,
1106
1273
  ...(rt.liveRef ? { messageRef: rt.liveRef } : {}),
1107
1274
  }),
@@ -1122,7 +1289,7 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
1122
1289
  const id = sessionId(ctx);
1123
1290
  const rt = runtimes.get(id);
1124
1291
  if (!rt || rt.redact) return;
1125
- flushTurnText(rt, id, rt.currentTurnText);
1292
+ flushTurnText(rt, id, rt.currentTurnText, false);
1126
1293
  });
1127
1294
 
1128
1295
  api.on("turn_end", (event, ctx) => {
@@ -1130,12 +1297,15 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
1130
1297
  const rt = runtimes.get(id);
1131
1298
  if (!rt) return;
1132
1299
  const text = rt.redact ? undefined : summaryFromMessage(event.message, turnTextMax());
1133
- if (text) flushTurnText(rt, id, text);
1300
+ if (text) flushTurnText(rt, id, text, true);
1134
1301
  // Reset per-turn streaming state so the next turn starts fresh and a later
1135
1302
  // turn with identical text is not falsely deduped.
1136
1303
  rt.currentTurnText = undefined;
1137
1304
  rt.preAskFlushedText = undefined;
1138
1305
  rt.liveRef = undefined;
1306
+ // Close the live-stream window: any message_update queued after turn_end is
1307
+ // dropped so it can never emit a stale live edit past the finalized turn.
1308
+ rt.turnClosed = true;
1139
1309
  rt.lastLiveAt = undefined;
1140
1310
  rt.lastLiveText = undefined;
1141
1311
  });
@@ -1147,7 +1317,7 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
1147
1317
  api.on("message_update", (event, ctx) => {
1148
1318
  const id = sessionId(ctx);
1149
1319
  const rt = runtimes.get(id);
1150
- if (!rt?.stream || rt.redact) return;
1320
+ if (!rt?.stream || rt.redact || rt.turnClosed) return;
1151
1321
  if ((event.message as { role?: unknown }).role !== "assistant") return;
1152
1322
  if (rt.liveRef === undefined) {
1153
1323
  rt.turnSeq = (rt.turnSeq ?? 0) + 1;