@syengup/friday-channel-next 0.1.22 → 0.1.23

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.
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import { sseEmitter } from "./sse/emitter.js";
4
4
  import { guessMimeType } from "./http/handlers/files.js";
5
+ import { downloadRemoteMedia, isHttpUrl } from "./media-fetch.js";
5
6
  import { getRunRoute } from "./run-metadata.js";
6
7
  import { resolveHistorySessionKeyForFridayDevice } from "./friday-session.js";
7
8
  const DISCOVERY = {
@@ -24,6 +25,9 @@ function pickString(params, keys) {
24
25
  return "";
25
26
  }
26
27
  async function readMediaFile(mediaPath, ctx) {
28
+ if (isHttpUrl(mediaPath)) {
29
+ return downloadRemoteMedia(mediaPath);
30
+ }
27
31
  if (ctx.mediaReadFile) {
28
32
  try {
29
33
  const buffer = await ctx.mediaReadFile(mediaPath);
@@ -44,7 +48,7 @@ async function readMediaFile(mediaPath, ctx) {
44
48
  async function handleSend(ctx) {
45
49
  const to = pickString(ctx.params, ["to", "target"]).toUpperCase();
46
50
  const text = pickString(ctx.params, ["message", "text", "content"]);
47
- const mediaPath = pickString(ctx.params, ["media", "path", "filePath", "fileUrl"]);
51
+ const mediaPath = pickString(ctx.params, ["media", "url", "path", "filePath", "fileUrl"]);
48
52
  const caption = pickString(ctx.params, ["caption"]);
49
53
  if (!to) {
50
54
  return { ok: false, error: "Missing required param: to" };
@@ -13,6 +13,7 @@ import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
13
13
  import { sseEmitter } from "./sse/emitter.js";
14
14
  import { describeMessageActions, handleMessageAction } from "./channel-actions.js";
15
15
  import { guessMimeType, resolveMediaAttachment } from "./http/handlers/files.js";
16
+ import { downloadRemoteMedia, isHttpUrl } from "./media-fetch.js";
16
17
  import { resolveFridayDeviceIdForOutbound, resolveHistorySessionKeyForFridayDevice, } from "./friday-session.js";
17
18
  import { getRunRoute } from "./run-metadata.js";
18
19
  import { getLastFridayInboundAt } from "./friday-inbound-stats.js";
@@ -212,12 +213,20 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
212
213
  };
213
214
  }
214
215
  let buffer = null;
216
+ let downloadedMimeType = null;
215
217
  if (ctx.mediaReadFile) {
216
218
  try {
217
219
  buffer = await ctx.mediaReadFile(mediaUrl);
218
220
  }
219
221
  catch {
220
- // fall through to fs
222
+ // fall through to remote download / fs
223
+ }
224
+ }
225
+ if (!buffer && isHttpUrl(mediaUrl)) {
226
+ const remote = await downloadRemoteMedia(mediaUrl);
227
+ if (remote) {
228
+ buffer = remote.buffer;
229
+ downloadedMimeType = remote.mimeType;
221
230
  }
222
231
  }
223
232
  if (!buffer) {
@@ -230,7 +239,7 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
230
239
  }
231
240
  }
232
241
  if (buffer) {
233
- const mimeType = guessMimeType(mediaUrl);
242
+ const mimeType = downloadedMimeType ?? guessMimeType(mediaUrl);
234
243
  const saved = await saveMediaBuffer(buffer, mimeType, "inbound");
235
244
  if (saved.id) {
236
245
  const fileUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
@@ -1,11 +1,11 @@
1
1
  import { sseEmitter } from "./sse/emitter.js";
2
2
  import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
3
- import { toSessionStoreKey, agentIdFromSessionKey } from "./session/session-manager.js";
3
+ import { toSessionStoreKey } from "./session/session-manager.js";
4
4
  import { getOpenClawAgentRunContext } from "./agent-run-context-bridge.js";
5
5
  import { observeAgentEventForActiveRuns } from "./agent/active-runs.js";
6
6
  import { getRunMetadata, ingestAgentEventMetadata } from "./run-metadata.js";
7
7
  import { consumeRunUsage } from "./agent/run-usage-accumulator.js";
8
- import { buildSessionUsageSnapshot } from "./session-usage-snapshot.js";
8
+ import { readSessionUsageSnapshotFromStore } from "./session-usage-store.js";
9
9
  import { lookupByRunId, registerSessionKeyForRun, registerSpawnIntent, consumeSpawnIntent, ensureSubagentFromSpawnTool, registerEnded as registerSubagentEnded, } from "./agent/subagent-registry.js";
10
10
  /** Last `data.text` per run for `stream: "thinking"` — OpenClaw core may send cumulative `delta`; we rewrite true increments for the app. */
11
11
  const lastThinkingTextByRun = new Map();
@@ -143,25 +143,6 @@ function mergeRunMetadataIntoLifecycleEnd(runId, base) {
143
143
  return base;
144
144
  return { ...base, ...extra };
145
145
  }
146
- function tryReadSessionUsageFromStore(sessionKeyForStore) {
147
- const access = getFridayAgentForwardRuntime();
148
- if (!access)
149
- return undefined;
150
- try {
151
- const cfg = access.getConfig();
152
- const storeConfig = cfg?.session?.store;
153
- const canonical = toSessionStoreKey(sessionKeyForStore);
154
- const storePath = access.resolveStorePath(storeConfig, { agentId: agentIdFromSessionKey(canonical) });
155
- const store = access.loadSessionStore(storePath, { skipCache: true });
156
- const entry = store[canonical] ?? store[sessionKeyForStore.trim()];
157
- if (!entry || typeof entry !== "object")
158
- return undefined;
159
- return buildSessionUsageSnapshot(entry);
160
- }
161
- catch {
162
- return undefined;
163
- }
164
- }
165
146
  function buildSessionUsageFromRunMetadata(runId) {
166
147
  const meta = getRunMetadata(runId);
167
148
  if (!meta)
@@ -425,7 +406,7 @@ export function forwardAgentEventRaw(evt) {
425
406
  // llm_output data is per-run; store is cumulative across rounds.
426
407
  setTimeout(() => {
427
408
  let data = outgoingData;
428
- const storeUsage = tryReadSessionUsageFromStore(sk);
409
+ const storeUsage = readSessionUsageSnapshotFromStore(sk);
429
410
  const llmUsage = consumeRunUsage(evt.runId);
430
411
  const memUsage = buildSessionUsageFromRunMetadata(evt.runId);
431
412
  let usage;
@@ -14,6 +14,7 @@ import { extractBearerToken } from "../middleware/auth.js";
14
14
  import { normalizeHistoryMessages } from "../../history/normalize-message.js";
15
15
  import { readSessionTranscriptRawMessages, resolveSessionId } from "../../history/read-transcript.js";
16
16
  import { resolveMediaAttachment } from "./files.js";
17
+ import { readSessionUsageSnapshotFromStore } from "../../session-usage-store.js";
17
18
  const DEFAULT_LIMIT = 200;
18
19
  const MAX_LIMIT = 1000;
19
20
  function resolveSubagentApi() {
@@ -86,6 +87,12 @@ export async function handleHistoryMessages(req, res) {
86
87
  delete message.mediaPaths;
87
88
  }
88
89
  const sessionId = resolveSessionId(sessionKey);
90
+ // Cumulative session-usage snapshot (model + context window/used) read from the
91
+ // session store — the SAME source the live `lifecycle.end` frame uses. The
92
+ // transcript carries per-message model/tokens but NOT the context-window figures,
93
+ // so the app stamps this snapshot onto the latest assistant turn on rebuild to
94
+ // keep the nav-bar context ring correct (and surviving app restarts).
95
+ const sessionUsage = readSessionUsageSnapshotFromStore(sessionKey);
89
96
  res.statusCode = 200;
90
97
  res.setHeader("Content-Type", "application/json");
91
98
  res.end(JSON.stringify({
@@ -95,6 +102,7 @@ export async function handleHistoryMessages(req, res) {
95
102
  ...(sessionId ? { sessionId } : {}),
96
103
  totalMessages: messages.length,
97
104
  messages,
105
+ ...(sessionUsage ? { sessionUsage } : {}),
98
106
  }));
99
107
  return true;
100
108
  }
@@ -0,0 +1,13 @@
1
+ export declare function isHttpUrl(value: string): boolean;
2
+ /**
3
+ * Download an http/https URL into a buffer. Returns null on any failure (non-2xx, oversize, timeout,
4
+ * network error) so callers degrade to text-only rather than throwing.
5
+ *
6
+ * The mime type prefers the response `Content-Type`, falling back to the URL extension. It is only a
7
+ * hint — `saveMediaBuffer` re-detects the real type from the buffer's magic bytes, so links without
8
+ * an extension (e.g. `picsum.photos/600/400`) still land with the correct file type.
9
+ */
10
+ export declare function downloadRemoteMedia(url: string): Promise<{
11
+ buffer: Buffer;
12
+ mimeType: string;
13
+ } | null>;
@@ -0,0 +1,53 @@
1
+ import { guessMimeType } from "./http/handlers/files.js";
2
+ /**
3
+ * Remote (http/https) media download for the `message` tool and outbound sendMedia.
4
+ *
5
+ * The agent often sends attachments as a direct link (`url: "https://.../foo.jpg"`) rather than a
6
+ * local file path. The gateway can reach that link even when the user's device can't (ATS, foreign
7
+ * TLS, intranet / geo differences), so we download it server-side and re-publish it through the
8
+ * gateway's `/friday-next/files/` route — identical to the local-file path. This keeps the app's
9
+ * download story uniform (always talks to the trusted gateway host with a bearer token).
10
+ */
11
+ const MAX_REMOTE_MEDIA_BYTES = 25 * 1024 * 1024; // 25MB
12
+ const REMOTE_MEDIA_TIMEOUT_MS = 20_000;
13
+ export function isHttpUrl(value) {
14
+ return /^https?:\/\//i.test(value.trim());
15
+ }
16
+ /**
17
+ * Download an http/https URL into a buffer. Returns null on any failure (non-2xx, oversize, timeout,
18
+ * network error) so callers degrade to text-only rather than throwing.
19
+ *
20
+ * The mime type prefers the response `Content-Type`, falling back to the URL extension. It is only a
21
+ * hint — `saveMediaBuffer` re-detects the real type from the buffer's magic bytes, so links without
22
+ * an extension (e.g. `picsum.photos/600/400`) still land with the correct file type.
23
+ */
24
+ export async function downloadRemoteMedia(url) {
25
+ const controller = new AbortController();
26
+ const timer = setTimeout(() => controller.abort(), REMOTE_MEDIA_TIMEOUT_MS);
27
+ try {
28
+ const res = await fetch(url, { redirect: "follow", signal: controller.signal });
29
+ if (!res.ok)
30
+ return null;
31
+ const declaredLength = Number(res.headers.get("content-length") ?? "");
32
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_REMOTE_MEDIA_BYTES) {
33
+ return null;
34
+ }
35
+ const buffer = Buffer.from(await res.arrayBuffer());
36
+ if (!buffer.length || buffer.length > MAX_REMOTE_MEDIA_BYTES)
37
+ return null;
38
+ const headerMime = res.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase();
39
+ const extMime = guessMimeType(url);
40
+ const mimeType = headerMime && headerMime !== "application/octet-stream"
41
+ ? headerMime
42
+ : extMime !== "application/octet-stream"
43
+ ? extMime
44
+ : headerMime || "application/octet-stream";
45
+ return { buffer, mimeType };
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ finally {
51
+ clearTimeout(timer);
52
+ }
53
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Reads the cumulative session-usage snapshot from the OpenClaw session store
3
+ * (`sessions.json`) for a given Friday session key.
4
+ *
5
+ * Shared by two readers:
6
+ * - the live terminal-lifecycle forward (`friday-session.ts`), which stamps the
7
+ * snapshot onto the `lifecycle.end` frame, and
8
+ * - the history endpoint (`http/handlers/history-messages.ts`), which returns it
9
+ * alongside the transcript so a rebuild can restore the nav-bar context ring.
10
+ *
11
+ * The snapshot is **session-cumulative**, not per-message: `context.windowMax` is
12
+ * the model's context window and `context.used` is the running session total. The
13
+ * transcript only carries per-message `model` + `usage.{total,input,output}`; the
14
+ * context-window figures live only here, in the session store.
15
+ */
16
+ import type { FridaySessionUsagePayload } from "./session-usage-snapshot.js";
17
+ export declare function readSessionUsageSnapshotFromStore(sessionKeyForStore: string): FridaySessionUsagePayload | undefined;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Reads the cumulative session-usage snapshot from the OpenClaw session store
3
+ * (`sessions.json`) for a given Friday session key.
4
+ *
5
+ * Shared by two readers:
6
+ * - the live terminal-lifecycle forward (`friday-session.ts`), which stamps the
7
+ * snapshot onto the `lifecycle.end` frame, and
8
+ * - the history endpoint (`http/handlers/history-messages.ts`), which returns it
9
+ * alongside the transcript so a rebuild can restore the nav-bar context ring.
10
+ *
11
+ * The snapshot is **session-cumulative**, not per-message: `context.windowMax` is
12
+ * the model's context window and `context.used` is the running session total. The
13
+ * transcript only carries per-message `model` + `usage.{total,input,output}`; the
14
+ * context-window figures live only here, in the session store.
15
+ */
16
+ import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
17
+ import { toSessionStoreKey, agentIdFromSessionKey } from "./session/session-manager.js";
18
+ import { buildSessionUsageSnapshot } from "./session-usage-snapshot.js";
19
+ export function readSessionUsageSnapshotFromStore(sessionKeyForStore) {
20
+ const access = getFridayAgentForwardRuntime();
21
+ if (!access)
22
+ return undefined;
23
+ try {
24
+ const cfg = access.getConfig();
25
+ const storeConfig = cfg?.session?.store;
26
+ const canonical = toSessionStoreKey(sessionKeyForStore);
27
+ const storePath = access.resolveStorePath(storeConfig, { agentId: agentIdFromSessionKey(canonical) });
28
+ const store = access.loadSessionStore(storePath, { skipCache: true });
29
+ const entry = store[canonical] ?? store[sessionKeyForStore.trim()];
30
+ if (!entry || typeof entry !== "object")
31
+ return undefined;
32
+ return buildSessionUsageSnapshot(entry);
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,15 +12,6 @@
12
12
  "tsconfig.json",
13
13
  "openclaw.plugin.json"
14
14
  ],
15
- "scripts": {
16
- "build": "tsc -p tsconfig.json",
17
- "prepublishOnly": "pnpm build && rm -rf dist/attachments",
18
- "test": "npm run test:unit && npm run test:e2e",
19
- "test:unit": "vitest run",
20
- "test:e2e": "vitest run --config vitest.e2e.config.ts",
21
- "test:smoke": "node scripts/e2e-smoke.mjs",
22
- "test:msg-live": "node scripts/message-roundtrip-live.mjs"
23
- },
24
15
  "bin": {
25
16
  "friday-channel-next": "install.js"
26
17
  },
@@ -66,5 +57,13 @@
66
57
  "typescript": "^6.0.3",
67
58
  "vitest": "^4.1.5",
68
59
  "zod": "^4.3.6"
60
+ },
61
+ "scripts": {
62
+ "build": "tsc -p tsconfig.json",
63
+ "test": "npm run test:unit && npm run test:e2e",
64
+ "test:unit": "vitest run",
65
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
66
+ "test:smoke": "node scripts/e2e-smoke.mjs",
67
+ "test:msg-live": "node scripts/message-roundtrip-live.mjs"
69
68
  }
70
- }
69
+ }
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { EventEmitter } from "node:events";
4
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { handleMessageAction } from "./channel-actions.js";
6
6
  import { sseEmitter } from "./sse/emitter.js";
7
7
  import { setOfflineQueueBaseDirForTest } from "./sse/offline-queue.js";
@@ -60,6 +60,7 @@ describe("channel-actions handleSend sessionKey routing", () => {
60
60
  });
61
61
 
62
62
  afterEach(() => {
63
+ vi.unstubAllGlobals();
63
64
  setOfflineQueueBaseDirForTest(null);
64
65
  removeTempHistoryDir(historyDir);
65
66
  });
@@ -95,6 +96,40 @@ describe("channel-actions handleSend sessionKey routing", () => {
95
96
  expect(media?.data.deviceId).toBe(deviceId);
96
97
  });
97
98
 
99
+ it("send media via an https `url` direct link downloads it and emits op:media", async () => {
100
+ const deviceId = "DEV-ACT-URL";
101
+ const runId = "run-act-url";
102
+ const appSession = "agent:operator:friday:direct:dev-act-url:1780561609";
103
+ registerRunRoute({ runId, deviceId, sessionKey: appSession });
104
+ sseEmitter.trackDeviceForRun(deviceId, runId);
105
+ const res = connect(deviceId);
106
+
107
+ // 8-byte PNG magic header so saveMediaBuffer's magic-byte detection recognizes an image.
108
+ const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]);
109
+ const directLink = "https://picsum.photos/600/400";
110
+ const fetchMock = vi.fn(async () =>
111
+ new Response(pngBytes, { status: 200, headers: { "content-type": "image/png" } }),
112
+ );
113
+ vi.stubGlobal("fetch", fetchMock);
114
+
115
+ const result = await handleMessageAction({
116
+ action: "send",
117
+ // The agent sends a direct link via the `url` param, not `media`.
118
+ params: { to: deviceId, message: "直链图来了", url: directLink },
119
+ sessionKey: "agent:operator:main",
120
+ });
121
+
122
+ expect((result as { ok?: boolean }).ok).toBe(true);
123
+ expect(fetchMock).toHaveBeenCalledWith(directLink, expect.anything());
124
+
125
+ const frames = parseOutboundFrames(res);
126
+ const media = frames.find((f) => f.type === "outbound" && f.data.op === "media");
127
+ expect(media).toBeTruthy();
128
+ expect(String(media?.data.mediaUrl)).toMatch(/^\/friday-next\/files\//);
129
+ expect((media?.data.ctx as { originalMediaUrl?: string })?.originalMediaUrl).toBe(directLink);
130
+ expect(media?.data.sessionKey).toBe(appSession);
131
+ });
132
+
98
133
  it("falls back to ctx.sessionKey when the device has no active run-route", async () => {
99
134
  const deviceId = "DEV-ACT-2";
100
135
  const res = connect(deviceId);
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import { sseEmitter } from "./sse/emitter.js";
4
4
  import { guessMimeType } from "./http/handlers/files.js";
5
+ import { downloadRemoteMedia, isHttpUrl } from "./media-fetch.js";
5
6
  import { getRunRoute } from "./run-metadata.js";
6
7
  import { resolveHistorySessionKeyForFridayDevice } from "./friday-session.js";
7
8
 
@@ -39,6 +40,9 @@ async function readMediaFile(
39
40
  mediaPath: string,
40
41
  ctx: MessageActionCtx,
41
42
  ): Promise<{ buffer: Buffer; mimeType: string } | null> {
43
+ if (isHttpUrl(mediaPath)) {
44
+ return downloadRemoteMedia(mediaPath);
45
+ }
42
46
  if (ctx.mediaReadFile) {
43
47
  try {
44
48
  const buffer = await ctx.mediaReadFile(mediaPath);
@@ -58,7 +62,7 @@ async function readMediaFile(
58
62
  async function handleSend(ctx: MessageActionCtx): Promise<unknown> {
59
63
  const to = pickString(ctx.params, ["to", "target"]).toUpperCase();
60
64
  const text = pickString(ctx.params, ["message", "text", "content"]);
61
- const mediaPath = pickString(ctx.params, ["media", "path", "filePath", "fileUrl"]);
65
+ const mediaPath = pickString(ctx.params, ["media", "url", "path", "filePath", "fileUrl"]);
62
66
  const caption = pickString(ctx.params, ["caption"]);
63
67
 
64
68
  if (!to) {
package/src/channel.ts CHANGED
@@ -15,6 +15,7 @@ import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
15
15
  import { sseEmitter } from "./sse/emitter.js";
16
16
  import { describeMessageActions, handleMessageAction } from "./channel-actions.js";
17
17
  import { guessMimeType, resolveMediaAttachment } from "./http/handlers/files.js";
18
+ import { downloadRemoteMedia, isHttpUrl } from "./media-fetch.js";
18
19
  import {
19
20
  resolveFridayDeviceIdForOutbound,
20
21
  resolveHistorySessionKeyForFridayDevice,
@@ -245,12 +246,21 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
245
246
  }
246
247
 
247
248
  let buffer: Buffer | null = null;
249
+ let downloadedMimeType: string | null = null;
248
250
 
249
251
  if (ctx.mediaReadFile) {
250
252
  try {
251
253
  buffer = await ctx.mediaReadFile(mediaUrl);
252
254
  } catch {
253
- // fall through to fs
255
+ // fall through to remote download / fs
256
+ }
257
+ }
258
+
259
+ if (!buffer && isHttpUrl(mediaUrl)) {
260
+ const remote = await downloadRemoteMedia(mediaUrl);
261
+ if (remote) {
262
+ buffer = remote.buffer;
263
+ downloadedMimeType = remote.mimeType;
254
264
  }
255
265
  }
256
266
 
@@ -264,7 +274,7 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
264
274
  }
265
275
 
266
276
  if (buffer) {
267
- const mimeType = guessMimeType(mediaUrl);
277
+ const mimeType = downloadedMimeType ?? guessMimeType(mediaUrl);
268
278
  const saved = await saveMediaBuffer(buffer, mimeType, "inbound");
269
279
  if (saved.id) {
270
280
  const fileUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
@@ -1,12 +1,12 @@
1
1
  import { sseEmitter } from "./sse/emitter.js";
2
2
  import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
3
- import { toSessionStoreKey, agentIdFromSessionKey } from "./session/session-manager.js";
3
+ import { toSessionStoreKey } from "./session/session-manager.js";
4
4
  import { getOpenClawAgentRunContext } from "./agent-run-context-bridge.js";
5
5
  import { observeAgentEventForActiveRuns } from "./agent/active-runs.js";
6
6
  import { getRunMetadata, ingestAgentEventMetadata } from "./run-metadata.js";
7
7
  import { consumeRunUsage } from "./agent/run-usage-accumulator.js";
8
- import { buildSessionUsageSnapshot } from "./session-usage-snapshot.js";
9
8
  import type { FridaySessionUsagePayload } from "./session-usage-snapshot.js";
9
+ import { readSessionUsageSnapshotFromStore } from "./session-usage-store.js";
10
10
  import {
11
11
  lookupByRunId,
12
12
  registerSessionKeyForRun,
@@ -178,23 +178,6 @@ function mergeRunMetadataIntoLifecycleEnd(
178
178
  return { ...base, ...extra };
179
179
  }
180
180
 
181
- function tryReadSessionUsageFromStore(sessionKeyForStore: string): FridaySessionUsagePayload | undefined {
182
- const access = getFridayAgentForwardRuntime();
183
- if (!access) return undefined;
184
- try {
185
- const cfg = access.getConfig() as { session?: { store?: string } } | null | undefined;
186
- const storeConfig = cfg?.session?.store;
187
- const canonical = toSessionStoreKey(sessionKeyForStore);
188
- const storePath = access.resolveStorePath(storeConfig, { agentId: agentIdFromSessionKey(canonical) });
189
- const store = access.loadSessionStore(storePath, { skipCache: true }) as Record<string, Record<string, unknown>>;
190
- const entry = store[canonical] ?? store[sessionKeyForStore.trim()];
191
- if (!entry || typeof entry !== "object") return undefined;
192
- return buildSessionUsageSnapshot(entry);
193
- } catch {
194
- return undefined;
195
- }
196
- }
197
-
198
181
  function buildSessionUsageFromRunMetadata(runId: string): FridaySessionUsagePayload | undefined {
199
182
  const meta = getRunMetadata(runId);
200
183
  if (!meta) return undefined;
@@ -487,7 +470,7 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
487
470
  // llm_output data is per-run; store is cumulative across rounds.
488
471
  setTimeout(() => {
489
472
  let data = outgoingData;
490
- const storeUsage = tryReadSessionUsageFromStore(sk);
473
+ const storeUsage = readSessionUsageSnapshotFromStore(sk);
491
474
  const llmUsage = consumeRunUsage(evt.runId);
492
475
  const memUsage = buildSessionUsageFromRunMetadata(evt.runId);
493
476
  let usage: FridaySessionUsagePayload | undefined;
@@ -114,6 +114,45 @@ describe("handleHistoryMessages", () => {
114
114
  expect(body.messages[1].text).toBe("hello");
115
115
  });
116
116
 
117
+ it("returns the cumulative sessionUsage snapshot from the store", async () => {
118
+ const file = writeTranscript("usage.jsonl", [
119
+ { type: "message", id: "u1", message: { role: "user", content: "hi" } },
120
+ { type: "message", id: "a1", message: { role: "assistant", content: [{ type: "text", text: "yo" }], model: "openai/gpt-4" } },
121
+ ]);
122
+ setForward({
123
+ "agent:main:main": {
124
+ sessionId: "s",
125
+ sessionFile: file,
126
+ model: "openai/gpt-4",
127
+ totalTokens: 12_480,
128
+ contextTokens: 128_000,
129
+ inputTokens: 9_000,
130
+ outputTokens: 3_480,
131
+ },
132
+ });
133
+
134
+ const res = new MockRes();
135
+ await handleHistoryMessages(makeReq("/friday-next/history/messages?sessionKey=agent:main:main", AUTH), res as any);
136
+ expect(res.statusCode).toBe(200);
137
+ const body = JSON.parse(res.body);
138
+ expect(body.sessionUsage).toBeDefined();
139
+ expect(body.sessionUsage.modelId).toBe("openai/gpt-4");
140
+ expect(body.sessionUsage.context).toEqual({ windowMax: 128_000, used: 12_480 });
141
+ expect(body.sessionUsage.tokens.total).toBe(12_480);
142
+ });
143
+
144
+ it("omits sessionUsage when the store has no entry", async () => {
145
+ const file = writeTranscript("nousage.jsonl", [
146
+ { type: "message", id: "u1", message: { role: "user", content: "hi" } },
147
+ ]);
148
+ setForward({ "agent:main:main": { sessionId: "s", sessionFile: file } });
149
+
150
+ const res = new MockRes();
151
+ await handleHistoryMessages(makeReq("/friday-next/history/messages?sessionKey=agent:main:main", AUTH), res as any);
152
+ const body = JSON.parse(res.body);
153
+ expect(body.sessionUsage).toBeUndefined();
154
+ });
155
+
117
156
  it("resolves the entry case-insensitively (app upper-cases deviceId)", async () => {
118
157
  const file = writeTranscript("fd.jsonl", [
119
158
  { type: "message", id: "u1", message: { role: "user", content: "from app" } },
@@ -16,6 +16,7 @@ import { extractBearerToken } from "../middleware/auth.js";
16
16
  import { normalizeHistoryMessages } from "../../history/normalize-message.js";
17
17
  import { readSessionTranscriptRawMessages, resolveSessionId } from "../../history/read-transcript.js";
18
18
  import { resolveMediaAttachment } from "./files.js";
19
+ import { readSessionUsageSnapshotFromStore } from "../../session-usage-store.js";
19
20
 
20
21
  const DEFAULT_LIMIT = 200;
21
22
  const MAX_LIMIT = 1000;
@@ -107,6 +108,13 @@ export async function handleHistoryMessages(
107
108
 
108
109
  const sessionId = resolveSessionId(sessionKey);
109
110
 
111
+ // Cumulative session-usage snapshot (model + context window/used) read from the
112
+ // session store — the SAME source the live `lifecycle.end` frame uses. The
113
+ // transcript carries per-message model/tokens but NOT the context-window figures,
114
+ // so the app stamps this snapshot onto the latest assistant turn on rebuild to
115
+ // keep the nav-bar context ring correct (and surviving app restarts).
116
+ const sessionUsage = readSessionUsageSnapshotFromStore(sessionKey);
117
+
110
118
  res.statusCode = 200;
111
119
  res.setHeader("Content-Type", "application/json");
112
120
  res.end(
@@ -117,6 +125,7 @@ export async function handleHistoryMessages(
117
125
  ...(sessionId ? { sessionId } : {}),
118
126
  totalMessages: messages.length,
119
127
  messages,
128
+ ...(sessionUsage ? { sessionUsage } : {}),
120
129
  }),
121
130
  );
122
131
  return true;
@@ -0,0 +1,78 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { downloadRemoteMedia, isHttpUrl } from "./media-fetch.js";
3
+
4
+ describe("isHttpUrl", () => {
5
+ it("matches http/https links, rejects local paths", () => {
6
+ expect(isHttpUrl("https://picsum.photos/600/400")).toBe(true);
7
+ expect(isHttpUrl("http://example.com/a.png")).toBe(true);
8
+ expect(isHttpUrl(" HTTPS://EXAMPLE.com/a.png ")).toBe(true);
9
+ expect(isHttpUrl("/tmp/test.jpg")).toBe(false);
10
+ expect(isHttpUrl("file:///tmp/test.jpg")).toBe(false);
11
+ expect(isHttpUrl("shot.png")).toBe(false);
12
+ });
13
+ });
14
+
15
+ describe("downloadRemoteMedia", () => {
16
+ afterEach(() => {
17
+ vi.unstubAllGlobals();
18
+ });
19
+
20
+ it("downloads bytes and prefers the response content-type", async () => {
21
+ const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
22
+ vi.stubGlobal(
23
+ "fetch",
24
+ vi.fn(async () => new Response(bytes, { status: 200, headers: { "content-type": "image/png" } })),
25
+ );
26
+
27
+ const result = await downloadRemoteMedia("https://picsum.photos/600/400");
28
+ expect(result).toBeTruthy();
29
+ expect(result?.mimeType).toBe("image/png");
30
+ expect(result?.buffer.length).toBe(bytes.length);
31
+ });
32
+
33
+ it("falls back to the URL extension when content-type is octet-stream", async () => {
34
+ const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
35
+ vi.stubGlobal(
36
+ "fetch",
37
+ vi.fn(
38
+ async () =>
39
+ new Response(bytes, {
40
+ status: 200,
41
+ headers: { "content-type": "application/octet-stream" },
42
+ }),
43
+ ),
44
+ );
45
+
46
+ const result = await downloadRemoteMedia("https://cdn.example.com/photo.jpg");
47
+ expect(result?.mimeType).toBe("image/jpeg");
48
+ });
49
+
50
+ it("returns null on a non-2xx response", async () => {
51
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 404 })));
52
+ expect(await downloadRemoteMedia("https://example.com/missing.png")).toBeNull();
53
+ });
54
+
55
+ it("returns null when the content-length exceeds the cap", async () => {
56
+ vi.stubGlobal(
57
+ "fetch",
58
+ vi.fn(
59
+ async () =>
60
+ new Response(new Uint8Array([1, 2, 3]), {
61
+ status: 200,
62
+ headers: { "content-type": "image/png", "content-length": String(64 * 1024 * 1024) },
63
+ }),
64
+ ),
65
+ );
66
+ expect(await downloadRemoteMedia("https://example.com/huge.png")).toBeNull();
67
+ });
68
+
69
+ it("returns null on a network error instead of throwing", async () => {
70
+ vi.stubGlobal(
71
+ "fetch",
72
+ vi.fn(async () => {
73
+ throw new Error("ECONNREFUSED");
74
+ }),
75
+ );
76
+ expect(await downloadRemoteMedia("https://example.com/x.png")).toBeNull();
77
+ });
78
+ });
@@ -0,0 +1,60 @@
1
+ import { guessMimeType } from "./http/handlers/files.js";
2
+
3
+ /**
4
+ * Remote (http/https) media download for the `message` tool and outbound sendMedia.
5
+ *
6
+ * The agent often sends attachments as a direct link (`url: "https://.../foo.jpg"`) rather than a
7
+ * local file path. The gateway can reach that link even when the user's device can't (ATS, foreign
8
+ * TLS, intranet / geo differences), so we download it server-side and re-publish it through the
9
+ * gateway's `/friday-next/files/` route — identical to the local-file path. This keeps the app's
10
+ * download story uniform (always talks to the trusted gateway host with a bearer token).
11
+ */
12
+
13
+ const MAX_REMOTE_MEDIA_BYTES = 25 * 1024 * 1024; // 25MB
14
+ const REMOTE_MEDIA_TIMEOUT_MS = 20_000;
15
+
16
+ export function isHttpUrl(value: string): boolean {
17
+ return /^https?:\/\//i.test(value.trim());
18
+ }
19
+
20
+ /**
21
+ * Download an http/https URL into a buffer. Returns null on any failure (non-2xx, oversize, timeout,
22
+ * network error) so callers degrade to text-only rather than throwing.
23
+ *
24
+ * The mime type prefers the response `Content-Type`, falling back to the URL extension. It is only a
25
+ * hint — `saveMediaBuffer` re-detects the real type from the buffer's magic bytes, so links without
26
+ * an extension (e.g. `picsum.photos/600/400`) still land with the correct file type.
27
+ */
28
+ export async function downloadRemoteMedia(
29
+ url: string,
30
+ ): Promise<{ buffer: Buffer; mimeType: string } | null> {
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), REMOTE_MEDIA_TIMEOUT_MS);
33
+ try {
34
+ const res = await fetch(url, { redirect: "follow", signal: controller.signal });
35
+ if (!res.ok) return null;
36
+
37
+ const declaredLength = Number(res.headers.get("content-length") ?? "");
38
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_REMOTE_MEDIA_BYTES) {
39
+ return null;
40
+ }
41
+
42
+ const buffer = Buffer.from(await res.arrayBuffer());
43
+ if (!buffer.length || buffer.length > MAX_REMOTE_MEDIA_BYTES) return null;
44
+
45
+ const headerMime = res.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase();
46
+ const extMime = guessMimeType(url);
47
+ const mimeType =
48
+ headerMime && headerMime !== "application/octet-stream"
49
+ ? headerMime
50
+ : extMime !== "application/octet-stream"
51
+ ? extMime
52
+ : headerMime || "application/octet-stream";
53
+
54
+ return { buffer, mimeType };
55
+ } catch {
56
+ return null;
57
+ } finally {
58
+ clearTimeout(timer);
59
+ }
60
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Reads the cumulative session-usage snapshot from the OpenClaw session store
3
+ * (`sessions.json`) for a given Friday session key.
4
+ *
5
+ * Shared by two readers:
6
+ * - the live terminal-lifecycle forward (`friday-session.ts`), which stamps the
7
+ * snapshot onto the `lifecycle.end` frame, and
8
+ * - the history endpoint (`http/handlers/history-messages.ts`), which returns it
9
+ * alongside the transcript so a rebuild can restore the nav-bar context ring.
10
+ *
11
+ * The snapshot is **session-cumulative**, not per-message: `context.windowMax` is
12
+ * the model's context window and `context.used` is the running session total. The
13
+ * transcript only carries per-message `model` + `usage.{total,input,output}`; the
14
+ * context-window figures live only here, in the session store.
15
+ */
16
+
17
+ import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
18
+ import { toSessionStoreKey, agentIdFromSessionKey } from "./session/session-manager.js";
19
+ import { buildSessionUsageSnapshot } from "./session-usage-snapshot.js";
20
+ import type { FridaySessionUsagePayload } from "./session-usage-snapshot.js";
21
+
22
+ export function readSessionUsageSnapshotFromStore(
23
+ sessionKeyForStore: string,
24
+ ): FridaySessionUsagePayload | undefined {
25
+ const access = getFridayAgentForwardRuntime();
26
+ if (!access) return undefined;
27
+ try {
28
+ const cfg = access.getConfig() as { session?: { store?: string } } | null | undefined;
29
+ const storeConfig = cfg?.session?.store;
30
+ const canonical = toSessionStoreKey(sessionKeyForStore);
31
+ const storePath = access.resolveStorePath(storeConfig, { agentId: agentIdFromSessionKey(canonical) });
32
+ const store = access.loadSessionStore(storePath, { skipCache: true }) as Record<
33
+ string,
34
+ Record<string, unknown>
35
+ >;
36
+ const entry = store[canonical] ?? store[sessionKeyForStore.trim()];
37
+ if (!entry || typeof entry !== "object") return undefined;
38
+ return buildSessionUsageSnapshot(entry);
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }