@clawchatsai/connector 0.0.62 → 0.0.64
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/index.js +26 -12
- package/package.json +1 -1
- package/server.js +137 -10
package/dist/index.js
CHANGED
|
@@ -125,8 +125,33 @@ async function ensureNativeModules(ctx) {
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
+
const CLAWCHATS_MD_CONTENT = `# ClawChats — Inline File Delivery
|
|
129
|
+
|
|
130
|
+
To deliver a file inline in this chat, print a MEDIA: line to stdout during an exec call:
|
|
131
|
+
|
|
132
|
+
MEDIA:/absolute/path/to/file
|
|
133
|
+
|
|
134
|
+
- Supported for images (png, jpg, gif, webp, svg, etc.) and other file types
|
|
135
|
+
- Include it at the end of the same exec command that creates the file
|
|
136
|
+
- Example: \`python3 generate.py --output /tmp/result.png && echo "MEDIA:/tmp/result.png"\`
|
|
137
|
+
- Output each path once — duplicates are ignored automatically
|
|
138
|
+
`;
|
|
128
139
|
async function startClawChats(ctx, api, mediaStash) {
|
|
129
140
|
_stopRequested = false;
|
|
141
|
+
// Bootstrap CLAWCHATS.md in the agent workspace so the agent always knows the MEDIA: protocol.
|
|
142
|
+
// Written once on plugin start; never overwrites an existing file (user may have customised it).
|
|
143
|
+
try {
|
|
144
|
+
const workspaceDir = path.join(ctx.stateDir, 'workspace');
|
|
145
|
+
const clawchatsDoc = path.join(workspaceDir, 'CLAWCHATS.md');
|
|
146
|
+
if (!fs.existsSync(clawchatsDoc)) {
|
|
147
|
+
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
148
|
+
fs.writeFileSync(clawchatsDoc, CLAWCHATS_MD_CONTENT, { encoding: 'utf8' });
|
|
149
|
+
ctx.logger.info('[clawchats] wrote CLAWCHATS.md to workspace');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
ctx.logger.warn(`[clawchats] could not write CLAWCHATS.md: ${e.message}`);
|
|
154
|
+
}
|
|
130
155
|
let config = loadConfig();
|
|
131
156
|
if (!config) {
|
|
132
157
|
ctx.logger.info('ClawChats not configured. Waiting for setup...');
|
|
@@ -356,8 +381,6 @@ async function stopClawChats(ctx) {
|
|
|
356
381
|
* guard checks MediaPath/MediaPaths but not inline base64 attachments).
|
|
357
382
|
* Injecting a minimal placeholder ensures the agent run proceeds.
|
|
358
383
|
*/
|
|
359
|
-
// Track which ClawChats thread sessions have received the file-hint (once per session).
|
|
360
|
-
const _hintedSessions = new Set();
|
|
361
384
|
function normalizeGatewayPayload(raw) {
|
|
362
385
|
try {
|
|
363
386
|
const parsed = JSON.parse(raw);
|
|
@@ -379,15 +402,6 @@ function normalizeGatewayPayload(raw) {
|
|
|
379
402
|
parsed.params.message = '[Image]';
|
|
380
403
|
return JSON.stringify(parsed);
|
|
381
404
|
}
|
|
382
|
-
// Hint injection: teach the agent to emit MEDIA:/path via exec after creating files.
|
|
383
|
-
// Injected once per ClawChats thread session (~15 tokens, invisible to user).
|
|
384
|
-
const sk = parsed.params.sessionKey || '';
|
|
385
|
-
if (sk.includes(':chat:') && !_hintedSessions.has(sk)) {
|
|
386
|
-
_hintedSessions.add(sk);
|
|
387
|
-
parsed.params.message += '\n[ClawChats: after creating or saving any file, run: exec echo "MEDIA:/absolute/path/to/file" — this delivers it inline to the chat]';
|
|
388
|
-
console.log(`[clawchats] hint-injected for session ${sk}`);
|
|
389
|
-
return JSON.stringify(parsed);
|
|
390
|
-
}
|
|
391
405
|
}
|
|
392
406
|
}
|
|
393
407
|
catch {
|
|
@@ -1102,7 +1116,7 @@ const plugin = {
|
|
|
1102
1116
|
if (!sessionKey)
|
|
1103
1117
|
return;
|
|
1104
1118
|
const existing = mediaStash.get(sessionKey) ?? [];
|
|
1105
|
-
mediaStash.set(sessionKey, [...existing, ...paths]);
|
|
1119
|
+
mediaStash.set(sessionKey, [...new Set([...existing, ...paths])]);
|
|
1106
1120
|
console.log(`[clawchats] media-capture: stashed ${paths.length} path(s) for ${sessionKey}:`, paths);
|
|
1107
1121
|
}, { name: 'clawchats-media-capture', description: 'Captures MEDIA: paths from exec results for inline image rendering' });
|
|
1108
1122
|
// Background service: signaling + gateway bridge + future WebRTC
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { execSync } from 'node:child_process';
|
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { createRequire } from 'node:module';
|
|
14
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
14
15
|
import { WebSocket as WS, WebSocketServer } from 'ws';
|
|
15
16
|
|
|
16
17
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -22,6 +23,9 @@ const __dirname = path.dirname(__filename);
|
|
|
22
23
|
// auto-rebuild in-place before proceeding. Falls back to a clear error message
|
|
23
24
|
// if the user is missing build tools.
|
|
24
25
|
const _require = createRequire(import.meta.url);
|
|
26
|
+
|
|
27
|
+
// Per-request workspace DB — isolates concurrent clients on different workspaces.
|
|
28
|
+
const _requestDbStore = new AsyncLocalStorage();
|
|
25
29
|
let Database;
|
|
26
30
|
{
|
|
27
31
|
const _nativeErr = (e) =>
|
|
@@ -331,7 +335,7 @@ function getDb(workspaceName) {
|
|
|
331
335
|
}
|
|
332
336
|
|
|
333
337
|
function getActiveDb() {
|
|
334
|
-
return getDb(getWorkspaces().active);
|
|
338
|
+
return _requestDbStore.getStore() || getDb(getWorkspaces().active);
|
|
335
339
|
}
|
|
336
340
|
|
|
337
341
|
const _globalDbCache = new Map(); // keyed by resolved dbPath
|
|
@@ -2172,6 +2176,12 @@ async function handleTranscribe(req, res) {
|
|
|
2172
2176
|
}
|
|
2173
2177
|
|
|
2174
2178
|
async function handleRequest(req, res) {
|
|
2179
|
+
const _wsName = req.headers?.['x-workspace'];
|
|
2180
|
+
const _requestDb = _wsName ? getDb(_wsName) : getActiveDb();
|
|
2181
|
+
return _requestDbStore.run(_requestDb, () => _handleRequestImpl(req, res));
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
async function _handleRequestImpl(req, res) {
|
|
2175
2185
|
// Parse URL and query string
|
|
2176
2186
|
const [urlPath, queryString] = (req.url || '/').split('?');
|
|
2177
2187
|
const query = {};
|
|
@@ -3207,6 +3217,61 @@ function extractContent(message) {
|
|
|
3207
3217
|
return '';
|
|
3208
3218
|
}
|
|
3209
3219
|
|
|
3220
|
+
// ─── Sentinel filtering helpers ───────────────────────────────────────────────
|
|
3221
|
+
// Mirrors logic from OpenClaw's tokens-C27XM9Ox.js so we can filter NO_REPLY /
|
|
3222
|
+
// HEARTBEAT_OK tokens before they reach the browser — including partial leaks
|
|
3223
|
+
// during streaming (e.g. "NO_" appearing mid-stream before the full token is
|
|
3224
|
+
// assembled). See GitHub issue #96.
|
|
3225
|
+
|
|
3226
|
+
// Returns true if text is exactly the sentinel (with optional surrounding whitespace).
|
|
3227
|
+
function isSilentReplyExact(text, token = 'NO_REPLY') {
|
|
3228
|
+
if (!text) return false;
|
|
3229
|
+
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
3230
|
+
return new RegExp(`^\\s*${escaped}\\s*$`).test(text);
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
// Returns true if text could be the beginning of the sentinel token —
|
|
3234
|
+
// i.e. it is a strict uppercase-only prefix of the token.
|
|
3235
|
+
// Example: "NO" and "NO_" and "NO_REPLY" all return true for token="NO_REPLY".
|
|
3236
|
+
function isSilentReplyPrefix(text, token = 'NO_REPLY') {
|
|
3237
|
+
if (!text) return false;
|
|
3238
|
+
const trimmed = text.trimStart();
|
|
3239
|
+
if (!trimmed) return false;
|
|
3240
|
+
if (trimmed !== trimmed.toUpperCase()) return false; // must be ALL CAPS
|
|
3241
|
+
const normalized = trimmed.toUpperCase();
|
|
3242
|
+
if (normalized.length < 2) return false;
|
|
3243
|
+
if (/[^A-Z_]/.test(normalized)) return false; // only letters + underscore
|
|
3244
|
+
const tokenUpper = token.toUpperCase();
|
|
3245
|
+
if (!tokenUpper.startsWith(normalized)) return false; // must be a prefix
|
|
3246
|
+
if (normalized.includes('_')) return true; // past the first word — unambiguous
|
|
3247
|
+
// Single word without underscore: only allow "NO" for NO_REPLY (avoids false-positives
|
|
3248
|
+
// on other short all-caps words like "HI", "OK", etc.)
|
|
3249
|
+
return tokenUpper === 'NO_REPLY' && normalized === 'NO';
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
// Strip a trailing sentinel token from mixed-content text (e.g. "answer NO_REPLY" → "answer").
|
|
3253
|
+
function stripTrailingSentinel(text, token = 'NO_REPLY') {
|
|
3254
|
+
if (!text) return text;
|
|
3255
|
+
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
3256
|
+
return text.replace(new RegExp(`(?:^|\\s+|\\*+)${escaped}\\s*$`), '').trim();
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
// Strip internal <final> / </final> tags that occasionally leak from the model.
|
|
3260
|
+
function stripFinalTags(text) {
|
|
3261
|
+
if (!text) return text;
|
|
3262
|
+
return text.replace(/<\s*\/?\s*final\s*>/gi, '');
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
// Apply all content sanitization steps before saving to DB.
|
|
3266
|
+
function sanitizeAssistantContent(text) {
|
|
3267
|
+
if (!text) return text;
|
|
3268
|
+
let out = stripFinalTags(text);
|
|
3269
|
+
out = out.replace(/^(?:[ \t]*\r?\n)+/, ''); // strip leading blank lines
|
|
3270
|
+
if (out.includes('NO_REPLY')) out = stripTrailingSentinel(out, 'NO_REPLY');
|
|
3271
|
+
if (out.includes('HEARTBEAT_OK')) out = stripTrailingSentinel(out, 'HEARTBEAT_OK');
|
|
3272
|
+
return out;
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3210
3275
|
// ─── Shared activity log helpers ─────────────────────────────────────────────
|
|
3211
3276
|
// Pure functions extracted from GatewayClient / _GatewayClient so both classes
|
|
3212
3277
|
// share a single implementation. Pass the workspace-scoped DB getter and a
|
|
@@ -3398,7 +3463,7 @@ export function createApp(config = {}) {
|
|
|
3398
3463
|
}
|
|
3399
3464
|
|
|
3400
3465
|
function _getActiveDb() {
|
|
3401
|
-
return _getDb(_getWorkspaces().active);
|
|
3466
|
+
return _requestDbStore.getStore() || _getDb(_getWorkspaces().active);
|
|
3402
3467
|
}
|
|
3403
3468
|
|
|
3404
3469
|
function _closeDb(workspaceName) {
|
|
@@ -4028,25 +4093,51 @@ export function createApp(config = {}) {
|
|
|
4028
4093
|
return;
|
|
4029
4094
|
}
|
|
4030
4095
|
if (msg.type === 'res' && msg.payload?.type === 'hello-ok') { console.log('Gateway handshake complete'); this.connected = true; this.broadcastGatewayStatus(true); }
|
|
4031
|
-
|
|
4032
|
-
|
|
4096
|
+
// Chat events are routed through handleChatEvent which decides when/if to
|
|
4097
|
+
// broadcast (sentinel hold, flush ordering, suppression). All other events
|
|
4098
|
+
// broadcast unconditionally.
|
|
4099
|
+
if (msg.type === 'event' && msg.event === 'chat' && msg.payload) {
|
|
4100
|
+
this.handleChatEvent(msg.payload, data);
|
|
4101
|
+
} else {
|
|
4102
|
+
this.broadcastToBrowsers(data);
|
|
4103
|
+
}
|
|
4033
4104
|
if (msg.type === 'event' && msg.event === 'agent' && msg.payload) this.handleAgentEvent(msg.payload);
|
|
4034
4105
|
}
|
|
4035
4106
|
|
|
4036
|
-
handleChatEvent(params) {
|
|
4107
|
+
handleChatEvent(params, rawData) {
|
|
4037
4108
|
const { sessionKey, state, message, seq } = params;
|
|
4109
|
+
|
|
4038
4110
|
if (state === 'delta') {
|
|
4039
4111
|
const parsed = parseSessionKey(sessionKey);
|
|
4040
4112
|
if (parsed) {
|
|
4041
|
-
const existing = this.streamState.get(sessionKey) || { buffer: '', threadId: parsed.threadId, state: 'streaming' };
|
|
4113
|
+
const existing = this.streamState.get(sessionKey) || { buffer: '', threadId: parsed.threadId, state: 'streaming', held: [] };
|
|
4042
4114
|
existing.buffer += extractContent(message);
|
|
4115
|
+
|
|
4116
|
+
// If the accumulated buffer looks like a NO_REPLY or HEARTBEAT_OK prefix,
|
|
4117
|
+
// hold this raw delta — don't forward to browsers yet.
|
|
4118
|
+
if (isSilentReplyPrefix(existing.buffer, 'NO_REPLY') || isSilentReplyPrefix(existing.buffer, 'HEARTBEAT_OK')) {
|
|
4119
|
+
existing.held = existing.held || [];
|
|
4120
|
+
existing.held.push(rawData);
|
|
4121
|
+
this.streamState.set(sessionKey, existing);
|
|
4122
|
+
return; // suppressed — wait for more chunks
|
|
4123
|
+
}
|
|
4124
|
+
|
|
4125
|
+
// Buffer diverged from any sentinel — flush held deltas first, then current.
|
|
4126
|
+
if (existing.held?.length > 0) {
|
|
4127
|
+
for (const h of existing.held) this.broadcastToBrowsers(h);
|
|
4128
|
+
existing.held = [];
|
|
4129
|
+
}
|
|
4043
4130
|
this.streamState.set(sessionKey, existing);
|
|
4044
4131
|
}
|
|
4132
|
+
this.broadcastToBrowsers(rawData);
|
|
4045
4133
|
return;
|
|
4046
4134
|
}
|
|
4135
|
+
|
|
4136
|
+
// Capture stream entry before deletion so we can flush held deltas if needed.
|
|
4137
|
+
const streamEntry = this.streamState.get(sessionKey);
|
|
4047
4138
|
if (state === 'final' || state === 'aborted' || state === 'error') this.streamState.delete(sessionKey);
|
|
4048
4139
|
|
|
4049
|
-
//
|
|
4140
|
+
// Title generation sessions: pass through without sentinel filtering.
|
|
4050
4141
|
if (sessionKey && sessionKey.includes('__clawchats_title_')) {
|
|
4051
4142
|
if (state === 'final') {
|
|
4052
4143
|
const content = extractContent(message);
|
|
@@ -4061,7 +4152,33 @@ export function createApp(config = {}) {
|
|
|
4061
4152
|
}
|
|
4062
4153
|
}
|
|
4063
4154
|
|
|
4064
|
-
if (state === 'final')
|
|
4155
|
+
if (state === 'final') {
|
|
4156
|
+
const rawContent = extractContent(message);
|
|
4157
|
+
|
|
4158
|
+
// Exact NO_REPLY or HEARTBEAT_OK — suppress entirely. Discard any held deltas,
|
|
4159
|
+
// don't broadcast the final event, don't save to DB.
|
|
4160
|
+
if (isSilentReplyExact(rawContent, 'NO_REPLY') || isSilentReplyExact(rawContent, 'HEARTBEAT_OK')) {
|
|
4161
|
+
return;
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
// If we held deltas (buffer was a sentinel prefix), flush them before the final
|
|
4165
|
+
// so the browser has a response element ready for finalization.
|
|
4166
|
+
if (streamEntry?.held?.length > 0) {
|
|
4167
|
+
for (const h of streamEntry.held) this.broadcastToBrowsers(h);
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4170
|
+
// Broadcast the final event, then persist.
|
|
4171
|
+
this.broadcastToBrowsers(rawData);
|
|
4172
|
+
this.saveAssistantMessage(sessionKey, message, seq);
|
|
4173
|
+
return;
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
if (state === 'aborted' || state === 'error') {
|
|
4177
|
+
// Discard any held deltas silently — if stream was aborted while holding a
|
|
4178
|
+
// NO_REPLY prefix, there's nothing to show. Broadcast the terminal event normally.
|
|
4179
|
+
this.broadcastToBrowsers(rawData);
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4065
4182
|
if (state === 'error') this.saveErrorMarker(sessionKey, message);
|
|
4066
4183
|
}
|
|
4067
4184
|
|
|
@@ -4073,7 +4190,7 @@ export function createApp(config = {}) {
|
|
|
4073
4190
|
const db = _getDb(parsed.workspace);
|
|
4074
4191
|
const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
|
|
4075
4192
|
if (!thread) { console.log(`Ignoring response for deleted thread: ${parsed.threadId}`); return; }
|
|
4076
|
-
let content = extractContent(message);
|
|
4193
|
+
let content = sanitizeAssistantContent(extractContent(message));
|
|
4077
4194
|
if (!content || !content.trim()) { console.log(`Skipping empty assistant response for thread ${parsed.threadId}`); return; }
|
|
4078
4195
|
|
|
4079
4196
|
// Attach media captured by the after_tool_call hook (MEDIA: lines from exec stdout).
|
|
@@ -4336,7 +4453,11 @@ export function createApp(config = {}) {
|
|
|
4336
4453
|
ws.send(JSON.stringify({ type: 'clawchats', event: 'gateway-status', connected: this.connected }));
|
|
4337
4454
|
const streams = [];
|
|
4338
4455
|
for (const [sessionKey, state] of this.streamState.entries()) {
|
|
4339
|
-
|
|
4456
|
+
// Skip sessions in sentinel-hold state — their buffer contains a NO_REPLY/HEARTBEAT_OK
|
|
4457
|
+
// prefix that should not be forwarded to reconnecting browsers.
|
|
4458
|
+
if (state.state === 'streaming' && !(state.held?.length > 0)) {
|
|
4459
|
+
streams.push({ sessionKey, threadId: state.threadId, buffer: state.buffer });
|
|
4460
|
+
}
|
|
4340
4461
|
}
|
|
4341
4462
|
if (streams.length > 0) ws.send(JSON.stringify({ type: 'clawchats', event: 'stream-sync', streams }));
|
|
4342
4463
|
}
|
|
@@ -4369,6 +4490,12 @@ export function createApp(config = {}) {
|
|
|
4369
4490
|
|
|
4370
4491
|
// ── handleRequest (scoped to factory state) ────────────────────────────────
|
|
4371
4492
|
async function _handleRequest(req, res) {
|
|
4493
|
+
const _wsName = req.headers?.['x-workspace'];
|
|
4494
|
+
const _requestDb = _wsName ? _getDb(_wsName) : _getActiveDb();
|
|
4495
|
+
return _requestDbStore.run(_requestDb, () => _handleRequestImplFactory(req, res));
|
|
4496
|
+
}
|
|
4497
|
+
|
|
4498
|
+
async function _handleRequestImplFactory(req, res) {
|
|
4372
4499
|
const [urlPath, queryString] = (req.url || '/').split('?');
|
|
4373
4500
|
const query = {};
|
|
4374
4501
|
if (queryString) {
|