Skip to content

Commit bd21cbd

Browse files
SashaMITcursoragent
andcommitted
feat(agent-creator-studio): backend foundation for Monetisation Agent S1
Lays the backend half of the v1.3.0 conversational minting flow. The Monetisation Agent (a new mode of the existing AI chat) walks creators through filling the same fields the Creator wizard already collects, then hands off to the Creator app for sign + mint. No new mint pipeline, no new wallet code, no new on-chain artifact. Architectural model: "shared INTENT format, two presentations" — the agent owns a new publish_intents table (pre-encryption, user-intent fields only) that the Creator app reads via puter.args.resumeIntent=<id> and uses to pre-fill its existing wizard. See PLAN.md §6 for the full spec. Why a separate publish_intents (not publish_drafts): publish_drafts is created post-encryption — its asset_cid / metadata_cid / encrypt_hash columns are required NOT NULL (Creator app.js ~L4740). The agent works pre-encryption, so it needs its own staging table. Changes: - Migration 34: new publish_intents table mirroring the input-side columns of publish_drafts (same names — channel, price, copies, royalty_partners, etc.), plus intent-specific fields (status, conversation_id, consumed_draft_id). CHECK constraint enforces the status state machine. Added to both schema.sql (fresh installs) and migrations.ts (existing installs), idempotent CREATE IF NOT EXISTS. - DatabaseManager: insertIntent / getIntentsByWallet / getIntentById / updateIntent / markIntentHandedOff / markIntentConsumed / deleteIntent + getChannelsByCreator (for the list_my_channels tool). updateIntent uses a field whitelist to prevent column injection. - api/intents.ts (new): REST surface — POST / GET / GET-by-id / PUT / PATCH-status / DELETE. Centralised validateIntentFields() enforces enum constraints, royalty sum-to-100, 0x address shape, copies range (1-10000), price-as-stringified-bigint, status state machine. - services/ai/tools/MonetisationAgentTools.ts (new): the 6-tool function-calling schema (analyze_file, list_my_channels, list_my_intents, update_intent, summarise_intent, open_creator_to_mint) with full JSON Schemas. Registered with AIChatService alongside the other tool families. - ToolExecutor.ts: 6 new case clauses implementing the tools. None write to chain or mutate a wallet. open_creator_to_mint flips intent status to handed_off and broadcasts a monetisation.open_creator Socket.IO event so the frontend can call puter.ui.launchApp. CI / test posture: - tsc --noEmit clean across the full pc2-node project. - Lint clean. - No new dependencies. - No changes to existing publish_drafts code path; chat path is additive. Next commits in this series: - frontend mode picker in UIAIChat.js - 5-line resumeIntent handler in elacity-creator/app.js - NR-4 byte-for-byte regression test (launch gate) Tracked in .cursor/tasks/AGENT-CREATOR-STUDIO-2026-05/PLAN.md. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2f170e2 commit bd21cbd

8 files changed

Lines changed: 1176 additions & 3 deletions

File tree

