@aion0/forge 0.9.11 → 0.9.13
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 +15 -4
- package/app/api/prompts/[name]/route.ts +37 -0
- package/app/api/prompts/route.ts +35 -0
- package/app/api/schedules/extract/route.ts +184 -0
- package/app/api/schedules/route.ts +14 -37
- package/components/ScheduleCreateModal.tsx +237 -537
- package/components/ScheduleQuickCreate.tsx +404 -0
- package/components/SchedulesView.tsx +18 -6
- package/components/SettingsModal.tsx +43 -4
- package/lib/forge-mcp-server.ts +84 -0
- package/lib/init.ts +7 -0
- package/lib/projects.ts +67 -2
- package/lib/prompts/store.ts +142 -0
- package/lib/prompts/types.ts +53 -0
- package/lib/schedules/scheduler.ts +51 -143
- package/lib/schedules/store.ts +6 -15
- package/lib/schedules/types.ts +10 -14
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
# Forge v0.9.
|
|
1
|
+
# Forge v0.9.13
|
|
2
2
|
|
|
3
|
-
Released: 2026-05-
|
|
3
|
+
Released: 2026-05-27
|
|
4
4
|
|
|
5
|
-
## Changes since v0.9.
|
|
5
|
+
## Changes since v0.9.12
|
|
6
6
|
|
|
7
|
+
### Other
|
|
8
|
+
- refactor(schedules): drop skill + connector_tool body kinds (web)
|
|
9
|
+
- fix(schedules): prompt mode end-to-end — MCP config + zombie reconciler
|
|
10
|
+
- fix(schedules-modal): normalize 'scratch' → '' on edit-mode load
|
|
11
|
+
- feat(projects): synthetic 'scratch' project as default workspace
|
|
12
|
+
- feat(schedules): natural-language quick create — extractor + Confirm Card
|
|
13
|
+
- feat(schedules-ui): prompt mode in ScheduleCreateModal + SchedulesView
|
|
14
|
+
- feat(schedules): body_kind='prompt' dispatch + task-backed run settle
|
|
15
|
+
- feat(prompts): store + CRUD API for V3 schedule body_kind='prompt'
|
|
16
|
+
- feat(mcp): list_connectors + call_connector tools for ai-orchestration
|
|
7
17
|
|
|
8
|
-
|
|
18
|
+
|
|
19
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.12...v0.9.13
|
|
@@ -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 {
|
|
20
|
-
import {
|
|
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', '
|
|
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 === '
|
|
75
|
-
//
|
|
76
|
-
|
|
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:
|
|
77
|
+
error: `prompt definition not found: prompts/${bodyRef}.yaml — create it via POST /api/prompts first`,
|
|
85
78
|
}, { status: 400 });
|
|
86
79
|
}
|
|
87
|
-
|
|
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.
|