@mulmobridge/chat-service 0.1.3 → 0.1.6
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/chat-state.d.ts +9 -1
- package/dist/chat-state.js +7 -1
- package/dist/commands.d.ts +26 -0
- package/dist/commands.js +46 -6
- package/dist/index.js +25 -1
- package/dist/keyed-serializer.d.ts +4 -0
- package/dist/keyed-serializer.js +33 -0
- package/dist/relay.js +98 -88
- package/dist/socket.d.ts +3 -1
- package/dist/socket.js +53 -15
- package/dist/types.d.ts +40 -6
- package/package.json +1 -1
package/dist/chat-state.d.ts
CHANGED
|
@@ -11,7 +11,15 @@ export interface ChatStateStore {
|
|
|
11
11
|
getChatState(transportId: string, externalChatId: string): Promise<TransportChatState | null>;
|
|
12
12
|
setChatState(transportId: string, state: TransportChatState): Promise<void>;
|
|
13
13
|
resetChatState(transportId: string, externalChatId: string, roleId: string): Promise<TransportChatState>;
|
|
14
|
-
|
|
14
|
+
/** Repoint the persisted chat state at another session. `roleId` is
|
|
15
|
+
* optional: when omitted, the existing state's roleId is preserved
|
|
16
|
+
* (the old default, kept for HTTP `/connect` callers that only know
|
|
17
|
+
* the session ID). Callers that DO know the target session's role —
|
|
18
|
+
* notably the `/switch` command, which already has `SessionSummary.roleId`
|
|
19
|
+
* from `/sessions` — MUST pass it, otherwise the file-backed state
|
|
20
|
+
* drifts into a stale-role / new-session pair and the next relay's
|
|
21
|
+
* `startChat` uses the mismatched pair (issue #1888). */
|
|
22
|
+
connectSession(transportId: string, externalChatId: string, chatSessionId: string, roleId?: string): Promise<TransportChatState | null>;
|
|
15
23
|
generateSessionId(transportId: string, externalChatId: string): string;
|
|
16
24
|
}
|
|
17
25
|
export declare function createChatStateStore(opts: {
|
package/dist/chat-state.js
CHANGED
|
@@ -57,13 +57,18 @@ export function createChatStateStore(opts) {
|
|
|
57
57
|
});
|
|
58
58
|
return state;
|
|
59
59
|
};
|
|
60
|
-
const connectSession = async (transportId, externalChatId, chatSessionId) => {
|
|
60
|
+
const connectSession = async (transportId, externalChatId, chatSessionId, roleId) => {
|
|
61
61
|
const existing = await getChatState(transportId, externalChatId);
|
|
62
62
|
if (!existing)
|
|
63
63
|
return null;
|
|
64
64
|
const updated = {
|
|
65
65
|
...existing,
|
|
66
66
|
sessionId: chatSessionId,
|
|
67
|
+
// A missing `roleId` arg means "preserve the current role" (that's
|
|
68
|
+
// the HTTP `/connect` route, which doesn't know the target's role);
|
|
69
|
+
// when the caller passes one (`/switch`), take theirs so state and
|
|
70
|
+
// downstream `startChat` agree on which role runs the resumed session.
|
|
71
|
+
...(roleId !== undefined ? { roleId } : {}),
|
|
67
72
|
updatedAt: new Date().toISOString(),
|
|
68
73
|
};
|
|
69
74
|
await setChatState(transportId, updated);
|
|
@@ -71,6 +76,7 @@ export function createChatStateStore(opts) {
|
|
|
71
76
|
transportId,
|
|
72
77
|
externalChatId,
|
|
73
78
|
sessionId: chatSessionId,
|
|
79
|
+
roleId: updated.roleId,
|
|
74
80
|
});
|
|
75
81
|
return updated;
|
|
76
82
|
};
|
package/dist/commands.d.ts
CHANGED
|
@@ -10,6 +10,32 @@ export interface CommandResult {
|
|
|
10
10
|
forwardAs?: string;
|
|
11
11
|
}
|
|
12
12
|
export type CommandHandler = (text: string, transportId: string, chatState: TransportChatState) => Promise<CommandResult | null>;
|
|
13
|
+
/**
|
|
14
|
+
* Split a `//{skill} [args...]` shortcut into the skill name and the
|
|
15
|
+
* verbatim argument text.
|
|
16
|
+
*
|
|
17
|
+
* The separator between the skill name and args is the FIRST run of
|
|
18
|
+
* whitespace (a single space, multiple spaces, a tab, a CRLF — the
|
|
19
|
+
* bridge can't disambiguate "long separator" from "short separator
|
|
20
|
+
* plus leading whitespace in args", so the simple choice is to drop
|
|
21
|
+
* the whole leading run as one logical separator). Whitespace INSIDE
|
|
22
|
+
* the args (after the first run) is preserved verbatim, so doubled
|
|
23
|
+
* spaces, tabs, and newlines pasted into a multi-line prompt all
|
|
24
|
+
* survive.
|
|
25
|
+
*
|
|
26
|
+
* Exported for unit tests; callers in the handler use it directly.
|
|
27
|
+
*
|
|
28
|
+
* Examples:
|
|
29
|
+
* "//mag2" → { skillName: "mag2", argsVerbatim: "" }
|
|
30
|
+
* "//mag2 url" → { skillName: "mag2", argsVerbatim: "url" }
|
|
31
|
+
* "//mag2 url" → { skillName: "mag2", argsVerbatim: "url" }
|
|
32
|
+
* "//mag2\r\nurl" → { skillName: "mag2", argsVerbatim: "url" }
|
|
33
|
+
* "//mag2 a b\tc" → { skillName: "mag2", argsVerbatim: "a b\tc" }
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseSkillShortcut(text: string): {
|
|
36
|
+
skillName: string;
|
|
37
|
+
argsVerbatim: string;
|
|
38
|
+
};
|
|
13
39
|
export declare function createCommandHandler(opts: {
|
|
14
40
|
loadAllRoles: () => Role[];
|
|
15
41
|
getRole: (roleId: string) => Role;
|
package/dist/commands.js
CHANGED
|
@@ -22,6 +22,40 @@ function formatRelativeTime(isoDate) {
|
|
|
22
22
|
const days = Math.floor(diffMs / ONE_DAY_MS);
|
|
23
23
|
return `${days}d ago`;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Split a `//{skill} [args...]` shortcut into the skill name and the
|
|
27
|
+
* verbatim argument text.
|
|
28
|
+
*
|
|
29
|
+
* The separator between the skill name and args is the FIRST run of
|
|
30
|
+
* whitespace (a single space, multiple spaces, a tab, a CRLF — the
|
|
31
|
+
* bridge can't disambiguate "long separator" from "short separator
|
|
32
|
+
* plus leading whitespace in args", so the simple choice is to drop
|
|
33
|
+
* the whole leading run as one logical separator). Whitespace INSIDE
|
|
34
|
+
* the args (after the first run) is preserved verbatim, so doubled
|
|
35
|
+
* spaces, tabs, and newlines pasted into a multi-line prompt all
|
|
36
|
+
* survive.
|
|
37
|
+
*
|
|
38
|
+
* Exported for unit tests; callers in the handler use it directly.
|
|
39
|
+
*
|
|
40
|
+
* Examples:
|
|
41
|
+
* "//mag2" → { skillName: "mag2", argsVerbatim: "" }
|
|
42
|
+
* "//mag2 url" → { skillName: "mag2", argsVerbatim: "url" }
|
|
43
|
+
* "//mag2 url" → { skillName: "mag2", argsVerbatim: "url" }
|
|
44
|
+
* "//mag2\r\nurl" → { skillName: "mag2", argsVerbatim: "url" }
|
|
45
|
+
* "//mag2 a b\tc" → { skillName: "mag2", argsVerbatim: "a b\tc" }
|
|
46
|
+
*/
|
|
47
|
+
export function parseSkillShortcut(text) {
|
|
48
|
+
const sepIdx = text.search(/\s/);
|
|
49
|
+
if (sepIdx < 0)
|
|
50
|
+
return { skillName: text.slice(2), argsVerbatim: "" };
|
|
51
|
+
const head = text.slice(0, sepIdx);
|
|
52
|
+
// Strip the full leading whitespace run — \s in JS already matches
|
|
53
|
+
// \r, \n, \t, \v, \f, NBSP (U+00A0), and the rest of Unicode whitespace,
|
|
54
|
+
// so CRLF separators collapse to nothing instead of leaving an orphan
|
|
55
|
+
// \n in argsVerbatim (Codex review iter-1).
|
|
56
|
+
const argsVerbatim = text.slice(sepIdx).replace(/^\s+/, "");
|
|
57
|
+
return { skillName: head.slice(2), argsVerbatim };
|
|
58
|
+
}
|
|
25
59
|
// ── Factory ──────────────────────────────────────────────────
|
|
26
60
|
export function createCommandHandler(opts) {
|
|
27
61
|
const { loadAllRoles, getRole, resetChatState, connectSession, listSessions, getSessionHistory, listRegisteredSkills } = opts;
|
|
@@ -177,7 +211,12 @@ export function createCommandHandler(opts) {
|
|
|
177
211
|
};
|
|
178
212
|
}
|
|
179
213
|
}
|
|
180
|
-
|
|
214
|
+
// Pass `target.roleId` so the persisted state's role tracks the session
|
|
215
|
+
// we just repointed at. Without this, `connectSession` would swap the
|
|
216
|
+
// sessionId but leave the previous role in place, and the next relay's
|
|
217
|
+
// `startChat` would run the resumed session under the wrong role
|
|
218
|
+
// (issue #1888).
|
|
219
|
+
const updated = await connectSession(transportId, chatState.externalChatId, target.id, target.roleId);
|
|
181
220
|
if (!updated) {
|
|
182
221
|
return { reply: "Failed to switch session." };
|
|
183
222
|
}
|
|
@@ -224,15 +263,16 @@ export function createCommandHandler(opts) {
|
|
|
224
263
|
return null;
|
|
225
264
|
// `//{skill} [args...]` shortcut — start a new session AND run
|
|
226
265
|
// the skill in one bridge turn. Args after the skill name are
|
|
227
|
-
// forwarded verbatim
|
|
228
|
-
//
|
|
266
|
+
// forwarded verbatim — split at the FIRST whitespace character
|
|
267
|
+
// and preserve everything after it (including doubled spaces /
|
|
268
|
+
// tabs) so payloads like a URL with internal whitespace, or
|
|
269
|
+
// multi-paragraph prompts, aren't silently rewritten.
|
|
229
270
|
if (text.startsWith("//")) {
|
|
230
271
|
const skills = await fetchSkills();
|
|
231
|
-
const
|
|
232
|
-
const skillName = head.slice(2);
|
|
272
|
+
const { skillName, argsVerbatim } = parseSkillShortcut(text);
|
|
233
273
|
if (skillName && skills.some((s) => s.name === skillName)) {
|
|
234
274
|
const nextState = await resetChatState(transportId, chatState.externalChatId, chatState.roleId);
|
|
235
|
-
const forwardAs =
|
|
275
|
+
const forwardAs = argsVerbatim.length > 0 ? `/${skillName} ${argsVerbatim}` : `/${skillName}`;
|
|
236
276
|
return {
|
|
237
277
|
reply: `Session reset. Running ${forwardAs}`,
|
|
238
278
|
nextState,
|
package/dist/index.js
CHANGED
|
@@ -97,7 +97,31 @@ export function createChatService(deps) {
|
|
|
97
97
|
badRequest(res, "chatSessionId is required");
|
|
98
98
|
return;
|
|
99
99
|
}
|
|
100
|
-
|
|
100
|
+
// Resolve the target session's role BEFORE calling connectSession so the
|
|
101
|
+
// persisted state's `roleId` tracks the new session's role — otherwise the
|
|
102
|
+
// next relay's `startChat` would resume the new session under the previous
|
|
103
|
+
// role (#1888 / #1894). Three fallback paths all treated as "preserve
|
|
104
|
+
// existing role":
|
|
105
|
+
// 1. No `getSessionRole` wired at all (backward compat for older hosts).
|
|
106
|
+
// 2. Resolver returns null (unknown / corrupt session metadata).
|
|
107
|
+
// 3. Resolver throws (host bug / timeout / IO error) — catch here so
|
|
108
|
+
// the route can never bubble the failure as a 500 to the API caller
|
|
109
|
+
// (codex review on #1895; the MulmoClaude host's resolver is
|
|
110
|
+
// hardened but the DI contract doesn't require hosts to be).
|
|
111
|
+
let resolvedRole = null;
|
|
112
|
+
if (deps.getSessionRole) {
|
|
113
|
+
try {
|
|
114
|
+
resolvedRole = await deps.getSessionRole(chatSessionId);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
logger.warn("chat-service", "getSessionRole threw; falling back to preserving existing role", {
|
|
118
|
+
chatSessionId,
|
|
119
|
+
error: err instanceof Error ? err.message : String(err),
|
|
120
|
+
});
|
|
121
|
+
resolvedRole = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const updated = await store.connectSession(transportId, externalChatId, chatSessionId, resolvedRole ?? undefined);
|
|
101
125
|
if (!updated) {
|
|
102
126
|
notFound(res, "No chat state found for this transport");
|
|
103
127
|
return;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @package-contract — see ./types.ts
|
|
2
|
+
//
|
|
3
|
+
// Per-key serialization primitive. `run(key, task)` guarantees that
|
|
4
|
+
// for any single key, tasks execute one-at-a-time in call order;
|
|
5
|
+
// tasks under different keys run concurrently. The bridge relay uses
|
|
6
|
+
// it to serialize all turns for one external chat so two concurrent
|
|
7
|
+
// first messages can't each create a separate session (#1878).
|
|
8
|
+
//
|
|
9
|
+
// In-memory and DI-free, matching push-queue.ts: a single server
|
|
10
|
+
// process owns every relay turn. Each per-key chain entry is dropped
|
|
11
|
+
// once its tail settles, so the map doesn't grow without bound.
|
|
12
|
+
export function createKeyedSerializer() {
|
|
13
|
+
const tails = new Map();
|
|
14
|
+
return {
|
|
15
|
+
run(key, task) {
|
|
16
|
+
// `prev` is a swallowed tail that never rejects, so the next
|
|
17
|
+
// task always starts regardless of how the previous one settled.
|
|
18
|
+
const prev = tails.get(key) ?? Promise.resolve();
|
|
19
|
+
const result = prev.then(() => task());
|
|
20
|
+
// Swallow the outcome (so the chain never breaks), then drop the
|
|
21
|
+
// map entry — but only when no later task has chained onto this
|
|
22
|
+
// tail, so unrelated keys never leave stale entries behind.
|
|
23
|
+
const tail = result
|
|
24
|
+
.then(() => { }, () => { })
|
|
25
|
+
.then(() => {
|
|
26
|
+
if (tails.get(key) === tail)
|
|
27
|
+
tails.delete(key);
|
|
28
|
+
});
|
|
29
|
+
tails.set(key, tail);
|
|
30
|
+
return result;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
package/dist/relay.js
CHANGED
|
@@ -7,104 +7,114 @@
|
|
|
7
7
|
// `createRelay(deps)` so the module has no direct imports from the
|
|
8
8
|
// host.
|
|
9
9
|
import { EVENT_TYPES } from "@mulmobridge/protocol";
|
|
10
|
+
import { createKeyedSerializer } from "./keyed-serializer.js";
|
|
10
11
|
// ── Constants ────────────────────────────────────────────────
|
|
11
12
|
const REPLY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
12
13
|
// ── Factory ──────────────────────────────────────────────────
|
|
13
14
|
export function createRelay(deps) {
|
|
15
|
+
const serialize = createKeyedSerializer();
|
|
16
|
+
return function relayMessage(params) {
|
|
17
|
+
// Serialize every turn for one external chat. Without this, two
|
|
18
|
+
// concurrent first messages each read "no state", create separate
|
|
19
|
+
// sessions, and split the conversation across them (#1878). The
|
|
20
|
+
// JSON pair is an unambiguous composite key for the two ids.
|
|
21
|
+
const key = JSON.stringify([params.transportId, params.externalChatId]);
|
|
22
|
+
return serialize.run(key, () => processRelayMessage(deps, params));
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async function processRelayMessage(deps, params) {
|
|
14
26
|
const { store, handleCommand, startChat, onSessionEvent, getRole, defaultRoleId, logger } = deps;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
mimeTypes: attachments.map((a) => a.mimeType),
|
|
24
|
-
}
|
|
25
|
-
: undefined;
|
|
26
|
-
logger.info("chat-service", "message received", {
|
|
27
|
-
transportId,
|
|
28
|
-
externalChatId,
|
|
29
|
-
textLength: text.length,
|
|
30
|
-
...(attachmentSummary ? { attachments: attachmentSummary } : {}),
|
|
31
|
-
});
|
|
32
|
-
let chatState = await store.getChatState(transportId, externalChatId);
|
|
33
|
-
if (!chatState) {
|
|
34
|
-
// Only on FIRST contact do we honour `bridgeOptions.defaultRole`
|
|
35
|
-
// — once the session exists, whatever role the user / command
|
|
36
|
-
// handler settled on is the source of truth. An unknown role
|
|
37
|
-
// id silently falls back to the host-app default (we log it so
|
|
38
|
-
// a typo in the bridge's env var is discoverable).
|
|
39
|
-
const resolved = resolveDefaultRole(bridgeOptions, getRole, defaultRoleId, logger, transportId);
|
|
40
|
-
chatState = await store.resetChatState(transportId, externalChatId, resolved);
|
|
41
|
-
}
|
|
42
|
-
const commandResult = await handleCommand(text, transportId, chatState);
|
|
43
|
-
if (commandResult) {
|
|
44
|
-
// `forwardAs` means "reset/mutate state AND continue into the
|
|
45
|
-
// agent with rewritten text" (see //{skill} shortcut). Without
|
|
46
|
-
// it, short-circuit with the canned reply.
|
|
47
|
-
if (!commandResult.forwardAs) {
|
|
48
|
-
return { kind: "ok", reply: commandResult.reply };
|
|
49
|
-
}
|
|
50
|
-
if (commandResult.nextState)
|
|
51
|
-
chatState = commandResult.nextState;
|
|
52
|
-
text = commandResult.forwardAs;
|
|
27
|
+
const { transportId, externalChatId, attachments, bridgeOptions } = params;
|
|
28
|
+
let { text } = params;
|
|
29
|
+
// Log attachment summary (count + mimeTypes) — NEVER log raw
|
|
30
|
+
// base64 data (performance, log size, information leak risk).
|
|
31
|
+
const attachmentSummary = attachments
|
|
32
|
+
? {
|
|
33
|
+
count: attachments.length,
|
|
34
|
+
mimeTypes: attachments.map((a) => a.mimeType),
|
|
53
35
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
externalChatId,
|
|
79
|
-
error: result.error,
|
|
80
|
-
});
|
|
81
|
-
return {
|
|
82
|
-
kind: "error",
|
|
83
|
-
status,
|
|
84
|
-
message: `Error: ${result.error}`,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
try {
|
|
88
|
-
const reply = await collectAgentReply(onSessionEvent, chatState.sessionId, params.onChunk);
|
|
89
|
-
await store.setChatState(transportId, {
|
|
90
|
-
...chatState,
|
|
91
|
-
updatedAt: new Date().toISOString(),
|
|
92
|
-
});
|
|
93
|
-
return { kind: "ok", reply };
|
|
36
|
+
: undefined;
|
|
37
|
+
logger.info("chat-service", "message received", {
|
|
38
|
+
transportId,
|
|
39
|
+
externalChatId,
|
|
40
|
+
textLength: text.length,
|
|
41
|
+
...(attachmentSummary ? { attachments: attachmentSummary } : {}),
|
|
42
|
+
});
|
|
43
|
+
let chatState = await store.getChatState(transportId, externalChatId);
|
|
44
|
+
if (!chatState) {
|
|
45
|
+
// Only on FIRST contact do we honour `bridgeOptions.defaultRole`
|
|
46
|
+
// — once the session exists, whatever role the user / command
|
|
47
|
+
// handler settled on is the source of truth. An unknown role
|
|
48
|
+
// id silently falls back to the host-app default (we log it so
|
|
49
|
+
// a typo in the bridge's env var is discoverable).
|
|
50
|
+
const resolved = resolveDefaultRole(bridgeOptions, getRole, defaultRoleId, logger, transportId);
|
|
51
|
+
chatState = await store.resetChatState(transportId, externalChatId, resolved);
|
|
52
|
+
}
|
|
53
|
+
const commandResult = await handleCommand(text, transportId, chatState);
|
|
54
|
+
if (commandResult) {
|
|
55
|
+
// `forwardAs` means "reset/mutate state AND continue into the
|
|
56
|
+
// agent with rewritten text" (see //{skill} shortcut). Without
|
|
57
|
+
// it, short-circuit with the canned reply.
|
|
58
|
+
if (!commandResult.forwardAs) {
|
|
59
|
+
return { kind: "ok", reply: commandResult.reply };
|
|
94
60
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
61
|
+
if (commandResult.nextState)
|
|
62
|
+
chatState = commandResult.nextState;
|
|
63
|
+
text = commandResult.forwardAs;
|
|
64
|
+
}
|
|
65
|
+
const result = await startChat({
|
|
66
|
+
message: text,
|
|
67
|
+
roleId: chatState.roleId,
|
|
68
|
+
chatSessionId: chatState.sessionId,
|
|
69
|
+
attachments,
|
|
70
|
+
origin: "bridge",
|
|
71
|
+
// Host app may use other keys (e.g. a future `defaultModel`);
|
|
72
|
+
// we forward the whole bag untouched.
|
|
73
|
+
bridgeOptions,
|
|
74
|
+
});
|
|
75
|
+
if (result.kind === "error") {
|
|
76
|
+
const status = result.status ?? 500;
|
|
77
|
+
if (status === 409) {
|
|
78
|
+
// Session busy — tell the bridge to retry. Keep the HTTP
|
|
79
|
+
// response shape the old handler returned (status 409 on
|
|
80
|
+
// the HTTP side, "ok" reply text on the socket side — both
|
|
81
|
+
// layers decide how to serialise).
|
|
101
82
|
return {
|
|
102
|
-
kind: "
|
|
103
|
-
|
|
104
|
-
message: "Error: failed to collect agent reply",
|
|
83
|
+
kind: "ok",
|
|
84
|
+
reply: "A previous message is still being processed. Please wait.",
|
|
105
85
|
};
|
|
106
86
|
}
|
|
107
|
-
|
|
87
|
+
logger.error("chat-service", "startChat failed", {
|
|
88
|
+
transportId,
|
|
89
|
+
externalChatId,
|
|
90
|
+
error: result.error,
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
kind: "error",
|
|
94
|
+
status,
|
|
95
|
+
message: `Error: ${result.error}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const reply = await collectAgentReply(onSessionEvent, chatState.sessionId, params.onChunk);
|
|
100
|
+
await store.setChatState(transportId, {
|
|
101
|
+
...chatState,
|
|
102
|
+
updatedAt: new Date().toISOString(),
|
|
103
|
+
});
|
|
104
|
+
return { kind: "ok", reply };
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
logger.error("chat-service", "reply collection failed", {
|
|
108
|
+
transportId,
|
|
109
|
+
externalChatId,
|
|
110
|
+
error: String(err),
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
kind: "error",
|
|
114
|
+
status: 500,
|
|
115
|
+
message: "Error: failed to collect agent reply",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
108
118
|
}
|
|
109
119
|
// ── Internals ────────────────────────────────────────────────
|
|
110
120
|
// Resolve the role id to seed a NEW bridge chat state with. Prefers
|
package/dist/socket.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type http from "http";
|
|
|
2
2
|
import { Server as SocketServer } from "socket.io";
|
|
3
3
|
import type { RelayFn } from "./relay.js";
|
|
4
4
|
import type { PushQueue } from "./push-queue.js";
|
|
5
|
-
import type { Logger } from "./types.js";
|
|
5
|
+
import type { Attachment, Logger } from "./types.js";
|
|
6
6
|
export declare const CHAT_SOCKET_PATH = "/ws/chat";
|
|
7
7
|
/**
|
|
8
8
|
* Custom socket.io events the chat transport defines. Keys mirror
|
|
@@ -43,4 +43,6 @@ type BridgeOptions = Readonly<Record<string, string | number | boolean>>;
|
|
|
43
43
|
export declare function bridgeRoom(transportId: string): string;
|
|
44
44
|
export declare function attachChatSocket(server: http.Server, deps: ChatSocketDeps): ChatSocketHandle;
|
|
45
45
|
export declare function sanitiseOptions(raw: unknown): BridgeOptions;
|
|
46
|
+
export declare function parseAttachments(raw: unknown): Attachment[] | undefined;
|
|
47
|
+
export declare function parseOneAttachment(item: unknown): Attachment | null;
|
|
46
48
|
export {};
|
package/dist/socket.js
CHANGED
|
@@ -237,7 +237,7 @@ function parseMessagePayload(payload) {
|
|
|
237
237
|
// is the outer gate; these are tighter, attachment-specific caps.
|
|
238
238
|
const MAX_ATTACHMENT_COUNT = 10;
|
|
239
239
|
const MAX_ATTACHMENT_TOTAL_BYTES = 20 * 1024 * 1024; // 20 MB base64
|
|
240
|
-
function parseAttachments(raw) {
|
|
240
|
+
export function parseAttachments(raw) {
|
|
241
241
|
if (!Array.isArray(raw) || raw.length === 0)
|
|
242
242
|
return undefined;
|
|
243
243
|
const valid = [];
|
|
@@ -245,23 +245,61 @@ function parseAttachments(raw) {
|
|
|
245
245
|
for (const item of raw) {
|
|
246
246
|
if (valid.length >= MAX_ATTACHMENT_COUNT)
|
|
247
247
|
break;
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
totalBytes += data.length;
|
|
248
|
+
const entry = parseOneAttachment(item);
|
|
249
|
+
if (!entry)
|
|
250
|
+
continue;
|
|
251
|
+
if (entry.data) {
|
|
252
|
+
totalBytes += entry.data.length;
|
|
254
253
|
if (totalBytes > MAX_ATTACHMENT_TOTAL_BYTES)
|
|
255
254
|
break;
|
|
256
|
-
const entry = {
|
|
257
|
-
mimeType: item.mimeType,
|
|
258
|
-
data,
|
|
259
|
-
};
|
|
260
|
-
const fn = item.filename;
|
|
261
|
-
if (typeof fn === "string" && fn.length > 0)
|
|
262
|
-
entry.filename = fn;
|
|
263
|
-
valid.push(entry);
|
|
264
255
|
}
|
|
256
|
+
valid.push(entry);
|
|
265
257
|
}
|
|
266
258
|
return valid.length > 0 ? valid : undefined;
|
|
267
259
|
}
|
|
260
|
+
// Accept either the inline `{ data, mimeType }` shape (bridges
|
|
261
|
+
// shipping raw bytes) or the path-only `{ path }` shape (Vue UI and
|
|
262
|
+
// any bridge that uploads to `data/attachments/` first). Either is
|
|
263
|
+
// valid per the exported `Attachment` type. The earlier parser only
|
|
264
|
+
// recognised the first shape, so a typed `{ path }` payload from a
|
|
265
|
+
// socket client passed type-check but got silently dropped at the
|
|
266
|
+
// wire boundary (#1050 review).
|
|
267
|
+
//
|
|
268
|
+
// Wire-boundary path guard: a malicious bridge can ship arbitrary
|
|
269
|
+
// strings as `path`. The downstream `isAttachmentPath` enforcement
|
|
270
|
+
// in the server is the authoritative gate, but defence-in-depth at
|
|
271
|
+
// each layer is required for external code (#1099 review). Reject
|
|
272
|
+
// absolute paths and any traversal segment up front so a payload
|
|
273
|
+
// like `path: "../../etc/passwd"` never reaches the relay.
|
|
274
|
+
// Windows-style drive-letter absolute path (`C:\…` or `C:/…`).
|
|
275
|
+
// `isSafeAttachmentPath` rejects these too — a malicious bridge
|
|
276
|
+
// can't ship a host-absolute path under any platform convention
|
|
277
|
+
// (#1099 review iter-2).
|
|
278
|
+
const WINDOWS_DRIVE_PATH_RE = /^[A-Za-z]:[\\/]/;
|
|
279
|
+
function isSafeAttachmentPath(value) {
|
|
280
|
+
if (value.length === 0)
|
|
281
|
+
return false;
|
|
282
|
+
if (value.startsWith("/") || value.startsWith("\\"))
|
|
283
|
+
return false;
|
|
284
|
+
if (WINDOWS_DRIVE_PATH_RE.test(value))
|
|
285
|
+
return false;
|
|
286
|
+
for (const segment of value.split(/[/\\]/)) {
|
|
287
|
+
if (segment === "..")
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
export function parseOneAttachment(item) {
|
|
293
|
+
if (!item || typeof item !== "object")
|
|
294
|
+
return null;
|
|
295
|
+
const record = item;
|
|
296
|
+
const filename = typeof record.filename === "string" && record.filename.length > 0 ? record.filename : undefined;
|
|
297
|
+
if (typeof record.mimeType === "string" && typeof record.data === "string") {
|
|
298
|
+
return { mimeType: record.mimeType, data: record.data, ...(filename ? { filename } : {}) };
|
|
299
|
+
}
|
|
300
|
+
if (typeof record.path === "string" && isSafeAttachmentPath(record.path)) {
|
|
301
|
+
const mimeType = typeof record.mimeType === "string" ? record.mimeType : undefined;
|
|
302
|
+
return { path: record.path, ...(mimeType ? { mimeType } : {}), ...(filename ? { filename } : {}) };
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -8,19 +8,42 @@ export interface Logger {
|
|
|
8
8
|
info(prefix: string, message: string, data?: Record<string, unknown>): void;
|
|
9
9
|
debug(prefix: string, message: string, data?: Record<string, unknown>): void;
|
|
10
10
|
}
|
|
11
|
-
/** A file attached to a bridge message. Generic enough for
|
|
12
|
-
* PDFs, documents, videos, etc. The server decides what to
|
|
13
|
-
* each based on mimeType — images become vision content
|
|
14
|
-
* unsupported types are ignored with a log.
|
|
11
|
+
/** A file attached to a bridge or UI message. Generic enough for
|
|
12
|
+
* images, PDFs, documents, videos, etc. The server decides what to
|
|
13
|
+
* do with each based on mimeType — images become vision content
|
|
14
|
+
* blocks, unsupported types are ignored with a log.
|
|
15
|
+
*
|
|
16
|
+
* Either `data` (inline base64 bytes) or `path` (workspace-relative
|
|
17
|
+
* path the server can read) MUST be set:
|
|
18
|
+
*
|
|
19
|
+
* - Bridges over the socket transport ship raw bytes, so they
|
|
20
|
+
* populate `data` (and usually `mimeType`).
|
|
21
|
+
* - The Vue UI uploads paste/drop and sidebar-pick files to disk
|
|
22
|
+
* before sending and populates `path`; the server reads bytes
|
|
23
|
+
* from disk and infers `mimeType` from the extension.
|
|
24
|
+
*
|
|
25
|
+
* Mirrors `@mulmobridge/protocol`'s `Attachment` (kept structurally
|
|
26
|
+
* duplicated here per the package-contract rules in this file). */
|
|
15
27
|
export interface Attachment {
|
|
16
|
-
mimeType
|
|
17
|
-
data
|
|
28
|
+
mimeType?: string;
|
|
29
|
+
data?: string;
|
|
30
|
+
path?: string;
|
|
18
31
|
filename?: string;
|
|
19
32
|
}
|
|
20
33
|
export interface StartChatParams {
|
|
21
34
|
message: string;
|
|
22
35
|
roleId: string;
|
|
23
36
|
chatSessionId: string;
|
|
37
|
+
/** Bridge-only legacy carrier for "the user picked this image".
|
|
38
|
+
* No in-tree bridge populates this today; the field stays on the
|
|
39
|
+
* type so external bridge clients on older protocol versions still
|
|
40
|
+
* type-check. Only workspace paths are accepted — `data:` URLs
|
|
41
|
+
* are no longer supported and the host app drops them with a
|
|
42
|
+
* warn. Bridges that need to ship raw bytes should use the
|
|
43
|
+
* modern `attachments[]` field with `{ mimeType, data }` entries;
|
|
44
|
+
* the host app persists those to `data/attachments/YYYY/MM/`
|
|
45
|
+
* server-side and rewrites them as path-bearing attachments
|
|
46
|
+
* before any other processing. */
|
|
24
47
|
selectedImageData?: string;
|
|
25
48
|
attachments?: Attachment[];
|
|
26
49
|
/** Session origin — application-defined (e.g. "human", "bridge") */
|
|
@@ -99,6 +122,17 @@ export interface ChatServiceDeps {
|
|
|
99
122
|
}>;
|
|
100
123
|
total: number;
|
|
101
124
|
}>;
|
|
125
|
+
/**
|
|
126
|
+
* Resolve the roleId a given session was started with. Used by the HTTP
|
|
127
|
+
* `/connect` route so the persisted bridge state's role tracks the target
|
|
128
|
+
* session's role after a repoint — same drift-fix as bridge `/switch`
|
|
129
|
+
* (issue #1888 / #1894), but for API callers that only supply a session ID.
|
|
130
|
+
* Returns null when the session isn't found OR when its role isn't known;
|
|
131
|
+
* on null the route falls back to the previous "preserve current role"
|
|
132
|
+
* behaviour (safe default). Omit this dep entirely to keep the old
|
|
133
|
+
* session-id-only semantics.
|
|
134
|
+
*/
|
|
135
|
+
getSessionRole?: (sessionId: string) => Promise<string | null>;
|
|
102
136
|
/**
|
|
103
137
|
* Return the skills the bridge command handler should expose. The
|
|
104
138
|
* handler uses the result for two things:
|
package/package.json
CHANGED