@syengup/friday-channel-next 0.1.36 → 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 (120) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/src/agent/dispatch-bridge.d.ts +1 -1
  3. package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
  4. package/dist/src/agent/node-pairing-bridge.js +6 -2
  5. package/dist/src/agent/subagent-registry.js +0 -3
  6. package/dist/src/channel-actions.js +3 -1
  7. package/dist/src/channel.js +0 -2
  8. package/dist/src/collect-message-media-paths.js +10 -1
  9. package/dist/src/friday-session.js +34 -10
  10. package/dist/src/history/normalize-message.js +22 -8
  11. package/dist/src/http/handlers/agent-config.js +10 -4
  12. package/dist/src/http/handlers/cancel.js +4 -2
  13. package/dist/src/http/handlers/device-approve.js +3 -1
  14. package/dist/src/http/handlers/files-download.js +6 -8
  15. package/dist/src/http/handlers/files.js +1 -1
  16. package/dist/src/http/handlers/health.js +18 -4
  17. package/dist/src/http/handlers/history-messages.js +1 -1
  18. package/dist/src/http/handlers/history-sessions.js +5 -3
  19. package/dist/src/http/handlers/messages.js +25 -11
  20. package/dist/src/http/handlers/models-list.js +1 -1
  21. package/dist/src/http/handlers/nodes-approve.js +1 -6
  22. package/dist/src/http/handlers/plugin-info.js +1 -1
  23. package/dist/src/http/server.js +4 -2
  24. package/dist/src/link-preview/og-parse.js +3 -1
  25. package/dist/src/plugin-install-info.js +4 -1
  26. package/dist/src/session/session-manager.js +9 -3
  27. package/dist/src/session-usage-store.js +3 -1
  28. package/dist/src/skills-discovery.d.ts +5 -4
  29. package/dist/src/skills-discovery.js +27 -22
  30. package/dist/src/sse/offline-queue.js +4 -1
  31. package/dist/src/tool-catalog.js +2 -3
  32. package/dist/src/upgrade-runtime.d.ts +1 -1
  33. package/dist/src/version.js +3 -1
  34. package/index.ts +43 -35
  35. package/install.js +131 -43
  36. package/package.json +10 -1
  37. package/src/agent/abort-run.ts +2 -3
  38. package/src/agent/dispatch-bridge.ts +2 -1
  39. package/src/agent/media-bridge.ts +9 -2
  40. package/src/agent/node-pairing-bridge.ts +29 -15
  41. package/src/agent/run-usage-accumulator.ts +4 -2
  42. package/src/agent/subagent-registry.ts +0 -4
  43. package/src/agent-run-context-bridge.ts +3 -1
  44. package/src/channel-actions.test.ts +10 -4
  45. package/src/channel-actions.ts +3 -1
  46. package/src/channel.outbound.test.ts +18 -4
  47. package/src/channel.ts +121 -123
  48. package/src/collect-message-media-paths.ts +15 -6
  49. package/src/config.ts +1 -4
  50. package/src/e2e/agents-list.e2e.test.ts +9 -2
  51. package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
  52. package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
  53. package/src/e2e/auto-approve.integration.test.ts +13 -7
  54. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
  55. package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
  56. package/src/e2e/offline-replay.e2e.test.ts +17 -3
  57. package/src/e2e/send-text.e2e.test.ts +11 -2
  58. package/src/e2e/slash-commands.e2e.test.ts +5 -1
  59. package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
  60. package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
  61. package/src/e2e/subagent.e2e.test.ts +136 -53
  62. package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
  63. package/src/friday-session.forward-agent.test.ts +44 -12
  64. package/src/friday-session.ts +44 -20
  65. package/src/history/normalize-message.test.ts +35 -8
  66. package/src/history/normalize-message.ts +24 -12
  67. package/src/history/read-transcript.ts +1 -4
  68. package/src/http/handlers/agent-config.test.ts +10 -3
  69. package/src/http/handlers/agent-config.ts +22 -8
  70. package/src/http/handlers/agents-list.test.ts +1 -5
  71. package/src/http/handlers/cancel.test.ts +12 -3
  72. package/src/http/handlers/cancel.ts +4 -2
  73. package/src/http/handlers/device-approve.test.ts +12 -3
  74. package/src/http/handlers/device-approve.ts +33 -21
  75. package/src/http/handlers/files-download.ts +17 -13
  76. package/src/http/handlers/files.test.ts +8 -2
  77. package/src/http/handlers/files.ts +21 -7
  78. package/src/http/handlers/health.test.ts +43 -11
  79. package/src/http/handlers/health.ts +22 -6
  80. package/src/http/handlers/history-messages.test.ts +51 -9
  81. package/src/http/handlers/history-messages.ts +4 -1
  82. package/src/http/handlers/history-sessions.test.ts +46 -9
  83. package/src/http/handlers/history-sessions.ts +5 -3
  84. package/src/http/handlers/history-set-title.test.ts +14 -5
  85. package/src/http/handlers/link-preview.test.ts +57 -16
  86. package/src/http/handlers/link-preview.ts +4 -1
  87. package/src/http/handlers/messages.test.ts +12 -8
  88. package/src/http/handlers/messages.ts +57 -19
  89. package/src/http/handlers/models-list.ts +14 -8
  90. package/src/http/handlers/nodes-approve.test.ts +15 -4
  91. package/src/http/handlers/nodes-approve.ts +38 -40
  92. package/src/http/handlers/plugin-info.ts +5 -6
  93. package/src/http/handlers/plugin-upgrade.ts +4 -1
  94. package/src/http/handlers/sse.ts +3 -1
  95. package/src/http/server.ts +9 -6
  96. package/src/link-preview/og-parse.test.ts +6 -2
  97. package/src/link-preview/og-parse.ts +10 -3
  98. package/src/link-preview/preview-service.ts +4 -1
  99. package/src/link-preview/ssrf-guard.test.ts +72 -15
  100. package/src/link-preview/ssrf-guard.ts +2 -1
  101. package/src/media-fetch.test.ts +7 -2
  102. package/src/media-fetch.ts +1 -2
  103. package/src/openclaw.d.ts +16 -9
  104. package/src/plugin-install-info.ts +20 -9
  105. package/src/run-metadata.ts +2 -1
  106. package/src/session/session-manager.ts +19 -11
  107. package/src/session-usage-snapshot.ts +3 -1
  108. package/src/session-usage-store.ts +3 -1
  109. package/src/skills-discovery.test.ts +14 -10
  110. package/src/skills-discovery.ts +43 -27
  111. package/src/sse/emitter.test.ts +1 -1
  112. package/src/sse/emitter.ts +9 -3
  113. package/src/sse/offline-queue.ts +17 -8
  114. package/src/test-support/app-simulator.ts +17 -3
  115. package/src/test-support/mock-dispatch.ts +17 -4
  116. package/src/thinking-levels.ts +3 -1
  117. package/src/tool-catalog.ts +16 -7
  118. package/src/upgrade-runtime.ts +4 -2
  119. package/src/version.ts +5 -1
  120. package/tsconfig.json +1 -1
