@sayknow-cli/coding-agent 0.3.9 → 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 (59) 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 +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
@@ -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 {
@@ -407,20 +409,18 @@ function streamIntervalMs(): number {
407
409
  return Math.max(200, Number(process.env.SKC_NOTIFICATIONS_STREAM_INTERVAL_MS) || 500);
408
410
  }
409
411
  // Max chars of a turn's assistant text carried by the FINALIZED turn_stream (and
410
- // the pre-ask capture). Default 3500 keeps the mirror a glanceable per-turn
411
- // summary; a client that splits long messages (the Telegram daemon does so via
412
- // splitTelegramHtml, scheduling each chunk through the shared rate-limit pool so
413
- // the fan-out never bypasses the per-chat limit) can raise it with
414
- // SKC_NOTIFICATIONS_TURN_MAX to deliver full turns. The value is clamped to a
415
- // finite [280, TURN_TEXT_MAX_CEILING] range: a non-finite or non-positive env
416
- // (unset, NaN, Infinity, <= 0) falls back to the default, so the cap can never
417
- // 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
418
418
  // editable preview message rather than fanning a long in-progress turn across
419
419
  // sends.
420
420
  const TURN_TEXT_MAX_CEILING = 40_000;
421
421
  function turnTextMax(): number {
422
422
  const raw = Number(process.env.SKC_NOTIFICATIONS_TURN_MAX);
423
- if (!Number.isFinite(raw) || raw <= 0) return 3500;
423
+ if (!Number.isFinite(raw) || raw <= 0) return TURN_TEXT_MAX_CEILING;
424
424
  return Math.min(TURN_TEXT_MAX_CEILING, Math.max(280, raw));
425
425
  }
426
426
  function resolveSettings(settingsOverride?: Settings): ResolvedSettings {
@@ -487,6 +487,121 @@ function mapAnswerToGate(
487
487
  return { selected: [] };
488
488
  }
489
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
+
490
605
  /** Register the interactive `ask` answer source for a session (the ask tool
491
606
  * races the local UI against a remote reply). Returns the deregister disposer. */
492
607
  function registerInteractiveAnswerSource(
@@ -703,6 +818,38 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
703
818
  }
704
819
  }
705
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
+ }
706
853
  });
707
854
 
