Skip to content

Commit 5be8b8a

Browse files
author
Ope Olatunji
committed
v0.5.483: Screenshot compression, search relevance, wallet redeem, price validation
- Screenshot compression: Sharp stubs replaced with real implementations, images >150KB auto-compressed to max 1024px JPEG (prevents 145K+ token LLM crashes) - Browser screenshot defaults: max side 2000→1280px, max bytes 5MB→200KB - Search relevance: client-side relevance filtering for poly_search_markets and screener — no more irrelevant political/gossip markets in search results - Volume ordering removed when search query is provided (lets API rank by relevance) - web_fetch errors: extract HTML title only instead of dumping CSS garbage - Momentum scanner: filter out expired markets (no more fake momentum from ended games) - poly_get_market: use slug as primary ID (fixes "Market not found" errors), improved lookup order with condition_id fallback - poly_place_order: auto-derive price from midpoint when not specified, clamp price to 0.01-0.99 range, updated description with constraints - Wallet redeem endpoint: POST /polymarket/:agentId/wallet/redeem — direct on-chain CTF redemption without needing the LLM - Dashboard redeem button: calls API directly with confirmation dialog, "Redeem All Winnings" button for batch claims - Won/Lost position display: WON (green) / LOST (red) status in positions table, lost positions auto-hidden after 3 days, no redeem button on lost trades - Fix TSC errors: Buffer type casts, DB shim type assertions, event type widening
1 parent 5cbe4a9 commit 5be8b8a

File tree

12 files changed

+354
-72
lines changed

12 files changed