@@ -1,4 +1,5 @@
1
- type DispatchFn = (args: unknown) => Promise<unknown> | unknown;
1
+ // Returns a value or a thenable; `unknown` covers both (callers `await` the result).
2
+ type DispatchFn = (args: unknown) => unknown;
2
3
 
3
4
  let overrideDispatch: DispatchFn | null = null;
4
5
 
@@ -11,7 +11,8 @@ import crypto from "node:crypto";
11
11
  */
12
12
  export async function resolveMediaMaxBytes(mimeType: string): Promise<number | undefined> {
13
13
  try {
14
- const { maxBytesForKind, mediaKindFromMime } = await import("openclaw/plugin-sdk/media-runtime");
14
+ const { maxBytesForKind, mediaKindFromMime } =
15
+ await import("openclaw/plugin-sdk/media-runtime");
15
16
  return maxBytesForKind(mediaKindFromMime(mimeType) ?? "document");
16
17
  } catch {
17
18
  return undefined;
@@ -31,7 +32,13 @@ export async function saveInboundMediaBuffer(
31
32
  // Pass the original filename (5th arg) so core's media-store preserves the
32
33
  // name+extension instead of saving a bare uuid. Otherwise the agent receives
33
34
  // `[media attached: file://.../inbound/<uuid>]` with no file-format signal.
34
- const saved = await sdk.saveMediaBuffer(buffer, mimeType, "inbound", maxBytes, originalFilename);
35
+ const saved = await sdk.saveMediaBuffer(
36
+ buffer,
37
+ mimeType,
38
+ "inbound",
39
+ maxBytes,
40
+ originalFilename,
41
+ );
35
42
  if (saved?.id && saved?.path) return { id: saved.id, path: saved.path };
36
43
  } catch {
37
44
  // fallback for tests or stripped runtime
@@ -1,7 +1,19 @@
1
1
  import { existsSync, readdirSync, realpathSync } from "node:fs";
2
2
  import { delimiter, dirname, join } from "node:path";
3
3
 
4
- let cache: { listNodePairing: Function; approveNodePairing: Function } | null = null;
4
+ // Results come from the untyped OpenClaw dist module, so the resolved shapes are
5
+ // `any` at this host boundary — callers read dynamic fields (.pending, .status, …).
6
+ type ListNodePairingFn = () => Promise<any>;
7
+ type ApproveNodePairingFn = (
8
+ requestId: string,
9
+ options: { callerScopes?: unknown },
10
+ ) => Promise<any>;
11
+ type NodePairingModule = {
12
+ listNodePairing: ListNodePairingFn;
13
+ approveNodePairing: ApproveNodePairingFn;
14
+ };
15
+
16
+ let cache: NodePairingModule | null = null;
5
17
 
6
18
  function resolveOpenClawDistFromPath(): string | null {
7
19
  // Walk PATH looking for the openclaw binary, then resolve its real
@@ -16,7 +28,9 @@ function resolveOpenClawDistFromPath(): string | null {
16
28
  const dist = join(dirname(real), "dist");
17
29
  readdirSync(dist);
18
30
  return dist;
19
- } catch {}
31
+ } catch {
32
+ // Not a real dist dir — keep walking PATH.
33
+ }
20
34
  }
21
35
  return null;
22
36
  }
@@ -43,15 +57,17 @@ function resolveOpenClawDist(): string {
43
57
  ].filter((v): v is string => typeof v === "string" && v.length > 0);
44
58
 
45
59
  for (const root of candidates) {
46
- try { readdirSync(root); return root; } catch {}
60
+ try {
61
+ readdirSync(root);
62
+ return root;
63
+ } catch {
64
+ // Candidate dir doesn't exist — try the next one.
65
+ }
47
66
  }
48
67
  throw new Error("OpenClaw dist directory not found. Set OPENCLAW_DIST env var.");
49
68
  }
