@selesai/code 0.8.9 → 0.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.8.11] - 2026-08-19
6
+
7
+ ### Added
8
+ - **Image captioning relay for text-only models.** When the active model cannot accept images
9
+ (e.g. DeepSeek `deepseek-v4-*` / `auto`) and a vision model is configured
10
+ (`images.imageCaptionModel`), images pasted into chat or read via the `read` tool are described
11
+ by the vision model (e.g. Gemma/Kimi) and the caption text is used by the main model in place of
12
+ the raw image. Configure the vision model and its context budget from `/settings` ("Vision caption
13
+ model" / "Vision context tokens").
14
+ - **Gemma 4 31B (Vision) as a bundled default** on the Token-In provider (`tokenin/gemma-4`,
15
+ `input: ["text", "image"]`) — usable both as a captioner and as a main model.
16
+ - **Context-aware captioning.** The caption request now includes the user's current prompt and a
17
+ bounded slice of the most recent user/assistant conversation (whole messages, tool output
18
+ excluded), capped by `images.imageCaptionContextTokens` (default 16384). Full history is never
19
+ sent, so the caption model's smaller context window is never overloaded.
20
+ - **Captioning status indicator.** While an image is being described, the TUI shows a "Reading image
21
+ with vision model..." spinner (with a per-image 15s timeout and a brief notice on total failure),
22
+ so captioning no longer looks like a frozen screen.
23
+ - **Skill enablement overrides** are now honored for built-in and additional skill resources
24
+ (package-manager/resource-loader), and `getResolvedSkills()` is exposed on the resource loader.
25
+
26
+ ### Changed
27
+ - **Model-prompt-injector** rule for the Token-In provider now matches `deepseek-v4-*` (was
28
+ `deepseek-v4-pro`).
29
+
30
+ ### Added (context)
31
+ - `images.imageCaptionContextTokens` sets the max recent-conversation token budget sent to the
32
+ caption model (`0` disables context).
33
+
34
+ ## [0.8.10] - 2026-08-18
35
+
36
+ ### Changed
37
+ - **Higher default agent retry budget.** `src/defaults/settings.json` raises `retry.maxRetries` from 7 to 10, so transient provider failures get more automatic retry attempts before surfacing to you.
38
+ - **Powerline footer shows cache-read tokens and hit rate by default.** The bundled settings now set `powerline.cache_read.format` to `"both"` (tokens + cache-hit percentage), instead of relying on the extension's `"tokens"` fallback.
39
+
5
40
  ## [0.8.9] - 2026-08-18
6
41
 
7
42
  ### Added
@@ -106,6 +106,11 @@ export type AgentSessionEvent = Exclude<AgentEvent, {
106
106
  type: "bash_execution_update";
107
107
  id?: string;
108
108
  delta: string;
109
+ } | {
110
+ type: "image_captioning_start";
111
+ } | {
112
+ type: "image_captioning_end";
113
+ ok: boolean;
109
114
  };
110
115
  /** Listener function for agent session events */
111
116
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
@@ -414,6 +419,25 @@ export declare class AgentSession {
414
419
  triggerTurn?: boolean;
415
420
  deliverAs?: "steer" | "followUp" | "nextTurn";
416
421
  }): Promise<void>;
422
+ private _captionImagesForCurrentModel;
423
+ /**
424
+ * Build a small, text-only slice of the most recent conversation to give the
425
+ * caption model some intent context without risking its context window.
426
+ * Only user and assistant text is included (tool output/code is skipped), and
427
+ * the result is capped to the given token budget.
428
+ */
429
+ /**
430
+ * Build a slice of the most recent conversation to give the caption model some
431
+ * intent context without risking its context window. Only user and assistant
432
+ * text is included (tool output is skipped).
433
+ *
434
+ * Walks messages backward from the end toward the first message. Messages are
435
+ * kept whole (never truncated mid-message): we stop adding once the next full
436
+ * message would push us past `maxTokens` (approx chars).
437
+ */
438
+ private _recentConversationTail;
439
+ /** Extract plain user/assistant text from a message; returns "" for others/empty. */
440
+ private _messageText;
417
441
  /**
418
442
  * Send a user message to the agent. Always triggers a turn.
419
443
  * When the agent is streaming, use deliverAs to specify how to queue the message.
@@ -38,6 +38,7 @@ import { createLocalBashOperations } from "./tools/bash.js";
38
38
  import { createAllToolDefinitions, stripToolParameterDescriptions } from "./tools/index.js";
39
39
  import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js";
40
40
  import { addUsageToTotals, createUsageTotals } from "./usage-totals.js";
41
+ import { captionImageWithModel } from "./vision-caption.js";
41
42
  /** Parse consecutive skill blocks at the start of a user message. */
