@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.
Files changed (43) hide show
  1. package/dist/src/agent-forward-runtime.d.ts +6 -0
  2. package/dist/src/agent-forward-runtime.js +2 -0
  3. package/dist/src/channel-actions.js +9 -1
  4. package/dist/src/channel.js +17 -4
  5. package/dist/src/friday-session.js +3 -4
  6. package/dist/src/history/normalize-message.d.ts +67 -0
  7. package/dist/src/history/normalize-message.js +224 -0
  8. package/dist/src/history/read-transcript.d.ts +22 -0
  9. package/dist/src/history/read-transcript.js +136 -0
  10. package/dist/src/http/handlers/files.d.ts +3 -2
  11. package/dist/src/http/handlers/files.js +20 -5
  12. package/dist/src/http/handlers/history-messages.d.ts +13 -0
  13. package/dist/src/http/handlers/history-messages.js +100 -0
  14. package/dist/src/http/handlers/history-sessions.d.ts +23 -0
  15. package/dist/src/http/handlers/history-sessions.js +163 -0
  16. package/dist/src/http/handlers/history-set-title.d.ts +10 -0
  17. package/dist/src/http/handlers/history-set-title.js +77 -0
  18. package/dist/src/http/handlers/messages.js +21 -6
  19. package/dist/src/http/server.js +15 -0
  20. package/dist/src/session/session-manager.d.ts +6 -0
  21. package/dist/src/session/session-manager.js +20 -8
  22. package/package.json +10 -11
  23. package/src/agent-forward-runtime.ts +10 -0
  24. package/src/channel-actions.test.ts +111 -0
  25. package/src/channel-actions.ts +10 -1
  26. package/src/channel.outbound.test.ts +137 -0
  27. package/src/channel.ts +24 -6
  28. package/src/friday-session.ts +3 -5
  29. package/src/history/normalize-message.test.ts +154 -0
  30. package/src/history/normalize-message.ts +292 -0
  31. package/src/history/read-transcript.ts +136 -0
  32. package/src/http/handlers/files.ts +21 -5
  33. package/src/http/handlers/history-messages.test.ts +144 -0
  34. package/src/http/handlers/history-messages.ts +123 -0
  35. package/src/http/handlers/history-sessions.test.ts +146 -0
  36. package/src/http/handlers/history-sessions.ts +184 -0
  37. package/src/http/handlers/history-set-title.test.ts +115 -0
  38. package/src/http/handlers/history-set-title.ts +86 -0
  39. package/src/http/handlers/messages.ts +31 -3
  40. package/src/http/server.ts +18 -0
  41. package/src/session/session-manager.test.ts +90 -0
  42. package/src/session/session-manager.ts +21 -8
  43. 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
- defaultModel = typeof model?.primary === "string" ? model.primary : undefined;
328
- defaultThinking =
329
- typeof agentDefaults?.thinkingDefault === "string"
330
- ? agentDefaults.thinkingDefault
331
- : undefined;
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 {
@@ -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
- if (c.startsWith(`agent:${FRIDAY_AGENT_ID}:`)) {
58
- const tail = c.slice(`agent:${FRIDAY_AGENT_ID}:`.length);
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/main/sessions/sessions.json");
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 sessionsFile = resolveSessionsFilePath(historyDir);
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 sessionsFile = resolveSessionsFilePath(historyDir);
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.12",
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
  }