@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.
- package/dist/src/agent-forward-runtime.d.ts +2 -0
- package/dist/src/agent-forward-runtime.js +2 -0
- package/dist/src/channel-actions.js +5 -1
- package/dist/src/channel.js +11 -2
- package/dist/src/friday-session.js +3 -22
- package/dist/src/http/handlers/agents-list.d.ts +8 -0
- package/dist/src/http/handlers/agents-list.js +60 -2
- package/dist/src/http/handlers/history-messages.js +8 -0
- package/dist/src/media-fetch.d.ts +13 -0
- package/dist/src/media-fetch.js +53 -0
- package/dist/src/session-usage-store.d.ts +17 -0
- package/dist/src/session-usage-store.js +37 -0
- package/package.json +10 -11
- package/src/agent-forward-runtime.ts +4 -0
- package/src/channel-actions.test.ts +36 -1
- package/src/channel-actions.ts +5 -1
- package/src/channel.ts +12 -2
- package/src/friday-session.ts +3 -20
- package/src/http/handlers/agents-list.test.ts +52 -1
- package/src/http/handlers/agents-list.ts +64 -2
- package/src/http/handlers/history-messages.test.ts +39 -0
- package/src/http/handlers/history-messages.ts +9 -0
- package/src/media-fetch.test.ts +78 -0
- package/src/media-fetch.ts +60 -0
- package/src/session-usage-store.ts +42 -0
|
@@ -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" };
|
package/dist/src/channel.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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 =
|
|
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
|
|
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) ??
|
|
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.
|
|
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);
|
package/src/channel-actions.ts
CHANGED
|
@@ -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)}`;
|
package/src/friday-session.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { sseEmitter } from "./sse/emitter.js";
|
|
2
2
|
import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
|
|
3
|
-
import { toSessionStoreKey
|
|
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 =
|
|
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
|
|
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
|
|
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:
|
|
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
|
+
}
|