amalgm 0.0.0 → 0.0.1
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 +37 -1
- package/bin/amalgm.js +8 -2
- package/lib/auth-store.js +223 -0
- package/lib/cli.js +1000 -0
- package/lib/paths.js +30 -0
- package/lib/supervisor.js +467 -0
- package/lib/tunnel-chat.js +328 -0
- package/lib/tunnel-events.js +499 -0
- package/package.json +29 -3
- package/runtime/README.md +4 -0
- package/runtime/lib/chatInput.js +306 -0
- package/runtime/lib/harnesses.js +988 -0
- package/runtime/lib/local/amalgmStore.js +128 -0
- package/runtime/lib/local/credentialResolver.js +425 -0
- package/runtime/lib/mcpApps/registry.js +619 -0
- package/runtime/package.json +5 -0
- package/runtime/scripts/amalgm-mcp/agents/rest.js +165 -0
- package/runtime/scripts/amalgm-mcp/agents/store.js +153 -0
- package/runtime/scripts/amalgm-mcp/agents/talk.js +1156 -0
- package/runtime/scripts/amalgm-mcp/agents/tools.js +210 -0
- package/runtime/scripts/amalgm-mcp/artifacts/advertise.js +132 -0
- package/runtime/scripts/amalgm-mcp/artifacts/rest.js +103 -0
- package/runtime/scripts/amalgm-mcp/artifacts/store.js +141 -0
- package/runtime/scripts/amalgm-mcp/artifacts/supervisor.js +402 -0
- package/runtime/scripts/amalgm-mcp/artifacts/tools.js +176 -0
- package/runtime/scripts/amalgm-mcp/browser/page.js +637 -0
- package/runtime/scripts/amalgm-mcp/browser/tools.js +688 -0
- package/runtime/scripts/amalgm-mcp/config.js +138 -0
- package/runtime/scripts/amalgm-mcp/credentials/rest.js +45 -0
- package/runtime/scripts/amalgm-mcp/deps.js +40 -0
- package/runtime/scripts/amalgm-mcp/email/inbound.js +215 -0
- package/runtime/scripts/amalgm-mcp/events/executor.js +179 -0
- package/runtime/scripts/amalgm-mcp/events/ingress.js +113 -0
- package/runtime/scripts/amalgm-mcp/events/matcher.js +125 -0
- package/runtime/scripts/amalgm-mcp/events/rest.js +200 -0
- package/runtime/scripts/amalgm-mcp/events/ring-buffer.js +19 -0
- package/runtime/scripts/amalgm-mcp/events/store.js +98 -0
- package/runtime/scripts/amalgm-mcp/events/tools.js +306 -0
- package/runtime/scripts/amalgm-mcp/events/webhook-url.js +28 -0
- package/runtime/scripts/amalgm-mcp/fs/rest.js +293 -0
- package/runtime/scripts/amalgm-mcp/index.js +100 -0
- package/runtime/scripts/amalgm-mcp/lib/chat-runner.js +167 -0
- package/runtime/scripts/amalgm-mcp/lib/email-md.js +288 -0
- package/runtime/scripts/amalgm-mcp/lib/mcp-resolver.js +63 -0
- package/runtime/scripts/amalgm-mcp/lib/prefs.js +393 -0
- package/runtime/scripts/amalgm-mcp/lib/storage.js +92 -0
- package/runtime/scripts/amalgm-mcp/lib/supabase.js +118 -0
- package/runtime/scripts/amalgm-mcp/lib/tool-result.js +177 -0
- package/runtime/scripts/amalgm-mcp/local/rest.js +80 -0
- package/runtime/scripts/amalgm-mcp/mcp-connections/rest.js +151 -0
- package/runtime/scripts/amalgm-mcp/notify/index.js +107 -0
- package/runtime/scripts/amalgm-mcp/server/http.js +335 -0
- package/runtime/scripts/amalgm-mcp/server/mcp.js +116 -0
- package/runtime/scripts/amalgm-mcp/slack/inbound.js +200 -0
- package/runtime/scripts/amalgm-mcp/tasks/executor.js +225 -0
- package/runtime/scripts/amalgm-mcp/tasks/rest.js +110 -0
- package/runtime/scripts/amalgm-mcp/tasks/schedule-normalization.js +85 -0
- package/runtime/scripts/amalgm-mcp/tasks/scheduler.js +105 -0
- package/runtime/scripts/amalgm-mcp/tasks/store.js +139 -0
- package/runtime/scripts/amalgm-mcp/tasks/tools.js +391 -0
- package/runtime/scripts/amalgm-mcp/user-api-keys/rest.js +105 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +389 -0
- package/runtime/scripts/chat-core/adapters/claude.js +163 -0
- package/runtime/scripts/chat-core/adapters/codex.js +313 -0
- package/runtime/scripts/chat-core/adapters/opencode.js +412 -0
- package/runtime/scripts/chat-core/auth.js +177 -0
- package/runtime/scripts/chat-core/contract.js +326 -0
- package/runtime/scripts/chat-core/credentials/store.js +212 -0
- package/runtime/scripts/chat-core/egress.js +87 -0
- package/runtime/scripts/chat-core/engine.js +195 -0
- package/runtime/scripts/chat-core/event-schema.js +231 -0
- package/runtime/scripts/chat-core/events.js +190 -0
- package/runtime/scripts/chat-core/index.js +11 -0
- package/runtime/scripts/chat-core/input.js +50 -0
- package/runtime/scripts/chat-core/normalizers/claude.js +450 -0
- package/runtime/scripts/chat-core/normalizers/codex.js +380 -0
- package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +259 -0
- package/runtime/scripts/chat-core/normalizers/opencode.js +552 -0
- package/runtime/scripts/chat-core/normalizers/tool_contract.md +123 -0
- package/runtime/scripts/chat-core/normalizers/usage_contract.md +304 -0
- package/runtime/scripts/chat-core/parts.js +253 -0
- package/runtime/scripts/chat-core/recorder.js +65 -0
- package/runtime/scripts/chat-core/runtime.js +86 -0
- package/runtime/scripts/chat-core/server.js +163 -0
- package/runtime/scripts/chat-core/sse.js +196 -0
- package/runtime/scripts/chat-core/stores.js +100 -0
- package/runtime/scripts/chat-core/tool-display.js +149 -0
- package/runtime/scripts/chat-core/tool-shape.js +143 -0
- package/runtime/scripts/chat-core/tooling/mcp-bundle.js +161 -0
- package/runtime/scripts/chat-core/tooling/mcp-relay.js +97 -0
- package/runtime/scripts/chat-core/tooling/native-binaries.js +608 -0
- package/runtime/scripts/chat-core/tooling/system-prompt.js +20 -0
- package/runtime/scripts/chat-core/usage.js +343 -0
- package/runtime/scripts/chat-server/config.js +110 -0
- package/runtime/scripts/chat-server/db.js +529 -0
- package/runtime/scripts/chat-server/index.js +33 -0
- package/runtime/scripts/chat-server/model-catalog.js +327 -0
- package/runtime/scripts/chat-server.js +75 -0
- package/runtime/scripts/credential-adapter.js +129 -0
- package/runtime/scripts/fs-watcher.js +888 -0
- package/runtime/scripts/local-gateway.js +852 -0
- package/runtime/scripts/platform-context.txt +246 -0
- package/runtime/scripts/port-monitor.js +175 -0
- package/runtime/scripts/proxy-token-store.js +162 -0
- package/runtime/scripts/runtime-auth.js +163 -0
- package/runtime/scripts/test-claude-code-models.js +87 -0
- package/runtime/tsconfig.json +15 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database — Supabase REST client, message persistence, title generation, history.
|
|
3
|
+
*
|
|
4
|
+
* All Supabase interactions live here. The rest of the server treats this
|
|
5
|
+
* as a black box: save message, load history, generate title.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
SUPABASE_URL,
|
|
10
|
+
SUPABASE_AUTH_HEADERS,
|
|
11
|
+
PROXY_TOKEN,
|
|
12
|
+
ensureFreshProxyToken,
|
|
13
|
+
readProxyToken,
|
|
14
|
+
OPENAI_API_KEY_TITLE,
|
|
15
|
+
AI_GATEWAY_API_KEY_TITLE,
|
|
16
|
+
AI_GATEWAY_URL_TITLE,
|
|
17
|
+
OPENAI_PROXY_URL,
|
|
18
|
+
AI_GATEWAY_PROXY_URL,
|
|
19
|
+
} = require('./config');
|
|
20
|
+
|
|
21
|
+
// ── Supabase primitives ─────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function usesSupabaseProxy() {
|
|
24
|
+
return Boolean(SUPABASE_URL && !SUPABASE_AUTH_HEADERS.apikey && /\/supabase\/?$/.test(SUPABASE_URL));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function hasSupabase() {
|
|
28
|
+
return !!(SUPABASE_URL && (SUPABASE_AUTH_HEADERS.apikey || PROXY_TOKEN || readProxyToken?.()));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function currentSupabaseAuthHeaders({ forceRefresh = false } = {}) {
|
|
32
|
+
if (!usesSupabaseProxy()) return SUPABASE_AUTH_HEADERS;
|
|
33
|
+
|
|
34
|
+
let token = '';
|
|
35
|
+
try {
|
|
36
|
+
token = await ensureFreshProxyToken?.({ force: forceRefresh, logger: console }) || '';
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.warn('[DB] Proxy token refresh failed:', err.message);
|
|
39
|
+
}
|
|
40
|
+
token = token || readProxyToken?.() || PROXY_TOKEN || '';
|
|
41
|
+
|
|
42
|
+
return token
|
|
43
|
+
? { Authorization: `Bearer ${token}` }
|
|
44
|
+
: SUPABASE_AUTH_HEADERS;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function supabaseFetch(operation, table, url, init) {
|
|
48
|
+
const buildInit = async (forceRefresh = false) => ({
|
|
49
|
+
...init,
|
|
50
|
+
headers: {
|
|
51
|
+
...(await currentSupabaseAuthHeaders({ forceRefresh })),
|
|
52
|
+
...(init.headers || {}),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
let res = await fetch(url, await buildInit(false));
|
|
57
|
+
if (res.status === 401 && usesSupabaseProxy()) {
|
|
58
|
+
console.warn(`[DB] ${operation} ${table} got 401; refreshing proxy token and retrying once`);
|
|
59
|
+
res = await fetch(url, await buildInit(true));
|
|
60
|
+
}
|
|
61
|
+
return res;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function supabaseInsert(table, data) {
|
|
65
|
+
const res = await supabaseFetch('INSERT', table, `${SUPABASE_URL}/rest/v1/${table}`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
Prefer: 'return=representation',
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify(data),
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const text = await res.text().catch(() => '');
|
|
75
|
+
console.error(`[DB] INSERT ${table} failed: ${res.status} ${text.slice(0, 200)}`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const rows = await res.json();
|
|
79
|
+
return Array.isArray(rows) ? rows[0] : rows;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function supabaseUpsert(table, data, onConflict = 'id') {
|
|
83
|
+
void onConflict;
|
|
84
|
+
const res = await supabaseFetch('UPSERT', table, `${SUPABASE_URL}/rest/v1/${table}`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: {
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
Prefer: `return=representation,resolution=merge-duplicates`,
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify(data),
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
const text = await res.text().catch(() => '');
|
|
94
|
+
console.error(`[DB] UPSERT ${table} failed: ${res.status} ${text.slice(0, 200)}`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const rows = await res.json();
|
|
98
|
+
return Array.isArray(rows) ? rows[0] : rows;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function supabasePatch(table, matchCol, matchVal, data) {
|
|
102
|
+
const res = await supabaseFetch('PATCH', table, `${SUPABASE_URL}/rest/v1/${table}?${matchCol}=eq.${matchVal}`, {
|
|
103
|
+
method: 'PATCH',
|
|
104
|
+
headers: {
|
|
105
|
+
'Content-Type': 'application/json',
|
|
106
|
+
Prefer: 'return=representation',
|
|
107
|
+
},
|
|
108
|
+
body: JSON.stringify(data),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const text = await res.text().catch(() => '');
|
|
112
|
+
console.error(`[DB] PATCH ${table} failed: ${res.status} ${text.slice(0, 200)}`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return res.json();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function supabaseSelect(table, query) {
|
|
119
|
+
const res = await supabaseFetch('SELECT', table, `${SUPABASE_URL}/rest/v1/${table}?${query}`, {
|
|
120
|
+
headers: {
|
|
121
|
+
Accept: 'application/json',
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
if (!res.ok) return [];
|
|
125
|
+
return res.json();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Title generation ────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
const DEFAULT_SESSION_TITLES = new Set(['', 'New Chat', 'New session', 'New code session']);
|
|
131
|
+
const TITLE_PROMPT = 'Generate a concise chat title. Return only the title, no quotes. Use 2 to 5 words. If the prompt is only a greeting, return "Quick Greeting".';
|
|
132
|
+
const TITLE_TIMEOUT_MS = Number(process.env.AMALGM_TITLE_TIMEOUT_MS || 5000);
|
|
133
|
+
const TITLE_MODEL = process.env.AMALGM_TITLE_MODEL || 'openai/gpt-5.4-nano';
|
|
134
|
+
|
|
135
|
+
function isDefaultSessionTitle(title) {
|
|
136
|
+
return DEFAULT_SESSION_TITLES.has(String(title || '').trim());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function gatewayTitleModel(model) {
|
|
140
|
+
const clean = String(model || '').trim() || 'openai/gpt-5.4-nano';
|
|
141
|
+
if (clean.startsWith('vercel/openai/')) return clean.slice('vercel/'.length);
|
|
142
|
+
if (clean.startsWith('openai/')) return clean;
|
|
143
|
+
if (/^(gpt-|gpt\d|o-|o\d)/i.test(clean)) return `openai/${clean}`;
|
|
144
|
+
return clean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function openAiTitleModel(model) {
|
|
148
|
+
return gatewayTitleModel(model).replace(/^openai\//, '');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function gatewaySdkBaseUrl(baseUrl) {
|
|
152
|
+
const clean = String(baseUrl || 'https://ai-gateway.vercel.sh').replace(/\/$/, '');
|
|
153
|
+
return clean.endsWith('/v1/ai') ? clean : `${clean}/v1/ai`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function titleEndpoints() {
|
|
157
|
+
const endpoints = [];
|
|
158
|
+
if (AI_GATEWAY_API_KEY_TITLE) {
|
|
159
|
+
endpoints.push({
|
|
160
|
+
name: 'vercel_ai_sdk_gateway',
|
|
161
|
+
kind: 'ai_sdk_gateway',
|
|
162
|
+
baseURL: gatewaySdkBaseUrl(AI_GATEWAY_URL_TITLE),
|
|
163
|
+
token: AI_GATEWAY_API_KEY_TITLE,
|
|
164
|
+
model: gatewayTitleModel(TITLE_MODEL),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (AI_GATEWAY_PROXY_URL && PROXY_TOKEN) {
|
|
168
|
+
endpoints.push({
|
|
169
|
+
name: 'ai_gateway_proxy',
|
|
170
|
+
kind: 'openai_compat',
|
|
171
|
+
url: `${AI_GATEWAY_PROXY_URL}/chat/completions`,
|
|
172
|
+
token: PROXY_TOKEN,
|
|
173
|
+
model: gatewayTitleModel(TITLE_MODEL),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (OPENAI_PROXY_URL && PROXY_TOKEN) {
|
|
177
|
+
endpoints.push({
|
|
178
|
+
name: 'openai_proxy',
|
|
179
|
+
kind: 'openai_compat',
|
|
180
|
+
url: `${OPENAI_PROXY_URL}/v1/chat/completions`,
|
|
181
|
+
token: PROXY_TOKEN,
|
|
182
|
+
model: openAiTitleModel(TITLE_MODEL),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (OPENAI_API_KEY_TITLE) {
|
|
186
|
+
endpoints.push({
|
|
187
|
+
name: 'direct_openai',
|
|
188
|
+
kind: 'openai_compat',
|
|
189
|
+
url: 'https://api.openai.com/v1/chat/completions',
|
|
190
|
+
token: OPENAI_API_KEY_TITLE,
|
|
191
|
+
model: openAiTitleModel(TITLE_MODEL),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return endpoints;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function requestTitleWithAiSdkGateway(endpoint, prompt, signal) {
|
|
198
|
+
const [{ generateText }, { createGatewayProvider }] = await Promise.all([
|
|
199
|
+
import('ai'),
|
|
200
|
+
import('@ai-sdk/gateway'),
|
|
201
|
+
]);
|
|
202
|
+
const gateway = createGatewayProvider({
|
|
203
|
+
apiKey: endpoint.token,
|
|
204
|
+
baseURL: endpoint.baseURL,
|
|
205
|
+
});
|
|
206
|
+
const result = await generateText({
|
|
207
|
+
model: gateway(endpoint.model),
|
|
208
|
+
system: TITLE_PROMPT,
|
|
209
|
+
prompt: String(prompt || '').slice(0, 2000),
|
|
210
|
+
maxOutputTokens: 32,
|
|
211
|
+
abortSignal: signal,
|
|
212
|
+
});
|
|
213
|
+
return cleanGeneratedTitle(result.text);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function requestTitle(endpoint, prompt, signal) {
|
|
217
|
+
if (endpoint.kind === 'ai_sdk_gateway') {
|
|
218
|
+
return requestTitleWithAiSdkGateway(endpoint, prompt, signal);
|
|
219
|
+
}
|
|
220
|
+
const res = await fetch(endpoint.url, {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
signal,
|
|
223
|
+
headers: {
|
|
224
|
+
Authorization: `Bearer ${endpoint.token}`,
|
|
225
|
+
'Content-Type': 'application/json',
|
|
226
|
+
},
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
model: endpoint.model,
|
|
229
|
+
messages: [
|
|
230
|
+
{ role: 'system', content: TITLE_PROMPT },
|
|
231
|
+
{ role: 'user', content: String(prompt || '').slice(0, 2000) },
|
|
232
|
+
],
|
|
233
|
+
max_completion_tokens: 32,
|
|
234
|
+
}),
|
|
235
|
+
});
|
|
236
|
+
if (!res.ok) {
|
|
237
|
+
const detail = await res.text().catch(() => '');
|
|
238
|
+
throw new Error(`${endpoint.name} ${res.status} ${detail.slice(0, 160)}`.trim());
|
|
239
|
+
}
|
|
240
|
+
return cleanGeneratedTitle(extractResponseText(await res.json()));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function generateTitle(prompt) {
|
|
244
|
+
const text = String(prompt || '').trim();
|
|
245
|
+
if (!text) return '';
|
|
246
|
+
|
|
247
|
+
const endpoints = titleEndpoints();
|
|
248
|
+
if (endpoints.length === 0) {
|
|
249
|
+
console.warn('[DB] Title generation skipped: no gateway/proxy credentials');
|
|
250
|
+
return '';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const endpoint of endpoints) {
|
|
254
|
+
const controller = new AbortController();
|
|
255
|
+
const timeout = setTimeout(() => controller.abort(), TITLE_TIMEOUT_MS);
|
|
256
|
+
try {
|
|
257
|
+
const title = await requestTitle(endpoint, text, controller.signal);
|
|
258
|
+
if (title) return title;
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.warn('[DB] Title generation endpoint failed:', err.message);
|
|
261
|
+
} finally {
|
|
262
|
+
clearTimeout(timeout);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return '';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function generateAndSaveTitle(sessionId, prompt, onTitle) {
|
|
269
|
+
if (!sessionId || !hasSupabase()) return null;
|
|
270
|
+
|
|
271
|
+
const initialRows = await supabaseSelect('sessions', `id=eq.${sessionId}&select=title`);
|
|
272
|
+
if (!isDefaultSessionTitle(initialRows?.[0]?.title)) return null;
|
|
273
|
+
|
|
274
|
+
const title = await generateTitle(prompt);
|
|
275
|
+
if (!title) return null;
|
|
276
|
+
|
|
277
|
+
const rows = await supabaseSelect('sessions', `id=eq.${sessionId}&select=title`);
|
|
278
|
+
if (!isDefaultSessionTitle(rows?.[0]?.title)) return null;
|
|
279
|
+
|
|
280
|
+
const updated = await supabasePatch('sessions', 'id', sessionId, { title });
|
|
281
|
+
if (!updated) return null;
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
if (onTitle) onTitle(title);
|
|
285
|
+
} catch {}
|
|
286
|
+
return title;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function extractResponseText(data) {
|
|
290
|
+
const choiceText = data?.choices?.[0]?.message?.content;
|
|
291
|
+
if (typeof choiceText === 'string') return choiceText;
|
|
292
|
+
if (typeof data?.output_text === 'string') return data.output_text;
|
|
293
|
+
const output = Array.isArray(data?.output) ? data.output : [];
|
|
294
|
+
const texts = [];
|
|
295
|
+
for (const item of output) {
|
|
296
|
+
const content = Array.isArray(item?.content) ? item.content : [];
|
|
297
|
+
for (const part of content) {
|
|
298
|
+
if (typeof part?.text === 'string') texts.push(part.text);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return texts.join(' ');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function cleanGeneratedTitle(title) {
|
|
305
|
+
const cleaned = String(title || '')
|
|
306
|
+
.replace(/^["'`]+|["'`]+$/g, '')
|
|
307
|
+
.replace(/\s+/g, ' ')
|
|
308
|
+
.trim();
|
|
309
|
+
if (!cleaned) return '';
|
|
310
|
+
return cleaned.length > 60 ? cleaned.slice(0, 57).trim() + '...' : cleaned;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Conversation history loader ─────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
async function loadConversationHistory(codeSessionId, excludeMessageId) {
|
|
316
|
+
if (!hasSupabase()) return '';
|
|
317
|
+
try {
|
|
318
|
+
const messages = await supabaseSelect(
|
|
319
|
+
'messages',
|
|
320
|
+
`session_id=eq.${codeSessionId}&order=created_at.asc&select=id,role,parts,content_plain`
|
|
321
|
+
);
|
|
322
|
+
if (!messages || messages.length === 0) return '';
|
|
323
|
+
|
|
324
|
+
const history = messages
|
|
325
|
+
.filter((msg) => msg.id !== excludeMessageId)
|
|
326
|
+
.slice(-20);
|
|
327
|
+
if (history.length === 0) return '';
|
|
328
|
+
const lines = history.map((msg) => {
|
|
329
|
+
const label = msg.role === 'user' ? 'Human' : 'Assistant';
|
|
330
|
+
let text = '';
|
|
331
|
+
if (Array.isArray(msg.parts)) {
|
|
332
|
+
const textParts = msg.parts.filter((p) => p.type === 'text' && p.text).map((p) => p.text);
|
|
333
|
+
text = textParts.join('\n');
|
|
334
|
+
const toolParts = msg.parts.filter((p) => typeof p.type === 'string' && p.type.startsWith('tool-'));
|
|
335
|
+
if (toolParts.length > 0) {
|
|
336
|
+
const toolNames = toolParts.map((t) => t.toolName || t.type.replace('tool-', '')).join(', ');
|
|
337
|
+
text += `\n[Used tools: ${toolNames}]`;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (!text) text = msg.content_plain || '';
|
|
341
|
+
const truncated = text.length > 2000 ? text.slice(0, 2000) + '...' : text;
|
|
342
|
+
return `${label}: ${truncated}`;
|
|
343
|
+
});
|
|
344
|
+
return `<conversation_history>\n${lines.join('\n')}\n</conversation_history>\n\n`;
|
|
345
|
+
} catch (err) {
|
|
346
|
+
console.warn('[DB] Failed to load conversation history:', err.message);
|
|
347
|
+
return '';
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Message helpers ─────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
function extractContentPlain(parts) {
|
|
354
|
+
if (!Array.isArray(parts)) return null;
|
|
355
|
+
return parts
|
|
356
|
+
.filter((p) => p.type === 'text' && p.text)
|
|
357
|
+
.map((p) => p.text)
|
|
358
|
+
.join('\n') || null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function saveUserMessage(config) {
|
|
362
|
+
if (!config.userMessageId || !hasSupabase()) return true;
|
|
363
|
+
const parts = Array.isArray(config.userParts) && config.userParts.length > 0
|
|
364
|
+
? config.userParts
|
|
365
|
+
: Array.isArray(config.chatInput?.parts) && config.chatInput.parts.length > 0
|
|
366
|
+
? config.chatInput.parts
|
|
367
|
+
: [{ type: 'text', text: config.prompt }];
|
|
368
|
+
const contentPlain = extractContentPlain(parts);
|
|
369
|
+
const originMetadata = {};
|
|
370
|
+
if (config.originName) originMetadata.originName = config.originName;
|
|
371
|
+
if (config.originHarnessId) originMetadata.originHarnessId = config.originHarnessId;
|
|
372
|
+
if (config.originBaseHarnessId) originMetadata.originBaseHarnessId = config.originBaseHarnessId;
|
|
373
|
+
const data = {
|
|
374
|
+
id: config.userMessageId,
|
|
375
|
+
session_id: config.codeSessionId,
|
|
376
|
+
role: 'user',
|
|
377
|
+
parts,
|
|
378
|
+
...(config.origin ? { origin: config.origin } : {}),
|
|
379
|
+
...(config.originId ? { origin_id: config.originId } : {}),
|
|
380
|
+
...(Object.keys(originMetadata).length > 0 ? { metadata: originMetadata } : {}),
|
|
381
|
+
};
|
|
382
|
+
let result = await supabaseUpsert('messages', { ...data, content_plain: contentPlain });
|
|
383
|
+
if (!result) {
|
|
384
|
+
result = await supabaseUpsert('messages', data);
|
|
385
|
+
}
|
|
386
|
+
if (!result) return false;
|
|
387
|
+
|
|
388
|
+
await supabasePatch('sessions', 'id', config.codeSessionId, {
|
|
389
|
+
last_message_at: new Date().toISOString(),
|
|
390
|
+
}).catch((err) => {
|
|
391
|
+
console.warn('[DB] Failed to update session metadata during user save:', err.message);
|
|
392
|
+
});
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function ensureAssistantMessage(config) {
|
|
397
|
+
if (!config.assistantMessageId || !hasSupabase()) return true;
|
|
398
|
+
|
|
399
|
+
const data = {
|
|
400
|
+
id: config.assistantMessageId,
|
|
401
|
+
session_id: config.codeSessionId,
|
|
402
|
+
role: 'assistant',
|
|
403
|
+
parts: [],
|
|
404
|
+
model: config.modelId || null,
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
let result = await supabaseUpsert('messages', { ...data, content_plain: null });
|
|
408
|
+
if (!result) {
|
|
409
|
+
result = await supabaseUpsert('messages', data);
|
|
410
|
+
}
|
|
411
|
+
return !!result;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function saveAssistantMessage(config, parts, providerSessionId, usage) {
|
|
415
|
+
if (!hasSupabase()) return true;
|
|
416
|
+
const contentPlain = extractContentPlain(parts);
|
|
417
|
+
|
|
418
|
+
const data = {
|
|
419
|
+
id: config.assistantMessageId,
|
|
420
|
+
session_id: config.codeSessionId,
|
|
421
|
+
role: 'assistant',
|
|
422
|
+
parts,
|
|
423
|
+
model: config.modelId || null,
|
|
424
|
+
};
|
|
425
|
+
// Usage data goes to usage_logs (via proxy), not messages table
|
|
426
|
+
let result = await supabaseUpsert('messages', { ...data, content_plain: contentPlain });
|
|
427
|
+
if (!result) {
|
|
428
|
+
result = await supabaseUpsert('messages', data);
|
|
429
|
+
}
|
|
430
|
+
if (!result) return false;
|
|
431
|
+
const sessionPatch = {
|
|
432
|
+
last_message_at: new Date().toISOString(),
|
|
433
|
+
status: 'complete',
|
|
434
|
+
new_messages: true,
|
|
435
|
+
...(providerSessionId ? { provider_session_id: providerSessionId } : {}),
|
|
436
|
+
};
|
|
437
|
+
await supabasePatch('sessions', 'id', config.codeSessionId, sessionPatch);
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function persistSseResumeState(codeSessionId) {
|
|
442
|
+
// Raw-temp reconnect is now owned by chat-core's in-memory turn store and
|
|
443
|
+
// clears after DB save. Keep this exported as a compatibility no-op while
|
|
444
|
+
// old callers are deleted.
|
|
445
|
+
void codeSessionId;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function shallowMergeObject(current, patch) {
|
|
449
|
+
return {
|
|
450
|
+
...(current && typeof current === 'object' && !Array.isArray(current) ? current : {}),
|
|
451
|
+
...(patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {}),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function mergeSessionMetadata(codeSessionId, metadataPatch) {
|
|
456
|
+
if (!hasSupabase() || !codeSessionId || !metadataPatch) return;
|
|
457
|
+
try {
|
|
458
|
+
const rows = await supabaseSelect('sessions', `id=eq.${codeSessionId}&select=metadata`);
|
|
459
|
+
const current = rows?.[0]?.metadata && typeof rows[0].metadata === 'object'
|
|
460
|
+
? rows[0].metadata
|
|
461
|
+
: {};
|
|
462
|
+
const merged = {
|
|
463
|
+
...current,
|
|
464
|
+
...metadataPatch,
|
|
465
|
+
agent: shallowMergeObject(current.agent, metadataPatch.agent),
|
|
466
|
+
persistence: shallowMergeObject(current.persistence, metadataPatch.persistence),
|
|
467
|
+
};
|
|
468
|
+
await supabasePatch('sessions', 'id', codeSessionId, { metadata: merged });
|
|
469
|
+
} catch (err) {
|
|
470
|
+
console.warn('[DB] Failed to merge session metadata:', err.message);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Log usage to usage_logs table.
|
|
476
|
+
* Called after each assistant turn with full context.
|
|
477
|
+
*/
|
|
478
|
+
function buildUsageLogRow({ userId, sessionId, messageId, agentId, harnessId, authMethod, modelId, usage, marketCostUsd }) {
|
|
479
|
+
const provider = modelId?.split('/')[0] || null;
|
|
480
|
+
return {
|
|
481
|
+
user_id: userId,
|
|
482
|
+
session_id: sessionId || null,
|
|
483
|
+
message_id: messageId || null,
|
|
484
|
+
agent_id: agentId || null,
|
|
485
|
+
harness_id: harnessId || null,
|
|
486
|
+
auth_method: authMethod || null,
|
|
487
|
+
provider,
|
|
488
|
+
model: modelId || null,
|
|
489
|
+
input_tokens: usage.inputTokens || 0,
|
|
490
|
+
output_tokens: usage.outputTokens || 0,
|
|
491
|
+
cache_read_tokens: usage.cacheReadTokens || 0,
|
|
492
|
+
cache_write_tokens: usage.cacheWriteTokens || 0,
|
|
493
|
+
cost_usd: usage.costUsd ?? 0,
|
|
494
|
+
market_cost_usd: marketCostUsd ?? usage.costUsd ?? 0,
|
|
495
|
+
container_id: process.env.AMALGM_CONTAINER_ID || null,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function logUsage({ userId, sessionId, messageId, agentId, harnessId, authMethod, modelId, usage, marketCostUsd }) {
|
|
500
|
+
if (!hasSupabase() || !usage) return;
|
|
501
|
+
if (!userId) {
|
|
502
|
+
console.warn('[DB] logUsage skipped: no userId');
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const data = buildUsageLogRow({ userId, sessionId, messageId, agentId, harnessId, authMethod, modelId, usage, marketCostUsd });
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
const result = await supabaseUpsert('usage_logs', data);
|
|
510
|
+
if (result) console.log('[DB] logUsage OK:', { agent: agentId, model: modelId, cost: data.cost_usd });
|
|
511
|
+
} catch (err) {
|
|
512
|
+
console.error('[DB] logUsage failed:', err.message);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
module.exports = {
|
|
517
|
+
hasSupabase,
|
|
518
|
+
supabasePatch,
|
|
519
|
+
supabaseSelect,
|
|
520
|
+
generateAndSaveTitle,
|
|
521
|
+
loadConversationHistory,
|
|
522
|
+
saveUserMessage,
|
|
523
|
+
ensureAssistantMessage,
|
|
524
|
+
saveAssistantMessage,
|
|
525
|
+
persistSseResumeState,
|
|
526
|
+
mergeSessionMetadata,
|
|
527
|
+
buildUsageLogRow,
|
|
528
|
+
logUsage,
|
|
529
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Chat server entrypoint.
|
|
5
|
+
*
|
|
6
|
+
* The middle of the chat path lives in scripts/chat-core:
|
|
7
|
+
* UI payload -> frozen contract -> native runtime adapter -> normalized events
|
|
8
|
+
* -> temp/raw-temp -> DB save -> usage policy.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { BIND_HOST, PORT } = require('./config');
|
|
12
|
+
const { hasSupabase } = require('./db');
|
|
13
|
+
const { createCore, createServer } = require('../chat-core/server');
|
|
14
|
+
|
|
15
|
+
const core = createCore({
|
|
16
|
+
port: PORT,
|
|
17
|
+
localBaseUrl: process.env.CHAT_CORE_LOCAL_BASE_URL || `http://127.0.0.1:${PORT}`,
|
|
18
|
+
});
|
|
19
|
+
const server = createServer(core);
|
|
20
|
+
|
|
21
|
+
process.on('unhandledRejection', (reason) => {
|
|
22
|
+
console.error('[ChatServer] Unhandled rejection:', reason);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
process.on('uncaughtException', (err) => {
|
|
26
|
+
console.error('[ChatServer] Uncaught exception:', err);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
server.listen(PORT, BIND_HOST, () => {
|
|
30
|
+
console.log(`[ChatServer] Listening on ${BIND_HOST}:${PORT}`);
|
|
31
|
+
console.log('[ChatServer] Runtime: chat-core');
|
|
32
|
+
console.log(`[ChatServer] Supabase: ${hasSupabase() ? 'configured' : 'NOT configured'}`);
|
|
33
|
+
});
|