pc2-node/src/api/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import accessControlRouter from './access-control.js';
5353
import didRouter from './did.js';
5454
import walletRouter from './wallet.js';
5555
import draftsRouter from './drafts.js';
56+
import intentsRouter from './intents.js';
5657
import gatewayRouter from './gateway.js';
5758
import systemRouter from './system.js';
5859
import contextRouter from './context.js';
@@ -637,6 +638,7 @@ export function setupAPI (app: Express): void {
637638
app.use('/api/did', didRouter);
638639
app.use('/api/wallet', walletRouter);
639640
app.use('/api/drafts', draftsRouter);
641+
app.use('/api/intents', intentsRouter);
640642
app.use('/api/gateway', gatewayRouter);
641643
app.use('/api/system', systemRouter);
642644
app.use('/api/context', contextRouter);

pc2-node/src/api/intents.ts

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
/**
2+
* Publish Intents API
3+
*
4+
* REST surface for the Monetisation Agent's pre-encryption working state.
5+
* Mirrors the input-side of publish_drafts. The Creator app consumes an
6+
* intent via puter.args.resumeIntent, pre-fills its wizard, encrypts +
7+
* pins, then writes a publish_drafts row and marks the intent 'consumed'.
8+
*
9+
* Wallet-scoped — each user only sees their own intents.
10+
* See .cursor/tasks/AGENT-CREATOR-STUDIO-2026-05/PLAN.md §6 + §7.
11+
*/
12+
13+
import { Router, Response } from 'express';
14+
import { authenticate, AuthenticatedRequest } from './middleware.js';
15+
import type { DatabaseManager } from '../storage/database.js';
16+
import { logger } from '../utils/logger.js';
17+
18+
const router = Router();
19+
20+
const VALID_CATEGORIES = ['Photography', 'Video', 'Audio', 'Document', 'Other'];
21+
const VALID_ACCESS_METHODS = ['free', 'buy_once', 'buy_and_resell'];
22+
const VALID_LICENSE_PROFILES = [
23+
'perpetual_personal_view',
24+
'perpetual_personal_print',
25+
'share_alike_nc',
26+
'custom',
27+
];
28+
29+
/**
30+
* Validate a partial set of intent field updates. Returns an error string
31+
* or null. Centralised so POST + PUT use the same rules.
32+
*/
33+
function validateIntentFields(fields: any): string | null {
34+
if (fields.category !== undefined && fields.category !== null) {
35+
if (!VALID_CATEGORIES.includes(fields.category)) {
36+
return `Invalid category. Allowed: ${VALID_CATEGORIES.join(', ')}`;
37+
}
38+
}
39+
if (fields.access_method !== undefined && fields.access_method !== null) {
40+
if (!VALID_ACCESS_METHODS.includes(fields.access_method)) {
41+
return `Invalid access_method. Allowed: ${VALID_ACCESS_METHODS.join(', ')}`;
42+
}
43+
}
44+
if (fields.license_profile !== undefined && fields.license_profile !== null) {
45+
if (!VALID_LICENSE_PROFILES.includes(fields.license_profile)) {
46+
return `Invalid license_profile. Allowed: ${VALID_LICENSE_PROFILES.join(', ')}`;
47+
}
48+
}
49+
if (fields.copies !== undefined && fields.copies !== null) {
50+
const c = Number(fields.copies);
51+
if (!Number.isInteger(c) || c < 1 || c > 10000) {
52+
return 'copies must be an integer between 1 and 10000';
53+
}
54+
}
55+
if (fields.price !== undefined && fields.price !== null && fields.price !== '') {
56+
// Price arrives stringified to preserve precision; non-free access modes need >0
57+
try {
58+
const big = BigInt(fields.price);
59+
if (fields.access_method && fields.access_method !== 'free' && big <= 0n) {
60+
return 'price must be > 0 for non-free access modes';
61+
}
62+
} catch {
63+
return 'price must be a stringified integer (wei or smallest unit)';
64+
}
65+
}
66+
if (fields.channel !== undefined && fields.channel !== null && fields.channel !== '') {
67+
if (typeof fields.channel !== 'string' || !/^0x[a-fA-F0-9]{40}$/.test(fields.channel)) {
68+
return 'channel must be a 0x-prefixed 40-hex-char address';
69+
}
70+
}
71+
if (fields.royalty_partners !== undefined && fields.royalty_partners !== null) {
72+
// Accept either array (will be JSON-stringified server-side) or pre-stringified
73+
let partners: any[] | null = null;
74+
if (Array.isArray(fields.royalty_partners)) {
75+
partners = fields.royalty_partners;
76+
} else if (typeof fields.royalty_partners === 'string') {
77+
try { partners = JSON.parse(fields.royalty_partners); } catch { return 'royalty_partners must be valid JSON'; }
78+
}
79+
if (partners && Array.isArray(partners)) {
80+
let totalPercent = 0;
81+
for (const p of partners) {
82+
if (!p || typeof p !== 'object') return 'each royalty partner must be an object';
83+
if (typeof p.address !== 'string' || !/^0x[a-fA-F0-9]{40}$/.test(p.address)) {
84+
return 'royalty partner address must be a 0x-prefixed 40-hex-char address';
85+
}
86+
const pct = Number(p.percent);
87+
if (!Number.isFinite(pct) || pct < 0 || pct > 100) {
88+
return 'royalty partner percent must be a number 0-100';
89+
}
90+
totalPercent += pct;
91+
}
92+
// Allow small float rounding (e.g. 33.33+33.33+33.34)
93+
if (partners.length > 0 && Math.abs(totalPercent - 100) > 0.05) {
94+
return `royalty partner percents must sum to 100 (got ${totalPercent.toFixed(2)})`;
95+
}
96+
}
97+
}
98+
if (fields.tags !== undefined && fields.tags !== null) {
99+
// Accept array or comma-separated string; normalize server-side
100+
if (!Array.isArray(fields.tags) && typeof fields.tags !== 'string') {
101+
return 'tags must be an array of strings or a comma-separated string';
102+
}
103+
}
104+
return null;
105+
}
106+
107+
/**
108+
* Normalize incoming fields for DB insertion: array → JSON string, etc.
109+
*/
110+
function normalizeForDb(fields: any): any {
111+
const out: any = { ...fields };
112+
if (Array.isArray(out.royalty_partners)) {
113+
out.royalty_partners = JSON.stringify(out.royalty_partners);
114+
}
115+
if (Array.isArray(out.tags)) {
116+
out.tags = JSON.stringify(out.tags);
117+
}
118+
return out;
119+
}
120+
121+
/**
122+
* Decorate an intent row for the response: parse JSON fields, coerce flags.
123+
*/
124+
function decorateRow(row: any): any {
125+
if (!row) return row;
126+
return {
127+
...row,
128+
tags: row.tags ? (() => { try { return JSON.parse(row.tags); } catch { return row.tags; } })() : null,
129+
royalty_partners: row.royalty_partners ? (() => { try { return JSON.parse(row.royalty_partners); } catch { return null; } })() : null,
130+
adult: !!row.adult,
131+
};
132+
}
133+
134+
/**
135+
* POST /api/intents
136+
* Create a new intent. All input fields optional (the agent fills incrementally).
137+
* Wallet ownership is set from the auth context, never the body.
138+
*/
139+
router.post('/', authenticate, async (req: AuthenticatedRequest, res: Response) => {
140+
try {
141+
const walletAddress = req.user?.wallet_address;
142+
if (!walletAddress) return res.status(401).json({ error: 'Not authenticated' });
143+
144+
const validationError = validateIntentFields(req.body || {});
145+
if (validationError) return res.status(400).json({ error: validationError });
146+
147+
const fields = normalizeForDb(req.body || {});
148+
const db = req.app.locals.db as DatabaseManager;
149+
const id = db.insertIntent({
150+
wallet_address: walletAddress,
151+
conversation_id: fields.conversation_id,
152+
source_file_path: fields.source_file_path,
153+
title: fields.title,
154+
description: fields.description,
155+
category: fields.category,
156+
file_name: fields.file_name,
157+
file_size: fields.file_size,
158+
mime_type: fields.mime_type,
159+
tags: fields.tags,
160+
channel: fields.channel,
161+
price: fields.price,
162+
currency_address: fields.currency_address,
163+
currency_symbol: fields.currency_symbol,
164+
copies: fields.copies,
165+
access_method: fields.access_method,
166+
reseller_cut: fields.reseller_cut,
167+
royalty_partners: fields.royalty_partners,
168+
license_profile: fields.license_profile,
169+
thumbnail_cid: fields.thumbnail_cid,
170+
thumbnail_path: fields.thumbnail_path,
171+
adult: fields.adult,
172+
});
173+
174+
const created = db.getIntentById(id, walletAddress);
175+
logger.info(`[Intents] Created intent #${id} for ${walletAddress.slice(0, 10)}...`);
176+
res.json(decorateRow(created));
177+
} catch (error: any) {
178+
logger.error(`[Intents] Create error: ${error.message}`);
179+
res.status(500).json({ error: 'Failed to create intent' });
180+
}
181+
});
182+
183+
/**
184+
* GET /api/intents
185+
* List intents for the authenticated wallet, optionally filtered by status.
186+
*/
187+
router.get('/', authenticate, async (req: AuthenticatedRequest, res: Response) => {
188+
try {
189+
const walletAddress = req.user?.wallet_address;
190+
if (!walletAddress) return res.status(401).json({ error: 'Not authenticated' });
191+
192+
const status = typeof req.query.status === 'string' ? req.query.status : undefined;
193+
const limit = typeof req.query.limit === 'string' ? Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200) : 50;
194+
195+
const db = req.app.locals.db as DatabaseManager;
196+
const rows = db.getIntentsByWallet(walletAddress, status, limit);
197+
res.json(rows.map(decorateRow));
198+
} catch (error: any) {
199+
logger.error(`[Intents] List error: ${error.message}`);
200+
res.status(500).json({ error: 'Failed to list intents' });
201+
}
202+
});
203+
204+
/**
205+
* GET /api/intents/:id
206+
* Fetch a single intent by ID (wallet-scoped).
207+
*/
208+
router.get('/:id', authenticate, async (req: AuthenticatedRequest, res: Response) => {
209+
try {
210+
const walletAddress = req.user?.wallet_address;
211+
if (!walletAddress) return res.status(401).json({ error: 'Not authenticated' });
212+
213+
const id = parseInt(req.params.id, 10);
214+
if (isNaN(id)) return res.status(400).json({ error: 'Invalid intent ID' });
215+
216+
const db = req.app.locals.db as DatabaseManager;
217+
const intent = db.getIntentById(id, walletAddress);
218+
if (!intent) return res.status(404).json({ error: 'Intent not found' });
219+
220+
res.json(decorateRow(intent));
221+
} catch (error: any) {
222+
logger.error(`[Intents] Get error: ${error.message}`);
223+
res.status(500).json({ error: 'Failed to get intent' });
224+
}
225+
});
226+
227+
/**
228+
* PUT /api/intents/:id
229+
* Partial update — only fields present in the body are written.
230+
* Only intents in 'draft' status can be edited. Use PATCH for status transitions.
231+
*/
232+
router.put('/:id', authenticate, async (req: AuthenticatedRequest, res: Response) => {
233+
try {
234+
const walletAddress = req.user?.wallet_address;
235+
if (!walletAddress) return res.status(401).json({ error: 'Not authenticated' });
236+
237+
const id = parseInt(req.params.id, 10);
238+
if (isNaN(id)) return res.status(400).json({ error: 'Invalid intent ID' });
239+
240+
const validationError = validateIntentFields(req.body || {});
241+
if (validationError) return res.status(400).json({ error: validationError });
242+
243+
const db = req.app.locals.db as DatabaseManager;
244+
const existing = db.getIntentById(id, walletAddress);
245+
if (!existing) return res.status(404).json({ error: 'Intent not found' });
246+
if (existing.status !== 'draft') {
247+
return res.status(409).json({ error: `Intent is ${existing.status}, only draft intents can be edited` });
248+
}
249+
250+
const updated = db.updateIntent(id, walletAddress, normalizeForDb(req.body || {}));
251+
if (!updated) return res.status(400).json({ error: 'No valid fields to update' });
252+
253+
const fresh = db.getIntentById(id, walletAddress);
254+
res.json(decorateRow(fresh));
255+
} catch (error: any) {
256+
logger.error(`[Intents] Update error: ${error.message}`);
257+
res.status(500).json({ error: 'Failed to update intent' });
258+
}
259+
});
260+
261+
/**
262+
* PATCH /api/intents/:id/status
263+
* Status transitions only. Valid transitions:
264+
* draft → handed_off (when agent calls open_creator_to_mint)
265+
* draft|handed_off → abandoned (when user cancels)
266+
* draft|handed_off → consumed (when Creator successfully writes a publish_drafts row; pass consumed_draft_id)
267+
*/
268+
router.patch('/:id/status', authenticate, async (req: AuthenticatedRequest, res: Response) => {
269+
try {
270+
const walletAddress = req.user?.wallet_address;
271+
if (!walletAddress) return res.status(401).json({ error: 'Not authenticated' });
272+
273+
const id = parseInt(req.params.id, 10);
274+
if (isNaN(id)) return res.status(400).json({ error: 'Invalid intent ID' });
275+
276+
const { status, consumed_draft_id } = req.body || {};
277+
if (!status) return res.status(400).json({ error: 'Missing status' });
278+
279+
const db = req.app.locals.db as DatabaseManager;
280+
const existing = db.getIntentById(id, walletAddress);
281+
if (!existing) return res.status(404).json({ error: 'Intent not found' });
282+
283+
let ok = false;
284+
if (status === 'handed_off') {
285+
ok = db.markIntentHandedOff(id, walletAddress);
286+
} else if (status === 'consumed') {
287+
const draftId = parseInt(consumed_draft_id, 10);
288+
if (isNaN(draftId)) return res.status(400).json({ error: 'consumed_draft_id is required when transitioning to consumed' });
289+
ok = db.markIntentConsumed(id, walletAddress, draftId);
290+
} else if (status === 'abandoned') {
291+
// Generic update path
292+
const dbAny = (req.app.locals.db as DatabaseManager) as any;
293+
const result = dbAny.getDB().prepare(`
294+
UPDATE publish_intents SET status = 'abandoned', updated_at = datetime('now')
295+
WHERE id = ? AND wallet_address = ? AND status IN ('draft', 'handed_off')
296+
`).run(id, walletAddress.toLowerCase());
297+
ok = result.changes > 0;
298+
} else {
299+
return res.status(400).json({ error: `Invalid status transition target: ${status}` });
300+
}
301+
302+
if (!ok) return res.status(409).json({ error: `Cannot transition intent from ${existing.status} to ${status}` });
303+
304+
const fresh = db.getIntentById(id, walletAddress);
305+
res.json(decorateRow(fresh));
306+
} catch (error: any) {
307+
logger.error(`[Intents] Status error: ${error.message}`);
308+
res.status(500).json({ error: 'Failed to update intent status' });
309+
}
310+
});
311+
312+
/**
313+
* DELETE /api/intents/:id
314+
* Hard delete (only intents in draft / abandoned status).
315+
*/
316+
router.delete('/:id', authenticate, async (req: AuthenticatedRequest, res: Response) => {
317+
try {
318+
const walletAddress = req.user?.wallet_address;
319+
if (!walletAddress) return res.status(401).json({ error: 'Not authenticated' });
320+
321+
const id = parseInt(req.params.id, 10);
322+
if (isNaN(id)) return res.status(400).json({ error: 'Invalid intent ID' });
323+
324+
const db = req.app.locals.db as DatabaseManager;
325+
const existing = db.getIntentById(id, walletAddress);
326+
if (!existing) return res.status(404).json({ error: 'Intent not found' });
327+
if (existing.status === 'handed_off' || existing.status === 'consumed') {
328+
return res.status(409).json({ error: `Cannot delete a ${existing.status} intent; mark abandoned first if needed` });
329+
}
330+
331+
const ok = db.deleteIntent(id, walletAddress);
332+
if (!ok) return res.status(404).json({ error: 'Intent not found' });
333+
334+
logger.info(`[Intents] Deleted intent #${id} for ${walletAddress.slice(0, 10)}...`);
335+
res.json({ deleted: true });
336+
} catch (error: any) {
337+
logger.error(`[Intents] Delete error: ${error.message}`);
338+
res.status(500).json({ error: 'Failed to delete intent' });
339+
}
340+
});
341+
342+
export default router;

