@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
|
@@ -8,6 +8,12 @@ export type FridayAgentForwardRuntime = {
|
|
|
8
8
|
maintenanceConfig?: unknown;
|
|
9
9
|
clone?: boolean;
|
|
10
10
|
}) => Record<string, unknown>;
|
|
11
|
+
/** Cache-owning entry write (syncs the app session name → server `displayName`). */
|
|
12
|
+
updateSessionStoreEntry?: (params: {
|
|
13
|
+
storePath: string;
|
|
14
|
+
sessionKey: string;
|
|
15
|
+
update: (entry: Record<string, unknown>) => Record<string, unknown> | null | Promise<Record<string, unknown> | null>;
|
|
16
|
+
}) => Promise<Record<string, unknown> | null>;
|
|
11
17
|
getConfig: () => unknown;
|
|
12
18
|
};
|
|
13
19
|
/** Called from `registerFull` so terminal lifecycle forwards can read `sessions.json` after persist. */
|
|
@@ -4,6 +4,8 @@ export function setFridayAgentForwardRuntime(api) {
|
|
|
4
4
|
forwardRuntime = {
|
|
5
5
|
resolveStorePath: api.runtime.agent.session.resolveStorePath,
|
|
6
6
|
loadSessionStore: api.runtime.agent.session.loadSessionStore,
|
|
7
|
+
updateSessionStoreEntry: api.runtime.agent.session
|
|
8
|
+
.updateSessionStoreEntry,
|
|
7
9
|
getConfig: () => api.runtime.config.current(),
|
|
8
10
|
};
|
|
9
11
|
}
|
|
@@ -2,6 +2,8 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import { sseEmitter } from "./sse/emitter.js";
|
|
4
4
|
import { guessMimeType } from "./http/handlers/files.js";
|
|
5
|
+
import { getRunRoute } from "./run-metadata.js";
|
|
6
|
+
import { resolveHistorySessionKeyForFridayDevice } from "./friday-session.js";
|
|
5
7
|
const DISCOVERY = {
|
|
6
8
|
actions: ["send", "channel-info", "channel-list"],
|
|
7
9
|
capabilities: ["text", "media"],
|
|
@@ -48,7 +50,13 @@ async function handleSend(ctx) {
|
|
|
48
50
|
return { ok: false, error: "Missing required param: to" };
|
|
49
51
|
}
|
|
50
52
|
const runId = crypto.randomUUID();
|
|
51
|
-
|
|
53
|
+
// The `message` tool's send runs as a fresh action; `ctx.sessionKey` is the agent's base/main
|
|
54
|
+
// session, not the app session that started the active run on this device. Recover the latter via
|
|
55
|
+
// the device's last tracked run-route so attachments land in the user's current session.
|
|
56
|
+
const activeRunId = sseEmitter.getLastRunIdForDevice(to) ?? undefined;
|
|
57
|
+
const sessionKey = (activeRunId ? getRunRoute(activeRunId)?.sessionKey : undefined) ??
|
|
58
|
+
ctx.sessionKey ??
|
|
59
|
+
resolveHistorySessionKeyForFridayDevice(to);
|
|
52
60
|
// Send text via SSE outbound
|
|
53
61
|
if (text) {
|
|
54
62
|
sseEmitter.broadcast({
|
package/dist/src/channel.js
CHANGED
|
@@ -14,6 +14,7 @@ import { sseEmitter } from "./sse/emitter.js";
|
|
|
14
14
|
import { describeMessageActions, handleMessageAction } from "./channel-actions.js";
|
|
15
15
|
import { guessMimeType, resolveMediaAttachment } from "./http/handlers/files.js";
|
|
16
16
|
import { resolveFridayDeviceIdForOutbound, resolveHistorySessionKeyForFridayDevice, } from "./friday-session.js";
|
|
17
|
+
import { getRunRoute } from "./run-metadata.js";
|
|
17
18
|
import { getLastFridayInboundAt } from "./friday-inbound-stats.js";
|
|
18
19
|
const logger = createFridayNextLogger("channel");
|
|
19
20
|
const CHANNEL_ID = "friday-next";
|
|
@@ -25,6 +26,20 @@ function pickFirstString(source, keys) {
|
|
|
25
26
|
}
|
|
26
27
|
return undefined;
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the sessionKey for an outbound message-tool send.
|
|
31
|
+
*
|
|
32
|
+
* OpenClaw's `ChannelOutboundContext` does not carry the originating run's sessionKey, so the raw
|
|
33
|
+
* ctx is almost always missing it. We recover the current run's session via the run-route registry
|
|
34
|
+
* (keyed by the device's last tracked runId) so message-tool text/media land in the session that
|
|
35
|
+
* triggered the run — not a device-level fallback. Falls back to the device's latest history session
|
|
36
|
+
* for cron / offline / no-run paths.
|
|
37
|
+
*/
|
|
38
|
+
function resolveOutboundSessionKey(deviceId, runId, rawCtx) {
|
|
39
|
+
return ((runId ? getRunRoute(runId)?.sessionKey : undefined) ??
|
|
40
|
+
pickFirstString(rawCtx, ["requesterSessionKey", "sessionKey"]) ??
|
|
41
|
+
resolveHistorySessionKeyForFridayDevice(deviceId));
|
|
42
|
+
}
|
|
28
43
|
function resolveLocalMediaPath(mediaUrl, localRoots) {
|
|
29
44
|
if (path.isAbsolute(mediaUrl))
|
|
30
45
|
return mediaUrl;
|
|
@@ -138,8 +153,7 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
|
|
|
138
153
|
"runId",
|
|
139
154
|
]);
|
|
140
155
|
const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
|
|
141
|
-
const sessionKey =
|
|
142
|
-
resolveHistorySessionKeyForFridayDevice(deviceId);
|
|
156
|
+
const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
|
|
143
157
|
const conn = sseEmitter.getConnection(deviceId);
|
|
144
158
|
const ts = new Date().toISOString();
|
|
145
159
|
logger.info(`[SEND_TEXT] to=${deviceId} runId=${runId ?? "(none)"} sessionKey=${sessionKey ?? "(none)"} textLen=${text.length} online=${!!conn}`);
|
|
@@ -178,8 +192,7 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
|
|
|
178
192
|
"runId",
|
|
179
193
|
]);
|
|
180
194
|
const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
|
|
181
|
-
const sessionKey =
|
|
182
|
-
resolveHistorySessionKeyForFridayDevice(deviceId);
|
|
195
|
+
const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
|
|
183
196
|
const audioAsVoice = ctx.audioAsVoice === true;
|
|
184
197
|
const caption = ctx.text ?? "";
|
|
185
198
|
if (!mediaUrl) {
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
export interface FridayHistoryImage {
|
|
14
|
+
mimeType?: string;
|
|
15
|
+
/** Base64 payload for inline ImageContent blocks. */
|
|
16
|
+
data?: string;
|
|
17
|
+
/** URL for `[media attached: ...]` markers or resolved `MEDIA:` attachments. */
|
|
18
|
+
url?: string;
|
|
19
|
+
/** Display/download filename (set for resolved `MEDIA:` attachments). */
|
|
20
|
+
filename?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface FridayHistoryToolCall {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
arguments?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
export interface FridayHistoryToolResult {
|
|
28
|
+
toolCallId?: string;
|
|
29
|
+
toolName?: string;
|
|
30
|
+
isError?: boolean;
|
|
31
|
+
text?: string;
|
|
32
|
+
images?: FridayHistoryImage[];
|
|
33
|
+
}
|
|
34
|
+
export interface FridayHistoryUsage {
|
|
35
|
+
totalTokens?: number;
|
|
36
|
+
input?: number;
|
|
37
|
+
output?: number;
|
|
38
|
+
}
|
|
39
|
+
export type FridayHistoryRole = "user" | "assistant" | "toolResult" | "system";
|
|
40
|
+
export interface FridayHistoryMessage {
|
|
41
|
+
/** Stable transcript entry id (sync key). Synthetic when upstream omitted it. */
|
|
42
|
+
id: string;
|
|
43
|
+
seq: number;
|
|
44
|
+
ts?: number;
|
|
45
|
+
role: FridayHistoryRole;
|
|
46
|
+
text?: string;
|
|
47
|
+
thinking?: string;
|
|
48
|
+
toolCalls?: FridayHistoryToolCall[];
|
|
49
|
+
toolResult?: FridayHistoryToolResult;
|
|
50
|
+
images?: FridayHistoryImage[];
|
|
51
|
+
/** Raw `MEDIA:<path>` server paths stripped from text; the handler resolves
|
|
52
|
+
* them to downloadable `/friday-next/files/...` attachment URLs. */
|
|
53
|
+
mediaPaths?: string[];
|
|
54
|
+
model?: string;
|
|
55
|
+
usage?: FridayHistoryUsage;
|
|
56
|
+
/** Non-message records surfaced for context (e.g. compaction dividers). */
|
|
57
|
+
kind?: "compaction";
|
|
58
|
+
/** True when `id` was synthesized because upstream had no stable id. */
|
|
59
|
+
synthetic?: boolean;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Normalize one raw transcript message. `index` is the position in the returned
|
|
63
|
+
* batch, used only to synthesize a stable-ish id when upstream omits one.
|
|
64
|
+
*/
|
|
65
|
+
export declare function normalizeHistoryMessage(raw: unknown, index: number): FridayHistoryMessage | null;
|
|
66
|
+
/** Normalize a batch of raw transcript messages, dropping unparseable entries. */
|
|
67
|
+
export declare function normalizeHistoryMessages(rawMessages: unknown[]): FridayHistoryMessage[];
|
|
@@ -0,0 +1,224 @@
|
|
|
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
|
+
function asRecord(value) {
|
|
14
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
15
|
+
? value
|
|
16
|
+
: undefined;
|
|
17
|
+
}
|
|
18
|
+
function readString(value) {
|
|
19
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
20
|
+
}
|
|
21
|
+
function readFiniteNumber(value) {
|
|
22
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
23
|
+
return value;
|
|
24
|
+
if (typeof value === "string" && value.trim()) {
|
|
25
|
+
const n = Number(value);
|
|
26
|
+
if (Number.isFinite(n))
|
|
27
|
+
return n;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/** `MEDIA:<path>` lines emitted for outbound attachments (e.g. generated images). */
|
|
32
|
+
const MEDIA_LINE_RE = /^[ \t]*MEDIA:[ \t]*(\S.*?)[ \t]*$/gim;
|
|
33
|
+
/** Strips `MEDIA:<path>` lines from text, returning the cleaned text + the paths. */
|
|
34
|
+
function splitMediaLines(text) {
|
|
35
|
+
if (!text.includes("MEDIA:"))
|
|
36
|
+
return { text, paths: [] };
|
|
37
|
+
const paths = [];
|
|
38
|
+
const cleaned = text
|
|
39
|
+
.replace(MEDIA_LINE_RE, (_m, p) => {
|
|
40
|
+
const v = p.trim();
|
|
41
|
+
if (v)
|
|
42
|
+
paths.push(v);
|
|
43
|
+
return "";
|
|
44
|
+
})
|
|
45
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
46
|
+
.trim();
|
|
47
|
+
return { text: cleaned, paths };
|
|
48
|
+
}
|
|
49
|
+
const MEDIA_MARKER_RE = /\[media attached:\s*([^\]]+)\]/gi;
|
|
50
|
+
/** Pull `[media attached: <url>]` markers out of free text into image refs. */
|
|
51
|
+
function extractMediaMarkers(text) {
|
|
52
|
+
const images = [];
|
|
53
|
+
for (const match of text.matchAll(MEDIA_MARKER_RE)) {
|
|
54
|
+
const url = match[1]?.trim();
|
|
55
|
+
if (url)
|
|
56
|
+
images.push({ url });
|
|
57
|
+
}
|
|
58
|
+
return images;
|
|
59
|
+
}
|
|
60
|
+
function parseContent(content) {
|
|
61
|
+
const out = { text: "", thinking: "", toolCalls: [], images: [] };
|
|
62
|
+
if (typeof content === "string") {
|
|
63
|
+
out.text = content;
|
|
64
|
+
out.images.push(...extractMediaMarkers(content));
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(content))
|
|
68
|
+
return out;
|
|
69
|
+
const textParts = [];
|
|
70
|
+
const thinkingParts = [];
|
|
71
|
+
for (const rawBlock of content) {
|
|
72
|
+
const block = asRecord(rawBlock);
|
|
73
|
+
if (!block)
|
|
74
|
+
continue;
|
|
75
|
+
switch (block.type) {
|
|
76
|
+
case "text": {
|
|
77
|
+
const t = readString(block.text);
|
|
78
|
+
if (t) {
|
|
79
|
+
textParts.push(t);
|
|
80
|
+
out.images.push(...extractMediaMarkers(t));
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case "thinking": {
|
|
85
|
+
const t = readString(block.thinking);
|
|
86
|
+
if (t)
|
|
87
|
+
thinkingParts.push(t);
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case "image": {
|
|
91
|
+
const data = readString(block.data);
|
|
92
|
+
const url = readString(block.url);
|
|
93
|
+
// Skip empty image blocks (no payload and no URL) — they'd render as
|
|
94
|
+
// broken attachment bubbles in the app.
|
|
95
|
+
if (data || url) {
|
|
96
|
+
out.images.push({
|
|
97
|
+
...(readString(block.mimeType) ? { mimeType: readString(block.mimeType) } : {}),
|
|
98
|
+
...(data ? { data } : {}),
|
|
99
|
+
...(url ? { url } : {}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case "toolCall": {
|
|
105
|
+
const id = readString(block.id);
|
|
106
|
+
const name = readString(block.name);
|
|
107
|
+
if (id && name) {
|
|
108
|
+
out.toolCalls.push({
|
|
109
|
+
id,
|
|
110
|
+
name,
|
|
111
|
+
...(asRecord(block.arguments) ? { arguments: asRecord(block.arguments) } : {}),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
default:
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
out.text = textParts.join("");
|
|
121
|
+
out.thinking = thinkingParts.join("");
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
function parseUsage(raw) {
|
|
125
|
+
const usage = asRecord(raw);
|
|
126
|
+
if (!usage)
|
|
127
|
+
return undefined;
|
|
128
|
+
const totalTokens = readFiniteNumber(usage.totalTokens) ?? readFiniteNumber(usage.total);
|
|
129
|
+
const input = readFiniteNumber(usage.input);
|
|
130
|
+
const output = readFiniteNumber(usage.output);
|
|
131
|
+
if (totalTokens === undefined && input === undefined && output === undefined)
|
|
132
|
+
return undefined;
|
|
133
|
+
return {
|
|
134
|
+
...(totalTokens !== undefined ? { totalTokens } : {}),
|
|
135
|
+
...(input !== undefined ? { input } : {}),
|
|
136
|
+
...(output !== undefined ? { output } : {}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function normalizeRole(raw) {
|
|
140
|
+
const role = readString(raw)?.toLowerCase();
|
|
141
|
+
switch (role) {
|
|
142
|
+
case "user":
|
|
143
|
+
return "user";
|
|
144
|
+
case "toolresult":
|
|
145
|
+
return "toolResult";
|
|
146
|
+
case "system":
|
|
147
|
+
return "system";
|
|
148
|
+
default:
|
|
149
|
+
return "assistant";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Normalize one raw transcript message. `index` is the position in the returned
|
|
154
|
+
* batch, used only to synthesize a stable-ish id when upstream omits one.
|
|
155
|
+
*/
|
|
156
|
+
export function normalizeHistoryMessage(raw, index) {
|
|
157
|
+
const record = asRecord(raw);
|
|
158
|
+
if (!record)
|
|
159
|
+
return null;
|
|
160
|
+
const meta = asRecord(record.__openclaw);
|
|
161
|
+
const role = normalizeRole(record.role);
|
|
162
|
+
const parsed = parseContent(record.content);
|
|
163
|
+
const seq = readFiniteNumber(meta?.seq) ?? index + 1;
|
|
164
|
+
const metaId = readString(meta?.id);
|
|
165
|
+
const id = metaId ?? `seq:${seq}`;
|
|
166
|
+
const synthetic = metaId === undefined;
|
|
167
|
+
const ts = readFiniteNumber(meta?.recordTimestampMs) ?? readFiniteNumber(record.timestamp);
|
|
168
|
+
const kind = readString(meta?.kind) === "compaction" ? "compaction" : undefined;
|
|
169
|
+
const message = {
|
|
170
|
+
id,
|
|
171
|
+
seq,
|
|
172
|
+
role,
|
|
173
|
+
...(ts !== undefined ? { ts } : {}),
|
|
174
|
+
...(synthetic ? { synthetic: true } : {}),
|
|
175
|
+
...(kind ? { kind } : {}),
|
|
176
|
+
};
|
|
177
|
+
if (kind === "compaction") {
|
|
178
|
+
message.text = parsed.text || "Compaction";
|
|
179
|
+
return message;
|
|
180
|
+
}
|
|
181
|
+
if (role === "toolResult") {
|
|
182
|
+
const split = splitMediaLines(parsed.text);
|
|
183
|
+
const toolResult = {
|
|
184
|
+
...(readString(record.toolCallId) ? { toolCallId: readString(record.toolCallId) } : {}),
|
|
185
|
+
...(readString(record.toolName) ? { toolName: readString(record.toolName) } : {}),
|
|
186
|
+
...(record.isError === true ? { isError: true } : {}),
|
|
187
|
+
...(split.text ? { text: split.text } : {}),
|
|
188
|
+
...(parsed.images.length ? { images: parsed.images } : {}),
|
|
189
|
+
};
|
|
190
|
+
message.toolResult = toolResult;
|
|
191
|
+
if (split.paths.length)
|
|
192
|
+
message.mediaPaths = split.paths;
|
|
193
|
+
return message;
|
|
194
|
+
}
|
|
195
|
+
const split = splitMediaLines(parsed.text);
|
|
196
|
+
if (split.text)
|
|
197
|
+
message.text = split.text;
|
|
198
|
+
if (split.paths.length)
|
|
199
|
+
message.mediaPaths = split.paths;
|
|
200
|
+
if (parsed.thinking)
|
|
201
|
+
message.thinking = parsed.thinking;
|
|
202
|
+
if (parsed.toolCalls.length)
|
|
203
|
+
message.toolCalls = parsed.toolCalls;
|
|
204
|
+
if (parsed.images.length)
|
|
205
|
+
message.images = parsed.images;
|
|
206
|
+
const model = readString(record.model) ?? readString(record.responseModel);
|
|
207
|
+
if (model)
|
|
208
|
+
message.model = model;
|
|
209
|
+
const usage = parseUsage(record.usage);
|
|
210
|
+
if (usage)
|
|
211
|
+
message.usage = usage;
|
|
212
|
+
return message;
|
|
213
|
+
}
|
|
214
|
+
/** Normalize a batch of raw transcript messages, dropping unparseable entries. */
|
|
215
|
+
export function normalizeHistoryMessages(rawMessages) {
|
|
216
|
+
const out = [];
|
|
217
|
+
for (let i = 0; i < rawMessages.length; i += 1) {
|
|
218
|
+
const normalized = normalizeHistoryMessage(rawMessages[i], i);
|
|
219
|
+
if (normalized)
|
|
220
|
+
out.push(normalized);
|
|
221
|
+
}
|
|
222
|
+
out.sort((a, b) => a.seq - b.seq);
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a session's transcript directly from disk via the forward runtime's
|
|
3
|
+
* session store (`sessions.json` → entry.sessionFile → the `.jsonl` transcript).
|
|
4
|
+
*
|
|
5
|
+
* We do NOT use `runtime.subagent.getSessionMessages` here: that dispatches the
|
|
6
|
+
* gateway `sessions.get` method which is only valid inside a gateway request
|
|
7
|
+
* scope and returns empty when called from a plugin HTTP route. Reading the
|
|
8
|
+
* transcript file mirrors how `history-sessions.ts` reads `sessions.json`.
|
|
9
|
+
*
|
|
10
|
+
* Each transcript line is `{type, id, parentId, timestamp, message:{role,content,...}}`.
|
|
11
|
+
* We surface message records (in file order) with an `__openclaw` envelope
|
|
12
|
+
* matching the gateway's own `sessions.get` output, so `normalize-message.ts`
|
|
13
|
+
* can consume either source identically.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveTranscriptPath(entry: unknown, storePath: string): string | undefined;
|
|
16
|
+
/** Resolves the real server-side session id for a session key, or undefined. */
|
|
17
|
+
export declare function resolveSessionId(sessionKey: string): string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Returns raw transcript message objects (newest tail up to `limit`), each with
|
|
20
|
+
* an `__openclaw: { id, seq, recordTimestampMs }` envelope. Empty on any failure.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readSessionTranscriptRawMessages(sessionKey: string, limit: number): unknown[];
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a session's transcript directly from disk via the forward runtime's
|
|
3
|
+
* session store (`sessions.json` → entry.sessionFile → the `.jsonl` transcript).
|
|
4
|
+
*
|
|
5
|
+
* We do NOT use `runtime.subagent.getSessionMessages` here: that dispatches the
|
|
6
|
+
* gateway `sessions.get` method which is only valid inside a gateway request
|
|
7
|
+
* scope and returns empty when called from a plugin HTTP route. Reading the
|
|
8
|
+
* transcript file mirrors how `history-sessions.ts` reads `sessions.json`.
|
|
9
|
+
*
|
|
10
|
+
* Each transcript line is `{type, id, parentId, timestamp, message:{role,content,...}}`.
|
|
11
|
+
* We surface message records (in file order) with an `__openclaw` envelope
|
|
12
|
+
* matching the gateway's own `sessions.get` output, so `normalize-message.ts`
|
|
13
|
+
* can consume either source identically.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { getFridayAgentForwardRuntime } from "../agent-forward-runtime.js";
|
|
18
|
+
import { agentIdFromSessionKey, toSessionStoreKey } from "../session/session-manager.js";
|
|
19
|
+
function entryString(entry, key) {
|
|
20
|
+
if (!entry || typeof entry !== "object")
|
|
21
|
+
return undefined;
|
|
22
|
+
const v = entry[key];
|
|
23
|
+
return typeof v === "string" && v.trim() ? v : undefined;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolves the store entry for a session key, tolerating case differences
|
|
27
|
+
* (the app's key carries an upper-case deviceId; `sessions.json` stores it
|
|
28
|
+
* lower-cased).
|
|
29
|
+
*/
|
|
30
|
+
function resolveEntry(store, sessionKey) {
|
|
31
|
+
if (store[sessionKey])
|
|
32
|
+
return store[sessionKey];
|
|
33
|
+
const canonical = toSessionStoreKey(sessionKey);
|
|
34
|
+
if (store[canonical])
|
|
35
|
+
return store[canonical];
|
|
36
|
+
const target = canonical.toLowerCase();
|
|
37
|
+
for (const [k, v] of Object.entries(store)) {
|
|
38
|
+
if (k.toLowerCase() === target)
|
|
39
|
+
return v;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
export function resolveTranscriptPath(entry, storePath) {
|
|
44
|
+
const sessionFile = entryString(entry, "sessionFile");
|
|
45
|
+
if (sessionFile) {
|
|
46
|
+
return path.isAbsolute(sessionFile)
|
|
47
|
+
? sessionFile
|
|
48
|
+
: path.join(path.dirname(storePath), sessionFile);
|
|
49
|
+
}
|
|
50
|
+
const sessionId = entryString(entry, "sessionId");
|
|
51
|
+
if (sessionId) {
|
|
52
|
+
return path.join(path.dirname(storePath), `${sessionId}.jsonl`);
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
/** Resolves the real server-side session id for a session key, or undefined. */
|
|
57
|
+
export function resolveSessionId(sessionKey) {
|
|
58
|
+
const rt = getFridayAgentForwardRuntime();
|
|
59
|
+
if (!rt)
|
|
60
|
+
return undefined;
|
|
61
|
+
const agentId = agentIdFromSessionKey(sessionKey);
|
|
62
|
+
try {
|
|
63
|
+
const store = rt.loadSessionStore(rt.resolveStorePath(undefined, { agentId })) ?? {};
|
|
64
|
+
return entryString(resolveEntry(store, sessionKey), "sessionId");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Returns raw transcript message objects (newest tail up to `limit`), each with
|
|
72
|
+
* an `__openclaw: { id, seq, recordTimestampMs }` envelope. Empty on any failure.
|
|
73
|
+
*/
|
|
74
|
+
export function readSessionTranscriptRawMessages(sessionKey, limit) {
|
|
75
|
+
const rt = getFridayAgentForwardRuntime();
|
|
76
|
+
if (!rt)
|
|
77
|
+
return [];
|
|
78
|
+
const agentId = agentIdFromSessionKey(sessionKey);
|
|
79
|
+
let storePath;
|
|
80
|
+
let store;
|
|
81
|
+
try {
|
|
82
|
+
storePath = rt.resolveStorePath(undefined, { agentId });
|
|
83
|
+
store = rt.loadSessionStore(storePath) ?? {};
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
const entry = resolveEntry(store, sessionKey);
|
|
89
|
+
if (!entry)
|
|
90
|
+
return [];
|
|
91
|
+
const filePath = resolveTranscriptPath(entry, storePath);
|
|
92
|
+
if (!filePath)
|
|
93
|
+
return [];
|
|
94
|
+
let content;
|
|
95
|
+
try {
|
|
96
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const raw = [];
|
|
102
|
+
let seq = 0;
|
|
103
|
+
for (const line of content.split("\n")) {
|
|
104
|
+
const trimmed = line.trim();
|
|
105
|
+
if (!trimmed)
|
|
106
|
+
continue;
|
|
107
|
+
let rec;
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(trimmed);
|
|
110
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
111
|
+
continue;
|
|
112
|
+
rec = parsed;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (rec.type === "session" || !rec.message || typeof rec.message !== "object")
|
|
118
|
+
continue;
|
|
119
|
+
seq += 1;
|
|
120
|
+
const tsRaw = rec.timestamp;
|
|
121
|
+
const ts = typeof tsRaw === "string"
|
|
122
|
+
? Date.parse(tsRaw)
|
|
123
|
+
: typeof tsRaw === "number"
|
|
124
|
+
? tsRaw
|
|
125
|
+
: Number.NaN;
|
|
126
|
+
raw.push({
|
|
127
|
+
...rec.message,
|
|
128
|
+
__openclaw: {
|
|
129
|
+
...(typeof rec.id === "string" ? { id: rec.id } : {}),
|
|
130
|
+
seq,
|
|
131
|
+
...(Number.isFinite(ts) ? { recordTimestampMs: ts } : {}),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return limit > 0 && raw.length > limit ? raw.slice(raw.length - limit) : raw;
|
|
136
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File manager for Friday Next channel attachments.
|
|
3
3
|
*
|
|
4
|
-
* Files are copied under
|
|
4
|
+
* Files are copied under `~/.openclaw/friday-next/attachments/` and served at
|
|
5
5
|
* GET /friday-next/files/{token} so the app can use stable gateway URLs after restarts.
|
|
6
6
|
*/
|
|
7
|
-
|
|
7
|
+
export declare function setAttachmentsDirForTest(dir: string | null): void;
|
|
8
|
+
/** `~/.openclaw/friday-next/attachments/` directory; created on first use. */
|
|
8
9
|
export declare function getAttachmentsDir(): string;
|
|
9
10
|
export interface StoredFile {
|
|
10
11
|
id: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File manager for Friday Next channel attachments.
|
|
3
3
|
*
|
|
4
|
-
* Files are copied under
|
|
4
|
+
* Files are copied under `~/.openclaw/friday-next/attachments/` and served at
|
|
5
5
|
* GET /friday-next/files/{token} so the app can use stable gateway URLs after restarts.
|
|
6
6
|
*/
|
|
7
7
|
import crypto from "node:crypto";
|
|
@@ -10,12 +10,27 @@ import os from "node:os";
|
|
|
10
10
|
import { createFridayNextLogger } from "../../logging.js";
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
import { resolveFridayNextConfig } from "../../config.js";
|
|
14
|
+
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
15
|
+
import { getFridayNextRuntime } from "../../runtime.js";
|
|
16
|
+
/** Test-only override for the attachments base directory. */
|
|
17
|
+
let testAttachmentsDir = null;
|
|
18
|
+
export function setAttachmentsDirForTest(dir) {
|
|
19
|
+
testAttachmentsDir = dir;
|
|
15
20
|
}
|
|
16
|
-
/**
|
|
21
|
+
/** Resolve `<historyDir>/../attachments`, mirroring the offline-queue layout. */
|
|
22
|
+
function resolveAttachmentsDir() {
|
|
23
|
+
try {
|
|
24
|
+
const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
|
|
25
|
+
return path.join(path.dirname(cfg.historyDir), "attachments");
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return path.join(os.homedir(), ".openclaw", "friday-next", "attachments");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** `~/.openclaw/friday-next/attachments/` directory; created on first use. */
|
|
17
32
|
export function getAttachmentsDir() {
|
|
18
|
-
const dir =
|
|
33
|
+
const dir = testAttachmentsDir ?? resolveAttachmentsDir();
|
|
19
34
|
try {
|
|
20
35
|
fs.mkdirSync(dir, { recursive: true });
|
|
21
36
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
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 type { IncomingMessage, ServerResponse } from "node:http";
|
|
13
|
+
export declare function handleHistoryMessages(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|