From 5ab85ce94aa6eb3cd623693aefb7761ac6baa0e4 Mon Sep 17 00:00:00 2001 From: Franlinozz Date: Thu, 4 Jun 2026 14:34:38 +0200 Subject: [PATCH] feat(skills): add live text utility skills --- apps/edge/src/index.ts | 38 ++++++++++++++----- .../src/components/agents/AgentDetailTabs.tsx | 8 ++-- .../agents/wizard/WizardStepSkills.tsx | 3 ++ apps/web/src/lib/skill-output.test.ts | 15 ++++++++ apps/web/src/lib/skill-output.ts | 23 ++++++++--- skills/core/src/index.ts | 9 +++++ skills/core/text.keywords/README.md | 3 ++ skills/core/text.keywords/handler.ts | 28 ++++++++++++++ skills/core/text.keywords/manifest.ts | 18 +++++++++ .../tests/text.keywords.integration.test.ts | 9 +++++ skills/core/text.rewrite/README.md | 3 ++ skills/core/text.rewrite/handler.ts | 14 +++++++ skills/core/text.rewrite/manifest.ts | 18 +++++++++ .../tests/text.rewrite.integration.test.ts | 9 +++++ skills/core/text.title/README.md | 3 ++ skills/core/text.title/handler.ts | 15 ++++++++ skills/core/text.title/manifest.ts | 18 +++++++++ .../tests/text.title.integration.test.ts | 9 +++++ 18 files changed, 224 insertions(+), 19 deletions(-) create mode 100644 skills/core/text.keywords/README.md create mode 100644 skills/core/text.keywords/handler.ts create mode 100644 skills/core/text.keywords/manifest.ts create mode 100644 skills/core/text.keywords/tests/text.keywords.integration.test.ts create mode 100644 skills/core/text.rewrite/README.md create mode 100644 skills/core/text.rewrite/handler.ts create mode 100644 skills/core/text.rewrite/manifest.ts create mode 100644 skills/core/text.rewrite/tests/text.rewrite.integration.test.ts create mode 100644 skills/core/text.title/README.md create mode 100644 skills/core/text.title/handler.ts create mode 100644 skills/core/text.title/manifest.ts create mode 100644 skills/core/text.title/tests/text.title.integration.test.ts diff --git a/apps/edge/src/index.ts b/apps/edge/src/index.ts index 4fd702d..f1325db 100644 --- a/apps/edge/src/index.ts +++ b/apps/edge/src/index.ts @@ -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; const stripWrappingQuotes = (value: string): string => value.trim().replace(/^(["'“”])(.+)\1$/s, '$2').trim(); @@ -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'] }, @@ -2265,6 +2268,12 @@ 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); @@ -2272,6 +2281,12 @@ export function buildEdgeServer(options: EdgeServerOptions): FastifyInstance { 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'); @@ -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":""}]}\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":""}]}\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":""}]}\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 { diff --git a/apps/web/src/components/agents/AgentDetailTabs.tsx b/apps/web/src/components/agents/AgentDetailTabs.tsx index c725494..79f7679 100644 --- a/apps/web/src/components/agents/AgentDetailTabs.tsx +++ b/apps/web/src/components/agents/AgentDetailTabs.tsx @@ -233,7 +233,7 @@ type SkillFormState = { prompt: string; }; -const INVOKABLE_SKILLS = new Set(['chat.completion', 'text.summarize', 'text.translate', 'text.sentiment', 'text.entities', 'code.review']); +const INVOKABLE_SKILLS = new Set(['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 { @@ -243,8 +243,10 @@ function sameAddr(a?: string, b?: string): boolean { function buildSkillPayload(skillId: string, form: SkillFormState, agentId: string): Record { 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 }; } @@ -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.'; } diff --git a/apps/web/src/components/agents/wizard/WizardStepSkills.tsx b/apps/web/src/components/agents/wizard/WizardStepSkills.tsx index 6bb4d8a..62ae7d9 100644 --- a/apps/web/src/components/agents/wizard/WizardStepSkills.tsx +++ b/apps/web/src/components/agents/wizard/WizardStepSkills.tsx @@ -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: [] }, diff --git a/apps/web/src/lib/skill-output.test.ts b/apps/web/src/lib/skill-output.test.ts index e7a1486..4c8f601 100644 --- a/apps/web/src/lib/skill-output.test.ts +++ b/apps/web/src/lib/skill-output.test.ts @@ -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 }); }); @@ -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 }); }); diff --git a/apps/web/src/lib/skill-output.ts b/apps/web/src/lib/skill-output.ts index 4e60bb2..20c8bc0 100644 --- a/apps/web/src/lib/skill-output.ts +++ b/apps/web/src/lib/skill-output.ts @@ -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 } @@ -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); @@ -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 }] : []; }); diff --git a/skills/core/src/index.ts b/skills/core/src/index.ts index 467627f..75fd86c 100644 --- a/skills/core/src/index.ts +++ b/skills/core/src/index.ts @@ -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'; @@ -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], diff --git a/skills/core/text.keywords/README.md b/skills/core/text.keywords/README.md new file mode 100644 index 0000000..beb709f --- /dev/null +++ b/skills/core/text.keywords/README.md @@ -0,0 +1,3 @@ +# text.keywords + +Extract key terms and phrases with 0G Compute. diff --git a/skills/core/text.keywords/handler.ts b/skills/core/text.keywords/handler.ts new file mode 100644 index 0000000..529ac80 --- /dev/null +++ b/skills/core/text.keywords/handler.ts @@ -0,0 +1,28 @@ +import type { SkillHandler, SkillHandlerContext } from '@apogee/skills-runtime'; + +const handler = (async function handler(raw: unknown, ctx: SkillHandlerContext): Promise { + const input = raw as { text: string }; + const response = await ctx.call('compute.chat', [{ + messages: [ + { 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\":\"\"}]}\n\nIf no keywords are found, return {\"keywords\":[]}.\n\nTEXT:\n${input.text}` }, + ], + maxTokens: 400, + }]) as { content?: string; reasoning_content?: string }; + const rawText = String(response.content || response.reasoning_content || '').trim(); + const cleaned = rawText.replace(/^```(?:json)?\s*/i, '').replace(/```$/i, '').trim(); + try { + const parsed = JSON.parse(cleaned) as { keywords?: unknown }; + const allowed = new Set(['KEYWORD', 'PHRASE']); + const keywords = Array.isArray(parsed.keywords) ? parsed.keywords.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) as 'KEYWORD' | 'PHRASE', value: item.value.trim() }]; + }) : []; + return { keywords }; + } catch { + await ctx.log('warn', 'text.keywords.parse_fallback', { raw: rawText.slice(0, 500) }); + return { keywords: [] }; + } +}) as SkillHandler; + +export default handler; diff --git a/skills/core/text.keywords/manifest.ts b/skills/core/text.keywords/manifest.ts new file mode 100644 index 0000000..dfb4f39 --- /dev/null +++ b/skills/core/text.keywords/manifest.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import type { SkillManifest } from '@apogee/skills-runtime'; + +const manifest = { + id: 'text.keywords', + version: '1.0.0', + author: { name: 'APOGEE Protocol' }, + description: 'Extract key terms and phrases with 0G Compute.', + inputs: z.object({ text: z.string().min(1).max(10_000) }), + outputs: z.object({ keywords: z.array(z.object({ type: z.enum(['KEYWORD', 'PHRASE']), value: z.string() })) }), + sideEffects: ['compute'], + declaredEgress: [], + pricePerCallWei: 0n, + requiresEnv: [], + timeoutMs: 30_000, +} satisfies SkillManifest; + +export default manifest; diff --git a/skills/core/text.keywords/tests/text.keywords.integration.test.ts b/skills/core/text.keywords/tests/text.keywords.integration.test.ts new file mode 100644 index 0000000..6831709 --- /dev/null +++ b/skills/core/text.keywords/tests/text.keywords.integration.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; +import manifest from '../manifest.js'; + +describe('text.keywords', () => { + it('declares compute side effect', () => { + expect(manifest.id).toBe('text.keywords'); + expect(manifest.sideEffects).toContain('compute'); + }); +}); diff --git a/skills/core/text.rewrite/README.md b/skills/core/text.rewrite/README.md new file mode 100644 index 0000000..7c9f4e7 --- /dev/null +++ b/skills/core/text.rewrite/README.md @@ -0,0 +1,3 @@ +# text.rewrite + +Rewrite text clearly and formally with 0G Compute. diff --git a/skills/core/text.rewrite/handler.ts b/skills/core/text.rewrite/handler.ts new file mode 100644 index 0000000..e013ad8 --- /dev/null +++ b/skills/core/text.rewrite/handler.ts @@ -0,0 +1,14 @@ +import type { SkillHandler, SkillHandlerContext } from '@apogee/skills-runtime'; + +const handler = (async function handler(raw: unknown, ctx: SkillHandlerContext): Promise { + const input = raw as { text: string }; + const response = await ctx.call('compute.chat', [{ + 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${input.text}` }, + ], + }]) as { content?: string; reasoning_content?: string }; + const text = String(response.content || response.reasoning_content || '').trim().replace(/^(["'“”])(.+)\1$/s, '$2').trim(); + return { rewrite: text }; +}) as SkillHandler; + +export default handler; diff --git a/skills/core/text.rewrite/manifest.ts b/skills/core/text.rewrite/manifest.ts new file mode 100644 index 0000000..e297c7e --- /dev/null +++ b/skills/core/text.rewrite/manifest.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import type { SkillManifest } from '@apogee/skills-runtime'; + +const manifest = { + id: 'text.rewrite', + version: '1.0.0', + author: { name: 'APOGEE Protocol' }, + description: 'Rewrite text clearly and formally with 0G Compute.', + inputs: z.object({ text: z.string().min(1).max(10_000) }), + outputs: z.object({ rewrite: z.string() }), + sideEffects: ['compute'], + declaredEgress: [], + pricePerCallWei: 0n, + requiresEnv: [], + timeoutMs: 30_000, +} satisfies SkillManifest; + +export default manifest; diff --git a/skills/core/text.rewrite/tests/text.rewrite.integration.test.ts b/skills/core/text.rewrite/tests/text.rewrite.integration.test.ts new file mode 100644 index 0000000..f20f29a --- /dev/null +++ b/skills/core/text.rewrite/tests/text.rewrite.integration.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; +import manifest from '../manifest.js'; + +describe('text.rewrite', () => { + it('declares compute side effect', () => { + expect(manifest.id).toBe('text.rewrite'); + expect(manifest.sideEffects).toContain('compute'); + }); +}); diff --git a/skills/core/text.title/README.md b/skills/core/text.title/README.md new file mode 100644 index 0000000..191d870 --- /dev/null +++ b/skills/core/text.title/README.md @@ -0,0 +1,3 @@ +# text.title + +Generate short titles with 0G Compute. diff --git a/skills/core/text.title/handler.ts b/skills/core/text.title/handler.ts new file mode 100644 index 0000000..fd787c9 --- /dev/null +++ b/skills/core/text.title/handler.ts @@ -0,0 +1,15 @@ +import type { SkillHandler, SkillHandlerContext } from '@apogee/skills-runtime'; + +const handler = (async function handler(raw: unknown, ctx: SkillHandlerContext): Promise { + const input = raw as { text: string }; + const response = await ctx.call('compute.chat', [{ + 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${input.text}` }, + ], + maxTokens: 160, + }]) as { content?: string; reasoning_content?: string }; + const text = String(response.content || response.reasoning_content || '').trim().replace(/^(["'“”])(.+)\1$/s, '$2').trim(); + return { title: text }; +}) as SkillHandler; + +export default handler; diff --git a/skills/core/text.title/manifest.ts b/skills/core/text.title/manifest.ts new file mode 100644 index 0000000..144edc8 --- /dev/null +++ b/skills/core/text.title/manifest.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import type { SkillManifest } from '@apogee/skills-runtime'; + +const manifest = { + id: 'text.title', + version: '1.0.0', + author: { name: 'APOGEE Protocol' }, + description: 'Generate short titles with 0G Compute.', + inputs: z.object({ text: z.string().min(1).max(10_000) }), + outputs: z.object({ title: z.string() }), + sideEffects: ['compute'], + declaredEgress: [], + pricePerCallWei: 0n, + requiresEnv: [], + timeoutMs: 30_000, +} satisfies SkillManifest; + +export default manifest; diff --git a/skills/core/text.title/tests/text.title.integration.test.ts b/skills/core/text.title/tests/text.title.integration.test.ts new file mode 100644 index 0000000..fac06af --- /dev/null +++ b/skills/core/text.title/tests/text.title.integration.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; +import manifest from '../manifest.js'; + +describe('text.title', () => { + it('declares compute side effect', () => { + expect(manifest.id).toBe('text.title'); + expect(manifest.sideEffects).toContain('compute'); + }); +});