@syengup/friday-channel-next 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agent-forward-runtime.d.ts +6 -0
- package/dist/src/agent-forward-runtime.js +2 -0
- package/dist/src/channel-actions.js +9 -1
- package/dist/src/channel.js +26 -4
- package/dist/src/history/normalize-message.d.ts +67 -0
- package/dist/src/history/normalize-message.js +224 -0
- package/dist/src/history/read-transcript.d.ts +22 -0
- package/dist/src/history/read-transcript.js +136 -0
- package/dist/src/http/handlers/files.d.ts +3 -2
- package/dist/src/http/handlers/files.js +20 -5
- package/dist/src/http/handlers/history-messages.d.ts +13 -0
- package/dist/src/http/handlers/history-messages.js +100 -0
- package/dist/src/http/handlers/history-sessions.d.ts +23 -0
- package/dist/src/http/handlers/history-sessions.js +163 -0
- package/dist/src/http/handlers/history-set-title.d.ts +10 -0
- package/dist/src/http/handlers/history-set-title.js +77 -0
- package/dist/src/http/handlers/messages.js +6 -38
- package/dist/src/http/handlers/sessions-settings.js +22 -7
- package/dist/src/http/server.js +15 -0
- package/dist/src/session/session-manager.d.ts +30 -1
- package/dist/src/session/session-manager.js +50 -1
- package/package.json +2 -2
- package/src/agent-forward-runtime.ts +10 -0
- package/src/channel-actions.test.ts +111 -0
- package/src/channel-actions.ts +10 -1
- package/src/channel.outbound-mirror-suppression.test.ts +36 -0
- package/src/channel.outbound.test.ts +137 -0
- package/src/channel.ts +33 -6
- package/src/history/normalize-message.test.ts +154 -0
- package/src/history/normalize-message.ts +292 -0
- package/src/history/read-transcript.ts +136 -0
- package/src/http/handlers/files.ts +21 -5
- package/src/http/handlers/history-messages.test.ts +144 -0
- package/src/http/handlers/history-messages.ts +123 -0
- package/src/http/handlers/history-sessions.test.ts +146 -0
- package/src/http/handlers/history-sessions.ts +184 -0
- package/src/http/handlers/history-set-title.test.ts +115 -0
- package/src/http/handlers/history-set-title.ts +86 -0
- package/src/http/handlers/messages.ts +8 -46
- package/src/http/handlers/sessions-settings.ts +23 -6
- package/src/http/server.ts +18 -0
- package/src/session/session-manager.test.ts +42 -0
- package/src/session/session-manager.ts +73 -3
- package/src/test-support/mock-runtime.ts +2 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /friday-next/history/messages?sessionKey=&agentId=&limit=
|
|
3
|
+
*
|
|
4
|
+
* Returns a session's transcript history as a flat, normalized message stream.
|
|
5
|
+
* The Friday app groups these into rounds itself (by role transitions) and uses
|
|
6
|
+
* each message's stable `id` (the upstream transcript entry id) as its sync key.
|
|
7
|
+
*
|
|
8
|
+
* Reads via the gateway `sessions.get` method (exposed to plugins as
|
|
9
|
+
* `runtime.subagent.getSessionMessages`), which already resolves the active
|
|
10
|
+
* branch and compaction, then normalizes each raw message into a stable DTO.
|
|
11
|
+
*/
|
|
12
|
+
import { getFridayNextRuntime } from "../../runtime.js";
|
|
13
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
14
|
+
import { normalizeHistoryMessages } from "../../history/normalize-message.js";
|
|
15
|
+
import { readSessionTranscriptRawMessages, resolveSessionId } from "../../history/read-transcript.js";
|
|
16
|
+
import { resolveMediaAttachment } from "./files.js";
|
|
17
|
+
const DEFAULT_LIMIT = 200;
|
|
18
|
+
const MAX_LIMIT = 1000;
|
|
19
|
+
function resolveSubagentApi() {
|
|
20
|
+
try {
|
|
21
|
+
const runtime = getFridayNextRuntime();
|
|
22
|
+
return runtime.subagent;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function handleHistoryMessages(req, res) {
|
|
29
|
+
if (req.method !== "GET") {
|
|
30
|
+
res.statusCode = 405;
|
|
31
|
+
res.setHeader("Content-Type", "application/json");
|
|
32
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const token = extractBearerToken(req);
|
|
36
|
+
if (!token) {
|
|
37
|
+
res.statusCode = 401;
|
|
38
|
+
res.setHeader("Content-Type", "application/json");
|
|
39
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
43
|
+
const sessionKey = url.searchParams.get("sessionKey")?.trim();
|
|
44
|
+
if (!sessionKey) {
|
|
45
|
+
res.statusCode = 400;
|
|
46
|
+
res.setHeader("Content-Type", "application/json");
|
|
47
|
+
res.end(JSON.stringify({ error: "Missing required query param: sessionKey" }));
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
const agentId = url.searchParams.get("agentId")?.trim() || undefined;
|
|
51
|
+
const limitParam = Number(url.searchParams.get("limit"));
|
|
52
|
+
const limit = Number.isFinite(limitParam) && limitParam > 0
|
|
53
|
+
? Math.min(Math.floor(limitParam), MAX_LIMIT)
|
|
54
|
+
: DEFAULT_LIMIT;
|
|
55
|
+
// Primary path: read the transcript file directly (works from an HTTP route).
|
|
56
|
+
let rawMessages = readSessionTranscriptRawMessages(sessionKey, limit);
|
|
57
|
+
// Fallback: the request-scoped gateway method (only works in some contexts).
|
|
58
|
+
if (rawMessages.length === 0) {
|
|
59
|
+
const sessionApi = resolveSubagentApi();
|
|
60
|
+
if (sessionApi?.getSessionMessages) {
|
|
61
|
+
try {
|
|
62
|
+
const response = await sessionApi.getSessionMessages({ sessionKey, limit });
|
|
63
|
+
rawMessages = Array.isArray(response?.messages) ? response.messages : [];
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Best-effort: an unreadable/unknown session yields an empty history
|
|
67
|
+
// rather than an error, so the app degrades gracefully.
|
|
68
|
+
rawMessages = [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const messages = normalizeHistoryMessages(rawMessages);
|
|
73
|
+
// Resolve `MEDIA:<server-path>` references into downloadable attachment URLs
|
|
74
|
+
// (copies the file into the plugin's attachments/ dir — the same mechanism the
|
|
75
|
+
// live deliver path uses), then drop the raw paths from the wire.
|
|
76
|
+
for (const message of messages) {
|
|
77
|
+
if (!message.mediaPaths?.length)
|
|
78
|
+
continue;
|
|
79
|
+
const resolved = message.mediaPaths
|
|
80
|
+
.map((p) => resolveMediaAttachment(p))
|
|
81
|
+
.filter((r) => Boolean(r))
|
|
82
|
+
.map((r) => ({ url: r.url, filename: r.fileName }));
|
|
83
|
+
if (resolved.length) {
|
|
84
|
+
message.images = [...(message.images ?? []), ...resolved];
|
|
85
|
+
}
|
|
86
|
+
delete message.mediaPaths;
|
|
87
|
+
}
|
|
88
|
+
const sessionId = resolveSessionId(sessionKey);
|
|
89
|
+
res.statusCode = 200;
|
|
90
|
+
res.setHeader("Content-Type", "application/json");
|
|
91
|
+
res.end(JSON.stringify({
|
|
92
|
+
ok: true,
|
|
93
|
+
sessionKey,
|
|
94
|
+
...(agentId ? { agentId } : {}),
|
|
95
|
+
...(sessionId ? { sessionId } : {}),
|
|
96
|
+
totalMessages: messages.length,
|
|
97
|
+
messages,
|
|
98
|
+
}));
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /friday-next/history/sessions
|
|
3
|
+
*
|
|
4
|
+
* Lists every session across every configured agent (lightweight metadata only).
|
|
5
|
+
* The Friday app uses this to surface sessions created on other platforms /
|
|
6
|
+
* channels in its sidebar before lazily fetching each session's history.
|
|
7
|
+
*
|
|
8
|
+
* Session message bodies are intentionally NOT read here — that is the job of the
|
|
9
|
+
* per-session history endpoint. We only read each agent's `sessions.json` via the
|
|
10
|
+
* forward runtime (`loadSessionStore`), matching the read path already used for
|
|
11
|
+
* terminal lifecycle forwards.
|
|
12
|
+
*/
|
|
13
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
14
|
+
export interface FridayHistorySessionSummary {
|
|
15
|
+
/** Canonical app session key, e.g. "agent:main:main". */
|
|
16
|
+
sessionKey: string;
|
|
17
|
+
agentId: string;
|
|
18
|
+
sessionId?: string;
|
|
19
|
+
updatedAt?: number;
|
|
20
|
+
model?: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function handleHistorySessions(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /friday-next/history/sessions
|
|
3
|
+
*
|
|
4
|
+
* Lists every session across every configured agent (lightweight metadata only).
|
|
5
|
+
* The Friday app uses this to surface sessions created on other platforms /
|
|
6
|
+
* channels in its sidebar before lazily fetching each session's history.
|
|
7
|
+
*
|
|
8
|
+
* Session message bodies are intentionally NOT read here — that is the job of the
|
|
9
|
+
* per-session history endpoint. We only read each agent's `sessions.json` via the
|
|
10
|
+
* forward runtime (`loadSessionStore`), matching the read path already used for
|
|
11
|
+
* terminal lifecycle forwards.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
|
|
15
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
16
|
+
import { resolveTranscriptPath } from "../../history/read-transcript.js";
|
|
17
|
+
const DEFAULT_AGENT_ID = "main";
|
|
18
|
+
const SAFE_AGENT_ID = /^[a-z0-9][a-z0-9_-]*$/;
|
|
19
|
+
/** Mirror of OpenClaw's `normalizeAgentId` (also used in agents-list.ts). */
|
|
20
|
+
function normalizeAgentId(value) {
|
|
21
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
22
|
+
if (!trimmed)
|
|
23
|
+
return DEFAULT_AGENT_ID;
|
|
24
|
+
const lowered = trimmed.toLowerCase();
|
|
25
|
+
if (SAFE_AGENT_ID.test(lowered))
|
|
26
|
+
return lowered;
|
|
27
|
+
return (lowered
|
|
28
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
29
|
+
.replace(/^-+|-+$/g, "")
|
|
30
|
+
.slice(0, 64) || DEFAULT_AGENT_ID);
|
|
31
|
+
}
|
|
32
|
+
function readString(value) {
|
|
33
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
34
|
+
}
|
|
35
|
+
function readNumber(value) {
|
|
36
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
37
|
+
}
|
|
38
|
+
/** Configured agent ids (deduped). Falls back to the implicit "main" agent. */
|
|
39
|
+
function resolveConfiguredAgentIds(config) {
|
|
40
|
+
const agents = config?.agents;
|
|
41
|
+
const list = agents?.list;
|
|
42
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
43
|
+
return [DEFAULT_AGENT_ID];
|
|
44
|
+
const ids = new Set();
|
|
45
|
+
for (const agent of list) {
|
|
46
|
+
if (agent && typeof agent === "object")
|
|
47
|
+
ids.add(normalizeAgentId(agent.id));
|
|
48
|
+
}
|
|
49
|
+
if (ids.size === 0)
|
|
50
|
+
ids.add(DEFAULT_AGENT_ID);
|
|
51
|
+
return [...ids];
|
|
52
|
+
}
|
|
53
|
+
/** Build the canonical app session key from an agent + a raw sessions.json key. */
|
|
54
|
+
function toCanonicalSessionKey(agentId, storeKey) {
|
|
55
|
+
const key = storeKey.trim();
|
|
56
|
+
if (key.toLowerCase().startsWith("agent:"))
|
|
57
|
+
return key;
|
|
58
|
+
return `agent:${agentId}:${key}`;
|
|
59
|
+
}
|
|
60
|
+
/** Session-id portion (everything after `agent:<id>:`). */
|
|
61
|
+
function sessionRest(canonicalKey) {
|
|
62
|
+
const m = canonicalKey.match(/^agent:[^:]+:(.+)$/);
|
|
63
|
+
return (m?.[1] ?? canonicalKey).toLowerCase();
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Internal/system sessions that aren't user-facing conversations — mirrors what
|
|
67
|
+
* OpenClaw's own `sessions.list` filters (cron/global/unknown/phantom) plus
|
|
68
|
+
* heartbeat / subagent / memory-dreaming runs.
|
|
69
|
+
*/
|
|
70
|
+
function isInternalSessionKey(canonicalKey, storeKey) {
|
|
71
|
+
const k = storeKey.toLowerCase();
|
|
72
|
+
if (k === "global" || k === "unknown")
|
|
73
|
+
return true;
|
|
74
|
+
const rest = sessionRest(canonicalKey);
|
|
75
|
+
return (rest === "sessions" || // phantom agent-store entry
|
|
76
|
+
rest.startsWith("subagent:") ||
|
|
77
|
+
rest.startsWith("cron:") ||
|
|
78
|
+
rest.startsWith("dreaming-narrative") ||
|
|
79
|
+
rest === "heartbeat" ||
|
|
80
|
+
rest.endsWith(":heartbeat"));
|
|
81
|
+
}
|
|
82
|
+
/** A session counts as archived/empty when its transcript file is gone or empty. */
|
|
83
|
+
function hasLiveTranscript(entry, storePath) {
|
|
84
|
+
const filePath = resolveTranscriptPath(entry, storePath);
|
|
85
|
+
if (!filePath)
|
|
86
|
+
return false;
|
|
87
|
+
try {
|
|
88
|
+
return fs.statSync(filePath).size > 0;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false; // archived (moved to .deleted/.bak) or never written
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function readAgentSessions(agentId) {
|
|
95
|
+
const rt = getFridayAgentForwardRuntime();
|
|
96
|
+
if (!rt)
|
|
97
|
+
return [];
|
|
98
|
+
let storePath;
|
|
99
|
+
let store;
|
|
100
|
+
try {
|
|
101
|
+
storePath = rt.resolveStorePath(undefined, { agentId });
|
|
102
|
+
store = rt.loadSessionStore(storePath) ?? {};
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
const summaries = [];
|
|
108
|
+
for (const [storeKey, rawEntry] of Object.entries(store)) {
|
|
109
|
+
if (!rawEntry || typeof rawEntry !== "object")
|
|
110
|
+
continue;
|
|
111
|
+
const entry = rawEntry;
|
|
112
|
+
const canonicalKey = toCanonicalSessionKey(agentId, storeKey);
|
|
113
|
+
// Drop internal/system sessions and subagent links.
|
|
114
|
+
if (isInternalSessionKey(canonicalKey, storeKey))
|
|
115
|
+
continue;
|
|
116
|
+
if (entry.spawnedBy || entry.subagentRole || entry.parentSessionKey)
|
|
117
|
+
continue;
|
|
118
|
+
// Drop archived/empty sessions (transcript moved away or never written).
|
|
119
|
+
if (!hasLiveTranscript(entry, storePath))
|
|
120
|
+
continue;
|
|
121
|
+
summaries.push({
|
|
122
|
+
sessionKey: canonicalKey,
|
|
123
|
+
agentId,
|
|
124
|
+
...(readString(entry.sessionId) ? { sessionId: readString(entry.sessionId) } : {}),
|
|
125
|
+
...(readNumber(entry.updatedAt) !== undefined ? { updatedAt: readNumber(entry.updatedAt) } : {}),
|
|
126
|
+
...(readString(entry.model) ?? readString(entry.modelOverride)
|
|
127
|
+
? { model: readString(entry.model) ?? readString(entry.modelOverride) }
|
|
128
|
+
: {}),
|
|
129
|
+
// Server-side session display name (matches OpenClaw's resolution order).
|
|
130
|
+
...(readString(entry.displayName) ?? readString(entry.label)
|
|
131
|
+
? { title: readString(entry.displayName) ?? readString(entry.label) }
|
|
132
|
+
: {}),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return summaries;
|
|
136
|
+
}
|
|
137
|
+
export async function handleHistorySessions(req, res) {
|
|
138
|
+
if (req.method !== "GET") {
|
|
139
|
+
res.statusCode = 405;
|
|
140
|
+
res.setHeader("Content-Type", "application/json");
|
|
141
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
const token = extractBearerToken(req);
|
|
145
|
+
if (!token) {
|
|
146
|
+
res.statusCode = 401;
|
|
147
|
+
res.setHeader("Content-Type", "application/json");
|
|
148
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
const rt = getFridayAgentForwardRuntime();
|
|
152
|
+
const config = rt?.getConfig();
|
|
153
|
+
const agentIds = resolveConfiguredAgentIds(config);
|
|
154
|
+
const sessions = [];
|
|
155
|
+
for (const agentId of agentIds) {
|
|
156
|
+
sessions.push(...readAgentSessions(agentId));
|
|
157
|
+
}
|
|
158
|
+
sessions.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
159
|
+
res.statusCode = 200;
|
|
160
|
+
res.setHeader("Content-Type", "application/json");
|
|
161
|
+
res.end(JSON.stringify({ ok: true, sessions }));
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PUT /friday-next/sessions/title body: { sessionKey, title }
|
|
3
|
+
*
|
|
4
|
+
* Syncs the app-set session name to the server session's `displayName` (the
|
|
5
|
+
* field OpenClaw resolves first for a session's display title, ahead of `label`).
|
|
6
|
+
* Writes via `updateSessionStoreEntry` (cache-owning, not request-scoped), so the
|
|
7
|
+
* change is also visible to webui and other clients.
|
|
8
|
+
*/
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
10
|
+
export declare function handleHistorySetTitle(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PUT /friday-next/sessions/title body: { sessionKey, title }
|
|
3
|
+
*
|
|
4
|
+
* Syncs the app-set session name to the server session's `displayName` (the
|
|
5
|
+
* field OpenClaw resolves first for a session's display title, ahead of `label`).
|
|
6
|
+
* Writes via `updateSessionStoreEntry` (cache-owning, not request-scoped), so the
|
|
7
|
+
* change is also visible to webui and other clients.
|
|
8
|
+
*/
|
|
9
|
+
import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
|
|
10
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
11
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
12
|
+
import { agentIdFromSessionKey, toSessionStoreKey } from "../../session/session-manager.js";
|
|
13
|
+
function json(res, status, body) {
|
|
14
|
+
res.statusCode = status;
|
|
15
|
+
res.setHeader("Content-Type", "application/json");
|
|
16
|
+
res.end(JSON.stringify(body));
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
/** Real store key matching `sessionKey`, tolerating deviceId case differences. */
|
|
20
|
+
function resolveStoreKey(store, sessionKey) {
|
|
21
|
+
if (store[sessionKey])
|
|
22
|
+
return sessionKey;
|
|
23
|
+
const canonical = toSessionStoreKey(sessionKey);
|
|
24
|
+
if (store[canonical])
|
|
25
|
+
return canonical;
|
|
26
|
+
const target = canonical.toLowerCase();
|
|
27
|
+
for (const k of Object.keys(store)) {
|
|
28
|
+
if (k.toLowerCase() === target)
|
|
29
|
+
return k;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
export async function handleHistorySetTitle(req, res) {
|
|
34
|
+
if (req.method !== "PUT" && req.method !== "POST") {
|
|
35
|
+
return json(res, 405, { error: "Method Not Allowed" });
|
|
36
|
+
}
|
|
37
|
+
if (!extractBearerToken(req)) {
|
|
38
|
+
return json(res, 401, { error: "Unauthorized: bearer token mismatch" });
|
|
39
|
+
}
|
|
40
|
+
const body = (await readJsonBody(req));
|
|
41
|
+
const sessionKey = typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "";
|
|
42
|
+
const title = typeof body?.title === "string" ? body.title.trim() : "";
|
|
43
|
+
if (!sessionKey) {
|
|
44
|
+
return json(res, 400, { error: "Missing required field: sessionKey" });
|
|
45
|
+
}
|
|
46
|
+
const rt = getFridayAgentForwardRuntime();
|
|
47
|
+
if (!rt?.updateSessionStoreEntry) {
|
|
48
|
+
return json(res, 503, { error: "Session store write not available" });
|
|
49
|
+
}
|
|
50
|
+
const agentId = agentIdFromSessionKey(sessionKey);
|
|
51
|
+
let storePath;
|
|
52
|
+
let store;
|
|
53
|
+
try {
|
|
54
|
+
storePath = rt.resolveStorePath(undefined, { agentId });
|
|
55
|
+
store = rt.loadSessionStore(storePath) ?? {};
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return json(res, 500, { error: "Failed to load session store" });
|
|
59
|
+
}
|
|
60
|
+
const storeKey = resolveStoreKey(store, sessionKey);
|
|
61
|
+
if (!storeKey) {
|
|
62
|
+
return json(res, 404, { error: `Session not found: ${sessionKey}` });
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const updated = await rt.updateSessionStoreEntry({
|
|
66
|
+
storePath,
|
|
67
|
+
sessionKey: storeKey,
|
|
68
|
+
// Empty title clears the override so the server can derive its own again.
|
|
69
|
+
update: () => ({ displayName: title || undefined }),
|
|
70
|
+
});
|
|
71
|
+
const sessionId = updated && typeof updated.sessionId === "string" ? updated.sessionId : undefined;
|
|
72
|
+
return json(res, 200, { ok: true, sessionKey, ...(sessionId ? { sessionId } : {}), title });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return json(res, 500, { error: "Failed to update session title" });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -13,8 +13,7 @@ import crypto from "node:crypto";
|
|
|
13
13
|
import { resolveFridayNextConfig } from "../../config.js";
|
|
14
14
|
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
15
15
|
import { getFridayNextRuntime } from "../../runtime.js";
|
|
16
|
-
import {
|
|
17
|
-
import { agentIdFromSessionKey, setSessionSettings, splitModelRef, toSessionStoreKey, } from "../../session/session-manager.js";
|
|
16
|
+
import { resolveAgentDefaults, setSessionSettings, splitModelRef, toSessionStoreKey, } from "../../session/session-manager.js";
|
|
18
17
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
19
18
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
20
19
|
import { readJsonBody } from "../middleware/body.js";
|
|
@@ -314,41 +313,9 @@ export async function handleMessages(req, res) {
|
|
|
314
313
|
res.end(JSON.stringify({ accepted: true, deviceId: normalizedDeviceId, runId }));
|
|
315
314
|
log("MESSAGE_RECEIVED", normalizedDeviceId, runId, `textLen=${trimmedText.length} attachments=${attachments.length} sessionKey=${baseSessionKey}`);
|
|
316
315
|
const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(runtime.config));
|
|
317
|
-
// Resolve defaults from the OpenClaw agent config so settings are never left empty.
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
// silently forced onto the global default model.
|
|
321
|
-
const targetAgentId = agentIdFromSessionKey(baseSessionKey);
|
|
322
|
-
let defaultModel;
|
|
323
|
-
let defaultThinking;
|
|
324
|
-
try {
|
|
325
|
-
const forwardRt = getFridayAgentForwardRuntime();
|
|
326
|
-
if (forwardRt) {
|
|
327
|
-
const ocCfg = (forwardRt.getConfig() ?? {});
|
|
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;
|
|
339
|
-
const agentDefaults = agents?.defaults;
|
|
340
|
-
const model = agentDefaults?.model;
|
|
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;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
catch {
|
|
350
|
-
// Config not available (tests) — leave defaults undefined.
|
|
351
|
-
}
|
|
316
|
+
// Resolve defaults from the OpenClaw agent config so settings are never left empty. Prefers the
|
|
317
|
+
// target agent's own model/thinking over the global defaults (see resolveAgentDefaults).
|
|
318
|
+
const { model: defaultModel, thinking: defaultThinking } = resolveAgentDefaults(baseSessionKey);
|
|
352
319
|
const modelRef = payload.modelRef ?? defaultModel;
|
|
353
320
|
const reasoningLevel = payload.reasoningLevel ?? "stream";
|
|
354
321
|
const thinkingLevel = payload.thinkingLevel ?? defaultThinking;
|
|
@@ -356,7 +323,8 @@ export async function handleMessages(req, res) {
|
|
|
356
323
|
if (modelRef) {
|
|
357
324
|
settings.modelRef = modelRef;
|
|
358
325
|
const split = splitModelRef(modelRef);
|
|
359
|
-
|
|
326
|
+
// `?? null` clears a stale provider when the resolved ref is bare (no `provider/` prefix).
|
|
327
|
+
settings.providerOverride = split.provider ?? null;
|
|
360
328
|
settings.modelOverride = split.modelId;
|
|
361
329
|
}
|
|
362
330
|
if (reasoningLevel)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { setSessionSettings, getSessionSettings, splitModelRef, } from "../../session/session-manager.js";
|
|
1
|
+
import { setSessionSettings, getSessionSettings, splitModelRef, resolveAgentDefaults, } from "../../session/session-manager.js";
|
|
2
2
|
import { readJsonBody } from "../middleware/body.js";
|
|
3
3
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
4
4
|
const VALID_REASONING = new Set(["on", "off", "stream"]);
|
|
@@ -43,7 +43,7 @@ export async function handleSessionsSettings(req, res) {
|
|
|
43
43
|
}
|
|
44
44
|
const reasoningLevel = typeof body?.reasoningLevel === "string" ? body.reasoningLevel : undefined;
|
|
45
45
|
const thinkingLevel = typeof body?.thinkingLevel === "string" ? body.thinkingLevel : undefined;
|
|
46
|
-
const modelRef = typeof body?.modelRef === "string" ? body.modelRef : undefined;
|
|
46
|
+
const modelRef = typeof body?.modelRef === "string" ? body.modelRef.trim() : undefined;
|
|
47
47
|
const errors = [];
|
|
48
48
|
if (reasoningLevel !== undefined && !VALID_REASONING.has(reasoningLevel)) {
|
|
49
49
|
errors.push(`reasoningLevel must be one of: ${[...VALID_REASONING].join(", ")}`);
|
|
@@ -57,11 +57,26 @@ export async function handleSessionsSettings(req, res) {
|
|
|
57
57
|
res.end(JSON.stringify({ error: errors.join("; ") }));
|
|
58
58
|
return true;
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
// The app omits (or empties) modelRef to mean "use the agent's default model". Resolve that
|
|
61
|
+
// default and write it as an *explicit* override, identical in shape to any other selection — so
|
|
62
|
+
// the agent runs the default exactly the way it runs an explicitly-picked model. Do NOT just
|
|
63
|
+
// clear the override here: the session entry is shared with the OpenClaw core, which stamps it
|
|
64
|
+
// with provenance fields (`modelOverrideSource`, `model`, `modelProvider`); deleting only our
|
|
65
|
+
// three fields leaves those dangling and the core mis-resolves to a fallback model.
|
|
66
|
+
const effectiveModelRef = modelRef || resolveAgentDefaults(sessionKey).model;
|
|
67
|
+
const settings = { reasoningLevel, thinkingLevel };
|
|
68
|
+
if (effectiveModelRef) {
|
|
69
|
+
const split = splitModelRef(effectiveModelRef);
|
|
70
|
+
settings.modelRef = effectiveModelRef;
|
|
71
|
+
// `?? null` clears a stale provider when the ref is bare (no `provider/` prefix).
|
|
72
|
+
settings.providerOverride = split.provider ?? null;
|
|
73
|
+
settings.modelOverride = split.modelId;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
// No configured default to resolve (e.g. config unavailable) — clear rather than pin a stale model.
|
|
77
|
+
settings.modelRef = null;
|
|
78
|
+
settings.providerOverride = null;
|
|
79
|
+
settings.modelOverride = null;
|
|
65
80
|
}
|
|
66
81
|
const result = setSessionSettings(sessionKey, settings);
|
|
67
82
|
res.statusCode = 200;
|
package/dist/src/http/server.js
CHANGED
|
@@ -14,6 +14,9 @@ import { handleNodesApprove } from "./handlers/nodes-approve.js";
|
|
|
14
14
|
import { handleSessionsSettings } from "./handlers/sessions-settings.js";
|
|
15
15
|
import { handleModelsList } from "./handlers/models-list.js";
|
|
16
16
|
import { handleAgentsList } from "./handlers/agents-list.js";
|
|
17
|
+
import { handleHistorySessions } from "./handlers/history-sessions.js";
|
|
18
|
+
import { handleHistoryMessages } from "./handlers/history-messages.js";
|
|
19
|
+
import { handleHistorySetTitle } from "./handlers/history-set-title.js";
|
|
17
20
|
import { handleStatus } from "./handlers/status.js";
|
|
18
21
|
import { handleHealth } from "./handlers/health.js";
|
|
19
22
|
import { applyCorsHeaders } from "./middleware/cors.js";
|
|
@@ -68,6 +71,18 @@ async function handleFridayNextRoute(req, res) {
|
|
|
68
71
|
if (req.method === "GET" && pathname === "/friday-next/status") {
|
|
69
72
|
return await handleStatus(req, res);
|
|
70
73
|
}
|
|
74
|
+
// Route: GET /friday-next/history/sessions (list all sessions across agents)
|
|
75
|
+
if (req.method === "GET" && pathname === "/friday-next/history/sessions") {
|
|
76
|
+
return await handleHistorySessions(req, res);
|
|
77
|
+
}
|
|
78
|
+
// Route: GET /friday-next/history/messages?sessionKey=&agentId=&limit=
|
|
79
|
+
if (req.method === "GET" && pathname === "/friday-next/history/messages") {
|
|
80
|
+
return await handleHistoryMessages(req, res);
|
|
81
|
+
}
|
|
82
|
+
// Route: PUT /friday-next/sessions/title (sync app session name → server displayName)
|
|
83
|
+
if ((req.method === "PUT" || req.method === "POST") && pathname === "/friday-next/sessions/title") {
|
|
84
|
+
return await handleHistorySetTitle(req, res);
|
|
85
|
+
}
|
|
71
86
|
// Route: GET /friday-next/health?deviceId=...&nodeDeviceId=...&selfHeal=true
|
|
72
87
|
if (req.method === "GET" && pathname === "/friday-next/health") {
|
|
73
88
|
return await handleHealth(req, res);
|
|
@@ -17,5 +17,34 @@ export interface FridaySessionSettings {
|
|
|
17
17
|
providerOverride?: string;
|
|
18
18
|
modelOverride?: string;
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Update shape for {@link setSessionSettings}. A field set to a string writes that value, a field
|
|
22
|
+
* left `undefined` is untouched, and a field set to `null` **clears** the stored value. The `null`
|
|
23
|
+
* case is what lets the app reset a model override back to the agent default — without it the merge
|
|
24
|
+
* could only ever add/replace overrides, never remove them (the cause of "selecting the default
|
|
25
|
+
* model doesn't take effect": a prior `provider/model` override survived and was read back).
|
|
26
|
+
*/
|
|
27
|
+
export type FridaySessionSettingsUpdate = {
|
|
28
|
+
reasoningLevel?: string | null;
|
|
29
|
+
thinkingLevel?: string | null;
|
|
30
|
+
modelRef?: string | null;
|
|
31
|
+
providerOverride?: string | null;
|
|
32
|
+
modelOverride?: string | null;
|
|
33
|
+
};
|
|
34
|
+
export declare function setSessionSettings(sessionKey: string, settings: FridaySessionSettingsUpdate, historyDir?: string): FridaySessionSettings;
|
|
21
35
|
export declare function getSessionSettings(sessionKey: string, historyDir?: string): FridaySessionSettings;
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the configured default model + thinking level for the agent that owns `sessionKey`,
|
|
38
|
+
* reading the live OpenClaw config. Prefers the target agent's own `model`/`thinkingDefault` over
|
|
39
|
+
* the global `agents.defaults`, so non-main agents aren't silently forced onto the global default.
|
|
40
|
+
*
|
|
41
|
+
* Used to write the default model as an **explicit** override when the app selects it (the app
|
|
42
|
+
* sends no modelRef for the default). Writing it explicitly — rather than clearing the stored
|
|
43
|
+
* override — keeps the shared session entry consistent with the core's provenance fields
|
|
44
|
+
* (`modelOverrideSource`, `model`, `modelProvider`); a bare clear leaves those dangling and the
|
|
45
|
+
* agent mis-resolves to a fallback model.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveAgentDefaults(sessionKey: string): {
|
|
48
|
+
model?: string;
|
|
49
|
+
thinking?: string;
|
|
50
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { getFridayAgentForwardRuntime } from "../agent-forward-runtime.js";
|
|
4
5
|
const FRIDAY_AGENT_ID = "main";
|
|
5
6
|
const SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
|
|
6
7
|
/** Path/shell-safe agent id (mirrors OpenClaw's `normalizeAgentId`). Anything else falls back to `main`. */
|
|
@@ -122,7 +123,17 @@ export function setSessionSettings(sessionKey, settings, historyDir) {
|
|
|
122
123
|
let updated = false;
|
|
123
124
|
for (const key of fieldKeys) {
|
|
124
125
|
const value = settings[key];
|
|
125
|
-
if (value
|
|
126
|
+
if (value === undefined)
|
|
127
|
+
continue; // leave the stored value untouched
|
|
128
|
+
if (value === null) {
|
|
129
|
+
// Explicit clear — remove the override so the agent falls back to its default.
|
|
130
|
+
if (key in data[fileKey]) {
|
|
131
|
+
delete data[fileKey][key];
|
|
132
|
+
updated = true;
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (data[fileKey][key] !== value) {
|
|
126
137
|
data[fileKey][key] = value;
|
|
127
138
|
updated = true;
|
|
128
139
|
}
|
|
@@ -163,3 +174,41 @@ export function getSessionSettings(sessionKey, historyDir) {
|
|
|
163
174
|
return {};
|
|
164
175
|
}
|
|
165
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Resolve the configured default model + thinking level for the agent that owns `sessionKey`,
|
|
179
|
+
* reading the live OpenClaw config. Prefers the target agent's own `model`/`thinkingDefault` over
|
|
180
|
+
* the global `agents.defaults`, so non-main agents aren't silently forced onto the global default.
|
|
181
|
+
*
|
|
182
|
+
* Used to write the default model as an **explicit** override when the app selects it (the app
|
|
183
|
+
* sends no modelRef for the default). Writing it explicitly — rather than clearing the stored
|
|
184
|
+
* override — keeps the shared session entry consistent with the core's provenance fields
|
|
185
|
+
* (`modelOverrideSource`, `model`, `modelProvider`); a bare clear leaves those dangling and the
|
|
186
|
+
* agent mis-resolves to a fallback model.
|
|
187
|
+
*/
|
|
188
|
+
export function resolveAgentDefaults(sessionKey) {
|
|
189
|
+
try {
|
|
190
|
+
const forwardRt = getFridayAgentForwardRuntime();
|
|
191
|
+
if (!forwardRt)
|
|
192
|
+
return {};
|
|
193
|
+
const ocCfg = (forwardRt.getConfig() ?? {});
|
|
194
|
+
const agents = ocCfg.agents;
|
|
195
|
+
const targetAgentId = agentIdFromSessionKey(sessionKey);
|
|
196
|
+
const agentEntry = agents?.list?.find((a) => agentIdFromSessionKey(`agent:${String(a?.id ?? "")}:x`) === targetAgentId);
|
|
197
|
+
const agentModel = agentEntry?.model;
|
|
198
|
+
const perAgentModel = typeof agentModel === "string"
|
|
199
|
+
? agentModel
|
|
200
|
+
: typeof agentModel?.primary === "string"
|
|
201
|
+
? agentModel.primary
|
|
202
|
+
: undefined;
|
|
203
|
+
const perAgentThinking = typeof agentEntry?.thinkingDefault === "string" ? agentEntry.thinkingDefault : undefined;
|
|
204
|
+
const agentDefaults = agents?.defaults;
|
|
205
|
+
const model = agentDefaults?.model;
|
|
206
|
+
const globalModel = typeof model?.primary === "string" ? model.primary : undefined;
|
|
207
|
+
const globalThinking = typeof agentDefaults?.thinkingDefault === "string" ? agentDefaults.thinkingDefault : undefined;
|
|
208
|
+
return { model: perAgentModel ?? globalModel, thinking: perAgentThinking ?? globalThinking };
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Config not available (e.g. unit tests) — caller decides the fallback.
|
|
212
|
+
return {};
|
|
213
|
+
}
|
|
214
|
+
}
|
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.15",
|
|
4
4
|
"description": "OpenClaw Friday Next Apple channel plugin",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc -p tsconfig.json",
|
|
17
|
-
"prepublishOnly": "pnpm build",
|
|
17
|
+
"prepublishOnly": "pnpm build && rm -rf dist/attachments",
|
|
18
18
|
"test": "npm run test:unit && npm run test:e2e",
|
|
19
19
|
"test:unit": "vitest run",
|
|
20
20
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|