@syengup/friday-channel-next 0.1.13 → 0.1.15

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