@syengup/friday-channel-next 0.1.29 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/dist/src/agent/abort-run.d.ts +12 -1
- package/dist/src/agent/abort-run.js +24 -9
- package/dist/src/agent/media-bridge.d.ts +8 -1
- package/dist/src/agent/media-bridge.js +23 -2
- package/dist/src/agent-forward-runtime.d.ts +15 -0
- package/dist/src/agent-forward-runtime.js +2 -0
- package/dist/src/agent-id.d.ts +8 -0
- package/dist/src/agent-id.js +21 -0
- package/dist/src/channel-actions.js +45 -14
- package/dist/src/channel.js +22 -1
- package/dist/src/http/handlers/agent-config.d.ts +27 -0
- package/dist/src/http/handlers/agent-config.js +182 -0
- package/dist/src/http/handlers/agent-files.d.ts +21 -0
- package/dist/src/http/handlers/agent-files.js +137 -0
- package/dist/src/http/handlers/agent-tools-catalog.d.ts +10 -0
- package/dist/src/http/handlers/agent-tools-catalog.js +33 -0
- package/dist/src/http/handlers/agents-list.js +1 -19
- package/dist/src/http/handlers/cancel.js +12 -6
- package/dist/src/http/handlers/files.d.ts +16 -0
- package/dist/src/http/handlers/files.js +80 -12
- package/dist/src/http/handlers/messages.js +8 -3
- package/dist/src/http/handlers/models-list.d.ts +5 -0
- package/dist/src/http/handlers/models-list.js +8 -0
- package/dist/src/http/handlers/sessions-settings.js +15 -10
- package/dist/src/http/server.js +23 -0
- package/dist/src/link-preview/ssrf-guard.js +6 -2
- package/dist/src/media-fetch.js +4 -1
- package/dist/src/skills-discovery.d.ts +58 -0
- package/dist/src/skills-discovery.js +247 -0
- package/dist/src/thinking-levels.d.ts +21 -0
- package/dist/src/thinking-levels.js +48 -0
- package/dist/src/tool-catalog.d.ts +53 -0
- package/dist/src/tool-catalog.js +192 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
- package/src/agent/abort-run.ts +24 -8
- package/src/agent/media-bridge.test.ts +71 -0
- package/src/agent/media-bridge.ts +23 -1
- package/src/agent-forward-runtime.ts +11 -0
- package/src/agent-id.ts +24 -0
- package/src/channel-actions.test.ts +47 -0
- package/src/channel-actions.ts +38 -14
- package/src/channel.lifecycle.test.ts +41 -0
- package/src/channel.ts +23 -1
- package/src/http/handlers/agent-config.test.ts +205 -0
- package/src/http/handlers/agent-config.ts +218 -0
- package/src/http/handlers/agent-files.test.ts +136 -0
- package/src/http/handlers/agent-files.ts +149 -0
- package/src/http/handlers/agent-tools-catalog.ts +42 -0
- package/src/http/handlers/agents-list.ts +1 -22
- package/src/http/handlers/cancel.test.ts +12 -2
- package/src/http/handlers/cancel.ts +12 -6
- package/src/http/handlers/files.test.ts +114 -0
- package/src/http/handlers/files.ts +97 -13
- package/src/http/handlers/messages.ts +7 -2
- package/src/http/handlers/models-list.test.ts +114 -0
- package/src/http/handlers/models-list.ts +12 -0
- package/src/http/handlers/sessions-settings.ts +16 -11
- package/src/http/server.ts +24 -0
- package/src/link-preview/ssrf-guard.test.ts +7 -2
- package/src/link-preview/ssrf-guard.ts +5 -1
- package/src/media-fetch.test.ts +1 -1
- package/src/media-fetch.ts +4 -1
- package/src/openclaw.d.ts +25 -1
- package/src/skills-discovery.test.ts +148 -0
- package/src/skills-discovery.ts +248 -0
- package/src/thinking-levels.test.ts +143 -0
- package/src/thinking-levels.ts +68 -0
- package/src/tool-catalog.ts +252 -0
- package/src/version.ts +1 -1
package/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
|
|
|
@@ -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,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
|
}
|
|
@@ -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);
|
|
@@ -78,23 +95,37 @@ async function handleSend(ctx) {
|
|
|
78
95
|
},
|
|
79
96
|
}, to, true);
|
|
80
97
|
}
|
|
81
|
-
// Resolve media
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
98
|
+
// Resolve the media to send. A `message` tool call with a structured `attachments[]` array is
|
|
99
|
+
// flattened by the OpenClaw core into `params.mediaUrls` (with `media` set to the first entry for
|
|
100
|
+
// back-compat), so prefer the full list; fall back to a single inline base64 buffer or a
|
|
101
|
+
// path/url reference for the single-attachment / direct-link / buffer cases.
|
|
102
|
+
const mediaSources = [];
|
|
103
|
+
const mediaUrls = pickStringArray(ctx.params, "mediaUrls");
|
|
104
|
+
if (mediaUrls.length > 0) {
|
|
105
|
+
for (const ref of mediaUrls) {
|
|
106
|
+
const loaded = await readMediaFile(ref, ctx);
|
|
107
|
+
if (loaded)
|
|
108
|
+
mediaSources.push({ ...loaded, originalMediaUrl: ref });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else if (inlineBase64) {
|
|
112
|
+
const loaded = decodeBase64Media(inlineBase64, mediaMimeHint || (filename ? guessMimeType(filename) : ""));
|
|
113
|
+
if (loaded)
|
|
114
|
+
mediaSources.push({ ...loaded, originalMediaUrl: filename || "inline-buffer" });
|
|
88
115
|
}
|
|
89
116
|
else if (mediaPath) {
|
|
90
|
-
|
|
91
|
-
|
|
117
|
+
const loaded = await readMediaFile(mediaPath, ctx);
|
|
118
|
+
if (loaded)
|
|
119
|
+
mediaSources.push({ ...loaded, originalMediaUrl: mediaPath });
|
|
92
120
|
}
|
|
93
|
-
// Send media via SSE outbound
|
|
94
|
-
|
|
121
|
+
// Send each media via its own SSE outbound event. They all share this send's `runId` so the app
|
|
122
|
+
// groups them into a single assistant message (first → attachment, rest → extra attachments).
|
|
123
|
+
if (mediaSources.length > 0) {
|
|
95
124
|
const { saveMediaBuffer } = await import("openclaw/plugin-sdk/media-store");
|
|
96
|
-
const
|
|
97
|
-
|
|
125
|
+
for (const source of mediaSources) {
|
|
126
|
+
const saved = await saveMediaBuffer(source.buffer, source.mimeType, "inbound");
|
|
127
|
+
if (!saved.id)
|
|
128
|
+
continue;
|
|
98
129
|
const publicUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
|
|
99
130
|
sseEmitter.broadcast({
|
|
100
131
|
type: "outbound",
|
|
@@ -107,7 +138,7 @@ async function handleSend(ctx) {
|
|
|
107
138
|
audioAsVoice: false,
|
|
108
139
|
caption: caption || text,
|
|
109
140
|
mediaUrl: publicUrl,
|
|
110
|
-
ctx: { to, text: caption || text, originalMediaUrl },
|
|
141
|
+
ctx: { to, text: caption || text, originalMediaUrl: source.originalMediaUrl },
|
|
111
142
|
},
|
|
112
143
|
}, to, true);
|
|
113
144
|
}
|
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,
|
|
@@ -240,7 +258,10 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
|
|
|
240
258
|
}
|
|
241
259
|
if (buffer) {
|
|
242
260
|
const mimeType = downloadedMimeType ?? guessMimeType(mediaUrl);
|
|
243
|
-
|
|
261
|
+
// Match what openclaw itself supports for this media kind rather than
|
|
262
|
+
// saveMediaBuffer's 5MB default.
|
|
263
|
+
const maxBytes = await resolveMediaMaxBytes(mimeType);
|
|
264
|
+
const saved = await saveMediaBuffer(buffer, mimeType, "inbound", maxBytes);
|
|
244
265
|
if (saved.id) {
|
|
245
266
|
const fileUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
|
|
246
267
|
const resolved = resolveMediaAttachment(fileUrl);
|
|
@@ -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>;
|
|
@@ -0,0 +1,182 @@
|
|
|
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 { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
|
|
21
|
+
import { getUpgradeRuntime } from "../../upgrade-runtime.js";
|
|
22
|
+
import { normalizeAgentId } from "../../agent-id.js";
|
|
23
|
+
import { discoverAvailableSkills } from "../../skills-discovery.js";
|
|
24
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
25
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
26
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
27
|
+
function json(res, status, body) {
|
|
28
|
+
res.statusCode = status;
|
|
29
|
+
res.setHeader("Content-Type", "application/json");
|
|
30
|
+
res.end(JSON.stringify(body));
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
function readString(value) {
|
|
34
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
35
|
+
}
|
|
36
|
+
function readStringArray(value) {
|
|
37
|
+
if (!Array.isArray(value))
|
|
38
|
+
return undefined;
|
|
39
|
+
const out = value.filter((v) => typeof v === "string" && v.trim().length > 0).map((v) => v.trim());
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function readToolsConfig(value) {
|
|
43
|
+
if (!value || typeof value !== "object")
|
|
44
|
+
return undefined;
|
|
45
|
+
const t = value;
|
|
46
|
+
const view = {};
|
|
47
|
+
const profile = readString(t.profile);
|
|
48
|
+
if (profile)
|
|
49
|
+
view.profile = profile;
|
|
50
|
+
const allow = readStringArray(t.allow);
|
|
51
|
+
if (allow)
|
|
52
|
+
view.allow = allow;
|
|
53
|
+
const alsoAllow = readStringArray(t.alsoAllow);
|
|
54
|
+
if (alsoAllow)
|
|
55
|
+
view.alsoAllow = alsoAllow;
|
|
56
|
+
const deny = readStringArray(t.deny);
|
|
57
|
+
if (deny)
|
|
58
|
+
view.deny = deny;
|
|
59
|
+
return view;
|
|
60
|
+
}
|
|
61
|
+
/** Locate the configured `agents.list[]` entry whose normalized id matches `agentId`. */
|
|
62
|
+
function findAgentEntry(cfg, agentId) {
|
|
63
|
+
const agents = cfg?.agents;
|
|
64
|
+
const list = agents?.list;
|
|
65
|
+
if (!Array.isArray(list))
|
|
66
|
+
return undefined;
|
|
67
|
+
return list.find((a) => a && typeof a === "object" && normalizeAgentId(a.id) === agentId);
|
|
68
|
+
}
|
|
69
|
+
function buildConfigView(agentId) {
|
|
70
|
+
const rt = getFridayAgentForwardRuntime();
|
|
71
|
+
const cfg = rt?.getConfig();
|
|
72
|
+
const entry = cfg ? findAgentEntry(cfg, agentId) : undefined;
|
|
73
|
+
return {
|
|
74
|
+
id: agentId,
|
|
75
|
+
exists: entry !== undefined,
|
|
76
|
+
model: entry?.model,
|
|
77
|
+
thinkingDefault: readString(entry?.thinkingDefault),
|
|
78
|
+
tools: readToolsConfig(entry?.tools),
|
|
79
|
+
// undefined = no `skills` field (inherit defaults); [] = field present but empty (all disabled).
|
|
80
|
+
skills: readStringArray(entry?.skills),
|
|
81
|
+
availableSkills: discoverAvailableSkills(cfg, agentId),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function readPatch(body, key, coerce) {
|
|
85
|
+
if (!(key in body))
|
|
86
|
+
return { sent: false, clear: false };
|
|
87
|
+
const raw = body[key];
|
|
88
|
+
if (raw === null)
|
|
89
|
+
return { sent: true, clear: true };
|
|
90
|
+
const value = coerce(raw);
|
|
91
|
+
if (value === undefined)
|
|
92
|
+
return { sent: false, clear: false };
|
|
93
|
+
return { sent: true, clear: false, value };
|
|
94
|
+
}
|
|
95
|
+
function coerceModel(raw) {
|
|
96
|
+
if (typeof raw === "string")
|
|
97
|
+
return raw.trim() || undefined;
|
|
98
|
+
if (raw && typeof raw === "object") {
|
|
99
|
+
const primary = readString(raw.primary);
|
|
100
|
+
if (!primary)
|
|
101
|
+
return undefined;
|
|
102
|
+
const fallbacks = readStringArray(raw.fallbacks);
|
|
103
|
+
return fallbacks && fallbacks.length > 0 ? { primary, fallbacks } : { primary };
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
function coerceTools(raw) {
|
|
108
|
+
return readToolsConfig(raw);
|
|
109
|
+
}
|
|
110
|
+
/** Skills: array (incl. empty = disable all) only; non-arrays are rejected upstream. */
|
|
111
|
+
function coerceSkills(raw) {
|
|
112
|
+
return Array.isArray(raw) ? readStringArray(raw) ?? [] : undefined;
|
|
113
|
+
}
|
|
114
|
+
// --- handler -----------------------------------------------------------------
|
|
115
|
+
export async function handleAgentConfig(req, res, rawAgentId) {
|
|
116
|
+
if (req.method !== "GET" && req.method !== "PUT") {
|
|
117
|
+
return json(res, 405, { error: "Method Not Allowed" });
|
|
118
|
+
}
|
|
119
|
+
if (!extractBearerToken(req)) {
|
|
120
|
+
return json(res, 401, { error: "Unauthorized: bearer token mismatch" });
|
|
121
|
+
}
|
|
122
|
+
const agentId = normalizeAgentId(rawAgentId);
|
|
123
|
+
if (req.method === "GET") {
|
|
124
|
+
return json(res, 200, { ok: true, ...buildConfigView(agentId) });
|
|
125
|
+
}
|
|
126
|
+
// PUT — partial patch.
|
|
127
|
+
const body = await readJsonBody(req);
|
|
128
|
+
if (!body)
|
|
129
|
+
return json(res, 400, { error: "Invalid or missing JSON body" });
|
|
130
|
+
const model = readPatch(body, "model", coerceModel);
|
|
131
|
+
const thinkingDefault = readPatch(body, "thinkingDefault", (r) => readString(r));
|
|
132
|
+
const tools = readPatch(body, "tools", coerceTools);
|
|
133
|
+
const skills = readPatch(body, "skills", coerceSkills);
|
|
134
|
+
if ("skills" in body && body.skills !== null && !Array.isArray(body.skills)) {
|
|
135
|
+
return json(res, 400, { error: "skills must be an array of skill ids, [] to disable all, or null to inherit defaults" });
|
|
136
|
+
}
|
|
137
|
+
if (!model.sent && !thinkingDefault.sent && !tools.sent && !skills.sent) {
|
|
138
|
+
return json(res, 400, { error: "No editable fields provided (model, thinkingDefault, tools, skills)" });
|
|
139
|
+
}
|
|
140
|
+
const upgrade = getUpgradeRuntime();
|
|
141
|
+
if (!upgrade)
|
|
142
|
+
return json(res, 503, { error: "Config write runtime unavailable" });
|
|
143
|
+
const log = createFridayNextLogger("agent-config");
|
|
144
|
+
try {
|
|
145
|
+
await upgrade.mutateConfigFile({
|
|
146
|
+
afterWrite: { mode: "auto" },
|
|
147
|
+
mutate: (draftRaw) => {
|
|
148
|
+
const draft = draftRaw;
|
|
149
|
+
const agents = (draft.agents ??= {});
|
|
150
|
+
const list = (agents.list ??= []);
|
|
151
|
+
let entry = list.find((a) => a && typeof a === "object" && normalizeAgentId(a.id) === agentId);
|
|
152
|
+
if (!entry) {
|
|
153
|
+
// Implicit agent (e.g. "main") with no list entry yet — create a bare one.
|
|
154
|
+
// Never set `default: true`: that would change default-agent resolution.
|
|
155
|
+
entry = { id: agentId };
|
|
156
|
+
list.push(entry);
|
|
157
|
+
}
|
|
158
|
+
applyField(entry, "model", model);
|
|
159
|
+
applyField(entry, "thinkingDefault", thinkingDefault);
|
|
160
|
+
applyField(entry, "tools", tools);
|
|
161
|
+
applyField(entry, "skills", skills);
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
167
|
+
log.error(`agent config write failed for "${agentId}": ${msg}`);
|
|
168
|
+
return json(res, 500, { error: "Failed to write agent config", detail: msg });
|
|
169
|
+
}
|
|
170
|
+
log.info(`agent config updated for "${agentId}"`);
|
|
171
|
+
return json(res, 200, { ok: true, ...buildConfigView(agentId) });
|
|
172
|
+
}
|
|
173
|
+
/** Apply a patch: clear → delete the key; set → assign; not sent → leave as-is. */
|
|
174
|
+
function applyField(entry, key, patch) {
|
|
175
|
+
if (!patch.sent)
|
|
176
|
+
return;
|
|
177
|
+
if (patch.clear) {
|
|
178
|
+
delete entry[key];
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
entry[key] = patch.value;
|
|
182
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET/PUT /friday-next/agents/{id}/files[/{name}]
|
|
3
|
+
*
|
|
4
|
+
* Reads and edits an agent's core workspace files — the same whitelist ControlUI
|
|
5
|
+
* exposes (AGENTS/IDENTITY/SOUL/TOOLS/MEMORY/USER/HEARTBEAT/BOOTSTRAP.md). These
|
|
6
|
+
* are plain workspace files, not config: written directly via Node fs into the
|
|
7
|
+
* dir resolved by `api.runtime.agent.resolveAgentWorkspaceDir` (the same call
|
|
8
|
+
* agents-list uses to read IDENTITY.md). No config mutation, no gateway restart —
|
|
9
|
+
* the agent re-reads them on its next run.
|
|
10
|
+
*
|
|
11
|
+
* - GET /agents/{id}/files → status of every whitelist file
|
|
12
|
+
* - GET /agents/{id}/files/{name} → one file's content
|
|
13
|
+
* - PUT /agents/{id}/files/{name} → write one file (body: { content })
|
|
14
|
+
*
|
|
15
|
+
* Security: the file name MUST be in the whitelist (no path traversal), and the
|
|
16
|
+
* resolved path is re-checked to stay inside the workspace dir as defense in depth.
|
|
17
|
+
*/
|
|
18
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
19
|
+
/** Core workspace files an agent edits, mirroring ControlUI's `agents.files` whitelist. */
|
|
20
|
+
export declare const CORE_AGENT_FILES: readonly ["AGENTS.md", "IDENTITY.md", "SOUL.md", "TOOLS.md", "MEMORY.md", "USER.md", "HEARTBEAT.md", "BOOTSTRAP.md"];
|
|
21
|
+
export declare function handleAgentFiles(req: IncomingMessage, res: ServerResponse, rawAgentId: string, fileName: string | undefined): Promise<boolean>;
|