@syengup/friday-channel-next 0.1.30 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +8 -4
  2. package/dist/index.js +1 -1
  3. package/dist/src/agent/abort-run.d.ts +12 -1
  4. package/dist/src/agent/abort-run.js +24 -9
  5. package/dist/src/agent/dispatch-bridge.d.ts +1 -1
  6. package/dist/src/agent/media-bridge.d.ts +8 -1
  7. package/dist/src/agent/media-bridge.js +23 -2
  8. package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
  9. package/dist/src/agent/node-pairing-bridge.js +6 -2
  10. package/dist/src/agent/subagent-registry.js +0 -3
  11. package/dist/src/agent-forward-runtime.d.ts +15 -0
  12. package/dist/src/agent-forward-runtime.js +2 -0
  13. package/dist/src/agent-id.d.ts +8 -0
  14. package/dist/src/agent-id.js +21 -0
  15. package/dist/src/channel-actions.js +48 -15
  16. package/dist/src/channel.js +22 -3
  17. package/dist/src/collect-message-media-paths.js +10 -1
  18. package/dist/src/friday-session.js +34 -10
  19. package/dist/src/history/normalize-message.js +22 -8
  20. package/dist/src/http/handlers/agent-config.d.ts +27 -0
  21. package/dist/src/http/handlers/agent-config.js +188 -0
  22. package/dist/src/http/handlers/agent-files.d.ts +21 -0
  23. package/dist/src/http/handlers/agent-files.js +137 -0
  24. package/dist/src/http/handlers/agent-tools-catalog.d.ts +10 -0
  25. package/dist/src/http/handlers/agent-tools-catalog.js +33 -0
  26. package/dist/src/http/handlers/agents-list.js +1 -19
  27. package/dist/src/http/handlers/cancel.js +14 -6
  28. package/dist/src/http/handlers/device-approve.js +3 -1
  29. package/dist/src/http/handlers/files-download.js +6 -8
  30. package/dist/src/http/handlers/files.d.ts +16 -0
  31. package/dist/src/http/handlers/files.js +81 -13
  32. package/dist/src/http/handlers/health.js +18 -4
  33. package/dist/src/http/handlers/history-messages.js +1 -1
  34. package/dist/src/http/handlers/history-sessions.js +5 -3
  35. package/dist/src/http/handlers/messages.js +33 -14
  36. package/dist/src/http/handlers/models-list.d.ts +5 -0
  37. package/dist/src/http/handlers/models-list.js +9 -1
  38. package/dist/src/http/handlers/nodes-approve.js +1 -6
  39. package/dist/src/http/handlers/plugin-info.js +1 -1
  40. package/dist/src/http/handlers/sessions-settings.js +15 -10
  41. package/dist/src/http/server.js +27 -2
  42. package/dist/src/link-preview/og-parse.js +3 -1
  43. package/dist/src/link-preview/ssrf-guard.js +6 -2
  44. package/dist/src/media-fetch.js +4 -1
  45. package/dist/src/plugin-install-info.js +4 -1
  46. package/dist/src/session/session-manager.js +9 -3
  47. package/dist/src/session-usage-store.js +3 -1
  48. package/dist/src/skills-discovery.d.ts +59 -0
  49. package/dist/src/skills-discovery.js +252 -0
  50. package/dist/src/sse/offline-queue.js +4 -1
  51. package/dist/src/thinking-levels.d.ts +21 -0
  52. package/dist/src/thinking-levels.js +48 -0
  53. package/dist/src/tool-catalog.d.ts +53 -0
  54. package/dist/src/tool-catalog.js +191 -0
  55. package/dist/src/upgrade-runtime.d.ts +1 -1
  56. package/dist/src/version.js +4 -2
  57. package/index.ts +43 -35
  58. package/install.js +131 -43
  59. package/package.json +10 -1
  60. package/src/agent/abort-run.ts +23 -8
  61. package/src/agent/dispatch-bridge.ts +2 -1
  62. package/src/agent/media-bridge.test.ts +71 -0
  63. package/src/agent/media-bridge.ts +30 -1
  64. package/src/agent/node-pairing-bridge.ts +29 -15
  65. package/src/agent/run-usage-accumulator.ts +4 -2
  66. package/src/agent/subagent-registry.ts +0 -4
  67. package/src/agent-forward-runtime.ts +11 -0
  68. package/src/agent-id.ts +24 -0
  69. package/src/agent-run-context-bridge.ts +3 -1
  70. package/src/channel-actions.test.ts +57 -4
  71. package/src/channel-actions.ts +41 -15
  72. package/src/channel.lifecycle.test.ts +41 -0
  73. package/src/channel.outbound.test.ts +18 -4
  74. package/src/channel.ts +140 -120
  75. package/src/collect-message-media-paths.ts +15 -6
  76. package/src/config.ts +1 -4
  77. package/src/e2e/agents-list.e2e.test.ts +9 -2
  78. package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
  79. package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
  80. package/src/e2e/auto-approve.integration.test.ts +13 -7
  81. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
  82. package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
  83. package/src/e2e/offline-replay.e2e.test.ts +17 -3
  84. package/src/e2e/send-text.e2e.test.ts +11 -2
  85. package/src/e2e/slash-commands.e2e.test.ts +5 -1
  86. package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
  87. package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
  88. package/src/e2e/subagent.e2e.test.ts +136 -53
  89. package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
  90. package/src/friday-session.forward-agent.test.ts +44 -12
  91. package/src/friday-session.ts +44 -20
  92. package/src/history/normalize-message.test.ts +35 -8
  93. package/src/history/normalize-message.ts +24 -12
  94. package/src/history/read-transcript.ts +1 -4
  95. package/src/http/handlers/agent-config.test.ts +212 -0
  96. package/src/http/handlers/agent-config.ts +232 -0
  97. package/src/http/handlers/agent-files.test.ts +136 -0
  98. package/src/http/handlers/agent-files.ts +149 -0
  99. package/src/http/handlers/agent-tools-catalog.ts +42 -0
  100. package/src/http/handlers/agents-list.test.ts +1 -5
  101. package/src/http/handlers/agents-list.ts +1 -22
  102. package/src/http/handlers/cancel.test.ts +23 -4
  103. package/src/http/handlers/cancel.ts +14 -6
  104. package/src/http/handlers/device-approve.test.ts +12 -3
  105. package/src/http/handlers/device-approve.ts +33 -21
  106. package/src/http/handlers/files-download.ts +17 -13
  107. package/src/http/handlers/files.test.ts +120 -0
  108. package/src/http/handlers/files.ts +115 -17
  109. package/src/http/handlers/health.test.ts +43 -11
  110. package/src/http/handlers/health.ts +22 -6
  111. package/src/http/handlers/history-messages.test.ts +51 -9
  112. package/src/http/handlers/history-messages.ts +4 -1
  113. package/src/http/handlers/history-sessions.test.ts +46 -9
  114. package/src/http/handlers/history-sessions.ts +5 -3
  115. package/src/http/handlers/history-set-title.test.ts +14 -5
  116. package/src/http/handlers/link-preview.test.ts +57 -16
  117. package/src/http/handlers/link-preview.ts +4 -1
  118. package/src/http/handlers/messages.test.ts +12 -8
  119. package/src/http/handlers/messages.ts +64 -21
  120. package/src/http/handlers/models-list.test.ts +114 -0
  121. package/src/http/handlers/models-list.ts +26 -8
  122. package/src/http/handlers/nodes-approve.test.ts +15 -4
  123. package/src/http/handlers/nodes-approve.ts +38 -40
  124. package/src/http/handlers/plugin-info.ts +5 -6
  125. package/src/http/handlers/plugin-upgrade.ts +4 -1
  126. package/src/http/handlers/sessions-settings.ts +16 -11
  127. package/src/http/handlers/sse.ts +3 -1
  128. package/src/http/server.ts +33 -6
  129. package/src/link-preview/og-parse.test.ts +6 -2
  130. package/src/link-preview/og-parse.ts +10 -3
  131. package/src/link-preview/preview-service.ts +4 -1
  132. package/src/link-preview/ssrf-guard.test.ts +78 -16
  133. package/src/link-preview/ssrf-guard.ts +7 -2
  134. package/src/media-fetch.test.ts +8 -3
  135. package/src/media-fetch.ts +5 -3
  136. package/src/openclaw.d.ts +41 -10
  137. package/src/plugin-install-info.ts +20 -9
  138. package/src/run-metadata.ts +2 -1
  139. package/src/session/session-manager.ts +19 -11
  140. package/src/session-usage-snapshot.ts +3 -1
  141. package/src/session-usage-store.ts +3 -1
  142. package/src/skills-discovery.test.ts +152 -0
  143. package/src/skills-discovery.ts +264 -0
  144. package/src/sse/emitter.test.ts +1 -1
  145. package/src/sse/emitter.ts +9 -3
  146. package/src/sse/offline-queue.ts +17 -8
  147. package/src/test-support/app-simulator.ts +17 -3
  148. package/src/test-support/mock-dispatch.ts +17 -4
  149. package/src/thinking-levels.test.ts +143 -0
  150. package/src/thinking-levels.ts +70 -0
  151. package/src/tool-catalog.ts +261 -0
  152. package/src/upgrade-runtime.ts +4 -2
  153. package/src/version.ts +6 -2
  154. package/tsconfig.json +1 -1
