@syengup/friday-channel-next 0.1.21 → 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.
@@ -14,6 +14,8 @@ export type FridayAgentForwardRuntime = {
14
14
  sessionKey: string;
15
15
  update: (entry: Record<string, unknown>) => Record<string, unknown> | null | Promise<Record<string, unknown> | null>;
16
16
  }) => Promise<Record<string, unknown> | null>;
17
+ /** Resolves an agent's workspace dir — used to read IDENTITY.md for the name fallback. */
18
+ resolveAgentWorkspaceDir?: (cfg: unknown, agentId: string) => string;
17
19
  getConfig: () => unknown;
18
20
  };
19
21
  /** Called from `registerFull` so terminal lifecycle forwards can read `sessions.json` after persist. */
@@ -6,6 +6,8 @@ export function setFridayAgentForwardRuntime(api) {
6
6
  loadSessionStore: api.runtime.agent.session.loadSessionStore,
7
7
  updateSessionStoreEntry: api.runtime.agent.session
8
8
  .updateSessionStoreEntry,
9
+ resolveAgentWorkspaceDir: api.runtime.agent
10
+ .resolveAgentWorkspaceDir,
9
11
  getConfig: () => api.runtime.config.current(),
10
12
  };
11
13
  }
@@ -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;
@@ -10,4 +10,12 @@ export interface FridayAgentEntry {
10
10
  emoji?: string;
11
11
  avatar?: string;
12
12
  }
