@aion0/forge 0.10.34 → 0.10.36
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/README.md +9 -0
- package/RELEASE_NOTES.md +10 -8
- package/components/DocTerminal.tsx +2 -2
- package/lib/chat/agent-loop.ts +138 -61
- package/lib/chat/tool-dispatcher.ts +228 -0
- package/lib/connectors/types.ts +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,6 +27,14 @@
|
|
|
27
27
|
|
|
28
28
|
## Install
|
|
29
29
|
|
|
30
|
+
**One-liner** (macOS + Linux — installs node deps, tmux, claude code, then Forge):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
curl -fsSL https://raw.githubusercontent.com/aiwatching/forge/main/scripts/install-deps.sh | bash
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or manually:
|
|
37
|
+
|
|
30
38
|
```bash
|
|
31
39
|
npm install -g @aion0/forge
|
|
32
40
|
forge server start
|
|
@@ -35,6 +43,7 @@ forge server start
|
|
|
35
43
|
Open `http://localhost:8403`. First launch prompts you to set an admin password.
|
|
36
44
|
|
|
37
45
|
**Requirements:** Node.js ≥ 20, tmux, [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)
|
|
46
|
+
**Optional:** jq, glab, gh (used by some pipelines)
|
|
38
47
|
|
|
39
48
|
## What is Forge?
|
|
40
49
|
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.36
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-04
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.35
|
|
6
|
+
|
|
7
|
+
### Documentation
|
|
8
|
+
- docs: README install section gets one-liner + optional deps line
|
|
6
9
|
|
|
7
10
|
### Other
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
- fix(marketplace): scrollbar on long project list in install dropdown
|
|
11
|
+
- scripts: add install-deps.sh cross-platform installer
|
|
12
|
+
- feat(chat): add 5 schedule builtin tools (create/list/delete/run/update)
|
|
13
|
+
- feat(vscode-ext): add Chat + Schedules views + session switcher
|
|
12
14
|
|
|
13
15
|
|
|
14
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
16
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.35...v0.10.36
|
|
@@ -166,13 +166,13 @@ export default function DocTerminal({ docRoot, agent }: { docRoot: string; agent
|
|
|
166
166
|
</span>
|
|
167
167
|
<div className="ml-auto flex items-center gap-1">
|
|
168
168
|
<button
|
|
169
|
-
onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" &&
|
|
169
|
+
onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && ${agentCmdRef.current}${sf}`); }}
|
|
170
170
|
className="text-[10px] px-2 py-0.5 text-[var(--accent)] hover:bg-[#2a2a4a] rounded"
|
|
171
171
|
>
|
|
172
172
|
New
|
|
173
173
|
</button>
|
|
174
174
|
<button
|
|
175
|
-
onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" &&
|
|
175
|
+
onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && ${agentCmdRef.current} -c${sf}`); }}
|
|
176
176
|
className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded"
|
|
177
177
|
>
|
|
178
178
|
Resume
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -207,7 +207,50 @@ export function resolveProvider(sessionProvider: string | null, sessionModel: st
|
|
|
207
207
|
};
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Build the connector catalog block — one entry per installed connector,
|
|
212
|
+
* showing what it can do. The LLM reads this to decide whether to call
|
|
213
|
+
* connector_open(<id>) before reaching for a tool.
|
|
214
|
+
*
|
|
215
|
+
* Prefers `catalog_summary` from the manifest (curated 2-4 line English
|
|
216
|
+
* blurb). Falls back to first 5 tool names if absent, so older manifests
|
|
217
|
+
* still produce something usable.
|
|
218
|
+
*/
|
|
219
|
+
function buildConnectorCatalog(openSet: Set<string>): string[] {
|
|
220
|
+
const lines: string[] = [];
|
|
221
|
+
for (const inst of listInstalledConnectors()) {
|
|
222
|
+
if (!inst.enabled) continue;
|
|
223
|
+
const def = inst.definition;
|
|
224
|
+
let toolCount = 0;
|
|
225
|
+
const sampleNames: string[] = [];
|
|
226
|
+
for (const entry of getConnectorEntries(def)) {
|
|
227
|
+
for (const tname of Object.keys(entry.tools || {})) {
|
|
228
|
+
toolCount += 1;
|
|
229
|
+
if (sampleNames.length < 5) sampleNames.push(tname);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const status = openSet.has(def.id) ? ' [OPEN]' : '';
|
|
233
|
+
const summary = (def.catalog_summary || '').trim();
|
|
234
|
+
if (summary) {
|
|
235
|
+
lines.push(`▸ ${def.id}${status} (${toolCount} tools):`);
|
|
236
|
+
for (const ln of summary.split('\n')) {
|
|
237
|
+
const trimmed = ln.trim();
|
|
238
|
+
if (trimmed) lines.push(` ${trimmed}`);
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
const sample = sampleNames.join(', ');
|
|
242
|
+
lines.push(`▸ ${def.id}${status}: ${toolCount} tools (e.g. ${sample}${toolCount > 5 ? ', …' : ''})`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return lines;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function buildSystemPrompt(
|
|
249
|
+
openConnectorTools: LlmTool[],
|
|
250
|
+
openSet: Set<string>,
|
|
251
|
+
builtinDefs: typeof BUILTIN_TOOL_DEFS,
|
|
252
|
+
sessionSystemPrompt: string | null,
|
|
253
|
+
): string {
|
|
211
254
|
const now = new Date().toISOString();
|
|
212
255
|
|
|
213
256
|
// Inject a brief Forge context block (project names only) so the LLM can
|
|
@@ -227,8 +270,10 @@ function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTI
|
|
|
227
270
|
`Current time: ${now}`,
|
|
228
271
|
'',
|
|
229
272
|
'Tool usage — IMPORTANT:',
|
|
230
|
-
'-
|
|
231
|
-
'
|
|
273
|
+
'- Connector tools (mantis.*, gitlab.*, nac.*, tp.*, etc.) are NOT in your active tool list by default. The "Connector catalog" below shows what each connector can do.',
|
|
274
|
+
' Call connector_open({name: "<id>"}) FIRST to load a connector — its tools become callable on your next turn. The open set resets at every new user message, so re-open as the user pivots topics.',
|
|
275
|
+
'- If the user mentions a system name (e.g. "teams", "mantis", "gitlab", "pmdb") — even casually like "在 teams 中..." / "from mantis" — open that connector and use its tools.',
|
|
276
|
+
' Don\'t explain how to do something manually before trying the tool. The connector tools run inside the user\'s actual logged-in browser session — they CAN do things you might think only the user can do manually.',
|
|
232
277
|
'- For Teams in particular: send_message can target any chat by name; if the chat doesn\'t exist yet, the tool will return a specific error and THEN you can advise. Don\'t pre-judge.',
|
|
233
278
|
'- If a tool call fails, read its error carefully — it usually tells you what to fix (wrong arg, missing setting, login required). Retry with the fix. Only give up after the tool explicitly says it cannot do the task.',
|
|
234
279
|
'- For trigger_pipeline / dispatch_task: when the user names a "project" (e.g. "FortiNAC"), pass it as input.project verbatim. The names in the "Forge projects" list below ARE the valid values. Call list_forge_context only if you need paths / agents / skills.',
|
|
@@ -250,14 +295,16 @@ function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTI
|
|
|
250
295
|
lines.push('', `Forge projects (valid input.project values): ${projectNames.join(', ')}`);
|
|
251
296
|
}
|
|
252
297
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
298
|
+
const catalog = buildConnectorCatalog(openSet);
|
|
299
|
+
if (catalog.length > 0) {
|
|
300
|
+
lines.push('', 'Connector catalog — call connector_open({name}) to load tools:');
|
|
301
|
+
lines.push(...catalog);
|
|
302
|
+
if (openConnectorTools.length > 0) {
|
|
303
|
+
lines.push('', `Currently open connectors (${[...openSet].join(', ')}) — their tools ARE in your active tool list this turn.`);
|
|
257
304
|
}
|
|
258
305
|
}
|
|
259
306
|
if (builtinDefs.length > 0) {
|
|
260
|
-
lines.push('', 'Builtin tools:');
|
|
307
|
+
lines.push('', 'Builtin tools (always available):');
|
|
261
308
|
for (const t of builtinDefs) {
|
|
262
309
|
lines.push(`- ${t.name}: ${t.description.slice(0, 100)}`);
|
|
263
310
|
}
|
|
@@ -460,36 +507,36 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
460
507
|
});
|
|
461
508
|
}
|
|
462
509
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const allConnectorIds = [...new Set(connectorTools.map((t) => t.name.split('.')[0]!))];
|
|
510
|
+
// ── Full connector tool inventory ────────────────────────────────
|
|
511
|
+
// We build the full set ONCE. The active `tools` field sent to the LLM
|
|
512
|
+
// is computed per iteration by filtering to the "open set" (connectors
|
|
513
|
+
// the LLM has opened via connector_open OR the user explicitly named).
|
|
514
|
+
const allConnectorTools = buildConnectorTools();
|
|
515
|
+
const allConnectorIds = [...new Set(allConnectorTools.map((t) => t.name.split('.')[0]!))];
|
|
470
516
|
const pluginCatalog = allConnectorIds.map((id) => {
|
|
471
517
|
const def = getConnector(id);
|
|
472
518
|
return { id, name: def?.name };
|
|
473
519
|
});
|
|
520
|
+
|
|
521
|
+
// User-mention auto-open: if the user's message names a connector (slash
|
|
522
|
+
// or bare), seed the open set so the LLM doesn't need an extra
|
|
523
|
+
// connector_open round-trip for the obvious cases. Strong signals
|
|
524
|
+
// (/connector) also emit a directive so the model treats it as a command.
|
|
474
525
|
const mentioned = detectMentionedConnectors(args.userText, pluginCatalog);
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
526
|
+
const autoOpenFromUserText: Set<string> = mentioned.strong.size > 0 ? mentioned.strong
|
|
527
|
+
: mentioned.medium.size > 0 ? mentioned.medium
|
|
528
|
+
: new Set();
|
|
478
529
|
let narrowDirective = '';
|
|
479
|
-
if (
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
} else {
|
|
486
|
-
console.log(`[chat] narrow MEDIUM → ${list}`);
|
|
487
|
-
}
|
|
530
|
+
if (mentioned.strong.size > 0) {
|
|
531
|
+
const list = [...mentioned.strong].join(', ');
|
|
532
|
+
narrowDirective = `\n\nUSER MENTIONED CONNECTOR(S) EXPLICITLY (slash-prefix): ${list}. These are already open for this turn — use their tools directly, do NOT call connector_open for them.`;
|
|
533
|
+
console.log(`[chat] auto-open STRONG → ${list}`);
|
|
534
|
+
} else if (mentioned.medium.size > 0) {
|
|
535
|
+
console.log(`[chat] auto-open MEDIUM → ${[...mentioned.medium].join(', ')}`);
|
|
488
536
|
}
|
|
489
537
|
|
|
490
538
|
console.log(
|
|
491
|
-
`[chat] tools=${
|
|
492
|
-
connectorTools.map((t) => t.name).join(', ').slice(0, 600),
|
|
539
|
+
`[chat] total connector tools=${allConnectorTools.length} across ${allConnectorIds.length} connectors`,
|
|
493
540
|
);
|
|
494
541
|
|
|
495
542
|
const builtinDefsAll = [
|
|
@@ -503,32 +550,64 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
503
550
|
input_schema: t.input_schema,
|
|
504
551
|
}));
|
|
505
552
|
|
|
506
|
-
// ──
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
553
|
+
// ── Open set computation ─────────────────────────────────────────
|
|
554
|
+
// A connector is "open" for this user-task if the LLM has called
|
|
555
|
+
// connector_open(name=<id>) OR has called <id>.<tool> in any assistant
|
|
556
|
+
// turn since the most recent user TEXT message. User text messages mark
|
|
557
|
+
// task boundaries — on the next user message, the open set resets to
|
|
558
|
+
// just what user-text auto-opens seed.
|
|
559
|
+
//
|
|
560
|
+
// assistantBlocksAccum captures the in-progress turn's blocks (not yet
|
|
561
|
+
// in history during the iteration). We OR them with the history scan.
|
|
562
|
+
function computeOpenSet(history: Message[], currentBlocks: ContentBlock[]): Set<string> {
|
|
563
|
+
const ns = new Set<string>(autoOpenFromUserText);
|
|
564
|
+
// Find the most recent user message that has text content. Tool-result
|
|
565
|
+
// user messages don't count as task boundaries.
|
|
566
|
+
let lastUserIdx = -1;
|
|
567
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
568
|
+
const m = history[i]!;
|
|
569
|
+
if (m.role !== 'user') continue;
|
|
570
|
+
if (m.blocks.some((b) => b.type === 'text')) { lastUserIdx = i; break; }
|
|
571
|
+
}
|
|
572
|
+
const start = lastUserIdx + 1;
|
|
573
|
+
for (let i = start; i < history.length; i++) {
|
|
574
|
+
const m = history[i]!;
|
|
575
|
+
if (m.role !== 'assistant') continue;
|
|
576
|
+
for (const b of m.blocks) {
|
|
577
|
+
if (b.type !== 'tool_use') continue;
|
|
578
|
+
if (b.name === 'connector_open') {
|
|
579
|
+
const opened = (b.input as { name?: string } | undefined)?.name;
|
|
580
|
+
if (opened) ns.add(String(opened));
|
|
581
|
+
} else if (b.name.includes('.')) {
|
|
582
|
+
ns.add(b.name.split('.')[0]!);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
for (const b of currentBlocks) {
|
|
587
|
+
if (b.type !== 'tool_use') continue;
|
|
588
|
+
if (b.name === 'connector_open') {
|
|
589
|
+
const opened = (b.input as { name?: string } | undefined)?.name;
|
|
590
|
+
if (opened) ns.add(String(opened));
|
|
591
|
+
} else if (b.name.includes('.')) {
|
|
516
592
|
ns.add(b.name.split('.')[0]!);
|
|
517
593
|
}
|
|
518
594
|
}
|
|
519
595
|
return ns;
|
|
520
596
|
}
|
|
597
|
+
|
|
521
598
|
const sessionSystemPrompt = session.system_prompt;
|
|
522
|
-
function buildSystem(
|
|
523
|
-
let s = buildSystemPrompt(
|
|
599
|
+
function buildSystem(openTools: LlmTool[], openSet: Set<string>): string {
|
|
600
|
+
let s = buildSystemPrompt(openTools, openSet, builtinDefsAll, sessionSystemPrompt);
|
|
524
601
|
if (narrowDirective) s += narrowDirective;
|
|
525
602
|
return s;
|
|
526
603
|
}
|
|
527
604
|
|
|
528
|
-
|
|
529
|
-
let
|
|
605
|
+
// Initial open set (before any iteration): just user-text auto-open seeds
|
|
606
|
+
let openSet: Set<string> = new Set(autoOpenFromUserText);
|
|
607
|
+
let openConnectorTools = allConnectorTools.filter((t) => openSet.has(t.name.split('.')[0]!));
|
|
608
|
+
let allTools: LlmTool[] = [...builtinToolDefs, ...openConnectorTools];
|
|
530
609
|
|
|
531
|
-
let system = buildSystem(
|
|
610
|
+
let system = buildSystem(openConnectorTools, openSet);
|
|
532
611
|
if (memContext) system += '\n\n─── Memory context (auto-loaded) ───\n' + memContext;
|
|
533
612
|
if (memStore.enabled) {
|
|
534
613
|
const searchHint = memStore.kind === 'local'
|
|
@@ -559,23 +638,21 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
559
638
|
return { ok: false, error: 'empty history' };
|
|
560
639
|
}
|
|
561
640
|
|
|
562
|
-
// ──
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
}
|
|
578
|
-
}
|
|
641
|
+
// ── Recompute open set every iteration ──────────────────────
|
|
642
|
+
// Scan history (since last user text msg) + this turn's accumulated
|
|
643
|
+
// blocks → which connectors are open right now. Then filter tools.
|
|
644
|
+
// First iteration: only user-text auto-opens seed the set. After
|
|
645
|
+
// the LLM calls connector_open, subsequent iterations pick that up.
|
|
646
|
+
const newOpenSet = computeOpenSet(history, assistantBlocksAccum);
|
|
647
|
+
const setChanged = newOpenSet.size !== openSet.size ||
|
|
648
|
+
[...newOpenSet].some((n) => !openSet.has(n));
|
|
649
|
+
if (setChanged) {
|
|
650
|
+
openSet = newOpenSet;
|
|
651
|
+
openConnectorTools = allConnectorTools.filter((t) => openSet.has(t.name.split('.')[0]!));
|
|
652
|
+
allTools = [...builtinToolDefs, ...openConnectorTools];
|
|
653
|
+
system = buildSystem(openConnectorTools, openSet);
|
|
654
|
+
if (memContext) system += '\n\n─── Memory context (auto-loaded) ───\n' + memContext;
|
|
655
|
+
console.log(`[chat] open set → {${[...openSet].join(',')}} (${openConnectorTools.length} connector tools active)`);
|
|
579
656
|
}
|
|
580
657
|
|
|
581
658
|
assistantBlocksAccum = [];
|
|
@@ -336,6 +336,160 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
336
336
|
? content.slice(0, MAX) + `\n\n…[truncated — doc is ${content.length} chars]`
|
|
337
337
|
: content;
|
|
338
338
|
},
|
|
339
|
+
|
|
340
|
+
// ── Schedules CRUD ─────────────────────────────────────────
|
|
341
|
+
// All five direct in-process via lib/schedules/store — no HTTP, no auth.
|
|
342
|
+
// Use these instead of dispatch_task + curl: cleaner, no token shenanigans.
|
|
343
|
+
|
|
344
|
+
create_schedule: async (input) => {
|
|
345
|
+
const p = (input as any) || {};
|
|
346
|
+
const name = String(p.name || '').trim();
|
|
347
|
+
const workflow = String(p.workflow || p.body_ref || '').trim();
|
|
348
|
+
if (!name) return JSON.stringify({ ok: false, error: 'name is required' });
|
|
349
|
+
if (!workflow) return JSON.stringify({ ok: false, error: 'workflow (pipeline name) is required' });
|
|
350
|
+
|
|
351
|
+
// Trigger normalization: prefer every_minutes; accept at (once) or cron.
|
|
352
|
+
let schedule_kind: 'period' | 'once' | 'cron' = 'period';
|
|
353
|
+
let schedule_interval_minutes: number | undefined;
|
|
354
|
+
let schedule_at: string | null | undefined;
|
|
355
|
+
let schedule_cron: string | null | undefined;
|
|
356
|
+
if (p.every_minutes != null) {
|
|
357
|
+
schedule_kind = 'period';
|
|
358
|
+
schedule_interval_minutes = Number(p.every_minutes);
|
|
359
|
+
} else if (p.at) {
|
|
360
|
+
schedule_kind = 'once';
|
|
361
|
+
schedule_at = String(p.at);
|
|
362
|
+
} else if (p.cron) {
|
|
363
|
+
schedule_kind = 'cron';
|
|
364
|
+
schedule_cron = String(p.cron);
|
|
365
|
+
} else {
|
|
366
|
+
return JSON.stringify({ ok: false, error: 'one of every_minutes / at / cron is required' });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const { createSchedule, seedNextRunAt } = await import('../schedules/store');
|
|
370
|
+
try {
|
|
371
|
+
const s = createSchedule({
|
|
372
|
+
name,
|
|
373
|
+
body_kind: 'pipeline',
|
|
374
|
+
body_ref: workflow,
|
|
375
|
+
input: (p.input && typeof p.input === 'object') ? p.input : {},
|
|
376
|
+
skills: Array.isArray(p.skills) ? p.skills : undefined,
|
|
377
|
+
enabled: p.enabled !== false,
|
|
378
|
+
schedule_kind,
|
|
379
|
+
schedule_interval_minutes,
|
|
380
|
+
schedule_at: schedule_at ?? null,
|
|
381
|
+
schedule_cron: schedule_cron ?? null,
|
|
382
|
+
action_kind: p.action || 'none',
|
|
383
|
+
});
|
|
384
|
+
seedNextRunAt(s.id);
|
|
385
|
+
return JSON.stringify({
|
|
386
|
+
ok: true,
|
|
387
|
+
schedule_id: s.id,
|
|
388
|
+
name: s.name,
|
|
389
|
+
enabled: s.enabled,
|
|
390
|
+
kind: s.schedule_kind,
|
|
391
|
+
next_run_at: s.next_run_at,
|
|
392
|
+
message: `Schedule "${s.name}" created. ${schedule_kind === 'period' ? `Fires every ${schedule_interval_minutes} minutes.` : schedule_kind === 'once' ? `Fires once at ${schedule_at}.` : `Fires on cron "${schedule_cron}".`}`,
|
|
393
|
+
});
|
|
394
|
+
} catch (e: any) {
|
|
395
|
+
return JSON.stringify({ ok: false, error: e?.message || String(e) });
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
list_schedules: async () => {
|
|
400
|
+
const { listSchedules } = await import('../schedules/store');
|
|
401
|
+
const { decorateSchedule } = await import('../schedules/state');
|
|
402
|
+
const all = listSchedules().map(decorateSchedule);
|
|
403
|
+
return JSON.stringify({
|
|
404
|
+
schedules: all.map((s) => ({
|
|
405
|
+
id: s.id,
|
|
406
|
+
name: s.name,
|
|
407
|
+
enabled: s.enabled,
|
|
408
|
+
active_state: s.active_state,
|
|
409
|
+
schedule_kind: s.schedule_kind,
|
|
410
|
+
body_ref: s.body_ref,
|
|
411
|
+
next_run_at: s.next_run_at,
|
|
412
|
+
last_run_at: s.last_run_at,
|
|
413
|
+
})),
|
|
414
|
+
total: all.length,
|
|
415
|
+
});
|
|
416
|
+
},
|
|
417
|
+
|
|
418
|
+
delete_schedule: async (input) => {
|
|
419
|
+
const id = String((input as any)?.id || '').trim();
|
|
420
|
+
if (!id) return JSON.stringify({ ok: false, error: 'id is required' });
|
|
421
|
+
const { deleteSchedule } = await import('../schedules/store');
|
|
422
|
+
const ok = deleteSchedule(id);
|
|
423
|
+
return JSON.stringify({ ok, message: ok ? `Schedule ${id} deleted.` : `Schedule ${id} not found.` });
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
run_schedule_now: async (input) => {
|
|
427
|
+
const id = String((input as any)?.id || '').trim();
|
|
428
|
+
if (!id) return JSON.stringify({ ok: false, error: 'id is required' });
|
|
429
|
+
const { getSchedule } = await import('../schedules/store');
|
|
430
|
+
const s = getSchedule(id);
|
|
431
|
+
if (!s) return JSON.stringify({ ok: false, error: `Schedule ${id} not found` });
|
|
432
|
+
const { executeSchedule } = await import('../schedules/scheduler');
|
|
433
|
+
try {
|
|
434
|
+
const runId = await executeSchedule(s, 'manual');
|
|
435
|
+
return JSON.stringify({ ok: true, schedule_id: id, run_id: runId, message: `Schedule "${s.name}" fired. Run id: ${runId}.` });
|
|
436
|
+
} catch (e: any) {
|
|
437
|
+
return JSON.stringify({ ok: false, error: e?.message || String(e) });
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
update_schedule: async (input) => {
|
|
442
|
+
const p = (input as any) || {};
|
|
443
|
+
const id = String(p.id || '').trim();
|
|
444
|
+
if (!id) return JSON.stringify({ ok: false, error: 'id is required' });
|
|
445
|
+
const patch: Record<string, unknown> = {};
|
|
446
|
+
if (typeof p.enabled === 'boolean') patch.enabled = p.enabled;
|
|
447
|
+
if (typeof p.name === 'string') patch.name = p.name;
|
|
448
|
+
if (p.input && typeof p.input === 'object') patch.input = p.input;
|
|
449
|
+
if (Array.isArray(p.skills)) patch.skills = p.skills;
|
|
450
|
+
if (typeof p.every_minutes === 'number') {
|
|
451
|
+
patch.schedule_kind = 'period';
|
|
452
|
+
patch.schedule_interval_minutes = p.every_minutes;
|
|
453
|
+
} else if (typeof p.at === 'string') {
|
|
454
|
+
patch.schedule_kind = 'once';
|
|
455
|
+
patch.schedule_at = p.at;
|
|
456
|
+
} else if (typeof p.cron === 'string') {
|
|
457
|
+
patch.schedule_kind = 'cron';
|
|
458
|
+
patch.schedule_cron = p.cron;
|
|
459
|
+
}
|
|
460
|
+
if (Object.keys(patch).length === 0) {
|
|
461
|
+
return JSON.stringify({ ok: false, error: 'no fields to update (try enabled / name / input / skills / every_minutes / at / cron)' });
|
|
462
|
+
}
|
|
463
|
+
const { updateSchedule, seedNextRunAt } = await import('../schedules/store');
|
|
464
|
+
const ok = updateSchedule(id, patch as any);
|
|
465
|
+
if (ok && (patch.schedule_kind || patch.enabled === true)) seedNextRunAt(id);
|
|
466
|
+
return JSON.stringify({ ok, message: ok ? `Schedule ${id} updated.` : `Schedule ${id} not found.` });
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
// Namespace gating meta-tool. Connector tools (mantis.*, gitlab.*, etc.)
|
|
470
|
+
// are NOT in the active tools list by default — only their catalog entry
|
|
471
|
+
// is visible in the system prompt. Calling connector_open({name}) makes
|
|
472
|
+
// that connector's tools available for the rest of this user-task (until
|
|
473
|
+
// the next user message). The handler returns a tiny confirmation; the
|
|
474
|
+
// actual side-effect is that agent-loop scans assistant history for
|
|
475
|
+
// these calls (and direct namespaced calls) to compute the "open set"
|
|
476
|
+
// each turn.
|
|
477
|
+
connector_open: async (input) => {
|
|
478
|
+
const { name } = (input as { name?: string } | undefined) || {};
|
|
479
|
+
if (!name) return JSON.stringify({ ok: false, error: 'name is required (e.g. "mantis", "gitlab", "nac")' });
|
|
480
|
+
const def = getConnector(String(name));
|
|
481
|
+
if (!def) return JSON.stringify({ ok: false, error: `unknown connector: ${name}. Call connector_open with a name from the catalog in the system prompt.` });
|
|
482
|
+
let toolCount = 0;
|
|
483
|
+
for (const entry of getConnectorEntries(def)) {
|
|
484
|
+
toolCount += Object.keys(entry.tools || {}).length;
|
|
485
|
+
}
|
|
486
|
+
return JSON.stringify({
|
|
487
|
+
ok: true,
|
|
488
|
+
connector: name,
|
|
489
|
+
tool_count: toolCount,
|
|
490
|
+
message: `${name} loaded — ${toolCount} tools available next turn. Call them as ${name}.<tool_name>.`,
|
|
491
|
+
});
|
|
492
|
+
},
|
|
339
493
|
};
|
|
340
494
|
|
|
341
495
|
export interface BuiltinToolDef {
|
|
@@ -446,6 +600,80 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
446
600
|
required: ['doc'],
|
|
447
601
|
},
|
|
448
602
|
},
|
|
603
|
+
{
|
|
604
|
+
name: 'create_schedule',
|
|
605
|
+
description: 'Create a recurring (or one-off) schedule that fires a Forge pipeline on a timer. NO HTTP, NO auth — runs in-process. Use this when the user says "every N minutes/hours" / "watch X" / "monitor Y" / "auto-run pipeline on schedule". REQUIRED args: name + workflow + ONE of {every_minutes, at, cron}. Returns { ok, schedule_id, next_run_at }.',
|
|
606
|
+
input_schema: {
|
|
607
|
+
type: 'object',
|
|
608
|
+
properties: {
|
|
609
|
+
name: { type: 'string', description: 'Human-readable name shown in the Schedules UI.' },
|
|
610
|
+
workflow: { type: 'string', description: 'Pipeline workflow name (file basename of flows/<name>.yaml). Run trigger_pipeline() with NO args first if unsure what names are available.' },
|
|
611
|
+
input: { type: 'object', description: 'Pipeline input fields. Same shape as trigger_pipeline.input. OMIT optional fields to use defaults.' },
|
|
612
|
+
skills: { type: 'array', items: { type: 'string' }, description: 'Skill names to inject into every Claude task this schedule spawns.' },
|
|
613
|
+
every_minutes: { type: 'number', description: 'Period in minutes (e.g. 60 = hourly). Most common trigger.' },
|
|
614
|
+
at: { type: 'string', description: 'ISO timestamp for one-shot run (e.g. "2026-06-05T09:00:00Z"). Mutually exclusive with every_minutes / cron.' },
|
|
615
|
+
cron: { type: 'string', description: 'Cron expression for complex schedules (e.g. "0 9 * * 1-5" = weekdays 9am). Mutually exclusive with every_minutes / at.' },
|
|
616
|
+
enabled: { type: 'boolean', description: 'Whether to start enabled. Default true.' },
|
|
617
|
+
action: { type: 'string', enum: ['none', 'chat', 'email', 'telegram'], description: 'Post-run notification action. Default "none".' },
|
|
618
|
+
},
|
|
619
|
+
required: ['name', 'workflow'],
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
name: 'list_schedules',
|
|
624
|
+
description: 'List all configured schedules with status (active_state: idle / running / last_failed / paused), kind, next_run_at, last_run_at. Use to find a schedule\'s id before update/delete/run.',
|
|
625
|
+
input_schema: { type: 'object', properties: {} },
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
name: 'delete_schedule',
|
|
629
|
+
description: 'Permanently delete a schedule by id. Cannot be undone. Find id via list_schedules first.',
|
|
630
|
+
input_schema: {
|
|
631
|
+
type: 'object',
|
|
632
|
+
properties: { id: { type: 'string', description: 'Schedule id from list_schedules.' } },
|
|
633
|
+
required: ['id'],
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: 'run_schedule_now',
|
|
638
|
+
description: 'Fire a schedule\'s configured pipeline immediately (manual trigger), regardless of when it would next fire on its timer. Returns the run_id.',
|
|
639
|
+
input_schema: {
|
|
640
|
+
type: 'object',
|
|
641
|
+
properties: { id: { type: 'string', description: 'Schedule id from list_schedules.' } },
|
|
642
|
+
required: ['id'],
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
name: 'update_schedule',
|
|
647
|
+
description: 'Patch fields on an existing schedule (enable/disable, rename, change input, swap trigger). Only the fields you pass are changed.',
|
|
648
|
+
input_schema: {
|
|
649
|
+
type: 'object',
|
|
650
|
+
properties: {
|
|
651
|
+
id: { type: 'string', description: 'Schedule id from list_schedules.' },
|
|
652
|
+
enabled: { type: 'boolean', description: 'true to enable, false to pause without deleting.' },
|
|
653
|
+
name: { type: 'string' },
|
|
654
|
+
input: { type: 'object', description: 'New pipeline input fields (replaces existing).' },
|
|
655
|
+
skills: { type: 'array', items: { type: 'string' } },
|
|
656
|
+
every_minutes: { type: 'number', description: 'Switch trigger to interval.' },
|
|
657
|
+
at: { type: 'string', description: 'Switch trigger to one-shot at this ISO time.' },
|
|
658
|
+
cron: { type: 'string', description: 'Switch trigger to cron expression.' },
|
|
659
|
+
},
|
|
660
|
+
required: ['id'],
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
name: 'connector_open',
|
|
665
|
+
description: 'Load a connector to make its tools (e.g. mantis.search_bugs, gitlab.list_my_todos) available for use. REQUIRED before calling any connector tool — the catalog block in the system prompt shows what each connector can do. Tools stay loaded only for the current user task; the next user message resets the open set, so re-open as needed.',
|
|
666
|
+
input_schema: {
|
|
667
|
+
type: 'object',
|
|
668
|
+
properties: {
|
|
669
|
+
name: {
|
|
670
|
+
type: 'string',
|
|
671
|
+
description: 'Connector id from the catalog (e.g. "mantis", "gitlab", "nac", "tp", "jenkins", "teams", "pmdb", "github-api").',
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
required: ['name'],
|
|
675
|
+
},
|
|
676
|
+
},
|
|
449
677
|
];
|
|
450
678
|
|
|
451
679
|
// ─── Connector dispatch ──────────────────────────────────
|
package/lib/connectors/types.ts
CHANGED
|
@@ -477,6 +477,14 @@ export interface ConnectorDefinition {
|
|
|
477
477
|
author?: string;
|
|
478
478
|
description?: string;
|
|
479
479
|
|
|
480
|
+
/**
|
|
481
|
+
* 2-4 line English summary of what this connector can do, shown in the
|
|
482
|
+
* chat system prompt's "Connector catalog" block. Drives the LLM's
|
|
483
|
+
* decision to call connector_open(<id>). When absent, the agent falls
|
|
484
|
+
* back to listing the first few tool names — informative but flat.
|
|
485
|
+
*/
|
|
486
|
+
catalog_summary?: string;
|
|
487
|
+
|
|
480
488
|
/**
|
|
481
489
|
* Minimum Forge version this manifest expects. The registry filter
|
|
482
490
|
* hides newer-than-supported manifests so users on older Forge
|
package/package.json
CHANGED