@syengup/friday-channel-next 0.1.11 → 0.1.13
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.js +2 -1
- package/dist/src/friday-session.js +3 -4
- package/dist/src/http/handlers/messages.js +21 -6
- package/dist/src/session/session-manager.d.ts +6 -0
- package/dist/src/session/session-manager.js +21 -9
- package/dist/src/sse/offline-queue.js +3 -3
- package/package.json +1 -1
- package/src/channel.ts +2 -1
- package/src/friday-session.ts +3 -5
- package/src/http/handlers/messages.ts +31 -3
- package/src/session/session-manager.test.ts +90 -0
- package/src/session/session-manager.ts +22 -9
- package/src/sse/offline-queue.ts +3 -3
package/dist/src/channel.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import crypto from "node:crypto";
|
|
7
7
|
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
8
9
|
import path from "node:path";
|
|
9
10
|
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
10
11
|
import { createFridayNextLogger } from "./logging.js";
|
|
@@ -27,7 +28,7 @@ function pickFirstString(source, keys) {
|
|
|
27
28
|
function resolveLocalMediaPath(mediaUrl, localRoots) {
|
|
28
29
|
if (path.isAbsolute(mediaUrl))
|
|
29
30
|
return mediaUrl;
|
|
30
|
-
const roots = localRoots ?? [process.cwd(),
|
|
31
|
+
const roots = localRoots ?? [process.cwd(), os.tmpdir()];
|
|
31
32
|
for (const root of roots) {
|
|
32
33
|
const candidate = path.join(root, mediaUrl);
|
|
33
34
|
if (fs.existsSync(candidate))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { sseEmitter } from "./sse/emitter.js";
|
|
2
2
|
import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
|
|
3
|
-
import { toSessionStoreKey } from "./session/session-manager.js";
|
|
3
|
+
import { toSessionStoreKey, agentIdFromSessionKey } 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";
|
|
@@ -118,7 +118,6 @@ export function resolveHistorySessionKeyForFridayDevice(deviceId) {
|
|
|
118
118
|
return fromMemory;
|
|
119
119
|
return `agent:main:friday-next-${did}`;
|
|
120
120
|
}
|
|
121
|
-
const DEFAULT_SESSION_STORE_AGENT_ID = "main";
|
|
122
121
|
function mergeRunMetadataIntoLifecycleEnd(runId, base) {
|
|
123
122
|
const meta = getRunMetadata(runId);
|
|
124
123
|
if (!meta)
|
|
@@ -151,9 +150,9 @@ function tryReadSessionUsageFromStore(sessionKeyForStore) {
|
|
|
151
150
|
try {
|
|
152
151
|
const cfg = access.getConfig();
|
|
153
152
|
const storeConfig = cfg?.session?.store;
|
|
154
|
-
const storePath = access.resolveStorePath(storeConfig, { agentId: DEFAULT_SESSION_STORE_AGENT_ID });
|
|
155
|
-
const store = access.loadSessionStore(storePath, { skipCache: true });
|
|
156
153
|
const canonical = toSessionStoreKey(sessionKeyForStore);
|
|
154
|
+
const storePath = access.resolveStorePath(storeConfig, { agentId: agentIdFromSessionKey(canonical) });
|
|
155
|
+
const store = access.loadSessionStore(storePath, { skipCache: true });
|
|
157
156
|
const entry = store[canonical] ?? store[sessionKeyForStore.trim()];
|
|
158
157
|
if (!entry || typeof entry !== "object")
|
|
159
158
|
return undefined;
|
|
@@ -14,7 +14,7 @@ import { resolveFridayNextConfig } from "../../config.js";
|
|
|
14
14
|
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
15
15
|
import { getFridayNextRuntime } from "../../runtime.js";
|
|
16
16
|
import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
|
|
17
|
-
import { setSessionSettings, splitModelRef, toSessionStoreKey } from "../../session/session-manager.js";
|
|
17
|
+
import { agentIdFromSessionKey, setSessionSettings, splitModelRef, toSessionStoreKey, } from "../../session/session-manager.js";
|
|
18
18
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
19
19
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
20
20
|
import { readJsonBody } from "../middleware/body.js";
|
|
@@ -315,6 +315,10 @@ export async function handleMessages(req, res) {
|
|
|
315
315
|
log("MESSAGE_RECEIVED", normalizedDeviceId, runId, `textLen=${trimmedText.length} attachments=${attachments.length} sessionKey=${baseSessionKey}`);
|
|
316
316
|
const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(runtime.config));
|
|
317
317
|
// Resolve defaults from the OpenClaw agent config so settings are never left empty.
|
|
318
|
+
// The target agent comes from the app-supplied sessionKey (`agent:<id>:<rest>`); prefer that
|
|
319
|
+
// agent's own configured model/thinking over the global defaults so non-main agents are not
|
|
320
|
+
// silently forced onto the global default model.
|
|
321
|
+
const targetAgentId = agentIdFromSessionKey(baseSessionKey);
|
|
318
322
|
let defaultModel;
|
|
319
323
|
let defaultThinking;
|
|
320
324
|
try {
|
|
@@ -322,13 +326,24 @@ export async function handleMessages(req, res) {
|
|
|
322
326
|
if (forwardRt) {
|
|
323
327
|
const ocCfg = (forwardRt.getConfig() ?? {});
|
|
324
328
|
const agents = ocCfg.agents;
|
|
329
|
+
const agentEntry = agents?.list?.find((a) => agentIdFromSessionKey(`agent:${String(a?.id ?? "")}:x`) === targetAgentId);
|
|
330
|
+
const agentModel = agentEntry?.model;
|
|
331
|
+
const perAgentModel = typeof agentModel === "string"
|
|
332
|
+
? agentModel
|
|
333
|
+
: typeof agentModel?.primary === "string"
|
|
334
|
+
? agentModel.primary
|
|
335
|
+
: undefined;
|
|
336
|
+
const perAgentThinking = typeof agentEntry?.thinkingDefault === "string"
|
|
337
|
+
? agentEntry.thinkingDefault
|
|
338
|
+
: undefined;
|
|
325
339
|
const agentDefaults = agents?.defaults;
|
|
326
340
|
const model = agentDefaults?.model;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
341
|
+
const globalModel = typeof model?.primary === "string" ? model.primary : undefined;
|
|
342
|
+
const globalThinking = typeof agentDefaults?.thinkingDefault === "string"
|
|
343
|
+
? agentDefaults.thinkingDefault
|
|
344
|
+
: undefined;
|
|
345
|
+
defaultModel = perAgentModel ?? globalModel;
|
|
346
|
+
defaultThinking = perAgentThinking ?? globalThinking;
|
|
332
347
|
}
|
|
333
348
|
}
|
|
334
349
|
catch {
|
|
@@ -3,6 +3,12 @@ export declare function splitModelRef(modelRef: string): {
|
|
|
3
3
|
modelId: string;
|
|
4
4
|
};
|
|
5
5
|
export declare function toSessionStoreKey(rawSessionKey: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Extract the agent id from a (possibly raw) session key. The downstream app now owns the
|
|
8
|
+
* full `agent:<id>:<rest>` key, so non-`main` agents must read/write their own session store
|
|
9
|
+
* directory. `agent:<id>:<rest>` → `<id>`; bare/legacy keys (or an unsafe id) → `main`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function agentIdFromSessionKey(rawSessionKey: string): string;
|
|
6
12
|
export declare function ensureSessionLevels(sessionKey: string, reasoningLevel: string, thinkingLevel: string, historyDir?: string): void;
|
|
7
13
|
export interface FridaySessionSettings {
|
|
8
14
|
reasoningLevel?: string;
|
|
@@ -3,9 +3,11 @@ import os from "node:os";
|
|
|
3
3
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
const FRIDAY_AGENT_ID = "main";
|
|
5
5
|
const SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
|
|
6
|
+
/** Path/shell-safe agent id (mirrors OpenClaw's `normalizeAgentId`). Anything else falls back to `main`. */
|
|
7
|
+
const SAFE_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
6
8
|
function deriveOpenClawBaseDir(historyDir) {
|
|
7
9
|
if (historyDir) {
|
|
8
|
-
const match = historyDir.replace(
|
|
10
|
+
const match = historyDir.replace(/[\\/]+$/, "").match(/(.*[\\/]\.openclaw)[\\/]/);
|
|
9
11
|
if (match?.[1])
|
|
10
12
|
return match[1];
|
|
11
13
|
}
|
|
@@ -37,6 +39,16 @@ export function toSessionStoreKey(rawSessionKey) {
|
|
|
37
39
|
}
|
|
38
40
|
return `agent:${FRIDAY_AGENT_ID}:${lowered}`;
|
|
39
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Extract the agent id from a (possibly raw) session key. The downstream app now owns the
|
|
44
|
+
* full `agent:<id>:<rest>` key, so non-`main` agents must read/write their own session store
|
|
45
|
+
* directory. `agent:<id>:<rest>` → `<id>`; bare/legacy keys (or an unsafe id) → `main`.
|
|
46
|
+
*/
|
|
47
|
+
export function agentIdFromSessionKey(rawSessionKey) {
|
|
48
|
+
const canonical = toSessionStoreKey(rawSessionKey);
|
|
49
|
+
const id = canonical.match(/^agent:([^:]+):/)?.[1];
|
|
50
|
+
return id && SAFE_AGENT_ID_RE.test(id) ? id : FRIDAY_AGENT_ID;
|
|
51
|
+
}
|
|
40
52
|
function toSafeSessionId(raw) {
|
|
41
53
|
const s = raw.trim();
|
|
42
54
|
if (SESSION_ID_RE.test(s))
|
|
@@ -54,8 +66,8 @@ function sessionIdForSessionsFile(fileKey, rawSessionKey) {
|
|
|
54
66
|
for (const c of candidates) {
|
|
55
67
|
if (SESSION_ID_RE.test(c))
|
|
56
68
|
return c;
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
const tail = c.match(/^agent:[^:]+:(.+)$/)?.[1];
|
|
70
|
+
if (tail) {
|
|
59
71
|
if (SESSION_ID_RE.test(tail))
|
|
60
72
|
return tail;
|
|
61
73
|
return toSafeSessionId(tail);
|
|
@@ -63,9 +75,9 @@ function sessionIdForSessionsFile(fileKey, rawSessionKey) {
|
|
|
63
75
|
}
|
|
64
76
|
return toSafeSessionId(rawSessionKey || fileKey);
|
|
65
77
|
}
|
|
66
|
-
function resolveSessionsFilePath(historyDir) {
|
|
78
|
+
function resolveSessionsFilePath(historyDir, agentId) {
|
|
67
79
|
const base = deriveOpenClawBaseDir(historyDir);
|
|
68
|
-
return join(base, "agents
|
|
80
|
+
return join(base, "agents", agentId, "sessions", "sessions.json");
|
|
69
81
|
}
|
|
70
82
|
function readSessionsData(path) {
|
|
71
83
|
try {
|
|
@@ -98,11 +110,11 @@ export function ensureSessionLevels(sessionKey, reasoningLevel, thinkingLevel, h
|
|
|
98
110
|
}
|
|
99
111
|
export function setSessionSettings(sessionKey, settings, historyDir) {
|
|
100
112
|
try {
|
|
101
|
-
const
|
|
113
|
+
const fileKey = toSessionStoreKey(sessionKey);
|
|
114
|
+
const sessionsFile = resolveSessionsFilePath(historyDir, agentIdFromSessionKey(fileKey));
|
|
102
115
|
const data = readSessionsData(sessionsFile);
|
|
103
116
|
if (!data)
|
|
104
117
|
return {};
|
|
105
|
-
const fileKey = toSessionStoreKey(sessionKey);
|
|
106
118
|
upsertSessionEntry(data, fileKey, sessionKey);
|
|
107
119
|
const fieldKeys = [
|
|
108
120
|
"reasoningLevel", "thinkingLevel", "modelRef", "providerOverride", "modelOverride",
|
|
@@ -137,11 +149,11 @@ function readSettingsFromEntry(entry) {
|
|
|
137
149
|
}
|
|
138
150
|
export function getSessionSettings(sessionKey, historyDir) {
|
|
139
151
|
try {
|
|
140
|
-
const
|
|
152
|
+
const fileKey = toSessionStoreKey(sessionKey);
|
|
153
|
+
const sessionsFile = resolveSessionsFilePath(historyDir, agentIdFromSessionKey(fileKey));
|
|
141
154
|
const data = readSessionsData(sessionsFile);
|
|
142
155
|
if (!data)
|
|
143
156
|
return {};
|
|
144
|
-
const fileKey = toSessionStoreKey(sessionKey);
|
|
145
157
|
const entry = data[fileKey];
|
|
146
158
|
if (!entry)
|
|
147
159
|
return {};
|
|
@@ -48,7 +48,7 @@ export class FridaySseOfflineQueue {
|
|
|
48
48
|
return 0;
|
|
49
49
|
let max = 0;
|
|
50
50
|
const content = fs.readFileSync(file, "utf8");
|
|
51
|
-
for (const line of content.split(
|
|
51
|
+
for (const line of content.split(/\r?\n/)) {
|
|
52
52
|
if (!line.trim())
|
|
53
53
|
continue;
|
|
54
54
|
try {
|
|
@@ -82,7 +82,7 @@ export class FridaySseOfflineQueue {
|
|
|
82
82
|
return [];
|
|
83
83
|
const out = [];
|
|
84
84
|
const content = fs.readFileSync(file, "utf8");
|
|
85
|
-
for (const line of content.split(
|
|
85
|
+
for (const line of content.split(/\r?\n/)) {
|
|
86
86
|
if (!line.trim())
|
|
87
87
|
continue;
|
|
88
88
|
try {
|
|
@@ -111,7 +111,7 @@ export class FridaySseOfflineQueue {
|
|
|
111
111
|
return;
|
|
112
112
|
const all = [];
|
|
113
113
|
const content = fs.readFileSync(file, "utf8");
|
|
114
|
-
for (const line of content.split(
|
|
114
|
+
for (const line of content.split(/\r?\n/)) {
|
|
115
115
|
if (!line.trim())
|
|
116
116
|
continue;
|
|
117
117
|
try {
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import crypto from "node:crypto";
|
|
8
8
|
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
11
12
|
import { createFridayNextLogger } from "./logging.js";
|
|
@@ -33,7 +34,7 @@ function pickFirstString(source: Record<string, unknown>, keys: string[]): strin
|
|
|
33
34
|
|
|
34
35
|
function resolveLocalMediaPath(mediaUrl: string, localRoots?: string[]): string {
|
|
35
36
|
if (path.isAbsolute(mediaUrl)) return mediaUrl;
|
|
36
|
-
const roots = localRoots ?? [process.cwd(),
|
|
37
|
+
const roots = localRoots ?? [process.cwd(), os.tmpdir()];
|
|
37
38
|
for (const root of roots) {
|
|
38
39
|
const candidate = path.join(root, mediaUrl);
|
|
39
40
|
if (fs.existsSync(candidate)) return candidate;
|
package/src/friday-session.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { sseEmitter } from "./sse/emitter.js";
|
|
2
2
|
import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
|
|
3
|
-
import { toSessionStoreKey } from "./session/session-manager.js";
|
|
3
|
+
import { toSessionStoreKey, agentIdFromSessionKey } 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";
|
|
@@ -138,8 +138,6 @@ export function resolveHistorySessionKeyForFridayDevice(deviceId: string): strin
|
|
|
138
138
|
return `agent:main:friday-next-${did}`;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
const DEFAULT_SESSION_STORE_AGENT_ID = "main";
|
|
142
|
-
|
|
143
141
|
type ForwardAgentEventArgs = {
|
|
144
142
|
runId: string;
|
|
145
143
|
seq?: number;
|
|
@@ -186,9 +184,9 @@ function tryReadSessionUsageFromStore(sessionKeyForStore: string): FridaySession
|
|
|
186
184
|
try {
|
|
187
185
|
const cfg = access.getConfig() as { session?: { store?: string } } | null | undefined;
|
|
188
186
|
const storeConfig = cfg?.session?.store;
|
|
189
|
-
const storePath = access.resolveStorePath(storeConfig, { agentId: DEFAULT_SESSION_STORE_AGENT_ID });
|
|
190
|
-
const store = access.loadSessionStore(storePath, { skipCache: true }) as Record<string, Record<string, unknown>>;
|
|
191
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>>;
|
|
192
190
|
const entry = store[canonical] ?? store[sessionKeyForStore.trim()];
|
|
193
191
|
if (!entry || typeof entry !== "object") return undefined;
|
|
194
192
|
return buildSessionUsageSnapshot(entry);
|
|
@@ -28,7 +28,12 @@ import { resolveFridayNextConfig } from "../../config.js";
|
|
|
28
28
|
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
29
29
|
import { getFridayNextRuntime } from "../../runtime.js";
|
|
30
30
|
import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
agentIdFromSessionKey,
|
|
33
|
+
setSessionSettings,
|
|
34
|
+
splitModelRef,
|
|
35
|
+
toSessionStoreKey,
|
|
36
|
+
} from "../../session/session-manager.js";
|
|
32
37
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
33
38
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
34
39
|
import { readJsonBody } from "../middleware/body.js";
|
|
@@ -421,6 +426,10 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
|
|
|
421
426
|
const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(runtime.config));
|
|
422
427
|
|
|
423
428
|
// Resolve defaults from the OpenClaw agent config so settings are never left empty.
|
|
429
|
+
// The target agent comes from the app-supplied sessionKey (`agent:<id>:<rest>`); prefer that
|
|
430
|
+
// agent's own configured model/thinking over the global defaults so non-main agents are not
|
|
431
|
+
// silently forced onto the global default model.
|
|
432
|
+
const targetAgentId = agentIdFromSessionKey(baseSessionKey);
|
|
424
433
|
let defaultModel: string | undefined;
|
|
425
434
|
let defaultThinking: string | undefined;
|
|
426
435
|
try {
|
|
@@ -428,13 +437,32 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
|
|
|
428
437
|
if (forwardRt) {
|
|
429
438
|
const ocCfg = (forwardRt.getConfig() ?? {}) as Record<string, unknown>;
|
|
430
439
|
const agents = ocCfg.agents as Record<string, unknown> | undefined;
|
|
440
|
+
|
|
441
|
+
const agentEntry = (agents?.list as Array<Record<string, unknown>> | undefined)?.find(
|
|
442
|
+
(a) => agentIdFromSessionKey(`agent:${String(a?.id ?? "")}:x`) === targetAgentId,
|
|
443
|
+
);
|
|
444
|
+
const agentModel = agentEntry?.model;
|
|
445
|
+
const perAgentModel =
|
|
446
|
+
typeof agentModel === "string"
|
|
447
|
+
? agentModel
|
|
448
|
+
: typeof (agentModel as Record<string, unknown> | undefined)?.primary === "string"
|
|
449
|
+
? ((agentModel as Record<string, unknown>).primary as string)
|
|
450
|
+
: undefined;
|
|
451
|
+
const perAgentThinking =
|
|
452
|
+
typeof agentEntry?.thinkingDefault === "string"
|
|
453
|
+
? (agentEntry.thinkingDefault as string)
|
|
454
|
+
: undefined;
|
|
455
|
+
|
|
431
456
|
const agentDefaults = agents?.defaults as Record<string, unknown> | undefined;
|
|
432
457
|
const model = agentDefaults?.model as Record<string, unknown> | undefined;
|
|
433
|
-
|
|
434
|
-
|
|
458
|
+
const globalModel = typeof model?.primary === "string" ? (model.primary as string) : undefined;
|
|
459
|
+
const globalThinking =
|
|
435
460
|
typeof agentDefaults?.thinkingDefault === "string"
|
|
436
461
|
? (agentDefaults.thinkingDefault as string)
|
|
437
462
|
: undefined;
|
|
463
|
+
|
|
464
|
+
defaultModel = perAgentModel ?? globalModel;
|
|
465
|
+
defaultThinking = perAgentThinking ?? globalThinking;
|
|
438
466
|
}
|
|
439
467
|
} catch {
|
|
440
468
|
// Config not available (tests) — leave defaults undefined.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import {
|
|
6
|
+
agentIdFromSessionKey,
|
|
7
|
+
toSessionStoreKey,
|
|
8
|
+
setSessionSettings,
|
|
9
|
+
getSessionSettings,
|
|
10
|
+
} from "./session-manager.js";
|
|
11
|
+
|
|
12
|
+
describe("agentIdFromSessionKey", () => {
|
|
13
|
+
it("extracts the agent id from a fully-qualified key", () => {
|
|
14
|
+
expect(agentIdFromSessionKey("agent:operator:friday:direct:abc:1")).toBe("operator");
|
|
15
|
+
expect(agentIdFromSessionKey("agent:ha-maestro:main")).toBe("ha-maestro");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("falls back to main for bare / legacy keys", () => {
|
|
19
|
+
expect(agentIdFromSessionKey("main")).toBe("main");
|
|
20
|
+
expect(agentIdFromSessionKey("friday:direct:dev:1")).toBe("main");
|
|
21
|
+
expect(agentIdFromSessionKey("")).toBe("main");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects path-unsafe agent ids (no traversal)", () => {
|
|
25
|
+
expect(agentIdFromSessionKey("agent:../../etc:foo")).toBe("main");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("per-agent session settings file routing", () => {
|
|
30
|
+
let baseDir: string;
|
|
31
|
+
let historyDir: string;
|
|
32
|
+
|
|
33
|
+
// historyDir must contain a `.openclaw` segment so deriveOpenClawBaseDir resolves the base.
|
|
34
|
+
function seedSessionsFile(agentId: string): string {
|
|
35
|
+
const dir = join(baseDir, ".openclaw", "agents", agentId, "sessions");
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
const file = join(dir, "sessions.json");
|
|
38
|
+
writeFileSync(file, JSON.stringify({}), "utf-8");
|
|
39
|
+
return file;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readEntry(agentId: string, fileKey: string): Record<string, unknown> | undefined {
|
|
43
|
+
const file = join(baseDir, ".openclaw", "agents", agentId, "sessions", "sessions.json");
|
|
44
|
+
const data = JSON.parse(readFileSync(file, "utf-8")) as Record<string, Record<string, unknown>>;
|
|
45
|
+
return data[fileKey];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
baseDir = mkdtempSync(join(tmpdir(), "friday-sm-"));
|
|
50
|
+
historyDir = join(baseDir, ".openclaw", "friday-next", "history");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("writes settings for a non-main agent into agents/<agentId>/sessions", () => {
|
|
58
|
+
seedSessionsFile("operator");
|
|
59
|
+
const sessionKey = "agent:operator:friday:direct:dev:1";
|
|
60
|
+
|
|
61
|
+
setSessionSettings(sessionKey, { reasoningLevel: "stream", thinkingLevel: "high" }, historyDir);
|
|
62
|
+
|
|
63
|
+
const entry = readEntry("operator", toSessionStoreKey(sessionKey));
|
|
64
|
+
expect(entry?.reasoningLevel).toBe("stream");
|
|
65
|
+
expect(entry?.thinkingLevel).toBe("high");
|
|
66
|
+
|
|
67
|
+
// Round-trips through getSessionSettings from the same per-agent file.
|
|
68
|
+
const read = getSessionSettings(sessionKey, historyDir);
|
|
69
|
+
expect(read.reasoningLevel).toBe("stream");
|
|
70
|
+
expect(read.thinkingLevel).toBe("high");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not leak a non-main agent's settings into the main store", () => {
|
|
74
|
+
seedSessionsFile("operator");
|
|
75
|
+
seedSessionsFile("main");
|
|
76
|
+
|
|
77
|
+
setSessionSettings("agent:operator:s1", { modelRef: "openai/gpt-x" }, historyDir);
|
|
78
|
+
|
|
79
|
+
expect(readEntry("operator", "agent:operator:s1")?.modelRef).toBe("openai/gpt-x");
|
|
80
|
+
expect(getSessionSettings("main", historyDir).modelRef).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("still routes bare/main keys to agents/main", () => {
|
|
84
|
+
seedSessionsFile("main");
|
|
85
|
+
|
|
86
|
+
setSessionSettings("main", { thinkingLevel: "low" }, historyDir);
|
|
87
|
+
|
|
88
|
+
expect(readEntry("main", "agent:main:main")?.thinkingLevel).toBe("low");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -4,10 +4,12 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
4
4
|
|
|
5
5
|
const FRIDAY_AGENT_ID = "main";
|
|
6
6
|
const SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
|
|
7
|
+
/** Path/shell-safe agent id (mirrors OpenClaw's `normalizeAgentId`). Anything else falls back to `main`. */
|
|
8
|
+
const SAFE_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
7
9
|
|
|
8
10
|
function deriveOpenClawBaseDir(historyDir?: string): string {
|
|
9
11
|
if (historyDir) {
|
|
10
|
-
const match = historyDir.replace(
|
|
12
|
+
const match = historyDir.replace(/[\\/]+$/, "").match(/(.*[\\/]\.openclaw)[\\/]/);
|
|
11
13
|
if (match?.[1]) return match[1];
|
|
12
14
|
}
|
|
13
15
|
return join(os.homedir(), ".openclaw");
|
|
@@ -41,6 +43,17 @@ export function toSessionStoreKey(rawSessionKey: string): string {
|
|
|
41
43
|
return `agent:${FRIDAY_AGENT_ID}:${lowered}`;
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Extract the agent id from a (possibly raw) session key. The downstream app now owns the
|
|
48
|
+
* full `agent:<id>:<rest>` key, so non-`main` agents must read/write their own session store
|
|
49
|
+
* directory. `agent:<id>:<rest>` → `<id>`; bare/legacy keys (or an unsafe id) → `main`.
|
|
50
|
+
*/
|
|
51
|
+
export function agentIdFromSessionKey(rawSessionKey: string): string {
|
|
52
|
+
const canonical = toSessionStoreKey(rawSessionKey);
|
|
53
|
+
const id = canonical.match(/^agent:([^:]+):/)?.[1];
|
|
54
|
+
return id && SAFE_AGENT_ID_RE.test(id) ? id : FRIDAY_AGENT_ID;
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
function toSafeSessionId(raw: string): string {
|
|
45
58
|
const s = raw.trim();
|
|
46
59
|
if (SESSION_ID_RE.test(s)) return s;
|
|
@@ -57,8 +70,8 @@ function sessionIdForSessionsFile(fileKey: string, rawSessionKey: string): strin
|
|
|
57
70
|
const candidates = [rawSessionKey.trim(), fileKey.trim()];
|
|
58
71
|
for (const c of candidates) {
|
|
59
72
|
if (SESSION_ID_RE.test(c)) return c;
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
const tail = c.match(/^agent:[^:]+:(.+)$/)?.[1];
|
|
74
|
+
if (tail) {
|
|
62
75
|
if (SESSION_ID_RE.test(tail)) return tail;
|
|
63
76
|
return toSafeSessionId(tail);
|
|
64
77
|
}
|
|
@@ -66,9 +79,9 @@ function sessionIdForSessionsFile(fileKey: string, rawSessionKey: string): strin
|
|
|
66
79
|
return toSafeSessionId(rawSessionKey || fileKey);
|
|
67
80
|
}
|
|
68
81
|
|
|
69
|
-
function resolveSessionsFilePath(historyDir
|
|
82
|
+
function resolveSessionsFilePath(historyDir: string | undefined, agentId: string): string {
|
|
70
83
|
const base = deriveOpenClawBaseDir(historyDir);
|
|
71
|
-
return join(base, "agents
|
|
84
|
+
return join(base, "agents", agentId, "sessions", "sessions.json");
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
function readSessionsData(path: string): Record<string, Record<string, unknown>> | null {
|
|
@@ -125,11 +138,11 @@ export function setSessionSettings(
|
|
|
125
138
|
historyDir?: string,
|
|
126
139
|
): FridaySessionSettings {
|
|
127
140
|
try {
|
|
128
|
-
const
|
|
141
|
+
const fileKey = toSessionStoreKey(sessionKey);
|
|
142
|
+
const sessionsFile = resolveSessionsFilePath(historyDir, agentIdFromSessionKey(fileKey));
|
|
129
143
|
const data = readSessionsData(sessionsFile);
|
|
130
144
|
if (!data) return {};
|
|
131
145
|
|
|
132
|
-
const fileKey = toSessionStoreKey(sessionKey);
|
|
133
146
|
upsertSessionEntry(data, fileKey, sessionKey);
|
|
134
147
|
|
|
135
148
|
const fieldKeys: (keyof FridaySessionSettings)[] = [
|
|
@@ -172,11 +185,11 @@ export function getSessionSettings(
|
|
|
172
185
|
historyDir?: string,
|
|
173
186
|
): FridaySessionSettings {
|
|
174
187
|
try {
|
|
175
|
-
const
|
|
188
|
+
const fileKey = toSessionStoreKey(sessionKey);
|
|
189
|
+
const sessionsFile = resolveSessionsFilePath(historyDir, agentIdFromSessionKey(fileKey));
|
|
176
190
|
const data = readSessionsData(sessionsFile);
|
|
177
191
|
if (!data) return {};
|
|
178
192
|
|
|
179
|
-
const fileKey = toSessionStoreKey(sessionKey);
|
|
180
193
|
const entry = data[fileKey];
|
|
181
194
|
if (!entry) return {};
|
|
182
195
|
return readSettingsFromEntry(entry);
|
package/src/sse/offline-queue.ts
CHANGED
|
@@ -55,7 +55,7 @@ export class FridaySseOfflineQueue {
|
|
|
55
55
|
if (!fs.existsSync(file)) return 0;
|
|
56
56
|
let max = 0;
|
|
57
57
|
const content = fs.readFileSync(file, "utf8");
|
|
58
|
-
for (const line of content.split(
|
|
58
|
+
for (const line of content.split(/\r?\n/)) {
|
|
59
59
|
if (!line.trim()) continue;
|
|
60
60
|
try {
|
|
61
61
|
const o = JSON.parse(line) as { id?: number };
|
|
@@ -87,7 +87,7 @@ export class FridaySseOfflineQueue {
|
|
|
87
87
|
if (!fs.existsSync(file)) return [];
|
|
88
88
|
const out: PersistedSseEntry[] = [];
|
|
89
89
|
const content = fs.readFileSync(file, "utf8");
|
|
90
|
-
for (const line of content.split(
|
|
90
|
+
for (const line of content.split(/\r?\n/)) {
|
|
91
91
|
if (!line.trim()) continue;
|
|
92
92
|
try {
|
|
93
93
|
const o = JSON.parse(line) as PersistedSseEntry;
|
|
@@ -115,7 +115,7 @@ export class FridaySseOfflineQueue {
|
|
|
115
115
|
if (!fs.existsSync(file)) return;
|
|
116
116
|
const all: PersistedSseEntry[] = [];
|
|
117
117
|
const content = fs.readFileSync(file, "utf8");
|
|
118
|
-
for (const line of content.split(
|
|
118
|
+
for (const line of content.split(/\r?\n/)) {
|
|
119
119
|
if (!line.trim()) continue;
|
|
120
120
|
try {
|
|
121
121
|
const o = JSON.parse(line) as PersistedSseEntry;
|