@syengup/friday-channel-next 0.1.24 → 0.1.26

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.
@@ -9,6 +9,7 @@
9
9
  * `runtime.subagent.getSessionMessages`), which already resolves the active
10
10
  * branch and compaction, then normalizes each raw message into a stable DTO.
11
11
  */
12
+ import { fileURLToPath } from "node:url";
12
13
  import { getFridayNextRuntime } from "../../runtime.js";
13
14
  import { extractBearerToken } from "../middleware/auth.js";
14
15
  import { normalizeHistoryMessages } from "../../history/normalize-message.js";
@@ -17,6 +18,28 @@ import { resolveMediaAttachment } from "./files.js";
17
18
  import { readSessionUsageSnapshotFromStore } from "../../session-usage-store.js";
18
19
  const DEFAULT_LIMIT = 200;
19
20
  const MAX_LIMIT = 1000;
21
+ /**
22
+ * For an `images[].url` produced from a `[media attached: …]` marker: returns the
23
+ * server-local filesystem path to resolve (`file://…` or a bare absolute path), or
24
+ * null to leave the image untouched. Already-served `/friday-next/files/…` URLs and
25
+ * remote `http(s)://` / `data:` URLs are NOT local paths — never feed them to
26
+ * `resolveMediaAttachment` (it would mis-treat them as paths and break valid URLs).
27
+ */
28
+ function serverLocalPathForImageUrl(url) {
29
+ if (url.startsWith("file://")) {
30
+ try {
31
+ return fileURLToPath(url);
32
+ }
33
+ catch {
34
+ return url.slice("file://".length);
35
+ }
36
+ }
37
+ if (url.startsWith("/friday-next/files/"))
38
+ return null;
39
+ if (url.startsWith("/"))
40
+ return url;
41
+ return null;
42
+ }
20
43
  function resolveSubagentApi() {
21
44
  try {
22
45
  const runtime = getFridayNextRuntime();
@@ -75,16 +98,34 @@ export async function handleHistoryMessages(req, res) {
75
98
  // (copies the file into the plugin's attachments/ dir — the same mechanism the
76
99
  // live deliver path uses), then drop the raw paths from the wire.
77
100
  for (const message of messages) {
78
- if (!message.mediaPaths?.length)
79
- continue;
80
- const resolved = message.mediaPaths
81
- .map((p) => resolveMediaAttachment(p))
82
- .filter((r) => Boolean(r))
83
- .map((r) => ({ url: r.url, filename: r.fileName }));
84
- if (resolved.length) {
85
- message.images = [...(message.images ?? []), ...resolved];
101
+ if (message.mediaPaths?.length) {
102
+ const resolved = message.mediaPaths
103
+ .map((p) => resolveMediaAttachment(p))
104
+ .filter((r) => Boolean(r))
105
+ .map((r) => ({ url: r.url, filename: r.fileName }));
106
+ if (resolved.length) {
107
+ message.images = [...(message.images ?? []), ...resolved];
108
+ }
109
+ delete message.mediaPaths;
110
+ }
111
+ // User attachments arrive as `[media attached: file://<server-path>]` markers,
112
+ // which normalize-message extracts into images[].url as a RAW server-local path.
113
+ // Unlike MEDIA: paths (resolved above), these were never copied into the file
114
+ // store, so the app would try to load a path that only exists on the gateway host
115
+ // and the attachment bubble is lost on history sync. Resolve them the same way.
116
+ if (message.images?.length) {
117
+ message.images = message.images.map((img) => {
118
+ if (!img.url || img.data)
119
+ return img;
120
+ const local = serverLocalPathForImageUrl(img.url);
121
+ if (!local)
122
+ return img;
123
+ const resolved = resolveMediaAttachment(local);
124
+ if (!resolved)
125
+ return img;
126
+ return { ...img, url: resolved.url, filename: img.filename ?? resolved.fileName };
127
+ });
86
128
  }
87
- delete message.mediaPaths;
88
129
  }
89
130
  const sessionId = resolveSessionId(sessionKey);
90
131
  // Cumulative session-usage snapshot (model + context window/used) read from the
@@ -110,10 +110,14 @@ function readAgentSessions(agentId) {
110
110
  continue;
111
111
  const entry = rawEntry;
112
112
  const canonicalKey = toCanonicalSessionKey(agentId, storeKey);
113
- // Drop internal/system sessions and subagent links.
113
+ // Drop internal/system sessions and subagent links. We key only on
114
+ // `spawnedBy`/`subagentRole` (plus the `subagent:` prefix in
115
+ // isInternalSessionKey) — NOT on `parentSessionKey`, which real user
116
+ // conversations (e.g. webchat sessions branched off another session) also
117
+ // carry and which would otherwise be wrongly excluded from the sidebar.
114
118
  if (isInternalSessionKey(canonicalKey, storeKey))
115
119
  continue;
116
- if (entry.spawnedBy || entry.subagentRole || entry.parentSessionKey)
120
+ if (entry.spawnedBy || entry.subagentRole)
117
121
  continue;
118
122
  // Drop archived/empty sessions (transcript moved away or never written).
119
123
  if (!hasLiveTranscript(entry, storePath))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -170,6 +170,39 @@ describe("handleHistoryMessages", () => {
170
170
  expect(body.messages[0].text).toBe("from app");
171
171
  });
172
172
 
173
+ it("resolves user [media attached: file://] markers into downloadable /friday-next/files URLs", async () => {
174
+ // The server-local source the marker points at (only exists on the gateway host).
175
+ const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), "friday-inbound-"));
176
+ const srcFile = path.join(srcDir, "ce1ff405-28ad-48b9-b4a7-4f2228d77649.jpg");
177
+ fs.writeFileSync(srcFile, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]));
178
+ const file = writeTranscript("media.jsonl", [
179
+ {
180
+ type: "message",
181
+ id: "u1",
182
+ message: {
183
+ role: "user",
184
+ content: `你见过这个可乐吗?\n\n[media attached: file://${srcFile}]`,
185
+ },
186
+ },
187
+ ]);
188
+ setForward({ "agent:main:main": { sessionId: "s", sessionFile: file } });
189
+
190
+ const res = new MockRes();
191
+ await handleHistoryMessages(
192
+ makeReq("/friday-next/history/messages?sessionKey=agent:main:main", AUTH),
193
+ res as any,
194
+ );
195
+ expect(res.statusCode).toBe(200);
196
+ const body = JSON.parse(res.body);
197
+ const userMsg = body.messages.find((m: any) => m.role === "user");
198
+ expect(userMsg.images?.length).toBe(1);
199
+ // The raw server-local file:// path must be resolved to a gateway-served URL the
200
+ // app can actually download — otherwise the attachment bubble is lost on history sync.
201
+ expect(userMsg.images[0].url.startsWith("file://")).toBe(false);
202
+ expect(userMsg.images[0].url.startsWith("/friday-next/files/")).toBe(true);
203
+ fs.rmSync(srcDir, { recursive: true, force: true });
204
+ });
205
+
173
206
  it("falls back to getSessionMessages when the transcript is not on disk", async () => {
174
207
  setForward({}); // no entry → disk read yields nothing
175
208
  setRuntime(async () => ({
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { IncomingMessage, ServerResponse } from "node:http";
14
+ import { fileURLToPath } from "node:url";
14
15
  import { getFridayNextRuntime } from "../../runtime.js";
15
16
  import { extractBearerToken } from "../middleware/auth.js";
16
17
  import { normalizeHistoryMessages } from "../../history/normalize-message.js";
@@ -28,6 +29,26 @@ type SubagentSessionApi = {
28
29
  }) => Promise<{ messages?: unknown[] }>;
29
30
  };
30
31
 
32
+ /**
33
+ * For an `images[].url` produced from a `[media attached: …]` marker: returns the
34
+ * server-local filesystem path to resolve (`file://…` or a bare absolute path), or
35
+ * null to leave the image untouched. Already-served `/friday-next/files/…` URLs and
36
+ * remote `http(s)://` / `data:` URLs are NOT local paths — never feed them to
37
+ * `resolveMediaAttachment` (it would mis-treat them as paths and break valid URLs).
38
+ */
39
+ function serverLocalPathForImageUrl(url: string): string | null {
40
+ if (url.startsWith("file://")) {
41
+ try {
42
+ return fileURLToPath(url);
43
+ } catch {
44
+ return url.slice("file://".length);
45
+ }
46
+ }
47
+ if (url.startsWith("/friday-next/files/")) return null;
48
+ if (url.startsWith("/")) return url;
49
+ return null;
50
+ }
51
+
31
52
  function resolveSubagentApi(): SubagentSessionApi | undefined {
32
53
  try {
33
54
  const runtime = getFridayNextRuntime();
@@ -95,15 +116,32 @@ export async function handleHistoryMessages(
95
116
  // (copies the file into the plugin's attachments/ dir — the same mechanism the
96
117
  // live deliver path uses), then drop the raw paths from the wire.
97
118
  for (const message of messages) {
98
- if (!message.mediaPaths?.length) continue;
99
- const resolved = message.mediaPaths
100
- .map((p) => resolveMediaAttachment(p))
101
- .filter((r): r is NonNullable<typeof r> => Boolean(r))
102
- .map((r) => ({ url: r.url, filename: r.fileName }));
103
- if (resolved.length) {
104
- message.images = [...(message.images ?? []), ...resolved];
119
+ if (message.mediaPaths?.length) {
120
+ const resolved = message.mediaPaths
121
+ .map((p) => resolveMediaAttachment(p))
122
+ .filter((r): r is NonNullable<typeof r> => Boolean(r))
123
+ .map((r) => ({ url: r.url, filename: r.fileName }));
124
+ if (resolved.length) {
125
+ message.images = [...(message.images ?? []), ...resolved];
126
+ }
127
+ delete message.mediaPaths;
128
+ }
129
+
130
+ // User attachments arrive as `[media attached: file://<server-path>]` markers,
131
+ // which normalize-message extracts into images[].url as a RAW server-local path.
132
+ // Unlike MEDIA: paths (resolved above), these were never copied into the file
133
+ // store, so the app would try to load a path that only exists on the gateway host
134
+ // and the attachment bubble is lost on history sync. Resolve them the same way.
135
+ if (message.images?.length) {
136
+ message.images = message.images.map((img) => {
137
+ if (!img.url || img.data) return img;
138
+ const local = serverLocalPathForImageUrl(img.url);
139
+ if (!local) return img;
140
+ const resolved = resolveMediaAttachment(local);
141
+ if (!resolved) return img;
142
+ return { ...img, url: resolved.url, filename: img.filename ?? resolved.fileName };
143
+ });
105
144
  }
106
- delete message.mediaPaths;
107
145
  }
108
146
 
109
147
  const sessionId = resolveSessionId(sessionKey);
@@ -143,4 +143,34 @@ describe("handleHistorySessions", () => {
143
143
  const keys = JSON.parse(res.body).sessions.map((s: any) => s.sessionKey);
144
144
  expect(keys).toEqual(["agent:main:main"]);
145
145
  });
146
+
147
+ it("keeps real conversations that merely carry a parentSessionKey", async () => {
148
+ setForward(
149
+ { agents: { list: [{ id: "main" }] } },
150
+ {
151
+ main: {
152
+ // Webchat session branched off another session: has parentSessionKey
153
+ // but is a genuine user conversation — must be surfaced.
154
+ "agent:main:dashboard:b91ad945": {
155
+ sessionId: "wc",
156
+ updatedAt: 9,
157
+ parentSessionKey: "agent:main:fridaynext:mq5zn7dp",
158
+ sessionFile: transcript("wc.jsonl"),
159
+ },
160
+ // Real subagent fork (spawnedBy) must still be filtered.
161
+ "agent:main:fork": {
162
+ sessionId: "fk",
163
+ updatedAt: 8,
164
+ spawnedBy: "agent:main:main",
165
+ parentSessionKey: "agent:main:main",
166
+ sessionFile: transcript("fk.jsonl"),
167
+ },
168
+ },
169
+ },
170
+ );
171
+ const res = new MockRes();
172
+ await handleHistorySessions(makeReq(AUTH), res as any);
173
+ const keys = JSON.parse(res.body).sessions.map((s: any) => s.sessionKey);
174
+ expect(keys).toEqual(["agent:main:dashboard:b91ad945"]);
175
+ });
146
176
  });
@@ -125,9 +125,13 @@ function readAgentSessions(agentId: string): FridayHistorySessionSummary[] {
125
125
  const entry = rawEntry as Record<string, unknown>;
126
126
  const canonicalKey = toCanonicalSessionKey(agentId, storeKey);
127
127
 
128
- // Drop internal/system sessions and subagent links.
128
+ // Drop internal/system sessions and subagent links. We key only on
129
+ // `spawnedBy`/`subagentRole` (plus the `subagent:` prefix in
130
+ // isInternalSessionKey) — NOT on `parentSessionKey`, which real user
131
+ // conversations (e.g. webchat sessions branched off another session) also
132
+ // carry and which would otherwise be wrongly excluded from the sidebar.
129
133
  if (isInternalSessionKey(canonicalKey, storeKey)) continue;
130
- if (entry.spawnedBy || entry.subagentRole || entry.parentSessionKey) continue;
134
+ if (entry.spawnedBy || entry.subagentRole) continue;
131
135
  // Drop archived/empty sessions (transcript moved away or never written).
132
136
  if (!hasLiveTranscript(entry, storePath)) continue;
133
137