odai-dsh-agent 0.0.4 → 0.0.6
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/README.md +12 -2
- package/bin/odai-dsh-agent.mjs +1 -1
- package/package.json +1 -1
- package/preset/odai/runtime/index.mjs +172 -27
- package/preset/odai/runtime/router.mjs +76 -17
- package/preset/odai/runtime/skill-bundle.mjs +295 -0
- package/preset/odai/runtime/skill-selection-state.mjs +53 -0
- package/preset/odai/runtime/skill-selector.mjs +134 -0
- package/preset/odai/runtime/skill-source-config.mjs +239 -0
- package/preset/odai/skills/odai/SKILL.md +2 -1
- package/preset/odai/skills/odai/assets/routing-roles/controller.md +1 -1
- package/preset/odai/skills/odai/assets/routing-roles/reviewer.md +1 -1
- package/preset/odai/skills/odai/manifest.json +33 -0
- package/preset/odai/skills/odai/references/craft.md +1 -0
- package/preset/odai/skills/odai/references/leverage.md +1 -0
- package/preset/odai/skills/odai/references/planning.md +81 -0
- package/src/installer.mjs +5 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const ODAI_RUNTIME_CONTRACT = 1;
|
|
6
|
+
export const SKILL_MANIFEST_FILE = "manifest.json";
|
|
7
|
+
export const SKILL_SOURCE_MODES = Object.freeze(["bundled", "auto", "user"]);
|
|
8
|
+
|
|
9
|
+
const MANIFEST_FIELDS = new Set([
|
|
10
|
+
"schemaVersion",
|
|
11
|
+
"name",
|
|
12
|
+
"skillVersion",
|
|
13
|
+
"runtimeContract",
|
|
14
|
+
"requiredFiles",
|
|
15
|
+
]);
|
|
16
|
+
const REQUIRED_RUNTIME_FILES = Object.freeze([
|
|
17
|
+
"SKILL.md",
|
|
18
|
+
"assets/routing-roles/controller.md",
|
|
19
|
+
"assets/routing-roles/planner.md",
|
|
20
|
+
"assets/routing-roles/executor.md",
|
|
21
|
+
"assets/routing-roles/reviewer.md",
|
|
22
|
+
]);
|
|
23
|
+
const PROJECT_SOURCES = new Set(["project-dsh", "project-agents", "custom"]);
|
|
24
|
+
const USER_SOURCES = new Set(["user-dsh", "user-agents"]);
|
|
25
|
+
const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u;
|
|
26
|
+
|
|
27
|
+
function assertPlainObject(value, field) {
|
|
28
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
29
|
+
throw new TypeError(`${field} must be an object`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseSkillVersion(value, field = "skillVersion") {
|
|
35
|
+
if (typeof value !== "string") throw new TypeError(`${field} must be a string`);
|
|
36
|
+
const match = value.match(VERSION_PATTERN);
|
|
37
|
+
if (!match) throw new TypeError(`${field} must use SemVer 2.0.0 syntax`);
|
|
38
|
+
const prerelease = match[4] === undefined ? [] : match[4].split(".");
|
|
39
|
+
for (const identifier of prerelease) {
|
|
40
|
+
if (/^\d+$/u.test(identifier) && identifier.length > 1 && identifier.startsWith("0")) {
|
|
41
|
+
throw new TypeError(`${field} has a numeric prerelease identifier with a leading zero`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return Object.freeze({
|
|
45
|
+
core: Object.freeze(match.slice(1, 4)),
|
|
46
|
+
prerelease: Object.freeze(prerelease),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function compareNumericIdentifier(left, right) {
|
|
51
|
+
if (left.length < right.length) return -1;
|
|
52
|
+
if (left.length > right.length) return 1;
|
|
53
|
+
if (left < right) return -1;
|
|
54
|
+
if (left > right) return 1;
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function comparePrerelease(left, right) {
|
|
59
|
+
if (left.length === 0 && right.length === 0) return 0;
|
|
60
|
+
if (left.length === 0) return 1;
|
|
61
|
+
if (right.length === 0) return -1;
|
|
62
|
+
const length = Math.max(left.length, right.length);
|
|
63
|
+
for (let index = 0; index < length; index += 1) {
|
|
64
|
+
const leftValue = left[index];
|
|
65
|
+
const rightValue = right[index];
|
|
66
|
+
if (leftValue === undefined) return -1;
|
|
67
|
+
if (rightValue === undefined) return 1;
|
|
68
|
+
const leftNumeric = /^\d+$/u.test(leftValue);
|
|
69
|
+
const rightNumeric = /^\d+$/u.test(rightValue);
|
|
70
|
+
if (leftNumeric && rightNumeric) {
|
|
71
|
+
const difference = compareNumericIdentifier(leftValue, rightValue);
|
|
72
|
+
if (difference !== 0) return difference;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
|
|
76
|
+
if (leftValue < rightValue) return -1;
|
|
77
|
+
if (leftValue > rightValue) return 1;
|
|
78
|
+
}
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function compareSkillVersions(left, right) {
|
|
83
|
+
const leftVersion = typeof left === "string" ? parseSkillVersion(left, "left skillVersion") : left;
|
|
84
|
+
const rightVersion = typeof right === "string" ? parseSkillVersion(right, "right skillVersion") : right;
|
|
85
|
+
for (let index = 0; index < 3; index += 1) {
|
|
86
|
+
const difference = compareNumericIdentifier(leftVersion.core[index], rightVersion.core[index]);
|
|
87
|
+
if (difference !== 0) return difference;
|
|
88
|
+
}
|
|
89
|
+
return comparePrerelease(leftVersion.prerelease, rightVersion.prerelease);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveBundleFile(root, relativePath) {
|
|
93
|
+
if (typeof relativePath !== "string" || relativePath.trim() === "") {
|
|
94
|
+
throw new TypeError("skill manifest requiredFiles entries must be non-empty strings");
|
|
95
|
+
}
|
|
96
|
+
const segments = relativePath.split("/");
|
|
97
|
+
if (relativePath !== relativePath.trim()
|
|
98
|
+
|| relativePath.includes("\\")
|
|
99
|
+
|| isAbsolute(relativePath)
|
|
100
|
+
|| segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
101
|
+
throw new TypeError(`skill manifest contains unsafe required file ${JSON.stringify(relativePath)}`);
|
|
102
|
+
}
|
|
103
|
+
const target = resolve(root, ...segments);
|
|
104
|
+
const fromRoot = relative(root, target);
|
|
105
|
+
if (fromRoot === "" || fromRoot === ".." || fromRoot.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
|
|
106
|
+
throw new TypeError(`skill manifest required file escapes its bundle: ${relativePath}`);
|
|
107
|
+
}
|
|
108
|
+
return target;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function assertRealPathInside(rootRealPath, target, label) {
|
|
112
|
+
const targetRealPath = realpathSync(target);
|
|
113
|
+
const fromRoot = relative(rootRealPath, targetRealPath);
|
|
114
|
+
if (fromRoot === "" || fromRoot === ".." || fromRoot.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
|
|
115
|
+
throw new Error(`Odai skill bundle path escapes through a symlink: ${label}`);
|
|
116
|
+
}
|
|
117
|
+
return targetRealPath;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function readSkillManifest(skillRoot) {
|
|
121
|
+
const rootRealPath = realpathSync(skillRoot);
|
|
122
|
+
const manifestPath = resolve(skillRoot, SKILL_MANIFEST_FILE);
|
|
123
|
+
let parsed;
|
|
124
|
+
try {
|
|
125
|
+
const manifestRealPath = assertRealPathInside(rootRealPath, manifestPath, SKILL_MANIFEST_FILE);
|
|
126
|
+
parsed = JSON.parse(readFileSync(manifestRealPath, "utf8"));
|
|
127
|
+
} catch (error) {
|
|
128
|
+
throw new Error(`cannot read Odai skill manifest ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
129
|
+
}
|
|
130
|
+
const manifest = assertPlainObject(parsed, `Odai skill manifest ${manifestPath}`);
|
|
131
|
+
const unknown = Object.keys(manifest).filter((field) => !MANIFEST_FIELDS.has(field));
|
|
132
|
+
if (unknown.length > 0) {
|
|
133
|
+
throw new TypeError(`Odai skill manifest ${manifestPath} has unknown fields: ${unknown.join(", ")}`);
|
|
134
|
+
}
|
|
135
|
+
if (manifest.schemaVersion !== 1) {
|
|
136
|
+
throw new TypeError(`Odai skill manifest ${manifestPath} has unsupported schemaVersion ${String(manifest.schemaVersion)}`);
|
|
137
|
+
}
|
|
138
|
+
if (manifest.name !== "odai") throw new TypeError(`Odai skill manifest ${manifestPath} must name odai`);
|
|
139
|
+
const versionParts = parseSkillVersion(manifest.skillVersion, `Odai skill manifest ${manifestPath}.skillVersion`);
|
|
140
|
+
if (!Number.isSafeInteger(manifest.runtimeContract) || manifest.runtimeContract <= 0) {
|
|
141
|
+
throw new TypeError(`Odai skill manifest ${manifestPath}.runtimeContract must be a positive integer`);
|
|
142
|
+
}
|
|
143
|
+
if (!Array.isArray(manifest.requiredFiles) || manifest.requiredFiles.length === 0) {
|
|
144
|
+
throw new TypeError(`Odai skill manifest ${manifestPath}.requiredFiles must be a non-empty array`);
|
|
145
|
+
}
|
|
146
|
+
const requiredFiles = manifest.requiredFiles.map((file) => {
|
|
147
|
+
resolveBundleFile(skillRoot, file);
|
|
148
|
+
return file;
|
|
149
|
+
});
|
|
150
|
+
if (new Set(requiredFiles).size !== requiredFiles.length) {
|
|
151
|
+
throw new TypeError(`Odai skill manifest ${manifestPath}.requiredFiles contains duplicates`);
|
|
152
|
+
}
|
|
153
|
+
for (const required of REQUIRED_RUNTIME_FILES) {
|
|
154
|
+
if (!requiredFiles.includes(required)) {
|
|
155
|
+
throw new TypeError(`Odai skill manifest ${manifestPath} is missing required runtime file ${required}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return Object.freeze({
|
|
159
|
+
schemaVersion: 1,
|
|
160
|
+
name: "odai",
|
|
161
|
+
skillVersion: manifest.skillVersion,
|
|
162
|
+
versionParts,
|
|
163
|
+
runtimeContract: manifest.runtimeContract,
|
|
164
|
+
requiredFiles: Object.freeze(requiredFiles),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function loadSkillBundle(skillPath, options = {}) {
|
|
169
|
+
const entryPath = resolve(skillPath);
|
|
170
|
+
const root = dirname(entryPath);
|
|
171
|
+
const rootRealPath = realpathSync(root);
|
|
172
|
+
const manifest = readSkillManifest(root);
|
|
173
|
+
const digest = createHash("sha256");
|
|
174
|
+
const contents = new Map();
|
|
175
|
+
digest.update(JSON.stringify({
|
|
176
|
+
schemaVersion: manifest.schemaVersion,
|
|
177
|
+
name: manifest.name,
|
|
178
|
+
skillVersion: manifest.skillVersion,
|
|
179
|
+
runtimeContract: manifest.runtimeContract,
|
|
180
|
+
requiredFiles: manifest.requiredFiles,
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
for (const relativePath of [...manifest.requiredFiles].sort()) {
|
|
184
|
+
const path = resolveBundleFile(root, relativePath);
|
|
185
|
+
if (!existsSync(path)) throw new Error(`Odai skill bundle ${root} is missing ${relativePath}`);
|
|
186
|
+
const realPath = assertRealPathInside(rootRealPath, path, relativePath);
|
|
187
|
+
const content = readFileSync(realPath);
|
|
188
|
+
contents.set(relativePath, content);
|
|
189
|
+
digest.update("\0");
|
|
190
|
+
digest.update(relativePath);
|
|
191
|
+
digest.update("\0");
|
|
192
|
+
digest.update(content);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const skillText = contents.get("SKILL.md").toString("utf8").trim();
|
|
196
|
+
if (!skillText) throw new Error(`Odai canonical skill is empty: ${entryPath}`);
|
|
197
|
+
const frontmatter = skillText.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u)?.[1];
|
|
198
|
+
if (!frontmatter || !/^name:\s*odai\s*$/mu.test(frontmatter)) {
|
|
199
|
+
throw new Error(`Odai canonical skill entry does not declare name odai: ${entryPath}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const roleContracts = Object.freeze(Object.fromEntries(["controller", "planner", "executor", "reviewer"].map((role) => {
|
|
203
|
+
const relativePath = `assets/routing-roles/${role}.md`;
|
|
204
|
+
const text = contents.get(relativePath).toString("utf8").trim();
|
|
205
|
+
if (!text) throw new Error(`Odai canonical ${role} role is unavailable: ${resolve(root, relativePath)}`);
|
|
206
|
+
return [role, text];
|
|
207
|
+
})));
|
|
208
|
+
|
|
209
|
+
return Object.freeze({
|
|
210
|
+
path: entryPath,
|
|
211
|
+
root,
|
|
212
|
+
source: typeof options.source === "string" && options.source ? options.source : "bundled",
|
|
213
|
+
provider: typeof options.provider === "string" && options.provider ? options.provider : "odai-dsh-runtime",
|
|
214
|
+
manifest,
|
|
215
|
+
skillText,
|
|
216
|
+
roleContracts,
|
|
217
|
+
digest: digest.digest("hex"),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function fallbackSelection(mode, bundled, reasonCode, detail, candidate) {
|
|
222
|
+
return Object.freeze({
|
|
223
|
+
mode,
|
|
224
|
+
status: "fallback",
|
|
225
|
+
reasonCode,
|
|
226
|
+
detail,
|
|
227
|
+
bundle: bundled,
|
|
228
|
+
...(candidate ? { candidate } : {}),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function selected(mode, bundle, reasonCode, candidate) {
|
|
233
|
+
return Object.freeze({
|
|
234
|
+
mode,
|
|
235
|
+
status: "selected",
|
|
236
|
+
reasonCode,
|
|
237
|
+
bundle,
|
|
238
|
+
...(candidate ? { candidate } : {}),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function chooseSkillBundle({ mode, bundled, candidate, candidateError } = {}) {
|
|
243
|
+
if (!SKILL_SOURCE_MODES.includes(mode)) throw new TypeError(`unknown Odai skill source mode: ${String(mode)}`);
|
|
244
|
+
if (!bundled) throw new TypeError("bundled Odai skill is required");
|
|
245
|
+
if (mode === "bundled") return selected(mode, bundled, "bundled-configured");
|
|
246
|
+
if (candidateError) {
|
|
247
|
+
return fallbackSelection(mode, bundled, "external-invalid", candidateError instanceof Error ? candidateError.message : String(candidateError));
|
|
248
|
+
}
|
|
249
|
+
if (!candidate) {
|
|
250
|
+
return mode === "auto"
|
|
251
|
+
? selected(mode, bundled, "external-not-installed")
|
|
252
|
+
: fallbackSelection(mode, bundled, "user-source-missing", "no compatible user-level Odai skill is installed");
|
|
253
|
+
}
|
|
254
|
+
if (candidate.manifest.runtimeContract !== ODAI_RUNTIME_CONTRACT) {
|
|
255
|
+
return fallbackSelection(
|
|
256
|
+
mode,
|
|
257
|
+
bundled,
|
|
258
|
+
"runtime-contract-mismatch",
|
|
259
|
+
`candidate runtimeContract ${candidate.manifest.runtimeContract} is incompatible with runtime contract ${ODAI_RUNTIME_CONTRACT}`,
|
|
260
|
+
candidate,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const versionOrder = compareSkillVersions(candidate.manifest.versionParts, bundled.manifest.versionParts);
|
|
265
|
+
if (versionOrder === 0) {
|
|
266
|
+
if (candidate.digest === bundled.digest) return selected(mode, bundled, "external-equivalent", candidate);
|
|
267
|
+
return fallbackSelection(
|
|
268
|
+
mode,
|
|
269
|
+
bundled,
|
|
270
|
+
"same-version-content-conflict",
|
|
271
|
+
`candidate ${candidate.manifest.skillVersion} differs from the bundled content with the same version`,
|
|
272
|
+
candidate,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (mode === "user") {
|
|
277
|
+
if (!USER_SOURCES.has(candidate.source) && candidate.source !== "custom") {
|
|
278
|
+
return fallbackSelection(mode, bundled, "user-source-invalid", `source ${candidate.source} is not user-level`, candidate);
|
|
279
|
+
}
|
|
280
|
+
return selected(mode, candidate, "user-configured", candidate);
|
|
281
|
+
}
|
|
282
|
+
if (PROJECT_SOURCES.has(candidate.source)) return selected(mode, candidate, "project-scope-override", candidate);
|
|
283
|
+
if (USER_SOURCES.has(candidate.source)) {
|
|
284
|
+
return versionOrder > 0
|
|
285
|
+
? selected(mode, candidate, "newer-user-skill", candidate)
|
|
286
|
+
: fallbackSelection(
|
|
287
|
+
mode,
|
|
288
|
+
bundled,
|
|
289
|
+
"user-skill-older",
|
|
290
|
+
`candidate ${candidate.manifest.skillVersion} is older than bundled ${bundled.manifest.skillVersion}`,
|
|
291
|
+
candidate,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
return fallbackSelection(mode, bundled, "external-source-unsupported", `source ${candidate.source} cannot provide Odai governance`, candidate);
|
|
295
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const SHARED_SELECTION_STATE = Symbol.for("odai.dsh.skill-selection-state.v1");
|
|
2
|
+
|
|
3
|
+
function sharedStore() {
|
|
4
|
+
if (!globalThis[SHARED_SELECTION_STATE]) {
|
|
5
|
+
Object.defineProperty(globalThis, SHARED_SELECTION_STATE, {
|
|
6
|
+
configurable: false,
|
|
7
|
+
enumerable: false,
|
|
8
|
+
writable: false,
|
|
9
|
+
value: Object.freeze({ selections: new WeakMap() }),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
return globalThis[SHARED_SELECTION_STATE];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function currentAgentTurn(agent) {
|
|
16
|
+
const phaseTurn = agent?.phase?.turn;
|
|
17
|
+
if (Number.isSafeInteger(phaseTurn) && phaseTurn >= 0) return phaseTurn;
|
|
18
|
+
const events = agent?.session?.events;
|
|
19
|
+
if (!Array.isArray(events)) return undefined;
|
|
20
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
21
|
+
const event = events[index];
|
|
22
|
+
if (event?.type === "turn/start" && Number.isSafeInteger(event.data?.turn)) return event.data.turn;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function selectSharedSkillForTurn(agent, select) {
|
|
28
|
+
if (!agent || typeof agent !== "object") throw new TypeError("an agent is required for Odai skill selection");
|
|
29
|
+
if (typeof select !== "function") throw new TypeError("Odai skill selector must be a function");
|
|
30
|
+
const turn = currentAgentTurn(agent);
|
|
31
|
+
const generation = turn === undefined ? "unknown" : turn;
|
|
32
|
+
const store = sharedStore();
|
|
33
|
+
const existing = store.selections.get(agent);
|
|
34
|
+
if (existing?.generation === generation) return existing.promise;
|
|
35
|
+
|
|
36
|
+
const state = { generation };
|
|
37
|
+
state.promise = Promise.resolve().then(select).then((selection) => {
|
|
38
|
+
state.selection = selection;
|
|
39
|
+
return selection;
|
|
40
|
+
}).catch((error) => {
|
|
41
|
+
if (store.selections.get(agent) === state) store.selections.delete(agent);
|
|
42
|
+
throw error;
|
|
43
|
+
});
|
|
44
|
+
store.selections.set(agent, state);
|
|
45
|
+
return state.promise;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function sharedSkillSelection(agent, turn = currentAgentTurn(agent)) {
|
|
49
|
+
const state = sharedStore().selections.get(agent);
|
|
50
|
+
const generation = turn === undefined ? "unknown" : turn;
|
|
51
|
+
if (state?.generation !== generation) return undefined;
|
|
52
|
+
return state.selection;
|
|
53
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, parse, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { chooseSkillBundle, loadSkillBundle } from "./skill-bundle.mjs";
|
|
6
|
+
|
|
7
|
+
const PROJECT_SOURCES = new Set(["project-dsh", "project-agents"]);
|
|
8
|
+
|
|
9
|
+
export function findProjectRoot(cwd) {
|
|
10
|
+
if (typeof cwd !== "string" || cwd.trim() === "") return undefined;
|
|
11
|
+
let current = resolve(cwd);
|
|
12
|
+
const root = parse(current).root;
|
|
13
|
+
while (true) {
|
|
14
|
+
if (existsSync(resolve(current, ".git"))) return current;
|
|
15
|
+
if (current === root) return resolve(cwd);
|
|
16
|
+
current = dirname(current);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function defaultDshHome(env) {
|
|
21
|
+
return typeof env.DSH_HOME === "string" && env.DSH_HOME.trim() !== ""
|
|
22
|
+
? resolve(env.DSH_HOME.trim())
|
|
23
|
+
: resolve(homedir(), ".dsh");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function defaultAgentsHome(env) {
|
|
27
|
+
return typeof env.DSH_AGENTS_HOME === "string" && env.DSH_AGENTS_HOME.trim() !== ""
|
|
28
|
+
? resolve(env.DSH_AGENTS_HOME.trim())
|
|
29
|
+
: resolve(homedir(), ".agents");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function registryCandidate(skills, scope, signal) {
|
|
33
|
+
if (!skills || typeof skills.get !== "function") return undefined;
|
|
34
|
+
const definition = await skills.get("odai", { scope, signal });
|
|
35
|
+
if (!new Set(["custom", "user-dsh", "user-agents"]).has(definition?.source)) return undefined;
|
|
36
|
+
if (typeof definition.path !== "string" || definition.path.trim() === "") {
|
|
37
|
+
throw new Error(`${definition.source} Odai skill does not expose a local bundle path`);
|
|
38
|
+
}
|
|
39
|
+
return Object.freeze({ path: definition.path, source: definition.source, provider: definition.provider });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function directCandidate(path, source) {
|
|
43
|
+
return Object.freeze({ path, source, provider: "odai-filesystem-resolver" });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function decorate(selection, rejections) {
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
...selection,
|
|
49
|
+
rejections: Object.freeze(rejections.map((rejection) => Object.freeze({ ...rejection }))),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rejectionFor(candidate, reasonCode, detail) {
|
|
54
|
+
return {
|
|
55
|
+
source: candidate.source,
|
|
56
|
+
reasonCode,
|
|
57
|
+
detail,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function resolveSkillSelection(options = {}) {
|
|
62
|
+
const {
|
|
63
|
+
mode,
|
|
64
|
+
bundled,
|
|
65
|
+
cwd,
|
|
66
|
+
scope,
|
|
67
|
+
signal,
|
|
68
|
+
skills,
|
|
69
|
+
env = process.env,
|
|
70
|
+
} = options;
|
|
71
|
+
if (mode === "bundled") return decorate(chooseSkillBundle({ mode, bundled }), []);
|
|
72
|
+
signal?.throwIfAborted?.();
|
|
73
|
+
|
|
74
|
+
const candidates = [];
|
|
75
|
+
if (mode === "auto") {
|
|
76
|
+
const projectRoot = findProjectRoot(cwd);
|
|
77
|
+
if (projectRoot) {
|
|
78
|
+
candidates.push(
|
|
79
|
+
directCandidate(resolve(projectRoot, ".dsh", "skills", "odai", "SKILL.md"), "project-dsh"),
|
|
80
|
+
directCandidate(resolve(projectRoot, ".agents", "skills", "odai", "SKILL.md"), "project-agents"),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const registrySkill = await registryCandidate(skills, scope, signal);
|
|
87
|
+
if (registrySkill) candidates.push(registrySkill);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (signal?.aborted === true) throw error;
|
|
90
|
+
candidates.push(Object.freeze({ source: "custom", provider: "dsh-skill-registry", error }));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
candidates.push(
|
|
94
|
+
directCandidate(resolve(defaultDshHome(env), "skills", "odai", "SKILL.md"), "user-dsh"),
|
|
95
|
+
directCandidate(resolve(defaultAgentsHome(env), "skills", "odai", "SKILL.md"), "user-agents"),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const rejections = [];
|
|
99
|
+
for (const candidate of candidates) {
|
|
100
|
+
signal?.throwIfAborted?.();
|
|
101
|
+
if (candidate.error) {
|
|
102
|
+
rejections.push(rejectionFor(candidate, "external-discovery-failed", candidate.error instanceof Error ? candidate.error.message : String(candidate.error)));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!existsSync(candidate.path)) continue;
|
|
106
|
+
|
|
107
|
+
let bundle;
|
|
108
|
+
try {
|
|
109
|
+
bundle = loadSkillBundle(candidate.path, candidate);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
rejections.push(rejectionFor(candidate, "external-invalid", error instanceof Error ? error.message : String(error)));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (mode === "user" && PROJECT_SOURCES.has(bundle.source)) continue;
|
|
115
|
+
|
|
116
|
+
const selection = chooseSkillBundle({ mode, bundled, candidate: bundle });
|
|
117
|
+
if (selection.bundle === bundle && selection.status === "selected") {
|
|
118
|
+
return decorate(selection, rejections);
|
|
119
|
+
}
|
|
120
|
+
if (selection.reasonCode === "external-equivalent") {
|
|
121
|
+
return decorate(selection, rejections);
|
|
122
|
+
}
|
|
123
|
+
rejections.push(rejectionFor(bundle, selection.reasonCode, selection.detail));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const fallback = chooseSkillBundle({ mode, bundled });
|
|
127
|
+
if (rejections.length === 0) return decorate(fallback, rejections);
|
|
128
|
+
return decorate({
|
|
129
|
+
...fallback,
|
|
130
|
+
status: "fallback",
|
|
131
|
+
reasonCode: mode === "user" ? "user-source-unusable" : "external-candidates-rejected",
|
|
132
|
+
detail: rejections.map((rejection) => `${rejection.source}:${rejection.reasonCode}`).join(", "),
|
|
133
|
+
}, rejections);
|
|
134
|
+
}
|