@aion0/forge 0.10.36 → 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/RELEASE_NOTES.md +3 -11
- 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 +6 -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)
|
|
@@ -709,7 +709,7 @@ function findConnectorTool(qualified: string): {
|
|
|
709
709
|
* the extension's runner finishes those at execution time.
|
|
710
710
|
*/
|
|
711
711
|
function buildConnectorPayload(
|
|
712
|
-
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' },
|
|
713
713
|
entry: ConnectorEntry,
|
|
714
714
|
settings: Record<string, any>,
|
|
715
715
|
) {
|
|
@@ -747,6 +747,11 @@ function buildConnectorPayload(
|
|
|
747
747
|
host_match: hostMatch,
|
|
748
748
|
login_redirect: loginRedirect,
|
|
749
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 } : {}),
|
|
750
755
|
entries: [expandedEntry],
|
|
751
756
|
};
|
|
752
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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_README": "Team template — import via Forge UI: Settings → Connectors → 'Import Template' button. Forge scans the ${key} placeholders, asks you once for each (shared keys like gitlab_pat are asked once and applied everywhere). After import, existing non-empty values are preserved — template never overwrites your real tokens.",
|
|
3
|
+
|
|
4
|
+
"_prompts": {
|
|
5
|
+
"gitlab_token_name": {
|
|
6
|
+
"label": "GitLab token name",
|
|
7
|
+
"hint": "Identifier of your GitLab token (typically your AD/LDAP login, e.g. 'zliu'). Reused by Jenkins as the gitlab_token_name + injected as TOKEN_USER build param.",
|
|
8
|
+
"group": "gitlab",
|
|
9
|
+
"required": true
|
|
10
|
+
},
|
|
11
|
+
"gitlab_pat": {
|
|
12
|
+
"label": "GitLab Personal Access Token",
|
|
13
|
+
"hint": "Scope: read_api + api. Reused as TOKEN_PASSWORD build param for Jenkins-triggered jobs.",
|
|
14
|
+
"url": "https://dops-git106.fortinet-us.com/-/user_settings/personal_access_tokens",
|
|
15
|
+
"url_label": "Open GitLab PAT page",
|
|
16
|
+
"secret": true,
|
|
17
|
+
"required": true
|
|
18
|
+
},
|
|
19
|
+
"jenkins_username": {
|
|
20
|
+
"label": "Jenkins username",
|
|
21
|
+
"hint": "Your Jenkins login (AD/LDAP), e.g. 'zliu'.",
|
|
22
|
+
"group": "jenkins",
|
|
23
|
+
"required": true
|
|
24
|
+
},
|
|
25
|
+
"jenkins_api_token": {
|
|
26
|
+
"label": "Jenkins API Token",
|
|
27
|
+
"hint": "User avatar → Configure → API Token → Add new",
|
|
28
|
+
"url": "http://nac-dev-jenkins.fortinet-us.com:8080/me/configure",
|
|
29
|
+
"url_label": "Open Jenkins user configure page",
|
|
30
|
+
"secret": true,
|
|
31
|
+
"required": true,
|
|
32
|
+
"group": "jenkins"
|
|
33
|
+
},
|
|
34
|
+
"blackduck_api_token": {
|
|
35
|
+
"label": "Black Duck API Token",
|
|
36
|
+
"hint": "User menu → My Access Tokens → Create new. Forge auto-exchanges for ~2h JWT.",
|
|
37
|
+
"url": "https://dops-blackduck.fortinet-us.com/api/current-user/tokens",
|
|
38
|
+
"url_label": "Open Black Duck tokens page",
|
|
39
|
+
"secret": true,
|
|
40
|
+
"required": true
|
|
41
|
+
},
|
|
42
|
+
"nac_admin_password": {
|
|
43
|
+
"label": "NAC admin password (optional)",
|
|
44
|
+
"hint": "Used by SSH + REST login() fallback. Leave blank if you'll pass per-call.",
|
|
45
|
+
"secret": true,
|
|
46
|
+
"required": false
|
|
47
|
+
},
|
|
48
|
+
"fortincm_password": {
|
|
49
|
+
"label": "FortiNCM admin password (optional)",
|
|
50
|
+
"hint": "Only needed if you use NCM",
|
|
51
|
+
"secret": true,
|
|
52
|
+
"required": false
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
"gitlab": {
|
|
57
|
+
"config": {
|
|
58
|
+
"base_url": "https://dops-git106.fortinet-us.com/",
|
|
59
|
+
"token": "${gitlab_pat}"
|
|
60
|
+
},
|
|
61
|
+
"enabled": true
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
"mantis": {
|
|
65
|
+
"config": {
|
|
66
|
+
"base_url": "https://mantis.fortinet.com/",
|
|
67
|
+
"default_project": "FortiNAC"
|
|
68
|
+
},
|
|
69
|
+
"enabled": true
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
"pmdb": {
|
|
73
|
+
"config": {
|
|
74
|
+
"base_url": "https://pmdb.fortinet.com/"
|
|
75
|
+
},
|
|
76
|
+
"enabled": true
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
"teams": {
|
|
80
|
+
"config": {
|
|
81
|
+
"base_url": "https://teams.microsoft.com/"
|
|
82
|
+
},
|
|
83
|
+
"enabled": true
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
"jenkins": {
|
|
87
|
+
"config": {
|
|
88
|
+
"instances": "[{\"name\":\"default-jenkins\",\"base_url\":\"http://nac-dev-jenkins.fortinet-us.com:8080\",\"username\":\"${jenkins_username}\",\"api_token\":\"${jenkins_api_token}\",\"gitlab_pat\":\"${gitlab_pat}\",\"gitlab_token_name\":\"${gitlab_token_name}\",\"gitlab_token_name_param\":\"${gitlab_token_name}\",\"gitlab_pat_param\":\"\",\"inject_params\":\"[{\\\"name\\\":\\\"TOKEN_USER\\\",\\\"value\\\":\\\"${gitlab_token_name}\\\"},{\\\"name\\\":\\\"TOKEN_PASSWORD\\\",\\\"value\\\":\\\"${gitlab_pat}\\\"}]\"}]"
|
|
89
|
+
},
|
|
90
|
+
"enabled": true
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
"tp": {
|
|
94
|
+
"config": {
|
|
95
|
+
"base_url": "https://nac-tp.fortinet-us.com",
|
|
96
|
+
"api_base_url": "https://nac-tp.fortinet-us.com:8000",
|
|
97
|
+
"username": "${user_name}"
|
|
98
|
+
},
|
|
99
|
+
"enabled": true
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
"nac": {
|
|
103
|
+
"config": {
|
|
104
|
+
"port": "22",
|
|
105
|
+
"username": "admin",
|
|
106
|
+
"password": "${nac_admin_password}"
|
|
107
|
+
},
|
|
108
|
+
"enabled": true
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
"fortincm": {
|
|
112
|
+
"config": {
|
|
113
|
+
"username": "admin",
|
|
114
|
+
"password": "${fortincm_password}"
|
|
115
|
+
},
|
|
116
|
+
"enabled": false
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
"scap": {
|
|
120
|
+
"config": {},
|
|
121
|
+
"enabled": true
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
"blackduck": {
|
|
125
|
+
"config": {
|
|
126
|
+
"base_url": "https://dops-blackduck.fortinet-us.com",
|
|
127
|
+
"api_token": "${blackduck_api_token}"
|
|
128
|
+
},
|
|
129
|
+
"enabled": true
|
|
130
|
+
}
|
|
131
|
+
}
|