@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.
@@ -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
- const cmd = `cd "${projectPath}" && claude --resume ${sessionId}${sf}${mcpFlag}\n`;
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 = {
@@ -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 budgets for the LLM history. Capped by message count
51
- // AND by token estimate (whichever hits first), see design §8. Older
52
- // raw is summarized by the memory-standalone Temper Summary sub-task
53
- // and recalled via buildMemoryContext as compact blocks instead.
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
- // Bumped 8000 32000 modern models all 200k context; 8000 was
56
- // stripping single oversized tool results (e.g. mantis.search_bugs
57
- // returning 20k chars), leaving history empty after orphan-trim.
58
- const HISTORY_TOKEN_BUDGET = 32000;
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
- const newOpenSet = computeOpenSet(history, assistantBlocksAccum);
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
- console.log(`[chat-tokens] session=${args.sessionId} turn=${iter} est_in=${_systemTok + _historyTok + _toolsTok} system=${_systemTok} history=${_historyTok}(${_historyMsgs}msgs) memory=${_memCtxTok} tools=${_toolsTok}`);
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,
@@ -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(opts.blocks),
265
+ blocks: JSON.stringify(cappedBlocks),
246
266
  ts: Date.now(),
247
- error: opts.error ?? null,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.35",
3
+ "version": "0.10.37",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {