@syengup/friday-channel-next 0.1.30 → 0.1.37
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/index.js +1 -1
- package/dist/src/agent/abort-run.d.ts +12 -1
- package/dist/src/agent/abort-run.js +24 -9
- package/dist/src/agent/dispatch-bridge.d.ts +1 -1
- package/dist/src/agent/media-bridge.d.ts +8 -1
- package/dist/src/agent/media-bridge.js +23 -2
- package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
- package/dist/src/agent/node-pairing-bridge.js +6 -2
- package/dist/src/agent/subagent-registry.js +0 -3
- 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 +48 -15
- package/dist/src/channel.js +22 -3
- package/dist/src/collect-message-media-paths.js +10 -1
- package/dist/src/friday-session.js +34 -10
- package/dist/src/history/normalize-message.js +22 -8
- package/dist/src/http/handlers/agent-config.d.ts +27 -0
- package/dist/src/http/handlers/agent-config.js +188 -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 +14 -6
- package/dist/src/http/handlers/device-approve.js +3 -1
- package/dist/src/http/handlers/files-download.js +6 -8
- package/dist/src/http/handlers/files.d.ts +16 -0
- package/dist/src/http/handlers/files.js +81 -13
- package/dist/src/http/handlers/health.js +18 -4
- package/dist/src/http/handlers/history-messages.js +1 -1
- package/dist/src/http/handlers/history-sessions.js +5 -3
- package/dist/src/http/handlers/messages.js +33 -14
- package/dist/src/http/handlers/models-list.d.ts +5 -0
- package/dist/src/http/handlers/models-list.js +9 -1
- package/dist/src/http/handlers/nodes-approve.js +1 -6
- package/dist/src/http/handlers/plugin-info.js +1 -1
- package/dist/src/http/handlers/sessions-settings.js +15 -10
- package/dist/src/http/server.js +27 -2
- package/dist/src/link-preview/og-parse.js +3 -1
- package/dist/src/link-preview/ssrf-guard.js +6 -2
- package/dist/src/media-fetch.js +4 -1
- package/dist/src/plugin-install-info.js +4 -1
- package/dist/src/session/session-manager.js +9 -3
- package/dist/src/session-usage-store.js +3 -1
- package/dist/src/skills-discovery.d.ts +59 -0
- package/dist/src/skills-discovery.js +252 -0
- package/dist/src/sse/offline-queue.js +4 -1
- 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 +191 -0
- package/dist/src/upgrade-runtime.d.ts +1 -1
- package/dist/src/version.js +4 -2
- package/index.ts +43 -35
- package/install.js +131 -43
- package/package.json +10 -1
- package/src/agent/abort-run.ts +23 -8
- package/src/agent/dispatch-bridge.ts +2 -1
- package/src/agent/media-bridge.test.ts +71 -0
- package/src/agent/media-bridge.ts +30 -1
- package/src/agent/node-pairing-bridge.ts +29 -15
- package/src/agent/run-usage-accumulator.ts +4 -2
- package/src/agent/subagent-registry.ts +0 -4
- package/src/agent-forward-runtime.ts +11 -0
- package/src/agent-id.ts +24 -0
- package/src/agent-run-context-bridge.ts +3 -1
- package/src/channel-actions.test.ts +57 -4
- package/src/channel-actions.ts +41 -15
- package/src/channel.lifecycle.test.ts +41 -0
- package/src/channel.outbound.test.ts +18 -4
- package/src/channel.ts +140 -120
- package/src/collect-message-media-paths.ts +15 -6
- package/src/config.ts +1 -4
- package/src/e2e/agents-list.e2e.test.ts +9 -2
- package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
- package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
- package/src/e2e/auto-approve.integration.test.ts +13 -7
- package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
- package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
- package/src/e2e/offline-replay.e2e.test.ts +17 -3
- package/src/e2e/send-text.e2e.test.ts +11 -2
- package/src/e2e/slash-commands.e2e.test.ts +5 -1
- package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
- package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
- package/src/e2e/subagent.e2e.test.ts +136 -53
- package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
- package/src/friday-session.forward-agent.test.ts +44 -12
- package/src/friday-session.ts +44 -20
- package/src/history/normalize-message.test.ts +35 -8
- package/src/history/normalize-message.ts +24 -12
- package/src/history/read-transcript.ts +1 -4
- package/src/http/handlers/agent-config.test.ts +212 -0
- package/src/http/handlers/agent-config.ts +232 -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.test.ts +1 -5
- package/src/http/handlers/agents-list.ts +1 -22
- package/src/http/handlers/cancel.test.ts +23 -4
- package/src/http/handlers/cancel.ts +14 -6
- package/src/http/handlers/device-approve.test.ts +12 -3
- package/src/http/handlers/device-approve.ts +33 -21
- package/src/http/handlers/files-download.ts +17 -13
- package/src/http/handlers/files.test.ts +120 -0
- package/src/http/handlers/files.ts +115 -17
- package/src/http/handlers/health.test.ts +43 -11
- package/src/http/handlers/health.ts +22 -6
- package/src/http/handlers/history-messages.test.ts +51 -9
- package/src/http/handlers/history-messages.ts +4 -1
- package/src/http/handlers/history-sessions.test.ts +46 -9
- package/src/http/handlers/history-sessions.ts +5 -3
- package/src/http/handlers/history-set-title.test.ts +14 -5
- package/src/http/handlers/link-preview.test.ts +57 -16
- package/src/http/handlers/link-preview.ts +4 -1
- package/src/http/handlers/messages.test.ts +12 -8
- package/src/http/handlers/messages.ts +64 -21
- package/src/http/handlers/models-list.test.ts +114 -0
- package/src/http/handlers/models-list.ts +26 -8
- package/src/http/handlers/nodes-approve.test.ts +15 -4
- package/src/http/handlers/nodes-approve.ts +38 -40
- package/src/http/handlers/plugin-info.ts +5 -6
- package/src/http/handlers/plugin-upgrade.ts +4 -1
- package/src/http/handlers/sessions-settings.ts +16 -11
- package/src/http/handlers/sse.ts +3 -1
- package/src/http/server.ts +33 -6
- package/src/link-preview/og-parse.test.ts +6 -2
- package/src/link-preview/og-parse.ts +10 -3
- package/src/link-preview/preview-service.ts +4 -1
- package/src/link-preview/ssrf-guard.test.ts +78 -16
- package/src/link-preview/ssrf-guard.ts +7 -2
- package/src/media-fetch.test.ts +8 -3
- package/src/media-fetch.ts +5 -3
- package/src/openclaw.d.ts +41 -10
- package/src/plugin-install-info.ts +20 -9
- package/src/run-metadata.ts +2 -1
- package/src/session/session-manager.ts +19 -11
- package/src/session-usage-snapshot.ts +3 -1
- package/src/session-usage-store.ts +3 -1
- package/src/skills-discovery.test.ts +152 -0
- package/src/skills-discovery.ts +264 -0
- package/src/sse/emitter.test.ts +1 -1
- package/src/sse/emitter.ts +9 -3
- package/src/sse/offline-queue.ts +17 -8
- package/src/test-support/app-simulator.ts +17 -3
- package/src/test-support/mock-dispatch.ts +17 -4
- package/src/thinking-levels.test.ts +143 -0
- package/src/thinking-levels.ts +70 -0
- package/src/tool-catalog.ts +261 -0
- package/src/upgrade-runtime.ts +4 -2
- package/src/version.ts +6 -2
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,152 @@
|
|
|
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("scans only the target agent's own workspace (not the default agent's), plus 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
|
+
// Default agent "main" workspace — its skills must NOT leak into other agents' catalogs.
|
|
54
|
+
makeSkills(path.join(configRoot, "workspace", "skills"), ["alpha"]);
|
|
55
|
+
// operator's own workspace — opencli also lives in the managed dir below, to prove
|
|
56
|
+
// workspace > installed precedence in dedup.
|
|
57
|
+
makeSkills(path.join(configRoot, "workspace", "agents", "operator", "skills"), [
|
|
58
|
+
"beta",
|
|
59
|
+
"opencli",
|
|
60
|
+
]);
|
|
61
|
+
// managed dir: <configDir>/skills (sibling of the default workspace)
|
|
62
|
+
makeSkills(path.join(configRoot, "skills"), ["managed-one", "opencli"]);
|
|
63
|
+
// config extraDirs
|
|
64
|
+
makeSkills(extraDir, ["gamma"]);
|
|
65
|
+
|
|
66
|
+
const cfg = {
|
|
67
|
+
agents: { list: [{ id: "main", default: true }, { id: "operator" }] },
|
|
68
|
+
skills: { load: { extraDirs: [extraDir] } },
|
|
69
|
+
};
|
|
70
|
+
wire(configRoot, cfg);
|
|
71
|
+
|
|
72
|
+
const result = discoverAvailableSkills(cfg, "operator");
|
|
73
|
+
// "alpha" is main-only → absent for operator (regression guard for the main-leak bug).
|
|
74
|
+
expect(result.map((s) => s.id)).toEqual(["beta", "gamma", "managed-one", "opencli"]);
|
|
75
|
+
const bySource = Object.fromEntries(result.map((s) => [s.id, s.source]));
|
|
76
|
+
expect(bySource).toEqual({
|
|
77
|
+
beta: "workspace",
|
|
78
|
+
opencli: "workspace", // workspace wins over the managed-dir duplicate
|
|
79
|
+
"managed-one": "installed",
|
|
80
|
+
gamma: "extra",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("ignores directories without SKILL.md", () => {
|
|
85
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "friday-disc-"));
|
|
86
|
+
const configRoot = path.join(root, "configdir");
|
|
87
|
+
const skillsDir = path.join(configRoot, "workspace", "skills");
|
|
88
|
+
makeSkills(skillsDir, ["real"]);
|
|
89
|
+
fs.mkdirSync(path.join(skillsDir, "empty-dir"), { recursive: true }); // no SKILL.md
|
|
90
|
+
|
|
91
|
+
const cfg = { agents: { list: [{ id: "main", default: true }] } };
|
|
92
|
+
wire(configRoot, cfg);
|
|
93
|
+
|
|
94
|
+
expect(discoverAvailableSkills(cfg, "main").map((s) => s.id)).toEqual(["real"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("uses the SKILL.md frontmatter name over the dir name, and finds nested skills", () => {
|
|
98
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "friday-disc-"));
|
|
99
|
+
const configRoot = path.join(root, "configdir");
|
|
100
|
+
const skillsDir = path.join(configRoot, "workspace", "skills");
|
|
101
|
+
|
|
102
|
+
// dir name != declared name
|
|
103
|
+
fs.mkdirSync(path.join(skillsDir, "self-improving-agent"), { recursive: true });
|
|
104
|
+
fs.writeFileSync(
|
|
105
|
+
path.join(skillsDir, "self-improving-agent", "SKILL.md"),
|
|
106
|
+
'---\nname: self-improvement\ndescription: "x"\n---\n# body',
|
|
107
|
+
);
|
|
108
|
+
// nested skill (redskill-style): SKILL.md two levels under the skills dir
|
|
109
|
+
const nested = path.join(skillsDir, "luckincoffee-mycoffeeskill", "my-coffee-skill");
|
|
110
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
111
|
+
fs.writeFileSync(path.join(nested, "SKILL.md"), "---\nname: my-coffee\n---\n# body");
|
|
112
|
+
|
|
113
|
+
const cfg = { agents: { list: [{ id: "main", default: true }] } };
|
|
114
|
+
wire(configRoot, cfg);
|
|
115
|
+
|
|
116
|
+
const result = discoverAvailableSkills(cfg, "main");
|
|
117
|
+
expect(result.map((s) => s.id)).toEqual(["my-coffee", "self-improvement"]);
|
|
118
|
+
expect(result.find((s) => s.id === "self-improvement")?.description).toBe("x");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns [] without throwing when nothing is resolvable", () => {
|
|
122
|
+
resetFridayAgentForwardRuntimeForTest();
|
|
123
|
+
expect(discoverAvailableSkills({}, "main")).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("enabledExtensionNames", () => {
|
|
128
|
+
it("unions plugins.allow with entries[name].enabled === true", () => {
|
|
129
|
+
const cfg = {
|
|
130
|
+
plugins: {
|
|
131
|
+
allow: ["browser", "canvas"],
|
|
132
|
+
entries: {
|
|
133
|
+
browser: { enabled: true },
|
|
134
|
+
telegram: { enabled: true },
|
|
135
|
+
tavily: { enabled: false },
|
|
136
|
+
"open-prose": {},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
const names = enabledExtensionNames(cfg);
|
|
141
|
+
expect(names.has("browser")).toBe(true);
|
|
142
|
+
expect(names.has("canvas")).toBe(true);
|
|
143
|
+
expect(names.has("telegram")).toBe(true); // from entries.enabled
|
|
144
|
+
expect(names.has("tavily")).toBe(false); // enabled:false
|
|
145
|
+
expect(names.has("open-prose")).toBe(false); // no enabled flag
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns an empty set when plugins config is absent", () => {
|
|
149
|
+
expect(enabledExtensionNames({}).size).toBe(0);
|
|
150
|
+
expect(enabledExtensionNames(undefined).size).toBe(0);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
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 TARGET agent's own workspace `skills/` only — mirroring ControlUI,
|
|
16
|
+
* which scans the single workspace resolved for that agent and never folds
|
|
17
|
+
* in another agent's workspace (main is just another agent, not a shared pool)
|
|
18
|
+
* - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
|
|
19
|
+
* - built-in : bundled core skills (`<openclaw>/skills`)
|
|
20
|
+
* - extra : skills from ENABLED extensions (`<openclaw>/dist/extensions/<ext>/skills`,
|
|
21
|
+
* gated by `plugins.allow`/`entries.enabled` like ControlUI) + config
|
|
22
|
+
* `skills.load.extraDirs` — mirrors ControlUI's "EXTRA" bucket
|
|
23
|
+
* (core tags extension skills `source: "extension"`).
|
|
24
|
+
*
|
|
25
|
+
* Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
|
|
26
|
+
*
|
|
27
|
+
* A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
|
|
28
|
+
* the containing dir name) — NOT the dir name itself, which often differs (e.g. the
|
|
29
|
+
* `self-improving-agent/` dir declares `name: self-improvement`). The `description:`
|
|
30
|
+
* frontmatter field is surfaced too. Discovery is RECURSIVE (mirroring core's
|
|
31
|
+
* `loadSkills`): some skills nest the `SKILL.md` a few levels deep (e.g. redskill
|
|
32
|
+
* installs at `<pkg>/<sub>/<skill>/SKILL.md`).
|
|
33
|
+
*
|
|
34
|
+
* NOT included (out of scope for a name catalog): ClawHub remote-only skills and
|
|
35
|
+
* per-skill eligibility/`disabled` flags. The enabled set is the agent's own
|
|
36
|
+
* `skills[]` config, already returned separately by the config view.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import fs from "node:fs";
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
import { createRequire } from "node:module";
|
|
42
|
+
import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
|
|
43
|
+
import { DEFAULT_AGENT_ID, normalizeAgentId } from "./agent-id.js";
|
|
44
|
+
|
|
45
|
+
/** Depth limit for the recursive walk under each source dir (skills nest a few levels). */
|
|
46
|
+
const MAX_SKILL_WALK_DEPTH = 6;
|
|
47
|
+
const IGNORED_WALK_DIRS = new Set(["node_modules", ".git"]);
|
|
48
|
+
|
|
49
|
+
export type SkillSource = "workspace" | "built-in" | "installed" | "extra";
|
|
50
|
+
|
|
51
|
+
export interface DiscoveredSkill {
|
|
52
|
+
id: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
source: SkillSource;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Extract `name`/`description` from a SKILL.md YAML frontmatter block. */
|
|
58
|
+
function parseSkillFrontmatter(content: string): { name?: string; description?: string } {
|
|
59
|
+
const lines = content.split(/\r?\n/);
|
|
60
|
+
if (lines[0]?.trim() !== "---") return {};
|
|
61
|
+
let name: string | undefined;
|
|
62
|
+
let description: string | undefined;
|
|
63
|
+
for (let i = 1; i < lines.length; i++) {
|
|
64
|
+
if (lines[i].trim() === "---") break;
|
|
65
|
+
const m = /^(name|description)\s*:\s*(.+?)\s*$/.exec(lines[i]);
|
|
66
|
+
if (!m) continue;
|
|
67
|
+
let v = m[2].trim();
|
|
68
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
69
|
+
v = v.slice(1, -1);
|
|
70
|
+
}
|
|
71
|
+
v = v.trim();
|
|
72
|
+
if (!v) continue;
|
|
73
|
+
if (m[1] === "name" && name === undefined) name = v;
|
|
74
|
+
else if (m[1] === "description" && description === undefined) description = v;
|
|
75
|
+
}
|
|
76
|
+
return { name, description };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Collect skills reachable under `root`, tagging each with `source`. A directory
|
|
81
|
+
* containing `SKILL.md` IS a skill (id = frontmatter `name`, else dir name) and is
|
|
82
|
+
* not descended into further; other directories are recursed up to a bounded depth.
|
|
83
|
+
* First occurrence of an id wins (call higher-priority sources first). Best-effort.
|
|
84
|
+
*/
|
|
85
|
+
function collectSkills(
|
|
86
|
+
root: string,
|
|
87
|
+
source: SkillSource,
|
|
88
|
+
out: Map<string, DiscoveredSkill>,
|
|
89
|
+
depth = 0,
|
|
90
|
+
): void {
|
|
91
|
+
if (depth > MAX_SKILL_WALK_DEPTH) return;
|
|
92
|
+
let entries: fs.Dirent[];
|
|
93
|
+
try {
|
|
94
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (entries.some((e) => e.isFile() && e.name === "SKILL.md")) {
|
|
99
|
+
let fm: { name?: string; description?: string } = {};
|
|
100
|
+
try {
|
|
101
|
+
fm = parseSkillFrontmatter(fs.readFileSync(path.join(root, "SKILL.md"), "utf-8"));
|
|
102
|
+
} catch {
|
|
103
|
+
// unreadable frontmatter → fall back to dir name, no description
|
|
104
|
+
}
|
|
105
|
+
const id = fm.name ?? path.basename(root);
|
|
106
|
+
if (!out.has(id)) out.set(id, { id, description: fm.description, source });
|
|
107
|
+
return; // a skill is a leaf — don't treat its internals as nested skills
|
|
108
|
+
}
|
|
109
|
+
for (const e of entries) {
|
|
110
|
+
if (e.isDirectory() && !e.name.startsWith(".") && !IGNORED_WALK_DIRS.has(e.name)) {
|
|
111
|
+
collectSkills(path.join(root, e.name), source, out, depth + 1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let cachedOpenClawRoot: string | null | undefined;
|
|
117
|
+
|
|
118
|
+
/** Locate the installed `openclaw` package root (cached). Shared with tool-catalog discovery. */
|
|
119
|
+
export function resolveOpenClawRoot(): string | null {
|
|
120
|
+
if (cachedOpenClawRoot !== undefined) return cachedOpenClawRoot;
|
|
121
|
+
cachedOpenClawRoot = computeOpenClawRoot();
|
|
122
|
+
return cachedOpenClawRoot;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function computeOpenClawRoot(): string | null {
|
|
126
|
+
const starts: string[] = [];
|
|
127
|
+
// Primary: resolve a subpath this plugin already imports (works inside the gateway
|
|
128
|
+
// where `openclaw/*` is resolvable). Standalone (e.g. unit tests) this throws → skipped.
|
|
129
|
+
try {
|
|
130
|
+
starts.push(createRequire(import.meta.url).resolve("openclaw/plugin-sdk/plugin-entry"));
|
|
131
|
+
} catch {
|
|
132
|
+
// not resolvable outside the gateway runtime
|
|
133
|
+
}
|
|
134
|
+
// Fallback: the gateway process entry (`<openclaw>/dist/index.js`) — the plugin runs in it.
|
|
135
|
+
if (typeof process.argv[1] === "string") starts.push(process.argv[1]);
|
|
136
|
+
|
|
137
|
+
for (const start of starts) {
|
|
138
|
+
let dir = path.dirname(start);
|
|
139
|
+
for (let i = 0; i < 10 && dir !== path.dirname(dir); i++) {
|
|
140
|
+
try {
|
|
141
|
+
const pkgPath = path.join(dir, "package.json");
|
|
142
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: string };
|
|
143
|
+
if (pkg?.name === "openclaw") return dir;
|
|
144
|
+
} catch {
|
|
145
|
+
// keep walking up
|
|
146
|
+
}
|
|
147
|
+
dir = path.dirname(dir);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The set of bundled extensions enabled for this install — `plugins.allow` plus any
|
|
155
|
+
* `plugins.entries[name].enabled === true`. ControlUI only surfaces skills from
|
|
156
|
+
* enabled extensions, so we gate on the same set (extension dir name == plugin id).
|
|
157
|
+
*/
|
|
158
|
+
export function enabledExtensionNames(cfg: unknown): Set<string> {
|
|
159
|
+
const plugins = (cfg as Record<string, unknown> | undefined)?.plugins as
|
|
160
|
+
| Record<string, unknown>
|
|
161
|
+
| undefined;
|
|
162
|
+
const names = new Set<string>();
|
|
163
|
+
const allow = plugins?.allow;
|
|
164
|
+
if (Array.isArray(allow)) for (const n of allow) if (typeof n === "string") names.add(n);
|
|
165
|
+
const entries = plugins?.entries as Record<string, unknown> | undefined;
|
|
166
|
+
if (entries && typeof entries === "object") {
|
|
167
|
+
for (const [name, val] of Object.entries(entries)) {
|
|
168
|
+
if (val && typeof val === "object" && (val as Record<string, unknown>).enabled === true)
|
|
169
|
+
names.add(name);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return names;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Bundled skill source dirs inside the openclaw install, tagged like ControlUI:
|
|
177
|
+
* core `<openclaw>/skills` → "built-in"; per-extension `dist/extensions/<ext>/skills`
|
|
178
|
+
* → "extra" (core tags these `source: "extension"`). Extension skills are included
|
|
179
|
+
* only when the extension is enabled, matching ControlUI's EXTRA bucket.
|
|
180
|
+
*/
|
|
181
|
+
function bundledSkillSources(
|
|
182
|
+
enabledExtensions: Set<string>,
|
|
183
|
+
): Array<{ dir: string; source: SkillSource }> {
|
|
184
|
+
const root = resolveOpenClawRoot();
|
|
185
|
+
if (!root) return [];
|
|
186
|
+
const out: Array<{ dir: string; source: SkillSource }> = [
|
|
187
|
+
{ dir: path.join(root, "skills"), source: "built-in" },
|
|
188
|
+
];
|
|
189
|
+
try {
|
|
190
|
+
const extRoot = path.join(root, "dist", "extensions");
|
|
191
|
+
for (const ext of fs.readdirSync(extRoot, { withFileTypes: true })) {
|
|
192
|
+
if (ext.isDirectory() && enabledExtensions.has(ext.name)) {
|
|
193
|
+
out.push({ dir: path.join(extRoot, ext.name, "skills"), source: "extra" });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// no extensions dir on this build
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function resolveDefaultAgentId(cfg: Record<string, unknown> | undefined): string {
|
|
203
|
+
const list = (cfg?.agents as Record<string, unknown> | undefined)?.list as
|
|
204
|
+
| Array<Record<string, unknown>>
|
|
205
|
+
| undefined;
|
|
206
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
207
|
+
const def = list.find((a) => a?.default === true) ?? list[0];
|
|
208
|
+
if (def?.id) return normalizeAgentId(def.id);
|
|
209
|
+
}
|
|
210
|
+
return DEFAULT_AGENT_ID;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Full set of skills `agentId` can load, sorted by id, each tagged with its source
|
|
215
|
+
* category. Aggregates the TARGET agent's own workspace, the managed dir, config extra
|
|
216
|
+
* dirs, and bundled core/extension skills. Every source is optional and failure-tolerant.
|
|
217
|
+
*/
|
|
218
|
+
export function discoverAvailableSkills(cfg: unknown, agentId: string): DiscoveredSkill[] {
|
|
219
|
+
const c = cfg as Record<string, unknown> | undefined;
|
|
220
|
+
const resolveWs = getFridayAgentForwardRuntime()?.resolveAgentWorkspaceDir;
|
|
221
|
+
const sources: Array<{ dir: string; source: SkillSource }> = [];
|
|
222
|
+
|
|
223
|
+
if (resolveWs) {
|
|
224
|
+
// Workspace skills come ONLY from the target agent's own workspace — matching ControlUI's
|
|
225
|
+
// `resolveSkillsAgentWorkspace`→`buildWorkspaceSkillStatus(workspaceDir)`, which scans the
|
|
226
|
+
// single resolved workspace. Folding in the default agent's workspace (the old behavior)
|
|
227
|
+
// leaked main's skills into every other agent's catalog.
|
|
228
|
+
try {
|
|
229
|
+
const ws = resolveWs(cfg, agentId);
|
|
230
|
+
if (ws) sources.push({ dir: path.join(ws, "skills"), source: "workspace" });
|
|
231
|
+
} catch {
|
|
232
|
+
// skip unresolvable workspace
|
|
233
|
+
}
|
|
234
|
+
// Managed skills dir: `<configDir>/skills`. It is agent-independent; anchor it off the
|
|
235
|
+
// DEFAULT agent's workspace parent (the default workspace lives directly under configDir,
|
|
236
|
+
// whereas non-default workspaces may be nested under it).
|
|
237
|
+
try {
|
|
238
|
+
const defaultWs = resolveWs(cfg, resolveDefaultAgentId(c));
|
|
239
|
+
if (defaultWs)
|
|
240
|
+
sources.push({ dir: path.join(path.dirname(defaultWs), "skills"), source: "installed" });
|
|
241
|
+
} catch {
|
|
242
|
+
// skip unresolvable managed dir
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const extraDirs = (
|
|
247
|
+
(c?.skills as Record<string, unknown> | undefined)?.load as Record<string, unknown> | undefined
|
|
248
|
+
)?.extraDirs;
|
|
249
|
+
if (Array.isArray(extraDirs)) {
|
|
250
|
+
for (const d of extraDirs)
|
|
251
|
+
if (typeof d === "string" && d.trim()) sources.push({ dir: d.trim(), source: "extra" });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
sources.push(...bundledSkillSources(enabledExtensionNames(c)));
|
|
255
|
+
|
|
256
|
+
const out = new Map<string, DiscoveredSkill>();
|
|
257
|
+
for (const { dir, source } of sources) collectSkills(dir, source, out, 0);
|
|
258
|
+
return [...out.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Test-only: reset the cached openclaw root. */
|
|
262
|
+
export function resetOpenClawRootCacheForTest(): void {
|
|
263
|
+
cachedOpenClawRoot = undefined;
|
|
264
|
+
}
|
package/src/sse/emitter.test.ts
CHANGED
|
@@ -78,7 +78,7 @@ describe("sseEmitter", () => {
|
|
|
78
78
|
const body = c.writes.join("");
|
|
79
79
|
expect(body).toContain("id: 2");
|
|
80
80
|
expect(body).toContain("id: 3");
|
|
81
|
-
expect(body).not.toContain(
|
|
81
|
+
expect(body).not.toContain('text":"a"');
|
|
82
82
|
|
|
83
83
|
sseEmitter.removeConnection("device-replay");
|
|
84
84
|
});
|
package/src/sse/emitter.ts
CHANGED
|
@@ -4,7 +4,14 @@ import { fridaySseOfflineQueue } from "./offline-queue.js";
|
|
|
4
4
|
|
|
5
5
|
const logger = createFridayNextLogger("sse", "info");
|
|
6
6
|
|
|
7
|
-
export type SseEventType =
|
|
7
|
+
export type SseEventType =
|
|
8
|
+
| "connected"
|
|
9
|
+
| "agent"
|
|
10
|
+
| "deliver"
|
|
11
|
+
| "tool-hook"
|
|
12
|
+
| "outbound"
|
|
13
|
+
| "ping"
|
|
14
|
+
| "subagent";
|
|
8
15
|
|
|
9
16
|
export interface SseEvent {
|
|
10
17
|
type: SseEventType;
|
|
@@ -36,8 +43,7 @@ export class SseConnection {
|
|
|
36
43
|
|
|
37
44
|
send(entry: BacklogEntry | SseEvent, flushNow?: boolean): void {
|
|
38
45
|
if (this.closed) return;
|
|
39
|
-
const normalized =
|
|
40
|
-
"id" in entry && "event" in entry ? entry : { id: Date.now(), event: entry as SseEvent };
|
|
46
|
+
const normalized = "id" in entry && "event" in entry ? entry : { id: Date.now(), event: entry };
|
|
41
47
|
const payload = JSON.stringify(normalized.event.data);
|
|
42
48
|
this.pending.push(
|
|
43
49
|
`id: ${normalized.id}\nevent: ${normalized.event.type}\ndata: ${payload}\n\n`,
|
package/src/sse/offline-queue.ts
CHANGED
|
@@ -21,7 +21,9 @@ export function setOfflineQueueBaseDirForTest(dir: string | null): void {
|
|
|
21
21
|
export function resolveFridayNextEventsQueueDir(): string {
|
|
22
22
|
if (testQueueBaseDir) return testQueueBaseDir;
|
|
23
23
|
try {
|
|
24
|
-
const cfg = resolveFridayNextConfig(
|
|
24
|
+
const cfg = resolveFridayNextConfig(
|
|
25
|
+
getHostOpenClawConfigSnapshot(getFridayNextRuntime().config),
|
|
26
|
+
);
|
|
25
27
|
return path.join(path.dirname(cfg.historyDir), "events-queue");
|
|
26
28
|
} catch {
|
|
27
29
|
return path.join(os.homedir(), ".openclaw", "friday-next", "events-queue");
|
|
@@ -71,7 +73,13 @@ export class FridaySseOfflineQueue {
|
|
|
71
73
|
return this.scanMaxId(deviceId.trim().toUpperCase());
|
|
72
74
|
}
|
|
73
75
|
|
|
74
|
-
append(
|
|
76
|
+
append(
|
|
77
|
+
deviceId: string,
|
|
78
|
+
id: number,
|
|
79
|
+
event: string,
|
|
80
|
+
data: Record<string, unknown>,
|
|
81
|
+
backlogLimit: number,
|
|
82
|
+
): void {
|
|
75
83
|
if (event === "connected") return;
|
|
76
84
|
this.ensureDir();
|
|
77
85
|
const file = this.devicePath(deviceId);
|
|
@@ -119,7 +127,12 @@ export class FridaySseOfflineQueue {
|
|
|
119
127
|
if (!line.trim()) continue;
|
|
120
128
|
try {
|
|
121
129
|
const o = JSON.parse(line) as PersistedSseEntry;
|
|
122
|
-
if (
|
|
130
|
+
if (
|
|
131
|
+
typeof o.id === "number" &&
|
|
132
|
+
typeof o.event === "string" &&
|
|
133
|
+
o.data &&
|
|
134
|
+
typeof o.data === "object"
|
|
135
|
+
) {
|
|
123
136
|
all.push(o);
|
|
124
137
|
}
|
|
125
138
|
} catch {
|
|
@@ -128,11 +141,7 @@ export class FridaySseOfflineQueue {
|
|
|
128
141
|
}
|
|
129
142
|
if (all.length <= keep) return;
|
|
130
143
|
const slice = all.slice(-keep);
|
|
131
|
-
fs.writeFileSync(
|
|
132
|
-
file,
|
|
133
|
-
slice.map((e) => JSON.stringify(e) + "\n").join(""),
|
|
134
|
-
"utf8",
|
|
135
|
-
);
|
|
144
|
+
fs.writeFileSync(file, slice.map((e) => JSON.stringify(e) + "\n").join(""), "utf8");
|
|
136
145
|
}
|
|
137
146
|
}
|
|
138
147
|
|
|
@@ -68,7 +68,9 @@ class MockRes extends Writable {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function createRouteHarness() {
|
|
71
|
-
let routeHandler:
|
|
71
|
+
let routeHandler:
|
|
72
|
+
| ((req: Readable & { method?: string; url?: string }, res: Writable) => Promise<boolean>)
|
|
73
|
+
| null = null;
|
|
72
74
|
const fakeApi = {
|
|
73
75
|
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
|
74
76
|
registerHttpRoute(route: { handler: (req: never, res: never) => Promise<boolean> }) {
|
|
@@ -193,7 +195,14 @@ export function createAppSimulator(opts?: { deviceId?: string; token?: string })
|
|
|
193
195
|
});
|
|
194
196
|
return { status: res.statusCode, body: jsonBody(res) };
|
|
195
197
|
},
|
|
196
|
-
async uploadFiles(
|
|
198
|
+
async uploadFiles(
|
|
199
|
+
parts: Array<{
|
|
200
|
+
name: string;
|
|
201
|
+
filename: string;
|
|
202
|
+
contentType: string;
|
|
203
|
+
content: string | Buffer;
|
|
204
|
+
}>,
|
|
205
|
+
) {
|
|
197
206
|
const boundary = "----friday-next-e2e-boundary";
|
|
198
207
|
const chunks: Buffer[] = [];
|
|
199
208
|
for (const part of parts) {
|
|
@@ -235,7 +244,12 @@ export function createAppSimulator(opts?: { deviceId?: string; token?: string })
|
|
|
235
244
|
const res = await request({ method: "OPTIONS", path, headers: { origin } });
|
|
236
245
|
return { status: res.statusCode, headers: res.headers };
|
|
237
246
|
},
|
|
238
|
-
async rawRequest(arg: {
|
|
247
|
+
async rawRequest(arg: {
|
|
248
|
+
method: string;
|
|
249
|
+
path: string;
|
|
250
|
+
headers?: Headers;
|
|
251
|
+
body?: string | Buffer;
|
|
252
|
+
}) {
|
|
239
253
|
const res = await request(arg);
|
|
240
254
|
return { status: res.statusCode, body: res.body.toString("utf-8"), headers: res.headers };
|
|
241
255
|
},
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
__setMockFridayDispatchForTests,
|
|
3
|
+
__resetMockFridayDispatchForTests,
|
|
4
|
+
} from "../agent/dispatch-bridge.js";
|
|
2
5
|
import { forwardAgentEventRaw } from "../friday-session.js";
|
|
3
6
|
|
|
4
|
-
type DispatchArg = Parameters<typeof __setMockFridayDispatchForTests>[0] extends (
|
|
7
|
+
type DispatchArg = Parameters<typeof __setMockFridayDispatchForTests>[0] extends (
|
|
8
|
+
arg: infer A,
|
|
9
|
+
) => unknown
|
|
5
10
|
? A
|
|
6
11
|
: never;
|
|
7
12
|
|
|
@@ -133,12 +138,20 @@ export class MockDispatchScript {
|
|
|
133
138
|
|
|
134
139
|
block(text: string, mediaUrls: string[] = [], audioAsVoice = false): this {
|
|
135
140
|
this.steps.push(async (_args, callbacks) => {
|
|
136
|
-
await callbacks.deliver?.(
|
|
141
|
+
await callbacks.deliver?.(
|
|
142
|
+
{ text, mediaUrls, audioAsVoice } as never,
|
|
143
|
+
{ kind: "block" } as never,
|
|
144
|
+
);
|
|
137
145
|
});
|
|
138
146
|
return this;
|
|
139
147
|
}
|
|
140
148
|
|
|
141
|
-
deliverFinal(payload: {
|
|
149
|
+
deliverFinal(payload: {
|
|
150
|
+
text: string;
|
|
151
|
+
mediaUrls?: string[];
|
|
152
|
+
audioAsVoice?: boolean;
|
|
153
|
+
isError?: boolean;
|
|
154
|
+
}): this {
|
|
142
155
|
this.steps.push(async (_args, callbacks) => {
|
|
143
156
|
await callbacks.deliver?.(payload as never, { kind: "final" } as never);
|
|
144
157
|
});
|