@aion0/forge 0.10.12 → 0.10.17
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 +6 -3
- package/app/api/public-info/[resource]/route.ts +40 -0
- package/components/SettingsModal.tsx +42 -33
- package/components/WorkspaceView.tsx +5 -3
- package/lib/agents/known-models.ts +75 -0
- package/lib/public-info/fetch.ts +116 -0
- package/lib/public-info/types.ts +38 -0
- package/lib/public-info/use-models-registry.ts +66 -0
- package/lib/settings.ts +9 -0
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
- package/lib/__tests__/foreach-batch-yaml.test.ts +0 -33
- package/lib/__tests__/foreach-before.test.ts +0 -201
- package/lib/__tests__/foreach-parse.test.ts +0 -114
- package/lib/__tests__/foreach-snapshot.test.ts +0 -112
- package/lib/__tests__/foreach-source.test.ts +0 -105
- package/lib/__tests__/foreach-template.test.ts +0 -112
- package/lib/workspace/__tests__/state-machine.test.ts +0 -388
- package/lib/workspace/__tests__/workspace.test.ts +0 -311
- package/scripts/bench/README.md +0 -66
- package/scripts/bench/results/.gitignore +0 -2
- package/scripts/bench/run.ts +0 -635
- package/scripts/bench/tasks/01-text-utils/task.md +0 -26
- package/scripts/bench/tasks/01-text-utils/validator.sh +0 -46
- package/scripts/bench/tasks/02-pagination/setup.sh +0 -19
- package/scripts/bench/tasks/02-pagination/task.md +0 -48
- package/scripts/bench/tasks/02-pagination/validator.sh +0 -69
- package/scripts/bench/tasks/03-bug-fix/setup.sh +0 -82
- package/scripts/bench/tasks/03-bug-fix/task.md +0 -30
- package/scripts/bench/tasks/03-bug-fix/validator.sh +0 -29
- package/scripts/test-agents-migrate.ts +0 -149
- package/scripts/test-mantis.ts +0 -223
- package/scripts/test-memory-local.ts +0 -139
- package/scripts/test-memory-upsert.ts +0 -106
- package/scripts/verify-usage.ts +0 -178
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.17
|
|
2
2
|
|
|
3
3
|
Released: 2026-05-30
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.16
|
|
6
6
|
|
|
7
|
+
### Features
|
|
8
|
+
- feat: API profile model picker uses registry providers
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
|
|
11
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.16...v0.10.17
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/public-info/<resource>?refresh=1
|
|
3
|
+
*
|
|
4
|
+
* Server-side proxy + cache for files under the public-info repo. UI
|
|
5
|
+
* never hits GitHub directly — avoids CORS / rate-limit headaches and
|
|
6
|
+
* gives us a single cache shared across all browser tabs.
|
|
7
|
+
*
|
|
8
|
+
* Supported resources (extend by adding to RESOURCE_MAP):
|
|
9
|
+
* - `models` → `models/registry.json`
|
|
10
|
+
*
|
|
11
|
+
* The UI passes the short name (`models`), this route translates to the
|
|
12
|
+
* actual path under the repo and pairs it with the right fallback.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
16
|
+
import { fetchPublicInfo } from '@/lib/public-info/fetch';
|
|
17
|
+
import { KNOWN_MODELS_FALLBACK } from '@/lib/agents/known-models';
|
|
18
|
+
|
|
19
|
+
interface ResourceSpec {
|
|
20
|
+
path: string;
|
|
21
|
+
fallback: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const RESOURCE_MAP: Record<string, ResourceSpec> = {
|
|
25
|
+
models: { path: 'models/registry.json', fallback: KNOWN_MODELS_FALLBACK },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function GET(
|
|
29
|
+
req: NextRequest,
|
|
30
|
+
{ params }: { params: Promise<{ resource: string }> },
|
|
31
|
+
): Promise<NextResponse> {
|
|
32
|
+
const { resource } = await params;
|
|
33
|
+
const spec = RESOURCE_MAP[resource];
|
|
34
|
+
if (!spec) {
|
|
35
|
+
return NextResponse.json({ error: `unknown resource: ${resource}` }, { status: 404 });
|
|
36
|
+
}
|
|
37
|
+
const forceRefresh = req.nextUrl.searchParams.get('refresh') === '1';
|
|
38
|
+
const data = await fetchPublicInfo(spec.path, spec.fallback, { forceRefresh });
|
|
39
|
+
return NextResponse.json(data);
|
|
40
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
4
|
import { FolderPicker } from './FolderPicker';
|
|
5
|
+
import { useModelsRegistry, pickerOptions } from '@/lib/public-info/use-models-registry';
|
|
5
6
|
|
|
6
7
|
function SecretInput({ value, onChange, placeholder, className }: {
|
|
7
8
|
value: string;
|
|
@@ -917,6 +918,7 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete, isApi: isApiProp
|
|
|
917
918
|
onUpdate: (cfg: any) => void; onDelete: () => void;
|
|
918
919
|
isApi?: boolean;
|
|
919
920
|
}) {
|
|
921
|
+
const { registry: modelsRegistry } = useModelsRegistry();
|
|
920
922
|
const [expanded, setExpanded] = useState(false);
|
|
921
923
|
const [testing, setTesting] = useState(false);
|
|
922
924
|
const [testResult, setTestResult] = useState<{ ok: boolean; message?: string; error?: string; duration_ms?: number } | null>(null);
|
|
@@ -975,20 +977,23 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete, isApi: isApiProp
|
|
|
975
977
|
<label className="text-[8px] text-[var(--text-secondary)]">Model</label>
|
|
976
978
|
<input value={cfg.model || ''} onChange={e => onUpdate({ ...cfg, model: e.target.value })}
|
|
977
979
|
list={`profile-model-${id}`} className={inputClass} />
|
|
978
|
-
{
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
980
|
+
{/* Datalist sourced from forge-public-info registry — adding a new
|
|
981
|
+
model id over there shows up here within 24h, no forge release.
|
|
982
|
+
API profiles look up by provider; CLI profiles look up by cliType. */}
|
|
983
|
+
<datalist id={`profile-model-${id}`}>
|
|
984
|
+
{(() => {
|
|
985
|
+
const models = isApi
|
|
986
|
+
? modelsRegistry.providers?.[cfg.provider || 'anthropic']?.models
|
|
987
|
+
: modelsRegistry.agents[
|
|
988
|
+
cfg.cliType === 'codex' || cfg.base === 'codex' ? 'codex'
|
|
989
|
+
: cfg.cliType === 'aider' || cfg.base === 'aider' ? 'aider'
|
|
990
|
+
: 'claude-code'
|
|
991
|
+
]?.models;
|
|
992
|
+
return (models || []).map(m => (
|
|
993
|
+
<option key={m.id} value={m.id} />
|
|
994
|
+
));
|
|
995
|
+
})()}
|
|
996
|
+
</datalist>
|
|
992
997
|
</div>
|
|
993
998
|
</div>
|
|
994
999
|
{isApi ? (
|
|
@@ -1127,6 +1132,7 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1127
1132
|
baseAgents: AgentEntry[];
|
|
1128
1133
|
onAdd: (id: string, cfg: any) => void;
|
|
1129
1134
|
}) {
|
|
1135
|
+
const { registry: modelsRegistry } = useModelsRegistry();
|
|
1130
1136
|
const [open, setOpen] = useState(false);
|
|
1131
1137
|
const [id, setId] = useState('');
|
|
1132
1138
|
const [name, setName] = useState('');
|
|
@@ -1228,22 +1234,17 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1228
1234
|
<div className="flex-1">
|
|
1229
1235
|
<label className="text-[8px] text-[var(--text-secondary)]">Model</label>
|
|
1230
1236
|
<input value={model} onChange={e => setModel(e.target.value)}
|
|
1231
|
-
placeholder={
|
|
1237
|
+
placeholder={
|
|
1238
|
+
base === 'claude' ? modelsRegistry.agents['claude-code']?.default || ''
|
|
1239
|
+
: base === 'codex' ? modelsRegistry.agents['codex']?.default || ''
|
|
1240
|
+
: ''
|
|
1241
|
+
}
|
|
1232
1242
|
list={`model-list-${base}`} className={inputClass} />
|
|
1233
|
-
{base
|
|
1234
|
-
|
|
1235
|
-
<option value=
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
</datalist>
|
|
1239
|
-
)}
|
|
1240
|
-
{base === 'codex' && (
|
|
1241
|
-
<datalist id="model-list-codex">
|
|
1242
|
-
<option value="codex-mini" />
|
|
1243
|
-
<option value="o4-mini" />
|
|
1244
|
-
<option value="gpt-4o" />
|
|
1245
|
-
</datalist>
|
|
1246
|
-
)}
|
|
1243
|
+
<datalist id={`model-list-${base}`}>
|
|
1244
|
+
{(modelsRegistry.agents[base === 'claude' ? 'claude-code' : base]?.models || []).map(m => (
|
|
1245
|
+
<option key={m.id} value={m.id} />
|
|
1246
|
+
))}
|
|
1247
|
+
</datalist>
|
|
1247
1248
|
</div>
|
|
1248
1249
|
</div>
|
|
1249
1250
|
<div>
|
|
@@ -1275,7 +1276,14 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1275
1276
|
</div>
|
|
1276
1277
|
<div className="flex-1">
|
|
1277
1278
|
<label className="text-[8px] text-[var(--text-secondary)]">Model</label>
|
|
1278
|
-
<input value={model} onChange={e => setModel(e.target.value)}
|
|
1279
|
+
<input value={model} onChange={e => setModel(e.target.value)}
|
|
1280
|
+
placeholder={modelsRegistry.providers?.[provider]?.default || ''}
|
|
1281
|
+
list={`api-model-list-${provider}`} className={inputClass} />
|
|
1282
|
+
<datalist id={`api-model-list-${provider}`}>
|
|
1283
|
+
{(modelsRegistry.providers?.[provider]?.models || []).map(m => (
|
|
1284
|
+
<option key={m.id} value={m.id} />
|
|
1285
|
+
))}
|
|
1286
|
+
</datalist>
|
|
1279
1287
|
</div>
|
|
1280
1288
|
</div>
|
|
1281
1289
|
<div>
|
|
@@ -1314,6 +1322,7 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1314
1322
|
}
|
|
1315
1323
|
|
|
1316
1324
|
function AgentsSection({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
|
|
1325
|
+
const { registry: modelsRegistry } = useModelsRegistry();
|
|
1317
1326
|
const [agents, setAgents] = useState<AgentEntry[]>([]);
|
|
1318
1327
|
const [loading, setLoading] = useState(true);
|
|
1319
1328
|
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
|
@@ -1649,9 +1658,9 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1649
1658
|
<span className="text-[8px] text-[var(--text-secondary)]">Presets:</span>
|
|
1650
1659
|
{((() => {
|
|
1651
1660
|
const ct = (settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic');
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
return
|
|
1661
|
+
// Models pulled from forge-public-info repo (lib/public-info/fetch.ts).
|
|
1662
|
+
// Add new IDs there and they show up here within 24h with no forge release.
|
|
1663
|
+
return pickerOptions(modelsRegistry, ct);
|
|
1655
1664
|
})()).map(preset => (
|
|
1656
1665
|
<button
|
|
1657
1666
|
key={preset}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useMemo, useRef, forwardRef, useImperativeHandle, lazy, Suspense } from 'react';
|
|
4
4
|
import { TerminalSessionPickerLazy, fetchAgentSessions, type PickerSelection } from './TerminalLauncher';
|
|
5
|
+
import { useModelsRegistry } from '@/lib/public-info/use-models-registry';
|
|
5
6
|
import {
|
|
6
7
|
ReactFlow, Background, Controls, Handle, Position, useReactFlow, ReactFlowProvider,
|
|
7
8
|
type Node, type NodeProps, MarkerType, type NodeChange,
|
|
@@ -715,6 +716,7 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
715
716
|
onConfirm: (cfg: Omit<AgentConfig, 'id'>) => void;
|
|
716
717
|
onCancel: () => void;
|
|
717
718
|
}) {
|
|
719
|
+
const { registry: modelsRegistry } = useModelsRegistry();
|
|
718
720
|
const [label, setLabel] = useState(initial.label || '');
|
|
719
721
|
const [icon, setIcon] = useState(initial.icon || '🤖');
|
|
720
722
|
const [role, setRole] = useState(initial.role || '');
|
|
@@ -1160,9 +1162,9 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
1160
1162
|
list="workspace-model-list"
|
|
1161
1163
|
className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] font-mono" />
|
|
1162
1164
|
<datalist id="workspace-model-list">
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1165
|
+
{(modelsRegistry.agents['claude-code']?.models || []).map(m => (
|
|
1166
|
+
<option key={m.id} value={m.id} />
|
|
1167
|
+
))}
|
|
1166
1168
|
</datalist>
|
|
1167
1169
|
</div>
|
|
1168
1170
|
);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in fallback model list. Forge prefers the public-info repo's
|
|
3
|
+
* registry (see `lib/public-info/fetch.ts`), but falls back to this
|
|
4
|
+
* when the network is unreachable, the file is malformed, or the
|
|
5
|
+
* user is on first-run before the cache is populated.
|
|
6
|
+
*
|
|
7
|
+
* Keep this list reasonably current — if the registry is permanently
|
|
8
|
+
* unreachable, this is what users see. New models in the wild should
|
|
9
|
+
* land in the public-info repo first (no code change required), and
|
|
10
|
+
* trickle into this fallback whenever the next forge release happens.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ModelsRegistry } from '../public-info/types';
|
|
14
|
+
|
|
15
|
+
export const KNOWN_MODELS_FALLBACK: ModelsRegistry = {
|
|
16
|
+
version: 1,
|
|
17
|
+
updatedAt: '2026-05-30',
|
|
18
|
+
note: 'Bundled fallback — actual current list lives in forge-public-info/models/registry.json',
|
|
19
|
+
agents: {
|
|
20
|
+
'claude-code': {
|
|
21
|
+
displayName: 'Claude Code',
|
|
22
|
+
default: 'claude-sonnet-4-6',
|
|
23
|
+
aliases: [
|
|
24
|
+
{ id: 'default', label: 'default (CLI decides)' },
|
|
25
|
+
{ id: 'sonnet', label: 'sonnet (alias)' },
|
|
26
|
+
{ id: 'opus', label: 'opus (alias)' },
|
|
27
|
+
{ id: 'haiku', label: 'haiku (alias)' },
|
|
28
|
+
],
|
|
29
|
+
models: [
|
|
30
|
+
{ id: 'claude-opus-4-8', label: 'Opus 4.8', tier: 'premium' },
|
|
31
|
+
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', tier: 'standard', default: true },
|
|
32
|
+
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', tier: 'fast' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
codex: {
|
|
36
|
+
displayName: 'OpenAI Codex',
|
|
37
|
+
default: 'o4-mini',
|
|
38
|
+
aliases: [{ id: 'default', label: 'default (CLI decides)' }],
|
|
39
|
+
models: [
|
|
40
|
+
{ id: 'o4-mini', label: 'o4-mini', tier: 'fast', default: true },
|
|
41
|
+
{ id: 'o3-mini', label: 'o3-mini', tier: 'fast' },
|
|
42
|
+
{ id: 'gpt-4.1', label: 'GPT-4.1', tier: 'standard' },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
aider: {
|
|
46
|
+
displayName: 'Aider',
|
|
47
|
+
default: 'default',
|
|
48
|
+
aliases: [{ id: 'default', label: 'default (CLI decides)' }],
|
|
49
|
+
models: [],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
providers: {
|
|
53
|
+
anthropic: {
|
|
54
|
+
displayName: 'Anthropic API',
|
|
55
|
+
default: 'claude-sonnet-4-6',
|
|
56
|
+
aliases: [],
|
|
57
|
+
models: [
|
|
58
|
+
{ id: 'claude-opus-4-8', label: 'Opus 4.8', tier: 'premium' },
|
|
59
|
+
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', tier: 'standard', default: true },
|
|
60
|
+
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', tier: 'fast' },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
openai: {
|
|
64
|
+
displayName: 'OpenAI API',
|
|
65
|
+
default: 'gpt-4.1',
|
|
66
|
+
aliases: [],
|
|
67
|
+
models: [
|
|
68
|
+
{ id: 'gpt-4.1', label: 'GPT-4.1', tier: 'premium', default: true },
|
|
69
|
+
{ id: 'gpt-4o', label: 'GPT-4o', tier: 'standard' },
|
|
70
|
+
{ id: 'o4-mini', label: 'o4-mini', tier: 'fast' },
|
|
71
|
+
{ id: 'o3-mini', label: 'o3-mini', tier: 'fast' },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic fetch+cache helper for `forge-public-info` resources.
|
|
3
|
+
*
|
|
4
|
+
* Pattern: GitHub-hosted JSON files live under
|
|
5
|
+
* `<publicInfoRepoUrl>/<resourceDir>/<file>.json`
|
|
6
|
+
*
|
|
7
|
+
* On read:
|
|
8
|
+
* 1. If a fresh cached copy exists (< TTL), return it.
|
|
9
|
+
* 2. Otherwise fetch from the configured `publicInfoRepoUrl`.
|
|
10
|
+
* 3. On any error (offline, 4xx, malformed JSON), return the bundled
|
|
11
|
+
* fallback so the UI never breaks.
|
|
12
|
+
*
|
|
13
|
+
* Cache lives at `<dataDir>/public-info-cache/<resource>.json`. Each
|
|
14
|
+
* file has a sidecar `<resource>.meta.json` carrying the fetch
|
|
15
|
+
* timestamp; we don't trust HTTP `Last-Modified` because GitHub raw
|
|
16
|
+
* serves through a CDN that strips it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { getDataDir } from '../dirs';
|
|
22
|
+
import { loadSettings } from '../settings';
|
|
23
|
+
|
|
24
|
+
const DEFAULT_REPO_URL = 'https://raw.githubusercontent.com/aiwatching/forge-public-info/main';
|
|
25
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
27
|
+
|
|
28
|
+
interface CacheMeta { fetchedAt: number; etag?: string }
|
|
29
|
+
|
|
30
|
+
function cacheDir(): string {
|
|
31
|
+
const dir = join(getDataDir(), 'public-info-cache');
|
|
32
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function repoUrl(): string {
|
|
37
|
+
return (loadSettings() as any).publicInfoRepoUrl || DEFAULT_REPO_URL;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cacheKey(resourcePath: string): string {
|
|
41
|
+
// public/info/models/registry.json → models-registry
|
|
42
|
+
return resourcePath.replace(/\.json$/, '').replace(/[^a-zA-Z0-9]/g, '-').replace(/^-+|-+$/g, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readCache(resourcePath: string): { data: unknown; meta: CacheMeta } | null {
|
|
46
|
+
const key = cacheKey(resourcePath);
|
|
47
|
+
const dataPath = join(cacheDir(), `${key}.json`);
|
|
48
|
+
const metaPath = join(cacheDir(), `${key}.meta.json`);
|
|
49
|
+
if (!existsSync(dataPath) || !existsSync(metaPath)) return null;
|
|
50
|
+
try {
|
|
51
|
+
return {
|
|
52
|
+
data: JSON.parse(readFileSync(dataPath, 'utf-8')),
|
|
53
|
+
meta: JSON.parse(readFileSync(metaPath, 'utf-8')),
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeCache(resourcePath: string, data: unknown): void {
|
|
61
|
+
const key = cacheKey(resourcePath);
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(join(cacheDir(), `${key}.json`), JSON.stringify(data, null, 2), 'utf-8');
|
|
64
|
+
writeFileSync(
|
|
65
|
+
join(cacheDir(), `${key}.meta.json`),
|
|
66
|
+
JSON.stringify({ fetchedAt: Date.now() } satisfies CacheMeta, null, 2),
|
|
67
|
+
'utf-8',
|
|
68
|
+
);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.warn('[public-info] cache write failed:', err instanceof Error ? err.message : err);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fetch a public-info resource with built-in cache + fallback.
|
|
76
|
+
*
|
|
77
|
+
* @param resourcePath Path under the repo, e.g. `"models/registry.json"`
|
|
78
|
+
* @param fallback Used when network + cache both fail
|
|
79
|
+
* @param opts.forceRefresh Skip cache TTL check (e.g., user-triggered refresh)
|
|
80
|
+
*/
|
|
81
|
+
export async function fetchPublicInfo<T>(
|
|
82
|
+
resourcePath: string,
|
|
83
|
+
fallback: T,
|
|
84
|
+
opts: { forceRefresh?: boolean } = {},
|
|
85
|
+
): Promise<T> {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
|
|
88
|
+
// 1. Cache hit (fresh)
|
|
89
|
+
if (!opts.forceRefresh) {
|
|
90
|
+
const cached = readCache(resourcePath);
|
|
91
|
+
if (cached && now - cached.meta.fetchedAt < CACHE_TTL_MS) {
|
|
92
|
+
return cached.data as T;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Network fetch
|
|
97
|
+
const url = `${repoUrl()}/${resourcePath}`;
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
100
|
+
if (res.ok) {
|
|
101
|
+
const data = await res.json();
|
|
102
|
+
writeCache(resourcePath, data);
|
|
103
|
+
return data as T;
|
|
104
|
+
}
|
|
105
|
+
console.warn(`[public-info] ${url} returned HTTP ${res.status}, using fallback`);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn(`[public-info] ${url} fetch failed:`, err instanceof Error ? err.message : err);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 3. Stale cache (better than fallback)
|
|
111
|
+
const cached = readCache(resourcePath);
|
|
112
|
+
if (cached) return cached.data as T;
|
|
113
|
+
|
|
114
|
+
// 4. Bundled fallback
|
|
115
|
+
return fallback;
|
|
116
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shapes of JSON resources served from the public-info repo
|
|
3
|
+
* (https://github.com/aiwatching/forge-public-info, by default).
|
|
4
|
+
*
|
|
5
|
+
* Each resource lives under `<repoUrl>/<resource-dir>/<file>.json` so new
|
|
6
|
+
* resource types can be added without touching settings. Forge fetches
|
|
7
|
+
* them via `lib/public-info/fetch.ts` (24h cache + built-in fallback).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ModelInfo {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
/** Rough quality/cost band — used by UI to sort/group. */
|
|
14
|
+
tier?: 'premium' | 'standard' | 'fast';
|
|
15
|
+
default?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ModelAlias {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AgentModels {
|
|
24
|
+
displayName: string;
|
|
25
|
+
default: string;
|
|
26
|
+
aliases: ModelAlias[];
|
|
27
|
+
models: ModelInfo[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ModelsRegistry {
|
|
31
|
+
version: number;
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
note?: string;
|
|
34
|
+
/** Keyed by CLI type (e.g. `claude-code`, `codex`, `aider`). */
|
|
35
|
+
agents: Record<string, AgentModels>;
|
|
36
|
+
/** Keyed by API provider id (e.g. `anthropic`, `openai`, `grok`, `google`, `deepseek`, `litellm`). */
|
|
37
|
+
providers?: Record<string, AgentModels>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side hook for the model registry. Lazy-fetches from
|
|
3
|
+
* `GET /api/public-info/models`, caches in module-level state so
|
|
4
|
+
* multiple components sharing the registry hit the network once per
|
|
5
|
+
* page load. Returns the bundled fallback shape until the network
|
|
6
|
+
* round-trip completes so callers never see `undefined`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use client';
|
|
10
|
+
|
|
11
|
+
import { useEffect, useState } from 'react';
|
|
12
|
+
import { KNOWN_MODELS_FALLBACK } from '../agents/known-models';
|
|
13
|
+
import type { ModelsRegistry, AgentModels } from './types';
|
|
14
|
+
|
|
15
|
+
let cached: ModelsRegistry | null = null;
|
|
16
|
+
let inflight: Promise<ModelsRegistry> | null = null;
|
|
17
|
+
|
|
18
|
+
async function load(refresh = false): Promise<ModelsRegistry> {
|
|
19
|
+
if (cached && !refresh) return cached;
|
|
20
|
+
if (inflight && !refresh) return inflight;
|
|
21
|
+
inflight = (async () => {
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(`/api/public-info/models${refresh ? '?refresh=1' : ''}`);
|
|
24
|
+
if (res.ok) {
|
|
25
|
+
const data = await res.json();
|
|
26
|
+
cached = data as ModelsRegistry;
|
|
27
|
+
return cached;
|
|
28
|
+
}
|
|
29
|
+
} catch {}
|
|
30
|
+
cached = KNOWN_MODELS_FALLBACK;
|
|
31
|
+
return cached;
|
|
32
|
+
})();
|
|
33
|
+
try { return await inflight; } finally { inflight = null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useModelsRegistry(): {
|
|
37
|
+
registry: ModelsRegistry;
|
|
38
|
+
refresh: () => Promise<void>;
|
|
39
|
+
loading: boolean;
|
|
40
|
+
} {
|
|
41
|
+
const [registry, setRegistry] = useState<ModelsRegistry>(cached ?? KNOWN_MODELS_FALLBACK);
|
|
42
|
+
const [loading, setLoading] = useState(!cached);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (cached) return;
|
|
46
|
+
setLoading(true);
|
|
47
|
+
load().then(r => { setRegistry(r); setLoading(false); });
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
async function refresh() {
|
|
51
|
+
setLoading(true);
|
|
52
|
+
const r = await load(true);
|
|
53
|
+
setRegistry(r);
|
|
54
|
+
setLoading(false);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { registry, refresh, loading };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Get the model option list for a specific agent CLI type. Returns the
|
|
61
|
+
* flat list of `[...aliases, ...models]` as the picker expects them. */
|
|
62
|
+
export function pickerOptions(registry: ModelsRegistry, cliType: string): string[] {
|
|
63
|
+
const agent: AgentModels | undefined = registry.agents[cliType];
|
|
64
|
+
if (!agent) return ['default'];
|
|
65
|
+
return [...agent.aliases.map(a => a.id), ...agent.models.map(m => m.id)];
|
|
66
|
+
}
|
package/lib/settings.ts
CHANGED
|
@@ -112,6 +112,14 @@ export interface Settings {
|
|
|
112
112
|
* shape as connectorsRepoUrl. Default: `aiwatching/forge-workflow`.
|
|
113
113
|
*/
|
|
114
114
|
workflowRepoUrl: string;
|
|
115
|
+
/**
|
|
116
|
+
* Base URL for the `forge-public-info` repo. Houses model lists and
|
|
117
|
+
* other small JSON files that the maintainer updates between npm
|
|
118
|
+
* releases — push a JSON commit, all users pick it up within 24h via
|
|
119
|
+
* the cache. Sub-paths (e.g. `models/registry.json`) are appended by
|
|
120
|
+
* `lib/public-info/fetch.ts`. Default: `aiwatching/forge-public-info`.
|
|
121
|
+
*/
|
|
122
|
+
publicInfoRepoUrl: string;
|
|
115
123
|
/**
|
|
116
124
|
* Maximum concurrent pipeline runs (running + pending). When a Job's
|
|
117
125
|
* scheduler tick would push the total above this, additional items
|
|
@@ -207,6 +215,7 @@ const defaults: Settings = {
|
|
|
207
215
|
skillsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-skills/main',
|
|
208
216
|
connectorsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-connectors/main',
|
|
209
217
|
workflowRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-workflow/main',
|
|
218
|
+
publicInfoRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-public-info/main',
|
|
210
219
|
maxConcurrentPipelines: 5,
|
|
211
220
|
displayName: 'Forge',
|
|
212
221
|
displayEmail: '',
|
package/next-env.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/
|
|
3
|
+
import "./.next/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
package/package.json
CHANGED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Smoke test: fortinet-mr-review-batch v0.3.0 parses end-to-end through
|
|
3
|
-
* parseWorkflow with the new for_each.before extension.
|
|
4
|
-
*/
|
|
5
|
-
import { parseWorkflow } from '../pipeline';
|
|
6
|
-
import { readFileSync } from 'fs';
|
|
7
|
-
|
|
8
|
-
const txt = readFileSync('/Users/zliu/.forge/data/flows/fortinet-mr-review-batch.yaml', 'utf8');
|
|
9
|
-
const wf = parseWorkflow(txt);
|
|
10
|
-
|
|
11
|
-
let ok = true;
|
|
12
|
-
function eq(actual: any, expected: any, label: string) {
|
|
13
|
-
if (JSON.stringify(actual) === JSON.stringify(expected)) {
|
|
14
|
-
console.log(` ✓ ${label}`);
|
|
15
|
-
} else {
|
|
16
|
-
console.log(` ✗ ${label}: got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)}`);
|
|
17
|
-
ok = false;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
console.log('fortinet-mr-review-batch v0.3.0 — parseWorkflow integration');
|
|
22
|
-
eq(wf.name, 'fortinet-mr-review-batch', 'workflow name');
|
|
23
|
-
eq(wf.for_each?.source, '{{nodes.list-iids.outputs.iids}}', 'for_each.source');
|
|
24
|
-
eq(wf.for_each?.as, 'mr_iid', 'for_each.as');
|
|
25
|
-
eq(wf.for_each?.on_failure, 'continue', 'for_each.on_failure');
|
|
26
|
-
eq(wf.for_each?.before, ['list-iids'], 'for_each.before');
|
|
27
|
-
eq(Object.keys(wf.nodes), ['list-iids', 'ingest', 'triage', 'fix', 'reply', 'cleanup'], 'all 6 nodes parsed');
|
|
28
|
-
eq(wf.nodes['list-iids']?.mode, 'shell', 'list-iids mode = shell');
|
|
29
|
-
eq(wf.nodes['list-iids']?.worktree, false, 'list-iids worktree = false');
|
|
30
|
-
eq(wf.nodes['list-iids']?.outputs?.[0]?.name, 'iids', 'list-iids outputs.iids name');
|
|
31
|
-
eq(wf.nodes['list-iids']?.outputs?.[0]?.extract, 'stdout', 'list-iids outputs.iids extract');
|
|
32
|
-
|
|
33
|
-
process.exit(ok ? 0 : 1);
|