@syengup/friday-channel-next 0.1.13 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agent-forward-runtime.d.ts +6 -0
- package/dist/src/agent-forward-runtime.js +2 -0
- package/dist/src/channel-actions.js +9 -1
- package/dist/src/channel.js +17 -4
- package/dist/src/history/normalize-message.d.ts +67 -0
- package/dist/src/history/normalize-message.js +224 -0
- package/dist/src/history/read-transcript.d.ts +22 -0
- package/dist/src/history/read-transcript.js +136 -0
- package/dist/src/http/handlers/files.d.ts +3 -2
- package/dist/src/http/handlers/files.js +20 -5
- package/dist/src/http/handlers/history-messages.d.ts +13 -0
- package/dist/src/http/handlers/history-messages.js +100 -0
- package/dist/src/http/handlers/history-sessions.d.ts +23 -0
- package/dist/src/http/handlers/history-sessions.js +163 -0
- package/dist/src/http/handlers/history-set-title.d.ts +10 -0
- package/dist/src/http/handlers/history-set-title.js +77 -0
- package/dist/src/http/server.js +15 -0
- package/package.json +10 -11
- package/src/agent-forward-runtime.ts +10 -0
- package/src/channel-actions.test.ts +111 -0
- package/src/channel-actions.ts +10 -1
- package/src/channel.outbound.test.ts +137 -0
- package/src/channel.ts +24 -6
- package/src/history/normalize-message.test.ts +154 -0
- package/src/history/normalize-message.ts +292 -0
- package/src/history/read-transcript.ts +136 -0
- package/src/http/handlers/files.ts +21 -5
- package/src/http/handlers/history-messages.test.ts +144 -0
- package/src/http/handlers/history-messages.ts +123 -0
- package/src/http/handlers/history-sessions.test.ts +146 -0
- package/src/http/handlers/history-sessions.ts +184 -0
- package/src/http/handlers/history-set-title.test.ts +115 -0
- package/src/http/handlers/history-set-title.ts +86 -0
- package/src/http/server.ts +18 -0
- package/src/test-support/mock-runtime.ts +2 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { fridayNextChannelPlugin } from "./channel.js";
|
|
6
|
+
import { sseEmitter } from "./sse/emitter.js";
|
|
7
|
+
import { setOfflineQueueBaseDirForTest } from "./sse/offline-queue.js";
|
|
8
|
+
import { registerRunRoute } from "./run-metadata.js";
|
|
9
|
+
import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "./test-support/mock-runtime.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Outbound (message-tool send) must route to the session that started the run.
|
|
13
|
+
*
|
|
14
|
+
* OpenClaw's `ChannelOutboundContext` does not carry the originating sessionKey, so the channel
|
|
15
|
+
* recovers it from the run-route registry via the device's last tracked runId. Without this the
|
|
16
|
+
* media/text would land in a device-level fallback session, not the user's current session.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
class MockRes extends EventEmitter {
|
|
20
|
+
writes: string[] = [];
|
|
21
|
+
write(chunk: string): boolean {
|
|
22
|
+
this.writes.push(chunk);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
end(): void {
|
|
26
|
+
// no-op
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type OutboundFrame = { type: string; data: Record<string, unknown> };
|
|
31
|
+
|
|
32
|
+
function parseOutboundFrames(res: MockRes): OutboundFrame[] {
|
|
33
|
+
const frames: OutboundFrame[] = [];
|
|
34
|
+
for (const block of res.writes.join("").split("\n\n")) {
|
|
35
|
+
if (!block.trim()) continue;
|
|
36
|
+
let type = "";
|
|
37
|
+
let data: Record<string, unknown> | undefined;
|
|
38
|
+
for (const line of block.split("\n")) {
|
|
39
|
+
if (line.startsWith("event: ")) type = line.slice("event: ".length).trim();
|
|
40
|
+
else if (line.startsWith("data: ")) {
|
|
41
|
+
try {
|
|
42
|
+
data = JSON.parse(line.slice("data: ".length));
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore non-JSON
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (data) frames.push({ type, data });
|
|
49
|
+
}
|
|
50
|
+
return frames;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const outbound = fridayNextChannelPlugin.outbound as {
|
|
54
|
+
sendText: (ctx: Record<string, unknown>) => Promise<unknown>;
|
|
55
|
+
sendMedia: (ctx: Record<string, unknown>) => Promise<unknown>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
describe("friday-next channel outbound sessionKey routing", () => {
|
|
59
|
+
let historyDir = "";
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
sseEmitter.resetForTest();
|
|
63
|
+
historyDir = createTempHistoryDir();
|
|
64
|
+
setOfflineQueueBaseDirForTest(historyDir);
|
|
65
|
+
setMockRuntime({ historyDir, authToken: "test-token" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
setOfflineQueueBaseDirForTest(null);
|
|
70
|
+
removeTempHistoryDir(historyDir);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function connect(deviceId: string): MockRes {
|
|
74
|
+
const res = new MockRes();
|
|
75
|
+
sseEmitter.addConnection(deviceId, res as never);
|
|
76
|
+
return res;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
it("sendText carries the run's sessionKey (recovered via run-route)", async () => {
|
|
80
|
+
const deviceId = "DEV-TEXT-1";
|
|
81
|
+
const runId = "run-text-1";
|
|
82
|
+
const sessionKey = "agent:operator:friday-next:direct:abc-text";
|
|
83
|
+
registerRunRoute({ runId, deviceId, sessionKey });
|
|
84
|
+
sseEmitter.trackDeviceForRun(deviceId, runId);
|
|
85
|
+
const res = connect(deviceId);
|
|
86
|
+
|
|
87
|
+
await outbound.sendText({ to: deviceId, text: "hi" });
|
|
88
|
+
|
|
89
|
+
const evt = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "text");
|
|
90
|
+
expect(evt).toBeDefined();
|
|
91
|
+
expect(evt?.data.sessionKey).toBe(sessionKey);
|
|
92
|
+
expect(evt?.data.deviceId).toBe(deviceId);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("run-route wins over ctx sessionKey (ctx carries the agent's base/main session, not the active app session)", async () => {
|
|
96
|
+
const deviceId = "DEV-TEXT-2";
|
|
97
|
+
const runId = "run-text-2";
|
|
98
|
+
registerRunRoute({ runId, deviceId, sessionKey: "agent:operator:friday-next:direct:route-session" });
|
|
99
|
+
sseEmitter.trackDeviceForRun(deviceId, runId);
|
|
100
|
+
const res = connect(deviceId);
|
|
101
|
+
|
|
102
|
+
await outbound.sendText({ to: deviceId, text: "hi", requesterSessionKey: "agent:operator:main" });
|
|
103
|
+
|
|
104
|
+
const evt = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "text");
|
|
105
|
+
expect(evt?.data.sessionKey).toBe("agent:operator:friday-next:direct:route-session");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("falls back to device-level session when no run-route exists", async () => {
|
|
109
|
+
const deviceId = "DEV-TEXT-3";
|
|
110
|
+
sseEmitter.trackDeviceForRun(deviceId, "run-text-3-untracked");
|
|
111
|
+
const res = connect(deviceId);
|
|
112
|
+
|
|
113
|
+
await outbound.sendText({ to: deviceId, text: "hi" });
|
|
114
|
+
|
|
115
|
+
const evt = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "text");
|
|
116
|
+
// No mapping registered for this device → synthesized device-level fallback.
|
|
117
|
+
expect(evt?.data.sessionKey).toBe(`agent:main:friday-next-${deviceId}`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("sendMedia carries the run's sessionKey (recovered via run-route)", async () => {
|
|
121
|
+
const deviceId = "DEV-MEDIA-1";
|
|
122
|
+
const runId = "run-media-1";
|
|
123
|
+
const sessionKey = "agent:operator:friday-next:direct:abc-media";
|
|
124
|
+
const mediaFile = path.join(historyDir, "shot.png");
|
|
125
|
+
fs.writeFileSync(mediaFile, "png-bytes");
|
|
126
|
+
registerRunRoute({ runId, deviceId, sessionKey });
|
|
127
|
+
sseEmitter.trackDeviceForRun(deviceId, runId);
|
|
128
|
+
const res = connect(deviceId);
|
|
129
|
+
|
|
130
|
+
await outbound.sendMedia({ to: deviceId, text: "caption", mediaUrl: mediaFile });
|
|
131
|
+
|
|
132
|
+
const evt = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "media");
|
|
133
|
+
expect(evt).toBeDefined();
|
|
134
|
+
expect(evt?.data.sessionKey).toBe(sessionKey);
|
|
135
|
+
expect(evt?.data.deviceId).toBe(deviceId);
|
|
136
|
+
});
|
|
137
|
+
});
|
package/src/channel.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
resolveFridayDeviceIdForOutbound,
|
|
20
20
|
resolveHistorySessionKeyForFridayDevice,
|
|
21
21
|
} from "./friday-session.js";
|
|
22
|
+
import { getRunRoute } from "./run-metadata.js";
|
|
22
23
|
import { getLastFridayInboundAt } from "./friday-inbound-stats.js";
|
|
23
24
|
|
|
24
25
|
const logger = createFridayNextLogger("channel");
|
|
@@ -32,6 +33,27 @@ function pickFirstString(source: Record<string, unknown>, keys: string[]): strin
|
|
|
32
33
|
return undefined;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the sessionKey for an outbound message-tool send.
|
|
38
|
+
*
|
|
39
|
+
* OpenClaw's `ChannelOutboundContext` does not carry the originating run's sessionKey, so the raw
|
|
40
|
+
* ctx is almost always missing it. We recover the current run's session via the run-route registry
|
|
41
|
+
* (keyed by the device's last tracked runId) so message-tool text/media land in the session that
|
|
42
|
+
* triggered the run — not a device-level fallback. Falls back to the device's latest history session
|
|
43
|
+
* for cron / offline / no-run paths.
|
|
44
|
+
*/
|
|
45
|
+
function resolveOutboundSessionKey(
|
|
46
|
+
deviceId: string,
|
|
47
|
+
runId: string | undefined,
|
|
48
|
+
rawCtx: Record<string, unknown>,
|
|
49
|
+
): string | undefined {
|
|
50
|
+
return (
|
|
51
|
+
(runId ? getRunRoute(runId)?.sessionKey : undefined) ??
|
|
52
|
+
pickFirstString(rawCtx, ["requesterSessionKey", "sessionKey"]) ??
|
|
53
|
+
resolveHistorySessionKeyForFridayDevice(deviceId)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
35
57
|
function resolveLocalMediaPath(mediaUrl: string, localRoots?: string[]): string {
|
|
36
58
|
if (path.isAbsolute(mediaUrl)) return mediaUrl;
|
|
37
59
|
const roots = localRoots ?? [process.cwd(), os.tmpdir()];
|
|
@@ -153,9 +175,7 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
|
|
|
153
175
|
"runId",
|
|
154
176
|
]);
|
|
155
177
|
const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
|
|
156
|
-
const sessionKey =
|
|
157
|
-
pickFirstString(rawCtx, ["requesterSessionKey", "sessionKey"]) ??
|
|
158
|
-
resolveHistorySessionKeyForFridayDevice(deviceId);
|
|
178
|
+
const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
|
|
159
179
|
|
|
160
180
|
const conn = sseEmitter.getConnection(deviceId);
|
|
161
181
|
const ts = new Date().toISOString();
|
|
@@ -203,9 +223,7 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
|
|
|
203
223
|
"runId",
|
|
204
224
|
]);
|
|
205
225
|
const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
|
|
206
|
-
const sessionKey =
|
|
207
|
-
pickFirstString(rawCtx, ["requesterSessionKey", "sessionKey"]) ??
|
|
208
|
-
resolveHistorySessionKeyForFridayDevice(deviceId);
|
|
226
|
+
const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
|
|
209
227
|
const audioAsVoice = ctx.audioAsVoice === true;
|
|
210
228
|
const caption = ctx.text ?? "";
|
|
211
229
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
normalizeHistoryMessage,
|
|
4
|
+
normalizeHistoryMessages,
|
|
5
|
+
} from "./normalize-message.js";
|
|
6
|
+
|
|
7
|
+
function meta(id?: string, seq = 1, extra: Record<string, unknown> = {}) {
|
|
8
|
+
return { __openclaw: { ...(id ? { id } : {}), seq, recordTimestampMs: 1700000000000, ...extra } };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("normalizeHistoryMessage", () => {
|
|
12
|
+
it("returns null for non-object input", () => {
|
|
13
|
+
expect(normalizeHistoryMessage("nope", 0)).toBeNull();
|
|
14
|
+
expect(normalizeHistoryMessage(null, 0)).toBeNull();
|
|
15
|
+
expect(normalizeHistoryMessage([], 0)).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("normalizes a string-content user message and carries the stable id + ts", () => {
|
|
19
|
+
const out = normalizeHistoryMessage(
|
|
20
|
+
{ role: "user", content: "hello", ...meta("entry-1", 3) },
|
|
21
|
+
7,
|
|
22
|
+
);
|
|
23
|
+
expect(out).toMatchObject({
|
|
24
|
+
id: "entry-1",
|
|
25
|
+
seq: 3,
|
|
26
|
+
ts: 1700000000000,
|
|
27
|
+
role: "user",
|
|
28
|
+
text: "hello",
|
|
29
|
+
});
|
|
30
|
+
expect(out?.synthetic).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("synthesizes an id and flags synthetic when upstream omits __openclaw.id", () => {
|
|
34
|
+
const out = normalizeHistoryMessage({ role: "user", content: "hi", __openclaw: { seq: 5 } }, 0);
|
|
35
|
+
expect(out?.id).toBe("seq:5");
|
|
36
|
+
expect(out?.synthetic).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("falls back to batch index for seq when missing", () => {
|
|
40
|
+
const out = normalizeHistoryMessage({ role: "user", content: "hi" }, 4);
|
|
41
|
+
expect(out?.seq).toBe(5);
|
|
42
|
+
expect(out?.id).toBe("seq:5");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("parses assistant text + thinking + toolCall blocks and model/usage", () => {
|
|
46
|
+
const out = normalizeHistoryMessage(
|
|
47
|
+
{
|
|
48
|
+
role: "assistant",
|
|
49
|
+
model: "openai/gpt-4",
|
|
50
|
+
usage: { input: 10, output: 20, totalTokens: 30 },
|
|
51
|
+
content: [
|
|
52
|
+
{ type: "thinking", thinking: "let me think" },
|
|
53
|
+
{ type: "text", text: "the answer" },
|
|
54
|
+
{ type: "toolCall", id: "tc-1", name: "search", arguments: { q: "x" } },
|
|
55
|
+
],
|
|
56
|
+
...meta("entry-2", 2),
|
|
57
|
+
},
|
|
58
|
+
0,
|
|
59
|
+
);
|
|
60
|
+
expect(out?.text).toBe("the answer");
|
|
61
|
+
expect(out?.thinking).toBe("let me think");
|
|
62
|
+
expect(out?.toolCalls).toEqual([{ id: "tc-1", name: "search", arguments: { q: "x" } }]);
|
|
63
|
+
expect(out?.model).toBe("openai/gpt-4");
|
|
64
|
+
expect(out?.usage).toEqual({ input: 10, output: 20, totalTokens: 30 });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("parses inline image content blocks", () => {
|
|
68
|
+
const out = normalizeHistoryMessage(
|
|
69
|
+
{
|
|
70
|
+
role: "user",
|
|
71
|
+
content: [
|
|
72
|
+
{ type: "text", text: "look" },
|
|
73
|
+
{ type: "image", mimeType: "image/png", data: "BASE64" },
|
|
74
|
+
],
|
|
75
|
+
...meta("entry-img", 1),
|
|
76
|
+
},
|
|
77
|
+
0,
|
|
78
|
+
);
|
|
79
|
+
expect(out?.text).toBe("look");
|
|
80
|
+
expect(out?.images).toEqual([{ mimeType: "image/png", data: "BASE64" }]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("extracts [media attached: ...] markers from text into image urls", () => {
|
|
84
|
+
const out = normalizeHistoryMessage(
|
|
85
|
+
{ role: "user", content: "see [media attached: file:///a.jpg]", ...meta("m", 1) },
|
|
86
|
+
0,
|
|
87
|
+
);
|
|
88
|
+
expect(out?.images).toEqual([{ url: "file:///a.jpg" }]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("normalizes a toolResult message", () => {
|
|
92
|
+
const out = normalizeHistoryMessage(
|
|
93
|
+
{
|
|
94
|
+
role: "toolResult",
|
|
95
|
+
toolCallId: "tc-1",
|
|
96
|
+
toolName: "search",
|
|
97
|
+
isError: false,
|
|
98
|
+
content: [{ type: "text", text: "result text" }],
|
|
99
|
+
...meta("entry-tr", 4),
|
|
100
|
+
},
|
|
101
|
+
0,
|
|
102
|
+
);
|
|
103
|
+
expect(out?.role).toBe("toolResult");
|
|
104
|
+
expect(out?.toolResult).toEqual({
|
|
105
|
+
toolCallId: "tc-1",
|
|
106
|
+
toolName: "search",
|
|
107
|
+
text: "result text",
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("strips MEDIA: lines from text into mediaPaths", () => {
|
|
112
|
+
const out = normalizeHistoryMessage(
|
|
113
|
+
{
|
|
114
|
+
role: "assistant",
|
|
115
|
+
content: "Here is the serene landscape 🌅\nMEDIA:/Users/me/.openclaw/media/tool-image-generation/x.png",
|
|
116
|
+
...meta("a1", 1),
|
|
117
|
+
},
|
|
118
|
+
0,
|
|
119
|
+
);
|
|
120
|
+
expect(out?.text).toBe("Here is the serene landscape 🌅");
|
|
121
|
+
expect(out?.mediaPaths).toEqual(["/Users/me/.openclaw/media/tool-image-generation/x.png"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("captures multiple MEDIA: lines and leaves text without markers untouched", () => {
|
|
125
|
+
const out = normalizeHistoryMessage(
|
|
126
|
+
{ role: "assistant", content: "two files\nMEDIA:/a/x.png\nMEDIA:/a/y.mp4", ...meta("a2", 1) },
|
|
127
|
+
0,
|
|
128
|
+
);
|
|
129
|
+
expect(out?.text).toBe("two files");
|
|
130
|
+
expect(out?.mediaPaths).toEqual(["/a/x.png", "/a/y.mp4"]);
|
|
131
|
+
const plain = normalizeHistoryMessage({ role: "user", content: "no media here", ...meta("u", 1) }, 0);
|
|
132
|
+
expect(plain?.mediaPaths).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("flags compaction records via __openclaw.kind", () => {
|
|
136
|
+
const out = normalizeHistoryMessage(
|
|
137
|
+
{ role: "system", content: [{ type: "text", text: "Compaction" }], ...meta("c1", 9, { kind: "compaction" }) },
|
|
138
|
+
0,
|
|
139
|
+
);
|
|
140
|
+
expect(out?.kind).toBe("compaction");
|
|
141
|
+
expect(out?.role).toBe("system");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("normalizeHistoryMessages", () => {
|
|
146
|
+
it("drops unparseable entries and sorts by seq", () => {
|
|
147
|
+
const result = normalizeHistoryMessages([
|
|
148
|
+
{ role: "assistant", content: "b", __openclaw: { id: "b", seq: 2 } },
|
|
149
|
+
"garbage",
|
|
150
|
+
{ role: "user", content: "a", __openclaw: { id: "a", seq: 1 } },
|
|
151
|
+
]);
|
|
152
|
+
expect(result.map((m) => m.id)).toEqual(["a", "b"]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes raw OpenClaw session transcript messages (as returned by the
|
|
3
|
+
* gateway `sessions.get` method / `runtime.subagent.getSessionMessages`) into a
|
|
4
|
+
* stable wire DTO the Friday app can parse without guessing at the upstream
|
|
5
|
+
* content-block shape.
|
|
6
|
+
*
|
|
7
|
+
* Each raw message is one persisted LLM message (UserMessage / AssistantMessage
|
|
8
|
+
* / ToolResultMessage) with an `__openclaw` metadata envelope attached by the
|
|
9
|
+
* gateway carrying the stable transcript entry `id`, a positional `seq`, and the
|
|
10
|
+
* record timestamp. That `id` is the durable, channel-agnostic identity the app
|
|
11
|
+
* uses as its sync/dedup key — runId is NOT persisted upstream.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface FridayHistoryImage {
|
|
15
|
+
mimeType?: string;
|
|
16
|
+
/** Base64 payload for inline ImageContent blocks. */
|
|
17
|
+
data?: string;
|
|
18
|
+
/** URL for `[media attached: ...]` markers or resolved `MEDIA:` attachments. */
|
|
19
|
+
url?: string;
|
|
20
|
+
/** Display/download filename (set for resolved `MEDIA:` attachments). */
|
|
21
|
+
filename?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FridayHistoryToolCall {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
arguments?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FridayHistoryToolResult {
|
|
31
|
+
toolCallId?: string;
|
|
32
|
+
toolName?: string;
|
|
33
|
+
isError?: boolean;
|
|
34
|
+
text?: string;
|
|
35
|
+
images?: FridayHistoryImage[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FridayHistoryUsage {
|
|
39
|
+
totalTokens?: number;
|
|
40
|
+
input?: number;
|
|
41
|
+
output?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type FridayHistoryRole = "user" | "assistant" | "toolResult" | "system";
|
|
45
|
+
|
|
46
|
+
export interface FridayHistoryMessage {
|
|
47
|
+
/** Stable transcript entry id (sync key). Synthetic when upstream omitted it. */
|
|
48
|
+
id: string;
|
|
49
|
+
seq: number;
|
|
50
|
+
ts?: number;
|
|
51
|
+
role: FridayHistoryRole;
|
|
52
|
+
text?: string;
|
|
53
|
+
thinking?: string;
|
|
54
|
+
toolCalls?: FridayHistoryToolCall[];
|
|
55
|
+
toolResult?: FridayHistoryToolResult;
|
|
56
|
+
images?: FridayHistoryImage[];
|
|
57
|
+
/** Raw `MEDIA:<path>` server paths stripped from text; the handler resolves
|
|
58
|
+
* them to downloadable `/friday-next/files/...` attachment URLs. */
|
|
59
|
+
mediaPaths?: string[];
|
|
60
|
+
model?: string;
|
|
61
|
+
usage?: FridayHistoryUsage;
|
|
62
|
+
/** Non-message records surfaced for context (e.g. compaction dividers). */
|
|
63
|
+
kind?: "compaction";
|
|
64
|
+
/** True when `id` was synthesized because upstream had no stable id. */
|
|
65
|
+
synthetic?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type RawRecord = Record<string, unknown>;
|
|
69
|
+
|
|
70
|
+
function asRecord(value: unknown): RawRecord | undefined {
|
|
71
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
72
|
+
? (value as RawRecord)
|
|
73
|
+
: undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readString(value: unknown): string | undefined {
|
|
77
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readFiniteNumber(value: unknown): number | undefined {
|
|
81
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
82
|
+
if (typeof value === "string" && value.trim()) {
|
|
83
|
+
const n = Number(value);
|
|
84
|
+
if (Number.isFinite(n)) return n;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `MEDIA:<path>` lines emitted for outbound attachments (e.g. generated images). */
|
|
90
|
+
const MEDIA_LINE_RE = /^[ \t]*MEDIA:[ \t]*(\S.*?)[ \t]*$/gim;
|
|
91
|
+
|
|
92
|
+
/** Strips `MEDIA:<path>` lines from text, returning the cleaned text + the paths. */
|
|
93
|
+
function splitMediaLines(text: string): { text: string; paths: string[] } {
|
|
94
|
+
if (!text.includes("MEDIA:")) return { text, paths: [] };
|
|
95
|
+
const paths: string[] = [];
|
|
96
|
+
const cleaned = text
|
|
97
|
+
.replace(MEDIA_LINE_RE, (_m, p: string) => {
|
|
98
|
+
const v = p.trim();
|
|
99
|
+
if (v) paths.push(v);
|
|
100
|
+
return "";
|
|
101
|
+
})
|
|
102
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
103
|
+
.trim();
|
|
104
|
+
return { text: cleaned, paths };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const MEDIA_MARKER_RE = /\[media attached:\s*([^\]]+)\]/gi;
|
|
108
|
+
|
|
109
|
+
/** Pull `[media attached: <url>]` markers out of free text into image refs. */
|
|
110
|
+
function extractMediaMarkers(text: string): FridayHistoryImage[] {
|
|
111
|
+
const images: FridayHistoryImage[] = [];
|
|
112
|
+
for (const match of text.matchAll(MEDIA_MARKER_RE)) {
|
|
113
|
+
const url = match[1]?.trim();
|
|
114
|
+
if (url) images.push({ url });
|
|
115
|
+
}
|
|
116
|
+
return images;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface ParsedContent {
|
|
120
|
+
text: string;
|
|
121
|
+
thinking: string;
|
|
122
|
+
toolCalls: FridayHistoryToolCall[];
|
|
123
|
+
images: FridayHistoryImage[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseContent(content: unknown): ParsedContent {
|
|
127
|
+
const out: ParsedContent = { text: "", thinking: "", toolCalls: [], images: [] };
|
|
128
|
+
|
|
129
|
+
if (typeof content === "string") {
|
|
130
|
+
out.text = content;
|
|
131
|
+
out.images.push(...extractMediaMarkers(content));
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
if (!Array.isArray(content)) return out;
|
|
135
|
+
|
|
136
|
+
const textParts: string[] = [];
|
|
137
|
+
const thinkingParts: string[] = [];
|
|
138
|
+
for (const rawBlock of content) {
|
|
139
|
+
const block = asRecord(rawBlock);
|
|
140
|
+
if (!block) continue;
|
|
141
|
+
switch (block.type) {
|
|
142
|
+
case "text": {
|
|
143
|
+
const t = readString(block.text);
|
|
144
|
+
if (t) {
|
|
145
|
+
textParts.push(t);
|
|
146
|
+
out.images.push(...extractMediaMarkers(t));
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case "thinking": {
|
|
151
|
+
const t = readString(block.thinking);
|
|
152
|
+
if (t) thinkingParts.push(t);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case "image": {
|
|
156
|
+
const data = readString(block.data);
|
|
157
|
+
const url = readString(block.url);
|
|
158
|
+
// Skip empty image blocks (no payload and no URL) — they'd render as
|
|
159
|
+
// broken attachment bubbles in the app.
|
|
160
|
+
if (data || url) {
|
|
161
|
+
out.images.push({
|
|
162
|
+
...(readString(block.mimeType) ? { mimeType: readString(block.mimeType) } : {}),
|
|
163
|
+
...(data ? { data } : {}),
|
|
164
|
+
...(url ? { url } : {}),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case "toolCall": {
|
|
170
|
+
const id = readString(block.id);
|
|
171
|
+
const name = readString(block.name);
|
|
172
|
+
if (id && name) {
|
|
173
|
+
out.toolCalls.push({
|
|
174
|
+
id,
|
|
175
|
+
name,
|
|
176
|
+
...(asRecord(block.arguments) ? { arguments: asRecord(block.arguments) } : {}),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
default:
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
out.text = textParts.join("");
|
|
186
|
+
out.thinking = thinkingParts.join("");
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseUsage(raw: unknown): FridayHistoryUsage | undefined {
|
|
191
|
+
const usage = asRecord(raw);
|
|
192
|
+
if (!usage) return undefined;
|
|
193
|
+
const totalTokens = readFiniteNumber(usage.totalTokens) ?? readFiniteNumber(usage.total);
|
|
194
|
+
const input = readFiniteNumber(usage.input);
|
|
195
|
+
const output = readFiniteNumber(usage.output);
|
|
196
|
+
if (totalTokens === undefined && input === undefined && output === undefined) return undefined;
|
|
197
|
+
return {
|
|
198
|
+
...(totalTokens !== undefined ? { totalTokens } : {}),
|
|
199
|
+
...(input !== undefined ? { input } : {}),
|
|
200
|
+
...(output !== undefined ? { output } : {}),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function normalizeRole(raw: unknown): FridayHistoryRole {
|
|
205
|
+
const role = readString(raw)?.toLowerCase();
|
|
206
|
+
switch (role) {
|
|
207
|
+
case "user":
|
|
208
|
+
return "user";
|
|
209
|
+
case "toolresult":
|
|
210
|
+
return "toolResult";
|
|
211
|
+
case "system":
|
|
212
|
+
return "system";
|
|
213
|
+
default:
|
|
214
|
+
return "assistant";
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Normalize one raw transcript message. `index` is the position in the returned
|
|
220
|
+
* batch, used only to synthesize a stable-ish id when upstream omits one.
|
|
221
|
+
*/
|
|
222
|
+
export function normalizeHistoryMessage(
|
|
223
|
+
raw: unknown,
|
|
224
|
+
index: number,
|
|
225
|
+
): FridayHistoryMessage | null {
|
|
226
|
+
const record = asRecord(raw);
|
|
227
|
+
if (!record) return null;
|
|
228
|
+
|
|
229
|
+
const meta = asRecord(record.__openclaw);
|
|
230
|
+
const role = normalizeRole(record.role);
|
|
231
|
+
const parsed = parseContent(record.content);
|
|
232
|
+
|
|
233
|
+
const seq = readFiniteNumber(meta?.seq) ?? index + 1;
|
|
234
|
+
const metaId = readString(meta?.id);
|
|
235
|
+
const id = metaId ?? `seq:${seq}`;
|
|
236
|
+
const synthetic = metaId === undefined;
|
|
237
|
+
const ts = readFiniteNumber(meta?.recordTimestampMs) ?? readFiniteNumber(record.timestamp);
|
|
238
|
+
const kind = readString(meta?.kind) === "compaction" ? "compaction" : undefined;
|
|
239
|
+
|
|
240
|
+
const message: FridayHistoryMessage = {
|
|
241
|
+
id,
|
|
242
|
+
seq,
|
|
243
|
+
role,
|
|
244
|
+
...(ts !== undefined ? { ts } : {}),
|
|
245
|
+
...(synthetic ? { synthetic: true } : {}),
|
|
246
|
+
...(kind ? { kind } : {}),
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
if (kind === "compaction") {
|
|
250
|
+
message.text = parsed.text || "Compaction";
|
|
251
|
+
return message;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (role === "toolResult") {
|
|
255
|
+
const split = splitMediaLines(parsed.text);
|
|
256
|
+
const toolResult: FridayHistoryToolResult = {
|
|
257
|
+
...(readString(record.toolCallId) ? { toolCallId: readString(record.toolCallId) } : {}),
|
|
258
|
+
...(readString(record.toolName) ? { toolName: readString(record.toolName) } : {}),
|
|
259
|
+
...(record.isError === true ? { isError: true } : {}),
|
|
260
|
+
...(split.text ? { text: split.text } : {}),
|
|
261
|
+
...(parsed.images.length ? { images: parsed.images } : {}),
|
|
262
|
+
};
|
|
263
|
+
message.toolResult = toolResult;
|
|
264
|
+
if (split.paths.length) message.mediaPaths = split.paths;
|
|
265
|
+
return message;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const split = splitMediaLines(parsed.text);
|
|
269
|
+
if (split.text) message.text = split.text;
|
|
270
|
+
if (split.paths.length) message.mediaPaths = split.paths;
|
|
271
|
+
if (parsed.thinking) message.thinking = parsed.thinking;
|
|
272
|
+
if (parsed.toolCalls.length) message.toolCalls = parsed.toolCalls;
|
|
273
|
+
if (parsed.images.length) message.images = parsed.images;
|
|
274
|
+
|
|
275
|
+
const model = readString(record.model) ?? readString(record.responseModel);
|
|
276
|
+
if (model) message.model = model;
|
|
277
|
+
const usage = parseUsage(record.usage);
|
|
278
|
+
if (usage) message.usage = usage;
|
|
279
|
+
|
|
280
|
+
return message;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Normalize a batch of raw transcript messages, dropping unparseable entries. */
|
|
284
|
+
export function normalizeHistoryMessages(rawMessages: unknown[]): FridayHistoryMessage[] {
|
|
285
|
+
const out: FridayHistoryMessage[] = [];
|
|
286
|
+
for (let i = 0; i < rawMessages.length; i += 1) {
|
|
287
|
+
const normalized = normalizeHistoryMessage(rawMessages[i], i);
|
|
288
|
+
if (normalized) out.push(normalized);
|
|
289
|
+
}
|
|
290
|
+
out.sort((a, b) => a.seq - b.seq);
|
|
291
|
+
return out;
|
|
292
|
+
}
|