@mulmobridge/chat-service 0.1.0 → 0.1.2
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 +19 -1
- package/dist/commands.js +172 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -4
- package/dist/relay.d.ts +7 -0
- package/dist/relay.js +37 -8
- package/dist/socket.d.ts +3 -0
- package/dist/socket.js +42 -7
- package/dist/types.d.ts +43 -0
- package/package.json +2 -2
package/dist/commands.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Role } from "./types.js";
|
|
1
|
+
import type { Role, SessionSummary } from "./types.js";
|
|
2
2
|
import type { ChatStateStore, TransportChatState } from "./chat-state.js";
|
|
3
3
|
export interface CommandResult {
|
|
4
4
|
reply: string;
|
|
@@ -9,4 +9,22 @@ export declare function createCommandHandler(opts: {
|
|
|
9
9
|
loadAllRoles: () => Role[];
|
|
10
10
|
getRole: (roleId: string) => Role;
|
|
11
11
|
resetChatState: ChatStateStore["resetChatState"];
|
|
12
|
+
connectSession: ChatStateStore["connectSession"];
|
|
13
|
+
listSessions?: (opts: {
|
|
14
|
+
limit: number;
|
|
15
|
+
offset: number;
|
|
16
|
+
}) => Promise<{
|
|
17
|
+
sessions: SessionSummary[];
|
|
18
|
+
total: number;
|
|
19
|
+
}>;
|
|
20
|
+
getSessionHistory?: (sessionId: string, opts: {
|
|
21
|
+
limit: number;
|
|
22
|
+
offset: number;
|
|
23
|
+
}) => Promise<{
|
|
24
|
+
messages: Array<{
|
|
25
|
+
source: string;
|
|
26
|
+
text: string;
|
|
27
|
+
}>;
|
|
28
|
+
total: number;
|
|
29
|
+
}>;
|
|
12
30
|
}): CommandHandler;
|
package/dist/commands.js
CHANGED
|
@@ -4,16 +4,59 @@
|
|
|
4
4
|
// /status) for the transport chat bridge. Role lookups and state
|
|
5
5
|
// reset arrive via the factory so this file has zero imports from
|
|
6
6
|
// the host app — only sibling module types.
|
|
7
|
+
// Mirror server/utils/time.ts names but declared locally since
|
|
8
|
+
// the chat-service package must not import from the host app.
|
|
9
|
+
const ONE_MINUTE_MS = 60_000;
|
|
10
|
+
const ONE_HOUR_MS = 3_600_000;
|
|
11
|
+
const ONE_DAY_MS = 86_400_000;
|
|
12
|
+
function formatRelativeTime(isoDate) {
|
|
13
|
+
const diffMs = Date.now() - new Date(isoDate).getTime();
|
|
14
|
+
const minutes = Math.floor(diffMs / ONE_MINUTE_MS);
|
|
15
|
+
if (minutes < 1)
|
|
16
|
+
return "just now";
|
|
17
|
+
if (minutes < 60)
|
|
18
|
+
return `${minutes} min ago`;
|
|
19
|
+
const hours = Math.floor(diffMs / ONE_HOUR_MS);
|
|
20
|
+
if (hours < 24)
|
|
21
|
+
return `${hours}h ago`;
|
|
22
|
+
const days = Math.floor(diffMs / ONE_DAY_MS);
|
|
23
|
+
return `${days}d ago`;
|
|
24
|
+
}
|
|
7
25
|
// ── Factory ──────────────────────────────────────────────────
|
|
8
26
|
export function createCommandHandler(opts) {
|
|
9
|
-
const { loadAllRoles, getRole, resetChatState } = opts;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
27
|
+
const { loadAllRoles, getRole, resetChatState, connectSession, listSessions, getSessionHistory } = opts;
|
|
28
|
+
// Cache /sessions results per chat so /switch resolves to the correct list.
|
|
29
|
+
// Key: "transportId:externalChatId". Bounded with max entries + TTL.
|
|
30
|
+
// See docs/bridge-session-design.md for multi-user scaling plan.
|
|
31
|
+
const MAX_CACHE_ENTRIES = 1000;
|
|
32
|
+
const CACHE_TTL_MS = 5 * ONE_MINUTE_MS;
|
|
33
|
+
const sessionListCache = new Map();
|
|
34
|
+
const cacheKey = (transportId, externalChatId) => `${transportId}:${externalChatId}`;
|
|
35
|
+
function getCachedSessions(key) {
|
|
36
|
+
const entry = sessionListCache.get(key);
|
|
37
|
+
if (!entry)
|
|
38
|
+
return null;
|
|
39
|
+
if (Date.now() - entry.createdAt > CACHE_TTL_MS) {
|
|
40
|
+
sessionListCache.delete(key);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return entry.sessions;
|
|
44
|
+
}
|
|
45
|
+
function setCachedSessions(key, sessions) {
|
|
46
|
+
if (sessionListCache.size >= MAX_CACHE_ENTRIES) {
|
|
47
|
+
const oldest = sessionListCache.keys().next().value;
|
|
48
|
+
if (oldest !== undefined)
|
|
49
|
+
sessionListCache.delete(oldest);
|
|
50
|
+
}
|
|
51
|
+
sessionListCache.set(key, { sessions, createdAt: Date.now() });
|
|
52
|
+
}
|
|
53
|
+
const getRolesText = () => ["Available roles:", ...loadAllRoles().map((r) => ` ${r.id} — ${r.name}`)].join("\n");
|
|
14
54
|
const getHelpText = () => [
|
|
15
55
|
"Commands:",
|
|
16
56
|
" /reset — Start a new session",
|
|
57
|
+
" /sessions [page] — List recent sessions (e.g. /sessions 2)",
|
|
58
|
+
" /switch <number|sessionId> — Switch to a session",
|
|
59
|
+
" /history [page] — Show recent messages in current session",
|
|
17
60
|
" /help — Show this help",
|
|
18
61
|
" /roles — List available roles",
|
|
19
62
|
" /role <id> — Switch role",
|
|
@@ -45,13 +88,126 @@ export function createCommandHandler(opts) {
|
|
|
45
88
|
const handleStatus = (chatState) => {
|
|
46
89
|
const role = getRole(chatState.roleId);
|
|
47
90
|
return {
|
|
48
|
-
reply: [
|
|
49
|
-
`Role: ${role.name} (${role.id})`,
|
|
50
|
-
`Session: ${chatState.sessionId}`,
|
|
51
|
-
`Last activity: ${chatState.updatedAt}`,
|
|
52
|
-
].join("\n"),
|
|
91
|
+
reply: [`Role: ${role.name} (${role.id})`, `Session: ${chatState.sessionId}`, `Last activity: ${chatState.updatedAt}`].join("\n"),
|
|
53
92
|
};
|
|
54
93
|
};
|
|
94
|
+
const SESSIONS_PAGE_SIZE = 10;
|
|
95
|
+
const handleSessions = async (transportId, chatState, pageArg) => {
|
|
96
|
+
if (!listSessions) {
|
|
97
|
+
return { reply: "Session listing is not available." };
|
|
98
|
+
}
|
|
99
|
+
const page = Math.max(1, parseInt(pageArg ?? "1", 10) || 1);
|
|
100
|
+
const offset = (page - 1) * SESSIONS_PAGE_SIZE;
|
|
101
|
+
const { sessions, total } = await listSessions({
|
|
102
|
+
limit: SESSIONS_PAGE_SIZE,
|
|
103
|
+
offset,
|
|
104
|
+
});
|
|
105
|
+
if (sessions.length === 0 && total === 0) {
|
|
106
|
+
return { reply: "No sessions found." };
|
|
107
|
+
}
|
|
108
|
+
if (sessions.length === 0) {
|
|
109
|
+
return { reply: `No more sessions. Total: ${total}` };
|
|
110
|
+
}
|
|
111
|
+
// Cache full page for /switch (keyed by offset so numbers are absolute)
|
|
112
|
+
const key = cacheKey(transportId, chatState.externalChatId);
|
|
113
|
+
const existing = getCachedSessions(key) ?? [];
|
|
114
|
+
// Merge into cache at correct positions
|
|
115
|
+
const merged = [...existing];
|
|
116
|
+
sessions.forEach((s, i) => {
|
|
117
|
+
merged[offset + i] = s;
|
|
118
|
+
});
|
|
119
|
+
setCachedSessions(key, merged);
|
|
120
|
+
const totalPages = Math.ceil(total / SESSIONS_PAGE_SIZE);
|
|
121
|
+
const lines = sessions.map((s, i) => {
|
|
122
|
+
const num = offset + i + 1;
|
|
123
|
+
const preview = s.preview.length > 40 ? s.preview.slice(0, 40) + "..." : s.preview;
|
|
124
|
+
return ` ${num}. [${s.roleId}] ${preview || "(no title)"} — ${formatRelativeTime(s.updatedAt)}`;
|
|
125
|
+
});
|
|
126
|
+
const header = `Sessions (page ${page}/${totalPages}, total ${total}):`;
|
|
127
|
+
const parts = [header, ...lines];
|
|
128
|
+
if (page < totalPages) {
|
|
129
|
+
parts.push(`\n/sessions ${page + 1} for next page`);
|
|
130
|
+
}
|
|
131
|
+
parts.push("Use /switch <number> or /switch <sessionId> to connect.");
|
|
132
|
+
return { reply: parts.join("\n") };
|
|
133
|
+
};
|
|
134
|
+
const handleSwitch = async (transportId, chatState, arg) => {
|
|
135
|
+
if (!arg) {
|
|
136
|
+
return {
|
|
137
|
+
reply: "Usage: /switch <number|sessionId>\nRun /sessions first to see the list.",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const key = cacheKey(transportId, chatState.externalChatId);
|
|
141
|
+
const cached = getCachedSessions(key) ?? [];
|
|
142
|
+
let target;
|
|
143
|
+
if (/^\d+$/.test(arg)) {
|
|
144
|
+
// Numeric — index into cached list
|
|
145
|
+
const index = parseInt(arg, 10);
|
|
146
|
+
if (index < 1 || index > cached.length) {
|
|
147
|
+
return {
|
|
148
|
+
reply: cached.length > 0 ? `Invalid number. Pick 1-${cached.length} from the /sessions list.` : "Run /sessions first to see available sessions.",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
target = cached[index - 1];
|
|
152
|
+
// Guard against sparse cache (user loaded page 2 but not page 1)
|
|
153
|
+
if (!target) {
|
|
154
|
+
const page = Math.ceil(index / SESSIONS_PAGE_SIZE);
|
|
155
|
+
return {
|
|
156
|
+
reply: `Run /sessions ${page} first to load that page.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
// Non-numeric — treat as session ID
|
|
162
|
+
target = cached.find((s) => s.id === arg);
|
|
163
|
+
if (!target) {
|
|
164
|
+
return {
|
|
165
|
+
reply: `Session "${arg}" not found. Run /sessions to see available sessions.`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const updated = await connectSession(transportId, chatState.externalChatId, target.id);
|
|
170
|
+
if (!updated) {
|
|
171
|
+
return { reply: "Failed to switch session." };
|
|
172
|
+
}
|
|
173
|
+
const role = getRole(target.roleId);
|
|
174
|
+
const preview = target.preview || "(no title)";
|
|
175
|
+
return {
|
|
176
|
+
reply: `Connected to "${preview}" (${role.name}). Send a message to continue.`,
|
|
177
|
+
nextState: updated,
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
const HISTORY_PAGE_SIZE = 5;
|
|
181
|
+
const MAX_MESSAGE_LENGTH = 200;
|
|
182
|
+
const handleHistory = async (chatState, pageArg) => {
|
|
183
|
+
if (!getSessionHistory) {
|
|
184
|
+
return { reply: "History is not available." };
|
|
185
|
+
}
|
|
186
|
+
const page = Math.max(1, parseInt(pageArg ?? "1", 10) || 1);
|
|
187
|
+
const offset = (page - 1) * HISTORY_PAGE_SIZE;
|
|
188
|
+
const { messages, total } = await getSessionHistory(chatState.sessionId, {
|
|
189
|
+
limit: HISTORY_PAGE_SIZE,
|
|
190
|
+
offset,
|
|
191
|
+
});
|
|
192
|
+
if (messages.length === 0 && total === 0) {
|
|
193
|
+
return { reply: "No messages in this session." };
|
|
194
|
+
}
|
|
195
|
+
if (messages.length === 0) {
|
|
196
|
+
return { reply: `No more messages. Total: ${total}` };
|
|
197
|
+
}
|
|
198
|
+
const totalPages = Math.ceil(total / HISTORY_PAGE_SIZE);
|
|
199
|
+
const lines = messages.map((m) => {
|
|
200
|
+
const label = m.source === "user" ? "You" : "AI";
|
|
201
|
+
const text = m.text.length > MAX_MESSAGE_LENGTH ? m.text.slice(0, MAX_MESSAGE_LENGTH) + "..." : m.text;
|
|
202
|
+
return `[${label}] ${text}`;
|
|
203
|
+
});
|
|
204
|
+
const header = `History (page ${page}/${totalPages}):`;
|
|
205
|
+
const parts = [header, "", ...lines];
|
|
206
|
+
if (page < totalPages) {
|
|
207
|
+
parts.push(`\n/history ${page + 1} for older messages`);
|
|
208
|
+
}
|
|
209
|
+
return { reply: parts.join("\n\n") };
|
|
210
|
+
};
|
|
55
211
|
const handleCommand = async (text, transportId, chatState) => {
|
|
56
212
|
if (!text.startsWith("/"))
|
|
57
213
|
return null;
|
|
@@ -59,6 +215,12 @@ export function createCommandHandler(opts) {
|
|
|
59
215
|
switch (command) {
|
|
60
216
|
case "/reset":
|
|
61
217
|
return handleReset(transportId, chatState);
|
|
218
|
+
case "/sessions":
|
|
219
|
+
return handleSessions(transportId, chatState, args[0]);
|
|
220
|
+
case "/switch":
|
|
221
|
+
return handleSwitch(transportId, chatState, args[0]);
|
|
222
|
+
case "/history":
|
|
223
|
+
return handleHistory(chatState, args[0]);
|
|
62
224
|
case "/help":
|
|
63
225
|
return { reply: getHelpText() };
|
|
64
226
|
case "/roles":
|
package/dist/index.d.ts
CHANGED
|
@@ -16,5 +16,5 @@ export interface ChatService {
|
|
|
16
16
|
pushToBridge: PushFn;
|
|
17
17
|
}
|
|
18
18
|
export declare function createChatService(deps: ChatServiceDeps): ChatService;
|
|
19
|
-
export type { ChatServiceDeps, StartChatFn, OnSessionEventFn
|
|
19
|
+
export type { ChatServiceDeps, StartChatFn, OnSessionEventFn } from "./types.js";
|
|
20
20
|
export { writeFileAtomic } from "./atomic-write.js";
|
package/dist/index.js
CHANGED
|
@@ -29,7 +29,7 @@ const badRequest = (res, error) => res.status(400).json({ error });
|
|
|
29
29
|
const notFound = (res, error) => res.status(404).json({ error });
|
|
30
30
|
// ── Factory ──────────────────────────────────────────────────
|
|
31
31
|
export function createChatService(deps) {
|
|
32
|
-
const { startChat, onSessionEvent, loadAllRoles, getRole, defaultRoleId, tokenProvider
|
|
32
|
+
const { startChat, onSessionEvent, loadAllRoles, getRole, defaultRoleId, tokenProvider } = deps;
|
|
33
33
|
const logger = deps.logger;
|
|
34
34
|
const store = createChatStateStore({
|
|
35
35
|
transportsDir: deps.transportsDir,
|
|
@@ -39,6 +39,9 @@ export function createChatService(deps) {
|
|
|
39
39
|
loadAllRoles,
|
|
40
40
|
getRole,
|
|
41
41
|
resetChatState: store.resetChatState,
|
|
42
|
+
connectSession: store.connectSession,
|
|
43
|
+
listSessions: deps.listSessions,
|
|
44
|
+
getSessionHistory: deps.getSessionHistory,
|
|
42
45
|
});
|
|
43
46
|
const relay = createRelay({
|
|
44
47
|
store,
|
|
@@ -88,9 +91,7 @@ export function createChatService(deps) {
|
|
|
88
91
|
// reassign the active session pointer for a transport chat.
|
|
89
92
|
router.post(CHAT_SERVICE_ROUTES.connect, async (req, res) => {
|
|
90
93
|
const { transportId, externalChatId } = req.params;
|
|
91
|
-
const chatSessionId = typeof req.body?.chatSessionId === "string"
|
|
92
|
-
? req.body.chatSessionId.trim()
|
|
93
|
-
: "";
|
|
94
|
+
const chatSessionId = typeof req.body?.chatSessionId === "string" ? req.body.chatSessionId.trim() : "";
|
|
94
95
|
if (!chatSessionId) {
|
|
95
96
|
badRequest(res, "chatSessionId is required");
|
|
96
97
|
return;
|
package/dist/relay.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ export interface RelayParams {
|
|
|
6
6
|
externalChatId: string;
|
|
7
7
|
text: string;
|
|
8
8
|
attachments?: Attachment[];
|
|
9
|
+
/** Flat primitive bag captured at handshake time (string /
|
|
10
|
+
* number / boolean values only — see socket.ts sanitiser).
|
|
11
|
+
* Forwarded to the host app's startChat callback as
|
|
12
|
+
* `bridgeOptions`. Empty when the bridge didn't send any.
|
|
13
|
+
* See plans/feat-bridge-options-passthrough.md. */
|
|
14
|
+
bridgeOptions?: Readonly<Record<string, string | number | boolean>>;
|
|
9
15
|
/** Called for each text chunk as the agent generates it. Used by
|
|
10
16
|
* the socket transport to stream text to the bridge in real time
|
|
11
17
|
* (Phase C of #268). */
|
|
@@ -30,3 +36,4 @@ export interface RelayDeps {
|
|
|
30
36
|
logger: Logger;
|
|
31
37
|
}
|
|
32
38
|
export declare function createRelay(deps: RelayDeps): RelayFn;
|
|
39
|
+
export declare function resolveDefaultRole(bridgeOptions: Readonly<Record<string, string | number | boolean>> | undefined, getRole: (roleId: string) => Role, fallbackRoleId: string, logger: Logger, transportId: string): string;
|
package/dist/relay.js
CHANGED
|
@@ -11,9 +11,9 @@ import { EVENT_TYPES } from "@mulmobridge/protocol";
|
|
|
11
11
|
const REPLY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
12
12
|
// ── Factory ──────────────────────────────────────────────────
|
|
13
13
|
export function createRelay(deps) {
|
|
14
|
-
const { store, handleCommand, startChat, onSessionEvent, getRole, defaultRoleId, logger
|
|
14
|
+
const { store, handleCommand, startChat, onSessionEvent, getRole, defaultRoleId, logger } = deps;
|
|
15
15
|
return async function relayMessage(params) {
|
|
16
|
-
const { transportId, externalChatId, text, attachments } = params;
|
|
16
|
+
const { transportId, externalChatId, text, attachments, bridgeOptions } = params;
|
|
17
17
|
// Log attachment summary (count + mimeTypes) — NEVER log raw
|
|
18
18
|
// base64 data (performance, log size, information leak risk).
|
|
19
19
|
const attachmentSummary = attachments
|
|
@@ -30,8 +30,13 @@ export function createRelay(deps) {
|
|
|
30
30
|
});
|
|
31
31
|
let chatState = await store.getChatState(transportId, externalChatId);
|
|
32
32
|
if (!chatState) {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// Only on FIRST contact do we honour `bridgeOptions.defaultRole`
|
|
34
|
+
// — once the session exists, whatever role the user / command
|
|
35
|
+
// handler settled on is the source of truth. An unknown role
|
|
36
|
+
// id silently falls back to the host-app default (we log it so
|
|
37
|
+
// a typo in the bridge's env var is discoverable).
|
|
38
|
+
const resolved = resolveDefaultRole(bridgeOptions, getRole, defaultRoleId, logger, transportId);
|
|
39
|
+
chatState = await store.resetChatState(transportId, externalChatId, resolved);
|
|
35
40
|
}
|
|
36
41
|
const commandResult = await handleCommand(text, transportId, chatState);
|
|
37
42
|
if (commandResult) {
|
|
@@ -42,6 +47,10 @@ export function createRelay(deps) {
|
|
|
42
47
|
roleId: chatState.roleId,
|
|
43
48
|
chatSessionId: chatState.sessionId,
|
|
44
49
|
attachments,
|
|
50
|
+
origin: "bridge",
|
|
51
|
+
// Host app may use other keys (e.g. a future `defaultModel`);
|
|
52
|
+
// we forward the whole bag untouched.
|
|
53
|
+
bridgeOptions,
|
|
45
54
|
});
|
|
46
55
|
if (result.kind === "error") {
|
|
47
56
|
const status = result.status ?? 500;
|
|
@@ -89,6 +98,28 @@ export function createRelay(deps) {
|
|
|
89
98
|
};
|
|
90
99
|
}
|
|
91
100
|
// ── Internals ────────────────────────────────────────────────
|
|
101
|
+
// Resolve the role id to seed a NEW bridge chat state with. Prefers
|
|
102
|
+
// `bridgeOptions.defaultRole` when the bridge sent one and it names
|
|
103
|
+
// a role the host app actually has. Falls back to the host-app
|
|
104
|
+
// default on absence / unknown id (with a warn log so an env-var
|
|
105
|
+
// typo is traceable). Exported for direct unit testing.
|
|
106
|
+
export function resolveDefaultRole(bridgeOptions, getRole, fallbackRoleId, logger, transportId) {
|
|
107
|
+
const requested = bridgeOptions?.defaultRole;
|
|
108
|
+
if (typeof requested !== "string" || requested.length === 0)
|
|
109
|
+
return fallbackRoleId;
|
|
110
|
+
// `getRole` on an unknown id silently returns the first built-in
|
|
111
|
+
// role — compare ids to catch that before we commit to it.
|
|
112
|
+
const resolved = getRole(requested);
|
|
113
|
+
if (resolved.id !== requested) {
|
|
114
|
+
logger.warn("chat-service", "bridge requested unknown default role; falling back", {
|
|
115
|
+
transportId,
|
|
116
|
+
requested,
|
|
117
|
+
fallback: fallbackRoleId,
|
|
118
|
+
});
|
|
119
|
+
return fallbackRoleId;
|
|
120
|
+
}
|
|
121
|
+
return resolved.id;
|
|
122
|
+
}
|
|
92
123
|
// Kept out of the factory closure so future packaging doesn't need
|
|
93
124
|
// to re-capture anything; `onSessionEvent` arrives as a plain param.
|
|
94
125
|
function collectAgentReply(onSessionEvent, chatSessionId, onChunk) {
|
|
@@ -96,8 +127,7 @@ function collectAgentReply(onSessionEvent, chatSessionId, onChunk) {
|
|
|
96
127
|
const textChunks = [];
|
|
97
128
|
const timer = setTimeout(() => {
|
|
98
129
|
unsubscribe();
|
|
99
|
-
resolve(textChunks.join("") ||
|
|
100
|
-
"The request timed out before a reply was generated.");
|
|
130
|
+
resolve(textChunks.join("") || "The request timed out before a reply was generated.");
|
|
101
131
|
}, REPLY_TIMEOUT_MS);
|
|
102
132
|
const unsubscribe = onSessionEvent(chatSessionId, (event) => {
|
|
103
133
|
const type = event.type;
|
|
@@ -114,8 +144,7 @@ function collectAgentReply(onSessionEvent, chatSessionId, onChunk) {
|
|
|
114
144
|
if (type === EVENT_TYPES.sessionFinished) {
|
|
115
145
|
clearTimeout(timer);
|
|
116
146
|
unsubscribe();
|
|
117
|
-
resolve(textChunks.join("") ||
|
|
118
|
-
"The assistant completed the request but produced no text reply.");
|
|
147
|
+
resolve(textChunks.join("") || "The assistant completed the request but produced no text reply.");
|
|
119
148
|
}
|
|
120
149
|
});
|
|
121
150
|
});
|
package/dist/socket.d.ts
CHANGED
|
@@ -39,5 +39,8 @@ export interface ChatSocketHandle {
|
|
|
39
39
|
* joiner. */
|
|
40
40
|
pushToBridge: PushFn;
|
|
41
41
|
}
|
|
42
|
+
type BridgeOptions = Readonly<Record<string, string | number | boolean>>;
|
|
42
43
|
export declare function bridgeRoom(transportId: string): string;
|
|
43
44
|
export declare function attachChatSocket(server: http.Server, deps: ChatSocketDeps): ChatSocketHandle;
|
|
45
|
+
export declare function sanitiseOptions(raw: unknown): BridgeOptions;
|
|
46
|
+
export {};
|
package/dist/socket.js
CHANGED
|
@@ -63,6 +63,9 @@ export function attachChatSocket(server, deps) {
|
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
65
|
socket.data.transportId = result.transportId;
|
|
66
|
+
// Stash options on the socket so every subsequent message from
|
|
67
|
+
// this bridge inherits the same config without re-sending it.
|
|
68
|
+
socket.data.bridgeOptions = result.options;
|
|
66
69
|
next();
|
|
67
70
|
});
|
|
68
71
|
io.on("connection", (socket) => {
|
|
@@ -116,6 +119,10 @@ export function attachChatSocket(server, deps) {
|
|
|
116
119
|
externalChatId: parsed.externalChatId,
|
|
117
120
|
text: parsed.text,
|
|
118
121
|
attachments: parsed.attachments,
|
|
122
|
+
// Options captured at handshake time (see io.use). Empty
|
|
123
|
+
// object when the bridge didn't send any — the host app
|
|
124
|
+
// treats absence and empty identically.
|
|
125
|
+
bridgeOptions: socket.data.bridgeOptions ?? {},
|
|
119
126
|
// Stream text chunks to this bridge socket in real time
|
|
120
127
|
// (Phase C of #268). The ack still returns the full text
|
|
121
128
|
// for backward compatibility.
|
|
@@ -151,18 +158,48 @@ export function attachChatSocket(server, deps) {
|
|
|
151
158
|
};
|
|
152
159
|
return { io, pushToBridge };
|
|
153
160
|
}
|
|
161
|
+
// `options` on the handshake is reduced to a FLAT PRIMITIVE bag:
|
|
162
|
+
// string / number / boolean only. This serves two purposes —
|
|
163
|
+
// 1. Rejects nested objects / arrays / functions so a downstream
|
|
164
|
+
// merge in the host app can't reintroduce prototype-pollution
|
|
165
|
+
// via a deeply-nested `__proto__` (top-level `__proto__` is
|
|
166
|
+
// stripped separately as a belt-and-braces guard).
|
|
167
|
+
// 2. Makes the wire contract explicit: every value the host sees
|
|
168
|
+
// is something you could put in an env var.
|
|
169
|
+
// Anything else (objects, arrays, null, undefined, functions,
|
|
170
|
+
// symbols, bigints) is dropped silently. The bridge author is
|
|
171
|
+
// responsible for flattening complex config into primitives before
|
|
172
|
+
// sending.
|
|
173
|
+
export function sanitiseOptions(raw) {
|
|
174
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
175
|
+
return {};
|
|
176
|
+
const out = {};
|
|
177
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
178
|
+
if (key === "__proto__" || key === "prototype" || key === "constructor")
|
|
179
|
+
continue;
|
|
180
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
181
|
+
// Drop non-finite numbers (NaN / Infinity) — they serialise
|
|
182
|
+
// oddly and the host app shouldn't be asked to distinguish
|
|
183
|
+
// "valid zero" from "broken input".
|
|
184
|
+
if (typeof value === "number" && !Number.isFinite(value))
|
|
185
|
+
continue;
|
|
186
|
+
out[key] = value;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
154
191
|
function validateHandshake(auth, tokenProvider) {
|
|
155
192
|
if (!auth || typeof auth !== "object") {
|
|
156
193
|
return { ok: false, error: "handshake auth is required" };
|
|
157
194
|
}
|
|
158
195
|
const transportIdRaw = auth.transportId;
|
|
159
|
-
if (typeof transportIdRaw !== "string" ||
|
|
160
|
-
transportIdRaw.trim().length === 0) {
|
|
196
|
+
if (typeof transportIdRaw !== "string" || transportIdRaw.trim().length === 0) {
|
|
161
197
|
return { ok: false, error: "transportId is required" };
|
|
162
198
|
}
|
|
163
199
|
const transportId = transportIdRaw.trim();
|
|
200
|
+
const options = sanitiseOptions(auth.options);
|
|
164
201
|
if (!tokenProvider) {
|
|
165
|
-
return { ok: true, transportId };
|
|
202
|
+
return { ok: true, transportId, options };
|
|
166
203
|
}
|
|
167
204
|
const expected = tokenProvider();
|
|
168
205
|
if (expected === null || expected.length === 0) {
|
|
@@ -178,15 +215,13 @@ function validateHandshake(auth, tokenProvider) {
|
|
|
178
215
|
if (provided !== expected) {
|
|
179
216
|
return { ok: false, error: "invalid token" };
|
|
180
217
|
}
|
|
181
|
-
return { ok: true, transportId };
|
|
218
|
+
return { ok: true, transportId, options };
|
|
182
219
|
}
|
|
183
220
|
function parseMessagePayload(payload) {
|
|
184
221
|
if (!payload || typeof payload !== "object") {
|
|
185
222
|
return { ok: false, error: "payload must be an object" };
|
|
186
223
|
}
|
|
187
|
-
const externalChatId = typeof payload.externalChatId === "string"
|
|
188
|
-
? payload.externalChatId.trim()
|
|
189
|
-
: "";
|
|
224
|
+
const externalChatId = typeof payload.externalChatId === "string" ? payload.externalChatId.trim() : "";
|
|
190
225
|
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
|
191
226
|
if (!externalChatId) {
|
|
192
227
|
return { ok: false, error: "externalChatId is required" };
|
package/dist/types.d.ts
CHANGED
|
@@ -23,6 +23,16 @@ export interface StartChatParams {
|
|
|
23
23
|
chatSessionId: string;
|
|
24
24
|
selectedImageData?: string;
|
|
25
25
|
attachments?: Attachment[];
|
|
26
|
+
/** Session origin — application-defined (e.g. "human", "bridge") */
|
|
27
|
+
origin?: string;
|
|
28
|
+
/** Flat primitive bag forwarded from the bridge handshake. Chat-
|
|
29
|
+
* service sanitises to string / number / boolean values only
|
|
30
|
+
* before this point — nested objects are dropped at the socket
|
|
31
|
+
* boundary so a downstream merge can't reintroduce prototype-
|
|
32
|
+
* pollution. The host app is free to look up its own keys
|
|
33
|
+
* (e.g. `defaultRole`). Empty object when the bridge didn't
|
|
34
|
+
* send any. */
|
|
35
|
+
bridgeOptions?: Readonly<Record<string, string | number | boolean>>;
|
|
26
36
|
}
|
|
27
37
|
export type StartChatResult = {
|
|
28
38
|
kind: "started";
|
|
@@ -35,6 +45,13 @@ export type StartChatResult = {
|
|
|
35
45
|
export type StartChatFn = (params: StartChatParams) => Promise<StartChatResult>;
|
|
36
46
|
export type SessionEventListener = (event: Record<string, unknown>) => void;
|
|
37
47
|
export type OnSessionEventFn = (sessionId: string, listener: SessionEventListener) => () => void;
|
|
48
|
+
/** Summary of a chat session — returned by listSessions. */
|
|
49
|
+
export interface SessionSummary {
|
|
50
|
+
id: string;
|
|
51
|
+
roleId: string;
|
|
52
|
+
preview: string;
|
|
53
|
+
updatedAt: string;
|
|
54
|
+
}
|
|
38
55
|
export interface ChatServiceDeps {
|
|
39
56
|
/** Relay a user turn into the agent loop. */
|
|
40
57
|
startChat: StartChatFn;
|
|
@@ -56,4 +73,30 @@ export interface ChatServiceDeps {
|
|
|
56
73
|
* `attachChatSocket` in ./socket.ts.
|
|
57
74
|
*/
|
|
58
75
|
tokenProvider?: () => string | null;
|
|
76
|
+
/**
|
|
77
|
+
* List recent sessions from the server. Used by /sessions command.
|
|
78
|
+
* Omit if session listing is not available (command will reply
|
|
79
|
+
* "not available").
|
|
80
|
+
*/
|
|
81
|
+
listSessions?: (opts: {
|
|
82
|
+
limit: number;
|
|
83
|
+
offset: number;
|
|
84
|
+
}) => Promise<{
|
|
85
|
+
sessions: SessionSummary[];
|
|
86
|
+
total: number;
|
|
87
|
+
}>;
|
|
88
|
+
/**
|
|
89
|
+
* Get recent messages from a session. Used by /history command.
|
|
90
|
+
* Returns newest-first array of {source, text} pairs.
|
|
91
|
+
*/
|
|
92
|
+
getSessionHistory?: (sessionId: string, opts: {
|
|
93
|
+
limit: number;
|
|
94
|
+
offset: number;
|
|
95
|
+
}) => Promise<{
|
|
96
|
+
messages: Array<{
|
|
97
|
+
source: string;
|
|
98
|
+
text: string;
|
|
99
|
+
}>;
|
|
100
|
+
total: number;
|
|
101
|
+
}>;
|
|
59
102
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mulmobridge/chat-service",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Server-side chat service for MulmoBridge — socket.io + REST bridge to Claude Code agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"author": "Receptron Team",
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@mulmobridge/protocol": "^0.1.
|
|
30
|
+
"@mulmobridge/protocol": "^0.1.4"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"express": "^5.0.0",
|