@aion0/forge 0.10.58 → 0.10.65
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 +9 -3
- package/app/chat/page.tsx +134 -2
- package/components/SettingsModal.tsx +8 -1
- package/lib/chat/agent-loop.ts +4 -2
- package/lib/chat/llm/anthropic.ts +5 -1
- package/lib/chat/llm/openai.ts +5 -1
- package/lib/chat/llm/types.ts +6 -0
- package/lib/chat/tool-dispatcher.ts +176 -8
- package/lib/connectors/sync.ts +6 -1
- package/lib/help-docs/13-schedules.md +15 -3
- package/package.json +1 -1
- package/publish.sh +26 -2
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.65
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-10
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.64
|
|
6
6
|
|
|
7
|
+
### Other
|
|
8
|
+
- fix(connectors): cache-bust github_api manifest fetch
|
|
9
|
+
- feat(chat): connector _files download channel + extract_archive + create_schedule prompt mode
|
|
10
|
+
- feat(settings): ↻ refresh-models button — pull registry past the 24h cache
|
|
11
|
+
- fix(publish): preflight gh with real API call + actionable 401 hint
|
|
7
12
|
|
|
8
|
-
|
|
13
|
+
|
|
14
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.64...v0.10.65
|
package/app/chat/page.tsx
CHANGED
|
@@ -43,6 +43,17 @@ interface ChatSession extends Session {
|
|
|
43
43
|
meta?: { kind?: 'main' | 'temp'; [k: string]: unknown };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// One configured API profile, as the header's quick-switcher lists them.
|
|
47
|
+
// Mirrors settings.apiProfiles entries (apiKey is masked by /api/settings —
|
|
48
|
+
// we never read it here, just id/name/provider/model to label the option).
|
|
49
|
+
interface ProfileOption {
|
|
50
|
+
id: string;
|
|
51
|
+
name: string;
|
|
52
|
+
provider: string;
|
|
53
|
+
model: string;
|
|
54
|
+
enabled: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
export default function ChatPage() {
|
|
47
58
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
|
48
59
|
// Inline rename state — only one row edits at a time. editingId is the
|
|
@@ -63,6 +74,13 @@ export default function ChatPage() {
|
|
|
63
74
|
const [memory, setMemory] = useState<MemoryStatus | null>(null);
|
|
64
75
|
const [memoryOpen, setMemoryOpen] = useState(false);
|
|
65
76
|
const [error, setError] = useState('');
|
|
77
|
+
// Configured API profiles + the header quick-switch dropdown's open state.
|
|
78
|
+
const [profiles, setProfiles] = useState<ProfileOption[]>([]);
|
|
79
|
+
const [switchOpen, setSwitchOpen] = useState(false);
|
|
80
|
+
// The model the backend ACTUALLY served on the last turn (from the
|
|
81
|
+
// provider response, not the model's self-description). Reset on session
|
|
82
|
+
// switch so it never shows a stale value from a different conversation.
|
|
83
|
+
const [servedModel, setServedModel] = useState('');
|
|
66
84
|
|
|
67
85
|
const eventSrcRef = useRef<EventSource | null>(null);
|
|
68
86
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
@@ -108,9 +126,32 @@ export default function ChatPage() {
|
|
|
108
126
|
}, []);
|
|
109
127
|
|
|
110
128
|
useEffect(() => {
|
|
129
|
+
setServedModel(''); // stale across sessions — cleared until next turn reports
|
|
111
130
|
if (activeId) loadMessages(activeId);
|
|
112
131
|
}, [activeId, loadMessages]);
|
|
113
132
|
|
|
133
|
+
// ─── Load configured API profiles (for the header quick-switcher) ──
|
|
134
|
+
// Served by next-server's /api/settings (same origin) with apiKeys masked.
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
(async () => {
|
|
137
|
+
try {
|
|
138
|
+
const r = await fetch('/api/settings');
|
|
139
|
+
if (!r.ok) return;
|
|
140
|
+
const s = (await r.json()) as { apiProfiles?: Record<string, { name?: string; enabled?: boolean; provider?: string; model?: string }> };
|
|
141
|
+
const list: ProfileOption[] = Object.entries(s.apiProfiles || {})
|
|
142
|
+
.filter(([, p]) => p && p.enabled !== false)
|
|
143
|
+
.map(([id, p]) => ({
|
|
144
|
+
id,
|
|
145
|
+
name: p.name || id,
|
|
146
|
+
provider: p.provider || 'anthropic',
|
|
147
|
+
model: p.model || 'default',
|
|
148
|
+
enabled: p.enabled !== false,
|
|
149
|
+
}));
|
|
150
|
+
setProfiles(list);
|
|
151
|
+
} catch { /* non-fatal — header just falls back to plain text */ }
|
|
152
|
+
})();
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
114
155
|
// ─── SSE subscription ─────────────────────────────────────
|
|
115
156
|
useEffect(() => {
|
|
116
157
|
if (!activeId) return;
|
|
@@ -139,6 +180,7 @@ export default function ChatPage() {
|
|
|
139
180
|
setStreaming(false);
|
|
140
181
|
setStopRequested(false);
|
|
141
182
|
setPartial('');
|
|
183
|
+
if (data.served_model) setServedModel(String(data.served_model));
|
|
142
184
|
loadMessages(activeId);
|
|
143
185
|
refreshSessions();
|
|
144
186
|
} else if (type === 'watch_status') {
|
|
@@ -320,6 +362,32 @@ export default function ChatPage() {
|
|
|
320
362
|
}
|
|
321
363
|
}
|
|
322
364
|
|
|
365
|
+
// Switch the active session's API profile. session.provider holds the
|
|
366
|
+
// apiProfile id; session.model the (optional) override. We set both to the
|
|
367
|
+
// chosen profile's id + its configured model so the next turn uses it —
|
|
368
|
+
// updateSession merges with ?? semantics, so passing the model explicitly
|
|
369
|
+
// avoids carrying a stale model from the previous profile.
|
|
370
|
+
async function switchProfile(p: ProfileOption) {
|
|
371
|
+
setSwitchOpen(false);
|
|
372
|
+
if (!activeId) return;
|
|
373
|
+
if (activeSession?.provider === p.id) return; // already on it
|
|
374
|
+
try {
|
|
375
|
+
const r = await fetch(`${PROXY}/sessions/${activeId}`, {
|
|
376
|
+
method: 'PATCH',
|
|
377
|
+
headers: { 'content-type': 'application/json' },
|
|
378
|
+
body: JSON.stringify({ provider: p.id, model: p.model }),
|
|
379
|
+
});
|
|
380
|
+
if (!r.ok) {
|
|
381
|
+
const j = await r.json().catch(() => ({}));
|
|
382
|
+
setError(j.error || `switch failed (HTTP ${r.status})`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
await refreshSessions();
|
|
386
|
+
} catch (e) {
|
|
387
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
323
391
|
async function clearMessages() {
|
|
324
392
|
if (!activeId) return;
|
|
325
393
|
if (!confirm('Clear all messages in this session?')) return;
|
|
@@ -497,10 +565,74 @@ export default function ChatPage() {
|
|
|
497
565
|
'No session'}
|
|
498
566
|
</div>
|
|
499
567
|
{activeSession && (
|
|
500
|
-
<div className="
|
|
501
|
-
|
|
568
|
+
<div className="relative inline-block">
|
|
569
|
+
<button
|
|
570
|
+
type="button"
|
|
571
|
+
onClick={() => setSwitchOpen((v) => !v)}
|
|
572
|
+
disabled={profiles.length === 0}
|
|
573
|
+
title={profiles.length ? 'Switch API profile for this conversation' : 'No API profiles configured'}
|
|
574
|
+
className="flex items-center gap-1 text-[11px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:cursor-default disabled:hover:text-[var(--text-secondary)] transition-colors"
|
|
575
|
+
>
|
|
576
|
+
<span>
|
|
577
|
+
{(() => {
|
|
578
|
+
const cur = profiles.find((p) => p.id === activeSession.provider);
|
|
579
|
+
const label = cur?.name || activeSession.provider || 'auto';
|
|
580
|
+
return `${label} · ${activeSession.model || cur?.model || 'default'}`;
|
|
581
|
+
})()}
|
|
582
|
+
</span>
|
|
583
|
+
{profiles.length > 0 && <span className="opacity-50 text-[9px]">▼</span>}
|
|
584
|
+
</button>
|
|
585
|
+
{switchOpen && profiles.length > 0 && (
|
|
586
|
+
<>
|
|
587
|
+
{/* click-away backdrop */}
|
|
588
|
+
<div className="fixed inset-0 z-10" onClick={() => setSwitchOpen(false)} />
|
|
589
|
+
<div className="absolute left-0 top-full mt-1 z-20 min-w-[200px] max-h-[60vh] overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-secondary)] shadow-lg py-1">
|
|
590
|
+
{profiles.map((p) => {
|
|
591
|
+
const active = p.id === activeSession.provider;
|
|
592
|
+
return (
|
|
593
|
+
<button
|
|
594
|
+
key={p.id}
|
|
595
|
+
type="button"
|
|
596
|
+
onClick={() => switchProfile(p)}
|
|
597
|
+
className={`w-full text-left px-3 py-1.5 text-[11px] hover:bg-[var(--bg-tertiary)] transition-colors ${
|
|
598
|
+
active ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'
|
|
599
|
+
}`}
|
|
600
|
+
>
|
|
601
|
+
<div className="flex items-center gap-1.5">
|
|
602
|
+
{active && <span className="text-[9px]">✓</span>}
|
|
603
|
+
<span className="font-medium truncate">{p.name}</span>
|
|
604
|
+
</div>
|
|
605
|
+
<div className="text-[10px] text-[var(--text-secondary)] truncate pl-0.5">
|
|
606
|
+
{p.provider} · {p.model}
|
|
607
|
+
</div>
|
|
608
|
+
</button>
|
|
609
|
+
);
|
|
610
|
+
})}
|
|
611
|
+
</div>
|
|
612
|
+
</>
|
|
613
|
+
)}
|
|
502
614
|
</div>
|
|
503
615
|
)}
|
|
616
|
+
{servedModel && (() => {
|
|
617
|
+
// The honest backend identity. Tint amber when it differs from
|
|
618
|
+
// the configured model — that means the proxy (litellm) fell
|
|
619
|
+
// back server-side (e.g. qwen → claude), which is the usual
|
|
620
|
+
// cause of "why is everything claude".
|
|
621
|
+
const cur = profiles.find((p) => p.id === activeSession?.provider);
|
|
622
|
+
const requested = activeSession?.model || cur?.model || '';
|
|
623
|
+
const mismatch = requested && servedModel && servedModel !== requested;
|
|
624
|
+
return (
|
|
625
|
+
<div
|
|
626
|
+
className="text-[10px] mt-0.5"
|
|
627
|
+
style={{ color: mismatch ? '#fbbf24' : 'var(--text-secondary)' }}
|
|
628
|
+
title={mismatch
|
|
629
|
+
? `Backend actually served "${servedModel}" — differs from the requested "${requested}". The proxy fell back server-side.`
|
|
630
|
+
: `Backend-reported model for the last reply (trustworthy — not the model's self-description)`}
|
|
631
|
+
>
|
|
632
|
+
served: {servedModel}{mismatch ? ` ⚠ (≠ ${requested})` : ''}
|
|
633
|
+
</div>
|
|
634
|
+
);
|
|
635
|
+
})()}
|
|
504
636
|
</div>
|
|
505
637
|
<button
|
|
506
638
|
onClick={clearMessages}
|
|
@@ -1799,7 +1799,7 @@ function MarketplaceProvidersSection() {
|
|
|
1799
1799
|
}
|
|
1800
1800
|
|
|
1801
1801
|
function AgentsSection({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
|
|
1802
|
-
const { registry: modelsRegistry } = useModelsRegistry();
|
|
1802
|
+
const { registry: modelsRegistry, refresh: refreshModels, loading: modelsLoading } = useModelsRegistry();
|
|
1803
1803
|
const [agents, setAgents] = useState<AgentEntry[]>([]);
|
|
1804
1804
|
const [loading, setLoading] = useState(true);
|
|
1805
1805
|
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
|
@@ -2126,6 +2126,13 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
2126
2126
|
{/* Preset models */}
|
|
2127
2127
|
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
|
|
2128
2128
|
<span className="text-[8px] text-[var(--text-secondary)]">Presets:</span>
|
|
2129
|
+
<button
|
|
2130
|
+
type="button"
|
|
2131
|
+
onClick={() => { void refreshModels(); }}
|
|
2132
|
+
disabled={modelsLoading}
|
|
2133
|
+
title="Refresh model list from the public-info registry (bypasses the 24h cache)"
|
|
2134
|
+
className="text-[8px] px-1 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
2135
|
+
>{modelsLoading ? '…' : '↻'}</button>
|
|
2129
2136
|
{((() => {
|
|
2130
2137
|
const ct = (settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic');
|
|
2131
2138
|
// Models pulled from forge-public-info repo (lib/public-info/fetch.ts).
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -773,6 +773,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
773
773
|
|
|
774
774
|
let iter = 0;
|
|
775
775
|
let lastStop = '';
|
|
776
|
+
let lastServedModelId = '';
|
|
776
777
|
let assistantBlocksAccum: ContentBlock[] = [];
|
|
777
778
|
|
|
778
779
|
// beginTurn already ran at function entry (see top of runTurn). The
|
|
@@ -933,8 +934,9 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
933
934
|
// ── Real usage from the provider (when reported) ──
|
|
934
935
|
if (result.usage) {
|
|
935
936
|
const u = result.usage;
|
|
936
|
-
console.log(`[chat-tokens] session=${args.sessionId} turn=${iter} REAL in=${u.inputTokens ?? '?'} out=${u.outputTokens ?? '?'} cache_read=${u.cacheReadTokens ?? 0} cache_create=${u.cacheCreationTokens ?? 0} stop=${result.stopReason}`);
|
|
937
|
+
console.log(`[chat-tokens] session=${args.sessionId} turn=${iter} REAL in=${u.inputTokens ?? '?'} out=${u.outputTokens ?? '?'} cache_read=${u.cacheReadTokens ?? 0} cache_create=${u.cacheCreationTokens ?? 0} stop=${result.stopReason} served=${result.servedModelId ?? '?'}`);
|
|
937
938
|
}
|
|
939
|
+
if (result.servedModelId) lastServedModelId = result.servedModelId;
|
|
938
940
|
|
|
939
941
|
lastStop = result.stopReason;
|
|
940
942
|
assistantBlocksAccum = result.content;
|
|
@@ -1008,7 +1010,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
1008
1010
|
cb({ type: 'error', data: { error: `iteration limit (${MAX_ITERATIONS}) exceeded` } });
|
|
1009
1011
|
}
|
|
1010
1012
|
|
|
1011
|
-
cb({ type: 'turn_done', data: { iterations: iter, stop_reason: lastStop } });
|
|
1013
|
+
cb({ type: 'turn_done', data: { iterations: iter, stop_reason: lastStop, served_model: lastServedModelId || undefined, requested_model: provider.model, profile: provider.name } });
|
|
1012
1014
|
return { ok: true };
|
|
1013
1015
|
} catch (err) {
|
|
1014
1016
|
let msg = err instanceof Error ? err.message : String(err);
|
|
@@ -203,6 +203,10 @@ export const anthropicAdapter: LlmAdapter = {
|
|
|
203
203
|
};
|
|
204
204
|
}
|
|
205
205
|
} catch {}
|
|
206
|
-
|
|
206
|
+
// The model the backend ACTUALLY served — may differ from req.model
|
|
207
|
+
// when a litellm/relay proxy falls back (e.g. forti-coder → claude).
|
|
208
|
+
let servedModelId: string | undefined;
|
|
209
|
+
try { servedModelId = (await result.response)?.modelId; } catch {}
|
|
210
|
+
return { stopReason: mapStop(finishReason), content, usage, servedModelId };
|
|
207
211
|
},
|
|
208
212
|
};
|
package/lib/chat/llm/openai.ts
CHANGED
|
@@ -123,6 +123,10 @@ export const openaiAdapter: LlmAdapter = {
|
|
|
123
123
|
};
|
|
124
124
|
}
|
|
125
125
|
} catch {}
|
|
126
|
-
|
|
126
|
+
// The model the backend ACTUALLY served — may differ from req.model
|
|
127
|
+
// when a litellm/relay proxy falls back (e.g. forti-coder → claude).
|
|
128
|
+
let servedModelId: string | undefined;
|
|
129
|
+
try { servedModelId = (await result.response)?.modelId; } catch {}
|
|
130
|
+
return { stopReason: mapStop(finishReason), content, usage, servedModelId };
|
|
127
131
|
},
|
|
128
132
|
};
|
package/lib/chat/llm/types.ts
CHANGED
|
@@ -35,6 +35,12 @@ export interface LlmTurnResult {
|
|
|
35
35
|
/** Token usage from the provider, if reported. May be partially-filled
|
|
36
36
|
* or absent for proxies that don't expose it. */
|
|
37
37
|
usage?: LlmTurnUsage;
|
|
38
|
+
/** The model id the backend actually served, read from the response
|
|
39
|
+
* (`response.modelId`). For litellm/relay proxies this can DIFFER from
|
|
40
|
+
* the requested model — the proxy may silently fall back (e.g. qwen →
|
|
41
|
+
* claude-sonnet-4-6). This is the only trustworthy identity, since the
|
|
42
|
+
* model's own self-description in the text is often wrong. */
|
|
43
|
+
servedModelId?: string;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
export interface LlmRequest {
|
|
@@ -116,6 +116,53 @@ async function resolveScratchRefsInArgs(args: Record<string, unknown>): Promise<
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// Download channel (mirror of the scratch:// upload path). A connector that
|
|
120
|
+
// fetched a binary (e.g. owa.download_attachment) can't return the bytes
|
|
121
|
+
// through the model — the 16KB tool-result cap would shred any base64. So it
|
|
122
|
+
// returns `_files: [{ filename, content_base64, content_type }]` and we
|
|
123
|
+
// materialize them HERE: decode → write to <dataDir>/tmp/ → strip the base64
|
|
124
|
+
// and replace each entry with a small {filename, path, file_url, size_bytes}
|
|
125
|
+
// pointer the model can hand to read_forge_file / extract_archive. Bytes
|
|
126
|
+
// never reach the LLM. Generic — any protocol's result is scanned.
|
|
127
|
+
async function materializeConnectorFiles(result: ToolResult): Promise<ToolResult> {
|
|
128
|
+
let parsed: unknown;
|
|
129
|
+
try { parsed = JSON.parse(result.content); } catch { return result; }
|
|
130
|
+
if (!parsed || typeof parsed !== 'object') return result;
|
|
131
|
+
const files = (parsed as { _files?: unknown })._files;
|
|
132
|
+
if (!Array.isArray(files) || files.length === 0) return result;
|
|
133
|
+
|
|
134
|
+
const { mkdir, writeFile } = await import('node:fs/promises');
|
|
135
|
+
const { dirname, basename } = await import('node:path');
|
|
136
|
+
const out: Array<Record<string, unknown>> = [];
|
|
137
|
+
for (const f of files) {
|
|
138
|
+
const e = (f && typeof f === 'object') ? f as Record<string, unknown> : {};
|
|
139
|
+
const b64 = typeof e.content_base64 === 'string' ? e.content_base64 : '';
|
|
140
|
+
const rawName = String(e.filename || e.name || 'download.bin');
|
|
141
|
+
// Bare, sanitized name under tmp/ — no traversal, no nesting (the cache
|
|
142
|
+
// janitor only sweeps top-level tmp/ files).
|
|
143
|
+
const safe = (basename(rawName).replace(/[\0/\\]/g, '_').replace(/^\.+/, '') || 'download.bin').slice(0, 200);
|
|
144
|
+
if (!b64) { out.push({ ...e, error: 'no content_base64' }); continue; }
|
|
145
|
+
try {
|
|
146
|
+
const buf = Buffer.from(b64, 'base64');
|
|
147
|
+
const target = await resolveTmpPath(safe);
|
|
148
|
+
await mkdir(dirname(target), { recursive: true });
|
|
149
|
+
await writeFile(target, buf);
|
|
150
|
+
out.push({
|
|
151
|
+
filename: safe,
|
|
152
|
+
path: `tmp/${safe}`,
|
|
153
|
+
local_path: target,
|
|
154
|
+
file_url: `file://${target}`,
|
|
155
|
+
size_bytes: buf.length,
|
|
156
|
+
...(e.content_type ? { content_type: e.content_type } : {}),
|
|
157
|
+
});
|
|
158
|
+
} catch (err) {
|
|
159
|
+
out.push({ filename: safe, error: (err as Error).message });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const next = { ...(parsed as Record<string, unknown>), _files: out };
|
|
163
|
+
return { ...result, content: JSON.stringify(next) };
|
|
164
|
+
}
|
|
165
|
+
|
|
119
166
|
const BUILTINS: Record<string, BuiltinHandler> = {
|
|
120
167
|
get_current_time: async () => new Date().toISOString(),
|
|
121
168
|
|
|
@@ -475,6 +522,82 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
475
522
|
});
|
|
476
523
|
},
|
|
477
524
|
|
|
525
|
+
// Extract a zip/tar/gz archive sitting in tmp/ (e.g. one a connector just
|
|
526
|
+
// downloaded via the _files channel) into tmp/<base>-extracted/, then
|
|
527
|
+
// return the file listing so the agent can read_forge_file each entry.
|
|
528
|
+
// The chat agent has no shell, so this shells out to system unzip/tar/
|
|
529
|
+
// gunzip on its behalf. Stays inside tmp/ on both ends.
|
|
530
|
+
extract_archive: async (input) => {
|
|
531
|
+
const params = (input as { filename?: string } | undefined) || {};
|
|
532
|
+
const raw = String(params.filename || '').trim().replace(/^tmp\//, '');
|
|
533
|
+
if (!raw) return JSON.stringify({ ok: false, error: 'filename is required (a tmp/ archive, e.g. "report.zip")' });
|
|
534
|
+
let archive: string;
|
|
535
|
+
try { archive = await resolveTmpPath(raw); }
|
|
536
|
+
catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
|
|
537
|
+
const { existsSync } = await import('node:fs');
|
|
538
|
+
if (!existsSync(archive)) return JSON.stringify({ ok: false, error: `archive not found: tmp/${raw}` });
|
|
539
|
+
|
|
540
|
+
const lower = raw.toLowerCase();
|
|
541
|
+
const base = raw.replace(/\.(zip|tar\.gz|tgz|tar|gz)$/i, '');
|
|
542
|
+
const outRel = `${base}-extracted`;
|
|
543
|
+
let outDir: string;
|
|
544
|
+
try { outDir = await resolveTmpPath(outRel); }
|
|
545
|
+
catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
|
|
546
|
+
|
|
547
|
+
const { execFile } = await import('node:child_process');
|
|
548
|
+
const { promisify } = await import('node:util');
|
|
549
|
+
const { mkdir, readdir, stat } = await import('node:fs/promises');
|
|
550
|
+
const { join, relative } = await import('node:path');
|
|
551
|
+
const run = promisify(execFile);
|
|
552
|
+
await mkdir(outDir, { recursive: true });
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
if (lower.endsWith('.zip')) {
|
|
556
|
+
await run('unzip', ['-o', '-qq', archive, '-d', outDir], { timeout: 60000 });
|
|
557
|
+
} else if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) {
|
|
558
|
+
await run('tar', ['-xf', archive, '-C', outDir], { timeout: 60000 });
|
|
559
|
+
} else if (lower.endsWith('.gz')) {
|
|
560
|
+
// single-file gzip → decompress to the same basename inside outDir
|
|
561
|
+
const { createReadStream, createWriteStream } = await import('node:fs');
|
|
562
|
+
const { createGunzip } = await import('node:zlib');
|
|
563
|
+
const { pipeline } = await import('node:stream/promises');
|
|
564
|
+
const dest = join(outDir, base.split('/').pop() || 'file');
|
|
565
|
+
await pipeline(createReadStream(archive), createGunzip(), createWriteStream(dest));
|
|
566
|
+
} else {
|
|
567
|
+
return JSON.stringify({ ok: false, error: `unsupported archive type: ${raw} (supported: .zip .tar .tar.gz .tgz .gz)` });
|
|
568
|
+
}
|
|
569
|
+
} catch (e) {
|
|
570
|
+
return JSON.stringify({ ok: false, error: `extraction failed: ${(e as Error).message}` });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Walk the extracted tree (capped) and return tmp-relative paths.
|
|
574
|
+
const files: Array<{ path: string; size_bytes: number }> = [];
|
|
575
|
+
const { getDataDir } = await import('../dirs');
|
|
576
|
+
const tmpRoot = join(getDataDir(), 'tmp');
|
|
577
|
+
async function walk(dir: string, depth: number): Promise<void> {
|
|
578
|
+
if (files.length >= 500 || depth > 8) return;
|
|
579
|
+
for (const ent of await readdir(dir, { withFileTypes: true })) {
|
|
580
|
+
if (files.length >= 500) break;
|
|
581
|
+
const full = join(dir, ent.name);
|
|
582
|
+
if (ent.isDirectory()) { await walk(full, depth + 1); continue; }
|
|
583
|
+
if (ent.isFile()) {
|
|
584
|
+
const s = await stat(full);
|
|
585
|
+
files.push({ path: `tmp/${relative(tmpRoot, full)}`, size_bytes: s.size });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
try { await walk(outDir, 0); } catch { /* partial listing is fine */ }
|
|
590
|
+
|
|
591
|
+
return JSON.stringify({
|
|
592
|
+
ok: true,
|
|
593
|
+
archive: `tmp/${raw}`,
|
|
594
|
+
extracted_dir: `tmp/${outRel}`,
|
|
595
|
+
count: files.length,
|
|
596
|
+
files,
|
|
597
|
+
note: 'Read any entry with read_forge_file (pass its `path`). Extracted dir is NOT auto-swept by the cache janitor (it only sweeps top-level tmp/ files).',
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
|
|
478
601
|
// List files anywhere under <dataDir>/ (Forge's own data dir).
|
|
479
602
|
// Replaces the "dispatch a task just to `ls`" workaround. The `dir`
|
|
480
603
|
// param is dataDir-relative — pass "tmp" / "scratch" / "flows" /
|
|
@@ -626,8 +749,11 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
626
749
|
const p = (input as any) || {};
|
|
627
750
|
const name = String(p.name || '').trim();
|
|
628
751
|
const workflow = String(p.workflow || p.body_ref || '').trim();
|
|
752
|
+
const promptText = String(p.prompt || '').trim();
|
|
629
753
|
if (!name) return JSON.stringify({ ok: false, error: 'name is required' });
|
|
630
|
-
if (!workflow
|
|
754
|
+
if (!workflow && !promptText) {
|
|
755
|
+
return JSON.stringify({ ok: false, error: 'provide either prompt (free-text instructions — dispatched as a one-shot Claude task WITH connector tools) or workflow (an existing pipeline name)' });
|
|
756
|
+
}
|
|
631
757
|
|
|
632
758
|
// Trigger normalization: prefer every_minutes; accept at (once) or cron.
|
|
633
759
|
let schedule_kind: 'period' | 'once' | 'cron' = 'period';
|
|
@@ -647,14 +773,36 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
647
773
|
return JSON.stringify({ ok: false, error: 'one of every_minutes / at / cron is required' });
|
|
648
774
|
}
|
|
649
775
|
|
|
776
|
+
// Body resolution. prompt mode (preferred for "run these instructions on
|
|
777
|
+
// a schedule") wins when both are somehow given — it dispatches a one-shot
|
|
778
|
+
// Claude task that HAS the full connector toolset (Teams/Mantis/etc.), so
|
|
779
|
+
// no pipeline YAML is needed. The prompt text is persisted to
|
|
780
|
+
// prompts/<slug>.yaml and the schedule row just references it by name.
|
|
781
|
+
let body_kind: 'pipeline' | 'prompt' = 'pipeline';
|
|
782
|
+
let body_ref = workflow;
|
|
783
|
+
const skills = Array.isArray(p.skills) ? p.skills : undefined;
|
|
784
|
+
if (promptText) {
|
|
785
|
+
const slug = (name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'schedule').slice(0, 64);
|
|
786
|
+
try {
|
|
787
|
+
const { createPrompt, getPrompt, updatePrompt } = await import('../prompts/store');
|
|
788
|
+
const execu = skills ? { skills } : undefined;
|
|
789
|
+
if (getPrompt(slug)) updatePrompt(slug, { prompt: promptText, executor: execu });
|
|
790
|
+
else createPrompt({ name: slug, prompt: promptText, executor: execu });
|
|
791
|
+
} catch (e) {
|
|
792
|
+
return JSON.stringify({ ok: false, error: `failed to save prompt: ${(e as Error).message}` });
|
|
793
|
+
}
|
|
794
|
+
body_kind = 'prompt';
|
|
795
|
+
body_ref = slug;
|
|
796
|
+
}
|
|
797
|
+
|
|
650
798
|
const { createSchedule, seedNextRunAt } = await import('../schedules/store');
|
|
651
799
|
try {
|
|
652
800
|
const s = createSchedule({
|
|
653
801
|
name,
|
|
654
|
-
body_kind
|
|
655
|
-
body_ref
|
|
802
|
+
body_kind,
|
|
803
|
+
body_ref,
|
|
656
804
|
input: (p.input && typeof p.input === 'object') ? p.input : {},
|
|
657
|
-
skills
|
|
805
|
+
skills,
|
|
658
806
|
enabled: p.enabled !== false,
|
|
659
807
|
schedule_kind,
|
|
660
808
|
schedule_interval_minutes,
|
|
@@ -889,6 +1037,17 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
889
1037
|
required: ['filename'],
|
|
890
1038
|
},
|
|
891
1039
|
},
|
|
1040
|
+
{
|
|
1041
|
+
name: 'extract_archive',
|
|
1042
|
+
description: 'Unpack an archive sitting in tmp/ (e.g. one a connector just downloaded — owa.download_attachment etc.) into tmp/<base>-extracted/, and return the file listing. USE THIS when an attachment / download is a .zip / .tar / .tar.gz / .tgz / .gz and you need to read what is inside — you have no shell, this runs unzip/tar for you. Then read individual entries with read_forge_file using each returned `path`. Returns JSON {ok, extracted_dir, count, files:[{path,size_bytes}]}.',
|
|
1043
|
+
input_schema: {
|
|
1044
|
+
type: 'object',
|
|
1045
|
+
properties: {
|
|
1046
|
+
filename: { type: 'string', description: 'tmp/ archive to extract, e.g. "report.zip" or "tmp/logs.tar.gz". Must already be in tmp/ (a download lands there via the connector _files channel).' },
|
|
1047
|
+
},
|
|
1048
|
+
required: ['filename'],
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
892
1051
|
{
|
|
893
1052
|
name: 'list_forge_files',
|
|
894
1053
|
description: 'List files / subdirs anywhere under <dataDir>/ (Forge\'s own data dir). PREFER THIS over `dispatch_task` with `ls` — it\'s sync, in-process, no LLM detour. `dir` is dataDir-relative: pass "tmp" for chat-saved temp files, "scratch" for task workspaces, "flows" / "prompts" / "connectors" for configs. Omit `dir` to see the dataDir root. Each entry has `path` (dataDir-relative), `kind` (file/dir), and `file_url` (file://… opens in Chrome on localhost). Sensitive items (encrypt key, sqlite DBs, server log, *-tokens.json) are filtered out automatically.',
|
|
@@ -947,12 +1106,13 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
947
1106
|
},
|
|
948
1107
|
{
|
|
949
1108
|
name: 'create_schedule',
|
|
950
|
-
description: 'Create a recurring (or one-off) schedule
|
|
1109
|
+
description: 'Create a recurring (or one-off) schedule on a timer. NO HTTP, NO auth — runs in-process. Use when the user says "every N minutes/hours" / "watch X" / "monitor Y" / "auto-run on schedule". TWO body modes — pick ONE:\n• prompt mode (PREFERRED for "do these instructions on a schedule"): pass `prompt` with free-text instructions. Each fire dispatches a one-shot Claude task that HAS THE FULL CONNECTOR TOOLSET (Teams/Mantis/GitLab/etc.) and your skills. No pipeline YAML needed — do NOT build a throwaway pipeline just to run a prompt on a timer.\n• pipeline mode: pass `workflow` (an existing pipeline name) for a structured multi-node DAG.\nREQUIRED: name + (prompt OR workflow) + ONE of {every_minutes, at, cron}. Returns { ok, schedule_id, next_run_at }.',
|
|
951
1110
|
input_schema: {
|
|
952
1111
|
type: 'object',
|
|
953
1112
|
properties: {
|
|
954
|
-
name: { type: 'string', description: 'Human-readable name shown in the Schedules UI.' },
|
|
955
|
-
|
|
1113
|
+
name: { type: 'string', description: 'Human-readable name shown in the Schedules UI. Also used as the prompt slug in prompt mode.' },
|
|
1114
|
+
prompt: { type: 'string', description: 'PROMPT MODE: free-text instructions run as a one-shot Claude task on each fire, with full connector tools + skills. Use this for "every hour check Teams channel X and notify me" style asks. Mutually exclusive with workflow.' },
|
|
1115
|
+
workflow: { type: 'string', description: 'PIPELINE MODE: existing pipeline workflow name (basename of flows/<name>.yaml). Run trigger_pipeline() with NO args first to list. Mutually exclusive with prompt.' },
|
|
956
1116
|
input: { type: 'object', description: 'Pipeline input fields. Same shape as trigger_pipeline.input. OMIT optional fields to use defaults.' },
|
|
957
1117
|
skills: { type: 'array', items: { type: 'string' }, description: 'Skill names to inject into every Claude task this schedule spawns.' },
|
|
958
1118
|
every_minutes: { type: 'number', description: 'Period in minutes (e.g. 60 = hourly). Most common trigger.' },
|
|
@@ -961,7 +1121,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
961
1121
|
enabled: { type: 'boolean', description: 'Whether to start enabled. Default true.' },
|
|
962
1122
|
action: { type: 'string', enum: ['none', 'chat', 'email', 'telegram'], description: 'Post-run notification action. Default "none".' },
|
|
963
1123
|
},
|
|
964
|
-
required: ['name'
|
|
1124
|
+
required: ['name'],
|
|
965
1125
|
},
|
|
966
1126
|
},
|
|
967
1127
|
{
|
|
@@ -1374,6 +1534,14 @@ export async function dispatchTool(
|
|
|
1374
1534
|
return { content: `unknown protocol "${protocol}" on tool ${call.name}`, is_error: true };
|
|
1375
1535
|
}
|
|
1376
1536
|
|
|
1537
|
+
// Download channel: materialize any `_files` base64 payload to tmp/ and
|
|
1538
|
+
// strip the bytes before they reach the model (and before the watch
|
|
1539
|
+
// below re-parses the result). No-op when the result has no _files.
|
|
1540
|
+
if (!result.is_error) {
|
|
1541
|
+
try { result = await materializeConnectorFiles(result); }
|
|
1542
|
+
catch (e) { console.warn('[dispatch] materializeConnectorFiles failed', (e as Error).message); }
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1377
1545
|
// Async (long-task watch): if the tool declared an `async` block and
|
|
1378
1546
|
// it ran without error, register a background watch that polls to
|
|
1379
1547
|
// completion and reports back to the originating chat session. The
|
package/lib/connectors/sync.ts
CHANGED
|
@@ -335,11 +335,16 @@ export async function fetchSourceFile(source: SourceMeta, relPath: string): Prom
|
|
|
335
335
|
// directly (no base64 decode needed). Works for private repos given
|
|
336
336
|
// a Fine-grained PAT with Contents: read.
|
|
337
337
|
const repo = (source.repo_url || '').replace(/^github\.com\//, '');
|
|
338
|
-
|
|
338
|
+
// &_t + no-cache: the GitHub contents API edge-caches the ?ref=main blob,
|
|
339
|
+
// so right after a push a plain fetch can return the STALE file — which
|
|
340
|
+
// made the marketplace keep showing old connector versions even after
|
|
341
|
+
// "↻ Sync all". The raw mode already busts via cacheBust(); match it here.
|
|
342
|
+
const url = `https://api.github.com/repos/${repo}/contents/${relPath}?ref=main&_t=${Date.now()}`;
|
|
339
343
|
try {
|
|
340
344
|
return await rawFetch(url, {
|
|
341
345
|
Authorization: `Bearer ${source.github_pat}`,
|
|
342
346
|
Accept: 'application/vnd.github.raw',
|
|
347
|
+
'Cache-Control': 'no-cache',
|
|
343
348
|
});
|
|
344
349
|
} catch (err) {
|
|
345
350
|
// GitHub returns 404 (not 401) for private repos when the PAT
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
# Schedules
|
|
2
2
|
|
|
3
|
-
**Schedule = a
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
**Schedule = a body + trigger.** The trigger decides *when* to fire; the body
|
|
4
|
+
decides *what* runs. There are two body kinds — pick whichever fits:
|
|
5
|
+
|
|
6
|
+
- **Prompt** (simplest, most common) — free-text instructions run as a one-shot
|
|
7
|
+
Claude task on each fire. **The task has the full connector toolset
|
|
8
|
+
(Teams / Mantis / GitLab / etc.) plus any skills you attach** — so "every hour
|
|
9
|
+
check the FortiNAC Teams channel and message me anything new" is a single
|
|
10
|
+
prompt schedule. You do **not** need a pipeline for this. The prompt text is
|
|
11
|
+
saved to `prompts/<slug>.yaml` and the schedule row references it by name.
|
|
12
|
+
- **Pipeline** — fires an existing pipeline (`flows/<name>.yaml`) with input.
|
|
13
|
+
Use when you need a structured multi-node DAG, `for_each:` iteration, or
|
|
14
|
+
per-node project/worktree control.
|
|
15
|
+
|
|
16
|
+
> Don't build a throwaway pipeline just to run a prompt on a timer — use prompt
|
|
17
|
+
> mode. The chat `create_schedule` tool accepts either `prompt` or `workflow`.
|
|
6
18
|
|
|
7
19
|
**Note:** Jobs (the older subsystem) is deprecated — its UI is hidden and the
|
|
8
20
|
scheduler no longer ticks. Any existing rows in the `jobs` table are inert.
|
package/package.json
CHANGED
package/publish.sh
CHANGED
|
@@ -9,6 +9,20 @@
|
|
|
9
9
|
|
|
10
10
|
set -e
|
|
11
11
|
|
|
12
|
+
# Preflight: fail BEFORE any version bump / commit / tag / push if gh can't
|
|
13
|
+
# actually talk to GitHub. We hit a real endpoint (gh api user) instead of
|
|
14
|
+
# `gh auth status` — status only checks a token is STORED, not that it still
|
|
15
|
+
# works; a stale/revoked keyring token passes status but 401s on every call.
|
|
16
|
+
GH_LOGIN_HINT="gh auth login --hostname github.com --web --git-protocol ssh"
|
|
17
|
+
if command -v gh &> /dev/null; then
|
|
18
|
+
gh api user &> /dev/null || {
|
|
19
|
+
echo "✗ gh can't authenticate to GitHub (token missing/expired/revoked)."
|
|
20
|
+
echo " Re-auth, then re-run ./publish.sh:"
|
|
21
|
+
echo " $GH_LOGIN_HINT"
|
|
22
|
+
exit 1
|
|
23
|
+
}
|
|
24
|
+
fi
|
|
25
|
+
|
|
12
26
|
VERSION_ARG=${1:-patch}
|
|
13
27
|
CURRENT=$(node -p "require('./package.json').version")
|
|
14
28
|
|
|
@@ -137,8 +151,18 @@ git push origin "v$NEW_VERSION"
|
|
|
137
151
|
if command -v gh &> /dev/null; then
|
|
138
152
|
echo ""
|
|
139
153
|
echo "Creating GitHub Release..."
|
|
140
|
-
gh release create "v$NEW_VERSION" --title "v$NEW_VERSION" --notes-file "$RELEASE_NOTES_FILE"
|
|
141
|
-
|
|
154
|
+
if gh release create "v$NEW_VERSION" --title "v$NEW_VERSION" --notes-file "$RELEASE_NOTES_FILE"; then
|
|
155
|
+
echo "✓ GitHub Release created: https://github.com/aiwatching/forge/releases/tag/v$NEW_VERSION"
|
|
156
|
+
else
|
|
157
|
+
# The tag is already pushed at this point — re-auth and just create the
|
|
158
|
+
# release, no need to re-run the whole publish. A stale keyring token
|
|
159
|
+
# 401s here even though `gh auth status` claims you're logged in.
|
|
160
|
+
echo ""
|
|
161
|
+
echo "✗ GitHub Release failed (usually a stale gh token — 401). The tag v$NEW_VERSION is already pushed."
|
|
162
|
+
echo " Fix: re-auth, then create just the release:"
|
|
163
|
+
echo " $GH_LOGIN_HINT"
|
|
164
|
+
echo " gh release create v$NEW_VERSION --title v$NEW_VERSION --notes-file $RELEASE_NOTES_FILE"
|
|
165
|
+
fi
|
|
142
166
|
else
|
|
143
167
|
echo ""
|
|
144
168
|
echo "gh CLI not found. Create release manually:"
|