@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.
Files changed (35) 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/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/server.js +15 -0
  18. package/package.json +10 -11
  19. package/src/agent-forward-runtime.ts +10 -0
  20. package/src/channel-actions.test.ts +111 -0
  21. package/src/channel-actions.ts +10 -1
  22. package/src/channel.outbound.test.ts +137 -0
  23. package/src/channel.ts +24 -6
  24. package/src/history/normalize-message.test.ts +154 -0
  25. package/src/history/normalize-message.ts +292 -0
  26. package/src/history/read-transcript.ts +136 -0
  27. package/src/http/handlers/files.ts +21 -5
  28. package/src/http/handlers/history-messages.test.ts +144 -0
  29. package/src/http/handlers/history-messages.ts +123 -0
  30. package/src/http/handlers/history-sessions.test.ts +146 -0
  31. package/src/http/handlers/history-sessions.ts +184 -0
  32. package/src/http/handlers/history-set-title.test.ts +115 -0
  33. package/src/http/handlers/history-set-title.ts +86 -0
  34. package/src/http/server.ts +18 -0
  35. package/src/test-support/mock-runtime.ts +2 -0
@@ -0,0 +1,184 @@
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
+
14
+ import type { IncomingMessage, ServerResponse } from "node:http";
15
+ import fs from "node:fs";
16
+ import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
17
+ import { extractBearerToken } from "../middleware/auth.js";
18
+ import { resolveTranscriptPath } from "../../history/read-transcript.js";
19
+
20
+ const DEFAULT_AGENT_ID = "main";
21
+ const SAFE_AGENT_ID = /^[a-z0-9][a-z0-9_-]*$/;
22
+
23
+ export interface FridayHistorySessionSummary {
24
+ /** Canonical app session key, e.g. "agent:main:main". */
25
+ sessionKey: string;
26
+ agentId: string;
27
+ sessionId?: string;
28
+ updatedAt?: number;
29
+ model?: string;
30
+ title?: string;
31
+ }
32
+
33
+ /** Mirror of OpenClaw's `normalizeAgentId` (also used in agents-list.ts). */
34
+ function normalizeAgentId(value: unknown): string {
35
+ const trimmed = typeof value === "string" ? value.trim() : "";
36
+ if (!trimmed) return DEFAULT_AGENT_ID;
37
+ const lowered = trimmed.toLowerCase();
38
+ if (SAFE_AGENT_ID.test(lowered)) return lowered;
39
+ return (
40
+ lowered
41
+ .replace(/[^a-z0-9_-]+/g, "-")
42
+ .replace(/^-+|-+$/g, "")
43
+ .slice(0, 64) || DEFAULT_AGENT_ID
44
+ );
45
+ }
46
+
47
+ function readString(value: unknown): string | undefined {
48
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
49
+ }
50
+
51
+ function readNumber(value: unknown): number | undefined {
52
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
53
+ }
54
+
55
+ /** Configured agent ids (deduped). Falls back to the implicit "main" agent. */
56
+ function resolveConfiguredAgentIds(config: Record<string, unknown> | undefined): string[] {
57
+ const agents = config?.agents as Record<string, unknown> | undefined;
58
+ const list = agents?.list as Array<Record<string, unknown>> | undefined;
59
+ if (!Array.isArray(list) || list.length === 0) return [DEFAULT_AGENT_ID];
60
+ const ids = new Set<string>();
61
+ for (const agent of list) {
62
+ if (agent && typeof agent === "object") ids.add(normalizeAgentId(agent.id));
63
+ }
64
+ if (ids.size === 0) ids.add(DEFAULT_AGENT_ID);
65
+ return [...ids];
66
+ }
67
+
68
+ /** Build the canonical app session key from an agent + a raw sessions.json key. */
69
+ function toCanonicalSessionKey(agentId: string, storeKey: string): string {
70
+ const key = storeKey.trim();
71
+ if (key.toLowerCase().startsWith("agent:")) return key;
72
+ return `agent:${agentId}:${key}`;
73
+ }
74
+
75
+ /** Session-id portion (everything after `agent:<id>:`). */
76
+ function sessionRest(canonicalKey: string): string {
77
+ const m = canonicalKey.match(/^agent:[^:]+:(.+)$/);
78
+ return (m?.[1] ?? canonicalKey).toLowerCase();
79
+ }
80
+
81
+ /**
82
+ * Internal/system sessions that aren't user-facing conversations — mirrors what
83
+ * OpenClaw's own `sessions.list` filters (cron/global/unknown/phantom) plus
84
+ * heartbeat / subagent / memory-dreaming runs.
85
+ */
86
+ function isInternalSessionKey(canonicalKey: string, storeKey: string): boolean {
87
+ const k = storeKey.toLowerCase();
88
+ if (k === "global" || k === "unknown") return true;
89
+ const rest = sessionRest(canonicalKey);
90
+ return (
91
+ rest === "sessions" || // phantom agent-store entry
92
+ rest.startsWith("subagent:") ||
93
+ rest.startsWith("cron:") ||
94
+ rest.startsWith("dreaming-narrative") ||
95
+ rest === "heartbeat" ||
96
+ rest.endsWith(":heartbeat")
97
+ );
98
+ }
99
+
100
+ /** A session counts as archived/empty when its transcript file is gone or empty. */
101
+ function hasLiveTranscript(entry: Record<string, unknown>, storePath: string): boolean {
102
+ const filePath = resolveTranscriptPath(entry, storePath);
103
+ if (!filePath) return false;
104
+ try {
105
+ return fs.statSync(filePath).size > 0;
106
+ } catch {
107
+ return false; // archived (moved to .deleted/.bak) or never written
108
+ }
109
+ }
110
+
111
+ function readAgentSessions(agentId: string): FridayHistorySessionSummary[] {
112
+ const rt = getFridayAgentForwardRuntime();
113
+ if (!rt) return [];
114
+ let storePath: string;
115
+ let store: Record<string, unknown>;
116
+ try {
117
+ storePath = rt.resolveStorePath(undefined, { agentId });
118
+ store = rt.loadSessionStore(storePath) ?? {};
119
+ } catch {
120
+ return [];
121
+ }
122
+ const summaries: FridayHistorySessionSummary[] = [];
123
+ for (const [storeKey, rawEntry] of Object.entries(store)) {
124
+ if (!rawEntry || typeof rawEntry !== "object") continue;
125
+ const entry = rawEntry as Record<string, unknown>;
126
+ const canonicalKey = toCanonicalSessionKey(agentId, storeKey);
127
+
128
+ // Drop internal/system sessions and subagent links.
129
+ if (isInternalSessionKey(canonicalKey, storeKey)) continue;
130
+ if (entry.spawnedBy || entry.subagentRole || entry.parentSessionKey) continue;
131
+ // Drop archived/empty sessions (transcript moved away or never written).
132
+ if (!hasLiveTranscript(entry, storePath)) continue;
133
+
134
+ summaries.push({
135
+ sessionKey: canonicalKey,
136
+ agentId,
137
+ ...(readString(entry.sessionId) ? { sessionId: readString(entry.sessionId) } : {}),
138
+ ...(readNumber(entry.updatedAt) !== undefined ? { updatedAt: readNumber(entry.updatedAt) } : {}),
139
+ ...(readString(entry.model) ?? readString(entry.modelOverride)
140
+ ? { model: readString(entry.model) ?? readString(entry.modelOverride) }
141
+ : {}),
142
+ // Server-side session display name (matches OpenClaw's resolution order).
143
+ ...(readString(entry.displayName) ?? readString(entry.label)
144
+ ? { title: readString(entry.displayName) ?? readString(entry.label) }
145
+ : {}),
146
+ });
147
+ }
148
+ return summaries;
149
+ }
150
+
151
+ export async function handleHistorySessions(
152
+ req: IncomingMessage,
153
+ res: ServerResponse,
154
+ ): Promise<boolean> {
155
+ if (req.method !== "GET") {
156
+ res.statusCode = 405;
157
+ res.setHeader("Content-Type", "application/json");
158
+ res.end(JSON.stringify({ error: "Method Not Allowed" }));
159
+ return true;
160
+ }
161
+
162
+ const token = extractBearerToken(req);
163
+ if (!token) {
164
+ res.statusCode = 401;
165
+ res.setHeader("Content-Type", "application/json");
166
+ res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
167
+ return true;
168
+ }
169
+
170
+ const rt = getFridayAgentForwardRuntime();
171
+ const config = rt?.getConfig() as Record<string, unknown> | undefined;
172
+ const agentIds = resolveConfiguredAgentIds(config);
173
+
174
+ const sessions: FridayHistorySessionSummary[] = [];
175
+ for (const agentId of agentIds) {
176
+ sessions.push(...readAgentSessions(agentId));
177
+ }
178
+ sessions.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
179
+
180
+ res.statusCode = 200;
181
+ res.setHeader("Content-Type", "application/json");
182
+ res.end(JSON.stringify({ ok: true, sessions }));
183
+ return true;
184
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { Readable } from "node:stream";
4
+ import { handleHistorySetTitle } from "./history-set-title.js";
5
+ import { setFridayNextRuntime } from "../../runtime.js";
6
+ import {
7
+ setFridayAgentForwardRuntime,
8
+ resetFridayAgentForwardRuntimeForTest,
9
+ } from "../../agent-forward-runtime.js";
10
+
11
+ class MockRes extends EventEmitter {
12
+ statusCode = 0;
13
+ headers: Record<string, string> = {};
14
+ body = "";
15
+ setHeader(name: string, value: string): void {
16
+ this.headers[name.toLowerCase()] = value;
17
+ }
18
+ end(body?: string): void {
19
+ if (body) this.body += body;
20
+ }
21
+ }
22
+
23
+ function makeReq(bodyObj: unknown, headers: Record<string, string> = {}, method = "PUT"): any {
24
+ const req = Readable.from([Buffer.from(JSON.stringify(bodyObj))]) as any;
25
+ req.method = method;
26
+ req.url = "/friday-next/sessions/title";
27
+ req.headers = headers;
28
+ return req;
29
+ }
30
+
31
+ const AUTH = { authorization: "Bearer test-token" };
32
+ const CFG = {
33
+ channels: { "friday-next": { authToken: "test-token", pathPrefix: "/friday-next" } },
34
+ gateway: { auth: { token: "test-token" } },
35
+ };
36
+
37
+ let captured: { storePath: string; sessionKey: string; patch: unknown } | null = null;
38
+
39
+ function setForward(store: Record<string, unknown>, withWriter = true): void {
40
+ captured = null;
41
+ setFridayAgentForwardRuntime({
42
+ runtime: {
43
+ agent: {
44
+ session: {
45
+ resolveStorePath: (_s?: string, opts?: { agentId?: string }) => `/store/${opts?.agentId ?? "main"}.json`,
46
+ loadSessionStore: () => store,
47
+ ...(withWriter
48
+ ? {
49
+ updateSessionStoreEntry: async (params: any) => {
50
+ const patch = await params.update({ sessionId: "sess-1" });
51
+ captured = { storePath: params.storePath, sessionKey: params.sessionKey, patch };
52
+ return { sessionId: "sess-1", ...patch };
53
+ },
54
+ }
55
+ : {}),
56
+ },
57
+ },
58
+ config: { current: () => CFG },
59
+ },
60
+ } as any);
61
+ }
62
+
63
+ describe("handleHistorySetTitle", () => {
64
+ beforeEach(() => setFridayNextRuntime({ config: { loadConfig: () => CFG }, logger: {} } as never));
65
+ afterEach(() => resetFridayAgentForwardRuntimeForTest());
66
+
67
+ it("rejects GET with 405", async () => {
68
+ setForward({});
69
+ const res = new MockRes();
70
+ await handleHistorySetTitle(makeReq({}, AUTH, "GET"), res as any);
71
+ expect(res.statusCode).toBe(405);
72
+ });
73
+
74
+ it("rejects missing token with 401", async () => {
75
+ setForward({});
76
+ const res = new MockRes();
77
+ await handleHistorySetTitle(makeReq({ sessionKey: "agent:main:main", title: "x" }), res as any);
78
+ expect(res.statusCode).toBe(401);
79
+ });
80
+
81
+ it("400s without sessionKey", async () => {
82
+ setForward({});
83
+ const res = new MockRes();
84
+ await handleHistorySetTitle(makeReq({ title: "x" }, AUTH), res as any);
85
+ expect(res.statusCode).toBe(400);
86
+ });
87
+
88
+ it("writes displayName for the resolved (case-insensitive) store key", async () => {
89
+ setForward({ "agent:main:friday:direct:abcd:9": { sessionId: "sess-1" } });
90
+ const res = new MockRes();
91
+ await handleHistorySetTitle(
92
+ makeReq({ sessionKey: "agent:main:friday:direct:ABCD:9", title: "My Chat" }, AUTH),
93
+ res as any,
94
+ );
95
+ expect(res.statusCode).toBe(200);
96
+ expect(captured?.sessionKey).toBe("agent:main:friday:direct:abcd:9");
97
+ expect(captured?.patch).toEqual({ displayName: "My Chat" });
98
+ const body = JSON.parse(res.body);
99
+ expect(body).toMatchObject({ ok: true, sessionId: "sess-1", title: "My Chat" });
100
+ });
101
+
102
+ it("404s when the session is unknown", async () => {
103
+ setForward({});
104
+ const res = new MockRes();
105
+ await handleHistorySetTitle(makeReq({ sessionKey: "agent:main:nope", title: "x" }, AUTH), res as any);
106
+ expect(res.statusCode).toBe(404);
107
+ });
108
+
109
+ it("503s when the store writer is unavailable", async () => {
110
+ setForward({ "agent:main:main": { sessionId: "s" } }, false);
111
+ const res = new MockRes();
112
+ await handleHistorySetTitle(makeReq({ sessionKey: "agent:main:main", title: "x" }, AUTH), res as any);
113
+ expect(res.statusCode).toBe(503);
114
+ });
115
+ })
@@ -0,0 +1,86 @@
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
+
10
+ import type { IncomingMessage, ServerResponse } from "node:http";
11
+ import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
12
+ import { extractBearerToken } from "../middleware/auth.js";
13
+ import { readJsonBody } from "../middleware/body.js";
14
+ import { agentIdFromSessionKey, toSessionStoreKey } from "../../session/session-manager.js";
15
+
16
+ function json(res: ServerResponse, status: number, body: unknown): true {
17
+ res.statusCode = status;
18
+ res.setHeader("Content-Type", "application/json");
19
+ res.end(JSON.stringify(body));
20
+ return true;
21
+ }
22
+
23
+ /** Real store key matching `sessionKey`, tolerating deviceId case differences. */
24
+ function resolveStoreKey(store: Record<string, unknown>, sessionKey: string): string | undefined {
25
+ if (store[sessionKey]) return sessionKey;
26
+ const canonical = toSessionStoreKey(sessionKey);
27
+ if (store[canonical]) return canonical;
28
+ const target = canonical.toLowerCase();
29
+ for (const k of Object.keys(store)) {
30
+ if (k.toLowerCase() === target) return k;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ export async function handleHistorySetTitle(
36
+ req: IncomingMessage,
37
+ res: ServerResponse,
38
+ ): Promise<boolean> {
39
+ if (req.method !== "PUT" && req.method !== "POST") {
40
+ return json(res, 405, { error: "Method Not Allowed" });
41
+ }
42
+ if (!extractBearerToken(req)) {
43
+ return json(res, 401, { error: "Unauthorized: bearer token mismatch" });
44
+ }
45
+
46
+ const body = (await readJsonBody(req)) as { sessionKey?: unknown; title?: unknown } | null;
47
+ const sessionKey = typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "";
48
+ const title = typeof body?.title === "string" ? body.title.trim() : "";
49
+ if (!sessionKey) {
50
+ return json(res, 400, { error: "Missing required field: sessionKey" });
51
+ }
52
+
53
+ const rt = getFridayAgentForwardRuntime();
54
+ if (!rt?.updateSessionStoreEntry) {
55
+ return json(res, 503, { error: "Session store write not available" });
56
+ }
57
+
58
+ const agentId = agentIdFromSessionKey(sessionKey);
59
+ let storePath: string;
60
+ let store: Record<string, unknown>;
61
+ try {
62
+ storePath = rt.resolveStorePath(undefined, { agentId });
63
+ store = rt.loadSessionStore(storePath) ?? {};
64
+ } catch {
65
+ return json(res, 500, { error: "Failed to load session store" });
66
+ }
67
+
68
+ const storeKey = resolveStoreKey(store, sessionKey);
69
+ if (!storeKey) {
70
+ return json(res, 404, { error: `Session not found: ${sessionKey}` });
71
+ }
72
+
73
+ try {
74
+ const updated = await rt.updateSessionStoreEntry({
75
+ storePath,
76
+ sessionKey: storeKey,
77
+ // Empty title clears the override so the server can derive its own again.
78
+ update: () => ({ displayName: title || undefined }),
79
+ });
80
+ const sessionId =
81
+ updated && typeof updated.sessionId === "string" ? updated.sessionId : undefined;
82
+ return json(res, 200, { ok: true, sessionKey, ...(sessionId ? { sessionId } : {}), title });
83
+ } catch {
84
+ return json(res, 500, { error: "Failed to update session title" });
85
+ }
86
+ }
@@ -16,6 +16,9 @@ import { handleNodesApprove } from "./handlers/nodes-approve.js";
16
16
  import { handleSessionsSettings } from "./handlers/sessions-settings.js";
17
17
  import { handleModelsList } from "./handlers/models-list.js";
18
18
  import { handleAgentsList } from "./handlers/agents-list.js";
19
+ import { handleHistorySessions } from "./handlers/history-sessions.js";
20
+ import { handleHistoryMessages } from "./handlers/history-messages.js";
21
+ import { handleHistorySetTitle } from "./handlers/history-set-title.js";
19
22
  import { handleStatus } from "./handlers/status.js";
20
23
  import { handleHealth } from "./handlers/health.js";
21
24
  import { applyCorsHeaders } from "./middleware/cors.js";
@@ -86,6 +89,21 @@ async function handleFridayNextRoute(
86
89
  return await handleStatus(req, res);
87
90
  }
88
91
 
92
+ // Route: GET /friday-next/history/sessions (list all sessions across agents)
93
+ if (req.method === "GET" && pathname === "/friday-next/history/sessions") {
94
+ return await handleHistorySessions(req, res);
95
+ }
96
+
97
+ // Route: GET /friday-next/history/messages?sessionKey=&agentId=&limit=
98
+ if (req.method === "GET" && pathname === "/friday-next/history/messages") {
99
+ return await handleHistoryMessages(req, res);
100
+ }
101
+
102
+ // Route: PUT /friday-next/sessions/title (sync app session name → server displayName)
103
+ if ((req.method === "PUT" || req.method === "POST") && pathname === "/friday-next/sessions/title") {
104
+ return await handleHistorySetTitle(req, res);
105
+ }
106
+
89
107
  // Route: GET /friday-next/health?deviceId=...&nodeDeviceId=...&selfHeal=true
90
108
  if (req.method === "GET" && pathname === "/friday-next/health") {
91
109
  return await handleHealth(req, res);
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { setFridayNextRuntime } from "../runtime.js";
5
5
  import { setOfflineQueueBaseDirForTest } from "../sse/offline-queue.js";
6
+ import { setAttachmentsDirForTest } from "../http/handlers/files.js";
6
7
  import { sseEmitter } from "../sse/emitter.js";
7
8
  import { resetActiveRunsForTest } from "../agent/active-runs.js";
8
9
  import { resetRunMetadataForTest } from "../run-metadata.js";
@@ -36,6 +37,7 @@ export function setMockRuntime(opts: MockRuntimeOptions = {}): void {
36
37
  resetSubagentRegistryForTest();
37
38
  const historyDir = opts.historyDir ?? createTempHistoryDir();
38
39
  setOfflineQueueBaseDirForTest(path.join(historyDir, "events-queue"));
40
+ setAttachmentsDirForTest(path.join(historyDir, "attachments"));
39
41
  const cfg = {
40
42
  gateway: {
41
43
  auth: {