@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
@@ -43,6 +43,7 @@ import {
43
43
  fridayAttachmentLookupKey,
44
44
  fridayFilesPublicUrl,
45
45
  readFile,
46
+ rememberInboundMediaName,
46
47
  resolveMediaAttachment,
47
48
  resolveMediaUrl,
48
49
  } from "./files.js";
@@ -76,7 +77,10 @@ const log = (
76
77
  logger[level](`[${action}] deviceId=${deviceId}${runPart}${detailPart}`);
77
78
  };
78
79
 
79
- function collectReplyPayloadMediaUrls(pl: { mediaUrls?: string[]; mediaUrl?: string | null }): string[] {
80
+ function collectReplyPayloadMediaUrls(pl: {
81
+ mediaUrls?: string[];
82
+ mediaUrl?: string | null;
83
+ }): string[] {
80
84
  const fromArr = Array.isArray(pl.mediaUrls)
81
85
  ? pl.mediaUrls.filter((u): u is string => typeof u === "string" && u.trim().length > 0)
82
86
  : [];
@@ -158,7 +162,12 @@ export function isCanvasSnapshotMediaPath(url: unknown): boolean {
158
162
  export function translateDeliverPayload(
159
163
  pl: FridayReplyPayload,
160
164
  kind: string,
161
- meta?: { modelName?: string; totalTokens?: number; contextTokensUsed?: number; contextWindowMax?: number },
165
+ meta?: {
166
+ modelName?: string;
167
+ totalTokens?: number;
168
+ contextTokensUsed?: number;
169
+ contextWindowMax?: number;
170
+ },
162
171
  ): Record<string, unknown> {
163
172
  // Strip canvas-snapshot tool-result images before any media resolution (paths here are still the
164
173
  // original `/tmp/openclaw/openclaw-canvas-snapshot-*.jpg` temp paths, not yet copied to friday files).
@@ -209,7 +218,11 @@ export function translateDeliverPayload(
209
218
  if (typeof meta?.modelName === "string" && meta.modelName.trim()) {
210
219
  nextFridayNext.modelName = meta.modelName.trim();
211
220
  }
212
- if (typeof meta?.totalTokens === "number" && Number.isFinite(meta.totalTokens) && meta.totalTokens > 0) {
221
+ if (
222
+ typeof meta?.totalTokens === "number" &&
223
+ Number.isFinite(meta.totalTokens) &&
224
+ meta.totalTokens > 0
225
+ ) {
213
226
  nextFridayNext.totalTokens = Math.floor(meta.totalTokens);
214
227
  }
215
228
  if (
@@ -259,8 +272,10 @@ function scheduleLateFinalMetaPatch(runId: string, attempts = 6): void {
259
272
  sessionKey: route.sessionKey,
260
273
  modelName: meta.modelName ?? null,
261
274
  totalTokens: typeof meta.totalTokens === "number" ? meta.totalTokens : null,
262
- contextTokensUsed: typeof meta.contextTokensUsed === "number" ? meta.contextTokensUsed : null,
263
- contextWindowMax: typeof meta.contextWindowMax === "number" ? meta.contextWindowMax : null,
275
+ contextTokensUsed:
276
+ typeof meta.contextTokensUsed === "number" ? meta.contextTokensUsed : null,
277
+ contextWindowMax:
278
+ typeof meta.contextWindowMax === "number" ? meta.contextWindowMax : null,
264
279
  ts: Date.now(),
265
280
  },
266
281
  },
@@ -297,11 +312,17 @@ function pickMetadataFromMessageLike(message: unknown): {
297
312
  ? usage.totalTokens
298
313
  : undefined) ??
299
314
  (typeof usage?.total === "number" && Number.isFinite(usage.total) ? usage.total : undefined) ??
300
- (typeof usage?.total_tokens === "number" && Number.isFinite(usage.total_tokens) ? usage.total_tokens : undefined);
315
+ (typeof usage?.total_tokens === "number" && Number.isFinite(usage.total_tokens)
316
+ ? usage.total_tokens
317
+ : undefined);
301
318
  const totalFromMessage =
302
- (typeof m.totalTokens === "number" && Number.isFinite(m.totalTokens) ? m.totalTokens : undefined) ??
303
- (typeof m.total_tokens === "number" && Number.isFinite(m.total_tokens) ? m.total_tokens : undefined);
304
- const totalTokens = Math.floor((totalFromUsage ?? totalFromMessage ?? 0));
319
+ (typeof m.totalTokens === "number" && Number.isFinite(m.totalTokens)
320
+ ? m.totalTokens
321
+ : undefined) ??
322
+ (typeof m.total_tokens === "number" && Number.isFinite(m.total_tokens)
323
+ ? m.total_tokens
324
+ : undefined);
325
+ const totalTokens = Math.floor(totalFromUsage ?? totalFromMessage ?? 0);
305
326
 
306
327
  let contextTokensUsed: number | undefined;
307
328
  if (usage) {
@@ -312,8 +333,12 @@ function pickMetadataFromMessageLike(message: unknown): {
312
333
  }
313
334
 
314
335
  const ctxMaxRaw =
315
- (typeof m.contextWindow === "number" && Number.isFinite(m.contextWindow) ? m.contextWindow : undefined) ??
316
- (typeof m.maxContextTokens === "number" && Number.isFinite(m.maxContextTokens) ? m.maxContextTokens : undefined);
336
+ (typeof m.contextWindow === "number" && Number.isFinite(m.contextWindow)
337
+ ? m.contextWindow
338
+ : undefined) ??
339
+ (typeof m.maxContextTokens === "number" && Number.isFinite(m.maxContextTokens)
340
+ ? m.maxContextTokens
341
+ : undefined);
317
342
  const contextWindowMax =
318
343
  typeof ctxMaxRaw === "number" && ctxMaxRaw > 0 ? Math.floor(ctxMaxRaw) : undefined;
319
344
 
@@ -335,9 +360,16 @@ async function resolveRunMetadataFromRuntimeSession(
335
360
  contextTokensUsed?: number;
336
361
  contextWindowMax?: number;
337
362
  } | null> {
338
- const sessionApi = (runtime as unknown as {
339
- subagent?: { getSessionMessages?: (params: { sessionKey: string; limit?: number }) => Promise<{ messages?: unknown[] }> };
340
- }).subagent;
363
+ const sessionApi = (
364
+ runtime as unknown as {
365
+ subagent?: {
366
+ getSessionMessages?: (params: {
367
+ sessionKey: string;
368
+ limit?: number;
369
+ }) => Promise<{ messages?: unknown[] }>;
370
+ };
371
+ }
372
+ ).subagent;
341
373
  if (!sessionApi?.getSessionMessages) return null;
342
374
  try {
343
375
  const response = await sessionApi.getSessionMessages({ sessionKey, limit: 80 });
@@ -372,16 +404,23 @@ export function composeBodyWithMediaRefs(text: string, mediaRefs: string[]): str
372
404
  return trimmed ? `${trimmed}\n\n${mediaRefs.join("\n")}` : mediaRefs.join("\n");
373
405
  }
374
406
 
375
- async function buildBodyForAgentWithAttachments(text: string, attachmentIds: string[]): Promise<string> {
407
+ async function buildBodyForAgentWithAttachments(
408
+ text: string,
409
+ attachmentIds: string[],
410
+ ): Promise<string> {
376
411
  if (attachmentIds.length === 0) return text.trim();
377
412
 
378
413
  const mediaRefs: string[] = [];
379
414
  for (const id of attachmentIds) {
380
- const { buffer, mimeType } = readFile(fridayAttachmentLookupKey(id));
415
+ const { buffer, mimeType, filename } = readFile(fridayAttachmentLookupKey(id));
381
416
  if (!buffer) continue;
382
417
 
383
- const saved = await saveInboundMediaBuffer(buffer, mimeType);
418
+ const saved = await saveInboundMediaBuffer(buffer, mimeType, filename);
384
419
  if (saved.id && saved.path) {
420
+ // Core's media-store renames inbound files to a bare uuid (no name/extension) and
421
+ // the transcript records that path — stash the original name now so history rebuild
422
+ // can restore it instead of surfacing the uuid.
423
+ if (filename) rememberInboundMediaName(saved.path, filename, mimeType);
385
424
  mediaRefs.push(`[media attached: file://${saved.path}]`);
386
425
  }
387
426
  }
@@ -534,7 +573,10 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
534
573
  dispatcherOptions: {
535
574
  deliver: async (pl: any, info: any) => {
536
575
  let meta = getRunMetadata(runId);
537
- if (info.kind.toLowerCase() === "final" && !(meta?.modelName || typeof meta?.totalTokens === "number")) {
576
+ if (
577
+ info.kind.toLowerCase() === "final" &&
578
+ !(meta?.modelName || typeof meta?.totalTokens === "number")
579
+ ) {
538
580
  const resolved = await resolveRunMetadataFromRuntimeSession(runtime, baseSessionKey);
539
581
  if (resolved) {
540
582
  setRunMetadata(runId, resolved);
@@ -597,10 +639,11 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
597
639
  // OpenClaw `pi-embedded-subscribe` gates `streamReasoning` on `typeof onReasoningStream === "function"`.
598
640
  // Without this, `emitReasoningStream` never runs and Friday SSE never sees `stream: "thinking"`.
599
641
  onReasoningStream: async (pl: unknown) => {
600
- const text =
642
+ const rawText =
601
643
  typeof pl === "object" && pl !== null && "text" in pl
602
- ? String((pl as { text?: unknown }).text ?? "")
603
- : "";
644
+ ? (pl as { text?: unknown }).text
645
+ : undefined;
646
+ const text = typeof rawText === "string" ? rawText : "";
604
647
  log("REASONING_STREAM", normalizedDeviceId, runId, `textLen=${text.length}`);
605
648
  },
606
649
  onReasoningEnd: async () => {
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { handleModelsList } from "./models-list.js";
4
+ import { setMockRuntime } from "../../test-support/mock-runtime.js";
5
+ import {
6
+ setFridayAgentForwardRuntime,
7
+ resetFridayAgentForwardRuntimeForTest,
8
+ } from "../../agent-forward-runtime.js";
9
+
10
+ class MockRes extends EventEmitter {
11
+ statusCode = 0;
12
+ headers: Record<string, string> = {};
13
+ body = "";
14
+ setHeader(name: string, value: string): void {
15
+ this.headers[name.toLowerCase()] = value;
16
+ }
17
+ end(body?: string): void {
18
+ if (body) this.body += body;
19
+ }
20
+ }
21
+
22
+ function makeReq(headers: Record<string, string> = {}, method = "GET"): any {
23
+ return { method, url: "/friday-next/models", headers };
24
+ }
25
+
26
+ const AUTH = { authorization: "Bearer test-token" };
27
+
28
+ /** Inject config + an optional per-model thinking-policy resolver into the forward runtime. */
29
+ function setRuntime(
30
+ config: unknown,
31
+ resolveThinkingPolicy?: (params: { provider?: string | null; model?: string | null }) => {
32
+ levels: Array<{ id: string; label: string }>;
33
+ defaultLevel?: string | null;
34
+ },
35
+ ): void {
36
+ setFridayAgentForwardRuntime({
37
+ runtime: {
38
+ agent: {
39
+ session: { resolveStorePath: () => "", loadSessionStore: () => ({}) },
40
+ ...(resolveThinkingPolicy ? { resolveThinkingPolicy } : {}),
41
+ },
42
+ config: { current: () => config },
43
+ },
44
+ } as never);
45
+ }
46
+
47
+ const CONFIG = {
48
+ models: {
49
+ providers: {
50
+ openai: { models: [{ id: "gpt-5.4", name: "GPT-5.4", reasoning: true }] },
51
+ },
52
+ },
53
+ agents: { defaults: { models: { "openai/gpt-5.4": {} }, model: "openai/gpt-5.4" } },
54
+ };
55
+
56
+ describe("handleModelsList thinking levels", () => {
57
+ beforeEach(() => {
58
+ setMockRuntime();
59
+ });
60
+
61
+ afterEach(() => {
62
+ resetFridayAgentForwardRuntimeForTest();
63
+ });
64
+
65
+ it("attaches the per-model thinking levels + default resolved from the runtime", async () => {
66
+ setRuntime(CONFIG, ({ provider, model }) => {
67
+ expect(provider).toBe("openai");
68
+ expect(model).toBe("gpt-5.4");
69
+ return {
70
+ levels: [
71
+ { id: "off", label: "off" },
72
+ { id: "low", label: "low" },
73
+ { id: "medium", label: "medium" },
74
+ { id: "high", label: "high" },
75
+ { id: "xhigh", label: "xhigh" },
76
+ ],
77
+ defaultLevel: "high",
78
+ };
79
+ });
80
+
81
+ const res = new MockRes();
82
+ await handleModelsList(makeReq(AUTH), res as any);
83
+
84
+ expect(res.statusCode).toBe(200);
85
+ const body = JSON.parse(res.body);
86
+ const model = body.models.find((m: any) => m.id === "openai/gpt-5.4");
87
+ expect(model.thinkingLevels.map((l: any) => l.id)).toEqual([
88
+ "off",
89
+ "low",
90
+ "medium",
91
+ "high",
92
+ "xhigh",
93
+ ]);
94
+ expect(model.thinkingDefault).toBe("high");
95
+ });
96
+
97
+ it("falls back to the base five levels and omits thinkingDefault on a legacy gateway", async () => {
98
+ setRuntime(CONFIG); // no resolveThinkingPolicy
99
+
100
+ const res = new MockRes();
101
+ await handleModelsList(makeReq(AUTH), res as any);
102
+
103
+ const body = JSON.parse(res.body);
104
+ const model = body.models.find((m: any) => m.id === "openai/gpt-5.4");
105
+ expect(model.thinkingLevels.map((l: any) => l.id)).toEqual([
106
+ "off",
107
+ "minimal",
108
+ "low",
109
+ "medium",
110
+ "high",
111
+ ]);
112
+ expect(model.thinkingDefault).toBeUndefined();
113
+ });
114
+ });
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
3
3
  import { splitModelRef } from "../../session/session-manager.js";
4
+ import { resolveModelThinking, type ThinkingLevelOption } from "../../thinking-levels.js";
4
5
  import { extractBearerToken } from "../middleware/auth.js";
5
6
 
6
7
  export interface FridayModelEntry {
@@ -10,6 +11,10 @@ export interface FridayModelEntry {
10
11
  reasoning?: boolean;
11
12
  contextWindow?: number;
12
13
  maxTokens?: number;
14
+ /** Thinking levels this model supports (varies per model). Omitted when only the base set applies. */
15
+ thinkingLevels?: ThinkingLevelOption[];
16
+ /** Provider/model default thinking level, when the gateway reports one. */
17
+ thinkingDefault?: string;
13
18
  }
14
19
 
15
20
  interface ResolvedModels {
@@ -39,7 +44,7 @@ function resolveConfiguredModels(): ResolvedModels {
39
44
  seen.add(modelKey);
40
45
  entries.push({
41
46
  id: modelKey,
42
- name: typeof info?.alias === "string" ? info.alias : meta?.name ?? split.modelId,
47
+ name: typeof info?.alias === "string" ? info.alias : (meta?.name ?? split.modelId),
43
48
  provider: split.provider,
44
49
  reasoning: meta?.reasoning,
45
50
  contextWindow: meta?.contextWindow,
@@ -89,16 +94,29 @@ function resolveConfiguredModels(): ResolvedModels {
89
94
  });
90
95
  }
91
96
 
97
+ for (const entry of entries) {
98
+ const split = splitModelRef(entry.id);
99
+ const thinking = resolveModelThinking(entry.provider || split.provider, split.modelId);
100
+ entry.thinkingLevels = thinking.levels;
101
+ if (thinking.default) entry.thinkingDefault = thinking.default;
102
+ }
103
+
92
104
  return { models: entries, defaultModel };
93
105
  }
94
106
 
95
- function buildProviderModelMeta(cfg: Record<string, unknown>): Map<string, {
96
- name?: string;
97
- reasoning?: boolean;
98
- contextWindow?: number;
99
- maxTokens?: number;
100
- }> {
101
- const meta = new Map<string, { name?: string; reasoning?: boolean; contextWindow?: number; maxTokens?: number }>();
107
+ function buildProviderModelMeta(cfg: Record<string, unknown>): Map<
108
+ string,
109
+ {
110
+ name?: string;
111
+ reasoning?: boolean;
112
+ contextWindow?: number;
113
+ maxTokens?: number;
114
+ }
115
+ > {
116
+ const meta = new Map<
117
+ string,
118
+ { name?: string; reasoning?: boolean; contextWindow?: number; maxTokens?: number }
119
+ >();
102
120
  const models = cfg?.models as Record<string, unknown> | undefined;
103
121
  const providers = models?.providers as Record<string, unknown> | undefined;
104
122
  if (providers) {
@@ -24,8 +24,14 @@ class MockRes extends EventEmitter {
24
24
  }
25
25
  }
26
26
 
27
- function mockReq(method: string, headers: Record<string, string> = {}): PassThrough & { method: string; headers: Record<string, string> } {
28
- const stream = new PassThrough() as unknown as PassThrough & { method: string; headers: Record<string, string> };
27
+ function mockReq(
28
+ method: string,
29
+ headers: Record<string, string> = {},
30
+ ): PassThrough & { method: string; headers: Record<string, string> } {
31
+ const stream = new PassThrough() as unknown as PassThrough & {
32
+ method: string;
33
+ headers: Record<string, string>;
34
+ };
29
35
  stream.method = method;
30
36
  stream.headers = headers;
31
37
  return stream;
@@ -92,7 +98,10 @@ describe("handleNodesApprove", () => {
92
98
  });
93
99
 
94
100
  it("returns 404 when listNodePairing returns data without matching node", async () => {
95
- mockList.mockResolvedValueOnce({ pending: [{ requestId: "x", nodeId: "UNMATCHED" }], paired: [] });
101
+ mockList.mockResolvedValueOnce({
102
+ pending: [{ requestId: "x", nodeId: "UNMATCHED" }],
103
+ paired: [],
104
+ });
96
105
 
97
106
  const req = mockReq("POST", { authorization: "Bearer test-token" });
98
107
  const res = new MockRes() as unknown as ServerResponse;
@@ -135,7 +144,9 @@ describe("handleNodesApprove", () => {
135
144
  it("returns 200 with alreadyApproved when node in paired with caps", async () => {
136
145
  mockList.mockResolvedValueOnce({
137
146
  pending: [],
138
- paired: [{ nodeId: NODE_ID, approvedAtMs: 100, caps: ["canvas"], commands: ["canvas.present"] }],
147
+ paired: [
148
+ { nodeId: NODE_ID, approvedAtMs: 100, caps: ["canvas"], commands: ["canvas.present"] },
149
+ ],
139
150
  });
140
151
 
141
152
  const req = mockReq("POST", { authorization: "Bearer test-token" });
@@ -14,15 +14,6 @@ interface PairedNodeEntry {
14
14
  caps?: string[];
15
15
  commands?: string[];
16
16
  }
17
- interface NodePairingList {
18
- pending: PendingNodeEntry[];
19
- paired: PairedNodeEntry[];
20
- }
21
- type ApproveNodePairingResult =
22
- | { requestId: string; node: PairedNodeEntry }
23
- | { status: "forbidden"; missingScope: string }
24
- | null;
25
-
26
17
  export async function handleNodesApprove(
27
18
  req: IncomingMessage,
28
19
  res: ServerResponse,
@@ -62,7 +53,7 @@ export async function handleNodesApprove(
62
53
 
63
54
  const normalizedNodeId = rawNodeId.trim().toUpperCase();
64
55
 
65
- let listData, listNodePairing, approveNodePairing;
56
+ let listData, listNodePairing, approveNodePairing;
66
57
  try {
67
58
  ({ listNodePairing, approveNodePairing } = await loadNodePairingModule());
68
59
  } catch (err) {
@@ -91,12 +82,7 @@ let listData, listNodePairing, approveNodePairing;
91
82
  const requestId = pendingMatch.requestId;
92
83
  log.info(`approving nodeId=${normalizedNodeId} requestId=${requestId}`);
93
84
 
94
- const callerScopes = [
95
- "operator.admin",
96
- "operator.pairing",
97
- "operator.read",
98
- "operator.write",
99
- ];
85
+ const callerScopes = ["operator.admin", "operator.pairing", "operator.read", "operator.write"];
100
86
 
101
87
  let approved;
102
88
  try {
@@ -105,10 +91,12 @@ let listData, listNodePairing, approveNodePairing;
105
91
  log.error(`approveNodePairing failed: ${err instanceof Error ? err.message : String(err)}`);
106
92
  res.statusCode = 502;
107
93
  res.setHeader("Content-Type", "application/json");
108
- res.end(JSON.stringify({
109
- error: "Node approval failed",
110
- detail: err instanceof Error ? err.message : "Unknown error",
111
- }));
94
+ res.end(
95
+ JSON.stringify({
96
+ error: "Node approval failed",
97
+ detail: err instanceof Error ? err.message : "Unknown error",
98
+ }),
99
+ );
112
100
  return true;
113
101
  }
114
102
 
@@ -122,18 +110,22 @@ let listData, listNodePairing, approveNodePairing;
122
110
  if ("status" in approved && approved.status === "forbidden") {
123
111
  res.statusCode = 403;
124
112
  res.setHeader("Content-Type", "application/json");
125
- res.end(JSON.stringify({ error: `Node approval forbidden: ${(approved as any).missingScope ?? "unknown"}` }));
113
+ res.end(
114
+ JSON.stringify({ error: `Node approval forbidden: ${approved.missingScope ?? "unknown"}` }),
115
+ );
126
116
  return true;
127
117
  }
128
118
 
129
119
  res.statusCode = 200;
130
120
  res.setHeader("Content-Type", "application/json");
131
- res.end(JSON.stringify({
132
- ok: true,
133
- nodeId: normalizedNodeId,
134
- requestId: (approved as any).requestId,
135
- approvedAtMs: (approved as any).node?.approvedAtMs,
136
- }));
121
+ res.end(
122
+ JSON.stringify({
123
+ ok: true,
124
+ nodeId: normalizedNodeId,
125
+ requestId: approved.requestId,
126
+ approvedAtMs: approved.node?.approvedAtMs,
127
+ }),
128
+ );
137
129
  return true;
138
130
  }
139
131
 
@@ -147,26 +139,32 @@ let listData, listNodePairing, approveNodePairing;
147
139
  const caps = pairedMatch.caps ?? [];
148
140
  const commands = pairedMatch.commands ?? [];
149
141
  if (caps.length > 0 || commands.length > 0) {
150
- log.info(`nodeId=${normalizedNodeId} already paired with caps=${caps.length} commands=${commands.length}`);
142
+ log.info(
143
+ `nodeId=${normalizedNodeId} already paired with caps=${caps.length} commands=${commands.length}`,
144
+ );
151
145
  res.statusCode = 200;
152
146
  res.setHeader("Content-Type", "application/json");
153
- res.end(JSON.stringify({
154
- ok: true,
155
- nodeId: normalizedNodeId,
156
- alreadyApproved: true,
157
- approvedAtMs: pairedMatch.approvedAtMs,
158
- caps,
159
- commands,
160
- }));
147
+ res.end(
148
+ JSON.stringify({
149
+ ok: true,
150
+ nodeId: normalizedNodeId,
151
+ alreadyApproved: true,
152
+ approvedAtMs: pairedMatch.approvedAtMs,
153
+ caps,
154
+ commands,
155
+ }),
156
+ );
161
157
  return true;
162
158
  }
163
159
  }
164
160
 
165
161
  res.statusCode = 404;
166
162
  res.setHeader("Content-Type", "application/json");
167
- res.end(JSON.stringify({
168
- error: "No pending node found for this nodeId",
169
- nodeId: normalizedNodeId,
170
- }));
163
+ res.end(
164
+ JSON.stringify({
165
+ error: "No pending node found for this nodeId",
166
+ nodeId: normalizedNodeId,
167
+ }),
168
+ );
171
169
  return true;
172
170
  }
@@ -1,11 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { extractBearerToken } from "../middleware/auth.js";
3
3
  import { PLUGIN_VERSION } from "../../version.js";
4
- import {
5
- fetchLatestVersion,
6
- getInstallSource,
7
- semverGreater,
8
- } from "../../plugin-install-info.js";
4
+ import { fetchLatestVersion, getInstallSource, semverGreater } from "../../plugin-install-info.js";
9
5
 
10
6
  export interface PluginInfoResult {
11
7
  currentVersion: string;
@@ -17,7 +13,10 @@ export interface PluginInfoResult {
17
13
  upgradable: boolean;
18
14
  }
19
15
 
20
- export async function handlePluginInfo(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
16
+ export async function handlePluginInfo(
17
+ req: IncomingMessage,
18
+ res: ServerResponse,
19
+ ): Promise<boolean> {
21
20
  if (req.method !== "GET") {
22
21
  res.statusCode = 405;
23
22
  res.setHeader("Content-Type", "application/json");
@@ -18,7 +18,10 @@ const RESTART_DELAY_MS = 500;
18
18
  * eligible — dev (load.paths / source==="path") installs return 409 to protect
19
19
  * the dev environment from duplicate npm installs.
20
20
  */
21
- export async function handlePluginUpgrade(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
21
+ export async function handlePluginUpgrade(
22
+ req: IncomingMessage,
23
+ res: ServerResponse,
24
+ ): Promise<boolean> {
22
25
  if (req.method !== "POST") {
23
26
  res.statusCode = 405;
24
27
  res.setHeader("Content-Type", "application/json");
@@ -8,9 +8,9 @@ import {
8
8
  } from "../../session/session-manager.js";
9
9
  import { readJsonBody } from "../middleware/body.js";
10
10
  import { extractBearerToken } from "../middleware/auth.js";
11
+ import { resolveModelThinkingForRef } from "../../thinking-levels.js";
11
12
 
12
13
  const VALID_REASONING = new Set(["on", "off", "stream"]);
13
- const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high"]);
14
14
 
15
15
  export async function handleSessionsSettings(
16
16
  req: IncomingMessage,
@@ -61,12 +61,25 @@ export async function handleSessionsSettings(
61
61
  const thinkingLevel = typeof body?.thinkingLevel === "string" ? body.thinkingLevel : undefined;
62
62
  const modelRef = typeof body?.modelRef === "string" ? body.modelRef.trim() : undefined;
63
63
 
64
+ // The app omits (or empties) modelRef to mean "use the agent's default model". Resolve that
65
+ // default and write it as an *explicit* override, identical in shape to any other selection — so
66
+ // the agent runs the default exactly the way it runs an explicitly-picked model. Do NOT just
67
+ // clear the override here: the session entry is shared with the OpenClaw core, which stamps it
68
+ // with provenance fields (`modelOverrideSource`, `model`, `modelProvider`); deleting only our
69
+ // three fields leaves those dangling and the core mis-resolves to a fallback model.
70
+ const effectiveModelRef = modelRef || resolveAgentDefaults(sessionKey).model;
71
+
64
72
  const errors: string[] = [];
65
73
  if (reasoningLevel !== undefined && !VALID_REASONING.has(reasoningLevel)) {
66
74
  errors.push(`reasoningLevel must be one of: ${[...VALID_REASONING].join(", ")}`);
67
75
  }
68
- if (thinkingLevel !== undefined && !VALID_THINKING.has(thinkingLevel)) {
69
- errors.push(`thinkingLevel must be one of: ${[...VALID_THINKING].join(", ")}`);
76
+ if (thinkingLevel !== undefined) {
77
+ // Thinking levels vary per model, so validate against the levels the *effective* model supports
78
+ // (resolved from the running gateway). Falls back to the base five levels when unresolvable.
79
+ const supported = resolveModelThinkingForRef(effectiveModelRef).levels.map((l) => l.id);
80
+ if (!supported.includes(thinkingLevel)) {
81
+ errors.push(`thinkingLevel must be one of: ${supported.join(", ")}`);
82
+ }
70
83
  }
71
84
 
72
85
  if (errors.length > 0) {
@@ -76,14 +89,6 @@ export async function handleSessionsSettings(
76
89
  return true;
77
90
  }
78
91
 
79
- // The app omits (or empties) modelRef to mean "use the agent's default model". Resolve that
80
- // default and write it as an *explicit* override, identical in shape to any other selection — so
81
- // the agent runs the default exactly the way it runs an explicitly-picked model. Do NOT just
82
- // clear the override here: the session entry is shared with the OpenClaw core, which stamps it
83
- // with provenance fields (`modelOverrideSource`, `model`, `modelProvider`); deleting only our
84
- // three fields leaves those dangling and the core mis-resolves to a fallback model.
85
- const effectiveModelRef = modelRef || resolveAgentDefaults(sessionKey).model;
86
-
87
92
  const settings: FridaySessionSettingsUpdate = { reasoningLevel, thinkingLevel };
88
93
  if (effectiveModelRef) {
89
94
  const split = splitModelRef(effectiveModelRef);
@@ -67,7 +67,9 @@ export async function handleSseStream(req: IncomingMessage, res: ServerResponse)
67
67
  const lastEventId = parseLastEventId(req, url);
68
68
  if (lastEventId > 0) sseEmitter.replayBacklog(deviceId, lastEventId);
69
69
 
70
- const config = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
70
+ const config = resolveFridayNextConfig(
71
+ getHostOpenClawConfigSnapshot(getFridayNextRuntime().config),
72
+ );
71
73
  const keepalive = setInterval(() => {
72
74
  if (conn.isClosed) {
73
75
  clearInterval(keepalive);