@aion0/forge 0.10.35 → 0.10.37
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 +4 -8
- package/app/api/connectors/import-config-template/route.ts +358 -0
- package/app/api/onboarding/detect-cli/route.ts +46 -0
- package/app/api/onboarding/route.ts +422 -0
- package/components/ConnectorsPanel.tsx +326 -0
- package/components/Dashboard.tsx +29 -1
- package/components/OnboardingWizard.tsx +924 -0
- package/components/SettingsModal.tsx +42 -0
- package/components/WebTerminal.tsx +16 -1
- package/lib/chat/agent-loop.ts +87 -30
- package/lib/chat/llm/openai.ts +5 -1
- package/lib/chat/session-store.ts +22 -2
- package/lib/chat/tool-dispatcher.ts +195 -1
- package/lib/help-docs/17-connectors.md +51 -0
- package/lib/settings.ts +16 -0
- package/package.json +1 -1
- package/templates/connector-config-template.json +131 -0
|
@@ -831,6 +831,31 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
831
831
|
/>
|
|
832
832
|
</div>
|
|
833
833
|
|
|
834
|
+
{/* Re-run Onboarding */}
|
|
835
|
+
<div className="space-y-2">
|
|
836
|
+
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
|
|
837
|
+
Onboarding
|
|
838
|
+
</label>
|
|
839
|
+
<button
|
|
840
|
+
type="button"
|
|
841
|
+
onClick={async () => {
|
|
842
|
+
await fetch('/api/onboarding', {
|
|
843
|
+
method: 'POST',
|
|
844
|
+
headers: { 'Content-Type': 'application/json' },
|
|
845
|
+
body: JSON.stringify({ action: 'reset' }),
|
|
846
|
+
});
|
|
847
|
+
// Reload so the Dashboard banner re-renders.
|
|
848
|
+
window.location.reload();
|
|
849
|
+
}}
|
|
850
|
+
className="text-[11px] px-2.5 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
|
|
851
|
+
>
|
|
852
|
+
↺ Re-run Onboarding wizard
|
|
853
|
+
</button>
|
|
854
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
855
|
+
Re-opens the first-run setup banner. Existing values are preserved — the wizard never overwrites non-empty fields.
|
|
856
|
+
</p>
|
|
857
|
+
</div>
|
|
858
|
+
|
|
834
859
|
{/* Admin Password */}
|
|
835
860
|
<div className="space-y-2">
|
|
836
861
|
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
|
|
@@ -1041,6 +1066,23 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete, isApi: isApiProp
|
|
|
1041
1066
|
className={inputClass + ' font-mono'}
|
|
1042
1067
|
/>
|
|
1043
1068
|
</div>
|
|
1069
|
+
<div>
|
|
1070
|
+
<label className="text-[8px] text-[var(--text-secondary)]">
|
|
1071
|
+
Max input tokens — total prompt cap (system + memory + history + tools). Default 200000 (Claude Code working window). Anything beyond gets strictly compressed: memory tail dropped first, then oldest history. Lower it (e.g. 12000) for 16k-context legacy models.
|
|
1072
|
+
</label>
|
|
1073
|
+
<input
|
|
1074
|
+
type="number"
|
|
1075
|
+
min={4000}
|
|
1076
|
+
value={cfg.maxInputTokens ?? ''}
|
|
1077
|
+
onChange={e => {
|
|
1078
|
+
const raw = e.target.value.trim();
|
|
1079
|
+
const n = raw === '' ? undefined : Number(raw);
|
|
1080
|
+
onUpdate({ ...cfg, maxInputTokens: Number.isFinite(n) ? n : undefined });
|
|
1081
|
+
}}
|
|
1082
|
+
placeholder="200000 (default — Claude Code 200k)"
|
|
1083
|
+
className={inputClass + ' font-mono'}
|
|
1084
|
+
/>
|
|
1085
|
+
</div>
|
|
1044
1086
|
<div className="flex items-center gap-2 pt-1">
|
|
1045
1087
|
<button
|
|
1046
1088
|
onClick={runTest}
|
|
@@ -268,6 +268,16 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
268
268
|
const [expandedRoot, setExpandedRoot] = useState<string | null>(null);
|
|
269
269
|
const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string; detected?: boolean }[]>([]);
|
|
270
270
|
const [selectedAgent, setSelectedAgent] = useState<string>('');
|
|
271
|
+
// Cached default-agent cliCmd. Resolved once on mount (fast in theory but
|
|
272
|
+
// the route can take 1–5s when many concurrent fetches saturate workers).
|
|
273
|
+
// openSessionInTerminal reads this directly — no await per session restore.
|
|
274
|
+
const defaultAgentCmdRef = useRef<string>('claude');
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
fetch('/api/agents?resolve=')
|
|
277
|
+
.then(r => r.json())
|
|
278
|
+
.then(info => { if (info?.cliCmd) defaultAgentCmdRef.current = info.cliCmd; })
|
|
279
|
+
.catch(() => { /* keep bare 'claude' */ });
|
|
280
|
+
}, []);
|
|
271
281
|
const [defaultAgentId, setDefaultAgentId] = useState('claude');
|
|
272
282
|
|
|
273
283
|
// Restore shared state from server after mount
|
|
@@ -359,7 +369,12 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
359
369
|
const sf = skipPermissions ? ' --dangerously-skip-permissions' : '';
|
|
360
370
|
let mcpFlag = '';
|
|
361
371
|
try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {}
|
|
362
|
-
|
|
372
|
+
// Use the cached default-agent cliCmd. Pre-fetched on mount above;
|
|
373
|
+
// if still 'claude' (fetch slow / pending), we use bare claude — old
|
|
374
|
+
// behavior, wrong if conda-base shadows the real one, but at least
|
|
375
|
+
// doesn't block the command from appearing.
|
|
376
|
+
const agentCmd = defaultAgentCmdRef.current;
|
|
377
|
+
const cmd = `cd "${projectPath}" && ${agentCmd} --resume ${sessionId}${sf}${mcpFlag}\n`;
|
|
363
378
|
pendingCommands.set(paneId, cmd);
|
|
364
379
|
const projectName = projectPath.split('/').pop() || 'Terminal';
|
|
365
380
|
const newTab: TabState = {
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -47,15 +47,25 @@ import type {
|
|
|
47
47
|
// sentinel message below so the user knows why the turn stopped.
|
|
48
48
|
const MAX_ITERATIONS = 24;
|
|
49
49
|
const MAX_TOKENS = 16000;
|
|
50
|
-
// Working-window
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
50
|
+
// Working-window message-count cap. Token cap is dynamic per-profile
|
|
51
|
+
// (see DEFAULT_MAX_INPUT_TOKENS + ApiProfile.maxInputTokens). Older raw
|
|
52
|
+
// is summarized by the memory-standalone Temper Summary sub-task and
|
|
53
|
+
// recalled via buildMemoryContext as compact blocks.
|
|
54
54
|
const HISTORY_MSG_BUDGET = 60;
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
55
|
+
// Default total input ceiling when the profile doesn't set one. Matches
|
|
56
|
+
// Claude Code's 200k working window — modern Claude / DeepSeek-V3 /
|
|
57
|
+
// Qwen-Max all expose ≥ 200k context. Anything beyond this gets strictly
|
|
58
|
+
// compressed (memory tail trimmed first, then oldest history evicted).
|
|
59
|
+
// Lower it per-profile (e.g. 12000) for older 16k-context models —
|
|
60
|
+
// Forge auto-trims memory then history to fit.
|
|
61
|
+
const DEFAULT_MAX_INPUT_TOKENS = 200_000;
|
|
62
|
+
// Reserved for the model's reply so we don't have to fight Anthropic
|
|
63
|
+
// hard limits where max_tokens is part of the context budget.
|
|
64
|
+
const OUTPUT_RESERVE_TOKENS = MAX_TOKENS;
|
|
65
|
+
// Absolute floor — if even after trimming memory we can't fit this
|
|
66
|
+
// many tokens of history, the profile's ceiling is unworkable and we
|
|
67
|
+
// surface a clear error instead of sending a useless request.
|
|
68
|
+
const MIN_HISTORY_TOKENS = 2_000;
|
|
59
69
|
// Hard cap on a single tool_result stored into the conversation (chars).
|
|
60
70
|
// A giant result (e.g. a connector returning a full test tree) would
|
|
61
71
|
// otherwise blow the whole HISTORY_TOKEN_BUDGET, push its paired
|
|
@@ -112,6 +122,9 @@ export interface ProviderResolution {
|
|
|
112
122
|
apiKey: string;
|
|
113
123
|
baseUrl: string;
|
|
114
124
|
model: string;
|
|
125
|
+
/** Total input-token ceiling for chat (system + memory + history + tools).
|
|
126
|
+
* Falls back to DEFAULT_MAX_INPUT_TOKENS when the profile leaves it unset. */
|
|
127
|
+
maxInputTokens?: number;
|
|
115
128
|
}
|
|
116
129
|
|
|
117
130
|
/**
|
|
@@ -204,6 +217,7 @@ export function resolveProvider(sessionProvider: string | null, sessionModel: st
|
|
|
204
217
|
apiKey: profile.apiKey,
|
|
205
218
|
baseUrl: profile.baseUrl || defaultBaseUrl(profile.provider),
|
|
206
219
|
model,
|
|
220
|
+
maxInputTokens: profile.maxInputTokens,
|
|
207
221
|
};
|
|
208
222
|
}
|
|
209
223
|
|
|
@@ -607,8 +621,11 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
607
621
|
let openConnectorTools = allConnectorTools.filter((t) => openSet.has(t.name.split('.')[0]!));
|
|
608
622
|
let allTools: LlmTool[] = [...builtinToolDefs, ...openConnectorTools];
|
|
609
623
|
|
|
624
|
+
// Keep memContext separate from `system` — we may need to trim it
|
|
625
|
+
// per-iteration when the profile's maxInputTokens is tight. The
|
|
626
|
+
// assembled string is recomputed each iter (after open-set + memory
|
|
627
|
+
// trim). `system` here holds the base (no memory section).
|
|
610
628
|
let system = buildSystem(openConnectorTools, openSet);
|
|
611
|
-
if (memContext) system += '\n\n─── Memory context (auto-loaded) ───\n' + memContext;
|
|
612
629
|
if (memStore.enabled) {
|
|
613
630
|
const searchHint = memStore.kind === 'local'
|
|
614
631
|
? '• memory_search is keyword LIKE over local blocks + episodes — useful for finding past notes; prefer memory_get_block / memory_list_blocks for first-person facts.'
|
|
@@ -627,23 +644,17 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
627
644
|
while (iter < MAX_ITERATIONS) {
|
|
628
645
|
iter += 1;
|
|
629
646
|
|
|
630
|
-
const history = trimOrphanToolResults(
|
|
631
|
-
listMessagesCapped(args.sessionId, HISTORY_MSG_BUDGET, HISTORY_TOKEN_BUDGET, estimateTokens),
|
|
632
|
-
);
|
|
633
|
-
// Belt-and-suspenders: tool_result truncation should keep a complete
|
|
634
|
-
// pair in-window, but if history is somehow empty, fail clearly
|
|
635
|
-
// instead of letting the provider throw "messages must not be empty".
|
|
636
|
-
if (history.length === 0) {
|
|
637
|
-
cb({ type: 'error', data: { error: 'Conversation context is empty after trimming an oversized result. Clear the chat or retry with a narrower query.' } });
|
|
638
|
-
return { ok: false, error: 'empty history' };
|
|
639
|
-
}
|
|
640
|
-
|
|
641
647
|
// ── Recompute open set every iteration ──────────────────────
|
|
642
648
|
// Scan history (since last user text msg) + this turn's accumulated
|
|
643
649
|
// blocks → which connectors are open right now. Then filter tools.
|
|
644
650
|
// First iteration: only user-text auto-opens seed the set. After
|
|
645
651
|
// the LLM calls connector_open, subsequent iterations pick that up.
|
|
646
|
-
|
|
652
|
+
// (Computed off a preview slice of history — refined below once
|
|
653
|
+
// we have the real history under budget.)
|
|
654
|
+
const previewHistory = trimOrphanToolResults(
|
|
655
|
+
listMessagesCapped(args.sessionId, HISTORY_MSG_BUDGET, 8_000, estimateTokens),
|
|
656
|
+
);
|
|
657
|
+
const newOpenSet = computeOpenSet(previewHistory, assistantBlocksAccum);
|
|
647
658
|
const setChanged = newOpenSet.size !== openSet.size ||
|
|
648
659
|
[...newOpenSet].some((n) => !openSet.has(n));
|
|
649
660
|
if (setChanged) {
|
|
@@ -651,23 +662,69 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
651
662
|
openConnectorTools = allConnectorTools.filter((t) => openSet.has(t.name.split('.')[0]!));
|
|
652
663
|
allTools = [...builtinToolDefs, ...openConnectorTools];
|
|
653
664
|
system = buildSystem(openConnectorTools, openSet);
|
|
654
|
-
if (memContext) system += '\n\n─── Memory context (auto-loaded) ───\n' + memContext;
|
|
655
665
|
console.log(`[chat] open set → {${[...openSet].join(',')}} (${openConnectorTools.length} connector tools active)`);
|
|
656
666
|
}
|
|
657
667
|
|
|
668
|
+
// ── Dynamic context budget ──────────────────────────────────
|
|
669
|
+
// Total profile cap (default 60k, override per profile). Subtract
|
|
670
|
+
// fixed contributors (system, tools schema) and reservation for
|
|
671
|
+
// the model's reply → what's left splits between memory + history.
|
|
672
|
+
// Memory gets trimmed (from tail = least-pinned recall hits) when
|
|
673
|
+
// history would dip below the floor.
|
|
674
|
+
const maxInputTokens = provider.maxInputTokens || DEFAULT_MAX_INPUT_TOKENS;
|
|
675
|
+
const systemTok = Math.ceil(system.length / 4);
|
|
676
|
+
const toolsTok = Math.ceil(JSON.stringify(allTools).length / 4);
|
|
677
|
+
const fixedTok = systemTok + toolsTok + OUTPUT_RESERVE_TOKENS;
|
|
678
|
+
|
|
679
|
+
let memCtxTrimmed = memContext;
|
|
680
|
+
let memCtxTok = Math.ceil(memCtxTrimmed.length / 4);
|
|
681
|
+
let historyBudget = maxInputTokens - fixedTok - memCtxTok;
|
|
682
|
+
|
|
683
|
+
if (historyBudget < MIN_HISTORY_TOKENS) {
|
|
684
|
+
// Need to recover from memory. Each token = ~4 chars; cut from the
|
|
685
|
+
// END of memContext (renderMemoryContext emits pinned blocks first,
|
|
686
|
+
// recall hits last → tail is the least-pinned, easiest to drop).
|
|
687
|
+
const shortBy = MIN_HISTORY_TOKENS - historyBudget;
|
|
688
|
+
const charsToDrop = Math.min(memCtxTrimmed.length, shortBy * 4);
|
|
689
|
+
if (charsToDrop > 0) {
|
|
690
|
+
memCtxTrimmed = memCtxTrimmed.slice(0, memCtxTrimmed.length - charsToDrop)
|
|
691
|
+
+ (memCtxTrimmed.length - charsToDrop > 0 ? '\n[… memory context trimmed to fit profile.maxInputTokens]' : '');
|
|
692
|
+
memCtxTok = Math.ceil(memCtxTrimmed.length / 4);
|
|
693
|
+
historyBudget = maxInputTokens - fixedTok - memCtxTok;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (historyBudget < 500) {
|
|
698
|
+
cb({ type: 'error', data: { error:
|
|
699
|
+
`API profile maxInputTokens=${maxInputTokens} too small: ` +
|
|
700
|
+
`baseline (system=${systemTok} + tools=${toolsTok} + memory=${memCtxTok} + reserve=${OUTPUT_RESERVE_TOKENS}) ` +
|
|
701
|
+
`already takes ${fixedTok + memCtxTok} tokens. ` +
|
|
702
|
+
`Raise maxInputTokens in Settings → API Profiles, or pick a model with a larger context window.`
|
|
703
|
+
} });
|
|
704
|
+
return { ok: false, error: 'profile context budget exhausted' };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const history = trimOrphanToolResults(
|
|
708
|
+
listMessagesCapped(args.sessionId, HISTORY_MSG_BUDGET, historyBudget, estimateTokens),
|
|
709
|
+
);
|
|
710
|
+
if (history.length === 0) {
|
|
711
|
+
cb({ type: 'error', data: { error: 'Conversation context is empty after trimming an oversized result. Clear the chat or retry with a narrower query.' } });
|
|
712
|
+
return { ok: false, error: 'empty history' };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Stitch trimmed memContext onto base system for this call only.
|
|
716
|
+
const systemForCall = memCtxTrimmed
|
|
717
|
+
? `${system}\n\n─── Memory context (auto-loaded) ───\n${memCtxTrimmed}`
|
|
718
|
+
: system;
|
|
719
|
+
|
|
658
720
|
assistantBlocksAccum = [];
|
|
659
721
|
let currentTextBuf = '';
|
|
660
722
|
|
|
661
723
|
// ── Token composition log (input side, BEFORE the call) ──
|
|
662
|
-
// Heuristic char/4. Lets you correlate later with the provider's
|
|
663
|
-
// real usage.input_tokens — if the gap widens turn-over-turn, the
|
|
664
|
-
// memory/tools blob is silently growing.
|
|
665
|
-
const _systemTok = Math.ceil(system.length / 4);
|
|
666
|
-
const _memCtxTok = Math.ceil(memContext.length / 4);
|
|
667
|
-
const _toolsTok = Math.ceil(JSON.stringify(allTools).length / 4);
|
|
668
724
|
const _historyTok = history.reduce((s, m) => s + estimateTokens(m), 0);
|
|
669
725
|
const _historyMsgs = history.length;
|
|
670
|
-
|
|
726
|
+
const _est_in = systemTok + memCtxTok + toolsTok + _historyTok;
|
|
727
|
+
console.log(`[chat-tokens] session=${args.sessionId} turn=${iter} cap=${maxInputTokens} est_in=${_est_in} system=${systemTok} memory=${memCtxTok}${memCtxTrimmed.length < memContext.length ? '(trimmed)' : ''} tools=${toolsTok} history=${_historyTok}(${_historyMsgs}msgs) reserve=${OUTPUT_RESERVE_TOKENS}`);
|
|
671
728
|
|
|
672
729
|
const result = await streamLlm(
|
|
673
730
|
{
|
|
@@ -675,7 +732,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
675
732
|
apiKey: provider.apiKey,
|
|
676
733
|
baseUrl: provider.baseUrl,
|
|
677
734
|
model: provider.model,
|
|
678
|
-
system,
|
|
735
|
+
system: systemForCall,
|
|
679
736
|
history,
|
|
680
737
|
tools: allTools,
|
|
681
738
|
maxTokens: MAX_TOKENS,
|
package/lib/chat/llm/openai.ts
CHANGED
|
@@ -80,8 +80,12 @@ export const openaiAdapter: LlmAdapter = {
|
|
|
80
80
|
// Some providers (litellm/vLLM) reject `tools: []` — they want the
|
|
81
81
|
// field omitted entirely when there are no tools.
|
|
82
82
|
const hasTools = Object.keys(tools).length > 0;
|
|
83
|
+
// Force /v1/chat/completions. @ai-sdk/openai v3 routes the default
|
|
84
|
+
// factory to the Responses API (/v1/responses), which only OpenAI
|
|
85
|
+
// first-party hosts — DeepSeek/Qianwen/LiteLLM proxies/vLLM/Ollama
|
|
86
|
+
// all 400 on it. .chat() picks the universally-supported endpoint.
|
|
83
87
|
const result = streamText({
|
|
84
|
-
model: client(req.model),
|
|
88
|
+
model: client.chat(req.model),
|
|
85
89
|
system: req.system,
|
|
86
90
|
messages: historyToModelMessages(req.history),
|
|
87
91
|
...(hasTools ? { tools } : {}),
|
|
@@ -231,6 +231,18 @@ function touchSession(id: string): void {
|
|
|
231
231
|
|
|
232
232
|
// ─── Messages ────────────────────────────────────────────
|
|
233
233
|
|
|
234
|
+
// Cap any single field to 64KB before it hits sqlite. LLM proxies (LiteLLM,
|
|
235
|
+
// vLLM) sometimes return error payloads listing every message that failed
|
|
236
|
+
// validation — a 188-turn chat trips 6000+ entries and the JSON balloons
|
|
237
|
+
// to 25+ MB. One bad row drags out every UI page-load that touches it,
|
|
238
|
+
// so truncate at write time.
|
|
239
|
+
const FIELD_BYTE_CAP = 64 * 1024;
|
|
240
|
+
function capLargeText(s: string | undefined | null): string | null {
|
|
241
|
+
if (s == null) return null;
|
|
242
|
+
if (s.length <= FIELD_BYTE_CAP) return s;
|
|
243
|
+
return s.slice(0, FIELD_BYTE_CAP) + `\n… (truncated, full length ${s.length} chars)`;
|
|
244
|
+
}
|
|
245
|
+
|
|
234
246
|
export function appendMessage(opts: {
|
|
235
247
|
session_id: string;
|
|
236
248
|
role: Role;
|
|
@@ -238,13 +250,21 @@ export function appendMessage(opts: {
|
|
|
238
250
|
error?: string;
|
|
239
251
|
}): Message {
|
|
240
252
|
ensureSchema();
|
|
253
|
+
// Truncate any text block that's gone runaway (e.g. a streamed error
|
|
254
|
+
// message stuffed into the assistant turn for UI display).
|
|
255
|
+
const cappedBlocks = opts.blocks.map(b => {
|
|
256
|
+
if (b.type === 'text' && b.text.length > FIELD_BYTE_CAP) {
|
|
257
|
+
return { ...b, text: capLargeText(b.text)! };
|
|
258
|
+
}
|
|
259
|
+
return b;
|
|
260
|
+
});
|
|
241
261
|
const row: MessageRow = {
|
|
242
262
|
id: randomUUID(),
|
|
243
263
|
session_id: opts.session_id,
|
|
244
264
|
role: opts.role,
|
|
245
|
-
blocks: JSON.stringify(
|
|
265
|
+
blocks: JSON.stringify(cappedBlocks),
|
|
246
266
|
ts: Date.now(),
|
|
247
|
-
error: opts.error
|
|
267
|
+
error: capLargeText(opts.error),
|
|
248
268
|
};
|
|
249
269
|
db().prepare(`
|
|
250
270
|
INSERT INTO chat_messages (id, session_id, role, blocks, ts, error)
|
|
@@ -337,6 +337,135 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
337
337
|
: content;
|
|
338
338
|
},
|
|
339
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
|
+
|
|
340
469
|
// Namespace gating meta-tool. Connector tools (mantis.*, gitlab.*, etc.)
|
|
341
470
|
// are NOT in the active tools list by default — only their catalog entry
|
|
342
471
|
// is visible in the system prompt. Calling connector_open({name}) makes
|
|
@@ -471,6 +600,66 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
471
600
|
required: ['doc'],
|
|
472
601
|
},
|
|
473
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
|
+
},
|
|
474
663
|
{
|
|
475
664
|
name: 'connector_open',
|
|
476
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.',
|
|
@@ -520,7 +709,7 @@ function findConnectorTool(qualified: string): {
|
|
|
520
709
|
* the extension's runner finishes those at execution time.
|
|
521
710
|
*/
|
|
522
711
|
function buildConnectorPayload(
|
|
523
|
-
def: { id: string; name: string; runner?: 'main' | 'isolated'; host_match?: string; login_redirect?: string },
|
|
712
|
+
def: { id: string; name: string; runner?: 'main' | 'isolated'; host_match?: string; login_redirect?: string; tab_strategy?: 'reuse' | 'ephemeral' },
|
|
524
713
|
entry: ConnectorEntry,
|
|
525
714
|
settings: Record<string, any>,
|
|
526
715
|
) {
|
|
@@ -558,6 +747,11 @@ function buildConnectorPayload(
|
|
|
558
747
|
host_match: hostMatch,
|
|
559
748
|
login_redirect: loginRedirect,
|
|
560
749
|
runner: def.runner || entry.runner || 'main',
|
|
750
|
+
// Connector-level opt-in: 'ephemeral' = always open a fresh background
|
|
751
|
+
// tab, close after. Missing/'reuse' = pick an existing matching tab.
|
|
752
|
+
// Was being silently dropped — extension never saw it, so manifests
|
|
753
|
+
// with tab_strategy:ephemeral (mantis, tp) ran in reuse mode.
|
|
754
|
+
...(def.tab_strategy ? { tab_strategy: def.tab_strategy } : {}),
|
|
561
755
|
entries: [expandedEntry],
|
|
562
756
|
};
|
|
563
757
|
}
|
|
@@ -340,6 +340,57 @@ X-Forge-Token: …
|
|
|
340
340
|
or `multipart/form-data` with a `file` field accepting `.yaml`,
|
|
341
341
|
`.yml`, or `.zip`. Zip must have `manifest.yaml` at the root.
|
|
342
342
|
|
|
343
|
+
### POST /api/connectors/import-config-template
|
|
344
|
+
|
|
345
|
+
Bulk-fill `connector-configs.json` from a JSON template. The Settings →
|
|
346
|
+
Connectors panel exposes this as the **↥ Import Template** button.
|
|
347
|
+
|
|
348
|
+
The template format:
|
|
349
|
+
|
|
350
|
+
```json
|
|
351
|
+
{
|
|
352
|
+
"_README": "...",
|
|
353
|
+
"_prompts": {
|
|
354
|
+
"gitlab_pat": {
|
|
355
|
+
"label": "GitLab PAT",
|
|
356
|
+
"hint": "where to grab it",
|
|
357
|
+
"secret": true,
|
|
358
|
+
"required": true
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
"gitlab": {
|
|
362
|
+
"config": { "base_url": "https://...", "token": "${gitlab_pat}" },
|
|
363
|
+
"enabled": true
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Behavior:
|
|
369
|
+
|
|
370
|
+
- Two-phase: `multipart/form-data` (analyze) returns deduplicated list of
|
|
371
|
+
`${key}` placeholders the user still needs to fill. `application/json
|
|
372
|
+
{template, values}` (apply) substitutes and merges.
|
|
373
|
+
- Same placeholder key referenced from multiple connectors (e.g.
|
|
374
|
+
`gitlab_pat` used by `gitlab.token` AND `jenkins.instances[0].gitlab_pat`)
|
|
375
|
+
is asked **once** and applied to every target.
|
|
376
|
+
- Static values (non-placeholder) are applied as-is.
|
|
377
|
+
- **Existing non-empty fields are preserved** — the template never
|
|
378
|
+
overwrites a real token a user already configured (a `TODO_*` carry-over
|
|
379
|
+
is treated as empty and gets overwritten).
|
|
380
|
+
- Connectors whose manifest is not installed yet are skipped and reported
|
|
381
|
+
in the result. Sync the marketplace first, then re-import.
|
|
382
|
+
- Keys starting with `_` (`_README`, `_prompts`, etc.) are metadata.
|
|
383
|
+
|
|
384
|
+
Forge ships a default template at `templates/connector-config-template.json`,
|
|
385
|
+
bundled into the build. The Import button always works out of the box.
|
|
386
|
+
Users can override the bundled one by dropping their own at
|
|
387
|
+
`<dataDir>/config-template.json` — the GET endpoint picks the override
|
|
388
|
+
first, else falls back to the bundle.
|
|
389
|
+
|
|
390
|
+
Each prompt may include `url` + `url_label`, surfaced in the modal as
|
|
391
|
+
a "↗ Get token" link next to the field so the user can jump straight
|
|
392
|
+
to the page that issues the value.
|
|
393
|
+
|
|
343
394
|
## Migration from pre-v0.9
|
|
344
395
|
|
|
345
396
|
Pre-v0.9 Forge stored connectors as built-in plugins under
|
package/lib/settings.ts
CHANGED
|
@@ -64,6 +64,15 @@ export interface ApiProfile {
|
|
|
64
64
|
model: string;
|
|
65
65
|
apiKey: string;
|
|
66
66
|
baseUrl?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Total input-token ceiling for chat. Includes system prompt + connector
|
|
69
|
+
* catalog + memory context (summary + Temper recall) + history. Forge
|
|
70
|
+
* dynamically trims (memory tail dropped first, then oldest history)
|
|
71
|
+
* to keep the assembled prompt under this. Defaults to 200k (Claude
|
|
72
|
+
* Code's working window) when unset; lower it (e.g. 12000) for older
|
|
73
|
+
* 16k-context models that would otherwise truncate server-side.
|
|
74
|
+
*/
|
|
75
|
+
maxInputTokens?: number;
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
/**
|
|
@@ -186,6 +195,12 @@ export interface Settings {
|
|
|
186
195
|
pipelineTmpKeepFailedDays: number;
|
|
187
196
|
pipelineTmpKeepCancelledDays: number;
|
|
188
197
|
pipelineTmpGcIntervalHours: number;
|
|
198
|
+
/**
|
|
199
|
+
* First-run onboarding wizard. False (or absent) → Forge shows the
|
|
200
|
+
* wizard on every launch until the user completes it or skips. True
|
|
201
|
+
* → silent. Settings → "Re-run Onboarding" sets it back to false.
|
|
202
|
+
*/
|
|
203
|
+
onboardingCompleted: boolean;
|
|
189
204
|
}
|
|
190
205
|
|
|
191
206
|
const defaults: Settings = {
|
|
@@ -236,6 +251,7 @@ const defaults: Settings = {
|
|
|
236
251
|
pipelineTmpKeepFailedDays: 3,
|
|
237
252
|
pipelineTmpKeepCancelledDays: 3,
|
|
238
253
|
pipelineTmpGcIntervalHours: 6,
|
|
254
|
+
onboardingCompleted: false,
|
|
239
255
|
};
|
|
240
256
|
|
|
241
257
|
/** Decrypt nested apiKey fields in agents (legacy migration window) +
|
package/package.json
CHANGED