spendos 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/.dockerignore +4 -0
- package/.env.example +30 -0
- package/AGENTS.md +212 -0
- package/BOOTSTRAP.md +55 -0
- package/Dockerfile +52 -0
- package/HEARTBEAT.md +7 -0
- package/IDENTITY.md +23 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/SOUL.md +202 -0
- package/SUBMISSION.md +128 -0
- package/TOOLS.md +40 -0
- package/USER.md +17 -0
- package/acp-seller/bin/acp.ts +807 -0
- package/acp-seller/config.json +34 -0
- package/acp-seller/package.json +55 -0
- package/acp-seller/src/commands/agent.ts +328 -0
- package/acp-seller/src/commands/bounty.ts +1189 -0
- package/acp-seller/src/commands/deploy.ts +414 -0
- package/acp-seller/src/commands/job.ts +217 -0
- package/acp-seller/src/commands/profile.ts +71 -0
- package/acp-seller/src/commands/resource.ts +91 -0
- package/acp-seller/src/commands/search.ts +327 -0
- package/acp-seller/src/commands/sell.ts +883 -0
- package/acp-seller/src/commands/serve.ts +258 -0
- package/acp-seller/src/commands/setup.ts +399 -0
- package/acp-seller/src/commands/token.ts +88 -0
- package/acp-seller/src/commands/wallet.ts +123 -0
- package/acp-seller/src/lib/api.ts +118 -0
- package/acp-seller/src/lib/auth.ts +291 -0
- package/acp-seller/src/lib/bounty.ts +257 -0
- package/acp-seller/src/lib/client.ts +42 -0
- package/acp-seller/src/lib/config.ts +240 -0
- package/acp-seller/src/lib/open.ts +41 -0
- package/acp-seller/src/lib/openclawCron.ts +138 -0
- package/acp-seller/src/lib/output.ts +104 -0
- package/acp-seller/src/lib/wallet.ts +81 -0
- package/acp-seller/src/seller/offerings/_shared/preTransactionScan.ts +127 -0
- package/acp-seller/src/seller/offerings/canonical-catalog.ts +221 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/handlers.ts +20 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/offering.json +18 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_translate/handlers.ts +21 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_translate/offering.json +22 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/handlers.ts +20 -0
- package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/offering.json +18 -0
- package/acp-seller/src/seller/runtime/acpSocket.ts +413 -0
- package/acp-seller/src/seller/runtime/logger.ts +36 -0
- package/acp-seller/src/seller/runtime/offeringTypes.ts +52 -0
- package/acp-seller/src/seller/runtime/offerings.ts +277 -0
- package/acp-seller/src/seller/runtime/paymentVerification.test.ts +207 -0
- package/acp-seller/src/seller/runtime/paymentVerification.ts +363 -0
- package/acp-seller/src/seller/runtime/seller.onchain.test.ts +220 -0
- package/acp-seller/src/seller/runtime/seller.test.ts +823 -0
- package/acp-seller/src/seller/runtime/seller.ts +1041 -0
- package/acp-seller/src/seller/runtime/sellerApi.ts +71 -0
- package/acp-seller/src/seller/runtime/startup.ts +270 -0
- package/acp-seller/src/seller/runtime/types.ts +62 -0
- package/acp-seller/tsconfig.json +20 -0
- package/bin/spendos.js +23 -0
- package/contracts/SpendOSAudit.sol +29 -0
- package/dist/mcp-server.mjs +153 -0
- package/jobs/translate.json +7 -0
- package/jobs/tweet-gen.json +7 -0
- package/openclaw.json +41 -0
- package/package.json +49 -0
- package/plugins/spendos-events/index.ts +78 -0
- package/plugins/spendos-events/package.json +14 -0
- package/policies/enforce-bounds.mjs +71 -0
- package/public/index.html +509 -0
- package/public/landing.html +241 -0
- package/railway.json +12 -0
- package/railway.toml +12 -0
- package/scripts/deploy.ts +48 -0
- package/scripts/test-x402-mainnet.ts +30 -0
- package/scripts/xmtp-listener.ts +61 -0
- package/setup.sh +278 -0
- package/skills/spendos/skill.md +26 -0
- package/src/agent.ts +152 -0
- package/src/audit.ts +166 -0
- package/src/governance.ts +367 -0
- package/src/job-registry.ts +306 -0
- package/src/mcp-public.ts +145 -0
- package/src/mcp-server.ts +171 -0
- package/src/opportunity-scanner.ts +138 -0
- package/src/server.ts +870 -0
- package/src/venice-x402.ts +234 -0
- package/src/xmtp.ts +109 -0
- package/src/zerion.ts +58 -0
- package/start.sh +168 -0
- package/tsconfig.json +14 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
initWallet,
|
|
7
|
+
getTreasuryAddress,
|
|
8
|
+
getTreasuryWallet,
|
|
9
|
+
createDelegation,
|
|
10
|
+
approveDelegation,
|
|
11
|
+
rejectDelegation,
|
|
12
|
+
revokeDelegation,
|
|
13
|
+
expireStale,
|
|
14
|
+
getDelegations,
|
|
15
|
+
getActiveDelegations,
|
|
16
|
+
getPendingDelegations,
|
|
17
|
+
getDelegation,
|
|
18
|
+
getPnL,
|
|
19
|
+
generateHeuristicInterpretation,
|
|
20
|
+
recordEarning,
|
|
21
|
+
recordSpending,
|
|
22
|
+
resetPnL,
|
|
23
|
+
} from './governance.js';
|
|
24
|
+
import { summarizeUrl, configureAgent, configureApiKey, getVeniceBalance, getInferenceMode } from './agent.js';
|
|
25
|
+
import { logAuditEvent, getAuditLog } from './audit.js';
|
|
26
|
+
import { exportWallet } from '@open-wallet-standard/core';
|
|
27
|
+
import { loadJobs, registerJobRoutes } from './job-registry.js';
|
|
28
|
+
import { mnemonicToAccount } from 'viem/accounts';
|
|
29
|
+
import { initXmtp, notifyDelegationRequest, notifyDelegationDecision, getXmtpAddress } from './xmtp.js';
|
|
30
|
+
// Scanner removed — OpenClaw cron jobs handle opportunity discovery via AI reasoning
|
|
31
|
+
|
|
32
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
const app = express();
|
|
34
|
+
const PORT = parseInt(process.env.PORT ?? '3030', 10);
|
|
35
|
+
|
|
36
|
+
app.use(cors());
|
|
37
|
+
app.use(express.json({ limit: '2mb' }));
|
|
38
|
+
|
|
39
|
+
// ── Init wallet first ──────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const wallet = initWallet();
|
|
42
|
+
|
|
43
|
+
// Configure Venice inference
|
|
44
|
+
try {
|
|
45
|
+
const { OWS_VAULT } = await import('./governance.js');
|
|
46
|
+
const mnemonic = exportWallet('spendos-treasury', process.env.OWS_PASSPHRASE ?? 'spendos-demo', OWS_VAULT);
|
|
47
|
+
const account = mnemonicToAccount(mnemonic);
|
|
48
|
+
configureAgent(
|
|
49
|
+
account.getHdKey().privateKey
|
|
50
|
+
? `0x${Buffer.from(account.getHdKey().privateKey!).toString('hex')}`
|
|
51
|
+
: '',
|
|
52
|
+
account.address,
|
|
53
|
+
);
|
|
54
|
+
console.log(`[SpendOS] Venice x402 wallet auth: ${account.address}`);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.log(`[SpendOS] Venice x402 wallet auth failed: ${err}`);
|
|
57
|
+
configureApiKey();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── XMTP Notifications ────────────────────────────────
|
|
61
|
+
initXmtp().catch(err => console.log(`[SpendOS] XMTP init skipped: ${err}`));
|
|
62
|
+
|
|
63
|
+
// ── x402 Sell-Side (MUST register before routes) ───────
|
|
64
|
+
|
|
65
|
+
const X402_FACILITATOR = process.env.X402_FACILITATOR_URL;
|
|
66
|
+
if (X402_FACILITATOR) {
|
|
67
|
+
// Catch ALL async x402 errors (RouteConfigurationError, facilitator 401, JWT failures)
|
|
68
|
+
// These fire after middleware registers — must not crash the server
|
|
69
|
+
const origListeners = process.listeners('uncaughtException');
|
|
70
|
+
process.once('uncaughtException', (err: Error) => {
|
|
71
|
+
const msg = String(err.message ?? err);
|
|
72
|
+
if (msg.includes('RouteConfiguration') || msg.includes('facilitator') || msg.includes('Facilitator') || msg.includes('x402') || msg.includes('supported payment kinds')) {
|
|
73
|
+
console.log(`[SpendOS] x402 init error (non-fatal): ${msg}`);
|
|
74
|
+
console.log(`[SpendOS] Running WITHOUT x402 payment gate — endpoints are free`);
|
|
75
|
+
return; // swallow — don't crash
|
|
76
|
+
}
|
|
77
|
+
// Re-throw non-x402 errors
|
|
78
|
+
for (const l of origListeners) (l as (err: Error) => void)(err);
|
|
79
|
+
if (origListeners.length === 0) { throw err; }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const { HTTPFacilitatorClient, x402ResourceServer } = await import('@x402/core/server');
|
|
84
|
+
const { ExactEvmScheme } = await import('@x402/evm/exact/server');
|
|
85
|
+
const { paymentMiddleware } = await import('@x402/express');
|
|
86
|
+
|
|
87
|
+
// Use official @coinbase/x402 package for CDP auth if API keys are present
|
|
88
|
+
const CDP_KEY_ID = process.env.CDP_API_KEY_ID;
|
|
89
|
+
const CDP_KEY_SECRET = process.env.CDP_API_KEY_SECRET;
|
|
90
|
+
let facilitatorConfig: { url: string; createAuthHeaders?: any } = { url: X402_FACILITATOR };
|
|
91
|
+
|
|
92
|
+
if (CDP_KEY_ID && CDP_KEY_SECRET && X402_FACILITATOR.includes('cdp.coinbase.com')) {
|
|
93
|
+
const { createFacilitatorConfig } = await import('@coinbase/x402');
|
|
94
|
+
facilitatorConfig = createFacilitatorConfig(CDP_KEY_ID, CDP_KEY_SECRET);
|
|
95
|
+
console.log(`[SpendOS] CDP auth configured via @coinbase/x402 (mainnet)`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const facilitator = new HTTPFacilitatorClient(facilitatorConfig);
|
|
99
|
+
const x402Server = new x402ResourceServer(facilitator)
|
|
100
|
+
.register('eip155:*', new ExactEvmScheme());
|
|
101
|
+
|
|
102
|
+
const payTo = getTreasuryAddress();
|
|
103
|
+
|
|
104
|
+
app.use(paymentMiddleware({
|
|
105
|
+
'POST /api/summarize': {
|
|
106
|
+
accepts: [{
|
|
107
|
+
scheme: 'exact',
|
|
108
|
+
price: '$0.01',
|
|
109
|
+
network: process.env.X402_NETWORK ?? 'eip155:84532',
|
|
110
|
+
payTo,
|
|
111
|
+
}],
|
|
112
|
+
description: 'AI-powered URL summarization',
|
|
113
|
+
mimeType: 'application/json',
|
|
114
|
+
},
|
|
115
|
+
'POST /api/generate-image': {
|
|
116
|
+
accepts: [{
|
|
117
|
+
scheme: 'exact',
|
|
118
|
+
price: '$0.05',
|
|
119
|
+
network: process.env.X402_NETWORK ?? 'eip155:84532',
|
|
120
|
+
payTo,
|
|
121
|
+
}],
|
|
122
|
+
description: 'AI image generation (1024x1024)',
|
|
123
|
+
mimeType: 'application/json',
|
|
124
|
+
},
|
|
125
|
+
}, x402Server));
|
|
126
|
+
|
|
127
|
+
console.log(`[SpendOS] x402 sell-side: $0.01/query → ${payTo} (network: ${process.env.X402_NETWORK ?? 'eip155:84532'})`);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.log(`[SpendOS] x402 sell-side failed: ${err}`);
|
|
130
|
+
console.log(`[SpendOS] Running WITHOUT x402 payment gate — endpoints are free`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Auth tokens (must be declared before middleware) ───
|
|
135
|
+
const ADMIN_TOKEN = process.env.SPENDOS_ADMIN_TOKEN ?? '';
|
|
136
|
+
|
|
137
|
+
// ── Auth gate for entire dashboard ─────────────────────
|
|
138
|
+
// Everything except /health and /api/summarize (x402-gated) requires auth
|
|
139
|
+
|
|
140
|
+
app.use((req, res, next) => {
|
|
141
|
+
// ── Always tag auth level first (downstream handlers need this) ──
|
|
142
|
+
const DEMO_TOKEN = process.env.SPENDOS_DEMO_TOKEN ?? '';
|
|
143
|
+
const isAdmin = (req.query.token === ADMIN_TOKEN) ||
|
|
144
|
+
(req.headers.authorization === `Bearer ${ADMIN_TOKEN}`);
|
|
145
|
+
const isDemo = DEMO_TOKEN && (
|
|
146
|
+
(req.query.token === DEMO_TOKEN) ||
|
|
147
|
+
(req.headers.authorization === `Bearer ${DEMO_TOKEN}`)
|
|
148
|
+
);
|
|
149
|
+
const isAuthed = isAdmin || isDemo;
|
|
150
|
+
(req as any).isAdmin = isAdmin;
|
|
151
|
+
(req as any).isDemo = isDemo;
|
|
152
|
+
|
|
153
|
+
// ── Public endpoints (exact match only — no startsWith for /api/requests) ──
|
|
154
|
+
const publicExact = ['/health', '/api/pnl', '/api/audit', '/api/wallet', '/api/jobs', '/api/delegations', '/favicon.ico', '/skill.md', '/api/skill'];
|
|
155
|
+
const publicPrefix = ['/api/summarize', '/api/generate-image', '/api/delegate', '/api/internal', '/api/jobs/'];
|
|
156
|
+
if (publicExact.includes(req.path) || publicPrefix.some(p => req.path.startsWith(p))) { next(); return; }
|
|
157
|
+
|
|
158
|
+
// Venice proxy — allow OpenClaw (localhost only) or gateway token
|
|
159
|
+
if (req.path === '/v1/chat/completions') {
|
|
160
|
+
const gwToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
161
|
+
const auth = req.headers.authorization;
|
|
162
|
+
// OpenClaw model provider uses apiKey "spendos" from localhost
|
|
163
|
+
if (auth === 'Bearer spendos') { next(); return; }
|
|
164
|
+
if (gwToken && auth === `Bearer ${gwToken}`) { next(); return; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Landing page for unauthenticated root access
|
|
168
|
+
if (req.path === '/' && !isAuthed && ADMIN_TOKEN) {
|
|
169
|
+
res.sendFile(join(__dirname, '..', 'public', 'landing.html'));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// All other routes require auth
|
|
174
|
+
if (!isAuthed && ADMIN_TOKEN) {
|
|
175
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
next();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── Static files ───────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
app.use(express.static(join(__dirname, '..', 'public')));
|
|
185
|
+
|
|
186
|
+
// ── Job Registry (agent can create new paid endpoints) ─
|
|
187
|
+
|
|
188
|
+
const loadedJobs = loadJobs();
|
|
189
|
+
registerJobRoutes(app);
|
|
190
|
+
console.log(`[SpendOS] Job registry: ${loadedJobs.length} jobs loaded`);
|
|
191
|
+
|
|
192
|
+
// ── Auth middleware for governance endpoints ───────────
|
|
193
|
+
|
|
194
|
+
function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction): void {
|
|
195
|
+
if (!ADMIN_TOKEN) { next(); return; } // No token set = open (dev mode)
|
|
196
|
+
if ((req as any).isAdmin) { next(); return; }
|
|
197
|
+
// Demo users can chat but can't approve/reject/revoke
|
|
198
|
+
if ((req as any).isDemo) {
|
|
199
|
+
res.status(403).json({ error: 'Demo mode — view only. Deploy your own SpendOS to manage delegations.' });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
res.status(401).json({ error: 'Unauthorized. Set Authorization: Bearer <token> header.' });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Agent execution trigger ────────────────────────────
|
|
206
|
+
|
|
207
|
+
async function triggerAgentExecution(d: any): Promise<void> {
|
|
208
|
+
const OPENCLAW_URL = process.env.OPENCLAW_INTERNAL_URL ?? 'http://localhost:18789';
|
|
209
|
+
const OPENCLAW_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN ?? '';
|
|
210
|
+
|
|
211
|
+
// Short, directive prompt — no open-ended tool exploration
|
|
212
|
+
const prompt = `DELEGATION APPROVED: "${d.reason}"
|
|
213
|
+
Session key: ${d.sessionKeyId}, expires: ${d.expiresAt}
|
|
214
|
+
Policy: chains=${d.chains?.join(',')}, ops=${d.operations?.join(',')}
|
|
215
|
+
|
|
216
|
+
Acknowledge this approval in ONE short sentence. Do NOT search for transaction hashes. Do NOT retry any tool calls. Just confirm you received the delegation and state what you would do with it.`;
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
220
|
+
if (OPENCLAW_TOKEN) headers['Authorization'] = `Bearer ${OPENCLAW_TOKEN}`;
|
|
221
|
+
// Isolated session — don't load 97KB of chat history
|
|
222
|
+
headers['X-Session-Id'] = `delegation-${d.id}`;
|
|
223
|
+
|
|
224
|
+
const res = await fetch(`${OPENCLAW_URL}/v1/chat/completions`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers,
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
model: 'openclaw',
|
|
229
|
+
messages: [{ role: 'user', content: prompt }],
|
|
230
|
+
max_tokens: 200,
|
|
231
|
+
stream: false,
|
|
232
|
+
}),
|
|
233
|
+
signal: AbortSignal.timeout(30000), // 30s hard cap
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (res.ok) {
|
|
237
|
+
const data = await res.json() as any;
|
|
238
|
+
const reply = data.choices?.[0]?.message?.content ?? '';
|
|
239
|
+
console.log(`[Agent] Delegation ${d.id} acknowledged: ${reply.slice(0, 100)}`);
|
|
240
|
+
await logAuditEvent('executed', d.id, `Agent: ${reply.slice(0, 200)}`, 0);
|
|
241
|
+
} else {
|
|
242
|
+
console.log(`[Agent] Trigger failed: ${res.status}`);
|
|
243
|
+
}
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.log(`[Agent] Trigger error: ${err}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Health check ───────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
app.get('/health', (_req, res) => {
|
|
252
|
+
res.json({ ok: true, wallet: getTreasuryAddress(), mode: getInferenceMode() });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── Auto-generated skill manifest for other agents ───
|
|
256
|
+
|
|
257
|
+
app.get('/skill.md', async (_req, res) => {
|
|
258
|
+
const { getJobs } = await import('./job-registry.js');
|
|
259
|
+
let allJobs: any[];
|
|
260
|
+
try { allJobs = getJobs(); } catch { allJobs = []; }
|
|
261
|
+
const baseUrl = `https://${_req.hostname}`;
|
|
262
|
+
const wallet = getTreasuryAddress();
|
|
263
|
+
|
|
264
|
+
const md = `# SpendOS Agent Skills
|
|
265
|
+
|
|
266
|
+
## Identity
|
|
267
|
+
- **Wallet:** ${wallet}
|
|
268
|
+
- **Network:** Base mainnet (eip155:8453)
|
|
269
|
+
- **Payment:** x402 (USDC micropayments)
|
|
270
|
+
- **Base URL:** ${baseUrl}
|
|
271
|
+
|
|
272
|
+
## Available Services
|
|
273
|
+
|
|
274
|
+
${allJobs.map(j => `### ${j.name}
|
|
275
|
+
- **Endpoint:** \`POST ${baseUrl}/api/jobs/${j.name}\`
|
|
276
|
+
- **Price:** ${j.price} USDC
|
|
277
|
+
- **Description:** ${j.description || j.name}
|
|
278
|
+
- **Inputs:** ${j.inputs.map((i: string) => `\`${i}\``).join(', ')}
|
|
279
|
+
- **Payment:** Send x402 payment header with request
|
|
280
|
+
|
|
281
|
+
\`\`\`bash
|
|
282
|
+
curl -X POST ${baseUrl}/api/jobs/${j.name} \\
|
|
283
|
+
-H "Content-Type: application/json" \\
|
|
284
|
+
-d '{${j.inputs.map((i: string) => `"${i}":"your-value"`)}}}'
|
|
285
|
+
\`\`\`
|
|
286
|
+
`).join('\n')}
|
|
287
|
+
### summarize
|
|
288
|
+
- **Endpoint:** \`POST ${baseUrl}/api/summarize\`
|
|
289
|
+
- **Price:** $0.01 USDC
|
|
290
|
+
- **Description:** AI-powered URL summarization
|
|
291
|
+
- **Inputs:** \`url\`
|
|
292
|
+
|
|
293
|
+
### generate-image
|
|
294
|
+
- **Endpoint:** \`POST ${baseUrl}/api/generate-image\`
|
|
295
|
+
- **Price:** $0.05 USDC
|
|
296
|
+
- **Description:** AI image generation (1024x1024)
|
|
297
|
+
- **Inputs:** \`prompt\`
|
|
298
|
+
|
|
299
|
+
## How to Pay
|
|
300
|
+
All endpoints use the x402 payment protocol. Send a request without payment to get a 402 response with payment instructions. Include the \`X-402-Payment\` header with your signed USDC payment to access the service.
|
|
301
|
+
|
|
302
|
+
## Governance
|
|
303
|
+
This agent's spending is governed by OWS delegations. All decisions are audited on Base mainnet at [${wallet.slice(0,6)}...${wallet.slice(-4)}](https://basescan.org/address/0xF74b481c9f196b5988cAA28Fb1452338597670B6).
|
|
304
|
+
`;
|
|
305
|
+
|
|
306
|
+
res.setHeader('Content-Type', 'text/markdown');
|
|
307
|
+
res.send(md);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// JSON version for programmatic access
|
|
311
|
+
app.get('/api/skill', async (_req, res) => {
|
|
312
|
+
const { getJobs } = await import('./job-registry.js');
|
|
313
|
+
let allJobs: any[];
|
|
314
|
+
try { allJobs = getJobs(); } catch { allJobs = []; }
|
|
315
|
+
const baseUrl = `https://${_req.hostname}`;
|
|
316
|
+
|
|
317
|
+
res.json({
|
|
318
|
+
name: 'SpendOS',
|
|
319
|
+
wallet: getTreasuryAddress(),
|
|
320
|
+
network: 'eip155:8453',
|
|
321
|
+
payment: 'x402',
|
|
322
|
+
baseUrl,
|
|
323
|
+
services: [
|
|
324
|
+
...allJobs.map(j => ({
|
|
325
|
+
name: j.name,
|
|
326
|
+
endpoint: `${baseUrl}/api/jobs/${j.name}`,
|
|
327
|
+
price: j.price,
|
|
328
|
+
description: j.description,
|
|
329
|
+
inputs: j.inputs,
|
|
330
|
+
})),
|
|
331
|
+
{ name: 'summarize', endpoint: `${baseUrl}/api/summarize`, price: '$0.01', description: 'AI URL summarization', inputs: ['url'] },
|
|
332
|
+
{ name: 'generate-image', endpoint: `${baseUrl}/api/generate-image`, price: '$0.05', description: 'AI image generation', inputs: ['prompt'] },
|
|
333
|
+
],
|
|
334
|
+
audit: 'https://basescan.org/address/0xF74b481c9f196b5988cAA28Fb1452338597670B6',
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Auth level check — frontend uses this to show/hide admin controls
|
|
339
|
+
app.get('/api/auth', (req, res) => {
|
|
340
|
+
res.json({ role: (req as any).isAdmin ? 'admin' : (req as any).isDemo ? 'demo' : 'none' });
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ── Expiry check ───────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
setInterval(expireStale, 10_000);
|
|
346
|
+
|
|
347
|
+
// ── Internal tool endpoints (no x402 gate — for agent self-testing) ──
|
|
348
|
+
|
|
349
|
+
app.post('/api/internal/summarize', async (req, res) => {
|
|
350
|
+
const { url } = req.body;
|
|
351
|
+
if (!url || typeof url !== 'string') { res.status(400).json({ error: 'Missing "url"' }); return; }
|
|
352
|
+
try {
|
|
353
|
+
const result = await summarizeUrl(url);
|
|
354
|
+
res.json(result);
|
|
355
|
+
} catch (err) { res.status(500).json({ error: String(err) }); }
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
app.post('/api/internal/generate-image', async (req, res) => {
|
|
359
|
+
const { prompt } = req.body;
|
|
360
|
+
if (!prompt || typeof prompt !== 'string') { res.status(400).json({ error: 'Missing "prompt"' }); return; }
|
|
361
|
+
try {
|
|
362
|
+
const { buildSiweHeader } = await import('./venice-x402.js');
|
|
363
|
+
const uri = 'https://outerface.venice.ai/api/v1/image/generate';
|
|
364
|
+
const siweHeader = await buildSiweHeader(uri);
|
|
365
|
+
const veniceRes = await fetch('https://api.venice.ai/api/v1/image/generate', {
|
|
366
|
+
method: 'POST',
|
|
367
|
+
headers: { 'Content-Type': 'application/json', 'X-Sign-In-With-X': siweHeader },
|
|
368
|
+
body: JSON.stringify({ model: 'fluently-xl', prompt }),
|
|
369
|
+
});
|
|
370
|
+
if (!veniceRes.ok) { res.status(veniceRes.status).json({ error: await veniceRes.text() }); return; }
|
|
371
|
+
const data = await veniceRes.json() as any;
|
|
372
|
+
recordSpending(0.01);
|
|
373
|
+
res.json({ images: data.data, cost: { inference: 0.01 } });
|
|
374
|
+
} catch (err) { res.status(500).json({ error: String(err) }); }
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ── MVAE: summarization endpoint ───────────────────────
|
|
378
|
+
|
|
379
|
+
app.post('/api/summarize', async (req, res) => {
|
|
380
|
+
const { url } = req.body;
|
|
381
|
+
if (!url || typeof url !== 'string') {
|
|
382
|
+
res.status(400).json({ error: 'Missing or invalid "url" field' });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
const result = await summarizeUrl(url);
|
|
388
|
+
await logAuditEvent('earned', 'mvae', `Summarized: ${url}`, result.cost.earned);
|
|
389
|
+
res.json(result);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
res.status(500).json({ error: String(err) });
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ── Image generation endpoint ──────────────────────────
|
|
396
|
+
|
|
397
|
+
app.post('/api/generate-image', async (req, res) => {
|
|
398
|
+
const { prompt } = req.body;
|
|
399
|
+
if (!prompt || typeof prompt !== 'string') {
|
|
400
|
+
res.status(400).json({ error: 'Missing "prompt" field' });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
const { buildSiweHeader } = await import('./venice-x402.js');
|
|
406
|
+
const siweHeader = await buildSiweHeader('https://outerface.venice.ai/api/v1/image/generate');
|
|
407
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'X-Sign-In-With-X': siweHeader };
|
|
408
|
+
|
|
409
|
+
const veniceRes = await fetch('https://api.venice.ai/api/v1/image/generate', {
|
|
410
|
+
method: 'POST',
|
|
411
|
+
headers,
|
|
412
|
+
body: JSON.stringify({ model: 'fluently-xl', prompt }),
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
if (!veniceRes.ok) {
|
|
416
|
+
const text = await veniceRes.text();
|
|
417
|
+
res.status(veniceRes.status).json({ error: text });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const data = await veniceRes.json() as any;
|
|
422
|
+
recordEarning(0.05);
|
|
423
|
+
recordSpending(0.01);
|
|
424
|
+
await logAuditEvent('earned', 'mvae-image', `Generated image: ${prompt.slice(0, 50)}`, 0.05);
|
|
425
|
+
|
|
426
|
+
res.json({
|
|
427
|
+
images: data.data,
|
|
428
|
+
cost: { earned: 0.05, inference: 0.01, profit: 0.04 },
|
|
429
|
+
});
|
|
430
|
+
} catch (err) {
|
|
431
|
+
res.status(500).json({ error: String(err) });
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// ── Delegation Management ──────────────────────────────
|
|
436
|
+
|
|
437
|
+
app.post('/api/delegate', (req, res) => {
|
|
438
|
+
const { agentAddress, reason, chains, operations, maxAmountPerAction, totalBudget, allowedRecipients, expiresAt } = req.body;
|
|
439
|
+
|
|
440
|
+
if (!agentAddress || !reason || !chains || !expiresAt) {
|
|
441
|
+
res.status(400).json({ error: 'Missing required fields: agentAddress, reason, chains, expiresAt' });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const delegation = createDelegation({
|
|
446
|
+
agentAddress,
|
|
447
|
+
reason,
|
|
448
|
+
chains: chains ?? ['eip155:8453'],
|
|
449
|
+
operations: operations ?? ['sign_message', 'sign_tx'],
|
|
450
|
+
maxAmountPerAction: maxAmountPerAction ?? '100.00',
|
|
451
|
+
totalBudget: totalBudget ?? '500.00',
|
|
452
|
+
allowedRecipients: allowedRecipients ?? [],
|
|
453
|
+
expiresAt,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
delegation.aiInterpretation = generateHeuristicInterpretation(delegation);
|
|
457
|
+
|
|
458
|
+
logAuditEvent('requested', delegation.id, delegation.reason, 0);
|
|
459
|
+
notifyDelegationRequest(delegation).catch(() => {});
|
|
460
|
+
res.status(201).json(delegation);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
app.get('/api/requests', (_req, res) => {
|
|
464
|
+
// Strip session key tokens from response — never expose to clients
|
|
465
|
+
const safe = getDelegations().map(d => {
|
|
466
|
+
const { sessionKeyToken, ...rest } = d as any;
|
|
467
|
+
return rest;
|
|
468
|
+
});
|
|
469
|
+
res.json(safe);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
app.post('/api/requests/:id/approve', requireAuth, async (req, res) => {
|
|
473
|
+
try {
|
|
474
|
+
const d = approveDelegation(req.params.id);
|
|
475
|
+
await logAuditEvent('approved', d.id, d.reason, parseFloat(d.totalBudget));
|
|
476
|
+
notifyDelegationDecision(d, 'approved').catch(() => {});
|
|
477
|
+
|
|
478
|
+
// Notify agent of approval (isolated session, 200 token cap, 30s timeout — no retry loops)
|
|
479
|
+
triggerAgentExecution(d).catch(err => console.log(`[Agent trigger] ${err}`));
|
|
480
|
+
|
|
481
|
+
res.json(d);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
res.status(400).json({ error: String(err) });
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
app.post('/api/requests/:id/reject', requireAuth, async (req, res) => {
|
|
488
|
+
try {
|
|
489
|
+
const d = rejectDelegation(req.params.id);
|
|
490
|
+
await logAuditEvent('rejected', d.id, d.reason, 0);
|
|
491
|
+
notifyDelegationDecision(d, 'rejected').catch(() => {});
|
|
492
|
+
res.json(d);
|
|
493
|
+
} catch (err) {
|
|
494
|
+
res.status(400).json({ error: String(err) });
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
app.post('/api/requests/:id/revoke', requireAuth, async (req, res) => {
|
|
499
|
+
try {
|
|
500
|
+
const d = revokeDelegation(req.params.id);
|
|
501
|
+
await logAuditEvent('revoked', d.id, d.reason, 0);
|
|
502
|
+
res.json(d);
|
|
503
|
+
} catch (err) {
|
|
504
|
+
res.status(400).json({ error: String(err) });
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// ── Data Endpoints ─────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
app.get('/api/pnl', (_req, res) => {
|
|
511
|
+
res.json(getPnL());
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
app.get('/api/audit', (_req, res) => {
|
|
515
|
+
res.json(getAuditLog());
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
app.get('/api/wallet', (_req, res) => {
|
|
519
|
+
const w = getTreasuryWallet();
|
|
520
|
+
res.json({
|
|
521
|
+
id: w.id,
|
|
522
|
+
name: w.name,
|
|
523
|
+
address: getTreasuryAddress(),
|
|
524
|
+
accounts: w.accounts,
|
|
525
|
+
createdAt: w.createdAt,
|
|
526
|
+
inferenceMode: getInferenceMode(),
|
|
527
|
+
veniceBalance: getVeniceBalance(),
|
|
528
|
+
xmtpAddress: getXmtpAddress(),
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// Reset P&L (admin only — for switching networks)
|
|
533
|
+
app.post('/api/pnl/reset', requireAuth, (_req, res) => {
|
|
534
|
+
resetPnL();
|
|
535
|
+
res.json({ ok: true, message: 'P&L reset to zero' });
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
app.get('/api/delegations/active', (_req, res) => {
|
|
539
|
+
res.json(getActiveDelegations());
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// ── Chat History (persistent JSONL on volume) ─────────
|
|
543
|
+
|
|
544
|
+
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
|
|
545
|
+
|
|
546
|
+
const CHAT_HISTORY_FILE = '/data/spendos/chat-history.jsonl';
|
|
547
|
+
const MAX_HISTORY_MESSAGES = 100; // compact after this many
|
|
548
|
+
|
|
549
|
+
function loadChatHistory(): Array<{ role: string; content: string }> {
|
|
550
|
+
try {
|
|
551
|
+
const lines = readFileSync(CHAT_HISTORY_FILE, 'utf-8').trim().split('\n').filter(Boolean);
|
|
552
|
+
return lines.map(l => JSON.parse(l));
|
|
553
|
+
} catch { return []; }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function appendChatMessage(msg: { role: string; content: string }): void {
|
|
557
|
+
appendFileSync(CHAT_HISTORY_FILE, JSON.stringify(msg) + '\n');
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function compactChatHistory(): void {
|
|
561
|
+
const history = loadChatHistory();
|
|
562
|
+
if (history.length <= MAX_HISTORY_MESSAGES) return;
|
|
563
|
+
// Keep last 50 messages
|
|
564
|
+
const compacted = history.slice(-50);
|
|
565
|
+
writeFileSync(CHAT_HISTORY_FILE, compacted.map(m => JSON.stringify(m)).join('\n') + '\n');
|
|
566
|
+
console.log(`[Chat] Compacted history: ${history.length} → ${compacted.length} messages`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
app.get('/api/chat/history', (_req, res) => {
|
|
570
|
+
const history = loadChatHistory();
|
|
571
|
+
res.json(history);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// ── Chat endpoint (routes to OpenClaw agent) ──────────
|
|
575
|
+
|
|
576
|
+
// Chat allows both admin and demo users
|
|
577
|
+
function requireAnyAuth(req: express.Request, res: express.Response, next: express.NextFunction): void {
|
|
578
|
+
if (!ADMIN_TOKEN) { next(); return; }
|
|
579
|
+
if ((req as any).isAdmin || (req as any).isDemo) { next(); return; }
|
|
580
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
app.post('/api/chat', requireAnyAuth, async (req, res) => {
|
|
584
|
+
const { message, history } = req.body;
|
|
585
|
+
if (!message) { res.status(400).json({ error: 'Missing message' }); return; }
|
|
586
|
+
|
|
587
|
+
// Save user message to persistent history
|
|
588
|
+
appendChatMessage({ role: 'user', content: message });
|
|
589
|
+
|
|
590
|
+
// Load full conversation history from disk (last 40 messages for context window)
|
|
591
|
+
const fullHistory = loadChatHistory();
|
|
592
|
+
const chatMessages = fullHistory.slice(-40);
|
|
593
|
+
|
|
594
|
+
const OPENCLAW_URL = process.env.OPENCLAW_INTERNAL_URL ?? 'http://localhost:18789';
|
|
595
|
+
const OPENCLAW_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN ?? '';
|
|
596
|
+
|
|
597
|
+
// ── Retry loop protection ──────────────────────────
|
|
598
|
+
const MAX_RESPONSE_BYTES = 15_000; // 15KB max response (~4K words)
|
|
599
|
+
const MAX_STREAM_MS = 120_000; // 2 minute hard timeout
|
|
600
|
+
const REPEAT_WINDOW = 5; // detect if last N chunks are similar
|
|
601
|
+
const abortController = new AbortController();
|
|
602
|
+
const streamTimeout = setTimeout(() => {
|
|
603
|
+
console.log(`[Chat] Stream timeout (${MAX_STREAM_MS / 1000}s) — aborting`);
|
|
604
|
+
abortController.abort();
|
|
605
|
+
}, MAX_STREAM_MS);
|
|
606
|
+
let assistantContent = '';
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
610
|
+
if (OPENCLAW_TOKEN) headers['Authorization'] = `Bearer ${OPENCLAW_TOKEN}`;
|
|
611
|
+
|
|
612
|
+
// Use a fixed session so the agent maintains memory across messages
|
|
613
|
+
headers['X-Session-Id'] = 'spendos-dashboard-chat';
|
|
614
|
+
|
|
615
|
+
const ocRes = await fetch(`${OPENCLAW_URL}/v1/chat/completions`, {
|
|
616
|
+
method: 'POST',
|
|
617
|
+
headers,
|
|
618
|
+
body: JSON.stringify({
|
|
619
|
+
model: 'openclaw',
|
|
620
|
+
messages: chatMessages,
|
|
621
|
+
max_tokens: 1000,
|
|
622
|
+
stream: true,
|
|
623
|
+
}),
|
|
624
|
+
signal: abortController.signal,
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
if (!ocRes.ok) {
|
|
628
|
+
clearTimeout(streamTimeout);
|
|
629
|
+
const errText = await ocRes.text();
|
|
630
|
+
console.error(`[Chat] OpenClaw error: ${ocRes.status} ${errText.slice(0, 200)}`);
|
|
631
|
+
res.status(ocRes.status).json({ error: `OpenClaw: ${ocRes.status}` });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Stream OpenClaw response with loop protection
|
|
636
|
+
console.log(`[Chat] OpenClaw streaming response`);
|
|
637
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
638
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
639
|
+
res.setHeader('Connection', 'keep-alive');
|
|
640
|
+
res.setHeader('X-Accel-Buffering', 'no'); // disable nginx/Railway buffering
|
|
641
|
+
res.flushHeaders();
|
|
642
|
+
const reader = ocRes.body?.getReader();
|
|
643
|
+
if (!reader) { clearTimeout(streamTimeout); res.end(); return; }
|
|
644
|
+
const decoder = new TextDecoder();
|
|
645
|
+
let totalBytes = 0;
|
|
646
|
+
const recentChunks: string[] = [];
|
|
647
|
+
let abortedReason = '';
|
|
648
|
+
|
|
649
|
+
while (true) {
|
|
650
|
+
const { done, value } = await reader.read();
|
|
651
|
+
if (done) break;
|
|
652
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
653
|
+
totalBytes += chunk.length;
|
|
654
|
+
|
|
655
|
+
// ── Guard 1: max response size ──
|
|
656
|
+
if (totalBytes > MAX_RESPONSE_BYTES) {
|
|
657
|
+
abortedReason = 'max_size';
|
|
658
|
+
console.log(`[Chat] Response exceeded ${MAX_RESPONSE_BYTES} bytes — cutting off`);
|
|
659
|
+
reader.cancel();
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ── Guard 2: repetition detection ──
|
|
664
|
+
// Extract text content from this chunk
|
|
665
|
+
let chunkText = '';
|
|
666
|
+
for (const line of chunk.split('\n')) {
|
|
667
|
+
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
|
|
668
|
+
try { const c = JSON.parse(line.slice(6)).choices?.[0]?.delta?.content; if (c) chunkText += c; } catch {}
|
|
669
|
+
}
|
|
670
|
+
if (chunkText.length > 20) {
|
|
671
|
+
recentChunks.push(chunkText.slice(0, 100));
|
|
672
|
+
if (recentChunks.length > REPEAT_WINDOW) recentChunks.shift();
|
|
673
|
+
// If we have enough chunks, check for repetition
|
|
674
|
+
if (recentChunks.length >= REPEAT_WINDOW) {
|
|
675
|
+
const unique = new Set(recentChunks.map(c => c.trim().toLowerCase().slice(0, 60)));
|
|
676
|
+
if (unique.size <= 2) {
|
|
677
|
+
abortedReason = 'repetition';
|
|
678
|
+
console.log(`[Chat] Repetition detected (${unique.size} unique in last ${REPEAT_WINDOW} chunks) — cutting off`);
|
|
679
|
+
reader.cancel();
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
res.write(chunk);
|
|
686
|
+
// Extract content from SSE for history
|
|
687
|
+
if (chunkText) assistantContent += chunkText;
|
|
688
|
+
else {
|
|
689
|
+
for (const line of chunk.split('\n')) {
|
|
690
|
+
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
|
|
691
|
+
try { const c = JSON.parse(line.slice(6)).choices?.[0]?.delta?.content; if (c) assistantContent += c; } catch {}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
clearTimeout(streamTimeout);
|
|
697
|
+
|
|
698
|
+
// If we cut off the stream, send a clean termination to the client
|
|
699
|
+
if (abortedReason) {
|
|
700
|
+
const cutoffMsg = abortedReason === 'repetition'
|
|
701
|
+
? '\n\n[Response cut off — repetitive loop detected]'
|
|
702
|
+
: '\n\n[Response cut off — max length reached]';
|
|
703
|
+
const cutoffEvent = `data: ${JSON.stringify({ choices: [{ delta: { content: cutoffMsg }, finish_reason: 'length' }] })}\n\ndata: [DONE]\n\n`;
|
|
704
|
+
res.write(cutoffEvent);
|
|
705
|
+
assistantContent += cutoffMsg;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Save assistant response to persistent history
|
|
709
|
+
if (assistantContent) {
|
|
710
|
+
appendChatMessage({ role: 'assistant', content: assistantContent });
|
|
711
|
+
compactChatHistory();
|
|
712
|
+
}
|
|
713
|
+
res.end();
|
|
714
|
+
} catch (err) {
|
|
715
|
+
clearTimeout(streamTimeout);
|
|
716
|
+
// AbortError from our own timeout is expected
|
|
717
|
+
if ((err as Error).name === 'AbortError') {
|
|
718
|
+
console.log(`[Chat] Stream aborted by timeout`);
|
|
719
|
+
const cutoffEvent = `data: ${JSON.stringify({ choices: [{ delta: { content: '\n\n[Response cut off — timeout reached]' }, finish_reason: 'length' }] })}\n\ndata: [DONE]\n\n`;
|
|
720
|
+
try { res.write(cutoffEvent); } catch {}
|
|
721
|
+
if (assistantContent) {
|
|
722
|
+
appendChatMessage({ role: 'assistant', content: assistantContent + '\n\n[Response cut off — timeout reached]' });
|
|
723
|
+
}
|
|
724
|
+
res.end();
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
console.error(`[Chat] Error: ${err}`);
|
|
728
|
+
res.status(500).json({ error: String(err) });
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// ── OpenAI-compatible proxy (Venice wallet auth) ───────
|
|
733
|
+
|
|
734
|
+
app.post('/v1/chat/completions', async (req, res) => {
|
|
735
|
+
try {
|
|
736
|
+
const { buildSiweHeader } = await import('./venice-x402.js');
|
|
737
|
+
const { model, messages, max_tokens, temperature, stream } = req.body;
|
|
738
|
+
const totalChars = JSON.stringify(messages).length;
|
|
739
|
+
console.log(`[Proxy] Incoming: model=${model} msgs=${messages?.length} chars=${totalChars}`);
|
|
740
|
+
|
|
741
|
+
if (stream) {
|
|
742
|
+
const uri = 'https://outerface.venice.ai/api/v1/chat/completions';
|
|
743
|
+
const siweHeader = await buildSiweHeader(uri);
|
|
744
|
+
|
|
745
|
+
const veniceRes = await fetch('https://api.venice.ai/api/v1/chat/completions', {
|
|
746
|
+
method: 'POST',
|
|
747
|
+
headers: { 'Content-Type': 'application/json', 'X-Sign-In-With-X': siweHeader },
|
|
748
|
+
body: JSON.stringify({ model: model ?? 'llama-3.3-70b', messages, max_tokens: max_tokens ?? 4096, temperature: temperature ?? 0.7, stream: true }),
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
if (veniceRes.status === 402) {
|
|
752
|
+
// Auto top-up Venice credits and retry
|
|
753
|
+
console.log(`[Proxy] Venice 402 — attempting auto top-up...`);
|
|
754
|
+
try {
|
|
755
|
+
const { topUp } = await import('./venice-x402.js');
|
|
756
|
+
const result = await topUp(5);
|
|
757
|
+
if (result) {
|
|
758
|
+
console.log(`[Proxy] Top-up success: $${result.credited} → balance $${result.newBalance}`);
|
|
759
|
+
// Retry the request with fresh SIWE header
|
|
760
|
+
const retryHeader = await buildSiweHeader(uri);
|
|
761
|
+
const retryRes = await fetch('https://api.venice.ai/api/v1/chat/completions', {
|
|
762
|
+
method: 'POST',
|
|
763
|
+
headers: { 'Content-Type': 'application/json', 'X-Sign-In-With-X': retryHeader },
|
|
764
|
+
body: JSON.stringify({ model: model ?? 'llama-3.3-70b', messages, max_tokens: max_tokens ?? 4096, temperature: temperature ?? 0.7, stream: true }),
|
|
765
|
+
});
|
|
766
|
+
if (retryRes.ok) {
|
|
767
|
+
// Stream the retry response
|
|
768
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
769
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
770
|
+
res.setHeader('Connection', 'keep-alive');
|
|
771
|
+
const retryReader = retryRes.body?.getReader();
|
|
772
|
+
if (!retryReader) { res.end(); return; }
|
|
773
|
+
const dec = new TextDecoder();
|
|
774
|
+
try { while (true) { const { done, value } = await retryReader.read(); if (done) break; res.write(dec.decode(value, { stream: true })); } } catch {}
|
|
775
|
+
res.end();
|
|
776
|
+
recordSpending(0.002);
|
|
777
|
+
console.log(`[Proxy] Stream complete after top-up (cost: $0.002)`);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
} catch (topUpErr) {
|
|
782
|
+
console.log(`[Proxy] Auto top-up failed: ${topUpErr}`);
|
|
783
|
+
}
|
|
784
|
+
const text = await veniceRes.text();
|
|
785
|
+
res.status(402).json({ error: { message: text, type: 'insufficient_balance' } });
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (!veniceRes.ok) {
|
|
790
|
+
const text = await veniceRes.text();
|
|
791
|
+
res.status(veniceRes.status).json({ error: { message: text, type: 'upstream_error' } });
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
796
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
797
|
+
res.setHeader('Connection', 'keep-alive');
|
|
798
|
+
|
|
799
|
+
const reader = veniceRes.body?.getReader();
|
|
800
|
+
if (!reader) { res.end(); return; }
|
|
801
|
+
|
|
802
|
+
const decoder = new TextDecoder();
|
|
803
|
+
try {
|
|
804
|
+
while (true) {
|
|
805
|
+
const { done, value } = await reader.read();
|
|
806
|
+
if (done) break;
|
|
807
|
+
res.write(decoder.decode(value, { stream: true }));
|
|
808
|
+
}
|
|
809
|
+
} catch { /* stream closed */ }
|
|
810
|
+
res.end();
|
|
811
|
+
// Track inference cost (~$0.002 per request for Kimi K2.5)
|
|
812
|
+
recordSpending(0.002);
|
|
813
|
+
console.log(`[Proxy] Stream complete (cost: $0.002)`);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Non-streaming
|
|
818
|
+
const uri = 'https://outerface.venice.ai/api/v1/chat/completions';
|
|
819
|
+
const siweHeader = await buildSiweHeader(uri);
|
|
820
|
+
|
|
821
|
+
let veniceRes = await fetch('https://api.venice.ai/api/v1/chat/completions', {
|
|
822
|
+
method: 'POST',
|
|
823
|
+
headers: { 'Content-Type': 'application/json', 'X-Sign-In-With-X': siweHeader },
|
|
824
|
+
body: JSON.stringify({ model: model ?? 'llama-3.3-70b', messages, max_tokens: max_tokens ?? 4096, temperature: temperature ?? 0.7, stream: false }),
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// Auto top-up on 402
|
|
828
|
+
if (veniceRes.status === 402) {
|
|
829
|
+
console.log(`[Proxy] Venice 402 (non-stream) — auto top-up...`);
|
|
830
|
+
try {
|
|
831
|
+
const { topUp } = await import('./venice-x402.js');
|
|
832
|
+
const result = await topUp(5);
|
|
833
|
+
if (result) {
|
|
834
|
+
console.log(`[Proxy] Top-up: $${result.credited} → $${result.newBalance}`);
|
|
835
|
+
const retryHeader = await buildSiweHeader(uri);
|
|
836
|
+
veniceRes = await fetch('https://api.venice.ai/api/v1/chat/completions', {
|
|
837
|
+
method: 'POST',
|
|
838
|
+
headers: { 'Content-Type': 'application/json', 'X-Sign-In-With-X': retryHeader },
|
|
839
|
+
body: JSON.stringify({ model: model ?? 'llama-3.3-70b', messages, max_tokens: max_tokens ?? 4096, temperature: temperature ?? 0.7, stream: false }),
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
} catch (e) { console.log(`[Proxy] Top-up failed: ${e}`); }
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (!veniceRes.ok) {
|
|
846
|
+
const text = await veniceRes.text();
|
|
847
|
+
res.status(veniceRes.status).json({ error: { message: text, type: 'upstream_error' } });
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const data = await veniceRes.json();
|
|
852
|
+
recordSpending(0.002);
|
|
853
|
+
console.log(`[Proxy] Venice OK (cost: $0.002): ${(data as any).choices?.[0]?.message?.content?.slice(0, 80) ?? 'no content'}`);
|
|
854
|
+
res.json(data);
|
|
855
|
+
} catch (err) {
|
|
856
|
+
console.error(`[Proxy] Error: ${err}`);
|
|
857
|
+
res.status(500).json({ error: { message: String(err), type: 'server_error' } });
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// ── Start ──────────────────────────────────────────────
|
|
862
|
+
|
|
863
|
+
app.listen(PORT, '0.0.0.0', () => {
|
|
864
|
+
console.log(`[SpendOS] Dashboard: http://localhost:${PORT}`);
|
|
865
|
+
console.log(`[SpendOS] Treasury: ${getTreasuryAddress()}`);
|
|
866
|
+
console.log(`[SpendOS] MVAE endpoint: POST http://localhost:${PORT}/api/summarize`);
|
|
867
|
+
|
|
868
|
+
// Investment proposals come from OpenClaw cron jobs (agent thinks + uses MCP tools)
|
|
869
|
+
// No hardcoded scanner — the agent uses MoonPay/Zerion MCP to find opportunities
|
|
870
|
+
});
|