pc2-node/src/services/ai/AIChatService.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { normalizeToolsObject } from './utils/FunctionCalling.js';
1717
import { filesystemTools } from './tools/FilesystemTools.js';
1818
import { walletTools } from './tools/WalletTools.js';
1919
import { settingsTools } from './tools/SettingsTools.js';
20+
import { monetisationAgentTools } from './tools/MonetisationAgentTools.js';
2021
import { agentKitTools } from './tools/AgentKitTools.js';
2122
import { skillsTools } from './tools/SkillsTools.js';
2223
import { canvasTools } from './tools/CanvasTools.js';
@@ -531,8 +532,8 @@ export class AIChatService {
531532
} else if (args.filesystem && args.walletAddress) {
532533
// Automatically include all AI tools if filesystem is available
533534
// This allows AI to perform filesystem, wallet, settings, and AgentKit operations
534-
const allTools = [...filesystemTools, ...walletTools, ...settingsTools, ...agentKitTools, ...skillsTools, ...canvasTools, ...agentTools];
535-
logger.info('[AIChatService] Auto-including all AI tools - filesystem:', filesystemTools.length, 'wallet:', walletTools.length, 'settings:', settingsTools.length, 'agentKit:', agentKitTools.length, 'skills:', skillsTools.length, 'canvas:', canvasTools.length, 'agent:', agentTools.length, 'total:', allTools.length);
535+
const allTools = [...filesystemTools, ...walletTools, ...settingsTools, ...agentKitTools, ...skillsTools, ...canvasTools, ...agentTools, ...monetisationAgentTools];
536+
logger.info('[AIChatService] Auto-including all AI tools - filesystem:', filesystemTools.length, 'wallet:', walletTools.length, 'settings:', settingsTools.length, 'agentKit:', agentKitTools.length, 'skills:', skillsTools.length, 'canvas:', canvasTools.length, 'agent:', agentTools.length, 'monetisation:', monetisationAgentTools.length, 'total:', allTools.length);
536537
tools = normalizeToolsObject(allTools);
537538

538539
// Mark all tools by their type

0 commit comments

Comments
 (0)