@aion0/forge 0.9.12 → 0.9.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/RELEASE_NOTES.md CHANGED
@@ -1,13 +1,15 @@
1
- # Forge v0.9.12
1
+ # Forge v0.9.14
2
2
 
3
3
  Released: 2026-05-27
4
4
 
5
- ## Changes since v0.9.11
5
+ ## Changes since v0.9.13
6
6
 
7
7
  ### Other
8
- - fix(settings): id-collision guards on agent + profile add
9
- - fix(settings): functional setSettings in profile add — preserves new agent
10
- - fix(settings): adding an Agent no longer creates a CLI Profile
8
+ - fix(pipeline): apply workflow.input defaults for missing fields
9
+ - feat(chat): trigger_pipeline schema validation + self-evolving rules
10
+ - feat(chat): pipeline input schemas + Forge context tools
11
+ - refactor(chat): LLM layer on Vercel AI SDK (provider-agnostic streaming)
12
+ - feat(chat): trigger_pipeline + dispatch_task builtin tools
11
13
 
12
14
 
13
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.11...v0.9.12
15
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.13...v0.9.14
@@ -0,0 +1,37 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getPrompt, updatePrompt, deletePrompt } from '@/lib/prompts/store';
3
+
4
+ // GET /api/prompts/[name]
5
+ export async function GET(_req: Request, { params }: { params: Promise<{ name: string }> }) {
6
+ const { name } = await params;
7
+ const p = getPrompt(name);
8
+ if (!p) return NextResponse.json({ error: 'not found' }, { status: 404 });
9
+ return NextResponse.json(p);
10
+ }
11
+
12
+ // PATCH /api/prompts/[name]
13
+ // body: { prompt?, executor?: { agent?, skills?, config? } }
14
+ export async function PATCH(req: Request, { params }: { params: Promise<{ name: string }> }) {
15
+ const { name } = await params;
16
+ let body: any;
17
+ try {
18
+ body = await req.json();
19
+ } catch {
20
+ return NextResponse.json({ error: 'invalid json body' }, { status: 400 });
21
+ }
22
+ try {
23
+ const p = updatePrompt(name, body);
24
+ return NextResponse.json(p);
25
+ } catch (e: any) {
26
+ const notFound = String(e?.message || '').includes('not found');
27
+ return NextResponse.json({ error: e.message }, { status: notFound ? 404 : 400 });
28
+ }
29
+ }
30
+
31
+ // DELETE /api/prompts/[name]
32
+ export async function DELETE(_req: Request, { params }: { params: Promise<{ name: string }> }) {
33
+ const { name } = await params;
34
+ return deletePrompt(name)
35
+ ? NextResponse.json({ ok: true })
36
+ : NextResponse.json({ error: 'not found' }, { status: 404 });
37
+ }
@@ -0,0 +1,35 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { listPrompts, createPrompt } from '@/lib/prompts/store';
3
+
4
+ // GET /api/prompts — list all stored prompts
5
+ export async function GET() {
6
+ return NextResponse.json(listPrompts());
7
+ }
8
+
9
+ // POST /api/prompts — create a new prompt
10
+ // body: { name, prompt, executor?: { agent?, skills?, config? } }
11
+ export async function POST(req: Request) {
12
+ let body: any;
13
+ try {
14
+ body = await req.json();
15
+ } catch {
16
+ return NextResponse.json({ error: 'invalid json body' }, { status: 400 });
17
+ }
18
+ if (!body?.name || typeof body.name !== 'string') {
19
+ return NextResponse.json({ error: 'name required' }, { status: 400 });
20
+ }
21
+ if (!body?.prompt || typeof body.prompt !== 'string') {
22
+ return NextResponse.json({ error: 'prompt required' }, { status: 400 });
23
+ }
24
+ try {
25
+ const p = createPrompt({
26
+ name: body.name,
27
+ prompt: body.prompt,
28
+ executor: body.executor,
29
+ });
30
+ return NextResponse.json(p, { status: 201 });
31
+ } catch (e: any) {
32
+ const conflict = String(e?.message || '').includes('already exists');
33
+ return NextResponse.json({ error: e.message }, { status: conflict ? 409 : 400 });
34
+ }
35
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * POST /api/schedules/extract
3
+ * body: { text: string }
4
+ * returns: { ok, extracted, confidence, missing[], raw }
5
+ *
6
+ * V3 D (#266). One-shot LLM call that parses a user's natural-language
7
+ * schedule request ("帮我每天 9 点查 mantis bug,发 telegram") into a structured
8
+ * spec the ScheduleConfirmCard renders + the user confirms. Always
9
+ * half-automatic: never auto-creates the schedule.
10
+ *
11
+ * Reuses the same API profile resolution the chat agent-loop uses, so the
12
+ * user doesn't have to configure an extra key. Anthropic-only for MVP
13
+ * (OpenAI path is a follow-up — adapter pattern is in place).
14
+ */
15
+
16
+ import { NextResponse } from 'next/server';
17
+ import { loadSettings } from '@/lib/settings';
18
+ import { inferAdapter, pickApiKey, pickBaseUrl } from '@/lib/chat/agent-loop';
19
+ import { makeAnthropicClient } from '@/lib/chat/llm/anthropic';
20
+ import { listAgents } from '@/lib/agents';
21
+ import { scanProjects } from '@/lib/projects';
22
+
23
+ interface Extracted {
24
+ name: string;
25
+ prompt_name: string;
26
+ prompt: string;
27
+ agent: string | null;
28
+ skills: string[];
29
+ project: string | null;
30
+ trigger: {
31
+ kind: 'period' | 'cron' | 'once' | 'manual';
32
+ interval_minutes: number | null;
33
+ cron: string | null;
34
+ at_iso: string | null;
35
+ };
36
+ action: {
37
+ kind: 'none' | 'chat' | 'email' | 'telegram';
38
+ config: Record<string, unknown>;
39
+ };
40
+ confidence: number;
41
+ missing: string[];
42
+ }
43
+
44
+ function buildSystem(now: string, availableAgents: string[], availableProjects: string[]): string {
45
+ return `You are the Forge Schedule extractor. Given a user's natural-language request, output ONE JSON object describing the schedule. No prose, no markdown, no code fences — just the JSON object.
46
+
47
+ Current time (ISO UTC): ${now}
48
+ Available agent ids: ${availableAgents.length ? availableAgents.join(', ') : '(none configured)'}
49
+ Available Forge projects: ${availableProjects.length ? availableProjects.join(', ') : '(none)'}
50
+
51
+ Output schema:
52
+ {
53
+ "name": string, // Schedule display name (short, human, original language OK)
54
+ "prompt_name": string, // Slug for prompts/<name>.yaml. lowercase, [a-z0-9-]
55
+ "prompt": string, // VERBATIM user prompt — do NOT rewrite or paraphrase
56
+ "agent": string|null, // Match against Available agent ids, else null
57
+ "skills": string[], // Default ["ai-orchestration"] unless user named more
58
+ "project": string|null, // Match against Available Forge projects, else null
59
+ "trigger": {
60
+ "kind": "period"|"cron"|"once"|"manual",
61
+ "interval_minutes": number|null, // for period
62
+ "cron": string|null, // for cron, standard 5-field
63
+ "at_iso": string|null // for once, ISO timestamp
64
+ },
65
+ "action": {
66
+ "kind": "none"|"chat"|"email"|"telegram",
67
+ "config": object // see below; leave sparse if user gave no detail
68
+ },
69
+ "confidence": number, // 0..1, how confident you are in the extraction
70
+ "missing": string[] // Required fields the user did not provide ("project", "action.chat_id", ...)
71
+ }
72
+
73
+ Hard rules:
74
+ 1. "prompt" MUST be the user's verbatim wording. If the user said "查一下我今天分到的 bug", keep it EXACTLY in that language and tone. Never translate, summarize, or "improve" it.
75
+ 2. Time mapping:
76
+ - "每天 9 点" / "every day at 9am" → cron "0 9 * * *"
77
+ - "每周一上午 10 点" → cron "0 10 * * 1"
78
+ - "每 30 分钟" / "every 30 min" → period, interval_minutes=30
79
+ - "5 分钟后跑一次" / "in 5 minutes" → once, at_iso = current_time + 5 minutes
80
+ - Ambiguous time → trigger.kind="manual", add "trigger" to missing
81
+ 3. Action mapping:
82
+ - "发我 Telegram" / "send to telegram" → kind=telegram, config can be empty {} (user fills chat_id later)
83
+ - "发邮件给 X" / "email me at X" → kind=email, config={"to": "..."} if X present
84
+ - "回到聊天" / "reply in chat" → kind=chat, config={} (session_id missing → add to missing)
85
+ - No action mentioned → kind=none
86
+ 4. project: try to match exactly (case-insensitive) against Available Forge projects. If user said a name not in the list, still put it in "project" and add a note via missing=["project"] so the UI flags it.
87
+ 5. skills: include "ai-orchestration" by default. If the user mentioned memory ("记得"/"remember") add "temper-memory". If they mentioned project docs add "knowledge-base".
88
+ 6. confidence:
89
+ - 1.0 if every field is unambiguous
90
+ - 0.7-0.9 if minor guesses made
91
+ - <0.7 if the prompt is unclear or required fields had to be guessed
92
+
93
+ Output ONLY the JSON object. No surrounding text.`;
94
+ }
95
+
96
+ export async function POST(req: Request) {
97
+ let body: any = {};
98
+ try { body = await req.json(); } catch {}
99
+ const text: string = typeof body?.text === 'string' ? body.text.trim() : '';
100
+ if (!text) return NextResponse.json({ ok: false, error: 'text required' }, { status: 400 });
101
+
102
+ const settings = loadSettings();
103
+ const agents = settings.agents || {};
104
+ const candidates = Object.entries(agents).filter(([_, a]) => {
105
+ if (!a || a.type !== 'api' || a.enabled === false) return false;
106
+ return pickApiKey(a, inferAdapter(a.provider)).length > 0;
107
+ });
108
+ if (candidates.length === 0) {
109
+ return NextResponse.json({
110
+ ok: false,
111
+ error: 'No API agent profile with an API key. Add one under Settings → Agents (type: API).',
112
+ }, { status: 400 });
113
+ }
114
+ const preferredId = settings.chatAgent || candidates[0]![0];
115
+ const profile = agents[preferredId]?.type === 'api' ? agents[preferredId] : candidates[0]![1];
116
+ const adapter = inferAdapter(profile.provider);
117
+ if (adapter !== 'anthropic') {
118
+ return NextResponse.json({
119
+ ok: false,
120
+ error: 'extractor currently supports anthropic profiles only (openai is a follow-up)',
121
+ }, { status: 400 });
122
+ }
123
+ const apiKey = pickApiKey(profile, adapter);
124
+ const baseUrl = pickBaseUrl(profile, adapter);
125
+ const model = profile.model || 'claude-sonnet-4-6';
126
+
127
+ const cliAgents = listAgents().filter((a: any) => a.backendType !== 'api');
128
+ const availableAgentIds = cliAgents.map((a: any) => a.id);
129
+ const availableProjectNames = scanProjects().map((p) => p.name);
130
+ const now = new Date().toISOString();
131
+ const system = buildSystem(now, availableAgentIds, availableProjectNames);
132
+
133
+ let raw = '';
134
+ try {
135
+ const client = makeAnthropicClient(apiKey, baseUrl);
136
+ const resp = await client.messages.create({
137
+ model,
138
+ max_tokens: 1500,
139
+ system,
140
+ messages: [{ role: 'user', content: text }],
141
+ });
142
+ for (const block of resp.content) {
143
+ if (block.type === 'text') raw += block.text;
144
+ }
145
+ } catch (e: any) {
146
+ return NextResponse.json({
147
+ ok: false,
148
+ error: `LLM call failed: ${e?.message || String(e)}`,
149
+ }, { status: 502 });
150
+ }
151
+
152
+ // Tolerate code fences / leading prose. Find the first { and last } and parse what's between.
153
+ const jsonText = sliceOutermostJson(raw);
154
+ let extracted: Extracted;
155
+ try {
156
+ extracted = JSON.parse(jsonText) as Extracted;
157
+ } catch (e: any) {
158
+ return NextResponse.json({
159
+ ok: false,
160
+ error: `LLM returned invalid JSON: ${e?.message || String(e)}`,
161
+ raw,
162
+ }, { status: 502 });
163
+ }
164
+
165
+ // Normalize: defaults + minimal validation. The card lets the user fix anything.
166
+ extracted.skills = Array.isArray(extracted.skills) ? extracted.skills.filter((s) => typeof s === 'string') : [];
167
+ if (extracted.skills.length === 0) extracted.skills = ['ai-orchestration'];
168
+ extracted.missing = Array.isArray(extracted.missing) ? extracted.missing.filter((s) => typeof s === 'string') : [];
169
+ extracted.confidence = Number.isFinite(extracted.confidence) ? Math.max(0, Math.min(1, extracted.confidence)) : 0.5;
170
+
171
+ return NextResponse.json({
172
+ ok: true,
173
+ extracted,
174
+ confidence: extracted.confidence,
175
+ missing: extracted.missing,
176
+ });
177
+ }
178
+
179
+ function sliceOutermostJson(s: string): string {
180
+ const first = s.indexOf('{');
181
+ const last = s.lastIndexOf('}');
182
+ if (first < 0 || last < 0 || last <= first) return s.trim();
183
+ return s.slice(first, last + 1);
184
+ }
@@ -16,9 +16,8 @@ import {
16
16
  } from '@/lib/schedules/store';
17
17
  import { decorateSchedule } from '@/lib/schedules/state';
18
18
  import { listWorkflows } from '@/lib/pipeline';
19
- import { listSkills } from '@/lib/skills';
20
- import { getProjectInfo } from '@/lib/projects';
21
- import { getInstalledConnector } from '@/lib/connectors/registry';
19
+ import { getProjectInfo, SCRATCH_PROJECT_NAME } from '@/lib/projects';
20
+ import { getPrompt } from '@/lib/prompts/store';
22
21
  import type {
23
22
  ScheduleKind,
24
23
  ScheduleBodyKind,
@@ -34,7 +33,7 @@ export async function GET() {
34
33
  }
35
34
 
36
35
  const VALID_KINDS: ScheduleKind[] = ['period', 'once', 'cron', 'manual'];
37
- const VALID_BODY_KINDS: ScheduleBodyKind[] = ['pipeline', 'skill', 'connector_tool'];
36
+ const VALID_BODY_KINDS: ScheduleBodyKind[] = ['pipeline', 'prompt'];
38
37
  const VALID_ACTION_KINDS: ScheduleActionKind[] = ['none', 'chat', 'email', 'telegram'];
39
38
 
40
39
  export async function POST(req: Request) {
@@ -71,46 +70,24 @@ export async function POST(req: Request) {
71
70
  }, { status: 400 });
72
71
  }
73
72
  }
74
- } else if (bodyKind === 'skill') {
75
- // Skill must be installed
76
- const installed = listSkills().some((sk) => sk.name === bodyRef && (sk.installedGlobal || (sk.installedProjects && sk.installedProjects.length > 0)));
77
- if (!installed) {
78
- return NextResponse.json({ error: `skill "${bodyRef}" is not installed` }, { status: 400 });
79
- }
80
- // input.project is required and must resolve
81
- const projectName = typeof input.project === 'string' ? input.project.trim() : '';
82
- if (!projectName) {
73
+ } else if (bodyKind === 'prompt') {
74
+ // Prompt definition must exist on disk
75
+ if (!getPrompt(bodyRef)) {
83
76
  return NextResponse.json({
84
- error: 'skill body requires input.project (a Forge project name)',
77
+ error: `prompt definition not found: prompts/${bodyRef}.yaml create it via POST /api/prompts first`,
85
78
  }, { status: 400 });
86
79
  }
87
- if (!getProjectInfo(projectName)) {
80
+ // input.project is OPTIONAL for prompt body — empty falls back to the
81
+ // synthetic 'scratch' project that init.ts materializes under <dataDir>/scratch.
82
+ // If specified, must resolve to a known project (scratch counts).
83
+ const projectName = typeof input.project === 'string' ? input.project.trim() : '';
84
+ if (!projectName) {
85
+ input.project = SCRATCH_PROJECT_NAME;
86
+ } else if (!getProjectInfo(projectName)) {
88
87
  return NextResponse.json({
89
88
  error: `project "${projectName}" not found in Forge's projects list`,
90
89
  }, { status: 400 });
91
90
  }
92
- } else if (bodyKind === 'connector_tool') {
93
- // body_ref is "<plugin_id>.<tool_name>"
94
- const dot = bodyRef.indexOf('.');
95
- if (dot <= 0 || dot === bodyRef.length - 1) {
96
- return NextResponse.json({
97
- error: 'connector_tool body_ref must be "<plugin_id>.<tool_name>"',
98
- }, { status: 400 });
99
- }
100
- const pluginId = bodyRef.slice(0, dot);
101
- const toolName = bodyRef.slice(dot + 1);
102
- const connector = getInstalledConnector(pluginId);
103
- if (!connector || !connector.enabled) {
104
- return NextResponse.json({
105
- error: `connector "${pluginId}" is not installed or disabled`,
106
- }, { status: 400 });
107
- }
108
- const tools = connector.definition.tools || {};
109
- if (!tools[toolName]) {
110
- return NextResponse.json({
111
- error: `tool "${toolName}" not found on connector "${pluginId}". Available: ${Object.keys(tools).join(', ')}`,
112
- }, { status: 400 });
113
- }
114
91
  }
115
92
 
116
93
  // Action — phase 4 ships chat. email/telegram land in phases 5-6.