@syengup/friday-channel-next 0.0.1
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/README.md +35 -0
- package/index.ts +191 -0
- package/install.mjs +158 -0
- package/install.sh +118 -0
- package/openclaw.plugin.json +53 -0
- package/package.json +65 -0
- package/src/agent/abort-run.ts +10 -0
- package/src/agent/active-runs.ts +26 -0
- package/src/agent/dispatch-bridge.ts +18 -0
- package/src/agent/media-bridge.ts +23 -0
- package/src/agent-forward-runtime.ts +30 -0
- package/src/agent-run-context-bridge.ts +32 -0
- package/src/channel-actions.ts +129 -0
- package/src/channel.ts +284 -0
- package/src/collect-message-media-paths.ts +132 -0
- package/src/config.test.ts +33 -0
- package/src/config.ts +64 -0
- package/src/e2e/attachments-inbound.e2e.test.ts +43 -0
- package/src/e2e/attachments-outbound.e2e.test.ts +43 -0
- package/src/e2e/cancel-reconnect-errors.e2e.test.ts +56 -0
- package/src/e2e/connect-and-connected.e2e.test.ts +44 -0
- package/src/e2e/offline-replay.e2e.test.ts +43 -0
- package/src/e2e/send-text.e2e.test.ts +73 -0
- package/src/e2e/slash-commands.e2e.test.ts +33 -0
- package/src/e2e/status-cors-auth.e2e.test.ts +41 -0
- package/src/e2e/tool-lifecycle.e2e.test.ts +49 -0
- package/src/friday-inbound-stats.ts +10 -0
- package/src/friday-session.forward-agent.test.ts +270 -0
- package/src/friday-session.ts +327 -0
- package/src/host-config.ts +20 -0
- package/src/http/handlers/cancel.test.ts +70 -0
- package/src/http/handlers/cancel.ts +35 -0
- package/src/http/handlers/files-download.ts +239 -0
- package/src/http/handlers/files-upload.ts +166 -0
- package/src/http/handlers/files.ts +335 -0
- package/src/http/handlers/messages.test.ts +119 -0
- package/src/http/handlers/messages.ts +555 -0
- package/src/http/handlers/models-list.ts +126 -0
- package/src/http/handlers/sessions-delete.ts +59 -0
- package/src/http/handlers/sessions-settings.ts +90 -0
- package/src/http/handlers/sse.test.ts +71 -0
- package/src/http/handlers/sse.ts +84 -0
- package/src/http/handlers/status.test.ts +52 -0
- package/src/http/handlers/status.ts +33 -0
- package/src/http/middleware/auth.test.ts +46 -0
- package/src/http/middleware/auth.ts +31 -0
- package/src/http/middleware/body.test.ts +27 -0
- package/src/http/middleware/body.ts +28 -0
- package/src/http/middleware/cors.test.ts +40 -0
- package/src/http/middleware/cors.ts +12 -0
- package/src/http/server.ts +106 -0
- package/src/logging.ts +27 -0
- package/src/openclaw.d.ts +32 -0
- package/src/run-metadata.ts +180 -0
- package/src/runtime.ts +14 -0
- package/src/session/session-manager.ts +230 -0
- package/src/session-usage-snapshot.ts +80 -0
- package/src/sse/emitter.test.ts +85 -0
- package/src/sse/emitter.ts +249 -0
- package/src/sse/frame-format.test.ts +56 -0
- package/src/sse/offline-queue.test.ts +65 -0
- package/src/sse/offline-queue.ts +140 -0
- package/src/test-support/app-simulator.ts +243 -0
- package/src/test-support/mock-dispatch.ts +181 -0
- package/src/test-support/mock-runtime.ts +74 -0
- package/src/vendor/runtime-store.ts +99 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP server registration for the Friday channel.
|
|
3
|
+
*
|
|
4
|
+
* Registers routes on the gateway HTTP server under the /friday-next/ path prefix.
|
|
5
|
+
* Routes are registered via the plugin API's registerHttpRoute method.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
9
|
+
import { handleMessages } from "./handlers/messages.js";
|
|
10
|
+
import { handleSseStream } from "./handlers/sse.js";
|
|
11
|
+
import { handleFilesUpload } from "./handlers/files-upload.js";
|
|
12
|
+
import { handleFilesDownload } from "./handlers/files-download.js";
|
|
13
|
+
import { handleCancel } from "./handlers/cancel.js";
|
|
14
|
+
import { handleSessionsDelete } from "./handlers/sessions-delete.js";
|
|
15
|
+
import { handleSessionsSettings } from "./handlers/sessions-settings.js";
|
|
16
|
+
import { handleModelsList } from "./handlers/models-list.js";
|
|
17
|
+
import { handleStatus } from "./handlers/status.js";
|
|
18
|
+
import { applyCorsHeaders } from "./middleware/cors.js";
|
|
19
|
+
import { resolveFridayNextConfig } from "../config.js";
|
|
20
|
+
import { getHostOpenClawConfigSnapshot } from "../host-config.js";
|
|
21
|
+
import { getFridayNextRuntime } from "../runtime.js";
|
|
22
|
+
import { sseEmitter } from "../sse/emitter.js";
|
|
23
|
+
|
|
24
|
+
/** Route matcher - returns the matched handler or null. */
|
|
25
|
+
async function handleFridayNextRoute(
|
|
26
|
+
req: IncomingMessage,
|
|
27
|
+
res: ServerResponse,
|
|
28
|
+
): Promise<boolean> {
|
|
29
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
30
|
+
const pathname = url.pathname;
|
|
31
|
+
applyCorsHeaders(res);
|
|
32
|
+
if (req.method === "OPTIONS") {
|
|
33
|
+
res.statusCode = 204;
|
|
34
|
+
res.end();
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Route: GET /friday-next/events?deviceId=...
|
|
39
|
+
if (req.method === "GET" && pathname === "/friday-next/events") {
|
|
40
|
+
return await handleSseStream(req, res);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Route: POST /friday-next/messages
|
|
44
|
+
if (req.method === "POST" && pathname === "/friday-next/messages") {
|
|
45
|
+
return await handleMessages(req, res);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Route: POST /friday-next/files (multipart upload)
|
|
49
|
+
if (req.method === "POST" && pathname === "/friday-next/files") {
|
|
50
|
+
return await handleFilesUpload(req, res);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Route: GET /friday-next/files/:id (download)
|
|
54
|
+
if (req.method === "GET" && pathname.startsWith("/friday-next/files/")) {
|
|
55
|
+
return await handleFilesDownload(req, res);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (req.method === "POST" && pathname === "/friday-next/cancel") {
|
|
59
|
+
return await handleCancel(req, res);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (req.method === "DELETE" && pathname === "/friday-next/sessions") {
|
|
63
|
+
return await handleSessionsDelete(req, res);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if ((req.method === "PUT" || req.method === "GET") && pathname === "/friday-next/sessions/settings") {
|
|
67
|
+
return await handleSessionsSettings(req, res);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (req.method === "GET" && pathname === "/friday-next/models") {
|
|
71
|
+
return await handleModelsList(req, res);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (req.method === "GET" && pathname === "/friday-next/status") {
|
|
75
|
+
return await handleStatus(req, res);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Not found
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function registerFridayNextHttpRoutes(api: {
|
|
83
|
+
logger: { info: (msg: string) => void; warn: (msg: string) => void };
|
|
84
|
+
registerHttpRoute: (route: {
|
|
85
|
+
path: string;
|
|
86
|
+
handler: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
|
87
|
+
auth: string;
|
|
88
|
+
match: string;
|
|
89
|
+
}) => void;
|
|
90
|
+
}): void {
|
|
91
|
+
const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
|
|
92
|
+
sseEmitter.setBacklogLimit(cfg.sseBacklogPerDevice);
|
|
93
|
+
if (!cfg.authToken) {
|
|
94
|
+
api.logger.warn("friday-next authToken not configured; all requests will 401");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Plugin handles its own auth via extractBearerToken()
|
|
98
|
+
api.registerHttpRoute({
|
|
99
|
+
path: "/friday-next",
|
|
100
|
+
handler: handleFridayNextRoute,
|
|
101
|
+
auth: "plugin",
|
|
102
|
+
match: "prefix",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
api.logger.info("Friday Next channel HTTP routes registered at /friday-next/*");
|
|
106
|
+
}
|
package/src/logging.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { FridayNextLogLevel } from "./config.js";
|
|
2
|
+
|
|
3
|
+
const levelOrder: Record<FridayNextLogLevel, number> = {
|
|
4
|
+
debug: 10,
|
|
5
|
+
info: 20,
|
|
6
|
+
warn: 30,
|
|
7
|
+
error: 40,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function createFridayNextLogger(scope: string, level: FridayNextLogLevel = "info") {
|
|
11
|
+
const base = `[friday-next:${scope}]`;
|
|
12
|
+
const enabled = (current: FridayNextLogLevel) => levelOrder[current] >= levelOrder[level];
|
|
13
|
+
return {
|
|
14
|
+
debug: (message: string) => {
|
|
15
|
+
if (enabled("debug")) console.debug(`${base} ${message}`);
|
|
16
|
+
},
|
|
17
|
+
info: (message: string) => {
|
|
18
|
+
if (enabled("info")) console.info(`${base} ${message}`);
|
|
19
|
+
},
|
|
20
|
+
warn: (message: string) => {
|
|
21
|
+
if (enabled("warn")) console.warn(`${base} ${message}`);
|
|
22
|
+
},
|
|
23
|
+
error: (message: string) => {
|
|
24
|
+
if (enabled("error")) console.error(`${base} ${message}`);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
declare module "openclaw/plugin-sdk/agent-harness" {
|
|
2
|
+
export const abortAgentHarnessRun: (runId: string) => void;
|
|
3
|
+
export const runAgentHarness: (...args: any[]) => any;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
declare module "openclaw/plugin-sdk/core" {
|
|
7
|
+
export const defineChannelPluginEntry: (...args: any[]) => any;
|
|
8
|
+
export const createChatChannelPlugin: (...args: any[]) => any;
|
|
9
|
+
export type ChannelPlugin = any;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
declare module "openclaw/plugin-sdk/media-store" {
|
|
13
|
+
export const saveMediaBuffer: (...args: any[]) => any;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
declare module "openclaw/plugin-sdk/plugin-entry" {
|
|
17
|
+
export type OpenClawPluginApi = any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare module "openclaw/plugin-sdk/plugins/types" {
|
|
21
|
+
export type PluginHookBeforeToolCallEvent = any;
|
|
22
|
+
export type PluginHookAfterToolCallEvent = any;
|
|
23
|
+
export type PluginHookToolContext = any;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
declare module "openclaw/plugin-sdk/reply-dispatch-runtime" {
|
|
27
|
+
export const dispatchReplyWithDispatcher: (...args: any[]) => any;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
declare module "openclaw/plugin-sdk/status-helpers" {
|
|
31
|
+
export type ChannelAccountSnapshot = any;
|
|
32
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
type RunRoute = {
|
|
2
|
+
runId: string;
|
|
3
|
+
deviceId: string;
|
|
4
|
+
sessionKey: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type RunMetadata = {
|
|
8
|
+
modelName?: string;
|
|
9
|
+
totalTokens?: number;
|
|
10
|
+
/** Tokens counted toward the model context window (prompt-side: input + cache read + cache write when present). */
|
|
11
|
+
contextTokensUsed?: number;
|
|
12
|
+
/** Resolved model context window limit when the runtime exposes it. */
|
|
13
|
+
contextWindowMax?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const runRouteById = new Map<string, RunRoute>();
|
|
17
|
+
const runMetadataById = new Map<string, RunMetadata>();
|
|
18
|
+
const finalDeliveredRunIds = new Set<string>();
|
|
19
|
+
|
|
20
|
+
/** Vitest / harness: clears per-run metadata and final-delivered flags (not routes). */
|
|
21
|
+
export function resetRunMetadataForTest(): void {
|
|
22
|
+
runMetadataById.clear();
|
|
23
|
+
finalDeliveredRunIds.clear();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function registerRunRoute(route: RunRoute): void {
|
|
27
|
+
if (!route.runId.trim()) return;
|
|
28
|
+
runRouteById.set(route.runId, route);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getRunRoute(runId: string): RunRoute | undefined {
|
|
32
|
+
return runRouteById.get(runId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function setRunMetadata(runId: string, metadata: RunMetadata): void {
|
|
36
|
+
if (!runId.trim()) return;
|
|
37
|
+
const existing = runMetadataById.get(runId) ?? {};
|
|
38
|
+
runMetadataById.set(runId, { ...existing, ...metadata });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getRunMetadata(runId: string): RunMetadata | undefined {
|
|
42
|
+
return runMetadataById.get(runId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function markRunFinalDelivered(runId: string): void {
|
|
46
|
+
if (!runId.trim()) return;
|
|
47
|
+
finalDeliveredRunIds.add(runId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function hasRunFinalDelivered(runId: string): boolean {
|
|
51
|
+
return finalDeliveredRunIds.has(runId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
55
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function recordValue(value: unknown): Record<string, unknown> | null {
|
|
60
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
61
|
+
return value as Record<string, unknown>;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pickInputTokens(u: Record<string, unknown>): number | undefined {
|
|
67
|
+
return (
|
|
68
|
+
finiteNumber(u.input) ??
|
|
69
|
+
finiteNumber(u.inputTokens) ??
|
|
70
|
+
finiteNumber(u.input_tokens) ??
|
|
71
|
+
finiteNumber(u.promptTokens) ??
|
|
72
|
+
finiteNumber(u.prompt_tokens) ??
|
|
73
|
+
finiteNumber(u.prompt_n)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pickOutputTokens(u: Record<string, unknown>): number | undefined {
|
|
78
|
+
return (
|
|
79
|
+
finiteNumber(u.output) ??
|
|
80
|
+
finiteNumber(u.outputTokens) ??
|
|
81
|
+
finiteNumber(u.output_tokens) ??
|
|
82
|
+
finiteNumber(u.completionTokens) ??
|
|
83
|
+
finiteNumber(u.completion_tokens) ??
|
|
84
|
+
finiteNumber(u.predicted_n)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pickCacheRead(u: Record<string, unknown>): number | undefined {
|
|
89
|
+
const inDet = recordValue(u.input_tokens_details);
|
|
90
|
+
const prDet = recordValue(u.prompt_tokens_details);
|
|
91
|
+
return (
|
|
92
|
+
finiteNumber(u.cacheRead) ??
|
|
93
|
+
finiteNumber(u.cache_read) ??
|
|
94
|
+
finiteNumber(u.cache_read_input_tokens) ??
|
|
95
|
+
finiteNumber(u.cached_tokens) ??
|
|
96
|
+
(inDet ? finiteNumber(inDet.cached_tokens) : undefined) ??
|
|
97
|
+
(prDet ? finiteNumber(prDet.cached_tokens) : undefined)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function pickCacheWrite(u: Record<string, unknown>): number | undefined {
|
|
102
|
+
return (
|
|
103
|
+
finiteNumber(u.cacheWrite) ??
|
|
104
|
+
finiteNumber(u.cache_write) ??
|
|
105
|
+
finiteNumber(u.cache_creation_input_tokens)
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Best-effort prompt-side context footprint from a provider usage object. */
|
|
110
|
+
export function contextTokensFromUsageRecord(u: Record<string, unknown>): number | undefined {
|
|
111
|
+
const inp = pickInputTokens(u);
|
|
112
|
+
const cr = pickCacheRead(u);
|
|
113
|
+
const cw = pickCacheWrite(u);
|
|
114
|
+
const total =
|
|
115
|
+
finiteNumber(u.total) ?? finiteNumber(u.total_tokens) ?? finiteNumber(u.totalTokens);
|
|
116
|
+
const out = pickOutputTokens(u);
|
|
117
|
+
if (inp !== undefined || cr !== undefined || cw !== undefined) {
|
|
118
|
+
return Math.max(0, Math.floor((inp ?? 0) + (cr ?? 0) + (cw ?? 0)));
|
|
119
|
+
}
|
|
120
|
+
if (total !== undefined && out !== undefined && total >= out) {
|
|
121
|
+
return Math.max(0, Math.floor(total - out));
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function pickContextWindowMaxFromData(data: Record<string, unknown>): number | undefined {
|
|
127
|
+
const v =
|
|
128
|
+
finiteNumber(data.contextWindow) ??
|
|
129
|
+
finiteNumber(data.context_window) ??
|
|
130
|
+
finiteNumber(data.maxContextTokens) ??
|
|
131
|
+
finiteNumber(data.max_context_tokens);
|
|
132
|
+
if (typeof v === "number" && v > 0) return Math.floor(v);
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function ingestAgentEventMetadata(runId: string, data: Record<string, unknown>): void {
|
|
137
|
+
if (!runId.trim()) return;
|
|
138
|
+
const next: RunMetadata = {};
|
|
139
|
+
const modelName =
|
|
140
|
+
(typeof data.modelName === "string" && data.modelName.trim()) ||
|
|
141
|
+
(typeof data.model === "string" && data.model.trim()) ||
|
|
142
|
+
undefined;
|
|
143
|
+
if (modelName) next.modelName = modelName;
|
|
144
|
+
|
|
145
|
+
const usage = recordValue(data.usage);
|
|
146
|
+
const totalTokens =
|
|
147
|
+
finiteNumber(data.totalTokens) ??
|
|
148
|
+
finiteNumber(data.total_tokens) ??
|
|
149
|
+
finiteNumber(usage?.totalTokens) ??
|
|
150
|
+
finiteNumber(usage?.total_tokens) ??
|
|
151
|
+
finiteNumber(usage?.total);
|
|
152
|
+
if (typeof totalTokens === "number" && totalTokens > 0) {
|
|
153
|
+
next.totalTokens = Math.floor(totalTokens);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const usageForContext = usage ?? data;
|
|
157
|
+
const ctxUsed = contextTokensFromUsageRecord(usageForContext);
|
|
158
|
+
if (typeof ctxUsed === "number" && ctxUsed > 0) {
|
|
159
|
+
next.contextTokensUsed = ctxUsed;
|
|
160
|
+
}
|
|
161
|
+
const ctxMax = pickContextWindowMaxFromData(data);
|
|
162
|
+
if (typeof ctxMax === "number") {
|
|
163
|
+
next.contextWindowMax = ctxMax;
|
|
164
|
+
}
|
|
165
|
+
if (!next.contextWindowMax && usage) {
|
|
166
|
+
const fromUsage = pickContextWindowMaxFromData(usage);
|
|
167
|
+
if (typeof fromUsage === "number") {
|
|
168
|
+
next.contextWindowMax = fromUsage;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (
|
|
173
|
+
next.modelName ||
|
|
174
|
+
typeof next.totalTokens === "number" ||
|
|
175
|
+
typeof next.contextTokensUsed === "number" ||
|
|
176
|
+
typeof next.contextWindowMax === "number"
|
|
177
|
+
) {
|
|
178
|
+
setRunMetadata(runId, next);
|
|
179
|
+
}
|
|
180
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createPluginRuntimeStore } from "./vendor/runtime-store.js";
|
|
2
|
+
|
|
3
|
+
type FridayRuntime = {
|
|
4
|
+
config: { loadConfig: () => unknown };
|
|
5
|
+
logger?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void };
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const { setRuntime, getRuntime, clearRuntime } = createPluginRuntimeStore<FridayRuntime>(
|
|
9
|
+
"Friday Next runtime not initialized",
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
export const setFridayNextRuntime = setRuntime;
|
|
13
|
+
export const getFridayNextRuntime = getRuntime;
|
|
14
|
+
export const clearFridayNextRuntime = clearRuntime;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const FRIDAY_AGENT_ID = "main";
|
|
6
|
+
const SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
|
|
7
|
+
|
|
8
|
+
function deriveOpenClawBaseDir(historyDir?: string): string {
|
|
9
|
+
if (historyDir) {
|
|
10
|
+
const match = historyDir.replace(/\/+$/, "").match(/(.*\/\.openclaw)\//);
|
|
11
|
+
if (match?.[1]) return match[1];
|
|
12
|
+
}
|
|
13
|
+
return join(os.homedir(), ".openclaw");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function splitModelRef(modelRef: string): { provider?: string; modelId: string } {
|
|
17
|
+
const slashIdx = modelRef.indexOf("/");
|
|
18
|
+
if (slashIdx > 0) {
|
|
19
|
+
return { provider: modelRef.slice(0, slashIdx), modelId: modelRef.slice(slashIdx + 1) };
|
|
20
|
+
}
|
|
21
|
+
return { modelId: modelRef };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function toSessionStoreKey(rawSessionKey: string): string {
|
|
25
|
+
const raw = rawSessionKey.trim();
|
|
26
|
+
const lowered = raw.trim().toLowerCase();
|
|
27
|
+
if (!raw || lowered === "main") {
|
|
28
|
+
return `agent:${FRIDAY_AGENT_ID}:main`;
|
|
29
|
+
}
|
|
30
|
+
const parts = lowered.split(":").filter(Boolean);
|
|
31
|
+
if (parts.length >= 3 && parts[0] === "agent") {
|
|
32
|
+
const agentId = parts[1];
|
|
33
|
+
const rest = parts.slice(2).join(":");
|
|
34
|
+
if (agentId && rest) {
|
|
35
|
+
return `agent:${agentId}:${rest}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (lowered.startsWith("agent:")) {
|
|
39
|
+
return lowered;
|
|
40
|
+
}
|
|
41
|
+
return `agent:${FRIDAY_AGENT_ID}:${lowered}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toSafeSessionId(raw: string): string {
|
|
45
|
+
const s = raw.trim();
|
|
46
|
+
if (SESSION_ID_RE.test(s)) return s;
|
|
47
|
+
const slug = s
|
|
48
|
+
.replace(/[^a-z0-9._-]+/gi, "-")
|
|
49
|
+
.replace(/-+/g, "-")
|
|
50
|
+
.replace(/^[-._]+|[-._]+$/g, "");
|
|
51
|
+
const base = slug || "session";
|
|
52
|
+
const prefixed = /^[a-z0-9]/i.test(base) ? base : `s${base}`;
|
|
53
|
+
return prefixed.slice(0, 128);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sessionIdForSessionsFile(fileKey: string, rawSessionKey: string): string {
|
|
57
|
+
const candidates = [rawSessionKey.trim(), fileKey.trim()];
|
|
58
|
+
for (const c of candidates) {
|
|
59
|
+
if (SESSION_ID_RE.test(c)) return c;
|
|
60
|
+
if (c.startsWith(`agent:${FRIDAY_AGENT_ID}:`)) {
|
|
61
|
+
const tail = c.slice(`agent:${FRIDAY_AGENT_ID}:`.length);
|
|
62
|
+
if (SESSION_ID_RE.test(tail)) return tail;
|
|
63
|
+
return toSafeSessionId(tail);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return toSafeSessionId(rawSessionKey || fileKey);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveSessionsFilePath(historyDir?: string): string {
|
|
70
|
+
const base = deriveOpenClawBaseDir(historyDir);
|
|
71
|
+
return join(base, "agents/main/sessions/sessions.json");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readSessionsData(path: string): Record<string, Record<string, unknown>> | null {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, Record<string, unknown>>;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function writeSessionsData(path: string, data: Record<string, Record<string, unknown>>): void {
|
|
83
|
+
try {
|
|
84
|
+
writeFileSync(path, JSON.stringify(data, null, 2), "utf-8");
|
|
85
|
+
} catch {
|
|
86
|
+
// best-effort
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function upsertSessionEntry(
|
|
91
|
+
data: Record<string, Record<string, unknown>>,
|
|
92
|
+
fileKey: string,
|
|
93
|
+
sessionKey: string,
|
|
94
|
+
): void {
|
|
95
|
+
const safeSessionId = sessionIdForSessionsFile(fileKey, sessionKey);
|
|
96
|
+
if (!data[fileKey]) {
|
|
97
|
+
data[fileKey] = { sessionId: safeSessionId, updatedAt: Date.now(), systemSent: true };
|
|
98
|
+
}
|
|
99
|
+
const currentSessionId = data[fileKey]["sessionId"];
|
|
100
|
+
if (typeof currentSessionId !== "string" || !SESSION_ID_RE.test(currentSessionId)) {
|
|
101
|
+
data[fileKey]["sessionId"] = safeSessionId;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function ensureSessionLevels(
|
|
106
|
+
sessionKey: string,
|
|
107
|
+
reasoningLevel: string,
|
|
108
|
+
thinkingLevel: string,
|
|
109
|
+
historyDir?: string,
|
|
110
|
+
): void {
|
|
111
|
+
setSessionSettings(sessionKey, { reasoningLevel, thinkingLevel }, historyDir);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function resolveSessionsDir(historyDir?: string): string {
|
|
115
|
+
const base = deriveOpenClawBaseDir(historyDir);
|
|
116
|
+
return join(base, "agents/main/sessions");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface DeleteSessionResult {
|
|
120
|
+
sessionKey: string;
|
|
121
|
+
sessionId?: string;
|
|
122
|
+
transcriptDeleted?: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function deleteFridaySession(
|
|
126
|
+
sessionKey: string,
|
|
127
|
+
historyDir?: string,
|
|
128
|
+
): DeleteSessionResult {
|
|
129
|
+
const result: DeleteSessionResult = { sessionKey };
|
|
130
|
+
const sessionsFile = resolveSessionsFilePath(historyDir);
|
|
131
|
+
const data = readSessionsData(sessionsFile);
|
|
132
|
+
if (!data) return result;
|
|
133
|
+
|
|
134
|
+
const fileKey = toSessionStoreKey(sessionKey);
|
|
135
|
+
const entry = data[fileKey];
|
|
136
|
+
if (!entry) return result;
|
|
137
|
+
|
|
138
|
+
const sessionId = typeof entry["sessionId"] === "string" ? entry["sessionId"] : undefined;
|
|
139
|
+
const sessionFilePath = typeof entry["sessionFile"] === "string" ? entry["sessionFile"] : undefined;
|
|
140
|
+
result.sessionId = sessionId;
|
|
141
|
+
|
|
142
|
+
if (sessionFilePath) {
|
|
143
|
+
try { unlinkSync(sessionFilePath); result.transcriptDeleted = true; } catch { /* gone already */ }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (sessionId) {
|
|
147
|
+
const dir = resolveSessionsDir(historyDir);
|
|
148
|
+
for (const suffix of [".trajectory.jsonl", ".trajectory-path.json"]) {
|
|
149
|
+
try { unlinkSync(join(dir, `${sessionId}${suffix}`)); } catch { /* optional */ }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
delete data[fileKey];
|
|
154
|
+
writeSessionsData(sessionsFile, data);
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface FridaySessionSettings {
|
|
159
|
+
reasoningLevel?: string;
|
|
160
|
+
thinkingLevel?: string;
|
|
161
|
+
modelRef?: string;
|
|
162
|
+
providerOverride?: string;
|
|
163
|
+
modelOverride?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function setSessionSettings(
|
|
167
|
+
sessionKey: string,
|
|
168
|
+
settings: FridaySessionSettings,
|
|
169
|
+
historyDir?: string,
|
|
170
|
+
): FridaySessionSettings {
|
|
171
|
+
try {
|
|
172
|
+
const sessionsFile = resolveSessionsFilePath(historyDir);
|
|
173
|
+
const data = readSessionsData(sessionsFile);
|
|
174
|
+
if (!data) return {};
|
|
175
|
+
|
|
176
|
+
const fileKey = toSessionStoreKey(sessionKey);
|
|
177
|
+
upsertSessionEntry(data, fileKey, sessionKey);
|
|
178
|
+
|
|
179
|
+
const fieldKeys: (keyof FridaySessionSettings)[] = [
|
|
180
|
+
"reasoningLevel", "thinkingLevel", "modelRef", "providerOverride", "modelOverride",
|
|
181
|
+
];
|
|
182
|
+
let updated = false;
|
|
183
|
+
for (const key of fieldKeys) {
|
|
184
|
+
const value = settings[key];
|
|
185
|
+
if (value !== undefined && data[fileKey][key] !== value) {
|
|
186
|
+
data[fileKey][key] = value;
|
|
187
|
+
updated = true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (updated) {
|
|
192
|
+
writeSessionsData(sessionsFile, data);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return readSettingsFromEntry(data[fileKey]);
|
|
196
|
+
} catch {
|
|
197
|
+
return {};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function readSettingsFromEntry(entry: Record<string, unknown>): FridaySessionSettings {
|
|
202
|
+
const provider = typeof entry["providerOverride"] === "string" ? entry["providerOverride"] : undefined;
|
|
203
|
+
const model = typeof entry["modelOverride"] === "string" ? entry["modelOverride"] : undefined;
|
|
204
|
+
const storedModelRef = typeof entry["modelRef"] === "string" ? entry["modelRef"] : undefined;
|
|
205
|
+
const modelRef = storedModelRef ?? (provider && model ? `${provider}/${model}` : undefined);
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
reasoningLevel: typeof entry["reasoningLevel"] === "string" ? entry["reasoningLevel"] : undefined,
|
|
209
|
+
thinkingLevel: typeof entry["thinkingLevel"] === "string" ? entry["thinkingLevel"] : undefined,
|
|
210
|
+
modelRef,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function getSessionSettings(
|
|
215
|
+
sessionKey: string,
|
|
216
|
+
historyDir?: string,
|
|
217
|
+
): FridaySessionSettings {
|
|
218
|
+
try {
|
|
219
|
+
const sessionsFile = resolveSessionsFilePath(historyDir);
|
|
220
|
+
const data = readSessionsData(sessionsFile);
|
|
221
|
+
if (!data) return {};
|
|
222
|
+
|
|
223
|
+
const fileKey = toSessionStoreKey(sessionKey);
|
|
224
|
+
const entry = data[fileKey];
|
|
225
|
+
if (!entry) return {};
|
|
226
|
+
return readSettingsFromEntry(entry);
|
|
227
|
+
} catch {
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable DTO for Friday SSE `lifecycle` terminal frames (`data.sessionUsage`).
|
|
3
|
+
* Populated from OpenClaw `SessionEntry` after `persistSessionUsageUpdate`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type FridaySessionUsagePayload = {
|
|
7
|
+
modelId?: string;
|
|
8
|
+
modelProvider?: string;
|
|
9
|
+
tokens?: {
|
|
10
|
+
input?: number;
|
|
11
|
+
output?: number;
|
|
12
|
+
cacheRead?: number;
|
|
13
|
+
cacheWrite?: number;
|
|
14
|
+
total?: number;
|
|
15
|
+
totalFresh?: boolean;
|
|
16
|
+
};
|
|
17
|
+
context?: {
|
|
18
|
+
windowMax?: number;
|
|
19
|
+
used?: number;
|
|
20
|
+
};
|
|
21
|
+
estimatedCostUsd?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function finiteNonNeg(n: unknown): number | undefined {
|
|
25
|
+
if (typeof n !== "number" || !Number.isFinite(n)) return undefined;
|
|
26
|
+
const t = Math.trunc(n);
|
|
27
|
+
return t >= 0 ? t : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function finiteCost(n: unknown): number | undefined {
|
|
31
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return undefined;
|
|
32
|
+
return n;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Build a compact snapshot from a loaded session store entry (unknown shape). */
|
|
36
|
+
export function buildSessionUsageSnapshot(entry: Record<string, unknown>): FridaySessionUsagePayload | undefined {
|
|
37
|
+
const payload: FridaySessionUsagePayload = {};
|
|
38
|
+
|
|
39
|
+
const modelId = typeof entry.model === "string" ? entry.model.trim() : "";
|
|
40
|
+
if (modelId) payload.modelId = modelId;
|
|
41
|
+
|
|
42
|
+
const modelProvider = typeof entry.modelProvider === "string" ? entry.modelProvider.trim() : "";
|
|
43
|
+
if (modelProvider) payload.modelProvider = modelProvider;
|
|
44
|
+
|
|
45
|
+
const tokens: NonNullable<FridaySessionUsagePayload["tokens"]> = {};
|
|
46
|
+
const input = finiteNonNeg(entry.inputTokens);
|
|
47
|
+
const output = finiteNonNeg(entry.outputTokens);
|
|
48
|
+
const cacheRead = finiteNonNeg(entry.cacheRead);
|
|
49
|
+
const cacheWrite = finiteNonNeg(entry.cacheWrite);
|
|
50
|
+
const total = finiteNonNeg(entry.totalTokens);
|
|
51
|
+
if (input !== undefined) tokens.input = input;
|
|
52
|
+
if (output !== undefined) tokens.output = output;
|
|
53
|
+
if (cacheRead !== undefined) tokens.cacheRead = cacheRead;
|
|
54
|
+
if (cacheWrite !== undefined) tokens.cacheWrite = cacheWrite;
|
|
55
|
+
if (total !== undefined) tokens.total = total;
|
|
56
|
+
if (entry.totalTokensFresh === true) tokens.totalFresh = true;
|
|
57
|
+
if (Object.keys(tokens).length > 0) payload.tokens = tokens;
|
|
58
|
+
|
|
59
|
+
const context: NonNullable<FridaySessionUsagePayload["context"]> = {};
|
|
60
|
+
const windowMax = finiteNonNeg(entry.contextTokens);
|
|
61
|
+
const used = finiteNonNeg(entry.totalTokens);
|
|
62
|
+
if (windowMax !== undefined) context.windowMax = windowMax;
|
|
63
|
+
if (used !== undefined) context.used = used;
|
|
64
|
+
if (Object.keys(context).length > 0) payload.context = context;
|
|
65
|
+
|
|
66
|
+
const cost = finiteCost(entry.estimatedCostUsd);
|
|
67
|
+
if (cost !== undefined) payload.estimatedCostUsd = cost;
|
|
68
|
+
|
|
69
|
+
if (
|
|
70
|
+
!payload.modelId &&
|
|
71
|
+
!payload.modelProvider &&
|
|
72
|
+
!payload.tokens &&
|
|
73
|
+
!payload.context &&
|
|
74
|
+
payload.estimatedCostUsd === undefined
|
|
75
|
+
) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return payload;
|
|
80
|
+
}
|