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.
Files changed (90) hide show
  1. package/.dockerignore +4 -0
  2. package/.env.example +30 -0
  3. package/AGENTS.md +212 -0
  4. package/BOOTSTRAP.md +55 -0
  5. package/Dockerfile +52 -0
  6. package/HEARTBEAT.md +7 -0
  7. package/IDENTITY.md +23 -0
  8. package/LICENSE +21 -0
  9. package/README.md +162 -0
  10. package/SOUL.md +202 -0
  11. package/SUBMISSION.md +128 -0
  12. package/TOOLS.md +40 -0
  13. package/USER.md +17 -0
  14. package/acp-seller/bin/acp.ts +807 -0
  15. package/acp-seller/config.json +34 -0
  16. package/acp-seller/package.json +55 -0
  17. package/acp-seller/src/commands/agent.ts +328 -0
  18. package/acp-seller/src/commands/bounty.ts +1189 -0
  19. package/acp-seller/src/commands/deploy.ts +414 -0
  20. package/acp-seller/src/commands/job.ts +217 -0
  21. package/acp-seller/src/commands/profile.ts +71 -0
  22. package/acp-seller/src/commands/resource.ts +91 -0
  23. package/acp-seller/src/commands/search.ts +327 -0
  24. package/acp-seller/src/commands/sell.ts +883 -0
  25. package/acp-seller/src/commands/serve.ts +258 -0
  26. package/acp-seller/src/commands/setup.ts +399 -0
  27. package/acp-seller/src/commands/token.ts +88 -0
  28. package/acp-seller/src/commands/wallet.ts +123 -0
  29. package/acp-seller/src/lib/api.ts +118 -0
  30. package/acp-seller/src/lib/auth.ts +291 -0
  31. package/acp-seller/src/lib/bounty.ts +257 -0
  32. package/acp-seller/src/lib/client.ts +42 -0
  33. package/acp-seller/src/lib/config.ts +240 -0
  34. package/acp-seller/src/lib/open.ts +41 -0
  35. package/acp-seller/src/lib/openclawCron.ts +138 -0
  36. package/acp-seller/src/lib/output.ts +104 -0
  37. package/acp-seller/src/lib/wallet.ts +81 -0
  38. package/acp-seller/src/seller/offerings/_shared/preTransactionScan.ts +127 -0
  39. package/acp-seller/src/seller/offerings/canonical-catalog.ts +221 -0
  40. package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/handlers.ts +20 -0
  41. package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/offering.json +18 -0
  42. package/acp-seller/src/seller/offerings/spendos/spendos_translate/handlers.ts +21 -0
  43. package/acp-seller/src/seller/offerings/spendos/spendos_translate/offering.json +22 -0
  44. package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/handlers.ts +20 -0
  45. package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/offering.json +18 -0
  46. package/acp-seller/src/seller/runtime/acpSocket.ts +413 -0
  47. package/acp-seller/src/seller/runtime/logger.ts +36 -0
  48. package/acp-seller/src/seller/runtime/offeringTypes.ts +52 -0
  49. package/acp-seller/src/seller/runtime/offerings.ts +277 -0
  50. package/acp-seller/src/seller/runtime/paymentVerification.test.ts +207 -0
  51. package/acp-seller/src/seller/runtime/paymentVerification.ts +363 -0
  52. package/acp-seller/src/seller/runtime/seller.onchain.test.ts +220 -0
  53. package/acp-seller/src/seller/runtime/seller.test.ts +823 -0
  54. package/acp-seller/src/seller/runtime/seller.ts +1041 -0
  55. package/acp-seller/src/seller/runtime/sellerApi.ts +71 -0
  56. package/acp-seller/src/seller/runtime/startup.ts +270 -0
  57. package/acp-seller/src/seller/runtime/types.ts +62 -0
  58. package/acp-seller/tsconfig.json +20 -0
  59. package/bin/spendos.js +23 -0
  60. package/contracts/SpendOSAudit.sol +29 -0
  61. package/dist/mcp-server.mjs +153 -0
  62. package/jobs/translate.json +7 -0
  63. package/jobs/tweet-gen.json +7 -0
  64. package/openclaw.json +41 -0
  65. package/package.json +49 -0
  66. package/plugins/spendos-events/index.ts +78 -0
  67. package/plugins/spendos-events/package.json +14 -0
  68. package/policies/enforce-bounds.mjs +71 -0
  69. package/public/index.html +509 -0
  70. package/public/landing.html +241 -0
  71. package/railway.json +12 -0
  72. package/railway.toml +12 -0
  73. package/scripts/deploy.ts +48 -0
  74. package/scripts/test-x402-mainnet.ts +30 -0
  75. package/scripts/xmtp-listener.ts +61 -0
  76. package/setup.sh +278 -0
  77. package/skills/spendos/skill.md +26 -0
  78. package/src/agent.ts +152 -0
  79. package/src/audit.ts +166 -0
  80. package/src/governance.ts +367 -0
  81. package/src/job-registry.ts +306 -0
  82. package/src/mcp-public.ts +145 -0
  83. package/src/mcp-server.ts +171 -0
  84. package/src/opportunity-scanner.ts +138 -0
  85. package/src/server.ts +870 -0
  86. package/src/venice-x402.ts +234 -0
  87. package/src/xmtp.ts +109 -0
  88. package/src/zerion.ts +58 -0
  89. package/start.sh +168 -0
  90. 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
+ });