@mulmobridge/chat-service 0.1.3 → 0.1.4
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/commands.d.ts +26 -0
- package/dist/commands.js +40 -5
- 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 +29 -6
- package/package.json +1 -1
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;
|
|
@@ -224,15 +258,16 @@ export function createCommandHandler(opts) {
|
|
|
224
258
|
return null;
|
|
225
259
|
// `//{skill} [args...]` shortcut — start a new session AND run
|
|
226
260
|
// the skill in one bridge turn. Args after the skill name are
|
|
227
|
-
// forwarded verbatim
|
|
228
|
-
//
|
|
261
|
+
// forwarded verbatim — split at the FIRST whitespace character
|
|
262
|
+
// and preserve everything after it (including doubled spaces /
|
|
263
|
+
// tabs) so payloads like a URL with internal whitespace, or
|
|
264
|
+
// multi-paragraph prompts, aren't silently rewritten.
|
|
229
265
|
if (text.startsWith("//")) {
|
|
230
266
|
const skills = await fetchSkills();
|
|
231
|
-
const
|
|
232
|
-
const skillName = head.slice(2);
|
|
267
|
+
const { skillName, argsVerbatim } = parseSkillShortcut(text);
|
|
233
268
|
if (skillName && skills.some((s) => s.name === skillName)) {
|
|
234
269
|
const nextState = await resetChatState(transportId, chatState.externalChatId, chatState.roleId);
|
|
235
|
-
const forwardAs =
|
|
270
|
+
const forwardAs = argsVerbatim.length > 0 ? `/${skillName} ${argsVerbatim}` : `/${skillName}`;
|
|
236
271
|
return {
|
|
237
272
|
reply: `Session reset. Running ${forwardAs}`,
|
|
238
273
|
nextState,
|
|
@@ -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") */
|
package/package.json
CHANGED