@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
@@ -1,4 +1,4 @@
1
- import type { ThinkingLevel } from "@sayknow-cli/agent-core";
1
+ import { ThinkingLevel } from "@sayknow-cli/agent-core";
2
2
  import type { Api, Model } from "@sayknow-cli/ai";
3
3
  import type { AgentSession } from "../session/agent-session";
4
4
  import { formatClampedModelSelector } from "../thinking";
@@ -43,7 +43,10 @@ export interface PrepareModelProfileActivationOptions {
43
43
  settings: Pick<Settings, "get">;
44
44
  profileName: string;
45
45
  }
46
-
46
+ export interface ApplyModelProfileActivationOptions {
47
+ persistDefault?: boolean;
48
+ thinkingLevelOverride?: ThinkingLevel;
49
+ }
47
50
  export interface PreparedModelProfileActivation {
48
51
  profileName: string;
49
52
  session: ModelProfileActivationSession & { setModelTemporary: AgentSession["setModelTemporary"] };
@@ -351,25 +354,31 @@ export async function prepareModelProfileActivation(
351
354
 
352
355
  export async function applyPreparedModelProfileActivation(
353
356
  prepared: PreparedModelProfileActivation,
354
- options: { persistDefault?: boolean } = {},
357
+ options: ApplyModelProfileActivationOptions = {},
355
358
  ): Promise<void> {
356
359
  const previousModel = prepared.previousModel;
357
360
  const previousThinkingLevel = prepared.previousThinkingLevel;
358
361
  const previousAgentModelOverrides = prepared.previousAgentModelOverrides;
359
362
  const previousModelRoles = prepared.previousModelRoles;
360
363
  const previousPersistedDefault = prepared.settings.get("modelProfile.default");
364
+ const previousDefaultThinkingLevel = prepared.settings.get("defaultThinkingLevel");
361
365
  const previousActiveModelProfile = prepared.previousActiveModelProfile;
362
366
  const previousSessionDefaultModel = prepared.previousSessionDefaultModel;
363
367
  let modelChanged = false;
364
368
  let overridesChanged = false;
365
369
  let defaultChanged = false;
366
370
  let modelRolesChanged = false;
371
+ let defaultThinkingChanged = false;
367
372
 
368
373
  try {
369
374
  if (prepared.defaultModel) {
370
- await prepared.session.setModelTemporary(prepared.defaultModel, prepared.defaultThinkingLevel, {
371
- persistAsSessionDefault: true,
372
- });
375
+ await prepared.session.setModelTemporary(
376
+ prepared.defaultModel,
377
+ options.thinkingLevelOverride ?? prepared.defaultThinkingLevel,
378
+ {
379
+ persistAsSessionDefault: true,
380
+ },
381
+ );
373
382
  modelChanged = true;
374
383
  }
375
384
  if (Object.keys(prepared.modelRoles).length > 0) {
@@ -386,6 +395,10 @@ export async function applyPreparedModelProfileActivation(
386
395
  if (options.persistDefault) {
387
396
  prepared.settings.set("modelRoles", {});
388
397
  prepared.settings.set("task.agentModelOverrides", {});
398
+ if (prepared.defaultThinkingLevel !== undefined && prepared.defaultThinkingLevel !== ThinkingLevel.Inherit) {
399
+ prepared.settings.set("defaultThinkingLevel", prepared.defaultThinkingLevel);
400
+ defaultThinkingChanged = true;
401
+ }
389
402
  prepared.settings.set("modelProfile.default", prepared.profileName);
390
403
  defaultChanged = true;
391
404
  await prepared.settings.flush();
@@ -396,6 +409,9 @@ export async function applyPreparedModelProfileActivation(
396
409
  prepared.settings.set("modelProfile.default", previousPersistedDefault);
397
410
  prepared.settings.set("modelRoles", previousModelRoles);
398
411
  prepared.settings.set("task.agentModelOverrides", previousAgentModelOverrides);
412
+ if (defaultThinkingChanged) {
413
+ prepared.settings.set("defaultThinkingLevel", previousDefaultThinkingLevel);
414
+ }
399
415
  }
400
416
  if (modelRolesChanged) {
401
417
  prepared.settings.override("modelRoles", previousModelRoles);
@@ -430,7 +446,7 @@ export async function applyPreparedModelProfileActivation(
430
446
 
431
447
  export async function activateModelProfile(
432
448
  options: PrepareModelProfileActivationOptions,
433
- applyOptions: { persistDefault?: boolean } = {},
449
+ applyOptions: ApplyModelProfileActivationOptions = {},
434
450
  ): Promise<void> {
435
451
  const prepared = await prepareModelProfileActivation(options);
436
452
  await applyPreparedModelProfileActivation(prepared, applyOptions);
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import {
4
4
  type Api,
5
5
  type AssistantMessageEventStream,
6
+ type AuthCredentialSelector,
6
7
  type CacheRetention,
7
8
  type Context,
8
9
  createModelManager,
@@ -83,7 +84,8 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
83
84
  };
84
85
 
85
86
  export const MODEL_ROLE_IDS: ModelRole[] = ["default"];
86
-
87
+ export const MODEL_PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
88
+ export const MODEL_PROFILE_NAME_PATTERN_DESCRIPTION = "lowercase letters, numbers, dots, underscores, or hyphens";
87
89
  export type SkcModelAssignmentTargetId = "default" | "executor" | "architect" | "planner" | "critic";
88
90
 
89
91
  export interface SkcModelAssignmentTargetInfo extends ModelRoleInfo {
@@ -922,10 +924,13 @@ function resolveCustomModelReference(modelId: string): Model<Api> | undefined {
922
924
  }
923
925
 
924
926
  function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomModelOverlay {
925
- if (model.id !== "gpt-5.4" || model.provider === "github-copilot" || model.contextWindow !== undefined) {
927
+ if (model.contextWindow !== undefined) {
926
928
  return model;
927
929
  }
928
- return { ...model, contextWindow: 1_000_000 };
930
+ if (model.id === "gpt-5.4" && model.provider !== "github-copilot") {
931
+ return { ...model, contextWindow: 1_000_000 };
932
+ }
933
+ return model;
929
934
  }
930
935
 
931
936
  function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuildOptions): Model<Api> {
@@ -1590,10 +1595,7 @@ export class ModelRegistry {
1590
1595
  const where = first?.path.length ? `/${first.path.map(String).join("/")}` : "root";
1591
1596
  throw new Error(`Generated models config is invalid at ${where}: ${first?.message ?? "unknown schema error"}`);
1592
1597
  }
1593
- await fs.mkdir(path.dirname(this.#modelsConfigFile.path()), { recursive: true });
1594
- await Bun.write(this.#modelsConfigFile.path(), Bun.YAML.stringify(checkedConfig.data, null, 2));
1595
- this.#modelsConfigFile.invalidate();
1596
- this.#reloadStaticModels();
1598
+ await this.#writeCheckedModelsConfig(checkedConfig.data);
1597
1599
  const modelMapping = { ...definition.model_mapping };
1598
1600
  const profile: ModelProfileDefinition = {
1599
1601
  name: normalizedName,
@@ -1604,6 +1606,84 @@ export class ModelRegistry {
1604
1606
  };
1605
1607
  return profile;
1606
1608
  }
1609
+
1610
+ async renameCustomModelProfile(name: string, displayName: string): Promise<ModelProfileDefinition> {
1611
+ const normalizedName = name.trim();
1612
+ const nextDisplayName = displayName.trim();
1613
+ if (!normalizedName) throw new Error("Profile name is required.");
1614
+ if (!nextDisplayName) throw new Error("Profile display name is required.");
1615
+ const { current, profile } = this.#loadCustomProfileForMutation(normalizedName, "rename");
1616
+ const nextProfiles = {
1617
+ ...(current.profiles ?? {}),
1618
+ [normalizedName]: {
1619
+ ...profile,
1620
+ display_name: nextDisplayName,
1621
+ },
1622
+ };
1623
+ const checkedConfig = ModelsConfigSchema.safeParse({ ...current, profiles: nextProfiles });
1624
+ if (!checkedConfig.success) {
1625
+ const first = checkedConfig.error.issues[0];
1626
+ const where = first?.path.length ? `/${first.path.map(String).join("/")}` : "root";
1627
+ throw new Error(`Generated models config is invalid at ${where}: ${first?.message ?? "unknown schema error"}`);
1628
+ }
1629
+ await this.#writeCheckedModelsConfig(checkedConfig.data);
1630
+ const modelMapping = { ...profile.model_mapping };
1631
+ return {
1632
+ name: normalizedName,
1633
+ displayName: nextDisplayName,
1634
+ requiredProviders: aggregateModelProfileRequiredProviders(profile.required_providers, { modelMapping }),
1635
+ modelMapping,
1636
+ source: "user",
1637
+ };
1638
+ }
1639
+
1640
+ async deleteCustomModelProfile(name: string): Promise<ModelProfileConfig> {
1641
+ const normalizedName = name.trim();
1642
+ if (!normalizedName) throw new Error("Profile name is required.");
1643
+ const { current, profile } = this.#loadCustomProfileForMutation(normalizedName, "delete");
1644
+ const nextProfiles = { ...(current.profiles ?? {}) };
1645
+ delete nextProfiles[normalizedName];
1646
+ const checkedConfig = ModelsConfigSchema.safeParse({
1647
+ ...current,
1648
+ profiles: Object.keys(nextProfiles).length > 0 ? nextProfiles : undefined,
1649
+ });
1650
+ if (!checkedConfig.success) {
1651
+ const first = checkedConfig.error.issues[0];
1652
+ const where = first?.path.length ? `/${first.path.map(String).join("/")}` : "root";
1653
+ throw new Error(`Generated models config is invalid at ${where}: ${first?.message ?? "unknown schema error"}`);
1654
+ }
1655
+ await this.#writeCheckedModelsConfig(checkedConfig.data);
1656
+ return profile;
1657
+ }
1658
+
1659
+ #loadCustomProfileForMutation(
1660
+ normalizedName: string,
1661
+ action: "rename" | "delete",
1662
+ ): { current: ModelsConfig; profile: ModelProfileConfig } {
1663
+ const loaded = this.#modelsConfigFile.tryLoad();
1664
+ if (loaded.status === "error") {
1665
+ throw new Error(
1666
+ `Cannot ${action} custom model profile because ${this.#modelsConfigFile.path()} is invalid. Fix the existing config before modifying presets.`,
1667
+ );
1668
+ }
1669
+ const current = loaded.value ?? this.#modelsConfigFile.createDefault();
1670
+ const profile = current.profiles?.[normalizedName];
1671
+ if (!profile) {
1672
+ const existing = this.#modelProfiles.get(normalizedName);
1673
+ if (existing && existing.source !== "user") {
1674
+ throw new Error(`Cannot ${action} bundled model profile: ${normalizedName}.`);
1675
+ }
1676
+ throw new Error(`Custom model profile does not exist: ${normalizedName}.`);
1677
+ }
1678
+ return { current, profile };
1679
+ }
1680
+
1681
+ async #writeCheckedModelsConfig(config: ModelsConfig): Promise<void> {
1682
+ await fs.mkdir(path.dirname(this.#modelsConfigFile.path()), { recursive: true });
1683
+ await Bun.write(this.#modelsConfigFile.path(), Bun.YAML.stringify(config, null, 2));
1684
+ this.#modelsConfigFile.invalidate();
1685
+ this.#reloadStaticModels();
1686
+ }
1607
1687
  applyConfiguredModelBindings(targetSettings: Settings): void {
1608
1688
  this.#modelBindingsTargetSettings = targetSettings;
1609
1689
  this.#applyConfiguredModelBindingsToTarget();
@@ -2579,21 +2659,37 @@ export class ModelRegistry {
2579
2659
  /**
2580
2660
  * Get API key for a model.
2581
2661
  */
2582
- async getApiKey(model: Model<Api>, sessionId?: string): Promise<string | undefined> {
2662
+ async getApiKey(
2663
+ model: Model<Api>,
2664
+ sessionId?: string,
2665
+ options: { credentialSelector?: AuthCredentialSelector } = {},
2666
+ ): Promise<string | undefined> {
2583
2667
  if (this.#keylessProviders.has(model.provider) && !this.authStorage.hasAuth(model.provider)) {
2584
2668
  return kNoAuth;
2585
2669
  }
2586
- return this.authStorage.getApiKey(model.provider, sessionId, { baseUrl: model.baseUrl, modelId: model.id });
2670
+ return this.authStorage.getApiKey(model.provider, sessionId, {
2671
+ baseUrl: model.baseUrl,
2672
+ modelId: model.id,
2673
+ credentialSelector: options.credentialSelector,
2674
+ });
2587
2675
  }
2588
2676
 
2589
2677
  /**
2590
2678
  * Get API key for a provider (e.g., "openai").
2591
2679
  */
2592
- async getApiKeyForProvider(provider: string, sessionId?: string, baseUrl?: string): Promise<string | undefined> {
2680
+ async getApiKeyForProvider(
2681
+ provider: string,
2682
+ sessionId?: string,
2683
+ baseUrl?: string,
2684
+ options: { credentialSelector?: AuthCredentialSelector } = {},
2685
+ ): Promise<string | undefined> {
2593
2686
  if (this.#keylessProviders.has(provider) && !this.authStorage.hasAuth(provider)) {
2594
2687
  return kNoAuth;
2595
2688
  }
2596
- return this.authStorage.getApiKey(provider, sessionId, { baseUrl });
2689
+ return this.authStorage.getApiKey(provider, sessionId, {
2690
+ baseUrl,
2691
+ credentialSelector: options.credentialSelector,
2692
+ });
2597
2693
  }
2598
2694
 
2599
2695
  async #peekApiKeyForProvider(provider: string): Promise<string | undefined> {
@@ -3,15 +3,7 @@
3
3
  */
4
4
 
5
5
  import { ThinkingLevel } from "@sayknow-cli/agent-core";
6
- import {
7
- type Api,
8
- clampThinkingLevelForModel,
9
- DEFAULT_MODEL_PER_PROVIDER,
10
- type Effort,
11
- type KnownProvider,
12
- type Model,
13
- modelsAreEqual,
14
- } from "@sayknow-cli/ai";
6
+ import { type Api, DEFAULT_MODEL_PER_PROVIDER, type KnownProvider, type Model, modelsAreEqual } from "@sayknow-cli/ai";
15
7
  import { fuzzyMatch } from "@sayknow-cli/tui";
16
8
  import { logger } from "@sayknow-cli/utils";
17
9
  import chalk from "chalk";
@@ -1197,7 +1189,7 @@ export async function findInitialModel(options: {
1197
1189
  isContinuing: boolean;
1198
1190
  defaultProvider?: string;
1199
1191
  defaultModelId?: string;
1200
- defaultThinkingSelector?: Effort;
1192
+ defaultThinkingSelector?: ThinkingLevel;
1201
1193
  modelRegistry: InitialModelRegistry;
1202
1194
  }): Promise<InitialModelResult> {
1203
1195
  const {
@@ -1212,7 +1204,7 @@ export async function findInitialModel(options: {
1212
1204
  } = options;
1213
1205
 
1214
1206
  let model: Model<Api> | undefined;
1215
- let thinkingLevel: Effort | undefined;
1207
+ let thinkingLevel: ThinkingLevel | undefined;
1216
1208
 
1217
1209
  // 1. CLI args take priority
1218
1210
  if (cliProvider && cliModel) {
@@ -1233,10 +1225,7 @@ export async function findInitialModel(options: {
1233
1225
  : (scoped.thinkingLevel ?? defaultThinkingSelector);
1234
1226
  return {
1235
1227
  model: scoped.model,
1236
- thinkingLevel:
1237
- scopedThinkingSelector === ThinkingLevel.Off
1238
- ? ThinkingLevel.Off
1239
- : clampThinkingLevelForModel(scoped.model, scopedThinkingSelector),
1228
+ thinkingLevel: resolveThinkingLevelForModel(scoped.model, scopedThinkingSelector),
1240
1229
  fallbackMessage: undefined,
1241
1230
  };
1242
1231
  }
@@ -1246,7 +1235,7 @@ export async function findInitialModel(options: {
1246
1235
  const found = modelRegistry.find(defaultProvider, defaultModelId);
1247
1236
  if (found) {
1248
1237
  model = found;
1249
- thinkingLevel = clampThinkingLevelForModel(found, defaultThinkingSelector);
1238
+ thinkingLevel = resolveThinkingLevelForModel(found, defaultThinkingSelector);
1250
1239
  return { model, thinkingLevel, fallbackMessage: undefined };
1251
1240
  }
1252
1241
  }
@@ -5,6 +5,7 @@ import { EDIT_MODES } from "../utils/edit-mode";
5
5
  import { CONFIGURABLE_SEARCH_PROVIDER_IDS } from "../web/search/types";
6
6
 
7
7
  const THINKING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as readonly Effort[];
8
+ const DEFAULT_THINKING_LEVELS = ["off", ...THINKING_EFFORTS] as const;
8
9
 
9
10
  import {
10
11
  DEFAULT_DISABLED_EXTENSIONS,
@@ -262,6 +263,8 @@ export const SETTINGS_SCHEMA = {
262
263
  "notifications.enabled": { type: "boolean", default: false },
263
264
  "notifications.telegram.botToken": { type: "string", default: undefined },
264
265
  "notifications.telegram.chatId": { type: "string", default: undefined },
266
+ "notifications.telegram.rich.enabled": { type: "boolean", default: true },
267
+ "notifications.telegram.richDraft.enabled": { type: "boolean", default: false },
265
268
  "notifications.discord.botToken": { type: "string", default: undefined },
266
269
  "notifications.discord.channelId": { type: "string", default: undefined },
267
270
  "notifications.slack.botToken": { type: "string", default: undefined },
@@ -827,13 +830,13 @@ export const SETTINGS_SCHEMA = {
827
830
  // Reasoning and prompts
828
831
  defaultThinkingLevel: {
829
832
  type: "enum",
830
- values: THINKING_EFFORTS,
833
+ values: DEFAULT_THINKING_LEVELS,
831
834
  default: THINKING_EFFORTS[3],
832
835
  ui: {
833
836
  tab: "model",
834
837
  label: "Thinking Level",
835
838
  description: "Reasoning depth for thinking-capable models",
836
- options: [...THINKING_EFFORTS.map(getThinkingLevelMetadata)],
839
+ options: [...DEFAULT_THINKING_LEVELS.map(getThinkingLevelMetadata)],
837
840
  },
838
841
  },
839
842
 
@@ -3610,6 +3613,12 @@ export interface NotificationsSettings {
3610
3613
  telegram: {
3611
3614
  botToken: string | undefined;
3612
3615
  chatId: string | undefined;
3616
+ rich: {
3617
+ enabled: boolean;
3618
+ };
3619
+ richDraft: {
3620
+ enabled: boolean;
3621
+ };
3613
3622
  };
3614
3623
  discord: {
3615
3624
  botToken: string | undefined;
@@ -375,6 +375,25 @@ export class Settings {
375
375
  }
376
376
  }
377
377
 
378
+ /**
379
+ * Like {@link flush}, but rejects if the durable save fails instead of
380
+ * swallowing the error. Use where the caller must confirm persistence before
381
+ * reporting success (e.g. the Telegram `/rich` toggle). In-memory instances
382
+ * ({@link isolated}) short-circuit in {@link #saveNow} and never throw.
383
+ */
384
+ async flushOrThrow(): Promise<void> {
385
+ if (this.#saveTimer) {
386
+ clearTimeout(this.#saveTimer);
387
+ this.#saveTimer = undefined;
388
+ }
389
+ if (this.#savePromise) {
390
+ await this.#savePromise;
391
+ }
392
+ if (this.#modified.size > 0) {
393
+ await this.#saveNow({ throwOnError: true });
394
+ }
395
+ }
396
+
378
397
  async cloneForCwd(cwd: string): Promise<Settings> {
379
398
  const cloned = new Settings({
380
399
  cwd,
@@ -818,7 +837,7 @@ export class Settings {
818
837
  }, 100);
819
838
  }
820
839
 
821
- async #saveNow(): Promise<void> {
840
+ async #saveNow(options: { throwOnError?: boolean } = {}): Promise<void> {
822
841
  if (!this.#persist || !this.#configPath || this.#modified.size === 0) return;
823
842
 
824
843
  const configPath = this.#configPath;
@@ -847,6 +866,10 @@ export class Settings {
847
866
  for (const p of modifiedPaths) {
848
867
  this.#modified.add(p);
849
868
  }
869
+ if (options.throwOnError) {
870
+ this.#rebuildMerged();
871
+ throw error;
872
+ }
850
873
  }
851
874
 
852
875
  this.#rebuildMerged();
@@ -0,0 +1,229 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { findRepoRoot } from "../capability/fs";
5
+ import type { Skill as CapabilitySkill } from "../capability/skill";
6
+ import type { SkillsSettings } from "../config/settings-schema";
7
+ import { compareSkillOrder, scanSkillsFromDir } from "../discovery/helpers";
8
+ import type { Skill } from "./skills";
9
+
10
+ export type RuntimeSkillDiscoverySource = "project" | "user";
11
+
12
+ export interface RuntimeSkillDiscoveryCandidate {
13
+ name: string;
14
+ description: string;
15
+ source: RuntimeSkillDiscoverySource;
16
+ path: string;
17
+ useWhen?: string[];
18
+ }
19
+
20
+ export interface DiscoverRuntimeSkillsOptions {
21
+ cwd: string;
22
+ home?: string;
23
+ query?: string;
24
+ limit?: number;
25
+ source?: RuntimeSkillDiscoverySource | "all";
26
+ policy?: SkillsSettings;
27
+ }
28
+
29
+ function getRuntimeHome(): string {
30
+ return process.env.HOME || os.homedir();
31
+ }
32
+
33
+ const DEFAULT_LIMIT = 20;
34
+ const MAX_LIMIT = 50;
35
+
36
+ function normalizeLimit(limit: number | undefined): number {
37
+ if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_LIMIT;
38
+ return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(limit)));
39
+ }
40
+
41
+ async function getProjectSkillDirs(cwd: string, home: string): Promise<{ dirs: string[]; repoRoot: string | null }> {
42
+ const dirs: string[] = [];
43
+ let current = path.resolve(cwd);
44
+ const resolvedHome = path.resolve(home);
45
+ const repoRoot = await findRepoRoot(current);
46
+ const stop = path.resolve(repoRoot ?? current);
47
+ while (true) {
48
+ if (current !== resolvedHome) {
49
+ dirs.push(path.join(current, ".skc", "skills"));
50
+ }
51
+ if (current === stop) break;
52
+ const parent = path.dirname(current);
53
+ if (parent === current) break;
54
+ current = parent;
55
+ }
56
+ return { dirs, repoRoot };
57
+ }
58
+
59
+ function getUseWhen(skill: CapabilitySkill): string[] | undefined {
60
+ const frontmatter = skill.frontmatter as Record<string, unknown> | undefined;
61
+ const values: string[] = [];
62
+ const globs = frontmatter?.globs;
63
+ if (Array.isArray(globs)) {
64
+ values.push(...globs.filter((value): value is string => typeof value === "string"));
65
+ } else if (typeof globs === "string") {
66
+ values.push(globs);
67
+ }
68
+ for (const key of ["use_when", "useWhen", "conditions"]) {
69
+ const raw = frontmatter?.[key];
70
+ if (typeof raw === "string") values.push(raw);
71
+ if (Array.isArray(raw)) values.push(...raw.filter((value): value is string => typeof value === "string"));
72
+ }
73
+ return values.length > 0 ? values : undefined;
74
+ }
75
+
76
+ function toRuntimeSkill(skill: CapabilitySkill, source: RuntimeSkillDiscoverySource): Skill {
77
+ return {
78
+ name: skill.name,
79
+ description: typeof skill.frontmatter?.description === "string" ? skill.frontmatter.description : "",
80
+ filePath: skill.path,
81
+ baseDir: skill.path.replace(/[\\/]SKILL\.md$/, ""),
82
+ source: `runtime:${source}`,
83
+ hide: skill.frontmatter?.hide === true,
84
+ _source: { ...skill._source, providerName: "Runtime skill discovery" },
85
+ };
86
+ }
87
+ function sourceEnabled(source: RuntimeSkillDiscoverySource, policy: SkillsSettings | undefined): boolean {
88
+ if (policy?.enabled !== true) return false;
89
+ if (source === "project") return policy.enablePiProject === true;
90
+ if (source === "user") return policy.enablePiUser === true;
91
+ return false;
92
+ }
93
+
94
+ function matchesIncludePatterns(name: string, includeSkills: string[] | undefined): boolean {
95
+ if (!includeSkills || includeSkills.length === 0) return true;
96
+ return includeSkills.some(pattern => new Bun.Glob(pattern).match(name));
97
+ }
98
+
99
+ function matchesIgnorePatterns(name: string, ignoredSkills: string[] | undefined): boolean {
100
+ if (!ignoredSkills || ignoredSkills.length === 0) return false;
101
+ return ignoredSkills.some(pattern => new Bun.Glob(pattern).match(name));
102
+ }
103
+
104
+ function isDisabledSkill(name: string, disabledExtensions: string[] | undefined): boolean {
105
+ return (disabledExtensions ?? []).some(id => id === `skill:${name}`);
106
+ }
107
+
108
+ function isAllowedByPolicy(
109
+ skill: CapabilitySkill,
110
+ source: RuntimeSkillDiscoverySource,
111
+ policy: SkillsSettings | undefined,
112
+ ): boolean {
113
+ if (!sourceEnabled(source, policy)) return false;
114
+ if (isDisabledSkill(skill.name, policy?.disabledExtensions)) return false;
115
+ if (matchesIgnorePatterns(skill.name, policy?.ignoredSkills)) return false;
116
+ if (!matchesIncludePatterns(skill.name, policy?.includeSkills)) return false;
117
+ return true;
118
+ }
119
+ function matchesQuery(candidate: RuntimeSkillDiscoveryCandidate, query: string): boolean {
120
+ const normalized = query.trim().toLowerCase();
121
+ if (!normalized) return true;
122
+ const haystack = [candidate.name, candidate.description, candidate.source, ...(candidate.useWhen ?? [])]
123
+ .join("\n")
124
+ .toLowerCase();
125
+ return normalized
126
+ .split(/\s+/)
127
+ .filter(Boolean)
128
+ .every(term => haystack.includes(term));
129
+ }
130
+
131
+ async function realPathOrSelf(filePath: string): Promise<string> {
132
+ try {
133
+ return await fs.realpath(filePath);
134
+ } catch {
135
+ return filePath;
136
+ }
137
+ }
138
+
139
+ export async function discoverRuntimeSkills(
140
+ options: DiscoverRuntimeSkillsOptions,
141
+ ): Promise<RuntimeSkillDiscoveryCandidate[]> {
142
+ const home = options.home ?? getRuntimeHome();
143
+ const source = options.source ?? "all";
144
+ const policy = options.policy;
145
+ const scanJobs: Array<Promise<{ skill: CapabilitySkill; source: RuntimeSkillDiscoverySource }[]>> = [];
146
+ const projectSkills = await getProjectSkillDirs(options.cwd, home);
147
+ const projectContext = { cwd: options.cwd, home, repoRoot: projectSkills.repoRoot };
148
+ if ((source === "all" || source === "project") && sourceEnabled("project", policy)) {
149
+ for (const dir of projectSkills.dirs) {
150
+ scanJobs.push(
151
+ scanSkillsFromDir(projectContext, {
152
+ dir,
153
+ providerId: "runtime",
154
+ level: "project",
155
+ requireDescription: true,
156
+ }).then(result => result.items.map(skill => ({ skill, source: "project" as const }))),
157
+ );
158
+ }
159
+ }
160
+ if ((source === "all" || source === "user") && sourceEnabled("user", policy)) {
161
+ scanJobs.push(
162
+ scanSkillsFromDir(
163
+ { cwd: options.cwd, home, repoRoot: home },
164
+ { dir: path.join(home, ".skc", "skills"), providerId: "runtime", level: "user", requireDescription: true },
165
+ ).then(result => result.items.map(skill => ({ skill, source: "user" as const }))),
166
+ );
167
+ }
168
+
169
+ const seenNames = new Set<string>();
170
+ const seenPaths = new Set<string>();
171
+ const candidates: RuntimeSkillDiscoveryCandidate[] = [];
172
+ for (const entry of (await Promise.all(scanJobs)).flat()) {
173
+ if (!isAllowedByPolicy(entry.skill, entry.source, policy)) continue;
174
+ const realPath = await realPathOrSelf(entry.skill.path);
175
+ if (seenPaths.has(realPath) || seenNames.has(entry.skill.name)) continue;
176
+ seenPaths.add(realPath);
177
+ seenNames.add(entry.skill.name);
178
+ const candidate: RuntimeSkillDiscoveryCandidate = {
179
+ name: entry.skill.name,
180
+ description:
181
+ typeof entry.skill.frontmatter?.description === "string" ? entry.skill.frontmatter.description : "",
182
+ source: entry.source,
183
+ path: entry.skill.path,
184
+ useWhen: getUseWhen(entry.skill),
185
+ };
186
+ if (matchesQuery(candidate, options.query ?? "")) candidates.push(candidate);
187
+ }
188
+ candidates.sort((a, b) => compareSkillOrder(a.name, a.path, b.name, b.path));
189
+ return candidates.slice(0, normalizeLimit(options.limit));
190
+ }
191
+
192
+ export async function findRuntimeSkillByName(
193
+ cwd: string,
194
+ name: string,
195
+ policy?: SkillsSettings,
196
+ home = getRuntimeHome(),
197
+ ): Promise<Skill | undefined> {
198
+ const normalized = name.trim();
199
+ if (!normalized) return undefined;
200
+ const scanJobs: Array<Promise<{ skill: CapabilitySkill; source: RuntimeSkillDiscoverySource }[]>> = [];
201
+ const projectSkills = await getProjectSkillDirs(cwd, home);
202
+ const projectContext = { cwd, home, repoRoot: projectSkills.repoRoot };
203
+ if (sourceEnabled("project", policy)) {
204
+ scanJobs.push(
205
+ ...projectSkills.dirs.map(dir =>
206
+ scanSkillsFromDir(projectContext, {
207
+ dir,
208
+ providerId: "runtime",
209
+ level: "project",
210
+ requireDescription: true,
211
+ }).then(result => result.items.map(skill => ({ skill, source: "project" as const }))),
212
+ ),
213
+ );
214
+ }
215
+ if (sourceEnabled("user", policy)) {
216
+ scanJobs.push(
217
+ scanSkillsFromDir(
218
+ { cwd, home, repoRoot: home },
219
+ { dir: path.join(home, ".skc", "skills"), providerId: "runtime", level: "user", requireDescription: true },
220
+ ).then(result => result.items.map(skill => ({ skill, source: "user" as const }))),
221
+ );
222
+ }
223
+ for (const entry of (await Promise.all(scanJobs)).flat()) {
224
+ if (entry.skill.name === normalized && isAllowedByPolicy(entry.skill, entry.source, policy)) {
225
+ return toRuntimeSkill(entry.skill, entry.source);
226
+ }
227
+ }
228
+ return undefined;
229
+ }