42
43
  export function parseSkillBlocks(text) {
43
44
  const skills = [];
@@ -882,6 +883,15 @@ export class AgentSession {
882
883
  expandedText = this._expandSkillCommand(expandedText);
883
884
  expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
884
885
  }
886
+ // Vision relay: when the active model cannot accept images, describe
887
+ // attached images with the configured caption model (images.imageCaptionModel)
888
+ // so the main model can work from the description text.
889
+ if (currentImages && currentImages.length > 0) {
890
+ const caption = await this._captionImagesForCurrentModel(currentImages, currentText);
891
+ if (caption) {
892
+ expandedText += `\n\n${caption}`;
893
+ }
894
+ }
885
895
  // If streaming, queue via steer() or followUp() based on option
886
896
  if (this.isStreaming) {
887
897
  if (!options?.streamingBehavior) {
@@ -1151,6 +1161,145 @@ export class AgentSession {
1151
1161
  this._emit({ type: "message_end", message: appMessage });
1152
1162
  }
1153
1163
  }
1164
+ /**
1165
+ * Vision relay for attached images: when the active model cannot accept image
1166
+ * input and a caption model is configured (images.imageCaptionModel), describe
1167
+ * each image with the caption model and return the descriptions as text.
1168
+ * Returns null when no captioning applies (vision-capable main model, no
1169
+ * caption model configured, or every caption attempt failed). The caption
1170
+ * model is always called with a minimal fresh context, so main-agent context
1171
+ * usage does not matter here.
1172
+ */
1173
+ async _captionImagesForCurrentModel(images, userPrompt) {
1174
+ const mainModel = this.model;
1175
+ if (!mainModel || mainModel.input.includes("image"))
1176
+ return null;
1177
+ const captionModelId = this.settingsManager.getImageCaptionModel();
1178
+ if (!captionModelId)
1179
+ return null;
1180
+ const slash = captionModelId.indexOf("/");
1181
+ if (slash <= 0 || slash === captionModelId.length - 1)
1182
+ return null;
1183
+ const captionModel = this._modelRuntime.getModel(captionModelId.slice(0, slash), captionModelId.slice(slash + 1));
1184
+ if (!captionModel || !captionModel.input.includes("image"))
1185
+ return null;
1186
+ let auth;
1187
+ try {
1188
+ auth = await this._modelRuntime.getAuth(captionModel);
1189
+ }
1190
+ catch {
1191
+ return null;
1192
+ }
1193
+ if (!auth)
1194
+ return null;
1195
+ const maxContextTokens = this.settingsManager.getImageCaptionContextTokens();
1196
+ const contextText = maxContextTokens > 0 ? this._recentConversationTail(maxContextTokens) : "";
1197
+ // Show a status spinner while the caption request runs so the UI never
1198
+ // looks frozen on a blank screen.
1199
+ this._emit({ type: "image_captioning_start" });
1200
+ try {
1201
+ const descriptions = [];
1202
+ let failureReason;
1203
+ for (const image of images) {
1204
+ const controller = new AbortController();
1205
+ let aborted = false;
1206
+ const timeout = setTimeout(() => {
1207
+ aborted = true;
1208
+ controller.abort();
1209
+ }, 15_000);
1210
+ try {
1211
+ const caption = await captionImageWithModel(captionModel, image, {
1212
+ apiKey: auth.auth.apiKey,
1213
+ headers: auth.auth.headers,
1214
+ signal: controller.signal,
1215
+ userPrompt,
1216
+ contextText,
1217
+ onError: (message) => {
1218
+ failureReason = message;
1219
+ console.error("[image-caption] request failed:", message);
1220
+ },
1221
+ });
1222
+ if (caption)
1223
+ descriptions.push(caption);
1224
+ }
1225
+ catch {
1226
+ // Captioning is best-effort; fall back to leaving the image attached.
1227
+ }
1228
+ finally {
1229
+ clearTimeout(timeout);
1230
+ }
1231
+ if (aborted) {
1232
+ failureReason = failureReason ?? "timed out after 15s";
1233
+ }
1234
+ }
1235
+ if (descriptions.length === 0) {
1236
+ if (failureReason)
1237
+ console.error("[image-caption] captioning failed:", failureReason);
1238
+ this._emit({ type: "image_captioning_end", ok: false });
1239
+ return null;
1240
+ }
1241
+ this._emit({ type: "image_captioning_end", ok: true });
1242
+ if (descriptions.length === 1) {
1243
+ return `[Image description provided by the ${captionModel.name} vision model:]\n${descriptions[0]}`;
1244
+ }
1245
+ return descriptions
1246
+ .map((d, i) => `[Image ${i + 1} of ${descriptions.length} description:]\n${d}`)
1247
+ .join("\n\n");
1248
+ }
1249
+ catch {
1250
+ this._emit({ type: "image_captioning_end", ok: false });
1251
+ return null;
1252
+ }
1253
+ }
1254
+ /**
1255
+ * Build a small, text-only slice of the most recent conversation to give the
1256
+ * caption model some intent context without risking its context window.
1257
+ * Only user and assistant text is included (tool output/code is skipped), and
1258
+ * the result is capped to the given token budget.
1259
+ */
1260
+ /**
1261
+ * Build a slice of the most recent conversation to give the caption model some
1262
+ * intent context without risking its context window. Only user and assistant
1263
+ * text is included (tool output is skipped).
1264
+ *
1265
+ * Walks messages backward from the end toward the first message. Messages are
1266
+ * kept whole (never truncated mid-message): we stop adding once the next full
1267
+ * message would push us past `maxTokens` (approx chars).
1268
+ */
1269
+ _recentConversationTail(maxTokens) {
1270
+ const messages = this.agent.state.messages;
1271
+ const maxChars = maxTokens * 4;
1272
+ const collected = [];
1273
+ let length = 0;
1274
+ for (let i = messages.length - 1; i >= 0; i--) {
1275
+ const msg = messages[i];
1276
+ const text = this._messageText(msg);
1277
+ if (!text)
1278
+ continue;
1279
+ const entry = `${msg.role === "user" ? "User" : "Assistant"}: ${text}`;
1280
+ // Cut at message boundaries only: once the next full message would
1281
+ // exceed the budget, stop (unless nothing collected yet, so the most
1282
+ // recent exchange is never dropped).
1283
+ if (length > 0 && length + entry.length > maxChars)
1284
+ break;
1285
+ collected.push(entry);
1286
+ length += entry.length;
1287
+ }
1288
+ return collected.reverse().join("\n\n");
1289
+ }
1290
+ /** Extract plain user/assistant text from a message; returns "" for others/empty. */
1291
+ _messageText(msg) {
1292
+ if (msg.role !== "user" && msg.role !== "assistant")
1293
+ return "";
1294
+ if (typeof msg.content === "string")
1295
+ return msg.content;
1296
+ if (!Array.isArray(msg.content))
1297
+ return "";
1298
+ return msg.content
1299
+ .filter((c) => c.type === "text")
1300
+ .map((c) => c.text)
1301
+ .join("\n");
1302
+ }
1154
1303
  /**
1155
1304
  * Send a user message to the agent. Always triggers a turn.
1156
1305
  * When the agent is streaming, use deliverAs to specify how to queue the message.
@@ -2092,6 +2241,7 @@ export class AgentSession {
2092
2241
  }
2093
2242
  _buildRuntime(options) {
2094
2243
  const autoResizeImages = this.settingsManager.getImageAutoResize();
2244
+ const imageCaptionModel = this.settingsManager.getImageCaptionModel();
2095
2245
  const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();
2096
2246
  const shellPath = this.settingsManager.getShellPath();
2097
2247
  const baseToolDefinitions = this._baseToolsOverride
@@ -2100,7 +2250,7 @@ export class AgentSession {
2100
2250
  createToolDefinitionFromAgentTool(tool),
2101
2251
  ]))
2102
2252
  : createAllToolDefinitions(this._cwd, {
2103
- read: { autoResizeImages },
2253
+ read: { autoResizeImages, imageCaptionModel },
2104
2254
  bash: { commandPrefix: shellCommandPrefix, shellPath },
2105
2255
  });
2106
2256
  this._baseToolDefinitions = new Map(Object.entries(baseToolDefinitions).map(([name, tool]) => [name, tool]));
@@ -83,6 +83,7 @@ export declare function getExtensionTempFolder(agentDir: string): string;
83
83
  * under the given root (e.g. a settings-listed absolute path elsewhere).
84
84
  */
85
85
  export declare function extensionEntryName(extPath: string, extensionsRoot: string): string | undefined;
86
+ export declare function isEnabledByOverrides(filePath: string, patterns: string[], baseDir: string, defaultEnabled?: boolean): boolean;
86
87
  export declare class DefaultPackageManager implements PackageManager {
87
88
  private cwd;
88
89
  private agentDir;
@@ -529,12 +529,12 @@ function matchesAnyExactPattern(filePath, patterns, baseDir) {
529
529
  function getOverridePatterns(entries) {
530
530
  return entries.filter((pattern) => pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-"));
531
531
  }
532
- function isEnabledByOverrides(filePath, patterns, baseDir) {
532
+ export function isEnabledByOverrides(filePath, patterns, baseDir, defaultEnabled = true) {
533
533
  const overrides = getOverridePatterns(patterns);
534
534
  const excludes = overrides.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1));
535
535
  const forceIncludes = overrides.filter((pattern) => pattern.startsWith("+")).map((pattern) => pattern.slice(1));
536
536
  const forceExcludes = overrides.filter((pattern) => pattern.startsWith("-")).map((pattern) => pattern.slice(1));
537
- let enabled = true;
537
+ let enabled = defaultEnabled;
538
538
  if (excludes.length > 0 && matchesAnyPattern(filePath, excludes, baseDir)) {
539
539
  enabled = false;
540
540
  }
@@ -1962,10 +1962,10 @@ export class DefaultPackageManager {
1962
1962
  const projectAgentsSkillDirs = projectTrusted
1963
1963
  ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
1964
1964
  : [];
1965
- const addResources = (resourceType, paths, metadata, overrides, baseDir) => {
1965
+ const addResources = (resourceType, paths, metadata, overrides, baseDir, defaultEnabled = true) => {
1966
1966
  const target = this.getTargetMap(accumulator, resourceType);
1967
1967
  for (const path of paths) {
1968
- const enabled = isEnabledByOverrides(path, overrides, baseDir);
1968
+ const enabled = isEnabledByOverrides(path, overrides, baseDir, defaultEnabled);
1969
1969
  this.addResource(target, path, metadata, enabled);
1970
1970
  }
1971
1971
  };
@@ -1973,7 +1973,7 @@ export class DefaultPackageManager {
1973
1973
  // Project extensions from .pi/
1974
1974
  addResources("extensions", collectAutoExtensionEntries(projectDirs.extensions), projectMetadata, projectOverrides.extensions, projectBaseDir);
1975
1975
  // Project skills from .pi/
1976
- addResources("skills", collectAutoSkillEntries(projectDirs.skills, "pi"), projectMetadata, projectOverrides.skills, projectBaseDir);
1976
+ addResources("skills", collectAutoSkillEntries(projectDirs.skills, "pi"), projectMetadata, projectOverrides.skills, projectBaseDir, false);
1977
1977
  }
1978
1978
  // Project skills from .agents/ (each with its own baseDir)
1979
1979
  for (const agentsSkillsDir of projectAgentsSkillDirs) {
@@ -1982,7 +1982,7 @@ export class DefaultPackageManager {
1982
1982
  ...projectMetadata,
1983
1983
  baseDir: agentsBaseDir,
1984
1984
  };
1985
- addResources("skills", collectAutoSkillEntries(agentsSkillsDir, "agents"), agentsMetadata, projectOverrides.skills, agentsBaseDir);
1985
+ addResources("skills", collectAutoSkillEntries(agentsSkillsDir, "agents"), agentsMetadata, projectOverrides.skills, agentsBaseDir, false);
1986
1986
  }
1987
1987
  if (projectTrusted) {
1988
1988
  addResources("prompts", collectAutoPromptEntries(projectDirs.prompts), projectMetadata, projectOverrides.prompts, projectBaseDir);
@@ -2057,14 +2057,14 @@ export class DefaultPackageManager {
2057
2057
  }
2058
2058
  addResources("extensions", piHostKept, { ...userMetadata, baseDir: piHostBaseDir }, userOverrides.extensions, piHostBaseDir);
2059
2059
  // User skills from ~/.selesai/agent/
2060
- addResources("skills", collectAutoSkillEntries(userDirs.skills, "pi"), userMetadata, userOverrides.skills, globalBaseDir);
2060
+ addResources("skills", collectAutoSkillEntries(userDirs.skills, "pi"), userMetadata, userOverrides.skills, globalBaseDir, false);
2061
2061
  // User skills from ~/.agents/ (with its own baseDir)
2062
2062
  const userAgentsBaseDir = dirname(userAgentsSkillsDir);
2063
2063
  const userAgentsMetadata = {
2064
2064
  ...userMetadata,
2065
2065
  baseDir: userAgentsBaseDir,
2066
2066
  };
2067
- addResources("skills", collectAutoSkillEntries(userAgentsSkillsDir, "agents"), userAgentsMetadata, userOverrides.skills, userAgentsBaseDir);
2067
+ addResources("skills", collectAutoSkillEntries(userAgentsSkillsDir, "agents"), userAgentsMetadata, userOverrides.skills, userAgentsBaseDir, false);
2068
2068
  addResources("prompts", collectAutoPromptEntries(userDirs.prompts), userMetadata, userOverrides.prompts, globalBaseDir);
2069
2069
  addResources("themes", collectAutoThemeEntries(userDirs.themes), userMetadata, userOverrides.themes, globalBaseDir);
2070
2070
  return collisions;
@@ -1,8 +1,9 @@
1
- import { mkdtempSync, rmSync } from "node:fs";
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
- import { extensionEntryName } from "./package-manager.js";
5
+ import { DefaultPackageManager, extensionEntryName } from "./package-manager.js";
6
+ import { SettingsManager } from "./settings-manager.js";
6
7
  describe("extensionEntryName", () => {
7
8
  let root;
8
9
  beforeEach(() => {
@@ -17,3 +18,41 @@ describe("extensionEntryName", () => {
17
18
  expect(extensionEntryName(join(root, "question", "index.ts"), root)).toBe("question");
18
19
  });
19
20
  });
21
+ describe("auto-discovered skills are opt-in by default", () => {
22
+ let root;
23
+ const SKILL_BODY = "---\nname: {name}\ndescription: A test skill.\n---\n\n# {name}\n";
24
+ beforeEach(() => {
25
+ root = mkdtempSync(join(tmpdir(), "selesai-skill-root-"));
26
+ });
27
+ afterEach(() => {
28
+ rmSync(root, { recursive: true, force: true });
29
+ });
30
+ function makeSkill(agentDir, name) {
31
+ const dir = join(agentDir, "skills", name);
32
+ mkdirSync(dir, { recursive: true });
33
+ writeFileSync(join(dir, "SKILL.md"), SKILL_BODY.replaceAll("{name}", name));
34
+ }
35
+ it("defaults auto-discovered user skills to disabled (opt-in)", async () => {
36
+ makeSkill(root, "alpha");
37
+ makeSkill(root, "beta");
38
+ const settingsManager = SettingsManager.inMemory({});
39
+ const pm = new DefaultPackageManager({ cwd: root, agentDir: root, settingsManager });
40
+ const resolved = await pm.resolve();
41
+ const localSkills = resolved.skills.filter((s) => s.path.startsWith(root));
42
+ expect(localSkills.length).toBe(2);
43
+ expect(localSkills.every((s) => s.enabled === false)).toBe(true);
44
+ });
45
+ it("enables a user skill when it is force-included via a + pattern", async () => {
46
+ makeSkill(root, "alpha");
47
+ makeSkill(root, "beta");
48
+ const settingsManager = SettingsManager.inMemory({
49
+ skills: ["+skills/alpha/SKILL.md"],
50
+ });
51
+ const pm = new DefaultPackageManager({ cwd: root, agentDir: root, settingsManager });
52
+ const resolved = await pm.resolve();
53
+ const alpha = resolved.skills.find((s) => s.path.startsWith(root) && s.path.endsWith("alpha/SKILL.md"));
54
+ const beta = resolved.skills.find((s) => s.path.startsWith(root) && s.path.endsWith("beta/SKILL.md"));
55
+ expect(alpha?.enabled).toBe(true);
56
+ expect(beta?.enabled).toBe(false);
57
+ });
58
+ });
@@ -3,7 +3,7 @@ import type { ResourceDiagnostic } from "./diagnostics.ts";
3
3
  export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";
4
4
  import { type EventBus } from "./event-bus.ts";
5
5
  import type { InlineExtension, LoadExtensionsResult } from "./extensions/types.ts";
6
- import { type PathMetadata } from "./package-manager.ts";
6
+ import { type PathMetadata, type ResolvedResource } from "./package-manager.ts";
7
7
  import type { PromptTemplate } from "./prompt-templates.ts";
8
8
  import { SettingsManager } from "./settings-manager.ts";
9
9
  import type { AgentPersona } from "./agents.ts";
@@ -34,6 +34,7 @@ export interface ResourceLoader {
34
34
  skills: Skill[];
35
35
  diagnostics: ResourceDiagnostic[];
36
36
  };
37
+ getResolvedSkills(): ResolvedResource[];
37
38
  getPrompts(): {
38
39
  prompts: PromptTemplate[];
39
40
  diagnostics: ResourceDiagnostic[];
@@ -164,6 +165,7 @@ export declare class DefaultResourceLoader implements ResourceLoader {
164
165
  private extensionDiagnostics;
165
166
  private skills;
166
167
  private skillDiagnostics;
168
+ private resolvedSkills;
167
169
  private agents;
168
170
  private agentDiagnostics;
169
171
  private prompts;
@@ -190,6 +192,7 @@ export declare class DefaultResourceLoader implements ResourceLoader {
190
192
  skills: Skill[];
191
193
  diagnostics: ResourceDiagnostic[];
192
194
  };
195
+ getResolvedSkills(): ResolvedResource[];
193
196
  getPrompts(): {
194
197
  prompts: PromptTemplate[];
195
198
  diagnostics: ResourceDiagnostic[];
@@ -223,6 +226,12 @@ export declare class DefaultResourceLoader implements ResourceLoader {
223
226
  private resolveExtensionLoadPath;
224
227
  private loadFinalExtensionSet;
225
228
  private addExtensionConflictDiagnostics;
229
+ /**
230
+ * Enumerate every skill subdirectory in the bundled/CLI additional skill dirs,
231
+ * each gated by the user's skill overrides and defaulting to disabled (opt-in).
232
+ * Returns resolved resources (SKILL.md paths + enabled state).
233
+ */
234
+ private additionalResolvedSkills;
226
235
  private mapSkillPath;
227
236
  private normalizeExtensionPaths;
228
237
  private updateSkillsFromPaths;
@@ -4,6 +4,7 @@ import chalk from "chalk";
4
4
  import { CONFIG_DIR_NAME } from "../config.js";
5
5
  import { loadThemeFromPath } from "../modes/interactive/theme/theme.js";
6
6
  import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.js";
7
+ import { isEnabledByOverrides } from "./package-manager.js";
7
8
  import { createEventBus } from "./event-bus.js";
8
9
  import { clearExtensionCache, createExtensionRuntime, loadExtensionFromFactory, loadExtensionsCached, } from "./extensions/loader.js";
9
10
  import { findGitPaths } from "./footer-data-provider.js";
@@ -139,6 +140,7 @@ export class DefaultResourceLoader {
139
140
  extensionDiagnostics;
140
141
  skills;
141
142
  skillDiagnostics;
143
+ resolvedSkills = [];
142
144
  agents;
143
145
  agentDiagnostics;
144
146
  prompts;
@@ -221,6 +223,9 @@ export class DefaultResourceLoader {
221
223
  getSkills() {
222
224
  return { skills: this.skills, diagnostics: this.skillDiagnostics };
223
225
  }
226
+ getResolvedSkills() {
227
+ return this.resolvedSkills;
228
+ }
224
229
  getPrompts() {
225
230
  return { prompts: this.prompts, diagnostics: this.promptDiagnostics };
226
231
  }
@@ -351,11 +356,13 @@ export class DefaultResourceLoader {
351
356
  }
352
357
  this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
353
358
  this.applyExtensionSourceInfo(this.extensionsResult.extensions, metadataByPath);
359
+ const additionalSkills = this.additionalResolvedSkills();
354
360
  const skillPaths = this.noSkills
355
361
  ? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths)
356
- : this.mergePaths([...cliEnabledSkills, ...enabledSkills], this.additionalSkillPaths);
362
+ : this.mergePaths([...cliEnabledSkills, ...enabledSkills], additionalSkills.filter((r) => r.enabled).map((r) => r.path));
357
363
  this.lastSkillPaths = skillPaths;
358
364
  this.updateSkillsFromPaths(skillPaths, metadataByPath);
365
+ this.resolvedSkills = [...resolvedPaths.skills, ...additionalSkills];
359
366
  for (const p of this.additionalSkillPaths) {
360
367
  if (isLocalPath(p)) {
361
368
  const resolved = this.resolveResourcePath(p);
@@ -498,6 +505,43 @@ export class DefaultResourceLoader {
498
505
  extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
499
506
  }
500
507
  }
508
+ /**
509
+ * Enumerate every skill subdirectory in the bundled/CLI additional skill dirs,
510
+ * each gated by the user's skill overrides and defaulting to disabled (opt-in).
511
+ * Returns resolved resources (SKILL.md paths + enabled state).
512
+ */
513
+ additionalResolvedSkills() {
514
+ const resolved = [];
515
+ const patterns = this.settingsManager.getSkillPaths();
516
+ for (const dir of this.additionalSkillPaths) {
517
+ if (!isLocalPath(dir) || !existsSync(this.resolveResourcePath(dir)))
518
+ continue;
519
+ const resolvedDir = this.resolveResourcePath(dir);
520
+ let entries = [];
521
+ try {
522
+ entries = readdirSync(resolvedDir, { withFileTypes: true })
523
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
524
+ .map((e) => join(resolvedDir, e.name, "SKILL.md"))
525
+ .filter((p) => existsSync(p));
526
+ }
527
+ catch {
528
+ continue;
529
+ }
530
+ for (const skillFile of entries) {
531
+ resolved.push({
532
+ path: skillFile,
533
+ enabled: isEnabledByOverrides(skillFile, patterns, resolvedDir, false),
534
+ metadata: {
535
+ source: dir,
536
+ scope: "user",
537
+ origin: "top-level",
538
+ baseDir: resolvedDir,
539
+ },
540
+ });
541
+ }
542
+ }
543
+ return resolved;
544
+ }
501
545
  mapSkillPath(resource, metadataByPath) {
502
546
  if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") {
503
547
  return resource.path;
@@ -32,6 +32,8 @@ export interface TerminalSettings {
32
32
  export interface ImageSettings {
33
33
  autoResize?: boolean;
34
34
  blockImages?: boolean;
35
+ imageCaptionModel?: string;
36
+ imageCaptionContextTokens?: number;
35
37
  }
36
38
  export interface ThinkingBudgetsSettings {
37
39
  minimal?: number;
@@ -296,6 +298,10 @@ export declare class SettingsManager {
296
298
  getImageAutoResize(): boolean;
297
299
  setImageAutoResize(enabled: boolean): void;
298
300
  getBlockImages(): boolean;
301
+ getImageCaptionModel(): string | undefined;
302
+ setImageCaptionModel(modelId: string | undefined): void;
303
+ getImageCaptionContextTokens(): number;
304
+ setImageCaptionContextTokens(tokens: number): void;
299
305
  setBlockImages(blocked: boolean): void;
300
306
  getEnabledModels(): string[] | undefined;
301
307
  getDefaultTools(): string[] | undefined;
@@ -859,6 +859,33 @@ export class SettingsManager {
859
859
  getBlockImages() {
860
860
  return this.settings.images?.blockImages ?? false;
861
861
  }
862
+ getImageCaptionModel() {
863
+ return this.settings.images?.imageCaptionModel;
864
+ }
865
+ setImageCaptionModel(modelId) {
866
+ if (!this.globalSettings.images) {
867
+ this.globalSettings.images = {};
868
+ }
869
+ if (modelId === undefined || modelId === "off") {
870
+ delete this.globalSettings.images.imageCaptionModel;
871
+ }
872
+ else {
873
+ this.globalSettings.images.imageCaptionModel = modelId;
874
+ }
875
+ this.markModified("images", "imageCaptionModel");
876
+ this.save();
877
+ }
878
+ getImageCaptionContextTokens() {
879
+ return this.settings.images?.imageCaptionContextTokens ?? 16384;
880
+ }
881
+ setImageCaptionContextTokens(tokens) {
882
+ if (!this.globalSettings.images) {
883
+ this.globalSettings.images = {};
884
+ }
885
+ this.globalSettings.images.imageCaptionContextTokens = tokens;
886
+ this.markModified("images", "imageCaptionContextTokens");
887
+ this.save();
888
+ }
862
889
  setBlockImages(blocked) {
863
890
  if (!this.globalSettings.images) {
864
891
  this.globalSettings.images = {};
@@ -0,0 +1 @@
1
+ export {};