@@ -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) {
@@ -151,7 +214,7 @@ export function storeFile(buffer, filename, mimeType) {
151
214
  fs.writeFileSync(storedPath, buffer);
152
215
  }
153
216
  catch (err) {
154
- throw new Error(`Failed to store file: ${String(err)}`);
217
+ throw new Error(`Failed to store file: ${String(err)}`, { cause: err });
155
218
  }
156
219
  const file = {
157
220
  id,
@@ -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
  }
@@ -43,7 +43,10 @@ export async function handleHealth(req, res) {
43
43
  if (nodeDeviceId) {
44
44
  result.nodePairing = await checkNodePairing(nodeDeviceId, selfHeal, result, log);
45
45
  }
46
- result.ok = !result.nodePairing || (result.nodePairing.status === "ok" || result.nodePairing.status === "pending");
46
+ result.ok =
47
+ !result.nodePairing ||
48
+ result.nodePairing.status === "ok" ||
49
+ result.nodePairing.status === "pending";
47
50
  res.statusCode = 200;
48
51
  res.setHeader("Content-Type", "application/json");
49
52
  res.end(JSON.stringify(result));
@@ -108,9 +111,16 @@ async function checkNodePairing(nodeDeviceId, selfHeal, result, log) {
108
111
  const pendingMatch = pendingNodes.find((entry) => entry.nodeId?.trim().toUpperCase() === normalizedNodeId);
109
112
  if (pendingMatch && selfHeal) {
110
113
  try {
111
- const callerScopes = ["operator.admin", "operator.pairing", "operator.read", "operator.write"];
114
+ const callerScopes = [
115
+ "operator.admin",
116
+ "operator.pairing",
117
+ "operator.read",
118
+ "operator.write",
119
+ ];
112
120
  const approved = await approveNodePairing(pendingMatch.requestId, { callerScopes });
113
- const succeeded = approved != null && !("status" in approved && approved.status === "forbidden") && "requestId" in approved;
121
+ const succeeded = approved != null &&
122
+ !("status" in approved && approved.status === "forbidden") &&
123
+ "requestId" in approved;
114
124
  (result.repairActions ??= []).push({
115
125
  component: "nodePairing",
116
126
  action: "approveNodePairing",
@@ -142,5 +152,9 @@ async function checkNodePairing(nodeDeviceId, selfHeal, result, log) {
142
152
  if (pendingMatch) {
143
153
  return { status: "pending", detail: "Node is pending approval", nodePaired: false };
144
154
  }
145
- return { status: "not_found", detail: `Node ${normalizedNodeId} not registered`, nodePaired: false };
155
+ return {
156
+ status: "not_found",
157
+ detail: `Node ${normalizedNodeId} not registered`,
158
+ nodePaired: false,
159
+ };
146
160
  }
@@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url";
13
13
  import { getFridayNextRuntime } from "../../runtime.js";
14
14
  import { extractBearerToken } from "../middleware/auth.js";
15
15
  import { normalizeHistoryMessages } from "../../history/normalize-message.js";
16
- import { readSessionTranscriptRawMessages, resolveSessionId } from "../../history/read-transcript.js";
16
+ import { readSessionTranscriptRawMessages, resolveSessionId, } from "../../history/read-transcript.js";
17
17
  import { resolveMediaAttachment } from "./files.js";
18
18
  import { readSessionUsageSnapshotFromStore } from "../../session-usage-store.js";
19
19
  const DEFAULT_LIMIT = 200;
@@ -126,12 +126,14 @@ function readAgentSessions(agentId) {
126
126
  sessionKey: canonicalKey,
127
127
  agentId,
128
128
  ...(readString(entry.sessionId) ? { sessionId: readString(entry.sessionId) } : {}),
129
- ...(readNumber(entry.updatedAt) !== undefined ? { updatedAt: readNumber(entry.updatedAt) } : {}),
130
- ...(readString(entry.model) ?? readString(entry.modelOverride)
129
+ ...(readNumber(entry.updatedAt) !== undefined
130
+ ? { updatedAt: readNumber(entry.updatedAt) }
131
+ : {}),
132
+ ...((readString(entry.model) ?? readString(entry.modelOverride))
131
133
  ? { model: readString(entry.model) ?? readString(entry.modelOverride) }
132
134
  : {}),
133
135
  // Server-side session display name (matches OpenClaw's resolution order).
134
- ...(readString(entry.displayName) ?? readString(entry.label)
136
+ ...((readString(entry.displayName) ?? readString(entry.label))
135
137
  ? { title: readString(entry.displayName) ?? readString(entry.label) }
136
138
  : {}),
137
139
  });
@@ -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";
@@ -148,7 +148,9 @@ export function translateDeliverPayload(pl, kind, meta) {
148
148
  if (typeof meta?.modelName === "string" && meta.modelName.trim()) {
149
149
  nextFridayNext.modelName = meta.modelName.trim();
150
150
  }
151
- if (typeof meta?.totalTokens === "number" && Number.isFinite(meta.totalTokens) && meta.totalTokens > 0) {
151
+ if (typeof meta?.totalTokens === "number" &&
152
+ Number.isFinite(meta.totalTokens) &&
153
+ meta.totalTokens > 0) {
152
154
  nextFridayNext.totalTokens = Math.floor(meta.totalTokens);
153
155
  }
154
156
  if (typeof meta?.contextTokensUsed === "number" &&
@@ -221,10 +223,16 @@ function pickMetadataFromMessageLike(message) {
221
223
  ? usage.totalTokens
222
224
  : undefined) ??
223
225
  (typeof usage?.total === "number" && Number.isFinite(usage.total) ? usage.total : undefined) ??
224
- (typeof usage?.total_tokens === "number" && Number.isFinite(usage.total_tokens) ? usage.total_tokens : undefined);
225
- const totalFromMessage = (typeof m.totalTokens === "number" && Number.isFinite(m.totalTokens) ? m.totalTokens : undefined) ??
226
- (typeof m.total_tokens === "number" && Number.isFinite(m.total_tokens) ? m.total_tokens : undefined);
227
- const totalTokens = Math.floor((totalFromUsage ?? totalFromMessage ?? 0));
226
+ (typeof usage?.total_tokens === "number" && Number.isFinite(usage.total_tokens)
227
+ ? usage.total_tokens
228
+ : undefined);
229
+ const totalFromMessage = (typeof m.totalTokens === "number" && Number.isFinite(m.totalTokens)
230
+ ? m.totalTokens
231
+ : undefined) ??
232
+ (typeof m.total_tokens === "number" && Number.isFinite(m.total_tokens)
233
+ ? m.total_tokens
234
+ : undefined);
235
+ const totalTokens = Math.floor(totalFromUsage ?? totalFromMessage ?? 0);
228
236
  let contextTokensUsed;
229
237
  if (usage) {
230
238
  const ctx = contextTokensFromUsageRecord(usage);
@@ -232,8 +240,12 @@ function pickMetadataFromMessageLike(message) {
232
240
  contextTokensUsed = ctx;
233
241
  }
234
242
  }
235
- const ctxMaxRaw = (typeof m.contextWindow === "number" && Number.isFinite(m.contextWindow) ? m.contextWindow : undefined) ??
236
- (typeof m.maxContextTokens === "number" && Number.isFinite(m.maxContextTokens) ? m.maxContextTokens : undefined);
243
+ const ctxMaxRaw = (typeof m.contextWindow === "number" && Number.isFinite(m.contextWindow)
244
+ ? m.contextWindow
245
+ : undefined) ??
246
+ (typeof m.maxContextTokens === "number" && Number.isFinite(m.maxContextTokens)
247
+ ? m.maxContextTokens
248
+ : undefined);
237
249
  const contextWindowMax = typeof ctxMaxRaw === "number" && ctxMaxRaw > 0 ? Math.floor(ctxMaxRaw) : undefined;
238
250
  if (!modelName && !(totalTokens > 0) && !contextTokensUsed && !contextWindowMax)
239
251
  return null;
@@ -277,11 +289,16 @@ async function buildBodyForAgentWithAttachments(text, attachmentIds) {
277
289
  return text.trim();
278
290
  const mediaRefs = [];
279
291
  for (const id of attachmentIds) {
280
- const { buffer, mimeType } = readFile(fridayAttachmentLookupKey(id));
292
+ const { buffer, mimeType, filename } = readFile(fridayAttachmentLookupKey(id));
281
293
  if (!buffer)
282
294
  continue;
283
- const saved = await saveInboundMediaBuffer(buffer, mimeType);
295
+ const saved = await saveInboundMediaBuffer(buffer, mimeType, filename);
284
296
  if (saved.id && saved.path) {
297
+ // Core's media-store renames inbound files to a bare uuid (no name/extension) and
298
+ // the transcript records that path — stash the original name now so history rebuild
299
+ // can restore it instead of surfacing the uuid.
300
+ if (filename)
301
+ rememberInboundMediaName(saved.path, filename, mimeType);
285
302
  mediaRefs.push(`[media attached: file://${saved.path}]`);
286
303
  }
287
304
  }
@@ -402,7 +419,8 @@ export async function handleMessages(req, res) {
402
419
  dispatcherOptions: {
403
420
  deliver: async (pl, info) => {
404
421
  let meta = getRunMetadata(runId);
405
- if (info.kind.toLowerCase() === "final" && !(meta?.modelName || typeof meta?.totalTokens === "number")) {
422
+ if (info.kind.toLowerCase() === "final" &&
423
+ !(meta?.modelName || typeof meta?.totalTokens === "number")) {
406
424
  const resolved = await resolveRunMetadataFromRuntimeSession(runtime, baseSessionKey);
407
425
  if (resolved) {
408
426
  setRunMetadata(runId, resolved);
@@ -457,9 +475,10 @@ export async function handleMessages(req, res) {
457
475
  // OpenClaw `pi-embedded-subscribe` gates `streamReasoning` on `typeof onReasoningStream === "function"`.
458
476
  // Without this, `emitReasoningStream` never runs and Friday SSE never sees `stream: "thinking"`.
459
477
  onReasoningStream: async (pl) => {
460
- const text = typeof pl === "object" && pl !== null && "text" in pl
461
- ? String(pl.text ?? "")
462
- : "";
478
+ const rawText = typeof pl === "object" && pl !== null && "text" in pl
479
+ ? pl.text
480
+ : undefined;
481
+ const text = typeof rawText === "string" ? rawText : "";
463
482
  log("REASONING_STREAM", normalizedDeviceId, runId, `textLen=${text.length}`);
464
483
  },
465
484
  onReasoningEnd: async () => {
@@ -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();
@@ -21,7 +22,7 @@ function resolveConfiguredModels() {
21
22
  seen.add(modelKey);
22
23
  entries.push({
23
24
  id: modelKey,
24
- name: typeof info?.alias === "string" ? info.alias : meta?.name ?? split.modelId,
25
+ name: typeof info?.alias === "string" ? info.alias : (meta?.name ?? split.modelId),
25
26
  provider: split.provider,
26
27
  reasoning: meta?.reasoning,
27
28
  contextWindow: meta?.contextWindow,
@@ -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) {
@@ -58,12 +58,7 @@ export async function handleNodesApprove(req, res) {
58
58
  if (pendingMatch) {
59
59
  const requestId = pendingMatch.requestId;
60
60
  log.info(`approving nodeId=${normalizedNodeId} requestId=${requestId}`);
61
- const callerScopes = [
62
- "operator.admin",
63
- "operator.pairing",
64
- "operator.read",
65
- "operator.write",
66
- ];
61
+ const callerScopes = ["operator.admin", "operator.pairing", "operator.read", "operator.write"];
67
62
  let approved;
68
63
  try {
69
64
  approved = await approveNodePairing(requestId, { callerScopes });
@@ -1,6 +1,6 @@
1
1
  import { extractBearerToken } from "../middleware/auth.js";
2
2
  import { PLUGIN_VERSION } from "../../version.js";
3
- import { fetchLatestVersion, getInstallSource, semverGreater, } from "../../plugin-install-info.js";
3
+ import { fetchLatestVersion, getInstallSource, semverGreater } from "../../plugin-install-info.js";
4
4
  export async function handlePluginInfo(req, res) {
5
5
  if (req.method !== "GET") {
6
6
  res.statusCode = 405;
@@ -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";
@@ -62,7 +65,8 @@ async function handleFridayNextRoute(req, res) {
62
65
  if (req.method === "POST" && pathname === "/friday-next/nodes-approve") {
63
66
  return await handleNodesApprove(req, res);
64
67
  }
65
- if ((req.method === "PUT" || req.method === "GET") && pathname === "/friday-next/sessions/settings") {
68
+ if ((req.method === "PUT" || req.method === "GET") &&
69
+ pathname === "/friday-next/sessions/settings") {
66
70
  return await handleSessionsSettings(req, res);
67
71
  }
68
72
  if (req.method === "GET" && pathname === "/friday-next/models") {
@@ -71,6 +75,26 @@ async function handleFridayNextRoute(req, res) {
71
75
  if (req.method === "GET" && pathname === "/friday-next/agents") {
72
76
  return await handleAgentsList(req, res);
73
77
  }
78
+ // Routes: GET/PUT /friday-next/agents/{id}/config
79
+ // GET /friday-next/agents/{id}/files
80
+ // GET/PUT /friday-next/agents/{id}/files/{name}
81
+ if (pathname.startsWith("/friday-next/agents/")) {
82
+ const segs = pathname
83
+ .slice("/friday-next/agents/".length)
84
+ .split("/")
85
+ .filter(Boolean)
86
+ .map((s) => decodeURIComponent(s));
87
+ const [id, sub, name] = segs;
88
+ if (id && sub === "config" && segs.length === 2) {
89
+ return await handleAgentConfig(req, res, id);
90
+ }
91
+ if (id && sub === "files" && (segs.length === 2 || segs.length === 3)) {
92
+ return await handleAgentFiles(req, res, id, name);
93
+ }
94
+ if (id && sub === "tools" && name === "catalog" && segs.length === 3) {
95
+ return await handleAgentToolsCatalog(req, res, id);
96
+ }
97
+ }
74
98
  if (req.method === "GET" && pathname === "/friday-next/status") {
75
99
  return await handleStatus(req, res);
76
100
  }
@@ -83,7 +107,8 @@ async function handleFridayNextRoute(req, res) {
83
107
  return await handleHistoryMessages(req, res);
84
108
  }
85
109
  // Route: PUT /friday-next/sessions/title (sync app session name → server displayName)
86
- if ((req.method === "PUT" || req.method === "POST") && pathname === "/friday-next/sessions/title") {
110
+ if ((req.method === "PUT" || req.method === "POST") &&
111
+ pathname === "/friday-next/sessions/title") {
87
112
  return await handleHistorySetTitle(req, res);
88
113
  }
89
114
  // Route: GET /friday-next/link-preview?url=... (Open Graph metadata for preview cards)
@@ -78,7 +78,9 @@ export function parseOpenGraph(html, baseUrl) {
78
78
  let metaDescription = null;
79
79
  for (const match of slice.matchAll(META_TAG_RE)) {
80
80
  const tag = match[0];
81
- const key = (attributeValue(tag, "property") ?? attributeValue(tag, "name"))?.trim().toLowerCase();
81
+ const key = (attributeValue(tag, "property") ?? attributeValue(tag, "name"))
82
+ ?.trim()
83
+ .toLowerCase();
82
84
  if (!key)
83
85
  continue;
84
86
  const content = attributeValue(tag, "content");
@@ -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());
@@ -87,7 +87,10 @@ export async function fetchLatestVersion(nowMs) {
87
87
  const controller = new AbortController();
88
88
  const timer = setTimeout(() => controller.abort(), 5000);
89
89
  try {
90
- const res = await fetch(`https://registry.npmjs.org/${PLUGIN_PACKAGE_NAME}/latest`, { signal: controller.signal, headers: { Accept: "application/json" } });
90
+ const res = await fetch(`https://registry.npmjs.org/${PLUGIN_PACKAGE_NAME}/latest`, {
91
+ signal: controller.signal,
92
+ headers: { Accept: "application/json" },
93
+ });
91
94
  if (res.ok) {
92
95
  const body = (await res.json());
93
96
  if (typeof body.version === "string" && body.version)
@@ -118,7 +118,11 @@ export function setSessionSettings(sessionKey, settings, historyDir) {
118
118
  return {};
119
119
  upsertSessionEntry(data, fileKey, sessionKey);
120
120
  const fieldKeys = [
121
- "reasoningLevel", "thinkingLevel", "modelRef", "providerOverride", "modelOverride",
121
+ "reasoningLevel",
122
+ "thinkingLevel",
123
+ "modelRef",
124
+ "providerOverride",
125
+ "modelOverride",
122
126
  ];
123
127
  let updated = false;
124
128
  for (const key of fieldKeys) {
@@ -193,7 +197,7 @@ export function resolveAgentDefaults(sessionKey) {
193
197
  const ocCfg = (forwardRt.getConfig() ?? {});
194
198
  const agents = ocCfg.agents;
195
199
  const targetAgentId = agentIdFromSessionKey(sessionKey);
196
- const agentEntry = agents?.list?.find((a) => agentIdFromSessionKey(`agent:${String(a?.id ?? "")}:x`) === targetAgentId);
200
+ const agentEntry = agents?.list?.find((a) => agentIdFromSessionKey(`agent:${typeof a?.id === "string" ? a.id : typeof a?.id === "number" ? String(a.id) : ""}:x`) === targetAgentId);
197
201
  const agentModel = agentEntry?.model;
198
202
  const perAgentModel = typeof agentModel === "string"
199
203
  ? agentModel
@@ -204,7 +208,9 @@ export function resolveAgentDefaults(sessionKey) {
204
208
  const agentDefaults = agents?.defaults;
205
209
  const model = agentDefaults?.model;
206
210
  const globalModel = typeof model?.primary === "string" ? model.primary : undefined;
207
- const globalThinking = typeof agentDefaults?.thinkingDefault === "string" ? agentDefaults.thinkingDefault : undefined;
211
+ const globalThinking = typeof agentDefaults?.thinkingDefault === "string"
212
+ ? agentDefaults.thinkingDefault
213
+ : undefined;
208
214
  return { model: perAgentModel ?? globalModel, thinking: perAgentThinking ?? globalThinking };
209
215
  }
210
216
  catch {
@@ -24,7 +24,9 @@ export function readSessionUsageSnapshotFromStore(sessionKeyForStore) {
24
24
  const cfg = access.getConfig();
25
25
  const storeConfig = cfg?.session?.store;
26
26
  const canonical = toSessionStoreKey(sessionKeyForStore);
27
- const storePath = access.resolveStorePath(storeConfig, { agentId: agentIdFromSessionKey(canonical) });
27
+ const storePath = access.resolveStorePath(storeConfig, {
28
+ agentId: agentIdFromSessionKey(canonical),
29
+ });
28
30
  const store = access.loadSessionStore(storePath, { skipCache: true });
29
31
  const entry = store[canonical] ?? store[sessionKeyForStore.trim()];
30
32
  if (!entry || typeof entry !== "object")