@syengup/friday-channel-next 0.1.13 → 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/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/server.js +15 -0
- 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/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/server.ts +18 -0
- 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
|
+
}
|
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);
|
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
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { handleMessageAction } from "./channel-actions.js";
|
|
6
|
+
import { sseEmitter } from "./sse/emitter.js";
|
|
7
|
+
import { setOfflineQueueBaseDirForTest } from "./sse/offline-queue.js";
|
|
8
|
+
import { registerRunRoute } from "./run-metadata.js";
|
|
9
|
+
import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "./test-support/mock-runtime.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The `message` tool's `action=send` is handled here (NOT via outbound.sendText/sendMedia).
|
|
13
|
+
* `ctx.sessionKey` is the agent's base/main session, so the send must recover the app session that
|
|
14
|
+
* started the device's active run from the run-route registry — otherwise attachments land in a
|
|
15
|
+
* device-level / main session instead of the user's current session.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
class MockRes extends EventEmitter {
|
|
19
|
+
writes: string[] = [];
|
|
20
|
+
write(chunk: string): boolean {
|
|
21
|
+
this.writes.push(chunk);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
end(): void {
|
|
25
|
+
// no-op
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type OutboundFrame = { type: string; data: Record<string, unknown> };
|
|
30
|
+
|
|
31
|
+
function parseOutboundFrames(res: MockRes): OutboundFrame[] {
|
|
32
|
+
const frames: OutboundFrame[] = [];
|
|
33
|
+
for (const block of res.writes.join("").split("\n\n")) {
|
|
34
|
+
if (!block.trim()) continue;
|
|
35
|
+
let type = "";
|
|
36
|
+
let data: Record<string, unknown> | undefined;
|
|
37
|
+
for (const line of block.split("\n")) {
|
|
38
|
+
if (line.startsWith("event: ")) type = line.slice("event: ".length).trim();
|
|
39
|
+
else if (line.startsWith("data: ")) {
|
|
40
|
+
try {
|
|
41
|
+
data = JSON.parse(line.slice("data: ".length));
|
|
42
|
+
} catch {
|
|
43
|
+
// ignore
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (data) frames.push({ type, data });
|
|
48
|
+
}
|
|
49
|
+
return frames;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("channel-actions handleSend sessionKey routing", () => {
|
|
53
|
+
let historyDir = "";
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
sseEmitter.resetForTest();
|
|
57
|
+
historyDir = createTempHistoryDir();
|
|
58
|
+
setOfflineQueueBaseDirForTest(historyDir);
|
|
59
|
+
setMockRuntime({ historyDir, authToken: "test-token" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
setOfflineQueueBaseDirForTest(null);
|
|
64
|
+
removeTempHistoryDir(historyDir);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function connect(deviceId: string): MockRes {
|
|
68
|
+
const res = new MockRes();
|
|
69
|
+
sseEmitter.addConnection(deviceId, res as never);
|
|
70
|
+
return res;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
it("send media routes to the active run's app session, not ctx.sessionKey", async () => {
|
|
74
|
+
const deviceId = "DEV-ACT-1";
|
|
75
|
+
const runId = "run-act-1";
|
|
76
|
+
const appSession = "agent:operator:friday:direct:dev-act-1:1780561609";
|
|
77
|
+
const mediaFile = path.join(historyDir, "shot.png");
|
|
78
|
+
fs.writeFileSync(mediaFile, "png-bytes");
|
|
79
|
+
registerRunRoute({ runId, deviceId, sessionKey: appSession });
|
|
80
|
+
sseEmitter.trackDeviceForRun(deviceId, runId);
|
|
81
|
+
const res = connect(deviceId);
|
|
82
|
+
|
|
83
|
+
const result = await handleMessageAction({
|
|
84
|
+
action: "send",
|
|
85
|
+
params: { to: deviceId, message: "桌面截图来了 📸", media: mediaFile },
|
|
86
|
+
sessionKey: "agent:operator:main", // ctx gives the base/main session — must be overridden
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect((result as { ok?: boolean }).ok).toBe(true);
|
|
90
|
+
const frames = parseOutboundFrames(res);
|
|
91
|
+
const media = frames.find((f) => f.type === "outbound" && f.data.op === "media");
|
|
92
|
+
const text = frames.find((f) => f.type === "outbound" && f.data.op === "text");
|
|
93
|
+
expect(media?.data.sessionKey).toBe(appSession);
|
|
94
|
+
expect(text?.data.sessionKey).toBe(appSession);
|
|
95
|
+
expect(media?.data.deviceId).toBe(deviceId);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("falls back to ctx.sessionKey when the device has no active run-route", async () => {
|
|
99
|
+
const deviceId = "DEV-ACT-2";
|
|
100
|
+
const res = connect(deviceId);
|
|
101
|
+
|
|
102
|
+
await handleMessageAction({
|
|
103
|
+
action: "send",
|
|
104
|
+
params: { to: deviceId, message: "hi" },
|
|
105
|
+
sessionKey: "agent:operator:friday:direct:fallback-session",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const text = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "text");
|
|
109
|
+
expect(text?.data.sessionKey).toBe("agent:operator:friday:direct:fallback-session");
|
|
110
|
+
});
|
|
111
|
+
});
|
package/src/channel-actions.ts
CHANGED
|
@@ -2,6 +2,8 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import { sseEmitter } from "./sse/emitter.js";
|
|
4
4
|
import { guessMimeType } from "./http/handlers/files.js";
|
|
5
|
+
import { getRunRoute } from "./run-metadata.js";
|
|
6
|
+
import { resolveHistorySessionKeyForFridayDevice } from "./friday-session.js";
|
|
5
7
|
|
|
6
8
|
type MessageActionCtx = {
|
|
7
9
|
action: string;
|
|
@@ -64,7 +66,14 @@ async function handleSend(ctx: MessageActionCtx): Promise<unknown> {
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
const runId = crypto.randomUUID();
|
|
67
|
-
|
|
69
|
+
// The `message` tool's send runs as a fresh action; `ctx.sessionKey` is the agent's base/main
|
|
70
|
+
// session, not the app session that started the active run on this device. Recover the latter via
|
|
71
|
+
// the device's last tracked run-route so attachments land in the user's current session.
|
|
72
|
+
const activeRunId = sseEmitter.getLastRunIdForDevice(to) ?? undefined;
|
|
73
|
+
const sessionKey =
|
|
74
|
+
(activeRunId ? getRunRoute(activeRunId)?.sessionKey : undefined) ??
|
|
75
|
+
ctx.sessionKey ??
|
|
76
|
+
resolveHistorySessionKeyForFridayDevice(to);
|
|
68
77
|
|
|
69
78
|
// Send text via SSE outbound
|
|
70
79
|
if (text) {
|