@syengup/friday-channel-next 0.1.22 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/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/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
|
@@ -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;
|
|
@@ -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
|
+
}
|
|
@@ -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;
|
|
@@ -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
|
+
}
|