13
+ /**
14
+ * Extract the `Name` field from an agent's IDENTITY.md, mirroring OpenClaw's
15
+ * `parseIdentityMarkdown` (src/agents/identity-file.ts) for the name label only:
16
+ * drop the leading "- ", split on the first ":", strip markdown emphasis, and
17
+ * skip the unfilled template placeholder. Returns the raw value verbatim (e.g.
18
+ * "星期五 (Friday)") so it matches what ControlUI shows under "身份名称".
19
+ */
20
+ export declare function parseIdentityNameFromMarkdown(content: string): string | undefined;
13
21
  export declare function handleAgentsList(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
@@ -1,4 +1,6 @@
1
- import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { getFridayAgentForwardRuntime, } from "../../agent-forward-runtime.js";
2
4
  import { extractBearerToken } from "../middleware/auth.js";
3
5
  const DEFAULT_AGENT_ID = "main";
4
6
  /** Agent ids already in path/shell-safe form skip the slug rewrite below. */
@@ -31,6 +33,60 @@ function resolvePrimaryModel(model) {
31
33
  function readString(value) {
32
34
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
33
35
  }
36
+ /** Unfilled IDENTITY.md template prompts that must not surface as a real name. */
37
+ const IDENTITY_NAME_PLACEHOLDERS = new Set(["pick something you like"]);
38
+ /**
39
+ * Extract the `Name` field from an agent's IDENTITY.md, mirroring OpenClaw's
40
+ * `parseIdentityMarkdown` (src/agents/identity-file.ts) for the name label only:
41
+ * drop the leading "- ", split on the first ":", strip markdown emphasis, and
42
+ * skip the unfilled template placeholder. Returns the raw value verbatim (e.g.
43
+ * "星期五 (Friday)") so it matches what ControlUI shows under "身份名称".
44
+ */
45
+ export function parseIdentityNameFromMarkdown(content) {
46
+ for (const rawLine of content.split(/\r?\n/)) {
47
+ const cleaned = rawLine.trim().replace(/^\s*-\s*/, "");
48
+ const colonIndex = cleaned.indexOf(":");
49
+ if (colonIndex === -1)
50
+ continue;
51
+ const label = cleaned.slice(0, colonIndex).replace(/[*_`]/g, "").trim().toLowerCase();
52
+ if (label !== "name")
53
+ continue;
54
+ const value = cleaned
55
+ .slice(colonIndex + 1)
56
+ .replace(/^[*_`\s]+|[*_`\s]+$/g, "")
57
+ .trim();
58
+ if (!value)
59
+ continue;
60
+ let normalized = value.replace(/[–—]/g, "-");
61
+ if (normalized.startsWith("(") && normalized.endsWith(")")) {
62
+ normalized = normalized.slice(1, -1).trim();
63
+ }
64
+ if (IDENTITY_NAME_PLACEHOLDERS.has(normalized.toLowerCase()))
65
+ continue;
66
+ return value;
67
+ }
68
+ return undefined;
69
+ }
70
+ /**
71
+ * Name fallback for agents with no `name`/`identity.name` in config (e.g. the
72
+ * implicit `main`): resolve the agent's workspace and parse its IDENTITY.md, the
73
+ * same source ControlUI reads. Best-effort — any failure yields undefined.
74
+ */
75
+ function readWorkspaceIdentityName(rt, cfg, agentId) {
76
+ const resolveWorkspace = rt.resolveAgentWorkspaceDir;
77
+ if (!resolveWorkspace)
78
+ return undefined;
79
+ try {
80
+ const workspace = resolveWorkspace(cfg, agentId);
81
+ if (!workspace)
82
+ return undefined;
83
+ const content = fs.readFileSync(path.join(workspace, "IDENTITY.md"), "utf-8");
84
+ return parseIdentityNameFromMarkdown(content);
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
34
90
  /**
35
91
  * Reads the configured agents directly from the runtime config (same approach as
36
92
  * models-list.ts). When no agents are configured OpenClaw runs an implicit "main"
@@ -64,7 +120,9 @@ function resolveConfiguredAgents() {
64
120
  const identity = agent.identity;
65
121
  entries.push({
66
122
  id,
67
- name: readString(agent.name) ?? readString(identity?.name),
123
+ name: readString(agent.name) ??
124
+ readString(identity?.name) ??
125
+ readWorkspaceIdentityName(rt, cfg, id),
68
126
  description: readString(agent.description),
69
127
  model: resolvePrimaryModel(agent.model),
70
128
  thinkingDefault: readString(agent.thinkingDefault),
@@ -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.21",
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
+ }
@@ -14,6 +14,8 @@ export type FridayAgentForwardRuntime = {
14
14
  entry: Record<string, unknown>,
15
15
  ) => Record<string, unknown> | null | Promise<Record<string, unknown> | null>;
16
16
  }) => Promise<Record<string, unknown> | null>;
17
+ /** Resolves an agent's workspace dir — used to read IDENTITY.md for the name fallback. */
18
+ resolveAgentWorkspaceDir?: (cfg: unknown, agentId: string) => string;
17
19
  getConfig: () => unknown;
18
20
  };
19
21
 
@@ -26,6 +28,8 @@ export function setFridayAgentForwardRuntime(api: OpenClawPluginApi): void {
26
28
  loadSessionStore: api.runtime.agent.session.loadSessionStore,
27
29
  updateSessionStoreEntry: (api.runtime.agent.session as Record<string, unknown>)
28
30
  .updateSessionStoreEntry as FridayAgentForwardRuntime["updateSessionStoreEntry"],
31
+ resolveAgentWorkspaceDir: (api.runtime.agent as Record<string, unknown>)
32
+ .resolveAgentWorkspaceDir as FridayAgentForwardRuntime["resolveAgentWorkspaceDir"],
29
33
  getConfig: () => api.runtime.config.current(),
30
34
  };
31
35
  }
@@ -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;
@@ -1,6 +1,9 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import { EventEmitter } from "node:events";
3
- import { handleAgentsList } from "./agents-list.js";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { handleAgentsList, parseIdentityNameFromMarkdown } from "./agents-list.js";
4
7
  import { setMockRuntime } from "../../test-support/mock-runtime.js";
5
8
  import {
6
9
  setFridayAgentForwardRuntime,
@@ -108,6 +111,33 @@ describe("handleAgentsList", () => {
108
111
  ]);
109
112
  });
110
113
 
114
+ it("falls back to the IDENTITY.md name when config has none", async () => {
115
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "friday-identity-"));
116
+ fs.writeFileSync(
117
+ path.join(workspace, "IDENTITY.md"),
118
+ "# IDENTITY.md\n\n- **Name:** 星期五 (Friday)\n- **Emoji:** 🌿\n",
119
+ );
120
+ try {
121
+ setFridayAgentForwardRuntime({
122
+ runtime: {
123
+ agent: {
124
+ session: { resolveStorePath: () => "", loadSessionStore: () => ({}) },
125
+ resolveAgentWorkspaceDir: () => workspace,
126
+ },
127
+ config: { current: () => ({ agents: { list: [{ id: "main" }] } }) },
128
+ },
129
+ } as any);
130
+
131
+ const res = new MockRes();
132
+ await handleAgentsList(makeReq(AUTH), res as any);
133
+
134
+ const body = JSON.parse(res.body);
135
+ expect(body.agents).toEqual([{ id: "main", name: "星期五 (Friday)", isDefault: true }]);
136
+ } finally {
137
+ fs.rmSync(workspace, { recursive: true, force: true });
138
+ }
139
+ });
140
+
111
141
  it("defaults to the first entry when none is marked default and dedups ids", async () => {
112
142
  setConfig({
113
143
  agents: {
@@ -127,3 +157,24 @@ describe("handleAgentsList", () => {
127
157
  expect(body.agents[0].isDefault).toBe(true);
128
158
  });
129
159
  });
160
+
161
+ describe("parseIdentityNameFromMarkdown", () => {
162
+ it("extracts the Name value from the OpenClaw template format", () => {
163
+ const md = "# IDENTITY.md\n\n- **Name:** 星期五 (Friday)\n- **Emoji:** 🌿\n";
164
+ expect(parseIdentityNameFromMarkdown(md)).toBe("星期五 (Friday)");
165
+ });
166
+
167
+ it("handles a plain unstyled `Name:` line", () => {
168
+ expect(parseIdentityNameFromMarkdown("Name: Jarvis")).toBe("Jarvis");
169
+ });
170
+
171
+ it("returns undefined when there is no Name field", () => {
172
+ expect(parseIdentityNameFromMarkdown("- **Emoji:** 🌿\n- **Vibe:** calm")).toBeUndefined();
173
+ });
174
+
175
+ it("skips the unfilled template placeholder", () => {
176
+ expect(
177
+ parseIdentityNameFromMarkdown("- **Name:** _(pick something you like)_"),
178
+ ).toBeUndefined();
179
+ });
180
+ });
@@ -1,5 +1,10 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ getFridayAgentForwardRuntime,
6
+ type FridayAgentForwardRuntime,
7
+ } from "../../agent-forward-runtime.js";
3
8
  import { extractBearerToken } from "../middleware/auth.js";
4
9
 
5
10
  const DEFAULT_AGENT_ID = "main";
@@ -54,6 +59,60 @@ function readString(value: unknown): string | undefined {
54
59
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
55
60
  }
56
61
 
62
+ /** Unfilled IDENTITY.md template prompts that must not surface as a real name. */
63
+ const IDENTITY_NAME_PLACEHOLDERS = new Set(["pick something you like"]);
64
+
65
+ /**
66
+ * Extract the `Name` field from an agent's IDENTITY.md, mirroring OpenClaw's
67
+ * `parseIdentityMarkdown` (src/agents/identity-file.ts) for the name label only:
68
+ * drop the leading "- ", split on the first ":", strip markdown emphasis, and
69
+ * skip the unfilled template placeholder. Returns the raw value verbatim (e.g.
70
+ * "星期五 (Friday)") so it matches what ControlUI shows under "身份名称".
71
+ */
72
+ export function parseIdentityNameFromMarkdown(content: string): string | undefined {
73
+ for (const rawLine of content.split(/\r?\n/)) {
74
+ const cleaned = rawLine.trim().replace(/^\s*-\s*/, "");
75
+ const colonIndex = cleaned.indexOf(":");
76
+ if (colonIndex === -1) continue;
77
+ const label = cleaned.slice(0, colonIndex).replace(/[*_`]/g, "").trim().toLowerCase();
78
+ if (label !== "name") continue;
79
+ const value = cleaned
80
+ .slice(colonIndex + 1)
81
+ .replace(/^[*_`\s]+|[*_`\s]+$/g, "")
82
+ .trim();
83
+ if (!value) continue;
84
+ let normalized = value.replace(/[–—]/g, "-");
85
+ if (normalized.startsWith("(") && normalized.endsWith(")")) {
86
+ normalized = normalized.slice(1, -1).trim();
87
+ }
88
+ if (IDENTITY_NAME_PLACEHOLDERS.has(normalized.toLowerCase())) continue;
89
+ return value;
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ /**
95
+ * Name fallback for agents with no `name`/`identity.name` in config (e.g. the
96
+ * implicit `main`): resolve the agent's workspace and parse its IDENTITY.md, the
97
+ * same source ControlUI reads. Best-effort — any failure yields undefined.
98
+ */
99
+ function readWorkspaceIdentityName(
100
+ rt: FridayAgentForwardRuntime,
101
+ cfg: unknown,
102
+ agentId: string,
103
+ ): string | undefined {
104
+ const resolveWorkspace = rt.resolveAgentWorkspaceDir;
105
+ if (!resolveWorkspace) return undefined;
106
+ try {
107
+ const workspace = resolveWorkspace(cfg, agentId);
108
+ if (!workspace) return undefined;
109
+ const content = fs.readFileSync(path.join(workspace, "IDENTITY.md"), "utf-8");
110
+ return parseIdentityNameFromMarkdown(content);
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ }
115
+
57
116
  /**
58
117
  * Reads the configured agents directly from the runtime config (same approach as
59
118
  * models-list.ts). When no agents are configured OpenClaw runs an implicit "main"
@@ -89,7 +148,10 @@ function resolveConfiguredAgents(): ResolvedAgents {
89
148
  const identity = agent.identity as Record<string, unknown> | undefined;
90
149
  entries.push({
91
150
  id,
92
- name: readString(agent.name) ?? readString(identity?.name),
151
+ name:
152
+ readString(agent.name) ??
153
+ readString(identity?.name) ??
154
+ readWorkspaceIdentityName(rt, cfg, id),
93
155
  description: readString(agent.description),
94
156
  model: resolvePrimaryModel(agent.model),
95
157
  thinkingDefault: readString(agent.thinkingDefault),
@@ -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
+ }