50
69
 
51
- export async function loadNodePairingModule(): Promise<{
52
- listNodePairing: Function;
53
- approveNodePairing: Function;
54
- }> {
70
+ export async function loadNodePairingModule(): Promise<NodePairingModule> {
55
71
  if (cache) return cache;
56
72
  const dist = resolveOpenClawDist();
57
73
  const file = readdirSync(dist).find(
@@ -63,12 +79,13 @@ export async function loadNodePairingModule(): Promise<{
63
79
  // bundled module uses `export { listNodePairing as r, … }`. Resolve the
64
80
  // correct functions by Function.name, which preserves the original name.
65
81
  const mod = await import(join(dist, file));
66
- let listNodePairing: Function | undefined;
67
- let approveNodePairing: Function | undefined;
82
+ let listNodePairing: ListNodePairingFn | undefined;
83
+ let approveNodePairing: ApproveNodePairingFn | undefined;
68
84
  for (const value of Object.values(mod)) {
69
85
  if (typeof value === "function") {
70
- if (value.name === "listNodePairing") listNodePairing = value;
71
- else if (value.name === "approveNodePairing") approveNodePairing = value;
86
+ if (value.name === "listNodePairing") listNodePairing = value as ListNodePairingFn;
87
+ else if (value.name === "approveNodePairing")
88
+ approveNodePairing = value as ApproveNodePairingFn;
72
89
  }
73
90
  }
74
91
  if (!listNodePairing || !approveNodePairing) {
@@ -79,9 +96,6 @@ export async function loadNodePairingModule(): Promise<{
79
96
  }
80
97
 
81
98
  /** Vitest-only: inject mock pairing functions. */
82
- export function __setMockNodePairingForTests(mock: {
83
- listNodePairing: Function;
84
- approveNodePairing: Function;
85
- }): void {
99
+ export function __setMockNodePairingForTests(mock: NodePairingModule): void {
86
100
  cache = mock;
87
101
  }
@@ -39,8 +39,10 @@ export function accumulateRunUsage(
39
39
  const entry = ensure(runId);
40
40
  if (typeof usage.input === "number" && usage.input > 0) entry.input += usage.input;
41
41
  if (typeof usage.output === "number" && usage.output > 0) entry.output += usage.output;
42
- if (typeof usage.cacheRead === "number" && usage.cacheRead > 0) entry.cacheRead += usage.cacheRead;
43
- if (typeof usage.cacheWrite === "number" && usage.cacheWrite > 0) entry.cacheWrite += usage.cacheWrite;
42
+ if (typeof usage.cacheRead === "number" && usage.cacheRead > 0)
43
+ entry.cacheRead += usage.cacheRead;
44
+ if (typeof usage.cacheWrite === "number" && usage.cacheWrite > 0)
45
+ entry.cacheWrite += usage.cacheWrite;
44
46
  if (typeof usage.total === "number" && usage.total > 0) entry.total += usage.total;
45
47
  if (model && model.trim()) entry.model = model.trim();
46
48
  if (provider && provider.trim()) entry.provider = provider.trim();
@@ -39,10 +39,6 @@ export function registerSessionKeyForRun(sessionKey: string, runId: string): voi
39
39
  sessionKeyToRunId.set(sessionKey, runId);
40
40
  }
41
41
 
42
- function resolveRunIdForSessionKey(sessionKey: string): string | undefined {
43
- return sessionKeyToRunId.get(sessionKey);
44
- }
45
-
46
42
  /**
47
43
  * Parse OpenClaw announce compound runId:
48
44
  * announce:v<version>:<sessionKey>:<bareRunId>
@@ -26,7 +26,9 @@ function getAgentEventState(): AgentEventStateLike | undefined {
26
26
  return { runContextById };
27
27
  }
28
28
 
29
- export function getOpenClawAgentRunContext(runId: string): OpenClawAgentRunContextBridge | undefined {
29
+ export function getOpenClawAgentRunContext(
30
+ runId: string,
31
+ ): OpenClawAgentRunContextBridge | undefined {
30
32
  if (!runId) return undefined;
31
33
  return getAgentEventState()?.runContextById.get(runId);
32
34
  }
@@ -6,7 +6,11 @@ import { handleMessageAction } from "./channel-actions.js";
6
6
  import { sseEmitter } from "./sse/emitter.js";
7
7
  import { setOfflineQueueBaseDirForTest } from "./sse/offline-queue.js";
8
8
  import { registerRunRoute } from "./run-metadata.js";
9
- import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "./test-support/mock-runtime.js";
9
+ import {
10
+ createTempHistoryDir,
11
+ removeTempHistoryDir,
12
+ setMockRuntime,
13
+ } from "./test-support/mock-runtime.js";
10
14
 
11
15
  /**
12
16
  * The `message` tool's `action=send` is handled here (NOT via outbound.sendText/sendMedia).
@@ -107,8 +111,8 @@ describe("channel-actions handleSend sessionKey routing", () => {
107
111
  // 8-byte PNG magic header so saveMediaBuffer's magic-byte detection recognizes an image.
108
112
  const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]);
109
113
  const directLink = "https://picsum.photos/600/400";
110
- const fetchMock = vi.fn(async () =>
111
- new Response(pngBytes, { status: 200, headers: { "content-type": "image/png" } }),
114
+ const fetchMock = vi.fn(
115
+ async () => new Response(pngBytes, { status: 200, headers: { "content-type": "image/png" } }),
112
116
  );
113
117
  vi.stubGlobal("fetch", fetchMock);
114
118
 
@@ -220,7 +224,9 @@ describe("channel-actions handleSend sessionKey routing", () => {
220
224
  sessionKey: "agent:operator:friday:direct:fallback-session",
221
225
  });
222
226
 
223
- const text = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "text");
227
+ const text = parseOutboundFrames(res).find(
228
+ (f) => f.type === "outbound" && f.data.op === "text",
229
+ );
224
230
  expect(text?.data.sessionKey).toBe("agent:operator:friday:direct:fallback-session");
225
231
  });
226
232
  });
@@ -64,7 +64,9 @@ async function readMediaFile(
64
64
  if (buffer?.length) {
65
65
  return { buffer, mimeType: guessMimeType(mediaPath) };
66
66
  }
67
- } catch { /* fall through */ }
67
+ } catch {
68
+ /* fall through */
69
+ }
68
70
  }
69
71
  try {
70
72
  const buffer = fs.readFileSync(mediaPath);
@@ -6,7 +6,11 @@ import { fridayNextChannelPlugin } from "./channel.js";
6
6
  import { sseEmitter } from "./sse/emitter.js";
7
7
  import { setOfflineQueueBaseDirForTest } from "./sse/offline-queue.js";
8
8
  import { registerRunRoute } from "./run-metadata.js";
9
- import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "./test-support/mock-runtime.js";
9
+ import {
10
+ createTempHistoryDir,
11
+ removeTempHistoryDir,
12
+ setMockRuntime,
13
+ } from "./test-support/mock-runtime.js";
10
14
 
11
15
  /**
12
16
  * Outbound (message-tool send) must route to the session that started the run.
@@ -95,11 +99,19 @@ describe("friday-next channel outbound sessionKey routing", () => {
95
99
  it("run-route wins over ctx sessionKey (ctx carries the agent's base/main session, not the active app session)", async () => {
96
100
  const deviceId = "DEV-TEXT-2";
97
101
  const runId = "run-text-2";
98
- registerRunRoute({ runId, deviceId, sessionKey: "agent:operator:friday-next:direct:route-session" });
102
+ registerRunRoute({
103
+ runId,
104
+ deviceId,
105
+ sessionKey: "agent:operator:friday-next:direct:route-session",
106
+ });
99
107
  sseEmitter.trackDeviceForRun(deviceId, runId);
100
108
  const res = connect(deviceId);
101
109
 
102
- await outbound.sendText({ to: deviceId, text: "hi", requesterSessionKey: "agent:operator:main" });
110
+ await outbound.sendText({
111
+ to: deviceId,
112
+ text: "hi",
113
+ requesterSessionKey: "agent:operator:main",
114
+ });
103
115
 
104
116
  const evt = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "text");
105
117
  expect(evt?.data.sessionKey).toBe("agent:operator:friday-next:direct:route-session");
@@ -129,7 +141,9 @@ describe("friday-next channel outbound sessionKey routing", () => {
129
141
 
130
142
  await outbound.sendMedia({ to: deviceId, text: "caption", mediaUrl: mediaFile });
131
143
 
132
- const evt = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "media");
144
+ const evt = parseOutboundFrames(res).find(
145
+ (f) => f.type === "outbound" && f.data.op === "media",
146
+ );
133
147
  expect(evt).toBeDefined();
134
148
  expect(evt?.data.sessionKey).toBe(sessionKey);
135
149
  expect(evt?.data.deviceId).toBe(deviceId);
package/src/channel.ts CHANGED
@@ -194,153 +194,151 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
194
194
  outbound: {
195
195
  deliveryMode: "direct" as const,
196
196
  sendText: async (ctx: any) => {
197
- const text = ctx.text ?? "";
198
- const rawCtx = ctx as unknown as Record<string, unknown>;
199
- const deviceId = resolveFridayDeviceIdForOutbound(ctx.to, rawCtx);
200
- const runIdFromCtx = pickFirstString(rawCtx, [
201
- "parentRunId",
202
- "requesterRunId",
203
- "originRunId",
204
- "runId",
205
- ]);
206
- const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
207
- const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
197
+ const text = ctx.text ?? "";
198
+ const rawCtx = ctx as unknown as Record<string, unknown>;
199
+ const deviceId = resolveFridayDeviceIdForOutbound(ctx.to, rawCtx);
200
+ const runIdFromCtx = pickFirstString(rawCtx, [
201
+ "parentRunId",
202
+ "requesterRunId",
203
+ "originRunId",
204
+ "runId",
205
+ ]);
206
+ const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
207
+ const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
208
208
 
209
- const conn = sseEmitter.getConnection(deviceId);
210
- const ts = new Date().toISOString();
211
- logger.info(
212
- `[SEND_TEXT] to=${deviceId} runId=${runId ?? "(none)"} sessionKey=${sessionKey ?? "(none)"} textLen=${text.length} online=${!!conn}`,
213
- );
209
+ const conn = sseEmitter.getConnection(deviceId);
210
+ logger.info(
211
+ `[SEND_TEXT] to=${deviceId} runId=${runId ?? "(none)"} sessionKey=${sessionKey ?? "(none)"} textLen=${text.length} online=${!!conn}`,
212
+ );
214
213
 
215
- if (conn) {
216
- sseEmitter.broadcast(
217
- {
218
- type: "outbound",
219
- data: {
220
- op: "text",
221
- ts: Date.now(),
222
- runId,
223
- deviceId,
224
- sessionKey,
225
- ctx: {
226
- text,
227
- to: ctx.to,
228
- mediaUrl: ctx.mediaUrl,
229
- audioAsVoice: ctx.audioAsVoice,
230
- },
214
+ if (conn) {
215
+ sseEmitter.broadcast(
216
+ {
217
+ type: "outbound",
218
+ data: {
219
+ op: "text",
220
+ ts: Date.now(),
221
+ runId,
222
+ deviceId,
223
+ sessionKey,
224
+ ctx: {
225
+ text,
226
+ to: ctx.to,
227
+ mediaUrl: ctx.mediaUrl,
228
+ audioAsVoice: ctx.audioAsVoice,
231
229
  },
232
230
  },
233
- deviceId,
234
- true,
235
- );
236
- }
231
+ },
232
+ deviceId,
233
+ true,
234
+ );
235
+ }
236
+
237
+ return {
238
+ channel: CHANNEL_ID,
239
+ messageId: crypto.randomUUID(),
240
+ timestamp: Date.now(),
241
+ };
242
+ },
243
+ sendMedia: async (ctx: any) => {
244
+ const rawCtx = ctx as unknown as Record<string, unknown>;
245
+ const deviceId = resolveFridayDeviceIdForOutbound(ctx.to, rawCtx);
246
+ const mediaUrl = ctx.mediaUrl;
247
+ const runIdFromCtx = pickFirstString(rawCtx, [
248
+ "parentRunId",
249
+ "requesterRunId",
250
+ "originRunId",
251
+ "runId",
252
+ ]);
253
+ const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
254
+ const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
255
+ const audioAsVoice = ctx.audioAsVoice === true;
256
+ const caption = ctx.text ?? "";
237
257
 
258
+ if (!mediaUrl) {
238
259
  return {
239
260
  channel: CHANNEL_ID,
240
261
  messageId: crypto.randomUUID(),
241
262
  timestamp: Date.now(),
242
263
  };
243
- },
244
- sendMedia: async (ctx: any) => {
245
- const rawCtx = ctx as unknown as Record<string, unknown>;
246
- const deviceId = resolveFridayDeviceIdForOutbound(ctx.to, rawCtx);
247
- const mediaUrl = ctx.mediaUrl;
248
- const runIdFromCtx = pickFirstString(rawCtx, [
249
- "parentRunId",
250
- "requesterRunId",
251
- "originRunId",
252
- "runId",
253
- ]);
254
- const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
255
- const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
256
- const audioAsVoice = ctx.audioAsVoice === true;
257
- const caption = ctx.text ?? "";
258
-
259
- if (!mediaUrl) {
260
- return {
261
- channel: CHANNEL_ID,
262
- messageId: crypto.randomUUID(),
263
- timestamp: Date.now(),
264
- };
265
- }
264
+ }
266
265
 
267
- let buffer: Buffer | null = null;
268
- let downloadedMimeType: string | null = null;
266
+ let buffer: Buffer | null = null;
267
+ let downloadedMimeType: string | null = null;
269
268
 
270
- if (ctx.mediaReadFile) {
271
- try {
272
- buffer = await ctx.mediaReadFile(mediaUrl);
273
- } catch {
274
- // fall through to remote download / fs
275
- }
269
+ if (ctx.mediaReadFile) {
270
+ try {
271
+ buffer = await ctx.mediaReadFile(mediaUrl);
272
+ } catch {
273
+ // fall through to remote download / fs
276
274
  }
275
+ }
277
276
 
278
- if (!buffer && isHttpUrl(mediaUrl)) {
279
- const remote = await downloadRemoteMedia(mediaUrl);
280
- if (remote) {
281
- buffer = remote.buffer;
282
- downloadedMimeType = remote.mimeType;
283
- }
277
+ if (!buffer && isHttpUrl(mediaUrl)) {
278
+ const remote = await downloadRemoteMedia(mediaUrl);
279
+ if (remote) {
280
+ buffer = remote.buffer;
281
+ downloadedMimeType = remote.mimeType;
284
282
  }
283
+ }
285
284
 
286
- if (!buffer) {
287
- try {
288
- const resolvedPath = resolveLocalMediaPath(mediaUrl, ctx.mediaLocalRoots);
289
- buffer = fs.readFileSync(resolvedPath);
290
- } catch {
291
- // file not found — skip media
292
- }
285
+ if (!buffer) {
286
+ try {
287
+ const resolvedPath = resolveLocalMediaPath(mediaUrl, ctx.mediaLocalRoots);
288
+ buffer = fs.readFileSync(resolvedPath);
289
+ } catch {
290
+ // file not found — skip media
293
291
  }
292
+ }
294
293
 
295
- if (buffer) {
296
- const mimeType = downloadedMimeType ?? guessMimeType(mediaUrl);
297
- // Match what openclaw itself supports for this media kind rather than
298
- // saveMediaBuffer's 5MB default.
299
- const maxBytes = await resolveMediaMaxBytes(mimeType);
300
- const saved = await saveMediaBuffer(buffer, mimeType, "inbound", maxBytes);
301
- if (saved.id) {
302
- const fileUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
303
- const resolved = resolveMediaAttachment(fileUrl);
304
- const publicUrl = resolved ? resolved.url : fileUrl;
294
+ if (buffer) {
295
+ const mimeType = downloadedMimeType ?? guessMimeType(mediaUrl);
296
+ // Match what openclaw itself supports for this media kind rather than
297
+ // saveMediaBuffer's 5MB default.
298
+ const maxBytes = await resolveMediaMaxBytes(mimeType);
299
+ const saved = await saveMediaBuffer(buffer, mimeType, "inbound", maxBytes);
300
+ if (saved.id) {
301
+ const fileUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
302
+ const resolved = resolveMediaAttachment(fileUrl);
303
+ const publicUrl = resolved ? resolved.url : fileUrl;
305
304
 
306
- const conn = sseEmitter.getConnection(deviceId);
307
- const ts = new Date().toISOString();
308
- logger.info(
309
- `[SEND_MEDIA] to=${deviceId} runId=${runId ?? "(none)"} sessionKey=${sessionKey ?? "(none)"} audioAsVoice=${audioAsVoice} url=${publicUrl} online=${!!conn}`,
310
- );
305
+ const conn = sseEmitter.getConnection(deviceId);
306
+ logger.info(
307
+ `[SEND_MEDIA] to=${deviceId} runId=${runId ?? "(none)"} sessionKey=${sessionKey ?? "(none)"} audioAsVoice=${audioAsVoice} url=${publicUrl} online=${!!conn}`,
308
+ );
311
309
 
312
- if (conn) {
313
- sseEmitter.broadcast(
314
- {
315
- type: "outbound",
316
- data: {
317
- op: "media",
318
- ts: Date.now(),
319
- runId,
320
- deviceId,
321
- sessionKey,
322
- audioAsVoice,
323
- caption,
324
- mediaUrl: publicUrl,
325
- ctx: {
326
- to: ctx.to,
327
- text: caption,
328
- originalMediaUrl: mediaUrl,
329
- },
310
+ if (conn) {
311
+ sseEmitter.broadcast(
312
+ {
313
+ type: "outbound",
314
+ data: {
315
+ op: "media",
316
+ ts: Date.now(),
317
+ runId,
318
+ deviceId,
319
+ sessionKey,
320
+ audioAsVoice,
321
+ caption,
322
+ mediaUrl: publicUrl,
323
+ ctx: {
324
+ to: ctx.to,
325
+ text: caption,
326
+ originalMediaUrl: mediaUrl,
330
327
  },
331
328
  },
332
- deviceId,
333
- true,
334
- );
335
- }
329
+ },
330
+ deviceId,
331
+ true,
332
+ );
336
333
  }
337
334
  }
335
+ }
338
336
 
339
- return {
340
- channel: CHANNEL_ID,
341
- messageId: crypto.randomUUID(),
342
- timestamp: Date.now(),
343
- };
344
- },
337
+ return {
338
+ channel: CHANNEL_ID,
339
+ messageId: crypto.randomUUID(),
340
+ timestamp: Date.now(),
341
+ };
342
+ },
345
343
  },
346
344
  });
@@ -50,7 +50,16 @@ export function collectMediaPathsFromToolResult(raw: unknown, acc?: Set<string>)
50
50
  else if (media && typeof media === "object" && !Array.isArray(media)) visit(media);
51
51
  const filePath = o.filePath;
52
52
  if (typeof filePath === "string") add(filePath);
53
- for (const k of ["details", "result", "content", "text", "body", "message", "arguments", "args"]) {
53
+ for (const k of [
54
+ "details",
55
+ "result",
56
+ "content",
57
+ "text",
58
+ "body",
59
+ "message",
60
+ "arguments",
61
+ "args",
62
+ ]) {
54
63
  if (o[k] !== undefined) visit(o[k]);
55
64
  }
56
65
  for (const val of Object.values(o)) {
@@ -112,20 +121,20 @@ export function extractLocalPathsFromToolTextBlob(s: string): Set<string> {
112
121
 
113
122
  // Verbatim /Users/.../file.ext (stop before quote or backslash — avoids eating JSON commas)
114
123
  for (const m of s.matchAll(/(\/Users\/[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
115
- add(m[1]!);
124
+ add(m[1]);
116
125
  }
117
126
  for (const m of s.matchAll(/(\/private\/var\/[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
118
- add(m[1]!);
127
+ add(m[1]);
119
128
  }
120
129
  for (const m of s.matchAll(/(\/tmp\/[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
121
- add(m[1]!);
130
+ add(m[1]);
122
131
  }
123
132
  for (const m of s.matchAll(/(\/home\/[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
124
- add(m[1]!);
133
+ add(m[1]);
125
134
  }
126
135
 
127
136
  for (const m of s.matchAll(/([A-Za-z]:\\[^"\\]+\.[A-Za-z0-9]{1,24})/g)) {
128
- add(m[1]!);
137
+ add(m[1]);
129
138
  }
130
139
 
131
140
  return out;
package/src/config.ts CHANGED
@@ -52,10 +52,7 @@ export function resolveFridayNextConfig(cfg: unknown): FridayNextConfig {
52
52
  pathPrefix: asString(section.pathPrefix, "/friday-next"),
53
53
  transport: asString(section.transport, "http+sse"),
54
54
  historyLimit: asNumber(section.historyLimit, 25, 1, 200),
55
- historyDir: asString(
56
- section.historyDir,
57
- `${homedir()}/.openclaw/friday-next/history`,
58
- ),
55
+ historyDir: asString(section.historyDir, `${homedir()}/.openclaw/friday-next/history`),
59
56
  logLevel: asString(section.logLevel, "info") as FridayNextLogLevel,
60
57
  authToken,
61
58
  corsEnabled: asBool(cors.enabled, false),
@@ -1,6 +1,10 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
2
  import { createAppSimulator } from "../test-support/app-simulator.js";
3
- import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "../test-support/mock-runtime.js";
3
+ import {
4
+ createTempHistoryDir,
5
+ removeTempHistoryDir,
6
+ setMockRuntime,
7
+ } from "../test-support/mock-runtime.js";
4
8
  import {
5
9
  setFridayAgentForwardRuntime,
6
10
  resetFridayAgentForwardRuntimeForTest,
@@ -20,7 +24,10 @@ function setForwardConfig(config: unknown): void {
20
24
  } as never);
21
25
  }
22
26
 
23
- async function getAgents(app: ReturnType<typeof createAppSimulator>, headers?: Record<string, string>) {
27
+ async function getAgents(
28
+ app: ReturnType<typeof createAppSimulator>,
29
+ headers?: Record<string, string>,
30
+ ) {
24
31
  const res = await app.rawRequest({ method: "GET", path: "/friday-next/agents", headers });
25
32
  return { status: res.status, body: res.body ? JSON.parse(res.body) : {}, headers: res.headers };
26
33
  }
@@ -1,7 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
2
  import { createAppSimulator } from "../test-support/app-simulator.js";
3
3
  import { mockDispatchScript, resetMockDispatch } from "../test-support/mock-dispatch.js";
4
- import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "../test-support/mock-runtime.js";
4
+ import {
5
+ createTempHistoryDir,
6
+ removeTempHistoryDir,
7
+ setMockRuntime,
8
+ } from "../test-support/mock-runtime.js";
5
9
 
6
10
  describe("e2e attachments inbound", () => {
7
11
  let historyDir = "";
@@ -3,7 +3,11 @@ import path from "node:path";
3
3
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
4
4
  import { createAppSimulator } from "../test-support/app-simulator.js";
5
5
  import { mockDispatchScript, resetMockDispatch } from "../test-support/mock-dispatch.js";
6
- import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "../test-support/mock-runtime.js";
6
+ import {
7
+ createTempHistoryDir,
8
+ removeTempHistoryDir,
9
+ setMockRuntime,
10
+ } from "../test-support/mock-runtime.js";
7
11
 
8
12
  describe("e2e attachments outbound", () => {
9
13
  let historyDir = "";
@@ -36,7 +40,8 @@ describe("e2e attachments outbound", () => {
36
40
 
37
41
  const delivers = frames.filter((x) => x.event === "deliver");
38
42
  expect(delivers.length).toBeGreaterThanOrEqual(1);
39
- const urls = (delivers[delivers.length - 1]?.data?.payload as { mediaUrls?: string[] })?.mediaUrls ?? [];
43
+ const urls =
44
+ (delivers[delivers.length - 1]?.data?.payload as { mediaUrls?: string[] })?.mediaUrls ?? [];
40
45
  expect(urls.some((u) => u.includes("/friday-next/files/"))).toBe(true);
41
46
  app.disconnectSSE();
42
47
  });