wtt-connect 0.1.0
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/README.md +295 -0
- package/bin/wtt-connect.js +7 -0
- package/package.json +32 -0
- package/scripts/install-agent.sh +4 -0
- package/scripts/install-launchd.sh +51 -0
- package/scripts/install-systemd-user.sh +189 -0
- package/scripts/uninstall-launchd.sh +18 -0
- package/scripts/uninstall-systemd-user.sh +42 -0
- package/src/adapter-registry.js +58 -0
- package/src/adapters/acp.js +196 -0
- package/src/adapters/claude-code.js +161 -0
- package/src/adapters/codex.js +88 -0
- package/src/adapters/generic-cli.js +258 -0
- package/src/adapters/index.js +28 -0
- package/src/adapters/openclaw.js +32 -0
- package/src/artifacts.js +68 -0
- package/src/attachments.js +181 -0
- package/src/config.js +118 -0
- package/src/env.js +42 -0
- package/src/events.js +23 -0
- package/src/logger.js +18 -0
- package/src/main.js +143 -0
- package/src/mime.js +12 -0
- package/src/permissions.js +28 -0
- package/src/runner.js +562 -0
- package/src/runtime-info.js +71 -0
- package/src/service-manager.js +510 -0
- package/src/session-manager.js +31 -0
- package/src/setup.js +71 -0
- package/src/shell-runner.js +184 -0
- package/src/smoke.js +99 -0
- package/src/store.js +58 -0
- package/src/stt.js +146 -0
- package/src/terminal-session.js +152 -0
- package/src/tts.js +40 -0
- package/src/wtt-api.js +79 -0
- package/src/wtt-client.js +144 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import { log } from '../logger.js';
|
|
4
|
+
|
|
5
|
+
const PROFILE_ALIASES = {
|
|
6
|
+
claude: 'claude-code',
|
|
7
|
+
'cursor-agent': 'cursor',
|
|
8
|
+
opencode: 'opencode',
|
|
9
|
+
crush: 'crush',
|
|
10
|
+
devin: 'devin',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const CLI_PROFILES = {
|
|
14
|
+
cursor: {
|
|
15
|
+
binKey: 'cursorBin',
|
|
16
|
+
defaultBin: 'agent',
|
|
17
|
+
sessionKey: 'cursorSessionId',
|
|
18
|
+
buildArgs: ({ prompt, config, sessionId }) => {
|
|
19
|
+
const args = ['--print', '--output-format', 'stream-json', '--trust'];
|
|
20
|
+
if (['yolo', 'full-auto', 'force', 'auto'].includes(normalizeMode(config.mode))) args.push('--force');
|
|
21
|
+
else if (['plan', 'suggest'].includes(normalizeMode(config.mode))) args.push('--mode', 'plan');
|
|
22
|
+
else if (normalizeMode(config.mode) === 'ask') args.push('--mode', 'ask');
|
|
23
|
+
if (sessionId) args.push('--resume', sessionId);
|
|
24
|
+
if (config.model) args.push('--model', config.model);
|
|
25
|
+
args.push('--workspace', config.workDir, '--', prompt);
|
|
26
|
+
return { args };
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
gemini: {
|
|
30
|
+
binKey: 'geminiBin',
|
|
31
|
+
defaultBin: 'gemini',
|
|
32
|
+
sessionKey: 'geminiSessionId',
|
|
33
|
+
buildArgs: ({ prompt, config, sessionId, context }) => {
|
|
34
|
+
const args = ['--output-format', 'stream-json'];
|
|
35
|
+
if (['yolo', 'full-auto', 'force'].includes(normalizeMode(config.mode))) args.push('-y');
|
|
36
|
+
else if (['auto-edit', 'auto_edit', 'auto'].includes(normalizeMode(config.mode))) args.push('--approval-mode', 'auto_edit');
|
|
37
|
+
else if (['plan', 'suggest'].includes(normalizeMode(config.mode))) args.push('--approval-mode', 'plan');
|
|
38
|
+
if (sessionId) args.push('--resume', sessionId);
|
|
39
|
+
if (config.model) args.push('-m', config.model);
|
|
40
|
+
args.push('-p', '-');
|
|
41
|
+
return { args, stdin: appendAttachmentRefs(prompt, context) };
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
qoder: {
|
|
45
|
+
binKey: 'qoderBin',
|
|
46
|
+
defaultBin: 'qodercli',
|
|
47
|
+
sessionKey: 'qoderSessionId',
|
|
48
|
+
buildArgs: ({ prompt, config, sessionId, context }) => {
|
|
49
|
+
const args = ['-p', appendAttachmentRefs(prompt, context), '-f', 'stream-json', '-q', '-w', config.workDir];
|
|
50
|
+
if (sessionId) args.push('-r', sessionId);
|
|
51
|
+
if (['yolo', 'full-auto', 'force'].includes(normalizeMode(config.mode))) args.push('--dangerously-skip-permissions');
|
|
52
|
+
if (config.model) args.push('--model', config.model);
|
|
53
|
+
return { args };
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
opencode: {
|
|
57
|
+
binKey: 'opencodeBin',
|
|
58
|
+
defaultBin: 'opencode',
|
|
59
|
+
sessionKey: 'opencodeSessionId',
|
|
60
|
+
buildArgs: ({ prompt, config, sessionId, context }) => {
|
|
61
|
+
const args = ['run', '--format', 'json'];
|
|
62
|
+
if (sessionId) args.push('--session', sessionId);
|
|
63
|
+
if (config.model) args.push('--model', config.model);
|
|
64
|
+
args.push('--dir', config.workDir, '--thinking');
|
|
65
|
+
for (const img of context.images || []) args.push('--file', img);
|
|
66
|
+
args.push('--', prompt);
|
|
67
|
+
return { args };
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
crush: {
|
|
71
|
+
binKey: 'crushBin',
|
|
72
|
+
defaultBin: 'crush',
|
|
73
|
+
sessionKey: 'crushSessionId',
|
|
74
|
+
buildArgs: ({ prompt, config, sessionId, context }) => CLI_PROFILES.opencode.buildArgs({ prompt, config, sessionId, context }),
|
|
75
|
+
},
|
|
76
|
+
iflow: {
|
|
77
|
+
binKey: 'iflowBin',
|
|
78
|
+
defaultBin: 'iflow',
|
|
79
|
+
sessionKey: 'iflowSessionId',
|
|
80
|
+
buildArgs: ({ prompt, config, sessionId, context }) => {
|
|
81
|
+
const args = [];
|
|
82
|
+
if (config.model) args.push('-m', config.model);
|
|
83
|
+
const mode = normalizeMode(config.mode);
|
|
84
|
+
if (['yolo', 'full-auto', 'force'].includes(mode)) args.push('--yolo');
|
|
85
|
+
else if (['plan', 'suggest'].includes(mode)) args.push('--plan');
|
|
86
|
+
else if (['auto-edit', 'auto_edit', 'auto'].includes(mode)) args.push('--autoEdit');
|
|
87
|
+
else args.push('--default');
|
|
88
|
+
if (sessionId) args.push('-r', sessionId);
|
|
89
|
+
args.push('-i', appendAttachmentRefs(prompt, context));
|
|
90
|
+
return { args };
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
kimi: {
|
|
94
|
+
binKey: 'kimiBin',
|
|
95
|
+
defaultBin: 'kimi',
|
|
96
|
+
sessionKey: 'kimiSessionId',
|
|
97
|
+
buildArgs: ({ prompt, config, sessionId, context }) => {
|
|
98
|
+
const args = ['--print', '--output-format', 'stream-json'];
|
|
99
|
+
if (['plan', 'suggest'].includes(normalizeMode(config.mode))) args.push('--plan');
|
|
100
|
+
else if (normalizeMode(config.mode) === 'quiet') args.push('--quiet');
|
|
101
|
+
if (sessionId) args.push('--resume', sessionId);
|
|
102
|
+
if (config.model) args.push('--model', config.model);
|
|
103
|
+
args.push('--work-dir', config.workDir, '--prompt', appendAttachmentRefs(prompt, context));
|
|
104
|
+
return { args };
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
pi: {
|
|
108
|
+
binKey: 'piBin',
|
|
109
|
+
defaultBin: 'pi',
|
|
110
|
+
sessionKey: 'piSessionId',
|
|
111
|
+
buildArgs: ({ prompt, config, sessionId, context }) => {
|
|
112
|
+
const args = ['--mode', 'json', '-p'];
|
|
113
|
+
if (sessionId) args.push('--session', sessionId);
|
|
114
|
+
if (config.model) args.push('--model', config.model);
|
|
115
|
+
if (['yolo', 'full-auto', 'force', 'auto'].includes(normalizeMode(config.mode))) args.push('--auto-approve');
|
|
116
|
+
if (config.reasoningEffort) args.push('--thinking', config.reasoningEffort);
|
|
117
|
+
for (const img of context.images || []) args.push(`@${img}`);
|
|
118
|
+
args.push(prompt);
|
|
119
|
+
return { args };
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export class GenericCliAdapter {
|
|
125
|
+
constructor(name, config, deps = {}) {
|
|
126
|
+
this.name = normalizeProfileName(name);
|
|
127
|
+
this.config = config;
|
|
128
|
+
this.store = deps.store;
|
|
129
|
+
this.profile = CLI_PROFILES[this.name];
|
|
130
|
+
if (!this.profile) throw new Error(`unsupported generic CLI adapter: ${name}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async run(prompt, context = {}) {
|
|
134
|
+
const sessionKey = context.sessionKey || 'default';
|
|
135
|
+
const stored = this.store?.getSession(sessionKey) || {};
|
|
136
|
+
const storedKey = this.profile.sessionKey || `${this.name}SessionId`;
|
|
137
|
+
const sessionId = stored[storedKey];
|
|
138
|
+
const bin = this.config[this.profile.binKey] || this.profile.defaultBin;
|
|
139
|
+
const built = this.profile.buildArgs({ prompt, config: this.config, sessionId, context });
|
|
140
|
+
log('info', `${this.name} launch`, { sessionKey, resume: Boolean(sessionId), mode: this.config.mode });
|
|
141
|
+
const result = await runCli(bin, built.args, built.stdin, this.config.workDir, (event) => {
|
|
142
|
+
const eventSessionId = extractSessionId(event);
|
|
143
|
+
if (eventSessionId) this.store?.patchSession(sessionKey, { [storedKey]: eventSessionId, adapter: this.name });
|
|
144
|
+
if (this.config.publishProgress && context.onProgress) context.onProgress(event).catch(() => {});
|
|
145
|
+
}, this.config.taskTimeoutSeconds * 1000);
|
|
146
|
+
if (result.sessionId) this.store?.patchSession(sessionKey, { [storedKey]: result.sessionId, adapter: this.name });
|
|
147
|
+
return result.text.trim();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function normalizeProfileName(name) {
|
|
152
|
+
const raw = String(name || '').trim().toLowerCase();
|
|
153
|
+
return PROFILE_ALIASES[raw] || raw;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function adapterBin(config, adapter) {
|
|
157
|
+
const name = normalizeProfileName(adapter);
|
|
158
|
+
const profile = CLI_PROFILES[name];
|
|
159
|
+
if (!profile) return '';
|
|
160
|
+
return config[profile.binKey] || profile.defaultBin;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function runCli(bin, args, stdinText, cwd, onEvent, timeoutMs) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const child = spawn(bin, args, { cwd, stdio: [stdinText == null ? 'ignore' : 'pipe', 'pipe', 'pipe'], env: { ...process.env } });
|
|
166
|
+
let stderr = '';
|
|
167
|
+
let plainStdout = '';
|
|
168
|
+
let sessionId = '';
|
|
169
|
+
const texts = [];
|
|
170
|
+
const timer = setTimeout(() => {
|
|
171
|
+
child.kill('SIGTERM');
|
|
172
|
+
reject(new Error(`${bin} timed out after ${timeoutMs}ms`));
|
|
173
|
+
}, timeoutMs);
|
|
174
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
175
|
+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
176
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
177
|
+
rl.on('line', (line) => {
|
|
178
|
+
if (!line.trim()) return;
|
|
179
|
+
let event;
|
|
180
|
+
try { event = JSON.parse(line); } catch {
|
|
181
|
+
plainStdout += `${line}\n`;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const eventSessionId = extractSessionId(event);
|
|
185
|
+
if (eventSessionId) sessionId = eventSessionId;
|
|
186
|
+
const text = extractText(event);
|
|
187
|
+
if (text) texts.push(text);
|
|
188
|
+
onEvent?.(event);
|
|
189
|
+
});
|
|
190
|
+
child.on('close', (code) => {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
if (code !== 0) reject(new Error(`${bin} exited ${code}: ${stderr.trim()}`));
|
|
193
|
+
else resolve({ text: collapseText(texts, plainStdout), sessionId });
|
|
194
|
+
});
|
|
195
|
+
if (stdinText != null) child.stdin.end(stdinText);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function collapseText(texts, plainStdout) {
|
|
200
|
+
const filtered = texts.map((x) => String(x || '').trim()).filter(Boolean);
|
|
201
|
+
if (filtered.length) return filtered.join('\n').trim();
|
|
202
|
+
return String(plainStdout || '').trim();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function extractSessionId(event) {
|
|
206
|
+
return event.session_id
|
|
207
|
+
|| event.sessionId
|
|
208
|
+
|| event.thread_id
|
|
209
|
+
|| event.threadId
|
|
210
|
+
|| event.conversation_id
|
|
211
|
+
|| event.conversationId
|
|
212
|
+
|| event.chat_id
|
|
213
|
+
|| event.chatId
|
|
214
|
+
|| (event.id && isSessionEvent(event) ? event.id : '');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isSessionEvent(event) {
|
|
218
|
+
const type = String(event.type || event.event || '').toLowerCase();
|
|
219
|
+
return type.includes('session') && (type.includes('start') || type.includes('created') || type.includes('new'));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function extractText(event) {
|
|
223
|
+
if (typeof event === 'string') return event;
|
|
224
|
+
for (const key of ['result', 'text', 'content', 'message', 'output', 'output_text', 'final', 'response']) {
|
|
225
|
+
const value = event[key];
|
|
226
|
+
const text = valueToText(value);
|
|
227
|
+
if (text) return text;
|
|
228
|
+
}
|
|
229
|
+
if (event.type === 'item.completed' && event.item) return valueToText(event.item);
|
|
230
|
+
if (event.type === 'assistant' || event.role === 'assistant') return valueToText(event.content || event.message);
|
|
231
|
+
if (event.type === 'result' || event.event === 'result') return valueToText(event.result || event.data || event.payload);
|
|
232
|
+
return '';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function valueToText(value) {
|
|
236
|
+
if (!value) return '';
|
|
237
|
+
if (typeof value === 'string') return value;
|
|
238
|
+
if (Array.isArray(value)) return value.map(valueToText).filter(Boolean).join('\n');
|
|
239
|
+
if (typeof value === 'object') {
|
|
240
|
+
if (value.type && !['text', 'message', 'assistant', 'agent_message'].includes(String(value.type))) return '';
|
|
241
|
+
for (const key of ['text', 'content', 'message', 'result', 'output_text']) {
|
|
242
|
+
const text = valueToText(value[key]);
|
|
243
|
+
if (text) return text;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return '';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function appendAttachmentRefs(prompt, context = {}) {
|
|
250
|
+
const refs = [];
|
|
251
|
+
if (context.images?.length) refs.push(`Attached images: ${context.images.join(', ')}`);
|
|
252
|
+
if (context.files?.length) refs.push(`Attached files: ${context.files.join(', ')}`);
|
|
253
|
+
return refs.length ? `${prompt}\n\n${refs.join('\n')}` : prompt;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function normalizeMode(mode) {
|
|
257
|
+
return String(mode || '').trim().toLowerCase().replace(/_/g, '-');
|
|
258
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { CodexAdapter } from './codex.js';
|
|
2
|
+
import { ClaudeCodeAdapter } from './claude-code.js';
|
|
3
|
+
import { OpenClawAdapter } from './openclaw.js';
|
|
4
|
+
import { AcpAdapter } from './acp.js';
|
|
5
|
+
import { GenericCliAdapter, normalizeProfileName } from './generic-cli.js';
|
|
6
|
+
|
|
7
|
+
const GENERIC_CLI_ADAPTERS = new Set(['cursor', 'gemini', 'qoder', 'opencode', 'crush', 'iflow', 'kimi', 'pi']);
|
|
8
|
+
|
|
9
|
+
export function createAdapter(config, deps = {}) {
|
|
10
|
+
const adapter = normalizeAdapterName(config.adapter);
|
|
11
|
+
switch (adapter) {
|
|
12
|
+
case 'codex': return new CodexAdapter({ ...config, adapter }, deps);
|
|
13
|
+
case 'claude-code': return new ClaudeCodeAdapter({ ...config, adapter }, deps);
|
|
14
|
+
case 'acp': return new AcpAdapter({ ...config, adapter }, deps);
|
|
15
|
+
case 'devin': return new AcpAdapter({ ...config, adapter, acpCommand: config.devinBin || 'devin', acpArgs: config.devinArgs?.length ? config.devinArgs : ['acp'], acpDisplayName: 'Devin' }, deps);
|
|
16
|
+
case 'openclaw': return new OpenClawAdapter({ ...config, adapter }, deps); // legacy/manual only; wtt-plugin is preferred.
|
|
17
|
+
default:
|
|
18
|
+
if (GENERIC_CLI_ADAPTERS.has(adapter)) return new GenericCliAdapter(adapter, { ...config, adapter }, deps);
|
|
19
|
+
throw new Error(`unsupported adapter: ${config.adapter}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function normalizeAdapterName(name) {
|
|
24
|
+
const normalized = normalizeProfileName(name);
|
|
25
|
+
if (normalized === 'claude') return 'claude-code';
|
|
26
|
+
if (normalized === 'cc-connect') return 'codex';
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export class OpenClawAdapter {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.config = config;
|
|
6
|
+
this.name = 'openclaw';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async run(prompt, context = {}) {
|
|
10
|
+
// First implementation uses the OpenClaw CLI as an external agent surface.
|
|
11
|
+
// Future versions should prefer OpenClaw's native session API when exposed
|
|
12
|
+
// outside the OpenClaw runtime.
|
|
13
|
+
const args = ['run', prompt];
|
|
14
|
+
return runText(this.config.openclawBin, args, this.config.workDir, this.config.taskTimeoutSeconds * 1000);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function runText(bin, args, cwd, timeoutMs) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
|
|
21
|
+
let stdout = '';
|
|
22
|
+
let stderr = '';
|
|
23
|
+
const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error(`${bin} timed out`)); }, timeoutMs);
|
|
24
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
25
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
26
|
+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
27
|
+
child.on('close', (code) => {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
code === 0 ? resolve(stdout.trim()) : reject(new Error(`${bin} exited ${code}: ${stderr.trim()}`));
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
package/src/artifacts.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { lookupMime } from './mime.js';
|
|
4
|
+
import { log } from './logger.js';
|
|
5
|
+
|
|
6
|
+
export class ArtifactManager {
|
|
7
|
+
constructor(config, store) {
|
|
8
|
+
this.config = config;
|
|
9
|
+
this.store = store;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async writeTextArtifact(name, text) {
|
|
13
|
+
const dir = this.config.artifactDir;
|
|
14
|
+
await fs.mkdir(dir, { recursive: true });
|
|
15
|
+
const file = path.join(dir, safeName(name));
|
|
16
|
+
await fs.writeFile(file, text, 'utf8');
|
|
17
|
+
return file;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async uploadFile(filePath) {
|
|
21
|
+
if (!this.config.uploadArtifacts) return null;
|
|
22
|
+
const stat = await fs.stat(filePath);
|
|
23
|
+
if (!stat.isFile()) return null;
|
|
24
|
+
const mimeType = lookupMime(filePath);
|
|
25
|
+
const name = path.basename(filePath);
|
|
26
|
+
const sign = await this.fetchJson('/media/sign', {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'content-type': 'application/json', ...this.authHeaders() },
|
|
29
|
+
body: JSON.stringify({ filename: name, mime_type: mimeType, size: stat.size }),
|
|
30
|
+
});
|
|
31
|
+
const uploadUrl = absoluteUrl(this.config.wttBaseUrl, sign.upload_url);
|
|
32
|
+
const bytes = await fs.readFile(filePath);
|
|
33
|
+
const put = await fetch(uploadUrl, { method: 'PUT', body: bytes, headers: this.authHeaders() });
|
|
34
|
+
if (!put.ok) throw new Error(`artifact upload failed: ${put.status} ${await put.text()}`);
|
|
35
|
+
const asset = await this.fetchJson('/media/commit', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'content-type': 'application/json', ...this.authHeaders() },
|
|
38
|
+
body: JSON.stringify({ upload_token: sign.upload_token }),
|
|
39
|
+
});
|
|
40
|
+
this.store?.addArtifact({ filePath, asset });
|
|
41
|
+
log('info', 'artifact uploaded', { file: filePath, url: asset.url || asset.public_url });
|
|
42
|
+
return asset;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async uploadText(name, text) {
|
|
46
|
+
const file = await this.writeTextArtifact(name, text);
|
|
47
|
+
return { file, asset: await this.uploadFile(file) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async fetchJson(url, options) {
|
|
51
|
+
const r = await fetch(absoluteUrl(this.config.wttBaseUrl, url), options);
|
|
52
|
+
if (!r.ok) throw new Error(`WTT media API failed: ${r.status} ${await r.text()}`);
|
|
53
|
+
return r.json();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
authHeaders() {
|
|
57
|
+
return this.config.httpToken ? { Authorization: `Bearer ${this.config.httpToken}` } : {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function absoluteUrl(base, maybeRelative) {
|
|
62
|
+
if (/^https?:\/\//.test(maybeRelative)) return maybeRelative;
|
|
63
|
+
return `${String(base).replace(/\/$/, '')}${maybeRelative.startsWith('/') ? '' : '/'}${maybeRelative}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function safeName(name) {
|
|
67
|
+
return String(name || 'artifact.txt').replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 120) || 'artifact.txt';
|
|
68
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { lookupMime } from './mime.js';
|
|
4
|
+
import { log } from './logger.js';
|
|
5
|
+
|
|
6
|
+
export class AttachmentManager {
|
|
7
|
+
constructor(config) {
|
|
8
|
+
this.config = config;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async stageMessage(message) {
|
|
12
|
+
const refs = collectRefs(message);
|
|
13
|
+
if (!refs.length) return { files: [], images: [], promptBlock: '' };
|
|
14
|
+
await fs.mkdir(this.config.inboxDir, { recursive: true });
|
|
15
|
+
const files = [];
|
|
16
|
+
const images = [];
|
|
17
|
+
for (const ref of refs) {
|
|
18
|
+
try {
|
|
19
|
+
const staged = await this.stageRef(ref);
|
|
20
|
+
files.push(staged);
|
|
21
|
+
if (staged.mimeType.startsWith('image/')) images.push(staged.path);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
log('warn', 'attachment stage failed', { name: ref.name || ref.url || ref.path, error: err.message });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { files, images, promptBlock: renderPromptBlock(files) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async stageRef(ref) {
|
|
30
|
+
if (ref.path) {
|
|
31
|
+
const resolved = path.resolve(ref.path);
|
|
32
|
+
const stat = await fs.stat(resolved);
|
|
33
|
+
if (!stat.isFile()) throw new Error('not a file');
|
|
34
|
+
return { ...ref, path: resolved, name: ref.name || path.basename(resolved), mimeType: ref.mimeType || lookupMime(resolved), size: stat.size };
|
|
35
|
+
}
|
|
36
|
+
if (!ref.url) throw new Error('missing attachment url/path');
|
|
37
|
+
const url = absoluteUrl(this.config.wttBaseUrl, ref.url);
|
|
38
|
+
const r = await fetch(url, { headers: this.authHeaders() });
|
|
39
|
+
if (!r.ok) throw new Error(`download failed: ${r.status}`);
|
|
40
|
+
const bytes = Buffer.from(await r.arrayBuffer());
|
|
41
|
+
const mimeType = ref.mimeType || r.headers.get('content-type')?.split(';')[0] || 'application/octet-stream';
|
|
42
|
+
const ext = extFromMime(mimeType) || path.extname(new URL(url).pathname) || '.bin';
|
|
43
|
+
const name = safeName(ref.name || path.basename(new URL(url).pathname) || `attachment${ext}`);
|
|
44
|
+
const file = path.resolve(this.config.inboxDir, `${Date.now()}-${crypto.randomUUID().slice(0, 8)}-${name.endsWith(ext) ? name : `${name}${ext}`}`);
|
|
45
|
+
await fs.writeFile(file, bytes);
|
|
46
|
+
return { ...ref, path: file, name, mimeType, size: bytes.length };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
authHeaders() {
|
|
50
|
+
return this.config.httpToken ? { Authorization: `Bearer ${this.config.httpToken}` } : {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function collectRefs(message) {
|
|
55
|
+
const out = [];
|
|
56
|
+
const push = (x) => {
|
|
57
|
+
if (!x) return;
|
|
58
|
+
if (typeof x === 'string') out.push({ url: x });
|
|
59
|
+
else out.push({
|
|
60
|
+
url: x.url || x.public_url || x.download_url || x.href,
|
|
61
|
+
path: x.path || x.file_path,
|
|
62
|
+
name: x.name || x.filename || x.original_name,
|
|
63
|
+
mimeType: x.mime_type || x.mimeType || x.content_type,
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
for (const key of ['attachments', 'files', 'images', 'audio', 'media']) {
|
|
67
|
+
const value = message?.[key];
|
|
68
|
+
if (Array.isArray(value)) value.forEach(push);
|
|
69
|
+
else push(value);
|
|
70
|
+
}
|
|
71
|
+
for (const ref of refsFromMarkdown(String(message?.content || ''))) push(ref);
|
|
72
|
+
for (const ref of refsFromMetadata(message?.metadata)) push(ref);
|
|
73
|
+
return dedupeRefs(out.filter((x) => x.url || x.path));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderPromptBlock(files) {
|
|
77
|
+
if (!files.length) return '';
|
|
78
|
+
const lines = ['\nAttachments staged locally:'];
|
|
79
|
+
for (const f of files) lines.push(`- ${f.name || path.basename(f.path)} (${f.mimeType}, ${f.size} bytes): ${f.path}`);
|
|
80
|
+
lines.push('Use these local paths when relevant.');
|
|
81
|
+
return lines.join('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function refsFromMarkdown(content) {
|
|
85
|
+
const out = [];
|
|
86
|
+
const imageRe = /!\[([^\]]*)\]\(([^\s)]+)(?:\s+"[^"]*")?\)/g;
|
|
87
|
+
for (const m of content.matchAll(imageRe)) {
|
|
88
|
+
out.push({ url: stripAngleBrackets(m[2]), name: m[1] || undefined });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const linkRe = /(?<!!)\[([^\]]+)\]\(([^\s)]+)(?:\s+"[^"]*")?\)/g;
|
|
92
|
+
for (const m of content.matchAll(linkRe)) {
|
|
93
|
+
const url = stripAngleBrackets(m[2]);
|
|
94
|
+
if (looksLikeMediaUrl(url)) out.push({ url, name: m[1] || undefined });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const bareUrlRe = /https?:\/\/[^\s)\]>"']+/g;
|
|
98
|
+
for (const m of content.matchAll(bareUrlRe)) {
|
|
99
|
+
const url = stripTrailingPunctuation(m[0]);
|
|
100
|
+
if (looksLikeMediaUrl(url)) out.push({ url });
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function refsFromMetadata(metadata) {
|
|
106
|
+
if (!metadata) return [];
|
|
107
|
+
let obj = metadata;
|
|
108
|
+
if (typeof metadata === 'string') {
|
|
109
|
+
try { obj = JSON.parse(metadata); } catch { return refsFromMarkdown(metadata); }
|
|
110
|
+
}
|
|
111
|
+
const out = [];
|
|
112
|
+
const visit = (value) => {
|
|
113
|
+
if (!value) return;
|
|
114
|
+
if (Array.isArray(value)) return value.forEach(visit);
|
|
115
|
+
if (typeof value === 'string') {
|
|
116
|
+
if (looksLikeMediaUrl(value)) out.push({ url: value });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (typeof value !== 'object') return;
|
|
120
|
+
const url = value.url || value.public_url || value.download_url || value.href;
|
|
121
|
+
const pathValue = value.path || value.file_path;
|
|
122
|
+
if (url || pathValue) out.push({
|
|
123
|
+
url,
|
|
124
|
+
path: pathValue,
|
|
125
|
+
name: value.name || value.filename || value.original_name,
|
|
126
|
+
mimeType: value.mime_type || value.mimeType || value.content_type,
|
|
127
|
+
});
|
|
128
|
+
for (const key of ['attachments', 'files', 'images', 'audio', 'media']) visit(value[key]);
|
|
129
|
+
};
|
|
130
|
+
visit(obj);
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function safeName(name) {
|
|
135
|
+
return String(name || 'attachment').replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 100) || 'attachment';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function looksLikeMediaUrl(url) {
|
|
139
|
+
try {
|
|
140
|
+
const u = new URL(stripAngleBrackets(url));
|
|
141
|
+
if (/\/media\//.test(u.pathname)) return true;
|
|
142
|
+
return /\.(png|jpe?g|gif|webp|bmp|svg|heic|mp3|wav|ogg|webm|mp4|mov|pdf|txt|md|json|zip)$/i.test(u.pathname);
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stripAngleBrackets(value) {
|
|
149
|
+
return String(value || '').replace(/^</, '').replace(/>$/, '');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function stripTrailingPunctuation(value) {
|
|
153
|
+
return stripAngleBrackets(value).replace(/[.,;:!?,。;:!?]+$/, '');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function dedupeRefs(refs) {
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const ref of refs) {
|
|
160
|
+
const key = ref.path || ref.url;
|
|
161
|
+
if (!key || seen.has(key)) continue;
|
|
162
|
+
seen.add(key);
|
|
163
|
+
out.push(ref);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function extFromMime(mime) {
|
|
169
|
+
const m = {
|
|
170
|
+
'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp',
|
|
171
|
+
'audio/mpeg': '.mp3', 'audio/wav': '.wav', 'audio/ogg': '.ogg', 'audio/webm': '.webm',
|
|
172
|
+
'video/mp4': '.mp4', 'video/webm': '.webm', 'application/pdf': '.pdf',
|
|
173
|
+
'text/plain': '.txt', 'text/markdown': '.md', 'text/html': '.html', 'application/json': '.json', 'application/zip': '.zip',
|
|
174
|
+
};
|
|
175
|
+
return m[mime] || '';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function absoluteUrl(base, maybeRelative) {
|
|
179
|
+
if (/^https?:\/\//.test(String(maybeRelative))) return maybeRelative;
|
|
180
|
+
return `${String(base).replace(/\/$/, '')}${String(maybeRelative).startsWith('/') ? '' : '/'}${maybeRelative}`;
|
|
181
|
+
}
|