Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 28 additions & 10 deletions apps/edge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ export const skillInvokeBootstrapComplete = (
export const skillInvokeCanRunAfterBootstrap = (status: AgentRecord['status'], bootstrapComplete: boolean, deploymentStatus?: DeploymentRecord['status']): boolean =>
status === 'active' || deploymentStatus === 'active' || ((status === 'initialized' || status === 'ready') && bootstrapComplete);

const LIVE_SKILL_IDS = new Set(['chat.completion', 'code.review', 'text.entities', 'text.sentiment', 'text.summarize', 'text.translate']);
const skillInvokeParamsSchema = z.object({ skillId: z.enum(['chat.completion', 'code.review', 'text.entities', 'text.sentiment', 'text.summarize', 'text.translate']) });
const LIVE_SKILL_IDS = new Set(['chat.completion', 'code.review', 'text.entities', 'text.keywords', 'text.rewrite', 'text.sentiment', 'text.summarize', 'text.title', 'text.translate']);
const skillInvokeParamsSchema = z.object({ skillId: z.enum(['chat.completion', 'code.review', 'text.entities', 'text.keywords', 'text.rewrite', 'text.sentiment', 'text.summarize', 'text.title', 'text.translate']) });
const skillInvokeBodySchema = z.record(z.string(), jsonValueSchema);
type SkillInvokeBody = z.infer<typeof skillInvokeBodySchema>;
const stripWrappingQuotes = (value: string): string => value.trim().replace(/^(["'“”])(.+)\1$/s, '$2').trim();
Expand Down Expand Up @@ -229,8 +229,11 @@ const DEFAULT_SKILLS = [
{ id: 'chat.completion', name: 'Chat Completion', version: '1.0.0', description: 'LLM chat via 0G Compute', category: 'AI', tier: 'free' as const, pricePerCallWei: '0', tags: ['ai', 'compute'], live: true },
{ id: 'code.review', name: 'Code Review', version: '1.0.0', description: 'Review code for bugs, style, and clarity with 0G Compute', category: 'Code', tier: 'free' as const, pricePerCallWei: '0', tags: ['code', 'compute'], live: true },
{ id: 'text.entities', name: 'Text Entities', version: '1.0.0', description: 'Extract named entities with 0G Compute', category: 'Text', tier: 'free' as const, pricePerCallWei: '0', tags: ['text', 'compute'], live: true },
{ id: 'text.keywords', name: 'Text Keywords', version: '1.0.0', description: 'Extract key terms and phrases with 0G Compute', category: 'Text', tier: 'free' as const, pricePerCallWei: '0', tags: ['text', 'compute'], live: true },
{ id: 'text.rewrite', name: 'Text Rewrite', version: '1.0.0', description: 'Rewrite text clearly and formally with 0G Compute', category: 'Text', tier: 'free' as const, pricePerCallWei: '0', tags: ['text', 'compute'], live: true },
{ id: 'text.sentiment', name: 'Text Sentiment', version: '1.0.0', description: 'Classify sentiment with 0G Compute', category: 'Text', tier: 'free' as const, pricePerCallWei: '0', tags: ['text', 'compute'], live: true },
{ id: 'text.summarize', name: 'Text Summarize', version: '1.0.0', description: 'Summarize text with 0G Compute', category: 'Text', tier: 'free' as const, pricePerCallWei: '0', tags: ['text', 'compute'], live: true },
{ id: 'text.title', name: 'Text Title', version: '1.0.0', description: 'Generate short titles with 0G Compute', category: 'Text', tier: 'free' as const, pricePerCallWei: '0', tags: ['text', 'compute'], live: true },
{ id: 'text.translate', name: 'Text Translate', version: '1.0.0', description: 'Translate text with 0G Compute', category: 'Text', tier: 'free' as const, pricePerCallWei: '0', tags: ['text', 'compute'], live: true },
{ id: 'memory.write', name: 'Memory Write', version: '1.0.0', description: 'Persist encrypted memory state to 0G Storage', category: 'Memory', tier: 'free' as const, pricePerCallWei: '0', tags: ['memory', 'storage'] },
{ id: 'memory.read', name: 'Memory Read', version: '1.0.0', description: 'Read agent memory entries', category: 'Memory', tier: 'free' as const, pricePerCallWei: '0', tags: ['memory'] },
Expand Down Expand Up @@ -2265,13 +2268,25 @@ export function buildEdgeServer(options: EdgeServerOptions): FastifyInstance {
messages = [{ role: 'user', content: `Summarize the following in ${maxWords} words or fewer.\nReturn only the summary text, no preamble or commentary.\n\nTEXT:\n${text}` }];
maxTokens = Math.max(SKILL_COMPLETION_TOKEN_BUDGET, Math.min(2048, maxWords * 8));
shapeOutput = (text) => ({ summary: stripWrappingQuotes(text) });
} else if (skillId === 'text.title') {
const text = requireString('text', 10_000);
if (!text) return problem(reply, 400, 'Bad Request', 'text is required and must be 1-10000 characters');
messages = [{ role: 'user', content: `Generate a short, clear title or headline for the following text.\nReturn only the title, no preamble, no commentary, and no quotation marks.\n\nTEXT:\n${text}` }];
maxTokens = SKILL_COMPLETION_TOKEN_BUDGET;
shapeOutput = (text) => ({ title: stripWrappingQuotes(text) });
} else if (skillId === 'text.translate') {
const text = requireString('text', 10_000);
const targetLanguage = requireString('targetLanguage', 80);
if (!text || !targetLanguage) return problem(reply, 400, 'Bad Request', 'text and targetLanguage are required');
messages = [{ role: 'user', content: `Translate the following text to ${targetLanguage}.\nReturn only the translation, no preamble or commentary, no parenthetical notes.\n\nTEXT:\n${text}` }];
maxTokens = SKILL_COMPLETION_TOKEN_BUDGET;
shapeOutput = (text) => ({ translation: stripWrappingQuotes(text) });
} else if (skillId === 'text.rewrite') {
const text = requireString('text', 10_000);
if (!text) return problem(reply, 400, 'Bad Request', 'text is required and must be 1-10000 characters');
messages = [{ role: 'user', content: `Rewrite the following text to be clearer and more formal while preserving the original meaning.\nReturn only the rewritten text, no preamble or commentary.\n\nTEXT:\n${text}` }];
maxTokens = SKILL_COMPLETION_TOKEN_BUDGET;
shapeOutput = (text) => ({ rewrite: stripWrappingQuotes(text) });
} else if (skillId === 'text.sentiment') {
const text = requireString('text', 10_000);
if (!text) return problem(reply, 400, 'Bad Request', 'text is required and must be 1-10000 characters');
Expand All @@ -2289,24 +2304,27 @@ export function buildEdgeServer(options: EdgeServerOptions): FastifyInstance {
return { sentiment: 'neutral', score: 0.5 };
}
};
} else if (skillId === 'text.entities') {
} else if (skillId === 'text.entities' || skillId === 'text.keywords') {
const text = requireString('text', 10_000);
if (!text) return problem(reply, 400, 'Bad Request', 'text is required and must be 1-10000 characters');
messages = [{ role: 'user', content: `Extract named entities from the following text. Respond with ONLY valid JSON in this exact shape — no preamble, no markdown fences, no extra fields:\n{"entities":[{"type":"PERSON"|"PLACE"|"ORG"|"OTHER","value":"<string>"}]}\n\nIf no entities are found, return {"entities":[]}.\n\nTEXT:\n${text}` }];
const outputKey = skillId === 'text.keywords' ? 'keywords' : 'entities';
messages = skillId === 'text.keywords'
? [{ role: 'user', content: `Extract the key terms and short phrases from the following text. Respond with ONLY valid JSON in this exact shape — no preamble, no markdown fences, no extra fields:\n{"keywords":[{"type":"KEYWORD"|"PHRASE","value":"<string>"}]}\n\nIf no keywords are found, return {"keywords":[]}.\n\nTEXT:\n${text}` }]
: [{ role: 'user', content: `Extract named entities from the following text. Respond with ONLY valid JSON in this exact shape — no preamble, no markdown fences, no extra fields:\n{"entities":[{"type":"PERSON"|"PLACE"|"ORG"|"OTHER","value":"<string>"}]}\n\nIf no entities are found, return {"entities":[]}.\n\nTEXT:\n${text}` }];
maxTokens = SKILL_COMPLETION_TOKEN_BUDGET;
shapeOutput = (text) => {
try {
const parsed = JSON.parse(stripJsonFences(text)) as { entities?: unknown };
const allowed = new Set(['PERSON', 'PLACE', 'ORG', 'OTHER']);
const entities = Array.isArray(parsed.entities) ? parsed.entities.flatMap((entry) => {
const parsed = JSON.parse(stripJsonFences(text)) as { entities?: unknown; keywords?: unknown };
const allowed = skillId === 'text.keywords' ? new Set(['KEYWORD', 'PHRASE']) : new Set(['PERSON', 'PLACE', 'ORG', 'OTHER']);
const entries = Array.isArray(parsed[outputKey]) ? parsed[outputKey].flatMap((entry) => {
const item = entry as { type?: unknown; value?: unknown };
if (!allowed.has(String(item.type)) || typeof item.value !== 'string' || item.value.trim().length === 0) return [];
return [{ type: String(item.type), value: item.value.trim() }];
}) : [];
return { entities };
return skillId === 'text.keywords' ? { keywords: entries } : { entities: entries };
} catch {
request.log.warn({ raw: text.slice(0, 500) }, 'text.entities.parse_fallback');
return { entities: [] };
request.log.warn({ raw: text.slice(0, 500) }, `${skillId}.parse_fallback`);
return skillId === 'text.keywords' ? { keywords: [] } : { entities: [] };
}
};
} else {
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/components/agents/AgentDetailTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ type SkillFormState = {
prompt: string;
};

const INVOKABLE_SKILLS = new Set<string>(['chat.completion', 'text.summarize', 'text.translate', 'text.sentiment', 'text.entities', 'code.review']);
const INVOKABLE_SKILLS = new Set<string>(['chat.completion', 'text.summarize', 'text.title', 'text.translate', 'text.rewrite', 'text.sentiment', 'text.entities', 'text.keywords', 'code.review']);
const DEFAULT_FORM: SkillFormState = { text: '', maxWords: 80, targetLanguage: '', code: '', language: '', prompt: '' };

function sameAddr(a?: string, b?: string): boolean {
Expand All @@ -243,8 +243,10 @@ function sameAddr(a?: string, b?: string): boolean {
function buildSkillPayload(skillId: string, form: SkillFormState, agentId: string): Record<string, unknown> {
if (skillId === 'chat.completion') return { agentId, messages: [{ role: 'user', content: form.prompt.trim() }] };
if (skillId === 'text.summarize') return { agentId, text: form.text.trim(), maxWords: form.maxWords };
if (skillId === 'text.title') return { agentId, text: form.text.trim() };
if (skillId === 'text.translate') return { agentId, text: form.text.trim(), targetLanguage: form.targetLanguage.trim() };
if (skillId === 'text.sentiment' || skillId === 'text.entities') return { agentId, text: form.text.trim() };
if (skillId === 'text.rewrite') return { agentId, text: form.text.trim() };
if (skillId === 'text.sentiment' || skillId === 'text.entities' || skillId === 'text.keywords') return { agentId, text: form.text.trim() };
if (skillId === 'code.review') return { agentId, code: form.code.trim(), language: form.language.trim() || undefined };
return { agentId };
}
Expand All @@ -257,7 +259,7 @@ function validateSkillForm(skillId: string, form: SkillFormState): string | null
return null;
}
if (skillId === 'code.review') return form.code.trim() ? null : 'Code is required.';
if (skillId === 'text.summarize' || skillId === 'text.sentiment' || skillId === 'text.entities') return form.text.trim() ? null : 'Text is required.';
if (skillId === 'text.summarize' || skillId === 'text.title' || skillId === 'text.rewrite' || skillId === 'text.sentiment' || skillId === 'text.entities' || skillId === 'text.keywords') return form.text.trim() ? null : 'Text is required.';
return 'This skill is not runnable from the UI yet.';
}

Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/agents/wizard/WizardStepSkills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ const FREE_SKILLS: SkillManifest[] = [
{ id: 'chat.completion', name: 'Chat Completion', version: '1.0.0', description: 'LLM chat via 0G Compute', category: 'AI', tier: 'free', pricePerCallWei: '0', tags: ['ai', 'compute'], live: true },
{ id: 'code.review', name: 'Code Review', version: '1.0.0', description: 'Review code for bugs, style, and clarity with 0G Compute', category: 'Code', tier: 'free', pricePerCallWei: '0', tags: ['code', 'compute'], live: true },
{ id: 'text.entities', name: 'Text Entities', version: '1.0.0', description: 'Extract named entities with 0G Compute', category: 'Text', tier: 'free', pricePerCallWei: '0', tags: ['text', 'compute'], live: true },
{ id: 'text.keywords', name: 'Text Keywords', version: '1.0.0', description: 'Extract key terms and phrases with 0G Compute', category: 'Text', tier: 'free', pricePerCallWei: '0', tags: ['text', 'compute'], live: true },
{ id: 'text.rewrite', name: 'Text Rewrite', version: '1.0.0', description: 'Rewrite text clearly and formally with 0G Compute', category: 'Text', tier: 'free', pricePerCallWei: '0', tags: ['text', 'compute'], live: true },
{ id: 'text.sentiment', name: 'Text Sentiment', version: '1.0.0', description: 'Classify sentiment with 0G Compute', category: 'Text', tier: 'free', pricePerCallWei: '0', tags: ['text', 'compute'], live: true },
{ id: 'text.summarize', name: 'Text Summarize', version: '1.0.0', description: 'Summarize text with 0G Compute', category: 'Text', tier: 'free', pricePerCallWei: '0', tags: ['text', 'compute'], live: true },
{ id: 'text.title', name: 'Text Title', version: '1.0.0', description: 'Generate short titles with 0G Compute', category: 'Text', tier: 'free', pricePerCallWei: '0', tags: ['text', 'compute'], live: true },
{ id: 'text.translate', name: 'Text Translate', version: '1.0.0', description: 'Translate text with 0G Compute', category: 'Text', tier: 'free', pricePerCallWei: '0', tags: ['text', 'compute'], live: true },
{ id: 'memory.write', name: 'Memory Write', version: '1.0.0', description: 'Persist encrypted memory', category: 'Memory', tier: 'free', pricePerCallWei: '0', tags: [] },
{ id: 'memory.read', name: 'Memory Read', version: '1.0.0', description: 'Read encrypted memory', category: 'Memory', tier: 'free', pricePerCallWei: '0', tags: [] },
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/lib/skill-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ describe('normalizeSkillOutput', () => {
expect(normalizeSkillOutput('text.translate', { translation: 'Bonjour.' })).toEqual({ kind: 'text', text: 'Bonjour.' });
});

it('renders text.title title text', () => {
expect(normalizeSkillOutput('text.title', { title: 'A crisp headline' })).toEqual({ kind: 'text', text: 'A crisp headline' });
});

it('renders text.rewrite rewritten text', () => {
expect(normalizeSkillOutput('text.rewrite', { rewrite: 'This is clearer.' })).toEqual({ kind: 'text', text: 'This is clearer.' });
});

it('renders text.sentiment label and score', () => {
expect(normalizeSkillOutput('text.sentiment', { sentiment: 'positive', score: 0.873 })).toEqual({ kind: 'sentiment', sentiment: 'positive', score: 0.873 });
});
Expand All @@ -25,6 +33,13 @@ describe('normalizeSkillOutput', () => {
});
});

it('renders text.keywords entries with normalized badges', () => {
expect(normalizeSkillOutput('text.keywords', { keywords: [{ type: 'KEYWORD', value: 'agent runtime' }, { type: 'unknown', value: '0G' }] })).toEqual({
kind: 'entities',
entities: [{ type: 'KEYWORD', value: 'agent runtime' }, { type: 'KEYWORD', value: '0G' }],
});
});

it('renders code.review in monospace mode', () => {
expect(normalizeSkillOutput('code.review', { review: '- Looks good\n- Add tests' })).toEqual({ kind: 'text', text: '- Looks good\n- Add tests', mono: true });
});
Expand Down
23 changes: 17 additions & 6 deletions apps/web/src/lib/skill-output.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type EntityType = 'PERSON' | 'PLACE' | 'ORG' | 'OTHER';
type EntityType = 'PERSON' | 'PLACE' | 'ORG' | 'OTHER' | 'KEYWORD' | 'PHRASE';

export type RenderedSkillOutput =
| { kind: 'text'; text: string; mono?: boolean }
Expand Down Expand Up @@ -40,11 +40,21 @@ export function normalizeSkillOutput(skillId: string, output: unknown): Rendered
return summary ? { kind: 'text', text: summary } : unexpected(output);
}

if (skillId === 'text.title') {
const title = asString(record.title);
return title ? { kind: 'text', text: title } : unexpected(output);
}

if (skillId === 'text.translate') {
const translation = asString(record.translation);
return translation ? { kind: 'text', text: translation } : unexpected(output);
}

if (skillId === 'text.rewrite') {
const rewrite = asString(record.rewrite);
return rewrite ? { kind: 'text', text: rewrite } : unexpected(output);
}

if (skillId === 'code.review') {
const review = asString(record.review);
return review ? { kind: 'text', text: review, mono: true } : unexpected(output);
Expand All @@ -63,12 +73,13 @@ export function normalizeSkillOutput(skillId: string, output: unknown): Rendered
return { kind: 'sentiment', sentiment, score: Math.min(1, Math.max(0, rawScore)) };
}

if (skillId === 'text.entities') {
if (!Array.isArray(record.entities)) return unexpected(output);
const entities = record.entities.flatMap((entry) => {
if (skillId === 'text.entities' || skillId === 'text.keywords') {
const key = skillId === 'text.keywords' ? 'keywords' : 'entities';
if (!Array.isArray(record[key])) return unexpected(output);
const entities = record[key].flatMap((entry) => {
if (!isRecord(entry)) return [];
const rawType = typeof entry.type === 'string' ? entry.type.toUpperCase() : 'OTHER';
const type: EntityType = rawType === 'PERSON' || rawType === 'PLACE' || rawType === 'ORG' || rawType === 'OTHER' ? rawType : 'OTHER';
const rawType = typeof entry.type === 'string' ? entry.type.toUpperCase() : (skillId === 'text.keywords' ? 'KEYWORD' : 'OTHER');
const type: EntityType = rawType === 'PERSON' || rawType === 'PLACE' || rawType === 'ORG' || rawType === 'OTHER' || rawType === 'KEYWORD' || rawType === 'PHRASE' ? rawType : (skillId === 'text.keywords' ? 'KEYWORD' : 'OTHER');
const value = asString(entry.value);
return value ? [{ type, value }] : [];
});
Expand Down
9 changes: 9 additions & 0 deletions skills/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,16 @@ import codeReviewManifest from '../code.review/manifest.js';
import codeReviewHandler from '../code.review/handler.js';
import textEntitiesManifest from '../text.entities/manifest.js';
import textEntitiesHandler from '../text.entities/handler.js';
import textKeywordsManifest from '../text.keywords/manifest.js';
import textKeywordsHandler from '../text.keywords/handler.js';
import textSentimentManifest from '../text.sentiment/manifest.js';
import textSentimentHandler from '../text.sentiment/handler.js';
import textSummarizeManifest from '../text.summarize/manifest.js';
import textSummarizeHandler from '../text.summarize/handler.js';
import textRewriteManifest from '../text.rewrite/manifest.js';
import textRewriteHandler from '../text.rewrite/handler.js';
import textTitleManifest from '../text.title/manifest.js';
import textTitleHandler from '../text.title/handler.js';
import textTranslateManifest from '../text.translate/manifest.js';
import textTranslateHandler from '../text.translate/handler.js';

Expand All @@ -48,8 +54,11 @@ export const coreSkills = [
[memoryWriteManifest, memoryWriteHandler],
[storageUploadManifest, storageUploadHandler],
[textEntitiesManifest, textEntitiesHandler],
[textKeywordsManifest, textKeywordsHandler],
[textSentimentManifest, textSentimentHandler],
[textSummarizeManifest, textSummarizeHandler],
[textRewriteManifest, textRewriteHandler],
[textTitleManifest, textTitleHandler],
[textTranslateManifest, textTranslateHandler],
[webFetchManifest, webFetchHandler],
[webSearchManifest, webSearchHandler],
Expand Down
3 changes: 3 additions & 0 deletions skills/core/text.keywords/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# text.keywords

Extract key terms and phrases with 0G Compute.
Loading
Loading