+354
-72
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@agenticmail/enterprise",
3-
"version": "0.5.476",
3+
"version": "0.5.483",
44
"description": "AgenticMail Enterprise — cloud-hosted AI agent identity, email, auth & compliance for organizations",
55
"type": "module",
66
"bin": {

src/admin/routes.ts

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3034,9 +3034,9 @@ export function createAdminRoutes(db: DatabaseAdapter) {
30343034
if (!e) return;
30353035
try {
30363036
// initPolymarketDB expects db.execute(); engine DB has run/get/all — shim it
3037-
const dbShim = e.execute ? e : Object.assign({}, e, {
3038-
execute: e.run || e.execute,
3039-
query: e.all || e.query,
3037+
const dbShim = (e as any).execute ? e : Object.assign({}, e, {
3038+
execute: (e as any).run || (e as any).execute,
3039+
query: (e as any).all || (e as any).query,
30403040
});
30413041
await initPolymarketDB(dbShim);
30423042

@@ -3822,6 +3822,77 @@ export function createAdminRoutes(db: DatabaseAdapter) {
38223822
} catch (e: any) { return c.json({ error: `Swap failed: ${e.message}` }, 500); }
38233823
});
38243824

3825+
// ── Redeem winning positions — direct on-chain, no LLM needed ──
3826+
api.post('/polymarket/:agentId/wallet/redeem', requireRole('owner'), async (c) => {
3827+
try {
3828+
const agentId = c.req.param('agentId');
3829+
const body = await c.req.json().catch(() => ({}));
3830+
const conditionId = body.condition_id;
3831+
3832+
// Load wallet credentials
3833+
const creds = await edb()?.get(`SELECT private_key_encrypted, funder_address FROM poly_wallet_credentials WHERE agent_id = ?`, [agentId]) as any;
3834+
if (!creds?.funder_address) return c.json({ error: 'No wallet configured' }, 404);
3835+
const decryptedKey = (() => { try { return vault.decrypt(creds.private_key_encrypted); } catch { return creds.private_key_encrypted; } })();
3836+
3837+
// Fetch redeemable positions from Polymarket Data API
3838+
const posRes = await fetch(`https://data-api.polymarket.com/positions?user=${creds.funder_address}&sizeThreshold=0`);
3839+
const positions = await posRes.json();
3840+
const redeemable = (positions as any[]).filter((pos: any) => pos.redeemable === true);
3841+
if (!redeemable.length) return c.json({ ok: true, status: 'nothing_to_redeem', message: 'No redeemable positions found.' });
3842+
3843+
// Filter by condition_id if specified, otherwise redeem all
3844+
const toRedeem = conditionId
3845+
? redeemable.filter((pos: any) => pos.conditionId === conditionId)
3846+
: redeemable;
3847+
if (!toRedeem.length) return c.json({ error: `No redeemable position found for condition ${conditionId}` }, 404);
3848+
3849+
// Connect to Polygon
3850+
const { Wallet, JsonRpcProvider, Contract } = await import('ethers');
3851+
const CTF = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
3852+
const CTF_ABI = ['function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] calldata indexSets) external'];
3853+
3854+
let provider: any = null;
3855+
for (const rpc of ['https://polygon.drpc.org', 'https://polygon-bor-rpc.publicnode.com', 'https://polygon.llamarpc.com']) {
3856+
try { provider = new JsonRpcProvider(rpc); await provider.getNetwork(); break; } catch { provider = null; }
3857+
}
3858+
if (!provider) return c.json({ error: 'Cannot connect to Polygon RPC' }, 502);
3859+
3860+
const wallet = new Wallet(decryptedKey, provider);
3861+
const ctf = new Contract(CTF, CTF_ABI, wallet);
3862+
const parentCollectionId = '0x0000000000000000000000000000000000000000000000000000000000000000';
3863+
3864+
const results: any[] = [];
3865+
for (const pos of toRedeem) {
3866+
try {
3867+
const indexSets = pos.negativeRisk ? [1] : [1, 2];
3868+
const tx = await ctf.redeemPositions(USDC_E_SHARED, parentCollectionId, pos.conditionId, indexSets, { gasLimit: 300000 });
3869+
const receipt = await tx.wait();
3870+
3871+
// Update trade log (best effort)
3872+
try {
3873+
await edb()?.run(`UPDATE poly_trade_log SET status = 'redeemed', pnl = ? WHERE token_id = ? AND agent_id = ? AND status != 'redeemed'`,
3874+
[pos.cashPnl || 0, pos.asset, agentId]);
3875+
} catch {}
3876+
3877+
results.push({ title: pos.title, outcome: pos.outcome, conditionId: pos.conditionId, shares: pos.size, value: pos.currentValue, profit: pos.cashPnl, txHash: receipt.hash, status: 'redeemed' });
3878+
} catch (e: any) {
3879+
results.push({ title: pos.title, conditionId: pos.conditionId, status: 'failed', error: e.message });
3880+
}
3881+
}
3882+
3883+
try { await (db as any).createAuditLog({ userId: c.get('userId' as any), action: 'wallet.redeem', resourceType: 'agent', resourceId: agentId, details: { redeemed: results.filter(r => r.status === 'redeemed').length, failed: results.filter(r => r.status === 'failed').length }, ipAddress: c.req.header('x-forwarded-for') || 'unknown' }); } catch {}
3884+
3885+
return c.json({
3886+
ok: true,
3887+
redeemed: results.filter(r => r.status === 'redeemed').length,
3888+
failed: results.filter(r => r.status === 'failed').length,
3889+
total_value: results.filter(r => r.status === 'redeemed').reduce((s, r) => s + (r.value || 0), 0),
3890+
total_profit: results.filter(r => r.status === 'redeemed').reduce((s, r) => s + (r.profit || 0), 0),
3891+
details: results,
3892+
});
3893+
} catch (e: any) { return c.json({ error: `Redeem failed: ${e.message}` }, 500); }
3894+
});
3895+
38253896
api.post('/polymarket/:agentId/wallet/transfer', requireRole('owner'), async (c) => {
38263897
try {
38273898
const agentId = c.req.param('agentId');
@@ -4422,23 +4493,35 @@ export function createAdminRoutes(db: DatabaseAdapter) {
44224493
const posResp = await fetch(`https://data-api.polymarket.com/positions?user=${wallet.funder_address}&sizeThreshold=0`);
44234494
const posData = await posResp.json();
44244495
if (Array.isArray(posData)) {
4496+
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
44254497
liveTradeRows = posData.filter((p: any) => parseFloat(p.size) > 0).map((p: any) => {
44264498
// If position is redeemable or curPrice is exactly 0 or 1, it's resolved
44274499
const curPrice = parseFloat(p.curPrice) || 0;
4428-
if (p.redeemable || curPrice === 1 || curPrice === 0) {
4500+
const isResolved = p.redeemable || curPrice === 1 || curPrice === 0;
4501+
if (isResolved) {
44294502
resolvedPrices[p.asset] = curPrice;
44304503
}
44314504
return {
44324505
token_id: p.asset,
4506+
conditionId: p.conditionId || '',
44334507
market_question: p.title || 'Unknown',
44344508
outcome: p.outcome || '',
44354509
side: 'BUY',
44364510
entry_price: parseFloat(p.avgPrice) || 0,
44374511
size: parseFloat(p.size) || 0,
44384512
redeemable: p.redeemable || false,
4439-
resolved_price: p.redeemable ? curPrice : undefined,
4513+
resolved_price: isResolved ? curPrice : undefined,
44404514
endDate: p.endDate || p.expirationDate || '',
4515+
isWon: isResolved && curPrice >= 0.99,
4516+
isLost: isResolved && curPrice <= 0.01,
44414517
};
4518+
}).filter((p: any) => {
4519+
// Auto-hide lost positions after 3 days (non-redeemable, resolved to 0)
4520+
if (p.isLost && !p.redeemable && p.endDate) {
4521+
const endTime = new Date(p.endDate).getTime();
4522+
if (!isNaN(endTime) && Date.now() - endTime > THREE_DAYS_MS) return false;
4523+
}
4524+
return true;
44424525
});
44434526
}
44444527
}
@@ -4499,6 +4582,7 @@ export function createAdminRoutes(db: DatabaseAdapter) {
44994582
: p.side;
45004583
return {
45014584
token_id: p.token_id,
4585+
conditionId: p.conditionId || '',
45024586
market: p.market_question,
45034587
side: p.side,
45044588
outcome: outcome,
@@ -4509,6 +4593,8 @@ export function createAdminRoutes(db: DatabaseAdapter) {
45094593
pnlPct: +((pnl / (p.entry_price * p.size)) * 100).toFixed(2),
45104594
redeemable: p.redeemable || false,
45114595
resolved: resolvedPrices[p.token_id] !== undefined,
4596+
isWon: p.isWon || false,
4597+
isLost: p.isLost || false,
45124598
endDate: p.endDate || '',
45134599
};
45144600
});

src/agent-tools/common.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,17 +238,51 @@ export function redactSecrets(
238238
* Create an image result from a file path.
239239
* Reads the file, detects mime type, and wraps in a tool result.
240240
*/
241+
// Max image size before compression (150KB → ~50K tokens, reasonable for vision)
242+
var MAX_IMAGE_BYTES = 150_000;
243+
// Max dimension for resize
244+
var MAX_IMAGE_SIDE = 1024;
245+
241246
export async function imageResultFromFile(params: {
242247
label: string;
243248
path: string;
244249
extraText?: string;
245250
details?: Record<string, unknown>;
246251
}): Promise<ToolResult<unknown>> {
247252
const fs = await import('node:fs/promises');
248-
const buf = await fs.readFile(params.path);
253+
var buf = await fs.readFile(params.path);
249254
const ext = params.path.split('.').pop()?.toLowerCase();
250255
const mimeMap: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml' };
251-
const mimeType = mimeMap[ext || ''] || 'image/png';
256+
var mimeType = mimeMap[ext || ''] || 'image/png';
257+
258+
// Compress large images to prevent 100K+ token payloads that crash models
259+
if (buf.byteLength > MAX_IMAGE_BYTES && mimeType !== 'image/svg+xml') {
260+
try {
261+
const sharp = (await import('sharp')).default;
262+
// Progressive quality reduction until under budget
263+
var qualities = [75, 60, 45, 30];
264+
var lastCompressed: any = null;
265+
for (var q of qualities) {
266+
lastCompressed = await sharp(buf)
267+
.resize({ width: MAX_IMAGE_SIDE, height: MAX_IMAGE_SIDE, fit: 'inside', withoutEnlargement: true })
268+
.jpeg({ quality: q, mozjpeg: true })
269+
.toBuffer();
270+
if (lastCompressed.byteLength <= MAX_IMAGE_BYTES) {
271+
buf = lastCompressed;
272+
mimeType = 'image/jpeg';
273+
break;
274+
}
275+
}
276+
// If still over budget after lowest quality, use the smallest we got
277+
if (buf.byteLength > MAX_IMAGE_BYTES && lastCompressed && lastCompressed.byteLength < buf.byteLength) {
278+
buf = lastCompressed;
279+
mimeType = 'image/jpeg';
280+
}
281+
} catch {
282+
// Sharp not available — proceed with original (will be large but functional)
283+
}
284+
}
285+
252286
return imageResult({
253287
label: params.label,
254288
path: params.path,

src/agent-tools/tools/polymarket.ts

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -328,12 +328,31 @@ export async function executeOrder(agentId: string, db: any, tradeId: string, p:
328328
const { Side } = clobModule;
329329
const side = p.side === 'BUY' ? Side.BUY : Side.SELL;
330330

331+
// ── Price validation: Polymarket requires 0.01-0.99 ──
332+
let orderPrice = typeof p.price === 'number' && Number.isFinite(p.price) ? p.price : undefined;
333+
if (orderPrice !== undefined) {
334+
// Clamp to valid range
335+
orderPrice = Math.max(0.01, Math.min(0.99, orderPrice));
336+
// Round to tick size (default 0.01)
337+
const tick = parseFloat(p.tick_size || '0.01');
338+
orderPrice = Math.round(orderPrice / tick) * tick;
339+
orderPrice = +orderPrice.toFixed(4);
340+
} else {
341+
// No price specified — use midpoint for a market-like order
342+
try {
343+
const mid = await apiFetch(`${CLOB_API}/midpoint?token_id=${p.token_id}`);
344+
const midPrice = parseFloat(mid?.mid || '0.5');
345+
orderPrice = Math.max(0.01, Math.min(0.99, midPrice));
346+
orderPrice = +orderPrice.toFixed(4);
347+
} catch { orderPrice = undefined; }
348+
}
349+
331350
const orderArgs: any = {
332351
tokenID: p.token_id,
333352
side,
334353
size: p.size,
335354
};
336-
if (p.price) orderArgs.price = p.price;
355+
if (orderPrice !== undefined) orderArgs.price = orderPrice;
337356
if (p.tick_size) orderArgs.feeRateBps = undefined; // SDK handles fees
338357
if (p.neg_risk !== undefined) orderArgs.negRisk = p.neg_risk;
339358

@@ -475,7 +494,7 @@ export async function executeOrder(agentId: string, db: any, tradeId: string, p:
475494

476495
function slimMarket(m: any) {
477496
return {
478-
id: m.conditionId || m.id,
497+
id: m.slug || m.conditionId || m.id,
479498
question: m.question,
480499
slug: m.slug,
481500
category: m.tags?.[0],
@@ -650,11 +669,13 @@ export function createPolymarketTools(options: ToolCreationOptions): AnyAgentToo
650669
if (p.end_date_before) qs.set('end_date_max', p.end_date_before);
651670
if (p.end_date_after) qs.set('end_date_min', p.end_date_after);
652671

653-
// Default order to volume desc if not specified (prevents returning ancient dead markets)
654-
if (!p.order) { qs.set('order', 'volume'); qs.set('ascending', 'false'); }
672+
// Default order: use volume when browsing, but let API handle relevance when searching
673+
if (!p.order && !p.query) { qs.set('order', 'volume'); qs.set('ascending', 'false'); }
655674

656675
// Search both /markets and /events endpoints for maximum coverage
657-
const evQs: Record<string, string> = { active: String(p.active !== undefined ? p.active : true), closed: String(p.closed || false), limit: String(Math.min((p.limit || 20) * 3, 100)), order: p.order || 'volume', ascending: String(p.ascending ?? false) };
676+
const evQs: Record<string, string> = { active: String(p.active !== undefined ? p.active : true), closed: String(p.closed || false), limit: String(Math.min((p.limit || 20) * 3, 100)) };
677+
// When searching, omit order to let API rank by relevance; otherwise default to volume
678+
if (!p.query) { evQs.order = p.order || 'volume'; evQs.ascending = String(p.ascending ?? false); }
658679
if (p.query) evQs.search = p.query;
659680
if (p.category) evQs.tag_id = p.category;
660681

@@ -710,6 +731,30 @@ export function createPolymarketTools(options: ToolCreationOptions): AnyAgentToo
710731

711732
let markets = allRaw.map(slimMarket);
712733

734+
// Client-side relevance scoring when searching — the Gamma API returns
735+
// events that match the query, but sub-markets within those events may be
736+
// completely unrelated (e.g., searching "NBA" returns "Celebrity News" event
737+
// which has both sports AND gossip sub-markets). Score each market by how
738+
// many query words appear in its question and sort by relevance.
739+
if (p.query && markets.length > 0) {
740+
const qWords = p.query.toLowerCase().split(/\s+/).filter((w: string) => w.length > 2);
741+
if (qWords.length > 0) {
742+
const scored = markets.map((m: any) => {
743+
const q = (m.question || '').toLowerCase();
744+
const slug = (m.slug || '').toLowerCase();
745+
let hits = 0;
746+
for (const w of qWords) { if (q.includes(w) || slug.includes(w)) hits++; }
747+
return { market: m, relevance: hits / qWords.length };
748+
});
749+
// Keep only markets with at least 1 query word match, or all if none match
750+
const relevant = scored.filter((s: any) => s.relevance > 0);
751+
if (relevant.length > 0) {
752+
relevant.sort((a: any, b: any) => b.relevance - a.relevance);
753+
markets = relevant.map((s: any) => s.market);
754+
}
755+
}
756+
}
757+
713758
// Post-filter by volume/liquidity
714759
if (p.min_volume) markets = markets.filter((m: any) => parseFloat(m.volume || '0') >= p.min_volume);
715760
if (p.min_liquidity) markets = markets.filter((m: any) => parseFloat(m.liquidity || '0') >= p.min_liquidity);
@@ -772,15 +817,29 @@ export function createPolymarketTools(options: ToolCreationOptions): AnyAgentToo
772817
if (c) return jsonResult(c);
773818

774819
let m;
775-
// Try numeric/direct ID first
776-
try { m = await apiFetch(`${GAMMA_API}/markets/${p.market_id}`); } catch {}
777-
// If condition ID (0x...), search by condition_id param
778-
if (!m && p.market_id.startsWith('0x')) {
779-
const arr = await apiFetch(`${GAMMA_API}/markets?condition_id=${p.market_id}&limit=1`);
780-
m = Array.isArray(arr) && arr[0];
820+
// Try slug first (most reliable — used as primary ID in search results)
821+
if (!p.market_id.startsWith('0x') && !/^\d+$/.test(p.market_id)) {
822+
try {
823+
const arr = await apiFetch(`${GAMMA_API}/markets?slug=${encodeURIComponent(p.market_id)}&limit=1`);
824+
m = Array.isArray(arr) && arr[0];
825+
} catch {}
781826
}
782-
// Try slug
827+
// Try numeric/direct ID
783828
if (!m) {
829+
try { m = await apiFetch(`${GAMMA_API}/markets/${p.market_id}`); } catch {}
830+
}
831+
// If condition ID (0x...), search by condition_id param (try both naming conventions)
832+
if (!m && p.market_id.startsWith('0x')) {
833+
try {
834+
let arr = await apiFetch(`${GAMMA_API}/markets?condition_id=${p.market_id}&limit=1`).catch(() => []);
835+
if (!Array.isArray(arr) || !arr[0]) {
836+
arr = await apiFetch(`${GAMMA_API}/markets?conditionId=${p.market_id}&limit=1`).catch(() => []);
837+
}
838+
m = Array.isArray(arr) && arr[0];
839+
} catch {}
840+
}
841+
// Final fallback: slug lookup (in case it looks numeric but is actually a slug)
842+
if (!m && (p.market_id.startsWith('0x') || /^\d+$/.test(p.market_id))) {
784843
try {
785844
const arr = await apiFetch(`${GAMMA_API}/markets?slug=${encodeURIComponent(p.market_id)}&limit=1`);
786845
m = Array.isArray(arr) && arr[0];
@@ -1708,11 +1767,12 @@ export function createPolymarketTools(options: ToolCreationOptions): AnyAgentToo
17081767

17091768
{
17101769
name: 'poly_place_order',
1711-
description: 'Place an order',
1770+
description: 'Place an order. IMPORTANT: price must be between 0.01 and 0.99 (Polymarket range). Size minimum is 5 shares. If you omit price, the current midpoint is used automatically.',
17121771
category: 'enterprise' as const,
17131772
parameters: { type: 'object' as const, properties: {
1714-
token_id: { type: 'string' }, side: { type: 'string' },
1715-
price: { type: 'number' }, size: { type: 'number' },
1773+
token_id: { type: 'string' }, side: { type: 'string', description: 'BUY or SELL' },
1774+
price: { type: 'number', description: 'Limit price between 0.01 and 0.99. Omit for market order at midpoint.' },
1775+
size: { type: 'number', description: 'Number of shares (minimum 5)' },
17161776
order_type: { type: 'string' }, expiration: { type: 'string' },
17171777
max_slippage_pct: { type: 'number' }, tick_size: { type: 'string' },
17181778
neg_risk: { type: 'boolean' }, market_question: { type: 'string' },

src/agent-tools/tools/web-fetch.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,16 @@ function formatWebFetchErrorDetail(params: {
8484
}): string {
8585
var { detail, contentType, maxChars } = params;
8686
if (!detail) return '';
87-
var text = detail;
8887
var contentTypeLower = contentType?.toLowerCase();
8988
if (contentTypeLower?.includes('text/html') || looksLikeHtml(detail)) {
89+
// Extract just the title from HTML error pages — the CSS/HTML body is useless noise
9090
var rendered = htmlToMarkdown(detail);
91-
var withTitle = rendered.title ? rendered.title + '\n' + rendered.text : rendered.text;
92-
text = markdownToText(withTitle);
91+
if (rendered.title) return rendered.title.slice(0, maxChars);
92+
// No title — try first line of extracted text
93+
var firstLine = markdownToText(rendered.text).trim().split('\n')[0] || '';
94+
return firstLine.slice(0, maxChars);
9395
}
94-
var truncated = truncateText(text.trim(), maxChars);
96+
var truncated = truncateText(detail.trim(), maxChars);
9597
return truncated.text;
9698
}
9799

0 commit comments

Comments
 (0)