@syengup/friday-channel-next 0.1.36 → 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/dist/index.js +1 -1
- package/dist/src/agent/dispatch-bridge.d.ts +1 -1
- 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/channel-actions.js +3 -1
- package/dist/src/channel.js +0 -2
- 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.js +10 -4
- package/dist/src/http/handlers/cancel.js +4 -2
- 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.js +1 -1
- 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 +25 -11
- package/dist/src/http/handlers/models-list.js +1 -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/server.js +4 -2
- package/dist/src/link-preview/og-parse.js +3 -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 +5 -4
- package/dist/src/skills-discovery.js +27 -22
- package/dist/src/sse/offline-queue.js +4 -1
- package/dist/src/tool-catalog.js +2 -3
- package/dist/src/upgrade-runtime.d.ts +1 -1
- package/dist/src/version.js +3 -1
- package/index.ts +43 -35
- package/install.js +131 -43
- package/package.json +10 -1
- package/src/agent/abort-run.ts +2 -3
- package/src/agent/dispatch-bridge.ts +2 -1
- package/src/agent/media-bridge.ts +9 -2
- 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-run-context-bridge.ts +3 -1
- package/src/channel-actions.test.ts +10 -4
- package/src/channel-actions.ts +3 -1
- package/src/channel.outbound.test.ts +18 -4
- package/src/channel.ts +121 -123
- 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 +10 -3
- package/src/http/handlers/agent-config.ts +22 -8
- package/src/http/handlers/agents-list.test.ts +1 -5
- package/src/http/handlers/cancel.test.ts +12 -3
- package/src/http/handlers/cancel.ts +4 -2
- 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 +8 -2
- package/src/http/handlers/files.ts +21 -7
- 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 +57 -19
- package/src/http/handlers/models-list.ts +14 -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/sse.ts +3 -1
- package/src/http/server.ts +9 -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 +72 -15
- package/src/link-preview/ssrf-guard.ts +2 -1
- package/src/media-fetch.test.ts +7 -2
- package/src/media-fetch.ts +1 -2
- package/src/openclaw.d.ts +16 -9
- 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 +14 -10
- package/src/skills-discovery.ts +43 -27
- 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.ts +3 -1
- package/src/tool-catalog.ts +16 -7
- package/src/upgrade-runtime.ts +4 -2
- package/src/version.ts +5 -1
- package/tsconfig.json +1 -1
|
@@ -46,7 +46,9 @@ describe("parseHttpUrl", () => {
|
|
|
46
46
|
describe("assertPublicHttpUrl", () => {
|
|
47
47
|
it("rejects non-default ports", () => {
|
|
48
48
|
expect(() => assertPublicHttpUrl(new URL("http://example.com:8080/"))).toThrow(BlockedUrlError);
|
|
49
|
-
expect(() => assertPublicHttpUrl(new URL("https://example.com:8443/"))).toThrow(
|
|
49
|
+
expect(() => assertPublicHttpUrl(new URL("https://example.com:8443/"))).toThrow(
|
|
50
|
+
BlockedUrlError,
|
|
51
|
+
);
|
|
50
52
|
});
|
|
51
53
|
|
|
52
54
|
it("allows default and explicit 80/443 ports", () => {
|
|
@@ -95,7 +97,14 @@ describe("isPrivateAddress", () => {
|
|
|
95
97
|
});
|
|
96
98
|
|
|
97
99
|
it("passes public IPv4", () => {
|
|
98
|
-
for (const ip of [
|
|
100
|
+
for (const ip of [
|
|
101
|
+
"8.8.8.8",
|
|
102
|
+
"93.184.216.34",
|
|
103
|
+
"100.63.0.1",
|
|
104
|
+
"100.128.0.1",
|
|
105
|
+
"172.32.0.1",
|
|
106
|
+
"198.20.0.1",
|
|
107
|
+
]) {
|
|
99
108
|
expect(isPrivateAddress(ip), ip).toBe(false);
|
|
100
109
|
}
|
|
101
110
|
});
|
|
@@ -106,7 +115,15 @@ describe("isPrivateAddress", () => {
|
|
|
106
115
|
});
|
|
107
116
|
|
|
108
117
|
it("flags private/reserved IPv6 and mapped IPv4", () => {
|
|
109
|
-
for (const ip of [
|
|
118
|
+
for (const ip of [
|
|
119
|
+
"::1",
|
|
120
|
+
"::",
|
|
121
|
+
"fd00::1",
|
|
122
|
+
"fd12:3456::1",
|
|
123
|
+
"fe80::1",
|
|
124
|
+
"::ffff:10.0.0.1",
|
|
125
|
+
"::ffff:127.0.0.1",
|
|
126
|
+
]) {
|
|
110
127
|
expect(isPrivateAddress(ip), ip).toBe(true);
|
|
111
128
|
}
|
|
112
129
|
});
|
|
@@ -140,11 +157,15 @@ describe("assertResolvesPublic", () => {
|
|
|
140
157
|
{ address: "93.184.216.34", family: 4 },
|
|
141
158
|
{ address: "10.0.0.5", family: 4 },
|
|
142
159
|
] as never);
|
|
143
|
-
await expect(assertResolvesPublic(new URL("https://rebind.example.com/"))).rejects.toThrow(
|
|
160
|
+
await expect(assertResolvesPublic(new URL("https://rebind.example.com/"))).rejects.toThrow(
|
|
161
|
+
BlockedUrlError,
|
|
162
|
+
);
|
|
144
163
|
});
|
|
145
164
|
|
|
146
165
|
it("validates IP-literal hosts without a DNS lookup", async () => {
|
|
147
|
-
await expect(assertResolvesPublic(new URL("http://127.0.0.1/"))).rejects.toThrow(
|
|
166
|
+
await expect(assertResolvesPublic(new URL("http://127.0.0.1/"))).rejects.toThrow(
|
|
167
|
+
BlockedUrlError,
|
|
168
|
+
);
|
|
148
169
|
await expect(assertResolvesPublic(new URL("http://93.184.216.34/"))).resolves.toBeUndefined();
|
|
149
170
|
expect(lookupMock).not.toHaveBeenCalled();
|
|
150
171
|
});
|
|
@@ -164,7 +185,13 @@ describe("fetchPublicUrl", () => {
|
|
|
164
185
|
mockPublicDns();
|
|
165
186
|
vi.stubGlobal(
|
|
166
187
|
"fetch",
|
|
167
|
-
vi.fn(
|
|
188
|
+
vi.fn(
|
|
189
|
+
async () =>
|
|
190
|
+
new Response("<html>hi</html>", {
|
|
191
|
+
status: 200,
|
|
192
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
193
|
+
}),
|
|
194
|
+
),
|
|
168
195
|
);
|
|
169
196
|
const result = await fetchPublicUrl("https://example.com/page", opts);
|
|
170
197
|
expect(result?.finalUrl).toBe("https://example.com/page");
|
|
@@ -176,8 +203,15 @@ describe("fetchPublicUrl", () => {
|
|
|
176
203
|
mockPublicDns();
|
|
177
204
|
const fetchMock = vi
|
|
178
205
|
.fn()
|
|
179
|
-
.mockResolvedValueOnce(
|
|
180
|
-
|
|
206
|
+
.mockResolvedValueOnce(
|
|
207
|
+
new Response(null, {
|
|
208
|
+
status: 302,
|
|
209
|
+
headers: { location: "https://other.example.com/final" },
|
|
210
|
+
}),
|
|
211
|
+
)
|
|
212
|
+
.mockResolvedValueOnce(
|
|
213
|
+
new Response("ok", { status: 200, headers: { "content-type": "text/html" } }),
|
|
214
|
+
);
|
|
181
215
|
vi.stubGlobal("fetch", fetchMock);
|
|
182
216
|
const result = await fetchPublicUrl("https://example.com/start", opts);
|
|
183
217
|
expect(result?.finalUrl).toBe("https://other.example.com/final");
|
|
@@ -189,9 +223,14 @@ describe("fetchPublicUrl", () => {
|
|
|
189
223
|
mockPublicDns();
|
|
190
224
|
vi.stubGlobal(
|
|
191
225
|
"fetch",
|
|
192
|
-
vi.fn(
|
|
226
|
+
vi.fn(
|
|
227
|
+
async () =>
|
|
228
|
+
new Response(null, { status: 302, headers: { location: "http://127.0.0.1/admin" } }),
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
await expect(fetchPublicUrl("https://example.com/start", opts)).rejects.toThrow(
|
|
232
|
+
BlockedUrlError,
|
|
193
233
|
);
|
|
194
|
-
await expect(fetchPublicUrl("https://example.com/start", opts)).rejects.toThrow(BlockedUrlError);
|
|
195
234
|
});
|
|
196
235
|
|
|
197
236
|
it("throws BlockedUrlError for a directly-blocked URL", async () => {
|
|
@@ -203,7 +242,13 @@ describe("fetchPublicUrl", () => {
|
|
|
203
242
|
const big = "x".repeat(2048);
|
|
204
243
|
vi.stubGlobal(
|
|
205
244
|
"fetch",
|
|
206
|
-
vi.fn(
|
|
245
|
+
vi.fn(
|
|
246
|
+
async () =>
|
|
247
|
+
new Response(big, {
|
|
248
|
+
status: 200,
|
|
249
|
+
headers: { "content-type": "text/html", "content-length": "10" },
|
|
250
|
+
}),
|
|
251
|
+
),
|
|
207
252
|
);
|
|
208
253
|
expect(await fetchPublicUrl("https://example.com/big", opts)).toBeNull();
|
|
209
254
|
});
|
|
@@ -212,16 +257,25 @@ describe("fetchPublicUrl", () => {
|
|
|
212
257
|
mockPublicDns();
|
|
213
258
|
vi.stubGlobal(
|
|
214
259
|
"fetch",
|
|
215
|
-
vi.fn(
|
|
260
|
+
vi.fn(
|
|
261
|
+
async () =>
|
|
262
|
+
new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
|
|
263
|
+
),
|
|
216
264
|
);
|
|
217
265
|
expect(
|
|
218
|
-
await fetchPublicUrl("https://example.com/api", {
|
|
266
|
+
await fetchPublicUrl("https://example.com/api", {
|
|
267
|
+
...opts,
|
|
268
|
+
requireContentTypePrefixes: ["text/html", "application/xhtml+xml"],
|
|
269
|
+
}),
|
|
219
270
|
).toBeNull();
|
|
220
271
|
});
|
|
221
272
|
|
|
222
273
|
it("returns null on non-2xx and on DNS failure", async () => {
|
|
223
274
|
mockPublicDns();
|
|
224
|
-
vi.stubGlobal(
|
|
275
|
+
vi.stubGlobal(
|
|
276
|
+
"fetch",
|
|
277
|
+
vi.fn(async () => new Response("nope", { status: 404 })),
|
|
278
|
+
);
|
|
225
279
|
expect(await fetchPublicUrl("https://example.com/missing", opts)).toBeNull();
|
|
226
280
|
|
|
227
281
|
lookupMock.mockRejectedValue(new Error("ENOTFOUND"));
|
|
@@ -232,7 +286,10 @@ describe("fetchPublicUrl", () => {
|
|
|
232
286
|
mockPublicDns();
|
|
233
287
|
vi.stubGlobal(
|
|
234
288
|
"fetch",
|
|
235
|
-
vi.fn(
|
|
289
|
+
vi.fn(
|
|
290
|
+
async () =>
|
|
291
|
+
new Response(null, { status: 302, headers: { location: "https://example.com/loop" } }),
|
|
292
|
+
),
|
|
236
293
|
);
|
|
237
294
|
expect(await fetchPublicUrl("https://example.com/loop", opts)).toBeNull();
|
|
238
295
|
});
|
|
@@ -66,7 +66,8 @@ export function isPrivateAddress(ip: string): boolean {
|
|
|
66
66
|
|
|
67
67
|
function isPrivateIPv4(ip: string): boolean {
|
|
68
68
|
const parts = ip.split(".").map(Number);
|
|
69
|
-
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
|
|
69
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
|
|
70
|
+
return true;
|
|
70
71
|
const [a, b] = parts;
|
|
71
72
|
if (a === 0) return true; // 0.0.0.0/8
|
|
72
73
|
if (a === 10) return true; // 10/8
|
package/src/media-fetch.test.ts
CHANGED
|
@@ -21,7 +21,9 @@ describe("downloadRemoteMedia", () => {
|
|
|
21
21
|
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
22
22
|
vi.stubGlobal(
|
|
23
23
|
"fetch",
|
|
24
|
-
vi.fn(
|
|
24
|
+
vi.fn(
|
|
25
|
+
async () => new Response(bytes, { status: 200, headers: { "content-type": "image/png" } }),
|
|
26
|
+
),
|
|
25
27
|
);
|
|
26
28
|
|
|
27
29
|
const result = await downloadRemoteMedia("https://picsum.photos/600/400");
|
|
@@ -48,7 +50,10 @@ describe("downloadRemoteMedia", () => {
|
|
|
48
50
|
});
|
|
49
51
|
|
|
50
52
|
it("returns null on a non-2xx response", async () => {
|
|
51
|
-
vi.stubGlobal(
|
|
53
|
+
vi.stubGlobal(
|
|
54
|
+
"fetch",
|
|
55
|
+
vi.fn(async () => new Response("nope", { status: 404 })),
|
|
56
|
+
);
|
|
52
57
|
expect(await downloadRemoteMedia("https://example.com/missing.png")).toBeNull();
|
|
53
58
|
});
|
|
54
59
|
|
package/src/media-fetch.ts
CHANGED
|
@@ -53,8 +53,7 @@ export function decodeBase64Media(
|
|
|
53
53
|
}
|
|
54
54
|
if (!buffer.length) return null;
|
|
55
55
|
|
|
56
|
-
const mimeType =
|
|
57
|
-
mimeHint?.trim().toLowerCase() || dataUrlMime || "application/octet-stream";
|
|
56
|
+
const mimeType = mimeHint?.trim().toLowerCase() || dataUrlMime || "application/octet-stream";
|
|
58
57
|
return { buffer, mimeType };
|
|
59
58
|
}
|
|
60
59
|
|
package/src/openclaw.d.ts
CHANGED
|
@@ -16,7 +16,11 @@ declare module "openclaw/plugin-sdk/agent-harness" {
|
|
|
16
16
|
|
|
17
17
|
declare module "openclaw/plugin-sdk/device-bootstrap" {
|
|
18
18
|
export const listDevicePairing: (baseDir?: string) => Promise<DevicePairingList>;
|
|
19
|
-
export const approveDevicePairing: (
|
|
19
|
+
export const approveDevicePairing: (
|
|
20
|
+
requestId: string,
|
|
21
|
+
options?: { callerScopes?: readonly string[] },
|
|
22
|
+
baseDir?: string,
|
|
23
|
+
) => Promise<ApproveDevicePairingResult>;
|
|
20
24
|
|
|
21
25
|
interface DevicePairingPendingRequest {
|
|
22
26
|
requestId: string;
|
|
@@ -34,14 +38,17 @@ declare module "openclaw/plugin-sdk/device-bootstrap" {
|
|
|
34
38
|
pending: DevicePairingPendingRequest[];
|
|
35
39
|
paired: PairedDevice[];
|
|
36
40
|
}
|
|
37
|
-
type ApproveDevicePairingResult =
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
type ApproveDevicePairingResult =
|
|
42
|
+
| {
|
|
43
|
+
status: "approved";
|
|
44
|
+
requestId: string;
|
|
45
|
+
device: PairedDevice;
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
status: "forbidden";
|
|
49
|
+
reason: string;
|
|
50
|
+
}
|
|
51
|
+
| null;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
declare module "openclaw/plugin-sdk/core" {
|
|
@@ -7,7 +7,14 @@ import { getUpgradeRuntime } from "./upgrade-runtime.js";
|
|
|
7
7
|
import { PLUGIN_ID, PLUGIN_PACKAGE_NAME } from "./version.js";
|
|
8
8
|
|
|
9
9
|
/** Install source from the OpenClaw config `plugins.installs[<id>].source`. */
|
|
10
|
-
export type InstallSource =
|
|
10
|
+
export type InstallSource =
|
|
11
|
+
| "npm"
|
|
12
|
+
| "path"
|
|
13
|
+
| "archive"
|
|
14
|
+
| "clawhub"
|
|
15
|
+
| "git"
|
|
16
|
+
| "marketplace"
|
|
17
|
+
| "unknown";
|
|
11
18
|
|
|
12
19
|
/**
|
|
13
20
|
* Infer the install source from the loaded plugin's filesystem path (`api.source`).
|
|
@@ -20,7 +27,9 @@ export type InstallSource = "npm" | "path" | "archive" | "clawhub" | "git" | "ma
|
|
|
20
27
|
* dev-no-duplicate-plugin-install), so anything under the managed projects dir
|
|
21
28
|
* is treated as "npm".
|
|
22
29
|
*/
|
|
23
|
-
export function classifyInstallSourceFromLoadedPath(
|
|
30
|
+
export function classifyInstallSourceFromLoadedPath(
|
|
31
|
+
loadedPath: string | null | undefined,
|
|
32
|
+
): InstallSource {
|
|
24
33
|
if (!loadedPath) return "unknown";
|
|
25
34
|
return loadedPath.includes("/.openclaw/npm/projects/") ? "npm" : "path";
|
|
26
35
|
}
|
|
@@ -42,9 +51,11 @@ export function getInstallSource(): InstallSource {
|
|
|
42
51
|
const rt = getUpgradeRuntime();
|
|
43
52
|
if (!rt) return "unknown";
|
|
44
53
|
try {
|
|
45
|
-
const cfg = rt.currentConfig() as
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
const cfg = rt.currentConfig() as
|
|
55
|
+
| {
|
|
56
|
+
plugins?: { installs?: Record<string, { source?: string } | undefined> };
|
|
57
|
+
}
|
|
58
|
+
| undefined;
|
|
48
59
|
const source = cfg?.plugins?.installs?.[PLUGIN_ID]?.source;
|
|
49
60
|
if (
|
|
50
61
|
source === "npm" ||
|
|
@@ -94,10 +105,10 @@ export async function fetchLatestVersion(nowMs: number): Promise<string | null>
|
|
|
94
105
|
const controller = new AbortController();
|
|
95
106
|
const timer = setTimeout(() => controller.abort(), 5000);
|
|
96
107
|
try {
|
|
97
|
-
const res = await fetch(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
);
|
|
108
|
+
const res = await fetch(`https://registry.npmjs.org/${PLUGIN_PACKAGE_NAME}/latest`, {
|
|
109
|
+
signal: controller.signal,
|
|
110
|
+
headers: { Accept: "application/json" },
|
|
111
|
+
});
|
|
101
112
|
if (res.ok) {
|
|
102
113
|
const body = (await res.json()) as { version?: string };
|
|
103
114
|
if (typeof body.version === "string" && body.version) version = body.version;
|
package/src/run-metadata.ts
CHANGED
|
@@ -173,7 +173,8 @@ export function ingestAgentEventMetadata(runId: string, data: Record<string, unk
|
|
|
173
173
|
const cacheRead = pickCacheRead(usageForTokens);
|
|
174
174
|
if (typeof cacheRead === "number" && cacheRead >= 0) next.cacheReadTokens = Math.floor(cacheRead);
|
|
175
175
|
const cacheWrite = pickCacheWrite(usageForTokens);
|
|
176
|
-
if (typeof cacheWrite === "number" && cacheWrite >= 0)
|
|
176
|
+
if (typeof cacheWrite === "number" && cacheWrite >= 0)
|
|
177
|
+
next.cacheWriteTokens = Math.floor(cacheWrite);
|
|
177
178
|
|
|
178
179
|
const usageForContext = usage ?? data;
|
|
179
180
|
const ctxUsed = contextTokensFromUsageRecord(usageForContext);
|
|
@@ -162,7 +162,11 @@ export function setSessionSettings(
|
|
|
162
162
|
upsertSessionEntry(data, fileKey, sessionKey);
|
|
163
163
|
|
|
164
164
|
const fieldKeys: (keyof FridaySessionSettingsUpdate)[] = [
|
|
165
|
-
"reasoningLevel",
|
|
165
|
+
"reasoningLevel",
|
|
166
|
+
"thinkingLevel",
|
|
167
|
+
"modelRef",
|
|
168
|
+
"providerOverride",
|
|
169
|
+
"modelOverride",
|
|
166
170
|
];
|
|
167
171
|
let updated = false;
|
|
168
172
|
for (const key of fieldKeys) {
|
|
@@ -193,22 +197,21 @@ export function setSessionSettings(
|
|
|
193
197
|
}
|
|
194
198
|
|
|
195
199
|
function readSettingsFromEntry(entry: Record<string, unknown>): FridaySessionSettings {
|
|
196
|
-
const provider =
|
|
200
|
+
const provider =
|
|
201
|
+
typeof entry["providerOverride"] === "string" ? entry["providerOverride"] : undefined;
|
|
197
202
|
const model = typeof entry["modelOverride"] === "string" ? entry["modelOverride"] : undefined;
|
|
198
203
|
const storedModelRef = typeof entry["modelRef"] === "string" ? entry["modelRef"] : undefined;
|
|
199
204
|
const modelRef = storedModelRef ?? (provider && model ? `${provider}/${model}` : undefined);
|
|
200
205
|
|
|
201
206
|
return {
|
|
202
|
-
reasoningLevel:
|
|
207
|
+
reasoningLevel:
|
|
208
|
+
typeof entry["reasoningLevel"] === "string" ? entry["reasoningLevel"] : undefined,
|
|
203
209
|
thinkingLevel: typeof entry["thinkingLevel"] === "string" ? entry["thinkingLevel"] : undefined,
|
|
204
210
|
modelRef,
|
|
205
211
|
};
|
|
206
212
|
}
|
|
207
213
|
|
|
208
|
-
export function getSessionSettings(
|
|
209
|
-
sessionKey: string,
|
|
210
|
-
historyDir?: string,
|
|
211
|
-
): FridaySessionSettings {
|
|
214
|
+
export function getSessionSettings(sessionKey: string, historyDir?: string): FridaySessionSettings {
|
|
212
215
|
try {
|
|
213
216
|
const fileKey = toSessionStoreKey(sessionKey);
|
|
214
217
|
const sessionsFile = resolveSessionsFilePath(historyDir, agentIdFromSessionKey(fileKey));
|
|
@@ -243,7 +246,10 @@ export function resolveAgentDefaults(sessionKey: string): { model?: string; thin
|
|
|
243
246
|
const targetAgentId = agentIdFromSessionKey(sessionKey);
|
|
244
247
|
|
|
245
248
|
const agentEntry = (agents?.list as Array<Record<string, unknown>> | undefined)?.find(
|
|
246
|
-
(a) =>
|
|
249
|
+
(a) =>
|
|
250
|
+
agentIdFromSessionKey(
|
|
251
|
+
`agent:${typeof a?.id === "string" ? a.id : typeof a?.id === "number" ? String(a.id) : ""}:x`,
|
|
252
|
+
) === targetAgentId,
|
|
247
253
|
);
|
|
248
254
|
const agentModel = agentEntry?.model;
|
|
249
255
|
const perAgentModel =
|
|
@@ -253,13 +259,15 @@ export function resolveAgentDefaults(sessionKey: string): { model?: string; thin
|
|
|
253
259
|
? ((agentModel as Record<string, unknown>).primary as string)
|
|
254
260
|
: undefined;
|
|
255
261
|
const perAgentThinking =
|
|
256
|
-
typeof agentEntry?.thinkingDefault === "string" ?
|
|
262
|
+
typeof agentEntry?.thinkingDefault === "string" ? agentEntry.thinkingDefault : undefined;
|
|
257
263
|
|
|
258
264
|
const agentDefaults = agents?.defaults as Record<string, unknown> | undefined;
|
|
259
265
|
const model = agentDefaults?.model as Record<string, unknown> | undefined;
|
|
260
|
-
const globalModel = typeof model?.primary === "string" ?
|
|
266
|
+
const globalModel = typeof model?.primary === "string" ? model.primary : undefined;
|
|
261
267
|
const globalThinking =
|
|
262
|
-
typeof agentDefaults?.thinkingDefault === "string"
|
|
268
|
+
typeof agentDefaults?.thinkingDefault === "string"
|
|
269
|
+
? agentDefaults.thinkingDefault
|
|
270
|
+
: undefined;
|
|
263
271
|
|
|
264
272
|
return { model: perAgentModel ?? globalModel, thinking: perAgentThinking ?? globalThinking };
|
|
265
273
|
} catch {
|
|
@@ -33,7 +33,9 @@ function finiteCost(n: unknown): number | undefined {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** Build a compact snapshot from a loaded session store entry (unknown shape). */
|
|
36
|
-
export function buildSessionUsageSnapshot(
|
|
36
|
+
export function buildSessionUsageSnapshot(
|
|
37
|
+
entry: Record<string, unknown>,
|
|
38
|
+
): FridaySessionUsagePayload | undefined {
|
|
37
39
|
const payload: FridaySessionUsagePayload = {};
|
|
38
40
|
|
|
39
41
|
const modelId = typeof entry.model === "string" ? entry.model.trim() : "";
|
|
@@ -28,7 +28,9 @@ export function readSessionUsageSnapshotFromStore(
|
|
|
28
28
|
const cfg = access.getConfig() as { session?: { store?: string } } | null | undefined;
|
|
29
29
|
const storeConfig = cfg?.session?.store;
|
|
30
30
|
const canonical = toSessionStoreKey(sessionKeyForStore);
|
|
31
|
-
const storePath = access.resolveStorePath(storeConfig, {
|
|
31
|
+
const storePath = access.resolveStorePath(storeConfig, {
|
|
32
|
+
agentId: agentIdFromSessionKey(canonical),
|
|
33
|
+
});
|
|
32
34
|
const store = access.loadSessionStore(storePath, { skipCache: true }) as Record<
|
|
33
35
|
string,
|
|
34
36
|
Record<string, unknown>
|
|
@@ -45,17 +45,21 @@ describe("discoverAvailableSkills", () => {
|
|
|
45
45
|
if (root) fs.rmSync(root, { recursive: true, force: true });
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
it("
|
|
48
|
+
it("scans only the target agent's own workspace (not the default agent's), plus managed + extra dirs, deduped and sorted", () => {
|
|
49
49
|
root = fs.mkdtempSync(path.join(os.tmpdir(), "friday-disc-"));
|
|
50
50
|
const configRoot = path.join(root, "configdir");
|
|
51
51
|
const extraDir = path.join(root, "extra");
|
|
52
52
|
|
|
53
|
-
//
|
|
54
|
-
makeSkills(path.join(configRoot, "workspace", "skills"), ["alpha"
|
|
55
|
-
// operator's own workspace —
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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"]);
|
|
59
63
|
// config extraDirs
|
|
60
64
|
makeSkills(extraDir, ["gamma"]);
|
|
61
65
|
|
|
@@ -66,12 +70,12 @@ describe("discoverAvailableSkills", () => {
|
|
|
66
70
|
wire(configRoot, cfg);
|
|
67
71
|
|
|
68
72
|
const result = discoverAvailableSkills(cfg, "operator");
|
|
69
|
-
|
|
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"]);
|
|
70
75
|
const bySource = Object.fromEntries(result.map((s) => [s.id, s.source]));
|
|
71
76
|
expect(bySource).toEqual({
|
|
72
|
-
alpha: "workspace",
|
|
73
77
|
beta: "workspace",
|
|
74
|
-
opencli: "workspace",
|
|
78
|
+
opencli: "workspace", // workspace wins over the managed-dir duplicate
|
|
75
79
|
"managed-one": "installed",
|
|
76
80
|
gamma: "extra",
|
|
77
81
|
});
|
package/src/skills-discovery.ts
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* marker core's `loadSkillsFromDir` uses).
|
|
13
13
|
*
|
|
14
14
|
* Each discovered skill is tagged with a `source` category for the UI:
|
|
15
|
-
* - workspace : the agent's own
|
|
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)
|
|
16
18
|
* - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
|
|
17
19
|
* - built-in : bundled core skills (`<openclaw>/skills`)
|
|
18
20
|
* - extra : skills from ENABLED extensions (`<openclaw>/dist/extensions/<ext>/skills`,
|
|
@@ -80,7 +82,12 @@ function parseSkillFrontmatter(content: string): { name?: string; description?:
|
|
|
80
82
|
* not descended into further; other directories are recursed up to a bounded depth.
|
|
81
83
|
* First occurrence of an id wins (call higher-priority sources first). Best-effort.
|
|
82
84
|
*/
|
|
83
|
-
function collectSkills(
|
|
85
|
+
function collectSkills(
|
|
86
|
+
root: string,
|
|
87
|
+
source: SkillSource,
|
|
88
|
+
out: Map<string, DiscoveredSkill>,
|
|
89
|
+
depth = 0,
|
|
90
|
+
): void {
|
|
84
91
|
if (depth > MAX_SKILL_WALK_DEPTH) return;
|
|
85
92
|
let entries: fs.Dirent[];
|
|
86
93
|
try {
|
|
@@ -149,14 +156,17 @@ function computeOpenClawRoot(): string | null {
|
|
|
149
156
|
* enabled extensions, so we gate on the same set (extension dir name == plugin id).
|
|
150
157
|
*/
|
|
151
158
|
export function enabledExtensionNames(cfg: unknown): Set<string> {
|
|
152
|
-
const plugins = (cfg as Record<string, unknown> | undefined)?.plugins as
|
|
159
|
+
const plugins = (cfg as Record<string, unknown> | undefined)?.plugins as
|
|
160
|
+
| Record<string, unknown>
|
|
161
|
+
| undefined;
|
|
153
162
|
const names = new Set<string>();
|
|
154
163
|
const allow = plugins?.allow;
|
|
155
164
|
if (Array.isArray(allow)) for (const n of allow) if (typeof n === "string") names.add(n);
|
|
156
165
|
const entries = plugins?.entries as Record<string, unknown> | undefined;
|
|
157
166
|
if (entries && typeof entries === "object") {
|
|
158
167
|
for (const [name, val] of Object.entries(entries)) {
|
|
159
|
-
if (val && typeof val === "object" && (val as Record<string, unknown>).enabled === true)
|
|
168
|
+
if (val && typeof val === "object" && (val as Record<string, unknown>).enabled === true)
|
|
169
|
+
names.add(name);
|
|
160
170
|
}
|
|
161
171
|
}
|
|
162
172
|
return names;
|
|
@@ -168,7 +178,9 @@ export function enabledExtensionNames(cfg: unknown): Set<string> {
|
|
|
168
178
|
* → "extra" (core tags these `source: "extension"`). Extension skills are included
|
|
169
179
|
* only when the extension is enabled, matching ControlUI's EXTRA bucket.
|
|
170
180
|
*/
|
|
171
|
-
function bundledSkillSources(
|
|
181
|
+
function bundledSkillSources(
|
|
182
|
+
enabledExtensions: Set<string>,
|
|
183
|
+
): Array<{ dir: string; source: SkillSource }> {
|
|
172
184
|
const root = resolveOpenClawRoot();
|
|
173
185
|
if (!root) return [];
|
|
174
186
|
const out: Array<{ dir: string; source: SkillSource }> = [
|
|
@@ -200,9 +212,8 @@ function resolveDefaultAgentId(cfg: Record<string, unknown> | undefined): string
|
|
|
200
212
|
|
|
201
213
|
/**
|
|
202
214
|
* Full set of skills `agentId` can load, sorted by id, each tagged with its source
|
|
203
|
-
* category. Aggregates the agent's workspace, the
|
|
204
|
-
*
|
|
205
|
-
* and failure-tolerant.
|
|
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.
|
|
206
217
|
*/
|
|
207
218
|
export function discoverAvailableSkills(cfg: unknown, agentId: string): DiscoveredSkill[] {
|
|
208
219
|
const c = cfg as Record<string, unknown> | undefined;
|
|
@@ -210,29 +221,34 @@ export function discoverAvailableSkills(cfg: unknown, agentId: string): Discover
|
|
|
210
221
|
const sources: Array<{ dir: string; source: SkillSource }> = [];
|
|
211
222
|
|
|
212
223
|
if (resolveWs) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
226
243
|
}
|
|
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
244
|
}
|
|
230
245
|
|
|
231
|
-
const extraDirs = (
|
|
232
|
-
| Record<string, unknown>
|
|
233
|
-
|
|
246
|
+
const extraDirs = (
|
|
247
|
+
(c?.skills as Record<string, unknown> | undefined)?.load as Record<string, unknown> | undefined
|
|
248
|
+
)?.extraDirs;
|
|
234
249
|
if (Array.isArray(extraDirs)) {
|
|
235
|
-
for (const d of extraDirs)
|
|
250
|
+
for (const d of extraDirs)
|
|
251
|
+
if (typeof d === "string" && d.trim()) sources.push({ dir: d.trim(), source: "extra" });
|
|
236
252
|
}
|
|
237
253
|
|
|
238
254
|
sources.push(...bundledSkillSources(enabledExtensionNames(c)));
|
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`,
|