@syengup/friday-channel-next 0.1.30 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/dist/src/agent/abort-run.d.ts +12 -1
- package/dist/src/agent/abort-run.js +24 -9
- package/dist/src/agent/media-bridge.d.ts +8 -1
- package/dist/src/agent/media-bridge.js +23 -2
- package/dist/src/agent-forward-runtime.d.ts +15 -0
- package/dist/src/agent-forward-runtime.js +2 -0
- package/dist/src/agent-id.d.ts +8 -0
- package/dist/src/agent-id.js +21 -0
- package/dist/src/channel-actions.js +45 -14
- package/dist/src/channel.js +22 -1
- package/dist/src/http/handlers/agent-config.d.ts +27 -0
- package/dist/src/http/handlers/agent-config.js +182 -0
- package/dist/src/http/handlers/agent-files.d.ts +21 -0
- package/dist/src/http/handlers/agent-files.js +137 -0
- package/dist/src/http/handlers/agent-tools-catalog.d.ts +10 -0
- package/dist/src/http/handlers/agent-tools-catalog.js +33 -0
- package/dist/src/http/handlers/agents-list.js +1 -19
- package/dist/src/http/handlers/cancel.js +12 -6
- package/dist/src/http/handlers/files.d.ts +16 -0
- package/dist/src/http/handlers/files.js +80 -12
- package/dist/src/http/handlers/messages.js +8 -3
- package/dist/src/http/handlers/models-list.d.ts +5 -0
- package/dist/src/http/handlers/models-list.js +8 -0
- package/dist/src/http/handlers/sessions-settings.js +15 -10
- package/dist/src/http/server.js +23 -0
- package/dist/src/link-preview/ssrf-guard.js +6 -2
- package/dist/src/media-fetch.js +4 -1
- package/dist/src/skills-discovery.d.ts +58 -0
- package/dist/src/skills-discovery.js +247 -0
- package/dist/src/thinking-levels.d.ts +21 -0
- package/dist/src/thinking-levels.js +48 -0
- package/dist/src/tool-catalog.d.ts +53 -0
- package/dist/src/tool-catalog.js +192 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
- package/src/agent/abort-run.ts +24 -8
- package/src/agent/media-bridge.test.ts +71 -0
- package/src/agent/media-bridge.ts +23 -1
- package/src/agent-forward-runtime.ts +11 -0
- package/src/agent-id.ts +24 -0
- package/src/channel-actions.test.ts +47 -0
- package/src/channel-actions.ts +38 -14
- package/src/channel.lifecycle.test.ts +41 -0
- package/src/channel.ts +23 -1
- package/src/http/handlers/agent-config.test.ts +205 -0
- package/src/http/handlers/agent-config.ts +218 -0
- package/src/http/handlers/agent-files.test.ts +136 -0
- package/src/http/handlers/agent-files.ts +149 -0
- package/src/http/handlers/agent-tools-catalog.ts +42 -0
- package/src/http/handlers/agents-list.ts +1 -22
- package/src/http/handlers/cancel.test.ts +12 -2
- package/src/http/handlers/cancel.ts +12 -6
- package/src/http/handlers/files.test.ts +114 -0
- package/src/http/handlers/files.ts +97 -13
- package/src/http/handlers/messages.ts +7 -2
- package/src/http/handlers/models-list.test.ts +114 -0
- package/src/http/handlers/models-list.ts +12 -0
- package/src/http/handlers/sessions-settings.ts +16 -11
- package/src/http/server.ts +24 -0
- package/src/link-preview/ssrf-guard.test.ts +7 -2
- package/src/link-preview/ssrf-guard.ts +5 -1
- package/src/media-fetch.test.ts +1 -1
- package/src/media-fetch.ts +4 -1
- package/src/openclaw.d.ts +25 -1
- package/src/skills-discovery.test.ts +148 -0
- package/src/skills-discovery.ts +248 -0
- package/src/thinking-levels.test.ts +143 -0
- package/src/thinking-levels.ts +68 -0
- package/src/tool-catalog.ts +252 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,137 @@
|
|
|
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 fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
|
|
21
|
+
import { normalizeAgentId } from "../../agent-id.js";
|
|
22
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
23
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
24
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
25
|
+
/** Core workspace files an agent edits, mirroring ControlUI's `agents.files` whitelist. */
|
|
26
|
+
export const CORE_AGENT_FILES = [
|
|
27
|
+
"AGENTS.md",
|
|
28
|
+
"IDENTITY.md",
|
|
29
|
+
"SOUL.md",
|
|
30
|
+
"TOOLS.md",
|
|
31
|
+
"MEMORY.md",
|
|
32
|
+
"USER.md",
|
|
33
|
+
"HEARTBEAT.md",
|
|
34
|
+
"BOOTSTRAP.md",
|
|
35
|
+
];
|
|
36
|
+
const CORE_FILE_SET = new Set(CORE_AGENT_FILES);
|
|
37
|
+
/** Max core-file size on write (256 KiB) — these are prompts, not data dumps. */
|
|
38
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
39
|
+
function json(res, status, body) {
|
|
40
|
+
res.statusCode = status;
|
|
41
|
+
res.setHeader("Content-Type", "application/json");
|
|
42
|
+
res.end(JSON.stringify(body));
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
/** Resolve the agent's workspace dir, or undefined if the runtime can't. */
|
|
46
|
+
function resolveWorkspace(agentId) {
|
|
47
|
+
const rt = getFridayAgentForwardRuntime();
|
|
48
|
+
if (!rt?.resolveAgentWorkspaceDir)
|
|
49
|
+
return undefined;
|
|
50
|
+
try {
|
|
51
|
+
const dir = rt.resolveAgentWorkspaceDir(rt.getConfig(), agentId);
|
|
52
|
+
return dir || undefined;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Whitelisted, traversal-safe absolute path for `name` inside `workspace`, or null. */
|
|
59
|
+
function safeFilePath(workspace, name) {
|
|
60
|
+
if (!CORE_FILE_SET.has(name))
|
|
61
|
+
return null;
|
|
62
|
+
const resolved = path.resolve(workspace, name);
|
|
63
|
+
// Defense in depth: the resolved path must sit directly inside the workspace.
|
|
64
|
+
if (path.dirname(resolved) !== path.resolve(workspace))
|
|
65
|
+
return null;
|
|
66
|
+
return resolved;
|
|
67
|
+
}
|
|
68
|
+
export async function handleAgentFiles(req, res, rawAgentId, fileName) {
|
|
69
|
+
const method = req.method;
|
|
70
|
+
if (method !== "GET" && method !== "PUT") {
|
|
71
|
+
return json(res, 405, { error: "Method Not Allowed" });
|
|
72
|
+
}
|
|
73
|
+
if (!extractBearerToken(req)) {
|
|
74
|
+
return json(res, 401, { error: "Unauthorized: bearer token mismatch" });
|
|
75
|
+
}
|
|
76
|
+
const agentId = normalizeAgentId(rawAgentId);
|
|
77
|
+
const workspace = resolveWorkspace(agentId);
|
|
78
|
+
if (!workspace)
|
|
79
|
+
return json(res, 503, { error: "Agent workspace not resolvable" });
|
|
80
|
+
// GET /files — list whitelist status.
|
|
81
|
+
if (method === "GET" && !fileName) {
|
|
82
|
+
const files = CORE_AGENT_FILES.map((name) => {
|
|
83
|
+
try {
|
|
84
|
+
const stat = fs.statSync(path.join(workspace, name));
|
|
85
|
+
return { name, exists: true, bytes: stat.size };
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return { name, exists: false, bytes: 0 };
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return json(res, 200, { ok: true, id: agentId, files });
|
|
92
|
+
}
|
|
93
|
+
if (!fileName || !CORE_FILE_SET.has(fileName)) {
|
|
94
|
+
return json(res, 400, {
|
|
95
|
+
error: `Unknown core file; allowed: ${CORE_AGENT_FILES.join(", ")}`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const filePath = safeFilePath(workspace, fileName);
|
|
99
|
+
if (!filePath)
|
|
100
|
+
return json(res, 400, { error: "Invalid file name" });
|
|
101
|
+
if (method === "GET") {
|
|
102
|
+
try {
|
|
103
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
104
|
+
return json(res, 200, { ok: true, id: agentId, name: fileName, exists: true, content });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return json(res, 200, { ok: true, id: agentId, name: fileName, exists: false, content: "" });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// PUT — write content.
|
|
111
|
+
const body = await readJsonBody(req);
|
|
112
|
+
if (!body || typeof body.content !== "string") {
|
|
113
|
+
return json(res, 400, { error: "Missing required field: content (string)" });
|
|
114
|
+
}
|
|
115
|
+
const content = body.content;
|
|
116
|
+
if (Buffer.byteLength(content, "utf-8") > MAX_FILE_BYTES) {
|
|
117
|
+
return json(res, 413, { error: `content exceeds ${MAX_FILE_BYTES} bytes` });
|
|
118
|
+
}
|
|
119
|
+
const log = createFridayNextLogger("agent-files");
|
|
120
|
+
try {
|
|
121
|
+
fs.mkdirSync(workspace, { recursive: true });
|
|
122
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
126
|
+
log.error(`write ${fileName} for "${agentId}" failed: ${msg}`);
|
|
127
|
+
return json(res, 500, { error: "Failed to write file", detail: msg });
|
|
128
|
+
}
|
|
129
|
+
log.info(`wrote ${fileName} for "${agentId}" (${Buffer.byteLength(content, "utf-8")} bytes)`);
|
|
130
|
+
return json(res, 200, {
|
|
131
|
+
ok: true,
|
|
132
|
+
id: agentId,
|
|
133
|
+
name: fileName,
|
|
134
|
+
exists: true,
|
|
135
|
+
bytes: Buffer.byteLength(content, "utf-8"),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /friday-next/agents/{id}/tools/catalog
|
|
3
|
+
*
|
|
4
|
+
* Returns the agent's full tool catalog (core + plugin tools, grouped by category,
|
|
5
|
+
* with descriptions, profiles, and per-tool effective `enabled`/`inProfile` state) for
|
|
6
|
+
* the app's toolbox editor — mirroring ControlUI. Edits are saved via the existing
|
|
7
|
+
* `PUT /agents/{id}/config` (tools.{profile,allow,alsoAllow,deny}).
|
|
8
|
+
*/
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
10
|
+
export declare function handleAgentToolsCatalog(req: IncomingMessage, res: ServerResponse, rawAgentId: string): Promise<boolean>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /friday-next/agents/{id}/tools/catalog
|
|
3
|
+
*
|
|
4
|
+
* Returns the agent's full tool catalog (core + plugin tools, grouped by category,
|
|
5
|
+
* with descriptions, profiles, and per-tool effective `enabled`/`inProfile` state) for
|
|
6
|
+
* the app's toolbox editor — mirroring ControlUI. Edits are saved via the existing
|
|
7
|
+
* `PUT /agents/{id}/config` (tools.{profile,allow,alsoAllow,deny}).
|
|
8
|
+
*/
|
|
9
|
+
import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
|
|
10
|
+
import { normalizeAgentId } from "../../agent-id.js";
|
|
11
|
+
import { buildAgentToolsCatalog } from "../../tool-catalog.js";
|
|
12
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
13
|
+
function json(res, status, body) {
|
|
14
|
+
res.statusCode = status;
|
|
15
|
+
res.setHeader("Content-Type", "application/json");
|
|
16
|
+
res.end(JSON.stringify(body));
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
export async function handleAgentToolsCatalog(req, res, rawAgentId) {
|
|
20
|
+
if (req.method !== "GET") {
|
|
21
|
+
return json(res, 405, { error: "Method Not Allowed" });
|
|
22
|
+
}
|
|
23
|
+
if (!extractBearerToken(req)) {
|
|
24
|
+
return json(res, 401, { error: "Unauthorized: bearer token mismatch" });
|
|
25
|
+
}
|
|
26
|
+
const agentId = normalizeAgentId(rawAgentId);
|
|
27
|
+
const cfg = getFridayAgentForwardRuntime()?.getConfig();
|
|
28
|
+
const catalog = await buildAgentToolsCatalog(cfg, agentId);
|
|
29
|
+
if (!catalog) {
|
|
30
|
+
return json(res, 503, { error: "Tool catalog unavailable" });
|
|
31
|
+
}
|
|
32
|
+
return json(res, 200, { ok: true, id: agentId, ...catalog });
|
|
33
|
+
}
|
|
@@ -2,25 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { getFridayAgentForwardRuntime, } from "../../agent-forward-runtime.js";
|
|
4
4
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
5
|
-
|
|
6
|
-
/** Agent ids already in path/shell-safe form skip the slug rewrite below. */
|
|
7
|
-
const SAFE_AGENT_ID = /^[a-z0-9][a-z0-9_-]*$/;
|
|
8
|
-
/**
|
|
9
|
-
* Mirror of OpenClaw's `normalizeAgentId` (src/routing/session-key.ts): trim,
|
|
10
|
-
* lowercase, keep path/shell-safe. Empty → "main".
|
|
11
|
-
*/
|
|
12
|
-
function normalizeAgentId(value) {
|
|
13
|
-
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
14
|
-
if (!trimmed)
|
|
15
|
-
return DEFAULT_AGENT_ID;
|
|
16
|
-
const lowered = trimmed.toLowerCase();
|
|
17
|
-
if (SAFE_AGENT_ID.test(lowered))
|
|
18
|
-
return lowered;
|
|
19
|
-
return (lowered
|
|
20
|
-
.replace(/[^a-z0-9_-]+/g, "-")
|
|
21
|
-
.replace(/^-+|-+$/g, "")
|
|
22
|
-
.slice(0, 64) || DEFAULT_AGENT_ID);
|
|
23
|
-
}
|
|
5
|
+
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../agent-id.js";
|
|
24
6
|
/** Extract a primary model ref from the `model` field (string or {primary,...}). */
|
|
25
7
|
function resolvePrimaryModel(model) {
|
|
26
8
|
if (typeof model === "string")
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { abortRunForSessionKey } from "../../agent/abort-run.js";
|
|
2
|
+
import { getRunRoute } from "../../run-metadata.js";
|
|
2
3
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
3
4
|
import { readJsonBody } from "../middleware/body.js";
|
|
4
5
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
@@ -18,16 +19,21 @@ export async function handleCancel(req, res) {
|
|
|
18
19
|
}
|
|
19
20
|
const body = await readJsonBody(req);
|
|
20
21
|
const runId = typeof body?.runId === "string" ? body.runId.trim() : "";
|
|
21
|
-
|
|
22
|
+
// sessionKey is the primary identifier (one active run per session); runId is a
|
|
23
|
+
// back-compat fallback for older apps — resolve it to a sessionKey via the run route.
|
|
24
|
+
const sessionKey = (typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "") ||
|
|
25
|
+
(runId ? getRunRoute(runId)?.sessionKey?.trim() ?? "" : "");
|
|
26
|
+
if (!sessionKey && !runId) {
|
|
22
27
|
res.statusCode = 400;
|
|
23
28
|
res.setHeader("Content-Type", "application/json");
|
|
24
|
-
res.end(JSON.stringify({ error: "Missing runId" }));
|
|
29
|
+
res.end(JSON.stringify({ error: "Missing sessionKey or runId" }));
|
|
25
30
|
return true;
|
|
26
31
|
}
|
|
27
|
-
await
|
|
28
|
-
|
|
32
|
+
const result = sessionKey ? await abortRunForSessionKey(sessionKey) : { aborted: false, drained: false };
|
|
33
|
+
if (runId)
|
|
34
|
+
sseEmitter.untrackRun(runId);
|
|
29
35
|
res.statusCode = 200;
|
|
30
36
|
res.setHeader("Content-Type", "application/json");
|
|
31
|
-
res.end(JSON.stringify({ ok: true, runId, cancelled: true }));
|
|
37
|
+
res.end(JSON.stringify({ ok: true, sessionKey, runId, cancelled: true, ...result }));
|
|
32
38
|
return true;
|
|
33
39
|
}
|
|
@@ -17,6 +17,18 @@ export interface StoredFile {
|
|
|
17
17
|
path: string;
|
|
18
18
|
createdAt: number;
|
|
19
19
|
}
|
|
20
|
+
/** Clear the in-memory file index. Test-only: simulates a gateway restart. */
|
|
21
|
+
export declare function clearFileIndexForTest(): void;
|
|
22
|
+
/**
|
|
23
|
+
* Remember the original upload filename for an inbound media file.
|
|
24
|
+
*
|
|
25
|
+
* When a user sends an attachment, core's media-store copies it to
|
|
26
|
+
* `~/.openclaw/media/inbound/<uuid>` — a bare uuid with no extension and no original
|
|
27
|
+
* name — and the transcript records THAT path. So on history rebuild the original name
|
|
28
|
+
* is unrecoverable. We stash it here (keyed by the inbound basename, reusing the sidecar
|
|
29
|
+
* scheme but inside our own attachments dir) at send time, while we still know it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function rememberInboundMediaName(inboundPath: string, filename: string, mimeType: string): void;
|
|
20
32
|
/**
|
|
21
33
|
* Read a file from `attachments/` by URL path token (disk basename).
|
|
22
34
|
* Used when the in-memory index was cleared after a gateway restart.
|
|
@@ -24,7 +36,10 @@ export interface StoredFile {
|
|
|
24
36
|
export declare function readAttachmentFileFromDisk(fileToken: string): {
|
|
25
37
|
buffer: Buffer;
|
|
26
38
|
mimeType: string;
|
|
39
|
+
/** Original display/download filename (from sidecar; falls back to the on-disk basename). */
|
|
27
40
|
filename: string;
|
|
41
|
+
/** On-disk basename / urlToken — use this to build `/friday-next/files/{token}` URLs. */
|
|
42
|
+
diskName: string;
|
|
28
43
|
} | null;
|
|
29
44
|
/**
|
|
30
45
|
* Copy a local file into `attachments/` and register it (no full-buffer read for the copy path).
|
|
@@ -54,6 +69,7 @@ export declare function getExternalFileSourceByUrlToken(token: string): string |
|
|
|
54
69
|
export declare function readFile(id: string): {
|
|
55
70
|
buffer: Buffer | null;
|
|
56
71
|
mimeType: string;
|
|
72
|
+
filename?: string;
|
|
57
73
|
};
|
|
58
74
|
/**
|
|
59
75
|
* Copy a file from a local filesystem path into the Friday Next channel file store
|
|
@@ -53,6 +53,57 @@ function registerStoredFile(file) {
|
|
|
53
53
|
function resolveStoredFile(key) {
|
|
54
54
|
return fileIndex.get(key) ?? fileTokenIndex.get(key);
|
|
55
55
|
}
|
|
56
|
+
/** Clear the in-memory file index. Test-only: simulates a gateway restart. */
|
|
57
|
+
export function clearFileIndexForTest() {
|
|
58
|
+
fileIndex.clear();
|
|
59
|
+
fileTokenIndex.clear();
|
|
60
|
+
externalFileSourceIndex.clear();
|
|
61
|
+
}
|
|
62
|
+
const META_SIDECAR_SUFFIX = ".fnmeta";
|
|
63
|
+
function metaSidecarPath(urlToken) {
|
|
64
|
+
return path.join(getAttachmentsDir(), `${urlToken}${META_SIDECAR_SUFFIX}`);
|
|
65
|
+
}
|
|
66
|
+
function writeAttachmentMetaSidecar(urlToken, filename, mimeType) {
|
|
67
|
+
try {
|
|
68
|
+
fs.writeFileSync(metaSidecarPath(urlToken), JSON.stringify({ filename, mimeType }));
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
logger.warn(`writeAttachmentMetaSidecar failed for "${urlToken}": ${String(err)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Remember the original upload filename for an inbound media file.
|
|
76
|
+
*
|
|
77
|
+
* When a user sends an attachment, core's media-store copies it to
|
|
78
|
+
* `~/.openclaw/media/inbound/<uuid>` — a bare uuid with no extension and no original
|
|
79
|
+
* name — and the transcript records THAT path. So on history rebuild the original name
|
|
80
|
+
* is unrecoverable. We stash it here (keyed by the inbound basename, reusing the sidecar
|
|
81
|
+
* scheme but inside our own attachments dir) at send time, while we still know it.
|
|
82
|
+
*/
|
|
83
|
+
export function rememberInboundMediaName(inboundPath, filename, mimeType) {
|
|
84
|
+
const key = path.basename(inboundPath);
|
|
85
|
+
const name = filename.trim();
|
|
86
|
+
if (!key || !name)
|
|
87
|
+
return;
|
|
88
|
+
writeAttachmentMetaSidecar(key, name, mimeType);
|
|
89
|
+
}
|
|
90
|
+
function readAttachmentMetaSidecar(urlToken) {
|
|
91
|
+
try {
|
|
92
|
+
const raw = fs.readFileSync(metaSidecarPath(urlToken), "utf8");
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
if (parsed && typeof parsed.filename === "string" && parsed.filename) {
|
|
95
|
+
return {
|
|
96
|
+
filename: parsed.filename,
|
|
97
|
+
mimeType: typeof parsed.mimeType === "string" ? parsed.mimeType : "",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Missing or malformed sidecar (e.g. attachment stored before this fix) — caller
|
|
103
|
+
// falls back to the on-disk basename.
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
56
107
|
/**
|
|
57
108
|
* Read a file from `attachments/` by URL path token (disk basename).
|
|
58
109
|
* Used when the in-memory index was cleared after a gateway restart.
|
|
@@ -61,13 +112,21 @@ export function readAttachmentFileFromDisk(fileToken) {
|
|
|
61
112
|
const safe = path.basename(fileToken);
|
|
62
113
|
if (!safe || safe === "." || safe === "..")
|
|
63
114
|
return null;
|
|
115
|
+
if (safe.endsWith(META_SIDECAR_SUFFIX))
|
|
116
|
+
return null;
|
|
64
117
|
const dir = getAttachmentsDir();
|
|
65
118
|
const full = path.join(dir, safe);
|
|
66
119
|
if (!fs.existsSync(full) || !fs.statSync(full).isFile())
|
|
67
120
|
return null;
|
|
68
121
|
try {
|
|
69
122
|
const buffer = fs.readFileSync(full);
|
|
70
|
-
|
|
123
|
+
const meta = readAttachmentMetaSidecar(safe);
|
|
124
|
+
return {
|
|
125
|
+
buffer,
|
|
126
|
+
mimeType: meta?.mimeType || guessMimeType(safe),
|
|
127
|
+
filename: meta?.filename || safe,
|
|
128
|
+
diskName: safe,
|
|
129
|
+
};
|
|
71
130
|
}
|
|
72
131
|
catch {
|
|
73
132
|
return null;
|
|
@@ -97,16 +156,19 @@ export function normalizeAgentMediaPath(raw) {
|
|
|
97
156
|
}
|
|
98
157
|
return s;
|
|
99
158
|
}
|
|
100
|
-
function copyLocalFileToAttachments(sourcePath) {
|
|
159
|
+
function copyLocalFileToAttachments(sourcePath, originalFilename) {
|
|
101
160
|
const resolvedPath = normalizeAgentMediaPath(sourcePath);
|
|
102
|
-
const
|
|
161
|
+
const diskBasename = path.basename(resolvedPath);
|
|
162
|
+
// Prefer the caller-supplied original name (recovered from an inbound sidecar); fall
|
|
163
|
+
// back to the on-disk basename (which for core inbound media is a bare uuid).
|
|
164
|
+
const filename = originalFilename?.trim() || diskBasename;
|
|
103
165
|
if (!filename)
|
|
104
166
|
return null;
|
|
105
167
|
try {
|
|
106
168
|
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile())
|
|
107
169
|
return null;
|
|
108
170
|
const id = crypto.randomUUID();
|
|
109
|
-
const ext = path.extname(filename);
|
|
171
|
+
const ext = path.extname(filename) || path.extname(diskBasename);
|
|
110
172
|
const urlToken = ext ? `${id}${ext}` : id;
|
|
111
173
|
const storedPath = path.join(getAttachmentsDir(), urlToken);
|
|
112
174
|
try {
|
|
@@ -131,6 +193,7 @@ function copyLocalFileToAttachments(sourcePath) {
|
|
|
131
193
|
createdAt: Date.now(),
|
|
132
194
|
};
|
|
133
195
|
registerStoredFile(file);
|
|
196
|
+
writeAttachmentMetaSidecar(urlToken, filename, mimeType);
|
|
134
197
|
return file;
|
|
135
198
|
}
|
|
136
199
|
catch (err) {
|
|
@@ -163,6 +226,7 @@ export function storeFile(buffer, filename, mimeType) {
|
|
|
163
226
|
createdAt: Date.now(),
|
|
164
227
|
};
|
|
165
228
|
registerStoredFile(file);
|
|
229
|
+
writeAttachmentMetaSidecar(urlToken, safeFilename, mimeType);
|
|
166
230
|
return file;
|
|
167
231
|
}
|
|
168
232
|
/**
|
|
@@ -196,7 +260,7 @@ export function fridayFilesPublicUrl(ref) {
|
|
|
196
260
|
}
|
|
197
261
|
const disk = readAttachmentFileFromDisk(lookupKey);
|
|
198
262
|
if (disk) {
|
|
199
|
-
return `/friday-next/files/${encodeURIComponent(disk.
|
|
263
|
+
return `/friday-next/files/${encodeURIComponent(disk.diskName)}`;
|
|
200
264
|
}
|
|
201
265
|
const trimmed = ref.trim();
|
|
202
266
|
if (trimmed.startsWith("/friday-next/files/")) {
|
|
@@ -215,10 +279,10 @@ export function readFile(id) {
|
|
|
215
279
|
if (!file)
|
|
216
280
|
return { buffer: null, mimeType: "application/octet-stream" };
|
|
217
281
|
try {
|
|
218
|
-
return { buffer: fs.readFileSync(file.path), mimeType: file.mimeType };
|
|
282
|
+
return { buffer: fs.readFileSync(file.path), mimeType: file.mimeType, filename: file.filename };
|
|
219
283
|
}
|
|
220
284
|
catch {
|
|
221
|
-
return { buffer: null, mimeType: file.mimeType };
|
|
285
|
+
return { buffer: null, mimeType: file.mimeType, filename: file.filename };
|
|
222
286
|
}
|
|
223
287
|
}
|
|
224
288
|
/**
|
|
@@ -262,19 +326,23 @@ export function resolveMediaAttachment(localPath) {
|
|
|
262
326
|
const fallback = path.basename(token);
|
|
263
327
|
return { fileName: fallback, url: localPath };
|
|
264
328
|
}
|
|
265
|
-
const
|
|
266
|
-
if (!
|
|
329
|
+
const basename = path.basename(localPath);
|
|
330
|
+
if (!basename)
|
|
267
331
|
return null;
|
|
268
|
-
|
|
332
|
+
// Core inbound media is stored as a bare uuid (no name/extension). Recover the original
|
|
333
|
+
// upload name we stashed at send time, keyed by that uuid basename.
|
|
334
|
+
const remembered = readAttachmentMetaSidecar(basename)?.filename;
|
|
335
|
+
const originalName = remembered || basename;
|
|
336
|
+
const stored = copyLocalFileToAttachments(localPath, originalName);
|
|
269
337
|
if (!stored) {
|
|
270
338
|
// Best-effort fallback: still return a Friday URL so app can receive attachment event.
|
|
271
339
|
// Download handler will try reading external source path lazily by token.
|
|
272
340
|
const id = crypto.randomUUID();
|
|
273
|
-
const ext = path.extname(
|
|
341
|
+
const ext = path.extname(originalName);
|
|
274
342
|
const token = ext ? `${id}${ext}` : id;
|
|
275
343
|
externalFileSourceIndex.set(token, normalizeAgentMediaPath(localPath));
|
|
276
344
|
return {
|
|
277
|
-
fileName:
|
|
345
|
+
fileName: originalName,
|
|
278
346
|
url: `/friday-next/files/${encodeURIComponent(token)}`,
|
|
279
347
|
};
|
|
280
348
|
}
|
|
@@ -19,7 +19,7 @@ import { extractBearerToken } from "../middleware/auth.js";
|
|
|
19
19
|
import { readJsonBody } from "../middleware/body.js";
|
|
20
20
|
import { registerFridaySessionDeviceMapping } from "../../friday-session.js";
|
|
21
21
|
import { touchFridayInbound } from "../../friday-inbound-stats.js";
|
|
22
|
-
import { fridayAttachmentLookupKey, fridayFilesPublicUrl, readFile, resolveMediaAttachment, resolveMediaUrl, } from "./files.js";
|
|
22
|
+
import { fridayAttachmentLookupKey, fridayFilesPublicUrl, readFile, rememberInboundMediaName, resolveMediaAttachment, resolveMediaUrl, } from "./files.js";
|
|
23
23
|
import { runFridayDispatch } from "../../agent/dispatch-bridge.js";
|
|
24
24
|
import { saveInboundMediaBuffer } from "../../agent/media-bridge.js";
|
|
25
25
|
import { contextTokensFromUsageRecord, getRunMetadata, getRunRoute, hasRunFinalDelivered, markRunFinalDelivered, registerRunRoute, setRunMetadata, } from "../../run-metadata.js";
|
|
@@ -277,11 +277,16 @@ async function buildBodyForAgentWithAttachments(text, attachmentIds) {
|
|
|
277
277
|
return text.trim();
|
|
278
278
|
const mediaRefs = [];
|
|
279
279
|
for (const id of attachmentIds) {
|
|
280
|
-
const { buffer, mimeType } = readFile(fridayAttachmentLookupKey(id));
|
|
280
|
+
const { buffer, mimeType, filename } = readFile(fridayAttachmentLookupKey(id));
|
|
281
281
|
if (!buffer)
|
|
282
282
|
continue;
|
|
283
|
-
const saved = await saveInboundMediaBuffer(buffer, mimeType);
|
|
283
|
+
const saved = await saveInboundMediaBuffer(buffer, mimeType, filename);
|
|
284
284
|
if (saved.id && saved.path) {
|
|
285
|
+
// Core's media-store renames inbound files to a bare uuid (no name/extension) and
|
|
286
|
+
// the transcript records that path — stash the original name now so history rebuild
|
|
287
|
+
// can restore it instead of surfacing the uuid.
|
|
288
|
+
if (filename)
|
|
289
|
+
rememberInboundMediaName(saved.path, filename, mimeType);
|
|
285
290
|
mediaRefs.push(`[media attached: file://${saved.path}]`);
|
|
286
291
|
}
|
|
287
292
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { type ThinkingLevelOption } from "../../thinking-levels.js";
|
|
2
3
|
export interface FridayModelEntry {
|
|
3
4
|
id: string;
|
|
4
5
|
name?: string;
|
|
@@ -6,5 +7,9 @@ export interface FridayModelEntry {
|
|
|
6
7
|
reasoning?: boolean;
|
|
7
8
|
contextWindow?: number;
|
|
8
9
|
maxTokens?: number;
|
|
10
|
+
/** Thinking levels this model supports (varies per model). Omitted when only the base set applies. */
|
|
11
|
+
thinkingLevels?: ThinkingLevelOption[];
|
|
12
|
+
/** Provider/model default thinking level, when the gateway reports one. */
|
|
13
|
+
thinkingDefault?: string;
|
|
9
14
|
}
|
|
10
15
|
export declare function handleModelsList(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
|
|
2
2
|
import { splitModelRef } from "../../session/session-manager.js";
|
|
3
|
+
import { resolveModelThinking } from "../../thinking-levels.js";
|
|
3
4
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
4
5
|
function resolveConfiguredModels() {
|
|
5
6
|
const rt = getFridayAgentForwardRuntime();
|
|
@@ -65,6 +66,13 @@ function resolveConfiguredModels() {
|
|
|
65
66
|
maxTokens: meta?.maxTokens,
|
|
66
67
|
});
|
|
67
68
|
}
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const split = splitModelRef(entry.id);
|
|
71
|
+
const thinking = resolveModelThinking(entry.provider || split.provider, split.modelId);
|
|
72
|
+
entry.thinkingLevels = thinking.levels;
|
|
73
|
+
if (thinking.default)
|
|
74
|
+
entry.thinkingDefault = thinking.default;
|
|
75
|
+
}
|
|
68
76
|
return { models: entries, defaultModel };
|
|
69
77
|
}
|
|
70
78
|
function buildProviderModelMeta(cfg) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { setSessionSettings, getSessionSettings, splitModelRef, resolveAgentDefaults, } from "../../session/session-manager.js";
|
|
2
2
|
import { readJsonBody } from "../middleware/body.js";
|
|
3
3
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
4
|
+
import { resolveModelThinkingForRef } from "../../thinking-levels.js";
|
|
4
5
|
const VALID_REASONING = new Set(["on", "off", "stream"]);
|
|
5
|
-
const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high"]);
|
|
6
6
|
export async function handleSessionsSettings(req, res) {
|
|
7
7
|
if (req.method !== "PUT" && req.method !== "GET") {
|
|
8
8
|
res.statusCode = 405;
|
|
@@ -44,12 +44,24 @@ export async function handleSessionsSettings(req, res) {
|
|
|
44
44
|
const reasoningLevel = typeof body?.reasoningLevel === "string" ? body.reasoningLevel : undefined;
|
|
45
45
|
const thinkingLevel = typeof body?.thinkingLevel === "string" ? body.thinkingLevel : undefined;
|
|
46
46
|
const modelRef = typeof body?.modelRef === "string" ? body.modelRef.trim() : undefined;
|
|
47
|
+
// The app omits (or empties) modelRef to mean "use the agent's default model". Resolve that
|
|
48
|
+
// default and write it as an *explicit* override, identical in shape to any other selection — so
|
|
49
|
+
// the agent runs the default exactly the way it runs an explicitly-picked model. Do NOT just
|
|
50
|
+
// clear the override here: the session entry is shared with the OpenClaw core, which stamps it
|
|
51
|
+
// with provenance fields (`modelOverrideSource`, `model`, `modelProvider`); deleting only our
|
|
52
|
+
// three fields leaves those dangling and the core mis-resolves to a fallback model.
|
|
53
|
+
const effectiveModelRef = modelRef || resolveAgentDefaults(sessionKey).model;
|
|
47
54
|
const errors = [];
|
|
48
55
|
if (reasoningLevel !== undefined && !VALID_REASONING.has(reasoningLevel)) {
|
|
49
56
|
errors.push(`reasoningLevel must be one of: ${[...VALID_REASONING].join(", ")}`);
|
|
50
57
|
}
|
|
51
|
-
if (thinkingLevel !== undefined
|
|
52
|
-
|
|
58
|
+
if (thinkingLevel !== undefined) {
|
|
59
|
+
// Thinking levels vary per model, so validate against the levels the *effective* model supports
|
|
60
|
+
// (resolved from the running gateway). Falls back to the base five levels when unresolvable.
|
|
61
|
+
const supported = resolveModelThinkingForRef(effectiveModelRef).levels.map((l) => l.id);
|
|
62
|
+
if (!supported.includes(thinkingLevel)) {
|
|
63
|
+
errors.push(`thinkingLevel must be one of: ${supported.join(", ")}`);
|
|
64
|
+
}
|
|
53
65
|
}
|
|
54
66
|
if (errors.length > 0) {
|
|
55
67
|
res.statusCode = 400;
|
|
@@ -57,13 +69,6 @@ export async function handleSessionsSettings(req, res) {
|
|
|
57
69
|
res.end(JSON.stringify({ error: errors.join("; ") }));
|
|
58
70
|
return true;
|
|
59
71
|
}
|
|
60
|
-
// The app omits (or empties) modelRef to mean "use the agent's default model". Resolve that
|
|
61
|
-
// default and write it as an *explicit* override, identical in shape to any other selection — so
|
|
62
|
-
// the agent runs the default exactly the way it runs an explicitly-picked model. Do NOT just
|
|
63
|
-
// clear the override here: the session entry is shared with the OpenClaw core, which stamps it
|
|
64
|
-
// with provenance fields (`modelOverrideSource`, `model`, `modelProvider`); deleting only our
|
|
65
|
-
// three fields leaves those dangling and the core mis-resolves to a fallback model.
|
|
66
|
-
const effectiveModelRef = modelRef || resolveAgentDefaults(sessionKey).model;
|
|
67
72
|
const settings = { reasoningLevel, thinkingLevel };
|
|
68
73
|
if (effectiveModelRef) {
|
|
69
74
|
const split = splitModelRef(effectiveModelRef);
|
package/dist/src/http/server.js
CHANGED
|
@@ -14,6 +14,9 @@ import { handleNodesApprove } from "./handlers/nodes-approve.js";
|
|
|
14
14
|
import { handleSessionsSettings } from "./handlers/sessions-settings.js";
|
|
15
15
|
import { handleModelsList } from "./handlers/models-list.js";
|
|
16
16
|
import { handleAgentsList } from "./handlers/agents-list.js";
|
|
17
|
+
import { handleAgentConfig } from "./handlers/agent-config.js";
|
|
18
|
+
import { handleAgentFiles } from "./handlers/agent-files.js";
|
|
19
|
+
import { handleAgentToolsCatalog } from "./handlers/agent-tools-catalog.js";
|
|
17
20
|
import { handleHistorySessions } from "./handlers/history-sessions.js";
|
|
18
21
|
import { handleHistoryMessages } from "./handlers/history-messages.js";
|
|
19
22
|
import { handleHistorySetTitle } from "./handlers/history-set-title.js";
|
|
@@ -71,6 +74,26 @@ async function handleFridayNextRoute(req, res) {
|
|
|
71
74
|
if (req.method === "GET" && pathname === "/friday-next/agents") {
|
|
72
75
|
return await handleAgentsList(req, res);
|
|
73
76
|
}
|
|
77
|
+
// Routes: GET/PUT /friday-next/agents/{id}/config
|
|
78
|
+
// GET /friday-next/agents/{id}/files
|
|
79
|
+
// GET/PUT /friday-next/agents/{id}/files/{name}
|
|
80
|
+
if (pathname.startsWith("/friday-next/agents/")) {
|
|
81
|
+
const segs = pathname
|
|
82
|
+
.slice("/friday-next/agents/".length)
|
|
83
|
+
.split("/")
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.map((s) => decodeURIComponent(s));
|
|
86
|
+
const [id, sub, name] = segs;
|
|
87
|
+
if (id && sub === "config" && segs.length === 2) {
|
|
88
|
+
return await handleAgentConfig(req, res, id);
|
|
89
|
+
}
|
|
90
|
+
if (id && sub === "files" && (segs.length === 2 || segs.length === 3)) {
|
|
91
|
+
return await handleAgentFiles(req, res, id, name);
|
|
92
|
+
}
|
|
93
|
+
if (id && sub === "tools" && name === "catalog" && segs.length === 3) {
|
|
94
|
+
return await handleAgentToolsCatalog(req, res, id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
74
97
|
if (req.method === "GET" && pathname === "/friday-next/status") {
|
|
75
98
|
return await handleStatus(req, res);
|
|
76
99
|
}
|
|
@@ -98,8 +98,12 @@ function isPrivateIPv6(ip) {
|
|
|
98
98
|
if (lower === "::" || lower === "::1")
|
|
99
99
|
return true; // unspecified / loopback
|
|
100
100
|
const head = lower.split(":")[0];
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
// fc00::/8 (the reserved, never-assigned half of ULA fc00::/7) intentionally NOT blocked:
|
|
102
|
+
// RFC 4193 requires locally-assigned ULA to set the L bit, so real LAN services live in
|
|
103
|
+
// fd00::/8, while fake-IP DNS setups (mihomo/clash tun mode with IPv6, common on gateway
|
|
104
|
+
// hosts) resolve EVERY domain into fc00::/18. Same rationale as 198.18/15 on the IPv4 side.
|
|
105
|
+
if (head.startsWith("fd"))
|
|
106
|
+
return true; // fd00::/8 locally-assigned ULA
|
|
103
107
|
if (/^fe[89ab]/.test(head))
|
|
104
108
|
return true; // fe80::/10 link-local
|
|
105
109
|
return false;
|
package/dist/src/media-fetch.js
CHANGED
|
@@ -8,7 +8,10 @@ import { guessMimeType } from "./http/handlers/files.js";
|
|
|
8
8
|
* gateway's `/friday-next/files/` route — identical to the local-file path. This keeps the app's
|
|
9
9
|
* download story uniform (always talks to the trusted gateway host with a bearer token).
|
|
10
10
|
*/
|
|
11
|
-
|
|
11
|
+
// Aligns with openclaw's own remote-media ceiling (DEFAULT_FETCH_MEDIA_MAX_BYTES =
|
|
12
|
+
// MAX_DOCUMENT_BYTES = 100MB). The real per-kind limit is enforced downstream by
|
|
13
|
+
// saveMediaBuffer (via resolveMediaMaxBytes); this is just the download ceiling.
|
|
14
|
+
const MAX_REMOTE_MEDIA_BYTES = 100 * 1024 * 1024; // 100MB
|
|
12
15
|
const REMOTE_MEDIA_TIMEOUT_MS = 20_000;
|
|
13
16
|
export function isHttpUrl(value) {
|
|
14
17
|
return /^https?:\/\//i.test(value.trim());
|