@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
package/README.md
CHANGED
|
@@ -10,15 +10,19 @@
|
|
|
10
10
|
- **Disk-backed SSE replay** per `deviceId` (JSONL + `Last-Event-ID` / `lastEventId`) for offline gaps and restarts
|
|
11
11
|
- File upload/download (`POST /friday-next/files`, `GET /friday-next/files/:id`)
|
|
12
12
|
- Cancel (`POST /friday-next/cancel`) and status with `activeRuns` (`GET /friday-next/status`)
|
|
13
|
+
- **Agent config editing** (mirrors ControlUI, no core changes): model / core `.md` files / tool permissions / skills, per agent — via `agents.list[]` (`mutateConfigFile`) + workspace fs
|
|
14
|
+
- History sync, link-preview cards, and in-app plugin self-upgrade
|
|
13
15
|
|
|
14
16
|
## Endpoints
|
|
15
17
|
|
|
16
18
|
- `GET /friday-next/events?deviceId=...`
|
|
17
19
|
- `POST /friday-next/messages`
|
|
18
|
-
- `POST /friday-next/files`
|
|
19
|
-
- `GET /friday-next/
|
|
20
|
-
- `
|
|
21
|
-
- `GET /friday-next/
|
|
20
|
+
- `POST /friday-next/files` · `GET /friday-next/files/:id`
|
|
21
|
+
- `POST /friday-next/cancel` · `GET /friday-next/status` · `GET /friday-next/health`
|
|
22
|
+
- `GET /friday-next/models` · `GET /friday-next/agents` · `GET|PUT /friday-next/sessions/settings`
|
|
23
|
+
- `GET|PUT /friday-next/agents/{id}/config` · `GET|PUT /friday-next/agents/{id}/files[/{name}]` · `GET /friday-next/agents/{id}/tools/catalog`
|
|
24
|
+
- `GET /friday-next/history/sessions` · `GET /friday-next/history/messages` · `GET /friday-next/link-preview`
|
|
25
|
+
- `GET /friday-next/plugin/info` · `POST /friday-next/plugin/upgrade`
|
|
22
26
|
|
|
23
27
|
See **`API.md`** (English) and **`API.zh-CN.md`** (Chinese) for payloads, event shapes, offline queue paths, and breaking changes.
|
|
24
28
|
|
package/dist/index.js
CHANGED
|
@@ -33,7 +33,7 @@ function deviceIdFromToolContext(ctx) {
|
|
|
33
33
|
}
|
|
34
34
|
const sk = typeof ctx.sessionKey === "string" && ctx.sessionKey.trim()
|
|
35
35
|
? ctx.sessionKey.trim()
|
|
36
|
-
: (ctx.runId ? getOpenClawAgentRunContext(ctx.runId)?.sessionKey?.trim() : undefined) ?? "";
|
|
36
|
+
: ((ctx.runId ? getOpenClawAgentRunContext(ctx.runId)?.sessionKey?.trim() : undefined) ?? "");
|
|
37
37
|
if (sk) {
|
|
38
38
|
const d = resolveFridayDeviceIdForSessionKey(sk);
|
|
39
39
|
if (d)
|
|
@@ -1 +1,12 @@
|
|
|
1
|
-
export
|
|
1
|
+
export type AbortRunResult = {
|
|
2
|
+
aborted: boolean;
|
|
3
|
+
drained: boolean;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Abort the active run for a channel `sessionKey`.
|
|
7
|
+
*
|
|
8
|
+
* A session has at most one active run at a time, and the SDK keys active runs by
|
|
9
|
+
* their internal `sessionId` (not the channel runId). So resolve sessionKey → sessionId
|
|
10
|
+
* first, then abort-and-drain so the caller learns whether the run actually settled.
|
|
11
|
+
*/
|
|
12
|
+
export declare function abortRunForSessionKey(sessionKey: string): Promise<AbortRunResult>;
|
|
@@ -1,11 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Abort the active run for a channel `sessionKey`.
|
|
3
|
+
*
|
|
4
|
+
* A session has at most one active run at a time, and the SDK keys active runs by
|
|
5
|
+
* their internal `sessionId` (not the channel runId). So resolve sessionKey → sessionId
|
|
6
|
+
* first, then abort-and-drain so the caller learns whether the run actually settled.
|
|
7
|
+
*/
|
|
8
|
+
export async function abortRunForSessionKey(sessionKey) {
|
|
9
|
+
if (process.env.VITEST === "true")
|
|
10
|
+
return { aborted: false, drained: false };
|
|
11
|
+
const key = sessionKey.trim();
|
|
12
|
+
if (!key)
|
|
13
|
+
return { aborted: false, drained: false };
|
|
14
|
+
try {
|
|
15
|
+
const { resolveActiveEmbeddedRunSessionId, abortAndDrainAgentHarnessRun } = await import("openclaw/plugin-sdk/agent-harness");
|
|
16
|
+
const sessionId = resolveActiveEmbeddedRunSessionId(key);
|
|
17
|
+
if (!sessionId)
|
|
18
|
+
return { aborted: false, drained: false };
|
|
19
|
+
const result = await abortAndDrainAgentHarnessRun({ sessionId, sessionKey: key });
|
|
20
|
+
return { aborted: result.aborted, drained: result.drained };
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// optional at runtime
|
|
24
|
+
return { aborted: false, drained: false };
|
|
10
25
|
}
|
|
11
26
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
type DispatchFn = (args: unknown) =>
|
|
1
|
+
type DispatchFn = (args: unknown) => unknown;
|
|
2
2
|
export declare function runFridayDispatch(args: Parameters<DispatchFn>[0]): ReturnType<DispatchFn>;
|
|
3
3
|
export declare function __setMockFridayDispatchForTests(fn: DispatchFn): void;
|
|
4
4
|
export declare function __resetMockFridayDispatchForTests(): void;
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* openclaw's own per-kind byte cap for a mime type (image 6MB / audio·video 16MB /
|
|
3
|
+
* document 100MB). Unknown mimes fall back to the most permissive ("document") cap.
|
|
4
|
+
* Returns undefined if the media runtime isn't importable (tests / stripped runtime),
|
|
5
|
+
* letting `saveMediaBuffer` apply its built-in default.
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveMediaMaxBytes(mimeType: string): Promise<number | undefined>;
|
|
8
|
+
export declare function saveInboundMediaBuffer(buffer: Buffer, mimeType: string, originalFilename?: string): Promise<{
|
|
2
9
|
id: string;
|
|
3
10
|
path: string;
|
|
4
11
|
}>;
|
|
@@ -2,10 +2,31 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* openclaw's own per-kind byte cap for a mime type (image 6MB / audio·video 16MB /
|
|
7
|
+
* document 100MB). Unknown mimes fall back to the most permissive ("document") cap.
|
|
8
|
+
* Returns undefined if the media runtime isn't importable (tests / stripped runtime),
|
|
9
|
+
* letting `saveMediaBuffer` apply its built-in default.
|
|
10
|
+
*/
|
|
11
|
+
export async function resolveMediaMaxBytes(mimeType) {
|
|
12
|
+
try {
|
|
13
|
+
const { maxBytesForKind, mediaKindFromMime } = await import("openclaw/plugin-sdk/media-runtime");
|
|
14
|
+
return maxBytesForKind(mediaKindFromMime(mimeType) ?? "document");
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function saveInboundMediaBuffer(buffer, mimeType, originalFilename) {
|
|
6
21
|
try {
|
|
7
22
|
const sdk = await import("openclaw/plugin-sdk/media-store");
|
|
8
|
-
|
|
23
|
+
// Accept whatever openclaw itself supports for this media kind instead of
|
|
24
|
+
// saveMediaBuffer's conservative 5MB default.
|
|
25
|
+
const maxBytes = await resolveMediaMaxBytes(mimeType);
|
|
26
|
+
// Pass the original filename (5th arg) so core's media-store preserves the
|
|
27
|
+
// name+extension instead of saving a bare uuid. Otherwise the agent receives
|
|
28
|
+
// `[media attached: file://.../inbound/<uuid>]` with no file-format signal.
|
|
29
|
+
const saved = await sdk.saveMediaBuffer(buffer, mimeType, "inbound", maxBytes, originalFilename);
|
|
9
30
|
if (saved?.id && saved?.path)
|
|
10
31
|
return { id: saved.id, path: saved.path };
|
|
11
32
|
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
}>;
|
|
1
|
+
type ListNodePairingFn = () => Promise<any>;
|
|
2
|
+
type ApproveNodePairingFn = (requestId: string, options: {
|
|
3
|
+
callerScopes?: unknown;
|
|
4
|
+
}) => Promise<any>;
|
|
5
|
+
type NodePairingModule = {
|
|
6
|
+
listNodePairing: ListNodePairingFn;
|
|
7
|
+
approveNodePairing: ApproveNodePairingFn;
|
|
8
|
+
};
|
|
9
|
+
export declare function loadNodePairingModule(): Promise<NodePairingModule>;
|
|
5
10
|
/** Vitest-only: inject mock pairing functions. */
|
|
6
|
-
export declare function __setMockNodePairingForTests(mock:
|
|
7
|
-
|
|
8
|
-
approveNodePairing: Function;
|
|
9
|
-
}): void;
|
|
11
|
+
export declare function __setMockNodePairingForTests(mock: NodePairingModule): void;
|
|
12
|
+
export {};
|
|
@@ -16,7 +16,9 @@ function resolveOpenClawDistFromPath() {
|
|
|
16
16
|
readdirSync(dist);
|
|
17
17
|
return dist;
|
|
18
18
|
}
|
|
19
|
-
catch {
|
|
19
|
+
catch {
|
|
20
|
+
// Not a real dist dir — keep walking PATH.
|
|
21
|
+
}
|
|
20
22
|
}
|
|
21
23
|
return null;
|
|
22
24
|
}
|
|
@@ -45,7 +47,9 @@ function resolveOpenClawDist() {
|
|
|
45
47
|
readdirSync(root);
|
|
46
48
|
return root;
|
|
47
49
|
}
|
|
48
|
-
catch {
|
|
50
|
+
catch {
|
|
51
|
+
// Candidate dir doesn't exist — try the next one.
|
|
52
|
+
}
|
|
49
53
|
}
|
|
50
54
|
throw new Error("OpenClaw dist directory not found. Set OPENCLAW_DIST env var.");
|
|
51
55
|
}
|
|
@@ -8,9 +8,6 @@ export function registerSessionKeyForRun(sessionKey, runId) {
|
|
|
8
8
|
return;
|
|
9
9
|
sessionKeyToRunId.set(sessionKey, runId);
|
|
10
10
|
}
|
|
11
|
-
function resolveRunIdForSessionKey(sessionKey) {
|
|
12
|
-
return sessionKeyToRunId.get(sessionKey);
|
|
13
|
-
}
|
|
14
11
|
/**
|
|
15
12
|
* Parse OpenClaw announce compound runId:
|
|
16
13
|
* announce:v<version>:<sessionKey>:<bareRunId>
|
|
@@ -16,6 +16,21 @@ export type FridayAgentForwardRuntime = {
|
|
|
16
16
|
}) => Promise<Record<string, unknown> | null>;
|
|
17
17
|
/** Resolves an agent's workspace dir — used to read IDENTITY.md for the name fallback. */
|
|
18
18
|
resolveAgentWorkspaceDir?: (cfg: unknown, agentId: string) => string;
|
|
19
|
+
/**
|
|
20
|
+
* Resolves the thinking-level options + default for a provider/model pair, driven by the running
|
|
21
|
+
* gateway's provider plugins + model catalog (so the option set varies per model). Optional: older
|
|
22
|
+
* gateways don't expose it, in which case callers fall back to the base five levels.
|
|
23
|
+
*/
|
|
24
|
+
resolveThinkingPolicy?: (params: {
|
|
25
|
+
provider?: string | null;
|
|
26
|
+
model?: string | null;
|
|
27
|
+
}) => {
|
|
28
|
+
levels: Array<{
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
}>;
|
|
32
|
+
defaultLevel?: string | null;
|
|
33
|
+
};
|
|
19
34
|
getConfig: () => unknown;
|
|
20
35
|
};
|
|
21
36
|
/** Called from `registerFull` so terminal lifecycle forwards can read `sessions.json` after persist. */
|
|
@@ -8,6 +8,8 @@ export function setFridayAgentForwardRuntime(api) {
|
|
|
8
8
|
.updateSessionStoreEntry,
|
|
9
9
|
resolveAgentWorkspaceDir: api.runtime.agent
|
|
10
10
|
.resolveAgentWorkspaceDir,
|
|
11
|
+
resolveThinkingPolicy: api.runtime.agent
|
|
12
|
+
.resolveThinkingPolicy,
|
|
11
13
|
getConfig: () => api.runtime.config.current(),
|
|
12
14
|
};
|
|
13
15
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent id normalization shared across handlers.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of OpenClaw's `normalizeAgentId` (src/routing/session-key.ts): trim,
|
|
5
|
+
* lowercase, keep path/shell-safe. Empty → "main".
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_AGENT_ID = "main";
|
|
8
|
+
export declare function normalizeAgentId(value: unknown): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent id normalization shared across handlers.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of OpenClaw's `normalizeAgentId` (src/routing/session-key.ts): trim,
|
|
5
|
+
* lowercase, keep path/shell-safe. Empty → "main".
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_AGENT_ID = "main";
|
|
8
|
+
/** Agent ids already in path/shell-safe form skip the slug rewrite below. */
|
|
9
|
+
const SAFE_AGENT_ID = /^[a-z0-9][a-z0-9_-]*$/;
|
|
10
|
+
export function normalizeAgentId(value) {
|
|
11
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
12
|
+
if (!trimmed)
|
|
13
|
+
return DEFAULT_AGENT_ID;
|
|
14
|
+
const lowered = trimmed.toLowerCase();
|
|
15
|
+
if (SAFE_AGENT_ID.test(lowered))
|
|
16
|
+
return lowered;
|
|
17
|
+
return (lowered
|
|
18
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
19
|
+
.replace(/^-+|-+$/g, "")
|
|
20
|
+
.slice(0, 64) || DEFAULT_AGENT_ID);
|
|
21
|
+
}
|
|
@@ -24,6 +24,23 @@ function pickString(params, keys) {
|
|
|
24
24
|
}
|
|
25
25
|
return "";
|
|
26
26
|
}
|
|
27
|
+
function pickStringArray(params, key) {
|
|
28
|
+
const v = params[key];
|
|
29
|
+
if (!Array.isArray(v))
|
|
30
|
+
return [];
|
|
31
|
+
const out = [];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
for (const entry of v) {
|
|
34
|
+
if (typeof entry !== "string")
|
|
35
|
+
continue;
|
|
36
|
+
const trimmed = entry.trim();
|
|
37
|
+
if (!trimmed || seen.has(trimmed))
|
|
38
|
+
continue;
|
|
39
|
+
seen.add(trimmed);
|
|
40
|
+
out.push(trimmed);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
27
44
|
async function readMediaFile(mediaPath, ctx) {
|
|
28
45
|
if (isHttpUrl(mediaPath)) {
|
|
29
46
|
return downloadRemoteMedia(mediaPath);
|
|
@@ -35,7 +52,9 @@ async function readMediaFile(mediaPath, ctx) {
|
|
|
35
52
|
return { buffer, mimeType: guessMimeType(mediaPath) };
|
|
36
53
|
}
|
|
37
54
|
}
|
|
38
|
-
catch {
|
|
55
|
+
catch {
|
|
56
|
+
/* fall through */
|
|
57
|
+
}
|
|
39
58
|
}
|
|
40
59
|
try {
|
|
41
60
|
const buffer = fs.readFileSync(mediaPath);
|
|
@@ -78,23 +97,37 @@ async function handleSend(ctx) {
|
|
|
78
97
|
},
|
|
79
98
|
}, to, true);
|
|
80
99
|
}
|
|
81
|
-
// Resolve media
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
// Resolve the media to send. A `message` tool call with a structured `attachments[]` array is
|
|
101
|
+
// flattened by the OpenClaw core into `params.mediaUrls` (with `media` set to the first entry for
|
|
102
|
+
// back-compat), so prefer the full list; fall back to a single inline base64 buffer or a
|
|
103
|
+
// path/url reference for the single-attachment / direct-link / buffer cases.
|
|
104
|
+
const mediaSources = [];
|
|
105
|
+
const mediaUrls = pickStringArray(ctx.params, "mediaUrls");
|
|
106
|
+
if (mediaUrls.length > 0) {
|
|
107
|
+
for (const ref of mediaUrls) {
|
|
108
|
+
const loaded = await readMediaFile(ref, ctx);
|
|
109
|
+
if (loaded)
|
|
110
|
+
mediaSources.push({ ...loaded, originalMediaUrl: ref });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (inlineBase64) {
|
|
114
|
+
const loaded = decodeBase64Media(inlineBase64, mediaMimeHint || (filename ? guessMimeType(filename) : ""));
|
|
115
|
+
if (loaded)
|
|
116
|
+
mediaSources.push({ ...loaded, originalMediaUrl: filename || "inline-buffer" });
|
|
88
117
|
}
|
|
89
118
|
else if (mediaPath) {
|
|
90
|
-
|
|
91
|
-
|
|
119
|
+
const loaded = await readMediaFile(mediaPath, ctx);
|
|
120
|
+
if (loaded)
|
|
121
|
+
mediaSources.push({ ...loaded, originalMediaUrl: mediaPath });
|
|
92
122
|
}
|
|
93
|
-
// Send media via SSE outbound
|
|
94
|
-
|
|
123
|
+
// Send each media via its own SSE outbound event. They all share this send's `runId` so the app
|
|
124
|
+
// groups them into a single assistant message (first → attachment, rest → extra attachments).
|
|
125
|
+
if (mediaSources.length > 0) {
|
|
95
126
|
const { saveMediaBuffer } = await import("openclaw/plugin-sdk/media-store");
|
|
96
|
-
const
|
|
97
|
-
|
|
127
|
+
for (const source of mediaSources) {
|
|
128
|
+
const saved = await saveMediaBuffer(source.buffer, source.mimeType, "inbound");
|
|
129
|
+
if (!saved.id)
|
|
130
|
+
continue;
|
|
98
131
|
const publicUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
|
|
99
132
|
sseEmitter.broadcast({
|
|
100
133
|
type: "outbound",
|
|
@@ -107,7 +140,7 @@ async function handleSend(ctx) {
|
|
|
107
140
|
audioAsVoice: false,
|
|
108
141
|
caption: caption || text,
|
|
109
142
|
mediaUrl: publicUrl,
|
|
110
|
-
ctx: { to, text: caption || text, originalMediaUrl },
|
|
143
|
+
ctx: { to, text: caption || text, originalMediaUrl: source.originalMediaUrl },
|
|
111
144
|
},
|
|
112
145
|
}, to, true);
|
|
113
146
|
}
|
package/dist/src/channel.js
CHANGED
|
@@ -8,12 +8,14 @@ import fs from "node:fs";
|
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
11
|
+
import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
|
|
11
12
|
import { createFridayNextLogger } from "./logging.js";
|
|
12
13
|
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
|
|
13
14
|
import { sseEmitter } from "./sse/emitter.js";
|
|
14
15
|
import { describeMessageActions, handleMessageAction } from "./channel-actions.js";
|
|
15
16
|
import { guessMimeType, resolveMediaAttachment } from "./http/handlers/files.js";
|
|
16
17
|
import { downloadRemoteMedia, isHttpUrl } from "./media-fetch.js";
|
|
18
|
+
import { resolveMediaMaxBytes } from "./agent/media-bridge.js";
|
|
17
19
|
import { resolveFridayDeviceIdForOutbound, resolveHistorySessionKeyForFridayDevice, } from "./friday-session.js";
|
|
18
20
|
import { getRunRoute } from "./run-metadata.js";
|
|
19
21
|
import { getLastFridayInboundAt } from "./friday-inbound-stats.js";
|
|
@@ -83,6 +85,21 @@ const fridayLifecycle = {
|
|
|
83
85
|
// No-op
|
|
84
86
|
},
|
|
85
87
|
};
|
|
88
|
+
/**
|
|
89
|
+
* friday-next is a passive HTTP+SSE channel: its routes live on the shared gateway server and
|
|
90
|
+
* SSE clients connect on demand, so there is no per-account socket or polling loop to maintain.
|
|
91
|
+
* But the core health-monitor reads the account's lifecycle `running` flag — which the framework
|
|
92
|
+
* flips to `false` the moment `startAccount` resolves/rejects. Without a long-lived startAccount
|
|
93
|
+
* the account is permanently seen as "stopped" and restarted every health poll (~5 min). A stopped
|
|
94
|
+
* account drops out of the deliverable-channel registry, so an agent `message` send landing in that
|
|
95
|
+
* window fails with `Unknown channel: friday-next`. Hold the account lifecycle open until abort
|
|
96
|
+
* (reload/shutdown) so the channel stays `running:true` and continuously deliverable.
|
|
97
|
+
*/
|
|
98
|
+
const fridayGateway = {
|
|
99
|
+
startAccount: async (ctx) => {
|
|
100
|
+
await waitUntilAbort(ctx.abortSignal);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
86
103
|
const fridayStatus = {
|
|
87
104
|
buildAccountSnapshot: async (params) => {
|
|
88
105
|
const { account, runtime } = params;
|
|
@@ -117,6 +134,7 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
|
|
|
117
134
|
},
|
|
118
135
|
config: fridayConfigAdapter,
|
|
119
136
|
lifecycle: fridayLifecycle,
|
|
137
|
+
gateway: fridayGateway,
|
|
120
138
|
status: fridayStatus,
|
|
121
139
|
bindings: {
|
|
122
140
|
compileConfiguredBinding: () => null,
|
|
@@ -165,7 +183,6 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
|
|
|
165
183
|
const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
|
|
166
184
|
const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
|
|
167
185
|
const conn = sseEmitter.getConnection(deviceId);
|
|
168
|
-
const ts = new Date().toISOString();
|
|
169
186
|
logger.info(`[SEND_TEXT] to=${deviceId} runId=${runId ?? "(none)"} sessionKey=${sessionKey ?? "(none)"} textLen=${text.length} online=${!!conn}`);
|
|
170
187
|
if (conn) {
|
|
171
188
|
sseEmitter.broadcast({
|
|
@@ -240,13 +257,15 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
|
|
|
240
257
|
}
|
|
241
258
|
if (buffer) {
|
|
242
259
|
const mimeType = downloadedMimeType ?? guessMimeType(mediaUrl);
|
|
243
|
-
|
|
260
|
+
// Match what openclaw itself supports for this media kind rather than
|
|
261
|
+
// saveMediaBuffer's 5MB default.
|
|
262
|
+
const maxBytes = await resolveMediaMaxBytes(mimeType);
|
|
263
|
+
const saved = await saveMediaBuffer(buffer, mimeType, "inbound", maxBytes);
|
|
244
264
|
if (saved.id) {
|
|
245
265
|
const fileUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
|
|
246
266
|
const resolved = resolveMediaAttachment(fileUrl);
|
|
247
267
|
const publicUrl = resolved ? resolved.url : fileUrl;
|
|
248
268
|
const conn = sseEmitter.getConnection(deviceId);
|
|
249
|
-
const ts = new Date().toISOString();
|
|
250
269
|
logger.info(`[SEND_MEDIA] to=${deviceId} runId=${runId ?? "(none)"} sessionKey=${sessionKey ?? "(none)"} audioAsVoice=${audioAsVoice} url=${publicUrl} online=${!!conn}`);
|
|
251
270
|
if (conn) {
|
|
252
271
|
sseEmitter.broadcast({
|
|
@@ -63,7 +63,16 @@ export function collectMediaPathsFromToolResult(raw, acc) {
|
|
|
63
63
|
const filePath = o.filePath;
|
|
64
64
|
if (typeof filePath === "string")
|
|
65
65
|
add(filePath);
|
|
66
|
-
for (const k of [
|
|
66
|
+
for (const k of [
|
|
67
|
+
"details",
|
|
68
|
+
"result",
|
|
69
|
+
"content",
|
|
70
|
+
"text",
|
|
71
|
+
"body",
|
|
72
|
+
"message",
|
|
73
|
+
"arguments",
|
|
74
|
+
"args",
|
|
75
|
+
]) {
|
|
67
76
|
if (o[k] !== undefined)
|
|
68
77
|
visit(o[k]);
|
|
69
78
|
}
|
|
@@ -37,7 +37,7 @@ export function deviceIdFromSessionKey(sessionKey) {
|
|
|
37
37
|
if (m1)
|
|
38
38
|
return m1[1] ?? null;
|
|
39
39
|
const m2 = sessionKey.match(/^agent:main:friday-next-(.+)$/i);
|
|
40
|
-
return m2 ? m2[1] ?? null : null;
|
|
40
|
+
return m2 ? (m2[1] ?? null) : null;
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
43
|
* When the app uses a plain `sessionKey` (e.g. `main` → `agent:main:main` in the gateway),
|
|
@@ -52,7 +52,8 @@ const deviceIdToLatestHistorySessionKey = new Map();
|
|
|
52
52
|
/** Last device that called POST /friday-next/messages (same gateway process). Used for cron/outbound when `to` is placeholder and the app is offline (no SSE). */
|
|
53
53
|
let lastRegisteredFridayDeviceId;
|
|
54
54
|
function normalizeFridaySessionKeyCase(sk) {
|
|
55
|
-
return /^friday-next-|^agent:main:friday-next-/i.test(sk) ||
|
|
55
|
+
return /^friday-next-|^agent:main:friday-next-/i.test(sk) ||
|
|
56
|
+
/^agent:main:friday-next:direct:/i.test(sk)
|
|
56
57
|
? sk.toLowerCase()
|
|
57
58
|
: sk;
|
|
58
59
|
}
|
|
@@ -126,7 +127,9 @@ function mergeRunMetadataIntoLifecycleEnd(runId, base) {
|
|
|
126
127
|
if (typeof meta.modelName === "string" && meta.modelName.trim()) {
|
|
127
128
|
extra.modelName = meta.modelName.trim();
|
|
128
129
|
}
|
|
129
|
-
if (typeof meta.totalTokens === "number" &&
|
|
130
|
+
if (typeof meta.totalTokens === "number" &&
|
|
131
|
+
Number.isFinite(meta.totalTokens) &&
|
|
132
|
+
meta.totalTokens > 0) {
|
|
130
133
|
extra.totalTokens = Math.floor(meta.totalTokens);
|
|
131
134
|
}
|
|
132
135
|
if (typeof meta.contextTokensUsed === "number" &&
|
|
@@ -242,6 +245,26 @@ export function resolveFridayDeviceIdForOutbound(to, rawCtx) {
|
|
|
242
245
|
return lastRegisteredFridayDeviceId;
|
|
243
246
|
return trimmed || "friday-next";
|
|
244
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Stringify a subagent error payload for the wire. `evt.data.error` comes from an
|
|
250
|
+
* in-process SDK callback, so it can be a real Error, a plain object, or a value
|
|
251
|
+
* that JSON.stringify would throw on (circular/BigInt) — keep this total and
|
|
252
|
+
* never-throwing, since it runs on the error path that emits `subagent ended`.
|
|
253
|
+
*/
|
|
254
|
+
function stringifySubagentError(raw) {
|
|
255
|
+
if (raw == null)
|
|
256
|
+
return "unknown";
|
|
257
|
+
if (typeof raw === "string")
|
|
258
|
+
return raw;
|
|
259
|
+
if (raw instanceof Error)
|
|
260
|
+
return raw.message || raw.name || "error";
|
|
261
|
+
try {
|
|
262
|
+
return JSON.stringify(raw) ?? "unknown";
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return "unstringifiable error";
|
|
266
|
+
}
|
|
267
|
+
}
|
|
245
268
|
/**
|
|
246
269
|
* Forward global OpenClaw agent events to the Friday SSE connection (transparent).
|
|
247
270
|
*
|
|
@@ -272,8 +295,7 @@ export function forwardAgentEventRaw(evt) {
|
|
|
272
295
|
if (!deviceIdRaw)
|
|
273
296
|
return;
|
|
274
297
|
if (!sk) {
|
|
275
|
-
sk =
|
|
276
|
-
latestHistorySessionKeyForDeviceId(deviceIdRaw) ?? `friday-next-${deviceIdRaw}`;
|
|
298
|
+
sk = latestHistorySessionKeyForDeviceId(deviceIdRaw) ?? `friday-next-${deviceIdRaw}`;
|
|
277
299
|
}
|
|
278
300
|
openClawRunIdToDeviceId.set(evt.runId, deviceIdRaw.toUpperCase());
|
|
279
301
|
// Register sessionKey → runId so we can resolve parentRunId
|
|
@@ -317,9 +339,7 @@ export function forwardAgentEventRaw(evt) {
|
|
|
317
339
|
const intent = toolCallId ? consumeSpawnIntent(toolCallId) : undefined;
|
|
318
340
|
const label = details.taskName ||
|
|
319
341
|
intent?.label ||
|
|
320
|
-
(typeof evt.data.meta === "string"
|
|
321
|
-
? evt.data.meta
|
|
322
|
-
: undefined);
|
|
342
|
+
(typeof evt.data.meta === "string" ? evt.data.meta : undefined);
|
|
323
343
|
const entry = ensureSubagentFromSpawnTool({
|
|
324
344
|
childSessionKey: details.childSessionKey,
|
|
325
345
|
bareRunId: details.runId,
|
|
@@ -351,7 +371,11 @@ export function forwardAgentEventRaw(evt) {
|
|
|
351
371
|
// share the announce runId but have a different sessionKey.
|
|
352
372
|
const isSubagentOwnEvent = subagentEntry && sk && subagentEntry.childSessionKey === sk;
|
|
353
373
|
const subagentMeta = isSubagentOwnEvent
|
|
354
|
-
? {
|
|
374
|
+
? {
|
|
375
|
+
label: subagentEntry.label,
|
|
376
|
+
parentRunId: subagentEntry.parentRunId,
|
|
377
|
+
depth: subagentEntry.depth,
|
|
378
|
+
}
|
|
355
379
|
: undefined;
|
|
356
380
|
let outgoingData = { ...evt.data };
|
|
357
381
|
if (evt.stream === "thinking") {
|
|
@@ -381,7 +405,7 @@ export function forwardAgentEventRaw(evt) {
|
|
|
381
405
|
// Emit subagent ended SSE when a subagent run terminates
|
|
382
406
|
if (isTerminalLifecycle && isSubagentOwnEvent && subagentEntry.status !== "ended") {
|
|
383
407
|
const outcome = lifecyclePhase === "error" ? "error" : "ok";
|
|
384
|
-
const errorStr = lifecyclePhase === "error" ?
|
|
408
|
+
const errorStr = lifecyclePhase === "error" ? stringifySubagentError(evt.data.error) : undefined;
|
|
385
409
|
const ended = registerSubagentEnded({ runId: evt.runId, outcome, error: errorStr });
|
|
386
410
|
if (ended) {
|
|
387
411
|
sseEmitter.broadcast({
|
|
@@ -46,6 +46,16 @@ function splitMediaLines(text) {
|
|
|
46
46
|
.trim();
|
|
47
47
|
return { text: cleaned, paths };
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Tools whose `toolResult` carries a user-facing PRODUCED image, which stays a
|
|
51
|
+
* chat attachment. Every OTHER tool's inline image block is the agent's visual
|
|
52
|
+
* INPUT — a file the `read` tool fed to the model, a `canvas` snapshot, a
|
|
53
|
+
* browser screenshot — and must NOT surface as an attachment on history rebuild
|
|
54
|
+
* (it spawns phantom, often-corrupt bubbles for turns where the agent never
|
|
55
|
+
* sent a file). Keep this a whitelist so any new image-CONSUMING tool is safe by
|
|
56
|
+
* default; add new image-PRODUCING tools here explicitly.
|
|
57
|
+
*/
|
|
58
|
+
const IMAGE_PRODUCING_TOOLS = new Set(["image_generation"]);
|
|
49
59
|
const MEDIA_MARKER_RE = /\[media attached:\s*([^\]]+)\]/gi;
|
|
50
60
|
/** Pull `[media attached: <url>]` markers out of free text into image refs. */
|
|
51
61
|
function extractMediaMarkers(text) {
|
|
@@ -181,14 +191,18 @@ export function normalizeHistoryMessage(raw, index) {
|
|
|
181
191
|
if (role === "toolResult") {
|
|
182
192
|
const split = splitMediaLines(parsed.text);
|
|
183
193
|
const toolName = readString(record.toolName);
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
194
|
+
// Inline image blocks on a toolResult are almost always the agent's visual
|
|
195
|
+
// INPUT — a file the `read` tool fed to the model, a `canvas` snapshot (so the
|
|
196
|
+
// agent can "see" the rendered page), a browser screenshot — NOT a user-facing
|
|
197
|
+
// attachment. Surfacing them spawns phantom, often-corrupt attachment bubbles on
|
|
198
|
+
// history rebuild for turns where the agent never sent a file. Only tools that
|
|
199
|
+
// PRODUCE a user-facing image keep their blocks. (This was a `canvas`-only
|
|
200
|
+
// blacklist, which still leaked `read`/screenshot images.) The streaming deliver
|
|
201
|
+
// path drops the canvas temp-file form separately (isCanvasSnapshotMediaPath in
|
|
202
|
+
// http/handlers/messages.ts); this is the transcript-rebuild counterpart.
|
|
203
|
+
const keepInlineImages = toolName ? IMAGE_PRODUCING_TOOLS.has(toolName) : false;
|
|
204
|
+
const images = keepInlineImages ? parsed.images : [];
|
|
205
|
+
const mediaPaths = toolName === "canvas" ? [] : split.paths;
|
|
192
206
|
const toolResult = {
|
|
193
207
|
...(readString(record.toolCallId) ? { toolCallId: readString(record.toolCallId) } : {}),
|
|
194
208
|
...(toolName ? { toolName } : {}),
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET/PUT /friday-next/agents/{id}/config
|
|
3
|
+
*
|
|
4
|
+
* Reads and edits a single agent's runtime configuration — the same fields
|
|
5
|
+
* OpenClaw's ControlUI manages, but written through the plugin's own config
|
|
6
|
+
* channel (`api.runtime.config.mutateConfigFile`, proven by plugin-upgrade) so
|
|
7
|
+
* NO OpenClaw core changes are needed. All edits land in `agents.list[]` of the
|
|
8
|
+
* host config file (`~/.clawrc`), exactly where ControlUI's `config.set` writes.
|
|
9
|
+
*
|
|
10
|
+
* Editable fields:
|
|
11
|
+
* - model → agents.list[i].model (string | {primary,fallbacks})
|
|
12
|
+
* - thinkingDefault → agents.list[i].thinkingDefault
|
|
13
|
+
* - tools → agents.list[i].tools ({profile,allow,alsoAllow,deny})
|
|
14
|
+
* - skills → agents.list[i].skills (string[]; [] disables all, absent inherits defaults)
|
|
15
|
+
*
|
|
16
|
+
* Clearing an override MUST delete the field (not leave a stale value) so the
|
|
17
|
+
* core's config merge falls back to `agents.defaults` — same hazard documented
|
|
18
|
+
* for the default-model bug. PUT therefore treats an explicit `null` as "clear".
|
|
19
|
+
*/
|
|
20
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
21
|
+
export interface AgentToolsConfig {
|
|
22
|
+
profile?: string;
|
|
23
|
+
allow?: string[];
|
|
24
|
+
alsoAllow?: string[];
|
|
25
|
+
deny?: string[];
|
|
26
|
+
}
|
|
27
|
+
export declare function handleAgentConfig(req: IncomingMessage, res: ServerResponse, rawAgentId: string): Promise<boolean>;
|