@syengup/friday-channel-next 0.1.12 → 0.1.14
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 +17 -4
- package/dist/src/friday-session.js +3 -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 +21 -6
- package/dist/src/http/server.js +15 -0
- package/dist/src/session/session-manager.d.ts +6 -0
- package/dist/src/session/session-manager.js +20 -8
- package/package.json +10 -11
- 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.test.ts +137 -0
- package/src/channel.ts +24 -6
- package/src/friday-session.ts +3 -5
- 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 +31 -3
- package/src/http/server.ts +18 -0
- package/src/session/session-manager.test.ts +90 -0
- package/src/session/session-manager.ts +21 -8
- 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
|
+
}
|
|
@@ -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 {
|
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);
|
|
@@ -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,6 +3,8 @@ 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
10
|
const match = historyDir.replace(/[\\/]+$/, "").match(/(.*[\\/]\.openclaw)[\\/]/);
|
|
@@ -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 {};
|
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.14",
|
|
4
4
|
"description": "OpenClaw Friday Next Apple channel plugin",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,15 +12,6 @@
|
|
|
12
12
|
"tsconfig.json",
|
|
13
13
|
"openclaw.plugin.json"
|
|
14
14
|
],
|
|
15
|
-
"scripts": {
|
|
16
|
-
"build": "tsc -p tsconfig.json",
|
|
17
|
-
"prepublishOnly": "pnpm build",
|
|
18
|
-
"test": "npm run test:unit && npm run test:e2e",
|
|
19
|
-
"test:unit": "vitest run",
|
|
20
|
-
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
21
|
-
"test:smoke": "node scripts/e2e-smoke.mjs",
|
|
22
|
-
"test:msg-live": "node scripts/message-roundtrip-live.mjs"
|
|
23
|
-
},
|
|
24
15
|
"bin": {
|
|
25
16
|
"friday-channel-next": "install.js"
|
|
26
17
|
},
|
|
@@ -66,5 +57,13 @@
|
|
|
66
57
|
"typescript": "^6.0.3",
|
|
67
58
|
"vitest": "^4.1.5",
|
|
68
59
|
"zod": "^4.3.6"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "tsc -p tsconfig.json",
|
|
63
|
+
"test": "npm run test:unit && npm run test:e2e",
|
|
64
|
+
"test:unit": "vitest run",
|
|
65
|
+
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
66
|
+
"test:smoke": "node scripts/e2e-smoke.mjs",
|
|
67
|
+
"test:msg-live": "node scripts/message-roundtrip-live.mjs"
|
|
69
68
|
}
|
|
70
|
-
}
|
|
69
|
+
}
|
|
@@ -6,6 +6,14 @@ export type FridayAgentForwardRuntime = {
|
|
|
6
6
|
path: string,
|
|
7
7
|
options?: { skipCache?: boolean; maintenanceConfig?: unknown; clone?: boolean },
|
|
8
8
|
) => Record<string, unknown>;
|
|
9
|
+
/** Cache-owning entry write (syncs the app session name → server `displayName`). */
|
|
10
|
+
updateSessionStoreEntry?: (params: {
|
|
11
|
+
storePath: string;
|
|
12
|
+
sessionKey: string;
|
|
13
|
+
update: (
|
|
14
|
+
entry: Record<string, unknown>,
|
|
15
|
+
) => Record<string, unknown> | null | Promise<Record<string, unknown> | null>;
|
|
16
|
+
}) => Promise<Record<string, unknown> | null>;
|
|
9
17
|
getConfig: () => unknown;
|
|
10
18
|
};
|
|
11
19
|
|
|
@@ -16,6 +24,8 @@ export function setFridayAgentForwardRuntime(api: OpenClawPluginApi): void {
|
|
|
16
24
|
forwardRuntime = {
|
|
17
25
|
resolveStorePath: api.runtime.agent.session.resolveStorePath,
|
|
18
26
|
loadSessionStore: api.runtime.agent.session.loadSessionStore,
|
|
27
|
+
updateSessionStoreEntry: (api.runtime.agent.session as Record<string, unknown>)
|
|
28
|
+
.updateSessionStoreEntry as FridayAgentForwardRuntime["updateSessionStoreEntry"],
|
|
19
29
|
getConfig: () => api.runtime.config.current(),
|
|
20
30
|
};
|
|
21
31
|
}
|