@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.
Files changed (71) hide show
  1. package/README.md +8 -4
  2. package/dist/src/agent/abort-run.d.ts +12 -1
  3. package/dist/src/agent/abort-run.js +24 -9
  4. package/dist/src/agent/media-bridge.d.ts +8 -1
  5. package/dist/src/agent/media-bridge.js +23 -2
  6. package/dist/src/agent-forward-runtime.d.ts +15 -0
  7. package/dist/src/agent-forward-runtime.js +2 -0
  8. package/dist/src/agent-id.d.ts +8 -0
  9. package/dist/src/agent-id.js +21 -0
  10. package/dist/src/channel-actions.js +45 -14
  11. package/dist/src/channel.js +22 -1
  12. package/dist/src/http/handlers/agent-config.d.ts +27 -0
  13. package/dist/src/http/handlers/agent-config.js +182 -0
  14. package/dist/src/http/handlers/agent-files.d.ts +21 -0
  15. package/dist/src/http/handlers/agent-files.js +137 -0
  16. package/dist/src/http/handlers/agent-tools-catalog.d.ts +10 -0
  17. package/dist/src/http/handlers/agent-tools-catalog.js +33 -0
  18. package/dist/src/http/handlers/agents-list.js +1 -19
  19. package/dist/src/http/handlers/cancel.js +12 -6
  20. package/dist/src/http/handlers/files.d.ts +16 -0
  21. package/dist/src/http/handlers/files.js +80 -12
  22. package/dist/src/http/handlers/messages.js +8 -3
  23. package/dist/src/http/handlers/models-list.d.ts +5 -0
  24. package/dist/src/http/handlers/models-list.js +8 -0
  25. package/dist/src/http/handlers/sessions-settings.js +15 -10
  26. package/dist/src/http/server.js +23 -0
  27. package/dist/src/link-preview/ssrf-guard.js +6 -2
  28. package/dist/src/media-fetch.js +4 -1
  29. package/dist/src/skills-discovery.d.ts +58 -0
  30. package/dist/src/skills-discovery.js +247 -0
  31. package/dist/src/thinking-levels.d.ts +21 -0
  32. package/dist/src/thinking-levels.js +48 -0
  33. package/dist/src/tool-catalog.d.ts +53 -0
  34. package/dist/src/tool-catalog.js +192 -0
  35. package/dist/src/version.js +1 -1
  36. package/package.json +1 -1
  37. package/src/agent/abort-run.ts +24 -8
  38. package/src/agent/media-bridge.test.ts +71 -0
  39. package/src/agent/media-bridge.ts +23 -1
  40. package/src/agent-forward-runtime.ts +11 -0
  41. package/src/agent-id.ts +24 -0
  42. package/src/channel-actions.test.ts +47 -0
  43. package/src/channel-actions.ts +38 -14
  44. package/src/channel.lifecycle.test.ts +41 -0
  45. package/src/channel.ts +23 -1
  46. package/src/http/handlers/agent-config.test.ts +205 -0
  47. package/src/http/handlers/agent-config.ts +218 -0
  48. package/src/http/handlers/agent-files.test.ts +136 -0
  49. package/src/http/handlers/agent-files.ts +149 -0
  50. package/src/http/handlers/agent-tools-catalog.ts +42 -0
  51. package/src/http/handlers/agents-list.ts +1 -22
  52. package/src/http/handlers/cancel.test.ts +12 -2
  53. package/src/http/handlers/cancel.ts +12 -6
  54. package/src/http/handlers/files.test.ts +114 -0
  55. package/src/http/handlers/files.ts +97 -13
  56. package/src/http/handlers/messages.ts +7 -2
  57. package/src/http/handlers/models-list.test.ts +114 -0
  58. package/src/http/handlers/models-list.ts +12 -0
  59. package/src/http/handlers/sessions-settings.ts +16 -11
  60. package/src/http/server.ts +24 -0
  61. package/src/link-preview/ssrf-guard.test.ts +7 -2
  62. package/src/link-preview/ssrf-guard.ts +5 -1
  63. package/src/media-fetch.test.ts +1 -1
  64. package/src/media-fetch.ts +4 -1
  65. package/src/openclaw.d.ts +25 -1
  66. package/src/skills-discovery.test.ts +148 -0
  67. package/src/skills-discovery.ts +248 -0
  68. package/src/thinking-levels.test.ts +143 -0
  69. package/src/thinking-levels.ts +68 -0
  70. package/src/tool-catalog.ts +252 -0
  71. 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
- const DEFAULT_AGENT_ID = "main";
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 { abortRun } from "../../agent/abort-run.js";
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
- if (!runId) {
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 abortRun(runId);
28
- sseEmitter.untrackRun(runId);
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
- return { buffer, mimeType: guessMimeType(safe), filename: safe };
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 filename = path.basename(resolvedPath);
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.filename)}`;
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 filename = path.basename(localPath);
266
- if (!filename)
329
+ const basename = path.basename(localPath);
330
+ if (!basename)
267
331
  return null;
268
- const stored = copyLocalFileToAttachments(localPath);
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(filename);
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: 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 && !VALID_THINKING.has(thinkingLevel)) {
52
- errors.push(`thinkingLevel must be one of: ${[...VALID_THINKING].join(", ")}`);
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);
@@ -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
- if (head.startsWith("fc") || head.startsWith("fd"))
102
- return true; // fc00::/7 ULA
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;
@@ -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
- const MAX_REMOTE_MEDIA_BYTES = 25 * 1024 * 1024; // 25MB
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());