@selesai/code 0.8.10 → 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 +29 -0
- package/dist/core/agent-session.d.ts +24 -0
- package/dist/core/agent-session.js +151 -1
- package/dist/core/package-manager.d.ts +1 -0
- package/dist/core/package-manager.js +8 -8
- package/dist/core/package-manager.test.js +41 -2
- package/dist/core/resource-loader.d.ts +10 -1
- package/dist/core/resource-loader.js +45 -1
- package/dist/core/settings-manager.d.ts +6 -0
- package/dist/core/settings-manager.js +27 -0
- package/dist/core/tools/read-vision.test.d.ts +1 -0
- package/dist/core/tools/read-vision.test.js +122 -0
- package/dist/core/tools/read.d.ts +13 -1
- package/dist/core/tools/read.js +51 -1
- package/dist/core/vision-caption.d.ts +30 -0
- package/dist/core/vision-caption.js +62 -0
- package/dist/defaults/models.json +12 -0
- package/dist/extensions/model-prompt-injector/config.json +1 -1
- package/dist/modes/interactive/components/settings-selector.d.ts +15 -0
- package/dist/modes/interactive/components/settings-selector.js +85 -2
- package/dist/modes/interactive/components/status-indicator.d.ts +4 -1
- package/dist/modes/interactive/components/status-indicator.js +5 -0
- package/dist/modes/interactive/interactive-mode.d.ts +4 -0
- package/dist/modes/interactive/interactive-mode.js +97 -1
- package/docs/settings.md +2 -0
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
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
|
+
|
|
5
34
|
## [0.8.10] - 2026-08-18
|
|
6
35
|
|
|
7
36
|
### Changed
|
|
@@ -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 =
|
|
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],
|
|
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 {};
|