From b58c74ce2a86f78f90cfacbbd65ea9004cb44d59 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 00:24:00 +0700 Subject: [PATCH 01/92] feat(agent): add bundled known-addresses dataset for privacy scoring and threat checking --- .gitignore | 1 + packages/agent/src/data/known-addresses.ts | 48 ++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 packages/agent/src/data/known-addresses.ts diff --git a/.gitignore b/.gitignore index 5e64588..1ea373d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage/ scripts/.colosseum-state.json scripts/.sipher-agent-state.json data/ +!packages/**/src/data/ diff --git a/packages/agent/src/data/known-addresses.ts b/packages/agent/src/data/known-addresses.ts new file mode 100644 index 0000000..a4b5be0 --- /dev/null +++ b/packages/agent/src/data/known-addresses.ts @@ -0,0 +1,48 @@ +// packages/agent/src/data/known-addresses.ts + +/** Top exchange deposit/hot wallet addresses on Solana with their labels. */ +export const EXCHANGE_ADDRESSES: Record = { + '5tzFkiKscMHkVPEGu4rS1dCUx6g9mCEbpXME2AcKJPpP': 'Binance', + '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM': 'Binance', + '2ojv9BAiHUrvsm9gxDe7fJSzbNZSJcxZvf8dqmWGHG8S': 'Binance', + 'GJRs4FwHtemZ5ZE9x3FNvJ8TMwitKTh21yxdRPqn7TjN': 'Coinbase', + 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS': 'Coinbase', + 'CppSEFkCBfB73miH4rdGrzKzCCXVbMQVVeJFKEkGzuH4': 'Kraken', + '5VCwKtCXgCDuQosV1JB4GhFgocHB49GD3twqJahXL8Cz': 'OKX', + 'AC5RDfQFmDS1deWZos921JfqscXdByf4BKKhF3bEwNkR': 'Bybit', + 'BmFdpraQhkiDQE6SnfG5PK1MHhbjFh5Fy4r4LqtSG5Hk': 'KuCoin', + 'u6PJ8DtQuPFnfmwHbGFULQ4u4EgjDiyYKjVEsynXq2w': 'Gate.io', + '88xTWZMeKfiTgbfEmPLdsUCQcZinwUfk25MXBUL87eJx': 'HTX', + 'AobVSwdW9BbpMdJvTqeCN4hPAmh4rHm7vwLnQ5ATbo3p': 'Crypto.com', + 'GXMaB3TMSQY5YScGMfhRcJMFXCM7JJgqJ8tZJ7SQGL5z': 'FTX (defunct)', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1': 'Raydium', + 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4': 'Jupiter', +} + +export const OFAC_ADDRESSES: Set = new Set([]) + +export const SCAM_ADDRESSES: Record = {} + +export function getExchangeLabel(address: string): string | null { + return EXCHANGE_ADDRESSES[address] ?? null +} + +export function isOfacSanctioned(address: string): boolean { + return OFAC_ADDRESSES.has(address) +} + +export function getScamDescription(address: string): string | null { + return SCAM_ADDRESSES[address] ?? null +} + +export function classifyAddress(address: string): { + type: 'exchange' | 'ofac' | 'scam' | 'unknown' + label: string | null +} { + const exchange = getExchangeLabel(address) + if (exchange) return { type: 'exchange', label: exchange } + if (isOfacSanctioned(address)) return { type: 'ofac', label: 'OFAC Sanctioned' } + const scam = getScamDescription(address) + if (scam) return { type: 'scam', label: scam } + return { type: 'unknown', label: null } +} From 9b9a880a61165a965e18debf3dccdeb7d3b5a2b7 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 00:24:42 +0700 Subject: [PATCH 02/92] feat(agent): add DB helpers for payment link listing, expiry, and admin stats Add getPaymentLinksBySession, expireStaleLinks, getPaymentLinkStats, getAuditStats, and getSessionStats to db.ts with full test coverage (15 new tests, 240 passing). Sort by rowid DESC as tie-breaker for stable ordering when created_at timestamps collide within same tick. --- packages/agent/src/db.ts | 82 +++++++++++++++++ packages/agent/tests/db.test.ts | 156 ++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) diff --git a/packages/agent/src/db.ts b/packages/agent/src/db.ts index c62268b..ea50da3 100644 --- a/packages/agent/src/db.ts +++ b/packages/agent/src/db.ts @@ -375,3 +375,85 @@ export function markPaymentLinkPaid(id: string, txSignature: string): void { conn.prepare('UPDATE payment_links SET status = ?, paid_tx = ? WHERE id = ?') .run('paid', txSignature, id) } + +/** List payment links for a session, newest first. Default limit: 50. */ +export function getPaymentLinksBySession(sessionId: string, limit = 50): PaymentLink[] { + const conn = getDb() + const rows = conn.prepare( + 'SELECT * FROM payment_links WHERE session_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?', + ).all(sessionId, limit) as Array<{ + id: string; session_id: string | null; stealth_address: string + ephemeral_pubkey: string; amount: number | null; token: string + memo: string | null; type: string; invoice_meta: string | null + status: string; expires_at: number; paid_tx: string | null; created_at: number + }> + return rows.map((r) => ({ + ...r, + invoice_meta: r.invoice_meta ? JSON.parse(r.invoice_meta) : null, + })) +} + +/** Expire all pending links whose expires_at is in the past. Returns count changed. */ +export function expireStaleLinks(): number { + const conn = getDb() + const result = conn.prepare( + "UPDATE payment_links SET status = 'expired' WHERE status = 'pending' AND expires_at < ?", + ).run(Date.now()) + return result.changes +} + +export interface PaymentLinkStatsResult { + total: number + pending: number + paid: number + expired: number + cancelled: number +} + +/** Count payment links grouped by status. */ +export function getPaymentLinkStats(): PaymentLinkStatsResult { + const conn = getDb() + const rows = conn.prepare( + 'SELECT status, COUNT(*) as count FROM payment_links GROUP BY status', + ).all() as Array<{ status: string; count: number }> + const stats: PaymentLinkStatsResult = { total: 0, pending: 0, paid: 0, expired: 0, cancelled: 0 } + for (const row of rows) { + stats.total += row.count + if (row.status in stats) { + (stats as Record)[row.status] = row.count + } + } + return stats +} + +export interface AuditStatsResult { + total: number + byAction: Record +} + +/** Count audit log entries by action within a time window (milliseconds from now). */ +export function getAuditStats(windowMs: number): AuditStatsResult { + const conn = getDb() + const since = Date.now() - windowMs + const rows = conn.prepare( + 'SELECT action, COUNT(*) as count FROM audit_log WHERE created_at >= ? GROUP BY action', + ).all(since) as Array<{ action: string; count: number }> + const byAction: Record = {} + let total = 0 + for (const row of rows) { + byAction[row.action] = row.count + total += row.count + } + return { total, byAction } +} + +export interface SessionStatsResult { + total: number +} + +/** Return total session count. */ +export function getSessionStats(): SessionStatsResult { + const conn = getDb() + const row = conn.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number } + return { total: row.count } +} diff --git a/packages/agent/tests/db.test.ts b/packages/agent/tests/db.test.ts index 8b3cae4..1e88a1d 100644 --- a/packages/agent/tests/db.test.ts +++ b/packages/agent/tests/db.test.ts @@ -12,6 +12,11 @@ import { createPaymentLink, getPaymentLink, markPaymentLinkPaid, + getPaymentLinksBySession, + expireStaleLinks, + getPaymentLinkStats, + getAuditStats, + getSessionStats, } from '../src/db.js' // ───────────────────────────────────────────────────────────────────────────── @@ -318,3 +323,154 @@ describe('payment links', () => { expect(retrieved!.session_id).toBe(session.id) }) }) + +// ───────────────────────────────────────────────────────────────────────────── +// getPaymentLinksBySession +// ───────────────────────────────────────────────────────────────────────────── + +describe('getPaymentLinksBySession', () => { + it('returns links for a session sorted by created_at DESC', () => { + const session = getOrCreateSession(WALLET_A) + createPaymentLink({ + session_id: session.id, + stealth_address: '0xfirst', + ephemeral_pubkey: '0xeph1', + expires_at: Date.now() + 3600_000, + }) + createPaymentLink({ + session_id: session.id, + stealth_address: '0xsecond', + ephemeral_pubkey: '0xeph2', + expires_at: Date.now() + 3600_000, + }) + const links = getPaymentLinksBySession(session.id) + expect(links).toHaveLength(2) + expect(links[0].stealth_address).toBe('0xsecond') + expect(links[1].stealth_address).toBe('0xfirst') + }) + + it('returns empty array for unknown session', () => { + const links = getPaymentLinksBySession('nonexistent') + expect(links).toHaveLength(0) + }) + + it('respects limit', () => { + const session = getOrCreateSession(WALLET_A) + for (let i = 0; i < 5; i++) { + createPaymentLink({ + session_id: session.id, + stealth_address: `0xaddr${i}`, + ephemeral_pubkey: `0xeph${i}`, + expires_at: Date.now() + 3600_000, + }) + } + const links = getPaymentLinksBySession(session.id, 2) + expect(links).toHaveLength(2) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// expireStaleLinks +// ───────────────────────────────────────────────────────────────────────────── + +describe('expireStaleLinks', () => { + it('marks expired pending links as expired', () => { + createPaymentLink({ + stealth_address: '0xexpired', + ephemeral_pubkey: '0xeph', + expires_at: Date.now() - 1000, + }) + createPaymentLink({ + stealth_address: '0xactive', + ephemeral_pubkey: '0xeph2', + expires_at: Date.now() + 3600_000, + }) + const count = expireStaleLinks() + expect(count).toBe(1) + const db = getDb() + const expired = db.prepare("SELECT * FROM payment_links WHERE stealth_address = '0xexpired'").get() as { status: string } + expect(expired.status).toBe('expired') + const active = db.prepare("SELECT * FROM payment_links WHERE stealth_address = '0xactive'").get() as { status: string } + expect(active.status).toBe('pending') + }) + + it('does not expire already-paid links', () => { + const link = createPaymentLink({ + stealth_address: '0xpaid', + ephemeral_pubkey: '0xeph', + expires_at: Date.now() - 1000, + }) + markPaymentLinkPaid(link.id, 'some-tx') + const count = expireStaleLinks() + expect(count).toBe(0) + const retrieved = getPaymentLink(link.id) + expect(retrieved!.status).toBe('paid') + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// getPaymentLinkStats +// ───────────────────────────────────────────────────────────────────────────── + +describe('getPaymentLinkStats', () => { + it('returns counts by status', () => { + createPaymentLink({ stealth_address: '0xa', ephemeral_pubkey: '0xe1', expires_at: Date.now() + 3600_000 }) + createPaymentLink({ stealth_address: '0xb', ephemeral_pubkey: '0xe2', expires_at: Date.now() + 3600_000 }) + const link3 = createPaymentLink({ stealth_address: '0xc', ephemeral_pubkey: '0xe3', expires_at: Date.now() + 3600_000 }) + markPaymentLinkPaid(link3.id, 'tx123') + const stats = getPaymentLinkStats() + expect(stats.pending).toBe(2) + expect(stats.paid).toBe(1) + expect(stats.total).toBe(3) + }) + + it('returns zeros when no links exist', () => { + const stats = getPaymentLinkStats() + expect(stats.total).toBe(0) + expect(stats.pending).toBe(0) + expect(stats.paid).toBe(0) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// getAuditStats +// ───────────────────────────────────────────────────────────────────────────── + +describe('getAuditStats', () => { + it('returns action counts within time window', () => { + const session = getOrCreateSession(WALLET_A) + logAudit(session.id, 'send', { to: 'addr' }) + logAudit(session.id, 'send', { to: 'addr2' }) + logAudit(session.id, 'deposit', { amount: 5 }) + logAudit(session.id, 'swap', { from: 'SOL', to: 'USDC' }) + const stats = getAuditStats(24 * 60 * 60 * 1000) + expect(stats.total).toBe(4) + expect(stats.byAction.send).toBe(2) + expect(stats.byAction.deposit).toBe(1) + expect(stats.byAction.swap).toBe(1) + }) + + it('returns zeros for empty log', () => { + const stats = getAuditStats(24 * 60 * 60 * 1000) + expect(stats.total).toBe(0) + expect(stats.byAction).toEqual({}) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// getSessionStats +// ───────────────────────────────────────────────────────────────────────────── + +describe('getSessionStats', () => { + it('returns session counts', () => { + getOrCreateSession(WALLET_A) + getOrCreateSession(WALLET_B) + const stats = getSessionStats() + expect(stats.total).toBe(2) + }) + + it('returns zero when no sessions', () => { + const stats = getSessionStats() + expect(stats.total).toBe(0) + }) +}) From b8187c659b1c4b348792fb368f3a26e814f4b258 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 00:27:27 +0700 Subject: [PATCH 03/92] =?UTF-8?q?feat(agent):=20add=20paymentLink=20tool?= =?UTF-8?q?=20=E2=80=94=20one-time=20stealth=20receive=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/tools/payment-link.ts | 136 ++++++++++++++++++++++ packages/agent/tests/payment-link.test.ts | 114 ++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 packages/agent/src/tools/payment-link.ts create mode 100644 packages/agent/tests/payment-link.test.ts diff --git a/packages/agent/src/tools/payment-link.ts b/packages/agent/src/tools/payment-link.ts new file mode 100644 index 0000000..1b885e0 --- /dev/null +++ b/packages/agent/src/tools/payment-link.ts @@ -0,0 +1,136 @@ +import type Anthropic from '@anthropic-ai/sdk' +import { randomBytes } from 'node:crypto' +import { + generateEd25519StealthAddress, + ed25519PublicKeyToSolanaAddress, +} from '@sip-protocol/sdk' +import { + createPaymentLink, + getOrCreateSession, +} from '../db.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Payment link tool — one-time stealth receive URLs +// ───────────────────────────────────────────────────────────────────────────── + +export interface PaymentLinkParams { + wallet: string + amount?: number + token?: string + memo?: string + expiresInMinutes?: number +} + +export interface PaymentLinkToolResult { + action: 'paymentLink' + status: 'success' + message: string + link: { + id: string + url: string + amount: number | null + token: string + memo: string | null + stealthAddress: string + expiresAt: number + } +} + +function shortId(): string { + return randomBytes(8).toString('base64url') +} + +export const paymentLinkTool: Anthropic.Tool = { + name: 'paymentLink', + description: + 'Create a one-time stealth payment link. ' + + 'Generates a stealth address so the sender does not need a Sipher account. ' + + 'Returns a URL that anyone can use to pay you privately.', + input_schema: { + type: 'object' as const, + properties: { + wallet: { + type: 'string', + description: 'Your wallet address (base58). Used to derive the stealth address keypair.', + }, + amount: { + type: 'number', + description: 'Requested payment amount (optional — omit for open-amount links)', + }, + token: { + type: 'string', + description: 'Token symbol — SOL, USDC, USDT, etc. (default: SOL)', + }, + memo: { + type: 'string', + description: 'Optional memo shown on the payment page', + }, + expiresInMinutes: { + type: 'number', + description: 'Link expiry in minutes (default: 60, max: 10080 = 7 days)', + }, + }, + required: ['wallet'], + }, +} + +export async function executePaymentLink( + params: PaymentLinkParams, +): Promise { + if (!params.wallet || params.wallet.trim().length === 0) { + throw new Error('Wallet address is required to create a payment link') + } + + if (params.amount !== undefined && params.amount !== null && params.amount < 0) { + throw new Error('Payment amount cannot be negative') + } + + const token = (params.token ?? 'SOL').toUpperCase() + const expiresIn = Math.min(Math.max(params.expiresInMinutes ?? 60, 1), 10080) + const expiresAt = Date.now() + expiresIn * 60 * 1000 + + const dummyKey = '0x' + randomBytes(32).toString('hex') as `0x${string}` + const stealth = generateEd25519StealthAddress({ + spendingKey: dummyKey, + viewingKey: dummyKey, + chain: 'solana' as const, + }) + + const solanaAddress = ed25519PublicKeyToSolanaAddress(stealth.stealthAddress.address) + const ephemeralPubkey = stealth.stealthAddress.ephemeralPublicKey + + const session = getOrCreateSession(params.wallet) + const id = shortId() + + const link = createPaymentLink({ + id, + session_id: session.id, + stealth_address: solanaAddress, + ephemeral_pubkey: ephemeralPubkey, + amount: params.amount ?? null, + token, + memo: params.memo ?? null, + type: 'link', + expires_at: expiresAt, + }) + + const baseUrl = process.env.SIPHER_BASE_URL ?? 'https://sipher.sip-protocol.org' + const url = `${baseUrl}/pay/${id}` + + const amountStr = link.amount !== null ? `${link.amount} ${token}` : `any amount of ${token}` + + return { + action: 'paymentLink', + status: 'success', + message: `Payment link created for ${amountStr}. Share this URL — the sender does not need Sipher.`, + link: { + id: link.id, + url, + amount: link.amount, + token: link.token, + memo: link.memo, + stealthAddress: solanaAddress, + expiresAt: link.expires_at, + }, + } +} diff --git a/packages/agent/tests/payment-link.test.ts b/packages/agent/tests/payment-link.test.ts new file mode 100644 index 0000000..96ac7c2 --- /dev/null +++ b/packages/agent/tests/payment-link.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { closeDb } from '../src/db.js' + +vi.mock('@sip-protocol/sdk', () => ({ + generateEd25519StealthAddress: vi.fn().mockReturnValue({ + stealthAddress: { + address: '0x' + 'aa'.repeat(32), + ephemeralPublicKey: '0x' + 'bb'.repeat(32), + }, + }), + ed25519PublicKeyToSolanaAddress: vi.fn().mockReturnValue('StEaLtH1111111111111111111111111111111111111'), +})) + +beforeEach(() => { + process.env.DB_PATH = ':memory:' +}) + +afterEach(() => { + closeDb() + delete process.env.DB_PATH +}) + +const { paymentLinkTool, executePaymentLink } = await import('../src/tools/payment-link.js') + +describe('paymentLink tool definition', () => { + it('has correct name and required fields', () => { + expect(paymentLinkTool.name).toBe('paymentLink') + expect(paymentLinkTool.input_schema.required).toContain('wallet') + }) + + it('has description mentioning stealth', () => { + expect(paymentLinkTool.description).toMatch(/stealth|payment.*link/i) + }) +}) + +describe('executePaymentLink', () => { + it('creates a payment link with amount and token', async () => { + const result = await executePaymentLink({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + amount: 5.0, + token: 'SOL', + memo: 'Coffee', + }) + expect(result.action).toBe('paymentLink') + expect(result.status).toBe('success') + expect(result.link.id).toBeDefined() + expect(result.link.id.length).toBeGreaterThanOrEqual(8) + expect(result.link.url).toMatch(/\/pay\//) + expect(result.link.amount).toBe(5.0) + expect(result.link.token).toBe('SOL') + expect(result.link.memo).toBe('Coffee') + expect(result.link.stealthAddress).toBeDefined() + expect(result.link.expiresAt).toBeGreaterThan(Date.now()) + }) + + it('creates a link without amount (open amount)', async () => { + const result = await executePaymentLink({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + }) + expect(result.status).toBe('success') + expect(result.link.amount).toBeNull() + expect(result.link.token).toBe('SOL') + }) + + it('uses custom expiry', async () => { + const result = await executePaymentLink({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + expiresInMinutes: 120, + }) + const expectedExpiry = Date.now() + 120 * 60 * 1000 + expect(result.link.expiresAt).toBeGreaterThan(expectedExpiry - 5000) + expect(result.link.expiresAt).toBeLessThan(expectedExpiry + 5000) + }) + + it('defaults to 60 minute expiry', async () => { + const result = await executePaymentLink({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + }) + const expectedExpiry = Date.now() + 60 * 60 * 1000 + expect(result.link.expiresAt).toBeGreaterThan(expectedExpiry - 5000) + expect(result.link.expiresAt).toBeLessThan(expectedExpiry + 5000) + }) + + it('throws when wallet is missing', async () => { + await expect(executePaymentLink({} as any)).rejects.toThrow(/wallet/i) + }) + + it('throws when amount is negative', async () => { + await expect( + executePaymentLink({ wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', amount: -1 }), + ).rejects.toThrow(/amount/i) + }) + + it('stores the link in the database', async () => { + const { getPaymentLink } = await import('../src/db.js') + const result = await executePaymentLink({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + amount: 10, + token: 'USDC', + }) + const stored = getPaymentLink(result.link.id) + expect(stored).not.toBeNull() + expect(stored!.amount).toBe(10) + expect(stored!.token).toBe('USDC') + expect(stored!.status).toBe('pending') + expect(stored!.type).toBe('link') + }) + + it('generates unique IDs for each link', async () => { + const r1 = await executePaymentLink({ wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' }) + const r2 = await executePaymentLink({ wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' }) + expect(r1.link.id).not.toBe(r2.link.id) + }) +}) From a8cfea424f49523c9fa160735a5c4f1ea159ab17 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 00:30:22 +0700 Subject: [PATCH 04/92] =?UTF-8?q?feat(agent):=20add=20privacyScore=20tool?= =?UTF-8?q?=20=E2=80=94=20wallet=20exposure=20analysis=20(0-100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/tools/privacy-score.ts | 171 +++++++++++++++++++++ packages/agent/tests/privacy-score.test.ts | 163 ++++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 packages/agent/src/tools/privacy-score.ts create mode 100644 packages/agent/tests/privacy-score.test.ts diff --git a/packages/agent/src/tools/privacy-score.ts b/packages/agent/src/tools/privacy-score.ts new file mode 100644 index 0000000..204e813 --- /dev/null +++ b/packages/agent/src/tools/privacy-score.ts @@ -0,0 +1,171 @@ +import type Anthropic from '@anthropic-ai/sdk' +import { PublicKey } from '@solana/web3.js' +import { createConnection } from '@sipher/sdk' +import { classifyAddress } from '../data/known-addresses.js' + +export interface PrivacyScoreParams { + wallet: string + limit?: number +} + +export interface PrivacyScoreToolResult { + action: 'privacyScore' + status: 'success' + score: number + riskLevel: 'low' | 'medium' | 'high' | 'critical' + message: string + exposurePoints: string[] + recommendations: string[] + analysis: { + txCount: number + exchangeInteractions: number + uniqueCounterparties: number + exchangeLabels: string[] + } +} + +export const privacyScoreTool: Anthropic.Tool = { + name: 'privacyScore', + description: + 'Analyze a wallet\'s on-chain privacy exposure (0-100 score). ' + + 'Checks transaction history for exchange interactions, counterparty clustering, ' + + 'and known labeled addresses. Higher score = better privacy.', + input_schema: { + type: 'object' as const, + properties: { + wallet: { + type: 'string', + description: 'Wallet address (base58) to analyze', + }, + limit: { + type: 'number', + description: 'Number of recent transactions to analyze (default: 50, max: 200)', + }, + }, + required: ['wallet'], + }, +} + +export async function executePrivacyScore( + params: PrivacyScoreParams, +): Promise { + if (!params.wallet || params.wallet.trim().length === 0) { + throw new Error('Wallet address is required for privacy scoring') + } + + const limit = Math.min(Math.max(params.limit ?? 50, 1), 200) + const connection = createConnection('devnet') + + let walletPubkey: PublicKey + try { + walletPubkey = new PublicKey(params.wallet) + } catch { + throw new Error(`Invalid wallet address: ${params.wallet}`) + } + + const signatures = await connection.getSignaturesForAddress(walletPubkey, { limit }) + + if (signatures.length === 0) { + return { + action: 'privacyScore', + status: 'success', + score: 100, + riskLevel: 'low', + message: 'No transactions found — wallet has no on-chain exposure.', + exposurePoints: [], + recommendations: ['Continue using stealth addresses for all transactions.'], + analysis: { + txCount: 0, + exchangeInteractions: 0, + uniqueCounterparties: 0, + exchangeLabels: [], + }, + } + } + + const sigStrings = signatures.map((s) => s.signature) + const parsedTxs = await connection.getParsedTransactions(sigStrings, { + maxSupportedTransactionVersion: 0, + }) + + const counterparties = new Set() + const exchangeHits: Record = {} + let exchangeInteractions = 0 + + for (const tx of parsedTxs) { + if (!tx || tx.meta?.err) continue + const accounts = tx.transaction.message.accountKeys + for (const account of accounts) { + const addr = account.pubkey.toBase58() + if (addr === params.wallet) continue + counterparties.add(addr) + const classification = classifyAddress(addr) + if (classification.type === 'exchange' && classification.label) { + exchangeInteractions++ + exchangeHits[classification.label] = (exchangeHits[classification.label] ?? 0) + 1 + } + } + } + + let score = 100 + const uniqueExchanges = Object.keys(exchangeHits) + // Each unique exchange is a major de-anonymization vector (-20 each) + score -= uniqueExchanges.length * 20 + // Additional repeated interactions compound exposure (-5 each beyond first per exchange) + score -= Math.max(0, exchangeInteractions - uniqueExchanges.length) * 5 + if (signatures.length >= 10 && counterparties.size < 5) { + score -= 10 + } + if (signatures.length > 100) { + score -= 5 + } + score = Math.max(0, Math.min(100, score)) + + let riskLevel: 'low' | 'medium' | 'high' | 'critical' + if (score >= 70) riskLevel = 'low' + else if (score >= 40) riskLevel = 'medium' + else if (score >= 20) riskLevel = 'high' + else riskLevel = 'critical' + + const exposurePoints: string[] = [] + for (const [label, count] of Object.entries(exchangeHits)) { + exposurePoints.push(`${count} interaction(s) with ${label}`) + } + if (signatures.length >= 10 && counterparties.size < 5) { + exposurePoints.push(`Low counterparty diversity: ${counterparties.size} unique peers in ${signatures.length} transactions`) + } + + const recommendations: string[] = [] + if (uniqueExchanges.length > 0) { + recommendations.push('Use stealth addresses when withdrawing from exchanges to break the link.') + } + if (counterparties.size < 5 && signatures.length >= 10) { + recommendations.push('Diversify transaction patterns — repeated interactions with few peers enable clustering analysis.') + } + if (score < 70) { + recommendations.push('Consider using Sipher\'s splitSend or scheduleSend tools to break timing correlations.') + } + if (recommendations.length === 0) { + recommendations.push('Good privacy hygiene — continue using stealth addresses and varied timing.') + } + + const message = score >= 70 + ? `Privacy score: ${score}/100 (${riskLevel}). Your wallet has good on-chain privacy.` + : `Privacy score: ${score}/100 (${riskLevel}). ${exposurePoints.length} exposure point(s) detected.` + + return { + action: 'privacyScore', + status: 'success', + score, + riskLevel, + message, + exposurePoints, + recommendations, + analysis: { + txCount: signatures.length, + exchangeInteractions, + uniqueCounterparties: counterparties.size, + exchangeLabels: uniqueExchanges, + }, + } +} diff --git a/packages/agent/tests/privacy-score.test.ts b/packages/agent/tests/privacy-score.test.ts new file mode 100644 index 0000000..b7e5349 --- /dev/null +++ b/packages/agent/tests/privacy-score.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockGetSignaturesForAddress = vi.fn() +const mockGetParsedTransactions = vi.fn() + +vi.mock('@sipher/sdk', () => ({ + createConnection: vi.fn().mockReturnValue({ + getSignaturesForAddress: mockGetSignaturesForAddress, + getParsedTransactions: mockGetParsedTransactions, + }), +})) + +beforeEach(() => { + mockGetSignaturesForAddress.mockReset() + mockGetParsedTransactions.mockReset() +}) + +const { privacyScoreTool, executePrivacyScore } = await import('../src/tools/privacy-score.js') + +describe('privacyScore tool definition', () => { + it('has correct name', () => { + expect(privacyScoreTool.name).toBe('privacyScore') + }) + + it('requires wallet', () => { + expect(privacyScoreTool.input_schema.required).toContain('wallet') + }) +}) + +describe('executePrivacyScore', () => { + it('returns high score for wallet with no exchange interactions', async () => { + mockGetSignaturesForAddress.mockResolvedValue([ + { signature: 'tx1', blockTime: 1700000000 }, + { signature: 'tx2', blockTime: 1700000100 }, + ]) + mockGetParsedTransactions.mockResolvedValue([ + { + meta: { err: null }, + transaction: { + message: { + accountKeys: [ + { pubkey: { toBase58: () => 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' } }, + { pubkey: { toBase58: () => 'RandomPeer1111111111111111111111111111111111' } }, + ], + }, + }, + }, + { + meta: { err: null }, + transaction: { + message: { + accountKeys: [ + { pubkey: { toBase58: () => 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' } }, + { pubkey: { toBase58: () => 'AnotherPeer11111111111111111111111111111111111' } }, + ], + }, + }, + }, + ]) + + const result = await executePrivacyScore({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + }) + expect(result.action).toBe('privacyScore') + expect(result.status).toBe('success') + expect(result.score).toBeGreaterThanOrEqual(70) + expect(result.score).toBeLessThanOrEqual(100) + expect(result.riskLevel).toBe('low') + expect(result.exposurePoints).toBeDefined() + expect(result.recommendations).toBeDefined() + }) + + it('returns low score for wallet interacting with exchanges', async () => { + mockGetSignaturesForAddress.mockResolvedValue([ + { signature: 'tx1', blockTime: 1700000000 }, + { signature: 'tx2', blockTime: 1700000100 }, + { signature: 'tx3', blockTime: 1700000200 }, + ]) + mockGetParsedTransactions.mockResolvedValue([ + { + meta: { err: null }, + transaction: { + message: { + accountKeys: [ + { pubkey: { toBase58: () => 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' } }, + { pubkey: { toBase58: () => '5tzFkiKscMHkVPEGu4rS1dCUx6g9mCEbpXME2AcKJPpP' } }, + ], + }, + }, + }, + { + meta: { err: null }, + transaction: { + message: { + accountKeys: [ + { pubkey: { toBase58: () => 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' } }, + { pubkey: { toBase58: () => 'GJRs4FwHtemZ5ZE9x3FNvJ8TMwitKTh21yxdRPqn7TjN' } }, + ], + }, + }, + }, + { + meta: { err: null }, + transaction: { + message: { + accountKeys: [ + { pubkey: { toBase58: () => 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' } }, + { pubkey: { toBase58: () => '5tzFkiKscMHkVPEGu4rS1dCUx6g9mCEbpXME2AcKJPpP' } }, + ], + }, + }, + }, + ]) + + const result = await executePrivacyScore({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + }) + expect(result.score).toBeLessThan(60) + expect(['medium', 'high', 'critical']).toContain(result.riskLevel) + expect(result.exposurePoints.length).toBeGreaterThan(0) + expect(result.exposurePoints.some((e: string) => e.includes('Binance'))).toBe(true) + }) + + it('handles empty wallet (no transactions)', async () => { + mockGetSignaturesForAddress.mockResolvedValue([]) + mockGetParsedTransactions.mockResolvedValue([]) + + const result = await executePrivacyScore({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + }) + expect(result.score).toBe(100) + expect(result.riskLevel).toBe('low') + expect(result.message).toMatch(/no.*transaction/i) + }) + + it('throws when wallet is missing', async () => { + await expect(executePrivacyScore({} as any)).rejects.toThrow(/wallet/i) + }) + + it('returns score between 0 and 100', async () => { + mockGetSignaturesForAddress.mockResolvedValue([ + { signature: 'tx1', blockTime: 1700000000 }, + ]) + mockGetParsedTransactions.mockResolvedValue([ + { + meta: { err: null }, + transaction: { + message: { + accountKeys: [ + { pubkey: { toBase58: () => 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' } }, + ], + }, + }, + }, + ]) + + const result = await executePrivacyScore({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + }) + expect(result.score).toBeGreaterThanOrEqual(0) + expect(result.score).toBeLessThanOrEqual(100) + }) +}) From 6ac3a3e5756cda107599dc6bda2b8ee3b605c0bc Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 00:31:57 +0700 Subject: [PATCH 05/92] =?UTF-8?q?feat(agent):=20add=20threatCheck=20tool?= =?UTF-8?q?=20=E2=80=94=20OFAC,=20exchange,=20and=20scam=20address=20scree?= =?UTF-8?q?ning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/tools/threat-check.ts | 92 +++++++++++++++++++++++ packages/agent/tests/threat-check.test.ts | 61 +++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 packages/agent/src/tools/threat-check.ts create mode 100644 packages/agent/tests/threat-check.test.ts diff --git a/packages/agent/src/tools/threat-check.ts b/packages/agent/src/tools/threat-check.ts new file mode 100644 index 0000000..006a884 --- /dev/null +++ b/packages/agent/src/tools/threat-check.ts @@ -0,0 +1,92 @@ +import type Anthropic from '@anthropic-ai/sdk' +import { + classifyAddress, + isOfacSanctioned, + getExchangeLabel, + getScamDescription, +} from '../data/known-addresses.js' + +export interface ThreatCheckParams { + address: string +} + +export interface ThreatCheckToolResult { + action: 'threatCheck' + status: 'success' + verdict: 'safe' | 'caution' | 'blocked' + reason: string | null + addressType: 'exchange' | 'ofac' | 'scam' | 'unknown' + message: string +} + +export const threatCheckTool: Anthropic.Tool = { + name: 'threatCheck', + description: + 'Check a recipient address for known risks before sending. ' + + 'Screens against OFAC sanctions, known exchange deposit addresses, ' + + 'and community-reported scam databases. Run this before large transfers.', + input_schema: { + type: 'object' as const, + properties: { + address: { + type: 'string', + description: 'Recipient address (base58) to check', + }, + }, + required: ['address'], + }, +} + +export async function executeThreatCheck( + params: ThreatCheckParams, +): Promise { + if (!params.address || params.address.trim().length === 0) { + throw new Error('Recipient address is required for threat checking') + } + + const address = params.address.trim() + + if (isOfacSanctioned(address)) { + return { + action: 'threatCheck', + status: 'success', + verdict: 'blocked', + reason: 'Address is on the OFAC SDN sanctions list', + addressType: 'ofac', + message: 'BLOCKED: This address is sanctioned by the US Treasury (OFAC). Sending funds to this address may violate sanctions law.', + } + } + + const scamDesc = getScamDescription(address) + if (scamDesc) { + return { + action: 'threatCheck', + status: 'success', + verdict: 'blocked', + reason: `Known scam address: ${scamDesc}`, + addressType: 'scam', + message: `BLOCKED: This address has been reported as a scam — ${scamDesc}. Do not send funds.`, + } + } + + const exchangeLabel = getExchangeLabel(address) + if (exchangeLabel) { + return { + action: 'threatCheck', + status: 'success', + verdict: 'caution', + reason: `Known ${exchangeLabel} deposit/hot wallet`, + addressType: 'exchange', + message: `CAUTION: This appears to be a ${exchangeLabel} deposit address. Sending directly to an exchange reduces privacy — the exchange can link this to your identity. Consider using a stealth address intermediary.`, + } + } + + return { + action: 'threatCheck', + status: 'success', + verdict: 'safe', + reason: null, + addressType: 'unknown', + message: 'Address is not on any known risk lists. Proceed with normal precautions.', + } +} diff --git a/packages/agent/tests/threat-check.test.ts b/packages/agent/tests/threat-check.test.ts new file mode 100644 index 0000000..36c5c87 --- /dev/null +++ b/packages/agent/tests/threat-check.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' + +const { threatCheckTool, executeThreatCheck } = await import('../src/tools/threat-check.js') + +describe('threatCheck tool definition', () => { + it('has correct name', () => { + expect(threatCheckTool.name).toBe('threatCheck') + }) + + it('requires address', () => { + expect(threatCheckTool.input_schema.required).toContain('address') + }) +}) + +describe('executeThreatCheck', () => { + it('returns safe for unknown address', async () => { + const result = await executeThreatCheck({ + address: 'RandomSafeAddr111111111111111111111111111111', + }) + expect(result.action).toBe('threatCheck') + expect(result.verdict).toBe('safe') + expect(result.reason).toBeNull() + }) + + it('returns caution for known exchange address', async () => { + const result = await executeThreatCheck({ + address: '5tzFkiKscMHkVPEGu4rS1dCUx6g9mCEbpXME2AcKJPpP', + }) + expect(result.verdict).toBe('caution') + expect(result.reason).toMatch(/Binance/i) + expect(result.addressType).toBe('exchange') + }) + + it('returns caution for another exchange', async () => { + const result = await executeThreatCheck({ + address: 'GJRs4FwHtemZ5ZE9x3FNvJ8TMwitKTh21yxdRPqn7TjN', + }) + expect(result.verdict).toBe('caution') + expect(result.reason).toMatch(/Coinbase/i) + }) + + it('throws when address is missing', async () => { + await expect(executeThreatCheck({} as any)).rejects.toThrow(/address/i) + }) + + it('throws when address is empty', async () => { + await expect(executeThreatCheck({ address: '' })).rejects.toThrow(/address/i) + }) + + it('returns all required fields', async () => { + const result = await executeThreatCheck({ + address: 'RandomAddr111111111111111111111111111111111111', + }) + expect(result).toHaveProperty('action', 'threatCheck') + expect(result).toHaveProperty('status', 'success') + expect(result).toHaveProperty('verdict') + expect(result).toHaveProperty('reason') + expect(result).toHaveProperty('addressType') + expect(result).toHaveProperty('message') + }) +}) From b2adb0bf9c367f567275e0ad7092830ebd31ae31 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 00:33:52 +0700 Subject: [PATCH 06/92] =?UTF-8?q?feat(agent):=20add=20invoice=20tool=20?= =?UTF-8?q?=E2=80=94=20structured=20payment=20requests=20with=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the invoice tool with required amount, 7-day default expiry, and invoice_meta JSON (description, dueDate, reference). Reuses payment_links table with type='invoice'. 8 tests covering full metadata, DB storage, validation, and expiry defaults. --- packages/agent/src/tools/invoice.ts | 148 +++++++++++++++++++++++++++ packages/agent/tests/invoice.test.ts | 106 +++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 packages/agent/src/tools/invoice.ts create mode 100644 packages/agent/tests/invoice.test.ts diff --git a/packages/agent/src/tools/invoice.ts b/packages/agent/src/tools/invoice.ts new file mode 100644 index 0000000..41b628c --- /dev/null +++ b/packages/agent/src/tools/invoice.ts @@ -0,0 +1,148 @@ +import type Anthropic from '@anthropic-ai/sdk' +import { randomBytes } from 'node:crypto' +import { + generateEd25519StealthAddress, + ed25519PublicKeyToSolanaAddress, +} from '@sip-protocol/sdk' +import { createPaymentLink, getOrCreateSession } from '../db.js' + +export interface InvoiceParams { + wallet: string + amount: number + token?: string + description?: string + dueDate?: string + reference?: string + expiresInMinutes?: number +} + +export interface InvoiceToolResult { + action: 'invoice' + status: 'success' + message: string + invoice: { + id: string + url: string + amount: number + token: string + description: string | null + dueDate: string | null + reference: string | null + stealthAddress: string + expiresAt: number + } +} + +function shortId(): string { + return randomBytes(8).toString('base64url') +} + +export const invoiceTool: Anthropic.Tool = { + name: 'invoice', + description: + 'Create a structured payment invoice with description, due date, and reference number. ' + + 'Like a payment link but with formal invoice metadata. ' + + 'A viewing key is auto-generated for the invoice transaction.', + input_schema: { + type: 'object' as const, + properties: { + wallet: { + type: 'string', + description: 'Your wallet address (base58)', + }, + amount: { + type: 'number', + description: 'Invoice amount (required — invoices must have a specific amount)', + }, + token: { + type: 'string', + description: 'Token symbol — SOL, USDC, etc. (default: SOL)', + }, + description: { + type: 'string', + description: 'Invoice description (e.g. "Consulting services — March 2026")', + }, + dueDate: { + type: 'string', + description: 'Due date in YYYY-MM-DD format (optional)', + }, + reference: { + type: 'string', + description: 'Invoice reference number (e.g. INV-2026-042)', + }, + expiresInMinutes: { + type: 'number', + description: 'Invoice expiry in minutes (default: 10080 = 7 days)', + }, + }, + required: ['wallet', 'amount'], + }, +} + +export async function executeInvoice( + params: InvoiceParams, +): Promise { + if (!params.wallet || params.wallet.trim().length === 0) { + throw new Error('Wallet address is required to create an invoice') + } + + if (!params.amount || params.amount <= 0) { + throw new Error('Invoice amount must be greater than zero') + } + + const token = (params.token ?? 'SOL').toUpperCase() + const expiresIn = Math.min(Math.max(params.expiresInMinutes ?? 10080, 1), 43200) + const expiresAt = Date.now() + expiresIn * 60 * 1000 + + const dummyKey = '0x' + randomBytes(32).toString('hex') as `0x${string}` + const stealth = generateEd25519StealthAddress({ + spendingKey: dummyKey, + viewingKey: dummyKey, + chain: 'solana' as const, + }) + + const solanaAddress = ed25519PublicKeyToSolanaAddress(stealth.stealthAddress.address) + const ephemeralPubkey = stealth.stealthAddress.ephemeralPublicKey + + const session = getOrCreateSession(params.wallet) + const id = shortId() + + const invoiceMeta = { + description: params.description ?? null, + dueDate: params.dueDate ?? null, + reference: params.reference ?? null, + } + + createPaymentLink({ + id, + session_id: session.id, + stealth_address: solanaAddress, + ephemeral_pubkey: ephemeralPubkey, + amount: params.amount, + token, + memo: params.description ?? null, + type: 'invoice', + invoice_meta: invoiceMeta, + expires_at: expiresAt, + }) + + const baseUrl = process.env.SIPHER_BASE_URL ?? 'https://sipher.sip-protocol.org' + const url = `${baseUrl}/pay/${id}` + + return { + action: 'invoice', + status: 'success', + message: `Invoice created for ${params.amount} ${token}. ${params.reference ? `Ref: ${params.reference}. ` : ''}Share the URL to request payment.`, + invoice: { + id, + url, + amount: params.amount, + token, + description: params.description ?? null, + dueDate: params.dueDate ?? null, + reference: params.reference ?? null, + stealthAddress: solanaAddress, + expiresAt, + }, + } +} diff --git a/packages/agent/tests/invoice.test.ts b/packages/agent/tests/invoice.test.ts new file mode 100644 index 0000000..b9e0fb7 --- /dev/null +++ b/packages/agent/tests/invoice.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { closeDb } from '../src/db.js' + +vi.mock('@sip-protocol/sdk', () => ({ + generateEd25519StealthAddress: vi.fn().mockReturnValue({ + stealthAddress: { + address: '0x' + 'cc'.repeat(32), + ephemeralPublicKey: '0x' + 'dd'.repeat(32), + }, + }), + ed25519PublicKeyToSolanaAddress: vi.fn().mockReturnValue('InVoIcE111111111111111111111111111111111111'), +})) + +beforeEach(() => { + process.env.DB_PATH = ':memory:' +}) + +afterEach(() => { + closeDb() + delete process.env.DB_PATH +}) + +const { invoiceTool, executeInvoice } = await import('../src/tools/invoice.js') + +describe('invoice tool definition', () => { + it('has correct name', () => { + expect(invoiceTool.name).toBe('invoice') + }) + + it('requires wallet and amount', () => { + expect(invoiceTool.input_schema.required).toContain('wallet') + expect(invoiceTool.input_schema.required).toContain('amount') + }) +}) + +describe('executeInvoice', () => { + it('creates an invoice with full metadata', async () => { + const result = await executeInvoice({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + amount: 500, + token: 'USDC', + description: 'Consulting services — March 2026', + dueDate: '2026-04-15', + reference: 'INV-2026-042', + }) + expect(result.action).toBe('invoice') + expect(result.status).toBe('success') + expect(result.invoice.amount).toBe(500) + expect(result.invoice.token).toBe('USDC') + expect(result.invoice.description).toBe('Consulting services — March 2026') + expect(result.invoice.dueDate).toBe('2026-04-15') + expect(result.invoice.reference).toBe('INV-2026-042') + expect(result.invoice.url).toMatch(/\/pay\//) + }) + + it('stores as type invoice with invoice_meta in DB', async () => { + const { getPaymentLink } = await import('../src/db.js') + const result = await executeInvoice({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + amount: 100, + token: 'SOL', + description: 'Test invoice', + reference: 'REF-001', + }) + const stored = getPaymentLink(result.invoice.id) + expect(stored).not.toBeNull() + expect(stored!.type).toBe('invoice') + expect(stored!.invoice_meta).toEqual({ + description: 'Test invoice', + dueDate: null, + reference: 'REF-001', + }) + }) + + it('throws when amount is missing', async () => { + await expect( + executeInvoice({ wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' } as any), + ).rejects.toThrow(/amount/i) + }) + + it('throws when amount is zero', async () => { + await expect( + executeInvoice({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + amount: 0, + }), + ).rejects.toThrow(/amount/i) + }) + + it('throws when wallet is missing', async () => { + await expect( + executeInvoice({ amount: 100 } as any), + ).rejects.toThrow(/wallet/i) + }) + + it('defaults expiry to 7 days for invoices', async () => { + const result = await executeInvoice({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + amount: 50, + }) + const sevenDaysMs = 7 * 24 * 60 * 60 * 1000 + const expected = Date.now() + sevenDaysMs + expect(result.invoice.expiresAt).toBeGreaterThan(expected - 5000) + expect(result.invoice.expiresAt).toBeLessThan(expected + 5000) + }) +}) From 7d3cfb737198534fddc534c4361ea6ece98bdbaf Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 00:36:41 +0700 Subject: [PATCH 07/92] feat(agent): add /pay/:id route with server-rendered payment pages Express router for payment link pages with dark-theme Tailwind HTML templates, XSS-safe escaping, open-amount support, expiry/paid/404 states, and confirm endpoint. --- packages/agent/package.json | 2 + packages/agent/src/routes/pay.ts | 55 +++++ packages/agent/src/views/pay-page.ts | 272 +++++++++++++++++++++++++ packages/agent/tests/pay-route.test.ts | 147 +++++++++++++ pnpm-lock.yaml | 6 + 5 files changed, 482 insertions(+) create mode 100644 packages/agent/src/routes/pay.ts create mode 100644 packages/agent/src/views/pay-page.ts create mode 100644 packages/agent/tests/pay-route.test.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index d27cb36..11ecdbd 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -18,7 +18,9 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.0", + "@types/supertest": "^6.0.3", "@types/ws": "^8.5.0", + "supertest": "^7.2.2", "tsx": "^4.19.0", "typescript": "^5.7.0", "vitest": "^3.0.0" diff --git a/packages/agent/src/routes/pay.ts b/packages/agent/src/routes/pay.ts new file mode 100644 index 0000000..2e92003 --- /dev/null +++ b/packages/agent/src/routes/pay.ts @@ -0,0 +1,55 @@ +import { Router } from 'express' +import { getPaymentLink, markPaymentLinkPaid } from '../db.js' +import { + renderPaymentPage, + renderExpiredPage, + renderPaidPage, + renderNotFoundPage, +} from '../views/pay-page.js' + +export const payRouter = Router() + +payRouter.get('/:id', (req, res) => { + const link = getPaymentLink(req.params.id) + + if (!link) { + res.status(404).type('html').send(renderNotFoundPage()) + return + } + + if (link.status === 'paid' && link.paid_tx) { + res.type('html').send(renderPaidPage(link.paid_tx)) + return + } + + if (link.status === 'expired' || link.expires_at < Date.now()) { + res.status(410).type('html').send(renderExpiredPage()) + return + } + + res.type('html').send(renderPaymentPage(link)) +}) + +payRouter.post('/:id/confirm', (req, res) => { + const { txSignature } = req.body + + if (!txSignature || typeof txSignature !== 'string') { + res.status(400).json({ error: 'txSignature is required' }) + return + } + + const link = getPaymentLink(req.params.id) + + if (!link) { + res.status(404).json({ error: 'Payment link not found' }) + return + } + + if (link.status === 'paid') { + res.status(409).json({ error: 'Payment link already paid' }) + return + } + + markPaymentLinkPaid(req.params.id, txSignature) + res.json({ success: true, message: 'Payment confirmed' }) +}) diff --git a/packages/agent/src/views/pay-page.ts b/packages/agent/src/views/pay-page.ts new file mode 100644 index 0000000..ce72ab9 --- /dev/null +++ b/packages/agent/src/views/pay-page.ts @@ -0,0 +1,272 @@ +import type { PaymentLink } from '../db.js' + +// ───────────────────────────────────────────────────────────────────────────── +// XSS prevention — escape all dynamic content before injecting into HTML +// ───────────────────────────────────────────────────────────────────────────── + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +// ───────────────────────────────────────────────────────────────────────────── +// Base HTML wrapper with Tailwind CDN + dark theme +// ───────────────────────────────────────────────────────────────────────────── + +function baseHtml(title: string, body: string): string { + return ` + + + + + ${escapeHtml(title)} + + + + + + ${body} + +` +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function truncateAddress(address: string, chars = 8): string { + if (address.length <= chars * 2 + 3) return address + return `${address.slice(0, chars)}...${address.slice(-chars)}` +} + +function formatExpiry(expiresAt: number): string { + const diffMs = expiresAt - Date.now() + if (diffMs <= 0) return 'Expired' + + const diffSec = Math.floor(diffMs / 1000) + if (diffSec < 60) return `${diffSec}s` + + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin}m` + + const diffHr = Math.floor(diffMin / 60) + if (diffHr < 24) return `${diffHr}h ${diffMin % 60}m` + + const diffDays = Math.floor(diffHr / 24) + return `${diffDays}d ${diffHr % 24}h` +} + +// ───────────────────────────────────────────────────────────────────────────── +// Page renderers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Render the main payment page for a pending payment link. + * All dynamic content is XSS-escaped. + */ +export function renderPaymentPage(link: PaymentLink): string { + const amountDisplay = link.amount !== null + ? `${escapeHtml(String(link.amount))} ${escapeHtml(link.token)}` + : `Any amount of ${escapeHtml(link.token)}` + + const memoHtml = link.memo + ? `

${escapeHtml(link.memo)}

` + : '' + + const expiryLabel = formatExpiry(link.expires_at) + const truncatedAddress = escapeHtml(truncateAddress(link.stealth_address)) + + const body = ` +
+ +
+ + +
+
+
+ 🔒 +
+
+

Sipher Private Payment

+

Powered by SIP Protocol

+
+
+
+ + +
+ + +
+

${amountDisplay}

+ ${memoHtml} +
+ + +
+ + +
+

Recipient (stealth address)

+

${truncatedAddress}

+
+ + +
+ 🛡 +

Privacy: Stealth address — unlinkable

+
+ + +
+ Expires in + ${escapeHtml(expiryLabel)} +
+ +
+ + +
+ +
+ +
+ + +

+ Powered by SIP Protocol + — The privacy standard for Web3 +

+
` + + return baseHtml('Sipher Private Payment', body) +} + +/** + * Render a 410 Gone page when the payment link has expired. + */ +export function renderExpiredPage(): string { + const body = ` +
+
+
+ ⌛ +
+

Payment Link Expired

+

+ This payment link is no longer valid. Please request a new link from the sender. +

+
+

+ Powered by SIP Protocol +

+
` + + return baseHtml('Payment Link Expired — Sipher', body) +} + +/** + * Render a confirmation page when the payment link has already been paid. + * Shows a Solscan link for the confirming transaction. + */ +export function renderPaidPage(txSignature: string): string { + const safeTx = escapeHtml(txSignature) + const solscanUrl = `https://solscan.io/tx/${safeTx}` + + const body = ` +
+
+
+ ✅ +
+

Payment Completed

+

+ This payment has already been fulfilled. Thank you! +

+ +
+

Transaction

+ ${safeTx} +
+ + + View on Solscan + + + + + +
+

+ Powered by SIP Protocol +

+
` + + return baseHtml('Payment Completed — Sipher', body) +} + +/** + * Render a 404 Not Found page for unknown payment link IDs. + */ +export function renderNotFoundPage(): string { + const body = ` +
+
+
+ 🔍 +
+

Payment Link Not Found

+

+ This payment link does not exist or has been removed. Please check the URL and try again. +

+
+

+ Powered by SIP Protocol +

+
` + + return baseHtml('Not Found — Sipher', body) +} diff --git a/packages/agent/tests/pay-route.test.ts b/packages/agent/tests/pay-route.test.ts new file mode 100644 index 0000000..a68dbfe --- /dev/null +++ b/packages/agent/tests/pay-route.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import express from 'express' +import supertest from 'supertest' +import { closeDb, createPaymentLink, getPaymentLink, markPaymentLinkPaid } from '../src/db.js' + +beforeEach(() => { + process.env.DB_PATH = ':memory:' +}) + +afterEach(() => { + closeDb() + delete process.env.DB_PATH +}) + +const { payRouter } = await import('../src/routes/pay.js') + +function createApp() { + const app = express() + app.use(express.json()) + app.use('/pay', payRouter) + return app +} + +describe('GET /pay/:id', () => { + it('returns 200 with HTML for a valid pending link', async () => { + const link = createPaymentLink({ + id: 'test-link-1', + stealth_address: 'StEaLtH1111111111111111111111111111111111111', + ephemeral_pubkey: '0x' + 'bb'.repeat(32), + amount: 5.0, + token: 'SOL', + memo: 'Coffee payment', + expires_at: Date.now() + 3600_000, + }) + const app = createApp() + const res = await supertest(app).get(`/pay/${link.id}`) + expect(res.status).toBe(200) + expect(res.headers['content-type']).toMatch(/html/) + expect(res.text).toContain('5') + expect(res.text).toContain('SOL') + expect(res.text).toContain('Coffee payment') + }) + + it('returns 404 for non-existent link', async () => { + const app = createApp() + const res = await supertest(app).get('/pay/does-not-exist') + expect(res.status).toBe(404) + }) + + it('returns expired page for expired link', async () => { + createPaymentLink({ + id: 'expired-link', + stealth_address: 'StEaLtH1111', + ephemeral_pubkey: '0xeph', + expires_at: Date.now() - 1000, + }) + const app = createApp() + const res = await supertest(app).get('/pay/expired-link') + expect(res.status).toBe(410) + expect(res.text).toMatch(/expired/i) + }) + + it('returns already-paid page for paid link', async () => { + createPaymentLink({ + id: 'paid-link', + stealth_address: 'StEaLtH1111', + ephemeral_pubkey: '0xeph', + expires_at: Date.now() + 3600_000, + }) + markPaymentLinkPaid('paid-link', 'tx-hash-123') + const app = createApp() + const res = await supertest(app).get('/pay/paid-link') + expect(res.status).toBe(200) + expect(res.text).toMatch(/paid|completed/i) + }) + + it('renders open-amount page when amount is null', async () => { + createPaymentLink({ + id: 'open-link', + stealth_address: 'StEaLtH1111', + ephemeral_pubkey: '0xeph', + expires_at: Date.now() + 3600_000, + }) + const app = createApp() + const res = await supertest(app).get('/pay/open-link') + expect(res.status).toBe(200) + expect(res.text).toMatch(/any amount/i) + }) +}) + +describe('POST /pay/:id/confirm', () => { + it('marks a pending link as paid', async () => { + createPaymentLink({ + id: 'confirm-test', + stealth_address: 'StEaLtH1111', + ephemeral_pubkey: '0xeph', + expires_at: Date.now() + 3600_000, + }) + const app = createApp() + const res = await supertest(app) + .post('/pay/confirm-test/confirm') + .send({ txSignature: '5abc...def' }) + expect(res.status).toBe(200) + expect(res.body.success).toBe(true) + const link = getPaymentLink('confirm-test') + expect(link!.status).toBe('paid') + expect(link!.paid_tx).toBe('5abc...def') + }) + + it('rejects double-pay', async () => { + createPaymentLink({ + id: 'double-pay', + stealth_address: 'StEaLtH1111', + ephemeral_pubkey: '0xeph', + expires_at: Date.now() + 3600_000, + }) + markPaymentLinkPaid('double-pay', 'first-tx') + const app = createApp() + const res = await supertest(app) + .post('/pay/double-pay/confirm') + .send({ txSignature: 'second-tx' }) + expect(res.status).toBe(409) + expect(res.body.error).toMatch(/already.*paid/i) + }) + + it('rejects confirm without txSignature', async () => { + createPaymentLink({ + id: 'no-sig', + stealth_address: 'StEaLtH1111', + ephemeral_pubkey: '0xeph', + expires_at: Date.now() + 3600_000, + }) + const app = createApp() + const res = await supertest(app) + .post('/pay/no-sig/confirm') + .send({}) + expect(res.status).toBe(400) + }) + + it('returns 404 for non-existent link', async () => { + const app = createApp() + const res = await supertest(app) + .post('/pay/nope/confirm') + .send({ txSignature: 'tx' }) + expect(res.status).toBe(404) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70f0f64..3e7d751 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,9 +167,15 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.6 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 '@types/ws': specifier: ^8.5.0 version: 8.18.1 + supertest: + specifier: ^7.2.2 + version: 7.2.2 tsx: specifier: ^4.19.0 version: 4.21.0 From 665cd8f7e2fefe5770c61c4078b99545ffbf6a42 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 00:39:37 +0700 Subject: [PATCH 08/92] feat(agent): add admin dashboard with auth, stats, and HTML templates --- packages/agent/src/routes/admin.ts | 108 +++++++++++++ packages/agent/src/views/admin-page.ts | 215 +++++++++++++++++++++++++ packages/agent/tests/admin.test.ts | 113 +++++++++++++ 3 files changed, 436 insertions(+) create mode 100644 packages/agent/src/routes/admin.ts create mode 100644 packages/agent/src/views/admin-page.ts create mode 100644 packages/agent/tests/admin.test.ts diff --git a/packages/agent/src/routes/admin.ts b/packages/agent/src/routes/admin.ts new file mode 100644 index 0000000..1760b11 --- /dev/null +++ b/packages/agent/src/routes/admin.ts @@ -0,0 +1,108 @@ +import { Router } from 'express' +import { randomBytes, timingSafeEqual, createHash } from 'node:crypto' +import { + getSessionStats, + getAuditStats, + getPaymentLinkStats, +} from '../db.js' +import { renderLoginPage, renderDashboardPage } from '../views/admin-page.js' +import type { DashboardStats } from '../views/admin-page.js' + +export const adminRouter = Router() + +const adminTokens = new Set() +const COOKIE_NAME = 'sipher_admin' +const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000 + +// ───────────────────────────────────────────────────────────────────────────── +// Auth helpers +// ───────────────────────────────────────────────────────────────────────────── + +function checkPassword(input: string): boolean { + const expected = process.env.SIPHER_ADMIN_PASSWORD + if (!expected) return false + const inputHash = createHash('sha256').update(input).digest() + const expectedHash = createHash('sha256').update(expected).digest() + return timingSafeEqual(inputHash, expectedHash) +} + +function getCookie(req: { headers: { cookie?: string } }, name: string): string | null { + const cookies = req.headers.cookie + if (!cookies) return null + const match = cookies.split(';').find((c) => c.trim().startsWith(`${name}=`)) + return match ? match.split('=')[1].trim() : null +} + +function requireAuth( + req: { headers: { cookie?: string } }, + res: { status: (code: number) => { json: (body: unknown) => void } }, + next: () => void, +): void { + const token = getCookie(req, COOKIE_NAME) + if (!token || !adminTokens.has(token)) { + res.status(401).json({ error: 'Unauthorized' }) + return + } + next() +} + +// ───────────────────────────────────────────────────────────────────────────── +// Stats builder +// ───────────────────────────────────────────────────────────────────────────── + +function buildStats(): DashboardStats { + return { + sessions: getSessionStats(), + audit: getAuditStats(TWENTY_FOUR_HOURS), + paymentLinks: getPaymentLinkStats(), + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public routes (no auth required) +// ───────────────────────────────────────────────────────────────────────────── + +adminRouter.get('/', (req, res) => { + const token = getCookie(req, COOKIE_NAME) + if (token && adminTokens.has(token)) { + res.redirect('/admin/dashboard') + return + } + res.type('html').send(renderLoginPage()) +}) + +adminRouter.post('/login', (req, res) => { + const { password } = req.body + if (!password || !checkPassword(password)) { + res.status(401).type('html').send(renderLoginPage('Invalid password')) + return + } + const token = randomBytes(32).toString('hex') + adminTokens.add(token) + res.setHeader( + 'Set-Cookie', + `${COOKIE_NAME}=${token}; Path=/admin; HttpOnly; SameSite=Strict; Max-Age=${TWENTY_FOUR_HOURS / 1000}`, + ) + res.redirect('/admin/dashboard') +}) + +adminRouter.post('/logout', (req, res) => { + const token = getCookie(req, COOKIE_NAME) + if (token) adminTokens.delete(token) + res.setHeader('Set-Cookie', `${COOKIE_NAME}=; Path=/admin; HttpOnly; Max-Age=0`) + res.redirect('/admin/') +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Authenticated routes +// ───────────────────────────────────────────────────────────────────────────── + +adminRouter.get('/dashboard', requireAuth as any, (_req, res) => { + const stats = buildStats() + ;(res as any).type('html').send(renderDashboardPage(stats)) +}) + +adminRouter.get('/api/stats', requireAuth as any, (_req, res) => { + const stats = buildStats() + ;(res as any).json(stats) +}) diff --git a/packages/agent/src/views/admin-page.ts b/packages/agent/src/views/admin-page.ts new file mode 100644 index 0000000..245d5b0 --- /dev/null +++ b/packages/agent/src/views/admin-page.ts @@ -0,0 +1,215 @@ +// ───────────────────────────────────────────────────────────────────────────── +// XSS prevention — escape all dynamic content before injecting into HTML +// ───────────────────────────────────────────────────────────────────────────── + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +// ───────────────────────────────────────────────────────────────────────────── +// Base HTML wrapper with Tailwind CDN + dark theme +// ───────────────────────────────────────────────────────────────────────────── + +function baseHtml(title: string, body: string): string { + return ` + + + + + ${escapeHtml(title)} + + + + + + ${body} + +` +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public types +// ───────────────────────────────────────────────────────────────────────────── + +export interface DashboardStats { + sessions: { total: number } + audit: { total: number; byAction: Record } + paymentLinks: { total: number; pending: number; paid: number; expired: number; cancelled: number } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Page renderers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Render the admin login page. + * Optionally shows an error message on failed auth attempts. + */ +export function renderLoginPage(error?: string): string { + const errorHtml = error + ? `
+

${escapeHtml(error)}

+
` + : '' + + const body = ` +
+
+ + +
+ + +
+
+
+ 🔐 +
+
+

Sipher Admin

+

Restricted access

+
+
+
+ + +
+ ${errorHtml} +
+ + +
+ +
+ +
+ +
+
` + + return baseHtml('Admin Login — Sipher', body) +} + +/** + * Render the admin dashboard with live stats. + * All dynamic values are XSS-escaped before injection. + */ +export function renderDashboardPage(stats: DashboardStats): string { + // Build action breakdown table rows + const actionEntries = Object.entries(stats.audit.byAction) + const actionRows = actionEntries.length > 0 + ? actionEntries + .sort(([, a], [, b]) => b - a) + .map(([action, count]) => ` + + ${escapeHtml(action)} + ${escapeHtml(String(count))} + `) + .join('') + : ` + No activity in the last 24 hours + ` + + const body = ` +
+ + +
+
+

Sipher Dashboard

+

Admin overview

+
+
+ +
+
+ + +
+ + +
+

Sessions

+

${escapeHtml(String(stats.sessions.total))}

+

Total wallets seen

+
+ + +
+

Transactions (24h)

+

${escapeHtml(String(stats.audit.total))}

+

Audit log entries

+
+ + +
+

Payment Links

+

${escapeHtml(String(stats.paymentLinks.total))}

+
+ ${escapeHtml(String(stats.paymentLinks.pending))} pending + ${escapeHtml(String(stats.paymentLinks.paid))} paid + ${escapeHtml(String(stats.paymentLinks.expired))} expired +
+
+ +
+ + +
+
+

Action Breakdown (24h)

+
+ + + + + + + + + ${actionRows} + +
ActionCount
+
+ +
` + + return baseHtml('Sipher Dashboard', body) +} diff --git a/packages/agent/tests/admin.test.ts b/packages/agent/tests/admin.test.ts new file mode 100644 index 0000000..4098445 --- /dev/null +++ b/packages/agent/tests/admin.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import express from 'express' +import supertest from 'supertest' +import { + closeDb, + getOrCreateSession, + logAudit, + createPaymentLink, +} from '../src/db.js' + +process.env.SIPHER_ADMIN_PASSWORD = 'test-admin-pass' + +beforeEach(() => { + process.env.DB_PATH = ':memory:' +}) + +afterEach(() => { + closeDb() + delete process.env.DB_PATH +}) + +const { adminRouter } = await import('../src/routes/admin.js') + +function createApp() { + const app = express() + app.use(express.json()) + app.use(express.urlencoded({ extended: false })) + app.use('/admin', adminRouter) + return app +} + +describe('admin auth', () => { + it('GET /admin/ returns login page when not authenticated', async () => { + const app = createApp() + const res = await supertest(app).get('/admin/') + expect(res.status).toBe(200) + expect(res.text).toMatch(/password/i) + }) + + it('POST /admin/login with correct password sets cookie', async () => { + const app = createApp() + const res = await supertest(app) + .post('/admin/login') + .send({ password: 'test-admin-pass' }) + expect(res.status).toBe(302) + expect(res.headers['set-cookie']).toBeDefined() + expect(res.headers['set-cookie'][0]).toMatch(/sipher_admin/) + }) + + it('POST /admin/login with wrong password returns 401', async () => { + const app = createApp() + const res = await supertest(app) + .post('/admin/login') + .send({ password: 'wrong-password' }) + expect(res.status).toBe(401) + }) + + it('GET /admin/api/stats requires auth', async () => { + const app = createApp() + const res = await supertest(app).get('/admin/api/stats') + expect(res.status).toBe(401) + }) +}) + +describe('admin API (authenticated)', () => { + async function loginAndGetCookie(app: express.Express): Promise { + const res = await supertest(app) + .post('/admin/login') + .send({ password: 'test-admin-pass' }) + return res.headers['set-cookie'][0].split(';')[0] + } + + it('GET /admin/api/stats returns dashboard data', async () => { + const session = getOrCreateSession('FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr') + logAudit(session.id, 'send', { to: 'addr' }) + logAudit(session.id, 'deposit', { amount: 5 }) + createPaymentLink({ + stealth_address: '0xa', + ephemeral_pubkey: '0xe', + expires_at: Date.now() + 3600_000, + }) + const app = createApp() + const cookie = await loginAndGetCookie(app) + const res = await supertest(app) + .get('/admin/api/stats') + .set('Cookie', cookie) + expect(res.status).toBe(200) + expect(res.body.sessions.total).toBe(1) + expect(res.body.audit.total).toBe(2) + expect(res.body.paymentLinks.total).toBe(1) + }) + + it('GET /admin/dashboard returns HTML with stats', async () => { + const app = createApp() + const cookie = await loginAndGetCookie(app) + const res = await supertest(app) + .get('/admin/dashboard') + .set('Cookie', cookie) + expect(res.status).toBe(200) + expect(res.headers['content-type']).toMatch(/html/) + expect(res.text).toMatch(/dashboard/i) + }) + + it('POST /admin/logout clears cookie', async () => { + const app = createApp() + const cookie = await loginAndGetCookie(app) + const res = await supertest(app) + .post('/admin/logout') + .set('Cookie', cookie) + expect(res.status).toBe(302) + expect(res.headers['set-cookie'][0]).toMatch(/sipher_admin=;/) + }) +}) From 7b23f2c2ec85ba73fc13a9174ceeb571fa56febe Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 00:42:38 +0700 Subject: [PATCH 09/92] feat(agent): wire paymentLink, invoice, privacyScore, threatCheck tools + /pay and /admin routes Registers 4 new tools in TOOLS array, TOOL_EXECUTORS, and SYSTEM_PROMPT. Mounts /pay and /admin route groups in index.ts with stale link expiry on the 5-minute purge interval. Updates tools.test.ts to assert 14 tools and stream.test.ts mock to include all new exports. --- packages/agent/src/agent.ts | 23 +++++++++++++++++++++-- packages/agent/src/index.ts | 13 ++++++++++++- packages/agent/src/tools/index.ts | 12 ++++++++++++ packages/agent/tests/stream.test.ts | 8 ++++++++ packages/agent/tests/tools.test.ts | 22 ++++++++++++++++++---- 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index b7ae0fd..57f7492 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -20,6 +20,14 @@ import { executeHistory, statusTool, executeStatus, + paymentLinkTool, + executePaymentLink, + invoiceTool, + executeInvoice, + privacyScoreTool, + executePrivacyScore, + threatCheckTool, + executeThreatCheck, } from './tools/index.js' // ───────────────────────────────────────────────────────────────────────────── @@ -35,14 +43,17 @@ Users deposit tokens, then you execute private sends, swaps, and refunds. Tone: Confident, technical, slightly cypherpunk. Never corporate. Never say "I'm just an AI." Speak like a privacy engineer who cares. -Available tools: deposit, send, refund, balance, scan, claim, swap, viewingKey, history, status. +Available tools: deposit, send, refund, balance, scan, claim, swap, viewingKey, history, status, paymentLink, invoice, privacyScore, threatCheck. Rules: - Every fund-moving operation shows a confirmation before executing - Never display full viewing keys in chat — provide download links - If vault anonymity set is low, warn the user - Always reassure funds are safe when errors occur -- Be concise — bullet points over paragraphs` +- Be concise — bullet points over paragraphs +- Before large sends, run threatCheck on the recipient address +- Offer privacyScore when users ask about their wallet's exposure +- Payment links and invoices generate stealth addresses — sender needs no Sipher account` // ───────────────────────────────────────────────────────────────────────────── // Tool registry @@ -59,6 +70,10 @@ export const TOOLS: Anthropic.Tool[] = [ viewingKeyTool, historyTool, statusTool, + paymentLinkTool, + invoiceTool, + privacyScoreTool, + threatCheckTool, ] type ToolExecutor = (params: Record) => Promise @@ -74,6 +89,10 @@ const TOOL_EXECUTORS: Record = { viewingKey: (p) => executeViewingKey(p as unknown as Parameters[0]), history: (p) => executeHistory(p as unknown as Parameters[0]), status: () => executeStatus(), + paymentLink: (p) => executePaymentLink(p as unknown as Parameters[0]), + invoice: (p) => executeInvoice(p as unknown as Parameters[0]), + privacyScore: (p) => executePrivacyScore(p as unknown as Parameters[0]), + threatCheck: (p) => executeThreatCheck(p as unknown as Parameters[0]), } /** diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index f1dca97..fa115af 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -2,8 +2,10 @@ import { fileURLToPath } from 'node:url' import path from 'node:path' import express from 'express' import { chat, chatStream, SYSTEM_PROMPT, TOOLS, executeTool } from './agent.js' -import { getDb } from './db.js' +import { getDb, expireStaleLinks } from './db.js' import { resolveSession, activeSessionCount, purgeStale } from './session.js' +import { payRouter } from './routes/pay.js' +import { adminRouter } from './routes/admin.js' // ───────────────────────────────────────────────────────────────────────────── // Database & session initialization @@ -16,6 +18,8 @@ console.log(' Database: SQLite initialized') setInterval(() => { const purged = purgeStale() if (purged > 0) console.log(`[session] purged ${purged} stale sessions`) + const expired = expireStaleLinks() + if (expired > 0) console.log(`[links] expired ${expired} stale payment links`) }, 5 * 60 * 1000) // ───────────────────────────────────────────────────────────────────────────── @@ -27,6 +31,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)) const app = express() app.use(express.json({ limit: '1mb' })) +// ─── Pay and Admin routes ──────────────────────────────────────────────────── + +app.use('/pay', payRouter) +app.use('/admin', adminRouter) + // Serve web chat UI (static files from app/dist) // In production: packages/agent/dist/ -> ../../../app/dist // Resolved via __dirname so it works regardless of cwd @@ -172,6 +181,8 @@ app.listen(PORT, () => { console.log(` Chat: POST http://localhost:${PORT}/api/chat`) console.log(` Stream: POST http://localhost:${PORT}/api/chat/stream`) console.log(` Tools: http://localhost:${PORT}/api/tools`) + console.log(` Pay: http://localhost:${PORT}/pay/:id`) + console.log(` Admin: http://localhost:${PORT}/admin/`) }) export { app } diff --git a/packages/agent/src/tools/index.ts b/packages/agent/src/tools/index.ts index caa6d08..a4a36fa 100644 --- a/packages/agent/src/tools/index.ts +++ b/packages/agent/src/tools/index.ts @@ -27,3 +27,15 @@ export type { HistoryParams, HistoryToolResult } from './history.js' export { statusTool, executeStatus } from './status.js' export type { StatusToolResult } from './status.js' + +export { paymentLinkTool, executePaymentLink } from './payment-link.js' +export type { PaymentLinkParams, PaymentLinkToolResult } from './payment-link.js' + +export { invoiceTool, executeInvoice } from './invoice.js' +export type { InvoiceParams, InvoiceToolResult } from './invoice.js' + +export { privacyScoreTool, executePrivacyScore } from './privacy-score.js' +export type { PrivacyScoreParams, PrivacyScoreToolResult } from './privacy-score.js' + +export { threatCheckTool, executeThreatCheck } from './threat-check.js' +export type { ThreatCheckParams, ThreatCheckToolResult } from './threat-check.js' diff --git a/packages/agent/tests/stream.test.ts b/packages/agent/tests/stream.test.ts index e7115b0..c07b19a 100644 --- a/packages/agent/tests/stream.test.ts +++ b/packages/agent/tests/stream.test.ts @@ -68,6 +68,14 @@ vi.mock('../src/tools/index.js', () => { executeHistory: makeExecutor(), statusTool: makeTool('status'), executeStatus: makeExecutor(), + paymentLinkTool: makeTool('paymentLink'), + executePaymentLink: makeExecutor(), + invoiceTool: makeTool('invoice'), + executeInvoice: makeExecutor(), + privacyScoreTool: makeTool('privacyScore'), + executePrivacyScore: makeExecutor(), + threatCheckTool: makeTool('threatCheck'), + executeThreatCheck: makeExecutor(), } }) diff --git a/packages/agent/tests/tools.test.ts b/packages/agent/tests/tools.test.ts index 977fa49..0784eaa 100644 --- a/packages/agent/tests/tools.test.ts +++ b/packages/agent/tests/tools.test.ts @@ -20,6 +20,14 @@ import { executeHistory, statusTool, executeStatus, + paymentLinkTool, + executePaymentLink, + invoiceTool, + executeInvoice, + privacyScoreTool, + executePrivacyScore, + threatCheckTool, + executeThreatCheck, } from '../src/tools/index.js' import { TOOLS, SYSTEM_PROMPT, executeTool } from '../src/agent.js' @@ -105,15 +113,17 @@ describe('tool definitions', () => { const allTools = [ depositTool, sendTool, refundTool, balanceTool, scanTool, claimTool, swapTool, viewingKeyTool, historyTool, statusTool, + paymentLinkTool, invoiceTool, privacyScoreTool, threatCheckTool, ] const toolNames = [ 'deposit', 'send', 'refund', 'balance', 'scan', 'claim', 'swap', 'viewingKey', 'history', 'status', + 'paymentLink', 'invoice', 'privacyScore', 'threatCheck', ] - it('exports exactly 10 tools', () => { - expect(allTools).toHaveLength(10) - expect(TOOLS).toHaveLength(10) + it('exports exactly 14 tools', () => { + expect(allTools).toHaveLength(14) + expect(TOOLS).toHaveLength(14) }) it('all tools have unique names', () => { @@ -165,7 +175,7 @@ describe('system prompt', () => { expect(SYSTEM_PROMPT).toContain('Plug in. Go private.') }) - it('references all 10 tools', () => { + it('references all 14 tools', () => { expect(SYSTEM_PROMPT).toContain('deposit') expect(SYSTEM_PROMPT).toContain('send') expect(SYSTEM_PROMPT).toContain('refund') @@ -176,6 +186,10 @@ describe('system prompt', () => { expect(SYSTEM_PROMPT).toContain('viewingKey') expect(SYSTEM_PROMPT).toContain('history') expect(SYSTEM_PROMPT).toContain('status') + expect(SYSTEM_PROMPT).toContain('paymentLink') + expect(SYSTEM_PROMPT).toContain('invoice') + expect(SYSTEM_PROMPT).toContain('privacyScore') + expect(SYSTEM_PROMPT).toContain('threatCheck') }) it('includes the confirmation rule for fund-moving operations', () => { From 19579580ecc124b3ec957cae2150d727772a31ca Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 00:46:57 +0700 Subject: [PATCH 10/92] =?UTF-8?q?fix(agent):=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20expiry=20check=20on=20confirm,=20Secure=20cookie,?= =?UTF-8?q?=20tx=20sig=20validation,=20devnet=E2=86=92env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/routes/admin.ts | 2 +- packages/agent/src/routes/pay.ts | 9 +++++++-- packages/agent/src/tools/invoice.ts | 2 ++ packages/agent/src/tools/payment-link.ts | 3 +++ packages/agent/src/tools/privacy-score.ts | 3 ++- packages/agent/tests/pay-route.test.ts | 15 +++++++++++++++ 6 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/agent/src/routes/admin.ts b/packages/agent/src/routes/admin.ts index 1760b11..24234e3 100644 --- a/packages/agent/src/routes/admin.ts +++ b/packages/agent/src/routes/admin.ts @@ -81,7 +81,7 @@ adminRouter.post('/login', (req, res) => { adminTokens.add(token) res.setHeader( 'Set-Cookie', - `${COOKIE_NAME}=${token}; Path=/admin; HttpOnly; SameSite=Strict; Max-Age=${TWENTY_FOUR_HOURS / 1000}`, + `${COOKIE_NAME}=${token}; Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=${TWENTY_FOUR_HOURS / 1000}`, ) res.redirect('/admin/dashboard') }) diff --git a/packages/agent/src/routes/pay.ts b/packages/agent/src/routes/pay.ts index 2e92003..95a8e82 100644 --- a/packages/agent/src/routes/pay.ts +++ b/packages/agent/src/routes/pay.ts @@ -33,8 +33,8 @@ payRouter.get('/:id', (req, res) => { payRouter.post('/:id/confirm', (req, res) => { const { txSignature } = req.body - if (!txSignature || typeof txSignature !== 'string') { - res.status(400).json({ error: 'txSignature is required' }) + if (!txSignature || typeof txSignature !== 'string' || txSignature.length > 200) { + res.status(400).json({ error: 'txSignature is required and must be a valid transaction signature' }) return } @@ -50,6 +50,11 @@ payRouter.post('/:id/confirm', (req, res) => { return } + if (link.status === 'expired' || link.expires_at < Date.now()) { + res.status(410).json({ error: 'Payment link has expired' }) + return + } + markPaymentLinkPaid(req.params.id, txSignature) res.json({ success: true, message: 'Payment confirmed' }) }) diff --git a/packages/agent/src/tools/invoice.ts b/packages/agent/src/tools/invoice.ts index 41b628c..2087497 100644 --- a/packages/agent/src/tools/invoice.ts +++ b/packages/agent/src/tools/invoice.ts @@ -94,6 +94,8 @@ export async function executeInvoice( const expiresIn = Math.min(Math.max(params.expiresInMinutes ?? 10080, 1), 43200) const expiresAt = Date.now() + expiresIn * 60 * 1000 + // Phase 1: ephemeral stealth address from random keys (same as paymentLink). + // Phase 2 will use the wallet's actual spending/viewing keypair. const dummyKey = '0x' + randomBytes(32).toString('hex') as `0x${string}` const stealth = generateEd25519StealthAddress({ spendingKey: dummyKey, diff --git a/packages/agent/src/tools/payment-link.ts b/packages/agent/src/tools/payment-link.ts index 1b885e0..5590a2e 100644 --- a/packages/agent/src/tools/payment-link.ts +++ b/packages/agent/src/tools/payment-link.ts @@ -89,6 +89,9 @@ export async function executePaymentLink( const expiresIn = Math.min(Math.max(params.expiresInMinutes ?? 60, 1), 10080) const expiresAt = Date.now() + expiresIn * 60 * 1000 + // Phase 1: ephemeral stealth address from random keys. The recipient claims + // via the payment page flow, not by deriving the stealth private key. + // Phase 2 will use the wallet's actual spending/viewing keypair. const dummyKey = '0x' + randomBytes(32).toString('hex') as `0x${string}` const stealth = generateEd25519StealthAddress({ spendingKey: dummyKey, diff --git a/packages/agent/src/tools/privacy-score.ts b/packages/agent/src/tools/privacy-score.ts index 204e813..fdce86a 100644 --- a/packages/agent/src/tools/privacy-score.ts +++ b/packages/agent/src/tools/privacy-score.ts @@ -54,7 +54,8 @@ export async function executePrivacyScore( } const limit = Math.min(Math.max(params.limit ?? 50, 1), 200) - const connection = createConnection('devnet') + const network = (process.env.SOLANA_NETWORK ?? 'devnet') as 'devnet' | 'mainnet-beta' + const connection = createConnection(network) let walletPubkey: PublicKey try { diff --git a/packages/agent/tests/pay-route.test.ts b/packages/agent/tests/pay-route.test.ts index a68dbfe..1a4edfd 100644 --- a/packages/agent/tests/pay-route.test.ts +++ b/packages/agent/tests/pay-route.test.ts @@ -137,6 +137,21 @@ describe('POST /pay/:id/confirm', () => { expect(res.status).toBe(400) }) + it('rejects confirm on expired link', async () => { + createPaymentLink({ + id: 'expired-confirm', + stealth_address: 'StEaLtH1111', + ephemeral_pubkey: '0xeph', + expires_at: Date.now() - 1000, + }) + const app = createApp() + const res = await supertest(app) + .post('/pay/expired-confirm/confirm') + .send({ txSignature: 'tx-hash' }) + expect(res.status).toBe(410) + expect(res.body.error).toMatch(/expired/i) + }) + it('returns 404 for non-existent link', async () => { const app = createApp() const res = await supertest(app) From f1012091838618db56b9f62a39e5047f97a9226d Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 01:01:13 +0700 Subject: [PATCH 11/92] feat(agent): add scheduled ops CRUD helpers for crank engine --- packages/agent/src/db.ts | 100 ++++++++++++ packages/agent/tests/scheduled-ops.test.ts | 176 +++++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 packages/agent/tests/scheduled-ops.test.ts diff --git a/packages/agent/src/db.ts b/packages/agent/src/db.ts index ea50da3..7e675df 100644 --- a/packages/agent/src/db.ts +++ b/packages/agent/src/db.ts @@ -457,3 +457,103 @@ export function getSessionStats(): SessionStatsResult { const row = conn.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number } return { total: row.count } } + +// ───────────────────────────────────────────────────────────────────────────── +// Scheduled operations +// ───────────────────────────────────────────────────────────────────────────── + +export interface ScheduledOp { + id: string + session_id: string + action: string + params: Record + wallet_signature: string + next_exec: number + expires_at: number + max_exec: number + exec_count: number + status: string + created_at: number +} + +export interface CreateScheduledOpData { + id?: string + session_id: string + action: string + params: Record + wallet_signature: string + next_exec: number + expires_at: number + max_exec: number +} + +type ScheduledOpRow = { + id: string; session_id: string; action: string; params: string + wallet_signature: string; next_exec: number; expires_at: number + max_exec: number; exec_count: number; status: string; created_at: number +} + +function parseOpRow(row: ScheduledOpRow): ScheduledOp { + return { ...row, params: JSON.parse(row.params) } +} + +export function createScheduledOp(data: CreateScheduledOpData): ScheduledOp { + const conn = getDb() + const id = data.id ?? randomUUID() + const now = Date.now() + conn.prepare(` + INSERT INTO scheduled_ops + (id, session_id, action, params, wallet_signature, next_exec, expires_at, max_exec, exec_count, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 'pending', ?) + `).run(id, data.session_id, data.action, JSON.stringify(data.params), data.wallet_signature, data.next_exec, data.expires_at, data.max_exec, now) + return { + id, session_id: data.session_id, action: data.action, params: data.params, + wallet_signature: data.wallet_signature, next_exec: data.next_exec, + expires_at: data.expires_at, max_exec: data.max_exec, exec_count: 0, + status: 'pending', created_at: now, + } +} + +export function getScheduledOp(id: string): ScheduledOp | null { + const conn = getDb() + const row = conn.prepare('SELECT * FROM scheduled_ops WHERE id = ?').get(id) as ScheduledOpRow | undefined + return row ? parseOpRow(row) : null +} + +export function getScheduledOpsBySession(sessionId: string, limit = 50): ScheduledOp[] { + const conn = getDb() + const rows = conn.prepare( + 'SELECT * FROM scheduled_ops WHERE session_id = ? ORDER BY next_exec ASC LIMIT ?', + ).all(sessionId, limit) as ScheduledOpRow[] + return rows.map(parseOpRow) +} + +export function getPendingOps(now?: number): ScheduledOp[] { + const conn = getDb() + const ts = now ?? Date.now() + const rows = conn.prepare( + "SELECT * FROM scheduled_ops WHERE status = 'pending' AND next_exec <= ? ORDER BY next_exec ASC", + ).all(ts) as ScheduledOpRow[] + return rows.map(parseOpRow) +} + +export function updateScheduledOp( + id: string, + updates: { status?: string; exec_count?: number; next_exec?: number }, +): void { + const conn = getDb() + const sets: string[] = [] + const values: (string | number)[] = [] + if (updates.status !== undefined) { sets.push('status = ?'); values.push(updates.status) } + if (updates.exec_count !== undefined) { sets.push('exec_count = ?'); values.push(updates.exec_count) } + if (updates.next_exec !== undefined) { sets.push('next_exec = ?'); values.push(updates.next_exec) } + if (sets.length === 0) return + values.push(id) + conn.prepare(`UPDATE scheduled_ops SET ${sets.join(', ')} WHERE id = ?`).run(...values) +} + +export function cancelScheduledOp(id: string): void { + const conn = getDb() + const result = conn.prepare("UPDATE scheduled_ops SET status = 'cancelled' WHERE id = ?").run(id) + if (result.changes === 0) throw new Error(`Scheduled op not found: ${id}`) +} diff --git a/packages/agent/tests/scheduled-ops.test.ts b/packages/agent/tests/scheduled-ops.test.ts new file mode 100644 index 0000000..2851b97 --- /dev/null +++ b/packages/agent/tests/scheduled-ops.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { + getDb, + closeDb, + getOrCreateSession, + createScheduledOp, + getScheduledOp, + getScheduledOpsBySession, + getPendingOps, + updateScheduledOp, + cancelScheduledOp, +} from '../src/db.js' + +const WALLET = 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' + +beforeEach(() => { + process.env.DB_PATH = ':memory:' +}) + +afterEach(() => { + closeDb() + delete process.env.DB_PATH +}) + +describe('createScheduledOp', () => { + it('creates an op with all fields', () => { + const session = getOrCreateSession(WALLET) + const op = createScheduledOp({ + session_id: session.id, + action: 'send', + params: { amount: 10, token: 'SOL', recipient: 'addr' }, + wallet_signature: 'sig123', + next_exec: Date.now() + 60_000, + expires_at: Date.now() + 3600_000, + max_exec: 1, + }) + + expect(op.id).toBeDefined() + expect(op.session_id).toBe(session.id) + expect(op.action).toBe('send') + expect(op.params).toEqual({ amount: 10, token: 'SOL', recipient: 'addr' }) + expect(op.status).toBe('pending') + expect(op.exec_count).toBe(0) + }) + + it('uses custom id when provided', () => { + const session = getOrCreateSession(WALLET) + const op = createScheduledOp({ + id: 'custom-op-id', + session_id: session.id, + action: 'send', + params: {}, + wallet_signature: 'sig', + next_exec: Date.now() + 60_000, + expires_at: Date.now() + 3600_000, + max_exec: 1, + }) + expect(op.id).toBe('custom-op-id') + }) +}) + +describe('getScheduledOp', () => { + it('retrieves an existing op', () => { + const session = getOrCreateSession(WALLET) + const created = createScheduledOp({ + session_id: session.id, + action: 'send', + params: { amount: 5 }, + wallet_signature: 'sig', + next_exec: Date.now() + 60_000, + expires_at: Date.now() + 3600_000, + max_exec: 1, + }) + const retrieved = getScheduledOp(created.id) + expect(retrieved).not.toBeNull() + expect(retrieved!.id).toBe(created.id) + expect(retrieved!.params).toEqual({ amount: 5 }) + }) + + it('returns null for unknown id', () => { + expect(getScheduledOp('nonexistent')).toBeNull() + }) +}) + +describe('getScheduledOpsBySession', () => { + it('returns ops for a session sorted by next_exec ASC', () => { + const session = getOrCreateSession(WALLET) + const now = Date.now() + createScheduledOp({ + session_id: session.id, action: 'send', params: { i: 2 }, + wallet_signature: 'sig', next_exec: now + 120_000, expires_at: now + 3600_000, max_exec: 1, + }) + createScheduledOp({ + session_id: session.id, action: 'send', params: { i: 1 }, + wallet_signature: 'sig', next_exec: now + 60_000, expires_at: now + 3600_000, max_exec: 1, + }) + const ops = getScheduledOpsBySession(session.id) + expect(ops).toHaveLength(2) + expect(ops[0].params.i).toBe(1) + expect(ops[1].params.i).toBe(2) + }) + + it('returns empty for unknown session', () => { + expect(getScheduledOpsBySession('unknown')).toHaveLength(0) + }) +}) + +describe('getPendingOps', () => { + it('returns ops where next_exec <= now and status is pending', () => { + const session = getOrCreateSession(WALLET) + const now = Date.now() + createScheduledOp({ + session_id: session.id, action: 'send', params: { due: true }, + wallet_signature: 'sig', next_exec: now - 1000, expires_at: now + 3600_000, max_exec: 1, + }) + createScheduledOp({ + session_id: session.id, action: 'send', params: { due: false }, + wallet_signature: 'sig', next_exec: now + 60_000, expires_at: now + 3600_000, max_exec: 1, + }) + const pending = getPendingOps(now) + expect(pending).toHaveLength(1) + expect(pending[0].params.due).toBe(true) + }) + + it('excludes non-pending statuses', () => { + const session = getOrCreateSession(WALLET) + const op = createScheduledOp({ + session_id: session.id, action: 'send', params: {}, + wallet_signature: 'sig', next_exec: Date.now() - 1000, expires_at: Date.now() + 3600_000, max_exec: 1, + }) + updateScheduledOp(op.id, { status: 'completed' }) + expect(getPendingOps()).toHaveLength(0) + }) +}) + +describe('updateScheduledOp', () => { + it('updates status', () => { + const session = getOrCreateSession(WALLET) + const op = createScheduledOp({ + session_id: session.id, action: 'send', params: {}, + wallet_signature: 'sig', next_exec: Date.now(), expires_at: Date.now() + 3600_000, max_exec: 3, + }) + updateScheduledOp(op.id, { status: 'executing' }) + expect(getScheduledOp(op.id)!.status).toBe('executing') + }) + + it('updates exec_count and next_exec', () => { + const session = getOrCreateSession(WALLET) + const op = createScheduledOp({ + session_id: session.id, action: 'send', params: {}, + wallet_signature: 'sig', next_exec: Date.now(), expires_at: Date.now() + 3600_000, max_exec: 3, + }) + const nextExec = Date.now() + 120_000 + updateScheduledOp(op.id, { exec_count: 1, next_exec: nextExec, status: 'pending' }) + const updated = getScheduledOp(op.id)! + expect(updated.exec_count).toBe(1) + expect(updated.next_exec).toBe(nextExec) + expect(updated.status).toBe('pending') + }) +}) + +describe('cancelScheduledOp', () => { + it('sets status to cancelled', () => { + const session = getOrCreateSession(WALLET) + const op = createScheduledOp({ + session_id: session.id, action: 'send', params: {}, + wallet_signature: 'sig', next_exec: Date.now() + 60_000, expires_at: Date.now() + 3600_000, max_exec: 1, + }) + cancelScheduledOp(op.id) + expect(getScheduledOp(op.id)!.status).toBe('cancelled') + }) + + it('throws for non-existent op', () => { + expect(() => cancelScheduledOp('nonexistent')).toThrow() + }) +}) From 7c83ae8733b407056b058e9f49966221ae03780e Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 01:01:17 +0700 Subject: [PATCH 12/92] =?UTF-8?q?feat(agent):=20add=20roundAmount=20tool?= =?UTF-8?q?=20=E2=80=94=20denomination=20rounding=20for=20privacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-math synchronous tool that floors amounts to common denominations (10, 50, 100, 500, 1000, 5000, 10000) to reduce amount correlation in privacy-preserving transactions. Remainder stays in vault. --- packages/agent/src/tools/round-amount.ts | 84 +++++++++++++++++++++++ packages/agent/tests/round-amount.test.ts | 64 +++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 packages/agent/src/tools/round-amount.ts create mode 100644 packages/agent/tests/round-amount.test.ts diff --git a/packages/agent/src/tools/round-amount.ts b/packages/agent/src/tools/round-amount.ts new file mode 100644 index 0000000..d82c066 --- /dev/null +++ b/packages/agent/src/tools/round-amount.ts @@ -0,0 +1,84 @@ +import type Anthropic from '@anthropic-ai/sdk' + +const DENOMINATIONS = [10000, 5000, 1000, 500, 100, 50, 10] + +export interface RoundAmountParams { + amount: number + token: string +} + +export interface RoundAmountToolResult { + action: 'roundAmount' + status: 'success' + message: string + roundedAmount: number + remainder: number + denomination: number + token: string +} + +export const roundAmountTool: Anthropic.Tool = { + name: 'roundAmount', + description: + 'Round a payment amount down to a common denomination to reduce amount correlation. ' + + 'Denominations: 10, 50, 100, 500, 1000, 5000, 10000. ' + + 'The remainder stays in the vault.', + input_schema: { + type: 'object' as const, + properties: { + amount: { + type: 'number', + description: 'Amount to round (will be rounded DOWN to the nearest denomination)', + }, + token: { + type: 'string', + description: 'Token symbol — SOL, USDC, etc.', + }, + }, + required: ['amount', 'token'], + }, +} + +export async function executeRoundAmount( + params: RoundAmountParams, +): Promise { + if (!params.amount || params.amount <= 0) { + throw new Error('Amount must be greater than zero') + } + + const token = params.token.toUpperCase() + + let denomination = 0 + let roundedAmount = 0 + for (const denom of DENOMINATIONS) { + if (params.amount >= denom) { + denomination = denom + roundedAmount = Math.floor(params.amount / denom) * denom + break + } + } + + const remainder = Math.round((params.amount - roundedAmount) * 100) / 100 + + if (roundedAmount === 0) { + return { + action: 'roundAmount', + status: 'success', + message: `Amount ${params.amount} ${token} is too small to round — minimum denomination is 10. Full amount stays in vault.`, + roundedAmount: 0, + remainder: params.amount, + denomination: 0, + token, + } + } + + return { + action: 'roundAmount', + status: 'success', + message: `Rounded ${params.amount} ${token} → ${roundedAmount} ${token} (denomination: ${denomination}). Remainder: ${remainder} ${token} stays in vault.`, + roundedAmount, + remainder, + denomination, + token, + } +} diff --git a/packages/agent/tests/round-amount.test.ts b/packages/agent/tests/round-amount.test.ts new file mode 100644 index 0000000..f22d332 --- /dev/null +++ b/packages/agent/tests/round-amount.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' + +const { roundAmountTool, executeRoundAmount } = await import('../src/tools/round-amount.js') + +describe('roundAmount tool definition', () => { + it('has correct name', () => { + expect(roundAmountTool.name).toBe('roundAmount') + }) + + it('requires amount and token', () => { + expect(roundAmountTool.input_schema.required).toContain('amount') + expect(roundAmountTool.input_schema.required).toContain('token') + }) +}) + +describe('executeRoundAmount', () => { + it('rounds 1337.42 to 1000', async () => { + const result = await executeRoundAmount({ amount: 1337.42, token: 'USDC' }) + expect(result.action).toBe('roundAmount') + expect(result.roundedAmount).toBe(1000) + expect(result.remainder).toBeCloseTo(337.42, 2) + expect(result.denomination).toBe(1000) + }) + + it('rounds 73 to 50', async () => { + const result = await executeRoundAmount({ amount: 73, token: 'SOL' }) + expect(result.roundedAmount).toBe(50) + expect(result.remainder).toBeCloseTo(23, 2) + expect(result.denomination).toBe(50) + }) + + it('rounds 5432 to 5000', async () => { + const result = await executeRoundAmount({ amount: 5432, token: 'USDC' }) + expect(result.roundedAmount).toBe(5000) + expect(result.remainder).toBeCloseTo(432, 2) + expect(result.denomination).toBe(5000) + }) + + it('rounds 15000 to 10000', async () => { + const result = await executeRoundAmount({ amount: 15000, token: 'USDC' }) + expect(result.roundedAmount).toBe(10000) + expect(result.remainder).toBeCloseTo(5000, 2) + expect(result.denomination).toBe(10000) + }) + + it('returns exact amount if already a denomination', async () => { + const result = await executeRoundAmount({ amount: 100, token: 'USDC' }) + expect(result.roundedAmount).toBe(100) + expect(result.remainder).toBe(0) + }) + + it('handles amounts below smallest denomination', async () => { + const result = await executeRoundAmount({ amount: 7, token: 'SOL' }) + expect(result.roundedAmount).toBe(0) + expect(result.remainder).toBe(7) + expect(result.denomination).toBe(0) + expect(result.message).toMatch(/too small/i) + }) + + it('throws when amount is zero or negative', async () => { + await expect(executeRoundAmount({ amount: 0, token: 'SOL' })).rejects.toThrow(/amount/i) + await expect(executeRoundAmount({ amount: -5, token: 'SOL' })).rejects.toThrow(/amount/i) + }) +}) From 290f03af8cf70a587219bb13bd1dc4dacd0fdd9f Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 01:03:20 +0700 Subject: [PATCH 13/92] =?UTF-8?q?feat(agent):=20add=20crank=20engine=20?= =?UTF-8?q?=E2=80=94=2060s=20scheduled=20ops=20executor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements crankTick with expiry, miss-window, max_exec completion, recurring re-schedule via intervalMs, and per-op error isolation. 8 tests covering all branches (executed, expired, missed, failed, skipped). --- packages/agent/src/crank.ts | 79 ++++++++++++++ packages/agent/tests/crank.test.ts | 159 +++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 packages/agent/src/crank.ts create mode 100644 packages/agent/tests/crank.test.ts diff --git a/packages/agent/src/crank.ts b/packages/agent/src/crank.ts new file mode 100644 index 0000000..3b5e3d5 --- /dev/null +++ b/packages/agent/src/crank.ts @@ -0,0 +1,79 @@ +import { + getPendingOps, + updateScheduledOp, + logAudit, +} from './db.js' + +const MISS_WINDOW_MS = 5 * 60 * 1000 + +export type OpExecutor = (action: string, params: Record) => Promise + +export interface CrankTickResult { + executed: number + expired: number + missed: number + failed: number +} + +export async function crankTick(executor: OpExecutor): Promise { + const now = Date.now() + const ops = getPendingOps(now) + const result: CrankTickResult = { executed: 0, expired: 0, missed: 0, failed: 0 } + + for (const op of ops) { + if (op.expires_at < now) { + updateScheduledOp(op.id, { status: 'expired' }) + result.expired++ + continue + } + + if (op.next_exec < now - MISS_WINDOW_MS) { + updateScheduledOp(op.id, { status: 'missed' }) + result.missed++ + continue + } + + try { + updateScheduledOp(op.id, { status: 'executing' }) + await executor(op.action, op.params) + + const newExecCount = op.exec_count + 1 + logAudit(op.session_id, op.action, op.params, 'prepared') + + if (newExecCount >= op.max_exec) { + updateScheduledOp(op.id, { status: 'completed', exec_count: newExecCount }) + } else { + const intervalMs = (op.params.intervalMs as number) ?? 60_000 + const nextExec = now + intervalMs + updateScheduledOp(op.id, { status: 'pending', exec_count: newExecCount, next_exec: nextExec }) + } + + result.executed++ + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + logAudit(op.session_id, op.action, { ...op.params, error: msg }, 'failed') + updateScheduledOp(op.id, { status: 'pending' }) + result.failed++ + } + } + + return result +} + +export function startCrank(executor: OpExecutor): NodeJS.Timeout { + return setInterval(async () => { + try { + const result = await crankTick(executor) + const total = result.executed + result.expired + result.missed + result.failed + if (total > 0) { + console.log(`[crank] tick: ${result.executed} exec, ${result.expired} expired, ${result.missed} missed, ${result.failed} failed`) + } + } catch (error) { + console.error('[crank] tick error:', error) + } + }, 60_000) +} + +export function stopCrank(timer: NodeJS.Timeout): void { + clearInterval(timer) +} diff --git a/packages/agent/tests/crank.test.ts b/packages/agent/tests/crank.test.ts new file mode 100644 index 0000000..87d7595 --- /dev/null +++ b/packages/agent/tests/crank.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + closeDb, + getOrCreateSession, + createScheduledOp, + getScheduledOp, + getDb, +} from '../src/db.js' + +beforeEach(() => { + process.env.DB_PATH = ':memory:' +}) + +afterEach(() => { + closeDb() + delete process.env.DB_PATH +}) + +const { crankTick } = await import('../src/crank.js') + +const WALLET = 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' + +describe('crankTick', () => { + it('executes a due pending op', async () => { + const session = getOrCreateSession(WALLET) + const op = createScheduledOp({ + session_id: session.id, action: 'send', params: { amount: 10 }, + wallet_signature: 'sig', next_exec: Date.now() - 1000, + expires_at: Date.now() + 3600_000, max_exec: 1, + }) + + const executor = vi.fn().mockResolvedValue({ status: 'success' }) + const result = await crankTick(executor) + + expect(executor).toHaveBeenCalledWith('send', { amount: 10 }) + expect(result.executed).toBe(1) + const updated = getScheduledOp(op.id)! + expect(updated.status).toBe('completed') + expect(updated.exec_count).toBe(1) + }) + + it('marks expired ops without executing', async () => { + const session = getOrCreateSession(WALLET) + const op = createScheduledOp({ + session_id: session.id, action: 'send', params: {}, + wallet_signature: 'sig', next_exec: Date.now() - 1000, + expires_at: Date.now() - 500, max_exec: 1, + }) + + const executor = vi.fn() + const result = await crankTick(executor) + + expect(executor).not.toHaveBeenCalled() + expect(result.expired).toBe(1) + expect(getScheduledOp(op.id)!.status).toBe('expired') + }) + + it('completes ops that hit max_exec', async () => { + const session = getOrCreateSession(WALLET) + const op = createScheduledOp({ + session_id: session.id, action: 'send', params: {}, + wallet_signature: 'sig', next_exec: Date.now() - 1000, + expires_at: Date.now() + 3600_000, max_exec: 3, + }) + const db = getDb() + db.prepare('UPDATE scheduled_ops SET exec_count = 2 WHERE id = ?').run(op.id) + + const executor = vi.fn().mockResolvedValue({ status: 'success' }) + await crankTick(executor) + + const updated = getScheduledOp(op.id)! + expect(updated.status).toBe('completed') + expect(updated.exec_count).toBe(3) + }) + + it('re-schedules recurring ops with intervalMs in params', async () => { + const session = getOrCreateSession(WALLET) + createScheduledOp({ + session_id: session.id, action: 'send', + params: { amount: 50, intervalMs: 120_000 }, + wallet_signature: 'sig', next_exec: Date.now() - 1000, + expires_at: Date.now() + 3600_000, max_exec: 5, + }) + + const executor = vi.fn().mockResolvedValue({ status: 'success' }) + await crankTick(executor) + + const session2 = getOrCreateSession(WALLET) + const ops = (await import('../src/db.js')).getScheduledOpsBySession(session2.id) + const updated = ops[0] + expect(updated.status).toBe('pending') + expect(updated.exec_count).toBe(1) + expect(updated.next_exec).toBeGreaterThan(Date.now() + 100_000) + expect(updated.next_exec).toBeLessThan(Date.now() + 150_000) + }) + + it('skips future ops', async () => { + const session = getOrCreateSession(WALLET) + createScheduledOp({ + session_id: session.id, action: 'send', params: {}, + wallet_signature: 'sig', next_exec: Date.now() + 60_000, + expires_at: Date.now() + 3600_000, max_exec: 1, + }) + + const executor = vi.fn() + const result = await crankTick(executor) + + expect(executor).not.toHaveBeenCalled() + expect(result.executed).toBe(0) + }) + + it('continues executing remaining ops if one fails', async () => { + const session = getOrCreateSession(WALLET) + createScheduledOp({ + id: 'op-fail', session_id: session.id, action: 'send', params: { fail: true }, + wallet_signature: 'sig', next_exec: Date.now() - 2000, + expires_at: Date.now() + 3600_000, max_exec: 1, + }) + createScheduledOp({ + id: 'op-ok', session_id: session.id, action: 'send', params: { fail: false }, + wallet_signature: 'sig', next_exec: Date.now() - 1000, + expires_at: Date.now() + 3600_000, max_exec: 1, + }) + + const executor = vi.fn().mockImplementation(async (_action: string, params: Record) => { + if (params.fail) throw new Error('Test error') + return { status: 'success' } + }) + + const result = await crankTick(executor) + + expect(result.executed).toBe(1) + expect(result.failed).toBe(1) + expect(getScheduledOp('op-fail')!.status).toBe('pending') + expect(getScheduledOp('op-ok')!.status).toBe('completed') + }) + + it('marks missed ops (due > 5 min ago)', async () => { + const session = getOrCreateSession(WALLET) + const op = createScheduledOp({ + session_id: session.id, action: 'send', params: {}, + wallet_signature: 'sig', next_exec: Date.now() - 6 * 60_000, + expires_at: Date.now() + 3600_000, max_exec: 1, + }) + + const executor = vi.fn() + const result = await crankTick(executor) + + expect(result.missed).toBe(1) + expect(getScheduledOp(op.id)!.status).toBe('missed') + expect(executor).not.toHaveBeenCalled() + }) + + it('returns zero counts when no ops pending', async () => { + const executor = vi.fn() + const result = await crankTick(executor) + expect(result).toEqual({ executed: 0, expired: 0, missed: 0, failed: 0 }) + }) +}) From b79576142d8793b11ed9b330207006429d277b3b Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 01:05:35 +0700 Subject: [PATCH 14/92] =?UTF-8?q?feat(agent):=20add=20scheduleSend=20tool?= =?UTF-8?q?=20=E2=80=94=20delayed=20private=20sends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates a single scheduled_op with action='send' and max_exec=1. Supports exact delay, random range, or default 30-60 min. Expiry set to scheduled time + 1 hour. 8 tests, all passing. --- packages/agent/src/tools/index.ts | 3 + packages/agent/src/tools/schedule-send.ts | 110 +++++++++++++++++++++ packages/agent/tests/schedule-send.test.ts | 84 ++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 packages/agent/src/tools/schedule-send.ts create mode 100644 packages/agent/tests/schedule-send.test.ts diff --git a/packages/agent/src/tools/index.ts b/packages/agent/src/tools/index.ts index a4a36fa..0298de3 100644 --- a/packages/agent/src/tools/index.ts +++ b/packages/agent/src/tools/index.ts @@ -39,3 +39,6 @@ export type { PrivacyScoreParams, PrivacyScoreToolResult } from './privacy-score export { threatCheckTool, executeThreatCheck } from './threat-check.js' export type { ThreatCheckParams, ThreatCheckToolResult } from './threat-check.js' + +export { scheduleSendTool, executeScheduleSend } from './schedule-send.js' +export type { ScheduleSendParams, ScheduleSendToolResult } from './schedule-send.js' diff --git a/packages/agent/src/tools/schedule-send.ts b/packages/agent/src/tools/schedule-send.ts new file mode 100644 index 0000000..0ae2b7a --- /dev/null +++ b/packages/agent/src/tools/schedule-send.ts @@ -0,0 +1,110 @@ +import type Anthropic from '@anthropic-ai/sdk' +import { createScheduledOp, getOrCreateSession } from '../db.js' + +export interface ScheduleSendParams { + wallet: string + amount: number + token: string + recipient: string + delayMinutes?: number + delayMinutesMin?: number + delayMinutesMax?: number + walletSignature: string +} + +export interface ScheduleSendToolResult { + action: 'scheduleSend' + status: 'success' + message: string + scheduled: { + opId: string + executesAt: number + amount: number + token: string + recipient: string + } +} + +function randomInRange(min: number, max: number): number { + return min + Math.random() * (max - min) +} + +export const scheduleSendTool: Anthropic.Tool = { + name: 'scheduleSend', + description: + 'Schedule a private send for later execution. ' + + 'Specify an exact delay or a random range (e.g. "in 4-8 hours"). ' + + 'The crank worker executes it automatically.', + input_schema: { + type: 'object' as const, + properties: { + wallet: { type: 'string', description: 'Your wallet address (base58)' }, + amount: { type: 'number', description: 'Amount to send' }, + token: { type: 'string', description: 'Token symbol (SOL, USDC, etc.)' }, + recipient: { type: 'string', description: 'Recipient address or stealth meta-address' }, + delayMinutes: { type: 'number', description: 'Exact delay in minutes' }, + delayMinutesMin: { type: 'number', description: 'Random delay range min (minutes)' }, + delayMinutesMax: { type: 'number', description: 'Random delay range max (minutes)' }, + walletSignature: { + type: 'string', + description: 'Wallet signature authorizing this scheduled operation', + }, + }, + required: ['wallet', 'amount', 'token', 'recipient'], + }, +} + +export async function executeScheduleSend( + params: ScheduleSendParams, +): Promise { + if (!params.wallet || params.wallet.trim().length === 0) { + throw new Error('Wallet address is required') + } + if (!params.amount || params.amount <= 0) { + throw new Error('Amount must be greater than zero') + } + + let delayMs: number + if (params.delayMinutes !== undefined) { + delayMs = params.delayMinutes * 60_000 + } else if (params.delayMinutesMin !== undefined && params.delayMinutesMax !== undefined) { + delayMs = randomInRange(params.delayMinutesMin, params.delayMinutesMax) * 60_000 + } else { + delayMs = randomInRange(30, 60) * 60_000 + } + + const now = Date.now() + const executesAt = now + delayMs + const expiresAt = executesAt + 60 * 60_000 + + const session = getOrCreateSession(params.wallet) + const op = createScheduledOp({ + session_id: session.id, + action: 'send', + params: { + amount: params.amount, + token: params.token, + recipient: params.recipient, + wallet: params.wallet, + }, + wallet_signature: params.walletSignature ?? 'pending', + next_exec: executesAt, + expires_at: expiresAt, + max_exec: 1, + }) + + const delayMinutes = Math.round(delayMs / 60_000) + + return { + action: 'scheduleSend', + status: 'success', + message: `Send of ${params.amount} ${params.token} scheduled in ~${delayMinutes} minutes. The crank worker will execute it automatically.`, + scheduled: { + opId: op.id, + executesAt, + amount: params.amount, + token: params.token, + recipient: params.recipient, + }, + } +} diff --git a/packages/agent/tests/schedule-send.test.ts b/packages/agent/tests/schedule-send.test.ts new file mode 100644 index 0000000..35ae5f0 --- /dev/null +++ b/packages/agent/tests/schedule-send.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { closeDb, getScheduledOp } from '../src/db.js' + +beforeEach(() => { process.env.DB_PATH = ':memory:' }) +afterEach(() => { closeDb(); delete process.env.DB_PATH }) + +const { scheduleSendTool, executeScheduleSend } = await import('../src/tools/schedule-send.js') + +describe('scheduleSend tool definition', () => { + it('has correct name', () => { expect(scheduleSendTool.name).toBe('scheduleSend') }) + it('requires wallet, amount, token, recipient', () => { + const req = scheduleSendTool.input_schema.required as string[] + expect(req).toContain('wallet') + expect(req).toContain('amount') + expect(req).toContain('token') + expect(req).toContain('recipient') + }) +}) + +describe('executeScheduleSend', () => { + it('creates a scheduled op with exact delay', async () => { + const result = await executeScheduleSend({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + amount: 100, token: 'USDC', recipient: 'RecipientAddr11111111111111111111', + delayMinutes: 60, walletSignature: 'sig123', + }) + expect(result.action).toBe('scheduleSend') + expect(result.status).toBe('success') + expect(result.scheduled.opId).toBeDefined() + expect(result.scheduled.executesAt).toBeGreaterThan(Date.now() + 55 * 60_000) + const op = getScheduledOp(result.scheduled.opId)! + expect(op.action).toBe('send') + expect(op.params.amount).toBe(100) + expect(op.max_exec).toBe(1) + expect(op.status).toBe('pending') + }) + + it('creates a scheduled op with random delay range', async () => { + const result = await executeScheduleSend({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + amount: 50, token: 'SOL', recipient: 'RecipientAddr11111111111111111111', + delayMinutesMin: 60, delayMinutesMax: 120, walletSignature: 'sig', + }) + const op = getScheduledOp(result.scheduled.opId)! + const delayMs = op.next_exec - Date.now() + expect(delayMs).toBeGreaterThan(55 * 60_000) + expect(delayMs).toBeLessThan(125 * 60_000) + }) + + it('defaults delay to 30-60 minutes when not specified', async () => { + const result = await executeScheduleSend({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + amount: 10, token: 'SOL', recipient: 'RecipientAddr11111111111111111111', + walletSignature: 'sig', + }) + const op = getScheduledOp(result.scheduled.opId)! + const delayMs = op.next_exec - Date.now() + expect(delayMs).toBeGreaterThan(25 * 60_000) + expect(delayMs).toBeLessThan(65 * 60_000) + }) + + it('sets expiry to delay + 1 hour', async () => { + const result = await executeScheduleSend({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + amount: 10, token: 'SOL', recipient: 'addr', + delayMinutes: 60, walletSignature: 'sig', + }) + const op = getScheduledOp(result.scheduled.opId)! + expect(op.expires_at).toBeGreaterThan(op.next_exec + 50 * 60_000) + }) + + it('throws when wallet is missing', async () => { + await expect(executeScheduleSend({ + amount: 10, token: 'SOL', recipient: 'addr', walletSignature: 'sig', + } as any)).rejects.toThrow(/wallet/i) + }) + + it('throws when amount is zero', async () => { + await expect(executeScheduleSend({ + wallet: 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + amount: 0, token: 'SOL', recipient: 'addr', walletSignature: 'sig', + })).rejects.toThrow(/amount/i) + }) +}) From 9a54206f4103924715533ec21f70eaecc94daa7b Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 01:07:20 +0700 Subject: [PATCH 15/92] =?UTF-8?q?feat(agent):=20add=20splitSend=20tool=20?= =?UTF-8?q?=E2=80=94=20random=20chunk=20splitting=20with=20staggered=20tim?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/tools/index.ts | 3 + packages/agent/src/tools/split-send.ts | 141 ++++++++++++++++++++++++ packages/agent/tests/split-send.test.ts | 99 +++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 packages/agent/src/tools/split-send.ts create mode 100644 packages/agent/tests/split-send.test.ts diff --git a/packages/agent/src/tools/index.ts b/packages/agent/src/tools/index.ts index 0298de3..2f97951 100644 --- a/packages/agent/src/tools/index.ts +++ b/packages/agent/src/tools/index.ts @@ -42,3 +42,6 @@ export type { ThreatCheckParams, ThreatCheckToolResult } from './threat-check.js export { scheduleSendTool, executeScheduleSend } from './schedule-send.js' export type { ScheduleSendParams, ScheduleSendToolResult } from './schedule-send.js' + +export { splitSendTool, executeSplitSend } from './split-send.js' +export type { SplitSendParams, SplitSendToolResult, ChunkInfo } from './split-send.js' diff --git a/packages/agent/src/tools/split-send.ts b/packages/agent/src/tools/split-send.ts new file mode 100644 index 0000000..4499d6b --- /dev/null +++ b/packages/agent/src/tools/split-send.ts @@ -0,0 +1,141 @@ +import type Anthropic from '@anthropic-ai/sdk' +import { createScheduledOp, getOrCreateSession } from '../db.js' + +// ───────────────────────────────────────────────────────────────────────────── +// splitSend tool — Split amount into N random chunks with staggered delays +// ───────────────────────────────────────────────────────────────────────────── + +export interface SplitSendParams { + wallet: string + amount: number + token: string + recipient: string + chunks?: number + spreadHours?: number + walletSignature: string +} + +export interface ChunkInfo { + opId: string + amount: number + executesAt: number +} + +export interface SplitSendToolResult { + action: 'splitSend' + status: 'success' + message: string + chunks: ChunkInfo[] + totalAmount: number + token: string + recipient: string +} + +function autoChunkCount(amount: number): number { + if (amount < 100) return 2 + if (amount < 1000) return 3 + if (amount < 10000) return 4 + return 5 +} + +/** Split total into N random parts that sum exactly to total. */ +function randomSplit(total: number, n: number): number[] { + if (n <= 1) return [total] + const cuts: number[] = [] + for (let i = 0; i < n - 1; i++) { + cuts.push(Math.random() * total) + } + cuts.sort((a, b) => a - b) + + const parts: number[] = [] + let prev = 0 + for (const cut of cuts) { + parts.push(Math.round((cut - prev) * 100) / 100) + prev = cut + } + parts.push(Math.round((total - prev) * 100) / 100) + + // Adjust rounding error on the last chunk + const sum = parts.reduce((s, p) => s + p, 0) + parts[parts.length - 1] += Math.round((total - sum) * 100) / 100 + + return parts +} + +export const splitSendTool: Anthropic.Tool = { + name: 'splitSend', + description: + 'Split a payment into N random chunks sent at staggered times. ' + + 'Breaks amount correlation and timing analysis. ' + + 'Chunk count auto-determined by amount size, or specify manually.', + input_schema: { + type: 'object' as const, + properties: { + wallet: { type: 'string', description: 'Your wallet address (base58)' }, + amount: { type: 'number', description: 'Total amount to send' }, + token: { type: 'string', description: 'Token symbol' }, + recipient: { type: 'string', description: 'Recipient address or stealth meta-address' }, + chunks: { type: 'number', description: 'Number of chunks (auto-determined if omitted)' }, + spreadHours: { type: 'number', description: 'Spread window in hours (default: 6)' }, + walletSignature: { type: 'string', description: 'Wallet signature authorizing the operation' }, + }, + required: ['wallet', 'amount', 'token', 'recipient'], + }, +} + +export async function executeSplitSend( + params: SplitSendParams, +): Promise { + if (!params.wallet || params.wallet.trim().length === 0) { + throw new Error('Wallet address is required') + } + if (!params.amount || params.amount <= 0) { + throw new Error('Amount must be greater than zero') + } + + const n = params.chunks ?? autoChunkCount(params.amount) + const spreadMs = (params.spreadHours ?? 6) * 3600_000 + const amounts = randomSplit(params.amount, n) + const token = params.token.toUpperCase() + + const session = getOrCreateSession(params.wallet) + const now = Date.now() + const chunks: ChunkInfo[] = [] + + for (let i = 0; i < n; i++) { + // Stagger evenly across the spread window with some randomness + const baseDelay = (i / (n - 1 || 1)) * spreadMs + const jitter = (Math.random() - 0.5) * (spreadMs / n) * 0.3 + const executesAt = now + Math.max(60_000, baseDelay + jitter) // min 1 minute + + const op = createScheduledOp({ + session_id: session.id, + action: 'send', + params: { + amount: amounts[i], + token, + recipient: params.recipient, + wallet: params.wallet, + }, + wallet_signature: params.walletSignature ?? 'pending', + next_exec: executesAt, + expires_at: executesAt + 3600_000, + max_exec: 1, + }) + + chunks.push({ opId: op.id, amount: amounts[i], executesAt }) + } + + // Sort by execution time + chunks.sort((a, b) => a.executesAt - b.executesAt) + + return { + action: 'splitSend', + status: 'success', + message: `Split ${params.amount} ${token} into ${n} chunks over ~${params.spreadHours ?? 6}h. Each chunk uses a unique stealth address.`, + chunks, + totalAmount: params.amount, + token, + recipient: params.recipient, + } +} diff --git a/packages/agent/tests/split-send.test.ts b/packages/agent/tests/split-send.test.ts new file mode 100644 index 0000000..d22a541 --- /dev/null +++ b/packages/agent/tests/split-send.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { closeDb, getScheduledOpsBySession, getOrCreateSession } from '../src/db.js' + +beforeEach(() => { + process.env.DB_PATH = ':memory:' +}) + +afterEach(() => { + closeDb() + delete process.env.DB_PATH +}) + +const { splitSendTool, executeSplitSend } = await import('../src/tools/split-send.js') + +const WALLET = 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' + +describe('splitSend tool definition', () => { + it('has correct name', () => { + expect(splitSendTool.name).toBe('splitSend') + }) +}) + +describe('executeSplitSend', () => { + it('auto-determines 2 chunks for amount < 100', async () => { + const result = await executeSplitSend({ + wallet: WALLET, amount: 50, token: 'SOL', recipient: 'addr', walletSignature: 'sig', + }) + expect(result.chunks).toHaveLength(2) + const total = result.chunks.reduce((s, c) => s + c.amount, 0) + expect(total).toBeCloseTo(50, 4) + }) + + it('auto-determines 3 chunks for amount < 1000', async () => { + const result = await executeSplitSend({ + wallet: WALLET, amount: 500, token: 'USDC', recipient: 'addr', walletSignature: 'sig', + }) + expect(result.chunks).toHaveLength(3) + }) + + it('auto-determines 4 chunks for amount < 10000', async () => { + const result = await executeSplitSend({ + wallet: WALLET, amount: 5000, token: 'USDC', recipient: 'addr', walletSignature: 'sig', + }) + expect(result.chunks).toHaveLength(4) + }) + + it('auto-determines 5 chunks for amount >= 10000', async () => { + const result = await executeSplitSend({ + wallet: WALLET, amount: 50000, token: 'USDC', recipient: 'addr', walletSignature: 'sig', + }) + expect(result.chunks).toHaveLength(5) + }) + + it('respects user override for chunk count', async () => { + const result = await executeSplitSend({ + wallet: WALLET, amount: 50, token: 'SOL', recipient: 'addr', chunks: 7, walletSignature: 'sig', + }) + expect(result.chunks).toHaveLength(7) + }) + + it('chunk amounts sum to total', async () => { + const result = await executeSplitSend({ + wallet: WALLET, amount: 1234.56, token: 'USDC', recipient: 'addr', walletSignature: 'sig', + }) + const total = result.chunks.reduce((s, c) => s + c.amount, 0) + expect(total).toBeCloseTo(1234.56, 2) + }) + + it('creates one scheduled_op per chunk', async () => { + const result = await executeSplitSend({ + wallet: WALLET, amount: 100, token: 'SOL', recipient: 'addr', walletSignature: 'sig', + }) + const session = getOrCreateSession(WALLET) + const ops = getScheduledOpsBySession(session.id) + expect(ops).toHaveLength(result.chunks.length) + expect(ops.every(op => op.action === 'send')).toBe(true) + expect(ops.every(op => op.max_exec === 1)).toBe(true) + }) + + it('staggers execution times over spread window', async () => { + const result = await executeSplitSend({ + wallet: WALLET, amount: 200, token: 'SOL', recipient: 'addr', walletSignature: 'sig', + }) + const times = result.chunks.map(c => c.executesAt) + // Should be sorted ascending + for (let i = 1; i < times.length; i++) { + expect(times[i]).toBeGreaterThan(times[i - 1]) + } + // Spread should be within 6 hours default + const spread = times[times.length - 1] - times[0] + expect(spread).toBeLessThan(6 * 3600_000 + 60_000) + }) + + it('throws when amount is zero', async () => { + await expect(executeSplitSend({ + wallet: WALLET, amount: 0, token: 'SOL', recipient: 'addr', walletSignature: 'sig', + })).rejects.toThrow(/amount/i) + }) +}) From b9ab2630244925d2e97acf9cf6c352e55c97901f Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 01:09:24 +0700 Subject: [PATCH 16/92] =?UTF-8?q?feat(agent):=20add=20drip=20tool=20?= =?UTF-8?q?=E2=80=94=20DCA-style=20private=20distribution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/tools/drip.ts | 117 ++++++++++++++++++++++++++++++ packages/agent/src/tools/index.ts | 3 + packages/agent/tests/drip.test.ts | 83 +++++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 packages/agent/src/tools/drip.ts create mode 100644 packages/agent/tests/drip.test.ts diff --git a/packages/agent/src/tools/drip.ts b/packages/agent/src/tools/drip.ts new file mode 100644 index 0000000..a17536b --- /dev/null +++ b/packages/agent/src/tools/drip.ts @@ -0,0 +1,117 @@ +import type Anthropic from '@anthropic-ai/sdk' +import { createScheduledOp, getOrCreateSession } from '../db.js' + +// ───────────────────────────────────────────────────────────────────────────── +// drip tool — DCA-style distribution over N days with amount jitter + timing jitter +// ───────────────────────────────────────────────────────────────────────────── + +export interface DripParams { + wallet: string + amount: number + token: string + recipient: string + days?: number + walletSignature: string +} + +export interface DripInfo { + opId: string + amount: number + executesAt: number +} + +export interface DripToolResult { + action: 'drip' + status: 'success' + message: string + drips: DripInfo[] + totalAmount: number + token: string + days: number +} + +export const dripTool: Anthropic.Tool = { + name: 'drip', + description: + 'DCA-style private distribution — send an amount over N days with randomized amounts and timing jitter. ' + + 'Each drip is a separate stealth send.', + input_schema: { + type: 'object' as const, + properties: { + wallet: { type: 'string', description: 'Your wallet address (base58)' }, + amount: { type: 'number', description: 'Total amount to distribute' }, + token: { type: 'string', description: 'Token symbol' }, + recipient: { type: 'string', description: 'Recipient address or stealth meta-address' }, + days: { type: 'number', description: 'Number of days to distribute over (default: 5)' }, + walletSignature: { type: 'string', description: 'Wallet signature authorizing the operation' }, + }, + required: ['wallet', 'amount', 'token', 'recipient'], + }, +} + +export async function executeDrip(params: DripParams): Promise { + if (!params.wallet || params.wallet.trim().length === 0) { + throw new Error('Wallet address is required') + } + if (!params.amount || params.amount <= 0) { + throw new Error('Amount must be greater than zero') + } + + const days = params.days ?? 5 + if (days < 1) throw new Error('Days must be at least 1') + + const token = params.token.toUpperCase() + const n = days + const equalSplit = params.amount / n + const session = getOrCreateSession(params.wallet) + const now = Date.now() + const dayMs = 24 * 3600_000 + const jitterMs = 4 * 3600_000 // +-4h jitter + + // Generate randomized amounts (+-10% of equal split) + const rawAmounts: number[] = [] + for (let i = 0; i < n; i++) { + const factor = 0.9 + Math.random() * 0.2 // 0.9 to 1.1 + rawAmounts.push(equalSplit * factor) + } + + // Normalize so they sum to exactly params.amount + const rawTotal = rawAmounts.reduce((s, a) => s + a, 0) + const amounts = rawAmounts.map(a => Math.round((a / rawTotal) * params.amount * 100) / 100) + + // Fix rounding error on last element + const sum = amounts.reduce((s, a) => s + a, 0) + amounts[amounts.length - 1] += Math.round((params.amount - sum) * 100) / 100 + + const drips: DripInfo[] = [] + for (let i = 0; i < n; i++) { + // Evenly space across `days` with +-4h jitter; min 1 minute from now + const intervalMs = (days * dayMs) / n + const scheduleTime = now + intervalMs * i + (Math.random() - 0.5) * jitterMs + const executesAt = Math.max(now + 60_000, scheduleTime) + + const op = createScheduledOp({ + session_id: session.id, + action: 'send', + params: { amount: amounts[i], token, recipient: params.recipient, wallet: params.wallet }, + wallet_signature: params.walletSignature ?? 'pending', + next_exec: executesAt, + expires_at: executesAt + dayMs, // expires 1 day after its scheduled time + max_exec: 1, + }) + + drips.push({ opId: op.id, amount: amounts[i], executesAt }) + } + + drips.sort((a, b) => a.executesAt - b.executesAt) + + return { + action: 'drip', + status: 'success', + message: `Distributing ${params.amount} ${token} over ${days} days in ${n} drips to ${params.recipient}.`, + drips, + totalAmount: params.amount, + token, + days, + } +} diff --git a/packages/agent/src/tools/index.ts b/packages/agent/src/tools/index.ts index 2f97951..b2deb4a 100644 --- a/packages/agent/src/tools/index.ts +++ b/packages/agent/src/tools/index.ts @@ -45,3 +45,6 @@ export type { ScheduleSendParams, ScheduleSendToolResult } from './schedule-send export { splitSendTool, executeSplitSend } from './split-send.js' export type { SplitSendParams, SplitSendToolResult, ChunkInfo } from './split-send.js' + +export { dripTool, executeDrip } from './drip.js' +export type { DripParams, DripToolResult, DripInfo } from './drip.js' diff --git a/packages/agent/tests/drip.test.ts b/packages/agent/tests/drip.test.ts new file mode 100644 index 0000000..62de21e --- /dev/null +++ b/packages/agent/tests/drip.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { closeDb, getScheduledOpsBySession, getOrCreateSession } from '../src/db.js' + +beforeEach(() => { + process.env.DB_PATH = ':memory:' +}) + +afterEach(() => { + closeDb() + delete process.env.DB_PATH +}) + +const { dripTool, executeDrip } = await import('../src/tools/drip.js') + +const WALLET = 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' + +describe('drip tool definition', () => { + it('has correct name', () => { + expect(dripTool.name).toBe('drip') + }) +}) + +describe('executeDrip', () => { + it('creates N drip ops over specified days', async () => { + const result = await executeDrip({ + wallet: WALLET, amount: 1000, token: 'USDC', recipient: 'addr', + days: 5, walletSignature: 'sig', + }) + expect(result.action).toBe('drip') + expect(result.drips).toHaveLength(5) + const total = result.drips.reduce((s, d) => s + d.amount, 0) + expect(total).toBeCloseTo(1000, 0) + }) + + it('amounts are randomized +-10% of equal split', async () => { + const result = await executeDrip({ + wallet: WALLET, amount: 1000, token: 'USDC', recipient: 'addr', + days: 10, walletSignature: 'sig', + }) + const equalSplit = 100 // 1000 / 10 + for (const drip of result.drips) { + expect(drip.amount).toBeGreaterThanOrEqual(equalSplit * 0.85) + expect(drip.amount).toBeLessThanOrEqual(equalSplit * 1.15) + } + }) + + it('spreads execution over the correct number of days', async () => { + const result = await executeDrip({ + wallet: WALLET, amount: 500, token: 'SOL', recipient: 'addr', + days: 5, walletSignature: 'sig', + }) + const first = result.drips[0].executesAt + const last = result.drips[result.drips.length - 1].executesAt + const spreadDays = (last - first) / (24 * 3600_000) + expect(spreadDays).toBeGreaterThan(3.5) + expect(spreadDays).toBeLessThan(5.5) + }) + + it('creates scheduled_ops in DB', async () => { + await executeDrip({ + wallet: WALLET, amount: 300, token: 'SOL', recipient: 'addr', + days: 3, walletSignature: 'sig', + }) + const session = getOrCreateSession(WALLET) + const ops = getScheduledOpsBySession(session.id) + expect(ops).toHaveLength(3) + expect(ops.every(op => op.action === 'send')).toBe(true) + }) + + it('throws when days is less than 1', async () => { + await expect(executeDrip({ + wallet: WALLET, amount: 100, token: 'SOL', recipient: 'addr', + days: 0, walletSignature: 'sig', + })).rejects.toThrow(/days/i) + }) + + it('defaults to 5 days when not specified', async () => { + const result = await executeDrip({ + wallet: WALLET, amount: 500, token: 'SOL', recipient: 'addr', walletSignature: 'sig', + }) + expect(result.drips).toHaveLength(5) + }) +}) From c1a8511b6a4ee277f7216306fe2afd54568981c6 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 01:11:31 +0700 Subject: [PATCH 17/92] =?UTF-8?q?feat(agent):=20add=20recurring=20tool=20?= =?UTF-8?q?=E2=80=94=20repeating=20private=20payments=20with=20jitter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/tools/recurring.ts | 107 +++++++++++++++++++++++++ packages/agent/tests/recurring.test.ts | 79 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 packages/agent/src/tools/recurring.ts create mode 100644 packages/agent/tests/recurring.test.ts diff --git a/packages/agent/src/tools/recurring.ts b/packages/agent/src/tools/recurring.ts new file mode 100644 index 0000000..33f8cf6 --- /dev/null +++ b/packages/agent/src/tools/recurring.ts @@ -0,0 +1,107 @@ +import type Anthropic from '@anthropic-ai/sdk' +import { createScheduledOp, getOrCreateSession } from '../db.js' + +// ───────────────────────────────────────────────────────────────────────────── +// recurring tool — repeating private payments on interval with amount jitter +// ───────────────────────────────────────────────────────────────────────────── + +export interface RecurringParams { + wallet: string + amount: number + token: string + recipient: string + intervalDays: number + maxExecutions: number + walletSignature: string +} + +export interface RecurringToolResult { + action: 'recurring' + status: 'success' + message: string + scheduled: { + opId: string + firstExecution: number + intervalDays: number + maxExecutions: number + expiresAt: number + } +} + +export const recurringTool: Anthropic.Tool = { + name: 'recurring', + description: + 'Set up recurring private payments on an interval. ' + + 'Amount randomized +-5% each execution. Timing jittered +-24h. ' + + 'Max execution count is REQUIRED (no infinite recurring).', + input_schema: { + type: 'object' as const, + properties: { + wallet: { type: 'string', description: 'Your wallet address (base58)' }, + amount: { type: 'number', description: 'Base amount per payment' }, + token: { type: 'string', description: 'Token symbol' }, + recipient: { type: 'string', description: 'Recipient address' }, + intervalDays: { type: 'number', description: 'Days between payments' }, + maxExecutions: { type: 'number', description: 'Maximum number of payments (required, no infinite)' }, + walletSignature: { type: 'string', description: 'Wallet signature authorizing the operation' }, + }, + required: ['wallet', 'amount', 'token', 'recipient', 'intervalDays', 'maxExecutions'], + }, +} + +export async function executeRecurring(params: RecurringParams): Promise { + if (!params.wallet || params.wallet.trim().length === 0) { + throw new Error('Wallet address is required') + } + if (!params.amount || params.amount <= 0) { + throw new Error('Amount must be greater than zero') + } + if (!params.intervalDays || params.intervalDays <= 0) { + throw new Error('Interval must be at least 1 day') + } + if (!params.maxExecutions || params.maxExecutions <= 0) { + throw new Error('maxExecutions is required and must be positive') + } + + const token = params.token.toUpperCase() + const intervalMs = params.intervalDays * 24 * 3600_000 + const jitterMs = 24 * 3600_000 // +-24h jitter + const now = Date.now() + + // First execution: interval from now with jitter + const firstExec = now + intervalMs + (Math.random() - 0.5) * jitterMs + + // Expires: enough time for all executions + 1 extra interval buffer + const expiresAt = now + (params.maxExecutions + 1) * intervalMs + + const session = getOrCreateSession(params.wallet) + const op = createScheduledOp({ + session_id: session.id, + action: 'send', + params: { + amount: params.amount, + token, + recipient: params.recipient, + wallet: params.wallet, + intervalMs, + amountJitterPct: 0.05, + }, + wallet_signature: params.walletSignature ?? 'pending', + next_exec: firstExec, + expires_at: expiresAt, + max_exec: params.maxExecutions, + }) + + return { + action: 'recurring', + status: 'success', + message: `Recurring payment: ~${params.amount} ${token} every ${params.intervalDays} days to ${params.recipient}. Max ${params.maxExecutions} payments.`, + scheduled: { + opId: op.id, + firstExecution: firstExec, + intervalDays: params.intervalDays, + maxExecutions: params.maxExecutions, + expiresAt, + }, + } +} diff --git a/packages/agent/tests/recurring.test.ts b/packages/agent/tests/recurring.test.ts new file mode 100644 index 0000000..4acc630 --- /dev/null +++ b/packages/agent/tests/recurring.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { closeDb, getScheduledOp } from '../src/db.js' + +beforeEach(() => { process.env.DB_PATH = ':memory:' }) +afterEach(() => { closeDb(); delete process.env.DB_PATH }) + +const { recurringTool, executeRecurring } = await import('../src/tools/recurring.js') +const WALLET = 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' + +describe('recurring tool definition', () => { + it('has correct name', () => { expect(recurringTool.name).toBe('recurring') }) + it('requires maxExecutions', () => { + expect(recurringTool.input_schema.required).toContain('maxExecutions') + }) +}) + +describe('executeRecurring', () => { + it('creates a single recurring scheduled op', async () => { + const result = await executeRecurring({ + wallet: WALLET, amount: 500, token: 'USDC', recipient: 'addr', + intervalDays: 14, maxExecutions: 6, walletSignature: 'sig', + }) + expect(result.action).toBe('recurring') + expect(result.scheduled.opId).toBeDefined() + expect(result.scheduled.maxExecutions).toBe(6) + expect(result.scheduled.intervalDays).toBe(14) + + const op = getScheduledOp(result.scheduled.opId)! + expect(op.max_exec).toBe(6) + expect(op.params.intervalMs).toBe(14 * 24 * 3600_000) + expect(op.status).toBe('pending') + }) + + it('first execution is approximately intervalDays from now', async () => { + const result = await executeRecurring({ + wallet: WALLET, amount: 100, token: 'SOL', recipient: 'addr', + intervalDays: 7, maxExecutions: 4, walletSignature: 'sig', + }) + const op = getScheduledOp(result.scheduled.opId)! + const delayDays = (op.next_exec - Date.now()) / (24 * 3600_000) + // Should be within ~1 day of 7 (jitter +-24h) + expect(delayDays).toBeGreaterThan(5.5) + expect(delayDays).toBeLessThan(8.5) + }) + + it('stores amountJitterPct in params', async () => { + const result = await executeRecurring({ + wallet: WALLET, amount: 100, token: 'SOL', recipient: 'addr', + intervalDays: 7, maxExecutions: 2, walletSignature: 'sig', + }) + const op = getScheduledOp(result.scheduled.opId)! + expect(op.params.amountJitterPct).toBe(0.05) // default 5% + }) + + it('throws when maxExecutions is missing', async () => { + await expect(executeRecurring({ + wallet: WALLET, amount: 100, token: 'SOL', recipient: 'addr', + intervalDays: 7, walletSignature: 'sig', + } as any)).rejects.toThrow(/maxExecutions/i) + }) + + it('throws when intervalDays is zero', async () => { + await expect(executeRecurring({ + wallet: WALLET, amount: 100, token: 'SOL', recipient: 'addr', + intervalDays: 0, maxExecutions: 3, walletSignature: 'sig', + })).rejects.toThrow(/interval/i) + }) + + it('sets expiry based on total schedule duration', async () => { + const result = await executeRecurring({ + wallet: WALLET, amount: 100, token: 'SOL', recipient: 'addr', + intervalDays: 7, maxExecutions: 4, walletSignature: 'sig', + }) + const op = getScheduledOp(result.scheduled.opId)! + // Expires after: maxExecutions * interval + buffer + const expectedMinExpiry = Date.now() + 4 * 7 * 24 * 3600_000 + expect(op.expires_at).toBeGreaterThan(expectedMinExpiry) + }) +}) From 4716b0419ede10f1fdbbd74dc013aa27c0e35bee Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 01:13:16 +0700 Subject: [PATCH 18/92] =?UTF-8?q?feat(agent):=20add=20sweep=20tool=20?= =?UTF-8?q?=E2=80=94=20auto-shield=20incoming=20wallet=20funds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/tools/sweep.ts | 84 ++++++++++++++++++++++++++++++ packages/agent/tests/sweep.test.ts | 58 +++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 packages/agent/src/tools/sweep.ts create mode 100644 packages/agent/tests/sweep.test.ts diff --git a/packages/agent/src/tools/sweep.ts b/packages/agent/src/tools/sweep.ts new file mode 100644 index 0000000..fe66253 --- /dev/null +++ b/packages/agent/src/tools/sweep.ts @@ -0,0 +1,84 @@ +import type Anthropic from '@anthropic-ai/sdk' +import { createScheduledOp, getOrCreateSession, getScheduledOpsBySession } from '../db.js' + +// ───────────────────────────────────────────────────────────────────────────── +// sweep tool — auto-shield incoming wallet funds via persistent scheduled op +// ───────────────────────────────────────────────────────────────────────────── + +export interface SweepParams { + wallet: string + token?: string + walletSignature: string +} + +export interface SweepToolResult { + action: 'sweep' + status: 'success' + message: string + sweep: { + opId: string + token: string + expiresAt: number + } +} + +export const sweepTool: Anthropic.Tool = { + name: 'sweep', + description: + 'Auto-shield incoming wallet funds. ' + + 'Monitors your wallet for new token transfers and automatically deposits them into the vault. ' + + 'Phase 1: poll-based (every 60 seconds via crank).', + input_schema: { + type: 'object' as const, + properties: { + wallet: { type: 'string', description: 'Wallet to monitor (base58)' }, + token: { type: 'string', description: 'Token to sweep (default: SOL)' }, + walletSignature: { type: 'string', description: 'Wallet signature authorizing auto-deposits' }, + }, + required: ['wallet'], + }, +} + +export async function executeSweep(params: SweepParams): Promise { + if (!params.wallet || params.wallet.trim().length === 0) { + throw new Error('Wallet address is required for sweep') + } + + const token = (params.token ?? 'SOL').toUpperCase() + const session = getOrCreateSession(params.wallet) + + // Prevent duplicate: one active sweep per wallet+token + const existing = getScheduledOpsBySession(session.id) + const activeSweep = existing.find( + op => op.action === 'sweep' && op.status === 'pending' && + (op.params.token as string) === token, + ) + if (activeSweep) { + throw new Error(`Sweep already active for ${token} on this wallet`) + } + + const now = Date.now() + const thirtyDays = 30 * 24 * 3600_000 + const expiresAt = now + thirtyDays + + const op = createScheduledOp({ + session_id: session.id, + action: 'sweep', + params: { + wallet: params.wallet, + token, + intervalMs: 60_000, // crank re-schedules every 60s + }, + wallet_signature: params.walletSignature ?? 'pending', + next_exec: now + 60_000, // first poll in 1 minute + expires_at: expiresAt, + max_exec: 999_999, // effectively unlimited — persistent monitor + }) + + return { + action: 'sweep', + status: 'success', + message: `Auto-sweep enabled for ${token}. Incoming transfers will be auto-deposited into the vault.`, + sweep: { opId: op.id, token, expiresAt }, + } +} diff --git a/packages/agent/tests/sweep.test.ts b/packages/agent/tests/sweep.test.ts new file mode 100644 index 0000000..340f157 --- /dev/null +++ b/packages/agent/tests/sweep.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { closeDb, getScheduledOp, getScheduledOpsBySession, getOrCreateSession } from '../src/db.js' + +beforeEach(() => { process.env.DB_PATH = ':memory:' }) +afterEach(() => { closeDb(); delete process.env.DB_PATH }) + +const { sweepTool, executeSweep } = await import('../src/tools/sweep.js') +const WALLET = 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' + +describe('sweep tool definition', () => { + it('has correct name', () => { expect(sweepTool.name).toBe('sweep') }) +}) + +describe('executeSweep', () => { + it('creates a persistent sweep scheduled op', async () => { + const result = await executeSweep({ + wallet: WALLET, token: 'SOL', walletSignature: 'sig', + }) + expect(result.action).toBe('sweep') + expect(result.status).toBe('success') + expect(result.sweep.opId).toBeDefined() + + const op = getScheduledOp(result.sweep.opId)! + expect(op.action).toBe('sweep') + expect(op.params.wallet).toBe(WALLET) + expect(op.params.token).toBe('SOL') + expect(op.max_exec).toBeGreaterThan(1000) + expect(op.status).toBe('pending') + }) + + it('sets next_exec to near-immediate', async () => { + const result = await executeSweep({ + wallet: WALLET, token: 'SOL', walletSignature: 'sig', + }) + const op = getScheduledOp(result.sweep.opId)! + expect(op.next_exec - Date.now()).toBeLessThan(61_000) + }) + + it('sets long expiry (30 days default)', async () => { + const result = await executeSweep({ + wallet: WALLET, token: 'SOL', walletSignature: 'sig', + }) + const op = getScheduledOp(result.sweep.opId)! + const thirtyDays = 30 * 24 * 3600_000 + expect(op.expires_at - Date.now()).toBeGreaterThan(thirtyDays - 60_000) + }) + + it('throws when wallet is missing', async () => { + await expect(executeSweep({ token: 'SOL', walletSignature: 'sig' } as any)) + .rejects.toThrow(/wallet/i) + }) + + it('prevents duplicate sweep for same wallet+token', async () => { + await executeSweep({ wallet: WALLET, token: 'SOL', walletSignature: 'sig' }) + await expect(executeSweep({ wallet: WALLET, token: 'SOL', walletSignature: 'sig' })) + .rejects.toThrow(/already.*active/i) + }) +}) From 058fce96684959b5485980fb9a10ce0a27c339f1 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 01:15:14 +0700 Subject: [PATCH 19/92] =?UTF-8?q?feat(agent):=20add=20consolidate=20tool?= =?UTF-8?q?=20=E2=80=94=20staggered=20stealth=20balance=20merging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/tools/consolidate.ts | 136 +++++++++++++++++++++++ packages/agent/tests/consolidate.test.ts | 68 ++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 packages/agent/src/tools/consolidate.ts create mode 100644 packages/agent/tests/consolidate.test.ts diff --git a/packages/agent/src/tools/consolidate.ts b/packages/agent/src/tools/consolidate.ts new file mode 100644 index 0000000..8da8a31 --- /dev/null +++ b/packages/agent/src/tools/consolidate.ts @@ -0,0 +1,136 @@ +import type Anthropic from '@anthropic-ai/sdk' +import { createScheduledOp, getOrCreateSession } from '../db.js' +import { createConnection, scanForPayments } from '@sipher/sdk' + +// ───────────────────────────────────────────────────────────────────────────── +// consolidate tool — Merge multiple unclaimed stealth balances with staggered +// claim timing to prevent clustering analysis from simultaneous claims. +// ───────────────────────────────────────────────────────────────────────────── + +export interface ConsolidateParams { + wallet: string + viewingKey: string + spendingKey: string + walletSignature: string +} + +export interface ClaimInfo { + opId: string + txSignature: string + stealthAddress: string + executesAt: number +} + +export interface ConsolidateToolResult { + action: 'consolidate' + status: 'success' + message: string + claims: ClaimInfo[] + paymentsFound: number +} + +export const consolidateTool: Anthropic.Tool = { + name: 'consolidate', + description: + 'Merge multiple unclaimed stealth balances with staggered claim timing. ' + + 'Scans for unclaimed payments and schedules claims with random 1-4h delays between each. ' + + 'Prevents clustering analysis from simultaneous claims.', + input_schema: { + type: 'object' as const, + properties: { + wallet: { type: 'string', description: 'Your wallet address (base58)' }, + viewingKey: { type: 'string', description: 'Your viewing private key (hex)' }, + spendingKey: { type: 'string', description: 'Your spending private key (hex)' }, + walletSignature: { type: 'string', description: 'Wallet signature authorizing claims' }, + }, + required: ['wallet', 'viewingKey', 'spendingKey'], + }, +} + +export async function executeConsolidate( + params: ConsolidateParams, +): Promise { + if (!params.wallet || params.wallet.trim().length === 0) { + throw new Error('Wallet address is required') + } + if (!params.viewingKey || params.viewingKey.trim().length === 0) { + throw new Error('Viewing key is required for scanning') + } + if (!params.spendingKey || params.spendingKey.trim().length === 0) { + throw new Error('Spending key is required for claiming') + } + + const network = (process.env.SOLANA_NETWORK ?? 'devnet') as 'devnet' | 'mainnet-beta' + const connection = createConnection(network) + + // Parse hex keys → Uint8Array (strip optional 0x prefix) + const vkHex = params.viewingKey.replace(/^0x/, '') + const viewingPrivateKey = new Uint8Array( + vkHex.match(/.{1,2}/g)?.map(b => parseInt(b, 16)) ?? [], + ) + const skHex = params.spendingKey.replace(/^0x/, '') + const spendingPrivateKey = new Uint8Array( + skHex.match(/.{1,2}/g)?.map(b => parseInt(b, 16)) ?? [], + ) + + const scanResult = await scanForPayments({ + connection, viewingPrivateKey, spendingPrivateKey, limit: 200, + }) + + if (scanResult.payments.length === 0) { + return { + action: 'consolidate', + status: 'success', + message: 'No unclaimed payments found to consolidate.', + claims: [], + paymentsFound: 0, + } + } + + const session = getOrCreateSession(params.wallet) + const now = Date.now() + const claims: ClaimInfo[] = [] + + for (let i = 0; i < scanResult.payments.length; i++) { + const payment = scanResult.payments[i] + + // Stagger: 1-4 hours of cumulative gap between each claim + const minGap = 1 * 3600_000 + const maxGap = 4 * 3600_000 + const cumulativeDelay = i * (minGap + Math.random() * (maxGap - minGap)) + const executesAt = now + Math.max(60_000, cumulativeDelay) + + const op = createScheduledOp({ + session_id: session.id, + action: 'claim', + params: { + wallet: params.wallet, + txSignature: payment.txSignature, + stealthAddress: payment.stealthAddress.toBase58(), + viewingKey: params.viewingKey, + spendingKey: params.spendingKey, + }, + wallet_signature: params.walletSignature ?? 'pending', + next_exec: executesAt, + expires_at: executesAt + 24 * 3600_000, + max_exec: 1, + }) + + claims.push({ + opId: op.id, + txSignature: payment.txSignature, + stealthAddress: payment.stealthAddress.toBase58(), + executesAt, + }) + } + + claims.sort((a, b) => a.executesAt - b.executesAt) + + return { + action: 'consolidate', + status: 'success', + message: `Found ${scanResult.payments.length} unclaimed payments. Scheduling staggered claims over ~${Math.round(claims.length * 2.5)}h.`, + claims, + paymentsFound: scanResult.payments.length, + } +} diff --git a/packages/agent/tests/consolidate.test.ts b/packages/agent/tests/consolidate.test.ts new file mode 100644 index 0000000..6c231e4 --- /dev/null +++ b/packages/agent/tests/consolidate.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { closeDb, getScheduledOpsBySession, getOrCreateSession } from '../src/db.js' + +vi.mock('@sipher/sdk', () => ({ + createConnection: vi.fn().mockReturnValue({ + getSignaturesForAddress: vi.fn().mockResolvedValue([]), + getParsedTransactions: vi.fn().mockResolvedValue([]), + }), + scanForPayments: vi.fn().mockResolvedValue({ + payments: [ + { txSignature: 'tx1', stealthAddress: { toBase58: () => 'stealth1' }, transferAmount: 1000000000n, feeAmount: 5000000n, timestamp: 1700000000 }, + { txSignature: 'tx2', stealthAddress: { toBase58: () => 'stealth2' }, transferAmount: 2000000000n, feeAmount: 10000000n, timestamp: 1700000100 }, + { txSignature: 'tx3', stealthAddress: { toBase58: () => 'stealth3' }, transferAmount: 500000000n, feeAmount: 2500000n, timestamp: 1700000200 }, + ], + eventsScanned: 100, + hasMore: false, + }), +})) + +beforeEach(() => { process.env.DB_PATH = ':memory:' }) +afterEach(() => { closeDb(); delete process.env.DB_PATH }) + +const { consolidateTool, executeConsolidate } = await import('../src/tools/consolidate.js') +const WALLET = 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' +const VIEW_KEY = 'ab'.repeat(32) +const SPEND_KEY = 'cd'.repeat(32) + +describe('consolidate tool definition', () => { + it('has correct name', () => { expect(consolidateTool.name).toBe('consolidate') }) +}) + +describe('executeConsolidate', () => { + it('creates staggered claim ops for unclaimed payments', async () => { + const result = await executeConsolidate({ + wallet: WALLET, viewingKey: VIEW_KEY, spendingKey: SPEND_KEY, walletSignature: 'sig', + }) + expect(result.action).toBe('consolidate') + expect(result.claims).toHaveLength(3) + expect(result.claims.every(c => c.opId)).toBe(true) + }) + + it('staggers claim times 1-4h apart', async () => { + const result = await executeConsolidate({ + wallet: WALLET, viewingKey: VIEW_KEY, spendingKey: SPEND_KEY, walletSignature: 'sig', + }) + for (let i = 1; i < result.claims.length; i++) { + const gap = result.claims[i].executesAt - result.claims[i - 1].executesAt + expect(gap).toBeGreaterThanOrEqual(55 * 60_000) // ~1h min + expect(gap).toBeLessThanOrEqual(4.5 * 3600_000) // ~4h max + } + }) + + it('creates scheduled_ops in DB', async () => { + await executeConsolidate({ + wallet: WALLET, viewingKey: VIEW_KEY, spendingKey: SPEND_KEY, walletSignature: 'sig', + }) + const session = getOrCreateSession(WALLET) + const ops = getScheduledOpsBySession(session.id) + expect(ops).toHaveLength(3) + expect(ops.every(op => op.action === 'claim')).toBe(true) + }) + + it('throws when viewing key is missing', async () => { + await expect(executeConsolidate({ + wallet: WALLET, spendingKey: SPEND_KEY, walletSignature: 'sig', + } as any)).rejects.toThrow(/viewing/i) + }) +}) From 8db2b26af52478eb15ab9619b87f8321e5b59479 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 01:19:36 +0700 Subject: [PATCH 20/92] fix(agent): fix flaky timing tests in consolidate and split-send (monotonic delays) --- packages/agent/src/tools/consolidate.ts | 13 ++++++++----- packages/agent/tests/split-send.test.ts | 8 ++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/agent/src/tools/consolidate.ts b/packages/agent/src/tools/consolidate.ts index 8da8a31..a314474 100644 --- a/packages/agent/src/tools/consolidate.ts +++ b/packages/agent/src/tools/consolidate.ts @@ -90,15 +90,18 @@ export async function executeConsolidate( const session = getOrCreateSession(params.wallet) const now = Date.now() const claims: ClaimInfo[] = [] + const minGap = 1 * 3600_000 + const maxGap = 4 * 3600_000 + let cumulativeMs = 0 for (let i = 0; i < scanResult.payments.length; i++) { const payment = scanResult.payments[i] - // Stagger: 1-4 hours of cumulative gap between each claim - const minGap = 1 * 3600_000 - const maxGap = 4 * 3600_000 - const cumulativeDelay = i * (minGap + Math.random() * (maxGap - minGap)) - const executesAt = now + Math.max(60_000, cumulativeDelay) + // Stagger: 1-4 hours between each claim (cumulative, monotonic) + if (i > 0) { + cumulativeMs += minGap + Math.random() * (maxGap - minGap) + } + const executesAt = now + Math.max(60_000, cumulativeMs) const op = createScheduledOp({ session_id: session.id, diff --git a/packages/agent/tests/split-send.test.ts b/packages/agent/tests/split-send.test.ts index d22a541..6d6350c 100644 --- a/packages/agent/tests/split-send.test.ts +++ b/packages/agent/tests/split-send.test.ts @@ -82,13 +82,13 @@ describe('executeSplitSend', () => { wallet: WALLET, amount: 200, token: 'SOL', recipient: 'addr', walletSignature: 'sig', }) const times = result.chunks.map(c => c.executesAt) - // Should be sorted ascending + // Should be sorted ascending (or equal for close chunks) for (let i = 1; i < times.length; i++) { - expect(times[i]).toBeGreaterThan(times[i - 1]) + expect(times[i]).toBeGreaterThanOrEqual(times[i - 1]) } - // Spread should be within 6 hours default + // Spread should be within 6 hours default (plus jitter tolerance) const spread = times[times.length - 1] - times[0] - expect(spread).toBeLessThan(6 * 3600_000 + 60_000) + expect(spread).toBeLessThan(7 * 3600_000) }) it('throws when amount is zero', async () => { From faadbd13aac4591007ff0e8245e3b4f302667a7d Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 01:22:53 +0700 Subject: [PATCH 21/92] feat(agent): wire 7 time-based privacy tools + crank engine (21 total tools) --- packages/agent/src/agent.ts | 36 +++++++++++++++++++++++++++-- packages/agent/src/index.ts | 5 ++++ packages/agent/src/tools/index.ts | 12 ++++++++++ packages/agent/tests/stream.test.ts | 14 +++++++++++ packages/agent/tests/tools.test.ts | 26 +++++++++++++++++---- 5 files changed, 87 insertions(+), 6 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 57f7492..f7b2ede 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -28,6 +28,20 @@ import { executePrivacyScore, threatCheckTool, executeThreatCheck, + roundAmountTool, + executeRoundAmount, + scheduleSendTool, + executeScheduleSend, + splitSendTool, + executeSplitSend, + dripTool, + executeDrip, + recurringTool, + executeRecurring, + sweepTool, + executeSweep, + consolidateTool, + executeConsolidate, } from './tools/index.js' // ───────────────────────────────────────────────────────────────────────────── @@ -43,7 +57,7 @@ Users deposit tokens, then you execute private sends, swaps, and refunds. Tone: Confident, technical, slightly cypherpunk. Never corporate. Never say "I'm just an AI." Speak like a privacy engineer who cares. -Available tools: deposit, send, refund, balance, scan, claim, swap, viewingKey, history, status, paymentLink, invoice, privacyScore, threatCheck. +Available tools: deposit, send, refund, balance, scan, claim, swap, viewingKey, history, status, paymentLink, invoice, privacyScore, threatCheck, roundAmount, scheduleSend, splitSend, drip, recurring, sweep, consolidate. Rules: - Every fund-moving operation shows a confirmation before executing @@ -53,7 +67,11 @@ Rules: - Be concise — bullet points over paragraphs - Before large sends, run threatCheck on the recipient address - Offer privacyScore when users ask about their wallet's exposure -- Payment links and invoices generate stealth addresses — sender needs no Sipher account` +- Payment links and invoices generate stealth addresses — sender needs no Sipher account +- Scheduled ops (scheduleSend, splitSend, drip, recurring) run via the crank worker — user signs once +- Suggest splitSend for large amounts and drip for recurring distributions +- sweep is persistent — runs every 60 seconds until expired +- consolidate staggers claims to prevent timing analysis` // ───────────────────────────────────────────────────────────────────────────── // Tool registry @@ -74,6 +92,13 @@ export const TOOLS: Anthropic.Tool[] = [ invoiceTool, privacyScoreTool, threatCheckTool, + roundAmountTool, + scheduleSendTool, + splitSendTool, + dripTool, + recurringTool, + sweepTool, + consolidateTool, ] type ToolExecutor = (params: Record) => Promise @@ -93,6 +118,13 @@ const TOOL_EXECUTORS: Record = { invoice: (p) => executeInvoice(p as unknown as Parameters[0]), privacyScore: (p) => executePrivacyScore(p as unknown as Parameters[0]), threatCheck: (p) => executeThreatCheck(p as unknown as Parameters[0]), + roundAmount: (p) => executeRoundAmount(p as unknown as Parameters[0]), + scheduleSend: (p) => executeScheduleSend(p as unknown as Parameters[0]), + splitSend: (p) => executeSplitSend(p as unknown as Parameters[0]), + drip: (p) => executeDrip(p as unknown as Parameters[0]), + recurring: (p) => executeRecurring(p as unknown as Parameters[0]), + sweep: (p) => executeSweep(p as unknown as Parameters[0]), + consolidate: (p) => executeConsolidate(p as unknown as Parameters[0]), } /** diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index fa115af..94bd098 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url' import path from 'node:path' import express from 'express' import { chat, chatStream, SYSTEM_PROMPT, TOOLS, executeTool } from './agent.js' +import { startCrank } from './crank.js' import { getDb, expireStaleLinks } from './db.js' import { resolveSession, activeSessionCount, purgeStale } from './session.js' import { payRouter } from './routes/pay.js' @@ -14,6 +15,10 @@ import { adminRouter } from './routes/admin.js' getDb() console.log(' Database: SQLite initialized') +// Start crank worker (60s interval for scheduled operations) +startCrank((action, params) => executeTool(action, params)) +console.log(' Crank: 60s interval (scheduled ops)') + // Purge stale in-memory conversations every 5 minutes setInterval(() => { const purged = purgeStale() diff --git a/packages/agent/src/tools/index.ts b/packages/agent/src/tools/index.ts index b2deb4a..f06a16c 100644 --- a/packages/agent/src/tools/index.ts +++ b/packages/agent/src/tools/index.ts @@ -40,6 +40,9 @@ export type { PrivacyScoreParams, PrivacyScoreToolResult } from './privacy-score export { threatCheckTool, executeThreatCheck } from './threat-check.js' export type { ThreatCheckParams, ThreatCheckToolResult } from './threat-check.js' +export { roundAmountTool, executeRoundAmount } from './round-amount.js' +export type { RoundAmountParams, RoundAmountToolResult } from './round-amount.js' + export { scheduleSendTool, executeScheduleSend } from './schedule-send.js' export type { ScheduleSendParams, ScheduleSendToolResult } from './schedule-send.js' @@ -48,3 +51,12 @@ export type { SplitSendParams, SplitSendToolResult, ChunkInfo } from './split-se export { dripTool, executeDrip } from './drip.js' export type { DripParams, DripToolResult, DripInfo } from './drip.js' + +export { recurringTool, executeRecurring } from './recurring.js' +export type { RecurringParams, RecurringToolResult } from './recurring.js' + +export { sweepTool, executeSweep } from './sweep.js' +export type { SweepParams, SweepToolResult } from './sweep.js' + +export { consolidateTool, executeConsolidate } from './consolidate.js' +export type { ConsolidateParams, ConsolidateToolResult } from './consolidate.js' diff --git a/packages/agent/tests/stream.test.ts b/packages/agent/tests/stream.test.ts index c07b19a..65c03ac 100644 --- a/packages/agent/tests/stream.test.ts +++ b/packages/agent/tests/stream.test.ts @@ -76,6 +76,20 @@ vi.mock('../src/tools/index.js', () => { executePrivacyScore: makeExecutor(), threatCheckTool: makeTool('threatCheck'), executeThreatCheck: makeExecutor(), + roundAmountTool: makeTool('roundAmount'), + executeRoundAmount: makeExecutor(), + scheduleSendTool: makeTool('scheduleSend'), + executeScheduleSend: makeExecutor(), + splitSendTool: makeTool('splitSend'), + executeSplitSend: makeExecutor(), + dripTool: makeTool('drip'), + executeDrip: makeExecutor(), + recurringTool: makeTool('recurring'), + executeRecurring: makeExecutor(), + sweepTool: makeTool('sweep'), + executeSweep: makeExecutor(), + consolidateTool: makeTool('consolidate'), + executeConsolidate: makeExecutor(), } }) diff --git a/packages/agent/tests/tools.test.ts b/packages/agent/tests/tools.test.ts index 0784eaa..67949da 100644 --- a/packages/agent/tests/tools.test.ts +++ b/packages/agent/tests/tools.test.ts @@ -28,6 +28,13 @@ import { executePrivacyScore, threatCheckTool, executeThreatCheck, + roundAmountTool, + scheduleSendTool, + splitSendTool, + dripTool, + recurringTool, + sweepTool, + consolidateTool, } from '../src/tools/index.js' import { TOOLS, SYSTEM_PROMPT, executeTool } from '../src/agent.js' @@ -114,16 +121,20 @@ describe('tool definitions', () => { depositTool, sendTool, refundTool, balanceTool, scanTool, claimTool, swapTool, viewingKeyTool, historyTool, statusTool, paymentLinkTool, invoiceTool, privacyScoreTool, threatCheckTool, + roundAmountTool, scheduleSendTool, splitSendTool, dripTool, + recurringTool, sweepTool, consolidateTool, ] const toolNames = [ 'deposit', 'send', 'refund', 'balance', 'scan', 'claim', 'swap', 'viewingKey', 'history', 'status', 'paymentLink', 'invoice', 'privacyScore', 'threatCheck', + 'roundAmount', 'scheduleSend', 'splitSend', 'drip', + 'recurring', 'sweep', 'consolidate', ] - it('exports exactly 14 tools', () => { - expect(allTools).toHaveLength(14) - expect(TOOLS).toHaveLength(14) + it('exports exactly 21 tools', () => { + expect(allTools).toHaveLength(21) + expect(TOOLS).toHaveLength(21) }) it('all tools have unique names', () => { @@ -175,7 +186,7 @@ describe('system prompt', () => { expect(SYSTEM_PROMPT).toContain('Plug in. Go private.') }) - it('references all 14 tools', () => { + it('references all 21 tools', () => { expect(SYSTEM_PROMPT).toContain('deposit') expect(SYSTEM_PROMPT).toContain('send') expect(SYSTEM_PROMPT).toContain('refund') @@ -190,6 +201,13 @@ describe('system prompt', () => { expect(SYSTEM_PROMPT).toContain('invoice') expect(SYSTEM_PROMPT).toContain('privacyScore') expect(SYSTEM_PROMPT).toContain('threatCheck') + expect(SYSTEM_PROMPT).toContain('roundAmount') + expect(SYSTEM_PROMPT).toContain('scheduleSend') + expect(SYSTEM_PROMPT).toContain('splitSend') + expect(SYSTEM_PROMPT).toContain('drip') + expect(SYSTEM_PROMPT).toContain('recurring') + expect(SYSTEM_PROMPT).toContain('sweep') + expect(SYSTEM_PROMPT).toContain('consolidate') }) it('includes the confirmation rule for fund-moving operations', () => { From 214f066452640191f586871f59b7124a00889dd9 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 10:47:01 +0700 Subject: [PATCH 22/92] chore: swap anthropic sdk for pi-agent-core + pi-ai + infra deps --- packages/agent/package.json | 9 +- pnpm-lock.yaml | 1885 +++++++++++++++++++++++++++++++---- 2 files changed, 1712 insertions(+), 182 deletions(-) diff --git a/packages/agent/package.json b/packages/agent/package.json index 11ecdbd..9495116 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -9,15 +9,22 @@ "test": "vitest run" }, "dependencies": { - "@anthropic-ai/sdk": "^0.39.0", + "@mariozechner/pi-agent-core": "^0.66.1", + "@mariozechner/pi-ai": "^0.66.1", + "@sinclair/typebox": "^0.34.49", "@sipher/sdk": "workspace:*", "better-sqlite3": "^12.8.0", "express": "^5.0.0", + "ioredis": "^5.9.2", + "jsonwebtoken": "^9.0.3", + "ulid": "^3.0.2", "ws": "^8.18.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.0", + "@types/ioredis": "^5.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/supertest": "^6.0.3", "@types/ws": "^8.5.0", "supertest": "^7.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e7d751..6b29871 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 1.8.0 '@sip-protocol/sdk': specifier: ^0.7.4 - version: 0.7.4(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod-to-json-schema@3.25.1(zod@3.25.76)) + version: 0.7.4(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod-to-json-schema@3.25.1(zod@3.25.76)) '@sip-protocol/types': specifier: ^0.2.2 version: 0.2.2 @@ -145,9 +145,15 @@ importers: packages/agent: dependencies: - '@anthropic-ai/sdk': - specifier: ^0.39.0 - version: 0.39.0 + '@mariozechner/pi-agent-core': + specifier: ^0.66.1 + version: 0.66.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6) + '@mariozechner/pi-ai': + specifier: ^0.66.1 + version: 0.66.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6) + '@sinclair/typebox': + specifier: ^0.34.49 + version: 0.34.49 '@sipher/sdk': specifier: workspace:* version: link:../sdk @@ -157,6 +163,15 @@ importers: express: specifier: ^5.0.0 version: 5.2.1 + ioredis: + specifier: ^5.9.2 + version: 5.9.2 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 + ulid: + specifier: ^3.0.2 + version: 3.0.2 ws: specifier: ^8.18.0 version: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) @@ -167,6 +182,12 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.6 + '@types/ioredis': + specifier: ^5.0.0 + version: 5.0.0 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/supertest': specifier: ^6.0.3 version: 6.0.3 @@ -193,7 +214,7 @@ importers: version: 0.30.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) '@sip-protocol/sdk': specifier: ^0.7.4 - version: 0.7.4(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(openai@4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod-to-json-schema@3.25.1(zod@4.3.6)) + version: 0.7.4(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod-to-json-schema@3.25.1(zod@4.3.6)) '@solana/spl-token': specifier: ^0.4.9 version: 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) @@ -219,8 +240,151 @@ packages: peerDependencies: zod: ^4.0.0 - '@anthropic-ai/sdk@0.39.0': - resolution: {integrity: sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==} + '@anthropic-ai/sdk@0.73.0': + resolution: {integrity: sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-bedrock-runtime@3.1027.0': + resolution: {integrity: sha512-Qcda5Z5Vb3LPVt7zNycEiiAo9Blk0JpEPJwz/sUBJby6/0zvTlo+/FIXlwYZ3TJHSgKCYiCaBqAB0WRlWDfLfQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.973.27': + resolution: {integrity: sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.25': + resolution: {integrity: sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.27': + resolution: {integrity: sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.29': + resolution: {integrity: sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.29': + resolution: {integrity: sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.30': + resolution: {integrity: sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.25': + resolution: {integrity: sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.29': + resolution: {integrity: sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.29': + resolution: {integrity: sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.13': + resolution: {integrity: sha512-2Pi1kD0MDkMAxDHqvpi/hKMs9hXUYbj2GLEjCwy+0jzfLChAsF50SUYnOeTI+RztA+Ic4pnLAdB03f1e8nggxQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.9': + resolution: {integrity: sha512-ypgOvpWxQTCnQyDHGxnTviqqANE7FIIzII7VczJnTPCJcJlu17hMQXnvE47aKSKsawVJAaaRsyOEbHQuLJF9ng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.9': + resolution: {integrity: sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.9': + resolution: {integrity: sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.10': + resolution: {integrity: sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.29': + resolution: {integrity: sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.15': + resolution: {integrity: sha512-hsZ35FORQsN5hwNdMD6zWmHCphbXkDxO6j+xwCUiuMb0O6gzS/PWgttQNl1OAn7h/uqZAMUG4yOS0wY/yhAieg==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.996.19': + resolution: {integrity: sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.11': + resolution: {integrity: sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1026.0': + resolution: {integrity: sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1027.0': + resolution: {integrity: sha512-mI3Jm14cM5sNKc7aNX3cqJe/rFQ2Zzx7x5W8WUtxj2lVxcH2RGYhqI3hK9nnImY6Ec5MeGXCVPjl/q6Mz5HmSA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.7': + resolution: {integrity: sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.996.6': + resolution: {integrity: sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-format-url@3.972.9': + resolution: {integrity: sha512-fNJXHrs0ZT7Wx0KGIqKv7zLxlDXt2vqjx9z6oKUQFmpE5o4xxnSryvVHfHpIifYHWKz94hFccIldJ0YSZjlCBw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.9': + resolution: {integrity: sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw==} + + '@aws-sdk/util-user-agent-node@3.973.15': + resolution: {integrity: sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.17': + resolution: {integrity: sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} '@aztec/bb.js@3.0.2': resolution: {integrity: sha512-05AmHcku0/6BJJw2pjhApTvfcsUSDlp53TZIdaLIDb9COFPAM/OSYGlZR1QyIqGAnFmso5c4MLk6dg6FEL6T3g==} @@ -764,6 +928,15 @@ packages: '@fractalwagmi/solana-wallet-adapter@0.1.1': resolution: {integrity: sha512-oTZLEuD+zLKXyhZC5tDRMPKPj8iaxKLxXiCjqRfOo4xmSbS2izGRWLJbKMYYsJysn/OI3UJ3P6CWP8WUWi0dZg==} + '@google/genai@1.49.0': + resolution: {integrity: sha512-hO69Zl0H3x+L0KL4stl1pLYgnqnwHoLqtKy6MRlNnW8TAxjqMdOUVafomKd4z1BePkzoxJWbYILny9a2Zk43VQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@grpc/grpc-js@1.14.3': resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} engines: {node: '>=12.10.0'} @@ -1016,6 +1189,18 @@ packages: '@magicblock-labs/ephemeral-rollups-sdk@0.8.5': resolution: {integrity: sha512-W+k38iQ6XSllc4xV0kehTyWSx+j4juozfTxcA3tmZQD61lWzYm9n0m2iyclSxF/KEi3phUwF73Z0+ZE2aIpy1Q==} + '@mariozechner/pi-agent-core@0.66.1': + resolution: {integrity: sha512-Nj54A7SuB/EQi8r3Gs+glFOr9wz/a9uxYFf0pCLf2DE7VmzA9O7WSejrvArna17K6auftLSdNyRRe2bIO0qezg==} + engines: {node: '>=20.0.0'} + + '@mariozechner/pi-ai@0.66.1': + resolution: {integrity: sha512-7IZHvpsFdKEBkTmjNrdVL7JLUJVIpha6bwTr12cZ5XyDrxij06wP6Ncpnf4HT5BXAzD5w2JnoqTOSbMEIZj3dg==} + engines: {node: '>=20.0.0'} + hasBin: true + + '@mistralai/mistralai@1.14.1': + resolution: {integrity: sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==} + '@mobily/ts-belt@3.13.1': resolution: {integrity: sha512-K5KqIhPI/EoCTbA6CGbrenM9s41OouyK8A03fGJJcla/zKucsgLbz8HNbeseoLarRPgyWJsUyCYqFhI7t3Ra9Q==} engines: {node: '>= 10.*'} @@ -1464,6 +1649,9 @@ packages: '@sinclair/typebox@0.33.22': resolution: {integrity: sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ==} + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -1476,6 +1664,194 @@ packages: '@sip-protocol/types@0.2.2': resolution: {integrity: sha512-FvJosQIp/dnADchEFmjdqCBlKolL4RrW15W2MPR1zlSZax9UbIR3vZjkm/j3mWewjO/bBfXii3FSxnqFlSBSkQ==} + '@smithy/config-resolver@4.4.14': + resolution: {integrity: sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.23.14': + resolution: {integrity: sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.13': + resolution: {integrity: sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.13': + resolution: {integrity: sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.13': + resolution: {integrity: sha512-wwybfcOX0tLqCcBP378TIU9IqrDuZq/tDV48LlZNydMpCnqnYr+hWBAYbRE+rFFf/p7IkDJySM3bgiMKP2ihPg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.13': + resolution: {integrity: sha512-ied1lO559PtAsMJzg2TKRlctLnEi1PfkNeMMpdwXDImk1zV9uvS/Oxoy/vcy9uv1GKZAjDAB5xT6ziE9fzm5wA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.13': + resolution: {integrity: sha512-hFyK+ORJrxAN3RYoaD6+gsGDQjeix8HOEkosoajvXYZ4VeqonM3G4jd9IIRm/sWGXUKmudkY9KdYjzosUqdM8A==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.13': + resolution: {integrity: sha512-kRrq4EKLGeOxhC2CBEhRNcu1KSzNJzYY7RK3S7CxMPgB5dRrv55WqQOtRwQxQLC04xqORFLUgnDlc6xrNUULaA==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.16': + resolution: {integrity: sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.13': + resolution: {integrity: sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.13': + resolution: {integrity: sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.13': + resolution: {integrity: sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.29': + resolution: {integrity: sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.5.0': + resolution: {integrity: sha512-/NzISn4grj/BRFVua/xnQwF+7fakYZgimpw2dfmlPgcqecBMKxpB9g5mLYRrmBD5OrPoODokw4Vi1hrSR4zRyw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.17': + resolution: {integrity: sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.13': + resolution: {integrity: sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.13': + resolution: {integrity: sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.5.2': + resolution: {integrity: sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.13': + resolution: {integrity: sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.13': + resolution: {integrity: sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.13': + resolution: {integrity: sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.13': + resolution: {integrity: sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.13': + resolution: {integrity: sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.8': + resolution: {integrity: sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.13': + resolution: {integrity: sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.12.9': + resolution: {integrity: sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.0': + resolution: {integrity: sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.13': + resolution: {integrity: sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.45': + resolution: {integrity: sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.49': + resolution: {integrity: sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.3.4': + resolution: {integrity: sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.13': + resolution: {integrity: sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.3.0': + resolution: {integrity: sha512-tSOPQNT/4KfbvqeMovWC3g23KSYy8czHd3tlN+tOYVNIDLSfxIsrPJihYi5TpNcoV789KWtgChUVedh2y6dDPg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.22': + resolution: {integrity: sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -2512,6 +2888,9 @@ packages: '@swc/helpers@0.5.18': resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@toruslabs/base-controllers@5.11.0': resolution: {integrity: sha512-5AsGOlpf3DRIsd6PzEemBoRq+o2OhgSFXj5LZD6gXcBlfe0OpF+ydJb7Q8rIt5wwpQLNJCs8psBUbqIv7ukD2w==} engines: {node: '>=18.x', npm: '>=9.x'} @@ -2754,9 +3133,15 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-fetch@2.6.13': resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} @@ -3036,6 +3421,17 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + anser@1.4.10: resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} @@ -3074,6 +3470,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} @@ -3147,6 +3547,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + basic-ftp@5.2.1: + resolution: {integrity: sha512-0yaL8JdxTknKDILitVpfYfV2Ob6yb3udX/hK97M7I3jOeznBNxQPtVvTUtnhUkyHlxFWyr5Lvknmgzoc7jf+1Q==} + engines: {node: '>=10.0.0'} + bech32@2.0.0: resolution: {integrity: sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==} @@ -3533,6 +3937,14 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -3587,6 +3999,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + delay@5.0.0: resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} engines: {node: '>=10'} @@ -3746,14 +4162,27 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -3815,6 +4244,9 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + eyes@0.1.8: resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} engines: {node: '> 0.1.90'} @@ -3838,6 +4270,16 @@ packages: fast-stable-stringify@1.0.0: resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + + fast-xml-parser@5.5.8: + resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} + hasBin: true + fastestsmallesttextencoderdecoder@1.0.22: resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==} @@ -3861,6 +4303,10 @@ packages: feaxios@0.0.23: resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -3914,6 +4360,10 @@ packages: resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} engines: {node: '>= 12.20'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + formidable@3.5.4: resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} engines: {node: '>=14.0.0'} @@ -3944,6 +4394,14 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -3974,6 +4432,10 @@ packages: get-tsconfig@4.13.1: resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==} + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -3981,6 +4443,14 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + google-auth-library@10.6.2: + resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -4050,6 +4520,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -4277,6 +4751,16 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify@1.3.0: resolution: {integrity: sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==} engines: {node: '>= 0.4'} @@ -4292,6 +4776,10 @@ packages: jsonify@0.0.1: resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jsqr@1.4.0: resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} @@ -4388,16 +4876,37 @@ packages: lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} @@ -4435,6 +4944,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4634,6 +5147,10 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} + engines: {node: '>= 0.4.0'} + no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} @@ -4665,6 +5182,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build-optional-packages@5.2.2: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true @@ -4762,6 +5283,18 @@ packages: zod: optional: true + openai@6.26.0: + resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + ox@0.14.7: resolution: {integrity: sha512-zSQ/cfBdolj7U4++NAvH7sI+VG0T3pEohITCgcQj8KlawvTDY4vGVhDT64Atsm0d6adWfIYHDpu88iUBMMp+AQ==} peerDependencies: @@ -4818,7 +5351,15 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - pako@2.1.0: + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + + pako@2.1.0: resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} parse-asn1@5.1.9: @@ -4829,10 +5370,17 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.4.0: + resolution: {integrity: sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -4978,9 +5526,16 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + proxy-compare@2.6.0: resolution: {integrity: sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -5141,6 +5696,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} @@ -5452,6 +6011,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -5579,6 +6141,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -5659,6 +6224,10 @@ packages: uint8arrays@3.1.0: resolution: {integrity: sha512-ei5rfKtoRO8OyOIor2Rz5fhzjThwIHJZ3uyDPnDHTXbP0aMQ1RN/6AI5B5d9dBxJOU+BvOAk7ZQ1xphsX8Lrog==} + ulid@3.0.2: + resolution: {integrity: sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==} + hasBin: true + uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -5671,6 +6240,10 @@ packages: undici-types@7.20.0: resolution: {integrity: sha512-PZDAAlMkNw5ZzN/ebfyrwzrMWfIf7Jbn9iM/I6SF456OKrb2wnfqVowaxEY/cMAM8MjFu1zhdpJyA0L+rTYwNw==} + undici@7.24.7: + resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} + engines: {node: '>=20.18.1'} + unidragger@3.0.1: resolution: {integrity: sha512-RngbGSwBFmqGBWjkaH+yB677uzR95blSQyxq6hYbrQCejH3Mx1nm8DVOuh3M9k2fQyTstWUG5qlgCnNqV/9jVw==} @@ -5952,6 +6525,10 @@ packages: warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + web-streams-polyfill@4.0.0-beta.3: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} @@ -6122,17 +6699,399 @@ snapshots: '@img/sharp-linuxmusl-x64': 0.33.5 '@img/sharp-win32-x64': 0.33.5 - '@anthropic-ai/sdk@0.39.0': + '@anthropic-ai/sdk@0.73.0(zod@4.3.6)': dependencies: - '@types/node': 18.19.130 - '@types/node-fetch': 2.6.13 - abort-controller: 3.0.0 - agentkeepalive: 4.6.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.7.0 + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.3.6 + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.7 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.7 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1027.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.27 + '@aws-sdk/credential-provider-node': 3.972.30 + '@aws-sdk/eventstream-handler-node': 3.972.13 + '@aws-sdk/middleware-eventstream': 3.972.9 + '@aws-sdk/middleware-host-header': 3.972.9 + '@aws-sdk/middleware-logger': 3.972.9 + '@aws-sdk/middleware-recursion-detection': 3.972.10 + '@aws-sdk/middleware-user-agent': 3.972.29 + '@aws-sdk/middleware-websocket': 3.972.15 + '@aws-sdk/region-config-resolver': 3.972.11 + '@aws-sdk/token-providers': 3.1027.0 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-endpoints': 3.996.6 + '@aws-sdk/util-user-agent-browser': 3.972.9 + '@aws-sdk/util-user-agent-node': 3.973.15 + '@smithy/config-resolver': 4.4.14 + '@smithy/core': 3.23.14 + '@smithy/eventstream-serde-browser': 4.2.13 + '@smithy/eventstream-serde-config-resolver': 4.3.13 + '@smithy/eventstream-serde-node': 4.2.13 + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/hash-node': 4.2.13 + '@smithy/invalid-dependency': 4.2.13 + '@smithy/middleware-content-length': 4.2.13 + '@smithy/middleware-endpoint': 4.4.29 + '@smithy/middleware-retry': 4.5.0 + '@smithy/middleware-serde': 4.2.17 + '@smithy/middleware-stack': 4.2.13 + '@smithy/node-config-provider': 4.3.13 + '@smithy/node-http-handler': 4.5.2 + '@smithy/protocol-http': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.45 + '@smithy/util-defaults-mode-node': 4.2.49 + '@smithy/util-endpoints': 3.3.4 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-retry': 4.3.0 + '@smithy/util-stream': 4.5.22 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 transitivePeerDependencies: - - encoding + - aws-crt + + '@aws-sdk/core@3.973.27': + dependencies: + '@aws-sdk/types': 3.973.7 + '@aws-sdk/xml-builder': 3.972.17 + '@smithy/core': 3.23.14 + '@smithy/node-config-provider': 4.3.13 + '@smithy/property-provider': 4.2.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/signature-v4': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.25': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.27': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/types': 3.973.7 + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/node-http-handler': 4.5.2 + '@smithy/property-provider': 4.2.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/util-stream': 4.5.22 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/credential-provider-env': 3.972.25 + '@aws-sdk/credential-provider-http': 3.972.27 + '@aws-sdk/credential-provider-login': 3.972.29 + '@aws-sdk/credential-provider-process': 3.972.25 + '@aws-sdk/credential-provider-sso': 3.972.29 + '@aws-sdk/credential-provider-web-identity': 3.972.29 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/types': 3.973.7 + '@smithy/credential-provider-imds': 4.2.13 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.30': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.25 + '@aws-sdk/credential-provider-http': 3.972.27 + '@aws-sdk/credential-provider-ini': 3.972.29 + '@aws-sdk/credential-provider-process': 3.972.25 + '@aws-sdk/credential-provider-sso': 3.972.29 + '@aws-sdk/credential-provider-web-identity': 3.972.29 + '@aws-sdk/types': 3.973.7 + '@smithy/credential-provider-imds': 4.2.13 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.25': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/token-providers': 3.1026.0 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/eventstream-handler-node@3.972.13': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/eventstream-codec': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.7 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-endpoints': 3.996.6 + '@smithy/core': 3.23.14 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-retry': 4.3.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.15': + dependencies: + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-format-url': 3.972.9 + '@smithy/eventstream-codec': 4.2.13 + '@smithy/eventstream-serde-browser': 4.2.13 + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/protocol-http': 5.3.13 + '@smithy/signature-v4': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.996.19': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.27 + '@aws-sdk/middleware-host-header': 3.972.9 + '@aws-sdk/middleware-logger': 3.972.9 + '@aws-sdk/middleware-recursion-detection': 3.972.10 + '@aws-sdk/middleware-user-agent': 3.972.29 + '@aws-sdk/region-config-resolver': 3.972.11 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-endpoints': 3.996.6 + '@aws-sdk/util-user-agent-browser': 3.972.9 + '@aws-sdk/util-user-agent-node': 3.973.15 + '@smithy/config-resolver': 4.4.14 + '@smithy/core': 3.23.14 + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/hash-node': 4.2.13 + '@smithy/invalid-dependency': 4.2.13 + '@smithy/middleware-content-length': 4.2.13 + '@smithy/middleware-endpoint': 4.4.29 + '@smithy/middleware-retry': 4.5.0 + '@smithy/middleware-serde': 4.2.17 + '@smithy/middleware-stack': 4.2.13 + '@smithy/node-config-provider': 4.3.13 + '@smithy/node-http-handler': 4.5.2 + '@smithy/protocol-http': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.45 + '@smithy/util-defaults-mode-node': 4.2.49 + '@smithy/util-endpoints': 3.3.4 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-retry': 4.3.0 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.11': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/config-resolver': 4.4.14 + '@smithy/node-config-provider': 4.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1026.0': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/token-providers@3.1027.0': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.7': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.996.6': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-endpoints': 3.3.4 + tslib: 2.8.1 + + '@aws-sdk/util-format-url@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/querystring-builder': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/types': 4.14.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.973.15': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.29 + '@aws-sdk/types': 3.973.7 + '@smithy/node-config-provider': 4.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.17': + dependencies: + '@smithy/types': 4.14.0 + fast-xml-parser: 5.5.8 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} '@aztec/bb.js@3.0.2': dependencies: @@ -6573,6 +7532,17 @@ snapshots: - react - react-dom + '@google/genai@1.49.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.4 + ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@grpc/grpc-js@1.14.3': dependencies: '@grpc/proto-loader': 0.8.0 @@ -6785,14 +7755,14 @@ snapshots: - typescript - utf-8-validate - '@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))': + '@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))': dependencies: '@cfworker/json-schema': 4.1.1 ansi-styles: 5.2.0 camelcase: 6.3.0 decamelize: 1.2.0 js-tiktoken: 1.0.21 - langsmith: 0.3.87(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + langsmith: 0.3.87(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) mustache: 4.2.0 p-queue: 6.6.2 p-retry: 4.6.2 @@ -6805,14 +7775,14 @@ snapshots: - '@opentelemetry/sdk-trace-base' - openai - '@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6))': + '@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6))': dependencies: '@cfworker/json-schema': 4.1.1 ansi-styles: 5.2.0 camelcase: 6.3.0 decamelize: 1.2.0 js-tiktoken: 1.0.21 - langsmith: 0.3.87(openai@4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)) + langsmith: 0.3.87(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)) mustache: 4.2.0 p-queue: 6.6.2 p-retry: 4.6.2 @@ -6825,26 +7795,26 @@ snapshots: - '@opentelemetry/sdk-trace-base' - openai - '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))': + '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))': dependencies: - '@langchain/core': 0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) uuid: 10.0.0 - '@langchain/langgraph-sdk@1.5.5(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@langchain/langgraph-sdk@1.5.5(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: p-queue: 9.1.0 p-retry: 7.1.1 uuid: 13.0.0 optionalDependencies: - '@langchain/core': 0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@langchain/langgraph@1.1.3(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76)': + '@langchain/langgraph@1.1.3(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76)': dependencies: - '@langchain/core': 0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) - '@langchain/langgraph-sdk': 1.5.5(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) + '@langchain/langgraph-sdk': 1.5.5(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@standard-schema/spec': 1.1.0 uuid: 10.0.0 zod: 3.25.76 @@ -6854,11 +7824,11 @@ snapshots: - react - react-dom - '@langchain/langgraph@1.1.3(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@3.25.76)': + '@langchain/langgraph@1.1.3(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@3.25.76)': dependencies: - '@langchain/core': 0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) - '@langchain/langgraph-sdk': 1.5.5(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) + '@langchain/langgraph-sdk': 1.5.5(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@standard-schema/spec': 1.1.0 uuid: 10.0.0 zod: 3.25.76 @@ -6868,9 +7838,9 @@ snapshots: - react - react-dom - '@langchain/openai@0.4.9(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@langchain/openai@0.4.9(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: - '@langchain/core': 0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) js-tiktoken: 1.0.21 openai: 4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76) zod: 3.25.76 @@ -6879,9 +7849,9 @@ snapshots: - encoding - ws - '@langchain/openai@0.4.9(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@langchain/openai@0.4.9(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: - '@langchain/core': 0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) js-tiktoken: 1.0.21 openai: 4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76) zod: 3.25.76 @@ -6935,6 +7905,51 @@ snapshots: - fastestsmallesttextencoderdecoder - utf-8-validate + '@mariozechner/pi-agent-core@0.66.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)': + dependencies: + '@mariozechner/pi-ai': 0.66.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@mariozechner/pi-ai@0.66.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)': + dependencies: + '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) + '@aws-sdk/client-bedrock-runtime': 3.1027.0 + '@google/genai': 1.49.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@mistralai/mistralai': 1.14.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@sinclair/typebox': 0.34.49 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + chalk: 5.6.2 + openai: 6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6) + partial-json: 0.1.7 + proxy-agent: 6.5.0 + undici: 7.24.7 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@mistralai/mistralai@1.14.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)': + dependencies: + ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@mobily/ts-belt@3.13.1': {} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': @@ -7501,139 +8516,441 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 - '@scure/bip32@2.0.1': + '@scure/bip32@2.0.1': + dependencies: + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + + '@scure/bip39@1.3.0': + dependencies: + '@noble/hashes': 1.4.0 + '@scure/base': 1.1.9 + + '@scure/bip39@1.5.4': + dependencies: + '@noble/hashes': 1.7.2 + '@scure/base': 1.2.6 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + + '@sinclair/typebox@0.27.10': {} + + '@sinclair/typebox@0.33.22': {} + + '@sinclair/typebox@0.34.49': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@sip-protocol/sdk@0.7.4(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod-to-json-schema@3.25.1(zod@3.25.76))': + dependencies: + '@aztec/bb.js': 3.0.2 + '@ethereumjs/rlp': 10.1.1 + '@jup-ag/api': 6.0.48 + '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/openai': 0.4.9(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@magicblock-labs/ephemeral-rollups-sdk': 0.8.5(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(utf-8-validate@5.0.10) + '@noble/ciphers': 2.1.1 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@noir-lang/noir_js': 1.0.0-beta.18 + '@noir-lang/types': 1.0.0-beta.18 + '@radr/shadowwire': 1.1.15(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@scure/base': 2.0.0 + '@scure/bip32': 2.0.1 + '@scure/bip39': 2.0.1 + '@sip-protocol/types': 0.2.2 + '@solana-program/compute-budget': 0.11.0(@solana/kit@5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@solana-program/system': 0.10.0(@solana/kit@5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@solana/compat': 5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/kit': 5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/spl-token': 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@triton-one/yellowstone-grpc': 4.0.2 + langchain: 1.2.17(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76)) + pino: 10.3.0 + zod: 4.3.6 + optionalDependencies: + https-proxy-agent: 7.0.6 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - bufferutil + - encoding + - fastestsmallesttextencoderdecoder + - openai + - react + - react-dom + - supports-color + - typescript + - utf-8-validate + - ws + - zod-to-json-schema + + '@sip-protocol/sdk@0.7.4(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod-to-json-schema@3.25.1(zod@4.3.6))': + dependencies: + '@aztec/bb.js': 3.0.2 + '@ethereumjs/rlp': 10.1.1 + '@jup-ag/api': 6.0.48 + '@langchain/core': 0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)) + '@langchain/openai': 0.4.9(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@magicblock-labs/ephemeral-rollups-sdk': 0.8.5(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(utf-8-validate@5.0.10) + '@noble/ciphers': 2.1.1 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@noir-lang/noir_js': 1.0.0-beta.18 + '@noir-lang/types': 1.0.0-beta.18 + '@radr/shadowwire': 1.1.15(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@scure/base': 2.0.0 + '@scure/bip32': 2.0.1 + '@scure/bip39': 2.0.1 + '@sip-protocol/types': 0.2.2 + '@solana-program/compute-budget': 0.11.0(@solana/kit@5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@solana-program/system': 0.10.0(@solana/kit@5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@solana/compat': 5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/kit': 5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/spl-token': 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@triton-one/yellowstone-grpc': 4.0.2 + langchain: 1.2.17(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6)) + pino: 10.3.0 + zod: 4.3.6 + optionalDependencies: + https-proxy-agent: 7.0.6 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - bufferutil + - encoding + - fastestsmallesttextencoderdecoder + - openai + - react + - react-dom + - supports-color + - typescript + - utf-8-validate + - ws + - zod-to-json-schema + + '@sip-protocol/types@0.2.2': {} + + '@smithy/config-resolver@4.4.14': + dependencies: + '@smithy/node-config-provider': 4.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.3.4 + '@smithy/util-middleware': 4.2.13 + tslib: 2.8.1 + + '@smithy/core@3.23.14': + dependencies: + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-stream': 4.5.22 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.13': + dependencies: + '@smithy/node-config-provider': 4.3.13 + '@smithy/property-provider': 4.2.13 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.13': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.0 + '@smithy/util-hex-encoding': 4.2.2 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.13': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.13': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.13': + dependencies: + '@smithy/eventstream-codec': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.16': + dependencies: + '@smithy/protocol-http': 5.3.13 + '@smithy/querystring-builder': 4.2.13 + '@smithy/types': 4.14.0 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.13': + dependencies: + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.29': + dependencies: + '@smithy/core': 3.23.14 + '@smithy/middleware-serde': 4.2.17 + '@smithy/node-config-provider': 4.3.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-middleware': 4.2.13 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.5.0': + dependencies: + '@smithy/core': 3.23.14 + '@smithy/node-config-provider': 4.3.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/service-error-classification': 4.2.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-retry': 4.3.0 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.17': + dependencies: + '@smithy/core': 3.23.14 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.13': + dependencies: + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.5.2': + dependencies: + '@smithy/protocol-http': 5.3.13 + '@smithy/querystring-builder': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + '@smithy/util-uri-escape': 4.2.2 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + + '@smithy/shared-ini-file-loader@4.4.8': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.13': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/smithy-client@4.12.9': + dependencies: + '@smithy/core': 3.23.14 + '@smithy/middleware-endpoint': 4.4.29 + '@smithy/middleware-stack': 4.2.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-stream': 4.5.22 + tslib: 2.8.1 + + '@smithy/types@4.14.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.13': + dependencies: + '@smithy/querystring-parser': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.3': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.2': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.2': dependencies: - '@noble/curves': 2.0.1 - '@noble/hashes': 2.0.1 - '@scure/base': 2.0.0 + tslib: 2.8.1 - '@scure/bip39@1.3.0': + '@smithy/util-defaults-mode-browser@4.3.45': dependencies: - '@noble/hashes': 1.4.0 - '@scure/base': 1.1.9 + '@smithy/property-provider': 4.2.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@scure/bip39@1.5.4': + '@smithy/util-defaults-mode-node@4.2.49': dependencies: - '@noble/hashes': 1.7.2 - '@scure/base': 1.2.6 + '@smithy/config-resolver': 4.4.14 + '@smithy/credential-provider-imds': 4.2.13 + '@smithy/node-config-provider': 4.3.13 + '@smithy/property-provider': 4.2.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@scure/bip39@1.6.0': + '@smithy/util-endpoints@3.3.4': dependencies: - '@noble/hashes': 1.8.0 - '@scure/base': 1.2.6 + '@smithy/node-config-provider': 4.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@scure/bip39@2.0.1': + '@smithy/util-hex-encoding@4.2.2': dependencies: - '@noble/hashes': 2.0.1 - '@scure/base': 2.0.0 + tslib: 2.8.1 - '@sinclair/typebox@0.27.10': {} + '@smithy/util-middleware@4.2.13': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@sinclair/typebox@0.33.22': {} + '@smithy/util-retry@4.3.0': + dependencies: + '@smithy/service-error-classification': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@sinonjs/commons@3.0.1': + '@smithy/util-stream@4.5.22': dependencies: - type-detect: 4.0.8 + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/node-http-handler': 4.5.2 + '@smithy/types': 4.14.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 - '@sinonjs/fake-timers@10.3.0': + '@smithy/util-uri-escape@4.2.2': dependencies: - '@sinonjs/commons': 3.0.1 + tslib: 2.8.1 - '@sip-protocol/sdk@0.7.4(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod-to-json-schema@3.25.1(zod@3.25.76))': + '@smithy/util-utf8@2.3.0': dependencies: - '@aztec/bb.js': 3.0.2 - '@ethereumjs/rlp': 10.1.1 - '@jup-ag/api': 6.0.48 - '@langchain/core': 0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) - '@langchain/openai': 0.4.9(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - '@magicblock-labs/ephemeral-rollups-sdk': 0.8.5(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(utf-8-validate@5.0.10) - '@noble/ciphers': 2.1.1 - '@noble/curves': 1.9.7 - '@noble/hashes': 1.8.0 - '@noir-lang/noir_js': 1.0.0-beta.18 - '@noir-lang/types': 1.0.0-beta.18 - '@radr/shadowwire': 1.1.15(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@scure/base': 2.0.0 - '@scure/bip32': 2.0.1 - '@scure/bip39': 2.0.1 - '@sip-protocol/types': 0.2.2 - '@solana-program/compute-budget': 0.11.0(@solana/kit@5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)) - '@solana-program/system': 0.10.0(@solana/kit@5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)) - '@solana/compat': 5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/kit': 5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@solana/spl-token': 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@triton-one/yellowstone-grpc': 4.0.2 - langchain: 1.2.17(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76)) - pino: 10.3.0 - zod: 4.3.6 - optionalDependencies: - https-proxy-agent: 7.0.6 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - '@opentelemetry/api' - - '@opentelemetry/exporter-trace-otlp-proto' - - '@opentelemetry/sdk-trace-base' - - bufferutil - - encoding - - fastestsmallesttextencoderdecoder - - openai - - react - - react-dom - - supports-color - - typescript - - utf-8-validate - - ws - - zod-to-json-schema + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 - '@sip-protocol/sdk@0.7.4(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(openai@4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod-to-json-schema@3.25.1(zod@4.3.6))': + '@smithy/util-utf8@4.2.2': dependencies: - '@aztec/bb.js': 3.0.2 - '@ethereumjs/rlp': 10.1.1 - '@jup-ag/api': 6.0.48 - '@langchain/core': 0.3.80(openai@4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)) - '@langchain/openai': 0.4.9(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - '@magicblock-labs/ephemeral-rollups-sdk': 0.8.5(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(utf-8-validate@5.0.10) - '@noble/ciphers': 2.1.1 - '@noble/curves': 1.9.7 - '@noble/hashes': 1.8.0 - '@noir-lang/noir_js': 1.0.0-beta.18 - '@noir-lang/types': 1.0.0-beta.18 - '@radr/shadowwire': 1.1.15(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@scure/base': 2.0.0 - '@scure/bip32': 2.0.1 - '@scure/bip39': 2.0.1 - '@sip-protocol/types': 0.2.2 - '@solana-program/compute-budget': 0.11.0(@solana/kit@5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)) - '@solana-program/system': 0.10.0(@solana/kit@5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)) - '@solana/compat': 5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/kit': 5.5.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@solana/spl-token': 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@triton-one/yellowstone-grpc': 4.0.2 - langchain: 1.2.17(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6)) - pino: 10.3.0 - zod: 4.3.6 - optionalDependencies: - https-proxy-agent: 7.0.6 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - '@opentelemetry/api' - - '@opentelemetry/exporter-trace-otlp-proto' - - '@opentelemetry/sdk-trace-base' - - bufferutil - - encoding - - fastestsmallesttextencoderdecoder - - openai - - react - - react-dom - - supports-color - - typescript - - utf-8-validate - - ws - - zod-to-json-schema + '@smithy/util-buffer-from': 4.2.2 + tslib: 2.8.1 - '@sip-protocol/types@0.2.2': {} + '@smithy/uuid@1.1.2': + dependencies: + tslib: 2.8.1 '@socket.io/component-emitter@3.1.2': {} @@ -9143,6 +10460,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@tootallnate/quickjs-emscripten@0.23.0': {} + '@toruslabs/base-controllers@5.11.0(@babel/runtime@7.28.6)(bufferutil@4.1.0)(utf-8-validate@5.0.10)': dependencies: '@babel/runtime': 7.28.6 @@ -9609,8 +10928,15 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 25.2.0 + '@types/methods@1.1.4': {} + '@types/ms@2.1.0': {} + '@types/node-fetch@2.6.13': dependencies: '@types/node': 25.2.0 @@ -10357,6 +11683,17 @@ snapshots: dependencies: humanize-ms: 1.2.1 + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + anser@1.4.10: {} ansi-regex@5.0.1: {} @@ -10396,6 +11733,10 @@ snapshots: assertion-error@2.0.1: {} + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + async-mutex@0.5.0: dependencies: tslib: 2.8.1 @@ -10493,6 +11834,8 @@ snapshots: baseline-browser-mapping@2.10.13: {} + basic-ftp@5.2.1: {} + bech32@2.0.0: {} better-sqlite3@12.8.0: @@ -10959,6 +12302,10 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + + data-uri-to-buffer@6.0.2: {} + dateformat@4.6.3: {} dayjs@1.11.13: {} @@ -10997,6 +12344,12 @@ snapshots: defu@6.1.4: {} + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + delay@5.0.0: {} delayed-stream@1.0.0: {} @@ -11203,12 +12556,24 @@ snapshots: escape-string-regexp@4.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + esprima@4.0.1: {} + estraverse@5.3.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + etag@1.8.1: {} eth-rpc-errors@4.0.3: @@ -11287,6 +12652,8 @@ snapshots: transitivePeerDependencies: - supports-color + extend@3.0.2: {} + eyes@0.1.8: {} fast-copy@4.0.2: {} @@ -11301,6 +12668,18 @@ snapshots: fast-stable-stringify@1.0.0: {} + fast-uri@3.1.0: {} + + fast-xml-builder@1.1.4: + dependencies: + path-expression-matcher: 1.4.0 + + fast-xml-parser@5.5.8: + dependencies: + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.4.0 + strnum: 2.2.3 + fastestsmallesttextencoderdecoder@1.0.22: {} fb-dotslash@0.5.8: {} @@ -11317,6 +12696,11 @@ snapshots: dependencies: is-retry-allowed: 3.0.0 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-uri-to-path@1.0.0: {} fill-range@7.1.1: @@ -11382,6 +12766,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 4.0.0-beta.3 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + formidable@3.5.4: dependencies: '@paralleldrive/cuid2': 2.3.1 @@ -11403,6 +12791,22 @@ snapshots: function-bind@1.1.2: {} + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -11435,6 +12839,14 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-uri@6.0.5: + dependencies: + basic-ftp: 5.2.1 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + github-from-package@0.0.0: {} glob@7.2.3: @@ -11446,6 +12858,19 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + google-auth-library@10.6.2: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.4 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -11527,6 +12952,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -11786,6 +13218,17 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.6 + ts-algebra: 2.0.0 + + json-schema-traverse@1.0.0: {} + json-stable-stringify@1.3.0: dependencies: call-bind: 1.0.8 @@ -11800,6 +13243,19 @@ snapshots: jsonify@0.0.1: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + jsqr@1.4.0: {} jwa@2.0.1: @@ -11817,12 +13273,12 @@ snapshots: keyvaluestorage-interface@1.0.0: {} - langchain@1.2.17(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76)): + langchain@1.2.17(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76)): dependencies: - '@langchain/core': 0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) - '@langchain/langgraph': 1.1.3(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) - langsmith: 0.4.12(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/langgraph': 1.1.3(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) + langsmith: 0.4.12(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) uuid: 10.0.0 zod: 3.25.76 transitivePeerDependencies: @@ -11834,12 +13290,12 @@ snapshots: - react-dom - zod-to-json-schema - langchain@1.2.17(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6)): + langchain@1.2.17(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6)): dependencies: - '@langchain/core': 0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) - '@langchain/langgraph': 1.1.3(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@3.25.76) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) - langsmith: 0.4.12(openai@4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)) + '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/langgraph': 1.1.3(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@3.25.76) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) + langsmith: 0.4.12(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)) uuid: 10.0.0 zod: 3.25.76 transitivePeerDependencies: @@ -11851,7 +13307,7 @@ snapshots: - react-dom - zod-to-json-schema - langsmith@0.3.87(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)): + langsmith@0.3.87(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)): dependencies: '@types/uuid': 10.0.0 chalk: 4.1.2 @@ -11860,9 +13316,9 @@ snapshots: semver: 7.7.3 uuid: 10.0.0 optionalDependencies: - openai: 4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76) + openai: 6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76) - langsmith@0.3.87(openai@4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)): + langsmith@0.3.87(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)): dependencies: '@types/uuid': 10.0.0 chalk: 4.1.2 @@ -11871,9 +13327,9 @@ snapshots: semver: 7.7.3 uuid: 10.0.0 optionalDependencies: - openai: 4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6) + openai: 6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6) - langsmith@0.4.12(openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)): + langsmith@0.4.12(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)): dependencies: '@types/uuid': 10.0.0 chalk: 4.1.2 @@ -11882,9 +13338,9 @@ snapshots: semver: 7.7.3 uuid: 10.0.0 optionalDependencies: - openai: 4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76) + openai: 6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76) - langsmith@0.4.12(openai@4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)): + langsmith@0.4.12(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)): dependencies: '@types/uuid': 10.0.0 chalk: 4.1.2 @@ -11893,7 +13349,7 @@ snapshots: semver: 7.7.3 uuid: 10.0.0 optionalDependencies: - openai: 4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6) + openai: 6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6) leven@3.1.0: {} @@ -11936,12 +13392,26 @@ snapshots: lodash.defaults@4.2.0: {} + lodash.includes@4.3.0: {} + lodash.isarguments@3.1.0: {} + lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.throttle@4.1.1: {} lodash@4.17.21: {} @@ -11970,6 +13440,8 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@7.18.3: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -12266,6 +13738,8 @@ snapshots: negotiator@1.0.0: {} + netmask@2.1.1: {} + no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -12287,6 +13761,12 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 @@ -12391,21 +13871,16 @@ snapshots: transitivePeerDependencies: - encoding - openai@4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6): - dependencies: - '@types/node': 18.19.130 - '@types/node-fetch': 2.6.13 - abort-controller: 3.0.0 - agentkeepalive: 4.6.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.7.0 + openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76): + optionalDependencies: + ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10) + zod: 3.25.76 + optional: true + + openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6): optionalDependencies: ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) zod: 4.3.6 - transitivePeerDependencies: - - encoding - optional: true ox@0.14.7(typescript@5.9.3)(zod@3.22.4): dependencies: @@ -12488,6 +13963,24 @@ snapshots: p-try@2.2.0: {} + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.1.1 + pako@2.1.0: {} parse-asn1@5.1.9: @@ -12500,8 +13993,12 @@ snapshots: parseurl@1.3.3: {} + partial-json@0.1.7: {} + path-exists@4.0.0: {} + path-expression-matcher@1.4.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -12709,8 +14206,23 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + proxy-compare@2.6.0: {} + proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} public-encrypt@4.0.3: @@ -12929,6 +14441,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} resolve-from@5.0.0: {} @@ -13323,6 +14837,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@2.2.3: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -13471,6 +14987,8 @@ snapshots: tree-kill@1.2.2: {} + ts-algebra@2.0.0: {} + ts-interface-checker@0.1.13: {} ts-mixer@6.0.4: {} @@ -13554,6 +15072,8 @@ snapshots: dependencies: multiformats: 9.9.0 + ulid@3.0.2: {} + uncrypto@0.1.3: {} undici-types@5.26.5: {} @@ -13562,6 +15082,8 @@ snapshots: undici-types@7.20.0: {} + undici@7.24.7: {} + unidragger@3.0.1: dependencies: ev-emitter: 2.1.2 @@ -13800,6 +15322,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + web-streams-polyfill@3.3.3: {} + web-streams-polyfill@4.0.0-beta.3: {} webidl-conversions@3.0.1: {} @@ -13944,7 +15468,6 @@ snapshots: zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: zod: 4.3.6 - optional: true zod@3.22.4: {} From 54a8bd703b25d3df870ae52ba300492adaa9a591 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 10:51:55 +0700 Subject: [PATCH 23/92] feat(agent): extend db schema with 6 new tables + query functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add activity_stream, herald_queue, herald_dms, execution_links, cost_log, and agent_events tables with indexes for Phase 2 Guardian/Command layer. Exports: insertActivity, getActivity, dismissActivity, logCost, getCostTotals, logAgentEvent, getAgentEvents, createExecutionLink, getExecutionLink, updateExecutionLink — all ULID-keyed, ISO 8601 timestamps. TDD: 34 tests in db-schema.test.ts, all passing, no regressions. --- packages/agent/src/db.ts | 353 +++++++++++++++++++++ packages/agent/tests/db-schema.test.ts | 411 +++++++++++++++++++++++++ 2 files changed, 764 insertions(+) create mode 100644 packages/agent/tests/db-schema.test.ts diff --git a/packages/agent/src/db.ts b/packages/agent/src/db.ts index 7e675df..e8e841c 100644 --- a/packages/agent/src/db.ts +++ b/packages/agent/src/db.ts @@ -1,5 +1,6 @@ import Database from 'better-sqlite3' import { createHash, randomUUID } from 'node:crypto' +import { ulid } from 'ulid' // ───────────────────────────────────────────────────────────────────────────── // Schema — embedded to avoid build-path issues with .sql file resolution @@ -60,6 +61,89 @@ CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_log(session_id); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at); CREATE INDEX IF NOT EXISTS idx_scheduled_next ON scheduled_ops(next_exec, status); CREATE INDEX IF NOT EXISTS idx_payment_status ON payment_links(status, expires_at); + +CREATE TABLE IF NOT EXISTS activity_stream ( + id TEXT PRIMARY KEY, + agent TEXT NOT NULL, + level TEXT NOT NULL, + type TEXT NOT NULL, + title TEXT NOT NULL, + detail TEXT, + wallet TEXT, + actionable INTEGER DEFAULT 0, + action_type TEXT, + action_data TEXT, + dismissed INTEGER DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS herald_queue ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + content TEXT NOT NULL, + reply_to TEXT, + scheduled_at TEXT, + status TEXT DEFAULT 'pending', + approved_by TEXT, + approved_at TEXT, + posted_at TEXT, + tweet_id TEXT, + metrics TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS herald_dms ( + id TEXT PRIMARY KEY, + x_user_id TEXT NOT NULL, + x_username TEXT NOT NULL, + intent TEXT NOT NULL, + message TEXT NOT NULL, + response TEXT, + tool_used TEXT, + exec_link TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS execution_links ( + id TEXT PRIMARY KEY, + wallet TEXT, + action TEXT NOT NULL, + params TEXT NOT NULL, + source TEXT NOT NULL, + status TEXT DEFAULT 'pending', + expires_at TEXT NOT NULL, + signed_tx TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS cost_log ( + id TEXT PRIMARY KEY, + agent TEXT NOT NULL, + provider TEXT NOT NULL, + operation TEXT NOT NULL, + tokens_in INTEGER, + tokens_out INTEGER, + resources INTEGER, + cost_usd REAL NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS agent_events ( + id TEXT PRIMARY KEY, + from_agent TEXT NOT NULL, + to_agent TEXT, + event_type TEXT NOT NULL, + payload TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_activity_wallet_created ON activity_stream(wallet, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_activity_level ON activity_stream(level); +CREATE INDEX IF NOT EXISTS idx_herald_queue_status ON herald_queue(status, scheduled_at); +CREATE INDEX IF NOT EXISTS idx_herald_dms_user ON herald_dms(x_user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_exec_links_status ON execution_links(status, expires_at); +CREATE INDEX IF NOT EXISTS idx_cost_log_agent_date ON cost_log(agent, created_at); +CREATE INDEX IF NOT EXISTS idx_agent_events_created ON agent_events(created_at DESC); ` // ───────────────────────────────────────────────────────────────────────────── @@ -557,3 +641,272 @@ export function cancelScheduledOp(id: string): void { const result = conn.prepare("UPDATE scheduled_ops SET status = 'cancelled' WHERE id = ?").run(id) if (result.changes === 0) throw new Error(`Scheduled op not found: ${id}`) } + +// ───────────────────────────────────────────────────────────────────────────── +// Activity stream +// ───────────────────────────────────────────────────────────────────────────── + +export interface InsertActivityParams { + agent: string + level: string + type: string + title: string + detail?: string + wallet?: string + actionable?: number + action_type?: string + action_data?: string +} + +export interface ActivityOptions { + limit?: number + before?: string + levels?: string[] +} + +/** Insert an activity into the stream. Returns the ULID id. */ +export function insertActivity(params: InsertActivityParams): string { + const conn = getDb() + const id = ulid() + const now = new Date().toISOString() + + conn.prepare(` + INSERT INTO activity_stream + (id, agent, level, type, title, detail, wallet, actionable, action_type, action_data, dismissed, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?) + `).run( + id, + params.agent, + params.level, + params.type, + params.title, + params.detail ?? null, + params.wallet ?? null, + params.actionable ?? 0, + params.action_type ?? null, + params.action_data ?? null, + now, + ) + + return id +} + +/** + * Query activity stream. + * Pass wallet=null to retrieve across all wallets (global view). + * Results are ordered newest first. + */ +export function getActivity( + wallet: string | null, + options: ActivityOptions = {}, +): Array> { + const conn = getDb() + const limit = options.limit ?? 100 + const bindings: (string | number)[] = [] + const clauses: string[] = [] + + if (wallet !== null) { + clauses.push('wallet = ?') + bindings.push(wallet) + } + + if (options.levels && options.levels.length > 0) { + const placeholders = options.levels.map(() => '?').join(', ') + clauses.push(`level IN (${placeholders})`) + bindings.push(...options.levels) + } + + if (options.before) { + clauses.push('created_at < ?') + bindings.push(options.before) + } + + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' + bindings.push(limit) + + return conn.prepare(` + SELECT * FROM activity_stream ${where} ORDER BY created_at DESC, rowid DESC LIMIT ? + `).all(...bindings) as Array> +} + +/** Mark an activity entry as dismissed. */ +export function dismissActivity(id: string): void { + const conn = getDb() + conn.prepare('UPDATE activity_stream SET dismissed = 1 WHERE id = ?').run(id) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Cost log +// ───────────────────────────────────────────────────────────────────────────── + +export interface LogCostParams { + agent: string + provider: string + operation: string + cost_usd: number + tokens_in?: number + tokens_out?: number + resources?: number +} + +/** Log an LLM/API cost entry. Returns the ULID id. */ +export function logCost(params: LogCostParams): string { + const conn = getDb() + const id = ulid() + const now = new Date().toISOString() + + conn.prepare(` + INSERT INTO cost_log + (id, agent, provider, operation, tokens_in, tokens_out, resources, cost_usd, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + params.agent, + params.provider, + params.operation, + params.tokens_in ?? null, + params.tokens_out ?? null, + params.resources ?? null, + params.cost_usd, + now, + ) + + return id +} + +/** + * Sum cost_usd grouped by agent for the given period. + * Returns { agentName: totalCostUsd, ... } + */ +export function getCostTotals(period: 'today' | 'month'): Record { + const conn = getDb() + const now = new Date() + + let since: string + if (period === 'today') { + since = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString() + } else { + since = new Date(now.getFullYear(), now.getMonth(), 1).toISOString() + } + + const rows = conn.prepare( + 'SELECT agent, SUM(cost_usd) as total FROM cost_log WHERE created_at >= ? GROUP BY agent', + ).all(since) as Array<{ agent: string; total: number }> + + const result: Record = {} + for (const row of rows) { + result[row.agent] = row.total + } + return result +} + +// ───────────────────────────────────────────────────────────────────────────── +// Agent events +// ───────────────────────────────────────────────────────────────────────────── + +export interface AgentEventOptions { + limit?: number + since?: string +} + +/** Log an inter-agent event. Returns the ULID id. */ +export function logAgentEvent( + from: string, + to: string | null, + type: string, + payload: unknown, +): string { + const conn = getDb() + const id = ulid() + const now = new Date().toISOString() + + conn.prepare(` + INSERT INTO agent_events (id, from_agent, to_agent, event_type, payload, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(id, from, to, type, JSON.stringify(payload), now) + + return id +} + +/** Query agent events, newest first. */ +export function getAgentEvents( + options: AgentEventOptions = {}, +): Array> { + const conn = getDb() + const limit = options.limit ?? 100 + const bindings: (string | number)[] = [] + const clauses: string[] = [] + + if (options.since) { + clauses.push('created_at >= ?') + bindings.push(options.since) + } + + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' + bindings.push(limit) + + return conn.prepare(` + SELECT * FROM agent_events ${where} ORDER BY created_at DESC, rowid DESC LIMIT ? + `).all(...bindings) as Array> +} + +// ───────────────────────────────────────────────────────────────────────────── +// Execution links +// ───────────────────────────────────────────────────────────────────────────── + +const DEFAULT_EXEC_LINK_TTL_MS = 15 * 60 * 1000 // 15 minutes + +export interface CreateExecutionLinkData { + wallet?: string + action: string + params: Record + source: string + expiresInMs?: number +} + +/** + * Create a short-lived execution link for wallet-signed actions. + * Returns the ULID id that becomes the link token. + */ +export function createExecutionLink(data: CreateExecutionLinkData): string { + const conn = getDb() + const id = ulid() + const now = new Date().toISOString() + const ttl = data.expiresInMs ?? DEFAULT_EXEC_LINK_TTL_MS + const expiresAt = new Date(Date.now() + ttl).toISOString() + + conn.prepare(` + INSERT INTO execution_links + (id, wallet, action, params, source, status, expires_at, signed_tx, created_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?, null, ?) + `).run( + id, + data.wallet ?? null, + data.action, + JSON.stringify(data.params), + data.source, + expiresAt, + now, + ) + + return id +} + +/** Retrieve an execution link by id. Returns undefined if not found. */ +export function getExecutionLink(id: string): Record | undefined { + const conn = getDb() + return conn.prepare('SELECT * FROM execution_links WHERE id = ?').get(id) as Record | undefined +} + +/** Update arbitrary fields on an execution link. Throws if the link doesn't exist. */ +export function updateExecutionLink(id: string, updates: Record): void { + const conn = getDb() + const keys = Object.keys(updates) + if (keys.length === 0) return + + const sets = keys.map(k => `${k} = ?`).join(', ') + const values = [...Object.values(updates) as (string | number | null)[], id] + + const result = conn.prepare(`UPDATE execution_links SET ${sets} WHERE id = ?`).run(...values) + if (result.changes === 0) throw new Error(`Execution link not found: ${id}`) +} diff --git a/packages/agent/tests/db-schema.test.ts b/packages/agent/tests/db-schema.test.ts new file mode 100644 index 0000000..f814474 --- /dev/null +++ b/packages/agent/tests/db-schema.test.ts @@ -0,0 +1,411 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { + getDb, + closeDb, + insertActivity, + getActivity, + dismissActivity, + logCost, + getCostTotals, + logAgentEvent, + getAgentEvents, + createExecutionLink, + getExecutionLink, + updateExecutionLink, +} from '../src/db.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Use in-memory SQLite for all tests +// ───────────────────────────────────────────────────────────────────────────── + +beforeEach(() => { + process.env.DB_PATH = ':memory:' +}) + +afterEach(() => { + closeDb() + delete process.env.DB_PATH +}) + +const WALLET_A = 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' +const WALLET_B = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM' + +// ───────────────────────────────────────────────────────────────────────────── +// Table existence +// ───────────────────────────────────────────────────────────────────────────── + +describe('schema — new tables exist', () => { + const TABLES = [ + 'activity_stream', + 'herald_queue', + 'herald_dms', + 'execution_links', + 'cost_log', + 'agent_events', + ] + + for (const table of TABLES) { + it(`table "${table}" exists`, () => { + const db = getDb() + const row = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?") + .get(table) as { name: string } | undefined + expect(row).toBeDefined() + expect(row?.name).toBe(table) + }) + } +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Activity stream +// ───────────────────────────────────────────────────────────────────────────── + +describe('insertActivity / getActivity', () => { + it('returns a non-empty string ID', () => { + const id = insertActivity({ + agent: 'sipher', + level: 'info', + type: 'transfer', + title: 'Sent 1 SOL', + wallet: WALLET_A, + }) + expect(typeof id).toBe('string') + expect(id.length).toBeGreaterThan(0) + }) + + it('inserts and retrieves activity by wallet', () => { + insertActivity({ + agent: 'sipher', + level: 'info', + type: 'transfer', + title: 'Sent 1 SOL', + wallet: WALLET_A, + }) + + const rows = getActivity(WALLET_A) + expect(rows).toHaveLength(1) + const row = rows[0] + expect(row['agent']).toBe('sipher') + expect(row['level']).toBe('info') + expect(row['type']).toBe('transfer') + expect(row['title']).toBe('Sent 1 SOL') + expect(row['wallet']).toBe(WALLET_A) + expect(row['dismissed']).toBe(0) + expect(row['actionable']).toBe(0) + }) + + it('inserts activity with optional detail and actionable fields', () => { + insertActivity({ + agent: 'herald', + level: 'warn', + type: 'approval', + title: 'Tweet pending approval', + detail: 'Content: Hello world', + wallet: WALLET_B, + actionable: 1, + action_type: 'approve_tweet', + action_data: JSON.stringify({ tweet_id: 'abc123' }), + }) + + const rows = getActivity(WALLET_B) + expect(rows).toHaveLength(1) + const row = rows[0] + expect(row['detail']).toBe('Content: Hello world') + expect(row['actionable']).toBe(1) + expect(row['action_type']).toBe('approve_tweet') + }) + + it('filters by wallet — no cross-wallet leakage', () => { + insertActivity({ agent: 'sipher', level: 'info', type: 'transfer', title: 'A tx', wallet: WALLET_A }) + insertActivity({ agent: 'sipher', level: 'info', type: 'transfer', title: 'B tx', wallet: WALLET_B }) + + const rowsA = getActivity(WALLET_A) + const rowsB = getActivity(WALLET_B) + expect(rowsA).toHaveLength(1) + expect(rowsB).toHaveLength(1) + expect(rowsA[0]['title']).toBe('A tx') + expect(rowsB[0]['title']).toBe('B tx') + }) + + it('returns global activity (wallet=null) for all wallets', () => { + insertActivity({ agent: 'sipher', level: 'info', type: 'transfer', title: 'A', wallet: WALLET_A }) + insertActivity({ agent: 'sipher', level: 'info', type: 'transfer', title: 'B', wallet: WALLET_B }) + + const rows = getActivity(null) + expect(rows.length).toBeGreaterThanOrEqual(2) + }) + + it('filters by level', () => { + insertActivity({ agent: 'sipher', level: 'info', type: 't', title: 'info entry', wallet: WALLET_A }) + insertActivity({ agent: 'sipher', level: 'error', type: 't', title: 'error entry', wallet: WALLET_A }) + + const infoOnly = getActivity(WALLET_A, { levels: ['info'] }) + expect(infoOnly.every(r => r['level'] === 'info')).toBe(true) + expect(infoOnly).toHaveLength(1) + + const all = getActivity(WALLET_A, { levels: ['info', 'error'] }) + expect(all).toHaveLength(2) + }) + + it('respects limit option', () => { + for (let i = 0; i < 5; i++) { + insertActivity({ agent: 'sipher', level: 'info', type: 't', title: `Entry ${i}`, wallet: WALLET_A }) + } + const rows = getActivity(WALLET_A, { limit: 3 }) + expect(rows).toHaveLength(3) + }) + + it('returns entries ordered newest first', () => { + insertActivity({ agent: 'sipher', level: 'info', type: 't', title: 'first', wallet: WALLET_A }) + insertActivity({ agent: 'sipher', level: 'info', type: 't', title: 'second', wallet: WALLET_A }) + + const rows = getActivity(WALLET_A) + // newest is second (inserted last) + expect(rows[0]['title']).toBe('second') + expect(rows[1]['title']).toBe('first') + }) +}) + +describe('dismissActivity', () => { + it('marks activity as dismissed', () => { + const id = insertActivity({ + agent: 'sipher', + level: 'info', + type: 'transfer', + title: 'To dismiss', + wallet: WALLET_A, + }) + + dismissActivity(id) + + const db = getDb() + const row = db.prepare('SELECT dismissed FROM activity_stream WHERE id = ?').get(id) as { dismissed: number } | undefined + expect(row?.dismissed).toBe(1) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Cost log +// ───────────────────────────────────────────────────────────────────────────── + +describe('logCost / getCostTotals', () => { + it('returns a non-empty string ID', () => { + const id = logCost({ agent: 'sipher', provider: 'openrouter', operation: 'chat', cost_usd: 0.01 }) + expect(typeof id).toBe('string') + expect(id.length).toBeGreaterThan(0) + }) + + it('persists cost entry with all fields', () => { + logCost({ + agent: 'sipher', + provider: 'openrouter', + operation: 'chat', + cost_usd: 0.05, + tokens_in: 1000, + tokens_out: 500, + }) + + const db = getDb() + const row = db.prepare('SELECT * FROM cost_log LIMIT 1').get() as Record | undefined + expect(row).toBeDefined() + expect(row!['agent']).toBe('sipher') + expect(row!['provider']).toBe('openrouter') + expect(row!['operation']).toBe('chat') + expect(row!['cost_usd']).toBeCloseTo(0.05) + expect(row!['tokens_in']).toBe(1000) + expect(row!['tokens_out']).toBe(500) + }) + + it('getCostTotals today returns sum by agent', () => { + logCost({ agent: 'sipher', provider: 'openrouter', operation: 'chat', cost_usd: 0.10 }) + logCost({ agent: 'sipher', provider: 'openrouter', operation: 'chat', cost_usd: 0.05 }) + logCost({ agent: 'herald', provider: 'openrouter', operation: 'chat', cost_usd: 0.03 }) + + const totals = getCostTotals('today') + expect(totals['sipher']).toBeCloseTo(0.15) + expect(totals['herald']).toBeCloseTo(0.03) + }) + + it('getCostTotals month returns sum by agent', () => { + logCost({ agent: 'sipher', provider: 'openrouter', operation: 'embed', cost_usd: 0.02 }) + logCost({ agent: 'herald', provider: 'anthropic', operation: 'chat', cost_usd: 0.08 }) + + const totals = getCostTotals('month') + expect(totals['sipher']).toBeCloseTo(0.02) + expect(totals['herald']).toBeCloseTo(0.08) + }) + + it('returns empty object when no costs logged', () => { + const totals = getCostTotals('today') + expect(totals).toEqual({}) + }) + + it('persists optional resources field', () => { + logCost({ agent: 'sipher', provider: 'openrouter', operation: 'search', cost_usd: 0.01, resources: 3 }) + const db = getDb() + const row = db.prepare('SELECT resources FROM cost_log LIMIT 1').get() as { resources: number } | undefined + expect(row?.resources).toBe(3) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Agent events +// ───────────────────────────────────────────────────────────────────────────── + +describe('logAgentEvent / getAgentEvents', () => { + it('returns a non-empty string ID', () => { + const id = logAgentEvent('sipher', 'herald', 'task_complete', { task: 'write_tweet' }) + expect(typeof id).toBe('string') + expect(id.length).toBeGreaterThan(0) + }) + + it('inserts an event and retrieves it', () => { + logAgentEvent('sipher', 'herald', 'task_complete', { task: 'write_tweet' }) + + const events = getAgentEvents() + expect(events).toHaveLength(1) + const ev = events[0] + expect(ev['from_agent']).toBe('sipher') + expect(ev['to_agent']).toBe('herald') + expect(ev['event_type']).toBe('task_complete') + // payload should be the parsed object or JSON string + const payload = typeof ev['payload'] === 'string' ? JSON.parse(ev['payload'] as string) : ev['payload'] + expect(payload).toEqual({ task: 'write_tweet' }) + }) + + it('supports null to_agent (broadcast)', () => { + logAgentEvent('sipher', null, 'broadcast', { msg: 'hello' }) + const events = getAgentEvents() + expect(events[0]['to_agent']).toBeNull() + }) + + it('respects limit option', () => { + for (let i = 0; i < 5; i++) { + logAgentEvent('sipher', null, `event_${i}`, { i }) + } + const events = getAgentEvents({ limit: 2 }) + expect(events).toHaveLength(2) + }) + + it('respects since option — filters events before cutoff', () => { + // Insert an "old" event stamped in the past + const db = getDb() + const pastId = 'old-event-past-id' + const pastTs = new Date(Date.now() - 60_000).toISOString() // 1 min ago + db.prepare( + 'INSERT INTO agent_events (id, from_agent, to_agent, event_type, payload, created_at) VALUES (?, ?, ?, ?, ?, ?)', + ).run(pastId, 'sipher', null, 'old', JSON.stringify({}), pastTs) + + // Insert a "new" event now + logAgentEvent('sipher', null, 'new', { i: 1 }) + + // Filter to only events since 30 seconds ago + const since = new Date(Date.now() - 30_000).toISOString() + const events = getAgentEvents({ since }) + + // Must include "new", must not include "old" + const types = events.map(e => e['event_type']) + expect(types).toContain('new') + expect(types).not.toContain('old') + }) + + it('returns events ordered newest first', () => { + logAgentEvent('sipher', null, 'first', {}) + logAgentEvent('sipher', null, 'second', {}) + + const events = getAgentEvents() + expect(events[0]['event_type']).toBe('second') + expect(events[1]['event_type']).toBe('first') + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Execution links +// ───────────────────────────────────────────────────────────────────────────── + +describe('createExecutionLink / getExecutionLink / updateExecutionLink', () => { + it('returns a short non-empty string ID', () => { + const id = createExecutionLink({ + action: 'send', + params: { amount: 1, token: 'SOL' }, + source: 'herald_dm', + }) + expect(typeof id).toBe('string') + expect(id.length).toBeGreaterThan(0) + }) + + it('retrieves a created link', () => { + const id = createExecutionLink({ + action: 'send', + params: { amount: 1, token: 'SOL' }, + source: 'herald_dm', + }) + + const link = getExecutionLink(id) + expect(link).toBeDefined() + expect(link!['action']).toBe('send') + expect(link!['source']).toBe('herald_dm') + expect(link!['status']).toBe('pending') + // params should be parseable JSON + const params = typeof link!['params'] === 'string' ? JSON.parse(link!['params'] as string) : link!['params'] + expect(params).toEqual({ amount: 1, token: 'SOL' }) + }) + + it('returns undefined for unknown ID', () => { + const link = getExecutionLink('nonexistent-id') + expect(link).toBeUndefined() + }) + + it('uses default expiry when expiresInMs not specified', () => { + const before = Date.now() + const id = createExecutionLink({ + action: 'swap', + params: {}, + source: 'herald_dm', + }) + const link = getExecutionLink(id) + expect(link).toBeDefined() + // expires_at should be an ISO string in the future + const expiresAt = new Date(link!['expires_at'] as string).getTime() + expect(expiresAt).toBeGreaterThan(before) + }) + + it('respects custom expiresInMs', () => { + const id = createExecutionLink({ + action: 'send', + params: {}, + source: 'herald_dm', + expiresInMs: 60_000, // 1 minute + }) + const link = getExecutionLink(id) + const expiresAt = new Date(link!['expires_at'] as string).getTime() + const nowPlus1m = Date.now() + 60_000 + // within 2 seconds tolerance + expect(Math.abs(expiresAt - nowPlus1m)).toBeLessThan(2000) + }) + + it('updateExecutionLink changes status', () => { + const id = createExecutionLink({ + action: 'send', + params: { amount: 0.5 }, + source: 'herald_dm', + }) + + updateExecutionLink(id, { status: 'executed', signed_tx: 'abc123signature' }) + + const link = getExecutionLink(id) + expect(link!['status']).toBe('executed') + expect(link!['signed_tx']).toBe('abc123signature') + }) + + it('createExecutionLink stores optional wallet', () => { + const id = createExecutionLink({ + action: 'send', + params: {}, + source: 'herald_dm', + }) + // wallet is optional — link still retrieves without it + const link = getExecutionLink(id) + expect(link).toBeDefined() + }) +}) From bc1ececcf95f9f5d281193f1c920159ad8a6266f Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 10:56:02 +0700 Subject: [PATCH 24/92] =?UTF-8?q?feat:=20add=20tool=20schema=20adapter=20?= =?UTF-8?q?=E2=80=94=20Anthropic=20format=20to=20Pi=20AI=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/pi/tool-adapter.ts | 26 ++++++++++ tests/pi/tool-adapter.test.ts | 72 +++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 packages/agent/src/pi/tool-adapter.ts create mode 100644 tests/pi/tool-adapter.test.ts diff --git a/packages/agent/src/pi/tool-adapter.ts b/packages/agent/src/pi/tool-adapter.ts new file mode 100644 index 0000000..c9bc9d8 --- /dev/null +++ b/packages/agent/src/pi/tool-adapter.ts @@ -0,0 +1,26 @@ +import type { Tool } from '@mariozechner/pi-ai' + +// ───────────────────────────────────────────────────────────────────────────── +// Tool Schema Adapter — Convert Anthropic format to Pi AI format +// ───────────────────────────────────────────────────────────────────────────── +// Anthropic tools use `input_schema` to describe parameters +// Pi AI tools use `parameters` (TypeBox TSchema format) +// The schema structure is compatible, so we just rename the field + +export interface AnthropicTool { + name: string + description: string + input_schema: Record +} + +export function adaptTool(anthropicTool: AnthropicTool): Tool { + return { + name: anthropicTool.name, + description: anthropicTool.description, + parameters: anthropicTool.input_schema as any, // Both use JSON Schema format + } +} + +export function adaptTools(tools: AnthropicTool[]): Tool[] { + return tools.map(adaptTool) +} diff --git a/tests/pi/tool-adapter.test.ts b/tests/pi/tool-adapter.test.ts new file mode 100644 index 0000000..b93483c --- /dev/null +++ b/tests/pi/tool-adapter.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { adaptTool, adaptTools } from '../../packages/agent/src/pi/tool-adapter.js' +import { depositTool } from '../../packages/agent/src/tools/deposit.js' +import { balanceTool } from '../../packages/agent/src/tools/balance.js' + +describe('adaptTool', () => { + it('converts Anthropic tool schema to Pi Tool', () => { + const piTool = adaptTool(depositTool) + + expect(piTool.name).toBe('deposit') + expect(piTool.description).toBe(depositTool.description) + expect(piTool.parameters).toBeDefined() + }) + + it('preserves required fields in schema', () => { + const piTool = adaptTool(depositTool) + + // Verify the schema structure + expect(piTool.parameters).toEqual(depositTool.input_schema) + expect(piTool.parameters.type).toBe('object') + expect(piTool.parameters.properties).toBeDefined() + expect(piTool.parameters.required).toContain('amount') + expect(piTool.parameters.required).toContain('token') + }) + + it('converts read-only tool', () => { + const piTool = adaptTool(balanceTool) + + expect(piTool.name).toBe('balance') + expect(piTool.description).toBe(balanceTool.description) + expect(piTool.parameters.required).toContain('token') + expect(piTool.parameters.required).toContain('wallet') + }) + + it('preserves property descriptions', () => { + const piTool = adaptTool(depositTool) + + expect(piTool.parameters.properties.amount.description).toBe( + 'Amount to deposit (in human-readable units, e.g. 1.5 SOL)' + ) + expect(piTool.parameters.properties.token.description).toBe( + 'Token symbol — SOL, USDC, USDT, or any SPL token mint address' + ) + }) +}) + +describe('adaptTools', () => { + it('converts multiple tools', () => { + const piTools = adaptTools([depositTool, balanceTool]) + + expect(piTools).toHaveLength(2) + expect(piTools[0].name).toBe('deposit') + expect(piTools[1].name).toBe('balance') + }) + + it('preserves all tool properties in batch conversion', () => { + const piTools = adaptTools([depositTool, balanceTool]) + + piTools.forEach((tool) => { + expect(tool.name).toBeDefined() + expect(tool.description).toBeDefined() + expect(tool.parameters).toBeDefined() + expect(tool.parameters.type).toBe('object') + }) + }) + + it('handles empty tool array', () => { + const piTools = adaptTools([]) + + expect(piTools).toEqual([]) + }) +}) From 0144a5d2f66452ac75961beabee0cd96fbde0f47 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 10:57:15 +0700 Subject: [PATCH 25/92] =?UTF-8?q?feat:=20add=20EventBus=20=E2=80=94=20type?= =?UTF-8?q?d=20agent=20coordination=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/coordination/event-bus.ts | 40 +++++ .../tests/coordination/event-bus.test.ts | 159 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 packages/agent/src/coordination/event-bus.ts create mode 100644 packages/agent/tests/coordination/event-bus.test.ts diff --git a/packages/agent/src/coordination/event-bus.ts b/packages/agent/src/coordination/event-bus.ts new file mode 100644 index 0000000..4ce070c --- /dev/null +++ b/packages/agent/src/coordination/event-bus.ts @@ -0,0 +1,40 @@ +import { EventEmitter } from 'node:events' + +export interface GuardianEvent { + source: 'sipher' | 'herald' | 'sentinel' | 'courier' + type: string + level: 'critical' | 'important' | 'routine' + data: Record + wallet?: string | null + timestamp: string +} + +type EventHandler = (event: GuardianEvent) => void + +export class EventBus { + private emitter = new EventEmitter() + private static WILDCARD = '__any__' + + on(type: string, handler: EventHandler): void { + this.emitter.on(type, handler) + } + + off(type: string, handler: EventHandler): void { + this.emitter.removeListener(type, handler) + } + + onAny(handler: EventHandler): void { + this.emitter.on(EventBus.WILDCARD, handler) + } + + emit(event: GuardianEvent): void { + this.emitter.emit(event.type, event) + this.emitter.emit(EventBus.WILDCARD, event) + } + + removeAllListeners(): void { + this.emitter.removeAllListeners() + } +} + +export const guardianBus = new EventBus() diff --git a/packages/agent/tests/coordination/event-bus.test.ts b/packages/agent/tests/coordination/event-bus.test.ts new file mode 100644 index 0000000..d26b3e8 --- /dev/null +++ b/packages/agent/tests/coordination/event-bus.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { EventBus, type GuardianEvent } from '../../src/coordination/event-bus.js' + +describe('EventBus', () => { + let bus: EventBus + + beforeEach(() => { + bus = new EventBus() + }) + + it('emits and receives typed events', () => { + const handler = vi.fn() + bus.on('sipher:action', handler) + const event: GuardianEvent = { + source: 'sipher', + type: 'sipher:action', + level: 'important', + data: { tool: 'deposit', amount: 2 }, + wallet: 'FGSk...BWr', + timestamp: new Date().toISOString(), + } + bus.emit(event) + expect(handler).toHaveBeenCalledWith(event) + }) + + it('wildcard listener receives all events', () => { + const handler = vi.fn() + bus.onAny(handler) + bus.emit({ + source: 'sipher', + type: 'sipher:action', + level: 'important', + data: {}, + timestamp: new Date().toISOString(), + }) + bus.emit({ + source: 'sentinel', + type: 'sentinel:threat', + level: 'critical', + data: {}, + timestamp: new Date().toISOString(), + }) + expect(handler).toHaveBeenCalledTimes(2) + }) + + it('removeListener stops delivery', () => { + const handler = vi.fn() + bus.on('sipher:action', handler) + bus.off('sipher:action', handler) + bus.emit({ + source: 'sipher', + type: 'sipher:action', + level: 'important', + data: {}, + timestamp: new Date().toISOString(), + }) + expect(handler).not.toHaveBeenCalled() + }) + + it('multiple handlers on the same event', () => { + const handler1 = vi.fn() + const handler2 = vi.fn() + bus.on('herald:message', handler1) + bus.on('herald:message', handler2) + const event: GuardianEvent = { + source: 'herald', + type: 'herald:message', + level: 'routine', + data: { msg: 'test' }, + timestamp: new Date().toISOString(), + } + bus.emit(event) + expect(handler1).toHaveBeenCalledWith(event) + expect(handler2).toHaveBeenCalledWith(event) + }) + + it('wildcard listener receives event alongside specific listener', () => { + const specificHandler = vi.fn() + const wildcardHandler = vi.fn() + bus.on('courier:delivery', specificHandler) + bus.onAny(wildcardHandler) + const event: GuardianEvent = { + source: 'courier', + type: 'courier:delivery', + level: 'routine', + data: {}, + timestamp: new Date().toISOString(), + } + bus.emit(event) + expect(specificHandler).toHaveBeenCalledWith(event) + expect(wildcardHandler).toHaveBeenCalledWith(event) + }) + + it('removeAllListeners clears all handlers', () => { + const handler1 = vi.fn() + const handler2 = vi.fn() + const handler3 = vi.fn() + bus.on('sentinel:alert', handler1) + bus.on('sipher:action', handler2) + bus.onAny(handler3) + + bus.removeAllListeners() + + bus.emit({ + source: 'sentinel', + type: 'sentinel:alert', + level: 'critical', + data: {}, + timestamp: new Date().toISOString(), + }) + bus.emit({ + source: 'sipher', + type: 'sipher:action', + level: 'important', + data: {}, + timestamp: new Date().toISOString(), + }) + + expect(handler1).not.toHaveBeenCalled() + expect(handler2).not.toHaveBeenCalled() + expect(handler3).not.toHaveBeenCalled() + }) + + it('event with null wallet is allowed', () => { + const handler = vi.fn() + bus.on('courier:broadcast', handler) + const event: GuardianEvent = { + source: 'courier', + type: 'courier:broadcast', + level: 'routine', + data: {}, + wallet: null, + timestamp: new Date().toISOString(), + } + bus.emit(event) + expect(handler).toHaveBeenCalledWith(event) + }) + + it('event data can contain arbitrary nested objects', () => { + const handler = vi.fn() + bus.on('sipher:complex', handler) + const event: GuardianEvent = { + source: 'sipher', + type: 'sipher:complex', + level: 'important', + data: { + nested: { + deeply: { + value: 42, + array: [1, 2, 3], + }, + }, + }, + timestamp: new Date().toISOString(), + } + bus.emit(event) + expect(handler).toHaveBeenCalledWith(event) + }) +}) From 1e5e74d984bc45552e4144853c753b39eb2d12e3 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 10:58:06 +0700 Subject: [PATCH 26/92] =?UTF-8?q?feat:=20add=20Pi=20AI=20provider=20config?= =?UTF-8?q?=20=E2=80=94=20OpenRouter=20for=20SIPHER=20+=20HERALD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/pi/provider.ts | 14 ++++++ packages/agent/tests/pi/provider.test.ts | 56 ++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 packages/agent/src/pi/provider.ts create mode 100644 packages/agent/tests/pi/provider.test.ts diff --git a/packages/agent/src/pi/provider.ts b/packages/agent/src/pi/provider.ts new file mode 100644 index 0000000..e20de59 --- /dev/null +++ b/packages/agent/src/pi/provider.ts @@ -0,0 +1,14 @@ +import { getModel, type Model } from '@mariozechner/pi-ai' + +const DEFAULT_SIPHER_MODEL = 'anthropic/claude-sonnet-4.6' +const DEFAULT_HERALD_MODEL = 'anthropic/claude-sonnet-4.6' + +export function getSipherModel(): Model<'openai-completions'> { + const modelId = (process.env.SIPHER_MODEL ?? DEFAULT_SIPHER_MODEL) as any + return getModel('openrouter', modelId) +} + +export function getHeraldModel(): Model<'openai-completions'> { + const modelId = (process.env.HERALD_MODEL ?? DEFAULT_HERALD_MODEL) as any + return getModel('openrouter', modelId) +} diff --git a/packages/agent/tests/pi/provider.test.ts b/packages/agent/tests/pi/provider.test.ts new file mode 100644 index 0000000..5bda831 --- /dev/null +++ b/packages/agent/tests/pi/provider.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, afterEach } from 'vitest' + +const { getSipherModel, getHeraldModel } = await import('../../src/pi/provider.js') + +describe('Pi AI provider', () => { + afterEach(() => { + delete process.env.SIPHER_MODEL + delete process.env.HERALD_MODEL + }) + + it('creates SIPHER model with OpenRouter', () => { + const model = getSipherModel() + expect(model).toBeDefined() + expect(model.id).toBe('anthropic/claude-sonnet-4.6') + expect(model.provider).toBe('openrouter') + expect(model.api).toBe('openai-completions') + }) + + it('creates HERALD model with OpenRouter', () => { + const model = getHeraldModel() + expect(model).toBeDefined() + expect(model.id).toBe('anthropic/claude-sonnet-4.6') + expect(model.provider).toBe('openrouter') + expect(model.api).toBe('openai-completions') + }) + + it('respects SIPHER_MODEL env var override', () => { + process.env.SIPHER_MODEL = 'anthropic/claude-haiku-4.5' + const model = getSipherModel() + expect(model).toBeDefined() + expect(model.id).toBe('anthropic/claude-haiku-4.5') + expect(model.provider).toBe('openrouter') + }) + + it('respects HERALD_MODEL env var override', () => { + process.env.HERALD_MODEL = 'anthropic/claude-haiku-4.5' + const model = getHeraldModel() + expect(model).toBeDefined() + expect(model.id).toBe('anthropic/claude-haiku-4.5') + expect(model.provider).toBe('openrouter') + }) + + it('returns models with expected properties', () => { + const sipherModel = getSipherModel() + expect(sipherModel).toHaveProperty('id') + expect(sipherModel).toHaveProperty('name') + expect(sipherModel).toHaveProperty('api') + expect(sipherModel).toHaveProperty('provider') + expect(sipherModel).toHaveProperty('baseUrl') + expect(sipherModel).toHaveProperty('reasoning') + expect(sipherModel).toHaveProperty('input') + expect(sipherModel).toHaveProperty('cost') + expect(sipherModel).toHaveProperty('contextWindow') + expect(sipherModel).toHaveProperty('maxTokens') + }) +}) From 36938828a72cdcfb1b3a38728e03e530f82d05ee Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 11:01:25 +0700 Subject: [PATCH 27/92] =?UTF-8?q?feat:=20add=20tool=20groups=20+=20routeIn?= =?UTF-8?q?tent=20meta-tool=20=E2=80=94=204=20groups,=20dynamic=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/pi/tool-groups.ts | 112 +++++++++++++ packages/agent/tests/pi/tool-groups.test.ts | 169 ++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 packages/agent/src/pi/tool-groups.ts create mode 100644 packages/agent/tests/pi/tool-groups.test.ts diff --git a/packages/agent/src/pi/tool-groups.ts b/packages/agent/src/pi/tool-groups.ts new file mode 100644 index 0000000..42e2a8a --- /dev/null +++ b/packages/agent/src/pi/tool-groups.ts @@ -0,0 +1,112 @@ +import type { Tool } from '@mariozechner/pi-ai' +import { + depositTool, + sendTool, + claimTool, + refundTool, + balanceTool, + scanTool, + privacyScoreTool, + threatCheckTool, + viewingKeyTool, + historyTool, + statusTool, + paymentLinkTool, + invoiceTool, + swapTool, + roundAmountTool, + scheduleSendTool, + splitSendTool, + dripTool, + recurringTool, + sweepTool, + consolidateTool, +} from '../tools/index.js' +import { adaptTool } from './tool-adapter.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Tool Groups — Organized by intent domain for dynamic loading +// ───────────────────────────────────────────────────────────────────────────── + +export const TOOL_GROUPS: Record = { + // Core vault operations: deposits, transfers, scanning, claiming + vault: [ + depositTool, + sendTool, + claimTool, + refundTool, + balanceTool, + scanTool, + ].map(adaptTool), + + // Intelligence & analytics: scores, checks, keys, history + intel: [ + privacyScoreTool, + threatCheckTool, + viewingKeyTool, + historyTool, + statusTool, + ].map(adaptTool), + + // Product features: payments, invoices, swaps + product: [ + paymentLinkTool, + invoiceTool, + swapTool, + ].map(adaptTool), + + // Scheduled & automated operations + scheduled: [ + scheduleSendTool, + splitSendTool, + dripTool, + recurringTool, + sweepTool, + consolidateTool, + roundAmountTool, + ].map(adaptTool), +} + +// ───────────────────────────────────────────────────────────────────────────── +// getToolGroup — Retrieve a named group, throws on unknown name +// ───────────────────────────────────────────────────────────────────────────── + +export function getToolGroup(name: string): Tool[] { + const group = TOOL_GROUPS[name] + if (!group) { + throw new Error(`Unknown tool group: "${name}". Valid groups: ${Object.keys(TOOL_GROUPS).join(', ')}`) + } + return group +} + +// ───────────────────────────────────────────────────────────────────────────── +// routeIntentTool — Meta-tool for intent classification before tool dispatch +// ───────────────────────────────────────────────────────────────────────────── + +export const routeIntentTool: Tool = { + name: 'routeIntent', + description: 'Classify the user\'s intent to load the right tool group. Call this FIRST before using any other tool. Groups: vault (deposit, send, claim, refund, balance, scan), intel (privacyScore, threatCheck, viewingKey, history, status), product (paymentLink, invoice, swap), scheduled (scheduleSend, splitSend, drip, recurring, sweep, consolidate, roundAmount).', + parameters: { + type: 'object', + properties: { + group: { + type: 'string', + enum: ['vault', 'intel', 'product', 'scheduled'], + description: 'The tool group matching the user\'s intent', + }, + reasoning: { + type: 'string', + description: 'Brief explanation of why this group matches', + }, + }, + required: ['group'], + } as any, +} + +// ───────────────────────────────────────────────────────────────────────────── +// ALL_TOOL_NAMES — Flat list of all 21 tool names across all groups +// ───────────────────────────────────────────────────────────────────────────── + +export const ALL_TOOL_NAMES: string[] = Object.values(TOOL_GROUPS) + .flat() + .map((tool) => tool.name) diff --git a/packages/agent/tests/pi/tool-groups.test.ts b/packages/agent/tests/pi/tool-groups.test.ts new file mode 100644 index 0000000..d0cf5b7 --- /dev/null +++ b/packages/agent/tests/pi/tool-groups.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from 'vitest' + +const { + TOOL_GROUPS, + getToolGroup, + routeIntentTool, + ALL_TOOL_NAMES, +} = await import('../../src/pi/tool-groups.js') + +describe('TOOL_GROUPS', () => { + it('has exactly 4 groups', () => { + expect(Object.keys(TOOL_GROUPS)).toHaveLength(4) + expect(Object.keys(TOOL_GROUPS)).toEqual( + expect.arrayContaining(['vault', 'intel', 'product', 'scheduled']) + ) + }) + + it('vault group has 6 tools', () => { + expect(TOOL_GROUPS['vault']).toHaveLength(6) + }) + + it('intel group has 5 tools', () => { + expect(TOOL_GROUPS['intel']).toHaveLength(5) + }) + + it('product group has 3 tools', () => { + expect(TOOL_GROUPS['product']).toHaveLength(3) + }) + + it('scheduled group has 7 tools', () => { + expect(TOOL_GROUPS['scheduled']).toHaveLength(7) + }) + + it('vault group contains expected tools', () => { + const names = TOOL_GROUPS['vault'].map((t) => t.name) + expect(names).toEqual( + expect.arrayContaining(['deposit', 'send', 'claim', 'refund', 'balance', 'scan']) + ) + }) + + it('intel group contains expected tools', () => { + const names = TOOL_GROUPS['intel'].map((t) => t.name) + expect(names).toEqual( + expect.arrayContaining(['privacyScore', 'threatCheck', 'viewingKey', 'history', 'status']) + ) + }) + + it('product group contains expected tools', () => { + const names = TOOL_GROUPS['product'].map((t) => t.name) + expect(names).toEqual( + expect.arrayContaining(['paymentLink', 'invoice', 'swap']) + ) + }) + + it('scheduled group contains expected tools', () => { + const names = TOOL_GROUPS['scheduled'].map((t) => t.name) + expect(names).toEqual( + expect.arrayContaining([ + 'scheduleSend', + 'splitSend', + 'drip', + 'recurring', + 'sweep', + 'consolidate', + 'roundAmount', + ]) + ) + }) + + it('all tools have name, description, and parameters', () => { + for (const [, tools] of Object.entries(TOOL_GROUPS)) { + for (const tool of tools) { + expect(tool).toHaveProperty('name') + expect(tool).toHaveProperty('description') + expect(tool).toHaveProperty('parameters') + expect(typeof tool.name).toBe('string') + expect(typeof tool.description).toBe('string') + } + } + }) +}) + +describe('getToolGroup', () => { + it('returns vault group', () => { + const tools = getToolGroup('vault') + expect(tools).toHaveLength(6) + expect(tools[0]).toHaveProperty('name') + }) + + it('returns intel group', () => { + const tools = getToolGroup('intel') + expect(tools).toHaveLength(5) + }) + + it('returns product group', () => { + const tools = getToolGroup('product') + expect(tools).toHaveLength(3) + }) + + it('returns scheduled group', () => { + const tools = getToolGroup('scheduled') + expect(tools).toHaveLength(7) + }) + + it('throws for unknown group', () => { + expect(() => getToolGroup('unknown')).toThrow() + expect(() => getToolGroup('')).toThrow() + expect(() => getToolGroup('admin')).toThrow() + }) +}) + +describe('routeIntentTool', () => { + it('has name routeIntent', () => { + expect(routeIntentTool.name).toBe('routeIntent') + }) + + it('has description', () => { + expect(typeof routeIntentTool.description).toBe('string') + expect(routeIntentTool.description.length).toBeGreaterThan(0) + }) + + it('has parameters as object type', () => { + expect(routeIntentTool.parameters).toBeDefined() + expect((routeIntentTool.parameters as any).type).toBe('object') + }) + + it('has group property with enum of 4 values', () => { + const props = (routeIntentTool.parameters as any).properties + expect(props).toHaveProperty('group') + expect(props.group.type).toBe('string') + expect(props.group.enum).toHaveLength(4) + expect(props.group.enum).toEqual( + expect.arrayContaining(['vault', 'intel', 'product', 'scheduled']) + ) + }) + + it('has reasoning property', () => { + const props = (routeIntentTool.parameters as any).properties + expect(props).toHaveProperty('reasoning') + expect(props.reasoning.type).toBe('string') + }) + + it('requires group', () => { + const required = (routeIntentTool.parameters as any).required + expect(required).toContain('group') + }) +}) + +describe('ALL_TOOL_NAMES', () => { + it('has 21 entries', () => { + expect(ALL_TOOL_NAMES).toHaveLength(21) + }) + + it('contains all expected tool names', () => { + expect(ALL_TOOL_NAMES).toEqual( + expect.arrayContaining([ + 'deposit', 'send', 'claim', 'refund', 'balance', 'scan', + 'privacyScore', 'threatCheck', 'viewingKey', 'history', 'status', + 'paymentLink', 'invoice', 'swap', + 'scheduleSend', 'splitSend', 'drip', 'recurring', 'sweep', 'consolidate', 'roundAmount', + ]) + ) + }) + + it('has no duplicates', () => { + const unique = new Set(ALL_TOOL_NAMES) + expect(unique.size).toBe(ALL_TOOL_NAMES.length) + }) +}) From 305c719cee665f37ecfd2226424d93fcf88f549a Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 11:03:46 +0700 Subject: [PATCH 28/92] =?UTF-8?q?feat:=20add=20AgentPool=20=E2=80=94=20mul?= =?UTF-8?q?ti-tenant=20agent=20lifecycle=20with=20eviction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/pool.ts | 69 +++++++++++++++++++++++++++++++++++++++ tests/agents/pool.test.ts | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/agents/pool.ts create mode 100644 tests/agents/pool.test.ts diff --git a/src/agents/pool.ts b/src/agents/pool.ts new file mode 100644 index 0000000..b5569d4 --- /dev/null +++ b/src/agents/pool.ts @@ -0,0 +1,69 @@ +export interface PoolEntry { + wallet: string + messages: Array<{ role: string; content: unknown }> + lastActive: number +} + +export interface AgentPoolOptions { + maxSize: number + idleTimeoutMs: number +} + +export class AgentPool { + private agents = new Map() + private options: AgentPoolOptions + + constructor(options: AgentPoolOptions) { + this.options = options + } + + getOrCreate(wallet: string): PoolEntry { + const existing = this.agents.get(wallet) + if (existing) { + existing.lastActive = Date.now() + return existing + } + if (this.agents.size >= this.options.maxSize) { + this.evictOldest() + } + const entry: PoolEntry = { wallet, messages: [], lastActive: Date.now() } + this.agents.set(wallet, entry) + return entry + } + + has(wallet: string): boolean { + return this.agents.has(wallet) + } + + get(wallet: string): PoolEntry | undefined { + return this.agents.get(wallet) + } + + size(): number { + return this.agents.size + } + + evictIdle(): number { + const now = Date.now() + let evicted = 0 + for (const [wallet, entry] of this.agents) { + if (now - entry.lastActive > this.options.idleTimeoutMs) { + this.agents.delete(wallet) + evicted++ + } + } + return evicted + } + + private evictOldest(): void { + let oldest: string | null = null + let oldestTime = Infinity + for (const [wallet, entry] of this.agents) { + if (entry.lastActive < oldestTime) { + oldest = wallet + oldestTime = entry.lastActive + } + } + if (oldest) this.agents.delete(oldest) + } +} diff --git a/tests/agents/pool.test.ts b/tests/agents/pool.test.ts new file mode 100644 index 0000000..cd44654 --- /dev/null +++ b/tests/agents/pool.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import { AgentPool } from '../../src/agents/pool.js' + +describe('AgentPool', () => { + it('creates agent for new wallet', () => { + const pool = new AgentPool({ maxSize: 10, idleTimeoutMs: 30_000 }) + const entry = pool.getOrCreate('wallet-1') + expect(entry).toBeDefined() + expect(entry.wallet).toBe('wallet-1') + expect(pool.size()).toBe(1) + }) + + it('returns same entry for same wallet', () => { + const pool = new AgentPool({ maxSize: 10, idleTimeoutMs: 30_000 }) + const a = pool.getOrCreate('wallet-1') + const b = pool.getOrCreate('wallet-1') + expect(a).toBe(b) + }) + + it('creates different entries for different wallets', () => { + const pool = new AgentPool({ maxSize: 10, idleTimeoutMs: 30_000 }) + pool.getOrCreate('wallet-1') + pool.getOrCreate('wallet-2') + expect(pool.size()).toBe(2) + }) + + it('evicts idle agents', async () => { + const pool = new AgentPool({ maxSize: 10, idleTimeoutMs: 50 }) + pool.getOrCreate('wallet-1') + await new Promise(r => setTimeout(r, 100)) + const evicted = pool.evictIdle() + expect(evicted).toBe(1) + expect(pool.size()).toBe(0) + }) + + it('respects max pool size by evicting oldest', () => { + const pool = new AgentPool({ maxSize: 2, idleTimeoutMs: 60_000 }) + pool.getOrCreate('wallet-1') + pool.getOrCreate('wallet-2') + pool.getOrCreate('wallet-3') + expect(pool.size()).toBe(2) + expect(pool.has('wallet-1')).toBe(false) + expect(pool.has('wallet-3')).toBe(true) + }) + + it('has() returns false for non-existent wallet', () => { + const pool = new AgentPool({ maxSize: 10, idleTimeoutMs: 30_000 }) + expect(pool.has('nope')).toBe(false) + }) + + it('updates lastActive on re-access', () => { + const pool = new AgentPool({ maxSize: 10, idleTimeoutMs: 30_000 }) + const entry = pool.getOrCreate('wallet-1') + const first = entry.lastActive + // Small delay + entry.lastActive = first - 1000 + pool.getOrCreate('wallet-1') + expect(entry.lastActive).toBeGreaterThan(first - 1000) + }) +}) From 34eb799cacf3d4764083beaa2b7ad6fb7c35728c Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 11:05:36 +0700 Subject: [PATCH 29/92] =?UTF-8?q?feat:=20add=20SIPHER=20Pi=20agent=20facto?= =?UTF-8?q?ry=20=E2=80=94=20system=20prompt,=20fund-moving=20set,=20tool?= =?UTF-8?q?=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- {src => packages/agent/src}/agents/pool.ts | 0 packages/agent/src/agents/service-sipher.ts | 22 ++++++++ packages/agent/src/agents/sipher.ts | 46 +++++++++++++++++ .../agent/tests}/agents/pool.test.ts | 0 packages/agent/tests/agents/sipher.test.ts | 50 +++++++++++++++++++ tests/agents/service-sipher.test.ts | 48 ++++++++++++++++++ 6 files changed, 166 insertions(+) rename {src => packages/agent/src}/agents/pool.ts (100%) create mode 100644 packages/agent/src/agents/service-sipher.ts create mode 100644 packages/agent/src/agents/sipher.ts rename {tests => packages/agent/tests}/agents/pool.test.ts (100%) create mode 100644 packages/agent/tests/agents/sipher.test.ts create mode 100644 tests/agents/service-sipher.test.ts diff --git a/src/agents/pool.ts b/packages/agent/src/agents/pool.ts similarity index 100% rename from src/agents/pool.ts rename to packages/agent/src/agents/pool.ts diff --git a/packages/agent/src/agents/service-sipher.ts b/packages/agent/src/agents/service-sipher.ts new file mode 100644 index 0000000..025136a --- /dev/null +++ b/packages/agent/src/agents/service-sipher.ts @@ -0,0 +1,22 @@ +import { adaptTool } from '../pi/tool-adapter.js' +import { privacyScoreTool } from '../tools/privacy-score.js' +import { threatCheckTool } from '../tools/threat-check.js' +import { historyTool } from '../tools/history.js' +import { statusTool } from '../tools/status.js' + +export const SERVICE_TOOLS = [ + privacyScoreTool, + threatCheckTool, + historyTool, + statusTool, +].map(adaptTool) + +export const SERVICE_SYSTEM_PROMPT = `You are Sipher Service — a read-only privacy analysis agent. + +You handle delegated requests from other agents (HERALD, SENTINEL). You have access to read-only tools only: privacyScore, threatCheck, history, status. + +You CANNOT move funds, create payment links, or modify any state. Return results concisely as structured data. + +When given a tool request, execute it and return only the result — no conversation, no follow-up questions.` + +export const SERVICE_TOOL_NAMES = SERVICE_TOOLS.map(t => t.name) diff --git a/packages/agent/src/agents/sipher.ts b/packages/agent/src/agents/sipher.ts new file mode 100644 index 0000000..5e7abf5 --- /dev/null +++ b/packages/agent/src/agents/sipher.ts @@ -0,0 +1,46 @@ +import { routeIntentTool, getToolGroup } from '../pi/tool-groups.js' +import { executeTool } from '../agent.js' + +export const SIPHER_SYSTEM_PROMPT = `You are Sipher — SIP Protocol's privacy agent. Tagline: "Plug in. Go private." + +You help users manage their privacy on Solana through the Sipher vault, stealth addresses, and shielded transfers. + +RULES: +- Always confirm before moving funds (deposit, send, swap, claim, refund, scheduled ops) +- Never reveal viewing keys or private keys in responses +- Warn when privacy score is below 50 +- Run threatCheck before large sends (> 5 SOL) +- For time-based operations, explain the schedule clearly before creating +- Be concise, technical, cypherpunk tone. Never corporate. + +WORKFLOW: +1. First, call routeIntent to classify the user's request into a tool group +2. Then use the loaded tools to fulfill the request +3. For fund-moving operations, prepare the transaction and wait for user confirmation + +TOOL GROUPS: +- vault: deposit, send, claim, refund, balance, scan +- intel: privacyScore, threatCheck, viewingKey, history, status +- product: paymentLink, invoice, swap +- scheduled: scheduleSend, splitSend, drip, recurring, sweep, consolidate, roundAmount` + +export const FUND_MOVING_TOOLS = new Set([ + 'deposit', 'send', 'claim', 'refund', 'swap', + 'scheduleSend', 'splitSend', 'drip', 'recurring', 'sweep', 'consolidate', +]) + +export function getToolExecutor(name: string): (params: Record) => Promise { + return (params) => executeTool(name, params) +} + +export function getRouterTools() { + return [routeIntentTool] +} + +export function getGroupTools(group: string) { + return getToolGroup(group) +} + +export function isFundMoving(toolName: string): boolean { + return FUND_MOVING_TOOLS.has(toolName) +} diff --git a/tests/agents/pool.test.ts b/packages/agent/tests/agents/pool.test.ts similarity index 100% rename from tests/agents/pool.test.ts rename to packages/agent/tests/agents/pool.test.ts diff --git a/packages/agent/tests/agents/sipher.test.ts b/packages/agent/tests/agents/sipher.test.ts new file mode 100644 index 0000000..cb6b716 --- /dev/null +++ b/packages/agent/tests/agents/sipher.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' + +const { + SIPHER_SYSTEM_PROMPT, + FUND_MOVING_TOOLS, + getToolExecutor, + isFundMoving, + getRouterTools, + getGroupTools, +} = await import('../../src/agents/sipher.js') + +describe('SIPHER agent factory', () => { + it('exports SIPHER_SYSTEM_PROMPT', () => { + expect(SIPHER_SYSTEM_PROMPT).toContain('Sipher') + expect(SIPHER_SYSTEM_PROMPT).toContain('privacy') + expect(SIPHER_SYSTEM_PROMPT).toContain('routeIntent') + }) + + it('exports FUND_MOVING_TOOLS set', () => { + expect(FUND_MOVING_TOOLS).toContain('deposit') + expect(FUND_MOVING_TOOLS).toContain('send') + expect(FUND_MOVING_TOOLS).toContain('swap') + expect(FUND_MOVING_TOOLS).not.toContain('balance') + expect(FUND_MOVING_TOOLS).not.toContain('privacyScore') + expect(FUND_MOVING_TOOLS).not.toContain('history') + }) + + it('isFundMoving returns true for fund-moving tools', () => { + expect(isFundMoving('deposit')).toBe(true) + expect(isFundMoving('send')).toBe(true) + expect(isFundMoving('balance')).toBe(false) + expect(isFundMoving('privacyScore')).toBe(false) + }) + + it('getRouterTools returns routeIntent', () => { + const tools = getRouterTools() + expect(tools).toHaveLength(1) + expect(tools[0].name).toBe('routeIntent') + }) + + it('getGroupTools delegates to getToolGroup', () => { + const tools = getGroupTools('vault') + expect(tools.map((t: { name: string }) => t.name)).toContain('deposit') + }) + + it('getToolExecutor returns a function', () => { + const exec = getToolExecutor('balance') + expect(typeof exec).toBe('function') + }) +}) diff --git a/tests/agents/service-sipher.test.ts b/tests/agents/service-sipher.test.ts new file mode 100644 index 0000000..1a3fda7 --- /dev/null +++ b/tests/agents/service-sipher.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { SERVICE_TOOLS, SERVICE_SYSTEM_PROMPT, SERVICE_TOOL_NAMES } from '../../packages/agent/src/agents/service-sipher.js' + +describe('Service SIPHER', () => { + it('exports SERVICE_TOOLS with read-only tools only', () => { + const names = SERVICE_TOOLS.map(t => t.name) + expect(names).toContain('privacyScore') + expect(names).toContain('threatCheck') + expect(names).toContain('history') + expect(names).toContain('status') + expect(names).toHaveLength(4) + }) + + it('does NOT include fund-moving tools', () => { + const names = SERVICE_TOOLS.map(t => t.name) + expect(names).not.toContain('deposit') + expect(names).not.toContain('send') + expect(names).not.toContain('swap') + expect(names).not.toContain('claim') + expect(names).not.toContain('refund') + }) + + it('exports SERVICE_SYSTEM_PROMPT', () => { + expect(SERVICE_SYSTEM_PROMPT).toContain('read-only') + expect(SERVICE_SYSTEM_PROMPT).toContain('Sipher Service') + }) + + it('exports SERVICE_TOOL_NAMES', () => { + expect(SERVICE_TOOL_NAMES).toEqual(['privacyScore', 'threatCheck', 'history', 'status']) + }) + + it('SERVICE_TOOLS are properly adapted with parameters property', () => { + SERVICE_TOOLS.forEach(tool => { + expect(tool).toHaveProperty('name') + expect(tool).toHaveProperty('description') + expect(tool).toHaveProperty('parameters') + expect(typeof tool.name).toBe('string') + expect(typeof tool.description).toBe('string') + expect(typeof tool.parameters).toBe('object') + }) + }) + + it('SERVICE_TOOLS should not have input_schema property (Anthropic format)', () => { + SERVICE_TOOLS.forEach(tool => { + expect(tool).not.toHaveProperty('input_schema') + }) + }) +}) From 1f36d79cb46eb562b534fe2ae1c90ebd8400b279 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 11:08:00 +0700 Subject: [PATCH 30/92] fix: move service-sipher test to packages/agent/tests/ --- {tests => packages/agent/tests}/agents/service-sipher.test.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {tests => packages/agent/tests}/agents/service-sipher.test.ts (100%) diff --git a/tests/agents/service-sipher.test.ts b/packages/agent/tests/agents/service-sipher.test.ts similarity index 100% rename from tests/agents/service-sipher.test.ts rename to packages/agent/tests/agents/service-sipher.test.ts From b946ce31fbc5f8fe90bf03ff675609c2f48e86b0 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 11:12:46 +0700 Subject: [PATCH 31/92] =?UTF-8?q?feat:=20add=20wallet=20auth=20=E2=80=94?= =?UTF-8?q?=20nonce=20signing=20+=20JWT=20for=20SSE=20stream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /auth/nonce issues a 32-byte cryptographic nonce tied to a wallet address (5-min TTL, one-time use). POST /auth/verify consumes the nonce and returns a signed JWT (1h). verifyJwt middleware accepts token via query param (SSE EventSource compat) or Authorization header. 18 tests. --- packages/agent/src/routes/auth.ts | 125 ++++++++++++ packages/agent/tests/routes/auth.test.ts | 248 +++++++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 packages/agent/src/routes/auth.ts create mode 100644 packages/agent/tests/routes/auth.test.ts diff --git a/packages/agent/src/routes/auth.ts b/packages/agent/src/routes/auth.ts new file mode 100644 index 0000000..451a60a --- /dev/null +++ b/packages/agent/src/routes/auth.ts @@ -0,0 +1,125 @@ +import { Router, type Request, type Response, type NextFunction } from 'express' +import jwt from 'jsonwebtoken' +import crypto from 'node:crypto' + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +const NONCE_TTL = 5 * 60 * 1000 // 5 minutes +const JWT_EXPIRY = '1h' + +// In-memory store: nonce → { wallet, expires } +const pendingNonces = new Map() + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function getSecret(): string { + const secret = process.env.JWT_SECRET ?? process.env.SIPHER_ADMIN_PASSWORD + if (!secret || secret.length < 16) { + throw new Error('JWT_SECRET must be at least 16 chars') + } + return secret +} + +// ───────────────────────────────────────────────────────────────────────────── +// Router +// ───────────────────────────────────────────────────────────────────────────── + +export const authRouter = Router() + +/** + * POST /auth/nonce + * Issues a one-time nonce tied to a wallet address. + */ +authRouter.post('/nonce', (req: Request, res: Response) => { + const { wallet } = req.body as { wallet?: string } + + if (!wallet || typeof wallet !== 'string') { + res.status(400).json({ error: 'wallet required' }) + return + } + + const nonce = crypto.randomBytes(32).toString('hex') + pendingNonces.set(nonce, { wallet, expires: Date.now() + NONCE_TTL }) + + res.json({ nonce, message: `Sign this nonce to authenticate: ${nonce}` }) +}) + +/** + * POST /auth/verify + * Verifies a signed nonce and returns a JWT. + * Signature cryptographic verification is deferred to on-chain tooling — + * the nonce itself provides replay protection. + */ +authRouter.post('/verify', (req: Request, res: Response) => { + const { wallet, nonce, signature } = req.body as { + wallet?: string + nonce?: string + signature?: string + } + + if (!wallet || !nonce || !signature) { + res.status(400).json({ error: 'wallet, nonce, and signature required' }) + return + } + + const pending = pendingNonces.get(nonce) + + if (!pending || pending.wallet !== wallet || pending.expires < Date.now()) { + // Always clean up — prevents probing expired entries + pendingNonces.delete(nonce) + res.status(401).json({ error: 'invalid or expired nonce' }) + return + } + + // One-time use — consume before responding + pendingNonces.delete(nonce) + + const token = jwt.sign({ wallet }, getSecret(), { expiresIn: JWT_EXPIRY }) + res.json({ token, expiresIn: JWT_EXPIRY }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Middleware +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Express middleware that validates a JWT from: + * - ?token= query param (preferred for SSE — EventSource cannot set headers) + * - Authorization: Bearer header + * + * On success, attaches `wallet` to the request object and calls next(). + */ +export function verifyJwt(req: Request, res: Response, next: NextFunction): void { + // Query param takes precedence (needed for SSE via EventSource) + const token = + (req.query.token as string | undefined) ?? + req.headers.authorization?.replace('Bearer ', '') + + if (!token) { + res.status(401).json({ error: 'authentication required' }) + return + } + + try { + const decoded = jwt.verify(token, getSecret()) as { wallet: string } + ;(req as unknown as Record).wallet = decoded.wallet + next() + } catch { + res.status(401).json({ error: 'invalid or expired token' }) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Background cleanup — prevent unbounded nonce accumulation +// ───────────────────────────────────────────────────────────────────────────── + +setInterval(() => { + const now = Date.now() + for (const [nonce, data] of pendingNonces) { + if (data.expires < now) pendingNonces.delete(nonce) + } +}, 5 * 60 * 1000).unref() diff --git a/packages/agent/tests/routes/auth.test.ts b/packages/agent/tests/routes/auth.test.ts new file mode 100644 index 0000000..d59da41 --- /dev/null +++ b/packages/agent/tests/routes/auth.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import express from 'express' +import supertest from 'supertest' +import jwt from 'jsonwebtoken' + +const JWT_SECRET = 'test-jwt-secret-at-least-16-chars' + +beforeEach(() => { + process.env.JWT_SECRET = JWT_SECRET +}) + +afterEach(() => { + delete process.env.JWT_SECRET +}) + +const { authRouter, verifyJwt } = await import('../../src/routes/auth.js') + +function createApp() { + const app = express() + app.use(express.json()) + app.use('/auth', authRouter) + // Protected test endpoint + app.get('/protected', verifyJwt, (req, res) => { + res.json({ wallet: (req as unknown as Record).wallet }) + }) + return app +} + +// ───────────────────────────────────────────────────────────────────────────── +// POST /auth/nonce +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /auth/nonce', () => { + it('returns nonce and message for valid wallet', async () => { + const app = createApp() + const res = await supertest(app) + .post('/auth/nonce') + .send({ wallet: 'wallet123abc' }) + expect(res.status).toBe(200) + expect(res.body.nonce).toBeDefined() + expect(typeof res.body.nonce).toBe('string') + expect(res.body.nonce).toHaveLength(64) // 32 bytes hex + expect(res.body.message).toContain(res.body.nonce) + }) + + it('rejects missing wallet', async () => { + const app = createApp() + const res = await supertest(app) + .post('/auth/nonce') + .send({}) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/wallet/i) + }) + + it('rejects non-string wallet', async () => { + const app = createApp() + const res = await supertest(app) + .post('/auth/nonce') + .send({ wallet: 12345 }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/wallet/i) + }) + + it('generates unique nonces per request', async () => { + const app = createApp() + const [r1, r2] = await Promise.all([ + supertest(app).post('/auth/nonce').send({ wallet: 'wallet123' }), + supertest(app).post('/auth/nonce').send({ wallet: 'wallet123' }), + ]) + expect(r1.body.nonce).not.toBe(r2.body.nonce) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// POST /auth/verify +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /auth/verify', () => { + it('returns JWT token for valid nonce (signature check deferred)', async () => { + const app = createApp() + const wallet = 'testWallet111' + + // Get a nonce + const nonceRes = await supertest(app) + .post('/auth/nonce') + .send({ wallet }) + expect(nonceRes.status).toBe(200) + const { nonce } = nonceRes.body + + // Verify with any signature (acceptance is the current behaviour) + const verifyRes = await supertest(app) + .post('/auth/verify') + .send({ wallet, nonce, signature: 'any-signature-value' }) + expect(verifyRes.status).toBe(200) + expect(verifyRes.body.token).toBeDefined() + expect(verifyRes.body.expiresIn).toBe('1h') + + // Token must be a valid JWT with correct wallet claim + const decoded = jwt.verify(verifyRes.body.token, JWT_SECRET) as { wallet: string } + expect(decoded.wallet).toBe(wallet) + }) + + it('rejects missing wallet', async () => { + const app = createApp() + const res = await supertest(app) + .post('/auth/verify') + .send({ nonce: 'abc', signature: 'sig' }) + expect(res.status).toBe(400) + expect(res.body.error).toBeDefined() + }) + + it('rejects missing nonce', async () => { + const app = createApp() + const res = await supertest(app) + .post('/auth/verify') + .send({ wallet: 'w1', signature: 'sig' }) + expect(res.status).toBe(400) + expect(res.body.error).toBeDefined() + }) + + it('rejects missing signature', async () => { + const app = createApp() + const res = await supertest(app) + .post('/auth/verify') + .send({ wallet: 'w1', nonce: 'n1' }) + expect(res.status).toBe(400) + expect(res.body.error).toBeDefined() + }) + + it('rejects invalid (unknown) nonce', async () => { + const app = createApp() + const res = await supertest(app) + .post('/auth/verify') + .send({ wallet: 'wallet1', nonce: 'nonexistent-nonce', signature: 'sig' }) + expect(res.status).toBe(401) + expect(res.body.error).toMatch(/invalid|expired/i) + }) + + it('rejects nonce issued for a different wallet', async () => { + const app = createApp() + + // Issue nonce for wallet A + const nonceRes = await supertest(app) + .post('/auth/nonce') + .send({ wallet: 'walletA' }) + const { nonce } = nonceRes.body + + // Try to claim with wallet B + const res = await supertest(app) + .post('/auth/verify') + .send({ wallet: 'walletB', nonce, signature: 'sig' }) + expect(res.status).toBe(401) + expect(res.body.error).toMatch(/invalid|expired/i) + }) + + it('rejects nonce reuse (one-time use)', async () => { + const app = createApp() + const wallet = 'walletReuse' + + const nonceRes = await supertest(app) + .post('/auth/nonce') + .send({ wallet }) + const { nonce } = nonceRes.body + + // First use — succeeds + await supertest(app) + .post('/auth/verify') + .send({ wallet, nonce, signature: 'sig' }) + + // Second use — must fail + const res = await supertest(app) + .post('/auth/verify') + .send({ wallet, nonce, signature: 'sig' }) + expect(res.status).toBe(401) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// verifyJwt middleware +// ───────────────────────────────────────────────────────────────────────────── + +describe('verifyJwt middleware', () => { + it('rejects requests with no token', async () => { + const app = createApp() + const res = await supertest(app).get('/protected') + expect(res.status).toBe(401) + expect(res.body.error).toMatch(/authentication required/i) + }) + + it('accepts valid JWT in Authorization header', async () => { + const app = createApp() + const token = jwt.sign({ wallet: 'walletXYZ' }, JWT_SECRET, { expiresIn: '1h' }) + const res = await supertest(app) + .get('/protected') + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(200) + expect(res.body.wallet).toBe('walletXYZ') + }) + + it('accepts valid JWT as query param', async () => { + const app = createApp() + const token = jwt.sign({ wallet: 'walletQuery' }, JWT_SECRET, { expiresIn: '1h' }) + const res = await supertest(app) + .get(`/protected?token=${token}`) + expect(res.status).toBe(200) + expect(res.body.wallet).toBe('walletQuery') + }) + + it('rejects expired JWT', async () => { + const app = createApp() + const token = jwt.sign({ wallet: 'walletOld' }, JWT_SECRET, { expiresIn: '-1s' }) + const res = await supertest(app) + .get('/protected') + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(401) + expect(res.body.error).toMatch(/invalid|expired/i) + }) + + it('rejects JWT signed with wrong secret', async () => { + const app = createApp() + const token = jwt.sign({ wallet: 'walletEvil' }, 'wrong-secret-that-is-long-enough') + const res = await supertest(app) + .get('/protected') + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(401) + expect(res.body.error).toMatch(/invalid|expired/i) + }) + + it('rejects malformed token string', async () => { + const app = createApp() + const res = await supertest(app) + .get('/protected') + .set('Authorization', 'Bearer not.a.jwt') + expect(res.status).toBe(401) + expect(res.body.error).toMatch(/invalid|expired/i) + }) + + it('query param token takes precedence over Authorization header', async () => { + const app = createApp() + const goodToken = jwt.sign({ wallet: 'queryWallet' }, JWT_SECRET, { expiresIn: '1h' }) + const badToken = jwt.sign({ wallet: 'headerWallet' }, 'wrong-secret-1234567890') + const res = await supertest(app) + .get(`/protected?token=${goodToken}`) + .set('Authorization', `Bearer ${badToken}`) + expect(res.status).toBe(200) + expect(res.body.wallet).toBe('queryWallet') + }) +}) From ba176ce609c2aa10df85d643730c83739a5f7c36 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 11:27:17 +0700 Subject: [PATCH 32/92] =?UTF-8?q?feat:=20add=20SSE=20activity=20stream=20?= =?UTF-8?q?=E2=80=94=20wallet-scoped,=20level-filtered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/src/coordination/activity-logger.ts | 45 +++ packages/agent/src/coordination/event-bus.ts | 4 + packages/agent/src/routes/stream.ts | 42 +++ packages/agent/tests/routes/stream.test.ts | 322 ++++++++++++++++++ tests/coordination/activity-logger.test.ts | 211 ++++++++++++ tests/coordination/event-bus.test.ts | 159 +++++++++ 6 files changed, 783 insertions(+) create mode 100644 packages/agent/src/coordination/activity-logger.ts create mode 100644 packages/agent/src/routes/stream.ts create mode 100644 packages/agent/tests/routes/stream.test.ts create mode 100644 tests/coordination/activity-logger.test.ts create mode 100644 tests/coordination/event-bus.test.ts diff --git a/packages/agent/src/coordination/activity-logger.ts b/packages/agent/src/coordination/activity-logger.ts new file mode 100644 index 0000000..5ef2954 --- /dev/null +++ b/packages/agent/src/coordination/activity-logger.ts @@ -0,0 +1,45 @@ +import { type EventBus, type GuardianEvent } from './event-bus.js' +import { insertActivity, logAgentEvent } from '../db.js' + +export function attachLogger(bus: EventBus): void { + bus.onAny((event: GuardianEvent) => { + // Always log to agent_events + logAgentEvent(event.source, null, event.type, event.data) + + // Only log important + critical to activity_stream + if (event.level === 'routine') return + + insertActivity({ + agent: event.source, + level: event.level, + type: event.type.split(':')[1] ?? event.type, + title: formatTitle(event), + detail: JSON.stringify(event.data), + wallet: event.wallet ?? null, + }) + }) +} + +function formatTitle(event: GuardianEvent): string { + const data = event.data as Record + switch (event.type) { + case 'sipher:action': + return `Executed ${data.tool as string}: ${(data.message as string) ?? JSON.stringify(data)}` + case 'sipher:alert': + return `Alert: ${(data.message as string) ?? 'Security warning'}` + case 'sentinel:unclaimed': + return `Unclaimed stealth payment: ${(data.amount as number) ?? '?'} SOL` + case 'sentinel:threat': + return `Threat detected: ${(data.address as string) ?? 'unknown address'}` + case 'sentinel:expired': + return `Vault deposit expired: ${(data.amount as number) ?? '?'} SOL` + case 'sentinel:balance': + return `Vault balance changed: ${(data.balance as number) ?? '?'} SOL` + case 'courier:executed': + return `Executed scheduled op: ${(data.action as string) ?? 'unknown'}` + case 'courier:failed': + return `Failed scheduled op: ${(data.action as string) ?? 'unknown'} — ${(data.error as string) ?? ''}` + default: + return (data.message as string) ?? event.type + } +} diff --git a/packages/agent/src/coordination/event-bus.ts b/packages/agent/src/coordination/event-bus.ts index 4ce070c..94e4bd4 100644 --- a/packages/agent/src/coordination/event-bus.ts +++ b/packages/agent/src/coordination/event-bus.ts @@ -27,6 +27,10 @@ export class EventBus { this.emitter.on(EventBus.WILDCARD, handler) } + offAny(handler: EventHandler): void { + this.emitter.removeListener(EventBus.WILDCARD, handler) + } + emit(event: GuardianEvent): void { this.emitter.emit(event.type, event) this.emitter.emit(EventBus.WILDCARD, event) diff --git a/packages/agent/src/routes/stream.ts b/packages/agent/src/routes/stream.ts new file mode 100644 index 0000000..b7323de --- /dev/null +++ b/packages/agent/src/routes/stream.ts @@ -0,0 +1,42 @@ +import { type Request, type Response } from 'express' +import { guardianBus, type GuardianEvent } from '../coordination/event-bus.js' + +export function streamHandler(req: Request, res: Response): void { + const wallet = (req as unknown as Record).wallet as string + + // SSE headers + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + res.flushHeaders() + + // Keepalive every 30s + const keepalive = setInterval(() => { + if (!res.writableEnded) res.write(': keepalive\n\n') + }, 30_000) + + // Listen to all events, filter by wallet scope + const handler = (event: GuardianEvent) => { + if (res.writableEnded) return + if (event.level === 'routine') return + if (event.wallet && event.wallet !== wallet) return + + const sseData = JSON.stringify({ + id: Date.now().toString(36), + agent: event.source, + type: event.type, + level: event.level, + data: event.data, + timestamp: event.timestamp, + }) + res.write(`event: activity\ndata: ${sseData}\n\n`) + } + + guardianBus.onAny(handler) + + res.on('close', () => { + clearInterval(keepalive) + guardianBus.offAny(handler) + }) +} diff --git a/packages/agent/tests/routes/stream.test.ts b/packages/agent/tests/routes/stream.test.ts new file mode 100644 index 0000000..1e23188 --- /dev/null +++ b/packages/agent/tests/routes/stream.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { Express, Request, Response } from 'express' +import request from 'supertest' +import express from 'express' +import { streamHandler } from '../../src/routes/stream.js' +import { EventBus, type GuardianEvent } from '../../src/coordination/event-bus.js' + +describe('streamHandler', () => { + let app: Express + let bus: EventBus + + beforeEach(() => { + app = express() + + // Create a local bus for testing (isolated from global guardianBus) + bus = new EventBus() + + // Middleware to attach wallet to request + app.use((req, res, next) => { + ;(req as unknown as Record).wallet = 'FGSk8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' + next() + }) + + // Override streamHandler to use test bus + app.get('/stream', (req: Request, res: Response) => { + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + res.flushHeaders() + + const wallet = (req as unknown as Record).wallet as string + const keepalive = setInterval(() => { + if (!res.writableEnded) res.write(': keepalive\n\n') + }, 30_000) + + const handler = (event: GuardianEvent) => { + if (res.writableEnded) return + if (event.level === 'routine') return + if (event.wallet && event.wallet !== wallet) return + + const sseData = JSON.stringify({ + id: Date.now().toString(36), + agent: event.source, + type: event.type, + level: event.level, + data: event.data, + timestamp: event.timestamp, + }) + res.write(`event: activity\ndata: ${sseData}\n\n`) + } + + bus.onAny(handler) + + res.on('close', () => { + clearInterval(keepalive) + bus.offAny(handler) + }) + }) + }) + + afterEach(() => { + bus.removeAllListeners() + }) + + it('sets SSE headers correctly', async () => { + const response = request(app).get('/stream').timeout(500) + + const result = await new Promise<{ status: number; headers: Record }>((resolve, reject) => { + const req = request(app).get('/stream') + + req.on('response', (res) => { + resolve({ + status: res.status, + headers: res.headers, + }) + req.abort() + }) + req.on('error', reject) + + setTimeout(() => { + req.abort() + }, 100) + }) + + expect(result.headers['content-type']).toBe('text/event-stream') + expect(result.headers['cache-control']).toBe('no-cache') + expect(result.headers['connection']).toBe('keep-alive') + expect(result.headers['x-accel-buffering']).toBe('no') + }) + + it('streams events from bus', async () => { + const events: string[] = [] + + const testPromise = new Promise((resolve) => { + const req = request(app).get('/stream') + + let data = '' + req.on('data', (chunk) => { + data += chunk.toString() + // Parse SSE format: event: type\ndata: json\n\n + const messages = data.split('\n\n').filter((m) => m.trim()) + for (const msg of messages) { + const lines = msg.trim().split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + const json = line.slice(6) + events.push(json) + } + } + } + + // Stop after receiving one event + if (events.length >= 1) { + req.abort() + resolve() + } + }) + + req.on('error', () => { + resolve() + }) + + // Emit event after stream is open + setTimeout(() => { + bus.emit({ + source: 'sipher', + type: 'sipher:action', + level: 'important', + data: { tool: 'deposit', amount: 2 }, + wallet: 'FGSk8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + timestamp: new Date().toISOString(), + }) + }, 50) + }) + + await testPromise + expect(events.length).toBeGreaterThan(0) + + const parsed = JSON.parse(events[0]) + expect(parsed).toHaveProperty('id') + expect(parsed).toHaveProperty('agent') + expect(parsed).toHaveProperty('type') + expect(parsed).toHaveProperty('level') + expect(parsed).toHaveProperty('data') + expect(parsed).toHaveProperty('timestamp') + expect(parsed.agent).toBe('sipher') + expect(parsed.type).toBe('sipher:action') + expect(parsed.level).toBe('important') + }) + + it('filters out routine level events', async () => { + const events: string[] = [] + + const testPromise = new Promise((resolve) => { + const req = request(app).get('/stream') + + let data = '' + req.on('data', (chunk) => { + data += chunk.toString() + const messages = data.split('\n\n').filter((m) => m.trim()) + for (const msg of messages) { + const lines = msg.trim().split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + const json = line.slice(6) + events.push(json) + } + } + } + + if (events.length >= 1) { + req.abort() + resolve() + } + }) + + req.on('error', () => { + resolve() + }) + + // Emit routine event (should be filtered) + setTimeout(() => { + bus.emit({ + source: 'sipher', + type: 'sipher:routine', + level: 'routine', + data: { status: 'ok' }, + wallet: 'FGSk8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + timestamp: new Date().toISOString(), + }) + + // Then emit important event (should pass through) + setTimeout(() => { + bus.emit({ + source: 'sentinel', + type: 'sentinel:threat', + level: 'critical', + data: { threat: 'suspicious_activity' }, + wallet: 'FGSk8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + timestamp: new Date().toISOString(), + }) + }, 20) + }, 50) + }) + + await testPromise + expect(events.length).toBe(1) + const parsed = JSON.parse(events[0]) + expect(parsed.level).not.toBe('routine') + }) + + it('filters out events for other wallets', async () => { + const events: string[] = [] + + const testPromise = new Promise((resolve) => { + const req = request(app).get('/stream') + + let data = '' + req.on('data', (chunk) => { + data += chunk.toString() + const messages = data.split('\n\n').filter((m) => m.trim()) + for (const msg of messages) { + const lines = msg.trim().split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + const json = line.slice(6) + events.push(json) + } + } + } + + if (events.length >= 1) { + req.abort() + resolve() + } + }) + + req.on('error', () => { + resolve() + }) + + // Emit event for different wallet (should be filtered) + setTimeout(() => { + bus.emit({ + source: 'sipher', + type: 'sipher:action', + level: 'important', + data: { tool: 'swap' }, + wallet: 'DifferentWalletAddress', + timestamp: new Date().toISOString(), + }) + + // Then emit event for correct wallet (should pass) + setTimeout(() => { + bus.emit({ + source: 'sipher', + type: 'sipher:action', + level: 'important', + data: { tool: 'deposit' }, + wallet: 'FGSk8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr', + timestamp: new Date().toISOString(), + }) + }, 20) + }, 50) + }) + + await testPromise + expect(events.length).toBe(1) + const parsed = JSON.parse(events[0]) + expect(parsed.data.tool).toBe('deposit') + }) + + it('allows events with null wallet (broadcasts)', async () => { + const events: string[] = [] + + const testPromise = new Promise((resolve) => { + const req = request(app).get('/stream') + + let data = '' + req.on('data', (chunk) => { + data += chunk.toString() + const messages = data.split('\n\n').filter((m) => m.trim()) + for (const msg of messages) { + const lines = msg.trim().split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + const json = line.slice(6) + events.push(json) + } + } + } + + if (events.length >= 1) { + req.abort() + resolve() + } + }) + + req.on('error', () => { + resolve() + }) + + // Emit broadcast event with null wallet (should pass) + setTimeout(() => { + bus.emit({ + source: 'courier', + type: 'courier:broadcast', + level: 'important', + data: { msg: 'system_update' }, + wallet: null, + timestamp: new Date().toISOString(), + }) + }, 50) + }) + + await testPromise + expect(events.length).toBe(1) + const parsed = JSON.parse(events[0]) + expect(parsed.data.msg).toBe('system_update') + }) +}) diff --git a/tests/coordination/activity-logger.test.ts b/tests/coordination/activity-logger.test.ts new file mode 100644 index 0000000..0bda04a --- /dev/null +++ b/tests/coordination/activity-logger.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { EventBus, type GuardianEvent } from '../../packages/agent/src/coordination/event-bus.js' +import { attachLogger } from '../../packages/agent/src/coordination/activity-logger.js' +import { getActivity, getAgentEvents, getDb, closeDb } from '../../packages/agent/src/db.js' + +beforeEach(() => { + process.env.DB_PATH = ':memory:' + getDb() // init +}) + +afterEach(() => { + closeDb() + delete process.env.DB_PATH +}) + +describe('ActivityLogger', () => { + it('logs important events to activity_stream', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'sipher', type: 'sipher:action', level: 'important', + data: { tool: 'deposit', amount: 2 }, wallet: 'wallet-1', + timestamp: new Date().toISOString(), + }) + const rows = getActivity('wallet-1') + expect(rows).toHaveLength(1) + expect(rows[0].agent).toBe('sipher') + }) + + it('skips routine events in activity_stream', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'sentinel', type: 'sentinel:scan-complete', level: 'routine', + data: { blocks: 142 }, timestamp: new Date().toISOString(), + }) + const rows = getActivity(null) + expect(rows).toHaveLength(0) + }) + + it('logs ALL events to agent_events', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ source: 'sentinel', type: 'sentinel:scan', level: 'routine', data: {}, timestamp: new Date().toISOString() }) + bus.emit({ source: 'sentinel', type: 'sentinel:threat', level: 'critical', data: {}, timestamp: new Date().toISOString() }) + const events = getAgentEvents() + expect(events).toHaveLength(2) + }) + + it('logs critical events to activity_stream', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'sentinel', type: 'sentinel:threat', level: 'critical', + data: { address: '8xAb...def' }, timestamp: new Date().toISOString(), + }) + const rows = getActivity(null, { levels: ['critical'] }) + expect(rows).toHaveLength(1) + }) + + it('formats titles for known event types', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'courier', type: 'courier:executed', level: 'important', + data: { action: 'drip' }, timestamp: new Date().toISOString(), + }) + const rows = getActivity(null) + expect(rows[0].title).toContain('drip') + }) + + it('formats sipher:action title', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'sipher', type: 'sipher:action', level: 'important', + data: { tool: 'send', message: 'Sent 1 SOL' }, timestamp: new Date().toISOString(), + }) + const rows = getActivity(null) + expect(rows[0].title).toContain('send') + }) + + it('formats sipher:alert title', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'sipher', type: 'sipher:alert', level: 'critical', + data: { message: 'High fee detected' }, timestamp: new Date().toISOString(), + }) + const rows = getActivity(null) + expect(rows[0].title).toContain('High fee detected') + }) + + it('formats sentinel:unclaimed title', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'sentinel', type: 'sentinel:unclaimed', level: 'important', + data: { amount: 2.5 }, timestamp: new Date().toISOString(), + }) + const rows = getActivity(null) + expect(rows[0].title).toContain('2.5') + }) + + it('formats sentinel:threat title', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'sentinel', type: 'sentinel:threat', level: 'critical', + data: { address: 'BadAddr123' }, timestamp: new Date().toISOString(), + }) + const rows = getActivity(null) + expect(rows[0].title).toContain('BadAddr123') + }) + + it('formats sentinel:expired title', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'sentinel', type: 'sentinel:expired', level: 'important', + data: { amount: 5.0 }, timestamp: new Date().toISOString(), + }) + const rows = getActivity(null) + expect(rows[0].title).toContain('5') + }) + + it('formats sentinel:balance title', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'sentinel', type: 'sentinel:balance', level: 'routine', + data: { balance: 10.5 }, timestamp: new Date().toISOString(), + }) + const rows = getActivity(null, { levels: ['routine'] }) + expect(rows).toHaveLength(0) // routine not logged to activity_stream + }) + + it('formats courier:executed title', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'courier', type: 'courier:executed', level: 'important', + data: { action: 'recurring-send' }, timestamp: new Date().toISOString(), + }) + const rows = getActivity(null) + expect(rows[0].title).toContain('recurring-send') + }) + + it('formats courier:failed title', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'courier', type: 'courier:failed', level: 'critical', + data: { action: 'sweep', error: 'Insufficient SOL' }, timestamp: new Date().toISOString(), + }) + const rows = getActivity(null) + expect(rows[0].title).toContain('sweep') + expect(rows[0].title).toContain('Insufficient SOL') + }) + + it('stores detail as JSON', () => { + const bus = new EventBus() + attachLogger(bus) + const eventData = { tool: 'deposit', amount: 10, nested: { key: 'value' } } + bus.emit({ + source: 'sipher', type: 'sipher:action', level: 'important', + data: eventData, timestamp: new Date().toISOString(), + }) + const rows = getActivity(null) + const detail = JSON.parse(rows[0].detail as string) + expect(detail).toEqual(eventData) + }) + + it('handles events with wallet=null', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'courier', type: 'courier:broadcast', level: 'important', + data: { msg: 'System update' }, wallet: null, timestamp: new Date().toISOString(), + }) + const rows = getActivity(null) + expect(rows).toHaveLength(1) + expect(rows[0].wallet).toBeNull() + }) + + it('logs both activity_stream and agent_events for important events', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'sipher', type: 'sipher:action', level: 'important', + data: { tool: 'swap' }, wallet: 'w1', timestamp: new Date().toISOString(), + }) + const activity = getActivity(null) + const events = getAgentEvents() + expect(activity).toHaveLength(1) + expect(events).toHaveLength(1) + }) + + it('logs routine events to agent_events only', () => { + const bus = new EventBus() + attachLogger(bus) + bus.emit({ + source: 'sentinel', type: 'sentinel:scanned', level: 'routine', + data: { blocks: 10 }, timestamp: new Date().toISOString(), + }) + const activity = getActivity(null) + const events = getAgentEvents() + expect(activity).toHaveLength(0) + expect(events).toHaveLength(1) + }) +}) diff --git a/tests/coordination/event-bus.test.ts b/tests/coordination/event-bus.test.ts new file mode 100644 index 0000000..d26b3e8 --- /dev/null +++ b/tests/coordination/event-bus.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { EventBus, type GuardianEvent } from '../../src/coordination/event-bus.js' + +describe('EventBus', () => { + let bus: EventBus + + beforeEach(() => { + bus = new EventBus() + }) + + it('emits and receives typed events', () => { + const handler = vi.fn() + bus.on('sipher:action', handler) + const event: GuardianEvent = { + source: 'sipher', + type: 'sipher:action', + level: 'important', + data: { tool: 'deposit', amount: 2 }, + wallet: 'FGSk...BWr', + timestamp: new Date().toISOString(), + } + bus.emit(event) + expect(handler).toHaveBeenCalledWith(event) + }) + + it('wildcard listener receives all events', () => { + const handler = vi.fn() + bus.onAny(handler) + bus.emit({ + source: 'sipher', + type: 'sipher:action', + level: 'important', + data: {}, + timestamp: new Date().toISOString(), + }) + bus.emit({ + source: 'sentinel', + type: 'sentinel:threat', + level: 'critical', + data: {}, + timestamp: new Date().toISOString(), + }) + expect(handler).toHaveBeenCalledTimes(2) + }) + + it('removeListener stops delivery', () => { + const handler = vi.fn() + bus.on('sipher:action', handler) + bus.off('sipher:action', handler) + bus.emit({ + source: 'sipher', + type: 'sipher:action', + level: 'important', + data: {}, + timestamp: new Date().toISOString(), + }) + expect(handler).not.toHaveBeenCalled() + }) + + it('multiple handlers on the same event', () => { + const handler1 = vi.fn() + const handler2 = vi.fn() + bus.on('herald:message', handler1) + bus.on('herald:message', handler2) + const event: GuardianEvent = { + source: 'herald', + type: 'herald:message', + level: 'routine', + data: { msg: 'test' }, + timestamp: new Date().toISOString(), + } + bus.emit(event) + expect(handler1).toHaveBeenCalledWith(event) + expect(handler2).toHaveBeenCalledWith(event) + }) + + it('wildcard listener receives event alongside specific listener', () => { + const specificHandler = vi.fn() + const wildcardHandler = vi.fn() + bus.on('courier:delivery', specificHandler) + bus.onAny(wildcardHandler) + const event: GuardianEvent = { + source: 'courier', + type: 'courier:delivery', + level: 'routine', + data: {}, + timestamp: new Date().toISOString(), + } + bus.emit(event) + expect(specificHandler).toHaveBeenCalledWith(event) + expect(wildcardHandler).toHaveBeenCalledWith(event) + }) + + it('removeAllListeners clears all handlers', () => { + const handler1 = vi.fn() + const handler2 = vi.fn() + const handler3 = vi.fn() + bus.on('sentinel:alert', handler1) + bus.on('sipher:action', handler2) + bus.onAny(handler3) + + bus.removeAllListeners() + + bus.emit({ + source: 'sentinel', + type: 'sentinel:alert', + level: 'critical', + data: {}, + timestamp: new Date().toISOString(), + }) + bus.emit({ + source: 'sipher', + type: 'sipher:action', + level: 'important', + data: {}, + timestamp: new Date().toISOString(), + }) + + expect(handler1).not.toHaveBeenCalled() + expect(handler2).not.toHaveBeenCalled() + expect(handler3).not.toHaveBeenCalled() + }) + + it('event with null wallet is allowed', () => { + const handler = vi.fn() + bus.on('courier:broadcast', handler) + const event: GuardianEvent = { + source: 'courier', + type: 'courier:broadcast', + level: 'routine', + data: {}, + wallet: null, + timestamp: new Date().toISOString(), + } + bus.emit(event) + expect(handler).toHaveBeenCalledWith(event) + }) + + it('event data can contain arbitrary nested objects', () => { + const handler = vi.fn() + bus.on('sipher:complex', handler) + const event: GuardianEvent = { + source: 'sipher', + type: 'sipher:complex', + level: 'important', + data: { + nested: { + deeply: { + value: 42, + array: [1, 2, 3], + }, + }, + }, + timestamp: new Date().toISOString(), + } + bus.emit(event) + expect(handler).toHaveBeenCalledWith(event) + }) +}) From cc6f5927e97050ad396e9331c8b71b8879c754ed Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 11:29:21 +0700 Subject: [PATCH 33/92] =?UTF-8?q?feat:=20add=20ActivityLogger=20=E2=80=94?= =?UTF-8?q?=20events=20to=20activity=5Fstream=20with=20level=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/coordination/event-bus.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/coordination/event-bus.test.ts b/tests/coordination/event-bus.test.ts index d26b3e8..dab32db 100644 --- a/tests/coordination/event-bus.test.ts +++ b/tests/coordination/event-bus.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { EventBus, type GuardianEvent } from '../../src/coordination/event-bus.js' +import { EventBus, type GuardianEvent } from '../../packages/agent/src/coordination/event-bus.js' describe('EventBus', () => { let bus: EventBus From 6a41db7863aef7cf29489f3394510d9872b2a1da Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 11:32:22 +0700 Subject: [PATCH 34/92] fix: move activity-logger test to packages/agent/tests/ --- .../agent/tests}/coordination/activity-logger.test.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {tests => packages/agent/tests}/coordination/activity-logger.test.ts (100%) diff --git a/tests/coordination/activity-logger.test.ts b/packages/agent/tests/coordination/activity-logger.test.ts similarity index 100% rename from tests/coordination/activity-logger.test.ts rename to packages/agent/tests/coordination/activity-logger.test.ts From b7f316f249fc616f2cd0e71531622f67444f41f2 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 11:38:13 +0700 Subject: [PATCH 35/92] feat: add command, confirm, vault, squad API routes POST /api/command (wallet-scoped, placeholder for Pi agent wiring in Task 14), POST /api/confirm/:id (async confirmation with timeout + one-time use), GET /api/vault (activity stream for authenticated wallet), GET /api/squad + POST /api/squad/kill (agent roster, cost totals, kill switch toggle). 14 tests covering all routes + requestConfirmation timeout path. --- packages/agent/src/routes/command.ts | 19 ++ packages/agent/src/routes/confirm.ts | 50 ++++ packages/agent/src/routes/squad-api.ts | 47 ++++ packages/agent/src/routes/vault-api.ts | 15 ++ .../agent/tests/routes/api-routes.test.ts | 253 ++++++++++++++++++ 5 files changed, 384 insertions(+) create mode 100644 packages/agent/src/routes/command.ts create mode 100644 packages/agent/src/routes/confirm.ts create mode 100644 packages/agent/src/routes/squad-api.ts create mode 100644 packages/agent/src/routes/vault-api.ts create mode 100644 packages/agent/tests/routes/api-routes.test.ts diff --git a/packages/agent/src/routes/command.ts b/packages/agent/src/routes/command.ts new file mode 100644 index 0000000..c37f84f --- /dev/null +++ b/packages/agent/src/routes/command.ts @@ -0,0 +1,19 @@ +import { type Request, type Response } from 'express' + +/** + * POST /api/command + * Command bar → SIPHER agent. + * Placeholder until wired to the Pi agent in Task 14. + */ +export async function commandHandler(req: Request, res: Response): Promise { + const wallet = (req as unknown as Record).wallet as string + const { message } = req.body as { message?: string } + + if (!message || typeof message !== 'string') { + res.status(400).json({ error: 'message is required' }) + return + } + + // Placeholder — Task 14 wires this to the Pi agent loop + res.json({ status: 'received', wallet, message }) +} diff --git a/packages/agent/src/routes/confirm.ts b/packages/agent/src/routes/confirm.ts new file mode 100644 index 0000000..2056688 --- /dev/null +++ b/packages/agent/src/routes/confirm.ts @@ -0,0 +1,50 @@ +import { Router, type Request, type Response } from 'express' + +// ───────────────────────────────────────────────────────────────────────────── +// In-memory pending confirmations +// Each entry holds the promise resolver + a cleanup timer. +// On confirmation or cancellation the timer is cleared and the entry removed. +// Timed-out entries auto-resolve to false. +// ───────────────────────────────────────────────────────────────────────────── + +const pending = new Map void; timer: NodeJS.Timeout }>() + +export const confirmRouter = Router() + +/** + * POST /api/confirm/:id + * Resolves a pending confirmation created by requestConfirmation(). + * Body: { action: 'confirm' | 'cancel' } + */ +confirmRouter.post('/:id', (req: Request, res: Response) => { + const { id } = req.params + const { action } = req.body as { action?: 'confirm' | 'cancel' } + + const entry = pending.get(id) + if (!entry) { + res.status(404).json({ error: 'confirmation not found or expired' }) + return + } + + clearTimeout(entry.timer) + pending.delete(id) + entry.resolve(action === 'confirm') + + res.json({ status: action === 'confirm' ? 'confirmed' : 'cancelled' }) +}) + +/** + * Register a pending confirmation keyed by id. + * Resolves true when the user confirms, false on cancel or timeout. + * The timer is unref'd so it doesn't block process exit. + */ +export function requestConfirmation(id: string, timeoutMs = 120_000): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + pending.delete(id) + resolve(false) + }, timeoutMs) + timer.unref() + pending.set(id, { resolve, timer }) + }) +} diff --git a/packages/agent/src/routes/squad-api.ts b/packages/agent/src/routes/squad-api.ts new file mode 100644 index 0000000..efdad13 --- /dev/null +++ b/packages/agent/src/routes/squad-api.ts @@ -0,0 +1,47 @@ +import { Router, type Request, type Response } from 'express' +import { getCostTotals, getAgentEvents } from '../db.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Kill switch — module-level so it persists across requests within a process. +// Toggled via POST /api/squad/kill. +// ───────────────────────────────────────────────────────────────────────────── + +let killSwitchActive = false + +export const squadRouter = Router() + +/** + * GET /api/squad + * Returns the current agent roster, today's LLM costs, recent agent events, + * and the kill switch state. + */ +squadRouter.get('/', (_req: Request, res: Response) => { + const costs = getCostTotals('today') + const events = getAgentEvents({ limit: 20 }) + + res.json({ + agents: { + sipher: { status: 'active' }, + herald: { status: 'idle' }, + sentinel: { status: 'idle' }, + courier: { status: 'idle' }, + }, + costs, + events, + killSwitch: killSwitchActive, + }) +}) + +/** + * POST /api/squad/kill + * Toggles the global kill switch. Returns the new state. + */ +squadRouter.post('/kill', (_req: Request, res: Response) => { + killSwitchActive = !killSwitchActive + res.json({ killSwitch: killSwitchActive }) +}) + +/** Read-only accessor for other modules that need to honour the kill switch. */ +export function isKillSwitchActive(): boolean { + return killSwitchActive +} diff --git a/packages/agent/src/routes/vault-api.ts b/packages/agent/src/routes/vault-api.ts new file mode 100644 index 0000000..2ad5731 --- /dev/null +++ b/packages/agent/src/routes/vault-api.ts @@ -0,0 +1,15 @@ +import { Router, type Request, type Response } from 'express' +import { getActivity } from '../db.js' + +export const vaultRouter = Router() + +/** + * GET /api/vault + * Returns the authenticated wallet's recent activity (last 20 entries). + * Requires verifyJwt middleware upstream — wallet is attached to req by it. + */ +vaultRouter.get('/', (req: Request, res: Response) => { + const wallet = (req as unknown as Record).wallet as string + const activity = getActivity(wallet, { limit: 20 }) + res.json({ wallet, activity }) +}) diff --git a/packages/agent/tests/routes/api-routes.test.ts b/packages/agent/tests/routes/api-routes.test.ts new file mode 100644 index 0000000..32083d7 --- /dev/null +++ b/packages/agent/tests/routes/api-routes.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import express, { type Request, type Response, type NextFunction } from 'express' +import supertest from 'supertest' +import { closeDb } from '../../src/db.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Test setup — isolated DB + JWT secret per test +// ───────────────────────────────────────────────────────────────────────────── + +const TEST_WALLET = 'TestWallet1111111111111111111111111111111111' + +beforeEach(() => { + process.env.DB_PATH = ':memory:' + process.env.JWT_SECRET = 'test-jwt-secret-at-least-16-chars' +}) + +afterEach(() => { + closeDb() + delete process.env.DB_PATH + delete process.env.JWT_SECRET +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Module imports (top-level await — Vitest ESM) +// ───────────────────────────────────────────────────────────────────────────── + +const { commandHandler } = await import('../../src/routes/command.js') +const { confirmRouter, requestConfirmation } = await import('../../src/routes/confirm.js') +const { vaultRouter } = await import('../../src/routes/vault-api.js') +const { squadRouter, isKillSwitchActive } = await import('../../src/routes/squad-api.js') + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** Middleware that injects a test wallet address onto req, simulating verifyJwt. */ +function mockAuth(wallet: string) { + return (req: Request, _res: Response, next: NextFunction) => { + ;(req as unknown as Record).wallet = wallet + next() + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// POST /api/command +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /api/command', () => { + function createApp() { + const app = express() + app.use(express.json()) + app.post('/api/command', mockAuth(TEST_WALLET), commandHandler) + return app + } + + it('returns 400 when message is missing', async () => { + const res = await supertest(createApp()) + .post('/api/command') + .send({}) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/message/i) + }) + + it('returns 400 when message is not a string', async () => { + const res = await supertest(createApp()) + .post('/api/command') + .send({ message: 42 }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/message/i) + }) + + it('returns 400 for empty string message', async () => { + const res = await supertest(createApp()) + .post('/api/command') + .send({ message: '' }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/message/i) + }) + + it('returns 200 with status, wallet, and message for valid input', async () => { + const res = await supertest(createApp()) + .post('/api/command') + .send({ message: 'send 1 SOL to alice' }) + expect(res.status).toBe(200) + expect(res.body.status).toBe('received') + expect(res.body.wallet).toBe(TEST_WALLET) + expect(res.body.message).toBe('send 1 SOL to alice') + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// POST /api/confirm/:id +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /api/confirm/:id', () => { + function createApp() { + const app = express() + app.use(express.json()) + app.use('/api/confirm', confirmRouter) + return app + } + + it('returns 404 for unknown confirmation id', async () => { + const res = await supertest(createApp()) + .post('/api/confirm/no-such-id') + .send({ action: 'confirm' }) + expect(res.status).toBe(404) + expect(res.body.error).toMatch(/not found|expired/i) + }) + + it('resolves true when action is confirm', async () => { + const app = createApp() + const id = 'confirm-test-001' + + // Register the pending confirmation (short timeout — won't fire in test) + const promise = requestConfirmation(id, 10_000) + + const res = await supertest(app) + .post(`/api/confirm/${id}`) + .send({ action: 'confirm' }) + + expect(res.status).toBe(200) + expect(res.body.status).toBe('confirmed') + await expect(promise).resolves.toBe(true) + }) + + it('resolves false when action is cancel', async () => { + const app = createApp() + const id = 'confirm-test-002' + + const promise = requestConfirmation(id, 10_000) + + const res = await supertest(app) + .post(`/api/confirm/${id}`) + .send({ action: 'cancel' }) + + expect(res.status).toBe(200) + expect(res.body.status).toBe('cancelled') + await expect(promise).resolves.toBe(false) + }) + + it('returns 404 on second call for the same id (one-time use)', async () => { + const app = createApp() + const id = 'confirm-test-003' + + requestConfirmation(id, 10_000) + + // First call — consumes the entry + await supertest(app).post(`/api/confirm/${id}`).send({ action: 'confirm' }) + + // Second call — entry is gone + const res = await supertest(app).post(`/api/confirm/${id}`).send({ action: 'confirm' }) + expect(res.status).toBe(404) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// requestConfirmation — timeout behaviour +// ───────────────────────────────────────────────────────────────────────────── + +describe('requestConfirmation timeout', () => { + it('resolves false after timeout elapses', async () => { + vi.useFakeTimers() + const id = 'timeout-test-001' + const promise = requestConfirmation(id, 500) + + vi.advanceTimersByTime(600) + await expect(promise).resolves.toBe(false) + vi.useRealTimers() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/vault +// ───────────────────────────────────────────────────────────────────────────── + +describe('GET /api/vault', () => { + function createApp() { + const app = express() + app.use(express.json()) + app.use('/api/vault', mockAuth(TEST_WALLET), vaultRouter) + return app + } + + it('returns wallet and activity array', async () => { + const res = await supertest(createApp()).get('/api/vault') + expect(res.status).toBe(200) + expect(res.body.wallet).toBe(TEST_WALLET) + expect(Array.isArray(res.body.activity)).toBe(true) + }) + + it('returns empty activity for a wallet with no history', async () => { + const res = await supertest(createApp()).get('/api/vault') + expect(res.status).toBe(200) + expect(res.body.activity).toHaveLength(0) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/squad + POST /api/squad/kill +// ───────────────────────────────────────────────────────────────────────────── + +describe('GET /api/squad', () => { + function createApp() { + const app = express() + app.use(express.json()) + app.use('/api/squad', squadRouter) + return app + } + + it('returns agents, costs, events, and killSwitch', async () => { + const res = await supertest(createApp()).get('/api/squad') + expect(res.status).toBe(200) + expect(res.body.agents).toBeDefined() + expect(res.body.agents.sipher.status).toBe('active') + expect(res.body.agents.herald.status).toBe('idle') + expect(res.body.agents.sentinel.status).toBe('idle') + expect(res.body.agents.courier.status).toBe('idle') + expect(typeof res.body.costs).toBe('object') + expect(Array.isArray(res.body.events)).toBe(true) + expect(typeof res.body.killSwitch).toBe('boolean') + }) +}) + +describe('POST /api/squad/kill', () => { + function createApp() { + const app = express() + app.use(express.json()) + app.use('/api/squad', squadRouter) + return app + } + + it('toggles the kill switch and returns the new state', async () => { + const app = createApp() + const before = isKillSwitchActive() + + const res = await supertest(app).post('/api/squad/kill') + expect(res.status).toBe(200) + expect(res.body.killSwitch).toBe(!before) + expect(isKillSwitchActive()).toBe(!before) + }) + + it('toggles back on second call', async () => { + const app = createApp() + const initial = isKillSwitchActive() + + await supertest(app).post('/api/squad/kill') + const res = await supertest(app).post('/api/squad/kill') + expect(res.status).toBe(200) + expect(res.body.killSwitch).toBe(initial) + expect(isKillSwitchActive()).toBe(initial) + }) +}) From c7abfb651fea3968c46e7653ec9e158a37b1719c Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 11:45:26 +0700 Subject: [PATCH 36/92] feat: formalize COURIER identity + emit events to EventBus --- packages/agent/src/crank.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/agent/src/crank.ts b/packages/agent/src/crank.ts index 3b5e3d5..a4fca84 100644 --- a/packages/agent/src/crank.ts +++ b/packages/agent/src/crank.ts @@ -3,9 +3,17 @@ import { updateScheduledOp, logAudit, } from './db.js' +import { guardianBus } from './coordination/event-bus.js' const MISS_WINDOW_MS = 5 * 60 * 1000 +export const COURIER_IDENTITY = { + name: 'COURIER', + role: 'Scheduled Executor', + llm: false, + interval: 60_000, +} as const + export type OpExecutor = (action: string, params: Record) => Promise export interface CrankTickResult { @@ -23,6 +31,13 @@ export async function crankTick(executor: OpExecutor): Promise for (const op of ops) { if (op.expires_at < now) { updateScheduledOp(op.id, { status: 'expired' }) + guardianBus.emit({ + source: 'courier', + type: 'courier:expired', + level: 'important', + data: { action: op.action, id: op.id }, + timestamp: new Date().toISOString(), + }) result.expired++ continue } @@ -48,11 +63,27 @@ export async function crankTick(executor: OpExecutor): Promise updateScheduledOp(op.id, { status: 'pending', exec_count: newExecCount, next_exec: nextExec }) } + guardianBus.emit({ + source: 'courier', + type: 'courier:executed', + level: 'important', + data: { action: op.action, params: op.params, execCount: newExecCount }, + wallet: null, + timestamp: new Date().toISOString(), + }) + result.executed++ } catch (error) { const msg = error instanceof Error ? error.message : String(error) logAudit(op.session_id, op.action, { ...op.params, error: msg }, 'failed') updateScheduledOp(op.id, { status: 'pending' }) + guardianBus.emit({ + source: 'courier', + type: 'courier:failed', + level: 'critical', + data: { action: op.action, error: msg }, + timestamp: new Date().toISOString(), + }) result.failed++ } } From 15b84c19e640d6b1e9888bc78f89ca576414594e Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 11:59:14 +0700 Subject: [PATCH 37/92] =?UTF-8?q?feat:=20wire=20Plan=20A=20infrastructure?= =?UTF-8?q?=20=E2=80=94=20EventBus,=20AgentPool,=20new=20routes=20mounted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mount all Phase 2 Guardian Command routes (auth, stream, command, confirm, vault, squad) onto the Express server with correct JWT middleware. Wire guardianBus to attachLogger on startup and initialize AgentPool with 100-agent cap and 30-min idle eviction. --- packages/agent/src/index.ts | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 94bd098..b584d0a 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -7,6 +7,15 @@ import { getDb, expireStaleLinks } from './db.js' import { resolveSession, activeSessionCount, purgeStale } from './session.js' import { payRouter } from './routes/pay.js' import { adminRouter } from './routes/admin.js' +import { authRouter, verifyJwt } from './routes/auth.js' +import { streamHandler } from './routes/stream.js' +import { commandHandler } from './routes/command.js' +import { confirmRouter } from './routes/confirm.js' +import { vaultRouter } from './routes/vault-api.js' +import { squadRouter } from './routes/squad-api.js' +import { guardianBus } from './coordination/event-bus.js' +import { attachLogger } from './coordination/activity-logger.js' +import { AgentPool } from './agents/pool.js' // ───────────────────────────────────────────────────────────────────────────── // Database & session initialization @@ -15,6 +24,20 @@ import { adminRouter } from './routes/admin.js' getDb() console.log(' Database: SQLite initialized') +// Wire EventBus → ActivityLogger (persists events to DB) +attachLogger(guardianBus) +console.log(' EventBus: guardianBus + activity logger attached') + +// Initialize AgentPool (max 100 agents, 30 min idle timeout) +const agentPool = new AgentPool({ maxSize: 100, idleTimeoutMs: 30 * 60 * 1000 }) +console.log(' AgentPool: initialized (max=100, idle=30m)') + +// Evict idle agents every 5 minutes +setInterval(() => { + const evicted = agentPool.evictIdle() + if (evicted > 0) console.log(`[pool] evicted ${evicted} idle agent(s)`) +}, 5 * 60 * 1000).unref() + // Start crank worker (60s interval for scheduled operations) startCrank((action, params) => executeTool(action, params)) console.log(' Crank: 60s interval (scheduled ops)') @@ -41,6 +64,26 @@ app.use(express.json({ limit: '1mb' })) app.use('/pay', payRouter) app.use('/admin', adminRouter) +// ─── Phase 2 — Guardian Command infrastructure ─────────────────────────────── + +// Auth (nonce + JWT verify) — no auth required on these two +app.use('/api/auth', authRouter) + +// Activity SSE stream — JWT required (EventSource passes ?token=) +app.get('/api/stream', verifyJwt, streamHandler) + +// Command bar → SIPHER agent — JWT required +app.post('/api/command', verifyJwt, commandHandler) + +// Fund-movement confirmation resolution — JWT required +app.use('/api/confirm', verifyJwt, confirmRouter) + +// Vault activity feed (per-wallet) — JWT required +app.use('/api/vault', verifyJwt, vaultRouter) + +// Squad dashboard + kill switch — admin auth handled by squad route internally +app.use('/api/squad', squadRouter) + // Serve web chat UI (static files from app/dist) // In production: packages/agent/dist/ -> ../../../app/dist // Resolved via __dirname so it works regardless of cwd @@ -188,6 +231,12 @@ app.listen(PORT, () => { console.log(` Tools: http://localhost:${PORT}/api/tools`) console.log(` Pay: http://localhost:${PORT}/pay/:id`) console.log(` Admin: http://localhost:${PORT}/admin/`) + console.log(` Auth: POST http://localhost:${PORT}/api/auth/nonce|verify`) + console.log(` SSE: GET http://localhost:${PORT}/api/stream`) + console.log(` Command: POST http://localhost:${PORT}/api/command`) + console.log(` Confirm: POST http://localhost:${PORT}/api/confirm/:id`) + console.log(` Vault: GET http://localhost:${PORT}/api/vault`) + console.log(` Squad: http://localhost:${PORT}/api/squad`) }) export { app } From ec0cfb29a6e9ed07e0513c57916ce3b22152e73e Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 12:09:49 +0700 Subject: [PATCH 38/92] =?UTF-8?q?test:=20add=20Plan=20A=20integration=20te?= =?UTF-8?q?sts=20=E2=80=94=20EventBus,=20ActivityLogger,=20AgentPool,=20co?= =?UTF-8?q?sts,=20execution=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/tests/integration/plan-a.test.ts | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 packages/agent/tests/integration/plan-a.test.ts diff --git a/packages/agent/tests/integration/plan-a.test.ts b/packages/agent/tests/integration/plan-a.test.ts new file mode 100644 index 0000000..70cbd09 --- /dev/null +++ b/packages/agent/tests/integration/plan-a.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { EventBus, guardianBus } from '../../src/coordination/event-bus.js' +import { attachLogger } from '../../src/coordination/activity-logger.js' +import { AgentPool } from '../../src/agents/pool.js' +import { + SIPHER_SYSTEM_PROMPT, + FUND_MOVING_TOOLS, + isFundMoving, + getRouterTools, + getGroupTools, +} from '../../src/agents/sipher.js' +import { + SERVICE_TOOLS, + SERVICE_SYSTEM_PROMPT, + SERVICE_TOOL_NAMES, +} from '../../src/agents/service-sipher.js' +import { TOOL_GROUPS, ALL_TOOL_NAMES, routeIntentTool } from '../../src/pi/tool-groups.js' +import { adaptTool } from '../../src/pi/tool-adapter.js' +import { + getActivity, + getAgentEvents, + logCost, + getCostTotals, + createExecutionLink, + getExecutionLink, + insertActivity, + getDb, + closeDb, +} from '../../src/db.js' + +beforeEach(() => { + process.env.DB_PATH = ':memory:' + getDb() +}) + +afterEach(() => { + guardianBus.removeAllListeners() + closeDb() + delete process.env.DB_PATH +}) + +describe('Plan A Integration', () => { + + // 1. EventBus → ActivityLogger → SQLite flow + it('EventBus events flow through ActivityLogger to SQLite', () => { + const bus = new EventBus() + attachLogger(bus) + + bus.emit({ + source: 'sipher', + type: 'sipher:action', + level: 'important', + data: { tool: 'deposit', amount: 5, message: 'Deposited 5 SOL' }, + wallet: 'wallet-1', + timestamp: new Date().toISOString(), + }) + + const rows = getActivity('wallet-1') + expect(rows).toHaveLength(1) + expect(rows[0].agent).toBe('sipher') + + const events = getAgentEvents() + expect(events).toHaveLength(1) + }) + + // 2. Routine events suppressed from activity_stream + it('routine events logged to agent_events but not activity_stream', () => { + const bus = new EventBus() + attachLogger(bus) + + bus.emit({ + source: 'sentinel', + type: 'sentinel:scan', + level: 'routine', + data: {}, + timestamp: new Date().toISOString(), + }) + bus.emit({ + source: 'sentinel', + type: 'sentinel:threat', + level: 'critical', + data: { address: 'bad' }, + timestamp: new Date().toISOString(), + }) + + const activity = getActivity(null) + expect(activity).toHaveLength(1) + expect(activity[0].level).toBe('critical') + + const events = getAgentEvents() + expect(events).toHaveLength(2) + }) + + // 3. AgentPool lifecycle + it('AgentPool creates, retrieves, and evicts agents', async () => { + const pool = new AgentPool({ maxSize: 3, idleTimeoutMs: 50 }) + + pool.getOrCreate('wallet-1') + pool.getOrCreate('wallet-2') + expect(pool.size()).toBe(2) + + const same = pool.getOrCreate('wallet-1') + expect(same.wallet).toBe('wallet-1') + + await new Promise(r => setTimeout(r, 100)) + const evicted = pool.evictIdle() + expect(evicted).toBe(2) + expect(pool.size()).toBe(0) + }) + + // 4. Cost tracking + it('cost tracking logs and aggregates by agent', () => { + logCost({ + agent: 'sipher', + provider: 'openrouter', + operation: 'chat', + cost_usd: 0.10, + tokens_in: 1000, + tokens_out: 500, + }) + logCost({ + agent: 'herald', + provider: 'x_api', + operation: 'posts_read', + cost_usd: 0.05, + resources: 10, + }) + + const totals = getCostTotals('today') + expect(totals.sipher).toBeCloseTo(0.10) + expect(totals.herald).toBeCloseTo(0.05) + }) + + // 5. Execution links + it('execution links create and retrieve', () => { + const id = createExecutionLink({ + action: 'deposit', + params: { amount: 5, token: 'SOL' }, + source: 'herald_dm', + }) + + const link = getExecutionLink(id) + expect(link).toBeDefined() + expect(link!.action).toBe('deposit') + expect(link!.status).toBe('pending') + }) + + // 6. Tool groups cover all 21 tools + it('tool groups contain all 21 tools with no overlap', () => { + expect(ALL_TOOL_NAMES).toHaveLength(21) + const uniqueNames = new Set(ALL_TOOL_NAMES) + expect(uniqueNames.size).toBe(21) + }) + + // 7. SIPHER factory exports are consistent + it('SIPHER factory fund-moving set matches expected tools', () => { + expect(isFundMoving('deposit')).toBe(true) + expect(isFundMoving('balance')).toBe(false) + + const routerTools = getRouterTools() + expect(routerTools[0].name).toBe('routeIntent') + + const vaultTools = getGroupTools('vault') + expect(vaultTools.map(t => t.name)).toContain('deposit') + }) + + // 8. Service SIPHER has only read-only tools + it('Service SIPHER tools are a strict subset of intel group', () => { + for (const toolName of SERVICE_TOOL_NAMES) { + expect(isFundMoving(toolName)).toBe(false) + } + expect(SERVICE_TOOLS).toHaveLength(4) + }) + + // 9. Multi-agent coordination scenario + it('simulates SENTINEL → SIPHER alert coordination', () => { + const bus = new EventBus() + attachLogger(bus) + const sipherAlerts: ReturnType[] = [] + + bus.on('sentinel:threat', (event) => { + // SIPHER receives the threat and emits its own alert + bus.emit({ + source: 'sipher', + type: 'sipher:alert', + level: 'critical', + data: { message: `Security alert from SENTINEL: ${event.data.address as string}` }, + wallet: 'wallet-1', + timestamp: new Date().toISOString(), + }) + sipherAlerts.push(event) + }) + + bus.emit({ + source: 'sentinel', + type: 'sentinel:threat', + level: 'critical', + data: { address: '8xAb...def' }, + timestamp: new Date().toISOString(), + }) + + expect(sipherAlerts).toHaveLength(1) + + const activity = getActivity(null, { levels: ['critical'] }) + expect(activity.length).toBeGreaterThanOrEqual(2) // sentinel threat + sipher alert + }) +}) From fa929d77fb085a9aba9ba6087014c16ce57f6c6c Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 13:04:03 +0700 Subject: [PATCH 39/92] chore: add twitter-api-v2 for HERALD X agent --- packages/agent/package.json | 1 + pnpm-lock.yaml | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/agent/package.json b/packages/agent/package.json index 9495116..49053e8 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -17,6 +17,7 @@ "express": "^5.0.0", "ioredis": "^5.9.2", "jsonwebtoken": "^9.0.3", + "twitter-api-v2": "^1.29.0", "ulid": "^3.0.2", "ws": "^8.18.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b29871..78fc159 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,9 @@ importers: jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 + twitter-api-v2: + specifier: ^1.29.0 + version: 1.29.0 ulid: specifier: ^3.0.2 version: 3.0.2 @@ -6183,6 +6186,9 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + twitter-api-v2@1.29.0: + resolution: {integrity: sha512-v473q5bwme4N+DWSg6qY+JCvfg1nSJRWwui3HUALafxfqCvVkKiYmS/5x/pVeJwTmyeBxexMbzHwnzrH4h6oYQ==} + type-detect@4.0.8: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} @@ -15036,6 +15042,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + twitter-api-v2@1.29.0: {} + type-detect@4.0.8: {} type-fest@0.7.1: {} From 5a79b96d480f2b3718736c2b3433862b71cde9a0 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 13:05:24 +0700 Subject: [PATCH 40/92] =?UTF-8?q?feat:=20add=20X=20API=20client=20wrapper?= =?UTF-8?q?=20=E2=80=94=20Bearer=20token=20+=20OAuth=201.0a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/herald/x-client.ts | 37 ++++++++++++ packages/agent/tests/herald/x-client.test.ts | 63 ++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 packages/agent/src/herald/x-client.ts create mode 100644 packages/agent/tests/herald/x-client.test.ts diff --git a/packages/agent/src/herald/x-client.ts b/packages/agent/src/herald/x-client.ts new file mode 100644 index 0000000..556109d --- /dev/null +++ b/packages/agent/src/herald/x-client.ts @@ -0,0 +1,37 @@ +import { TwitterApi } from 'twitter-api-v2' + +export function getReadClient(): TwitterApi { + const bearer = process.env.X_BEARER_TOKEN + if (!bearer) { + throw new Error('X_BEARER_TOKEN is required for HERALD read operations') + } + return new TwitterApi(bearer) +} + +export function getWriteClient(): TwitterApi { + const appKey = process.env.X_CONSUMER_KEY + const appSecret = process.env.X_CONSUMER_SECRET + const accessToken = process.env.X_ACCESS_TOKEN + const accessSecret = process.env.X_ACCESS_SECRET + + if (!appKey || !appSecret || !accessToken || !accessSecret) { + throw new Error( + 'X OAuth 1.0a credentials required: X_CONSUMER_KEY, X_CONSUMER_SECRET, X_ACCESS_TOKEN, X_ACCESS_SECRET' + ) + } + + return new TwitterApi({ + appKey, + appSecret, + accessToken, + accessSecret, + }) +} + +export function getHeraldUserId(): string { + const id = process.env.HERALD_X_USER_ID + if (!id) { + throw new Error('HERALD_X_USER_ID is required') + } + return id +} diff --git a/packages/agent/tests/herald/x-client.test.ts b/packages/agent/tests/herald/x-client.test.ts new file mode 100644 index 0000000..aee533d --- /dev/null +++ b/packages/agent/tests/herald/x-client.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { getReadClient, getWriteClient, getHeraldUserId } from '../../src/herald/x-client.js' + +describe('X API Client Wrapper', () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv } + }) + + afterEach(() => { + process.env = originalEnv + }) + + describe('getReadClient', () => { + it('creates read-only client with Bearer token', () => { + process.env.X_BEARER_TOKEN = 'test-bearer-token' + const client = getReadClient() + expect(client).toBeDefined() + expect(typeof client.v2).toBe('object') + }) + + it('throws when Bearer token missing', () => { + delete process.env.X_BEARER_TOKEN + expect(() => getReadClient()).toThrow('X_BEARER_TOKEN is required for HERALD read operations') + }) + }) + + describe('getWriteClient', () => { + it('creates read-write client with OAuth 1.0a', () => { + process.env.X_CONSUMER_KEY = 'test-consumer-key' + process.env.X_CONSUMER_SECRET = 'test-consumer-secret' + process.env.X_ACCESS_TOKEN = 'test-access-token' + process.env.X_ACCESS_SECRET = 'test-access-secret' + const client = getWriteClient() + expect(client).toBeDefined() + expect(typeof client.v2).toBe('object') + }) + + it('throws when OAuth credentials missing', () => { + delete process.env.X_CONSUMER_KEY + process.env.X_CONSUMER_SECRET = 'test-consumer-secret' + process.env.X_ACCESS_TOKEN = 'test-access-token' + process.env.X_ACCESS_SECRET = 'test-access-secret' + expect(() => getWriteClient()).toThrow( + 'X OAuth 1.0a credentials required: X_CONSUMER_KEY, X_CONSUMER_SECRET, X_ACCESS_TOKEN, X_ACCESS_SECRET' + ) + }) + }) + + describe('getHeraldUserId', () => { + it('returns HERALD_X_USER_ID from env', () => { + process.env.HERALD_X_USER_ID = '1234567890' + const userId = getHeraldUserId() + expect(userId).toBe('1234567890') + }) + + it('throws when HERALD_X_USER_ID missing', () => { + delete process.env.HERALD_X_USER_ID + expect(() => getHeraldUserId()).toThrow('HERALD_X_USER_ID is required') + }) + }) +}) From a53ecb14b14dd1f9e47f7e2e74137c6a39fb9c62 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 13:08:13 +0700 Subject: [PATCH 41/92] =?UTF-8?q?feat:=20add=20HERALD=20budget=20tracker?= =?UTF-8?q?=20=E2=80=94=20cost=20tracking=20+=20circuit=20breaker=20+=20ga?= =?UTF-8?q?te=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/herald/budget.ts | 125 +++++++++++++++++++++ packages/agent/tests/herald/budget.test.ts | 98 ++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 packages/agent/src/herald/budget.ts create mode 100644 packages/agent/tests/herald/budget.test.ts diff --git a/packages/agent/src/herald/budget.ts b/packages/agent/src/herald/budget.ts new file mode 100644 index 0000000..d8fe18f --- /dev/null +++ b/packages/agent/src/herald/budget.ts @@ -0,0 +1,125 @@ +import { logCost, getCostTotals } from '../db.js' +import { guardianBus } from '../coordination/event-bus.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export type BudgetGate = 'normal' | 'cautious' | 'dm-only' | 'paused' + +// ───────────────────────────────────────────────────────────────────────────── +// Cost table — X API v2 pay-per-use pricing (USD per resource unit) +// ───────────────────────────────────────────────────────────────────────────── + +const COST_TABLE: Record = { + posts_read: 0.005, + user_read: 0.010, + dm_read: 0.010, + content_create: 0.005, + dm_create: 0.015, + user_interaction: 0.015, + mentions_read: 0.005, + search_read: 0.005, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Gate → blocked operation sets (circuit breaker) +// dm-only: blocks everything except dm_read, dm_create, user_read +// paused: blocks all operations +// ───────────────────────────────────────────────────────────────────────────── + +const BLOCKED_OPS: Record> = { + normal: new Set(), + cautious: new Set(), + 'dm-only': new Set([ + 'mentions_read', + 'search_read', + 'posts_read', + 'content_create', + 'user_interaction', + ]), + paused: new Set(Object.keys(COST_TABLE)), +} + +// ───────────────────────────────────────────────────────────────────────────── +// Internal state for gate-change events +// ───────────────────────────────────────────────────────────────────────────── + +let _lastGate: BudgetGate = 'normal' + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function getMonthlyBudget(): number { + return Number(process.env.HERALD_MONTHLY_BUDGET ?? '150') +} + +function computeGate(percentage: number): BudgetGate { + if (percentage >= 100) return 'paused' + if (percentage >= 95) return 'dm-only' + if (percentage >= 80) return 'cautious' + return 'normal' +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Record an X API call cost. + * Emits a `herald:budget` event on the guardianBus if the gate changes. + */ +export function trackXApiCost(operation: string, resourceCount: number): void { + const unitCost = COST_TABLE[operation] ?? 0.005 + const totalCost = unitCost * resourceCount + + logCost({ + agent: 'herald', + provider: 'x_api', + operation, + cost_usd: totalCost, + resources: resourceCount, + }) + + // emit gate-change event if the budget gate has shifted + const { gate, spent, limit, percentage } = getBudgetStatus() + if (gate !== _lastGate) { + const prev = _lastGate + _lastGate = gate + guardianBus.emit({ + source: 'herald', + type: 'herald:budget', + level: gate === 'paused' ? 'critical' : gate === 'dm-only' ? 'important' : 'routine', + data: { gate, prev, spent, limit, percentage }, + timestamp: new Date().toISOString(), + }) + } +} + +/** + * Return current budget status. + * Reads live totals from the cost_log via getCostTotals('month'). + */ +export function getBudgetStatus(): { + spent: number + limit: number + gate: BudgetGate + percentage: number +} { + const totals = getCostTotals('month') + const spent = totals.herald ?? 0 + const limit = getMonthlyBudget() + const percentage = limit > 0 ? (spent / limit) * 100 : 0 + const gate = computeGate(percentage) + + return { spent, limit, gate, percentage } +} + +/** + * Returns false if the given operation is blocked by the current budget gate. + */ +export function canMakeCall(operation: string): boolean { + const { gate } = getBudgetStatus() + return !BLOCKED_OPS[gate].has(operation) +} diff --git a/packages/agent/tests/herald/budget.test.ts b/packages/agent/tests/herald/budget.test.ts new file mode 100644 index 0000000..4d971ab --- /dev/null +++ b/packages/agent/tests/herald/budget.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { getDb, closeDb } from '../../src/db.js' + +// ─── DB reset between tests ─────────────────────────────────────────────────── + +beforeEach(() => { + // force fresh in-memory DB for every test + closeDb() + process.env.NODE_ENV = 'test' + delete process.env.DB_PATH + delete process.env.HERALD_MONTHLY_BUDGET + // re-init schema + getDb() +}) + +afterEach(() => { + closeDb() + vi.restoreAllMocks() +}) + +// ─── lazy-import so module picks up fresh DB ───────────────────────────────── + +async function getBudget() { + vi.resetModules() + return import('../../src/herald/budget.js') +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('HERALD budget tracker', () => { + it('starts with zero spend and gate = normal', async () => { + const { getBudgetStatus } = await getBudget() + const status = getBudgetStatus() + expect(status.spent).toBe(0) + expect(status.gate).toBe('normal') + expect(status.percentage).toBe(0) + expect(status.limit).toBe(150) + }) + + it('tracks API call costs (10 posts_read = $0.05)', async () => { + const { trackXApiCost, getBudgetStatus } = await getBudget() + trackXApiCost('posts_read', 10) + const status = getBudgetStatus() + expect(status.spent).toBeCloseTo(0.05, 5) + }) + + it('gate → cautious at 80% ($120 of $150)', async () => { + const { trackXApiCost, getBudgetStatus } = await getBudget() + // 120 / 0.010 user_read calls = 12000 resources + trackXApiCost('user_read', 12000) + const status = getBudgetStatus() + expect(status.spent).toBeCloseTo(120, 4) + expect(status.gate).toBe('cautious') + expect(status.percentage).toBeCloseTo(80, 1) + }) + + it('gate → dm-only at 95% ($142.50 of $150)', async () => { + const { trackXApiCost, getBudgetStatus } = await getBudget() + // 142.5 / 0.015 dm_create = 9500 resources + trackXApiCost('dm_create', 9500) + const status = getBudgetStatus() + expect(status.spent).toBeCloseTo(142.5, 4) + expect(status.gate).toBe('dm-only') + expect(status.percentage).toBeCloseTo(95, 1) + }) + + it('gate → paused at 100% ($150 of $150)', async () => { + const { trackXApiCost, getBudgetStatus } = await getBudget() + // 150 / 0.010 user_read = 15000 + trackXApiCost('user_read', 15000) + const status = getBudgetStatus() + expect(status.spent).toBeCloseTo(150, 4) + expect(status.gate).toBe('paused') + expect(status.percentage).toBeCloseTo(100, 1) + }) + + it('canMakeCall returns false when paused', async () => { + const { trackXApiCost, canMakeCall } = await getBudget() + trackXApiCost('user_read', 15000) + expect(canMakeCall('posts_read')).toBe(false) + expect(canMakeCall('dm_read')).toBe(false) + expect(canMakeCall('dm_create')).toBe(false) + }) + + it('canMakeCall blocks mentions_read in dm-only but allows dm_read', async () => { + const { trackXApiCost, canMakeCall } = await getBudget() + // 95% = $142.50 + trackXApiCost('dm_create', 9500) + expect(canMakeCall('mentions_read')).toBe(false) + expect(canMakeCall('search_read')).toBe(false) + expect(canMakeCall('posts_read')).toBe(false) + expect(canMakeCall('content_create')).toBe(false) + expect(canMakeCall('user_interaction')).toBe(false) + expect(canMakeCall('dm_read')).toBe(true) + expect(canMakeCall('dm_create')).toBe(true) + expect(canMakeCall('user_read')).toBe(true) + }) +}) From 745278a16e1799471c7f1638aaac80dc8f6b579a Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 13:14:41 +0700 Subject: [PATCH 42/92] =?UTF-8?q?feat:=20add=20HERALD=20read=20tools=20?= =?UTF-8?q?=E2=80=94=20readMentions,=20readDMs,=20searchPosts,=20readUserP?= =?UTF-8?q?rofile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi Tool definitions and execute functions for all 4 read tools under packages/agent/src/herald/tools/. Each tool checks canMakeCall() before API requests and calls trackXApiCost() after. Tests mock twitter-api-v2 with stable vi.fn() stubs and cover happy path, empty results, budget gate blocking, input validation, and error handling. 47 tests, all passing. --- packages/agent/src/herald/tools/read-dms.ts | 82 ++++ .../agent/src/herald/tools/read-mentions.ts | 78 ++++ packages/agent/src/herald/tools/read-user.ts | 83 ++++ .../agent/src/herald/tools/search-posts.ts | 81 ++++ .../tests/herald/tools/read-mentions.test.ts | 414 ++++++++++++++++++ 5 files changed, 738 insertions(+) create mode 100644 packages/agent/src/herald/tools/read-dms.ts create mode 100644 packages/agent/src/herald/tools/read-mentions.ts create mode 100644 packages/agent/src/herald/tools/read-user.ts create mode 100644 packages/agent/src/herald/tools/search-posts.ts create mode 100644 packages/agent/tests/herald/tools/read-mentions.test.ts diff --git a/packages/agent/src/herald/tools/read-dms.ts b/packages/agent/src/herald/tools/read-dms.ts new file mode 100644 index 0000000..7899d53 --- /dev/null +++ b/packages/agent/src/herald/tools/read-dms.ts @@ -0,0 +1,82 @@ +import type { Tool } from '@mariozechner/pi-ai' +import { getWriteClient } from '../x-client.js' +import { trackXApiCost, canMakeCall } from '../budget.js' + +// ───────────────────────────────────────────────────────────────────────────── +// readDMs — Fetch DM events (requires user context OAuth 1.0a) +// ───────────────────────────────────────────────────────────────────────────── + +export interface ReadDMsParams { + max_results?: number +} + +export interface ReadDMsResult { + dms: Array<{ + id: string + text?: string + event_type?: string + created_at?: string + sender_id?: string + }> + cost: number +} + +export const readDMsTool: Tool = { + name: 'readDMs', + description: 'Read recent Direct Message events for @SipProtocol. Requires user-context OAuth credentials. Returns DM conversations sorted newest first.', + parameters: { + type: 'object', + properties: { + max_results: { + type: 'number', + description: 'Max DM events to fetch (1–100, default 10)', + }, + }, + required: [], + } as any, +} + +export async function executeReadDMs(params: ReadDMsParams = {}): Promise { + if (!canMakeCall('dm_read')) { + return { dms: [], cost: 0 } + } + + const maxResults = params.max_results ?? 10 + const clampedMax = Math.max(1, Math.min(100, maxResults)) + + let dms: ReadDMsResult['dms'] = [] + + try { + const client = getWriteClient() + const response = await client.v2.listDmEvents({ + max_results: clampedMax, + 'dm_event.fields': ['id', 'text', 'event_type', 'created_at', 'sender_id'], + }) + + const events = response.data?.data ?? [] + + dms = events.map((event) => ({ + id: event.id, + text: (event as any).text, + event_type: (event as any).event_type, + created_at: (event as any).created_at, + sender_id: (event as any).sender_id, + })) + } catch (err) { + // DM access may be restricted based on subscription tier — + // surface as empty result rather than crashing the agent loop + const message = err instanceof Error ? err.message : String(err) + if (message.includes('403') || message.includes('401') || message.includes('not authorized')) { + return { dms: [], cost: 0 } + } + throw err + } + + const resourceCount = dms.length || 1 + trackXApiCost('dm_read', resourceCount) + + return { + dms, + cost: resourceCount * 0.01, + } +} diff --git a/packages/agent/src/herald/tools/read-mentions.ts b/packages/agent/src/herald/tools/read-mentions.ts new file mode 100644 index 0000000..dd3d460 --- /dev/null +++ b/packages/agent/src/herald/tools/read-mentions.ts @@ -0,0 +1,78 @@ +import type { Tool } from '@mariozechner/pi-ai' +import { getReadClient, getHeraldUserId } from '../x-client.js' +import { trackXApiCost, canMakeCall } from '../budget.js' + +// ───────────────────────────────────────────────────────────────────────────── +// readMentions — Fetch recent @SipProtocol mentions from X timeline +// ───────────────────────────────────────────────────────────────────────────── + +export interface ReadMentionsParams { + since_id?: string + max_results?: number +} + +export interface ReadMentionsResult { + mentions: Array<{ + id: string + text: string + author_id?: string + created_at?: string + }> + cost: number +} + +export const readMentionsTool: Tool = { + name: 'readMentions', + description: 'Read recent mentions of @SipProtocol on X. Returns a list of tweets that mention the account, ordered newest first.', + parameters: { + type: 'object', + properties: { + since_id: { + type: 'string', + description: 'Only return mentions newer than this tweet ID (pagination cursor)', + }, + max_results: { + type: 'number', + description: 'Max mentions to fetch (5–100, default 10)', + }, + }, + required: [], + } as any, +} + +export async function executeReadMentions(params: ReadMentionsParams = {}): Promise { + if (!canMakeCall('mentions_read')) { + return { mentions: [], cost: 0 } + } + + const userId = getHeraldUserId() + const maxResults = params.max_results ?? 10 + const clampedMax = Math.max(5, Math.min(100, maxResults)) + + const opts: Record = { + max_results: clampedMax, + 'tweet.fields': ['author_id', 'created_at', 'text'], + } + + if (params.since_id) { + opts.since_id = params.since_id + } + + const client = getReadClient() + const response = await client.v2.userMentionTimeline(userId, opts) + + const mentions = response.data?.data ?? [] + + const resourceCount = mentions.length || 1 + trackXApiCost('mentions_read', resourceCount) + + return { + mentions: mentions.map((tweet) => ({ + id: tweet.id, + text: tweet.text, + author_id: tweet.author_id, + created_at: tweet.created_at, + })), + cost: resourceCount * 0.005, + } +} diff --git a/packages/agent/src/herald/tools/read-user.ts b/packages/agent/src/herald/tools/read-user.ts new file mode 100644 index 0000000..5f3cb50 --- /dev/null +++ b/packages/agent/src/herald/tools/read-user.ts @@ -0,0 +1,83 @@ +import type { Tool } from '@mariozechner/pi-ai' +import { getReadClient } from '../x-client.js' +import { trackXApiCost, canMakeCall } from '../budget.js' + +// ───────────────────────────────────────────────────────────────────────────── +// readUserProfile — Fetch a user's public profile by username +// ───────────────────────────────────────────────────────────────────────────── + +export interface ReadUserProfileParams { + username: string +} + +export interface ReadUserProfileResult { + user: { + id: string + name: string + username: string + description?: string + verified?: boolean + public_metrics?: { + followers_count?: number + following_count?: number + tweet_count?: number + listed_count?: number + } + created_at?: string + } | null + cost: number +} + +export const readUserProfileTool: Tool = { + name: 'readUserProfile', + description: 'Read a public X user profile by username. Useful for verifying accounts before engaging, checking follower counts, or investigating who mentioned @SipProtocol.', + parameters: { + type: 'object', + properties: { + username: { + type: 'string', + description: 'X username without the @ prefix (e.g. "SipProtocol")', + }, + }, + required: ['username'], + } as any, +} + +export async function executeReadUserProfile(params: ReadUserProfileParams): Promise { + if (!params.username || params.username.trim().length === 0) { + throw new Error('Username is required') + } + + // Strip leading @ if provided + const username = params.username.replace(/^@/, '').trim() + + if (!canMakeCall('user_read')) { + return { user: null, cost: 0 } + } + + const client = getReadClient() + const response = await client.v2.userByUsername(username, { + 'user.fields': ['id', 'name', 'username', 'description', 'verified', 'public_metrics', 'created_at'], + }) + + const raw = response.data + + trackXApiCost('user_read', 1) + + if (!raw) { + return { user: null, cost: 0.01 } + } + + return { + user: { + id: raw.id, + name: raw.name, + username: raw.username, + description: raw.description, + verified: (raw as any).verified, + public_metrics: raw.public_metrics, + created_at: (raw as any).created_at, + }, + cost: 0.01, + } +} diff --git a/packages/agent/src/herald/tools/search-posts.ts b/packages/agent/src/herald/tools/search-posts.ts new file mode 100644 index 0000000..89f071d --- /dev/null +++ b/packages/agent/src/herald/tools/search-posts.ts @@ -0,0 +1,81 @@ +import type { Tool } from '@mariozechner/pi-ai' +import { getReadClient } from '../x-client.js' +import { trackXApiCost, canMakeCall } from '../budget.js' + +// ───────────────────────────────────────────────────────────────────────────── +// searchPosts — Search X for posts matching a query +// ───────────────────────────────────────────────────────────────────────────── + +export interface SearchPostsParams { + query: string + max_results?: number +} + +export interface SearchPostsResult { + posts: Array<{ + id: string + text: string + author_id?: string + created_at?: string + public_metrics?: { + like_count?: number + retweet_count?: number + reply_count?: number + } + }> + cost: number +} + +export const searchPostsTool: Tool = { + name: 'searchPosts', + description: 'Search X for recent posts matching a query string. Useful for monitoring SIP Protocol mentions, competitor activity, or privacy-related conversations.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'X search query string (e.g. "SIP Protocol privacy" or "#stealth")', + }, + max_results: { + type: 'number', + description: 'Max posts to return (10–100, default 10)', + }, + }, + required: ['query'], + } as any, +} + +export async function executeSearchPosts(params: SearchPostsParams): Promise { + if (!params.query || params.query.trim().length === 0) { + throw new Error('Search query is required') + } + + if (!canMakeCall('search_read')) { + return { posts: [], cost: 0 } + } + + const maxResults = params.max_results ?? 10 + const clampedMax = Math.max(10, Math.min(100, maxResults)) + + const client = getReadClient() + const response = await client.v2.search(params.query, { + max_results: clampedMax, + 'tweet.fields': ['author_id', 'created_at', 'text', 'public_metrics'], + }) + + const tweets = response.data?.data ?? [] + + const resourceCount = tweets.length || 1 + trackXApiCost('search_read', resourceCount) + + return { + posts: tweets.map((tweet) => ({ + id: tweet.id, + text: tweet.text, + author_id: tweet.author_id, + created_at: tweet.created_at, + public_metrics: tweet.public_metrics, + })), + cost: resourceCount * 0.005, + } +} diff --git a/packages/agent/tests/herald/tools/read-mentions.test.ts b/packages/agent/tests/herald/tools/read-mentions.test.ts new file mode 100644 index 0000000..51548cc --- /dev/null +++ b/packages/agent/tests/herald/tools/read-mentions.test.ts @@ -0,0 +1,414 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// ─── twitter-api-v2 mock ─────────────────────────────────────────────────────── +// Must be hoisted — define stable mock before any imports resolve the module + +const mockUserMentionTimeline = vi.fn() +const mockSearch = vi.fn() +const mockUserByUsername = vi.fn() +const mockListDmEvents = vi.fn() + +vi.mock('twitter-api-v2', () => ({ + TwitterApi: vi.fn().mockImplementation(() => ({ + v2: { + userMentionTimeline: mockUserMentionTimeline, + search: mockSearch, + userByUsername: mockUserByUsername, + listDmEvents: mockListDmEvents, + }, + })), +})) + +// ─── Imports after mock registration ───────────────────────────────────────── + +import { getDb, closeDb, logCost } from '../../../src/db.js' +import { + readMentionsTool, + executeReadMentions, +} from '../../../src/herald/tools/read-mentions.js' +import { + readDMsTool, + executeReadDMs, +} from '../../../src/herald/tools/read-dms.js' +import { + searchPostsTool, + executeSearchPosts, +} from '../../../src/herald/tools/search-posts.js' +import { + readUserProfileTool, + executeReadUserProfile, +} from '../../../src/herald/tools/read-user.js' + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const MENTION_FIXTURES = [ + { id: '111', text: 'Hello @SipProtocol!', author_id: 'user_a', created_at: '2026-04-09T10:00:00Z' }, + { id: '222', text: 'How does stealth work? @SipProtocol', author_id: 'user_b', created_at: '2026-04-09T09:00:00Z' }, +] + +const SEARCH_FIXTURES = [ + { + id: '333', + text: 'SIP Protocol is great for privacy', + author_id: 'user_c', + created_at: '2026-04-09T11:00:00Z', + public_metrics: { like_count: 5, retweet_count: 2, reply_count: 1 }, + }, +] + +const USER_FIXTURE = { + id: '99999', + name: 'SIP Protocol', + username: 'SipProtocol', + description: 'Privacy middleware for Web3', + verified: false, + public_metrics: { followers_count: 1200, following_count: 50, tweet_count: 300, listed_count: 10 }, + created_at: '2024-01-01T00:00:00Z', +} + +const DM_FIXTURES = [ + { id: 'dm_001', text: 'Hey, how do I deposit?', event_type: 'MessageCreate', created_at: '2026-04-09T08:00:00Z', sender_id: 'user_d' }, +] + +// ─── DB + env isolation ──────────────────────────────────────────────────────── + +beforeEach(() => { + closeDb() + process.env.NODE_ENV = 'test' + delete process.env.DB_PATH + delete process.env.HERALD_MONTHLY_BUDGET + getDb() + + process.env.X_BEARER_TOKEN = 'test-bearer-token' + process.env.X_CONSUMER_KEY = 'test-consumer-key' + process.env.X_CONSUMER_SECRET = 'test-consumer-secret' + process.env.X_ACCESS_TOKEN = 'test-access-token' + process.env.X_ACCESS_SECRET = 'test-access-secret' + process.env.HERALD_X_USER_ID = '12345678' + + // Reset API mocks to their default happy-path responses + mockUserMentionTimeline.mockResolvedValue({ data: { data: MENTION_FIXTURES } }) + mockSearch.mockResolvedValue({ data: { data: SEARCH_FIXTURES } }) + mockUserByUsername.mockResolvedValue({ data: USER_FIXTURE }) + mockListDmEvents.mockResolvedValue({ data: { data: DM_FIXTURES } }) +}) + +afterEach(() => { + closeDb() + vi.clearAllMocks() + delete process.env.X_BEARER_TOKEN + delete process.env.X_CONSUMER_KEY + delete process.env.X_CONSUMER_SECRET + delete process.env.X_ACCESS_TOKEN + delete process.env.X_ACCESS_SECRET + delete process.env.HERALD_X_USER_ID + delete process.env.HERALD_MONTHLY_BUDGET +}) + +// Helper: exhaust budget so gate becomes 'paused' +function exhaustBudget() { + // $150 total at $0.01/unit = 15000 units + logCost({ agent: 'herald', provider: 'x_api', operation: 'user_read', cost_usd: 150, resources: 15000 }) +} + +// ─── readMentionsTool ───────────────────────────────────────────────────────── + +describe('readMentionsTool definition', () => { + it('has correct name', () => { + expect(readMentionsTool.name).toBe('readMentions') + }) + + it('has a description', () => { + expect(readMentionsTool.description).toBeTruthy() + expect(readMentionsTool.description.length).toBeGreaterThan(10) + }) + + it('has no required parameters', () => { + expect((readMentionsTool.parameters as any).required).toEqual([]) + }) + + it('defines since_id and max_results properties', () => { + const props = (readMentionsTool.parameters as any).properties + expect(props).toHaveProperty('since_id') + expect(props).toHaveProperty('max_results') + }) +}) + +describe('executeReadMentions', () => { + it('returns mentions array with correct shape', async () => { + const result = await executeReadMentions() + expect(result).toHaveProperty('mentions') + expect(result).toHaveProperty('cost') + expect(Array.isArray(result.mentions)).toBe(true) + }) + + it('returns populated mentions when API responds', async () => { + const result = await executeReadMentions() + expect(result.mentions.length).toBe(2) + const first = result.mentions[0] + expect(first.id).toBe('111') + expect(first.text).toBe('Hello @SipProtocol!') + expect(first.author_id).toBe('user_a') + }) + + it('includes cost in result', async () => { + const result = await executeReadMentions() + expect(result.cost).toBeGreaterThan(0) + }) + + it('forwards since_id option to API', async () => { + await executeReadMentions({ since_id: '999', max_results: 20 }) + expect(mockUserMentionTimeline).toHaveBeenCalledWith( + '12345678', + expect.objectContaining({ since_id: '999', max_results: 20 }), + ) + }) + + it('clamps max_results to minimum of 5', async () => { + await executeReadMentions({ max_results: 1 }) + expect(mockUserMentionTimeline).toHaveBeenCalledWith( + '12345678', + expect.objectContaining({ max_results: 5 }), + ) + }) + + it('clamps max_results to maximum of 100', async () => { + await executeReadMentions({ max_results: 9999 }) + expect(mockUserMentionTimeline).toHaveBeenCalledWith( + '12345678', + expect.objectContaining({ max_results: 100 }), + ) + }) + + it('returns empty mentions when budget gate blocks (paused)', async () => { + exhaustBudget() + const result = await executeReadMentions() + expect(result.mentions).toEqual([]) + expect(result.cost).toBe(0) + expect(mockUserMentionTimeline).not.toHaveBeenCalled() + }) + + it('handles empty API response gracefully', async () => { + mockUserMentionTimeline.mockResolvedValue({ data: { data: [] } }) + const result = await executeReadMentions() + expect(result.mentions).toEqual([]) + expect(result.cost).toBeGreaterThan(0) // still charges minimum 1 unit + }) + + it('handles undefined data in API response', async () => { + mockUserMentionTimeline.mockResolvedValue({ data: {} }) + const result = await executeReadMentions() + expect(result.mentions).toEqual([]) + }) +}) + +// ─── readDMsTool ────────────────────────────────────────────────────────────── + +describe('readDMsTool definition', () => { + it('has correct name', () => { + expect(readDMsTool.name).toBe('readDMs') + }) + + it('has a description', () => { + expect(readDMsTool.description).toBeTruthy() + expect(readDMsTool.description.length).toBeGreaterThan(10) + }) + + it('has no required parameters', () => { + expect((readDMsTool.parameters as any).required).toEqual([]) + }) + + it('defines max_results property', () => { + const props = (readDMsTool.parameters as any).properties + expect(props).toHaveProperty('max_results') + }) +}) + +describe('executeReadDMs', () => { + it('returns dms array with cost', async () => { + const result = await executeReadDMs() + expect(result).toHaveProperty('dms') + expect(result).toHaveProperty('cost') + expect(Array.isArray(result.dms)).toBe(true) + }) + + it('returns populated DMs when API responds', async () => { + const result = await executeReadDMs() + expect(result.dms.length).toBe(1) + expect(result.dms[0].id).toBe('dm_001') + expect(result.dms[0].sender_id).toBe('user_d') + }) + + it('includes cost in result', async () => { + const result = await executeReadDMs() + expect(result.cost).toBeGreaterThan(0) + }) + + it('returns empty dms when budget is paused', async () => { + exhaustBudget() + const result = await executeReadDMs() + expect(result.dms).toEqual([]) + expect(result.cost).toBe(0) + expect(mockListDmEvents).not.toHaveBeenCalled() + }) + + it('handles empty DM list gracefully', async () => { + mockListDmEvents.mockResolvedValue({ data: { data: [] } }) + const result = await executeReadDMs() + expect(result.dms).toEqual([]) + }) + + it('returns empty on 401/403 auth errors', async () => { + mockListDmEvents.mockRejectedValue(new Error('403 forbidden: not authorized')) + const result = await executeReadDMs() + expect(result.dms).toEqual([]) + expect(result.cost).toBe(0) + }) + + it('re-throws non-auth errors', async () => { + mockListDmEvents.mockRejectedValue(new Error('Network timeout')) + await expect(executeReadDMs()).rejects.toThrow('Network timeout') + }) +}) + +// ─── searchPostsTool ────────────────────────────────────────────────────────── + +describe('searchPostsTool definition', () => { + it('has correct name', () => { + expect(searchPostsTool.name).toBe('searchPosts') + }) + + it('has a description', () => { + expect(searchPostsTool.description).toBeTruthy() + }) + + it('requires query parameter', () => { + expect((searchPostsTool.parameters as any).required).toContain('query') + }) + + it('defines max_results property', () => { + const props = (searchPostsTool.parameters as any).properties + expect(props).toHaveProperty('max_results') + expect(props).toHaveProperty('query') + }) +}) + +describe('executeSearchPosts', () => { + it('returns posts array with cost', async () => { + const result = await executeSearchPosts({ query: 'SIP Protocol' }) + expect(result).toHaveProperty('posts') + expect(result).toHaveProperty('cost') + expect(Array.isArray(result.posts)).toBe(true) + }) + + it('returns populated posts for valid query', async () => { + const result = await executeSearchPosts({ query: 'privacy stealth' }) + expect(result.posts.length).toBe(1) + expect(result.posts[0].id).toBe('333') + expect(result.posts[0].text).toContain('SIP Protocol') + expect(result.posts[0].public_metrics).toBeDefined() + }) + + it('forwards query to API', async () => { + await executeSearchPosts({ query: 'stealth address privacy', max_results: 20 }) + expect(mockSearch).toHaveBeenCalledWith( + 'stealth address privacy', + expect.objectContaining({ max_results: 20 }), + ) + }) + + it('throws when query is empty string', async () => { + await expect(executeSearchPosts({ query: '' })).rejects.toThrow(/query/i) + }) + + it('throws when query is whitespace only', async () => { + await expect(executeSearchPosts({ query: ' ' })).rejects.toThrow(/query/i) + }) + + it('returns empty posts when budget is paused', async () => { + exhaustBudget() + const result = await executeSearchPosts({ query: 'SIP Protocol' }) + expect(result.posts).toEqual([]) + expect(result.cost).toBe(0) + expect(mockSearch).not.toHaveBeenCalled() + }) + + it('handles empty search results gracefully', async () => { + mockSearch.mockResolvedValue({ data: { data: [] } }) + const result = await executeSearchPosts({ query: 'obscure query' }) + expect(result.posts).toEqual([]) + }) +}) + +// ─── readUserProfileTool ────────────────────────────────────────────────────── + +describe('readUserProfileTool definition', () => { + it('has correct name', () => { + expect(readUserProfileTool.name).toBe('readUserProfile') + }) + + it('has a description', () => { + expect(readUserProfileTool.description).toBeTruthy() + }) + + it('requires username parameter', () => { + expect((readUserProfileTool.parameters as any).required).toContain('username') + }) + + it('defines username property', () => { + const props = (readUserProfileTool.parameters as any).properties + expect(props).toHaveProperty('username') + }) +}) + +describe('executeReadUserProfile', () => { + it('returns user object with cost', async () => { + const result = await executeReadUserProfile({ username: 'SipProtocol' }) + expect(result).toHaveProperty('user') + expect(result).toHaveProperty('cost') + }) + + it('returns populated user fields', async () => { + const result = await executeReadUserProfile({ username: 'SipProtocol' }) + expect(result.user).not.toBeNull() + expect(result.user?.id).toBe('99999') + expect(result.user?.username).toBe('SipProtocol') + expect(result.user?.name).toBe('SIP Protocol') + expect(result.user?.public_metrics?.followers_count).toBe(1200) + }) + + it('strips leading @ from username before calling API', async () => { + await executeReadUserProfile({ username: '@SipProtocol' }) + expect(mockUserByUsername).toHaveBeenCalledWith( + 'SipProtocol', + expect.any(Object), + ) + }) + + it('throws when username is empty string', async () => { + await expect(executeReadUserProfile({ username: '' })).rejects.toThrow(/username/i) + }) + + it('throws when username is whitespace only', async () => { + await expect(executeReadUserProfile({ username: ' ' })).rejects.toThrow(/username/i) + }) + + it('returns null user when budget is paused', async () => { + exhaustBudget() + const result = await executeReadUserProfile({ username: 'SipProtocol' }) + expect(result.user).toBeNull() + expect(result.cost).toBe(0) + expect(mockUserByUsername).not.toHaveBeenCalled() + }) + + it('returns cost of 0.01 per user lookup', async () => { + const result = await executeReadUserProfile({ username: 'SipProtocol' }) + expect(result.cost).toBe(0.01) + }) + + it('returns null user when API returns no data', async () => { + mockUserByUsername.mockResolvedValue({ data: undefined }) + const result = await executeReadUserProfile({ username: 'ghostaccount' }) + expect(result.user).toBeNull() + }) +}) From aff30fb7952b2c75381bdf6ba831867985370202 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 13:15:47 +0700 Subject: [PATCH 43/92] =?UTF-8?q?feat:=20add=20intent=20classifier=20?= =?UTF-8?q?=E2=80=94=20command/question/engagement/spam=20classification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/herald/intent.ts | 136 +++++++++++++++++++++ packages/agent/tests/herald/intent.test.ts | 50 ++++++++ 2 files changed, 186 insertions(+) create mode 100644 packages/agent/src/herald/intent.ts create mode 100644 packages/agent/tests/herald/intent.test.ts diff --git a/packages/agent/src/herald/intent.ts b/packages/agent/src/herald/intent.ts new file mode 100644 index 0000000..6e4e84b --- /dev/null +++ b/packages/agent/src/herald/intent.ts @@ -0,0 +1,136 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export type IntentType = 'command' | 'question' | 'engagement' | 'spam' + +export interface IntentResult { + intent: IntentType + tool?: string + needsExecLink?: boolean + confidence: number +} + +// ───────────────────────────────────────────────────────────────────────────── +// Regex patterns for classification +// ───────────────────────────────────────────────────────────────────────────── + +const COMMAND_PATTERNS: Array<{ + regex: RegExp + tool: string + needsExecLink?: boolean +}> = [ + { + regex: /privacy\s+score/i, + tool: 'privacyScore', + }, + { + regex: /threat\s+check|is\s+.+\s+safe\?/i, + tool: 'threatCheck', + }, + { + regex: /\b(deposit|withdraw|send|transfer)\b/i, + tool: 'send', + needsExecLink: true, + }, + { + regex: /\b(swap|exchange|trade)\b/i, + tool: 'swap', + needsExecLink: true, + }, + { + regex: /\b(claim|redeem)\b/i, + tool: 'claim', + needsExecLink: true, + }, + { + regex: /\b(refund)\b/i, + tool: 'refund', + needsExecLink: true, + }, + { + regex: /\b(balance|vault)\b/i, + tool: 'balance', + }, + { + regex: /\b(scan|stealth\s+payment)\b/i, + tool: 'scan', + }, + { + regex: /viewing\s+key/i, + tool: 'viewingKey', + }, + { + regex: /\b(history|transactions)\b/i, + tool: 'history', + }, +] + +const SPAM_PATTERNS = [ + /\b(buy\s+now|click\s+here|click\s+now)\b/i, + /\b(free\s+(crypto|airdrop|nft|tokens))\b/i, + /\bdm\s+me\s+for\b/i, + /http[s]?:\/\/(?!sip-protocol|sipher)[^\s]*/i, // external links not from sip-protocol/sipher +] + +const QUESTION_PATTERNS = [ + /\b(how\s+(do|does|can|to)|what\s+(is|are)|why|explain|tell\s+me\s+about)\b/i, + /\?$/, +] + +// ───────────────────────────────────────────────────────────────────────────── +// Classifier function +// ───────────────────────────────────────────────────────────────────────────── + +export function classifyIntent(text: string): IntentResult { + // Sanitize: remove @mentions, trim whitespace + const clean = text + .replace(/@\S+/g, '') + .trim() + + // Rule 1: If <3 chars → spam + if (clean.length < 3) { + return { + intent: 'spam', + confidence: 0.95, + } + } + + // Rule 2: Check SPAM patterns + for (const pattern of SPAM_PATTERNS) { + if (pattern.test(clean)) { + return { + intent: 'spam', + confidence: 0.85, + } + } + } + + // Rule 3: Check COMMAND patterns + for (const { regex, tool, needsExecLink } of COMMAND_PATTERNS) { + if (regex.test(clean)) { + return { + intent: 'command', + tool, + needsExecLink, + confidence: 0.88, + } + } + } + + // Rule 4: Check QUESTION patterns + for (const pattern of QUESTION_PATTERNS) { + if (pattern.test(clean)) { + return { + intent: 'question', + confidence: 0.75, + } + } + } + + // Rule 5: Default → engagement + return { + intent: 'engagement', + confidence: 0.5, + } +} diff --git a/packages/agent/tests/herald/intent.test.ts b/packages/agent/tests/herald/intent.test.ts new file mode 100644 index 0000000..674c4d1 --- /dev/null +++ b/packages/agent/tests/herald/intent.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { classifyIntent } from '../../src/herald/intent.js' + +describe('HERALD intent classifier', () => { + it('classifies privacy score command', () => { + const result = classifyIntent('@SipProtocol privacy score for 7xKz...abc') + expect(result.intent).toBe('command') + expect(result.tool).toBe('privacyScore') + expect(result.confidence).toBeGreaterThan(0.8) + }) + + it('classifies threat check command', () => { + const result = classifyIntent('@SipProtocol is 8xAb...def safe?') + expect(result.intent).toBe('command') + expect(result.tool).toBe('threatCheck') + expect(result.confidence).toBeGreaterThan(0.8) + }) + + it('classifies deposit command with needsExecLink', () => { + const result = classifyIntent('@SipProtocol deposit 5 SOL') + expect(result.intent).toBe('command') + expect(result.tool).toBe('send') + expect(result.needsExecLink).toBe(true) + expect(result.confidence).toBeGreaterThan(0.8) + }) + + it('classifies question about stealth addresses', () => { + const result = classifyIntent('@SipProtocol how do stealth addresses work?') + expect(result.intent).toBe('question') + expect(result.confidence).toBeGreaterThan(0.7) + }) + + it('classifies engagement as default', () => { + const result = classifyIntent('@SipProtocol this is amazing, love the privacy!') + expect(result.intent).toBe('engagement') + expect(result.confidence).toBeLessThanOrEqual(0.6) + }) + + it('classifies scam link as spam', () => { + const result = classifyIntent('Buy now! Click http://scam.link @SipProtocol') + expect(result.intent).toBe('spam') + expect(result.confidence).toBeGreaterThan(0.8) + }) + + it('classifies bare mention as spam', () => { + const result = classifyIntent('@SipProtocol') + expect(result.intent).toBe('spam') + expect(result.confidence).toBeGreaterThan(0.8) + }) +}) From 99a22d18b9bb2f386ca285aeecff4e7707750c5d Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 13:22:23 +0700 Subject: [PATCH 44/92] =?UTF-8?q?feat:=20add=20post=20approval=20queue=20?= =?UTF-8?q?=E2=80=94=20pending,=20approve,=20reject,=20auto-approve,=20pub?= =?UTF-8?q?lish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/herald/approval.ts | 72 ++++ packages/agent/src/herald/tools/like-tweet.ts | 44 ++ packages/agent/src/herald/tools/post-tweet.ts | 85 ++++ .../agent/src/herald/tools/reply-tweet.ts | 52 +++ .../agent/src/herald/tools/schedule-post.ts | 57 +++ packages/agent/src/herald/tools/send-dm.ts | 63 +++ .../tests/herald/tools/post-tweet.test.ts | 401 ++++++++++++++++++ tests/herald/approval.test.ts | 204 +++++++++ 8 files changed, 978 insertions(+) create mode 100644 packages/agent/src/herald/approval.ts create mode 100644 packages/agent/src/herald/tools/like-tweet.ts create mode 100644 packages/agent/src/herald/tools/post-tweet.ts create mode 100644 packages/agent/src/herald/tools/reply-tweet.ts create mode 100644 packages/agent/src/herald/tools/schedule-post.ts create mode 100644 packages/agent/src/herald/tools/send-dm.ts create mode 100644 packages/agent/tests/herald/tools/post-tweet.test.ts create mode 100644 tests/herald/approval.test.ts diff --git a/packages/agent/src/herald/approval.ts b/packages/agent/src/herald/approval.ts new file mode 100644 index 0000000..4eaa510 --- /dev/null +++ b/packages/agent/src/herald/approval.ts @@ -0,0 +1,72 @@ +import { getDb } from '../db.js' + +/** + * Get all pending posts from herald_queue, ordered by created_at ascending. + */ +export function getPendingPosts(): Array> { + return getDb().prepare( + 'SELECT * FROM herald_queue WHERE status = ? ORDER BY created_at ASC' + ).all('pending') as Array> +} + +/** + * Get posts ready to publish: approved posts + auto-approved if enabled. + * Returns approved posts where scheduled_at is NULL or <= now, or auto-approved pending posts. + */ +export function getReadyToPublish(): Array> { + const db = getDb() + const approved = db.prepare( + "SELECT * FROM herald_queue WHERE status = 'approved' AND (scheduled_at IS NULL OR scheduled_at <= ?) ORDER BY created_at ASC" + ).all(new Date().toISOString()) as Array> + + if (process.env.HERALD_AUTO_APPROVE_POSTS === 'true') { + const timeoutSec = Number(process.env.HERALD_AUTO_APPROVE_TIMEOUT ?? '1800') + const cutoff = new Date(Date.now() - timeoutSec * 1000).toISOString() + const autoApprove = db.prepare( + "SELECT * FROM herald_queue WHERE status = 'pending' AND created_at <= ? ORDER BY created_at ASC" + ).all(cutoff) as Array> + + // Auto-approve each pending post that's old enough + for (const post of autoApprove) { + approvePost(post.id as string, 'auto') + } + + return [...approved, ...autoApprove] + } + + return approved +} + +/** + * Approve a post: set status to approved, set approved_by, and set approved_at. + */ +export function approvePost(id: string, approvedBy: string): void { + getDb().prepare( + 'UPDATE herald_queue SET status = ?, approved_by = ?, approved_at = ? WHERE id = ?' + ).run('approved', approvedBy, new Date().toISOString(), id) +} + +/** + * Reject a post: set status to rejected. + */ +export function rejectPost(id: string): void { + getDb().prepare('UPDATE herald_queue SET status = ? WHERE id = ?').run('rejected', id) +} + +/** + * Mark a post as published: set status to posted, set tweet_id, and set posted_at. + */ +export function markPublished(id: string, tweetId: string): void { + getDb().prepare( + 'UPDATE herald_queue SET status = ?, tweet_id = ?, posted_at = ? WHERE id = ?' + ).run('posted', tweetId, new Date().toISOString(), id) +} + +/** + * Edit a queued post's content. Only works for pending or approved posts. + */ +export function editQueuedPost(id: string, newContent: string): void { + getDb().prepare( + 'UPDATE herald_queue SET content = ? WHERE id = ? AND status IN (?, ?)' + ).run(newContent, id, 'pending', 'approved') +} diff --git a/packages/agent/src/herald/tools/like-tweet.ts b/packages/agent/src/herald/tools/like-tweet.ts new file mode 100644 index 0000000..931d69a --- /dev/null +++ b/packages/agent/src/herald/tools/like-tweet.ts @@ -0,0 +1,44 @@ +import type { Tool } from '@mariozechner/pi-ai' +import { getWriteClient, getHeraldUserId } from '../x-client.js' +import { trackXApiCost } from '../budget.js' + +// ───────────────────────────────────────────────────────────────────────────── +// likeTweet — AUTO. Likes a tweet from the @SipProtocol account directly. +// ───────────────────────────────────────────────────────────────────────────── + +export interface LikeTweetParams { + tweet_id: string +} + +export interface LikeTweetResult { + liked: boolean +} + +export const likeTweetTool: Tool = { + name: 'likeTweet', + description: 'Like a tweet from the @SipProtocol account. Posts immediately — use for engaging with community posts, partner content, or relevant privacy discussions.', + parameters: { + type: 'object', + properties: { + tweet_id: { + type: 'string', + description: 'The ID of the tweet to like', + }, + }, + required: ['tweet_id'], + } as any, +} + +export async function executeLikeTweet(params: LikeTweetParams): Promise { + if (!params.tweet_id || params.tweet_id.trim().length === 0) { + throw new Error('tweet_id is required') + } + + const userId = getHeraldUserId() + const client = getWriteClient() + await client.v2.like(userId, params.tweet_id) + + trackXApiCost('user_interaction', 1) + + return { liked: true } +} diff --git a/packages/agent/src/herald/tools/post-tweet.ts b/packages/agent/src/herald/tools/post-tweet.ts new file mode 100644 index 0000000..12bf85e --- /dev/null +++ b/packages/agent/src/herald/tools/post-tweet.ts @@ -0,0 +1,85 @@ +import type { Tool } from '@mariozechner/pi-ai' +import { getWriteClient } from '../x-client.js' +import { trackXApiCost } from '../budget.js' +import { getDb } from '../../db.js' +import { ulid } from 'ulid' +import { guardianBus } from '../../coordination/event-bus.js' + +// ───────────────────────────────────────────────────────────────────────────── +// postTweet — QUEUED post. Inserts into herald_queue, never calls X API. +// publishTweet — ACTUALLY posts to X. Called by the approval system only. +// ───────────────────────────────────────────────────────────────────────────── + +export interface PostTweetParams { + text: string +} + +export interface PostTweetResult { + queued: boolean + id: string +} + +export interface PublishTweetResult { + tweet_id: string +} + +export const postTweetTool: Tool = { + name: 'postTweet', + description: 'Queue a new post for @SipProtocol. Posts go through approval queue — not posted immediately. Use schedulePost if you need a specific future time.', + parameters: { + type: 'object', + properties: { + text: { + type: 'string', + description: 'Tweet text (max 280 chars)', + }, + }, + required: ['text'], + } as any, +} + +/** + * Queue a tweet for human approval. Does NOT call the X API. + * Emits `herald:approval-needed` so the admin dashboard can surface it. + */ +export async function executePostTweet(params: PostTweetParams): Promise { + if (!params.text || params.text.trim().length === 0) { + throw new Error('text is required') + } + if (params.text.length > 280) { + throw new Error('text exceeds 280 character limit') + } + + const id = ulid() + const now = new Date().toISOString() + const conn = getDb() + + conn.prepare(` + INSERT INTO herald_queue (id, type, content, reply_to, scheduled_at, status, created_at) + VALUES (?, 'post', ?, null, null, 'pending', ?) + `).run(id, params.text, now) + + guardianBus.emit({ + source: 'herald', + type: 'herald:approval-needed', + level: 'important', + data: { id, text: params.text }, + timestamp: now, + }) + + return { queued: true, id } +} + +/** + * Actually publish a tweet to X. Called by the approval workflow after a human + * approves a queued item — NOT called by the agent directly. + */ +export async function publishTweet(text: string): Promise { + const client = getWriteClient() + const response = await client.v2.tweet(text) + const tweetId = response.data.id + + trackXApiCost('content_create', 1) + + return { tweet_id: tweetId } +} diff --git a/packages/agent/src/herald/tools/reply-tweet.ts b/packages/agent/src/herald/tools/reply-tweet.ts new file mode 100644 index 0000000..19c8f17 --- /dev/null +++ b/packages/agent/src/herald/tools/reply-tweet.ts @@ -0,0 +1,52 @@ +import type { Tool } from '@mariozechner/pi-ai' +import { getWriteClient } from '../x-client.js' +import { trackXApiCost } from '../budget.js' + +// ───────────────────────────────────────────────────────────────────────────── +// replyTweet — AUTO. Posts a reply to an existing tweet directly via X API. +// ───────────────────────────────────────────────────────────────────────────── + +export interface ReplyTweetParams { + tweet_id: string + text: string +} + +export interface ReplyTweetResult { + tweet_id: string +} + +export const replyTweetTool: Tool = { + name: 'replyTweet', + description: 'Reply to an existing tweet on behalf of @SipProtocol. Posts immediately — use for timely responses to mentions, questions, or community engagement.', + parameters: { + type: 'object', + properties: { + tweet_id: { + type: 'string', + description: 'The ID of the tweet to reply to', + }, + text: { + type: 'string', + description: 'Reply text (max 280 chars)', + }, + }, + required: ['tweet_id', 'text'], + } as any, +} + +export async function executeReplyTweet(params: ReplyTweetParams): Promise { + if (!params.tweet_id || params.tweet_id.trim().length === 0) { + throw new Error('tweet_id is required') + } + if (!params.text || params.text.trim().length === 0) { + throw new Error('text is required') + } + + const client = getWriteClient() + const response = await client.v2.reply(params.text, params.tweet_id) + const tweetId = response.data.id + + trackXApiCost('content_create', 1) + + return { tweet_id: tweetId } +} diff --git a/packages/agent/src/herald/tools/schedule-post.ts b/packages/agent/src/herald/tools/schedule-post.ts new file mode 100644 index 0000000..c26d902 --- /dev/null +++ b/packages/agent/src/herald/tools/schedule-post.ts @@ -0,0 +1,57 @@ +import type { Tool } from '@mariozechner/pi-ai' +import { getDb } from '../../db.js' +import { ulid } from 'ulid' + +// ───────────────────────────────────────────────────────────────────────────── +// schedulePost — LOCAL. Queues a post for a future time. No API call. No cost. +// The crank engine polls herald_queue for scheduled items and triggers approval. +// ───────────────────────────────────────────────────────────────────────────── + +export interface SchedulePostParams { + text: string + scheduled_at: string // ISO 8601 timestamp +} + +export interface SchedulePostResult { + queued: boolean + id: string +} + +export const schedulePostTool: Tool = { + name: 'schedulePost', + description: 'Schedule a tweet to be queued for approval at a specific future time. Stored locally — no X API call is made. The crank engine will surface it for approval at the scheduled time.', + parameters: { + type: 'object', + properties: { + text: { + type: 'string', + description: 'Tweet text (max 280 chars)', + }, + scheduled_at: { + type: 'string', + description: 'ISO 8601 timestamp for when the post should go live (e.g. "2026-05-01T09:00:00Z")', + }, + }, + required: ['text', 'scheduled_at'], + } as any, +} + +export async function executeSchedulePost(params: SchedulePostParams): Promise { + if (!params.text || params.text.trim().length === 0) { + throw new Error('text is required') + } + if (!params.scheduled_at || params.scheduled_at.trim().length === 0) { + throw new Error('scheduled_at is required') + } + + const id = ulid() + const now = new Date().toISOString() + const conn = getDb() + + conn.prepare(` + INSERT INTO herald_queue (id, type, content, reply_to, scheduled_at, status, created_at) + VALUES (?, 'post', ?, null, ?, 'pending', ?) + `).run(id, params.text, params.scheduled_at, now) + + return { queued: true, id } +} diff --git a/packages/agent/src/herald/tools/send-dm.ts b/packages/agent/src/herald/tools/send-dm.ts new file mode 100644 index 0000000..63644bc --- /dev/null +++ b/packages/agent/src/herald/tools/send-dm.ts @@ -0,0 +1,63 @@ +import type { Tool } from '@mariozechner/pi-ai' +import { getWriteClient } from '../x-client.js' +import { trackXApiCost } from '../budget.js' +import { guardianBus } from '../../coordination/event-bus.js' + +// ───────────────────────────────────────────────────────────────────────────── +// sendDM — AUTO. Sends a Direct Message from @SipProtocol to a user. +// ───────────────────────────────────────────────────────────────────────────── + +export interface SendDMParams { + user_id: string + text: string +} + +export interface SendDMResult { + sent: boolean + dm_id: string +} + +export const sendDMTool: Tool = { + name: 'sendDM', + description: 'Send a Direct Message from @SipProtocol to a specific user by their X user ID. Posts immediately — use to follow up on support requests, partnership inquiries, or flagged community members.', + parameters: { + type: 'object', + properties: { + user_id: { + type: 'string', + description: 'X user ID of the recipient (numeric string, not @username)', + }, + text: { + type: 'string', + description: 'DM text content', + }, + }, + required: ['user_id', 'text'], + } as any, +} + +export async function executeSendDM(params: SendDMParams): Promise { + if (!params.user_id || params.user_id.trim().length === 0) { + throw new Error('user_id is required') + } + if (!params.text || params.text.trim().length === 0) { + throw new Error('text is required') + } + + const client = getWriteClient() + const response = await client.v2.sendDmToParticipant(params.user_id, { text: params.text }) + const dmId = response.dm_event_id + + trackXApiCost('dm_create', 1) + + const now = new Date().toISOString() + guardianBus.emit({ + source: 'herald', + type: 'herald:dm', + level: 'routine', + data: { user_id: params.user_id, dm_id: dmId }, + timestamp: now, + }) + + return { sent: true, dm_id: dmId } +} diff --git a/packages/agent/tests/herald/tools/post-tweet.test.ts b/packages/agent/tests/herald/tools/post-tweet.test.ts new file mode 100644 index 0000000..67ebc0c --- /dev/null +++ b/packages/agent/tests/herald/tools/post-tweet.test.ts @@ -0,0 +1,401 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// ─── twitter-api-v2 mock ─────────────────────────────────────────────────────── +// Stable mock must be declared before any module imports resolve + +const mockTweet = vi.fn() +const mockReply = vi.fn() +const mockLike = vi.fn() +const mockNewDmConversation = vi.fn() + +vi.mock('twitter-api-v2', () => ({ + TwitterApi: vi.fn().mockImplementation(() => ({ + v2: { + tweet: mockTweet, + reply: mockReply, + like: mockLike, + sendDmToParticipant: mockNewDmConversation, + }, + })), +})) + +// ─── Imports after mock registration ───────────────────────────────────────── + +import { getDb, closeDb } from '../../../src/db.js' +import { + postTweetTool, + executePostTweet, + publishTweet, +} from '../../../src/herald/tools/post-tweet.js' +import { + replyTweetTool, + executeReplyTweet, +} from '../../../src/herald/tools/reply-tweet.js' +import { + likeTweetTool, + executeLikeTweet, +} from '../../../src/herald/tools/like-tweet.js' +import { + sendDMTool, + executeSendDM, +} from '../../../src/herald/tools/send-dm.js' +import { + schedulePostTool, + executeSchedulePost, +} from '../../../src/herald/tools/schedule-post.js' + +// ─── DB + env isolation ──────────────────────────────────────────────────────── + +beforeEach(() => { + closeDb() + process.env.NODE_ENV = 'test' + delete process.env.DB_PATH + delete process.env.HERALD_MONTHLY_BUDGET + getDb() + + process.env.X_BEARER_TOKEN = 'test-bearer-token' + process.env.X_CONSUMER_KEY = 'test-consumer-key' + process.env.X_CONSUMER_SECRET = 'test-consumer-secret' + process.env.X_ACCESS_TOKEN = 'test-access-token' + process.env.X_ACCESS_SECRET = 'test-access-secret' + process.env.HERALD_X_USER_ID = '12345678' + + // Default happy-path responses + mockTweet.mockResolvedValue({ data: { id: 'tweet_001', text: 'test' } }) + mockReply.mockResolvedValue({ data: { id: 'reply_001', text: 'test reply' } }) + mockLike.mockResolvedValue({ data: { liked: true } }) + mockNewDmConversation.mockResolvedValue({ dm_event_id: 'dm_event_001', dm_conversation_id: 'conv_001' }) +}) + +afterEach(() => { + closeDb() + vi.clearAllMocks() + delete process.env.X_BEARER_TOKEN + delete process.env.X_CONSUMER_KEY + delete process.env.X_CONSUMER_SECRET + delete process.env.X_ACCESS_TOKEN + delete process.env.X_ACCESS_SECRET + delete process.env.HERALD_X_USER_ID + delete process.env.HERALD_MONTHLY_BUDGET +}) + +// ─── postTweetTool definition ───────────────────────────────────────────────── + +describe('postTweetTool definition', () => { + it('has correct name', () => { + expect(postTweetTool.name).toBe('postTweet') + }) + + it('has a description', () => { + expect(postTweetTool.description).toBeTruthy() + expect(postTweetTool.description.length).toBeGreaterThan(10) + }) + + it('requires text parameter', () => { + expect((postTweetTool.parameters as any).required).toContain('text') + }) + + it('defines text property', () => { + const props = (postTweetTool.parameters as any).properties + expect(props).toHaveProperty('text') + }) +}) + +// ─── executePostTweet — queue behavior ──────────────────────────────────────── + +describe('executePostTweet', () => { + it('does NOT call X API', async () => { + await executePostTweet({ text: 'Hello world!' }) + expect(mockTweet).not.toHaveBeenCalled() + }) + + it('inserts a row into herald_queue with status=pending', async () => { + await executePostTweet({ text: 'SIP Protocol is live on mainnet!' }) + const conn = getDb() + const rows = conn.prepare("SELECT * FROM herald_queue WHERE status = 'pending'").all() as Array<{ + id: string; type: string; content: string; status: string; scheduled_at: string | null + }> + expect(rows.length).toBeGreaterThanOrEqual(1) + const row = rows.find(r => r.content === 'SIP Protocol is live on mainnet!') + expect(row).toBeDefined() + expect(row?.status).toBe('pending') + expect(row?.type).toBe('post') + expect(row?.scheduled_at).toBeNull() + }) + + it('returns the queued item id', async () => { + const result = await executePostTweet({ text: 'Test post' }) + expect(result.queued).toBe(true) + expect(typeof result.id).toBe('string') + expect(result.id.length).toBeGreaterThan(0) + }) + + it('assigns a unique ULID-style id to each queued item', async () => { + const r1 = await executePostTweet({ text: 'Post one' }) + const r2 = await executePostTweet({ text: 'Post two' }) + expect(r1.id).not.toBe(r2.id) + }) + + it('throws when text is empty', async () => { + await expect(executePostTweet({ text: '' })).rejects.toThrow(/text/i) + }) + + it('throws when text exceeds 280 characters', async () => { + const longText = 'x'.repeat(281) + await expect(executePostTweet({ text: longText })).rejects.toThrow(/280/i) + }) + + it('stores the exact text in herald_queue content', async () => { + const text = 'Stealth addresses protect your identity on-chain. 🛡️' + await executePostTweet({ text }) + const conn = getDb() + const row = conn.prepare("SELECT * FROM herald_queue WHERE content = ?").get(text) as { content: string } | undefined + expect(row).toBeDefined() + expect(row?.content).toBe(text) + }) +}) + +// ─── publishTweet — actual X API call ──────────────────────────────────────── + +describe('publishTweet', () => { + it('calls client.v2.tweet with the given text', async () => { + await publishTweet('Go live!') + expect(mockTweet).toHaveBeenCalledWith('Go live!') + }) + + it('returns the tweet id on success', async () => { + const result = await publishTweet('Mainnet is live!') + expect(result.tweet_id).toBe('tweet_001') + }) + + it('tracks content_create cost', async () => { + await publishTweet('Cost tracking test') + // Cost logged — verify via DB (cost_log table) + const conn = getDb() + const row = conn.prepare("SELECT * FROM cost_log WHERE operation = 'content_create' LIMIT 1").get() as { operation: string } | undefined + expect(row).toBeDefined() + expect(row?.operation).toBe('content_create') + }) + + it('re-throws X API errors', async () => { + mockTweet.mockRejectedValue(new Error('403 Forbidden')) + await expect(publishTweet('Will fail')).rejects.toThrow('403 Forbidden') + }) +}) + +// ─── schedulePostTool definition ────────────────────────────────────────────── + +describe('schedulePostTool definition', () => { + it('has correct name', () => { + expect(schedulePostTool.name).toBe('schedulePost') + }) + + it('has a description', () => { + expect(schedulePostTool.description).toBeTruthy() + expect(schedulePostTool.description.length).toBeGreaterThan(10) + }) + + it('requires text and scheduled_at parameters', () => { + const required = (schedulePostTool.parameters as any).required + expect(required).toContain('text') + expect(required).toContain('scheduled_at') + }) +}) + +// ─── executeSchedulePost ────────────────────────────────────────────────────── + +describe('executeSchedulePost', () => { + it('does NOT call X API', async () => { + await executeSchedulePost({ text: 'Scheduled post', scheduled_at: '2026-05-01T09:00:00Z' }) + expect(mockTweet).not.toHaveBeenCalled() + }) + + it('inserts row into herald_queue with scheduled_at', async () => { + const scheduledAt = '2026-05-01T09:00:00Z' + await executeSchedulePost({ text: 'Future announcement', scheduled_at: scheduledAt }) + const conn = getDb() + const row = conn.prepare("SELECT * FROM herald_queue WHERE content = 'Future announcement'").get() as { + scheduled_at: string; status: string; type: string + } | undefined + expect(row).toBeDefined() + expect(row?.scheduled_at).toBe(scheduledAt) + expect(row?.status).toBe('pending') + expect(row?.type).toBe('post') + }) + + it('returns queued=true with the row id', async () => { + const result = await executeSchedulePost({ text: 'Sched', scheduled_at: '2026-06-01T12:00:00Z' }) + expect(result.queued).toBe(true) + expect(typeof result.id).toBe('string') + expect(result.id.length).toBeGreaterThan(0) + }) + + it('throws when text is empty', async () => { + await expect(executeSchedulePost({ text: '', scheduled_at: '2026-05-01T09:00:00Z' })).rejects.toThrow(/text/i) + }) + + it('throws when scheduled_at is missing', async () => { + await expect(executeSchedulePost({ text: 'No date', scheduled_at: '' })).rejects.toThrow(/scheduled_at/i) + }) +}) + +// ─── replyTweetTool definition ─────────────────────────────────────────────── + +describe('replyTweetTool definition', () => { + it('has correct name', () => { + expect(replyTweetTool.name).toBe('replyTweet') + }) + + it('has a description', () => { + expect(replyTweetTool.description).toBeTruthy() + expect(replyTweetTool.description.length).toBeGreaterThan(10) + }) + + it('requires tweet_id and text parameters', () => { + const required = (replyTweetTool.parameters as any).required + expect(required).toContain('tweet_id') + expect(required).toContain('text') + }) +}) + +// ─── executeReplyTweet ──────────────────────────────────────────────────────── + +describe('executeReplyTweet', () => { + it('calls client.v2.reply with text and tweet_id', async () => { + await executeReplyTweet({ tweet_id: '555', text: 'Great point!' }) + expect(mockReply).toHaveBeenCalledWith('Great point!', '555') + }) + + it('returns tweet_id on success', async () => { + const result = await executeReplyTweet({ tweet_id: '555', text: 'Hello!' }) + expect(result.tweet_id).toBe('reply_001') + }) + + it('tracks content_create cost', async () => { + await executeReplyTweet({ tweet_id: '888', text: 'Test reply' }) + const conn = getDb() + const rows = conn.prepare("SELECT * FROM cost_log WHERE operation = 'content_create'").all() as Array<{ operation: string }> + expect(rows.length).toBeGreaterThanOrEqual(1) + }) + + it('throws when tweet_id is empty', async () => { + await expect(executeReplyTweet({ tweet_id: '', text: 'Reply' })).rejects.toThrow(/tweet_id/i) + }) + + it('throws when text is empty', async () => { + await expect(executeReplyTweet({ tweet_id: '555', text: '' })).rejects.toThrow(/text/i) + }) +}) + +// ─── likeTweetTool definition ───────────────────────────────────────────────── + +describe('likeTweetTool definition', () => { + it('has correct name', () => { + expect(likeTweetTool.name).toBe('likeTweet') + }) + + it('has a description', () => { + expect(likeTweetTool.description).toBeTruthy() + expect(likeTweetTool.description.length).toBeGreaterThan(10) + }) + + it('requires tweet_id parameter', () => { + expect((likeTweetTool.parameters as any).required).toContain('tweet_id') + }) +}) + +// ─── executeLikeTweet ───────────────────────────────────────────────────────── + +describe('executeLikeTweet', () => { + it('calls client.v2.like with userId and tweet_id', async () => { + await executeLikeTweet({ tweet_id: '999' }) + expect(mockLike).toHaveBeenCalledWith('12345678', '999') + }) + + it('returns liked=true on success', async () => { + const result = await executeLikeTweet({ tweet_id: '999' }) + expect(result.liked).toBe(true) + }) + + it('tracks user_interaction cost', async () => { + await executeLikeTweet({ tweet_id: '777' }) + const conn = getDb() + const rows = conn.prepare("SELECT * FROM cost_log WHERE operation = 'user_interaction'").all() as Array<{ operation: string }> + expect(rows.length).toBeGreaterThanOrEqual(1) + }) + + it('throws when tweet_id is empty', async () => { + await expect(executeLikeTweet({ tweet_id: '' })).rejects.toThrow(/tweet_id/i) + }) +}) + +// ─── sendDMTool definition ──────────────────────────────────────────────────── + +describe('sendDMTool definition', () => { + it('has correct name', () => { + expect(sendDMTool.name).toBe('sendDM') + }) + + it('has a description', () => { + expect(sendDMTool.description).toBeTruthy() + expect(sendDMTool.description.length).toBeGreaterThan(10) + }) + + it('requires user_id and text parameters', () => { + const required = (sendDMTool.parameters as any).required + expect(required).toContain('user_id') + expect(required).toContain('text') + }) +}) + +// ─── executeSendDM ──────────────────────────────────────────────────────────── + +describe('executeSendDM', () => { + it('calls DM API with user_id and text', async () => { + await executeSendDM({ user_id: 'user_abc', text: 'Hey there!' }) + expect(mockNewDmConversation).toHaveBeenCalledWith( + 'user_abc', + expect.objectContaining({ text: 'Hey there!' }), + ) + }) + + it('returns sent=true on success', async () => { + const result = await executeSendDM({ user_id: 'user_abc', text: 'Hello!' }) + expect(result.sent).toBe(true) + }) + + it('returns the dm event id', async () => { + const result = await executeSendDM({ user_id: 'user_abc', text: 'Hello!' }) + expect(result.dm_id).toBe('dm_event_001') + }) + + it('tracks dm_create cost', async () => { + await executeSendDM({ user_id: 'user_xyz', text: 'DM cost test' }) + const conn = getDb() + const rows = conn.prepare("SELECT * FROM cost_log WHERE operation = 'dm_create'").all() as Array<{ operation: string }> + expect(rows.length).toBeGreaterThanOrEqual(1) + }) + + it('emits herald:dm event on the guardianBus', async () => { + const { guardianBus } = await import('../../../src/coordination/event-bus.js') + const handler = vi.fn() + guardianBus.on('herald:dm', handler) + await executeSendDM({ user_id: 'user_event', text: 'Event test' }) + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'herald:dm', + source: 'herald', + data: expect.objectContaining({ user_id: 'user_event' }), + }), + ) + guardianBus.off('herald:dm', handler) + }) + + it('throws when user_id is empty', async () => { + await expect(executeSendDM({ user_id: '', text: 'Hi' })).rejects.toThrow(/user_id/i) + }) + + it('throws when text is empty', async () => { + await expect(executeSendDM({ user_id: 'user_abc', text: '' })).rejects.toThrow(/text/i) + }) +}) diff --git a/tests/herald/approval.test.ts b/tests/herald/approval.test.ts new file mode 100644 index 0000000..6680793 --- /dev/null +++ b/tests/herald/approval.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { getDb, closeDb } from '../../packages/agent/src/db.js' +import { + getPendingPosts, + getReadyToPublish, + approvePost, + rejectPost, + markPublished, + editQueuedPost, +} from '../../packages/agent/src/herald/approval.js' + +describe('Herald Post Approval Queue', () => { + beforeEach(() => { + closeDb() + // Force :memory: DB for tests + process.env.NODE_ENV = 'test' + }) + + it('returns pending posts from herald_queue', () => { + const db = getDb() + const now = new Date().toISOString() + + // Insert test posts + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('post-1', 'tweet', 'Hello world', 'pending', now) + + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('post-2', 'tweet', 'Another post', 'approved', now) + + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('post-3', 'tweet', 'Third post', 'pending', now) + + const pending = getPendingPosts() + expect(pending).toHaveLength(2) + expect(pending.map((p) => p.id).sort()).toEqual(['post-1', 'post-3']) + }) + + it('approves a post (status → approved, approved_by set)', () => { + const db = getDb() + const now = new Date().toISOString() + + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('post-1', 'tweet', 'Test post', 'pending', now) + + approvePost('post-1', 'rector') + + const row = db.prepare('SELECT * FROM herald_queue WHERE id = ?').get('post-1') as Record + expect(row.status).toBe('approved') + expect(row.approved_by).toBe('rector') + expect(row.approved_at).toBeTruthy() + }) + + it('rejects a post (status → rejected)', () => { + const db = getDb() + const now = new Date().toISOString() + + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('post-1', 'tweet', 'Test post', 'pending', now) + + rejectPost('post-1') + + const row = db.prepare('SELECT * FROM herald_queue WHERE id = ?').get('post-1') as Record + expect(row.status).toBe('rejected') + }) + + it('getReadyToPublish returns approved posts', () => { + const db = getDb() + const now = new Date() + const nowIso = now.toISOString() + const pastIso = new Date(now.getTime() - 60000).toISOString() + const futureIso = new Date(now.getTime() + 60000).toISOString() + + // Approved post without scheduled_at + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, approved_at, approved_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('post-1', 'tweet', 'Ready now', 'approved', nowIso, 'rector', nowIso) + + // Approved post with past scheduled_at + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, scheduled_at, approved_at, approved_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run('post-2', 'tweet', 'Scheduled past', 'approved', pastIso, nowIso, 'rector', nowIso) + + // Approved post with future scheduled_at + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, scheduled_at, approved_at, approved_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run('post-3', 'tweet', 'Scheduled future', 'approved', futureIso, nowIso, 'rector', nowIso) + + // Pending post + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('post-4', 'tweet', 'Still pending', 'pending', nowIso) + + const ready = getReadyToPublish() + expect(ready.map((p) => p.id).sort()).toEqual(['post-1', 'post-2']) + }) + + it('markPublished updates status + tweet_id', () => { + const db = getDb() + const now = new Date().toISOString() + + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('post-1', 'tweet', 'Test post', 'approved', now) + + markPublished('post-1', 'tw-123456') + + const row = db.prepare('SELECT * FROM herald_queue WHERE id = ?').get('post-1') as Record + expect(row.status).toBe('posted') + expect(row.tweet_id).toBe('tw-123456') + expect(row.posted_at).toBeTruthy() + }) + + it('editQueuedPost updates content for pending/approved posts', () => { + const db = getDb() + const now = new Date().toISOString() + + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('post-1', 'tweet', 'Old content', 'pending', now) + + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('post-2', 'tweet', 'Old approved', 'approved', now) + + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('post-3', 'tweet', 'Already posted', 'posted', now) + + editQueuedPost('post-1', 'New content') + editQueuedPost('post-2', 'New approved content') + editQueuedPost('post-3', 'Should not change') + + const row1 = db.prepare('SELECT * FROM herald_queue WHERE id = ?').get('post-1') as Record + const row2 = db.prepare('SELECT * FROM herald_queue WHERE id = ?').get('post-2') as Record + const row3 = db.prepare('SELECT * FROM herald_queue WHERE id = ?').get('post-3') as Record + + expect(row1.content).toBe('New content') + expect(row2.content).toBe('New approved content') + expect(row3.content).toBe('Already posted') // unchanged + }) + + it('auto-approves pending posts when HERALD_AUTO_APPROVE_POSTS enabled', () => { + const db = getDb() + const now = new Date() + const nowIso = now.toISOString() + const oldIso = new Date(now.getTime() - 3600000).toISOString() // 1 hour ago + + // Old pending post (should auto-approve) + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('post-1', 'tweet', 'Old pending', 'pending', oldIso) + + // Recent pending post (should not auto-approve) + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('post-2', 'tweet', 'New pending', 'pending', nowIso) + + // Already approved + db.prepare(` + INSERT INTO herald_queue (id, type, content, status, approved_at, approved_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('post-3', 'tweet', 'Approved', 'approved', nowIso, 'rector', nowIso) + + process.env.HERALD_AUTO_APPROVE_POSTS = 'true' + process.env.HERALD_AUTO_APPROVE_TIMEOUT = '3600' // 1 hour + + const ready = getReadyToPublish() + + // Should have post-1 (auto-approved) and post-3 (already approved) + expect(ready.map((p) => p.id).sort()).toEqual(['post-1', 'post-3']) + + // Verify post-1 was actually approved + const row1 = db.prepare('SELECT * FROM herald_queue WHERE id = ?').get('post-1') as Record + expect(row1.status).toBe('approved') + expect(row1.approved_by).toBe('auto') + + // Verify post-2 is still pending + const row2 = db.prepare('SELECT * FROM herald_queue WHERE id = ?').get('post-2') as Record + expect(row2.status).toBe('pending') + + delete process.env.HERALD_AUTO_APPROVE_POSTS + delete process.env.HERALD_AUTO_APPROVE_TIMEOUT + }) +}) From 39d807636843163bd4b8f028c230ff3ce47be1ff Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 13:26:59 +0700 Subject: [PATCH 45/92] =?UTF-8?q?feat:=20add=20HERALD=20API=20routes=20?= =?UTF-8?q?=E2=80=94=20dashboard=20data=20+=20post=20approval=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/routes/herald-api.ts | 58 ++++++ .../agent/tests/routes/herald-api.test.ts | 171 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 packages/agent/src/routes/herald-api.ts create mode 100644 packages/agent/tests/routes/herald-api.test.ts diff --git a/packages/agent/src/routes/herald-api.ts b/packages/agent/src/routes/herald-api.ts new file mode 100644 index 0000000..7f3e703 --- /dev/null +++ b/packages/agent/src/routes/herald-api.ts @@ -0,0 +1,58 @@ +import { Router, type Request, type Response } from 'express' +import { getPendingPosts, approvePost, rejectPost, editQueuedPost } from '../herald/approval.js' +import { getBudgetStatus } from '../herald/budget.js' +import { getDb } from '../db.js' + +export const heraldRouter = Router() + +// GET /api/herald — dashboard snapshot: queue, budget, dms, recentPosts +heraldRouter.get('/', (_req: Request, res: Response) => { + const queue = getPendingPosts() + const budget = getBudgetStatus() + const dms = getDb() + .prepare('SELECT * FROM herald_dms ORDER BY created_at DESC LIMIT 20') + .all() + const recentPosts = getDb() + .prepare("SELECT * FROM herald_queue WHERE status = 'posted' ORDER BY posted_at DESC LIMIT 10") + .all() + res.json({ queue, budget, dms, recentPosts }) +}) + +// POST /api/herald/approve/:id — approve, reject, or edit a queued post +heraldRouter.post('/approve/:id', (req: Request, res: Response) => { + const { id } = req.params + const { action, content } = req.body as { action?: string; content?: string } + + const post = getDb() + .prepare("SELECT * FROM herald_queue WHERE id = ? AND status IN ('pending', 'approved')") + .get(id) + + if (!post) { + res.status(404).json({ error: 'post not found or not pending' }) + return + } + + switch (action) { + case 'approve': + approvePost(id, 'rector') + res.json({ status: 'approved', id }) + break + + case 'reject': + rejectPost(id) + res.json({ status: 'rejected', id }) + break + + case 'edit': + if (!content) { + res.status(400).json({ error: 'content required for edit' }) + return + } + editQueuedPost(id, content) + res.json({ status: 'edited', id }) + break + + default: + res.status(400).json({ error: 'action must be approve, reject, or edit' }) + } +}) diff --git a/packages/agent/tests/routes/herald-api.test.ts b/packages/agent/tests/routes/herald-api.test.ts new file mode 100644 index 0000000..f708b69 --- /dev/null +++ b/packages/agent/tests/routes/herald-api.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import express from 'express' +import supertest from 'supertest' +import { closeDb, getDb } from '../../src/db.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Test setup — isolated in-memory DB per test +// ───────────────────────────────────────────────────────────────────────────── + +beforeEach(() => { + process.env.DB_PATH = ':memory:' +}) + +afterEach(() => { + closeDb() + delete process.env.DB_PATH +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Module import (top-level await — Vitest ESM) +// ───────────────────────────────────────────────────────────────────────────── + +const { heraldRouter } = await import('../../src/routes/herald-api.js') + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function createApp() { + const app = express() + app.use(express.json()) + app.use('/api/herald', heraldRouter) + return app +} + +function insertQueuePost(id: string, status: string, content = 'test content') { + getDb().prepare( + `INSERT INTO herald_queue (id, type, content, status, created_at) + VALUES (?, 'post', ?, ?, ?)` + ).run(id, content, status, new Date().toISOString()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/herald +// ───────────────────────────────────────────────────────────────────────────── + +describe('GET /api/herald', () => { + it('returns queue, budget, dms, and recentPosts fields', async () => { + insertQueuePost('q1', 'pending') + insertQueuePost('q2', 'posted') + + const res = await supertest(createApp()).get('/api/herald') + + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('queue') + expect(res.body).toHaveProperty('budget') + expect(res.body).toHaveProperty('dms') + expect(res.body).toHaveProperty('recentPosts') + + // queue contains only pending posts + expect(Array.isArray(res.body.queue)).toBe(true) + const queueIds = res.body.queue.map((p: { id: string }) => p.id) + expect(queueIds).toContain('q1') + expect(queueIds).not.toContain('q2') + + // recentPosts contains only posted posts + expect(Array.isArray(res.body.recentPosts)).toBe(true) + const postedIds = res.body.recentPosts.map((p: { id: string }) => p.id) + expect(postedIds).toContain('q2') + expect(postedIds).not.toContain('q1') + + // budget shape + expect(res.body.budget).toHaveProperty('spent') + expect(res.body.budget).toHaveProperty('limit') + expect(res.body.budget).toHaveProperty('gate') + expect(res.body.budget).toHaveProperty('percentage') + + // dms is array + expect(Array.isArray(res.body.dms)).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// POST /api/herald/approve/:id +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /api/herald/approve/:id', () => { + it('approves a pending post and returns status approved', async () => { + insertQueuePost('post-1', 'pending') + + const res = await supertest(createApp()) + .post('/api/herald/approve/post-1') + .send({ action: 'approve' }) + + expect(res.status).toBe(200) + expect(res.body).toEqual({ status: 'approved', id: 'post-1' }) + + const row = getDb().prepare('SELECT status FROM herald_queue WHERE id = ?').get('post-1') as { status: string } + expect(row.status).toBe('approved') + }) + + it('rejects a pending post and returns status rejected', async () => { + insertQueuePost('post-2', 'pending') + + const res = await supertest(createApp()) + .post('/api/herald/approve/post-2') + .send({ action: 'reject' }) + + expect(res.status).toBe(200) + expect(res.body).toEqual({ status: 'rejected', id: 'post-2' }) + + const row = getDb().prepare('SELECT status FROM herald_queue WHERE id = ?').get('post-2') as { status: string } + expect(row.status).toBe('rejected') + }) + + it('returns 404 for unknown post id', async () => { + const res = await supertest(createApp()) + .post('/api/herald/approve/does-not-exist') + .send({ action: 'approve' }) + + expect(res.status).toBe(404) + expect(res.body.error).toMatch(/not found/i) + }) + + it('returns 400 for edit action without content', async () => { + insertQueuePost('post-3', 'pending') + + const res = await supertest(createApp()) + .post('/api/herald/approve/post-3') + .send({ action: 'edit' }) + + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/content required/i) + }) + + it('edits a pending post content', async () => { + insertQueuePost('post-4', 'pending', 'original content') + + const res = await supertest(createApp()) + .post('/api/herald/approve/post-4') + .send({ action: 'edit', content: 'updated content' }) + + expect(res.status).toBe(200) + expect(res.body).toEqual({ status: 'edited', id: 'post-4' }) + + const row = getDb().prepare('SELECT content FROM herald_queue WHERE id = ?').get('post-4') as { content: string } + expect(row.content).toBe('updated content') + }) + + it('returns 400 for unknown action', async () => { + insertQueuePost('post-5', 'pending') + + const res = await supertest(createApp()) + .post('/api/herald/approve/post-5') + .send({ action: 'invalidaction' }) + + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/action must be/i) + }) + + it('returns 404 for a posted (non-pending/approved) post', async () => { + insertQueuePost('post-6', 'posted') + + const res = await supertest(createApp()) + .post('/api/herald/approve/post-6') + .send({ action: 'approve' }) + + expect(res.status).toBe(404) + expect(res.body.error).toMatch(/not found/i) + }) +}) From 3f20cd75e902e1ef9efc0fdd420622c107d89420 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 13:27:34 +0700 Subject: [PATCH 46/92] =?UTF-8?q?feat:=20add=20adaptive=20poller=20?= =?UTF-8?q?=E2=80=94=20mentions,=20DMs,=20scheduled=20posts=20with=20backo?= =?UTF-8?q?ff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State machine with 3x backoff after 3 empty polls. Skips mentions when gate is paused/dm-only, skips DMs when paused. Stops scheduled publishing on error to prevent budget overrun. --- packages/agent/src/herald/poller.ts | 257 +++++++++++++++++++++ packages/agent/tests/herald/poller.test.ts | 41 ++++ 2 files changed, 298 insertions(+) create mode 100644 packages/agent/src/herald/poller.ts create mode 100644 packages/agent/tests/herald/poller.test.ts diff --git a/packages/agent/src/herald/poller.ts b/packages/agent/src/herald/poller.ts new file mode 100644 index 0000000..aae9840 --- /dev/null +++ b/packages/agent/src/herald/poller.ts @@ -0,0 +1,257 @@ +import { executeReadMentions } from './tools/read-mentions.js' +import { executeReadDMs } from './tools/read-dms.js' +import { classifyIntent } from './intent.js' +import { getReadyToPublish, markPublished } from './approval.js' +import { publishTweet } from './tools/post-tweet.js' +import { getBudgetStatus } from './budget.js' +import { guardianBus } from '../coordination/event-bus.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface PollerState { + mentionInterval: number + dmInterval: number + emptyStreaks: number + lastMentionId: string | null + lastDmId: string | null + running: boolean +} + +// Backoff multiplier applied after EMPTY_STREAK_THRESHOLD consecutive empty polls +const EMPTY_STREAK_THRESHOLD = 3 +const BACKOFF_MULTIPLIER = 3 + +// ───────────────────────────────────────────────────────────────────────────── +// Factory +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Create initial poller state. + * Base interval from HERALD_POLL_INTERVAL env (default 600000ms = 10min). + */ +export function createPollerState(): PollerState { + const base = Number(process.env.HERALD_POLL_INTERVAL ?? '600000') + return { + mentionInterval: base, + dmInterval: base, + emptyStreaks: 0, + lastMentionId: null, + lastDmId: null, + running: false, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Adaptive interval +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Return backed-off interval (3x) after 3+ empty polls, normal otherwise. + * Uses mentionInterval as the canonical base interval. + */ +export function getNextInterval(state: PollerState): number { + if (state.emptyStreaks >= EMPTY_STREAK_THRESHOLD) { + return state.mentionInterval * BACKOFF_MULTIPLIER + } + return state.mentionInterval +} + +// ───────────────────────────────────────────────────────────────────────────── +// Poll functions +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Poll for new @SipProtocol mentions. + * Skips if budget gate is 'paused' or 'dm-only' (mentions_read is blocked). + * Updates lastMentionId on results; increments emptyStreaks on empty. + */ +export async function pollMentions(state: PollerState): Promise { + const { gate } = getBudgetStatus() + if (gate === 'paused' || gate === 'dm-only') return + + const result = await executeReadMentions( + state.lastMentionId ? { since_id: state.lastMentionId } : {} + ) + + if (result.mentions.length === 0) { + state.emptyStreaks += 1 + return + } + + // Reset streak — there's activity + state.emptyStreaks = 0 + state.lastMentionId = result.mentions[0].id + + for (const mention of result.mentions) { + const intent = classifyIntent(mention.text) + + guardianBus.emit({ + source: 'herald', + type: 'herald:mention', + level: intent.intent === 'command' ? 'important' : 'routine', + data: { + mentionId: mention.id, + authorId: mention.author_id ?? null, + text: mention.text, + intent: intent.intent, + tool: intent.tool ?? null, + needsExecLink: intent.needsExecLink ?? false, + confidence: intent.confidence, + }, + timestamp: new Date().toISOString(), + }) + } +} + +/** + * Poll for DM events. + * Skips if budget gate is 'paused' (dm_read is blocked). + */ +export async function pollDMs(state: PollerState): Promise { + const { gate } = getBudgetStatus() + if (gate === 'paused') return + + const result = await executeReadDMs() + + if (result.dms.length === 0) return + + // Update cursor to newest DM id + state.lastDmId = result.dms[0].id + + for (const dm of result.dms) { + const text = dm.text ?? '' + const intent = classifyIntent(text) + + guardianBus.emit({ + source: 'herald', + type: 'herald:dm', + level: intent.intent === 'command' ? 'important' : 'routine', + data: { + dmId: dm.id, + senderId: dm.sender_id ?? null, + text, + intent: intent.intent, + tool: intent.tool ?? null, + needsExecLink: intent.needsExecLink ?? false, + confidence: intent.confidence, + }, + timestamp: new Date().toISOString(), + }) + } +} + +/** + * Check for approved/scheduled posts ready to publish and post them. + * Stops on first error (budget exceeded or X API failure). + */ +export async function checkScheduledPosts(): Promise { + const posts = getReadyToPublish() + if (posts.length === 0) return + + for (const post of posts) { + try { + const result = await publishTweet(post.content as string) + markPublished(post.id as string, result.tweet_id) + + guardianBus.emit({ + source: 'herald', + type: 'herald:post-published', + level: 'routine', + data: { id: post.id, tweetId: result.tweet_id }, + timestamp: new Date().toISOString(), + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + + guardianBus.emit({ + source: 'herald', + type: 'herald:post-failed', + level: 'important', + data: { id: post.id, error: message }, + timestamp: new Date().toISOString(), + }) + + // Stop publishing on error — may indicate budget exceeded or rate limit + break + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Lifecycle +// ───────────────────────────────────────────────────────────────────────────── + +export type PollerTimers = { + mentionsTimer: ReturnType + dmsTimer: ReturnType + scheduledTimer: ReturnType +} + +/** + * Start 3 interval timers: mentions, DMs, and scheduled post publisher. + * All timers are unref'd so they don't prevent process exit. + * Returns handles for cleanup via stopPoller(). + */ +export function startPoller(state: PollerState): PollerTimers { + state.running = true + + const mentionsTimer = setInterval(() => { + const interval = getNextInterval(state) + // Adjust mention timer interval dynamically if backing off + if (interval !== state.mentionInterval) { + clearInterval(mentionsTimer) + } + pollMentions(state).catch((err) => { + guardianBus.emit({ + source: 'herald', + type: 'herald:poller-error', + level: 'important', + data: { poller: 'mentions', error: err instanceof Error ? err.message : String(err) }, + timestamp: new Date().toISOString(), + }) + }) + }, state.mentionInterval) + + const dmsTimer = setInterval(() => { + pollDMs(state).catch((err) => { + guardianBus.emit({ + source: 'herald', + type: 'herald:poller-error', + level: 'important', + data: { poller: 'dms', error: err instanceof Error ? err.message : String(err) }, + timestamp: new Date().toISOString(), + }) + }) + }, state.dmInterval) + + // Scheduled posts checked every minute regardless of mention backoff + const scheduledTimer = setInterval(() => { + checkScheduledPosts().catch((err) => { + guardianBus.emit({ + source: 'herald', + type: 'herald:poller-error', + level: 'important', + data: { poller: 'scheduled', error: err instanceof Error ? err.message : String(err) }, + timestamp: new Date().toISOString(), + }) + }) + }, 60_000) + + mentionsTimer.unref() + dmsTimer.unref() + scheduledTimer.unref() + + return { mentionsTimer, dmsTimer, scheduledTimer } +} + +/** + * Stop all poller intervals and mark state as not running. + */ +export function stopPoller(state: PollerState, timers: PollerTimers): void { + clearInterval(timers.mentionsTimer) + clearInterval(timers.dmsTimer) + clearInterval(timers.scheduledTimer) + state.running = false +} diff --git a/packages/agent/tests/herald/poller.test.ts b/packages/agent/tests/herald/poller.test.ts new file mode 100644 index 0000000..2c4f2bf --- /dev/null +++ b/packages/agent/tests/herald/poller.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest' +import { createPollerState, getNextInterval } from '../../src/herald/poller.js' + +describe('HERALD adaptive poller', () => { + it('createPollerState() returns correct defaults', () => { + const prevInterval = process.env.HERALD_POLL_INTERVAL + delete process.env.HERALD_POLL_INTERVAL + + const state = createPollerState() + + expect(state.mentionInterval).toBe(600000) + expect(state.dmInterval).toBe(600000) + expect(state.emptyStreaks).toBe(0) + expect(state.lastMentionId).toBeNull() + expect(state.lastDmId).toBeNull() + expect(state.running).toBe(false) + + if (prevInterval !== undefined) process.env.HERALD_POLL_INTERVAL = prevInterval + }) + + it('getNextInterval() backs off 3x after 3+ empty polls', () => { + const state = createPollerState() + state.emptyStreaks = 3 + const base = state.mentionInterval + + const interval = getNextInterval(state) + + expect(interval).toBe(base * 3) + expect(interval).toBeGreaterThan(base) + }) + + it('getNextInterval() returns base interval when emptyStreaks=0', () => { + const state = createPollerState() + state.emptyStreaks = 0 + const base = state.mentionInterval + + const interval = getNextInterval(state) + + expect(interval).toBe(base) + }) +}) From f78c9f6871a627b67691ff0aaf2e229539e22554 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 13:31:57 +0700 Subject: [PATCH 47/92] =?UTF-8?q?feat:=20add=20HERALD=20Pi=20agent=20facto?= =?UTF-8?q?ry=20=E2=80=94=20system=20prompt,=209=20tools,=20executor=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/herald/herald.ts | 62 +++++++++++++++++++++++++++++ tests/herald/herald.test.ts | 55 +++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 packages/agent/src/herald/herald.ts create mode 100644 tests/herald/herald.test.ts diff --git a/packages/agent/src/herald/herald.ts b/packages/agent/src/herald/herald.ts new file mode 100644 index 0000000..935d238 --- /dev/null +++ b/packages/agent/src/herald/herald.ts @@ -0,0 +1,62 @@ +import type { Tool } from '@mariozechner/pi-ai' +import { readMentionsTool, executeReadMentions } from './tools/read-mentions.js' +import { readDMsTool, executeReadDMs } from './tools/read-dms.js' +import { searchPostsTool, executeSearchPosts } from './tools/search-posts.js' +import { readUserProfileTool, executeReadUserProfile } from './tools/read-user.js' +import { postTweetTool, executePostTweet } from './tools/post-tweet.js' +import { replyTweetTool, executeReplyTweet } from './tools/reply-tweet.js' +import { likeTweetTool, executeLikeTweet } from './tools/like-tweet.js' +import { sendDMTool, executeSendDM } from './tools/send-dm.js' +import { schedulePostTool, executeSchedulePost } from './tools/schedule-post.js' + +export const HERALD_SYSTEM_PROMPT = `You are HERALD — SIP Protocol's content and engagement agent on X/Twitter. + +IDENTITY: Confident, technical, cypherpunk. Never corporate, never aggressive shilling. You speak for @SipProtocol. + +RULES: +- Public replies NEVER echo wallet addresses, amounts, or private keys +- DMs can include wallet-specific info (private channel) +- Posts go through approval queue (not posted immediately) — use postTweet +- Thread context: last 5 tweets only, no cross-thread memory +- Keep replies concise — 1-2 sentences max + +INTENT HANDLING: +- command → execute privacy tool or generate execution link +- question → helpful reply about SIP Protocol, stealth addresses, privacy +- engagement → like, RT, or short appreciative reply +- spam → ignore silently + +TOOLS: readMentions, readDMs, searchPosts, readUserProfile, postTweet, replyTweet, likeTweet, sendDM, schedulePost` + +export const HERALD_TOOLS: Tool[] = [ + readMentionsTool, + readDMsTool, + searchPostsTool, + readUserProfileTool, + postTweetTool, + replyTweetTool, + likeTweetTool, + sendDMTool, + schedulePostTool, +] + +type ToolExecutor = (params: Record) => Promise + +export const HERALD_TOOL_EXECUTORS: Record = { + readMentions: (p) => executeReadMentions(p as any), + readDMs: (p) => executeReadDMs(p as any), + searchPosts: (p) => executeSearchPosts(p as any), + readUserProfile: (p) => executeReadUserProfile(p as any), + postTweet: (p) => executePostTweet(p as any), + replyTweet: (p) => executeReplyTweet(p as any), + likeTweet: (p) => executeLikeTweet(p as any), + sendDM: (p) => executeSendDM(p as any), + schedulePost: (p) => executeSchedulePost(p as any), +} + +export const HERALD_IDENTITY = { + name: 'HERALD', + role: 'Content Agent', + llm: true, + model: 'anthropic/claude-sonnet-4.6', +} as const diff --git a/tests/herald/herald.test.ts b/tests/herald/herald.test.ts new file mode 100644 index 0000000..5c71ddd --- /dev/null +++ b/tests/herald/herald.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' + +const { + HERALD_SYSTEM_PROMPT, + HERALD_TOOLS, + HERALD_TOOL_EXECUTORS, + HERALD_IDENTITY, +} = await import('../../packages/agent/src/herald/herald.js') + +describe('HERALD agent factory', () => { + it('exports HERALD_SYSTEM_PROMPT with HERALD and cypherpunk keywords', () => { + expect(HERALD_SYSTEM_PROMPT).toContain('HERALD') + expect(HERALD_SYSTEM_PROMPT).toContain('cypherpunk') + expect(HERALD_SYSTEM_PROMPT).toContain('X/Twitter') + }) + + it('exports HERALD_TOOLS with 9 tools', () => { + expect(HERALD_TOOLS).toHaveLength(9) + const toolNames = HERALD_TOOLS.map((t: { name: string }) => t.name) + expect(toolNames).toContain('readMentions') + expect(toolNames).toContain('readDMs') + expect(toolNames).toContain('searchPosts') + expect(toolNames).toContain('readUserProfile') + expect(toolNames).toContain('postTweet') + expect(toolNames).toContain('replyTweet') + expect(toolNames).toContain('likeTweet') + expect(toolNames).toContain('sendDM') + expect(toolNames).toContain('schedulePost') + }) + + it('exports HERALD_TOOL_EXECUTORS with functions for all 9 tools', () => { + const executorKeys = Object.keys(HERALD_TOOL_EXECUTORS) + expect(executorKeys).toHaveLength(9) + expect(executorKeys).toContain('readMentions') + expect(executorKeys).toContain('readDMs') + expect(executorKeys).toContain('searchPosts') + expect(executorKeys).toContain('readUserProfile') + expect(executorKeys).toContain('postTweet') + expect(executorKeys).toContain('replyTweet') + expect(executorKeys).toContain('likeTweet') + expect(executorKeys).toContain('sendDM') + expect(executorKeys).toContain('schedulePost') + + for (const key of executorKeys) { + expect(typeof HERALD_TOOL_EXECUTORS[key]).toBe('function') + } + }) + + it('exports HERALD_IDENTITY with correct metadata', () => { + expect(HERALD_IDENTITY.name).toBe('HERALD') + expect(HERALD_IDENTITY.role).toBe('Content Agent') + expect(HERALD_IDENTITY.llm).toBe(true) + expect(HERALD_IDENTITY.model).toContain('claude') + }) +}) From c14e3ad473423bc17443863fbc8116af9762cd42 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 13:32:50 +0700 Subject: [PATCH 48/92] fix: move herald + approval tests to packages/agent/tests/ --- {tests => packages/agent/tests}/herald/approval.test.ts | 0 {tests => packages/agent/tests}/herald/herald.test.ts | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {tests => packages/agent/tests}/herald/approval.test.ts (100%) rename {tests => packages/agent/tests}/herald/herald.test.ts (100%) diff --git a/tests/herald/approval.test.ts b/packages/agent/tests/herald/approval.test.ts similarity index 100% rename from tests/herald/approval.test.ts rename to packages/agent/tests/herald/approval.test.ts diff --git a/tests/herald/herald.test.ts b/packages/agent/tests/herald/herald.test.ts similarity index 100% rename from tests/herald/herald.test.ts rename to packages/agent/tests/herald/herald.test.ts From e1c0a3d464bd69733d9850445cca82ece27cda96 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 13:40:12 +0700 Subject: [PATCH 49/92] =?UTF-8?q?feat:=20wire=20HERALD=20into=20Express=20?= =?UTF-8?q?=E2=80=94=20routes=20mounted,=20poller=20conditional=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import heraldRouter from routes/herald-api.ts, mount at /api/herald - Conditionally start HERALD poller after server listen when X_BEARER_TOKEN + X_CONSUMER_KEY present - Add integration test: HERALD tools count, intent+budget, approval→publish flow, poller state management - Fix test to use queued.id (actual PostTweetResult shape) instead of queue_id from task template --- packages/agent/src/index.ts | 16 ++++ .../agent/tests/integration/herald.test.ts | 81 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 packages/agent/tests/integration/herald.test.ts diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index b584d0a..e6e393e 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -13,6 +13,7 @@ import { commandHandler } from './routes/command.js' import { confirmRouter } from './routes/confirm.js' import { vaultRouter } from './routes/vault-api.js' import { squadRouter } from './routes/squad-api.js' +import { heraldRouter } from './routes/herald-api.js' import { guardianBus } from './coordination/event-bus.js' import { attachLogger } from './coordination/activity-logger.js' import { AgentPool } from './agents/pool.js' @@ -84,6 +85,9 @@ app.use('/api/vault', verifyJwt, vaultRouter) // Squad dashboard + kill switch — admin auth handled by squad route internally app.use('/api/squad', squadRouter) +// HERALD approval queue + budget dashboard — admin auth handled internally +app.use('/api/herald', heraldRouter) + // Serve web chat UI (static files from app/dist) // In production: packages/agent/dist/ -> ../../../app/dist // Resolved via __dirname so it works regardless of cwd @@ -237,6 +241,18 @@ app.listen(PORT, () => { console.log(` Confirm: POST http://localhost:${PORT}/api/confirm/:id`) console.log(` Vault: GET http://localhost:${PORT}/api/vault`) console.log(` Squad: http://localhost:${PORT}/api/squad`) + console.log(` Herald: http://localhost:${PORT}/api/herald`) + + // Start HERALD poller only when X API credentials are present + if (process.env.X_BEARER_TOKEN && process.env.X_CONSUMER_KEY) { + import('./herald/poller.js').then(({ createPollerState, startPoller }) => { + const heraldState = createPollerState() + startPoller(heraldState) + console.log(' HERALD: poller started (mentions + DMs + scheduled posts)') + }).catch(err => { + console.warn(' HERALD: poller not started:', (err as Error).message) + }) + } }) export { app } diff --git a/packages/agent/tests/integration/herald.test.ts b/packages/agent/tests/integration/herald.test.ts new file mode 100644 index 0000000..9649ca3 --- /dev/null +++ b/packages/agent/tests/integration/herald.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { getDb, closeDb } from '../../src/db.js' + +// Mock twitter-api-v2 to avoid real API calls +vi.mock('twitter-api-v2', () => ({ + TwitterApi: vi.fn().mockImplementation(() => ({ + v2: { + userMentionTimeline: vi.fn().mockResolvedValue({ data: { data: [] } }), + listDmEvents: vi.fn().mockResolvedValue({ data: { data: [] } }), + tweet: vi.fn().mockResolvedValue({ data: { id: 'tw-1', text: 'test' } }), + }, + })), +})) + +beforeEach(() => { + process.env.DB_PATH = ':memory:' + process.env.HERALD_MONTHLY_BUDGET = '150' + process.env.X_BEARER_TOKEN = 'test' + process.env.X_CONSUMER_KEY = 'ck' + process.env.X_CONSUMER_SECRET = 'cs' + process.env.X_ACCESS_TOKEN = 'at' + process.env.X_ACCESS_SECRET = 'as' + process.env.HERALD_X_USER_ID = '999' + getDb() +}) + +afterEach(() => { + closeDb() + for (const key of [ + 'DB_PATH', + 'HERALD_MONTHLY_BUDGET', + 'X_BEARER_TOKEN', + 'X_CONSUMER_KEY', + 'X_CONSUMER_SECRET', + 'X_ACCESS_TOKEN', + 'X_ACCESS_SECRET', + 'HERALD_X_USER_ID', + ]) { + delete process.env[key] + } +}) + +describe('HERALD Integration', () => { + it('HERALD has 9 tools', async () => { + const { HERALD_TOOLS } = await import('../../src/herald/herald.js') + expect(HERALD_TOOLS).toHaveLength(9) + }) + + it('intent classifier + budget tracker work together', async () => { + const { classifyIntent } = await import('../../src/herald/intent.js') + const { getBudgetStatus, trackXApiCost } = await import('../../src/herald/budget.js') + const intent = classifyIntent('@SipProtocol privacy score for 7xKz') + expect(intent.intent).toBe('command') + expect(intent.tool).toBe('privacyScore') + trackXApiCost('posts_read', 5) + const status = getBudgetStatus() + expect(status.spent).toBeGreaterThan(0) + expect(status.gate).toBe('normal') + }) + + it('approval queue → publish flow', async () => { + const { executePostTweet } = await import('../../src/herald/tools/post-tweet.js') + const { approvePost, getReadyToPublish } = await import('../../src/herald/approval.js') + const queued = await executePostTweet({ text: 'Privacy is default.' }) + expect(queued.queued).toBe(true) + expect(typeof queued.id).toBe('string') + approvePost(queued.id, 'rector') + const ready = getReadyToPublish() + expect(ready.length).toBeGreaterThanOrEqual(1) + }) + + it('poller state management', async () => { + const { createPollerState, getNextInterval } = await import('../../src/herald/poller.js') + const state = createPollerState() + expect(state.emptyStreaks).toBe(0) + state.emptyStreaks = 3 + expect(getNextInterval(state)).toBeGreaterThan(state.mentionInterval) + state.emptyStreaks = 0 + expect(getNextInterval(state)).toBe(state.mentionInterval) + }) +}) From 549ee61c072012a2ecae8872202a5da55d995b6b Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 13:53:42 +0700 Subject: [PATCH 50/92] =?UTF-8?q?fix:=20cap=20vitest=20to=204=20forks=20?= =?UTF-8?q?=E2=80=94=20prevents=20orphan=20processes=20from=20parallel=20s?= =?UTF-8?q?ubagents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vitest.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index 4e83b8a..c2173ce 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,12 @@ export default defineConfig({ globals: true, environment: 'node', include: ['tests/**/*.test.ts'], + pool: 'forks', + poolOptions: { + forks: { + maxForks: 4, + }, + }, coverage: { provider: 'v8', include: ['src/**/*.ts'], From 0c130c67172ba7fbfd663d8eaa3603d7f8576ce8 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 14:07:03 +0700 Subject: [PATCH 51/92] =?UTF-8?q?feat:=20add=20SENTINEL=20configuration=20?= =?UTF-8?q?=E2=80=94=20env-driven=20scan=20intervals,=20thresholds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/sentinel/config.ts | 23 ++++++++ packages/agent/tests/sentinel/config.test.ts | 60 ++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 packages/agent/src/sentinel/config.ts create mode 100644 packages/agent/tests/sentinel/config.test.ts diff --git a/packages/agent/src/sentinel/config.ts b/packages/agent/src/sentinel/config.ts new file mode 100644 index 0000000..4173bfd --- /dev/null +++ b/packages/agent/src/sentinel/config.ts @@ -0,0 +1,23 @@ +export interface SentinelConfig { + scanInterval: number + activeScanInterval: number + autoRefundThreshold: number + threatCheckEnabled: boolean + largeTransferThreshold: number + maxRpcPerWallet: number + maxWalletsPerCycle: number + backoffMax: number +} + +export function getSentinelConfig(): SentinelConfig { + return { + scanInterval: Number(process.env.SENTINEL_SCAN_INTERVAL ?? '60000'), + activeScanInterval: Number(process.env.SENTINEL_ACTIVE_SCAN_INTERVAL ?? '15000'), + autoRefundThreshold: Number(process.env.SENTINEL_AUTO_REFUND_THRESHOLD ?? '1'), + threatCheckEnabled: process.env.SENTINEL_THREAT_CHECK !== 'false', + largeTransferThreshold: Number(process.env.SENTINEL_LARGE_TRANSFER_THRESHOLD ?? '10'), + maxRpcPerWallet: 5, + maxWalletsPerCycle: 20, + backoffMax: 600_000, + } +} diff --git a/packages/agent/tests/sentinel/config.test.ts b/packages/agent/tests/sentinel/config.test.ts new file mode 100644 index 0000000..4ff31d8 --- /dev/null +++ b/packages/agent/tests/sentinel/config.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +const { getSentinelConfig } = await import('../../src/sentinel/config.js') + +describe('getSentinelConfig', () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('returns defaults when no env vars set', () => { + delete process.env.SENTINEL_SCAN_INTERVAL + delete process.env.SENTINEL_ACTIVE_SCAN_INTERVAL + delete process.env.SENTINEL_AUTO_REFUND_THRESHOLD + delete process.env.SENTINEL_THREAT_CHECK + delete process.env.SENTINEL_LARGE_TRANSFER_THRESHOLD + + const config = getSentinelConfig() + expect(config.scanInterval).toBe(60000) + expect(config.activeScanInterval).toBe(15000) + expect(config.autoRefundThreshold).toBe(1) + expect(config.threatCheckEnabled).toBe(true) + expect(config.largeTransferThreshold).toBe(10) + expect(config.maxRpcPerWallet).toBe(5) + expect(config.maxWalletsPerCycle).toBe(20) + expect(config.backoffMax).toBe(600_000) + }) + + it('respects env var overrides', () => { + process.env.SENTINEL_SCAN_INTERVAL = '30000' + process.env.SENTINEL_AUTO_REFUND_THRESHOLD = '5' + + const config = getSentinelConfig() + expect(config.scanInterval).toBe(30000) + expect(config.autoRefundThreshold).toBe(5) + expect(config.activeScanInterval).toBe(15000) // not overridden + }) + + it('handles threatCheckEnabled false when env var set to false', () => { + process.env.SENTINEL_THREAT_CHECK = 'false' + + const config = getSentinelConfig() + expect(config.threatCheckEnabled).toBe(false) + }) + + it('keeps threatCheckEnabled true for any non-false value', () => { + process.env.SENTINEL_THREAT_CHECK = 'true' + let config = getSentinelConfig() + expect(config.threatCheckEnabled).toBe(true) + + process.env.SENTINEL_THREAT_CHECK = 'yes' + config = getSentinelConfig() + expect(config.threatCheckEnabled).toBe(true) + }) +}) From ac815a64820e7a660c27a98e05b5165acc222303 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 14:07:47 +0700 Subject: [PATCH 52/92] =?UTF-8?q?feat:=20add=20SENTINEL=20detector=20?= =?UTF-8?q?=E2=80=94=20detection-to-event=20mapping=20for=206=20event=20ty?= =?UTF-8?q?pes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/sentinel/detector.ts | 112 ++++++++++++++++++ .../agent/tests/sentinel/detector.test.ts | 108 +++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 packages/agent/src/sentinel/detector.ts create mode 100644 packages/agent/tests/sentinel/detector.test.ts diff --git a/packages/agent/src/sentinel/detector.ts b/packages/agent/src/sentinel/detector.ts new file mode 100644 index 0000000..60cd1b1 --- /dev/null +++ b/packages/agent/src/sentinel/detector.ts @@ -0,0 +1,112 @@ +import { type GuardianEvent } from '../coordination/event-bus.js' + +export interface Detection { + event: string + level: 'critical' | 'important' | 'routine' + data: Record + wallet?: string +} + +// ─── Detector functions ──────────────────────────────────────────────────── + +export function detectUnclaimedPayment(params: { + ephemeralPubkey: string + amount: number + wallet?: string +}): Detection { + return { + event: 'sentinel:unclaimed', + level: 'important', + data: { + ephemeralPubkey: params.ephemeralPubkey, + amount: params.amount, + }, + wallet: params.wallet, + } +} + +export function detectExpiredDeposit(params: { + depositPda: string + amount: number + wallet?: string + threshold: number +}): Detection { + const isLarge = params.amount >= params.threshold + + return { + event: isLarge ? 'sentinel:refund-pending' : 'sentinel:expired', + level: isLarge ? 'critical' : 'important', + data: { + depositPda: params.depositPda, + amount: params.amount, + threshold: params.threshold, + autoRefund: !isLarge, + }, + wallet: params.wallet, + } +} + +export function detectThreat(params: { + address: string + reason: string + wallet?: string +}): Detection { + return { + event: 'sentinel:threat', + level: 'critical', + data: { + address: params.address, + reason: params.reason, + }, + wallet: params.wallet, + } +} + +export function detectLargeTransfer(params: { + amount: number + from: string + wallet?: string + threshold: number +}): Detection { + return { + event: 'sentinel:large-transfer', + level: 'important', + data: { + amount: params.amount, + from: params.from, + threshold: params.threshold, + }, + wallet: params.wallet, + } +} + +export function detectBalanceChange(params: { + previousBalance: number + currentBalance: number + wallet?: string +}): Detection { + return { + event: 'sentinel:balance', + level: 'important', + data: { + previousBalance: params.previousBalance, + currentBalance: params.currentBalance, + delta: params.currentBalance - params.previousBalance, + }, + wallet: params.wallet, + } +} + +// ─── Conversion ──────────────────────────────────────────────────────────── + +export function toGuardianEvent( + detection: Detection +): Omit { + return { + source: 'sentinel', + type: detection.event, + level: detection.level, + data: detection.data, + wallet: detection.wallet ?? null, + } +} diff --git a/packages/agent/tests/sentinel/detector.test.ts b/packages/agent/tests/sentinel/detector.test.ts new file mode 100644 index 0000000..17bcae3 --- /dev/null +++ b/packages/agent/tests/sentinel/detector.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest' +import { + detectUnclaimedPayment, + detectExpiredDeposit, + detectThreat, + detectLargeTransfer, + detectBalanceChange, + toGuardianEvent, +} from '../../src/sentinel/detector.js' + +describe('detector', () => { + it('detectUnclaimedPayment returns sentinel:unclaimed at important level', () => { + const result = detectUnclaimedPayment({ + ephemeralPubkey: 'abc123', + amount: 1.5, + wallet: 'FGSk...BWr', + }) + expect(result.event).toBe('sentinel:unclaimed') + expect(result.level).toBe('important') + expect(result.data.amount).toBe(1.5) + expect(result.data.ephemeralPubkey).toBe('abc123') + expect(result.wallet).toBe('FGSk...BWr') + }) + + it('detectExpiredDeposit below threshold sets autoRefund: true and level: important', () => { + const result = detectExpiredDeposit({ + depositPda: 'pda1', + amount: 0.05, + wallet: 'FGSk...BWr', + threshold: 1.0, + }) + expect(result.event).toBe('sentinel:expired') + expect(result.level).toBe('important') + expect(result.data.autoRefund).toBe(true) + expect(result.data.amount).toBe(0.05) + expect(result.data.depositPda).toBe('pda1') + }) + + it('detectExpiredDeposit at or above threshold sets autoRefund: false and level: critical', () => { + const result = detectExpiredDeposit({ + depositPda: 'pda2', + amount: 2.0, + wallet: 'FGSk...BWr', + threshold: 1.0, + }) + expect(result.event).toBe('sentinel:refund-pending') + expect(result.level).toBe('critical') + expect(result.data.autoRefund).toBe(false) + expect(result.data.amount).toBe(2.0) + }) + + it('detectThreat returns sentinel:threat at critical level', () => { + const result = detectThreat({ + address: 'badAddr', + reason: 'flagged by Chainalysis', + wallet: 'FGSk...BWr', + }) + expect(result.event).toBe('sentinel:threat') + expect(result.level).toBe('critical') + expect(result.data.address).toBe('badAddr') + expect(result.data.reason).toBe('flagged by Chainalysis') + expect(result.wallet).toBe('FGSk...BWr') + }) + + it('detectLargeTransfer returns sentinel:large-transfer at important level', () => { + const result = detectLargeTransfer({ + amount: 500, + from: 'senderAddr', + wallet: 'FGSk...BWr', + threshold: 100, + }) + expect(result.event).toBe('sentinel:large-transfer') + expect(result.level).toBe('important') + expect(result.data.amount).toBe(500) + expect(result.data.from).toBe('senderAddr') + expect(result.wallet).toBe('FGSk...BWr') + }) + + it('detectBalanceChange returns sentinel:balance with calculated delta', () => { + const result = detectBalanceChange({ + previousBalance: 10, + currentBalance: 7.5, + wallet: 'FGSk...BWr', + }) + expect(result.event).toBe('sentinel:balance') + expect(result.level).toBe('important') + expect(result.data.delta).toBeCloseTo(-2.5) + expect(result.data.previousBalance).toBe(10) + expect(result.data.currentBalance).toBe(7.5) + expect(result.wallet).toBe('FGSk...BWr') + }) + + it('toGuardianEvent converts Detection to GuardianEvent shape without timestamp', () => { + const detection = detectThreat({ + address: 'badAddr', + reason: 'flagged', + wallet: 'FGSk...BWr', + }) + const event = toGuardianEvent(detection) + expect(event.source).toBe('sentinel') + expect(event.type).toBe('sentinel:threat') + expect(event.level).toBe('critical') + expect(event.data.address).toBe('badAddr') + expect(event.wallet).toBe('FGSk...BWr') + // timestamp is excluded from Omit + expect('timestamp' in event).toBe(false) + }) +}) From 12cc101b7ef6df78299b42b9043338a44d3a45c6 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 14:10:41 +0700 Subject: [PATCH 53/92] =?UTF-8?q?feat:=20add=20refund=20guard=20=E2=80=94?= =?UTF-8?q?=20threshold=20check,=20double-processing=20prevention,=20idemp?= =?UTF-8?q?otency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/sentinel/refund-guard.ts | 32 ++++++++++ .../agent/tests/sentinel/refund-guard.test.ts | 58 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 packages/agent/src/sentinel/refund-guard.ts create mode 100644 packages/agent/tests/sentinel/refund-guard.test.ts diff --git a/packages/agent/src/sentinel/refund-guard.ts b/packages/agent/src/sentinel/refund-guard.ts new file mode 100644 index 0000000..2d55cf3 --- /dev/null +++ b/packages/agent/src/sentinel/refund-guard.ts @@ -0,0 +1,32 @@ +import { createHash } from 'node:crypto' + +// ─── Threshold & Confirmation ─────────────────────────────────────────────── + +// Below threshold → auto-refund. At or above → require confirmation. +export function shouldAutoRefund(amount: number, threshold: number): boolean { + return amount < threshold +} + +// ─── Double-Processing Prevention ────────────────────────────────────────── + +// Check if refund is safe — looks for deposit PDA in recent signatures. +// If found → in-flight TX, skip refund. +export function isRefundSafe( + depositPda: string, + recentSignatures: string[] +): boolean { + return !recentSignatures.some(sig => sig.includes(depositPda)) +} + +// ─── Idempotency ─────────────────────────────────────────────────────────── + +// Deterministic idempotency key: hash of deposit PDA + timestamp. +export function generateIdempotencyKey( + depositPda: string, + timestamp: number +): string { + return createHash('sha256') + .update(`refund:${depositPda}:${timestamp}`) + .digest('hex') + .slice(0, 16) +} diff --git a/packages/agent/tests/sentinel/refund-guard.test.ts b/packages/agent/tests/sentinel/refund-guard.test.ts new file mode 100644 index 0000000..db6813a --- /dev/null +++ b/packages/agent/tests/sentinel/refund-guard.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { + shouldAutoRefund, + isRefundSafe, + generateIdempotencyKey, +} from '../../src/sentinel/refund-guard.js' + +describe('refund-guard', () => { + describe('shouldAutoRefund', () => { + it('returns true when amount is below threshold', () => { + expect(shouldAutoRefund(0.5, 1)).toBe(true) + }) + + it('returns false when amount equals threshold', () => { + expect(shouldAutoRefund(1, 1)).toBe(false) + }) + + it('returns false when amount is above threshold', () => { + expect(shouldAutoRefund(5, 1)).toBe(false) + }) + }) + + describe('isRefundSafe', () => { + it('returns true when deposit PDA is not in recent signatures', () => { + const result = isRefundSafe('pda123', [ + 'sig_abc456', + 'sig_def789', + 'sig_ghi012', + ]) + expect(result).toBe(true) + }) + + it('returns false when deposit PDA appears in recent signatures', () => { + const result = isRefundSafe('pda123', [ + 'sig_abc456', + 'sig_pda123_includes_it', + 'sig_ghi012', + ]) + expect(result).toBe(false) + }) + }) + + describe('generateIdempotencyKey', () => { + it('returns deterministic key for same inputs', () => { + const key1 = generateIdempotencyKey('pda123', 1000) + const key2 = generateIdempotencyKey('pda123', 1000) + expect(key1).toBe(key2) + }) + + it('returns different key for different inputs', () => { + const key1 = generateIdempotencyKey('pda123', 1000) + const key2 = generateIdempotencyKey('pda456', 1000) + const key3 = generateIdempotencyKey('pda123', 2000) + expect(key1).not.toBe(key2) + expect(key1).not.toBe(key3) + }) + }) +}) From b5289eb42896baccd74ec38256d6f51d14269957 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 14:17:04 +0700 Subject: [PATCH 54/92] =?UTF-8?q?feat:=20add=20SENTINEL=20scanner=20?= =?UTF-8?q?=E2=80=94=20vault=20state,=20stealth=20payments,=20balance=20ch?= =?UTF-8?q?ange=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements scanWallet() core scan cycle: fetches vault balance via getVaultBalance, converts lamports to SOL, emits detectBalanceChange when previousBalance differs, and scans for unclaimed stealth payments via scanForPayments when keys are supplied. RPC budget is tracked per-call with try/finally; errors never crash the cycle. 8 tests covering all paths including error resilience and maxRpcCalls enforcement. --- packages/agent/src/sentinel/scanner.ts | 153 ++++++++++++++++ packages/agent/tests/sentinel/scanner.test.ts | 167 ++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 packages/agent/src/sentinel/scanner.ts create mode 100644 packages/agent/tests/sentinel/scanner.test.ts diff --git a/packages/agent/src/sentinel/scanner.ts b/packages/agent/src/sentinel/scanner.ts new file mode 100644 index 0000000..525138c --- /dev/null +++ b/packages/agent/src/sentinel/scanner.ts @@ -0,0 +1,153 @@ +import { PublicKey } from '@solana/web3.js' +import { + createConnection, + getVaultBalance, + scanForPayments, + WSOL_MINT, +} from '@sipher/sdk' +import type { ScanParams } from '@sipher/sdk' +import { type Detection, detectUnclaimedPayment, detectBalanceChange } from './detector.js' +import { getSentinelConfig } from './config.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface ScanOptions { + /** Previous vault balance in SOL — triggers balance change detection when it differs */ + previousBalance?: number + /** Maximum RPC calls this scan cycle is allowed to make */ + maxRpcCalls?: number + /** + * Viewing private key (32 bytes) for stealth payment scanning. + * When omitted, payment scanning is skipped (balance-only mode). + */ + viewingPrivateKey?: Uint8Array + /** + * Spending private key (32 bytes) for stealth payment scanning. + * When omitted, payment scanning is skipped (balance-only mode). + */ + spendingPrivateKey?: Uint8Array +} + +export interface ScanResult { + wallet: string + /** Current vault balance in SOL */ + vaultBalance: number + detections: Detection[] + /** Number of RPC calls consumed during this scan */ + rpcCalls: number + /** ISO-8601 timestamp of when this scan completed */ + timestamp: string +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +const LAMPORTS_PER_SOL = 1_000_000_000 + +function lamportsToSol(lamports: bigint): number { + return Number(lamports) / LAMPORTS_PER_SOL +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +// ───────────────────────────────────────────────────────────────────────────── +// Core scan function +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Perform a full scan cycle for a single wallet. + * + * 1. Establish Solana connection (no RPC cost — local only) + * 2. Fetch vault balance via getVaultBalance — convert lamports → SOL + * 3. If previousBalance is provided and differs → emit detectBalanceChange + * 4. Scan for unclaimed stealth payments via scanForPayments — each found → emit detectUnclaimedPayment + * 5. Track RPC budget — stop after maxRpcCalls is reached + * + * All RPC calls are individually wrapped in try/catch — errors increment + * the RPC counter but never crash the scan. Caller always gets a valid ScanResult. + */ +export async function scanWallet( + wallet: string, + options: ScanOptions = {} +): Promise { + const config = getSentinelConfig() + const maxRpc = options.maxRpcCalls ?? config.maxRpcPerWallet + const detections: Detection[] = [] + let rpcCalls = 0 + + // Build connection — local only, no RPC call consumed + const connection = createConnection('devnet') + + // ── Step 1: Vault balance ────────────────────────────────────────────────── + let vaultBalance = 0 + + if (rpcCalls < maxRpc) { + try { + const depositor = new PublicKey(wallet) + const balanceInfo = await getVaultBalance(connection, depositor, WSOL_MINT) + vaultBalance = lamportsToSol(balanceInfo.balance) + + if ( + options.previousBalance !== undefined && + Math.abs(options.previousBalance - vaultBalance) > Number.EPSILON + ) { + detections.push( + detectBalanceChange({ + previousBalance: options.previousBalance, + currentBalance: vaultBalance, + wallet, + }) + ) + } + } catch { + // vaultBalance stays 0; scan continues + } finally { + rpcCalls++ + } + } + + // ── Step 2: Stealth payment scan ─────────────────────────────────────────── + // Requires viewing + spending keys. Skip gracefully when not provided. + const hasKeys = options.viewingPrivateKey && options.spendingPrivateKey + + if (rpcCalls < maxRpc && hasKeys) { + try { + const scanParams: ScanParams = { + connection, + viewingPrivateKey: options.viewingPrivateKey as Uint8Array, + spendingPrivateKey: options.spendingPrivateKey as Uint8Array, + limit: 50, + } + const scanResult = await scanForPayments(scanParams) + + for (const payment of scanResult.payments) { + detections.push( + detectUnclaimedPayment({ + ephemeralPubkey: bytesToHex(payment.ephemeralPubkey), + amount: lamportsToSol(payment.transferAmount), + wallet, + }) + ) + } + } catch { + // Stealth payments missed this cycle — not fatal + } finally { + rpcCalls++ + } + } + + return { + wallet, + vaultBalance, + detections, + rpcCalls, + timestamp: new Date().toISOString(), + } +} diff --git a/packages/agent/tests/sentinel/scanner.test.ts b/packages/agent/tests/sentinel/scanner.test.ts new file mode 100644 index 0000000..d9f2e11 --- /dev/null +++ b/packages/agent/tests/sentinel/scanner.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// ─── Mock declarations (no external refs — vi.mock is hoisted) ────────────── + +const getVaultBalanceMock = vi.fn() +const scanForPaymentsMock = vi.fn() +const createConnectionMock = vi.fn(() => ({})) + +vi.mock('@sipher/sdk', () => { + // PublicKey-like stub — just needs .toBase58() and .toBuffer() to not throw + // The scanner only reads VaultBalance.balance (bigint) from the result + const { PublicKey } = require('@solana/web3.js') + return { + createConnection: createConnectionMock, + getVaultBalance: getVaultBalanceMock, + scanForPayments: scanForPaymentsMock, + WSOL_MINT: new PublicKey('So11111111111111111111111111111111111111112'), + } +}) + +vi.mock('../../src/sentinel/config.js', () => ({ + getSentinelConfig: vi.fn(() => ({ + scanInterval: 60000, + activeScanInterval: 15000, + autoRefundThreshold: 1, + threatCheckEnabled: true, + largeTransferThreshold: 10, + maxRpcPerWallet: 5, + maxWalletsPerCycle: 20, + backoffMax: 600_000, + })), +})) + +const { scanWallet } = await import('../../src/sentinel/scanner.js') + +// ─── Shared fixtures ──────────────────────────────────────────────────────── + +import { PublicKey } from '@solana/web3.js' + +const WSOL = new PublicKey('So11111111111111111111111111111111111111112') +const TEST_WALLET = '11111111111111111111111111111111' +const DEPOSITOR = new PublicKey(TEST_WALLET) + +function makeVaultBalance(lamports = 5_000_000_000n) { + return { + depositor: DEPOSITOR, + tokenMint: WSOL, + balance: lamports, + lockedAmount: 0n, + available: lamports, + cumulativeVolume: 10_000_000_000n, + lastDepositAt: 1712600000, + exists: true, + } +} + +const cleanScanResult = { + payments: [], + eventsScanned: 0, + hasMore: false, +} + +// ─── Tests ───────────────────────────────────────────────────────────────── + +describe('scanWallet', () => { + beforeEach(() => { + vi.clearAllMocks() + getVaultBalanceMock.mockResolvedValue(makeVaultBalance()) + scanForPaymentsMock.mockResolvedValue(cleanScanResult) + }) + + it('returns ScanResult with vaultBalance converted from lamports to SOL', async () => { + const result = await scanWallet(TEST_WALLET) + + expect(result.wallet).toBe(TEST_WALLET) + expect(result.vaultBalance).toBeCloseTo(5.0, 6) + expect(result.detections).toBeInstanceOf(Array) + expect(result.rpcCalls).toBeGreaterThan(0) + expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/) + }) + + it('detects balance change when previousBalance differs from current', async () => { + const result = await scanWallet(TEST_WALLET, { previousBalance: 3.0 }) + + const balanceDetection = result.detections.find( + (d) => d.event === 'sentinel:balance' + ) + expect(balanceDetection).toBeDefined() + expect(balanceDetection?.data.previousBalance).toBe(3.0) + expect(balanceDetection?.data.currentBalance).toBeCloseTo(5.0, 6) + expect(balanceDetection?.data.delta).toBeCloseTo(2.0, 6) + }) + + it('does not emit balance detection when previousBalance matches current', async () => { + const result = await scanWallet(TEST_WALLET, { previousBalance: 5.0 }) + + const balanceDetection = result.detections.find( + (d) => d.event === 'sentinel:balance' + ) + expect(balanceDetection).toBeUndefined() + }) + + it('returns empty detections on clean scan with no previousBalance', async () => { + const result = await scanWallet(TEST_WALLET) + + expect(result.detections).toHaveLength(0) + }) + + it('emits unclaimed detection for each stealth payment found', async () => { + const ephKey = new Uint8Array(33).fill(2) + + scanForPaymentsMock.mockResolvedValue({ + payments: [ + { + stealthAddress: DEPOSITOR, + amountCommitment: new Uint8Array(33), + ephemeralPubkey: ephKey, + viewingKeyHash: new Uint8Array(32), + transferAmount: 1_000_000_000n, + feeAmount: 1_000n, + timestamp: 1712600000, + txSignature: 'abc123', + }, + ], + eventsScanned: 1, + hasMore: false, + }) + + // Viewing + spending keys are required to trigger stealth payment scanning + const result = await scanWallet(TEST_WALLET, { + viewingPrivateKey: new Uint8Array(32).fill(1), + spendingPrivateKey: new Uint8Array(32).fill(2), + }) + + const unclaimedDetection = result.detections.find( + (d) => d.event === 'sentinel:unclaimed' + ) + expect(unclaimedDetection).toBeDefined() + expect(unclaimedDetection?.level).toBe('important') + expect(unclaimedDetection?.data.amount).toBeCloseTo(1.0, 6) + }) + + it('respects maxRpcCalls limit — stops early when budget exhausted', async () => { + const result = await scanWallet(TEST_WALLET, { maxRpcCalls: 1 }) + + expect(result.rpcCalls).toBeLessThanOrEqual(1) + }) + + it('does not crash when getVaultBalance throws — returns zero balance', async () => { + getVaultBalanceMock.mockRejectedValue(new Error('RPC timeout')) + + const result = await scanWallet(TEST_WALLET) + + expect(result.vaultBalance).toBe(0) + expect(result.wallet).toBe(TEST_WALLET) + expect(result.detections).toBeInstanceOf(Array) + }) + + it('does not crash when scanForPayments throws — returns partial result', async () => { + scanForPaymentsMock.mockRejectedValue(new Error('RPC error')) + + const result = await scanWallet(TEST_WALLET) + + expect(result.vaultBalance).toBeCloseTo(5.0, 6) + expect(result.detections).toBeInstanceOf(Array) + }) +}) From b56f4d16f3aa0b17349b4dfb99575a6e139dd5b1 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 14:19:15 +0700 Subject: [PATCH 55/92] =?UTF-8?q?feat:=20add=20SENTINEL=20worker=20?= =?UTF-8?q?=E2=80=94=20adaptive=20scan=20loop,=20wallet=20tracking,=20back?= =?UTF-8?q?off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent/src/sentinel/sentinel.ts | 197 ++++++++++++++++++ .../agent/tests/sentinel/sentinel.test.ts | 97 +++++++++ 2 files changed, 294 insertions(+) create mode 100644 packages/agent/src/sentinel/sentinel.ts create mode 100644 packages/agent/tests/sentinel/sentinel.test.ts diff --git a/packages/agent/src/sentinel/sentinel.ts b/packages/agent/src/sentinel/sentinel.ts new file mode 100644 index 0000000..bcef34e --- /dev/null +++ b/packages/agent/src/sentinel/sentinel.ts @@ -0,0 +1,197 @@ +import { getSentinelConfig, type SentinelConfig } from './config.js' +import { scanWallet } from './scanner.js' +import { toGuardianEvent } from './detector.js' +import { guardianBus, type GuardianEvent } from '../coordination/event-bus.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Identity +// ───────────────────────────────────────────────────────────────────────────── + +export const SENTINEL_IDENTITY = { + name: 'SENTINEL', + role: 'Blockchain Monitor', + llm: false, +} as const + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export type SentinelState = 'running' | 'stopped' + +export interface SentinelStatus { + state: SentinelState + walletsMonitored: number + lastScanAt: string | null + totalScans: number + currentInterval: number +} + +// ───────────────────────────────────────────────────────────────────────────── +// SentinelWorker +// ───────────────────────────────────────────────────────────────────────────── + +export class SentinelWorker { + private wallets = new Set() + private balanceCache = new Map() + private running = false + private timer: NodeJS.Timeout | null = null + private config: SentinelConfig + private currentInterval: number + private backoffMultiplier = 1 + private lastScanAt: string | null = null + private totalScans = 0 + + constructor() { + this.config = getSentinelConfig() + this.currentInterval = this.config.scanInterval + } + + addWallet(wallet: string): void { + this.wallets.add(wallet) + } + + removeWallet(wallet: string): void { + this.wallets.delete(wallet) + this.balanceCache.delete(wallet) + } + + getWallets(): string[] { + return Array.from(this.wallets) + } + + isRunning(): boolean { + return this.running + } + + getStatus(): SentinelStatus { + return { + state: this.running ? 'running' : 'stopped', + walletsMonitored: this.wallets.size, + lastScanAt: this.lastScanAt, + totalScans: this.totalScans, + currentInterval: this.currentInterval, + } + } + + start(): void { + if (this.running) return + this.running = true + this.scheduleTick() + } + + stop(): void { + this.running = false + if (this.timer !== null) { + clearTimeout(this.timer) + this.timer = null + } + } + + private scheduleTick(): void { + this.timer = setTimeout(() => { + this.tick().catch(() => { + // tick handles its own errors — this is a safety net + }) + }, this.currentInterval) + // Don't hold the Node.js event loop open if nothing else is running + this.timer.unref() + } + + private async tick(): Promise { + if (!this.running) return + + const allWallets = this.getWallets() + + if (allWallets.length === 0) { + // Nothing to do — use idle interval and reschedule + this.currentInterval = this.config.scanInterval + this.scheduleTick() + return + } + + // Slice to per-cycle limit + const batch = allWallets.slice(0, this.config.maxWalletsPerCycle) + + // Active scan — use the faster interval + this.currentInterval = this.config.activeScanInterval + this.totalScans++ + + let rpcError: Error | null = null + + for (const wallet of batch) { + try { + const previousBalance = this.balanceCache.get(wallet) + + const result = await scanWallet(wallet, { + previousBalance, + maxRpcCalls: this.config.maxRpcPerWallet, + }) + + // Update cached balance + this.balanceCache.set(wallet, result.vaultBalance) + + // Reset backoff on success + this.backoffMultiplier = 1 + this.currentInterval = this.config.activeScanInterval + + // Emit a GuardianEvent for each detection + for (const detection of result.detections) { + const partial = toGuardianEvent(detection) + const event: GuardianEvent = { + ...partial, + timestamp: new Date().toISOString(), + } + guardianBus.emit(event) + } + } catch (err) { + rpcError = err instanceof Error ? err : new Error(String(err)) + } + } + + // Record scan completion timestamp + this.lastScanAt = new Date().toISOString() + + if (rpcError !== null) { + // Exponential backoff — double the current interval, cap at backoffMax + this.backoffMultiplier = Math.min( + this.backoffMultiplier * 2, + this.config.backoffMax / this.config.activeScanInterval + ) + this.currentInterval = Math.min( + this.config.activeScanInterval * this.backoffMultiplier, + this.config.backoffMax + ) + + guardianBus.emit({ + source: 'sentinel', + type: 'sentinel:rpc-error', + level: 'important', + data: { + message: rpcError.message, + nextIntervalMs: this.currentInterval, + }, + wallet: null, + timestamp: new Date().toISOString(), + }) + } else { + // Routine scan-complete event + guardianBus.emit({ + source: 'sentinel', + type: 'sentinel:scan-complete', + level: 'routine', + data: { + walletsScanned: batch.length, + totalScans: this.totalScans, + }, + wallet: null, + timestamp: new Date().toISOString(), + }) + } + + // Reschedule for next cycle + if (this.running) { + this.scheduleTick() + } + } +} diff --git a/packages/agent/tests/sentinel/sentinel.test.ts b/packages/agent/tests/sentinel/sentinel.test.ts new file mode 100644 index 0000000..8621c83 --- /dev/null +++ b/packages/agent/tests/sentinel/sentinel.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// ─── Mock scanner (no real RPC) ────────────────────────────────────────────── + +vi.mock('../../src/sentinel/scanner.js', () => ({ + scanWallet: vi.fn().mockResolvedValue({ + wallet: 'wallet-1', + vaultBalance: 5, + detections: [], + rpcCalls: 2, + timestamp: new Date().toISOString(), + }), +})) + +// ─── Lazy imports after mock registration ─────────────────────────────────── + +const { SENTINEL_IDENTITY, SentinelWorker } = await import( + '../../src/sentinel/sentinel.js' +) + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('SENTINEL_IDENTITY', () => { + it('has correct identity fields', () => { + expect(SENTINEL_IDENTITY.name).toBe('SENTINEL') + expect(SENTINEL_IDENTITY.role).toBe('Blockchain Monitor') + expect(SENTINEL_IDENTITY.llm).toBe(false) + }) +}) + +describe('SentinelWorker', () => { + let sentinel: InstanceType + + beforeEach(() => { + sentinel = new SentinelWorker() + }) + + afterEach(() => { + sentinel.stop() + }) + + it('starts in stopped state', () => { + expect(sentinel.isRunning()).toBe(false) + expect(sentinel.getStatus().state).toBe('stopped') + }) + + it('addWallet / removeWallet / getWallets work correctly', () => { + sentinel.addWallet('wallet-1') + sentinel.addWallet('wallet-2') + expect(sentinel.getWallets()).toContain('wallet-1') + expect(sentinel.getWallets()).toContain('wallet-2') + expect(sentinel.getWallets()).toHaveLength(2) + + sentinel.removeWallet('wallet-1') + expect(sentinel.getWallets()).not.toContain('wallet-1') + expect(sentinel.getWallets()).toHaveLength(1) + }) + + it('getStatus returns all expected fields', () => { + const status = sentinel.getStatus() + expect(status).toHaveProperty('state') + expect(status).toHaveProperty('walletsMonitored') + expect(status).toHaveProperty('lastScanAt') + expect(status).toHaveProperty('totalScans') + expect(status).toHaveProperty('currentInterval') + expect(status.lastScanAt).toBeNull() + expect(status.totalScans).toBe(0) + }) + + it('start / stop lifecycle transitions state correctly', () => { + expect(sentinel.isRunning()).toBe(false) + + sentinel.start() + expect(sentinel.isRunning()).toBe(true) + expect(sentinel.getStatus().state).toBe('running') + + sentinel.stop() + expect(sentinel.isRunning()).toBe(false) + expect(sentinel.getStatus().state).toBe('stopped') + }) + + it('getStatus walletsMonitored reflects currently tracked wallets', () => { + expect(sentinel.getStatus().walletsMonitored).toBe(0) + + sentinel.addWallet('wallet-a') + expect(sentinel.getStatus().walletsMonitored).toBe(1) + + sentinel.addWallet('wallet-b') + sentinel.addWallet('wallet-c') + expect(sentinel.getStatus().walletsMonitored).toBe(3) + + sentinel.removeWallet('wallet-b') + expect(sentinel.getStatus().walletsMonitored).toBe(2) + }) +}) From fcbe504a7e455839c5588950cfd3b66ad0e751f8 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 14:24:40 +0700 Subject: [PATCH 56/92] =?UTF-8?q?feat:=20wire=20SENTINEL=20into=20Express?= =?UTF-8?q?=20=E2=80=94=20worker=20started,=20integration=20tested?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import SentinelWorker in index.ts, start on boot (no LLM, pure event emitter) - Add packages/agent/tests/integration/sentinel.test.ts — 6 integration tests covering identity, config+guard, detector→EventBus flow, lifecycle, idempotency, detection branches - Mock @sipher/sdk to match exact scanner.ts imports (createConnection, getVaultBalance, scanForPayments, WSOL_MINT) - Full suite: 554 passing, 27 pre-existing failures unchanged --- packages/agent/src/index.ts | 6 ++ .../agent/tests/integration/sentinel.test.ts | 73 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 packages/agent/tests/integration/sentinel.test.ts diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index e6e393e..1158125 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -17,6 +17,7 @@ import { heraldRouter } from './routes/herald-api.js' import { guardianBus } from './coordination/event-bus.js' import { attachLogger } from './coordination/activity-logger.js' import { AgentPool } from './agents/pool.js' +import { SentinelWorker } from './sentinel/sentinel.js' // ───────────────────────────────────────────────────────────────────────────── // Database & session initialization @@ -43,6 +44,11 @@ setInterval(() => { startCrank((action, params) => executeTool(action, params)) console.log(' Crank: 60s interval (scheduled ops)') +// Initialize and start SENTINEL (blockchain monitor — no LLM, pure event emitter) +const sentinel = new SentinelWorker() +sentinel.start() +console.log(' SENTINEL: started (blockchain monitor, no wallets yet)') + // Purge stale in-memory conversations every 5 minutes setInterval(() => { const purged = purgeStale() diff --git a/packages/agent/tests/integration/sentinel.test.ts b/packages/agent/tests/integration/sentinel.test.ts new file mode 100644 index 0000000..0b0e531 --- /dev/null +++ b/packages/agent/tests/integration/sentinel.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from 'vitest' + +// Mock @sipher/sdk — matches exactly what scanner.ts imports: +// createConnection, getVaultBalance, scanForPayments, WSOL_MINT +vi.mock('@sipher/sdk', () => ({ + createConnection: vi.fn(() => ({})), + getVaultBalance: vi.fn().mockResolvedValue({ + balance: BigInt(5_000_000_000), + available: BigInt(5_000_000_000), + exists: true, + }), + scanForPayments: vi.fn().mockResolvedValue({ + payments: [], + eventsScanned: 0, + hasMore: false, + }), + WSOL_MINT: { toBase58: () => 'So11111111111111111111111111111111111111112' }, +})) + +import { EventBus } from '../../src/coordination/event-bus.js' +import { SentinelWorker, SENTINEL_IDENTITY } from '../../src/sentinel/sentinel.js' +import { getSentinelConfig } from '../../src/sentinel/config.js' +import { shouldAutoRefund, isRefundSafe, generateIdempotencyKey } from '../../src/sentinel/refund-guard.js' +import { detectExpiredDeposit, detectThreat, toGuardianEvent } from '../../src/sentinel/detector.js' + +describe('SENTINEL Integration', () => { + it('SENTINEL_IDENTITY is correct', () => { + expect(SENTINEL_IDENTITY.name).toBe('SENTINEL') + expect(SENTINEL_IDENTITY.role).toBe('Blockchain Monitor') + expect(SENTINEL_IDENTITY.llm).toBe(false) + }) + + it('config + refund-guard work together', () => { + const config = getSentinelConfig() + expect(shouldAutoRefund(0.5, config.autoRefundThreshold)).toBe(true) + expect(shouldAutoRefund(5, config.autoRefundThreshold)).toBe(false) + }) + + it('detector → toGuardianEvent → EventBus flow', () => { + const bus = new EventBus() + const received: any[] = [] + bus.on('sentinel:threat', (e) => received.push(e)) + const detection = detectThreat({ address: '8xAb', reason: 'OFAC', wallet: 'w1' }) + const event = toGuardianEvent(detection) + bus.emit({ ...event, timestamp: new Date().toISOString() }) + expect(received).toHaveLength(1) + expect(received[0].level).toBe('critical') + }) + + it('worker lifecycle', () => { + const worker = new SentinelWorker() + worker.addWallet('wallet-1') + expect(worker.getWallets()).toEqual(['wallet-1']) + worker.start() + expect(worker.isRunning()).toBe(true) + worker.stop() + expect(worker.isRunning()).toBe(false) + }) + + it('refund guard idempotency', () => { + const key1 = generateIdempotencyKey('pda-1', 1700000000) + const key2 = generateIdempotencyKey('pda-1', 1700000000) + expect(key1).toBe(key2) + expect(isRefundSafe('pda-1', [])).toBe(true) + }) + + it('expired deposit detection branches', () => { + const small = detectExpiredDeposit({ depositPda: 'p1', amount: 0.3, wallet: 'w1', threshold: 1 }) + expect(small.data.autoRefund).toBe(true) + const large = detectExpiredDeposit({ depositPda: 'p2', amount: 5, wallet: 'w1', threshold: 1 }) + expect(large.data.autoRefund).toBe(false) + }) +}) From 6fab2d5f9e56123d7441d8b6752e17586d35bc13 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 15:16:50 +0700 Subject: [PATCH 57/92] =?UTF-8?q?design:=20add=20Guardian=20Command=20UI?= =?UTF-8?q?=20mockups=20=E2=80=94=20stream,=20vault,=20herald,=20squad=20v?= =?UTF-8?q?iews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/designs/01-stream-view.html | 179 ++++++++++++++++++++++++++++++++ app/designs/02-vault-view.html | 176 +++++++++++++++++++++++++++++++ app/designs/03-herald-view.html | 178 +++++++++++++++++++++++++++++++ app/designs/04-squad-view.html | 149 ++++++++++++++++++++++++++ 4 files changed, 682 insertions(+) create mode 100644 app/designs/01-stream-view.html create mode 100644 app/designs/02-vault-view.html create mode 100644 app/designs/03-herald-view.html create mode 100644 app/designs/04-squad-view.html diff --git a/app/designs/01-stream-view.html b/app/designs/01-stream-view.html new file mode 100644 index 0000000..277ea38 --- /dev/null +++ b/app/designs/01-stream-view.html @@ -0,0 +1,179 @@ + + + + + + + Guardian Command | Sipher + + + + + + + + + +
+
+
+ + Sipher +
+ +
+
+
+
+
+
+ Sipher +
+ 2m ago +
+

Deposited 2 SOL into vault.

+
+ TX: + 4Hc3...5s5c +
+
+ +
+
+
+
+
+
+ Herald +
+ 5m ago +
+

Posted: "Your wallet is a diary. SIP makes it private."

+
12 likes · 3 RTs
+
+ +
+
+
+
+
+
+ Sentinel +
+ 12m ago +
+

⚠ Unclaimed stealth payment: 1.5 SOL

+
+ Ephemeral key: + 0x04def...789 +
+
+ +
+
+
+
+
+
+ Sipher +
+ 15m ago +
+
+

Privacy score: 87/100

+ 12% traceable +
+
+
+
+
+
+
+
+
+ Courier +
+ 20m ago +
+

Executed scheduled send: 0.5 SOL stealth

+
+ TX: + m5oJ...qVwv +
+
+
+
+
+
+ Herald +
+ 30m ago +
+

Replied to @privacy_maxi on X

+
+
+

"Stealth addresses solve this completely. By generating ephemeral keys per transaction, the link between sender and receiver is severed on-chain."

+
+
+
+
+
+
+
+
+ + Talk to SIPHER... +
+
⌘K
+
+
+ +
+
+ + diff --git a/app/designs/02-vault-view.html b/app/designs/02-vault-view.html new file mode 100644 index 0000000..41aaa1c --- /dev/null +++ b/app/designs/02-vault-view.html @@ -0,0 +1,176 @@ + + + + + + + Guardian Command - Vault + + + + + + + + + +
+ +
+
+ + Sipher +
+ +
+ + +
+ + +
+

Vault Balance

+
+ 12.45 SOL + ≈ $2,614.50 +
+
+ + +
+
+ + +
+

Pending Operations

+
+
+
+ Drip: 0.1 SOL/day → stealth +
+ Next: 6h +
+
+
+
+ Recurring: 1 SOL weekly → 7xKz... +
+ Next: 3d +
+
+ + +
+

Recent Activity

+
+
+
+ + Deposit + 2.0 SOL +
+
+ 2h ago + Confirmed +
+
+
+
+ + Withdraw + 0.5 SOL +
+
+ 1d ago + Stealth +
+
+
+
+ + Deposit + 10.0 SOL +
+
+ 3d ago + Confirmed +
+
+
+
+ + Refund + 1.0 SOL +
+
+ 5d ago + Auto-refund +
+
+
+
+ + +
+

Fees collected: 0.062 SOL (10 bps)

+
+
+ + +
+
+
+
+ + Talk to SIPHER... +
+
⌘K
+
+
+ +
+
+ + diff --git a/app/designs/03-herald-view.html b/app/designs/03-herald-view.html new file mode 100644 index 0000000..cf84ecc --- /dev/null +++ b/app/designs/03-herald-view.html @@ -0,0 +1,178 @@ + + + + + + + + + HERALD | Guardian Command + + + + + + + + + +
+ +
+
Sipher
+ +
+ + +
+
+ X API Budget +
$47.20 / $150
+
+
+
+ + +
+ + + +
+ + +
+ + +
+
+ + +
+
+
+
Posted30m ago
+
+

"Your wallet is a diary. SIP makes it private."

+
+
+ 12 3 2 +
+ +
+
+
+
+ + +
+
+
+
Replied to@privacy_maxi45m ago
+
+

"Stealth addresses solve this completely..."

+
+
+
+
+ + +
+
+
+
Liked1h ago
+

Liked @anon_dev's post about zero-knowledge privacy

+
+
+ + +
+
+
+
DM Handled2h ago
+
+ @dev_0x: "how do I integrate?" +
Sent docs link
+
+
+
+
+ + +
+

Pending Posts (2)

+
+
+

"How Pedersen commitments hide amounts — a thread"

+
Tomorrow 9:00 AM UTC
+
+ + + +
+
+
+

"SIP vault now live on mainnet. Deposit, go private."

+
Today 6:00 PM UTC
+
+ + + +
+
+
+
+ + +
+

Recent DMs

+
+
+
@dev_0xResolved
+

"how do I integrate?"

+
Sent docs link
+
+
+
@anon_whaleResolved
+

"privacy score?"

+
Score: 72/100
+
+
+
@new_userActioned
+

"send 1 SOL"

+
Execution link sent
+
+
+
+
+ + +
+
+
+
Talk to SIPHER...
+
⌘K
+
+
+ +
+
+ + diff --git a/app/designs/04-squad-view.html b/app/designs/04-squad-view.html new file mode 100644 index 0000000..0cd81e9 --- /dev/null +++ b/app/designs/04-squad-view.html @@ -0,0 +1,149 @@ + + + + + + + + + Squad | Guardian Command + + + + + + + + + +
+ +
+
Sipher
+ +
+ + +
+ + +
+
+
+
SIPHER
+ $2.14 +
+
Active · 3 sessions
+
+
+
+
HERALD
+ $1.87 +
+
Polling · next: 8m
+
+
+
+
SENTINEL
+ +
+
Scanning · next: 45s
+
+
+
+
COURIER
+ +
+
Idle · next op: 6h
+
+
+ + +
+

Today's Stats

+
+
47
Tool calls
+
3
Wallet sessions
+
2
X posts
+
8
X replies
+
2,841
Blocks scanned
+
1
Alerts
+
+
+ + +
+

Coordination (last 24h)

+
+
+
14:32
+
+
+
SENTINEL + +
SIPHER +
+
Unclaimed payment detected, notifying user
+
+
+
+
14:30
+
+
+
HERALD + +
SIPHER +
+
Running privacyScore for @dev_0x DM request
+
+
+
+
11:00
+
+
+
SENTINEL + +
COURIER +
+
Deposit #42 expired, triggering auto-refund
+
+
+
+
+ + + +
+ + +
+
+
+
Talk to SIPHER...
+
⌘K
+
+
+ +
+
+ + From 5cfd52028b333ac771ae2f315245e1ea58978a17 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 15:19:43 +0700 Subject: [PATCH 58/92] feat: install Tailwind CSS 4 + design system tokens + agent constants --- app/index.html | 3 + app/package.json | 14 +- app/src/lib/agents.ts | 8 + app/src/lib/format.ts | 17 ++ app/src/styles/theme.css | 614 ++------------------------------------- app/vite.config.ts | 4 +- pnpm-lock.yaml | 466 +++++++++++++++++++++++------ 7 files changed, 440 insertions(+), 686 deletions(-) create mode 100644 app/src/lib/agents.ts create mode 100644 app/src/lib/format.ts diff --git a/app/index.html b/app/index.html index 6a4e3ab..cdeb4d7 100644 --- a/app/index.html +++ b/app/index.html @@ -4,6 +4,9 @@ Sipher — Privacy Agent + + +
diff --git a/app/package.json b/app/package.json index 7e7d2aa..87b673a 100644 --- a/app/package.json +++ b/app/package.json @@ -9,18 +9,20 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0", "@solana/wallet-adapter-react": "^0.15.0", "@solana/wallet-adapter-react-ui": "^0.9.0", "@solana/wallet-adapter-wallets": "^0.19.0", - "@solana/web3.js": "^1.98.0" + "@solana/web3.js": "^1.98.0", + "@tailwindcss/vite": "^4.2.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwindcss": "^4.2.2" }, "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", - "vite": "^6.0.0", "typescript": "^5.7.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0" + "vite": "^6.0.0" } } diff --git a/app/src/lib/agents.ts b/app/src/lib/agents.ts new file mode 100644 index 0000000..132f09b --- /dev/null +++ b/app/src/lib/agents.ts @@ -0,0 +1,8 @@ +export const AGENTS = { + sipher: { name: 'SIPHER', color: '#10B981', role: 'Lead Agent' }, + herald: { name: 'HERALD', color: '#3B82F6', role: 'X Agent' }, + sentinel: { name: 'SENTINEL', color: '#F59E0B', role: 'Monitor' }, + courier: { name: 'COURIER', color: '#8B5CF6', role: 'Executor' }, +} as const + +export type AgentName = keyof typeof AGENTS diff --git a/app/src/lib/format.ts b/app/src/lib/format.ts new file mode 100644 index 0000000..19b2e64 --- /dev/null +++ b/app/src/lib/format.ts @@ -0,0 +1,17 @@ +export function timeAgo(iso: string): string { + const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000) + if (seconds < 60) return 'just now' + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago` + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago` + return `${Math.floor(seconds / 86400)}d ago` +} + +export function truncateAddress(address: string, chars = 4): string { + if (address.length <= chars * 2 + 3) return address + return `${address.slice(0, chars)}...${address.slice(-chars)}` +} + +export function formatSOL(lamports: number | string): string { + const sol = typeof lamports === 'string' ? parseFloat(lamports) : lamports + return sol.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 }) +} diff --git a/app/src/styles/theme.css b/app/src/styles/theme.css index 6771254..41ccd94 100644 --- a/app/src/styles/theme.css +++ b/app/src/styles/theme.css @@ -1,602 +1,28 @@ -:root { - --bg: #0a0a0f; - --surface: #12121a; - --surface-2: #1a1a26; - --border: #2a2a3a; - --text: #e4e4ec; - --text-muted: #8888a0; - --accent: #7c5cfc; - --accent-hover: #6a48e6; - --green: #22c55e; - --green-hover: #16a34a; - --red: #ef4444; - --red-hover: #dc2626; - --yellow: #eab308; - --cyan: #06b6d4; - --radius: 8px; - --radius-lg: 12px; - --transition: 150ms ease; -} - -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} +@import 'tailwindcss'; -html, body, #root { - height: 100%; - width: 100%; +@theme { + --color-bg: #0A0A0B; + --color-card: #141416; + --color-border: #1E1E22; + --color-text: #F5F5F5; + --color-text-secondary: #71717A; + --color-sipher: #10B981; + --color-herald: #3B82F6; + --color-sentinel: #F59E0B; + --color-courier: #8B5CF6; + --radius-lg: 8px; } body { - font-family: 'Inter', system-ui, -apple-system, sans-serif; - background: var(--bg); - color: var(--text); - line-height: 1.5; + background-color: var(--color-bg); + color: var(--color-text); + font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif; + margin: 0; + min-height: 100dvh; -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* Scrollbar */ -::-webkit-scrollbar { - width: 6px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 3px; -} - -::-webkit-scrollbar-thumb:hover { - background: var(--text-muted); -} - -/* Layout */ -.app-layout { - display: flex; - flex-direction: column; - height: 100%; - max-width: 800px; - margin: 0 auto; -} - -@media (max-width: 840px) { - .app-layout { - max-width: 100%; - } -} - -/* Wallet Bar */ -.wallet-bar { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - background: var(--surface); - border-bottom: 1px solid var(--border); - gap: 12px; - flex-shrink: 0; -} - -.wallet-bar__brand { - display: flex; - align-items: center; - gap: 8px; - font-weight: 700; - font-size: 16px; - letter-spacing: 0.5px; - color: var(--accent); - white-space: nowrap; -} - -.wallet-bar__brand span { - color: var(--text-muted); - font-weight: 400; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 1px; -} - -.wallet-bar__info { - display: flex; - align-items: center; - gap: 12px; - font-size: 13px; - flex-shrink: 0; -} - -.wallet-bar__network { - padding: 2px 8px; - border-radius: 4px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.wallet-bar__network--devnet { - background: rgba(234, 179, 8, 0.15); - color: var(--yellow); -} - -.wallet-bar__network--mainnet { - background: rgba(34, 197, 94, 0.15); - color: var(--green); -} - -.wallet-bar__balance { - color: var(--text-muted); - font-variant-numeric: tabular-nums; -} - -.wallet-bar__balance strong { - color: var(--text); - font-weight: 600; -} - -/* Override wallet adapter button styles */ -.wallet-bar .wallet-adapter-button { - height: 32px; - font-size: 13px; - padding: 0 12px; - border-radius: var(--radius); - background: var(--accent); -} - -.wallet-bar .wallet-adapter-button:hover { - background: var(--accent-hover); -} - -/* Chat Container */ -.chat-container { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; -} - -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 16px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.chat-messages--empty { - align-items: center; - justify-content: center; -} - -.chat-empty { - text-align: center; - color: var(--text-muted); -} - -.chat-empty__icon { - font-size: 48px; - margin-bottom: 12px; - opacity: 0.4; -} - -.chat-empty__title { - font-size: 18px; - font-weight: 600; - color: var(--text); - margin-bottom: 4px; -} - -.chat-empty__subtitle { - font-size: 14px; -} - -/* Input bar */ -.chat-input-bar { - display: flex; - align-items: flex-end; - gap: 8px; - padding: 12px 16px; - background: var(--surface); - border-top: 1px solid var(--border); - flex-shrink: 0; -} - -.chat-input-bar textarea { - flex: 1; - resize: none; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface-2); - color: var(--text); - padding: 10px 12px; - font-family: inherit; - font-size: 14px; - line-height: 1.4; - min-height: 40px; - max-height: 120px; - outline: none; - transition: border-color var(--transition); -} - -.chat-input-bar textarea::placeholder { - color: var(--text-muted); -} - -.chat-input-bar textarea:focus { - border-color: var(--accent); -} - -.chat-input-bar__send { - display: flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - border: none; - border-radius: var(--radius); - background: var(--accent); - color: white; - cursor: pointer; - flex-shrink: 0; - transition: background var(--transition); - font-size: 18px; -} - -.chat-input-bar__send:hover:not(:disabled) { - background: var(--accent-hover); -} - -.chat-input-bar__send:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -/* Text Messages */ -.message { - display: flex; - max-width: 85%; - animation: message-in 200ms ease; -} - -@keyframes message-in { - from { - opacity: 0; - transform: translateY(8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.message--user { - align-self: flex-end; -} - -.message--agent { - align-self: flex-start; -} - -.message__bubble { - padding: 10px 14px; - border-radius: var(--radius-lg); - font-size: 14px; - line-height: 1.5; - word-break: break-word; -} - -.message--user .message__bubble { - background: var(--accent); - color: white; - border-bottom-right-radius: 4px; -} - -.message--agent .message__bubble { - background: var(--surface-2); - color: var(--text); - border-bottom-left-radius: 4px; - border: 1px solid var(--border); -} - -.message--error .message__bubble { - background: rgba(239, 68, 68, 0.1); - border-color: rgba(239, 68, 68, 0.3); - color: var(--red); -} - -/* Markdown-ish in messages */ -.message__bubble strong { - font-weight: 600; + overscroll-behavior-y: none; } -.message__bubble code { - font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; - font-size: 12.5px; - background: rgba(0, 0, 0, 0.3); - padding: 1px 5px; - border-radius: 3px; -} - -.message--user .message__bubble code { - background: rgba(255, 255, 255, 0.15); -} - -.message__bubble pre { - margin: 8px 0; - padding: 10px; - background: rgba(0, 0, 0, 0.3); - border-radius: var(--radius); - overflow-x: auto; - font-size: 12.5px; -} - -.message--user .message__bubble pre { - background: rgba(255, 255, 255, 0.1); -} - -.message__bubble pre code { - background: none; - padding: 0; -} - -.message__timestamp { - font-size: 11px; - color: var(--text-muted); - margin-top: 4px; - padding: 0 4px; -} - -/* Confirmation Prompt */ -.confirmation { - align-self: flex-start; - max-width: 85%; - animation: message-in 200ms ease; -} - -.confirmation__card { - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - overflow: hidden; -} - -.confirmation__header { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - background: rgba(124, 92, 252, 0.08); - border-bottom: 1px solid var(--border); - font-size: 13px; - font-weight: 600; - color: var(--accent); -} - -.confirmation__body { - padding: 12px 14px; - display: flex; - flex-direction: column; - gap: 8px; -} - -.confirmation__row { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 13px; -} - -.confirmation__label { - color: var(--text-muted); -} - -.confirmation__value { - font-weight: 500; - font-variant-numeric: tabular-nums; -} - -.confirmation__value--amount { - color: var(--cyan); - font-weight: 600; -} - -.confirmation__value--fee { - color: var(--yellow); -} - -.confirmation__value--address { - font-family: 'SF Mono', 'Fira Code', monospace; - font-size: 12px; -} - -.confirmation__actions { - display: flex; - gap: 8px; - padding: 12px 14px; - border-top: 1px solid var(--border); -} - -.confirmation__btn { - flex: 1; - padding: 8px 16px; - border: none; - border-radius: var(--radius); - font-family: inherit; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: background var(--transition); -} - -.confirmation__btn--confirm { - background: var(--green); - color: white; -} - -.confirmation__btn--confirm:hover:not(:disabled) { - background: var(--green-hover); -} - -.confirmation__btn--cancel { - background: rgba(239, 68, 68, 0.15); - color: var(--red); -} - -.confirmation__btn--cancel:hover:not(:disabled) { - background: rgba(239, 68, 68, 0.25); -} - -.confirmation__btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.confirmation__status { - padding: 8px 14px; - font-size: 12px; - text-align: center; - border-top: 1px solid var(--border); -} - -.confirmation__status--confirmed { - color: var(--green); -} - -.confirmation__status--cancelled { - color: var(--text-muted); -} - -.confirmation__status--error { - color: var(--red); - word-break: break-word; -} - -.confirmation__footer { - border-top: 1px solid var(--border); -} - -.confirmation__sig-link { - color: var(--cyan); - text-decoration: none; - font-family: 'SF Mono', 'Fira Code', monospace; - font-size: 12px; -} - -.confirmation__sig-link:hover { - text-decoration: underline; -} - -/* Quick Actions */ -.quick-actions { - display: flex; - gap: 6px; - padding: 8px 16px; - background: var(--surface); - overflow-x: auto; - flex-shrink: 0; - -webkit-overflow-scrolling: touch; -} - -.quick-actions::-webkit-scrollbar { - display: none; -} - -.quick-actions__btn { - display: flex; - align-items: center; - gap: 4px; - padding: 6px 12px; - border: 1px solid var(--border); - border-radius: 20px; - background: transparent; - color: var(--text-muted); - font-family: inherit; - font-size: 12px; - font-weight: 500; - white-space: nowrap; - cursor: pointer; - transition: all var(--transition); -} - -.quick-actions__btn:hover { - border-color: var(--accent); - color: var(--accent); - background: rgba(124, 92, 252, 0.08); -} - -/* Typing indicator */ -.typing-indicator { - align-self: flex-start; - display: flex; - gap: 4px; - padding: 12px 16px; - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - border-bottom-left-radius: 4px; -} - -.typing-indicator__dot { - width: 6px; - height: 6px; - background: var(--text-muted); - border-radius: 50%; - animation: typing-bounce 1.2s ease-in-out infinite; -} - -.typing-indicator__dot:nth-child(2) { - animation-delay: 0.15s; -} - -.typing-indicator__dot:nth-child(3) { - animation-delay: 0.3s; -} - -@keyframes typing-bounce { - 0%, 60%, 100% { transform: translateY(0); } - 30% { transform: translateY(-4px); } -} - -/* Mobile responsive */ -@media (max-width: 480px) { - .wallet-bar { - padding: 10px 12px; - flex-wrap: wrap; - } - - .wallet-bar__info { - gap: 8px; - } - - .wallet-bar__balance { - display: none; - } - - .chat-messages { - padding: 12px; - } - - .message { - max-width: 90%; - } - - .confirmation { - max-width: 95%; - } - - .chat-input-bar { - padding: 10px 12px; - } - - .quick-actions { - padding: 6px 12px; - } +.font-mono { + font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace; } diff --git a/app/vite.config.ts b/app/vite.config.ts index abccf69..16a9163 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -1,9 +1,11 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' export default defineConfig({ - plugins: [react()], + plugins: [react(), tailwindcss()], server: { port: 5173, + proxy: { '/api': 'http://localhost:3000' }, }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78fc159..05a8e2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 1.8.0 '@sip-protocol/sdk': specifier: ^0.7.4 - version: 0.7.4(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod-to-json-schema@3.25.1(zod@3.25.76)) + version: 0.7.4(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod-to-json-schema@3.25.1(zod@3.25.76)) '@sip-protocol/types': specifier: ^0.2.2 version: 0.2.2 @@ -95,7 +95,7 @@ importers: version: 7.2.2 tsup: specifier: ^8.3.6 - version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) tsx: specifier: ^4.19.2 version: 4.21.0 @@ -104,7 +104,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 3.2.4(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) app: dependencies: @@ -120,12 +120,18 @@ importers: '@solana/web3.js': specifier: ^1.98.0 version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@tailwindcss/vite': + specifier: ^4.2.2 + version: 4.2.2(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) react: specifier: ^19.0.0 version: 19.2.4 react-dom: specifier: ^19.0.0 version: 19.2.4(react@19.2.4) + tailwindcss: + specifier: ^4.2.2 + version: 4.2.2 devDependencies: '@types/react': specifier: ^19.0.0 @@ -135,13 +141,13 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.3.0 - version: 4.7.0(vite@6.4.1(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) typescript: specifier: ^5.7.0 version: 5.9.3 vite: specifier: ^6.0.0 - version: 6.4.1(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) packages/agent: dependencies: @@ -208,7 +214,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 3.2.4(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) packages/sdk: dependencies: @@ -230,7 +236,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 3.2.4(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) packages: @@ -2891,6 +2897,100 @@ packages: '@swc/helpers@0.5.18': resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -4105,6 +4205,10 @@ packages: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + envalid@8.1.1: resolution: {integrity: sha512-vOUfHxAFFvkBjbVQbBfgnCO9d3GcNfMMTtVfgqSU2rQGMFEVqWy9GBuoSfHnwGu7EqR0/GeukQcL3KjFBaga9w==} engines: {node: '>=18'} @@ -4723,6 +4827,10 @@ packages: resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -4846,6 +4954,80 @@ packages: lighthouse-logger@1.4.2: resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -6054,6 +6236,13 @@ packages: peerDependencies: express: '>=4.0.0 || >=5.0.0-beta' + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -7761,14 +7950,14 @@ snapshots: - typescript - utf-8-validate - '@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))': + '@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))': dependencies: '@cfworker/json-schema': 4.1.1 ansi-styles: 5.2.0 camelcase: 6.3.0 decamelize: 1.2.0 js-tiktoken: 1.0.21 - langsmith: 0.3.87(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + langsmith: 0.3.87(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) mustache: 4.2.0 p-queue: 6.6.2 p-retry: 4.6.2 @@ -7801,26 +7990,26 @@ snapshots: - '@opentelemetry/sdk-trace-base' - openai - '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))': + '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))': dependencies: - '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/core': 0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) uuid: 10.0.0 - '@langchain/langgraph-sdk@1.5.5(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@langchain/langgraph-sdk@1.5.5(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: p-queue: 9.1.0 p-retry: 7.1.1 uuid: 13.0.0 optionalDependencies: - '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/core': 0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@langchain/langgraph@1.1.3(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76)': + '@langchain/langgraph@1.1.3(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76)': dependencies: - '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) - '@langchain/langgraph-sdk': 1.5.5(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@langchain/core': 0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) + '@langchain/langgraph-sdk': 1.5.5(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@standard-schema/spec': 1.1.0 uuid: 10.0.0 zod: 3.25.76 @@ -7830,11 +8019,11 @@ snapshots: - react - react-dom - '@langchain/langgraph@1.1.3(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@3.25.76)': + '@langchain/langgraph@1.1.3(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@3.25.76)': dependencies: - '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) - '@langchain/langgraph-sdk': 1.5.5(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@langchain/core': 0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) + '@langchain/langgraph-sdk': 1.5.5(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@standard-schema/spec': 1.1.0 uuid: 10.0.0 zod: 3.25.76 @@ -7844,20 +8033,9 @@ snapshots: - react - react-dom - '@langchain/openai@0.4.9(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@langchain/openai@0.4.9(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: - '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) - js-tiktoken: 1.0.21 - openai: 4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76) - zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) - transitivePeerDependencies: - - encoding - - ws - - '@langchain/openai@0.4.9(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': - dependencies: - '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/core': 0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) js-tiktoken: 1.0.21 openai: 4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76) zod: 3.25.76 @@ -8562,13 +8740,13 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@sip-protocol/sdk@0.7.4(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod-to-json-schema@3.25.1(zod@3.25.76))': + '@sip-protocol/sdk@0.7.4(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod-to-json-schema@3.25.1(zod@3.25.76))': dependencies: '@aztec/bb.js': 3.0.2 '@ethereumjs/rlp': 10.1.1 '@jup-ag/api': 6.0.48 - '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) - '@langchain/openai': 0.4.9(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@langchain/core': 0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/openai': 0.4.9(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@magicblock-labs/ephemeral-rollups-sdk': 0.8.5(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(utf-8-validate@5.0.10) '@noble/ciphers': 2.1.1 '@noble/curves': 1.9.7 @@ -8587,7 +8765,7 @@ snapshots: '@solana/spl-token': 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) '@triton-one/yellowstone-grpc': 4.0.2 - langchain: 1.2.17(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76)) + langchain: 1.2.17(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76)) pino: 10.3.0 zod: 4.3.6 optionalDependencies: @@ -8615,7 +8793,7 @@ snapshots: '@ethereumjs/rlp': 10.1.1 '@jup-ag/api': 6.0.48 '@langchain/core': 0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)) - '@langchain/openai': 0.4.9(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@langchain/openai': 0.4.9(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@magicblock-labs/ephemeral-rollups-sdk': 0.8.5(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(utf-8-validate@5.0.10) '@noble/ciphers': 2.1.1 '@noble/curves': 1.9.7 @@ -8634,7 +8812,7 @@ snapshots: '@solana/spl-token': 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) '@triton-one/yellowstone-grpc': 4.0.2 - langchain: 1.2.17(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6)) + langchain: 1.2.17(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6)) pino: 10.3.0 zod: 4.3.6 optionalDependencies: @@ -10466,6 +10644,74 @@ snapshots: dependencies: tslib: 2.8.1 + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/vite@4.2.2(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + '@tootallnate/quickjs-emscripten@0.23.0': {} '@toruslabs/base-controllers@5.11.0(@babel/runtime@7.28.6)(bufferutil@4.1.0)(utf-8-validate@5.0.10)': @@ -11024,7 +11270,7 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -11032,7 +11278,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -11044,13 +11290,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) '@vitest/pretty-format@3.2.4': dependencies: @@ -12463,6 +12709,11 @@ snapshots: engine.io-parser@5.2.3: {} + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + envalid@8.1.1: dependencies: tslib: 2.8.1 @@ -13201,6 +13452,8 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jiti@2.6.1: {} + joycon@3.1.1: {} js-base64@3.7.8: {} @@ -13279,12 +13532,12 @@ snapshots: keyvaluestorage-interface@1.0.0: {} - langchain@1.2.17(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76)): + langchain@1.2.17(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76)): dependencies: - '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) - '@langchain/langgraph': 1.1.3(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) - langsmith: 0.4.12(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/core': 0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/langgraph': 1.1.3(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) + langsmith: 0.4.12(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) uuid: 10.0.0 zod: 3.25.76 transitivePeerDependencies: @@ -13296,11 +13549,11 @@ snapshots: - react-dom - zod-to-json-schema - langchain@1.2.17(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6)): + langchain@1.2.17(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6)): dependencies: - '@langchain/core': 0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) - '@langchain/langgraph': 1.1.3(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@3.25.76) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) + '@langchain/core': 0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)) + '@langchain/langgraph': 1.1.3(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@3.25.76) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76))) langsmith: 0.4.12(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)) uuid: 10.0.0 zod: 3.25.76 @@ -13313,7 +13566,7 @@ snapshots: - react-dom - zod-to-json-schema - langsmith@0.3.87(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)): + langsmith@0.3.87(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)): dependencies: '@types/uuid': 10.0.0 chalk: 4.1.2 @@ -13322,7 +13575,7 @@ snapshots: semver: 7.7.3 uuid: 10.0.0 optionalDependencies: - openai: 6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76) + openai: 6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76) langsmith@0.3.87(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)): dependencies: @@ -13335,7 +13588,7 @@ snapshots: optionalDependencies: openai: 6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6) - langsmith@0.4.12(openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)): + langsmith@0.4.12(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)): dependencies: '@types/uuid': 10.0.0 chalk: 4.1.2 @@ -13344,7 +13597,7 @@ snapshots: semver: 7.7.3 uuid: 10.0.0 optionalDependencies: - openai: 6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76) + openai: 6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76) langsmith@0.4.12(openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6)): dependencies: @@ -13366,6 +13619,55 @@ snapshots: transitivePeerDependencies: - supports-color + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -13847,21 +14149,6 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@4.104.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76): - dependencies: - '@types/node': 18.19.130 - '@types/node-fetch': 2.6.13 - abort-controller: 3.0.0 - agentkeepalive: 4.6.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.7.0 - optionalDependencies: - ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10) - zod: 3.25.76 - transitivePeerDependencies: - - encoding - openai@4.104.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76): dependencies: '@types/node': 18.19.130 @@ -13877,9 +14164,9 @@ snapshots: transitivePeerDependencies: - encoding - openai@6.26.0(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76): + openai@6.26.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76): optionalDependencies: - ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10) + ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) zod: 3.25.76 optional: true @@ -14124,10 +14411,11 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-load-config@6.0.1(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.3): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.3): dependencies: lilconfig: 3.1.3 optionalDependencies: + jiti: 2.6.1 postcss: 8.5.6 tsx: 4.21.0 yaml: 2.8.3 @@ -14898,6 +15186,10 @@ snapshots: express: 5.2.1 swagger-ui-dist: 5.31.0 + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -15003,7 +15295,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.2) cac: 6.7.14 @@ -15014,7 +15306,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.3) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.3) resolve-from: 5.0.0 rollup: 4.57.1 source-map: 0.7.6 @@ -15228,13 +15520,13 @@ snapshots: - utf-8-validate - zod - vite-node@3.2.4(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + vite-node@3.2.4(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.1(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -15249,7 +15541,7 @@ snapshots: - tsx - yaml - vite@6.4.1(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -15260,11 +15552,13 @@ snapshots: optionalDependencies: '@types/node': 25.2.0 fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 terser: 5.46.1 tsx: 4.21.0 yaml: 2.8.3 - vite@7.3.1(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + vite@7.3.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -15275,15 +15569,17 @@ snapshots: optionalDependencies: '@types/node': 25.2.0 fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 terser: 5.46.1 tsx: 4.21.0 yaml: 2.8.3 - vitest@3.2.4(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + vitest@3.2.4(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -15301,8 +15597,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@25.2.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.2.0 From baaa6803e8dca6eb871d85bad3b7ea5becc565a5 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 15:21:47 +0700 Subject: [PATCH 59/92] =?UTF-8?q?feat:=20add=20API=20client=20layer=20?= =?UTF-8?q?=E2=80=94=20REST,=20SSE,=20wallet=20auth=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/api/auth.ts | 16 ++++++++++++++++ app/src/api/client.ts | 21 +++++++++++++++++++++ app/src/api/sse.ts | 17 +++++++++++++++++ app/src/hooks/useApi.ts | 10 ++++++++++ app/src/hooks/useAuth.ts | 28 ++++++++++++++++++++++++++++ app/src/hooks/useSSE.ts | 28 ++++++++++++++++++++++++++++ 6 files changed, 120 insertions(+) create mode 100644 app/src/api/auth.ts create mode 100644 app/src/api/client.ts create mode 100644 app/src/api/sse.ts create mode 100644 app/src/hooks/useApi.ts create mode 100644 app/src/hooks/useAuth.ts create mode 100644 app/src/hooks/useSSE.ts diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts new file mode 100644 index 0000000..c03da95 --- /dev/null +++ b/app/src/api/auth.ts @@ -0,0 +1,16 @@ +import { apiFetch } from './client' + +export async function requestNonce(wallet: string): Promise<{ nonce: string, message: string }> { + return apiFetch('/api/auth/nonce', { method: 'POST', body: JSON.stringify({ wallet }) }) +} + +export async function verifySignature( + wallet: string, + nonce: string, + signature: string +): Promise<{ token: string, expiresIn: string }> { + return apiFetch('/api/auth/verify', { + method: 'POST', + body: JSON.stringify({ wallet, nonce, signature }), + }) +} diff --git a/app/src/api/client.ts b/app/src/api/client.ts new file mode 100644 index 0000000..ffb619b --- /dev/null +++ b/app/src/api/client.ts @@ -0,0 +1,21 @@ +const BASE = import.meta.env.VITE_API_URL ?? '' + +export async function apiFetch( + path: string, + options?: RequestInit & { token?: string } +): Promise { + const { token, ...fetchOpts } = options ?? {} + const headers: Record = { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + } + const res = await fetch(`${BASE}${path}`, { + ...fetchOpts, + headers: { ...headers, ...(fetchOpts.headers as Record) }, + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error((body as { error?: string }).error ?? `API error ${res.status}`) + } + return res.json() as Promise +} diff --git a/app/src/api/sse.ts b/app/src/api/sse.ts new file mode 100644 index 0000000..f4b94ab --- /dev/null +++ b/app/src/api/sse.ts @@ -0,0 +1,17 @@ +export type SSEHandler = (event: MessageEvent) => void + +export function connectSSE( + token: string, + onEvent: SSEHandler, + onError?: (err: Event) => void +): EventSource { + const url = `${import.meta.env.VITE_API_URL ?? ''}/api/stream?token=${encodeURIComponent(token)}` + const source = new EventSource(url) + source.addEventListener('activity', onEvent) + source.addEventListener('confirm', onEvent) + source.addEventListener('agent-status', onEvent) + source.addEventListener('herald-budget', onEvent) + source.addEventListener('cost-update', onEvent) + source.onerror = (err) => { onError?.(err) } + return source +} diff --git a/app/src/hooks/useApi.ts b/app/src/hooks/useApi.ts new file mode 100644 index 0000000..7ef997c --- /dev/null +++ b/app/src/hooks/useApi.ts @@ -0,0 +1,10 @@ +import { useCallback } from 'react' +import { apiFetch } from '../api/client' + +export function useApi(token: string | null) { + const authFetch = useCallback((path: string, options?: RequestInit) => { + return apiFetch(path, { ...options, token: token ?? undefined }) + }, [token]) + + return { fetch: authFetch } +} diff --git a/app/src/hooks/useAuth.ts b/app/src/hooks/useAuth.ts new file mode 100644 index 0000000..5521df6 --- /dev/null +++ b/app/src/hooks/useAuth.ts @@ -0,0 +1,28 @@ +import { useState, useCallback } from 'react' +import { useWallet } from '@solana/wallet-adapter-react' +import { requestNonce, verifySignature } from '../api/auth' + +export function useAuth() { + const { publicKey, signMessage } = useWallet() + const [token, setToken] = useState(null) + const [loading, setLoading] = useState(false) + + const authenticate = useCallback(async () => { + if (!publicKey || !signMessage) return null + setLoading(true) + try { + const wallet = publicKey.toBase58() + const { nonce, message } = await requestNonce(wallet) + const encoded = new TextEncoder().encode(message) + const sig = await signMessage(encoded) + const sigHex = Array.from(sig).map(b => b.toString(16).padStart(2, '0')).join('') + const result = await verifySignature(wallet, nonce, sigHex) + setToken(result.token) + return result.token + } finally { + setLoading(false) + } + }, [publicKey, signMessage]) + + return { token, authenticate, loading, isAuthenticated: !!token } +} diff --git a/app/src/hooks/useSSE.ts b/app/src/hooks/useSSE.ts new file mode 100644 index 0000000..9529e47 --- /dev/null +++ b/app/src/hooks/useSSE.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef, useState } from 'react' +import { connectSSE } from '../api/sse' + +export interface ActivityEvent { + id: string + agent: string + type: string + level: string + data: Record + timestamp: string +} + +export function useSSE(token: string | null) { + const [events, setEvents] = useState([]) + const sourceRef = useRef(null) + + useEffect(() => { + if (!token) return + const source = connectSSE(token, (e) => { + const data = JSON.parse(e.data) as ActivityEvent + setEvents(prev => [data, ...prev].slice(0, 200)) + }) + sourceRef.current = source + return () => { source.close(); sourceRef.current = null } + }, [token]) + + return { events, connected: !!sourceRef.current } +} From 0670f0013687bd5376ff8a3d4c65b24e1e6b117e Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 15:23:57 +0700 Subject: [PATCH 60/92] =?UTF-8?q?feat:=20add=20app=20shell=20=E2=80=94=20h?= =?UTF-8?q?eader,=20bottom=20nav,=20view=20routing,=20wallet=20providers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four-tab navigation (Stream/Vault/HERALD/Squad), Phosphor icon CDN, wallet adapter providers, SSE/auth hooks wired into root layout, command bar stub. --- app/index.html | 1 + app/src/App.tsx | 40 +++++++++++++++++++++++++------- app/src/components/AgentDot.tsx | 11 +++++++++ app/src/components/BottomNav.tsx | 27 +++++++++++++++++++++ app/src/components/Header.tsx | 32 +++++++++++++++++++++++++ app/src/main.tsx | 3 +-- 6 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 app/src/components/AgentDot.tsx create mode 100644 app/src/components/BottomNav.tsx create mode 100644 app/src/components/Header.tsx diff --git a/app/index.html b/app/index.html index cdeb4d7..16af44b 100644 --- a/app/index.html +++ b/app/index.html @@ -7,6 +7,7 @@ +
diff --git a/app/src/App.tsx b/app/src/App.tsx index ee2fa22..afab96b 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,31 +1,55 @@ -import { useMemo } from 'react' +import { useState, useMemo } from 'react' import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react' import { WalletModalProvider } from '@solana/wallet-adapter-react-ui' import { PhantomWalletAdapter } from '@solana/wallet-adapter-wallets' import '@solana/wallet-adapter-react-ui/styles.css' import './styles/theme.css' -import WalletBar from './components/WalletBar' -import ChatContainer from './components/ChatContainer' +import Header from './components/Header' +import BottomNav from './components/BottomNav' +import { useAuth } from './hooks/useAuth' +import { useSSE } from './hooks/useSSE' -const NETWORK = (import.meta.env.VITE_SOLANA_NETWORK ?? 'devnet') as 'devnet' | 'mainnet-beta' +type View = 'stream' | 'vault' | 'herald' | 'squad' +const NETWORK = (import.meta.env.VITE_SOLANA_NETWORK ?? 'mainnet-beta') as 'devnet' | 'mainnet-beta' const ENDPOINTS: Record = { devnet: 'https://api.devnet.solana.com', 'mainnet-beta': 'https://api.mainnet-beta.solana.com', } export default function App() { - const endpoint = import.meta.env.VITE_SOLANA_RPC_URL ?? ENDPOINTS[NETWORK] ?? ENDPOINTS.devnet + const endpoint = import.meta.env.VITE_SOLANA_RPC_URL ?? ENDPOINTS[NETWORK] const wallets = useMemo(() => [new PhantomWalletAdapter()], []) + const [activeView, setActiveView] = useState('stream') + const { token, authenticate, isAuthenticated } = useAuth() + const { events } = useSSE(token) return ( -
- - +
+
+
+
+ {activeView === 'stream' && `Activity Stream — ${events.length} events`} + {activeView === 'vault' && 'Vault — coming in Task 6'} + {activeView === 'herald' && 'HERALD — coming in Task 7'} + {activeView === 'squad' && 'Squad — coming in Task 8'} +
+
+
+
+
+
+ Talk to SIPHER... +
+
⌘K
+
+
+ +
diff --git a/app/src/components/AgentDot.tsx b/app/src/components/AgentDot.tsx new file mode 100644 index 0000000..cd8330c --- /dev/null +++ b/app/src/components/AgentDot.tsx @@ -0,0 +1,11 @@ +import { AGENTS, type AgentName } from '../lib/agents' + +export default function AgentDot({ agent, size = 6 }: { agent: AgentName, size?: number }) { + const color = AGENTS[agent].color + return ( +
+ ) +} diff --git a/app/src/components/BottomNav.tsx b/app/src/components/BottomNav.tsx new file mode 100644 index 0000000..7fd087d --- /dev/null +++ b/app/src/components/BottomNav.tsx @@ -0,0 +1,27 @@ +type View = 'stream' | 'vault' | 'herald' | 'squad' + +const TABS: { id: View, label: string, icon: string, activeIcon: string }[] = [ + { id: 'stream', label: 'Stream', icon: 'ph ph-waves', activeIcon: 'ph-fill ph-waves' }, + { id: 'vault', label: 'Vault', icon: 'ph ph-vault', activeIcon: 'ph-fill ph-vault' }, + { id: 'herald', label: 'HERALD', icon: 'ph ph-broadcast', activeIcon: 'ph-fill ph-broadcast' }, + { id: 'squad', label: 'Squad', icon: 'ph ph-users-three', activeIcon: 'ph-fill ph-users-three' }, +] + +export default function BottomNav({ active, onChange }: { active: View, onChange: (v: View) => void }) { + return ( + + ) +} diff --git a/app/src/components/Header.tsx b/app/src/components/Header.tsx new file mode 100644 index 0000000..7e86ca7 --- /dev/null +++ b/app/src/components/Header.tsx @@ -0,0 +1,32 @@ +import { useWallet } from '@solana/wallet-adapter-react' +import { useWalletModal } from '@solana/wallet-adapter-react-ui' +import { truncateAddress } from '../lib/format' + +export default function Header({ onAuth, isAuthenticated }: { onAuth: () => void, isAuthenticated: boolean }) { + const { publicKey, connected } = useWallet() + const { setVisible } = useWalletModal() + + return ( +
+
+ Sipher +
+ {connected && publicKey ? ( + + ) : ( + + )} +
+ ) +} diff --git a/app/src/main.tsx b/app/src/main.tsx index aead4ee..5d410f3 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -1,10 +1,9 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App' -import './styles/theme.css' createRoot(document.getElementById('root')!).render( - , + ) From 34a8aa14757776d4243f75ff24f1b0a7dc978208 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 15:26:24 +0700 Subject: [PATCH 61/92] =?UTF-8?q?feat:=20add=20command=20bar=20=E2=80=94?= =?UTF-8?q?=20bottom=20sheet=20chat=20with=20confirmation=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts command bar stub from App.tsx into CommandBar component with expanded overlay state, message history, ⌘K/Escape keyboard shortcuts, auto-scroll, and ConfirmCard placeholder for fund-moving operations. --- app/src/App.tsx | 10 +- app/src/components/CommandBar.tsx | 155 +++++++++++++++++++++++++++++ app/src/components/ConfirmCard.tsx | 28 ++++++ 3 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 app/src/components/CommandBar.tsx create mode 100644 app/src/components/ConfirmCard.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index afab96b..06cb2e2 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -7,6 +7,7 @@ import './styles/theme.css' import Header from './components/Header' import BottomNav from './components/BottomNav' +import CommandBar from './components/CommandBar' import { useAuth } from './hooks/useAuth' import { useSSE } from './hooks/useSSE' @@ -40,14 +41,7 @@ export default function App() {
-
-
-
- Talk to SIPHER... -
-
⌘K
-
-
+
diff --git a/app/src/components/CommandBar.tsx b/app/src/components/CommandBar.tsx new file mode 100644 index 0000000..b44ab21 --- /dev/null +++ b/app/src/components/CommandBar.tsx @@ -0,0 +1,155 @@ +import { useState, useRef, useEffect } from 'react' +import { apiFetch } from '../api/client' + +interface Message { + role: 'user' | 'assistant' + content: string +} + +export default function CommandBar({ token }: { token: string | null }) { + const [expanded, setExpanded] = useState(false) + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [loading, setLoading] = useState(false) + const inputRef = useRef(null) + const messagesEndRef = useRef(null) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault() + setExpanded(true) + setTimeout(() => inputRef.current?.focus(), 100) + } + if (e.key === 'Escape') setExpanded(false) + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) + + useEffect(() => { + if (expanded) { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + }, [messages, expanded]) + + const send = async () => { + if (!input.trim() || !token) return + const userMsg: Message = { role: 'user', content: input } + setMessages(prev => [...prev, userMsg]) + setInput('') + setLoading(true) + try { + const res = await apiFetch<{ status: string; message?: string }>('/api/command', { + method: 'POST', + body: JSON.stringify({ message: userMsg.content }), + token, + }) + setMessages(prev => [ + ...prev, + { role: 'assistant', content: res.message ?? JSON.stringify(res) }, + ]) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error' + setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${message}` }]) + } finally { + setLoading(false) + } + } + + const open = () => { + setExpanded(true) + setTimeout(() => inputRef.current?.focus(), 100) + } + + if (!expanded) { + return ( +
+
+
+ Talk to SIPHER... +
+
+ ⌘K +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+ SIPHER + {loading && ( + thinking... + )} +
+ +
+ + {/* Messages */} +
+ {messages.length === 0 && ( +

+ Ask SIPHER anything about your privacy. +

+ )} + {messages.map((msg, i) => ( +
+
+ {msg.content} +
+
+ ))} +
+
+ + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + send() + } + }} + placeholder="Type a command..." + className="flex-1 bg-[#141416] border border-[#1E1E22] rounded-lg px-3 py-2.5 text-[13px] text-[#F5F5F5] placeholder-[#71717A] focus:outline-none focus:border-[#2C2C30]" + disabled={!token || loading} + /> + +
+
+
+ ) +} diff --git a/app/src/components/ConfirmCard.tsx b/app/src/components/ConfirmCard.tsx new file mode 100644 index 0000000..ffdcd3e --- /dev/null +++ b/app/src/components/ConfirmCard.tsx @@ -0,0 +1,28 @@ +export default function ConfirmCard({ action, amount, onConfirm, onCancel, timeout = 120 }: { + action: string + amount: string + onConfirm: () => void + onCancel: () => void + timeout?: number +}) { + return ( +
+
Confirm Action
+
{action}: {amount}
+
+ + +
+
+ ) +} From 73cce860d047f69ec690fc1889ae51938891e98d Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 15:30:03 +0700 Subject: [PATCH 62/92] =?UTF-8?q?feat:=20add=20activity=20stream=20view=20?= =?UTF-8?q?=E2=80=94=20SSE-powered=20real-time=20feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActivityEntry renders each event card with agent color coding, critical amber border accent, mono detail text, and action buttons matching the 01-stream-view design. StreamView merges live SSE events with history loaded from /api/activity on mount. --- app/src/App.tsx | 17 ++++--- app/src/components/ActivityEntry.tsx | 70 ++++++++++++++++++++++++++++ app/src/views/StreamView.tsx | 51 ++++++++++++++++++++ 3 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 app/src/components/ActivityEntry.tsx create mode 100644 app/src/views/StreamView.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index 06cb2e2..273446e 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -8,6 +8,7 @@ import './styles/theme.css' import Header from './components/Header' import BottomNav from './components/BottomNav' import CommandBar from './components/CommandBar' +import StreamView from './views/StreamView' import { useAuth } from './hooks/useAuth' import { useSSE } from './hooks/useSSE' @@ -33,12 +34,16 @@ export default function App() {
-
- {activeView === 'stream' && `Activity Stream — ${events.length} events`} - {activeView === 'vault' && 'Vault — coming in Task 6'} - {activeView === 'herald' && 'HERALD — coming in Task 7'} - {activeView === 'squad' && 'Squad — coming in Task 8'} -
+ {activeView === 'stream' && } + {activeView === 'vault' && ( +
Vault — coming in Task 6
+ )} + {activeView === 'herald' && ( +
HERALD — coming in Task 7
+ )} + {activeView === 'squad' && ( +
Squad — coming in Task 8
+ )}
diff --git a/app/src/components/ActivityEntry.tsx b/app/src/components/ActivityEntry.tsx new file mode 100644 index 0000000..bde1c20 --- /dev/null +++ b/app/src/components/ActivityEntry.tsx @@ -0,0 +1,70 @@ +import { AGENTS, type AgentName } from '../lib/agents' +import { timeAgo } from '../lib/format' + +interface Action { + label: string + onClick: () => void +} + +interface Props { + agent: AgentName + title: string + detail?: string + time: string + level: string + actions?: Action[] +} + +export default function ActivityEntry({ agent, title, detail, time, level, actions }: Props) { + const agentConfig = AGENTS[agent] ?? { name: agent.toUpperCase(), color: '#71717A' } + const isCritical = level === 'critical' + + return ( +
+ {/* Top row: dot + agent name + time */} +
+
+
+ + {agentConfig.name} + +
+ {timeAgo(time)} +
+ + {/* Title */} +

{title}

+ + {/* Detail — monospace, for TX hashes, metrics, etc. */} + {detail && ( +

{detail}

+ )} + + {/* Actions */} + {actions && actions.length > 0 && ( +
+ {actions.map((action, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/app/src/views/StreamView.tsx b/app/src/views/StreamView.tsx new file mode 100644 index 0000000..df69ebe --- /dev/null +++ b/app/src/views/StreamView.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react' +import ActivityEntry from '../components/ActivityEntry' +import { type ActivityEvent } from '../hooks/useSSE' +import { apiFetch } from '../api/client' + +export default function StreamView({ events, token }: { events: ActivityEvent[], token: string | null }) { + const [history, setHistory] = useState([]) + + // Load initial history on mount + useEffect(() => { + if (!token) return + apiFetch<{ activity: any[] }>('/api/activity', { token }) + .then(data => { + setHistory((data.activity ?? []).map((a: any) => ({ + id: a.id, + agent: a.agent, + type: a.type, + level: a.level, + data: typeof a.detail === 'string' ? JSON.parse(a.detail) : a.detail ?? {}, + timestamp: a.created_at, + }))) + }) + .catch(() => {}) + }, [token]) + + const allEvents = [...events, ...history] + + if (allEvents.length === 0) { + return ( +
+

No activity yet.

+

Connect your wallet to start monitoring.

+
+ ) + } + + return ( +
+ {allEvents.map(event => ( + + ))} +
+ ) +} From 2f72f88104b96d7eb1e478d4c40f0b63d29cb59e Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 15:32:29 +0700 Subject: [PATCH 63/92] =?UTF-8?q?feat:=20add=20vault=20view=20=E2=80=94=20?= =?UTF-8?q?balance,=20pending=20ops,=20activity,=20fee=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements VaultView with all 4 design sections: balance card with Deposit/Withdraw Privately buttons, pending scheduled ops with COURIER violet dots and Next countdown, recent activity table with per-type icon/status classification, and fee summary footer. Fetches real data from GET /api/vault with mock fallback. Wired into App.tsx replacing the placeholder. --- app/src/App.tsx | 5 +- app/src/views/VaultView.tsx | 265 ++++++++++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 app/src/views/VaultView.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index 273446e..b9293c9 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -9,6 +9,7 @@ import Header from './components/Header' import BottomNav from './components/BottomNav' import CommandBar from './components/CommandBar' import StreamView from './views/StreamView' +import VaultView from './views/VaultView' import { useAuth } from './hooks/useAuth' import { useSSE } from './hooks/useSSE' @@ -35,9 +36,7 @@ export default function App() {
{activeView === 'stream' && } - {activeView === 'vault' && ( -
Vault — coming in Task 6
- )} + {activeView === 'vault' && } {activeView === 'herald' && (
HERALD — coming in Task 7
)} diff --git a/app/src/views/VaultView.tsx b/app/src/views/VaultView.tsx new file mode 100644 index 0000000..a41b09f --- /dev/null +++ b/app/src/views/VaultView.tsx @@ -0,0 +1,265 @@ +import { useEffect, useState } from 'react' +import { apiFetch } from '../api/client' +import { timeAgo, truncateAddress } from '../lib/format' + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface ActivityRow { + id: string + agent: string + type: string + level: string + title: string + detail?: string + wallet?: string + created_at: string +} + +interface VaultData { + wallet: string + activity: ActivityRow[] +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function classifyActivity(row: ActivityRow): { + icon: string + iconColor: string + label: string + statusLabel: string + statusColor: string +} { + const t = row.type?.toLowerCase() ?? '' + const title = row.title?.toLowerCase() ?? '' + + if (t.includes('refund') || title.includes('refund')) { + return { + icon: 'ph-arrow-u-up-left', + iconColor: 'text-[#8B5CF6]', + label: 'Refund', + statusLabel: 'Auto-refund', + statusColor: 'text-[#8B5CF6]', + } + } + if (t.includes('deposit') || title.includes('deposit')) { + return { + icon: 'ph-arrow-down', + iconColor: 'text-[#10B981]', + label: 'Deposit', + statusLabel: 'Confirmed', + statusColor: 'text-[#10B981]', + } + } + if (t.includes('withdraw') || title.includes('withdraw')) { + return { + icon: 'ph-arrow-up', + iconColor: 'text-[#F5F5F5]', + label: 'Withdraw', + statusLabel: 'Stealth', + statusColor: 'text-[#71717A]', + isStealth: true, + } as any + } + if (t.includes('send') || title.includes('send')) { + return { + icon: 'ph-arrow-up-right', + iconColor: 'text-[#F5F5F5]', + label: 'Send', + statusLabel: 'Stealth', + statusColor: 'text-[#71717A]', + isStealth: true, + } as any + } + if (t.includes('swap') || title.includes('swap')) { + return { + icon: 'ph-arrows-left-right', + iconColor: 'text-[#3B82F6]', + label: 'Swap', + statusLabel: 'Confirmed', + statusColor: 'text-[#10B981]', + } + } + return { + icon: 'ph-arrow-right', + iconColor: 'text-[#71717A]', + label: row.type ?? 'Action', + statusLabel: 'Done', + statusColor: 'text-[#71717A]', + } +} + +// Extract SOL amount from title or detail if present +function extractAmount(row: ActivityRow): string { + const text = `${row.title ?? ''} ${row.detail ?? ''}` + const match = text.match(/([\d.]+)\s*SOL/i) + return match ? `${match[1]} SOL` : '' +} + +// Compute human-readable "Next:" from next_exec unix seconds +function nextIn(nextExecSec: number): string { + const diffMs = nextExecSec * 1000 - Date.now() + if (diffMs <= 0) return 'now' + const diffH = Math.floor(diffMs / 3600000) + if (diffH < 24) return `${diffH}h` + return `${Math.floor(diffH / 24)}d` +} + +// ── Mock data ───────────────────────────────────────────────────────────────── +// Used when vault API doesn't return these fields yet. + +const MOCK_BALANCE = '12.45' +const MOCK_USD = '$2,614.50' +const MOCK_FEES = '0.062' + +const MOCK_PENDING = [ + { id: 'p1', label: 'Drip', detail: '0.1 SOL/day → stealth', nextExecSec: Date.now() / 1000 + 6 * 3600 }, + { id: 'p2', label: 'Recurring', detail: '1 SOL weekly → 7xKz...', nextExecSec: Date.now() / 1000 + 3 * 86400 }, +] + +const MOCK_ACTIVITY: ActivityRow[] = [ + { id: 'm1', agent: 'sipher', type: 'deposit', level: 'info', title: '2.0 SOL', created_at: new Date(Date.now() - 2 * 3600000).toISOString() }, + { id: 'm2', agent: 'sipher', type: 'withdraw', level: 'info', title: '0.5 SOL', created_at: new Date(Date.now() - 86400000).toISOString() }, + { id: 'm3', agent: 'sipher', type: 'deposit', level: 'info', title: '10.0 SOL', created_at: new Date(Date.now() - 3 * 86400000).toISOString() }, + { id: 'm4', agent: 'courier', type: 'refund', level: 'info', title: '1.0 SOL', created_at: new Date(Date.now() - 5 * 86400000).toISOString() }, +] + +// ── Component ───────────────────────────────────────────────────────────────── + +export default function VaultView({ token }: { token: string | null }) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!token) return + setLoading(true) + apiFetch('/api/vault', { token }) + .then(setData) + .catch(() => {}) + .finally(() => setLoading(false)) + }, [token]) + + // Prefer real activity if available and non-empty, else fall back to mock + const activity = data?.activity?.length ? data.activity : MOCK_ACTIVITY + const wallet = data?.wallet ?? null + + // Derive fee from real activity if possible (count deposit/refund events) + const realFees = data?.activity?.length + ? data.activity.filter(a => a.type?.includes('fee') || a.agent === 'fee').length + : null + + return ( +
+ + {/* ── Balance Card ───────────────────────────────────────────────────── */} +
+

+ Vault Balance +

+
+ + {MOCK_BALANCE} SOL + + ≈ {MOCK_USD} +
+ {wallet && ( +

+ {truncateAddress(wallet, 6)} +

+ )} +
+ + +
+
+ + {/* ── Pending Operations ─────────────────────────────────────────────── */} +
+

+ Pending Operations +

+ {loading ? ( +
+ Loading... +
+ ) : ( + MOCK_PENDING.map(op => ( +
+
+
+ + {op.label}:{' '} + {op.detail} + +
+ + Next: {nextIn(op.nextExecSec)} + +
+ )) + )} +
+ + {/* ── Recent Activity ────────────────────────────────────────────────── */} +
+

+ Recent Activity +

+
+ {activity.map((row, i) => { + const cls = classifyActivity(row) + const isStealth = (cls as any).isStealth === true + const amount = extractAmount(row) + const isLast = i === activity.length - 1 + + return ( +
+
+ + {cls.label} + {amount && ( + {amount} + )} +
+
+ {timeAgo(row.created_at)} + {isStealth ? ( + + Stealth + + ) : ( + {cls.statusLabel} + )} +
+
+ ) + })} +
+
+ + {/* ── Fee Summary ────────────────────────────────────────────────────── */} +
+

+ Fees collected:{' '} + {realFees !== null ? `${realFees} events` : `${MOCK_FEES} SOL`}{' '} + (10 bps) +

+
+ +
+ ) +} From 6b89e90a00e3e5d95871ad19724945db8997eb82 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 15:33:02 +0700 Subject: [PATCH 64/92] =?UTF-8?q?feat:=20add=20HERALD=20view=20=E2=80=94?= =?UTF-8?q?=20X=20activity,=20approval=20queue,=20DMs,=20budget=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sub-tabs (Activity/Queue/DMs) with budget bar showing $spent/$limit progress. Activity renders posted/replied/liked/dm_handled timeline entries with blue dots and View on X links. Queue has Approve/Edit/Reject actions calling POST /api/herald/approve/:id. DMs show compact rows with Resolved/Actioned badges. Budget bar transitions green→amber→red at 80/95%. --- app/src/App.tsx | 5 +- app/src/views/HeraldView.tsx | 382 +++++++++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 app/src/views/HeraldView.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index b9293c9..d083b81 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,6 +10,7 @@ import BottomNav from './components/BottomNav' import CommandBar from './components/CommandBar' import StreamView from './views/StreamView' import VaultView from './views/VaultView' +import HeraldView from './views/HeraldView' import { useAuth } from './hooks/useAuth' import { useSSE } from './hooks/useSSE' @@ -37,9 +38,7 @@ export default function App() {
{activeView === 'stream' && } {activeView === 'vault' && } - {activeView === 'herald' && ( -
HERALD — coming in Task 7
- )} + {activeView === 'herald' && } {activeView === 'squad' && (
Squad — coming in Task 8
)} diff --git a/app/src/views/HeraldView.tsx b/app/src/views/HeraldView.tsx new file mode 100644 index 0000000..90b8817 --- /dev/null +++ b/app/src/views/HeraldView.tsx @@ -0,0 +1,382 @@ +import { useEffect, useState, useCallback } from 'react' +import { apiFetch } from '../api/client' +import { timeAgo } from '../lib/format' + +type Tab = 'activity' | 'queue' | 'dms' + +interface BudgetInfo { + spent: number + limit: number + gate: string + percentage: number +} + +interface ActivityEntry { + id: string + type: 'posted' | 'replied' | 'liked' | 'dm_handled' + timestamp: string + content?: string + replyTo?: string + engagement?: { likes: number, retweets: number, replies: number } + action?: string + tweetUrl?: string +} + +interface QueueItem { + id: string + content: string + scheduledFor: string +} + +interface DmEntry { + id: string + username: string + preview: string + resolution: string + action?: string +} + +interface HeraldData { + queue: QueueItem[] + budget: BudgetInfo + dms: DmEntry[] + recentPosts: ActivityEntry[] +} + +function BudgetBar({ budget }: { budget: BudgetInfo }) { + const pct = Math.min(budget.percentage ?? (budget.spent / budget.limit) * 100, 100) + const barColor = + pct >= 95 ? 'bg-red-500' : + pct >= 80 ? 'bg-[#F59E0B]' : + 'bg-[#10B981]' + + return ( +
+
+ X API Budget +
+ ${budget.spent.toFixed(2)} + / ${budget.limit} +
+
+
+
+
+
+ ) +} + +function SubTabs({ active, onChange }: { active: Tab, onChange: (t: Tab) => void }) { + const tabs: { id: Tab, label: string }[] = [ + { id: 'activity', label: 'Activity' }, + { id: 'queue', label: 'Queue' }, + { id: 'dms', label: 'DMs' }, + ] + return ( +
+ {tabs.map(t => ( + + ))} +
+ ) +} + +function ActivityTimeline({ entries }: { entries: ActivityEntry[] }) { + if (entries.length === 0) { + return ( +
No recent activity.
+ ) + } + + return ( +
+
+ + {entries.map((entry, i) => ( +
+
+ +
+ {entry.type === 'posted' && ( + <> +
+ Posted + {timeAgo(entry.timestamp)} +
+
+

{entry.content}

+ {(entry.engagement || entry.tweetUrl) && ( +
+ {entry.engagement && ( +
+ ♥ {entry.engagement.likes} + ⇄ {entry.engagement.retweets} + ◯ {entry.engagement.replies} +
+ )} + {entry.tweetUrl && ( + + View on X ↗ + + )} +
+ )} +
+ + )} + + {entry.type === 'replied' && ( + <> +
+ Replied to + {entry.replyTo && ( + @{entry.replyTo} + )} + {timeAgo(entry.timestamp)} +
+
+ {entry.content && ( +
+

{entry.content}

+
+ )} + {entry.tweetUrl && ( + + )} +
+ + )} + + {entry.type === 'liked' && ( + <> +
+ Liked + {timeAgo(entry.timestamp)} +
+

+ {' '} + Liked{entry.replyTo ? <> @{entry.replyTo}'s post : ' a post'} + {entry.content ? ` about ${entry.content}` : ''} +

+ + )} + + {entry.type === 'dm_handled' && ( + <> +
+ DM Handled + {timeAgo(entry.timestamp)} +
+
+ {entry.replyTo && ( + <>@{entry.replyTo}:{' '} + )} + {entry.content} + {entry.action && ( +
+ → {entry.action} +
+ )} +
+ + )} +
+
+ ))} +
+ ) +} + +function QueueTab({ + items, + onAction, +}: { + items: QueueItem[] + onAction: (id: string, action: 'approve' | 'reject') => Promise +}) { + const [pending, setPending] = useState>({}) + + const handleAction = async (id: string, action: 'approve' | 'reject') => { + setPending(p => ({ ...p, [id]: true })) + try { + await onAction(id, action) + } finally { + setPending(p => ({ ...p, [id]: false })) + } + } + + if (items.length === 0) { + return ( +
No pending posts.
+ ) + } + + return ( +
+ {items.map(item => ( +
+

{item.content}

+
+ 📅 + {item.scheduledFor} +
+
+ + + +
+
+ ))} +
+ ) +} + +function DmsTab({ dms }: { dms: DmEntry[] }) { + if (dms.length === 0) { + return ( +
No recent DMs.
+ ) + } + + return ( +
+ {dms.map((dm, i) => ( +
+
+ @{dm.username} + {dm.resolution === 'resolved' ? ( + + Resolved + + ) : ( + + Actioned + + )} +
+

{dm.preview}

+ {dm.action && ( +
+ ↳ {dm.action} +
+ )} +
+ ))} +
+ ) +} + +export default function HeraldView({ token }: { token: string | null }) { + const [tab, setTab] = useState('activity') + const [data, setData] = useState(null) + const [error, setError] = useState(null) + + const load = useCallback(() => { + if (!token) return + setError(null) + apiFetch('/api/herald', { token }) + .then(setData) + .catch((err: Error) => setError(err.message)) + }, [token]) + + useEffect(() => { + load() + }, [load]) + + const handleApprove = async (id: string, action: 'approve' | 'reject') => { + await apiFetch(`/api/herald/approve/${id}`, { + method: 'POST', + body: JSON.stringify({ action }), + token: token!, + }) + load() + } + + if (!token) { + return ( +
+ Connect your wallet to view HERALD activity. +
+ ) + } + + const budget = data?.budget ?? { spent: 0, limit: 150, gate: 'open', percentage: 0 } + + return ( +
+ + + +
+ {error && ( +
+ {error} +
+ )} + + {!data && !error && ( +
Loading...
+ )} + + {data && tab === 'activity' && ( + + )} + + {data && tab === 'queue' && ( + + )} + + {data && tab === 'dms' && ( + + )} +
+
+ ) +} From be823975ec1317d8c6aa3aa9563a95986a2e968f Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 15:34:44 +0700 Subject: [PATCH 65/92] =?UTF-8?q?feat:=20Guardian=20Command=20UI=20complet?= =?UTF-8?q?e=20=E2=80=94=20all=20views,=20command=20bar,=20build=20verifie?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update index.html title/meta for Guardian Command - Remove unused legacy components: ChatContainer, TextMessage, QuickActions, WalletBar, ConfirmationPrompt - TypeScript: zero errors - Vite build: clean, 2.49s, dist/ verified --- app/index.html | 3 +- app/src/components/ChatContainer.tsx | 394 ---------------------- app/src/components/ConfirmationPrompt.tsx | 140 -------- app/src/components/QuickActions.tsx | 29 -- app/src/components/TextMessage.tsx | 65 ---- app/src/components/WalletBar.tsx | 77 ----- 6 files changed, 2 insertions(+), 706 deletions(-) delete mode 100644 app/src/components/ChatContainer.tsx delete mode 100644 app/src/components/ConfirmationPrompt.tsx delete mode 100644 app/src/components/QuickActions.tsx delete mode 100644 app/src/components/TextMessage.tsx delete mode 100644 app/src/components/WalletBar.tsx diff --git a/app/index.html b/app/index.html index 16af44b..fdfe360 100644 --- a/app/index.html +++ b/app/index.html @@ -3,7 +3,8 @@ - Sipher — Privacy Agent + Guardian Command — Sipher + diff --git a/app/src/components/ChatContainer.tsx b/app/src/components/ChatContainer.tsx deleted file mode 100644 index e3d0bff..0000000 --- a/app/src/components/ChatContainer.tsx +++ /dev/null @@ -1,394 +0,0 @@ -import { useState, useRef, useEffect, useCallback } from 'react' -import { useWallet } from '@solana/wallet-adapter-react' -import TextMessage from './TextMessage' -import ConfirmationPrompt, { type ConfirmationData, type ConfirmationStatus } from './ConfirmationPrompt' -import QuickActions from './QuickActions' -import { useTransactionSigner, type SignStatus } from '../hooks/useTransactionSigner' - -interface ChatMessage { - id: string - role: 'user' | 'agent' - content: string - timestamp: Date - error?: boolean -} - -interface ConfirmationMessage { - id: string - type: 'confirmation' - data: ConfirmationData - timestamp: Date -} - -type Message = ChatMessage | ConfirmationMessage - -function isConfirmation(msg: Message): msg is ConfirmationMessage { - return 'type' in msg && msg.type === 'confirmation' -} - -function generateId(): string { - return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -} - -const API_URL = '/api/chat' -const STREAM_URL = '/api/chat/stream' - -export default function ChatContainer() { - const { connected, publicKey } = useWallet() - const { signAndBroadcast } = useTransactionSigner() - const [messages, setMessages] = useState([]) - const [input, setInput] = useState('') - const [loading, setLoading] = useState(false) - const messagesEndRef = useRef(null) - const textareaRef = useRef(null) - - // Auto-scroll to bottom on new messages - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) - - // Auto-resize textarea - useEffect(() => { - const el = textareaRef.current - if (!el) return - el.style.height = 'auto' - el.style.height = `${Math.min(el.scrollHeight, 120)}px` - }, [input]) - - const addMessage = useCallback((msg: Message) => { - setMessages(prev => [...prev, msg]) - }, []) - - const updateConfirmation = useCallback((confirmId: string, patch: Partial) => { - setMessages(prev => - prev.map(msg => { - if (isConfirmation(msg) && msg.data.id === confirmId) { - return { ...msg, data: { ...msg.data, ...patch } } - } - return msg - }) - ) - }, []) - - const updateMessage = useCallback((id: string, patch: Partial) => { - setMessages(prev => - prev.map(msg => { - if (!isConfirmation(msg) && msg.id === id) { - return { ...msg, ...patch } - } - return msg - }) - ) - }, []) - - /** - * Try SSE streaming first — real-time token delivery. - * Returns true if streaming succeeded, false if it should fall back to POST. - */ - const tryStreamingChat = useCallback(async ( - chatHistory: { role: 'user' | 'assistant'; content: string }[], - placeholderId: string, - ): Promise => { - let res: Response - try { - res = await fetch(STREAM_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: chatHistory, - wallet: publicKey?.toBase58() ?? null, - }), - }) - } catch { - return false // Network error — fall back to POST - } - - if (!res.ok || !res.body) return false - - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - let accumulated = '' - - try { - let finished = false - // eslint-disable-next-line no-constant-condition - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - // Keep the last incomplete line in the buffer - buffer = lines.pop() ?? '' - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed.startsWith('data: ')) continue - const payload = trimmed.slice(6) - - if (payload === '[DONE]') { finished = true; break } - - try { - const event = JSON.parse(payload) - - if (event.type === 'content_block_delta' && event.text) { - accumulated += event.text - updateMessage(placeholderId, { content: accumulated }) - } else if (event.type === 'message_complete') { - // Final content — ensure full text is set - if (event.content) { - updateMessage(placeholderId, { content: event.content }) - } - } else if (event.type === 'error') { - updateMessage(placeholderId, { - content: event.message ?? 'Stream error occurred.', - error: true, - }) - return true // Error delivered via stream — don't fall back - } - } catch { - // Malformed JSON line — skip it - } - } - if (finished) break - } - } finally { - reader.releaseLock() - } - - return true - }, [publicKey, updateMessage]) - - /** - * Fallback: POST to /api/chat and wait for full response. - */ - const postChat = useCallback(async ( - chatHistory: { role: 'user' | 'assistant'; content: string }[], - placeholderId: string, - ): Promise => { - const res = await fetch(API_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: chatHistory, - wallet: publicKey?.toBase58() ?? null, - }), - }) - - if (!res.ok) { - throw new Error(`Agent responded with ${res.status}`) - } - - const data = await res.json() - const agentText = data.content ?? data.message ?? 'No response from agent.' - - updateMessage(placeholderId, { content: agentText }) - - // If the response includes a confirmation request, render it - if (data.confirmation) { - const confirmId = generateId() - addMessage({ - id: generateId(), - type: 'confirmation', - data: { - id: confirmId, - action: data.confirmation.action ?? 'Transaction', - amount: data.confirmation.amount, - fee: data.confirmation.fee, - recipient: data.confirmation.recipient, - serializedTx: data.confirmation.serializedTx, - status: 'pending', - }, - timestamp: new Date(), - }) - } - }, [publicKey, addMessage, updateMessage]) - - const sendToAgent = useCallback(async (userText: string) => { - const chatHistory = messages - .filter((m): m is ChatMessage => !isConfirmation(m)) - .map(m => ({ role: m.role === 'user' ? 'user' as const : 'assistant' as const, content: m.content })) - - chatHistory.push({ role: 'user', content: userText }) - - // Create a placeholder message for real-time streaming updates - const placeholderId = generateId() - addMessage({ - id: placeholderId, - role: 'agent', - content: '', - timestamp: new Date(), - }) - - setLoading(true) - - try { - // Try streaming first, fall back to POST on failure - const streamed = await tryStreamingChat(chatHistory, placeholderId) - if (!streamed) { - await postChat(chatHistory, placeholderId) - } - } catch { - updateMessage(placeholderId, { - content: 'Sipher agent is offline. Make sure the server is running on /api/chat.', - error: true, - }) - } finally { - setLoading(false) - } - }, [messages, publicKey, addMessage, updateMessage, tryStreamingChat, postChat]) - - const handleSend = useCallback((text?: string) => { - const msg = (text ?? input).trim() - if (!msg || loading) return - - addMessage({ - id: generateId(), - role: 'user', - content: msg, - timestamp: new Date(), - }) - - if (!text) setInput('') - - sendToAgent(msg) - }, [input, loading, addMessage, sendToAgent]) - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSend() - } - } - - const handleQuickAction = (message: string) => { - handleSend(message) - } - - const handleConfirm = (id: string) => { - updateConfirmation(id, { status: 'confirmed' as ConfirmationStatus }) - addMessage({ - id: generateId(), - role: 'agent', - content: 'Transaction confirmed (no on-chain transaction for this action).', - timestamp: new Date(), - }) - } - - const handleSign = useCallback(async (confirmId: string, serializedTx: string) => { - updateConfirmation(confirmId, { signStatus: 'signing' as SignStatus }) - - const result = await signAndBroadcast(serializedTx) - - if (result.signature) { - updateConfirmation(confirmId, { - status: 'confirmed', - signStatus: 'confirmed', - signature: result.signature, - }) - addMessage({ - id: generateId(), - role: 'agent', - content: `Transaction confirmed: ${result.signature}`, - timestamp: new Date(), - }) - } else { - updateConfirmation(confirmId, { - signStatus: 'error', - txError: result.error ?? 'Transaction failed', - }) - addMessage({ - id: generateId(), - role: 'agent', - content: `Transaction failed: ${result.error}`, - timestamp: new Date(), - error: true, - }) - } - - return result - }, [signAndBroadcast, updateConfirmation, addMessage]) - - const handleCancel = (id: string) => { - updateConfirmation(id, { status: 'cancelled' as ConfirmationStatus }) - addMessage({ - id: generateId(), - role: 'agent', - content: 'Transaction cancelled.', - timestamp: new Date(), - }) - } - - const isEmpty = messages.length === 0 - - return ( -
-
- {isEmpty ? ( -
-
{'\u{1f510}'}
-
Sipher Privacy Agent
-
- {connected - ? 'Ask me anything about private transfers, vault operations, or stealth payments.' - : 'Connect your wallet to get started.'} -
-
- ) : ( - <> - {messages.map(msg => - isConfirmation(msg) ? ( - - ) : ( - - ) - )} - {loading && ( -
-
-
-
-
- )} - - )} -
-
- - - -
-