708
855
  try {
@@ -49,6 +49,7 @@ export function shouldPromoteRich(input: { enabled?: boolean; send: ThreadedSend
49
49
  send.editable !== true &&
50
50
  typeof send.richMarkdown === "string" &&
51
51
  send.richMarkdown.trim().length > 0 &&
52
+ send.richMarkdown.length <= RICH_MESSAGE_LIMIT &&
52
53
  typeof send.text === "string" &&
53
54
  send.text.length > 0
54
55
  );
@@ -9,7 +9,7 @@ import type { Settings } from "../config/settings";
9
9
  import type { DaemonRuntimeInfo } from "../daemon/control-types";
10
10
  import { resolveSkcRuntimeSpawnInfo } from "../daemon/runtime";
11
11
  import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
12
- import { parseInThreadConfigCommand, parseRichToggleCommand } from "./config-commands";
12
+ import { parseInThreadConfigCommand, parseRichToggleCommand, parseTelegramControlCommand } from "./config-commands";
13
13
  import { daemonPaths } from "./daemon-paths";
14
14
  import {
15
15
  buildCompactChoiceGrid,
@@ -111,6 +111,21 @@ export const CLIENT_PING_PONG_CAPABILITY = "client_ping_pong";
111
111
  export const NOTIFICATION_PROTOCOL_VERSION = 2;
112
112
 
113
113
  const nodeFs: TelegramDaemonFs = fs.promises as unknown as TelegramDaemonFs;
114
+
115
+ /**
116
+ * Durably persist a `/rich` toggle. A real {@link Settings} exposes
117
+ * `flushOrThrow()`, which rejects on a failed config.yml write (its `set()` is a
118
+ * fire-and-forget whose background save swallows errors). The lightweight daemon
119
+ * settings has no `flushOrThrow` — its `set()` already wrote durably and throws
120
+ * on failure — so its plain `flush()` no-op drain is sufficient.
121
+ */
122
+ async function flushRichToggleSettings(settings: Settings): Promise<void> {
123
+ if (typeof settings.flushOrThrow === "function") {
124
+ await settings.flushOrThrow();
125
+ return;
126
+ }
127
+ await settings.flush();
128
+ }
114
129
  const RATE_LIMIT_FLUSH_INTERVAL_MS = 1_000;
115
130
  // How often the daemon rescans for newly-started sessions. This MUST run
116
131
  // independently of the Telegram getUpdates long-poll (up to 25s): otherwise a
@@ -1550,6 +1565,7 @@ export class TelegramNotificationDaemon {
1550
1565
  "image_attachment",
1551
1566
  "file_attachment",
1552
1567
  "config_update",
1568
+ "control_command_result",
1553
1569
  ]);
1554
1570
 
1555
1571
  private topicNameFor(sessionId: string, msg: { title?: unknown; repo?: unknown; branch?: unknown }): string {
@@ -1913,7 +1929,7 @@ export class TelegramNotificationDaemon {
1913
1929
  // upstream edit/send path, so off behavior is byte-identical.
1914
1930
  if (
1915
1931
  shouldPromoteRich({
1916
- enabled: this.opts.rich?.enabled === false ? false : true,
1932
+ enabled: this.opts.rich?.enabled !== false,
1917
1933
  send,
1918
1934
  })
1919
1935
  ) {
@@ -2219,16 +2235,22 @@ export class TelegramNotificationDaemon {
2219
2235
  // Rename the topic if the title changed (e.g. the session title was
2220
2236
  // auto-generated after the topic was first created). This runs on
2221
2237
  // every identity frame, but does NOT re-send the bulleted message.
2238
+ // Only commit the new registry name after Telegram accepts the edit:
2239
+ // a transient editForumTopic failure must remain retryable on the
2240
+ // next identity re-assert instead of leaving the remote topic stuck
2241
+ // at the provisional "SKC <id>" name forever.
2222
2242
  const name = this.topicNameFor(session.sessionId, msg);
2223
- if (this.topics.applyName(session.sessionId, name)) {
2243
+ if (this.topics.needsRename(session.sessionId, name)) {
2224
2244
  try {
2225
2245
  await this.botApi.call("editForumTopic", {
2226
2246
  chat_id: this.opts.chatId,
2227
2247
  message_thread_id: Number(topicId),
2228
2248
  name,
2229
2249
  });
2250
+ this.topics.markNameApplied(session.sessionId, name);
2230
2251
  } catch {
2231
- // Best-effort rename; never block delivery.
2252
+ // Best-effort rename; never block delivery. Leave the old
2253
+ // registry name intact so a later identity frame retries.
2232
2254
  }
2233
2255
  }
2234
2256
  // Send the full bulleted identity header EXACTLY ONCE per topic.
@@ -2415,6 +2437,15 @@ export class TelegramNotificationDaemon {
2415
2437
  }
2416
2438
  try {
2417
2439
  await this.opts.settings.set("notifications.telegram.rich.enabled", desired);
2440
+ // Confirm success only after a DURABLE write. The real Settings.set is
2441
+ // a synchronous fire-and-forget whose queued save (Settings.#saveNow)
2442
+ // swallows write errors, and Settings.flush() inherits that — neither
2443
+ // rejects on a failed config.yml write. flushOrThrow() rethrows the
2444
+ // durable-write failure so it lands in the catch below (in-memory
2445
+ // isolated Settings short-circuit and never throw). The lightweight
2446
+ // daemon settings has no flushOrThrow: its set() already wrote durably
2447
+ // (and throws on failure), so its flush() is only a no-op drain.
2448
+ await flushRichToggleSettings(this.opts.settings);
2418
2449
  } catch (err) {
2419
2450
  logger.warn(
2420
2451
  `notifications: /rich settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
@@ -2468,6 +2499,45 @@ export class TelegramNotificationDaemon {
2468
2499
  const injectedText = repliedOriginal
2469
2500
  ? `> replied-to message:\n${repliedOriginal}\n\n${baseInjectedText}`
2470
2501
  : baseInjectedText;
2502
+ const control = hasMedia
2503
+ ? { kind: "none" as const }
2504
+ : parseTelegramControlCommand(inbound.text, this.botUsername);
2505
+ if (control.kind !== "none") {
2506
+ await this.rememberSeenUpdateId(inbound.updateId);
2507
+ const sendControlNotice = async (body: string): Promise<void> => {
2508
+ try {
2509
+ await this.botApi.call("sendMessage", {
2510
+ chat_id: this.opts.chatId,
2511
+ message_thread_id: Number(inbound.threadId),
2512
+ text: body,
2513
+ parse_mode: TELEGRAM_PARSE_MODE,
2514
+ });
2515
+ } catch {
2516
+ // Best-effort control feedback; never convert to user input.
2517
+ }
2518
+ };
2519
+ if (control.kind === "ignored") return;
2520
+ if (control.kind === "invalid") {
2521
+ await sendControlNotice(control.usage);
2522
+ return;
2523
+ }
2524
+ if (session?.ws.readyState !== WebSocket.OPEN) {
2525
+ await sendControlNotice("Session control unavailable: session is disconnected.");
2526
+ return;
2527
+ }
2528
+ session.ws.send(
2529
+ JSON.stringify({
2530
+ type: "control_command",
2531
+ sessionId: inbound.sessionId,
2532
+ token: session.token,
2533
+ requestId: `tg:${inbound.updateId}`,
2534
+ updateId: inbound.updateId,
2535
+ threadId: inbound.threadId,
2536
+ command: control.command,
2537
+ }),
2538
+ );
2539
+ return;
2540
+ }
2471
2541
  const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
2472
2542
  // A plain (non-config) message while an ask is pending for this session
2473
2543
  // answers that ask as free-input — instead of starting a new user turn.
@@ -2484,6 +2554,15 @@ export class TelegramNotificationDaemon {
2484
2554
  }),
2485
2555
  );
2486
2556
  await this.rememberSeenUpdateId(inbound.updateId);
2557
+ await this.botApi
2558
+ .call("sendMessage", {
2559
+ chat_id: this.opts.chatId,
2560
+ message_thread_id: Number(inbound.threadId),
2561
+ text: "Received as an answer to the pending ask.",
2562
+ })
2563
+ .catch(error => {
2564
+ logger.warn(`telegram: failed to acknowledge pending ask reply: ${String(error)}`);
2565
+ });
2487
2566
  if (inbound.messageId !== undefined) await this.setReaction(inbound.messageId, QUEUED_REACTION);
2488
2567
  return;
2489
2568
  }
@@ -2548,6 +2627,10 @@ export class TelegramNotificationDaemon {
2548
2627
  { command: "lean", description: "Mirror assistant text + tool names only (default)" },
2549
2628
  { command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
2550
2629
  { command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
2630
+ { command: "reasoning", description: "Show or change reasoning effort in this session" },
2631
+ { command: "usage", description: "Show provider/local usage for this session" },
2632
+ { command: "context", description: "Show current context usage for this session" },
2633
+ { command: "compact", description: "Compact this session: /compact [instructions]" },
2551
2634
  { command: "session_create", description: "Create a SKC session: path, worktree, or dir [--mpreset]" },
2552
2635
  { command: "session_recent", description: "List recent SKC sessions" },
2553
2636
  { command: "session_close", description: "Close a SKC-managed session" },
@@ -77,6 +77,9 @@ interface ThreadedFrame {
77
77
  // config_update
78
78
  verbosity?: unknown;
79
79
  redact?: unknown;
80
+ // control_command_result
81
+ status?: unknown;
82
+ message?: unknown;
80
83
  }
81
84
 
82
85
  function str(v: unknown): string | undefined {
@@ -218,6 +221,17 @@ export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefi
218
221
  }
219
222
  : undefined;
220
223
  }
224
+ case "control_command_result": {
225
+ const message = str(frame.message);
226
+ if (!message) return undefined;
227
+ const status = str(frame.status);
228
+ const prefix = status === "ok" ? "✅" : status === "unavailable" ? "⚠️" : "❌";
229
+ return {
230
+ method: "sendMessage",
231
+ lane: "idle",
232
+ text: finalizeTelegramHtml(`${prefix} ${escapeHtml(truncate(message, 1200))}`),
233
+ };
234
+ }
221
235
  default:
222
236
  return undefined;
223
237
  }
@@ -120,15 +120,16 @@ export class TopicRegistry {
120
120
  return record ? !record.identitySent : true;
121
121
  }
122
122
 
123
- /**
124
- * Record the topic's applied title. Returns `true` when it changed (so the
125
- * caller should `editForumTopic`), `false` when already current or unknown.
126
- */
127
- applyName(sessionId: string, name: string): boolean {
123
+ /** Whether a known session topic's applied title differs from `name`. */
124
+ needsRename(sessionId: string, name: string): boolean {
128
125
  const record = this.topics.get(sessionId);
129
- if (!record || record.name === name) return false;
130
- record.name = name;
131
- return true;
126
+ return record !== undefined && record.name !== name;
127
+ }
128
+
129
+ /** Commit a successfully-applied Telegram topic title. */
130
+ markNameApplied(sessionId: string, name: string): void {
131
+ const record = this.topics.get(sessionId);
132
+ if (record) record.name = name;
132
133
  }
133
134
 
134
135
  /** Remove a session topic record after Telegram deletes the topic. */
@@ -0,0 +1,13 @@
1
+ Discover project and user runtime skills without loading full skill content.
2
+
3
+ <instruction>
4
+ - Searches only custom runtime skill locations: project `.skc/skills` and user `~/.skc/skills`.
5
+ - Built-in, bundled, and internal workflow skills are intentionally excluded.
6
+ - Returns thin metadata only: name, description, source scope, path, and use conditions when present.
7
+ - To load a selected skill's full `SKILL.md`, invoke it through the existing `skill` tool with the exact `name` returned here.
8
+ </instruction>
9
+
10
+ Input:
11
+ - `query` (optional): words to match against skill name, description, source, or use conditions.
12
+ - `source` (optional): `all`, `project`, or `user`.
13
+ - `limit` (optional): maximum results, 1-50.
@@ -0,0 +1,35 @@
1
+ import type { AuthCredentialSelector, AuthCredentialSelectorKind } from "@sayknow-cli/ai";
2
+
3
+ export interface CliCredentialSelector {
4
+ provider?: string;
5
+ selector: AuthCredentialSelector;
6
+ raw: string;
7
+ }
8
+
9
+ const SELECTOR_KINDS = new Set<AuthCredentialSelectorKind>(["id", "email", "account", "project"]);
10
+
11
+ function parseSelectorBody(body: string): AuthCredentialSelector | undefined {
12
+ const separator = body.indexOf(":");
13
+ if (separator === -1) {
14
+ if (body.includes("@")) return { kind: "email", value: body };
15
+ return undefined;
16
+ }
17
+ const rawKind = body.slice(0, separator);
18
+ const value = body.slice(separator + 1).trim();
19
+ if (!SELECTOR_KINDS.has(rawKind as AuthCredentialSelectorKind) || value.length === 0) return undefined;
20
+ return { kind: rawKind as AuthCredentialSelectorKind, value };
21
+ }
22
+
23
+ export function parseCliCredentialSelector(raw: string): CliCredentialSelector {
24
+ const trimmed = raw.trim();
25
+ const slash = trimmed.indexOf("/");
26
+ const provider = slash === -1 ? undefined : trimmed.slice(0, slash).trim();
27
+ const body = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim();
28
+ const selector = parseSelectorBody(body);
29
+ if (!selector || provider === "") {
30
+ throw new Error(
31
+ `Invalid --credential selector "${raw}". Use email:name@example.com, id:123, account:<id>, project:<id>, or provider/email:name@example.com.`,
32
+ );
33
+ }
34
+ return { ...(provider ? { provider } : {}), selector, raw };
35
+ }
package/src/sdk.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  } from "@sayknow-cli/agent-core";
11
11
  import {
12
12
  type Api,
13
+ type AuthCredentialSelector,
13
14
  type CredentialDisabledEvent,
14
15
  type Message,
15
16
  type Model,
@@ -40,7 +41,7 @@ import {
40
41
  import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled, jobElapsedMs } from "./async";
41
42
  import { loadCapability } from "./capability";
42
43
  import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
43
- import { ModelRegistry } from "./config/model-registry";
44
+ import { kNoAuth, ModelRegistry } from "./config/model-registry";
44
45
  import {
45
46
  defaultModelPerProvider,
46
47
  formatModelString,
@@ -274,6 +275,8 @@ export interface CreateAgentSessionOptions {
274
275
  /** Optional provider-facing session identifier for prompt caches and sticky auth selection.
275
276
  * Keeps persisted session files isolated while reusing provider-side caches. */
276
277
  providerSessionId?: string;
278
+ /** Runtime credential selector for multi-account auth pools. */
279
+ credentialSelector?: { provider?: string; selector: AuthCredentialSelector; raw: string };
277
280
 
278
281
  /** Custom tools to register (in addition to built-in tools). Accepts both CustomTool and ToolDefinition. */
279
282
  customTools?: (CustomTool | ToolDefinition)[];
@@ -905,10 +908,22 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
905
908
  startupCredentialDisabledEvents.push(event);
906
909
  }
907
910
  });
911
+ let runtimeCredentialSelectorInstalled = false;
912
+ const installRuntimeCredentialSelector = (provider: string): void => {
913
+ if (!options.credentialSelector || runtimeCredentialSelectorInstalled) return;
914
+ authStorage.setRuntimeCredentialSelector(provider, options.credentialSelector.selector);
915
+ runtimeCredentialSelectorInstalled = true;
916
+ };
917
+ const earlyCredentialSelectorProvider = options.credentialSelector?.provider ?? options.model?.provider;
918
+ if (earlyCredentialSelectorProvider) {
919
+ installRuntimeCredentialSelector(earlyCredentialSelectorProvider);
920
+ }
908
921
  const settings = options.settings ?? (await logger.time("settings", Settings.init, { cwd, agentDir }));
909
922
  modelRegistry.applyConfiguredModelBindings(settings);
910
923
  logger.time("initializeWithSettings", initializeWithSettings, settings);
911
- if (!options.modelRegistry) {
924
+ const canRefreshModelsBeforeCredentialSelector =
925
+ !options.credentialSelector || runtimeCredentialSelectorInstalled || options.modelRegistry !== undefined;
926
+ if (!options.modelRegistry && canRefreshModelsBeforeCredentialSelector) {
912
927
  modelRegistry.refreshInBackground();
913
928
  }
914
929
  // Kick off workspace tree discovery early. The native workspace scan returns
@@ -974,7 +989,29 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
974
989
  return cached;
975
990
  }
976
991
 
977
- const hasKey = !!(await modelRegistry.getApiKey(candidate, providerSessionId));
992
+ const credentialSelector =
993
+ options.credentialSelector && !runtimeCredentialSelectorInstalled
994
+ ? options.credentialSelector.provider === undefined ||
995
+ options.credentialSelector.provider === candidate.provider
996
+ ? options.credentialSelector.selector
997
+ : undefined
998
+ : undefined;
999
+ if (options.credentialSelector?.provider && options.credentialSelector.provider !== candidate.provider) {
1000
+ modelApiKeyAvailability.set(availabilityKey, false);
1001
+ return false;
1002
+ }
1003
+ const key = await modelRegistry.getApiKey(candidate, providerSessionId, { credentialSelector }).catch(error => {
1004
+ if (credentialSelector) {
1005
+ logger.debug("Credential selector did not match model availability candidate", {
1006
+ provider: candidate.provider,
1007
+ model: candidate.id,
1008
+ error: error instanceof Error ? error.message : String(error),
1009
+ });
1010
+ return undefined;
1011
+ }
1012
+ throw error;
1013
+ });
1014
+ const hasKey = Boolean(key) && (!credentialSelector || key !== kNoAuth);
978
1015
  modelApiKeyAvailability.set(availabilityKey, hasKey);
979
1016
  return hasKey;
980
1017
  };
@@ -1049,6 +1086,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1049
1086
  const taskDepth = options.taskDepth ?? 0;
1050
1087
 
1051
1088
  let thinkingLevel = options.thinkingLevel;
1089
+ const hasExplicitDefaultThinkingLevel = settings.has("defaultThinkingLevel");
1090
+ let thinkingLevelFromSchemaDefault = false;
1052
1091
 
1053
1092
  // If session has data and includes a thinking entry, restore it
1054
1093
  if (thinkingLevel === undefined && hasExistingSession && hasThinkingEntry) {
@@ -1059,13 +1098,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1059
1098
  thinkingLevel = defaultRoleSpec.thinkingLevel;
1060
1099
  }
1061
1100
 
1062
- // Prefer the selected model's configured defaultLevel, otherwise fall back
1063
- // to the global settings default.
1101
+ // An explicit user/project default should win over the model's bundled
1102
+ // defaultLevel. The schema default is only a final fallback so model metadata
1103
+ // can keep driving first-run behavior until the user chooses "Set as default".
1104
+ if (thinkingLevel === undefined && hasExplicitDefaultThinkingLevel) {
1105
+ thinkingLevel = settings.get("defaultThinkingLevel");
1106
+ }
1107
+
1064
1108
  if (thinkingLevel === undefined && model?.thinking?.defaultLevel !== undefined) {
1065
1109
  thinkingLevel = model.thinking.defaultLevel;
1066
1110
  }
1111
+
1067
1112
  if (thinkingLevel === undefined) {
1068
1113
  thinkingLevel = settings.get("defaultThinkingLevel");
1114
+ thinkingLevelFromSchemaDefault = true;
1069
1115
  }
1070
1116
  if (model) {
1071
1117
  const resolvedModel = model;
@@ -1610,6 +1656,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1610
1656
  if (resolved) {
1611
1657
  model = resolved;
1612
1658
  modelFallbackMessage = undefined;
1659
+ if (thinkingLevelFromSchemaDefault && resolved.thinking?.defaultLevel !== undefined) {
1660
+ thinkingLevel = resolved.thinking.defaultLevel;
1661
+ thinkingLevelFromSchemaDefault = false;
1662
+ }
1663
+ thinkingLevel = resolveThinkingLevelForModel(resolved, thinkingLevel);
1613
1664
  } else {
1614
1665
  modelFallbackMessage = `Model "${options.modelPattern}" not found`;
1615
1666
  }
@@ -1662,6 +1713,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1662
1713
  }
1663
1714
  }
1664
1715
 
1716
+ if (options.credentialSelector && !runtimeCredentialSelectorInstalled) {
1717
+ const credentialProvider = options.credentialSelector.provider ?? model?.provider;
1718
+ if (!credentialProvider) {
1719
+ throw new Error(
1720
+ `--credential ${options.credentialSelector.raw} requires a resolved model or an explicit provider prefix`,
1721
+ );
1722
+ }
1723
+ installRuntimeCredentialSelector(credentialProvider);
1724
+ if (!options.modelRegistry && !canRefreshModelsBeforeCredentialSelector) {
1725
+ modelRegistry.refreshInBackground();
1726
+ }
1727
+ }
1665
1728
  const customCommandsResult: CustomCommandsLoadResult = { commands: [], errors: [] };
1666
1729
 
1667
1730
  let extensionRunner: ExtensionRunner | undefined;