@syengup/friday-channel-next 0.1.30 → 0.1.36
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 +8 -4
- package/dist/src/agent/abort-run.d.ts +12 -1
- package/dist/src/agent/abort-run.js +24 -9
- package/dist/src/agent/media-bridge.d.ts +8 -1
- package/dist/src/agent/media-bridge.js +23 -2
- package/dist/src/agent-forward-runtime.d.ts +15 -0
- package/dist/src/agent-forward-runtime.js +2 -0
- package/dist/src/agent-id.d.ts +8 -0
- package/dist/src/agent-id.js +21 -0
- package/dist/src/channel-actions.js +45 -14
- package/dist/src/channel.js +22 -1
- package/dist/src/http/handlers/agent-config.d.ts +27 -0
- package/dist/src/http/handlers/agent-config.js +182 -0
- package/dist/src/http/handlers/agent-files.d.ts +21 -0
- package/dist/src/http/handlers/agent-files.js +137 -0
- package/dist/src/http/handlers/agent-tools-catalog.d.ts +10 -0
- package/dist/src/http/handlers/agent-tools-catalog.js +33 -0
- package/dist/src/http/handlers/agents-list.js +1 -19
- package/dist/src/http/handlers/cancel.js +12 -6
- package/dist/src/http/handlers/files.d.ts +16 -0
- package/dist/src/http/handlers/files.js +80 -12
- package/dist/src/http/handlers/messages.js +8 -3
- package/dist/src/http/handlers/models-list.d.ts +5 -0
- package/dist/src/http/handlers/models-list.js +8 -0
- package/dist/src/http/handlers/sessions-settings.js +15 -10
- package/dist/src/http/server.js +23 -0
- package/dist/src/link-preview/ssrf-guard.js +6 -2
- package/dist/src/media-fetch.js +4 -1
- package/dist/src/skills-discovery.d.ts +58 -0
- package/dist/src/skills-discovery.js +247 -0
- package/dist/src/thinking-levels.d.ts +21 -0
- package/dist/src/thinking-levels.js +48 -0
- package/dist/src/tool-catalog.d.ts +53 -0
- package/dist/src/tool-catalog.js +192 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
- package/src/agent/abort-run.ts +24 -8
- package/src/agent/media-bridge.test.ts +71 -0
- package/src/agent/media-bridge.ts +23 -1
- package/src/agent-forward-runtime.ts +11 -0
- package/src/agent-id.ts +24 -0
- package/src/channel-actions.test.ts +47 -0
- package/src/channel-actions.ts +38 -14
- package/src/channel.lifecycle.test.ts +41 -0
- package/src/channel.ts +23 -1
- package/src/http/handlers/agent-config.test.ts +205 -0
- package/src/http/handlers/agent-config.ts +218 -0
- package/src/http/handlers/agent-files.test.ts +136 -0
- package/src/http/handlers/agent-files.ts +149 -0
- package/src/http/handlers/agent-tools-catalog.ts +42 -0
- package/src/http/handlers/agents-list.ts +1 -22
- package/src/http/handlers/cancel.test.ts +12 -2
- package/src/http/handlers/cancel.ts +12 -6
- package/src/http/handlers/files.test.ts +114 -0
- package/src/http/handlers/files.ts +97 -13
- package/src/http/handlers/messages.ts +7 -2
- package/src/http/handlers/models-list.test.ts +114 -0
- package/src/http/handlers/models-list.ts +12 -0
- package/src/http/handlers/sessions-settings.ts +16 -11
- package/src/http/server.ts +24 -0
- package/src/link-preview/ssrf-guard.test.ts +7 -2
- package/src/link-preview/ssrf-guard.ts +5 -1
- package/src/media-fetch.test.ts +1 -1
- package/src/media-fetch.ts +4 -1
- package/src/openclaw.d.ts +25 -1
- package/src/skills-discovery.test.ts +148 -0
- package/src/skills-discovery.ts +248 -0
- package/src/thinking-levels.test.ts +143 -0
- package/src/thinking-levels.ts +68 -0
- package/src/tool-catalog.ts +252 -0
- package/src/version.ts +1 -1
package/src/openclaw.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
declare module "openclaw/plugin-sdk/agent-harness" {
|
|
2
|
-
|
|
2
|
+
/** Abort the active embedded run keyed by its internal `sessionId` (NOT the channel runId). */
|
|
3
|
+
export const abortAgentHarnessRun: (sessionId: string) => boolean;
|
|
4
|
+
/** Abort the active embedded run and wait for it to actually settle. */
|
|
5
|
+
export const abortAndDrainAgentHarnessRun: (params: {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
sessionKey?: string;
|
|
8
|
+
settleMs?: number;
|
|
9
|
+
forceClear?: boolean;
|
|
10
|
+
reason?: string;
|
|
11
|
+
}) => Promise<{ aborted: boolean; drained: boolean; forceCleared: boolean }>;
|
|
12
|
+
/** Map a channel sessionKey → the active embedded run's internal sessionId. */
|
|
13
|
+
export const resolveActiveEmbeddedRunSessionId: (sessionKey: string) => string | undefined;
|
|
3
14
|
export const runAgentHarness: (...args: any[]) => any;
|
|
4
15
|
}
|
|
5
16
|
|
|
@@ -43,6 +54,19 @@ declare module "openclaw/plugin-sdk/media-store" {
|
|
|
43
54
|
export const saveMediaBuffer: (...args: any[]) => any;
|
|
44
55
|
}
|
|
45
56
|
|
|
57
|
+
declare module "openclaw/plugin-sdk/channel-lifecycle" {
|
|
58
|
+
export const waitUntilAbort: (
|
|
59
|
+
signal?: AbortSignal,
|
|
60
|
+
onAbort?: () => void | Promise<void>,
|
|
61
|
+
) => Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
declare module "openclaw/plugin-sdk/media-runtime" {
|
|
65
|
+
export type MediaKind = "image" | "audio" | "video" | "document";
|
|
66
|
+
export const mediaKindFromMime: (mime?: string | null) => MediaKind | undefined;
|
|
67
|
+
export const maxBytesForKind: (kind: MediaKind) => number;
|
|
68
|
+
}
|
|
69
|
+
|
|
46
70
|
declare module "openclaw/plugin-sdk/plugin-entry" {
|
|
47
71
|
export type OpenClawPluginApi = any;
|
|
48
72
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
discoverAvailableSkills,
|
|
7
|
+
enabledExtensionNames,
|
|
8
|
+
resetOpenClawRootCacheForTest,
|
|
9
|
+
} from "./skills-discovery.js";
|
|
10
|
+
import {
|
|
11
|
+
setFridayAgentForwardRuntime,
|
|
12
|
+
resetFridayAgentForwardRuntimeForTest,
|
|
13
|
+
} from "./agent-forward-runtime.js";
|
|
14
|
+
|
|
15
|
+
/** Create `<parent>/<id>/SKILL.md` for each id. */
|
|
16
|
+
function makeSkills(parent: string, ids: string[]): void {
|
|
17
|
+
for (const id of ids) {
|
|
18
|
+
fs.mkdirSync(path.join(parent, id), { recursive: true });
|
|
19
|
+
fs.writeFileSync(path.join(parent, id, "SKILL.md"), "# " + id);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("discoverAvailableSkills", () => {
|
|
24
|
+
let root: string;
|
|
25
|
+
|
|
26
|
+
function wire(configRoot: string, cfg: unknown): void {
|
|
27
|
+
setFridayAgentForwardRuntime({
|
|
28
|
+
runtime: {
|
|
29
|
+
agent: {
|
|
30
|
+
session: { resolveStorePath: () => "", loadSessionStore: () => ({}) },
|
|
31
|
+
// main → <configRoot>/workspace ; others → <configRoot>/workspace/agents/<id>
|
|
32
|
+
resolveAgentWorkspaceDir: (_c: unknown, id: string) =>
|
|
33
|
+
id === "main"
|
|
34
|
+
? path.join(configRoot, "workspace")
|
|
35
|
+
: path.join(configRoot, "workspace", "agents", id),
|
|
36
|
+
},
|
|
37
|
+
config: { current: () => cfg },
|
|
38
|
+
},
|
|
39
|
+
} as never);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
resetFridayAgentForwardRuntimeForTest();
|
|
44
|
+
resetOpenClawRootCacheForTest();
|
|
45
|
+
if (root) fs.rmSync(root, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("aggregates agent + shared root + managed + extra dirs, deduped and sorted", () => {
|
|
49
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "friday-disc-"));
|
|
50
|
+
const configRoot = path.join(root, "configdir");
|
|
51
|
+
const extraDir = path.join(root, "extra");
|
|
52
|
+
|
|
53
|
+
// Shared root pool (default agent "main" workspace)
|
|
54
|
+
makeSkills(path.join(configRoot, "workspace", "skills"), ["alpha", "opencli"]);
|
|
55
|
+
// operator's own workspace — includes a duplicate (opencli) to prove dedup
|
|
56
|
+
makeSkills(path.join(configRoot, "workspace", "agents", "operator", "skills"), ["beta", "opencli"]);
|
|
57
|
+
// managed dir: <configDir>/skills (sibling of workspace)
|
|
58
|
+
makeSkills(path.join(configRoot, "skills"), ["managed-one"]);
|
|
59
|
+
// config extraDirs
|
|
60
|
+
makeSkills(extraDir, ["gamma"]);
|
|
61
|
+
|
|
62
|
+
const cfg = {
|
|
63
|
+
agents: { list: [{ id: "main", default: true }, { id: "operator" }] },
|
|
64
|
+
skills: { load: { extraDirs: [extraDir] } },
|
|
65
|
+
};
|
|
66
|
+
wire(configRoot, cfg);
|
|
67
|
+
|
|
68
|
+
const result = discoverAvailableSkills(cfg, "operator");
|
|
69
|
+
expect(result.map((s) => s.id)).toEqual(["alpha", "beta", "gamma", "managed-one", "opencli"]);
|
|
70
|
+
const bySource = Object.fromEntries(result.map((s) => [s.id, s.source]));
|
|
71
|
+
expect(bySource).toEqual({
|
|
72
|
+
alpha: "workspace",
|
|
73
|
+
beta: "workspace",
|
|
74
|
+
opencli: "workspace",
|
|
75
|
+
"managed-one": "installed",
|
|
76
|
+
gamma: "extra",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("ignores directories without SKILL.md", () => {
|
|
81
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "friday-disc-"));
|
|
82
|
+
const configRoot = path.join(root, "configdir");
|
|
83
|
+
const skillsDir = path.join(configRoot, "workspace", "skills");
|
|
84
|
+
makeSkills(skillsDir, ["real"]);
|
|
85
|
+
fs.mkdirSync(path.join(skillsDir, "empty-dir"), { recursive: true }); // no SKILL.md
|
|
86
|
+
|
|
87
|
+
const cfg = { agents: { list: [{ id: "main", default: true }] } };
|
|
88
|
+
wire(configRoot, cfg);
|
|
89
|
+
|
|
90
|
+
expect(discoverAvailableSkills(cfg, "main").map((s) => s.id)).toEqual(["real"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("uses the SKILL.md frontmatter name over the dir name, and finds nested skills", () => {
|
|
94
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "friday-disc-"));
|
|
95
|
+
const configRoot = path.join(root, "configdir");
|
|
96
|
+
const skillsDir = path.join(configRoot, "workspace", "skills");
|
|
97
|
+
|
|
98
|
+
// dir name != declared name
|
|
99
|
+
fs.mkdirSync(path.join(skillsDir, "self-improving-agent"), { recursive: true });
|
|
100
|
+
fs.writeFileSync(
|
|
101
|
+
path.join(skillsDir, "self-improving-agent", "SKILL.md"),
|
|
102
|
+
'---\nname: self-improvement\ndescription: "x"\n---\n# body',
|
|
103
|
+
);
|
|
104
|
+
// nested skill (redskill-style): SKILL.md two levels under the skills dir
|
|
105
|
+
const nested = path.join(skillsDir, "luckincoffee-mycoffeeskill", "my-coffee-skill");
|
|
106
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
107
|
+
fs.writeFileSync(path.join(nested, "SKILL.md"), "---\nname: my-coffee\n---\n# body");
|
|
108
|
+
|
|
109
|
+
const cfg = { agents: { list: [{ id: "main", default: true }] } };
|
|
110
|
+
wire(configRoot, cfg);
|
|
111
|
+
|
|
112
|
+
const result = discoverAvailableSkills(cfg, "main");
|
|
113
|
+
expect(result.map((s) => s.id)).toEqual(["my-coffee", "self-improvement"]);
|
|
114
|
+
expect(result.find((s) => s.id === "self-improvement")?.description).toBe("x");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns [] without throwing when nothing is resolvable", () => {
|
|
118
|
+
resetFridayAgentForwardRuntimeForTest();
|
|
119
|
+
expect(discoverAvailableSkills({}, "main")).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("enabledExtensionNames", () => {
|
|
124
|
+
it("unions plugins.allow with entries[name].enabled === true", () => {
|
|
125
|
+
const cfg = {
|
|
126
|
+
plugins: {
|
|
127
|
+
allow: ["browser", "canvas"],
|
|
128
|
+
entries: {
|
|
129
|
+
browser: { enabled: true },
|
|
130
|
+
telegram: { enabled: true },
|
|
131
|
+
tavily: { enabled: false },
|
|
132
|
+
"open-prose": {},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
const names = enabledExtensionNames(cfg);
|
|
137
|
+
expect(names.has("browser")).toBe(true);
|
|
138
|
+
expect(names.has("canvas")).toBe(true);
|
|
139
|
+
expect(names.has("telegram")).toBe(true); // from entries.enabled
|
|
140
|
+
expect(names.has("tavily")).toBe(false); // enabled:false
|
|
141
|
+
expect(names.has("open-prose")).toBe(false); // no enabled flag
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("returns an empty set when plugins config is absent", () => {
|
|
145
|
+
expect(enabledExtensionNames({}).size).toBe(0);
|
|
146
|
+
expect(enabledExtensionNames(undefined).size).toBe(0);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discover the full catalog of skills an agent can load — the selectable set the
|
|
3
|
+
* app's skill-management UI offers, matching what OpenClaw's ControlUI shows.
|
|
4
|
+
*
|
|
5
|
+
* OpenClaw resolves an agent's loadable skills from several directories. None of
|
|
6
|
+
* the core's skill-discovery functions are reachable through a stable plugin-sdk
|
|
7
|
+
* specifier (they live only in hash-named chunks), so instead of deep-importing
|
|
8
|
+
* brittle core code we scan the same DIRECTORIES core scans — a far more stable
|
|
9
|
+
* coupling (directory conventions change far less often than bundled chunk names),
|
|
10
|
+
* and every access is guarded so a missing/renamed source just yields fewer skills
|
|
11
|
+
* rather than an error. A skill is a sub-directory containing `SKILL.md` (the same
|
|
12
|
+
* marker core's `loadSkillsFromDir` uses).
|
|
13
|
+
*
|
|
14
|
+
* Each discovered skill is tagged with a `source` category for the UI:
|
|
15
|
+
* - workspace : the agent's own + the shared default-agent workspace `skills/`
|
|
16
|
+
* - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
|
|
17
|
+
* - built-in : bundled core skills (`<openclaw>/skills`)
|
|
18
|
+
* - extra : skills from ENABLED extensions (`<openclaw>/dist/extensions/<ext>/skills`,
|
|
19
|
+
* gated by `plugins.allow`/`entries.enabled` like ControlUI) + config
|
|
20
|
+
* `skills.load.extraDirs` — mirrors ControlUI's "EXTRA" bucket
|
|
21
|
+
* (core tags extension skills `source: "extension"`).
|
|
22
|
+
*
|
|
23
|
+
* Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
|
|
24
|
+
*
|
|
25
|
+
* A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
|
|
26
|
+
* the containing dir name) — NOT the dir name itself, which often differs (e.g. the
|
|
27
|
+
* `self-improving-agent/` dir declares `name: self-improvement`). The `description:`
|
|
28
|
+
* frontmatter field is surfaced too. Discovery is RECURSIVE (mirroring core's
|
|
29
|
+
* `loadSkills`): some skills nest the `SKILL.md` a few levels deep (e.g. redskill
|
|
30
|
+
* installs at `<pkg>/<sub>/<skill>/SKILL.md`).
|
|
31
|
+
*
|
|
32
|
+
* NOT included (out of scope for a name catalog): ClawHub remote-only skills and
|
|
33
|
+
* per-skill eligibility/`disabled` flags. The enabled set is the agent's own
|
|
34
|
+
* `skills[]` config, already returned separately by the config view.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
import { createRequire } from "node:module";
|
|
40
|
+
import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
|
|
41
|
+
import { DEFAULT_AGENT_ID, normalizeAgentId } from "./agent-id.js";
|
|
42
|
+
|
|
43
|
+
/** Depth limit for the recursive walk under each source dir (skills nest a few levels). */
|
|
44
|
+
const MAX_SKILL_WALK_DEPTH = 6;
|
|
45
|
+
const IGNORED_WALK_DIRS = new Set(["node_modules", ".git"]);
|
|
46
|
+
|
|
47
|
+
export type SkillSource = "workspace" | "built-in" | "installed" | "extra";
|
|
48
|
+
|
|
49
|
+
export interface DiscoveredSkill {
|
|
50
|
+
id: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
source: SkillSource;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Extract `name`/`description` from a SKILL.md YAML frontmatter block. */
|
|
56
|
+
function parseSkillFrontmatter(content: string): { name?: string; description?: string } {
|
|
57
|
+
const lines = content.split(/\r?\n/);
|
|
58
|
+
if (lines[0]?.trim() !== "---") return {};
|
|
59
|
+
let name: string | undefined;
|
|
60
|
+
let description: string | undefined;
|
|
61
|
+
for (let i = 1; i < lines.length; i++) {
|
|
62
|
+
if (lines[i].trim() === "---") break;
|
|
63
|
+
const m = /^(name|description)\s*:\s*(.+?)\s*$/.exec(lines[i]);
|
|
64
|
+
if (!m) continue;
|
|
65
|
+
let v = m[2].trim();
|
|
66
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
67
|
+
v = v.slice(1, -1);
|
|
68
|
+
}
|
|
69
|
+
v = v.trim();
|
|
70
|
+
if (!v) continue;
|
|
71
|
+
if (m[1] === "name" && name === undefined) name = v;
|
|
72
|
+
else if (m[1] === "description" && description === undefined) description = v;
|
|
73
|
+
}
|
|
74
|
+
return { name, description };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Collect skills reachable under `root`, tagging each with `source`. A directory
|
|
79
|
+
* containing `SKILL.md` IS a skill (id = frontmatter `name`, else dir name) and is
|
|
80
|
+
* not descended into further; other directories are recursed up to a bounded depth.
|
|
81
|
+
* First occurrence of an id wins (call higher-priority sources first). Best-effort.
|
|
82
|
+
*/
|
|
83
|
+
function collectSkills(root: string, source: SkillSource, out: Map<string, DiscoveredSkill>, depth = 0): void {
|
|
84
|
+
if (depth > MAX_SKILL_WALK_DEPTH) return;
|
|
85
|
+
let entries: fs.Dirent[];
|
|
86
|
+
try {
|
|
87
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
88
|
+
} catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (entries.some((e) => e.isFile() && e.name === "SKILL.md")) {
|
|
92
|
+
let fm: { name?: string; description?: string } = {};
|
|
93
|
+
try {
|
|
94
|
+
fm = parseSkillFrontmatter(fs.readFileSync(path.join(root, "SKILL.md"), "utf-8"));
|
|
95
|
+
} catch {
|
|
96
|
+
// unreadable frontmatter → fall back to dir name, no description
|
|
97
|
+
}
|
|
98
|
+
const id = fm.name ?? path.basename(root);
|
|
99
|
+
if (!out.has(id)) out.set(id, { id, description: fm.description, source });
|
|
100
|
+
return; // a skill is a leaf — don't treat its internals as nested skills
|
|
101
|
+
}
|
|
102
|
+
for (const e of entries) {
|
|
103
|
+
if (e.isDirectory() && !e.name.startsWith(".") && !IGNORED_WALK_DIRS.has(e.name)) {
|
|
104
|
+
collectSkills(path.join(root, e.name), source, out, depth + 1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let cachedOpenClawRoot: string | null | undefined;
|
|
110
|
+
|
|
111
|
+
/** Locate the installed `openclaw` package root (cached). Shared with tool-catalog discovery. */
|
|
112
|
+
export function resolveOpenClawRoot(): string | null {
|
|
113
|
+
if (cachedOpenClawRoot !== undefined) return cachedOpenClawRoot;
|
|
114
|
+
cachedOpenClawRoot = computeOpenClawRoot();
|
|
115
|
+
return cachedOpenClawRoot;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function computeOpenClawRoot(): string | null {
|
|
119
|
+
const starts: string[] = [];
|
|
120
|
+
// Primary: resolve a subpath this plugin already imports (works inside the gateway
|
|
121
|
+
// where `openclaw/*` is resolvable). Standalone (e.g. unit tests) this throws → skipped.
|
|
122
|
+
try {
|
|
123
|
+
starts.push(createRequire(import.meta.url).resolve("openclaw/plugin-sdk/plugin-entry"));
|
|
124
|
+
} catch {
|
|
125
|
+
// not resolvable outside the gateway runtime
|
|
126
|
+
}
|
|
127
|
+
// Fallback: the gateway process entry (`<openclaw>/dist/index.js`) — the plugin runs in it.
|
|
128
|
+
if (typeof process.argv[1] === "string") starts.push(process.argv[1]);
|
|
129
|
+
|
|
130
|
+
for (const start of starts) {
|
|
131
|
+
let dir = path.dirname(start);
|
|
132
|
+
for (let i = 0; i < 10 && dir !== path.dirname(dir); i++) {
|
|
133
|
+
try {
|
|
134
|
+
const pkgPath = path.join(dir, "package.json");
|
|
135
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: string };
|
|
136
|
+
if (pkg?.name === "openclaw") return dir;
|
|
137
|
+
} catch {
|
|
138
|
+
// keep walking up
|
|
139
|
+
}
|
|
140
|
+
dir = path.dirname(dir);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The set of bundled extensions enabled for this install — `plugins.allow` plus any
|
|
148
|
+
* `plugins.entries[name].enabled === true`. ControlUI only surfaces skills from
|
|
149
|
+
* enabled extensions, so we gate on the same set (extension dir name == plugin id).
|
|
150
|
+
*/
|
|
151
|
+
export function enabledExtensionNames(cfg: unknown): Set<string> {
|
|
152
|
+
const plugins = (cfg as Record<string, unknown> | undefined)?.plugins as Record<string, unknown> | undefined;
|
|
153
|
+
const names = new Set<string>();
|
|
154
|
+
const allow = plugins?.allow;
|
|
155
|
+
if (Array.isArray(allow)) for (const n of allow) if (typeof n === "string") names.add(n);
|
|
156
|
+
const entries = plugins?.entries as Record<string, unknown> | undefined;
|
|
157
|
+
if (entries && typeof entries === "object") {
|
|
158
|
+
for (const [name, val] of Object.entries(entries)) {
|
|
159
|
+
if (val && typeof val === "object" && (val as Record<string, unknown>).enabled === true) names.add(name);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return names;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Bundled skill source dirs inside the openclaw install, tagged like ControlUI:
|
|
167
|
+
* core `<openclaw>/skills` → "built-in"; per-extension `dist/extensions/<ext>/skills`
|
|
168
|
+
* → "extra" (core tags these `source: "extension"`). Extension skills are included
|
|
169
|
+
* only when the extension is enabled, matching ControlUI's EXTRA bucket.
|
|
170
|
+
*/
|
|
171
|
+
function bundledSkillSources(enabledExtensions: Set<string>): Array<{ dir: string; source: SkillSource }> {
|
|
172
|
+
const root = resolveOpenClawRoot();
|
|
173
|
+
if (!root) return [];
|
|
174
|
+
const out: Array<{ dir: string; source: SkillSource }> = [
|
|
175
|
+
{ dir: path.join(root, "skills"), source: "built-in" },
|
|
176
|
+
];
|
|
177
|
+
try {
|
|
178
|
+
const extRoot = path.join(root, "dist", "extensions");
|
|
179
|
+
for (const ext of fs.readdirSync(extRoot, { withFileTypes: true })) {
|
|
180
|
+
if (ext.isDirectory() && enabledExtensions.has(ext.name)) {
|
|
181
|
+
out.push({ dir: path.join(extRoot, ext.name, "skills"), source: "extra" });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
// no extensions dir on this build
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function resolveDefaultAgentId(cfg: Record<string, unknown> | undefined): string {
|
|
191
|
+
const list = (cfg?.agents as Record<string, unknown> | undefined)?.list as
|
|
192
|
+
| Array<Record<string, unknown>>
|
|
193
|
+
| undefined;
|
|
194
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
195
|
+
const def = list.find((a) => a?.default === true) ?? list[0];
|
|
196
|
+
if (def?.id) return normalizeAgentId(def.id);
|
|
197
|
+
}
|
|
198
|
+
return DEFAULT_AGENT_ID;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Full set of skills `agentId` can load, sorted by id, each tagged with its source
|
|
203
|
+
* category. Aggregates the agent's workspace, the shared root workspace, the managed
|
|
204
|
+
* dir, config extra dirs, and bundled core/extension skills. Every source is optional
|
|
205
|
+
* and failure-tolerant.
|
|
206
|
+
*/
|
|
207
|
+
export function discoverAvailableSkills(cfg: unknown, agentId: string): DiscoveredSkill[] {
|
|
208
|
+
const c = cfg as Record<string, unknown> | undefined;
|
|
209
|
+
const resolveWs = getFridayAgentForwardRuntime()?.resolveAgentWorkspaceDir;
|
|
210
|
+
const sources: Array<{ dir: string; source: SkillSource }> = [];
|
|
211
|
+
|
|
212
|
+
if (resolveWs) {
|
|
213
|
+
const defaultId = resolveDefaultAgentId(c);
|
|
214
|
+
const ids = agentId === defaultId ? [agentId] : [agentId, defaultId];
|
|
215
|
+
let defaultWs: string | undefined;
|
|
216
|
+
for (const id of ids) {
|
|
217
|
+
try {
|
|
218
|
+
const ws = resolveWs(cfg, id);
|
|
219
|
+
if (ws) {
|
|
220
|
+
sources.push({ dir: path.join(ws, "skills"), source: "workspace" });
|
|
221
|
+
if (id === defaultId) defaultWs = ws;
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
// skip unresolvable workspace
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Managed skills dir: `<configDir>/skills`, the workspace's parent sibling.
|
|
228
|
+
if (defaultWs) sources.push({ dir: path.join(path.dirname(defaultWs), "skills"), source: "installed" });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const extraDirs = ((c?.skills as Record<string, unknown> | undefined)?.load as
|
|
232
|
+
| Record<string, unknown>
|
|
233
|
+
| undefined)?.extraDirs;
|
|
234
|
+
if (Array.isArray(extraDirs)) {
|
|
235
|
+
for (const d of extraDirs) if (typeof d === "string" && d.trim()) sources.push({ dir: d.trim(), source: "extra" });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
sources.push(...bundledSkillSources(enabledExtensionNames(c)));
|
|
239
|
+
|
|
240
|
+
const out = new Map<string, DiscoveredSkill>();
|
|
241
|
+
for (const { dir, source } of sources) collectSkills(dir, source, out, 0);
|
|
242
|
+
return [...out.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Test-only: reset the cached openclaw root. */
|
|
246
|
+
export function resetOpenClawRootCacheForTest(): void {
|
|
247
|
+
cachedOpenClawRoot = undefined;
|
|
248
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
resolveModelThinking,
|
|
4
|
+
resolveModelThinkingForRef,
|
|
5
|
+
isThinkingLevelSupportedForRef,
|
|
6
|
+
} from "./thinking-levels.js";
|
|
7
|
+
import {
|
|
8
|
+
setFridayAgentForwardRuntime,
|
|
9
|
+
resetFridayAgentForwardRuntimeForTest,
|
|
10
|
+
} from "./agent-forward-runtime.js";
|
|
11
|
+
|
|
12
|
+
/** Inject a forward runtime whose `resolveThinkingPolicy` echoes the recorded calls + a fixed reply. */
|
|
13
|
+
function setThinkingPolicy(
|
|
14
|
+
impl: (params: { provider?: string | null; model?: string | null }) => {
|
|
15
|
+
levels: Array<{ id: string; label: string }>;
|
|
16
|
+
defaultLevel?: string | null;
|
|
17
|
+
},
|
|
18
|
+
): { calls: Array<{ provider?: string | null; model?: string | null }> } {
|
|
19
|
+
const calls: Array<{ provider?: string | null; model?: string | null }> = [];
|
|
20
|
+
setFridayAgentForwardRuntime({
|
|
21
|
+
runtime: {
|
|
22
|
+
agent: {
|
|
23
|
+
session: { resolveStorePath: () => "", loadSessionStore: () => ({}) },
|
|
24
|
+
resolveThinkingPolicy: (params: { provider?: string | null; model?: string | null }) => {
|
|
25
|
+
calls.push(params);
|
|
26
|
+
return impl(params);
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
config: { current: () => ({}) },
|
|
30
|
+
},
|
|
31
|
+
} as never);
|
|
32
|
+
return { calls };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("resolveModelThinking", () => {
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
resetFridayAgentForwardRuntimeForTest();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns the runtime-resolved levels + default for a model that supports xhigh", () => {
|
|
41
|
+
setThinkingPolicy(() => ({
|
|
42
|
+
levels: [
|
|
43
|
+
{ id: "off", label: "off" },
|
|
44
|
+
{ id: "minimal", label: "minimal" },
|
|
45
|
+
{ id: "low", label: "low" },
|
|
46
|
+
{ id: "medium", label: "medium" },
|
|
47
|
+
{ id: "high", label: "high" },
|
|
48
|
+
{ id: "xhigh", label: "xhigh" },
|
|
49
|
+
],
|
|
50
|
+
defaultLevel: "high",
|
|
51
|
+
}));
|
|
52
|
+
const result = resolveModelThinking("openai", "gpt-5.4");
|
|
53
|
+
expect(result.levels.map((l) => l.id)).toEqual([
|
|
54
|
+
"off",
|
|
55
|
+
"minimal",
|
|
56
|
+
"low",
|
|
57
|
+
"medium",
|
|
58
|
+
"high",
|
|
59
|
+
"xhigh",
|
|
60
|
+
]);
|
|
61
|
+
expect(result.default).toBe("high");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("passes binary on/off labels through unchanged", () => {
|
|
65
|
+
setThinkingPolicy(() => ({
|
|
66
|
+
levels: [
|
|
67
|
+
{ id: "off", label: "off" },
|
|
68
|
+
{ id: "low", label: "on" },
|
|
69
|
+
],
|
|
70
|
+
}));
|
|
71
|
+
const result = resolveModelThinking("moonshot", "kimi-k2");
|
|
72
|
+
expect(result.levels).toEqual([
|
|
73
|
+
{ id: "off", label: "off" },
|
|
74
|
+
{ id: "low", label: "on" },
|
|
75
|
+
]);
|
|
76
|
+
expect(result.default).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("falls back to the base five levels when no runtime is registered", () => {
|
|
80
|
+
const result = resolveModelThinking("anything", "model-x");
|
|
81
|
+
expect(result.levels.map((l) => l.id)).toEqual(["off", "minimal", "low", "medium", "high"]);
|
|
82
|
+
expect(result.default).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("falls back to the base levels when resolveThinkingPolicy throws", () => {
|
|
86
|
+
setThinkingPolicy(() => {
|
|
87
|
+
throw new Error("boom");
|
|
88
|
+
});
|
|
89
|
+
const result = resolveModelThinking("openai", "gpt-5.4");
|
|
90
|
+
expect(result.levels.map((l) => l.id)).toEqual(["off", "minimal", "low", "medium", "high"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("falls back to the base levels when the policy returns no levels", () => {
|
|
94
|
+
setThinkingPolicy(() => ({ levels: [] }));
|
|
95
|
+
const result = resolveModelThinking("openai", "gpt-5.4");
|
|
96
|
+
expect(result.levels.map((l) => l.id)).toEqual(["off", "minimal", "low", "medium", "high"]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("resolveModelThinkingForRef", () => {
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
resetFridayAgentForwardRuntimeForTest();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("splits a provider/model ref and forwards both parts to the policy", () => {
|
|
106
|
+
const { calls } = setThinkingPolicy(() => ({ levels: [{ id: "off", label: "off" }] }));
|
|
107
|
+
resolveModelThinkingForRef("deepseek/deepseek-v4-pro");
|
|
108
|
+
expect(calls).toEqual([{ provider: "deepseek", model: "deepseek-v4-pro" }]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("treats a bare model id as having no provider", () => {
|
|
112
|
+
const { calls } = setThinkingPolicy(() => ({ levels: [{ id: "off", label: "off" }] }));
|
|
113
|
+
resolveModelThinkingForRef("just-a-model");
|
|
114
|
+
expect(calls).toEqual([{ provider: null, model: "just-a-model" }]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns the base set for an empty ref without calling the runtime", () => {
|
|
118
|
+
const { calls } = setThinkingPolicy(() => ({ levels: [{ id: "off", label: "off" }] }));
|
|
119
|
+
const result = resolveModelThinkingForRef("");
|
|
120
|
+
expect(calls).toEqual([]);
|
|
121
|
+
expect(result.levels.map((l) => l.id)).toEqual(["off", "minimal", "low", "medium", "high"]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("isThinkingLevelSupportedForRef", () => {
|
|
126
|
+
afterEach(() => {
|
|
127
|
+
resetFridayAgentForwardRuntimeForTest();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("accepts a level the model supports and rejects one it does not", () => {
|
|
131
|
+
setThinkingPolicy(() => ({
|
|
132
|
+
levels: [
|
|
133
|
+
{ id: "off", label: "off" },
|
|
134
|
+
{ id: "low", label: "low" },
|
|
135
|
+
{ id: "medium", label: "medium" },
|
|
136
|
+
{ id: "high", label: "high" },
|
|
137
|
+
{ id: "max", label: "max" },
|
|
138
|
+
],
|
|
139
|
+
}));
|
|
140
|
+
expect(isThinkingLevelSupportedForRef("deepseek/deepseek-v4", "max")).toBe(true);
|
|
141
|
+
expect(isThinkingLevelSupportedForRef("deepseek/deepseek-v4", "xhigh")).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
|
|
2
|
+
import { splitModelRef } from "./session/session-manager.js";
|
|
3
|
+
|
|
4
|
+
export interface ThinkingLevelOption {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ResolvedModelThinking {
|
|
10
|
+
/** Ordered (off → highest) levels the model supports. */
|
|
11
|
+
levels: ThinkingLevelOption[];
|
|
12
|
+
/** Provider/model default level, when the gateway reports one. */
|
|
13
|
+
default?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Base five levels used when the running gateway is too old to expose `resolveThinkingPolicy`, or
|
|
18
|
+
* when resolution fails. Mirrors core `BASE_THINKING_LEVELS` (label === id for the base set).
|
|
19
|
+
*/
|
|
20
|
+
const BASE_THINKING_LEVELS: ThinkingLevelOption[] = [
|
|
21
|
+
{ id: "off", label: "off" },
|
|
22
|
+
{ id: "minimal", label: "minimal" },
|
|
23
|
+
{ id: "low", label: "low" },
|
|
24
|
+
{ id: "medium", label: "medium" },
|
|
25
|
+
{ id: "high", label: "high" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolves the thinking-level option set for `provider`/`modelId` from the running gateway's
|
|
30
|
+
* provider plugins + model catalog. The set varies per model (e.g. GPT-5.4 adds `xhigh`, Gemini adds
|
|
31
|
+
* `adaptive`, DeepSeek V4 adds `xhigh`/`max`, binary providers collapse to `off`/`on`). Falls back to
|
|
32
|
+
* the base five levels when the runtime API is unavailable.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveModelThinking(
|
|
35
|
+
provider: string | undefined | null,
|
|
36
|
+
modelId: string | undefined | null,
|
|
37
|
+
): ResolvedModelThinking {
|
|
38
|
+
const resolve = getFridayAgentForwardRuntime()?.resolveThinkingPolicy;
|
|
39
|
+
if (resolve) {
|
|
40
|
+
try {
|
|
41
|
+
const policy = resolve({ provider: provider ?? null, model: modelId ?? null });
|
|
42
|
+
if (policy?.levels?.length) {
|
|
43
|
+
return {
|
|
44
|
+
levels: policy.levels.map((l) => ({ id: l.id, label: l.label })),
|
|
45
|
+
default: policy.defaultLevel ?? undefined,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Fall through to the base levels below.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { levels: BASE_THINKING_LEVELS };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolves thinking levels for a full `provider/model` ref (or bare model id). */
|
|
56
|
+
export function resolveModelThinkingForRef(modelRef: string | undefined | null): ResolvedModelThinking {
|
|
57
|
+
if (!modelRef) return { levels: BASE_THINKING_LEVELS };
|
|
58
|
+
const split = splitModelRef(modelRef);
|
|
59
|
+
return resolveModelThinking(split.provider, split.modelId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether `level` is a supported thinking level for the given `provider/model` ref. */
|
|
63
|
+
export function isThinkingLevelSupportedForRef(
|
|
64
|
+
modelRef: string | undefined | null,
|
|
65
|
+
level: string,
|
|
66
|
+
): boolean {
|
|
67
|
+
return resolveModelThinkingForRef(modelRef).levels.some((l) => l.id === level);
|
|
68
|
+
}
|