freddie 0.0.76 → 0.0.78

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/AGENTS.md CHANGED
@@ -242,7 +242,10 @@ All 21 named integration tests in `test.js` pass (exit 0). Subsystem coverage:
242
242
  ## LLM backends and acptoapi
243
243
 
244
244
  - **acptoapi bridge** — Integrated at `src/agent/acptoapi-bridge.js` + `src/agent/llm_resolver.js` (commit 5f55f1e). Localhost API (default port 4800) converting OpenAI/Anthropic SDK calls to multiple backends: Kilo Code, opencode, Claude CLI, Anthropic API, Gemini, Ollama, Bedrock. Endpoint `/v1/chat/completions`, OpenAI-compatible, accepts `Bearer none` auth.
245
- - **LLM resolver priority** (1) explicit `callLLM` arg, (2) pi-bridge if `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GROQ_API_KEY` / `OPENROUTER_API_KEY` env set, (3) acptoapi if `/v1/models` returns 200, (4) throw with actionable error. Configurable via `FREDDIE_LLM_URL` and `FREDDIE_LLM_MODEL` env vars.
245
+ - **acptoapi dep pattern** (2026-05-10) `package.json` pins `"acptoapi": "file:../acptoapi"` (same pattern as `anentrypoint-design`). Always tracks local SDK without publish cycles. CJS/ESM boundary bridged via `createRequire(import.meta.url)` in freddie ESM files that import acptoapi CJS exports.
246
+ - **LLM resolver priority** (2026-05-10) — (1) explicit provider+key, (2) acptoapi if `/v1/models` returns 200, (3) `agent.model_preference` config array (ordered failover, sampler-gated), (4) `sdk.buildAutoChain()` env-key scan, (5) throw. `PROVIDER_KEYS` and `PROVIDER_DEFAULTS` imported from `acptoapi` — not maintained in freddie. `sdk.chat()` returns OpenAI `{choices:[{message}]}` format; `sdkChat()` adapter in llm_resolver converts to freddie's `{content, tool_calls, raw}`.
247
+ - **Model sampler — re-export shim** (2026-05-10) — `src/agent/model-sampler.js` is a 13-line re-export shim over acptoapi sampler. Sampler logic (5-step backoff 30s→480s, createSampler factory, singleton) lives in `c:\dev\acptoapi\lib\sampler.js`. Exports: `isAvailable`, `markFailed`, `markOk`, `resetAvailability`, `getStatus`, `probe`, `startSampler`, `stopSampler`, `createSampler`.
248
+ - **model_preference config key** (2026-05-10) — `agent.model_preference: []` in `~/.freddie/config.yaml`. Array of `{ provider, model? }` objects; `resolveCallLLM` tries each in order, skipping unavailable (sampler-gated) and marking failures with backoff. Config v2 migration adds the key on upgrade from v1.
246
249
  - **acptoapi Claude backend verified** (2026-05-03) — Live agent loop working: start acptoapi server `node bin/agentapi.js --port 4800` (c:\dev\acptoapi), then set `FREDDIE_LLM_URL=http://localhost:4800/v1 + FREDDIE_LLM_MODEL=claude/haiku`. Model prefix `claude/` routes to Claude CLI subprocess. freddie test.js 12/12 green confirming integration production-ready. Dashboard `/api/chat` and `/api/batch` are POST-only; GET returns 404 (correct).
247
250
 
248
251
  ## Pre-rename validation snapshot (2026-05-03)
@@ -261,10 +264,12 @@ All 12 test.js named groups passing: home+config+skin, sessions+FTS5, tools+tool
261
264
  - 2026-05-03 (session 3): Ingested libsql-async-debt-class into AGENTS.md Substrate gotchas (sessions.js + cron/scheduler.js async callsites; silent TypeError class; test.js passes while CLI broken). rs-learn store still unavailable (exec:memorize/exec:recall not on PATH). 0 migration audit items queried. 1 new fact added.
262
265
  - 2026-05-03 (session 4): Plugin-architecture decomposition recorded — added "Plugin architecture" section before Layout, rewrote "Adding a tool" + "Adding a gateway platform" for plugins/<name>/{plugin,handler}.js shape. Ingested 6 facts to rs-learn (project/freddie-plugin-architecture, reference/freddie-host-contract, reference/freddie-plugin-ctx, project/freddie-migrated-subsystems, reference/freddie-thin-shims, project/freddie-plugin-witness). Audit: 5 queries fired (pi-ai env keys, profile safe paths, libsql async debt, browser inline module errors, yaml colon space trap, plus self-test on freddie-plugin-architecture) — all returned "No recall results". rs-learn ingest path live but retrieval side empty for this session (likely needs learn-build propagation). 0 items migrated; AGENTS.md items retained.
263
266
  - 2026-05-04: Recorded freddie publish workflow root fix — `.github/workflows/restore-package.cjs` now pins anentrypoint-design via `npm view` + version (`^<latest>`) instead of file: dep; `publish.yml` runs `npm install --package-lock-only` to sync lockfile. Updated "Rebase regression trap" entry with detailed causation + recurrence tell. Added new "GitHub Actions deploy-pages@v5 duplicate artifact rejection" caveat (rerun --failed silently re-uploads; trigger fresh run instead). rs-learn store still unavailable. 0 items migrated; 2 facts added/refined in AGENTS.md.
267
+ - 2026-05-10: 15-provider LLM resolver expansion. Added model-sampler.js (backoff), PROVIDER_KEYS/DEFAULTS expansion, model_preference config key, config v2 migration. Updated "LLM resolver priority" + "15-provider support" + "Model sampler" + "model_preference" entries in AGENTS.md. test.js 12/12 green at exactly 200 lines. rs-learn store still unavailable. 4 new facts added to AGENTS.md.
268
+ - 2026-05-10 (session 2): Deep integration audit between freddie and acptoapi SDK. Moved sampler logic into acptoapi lib/sampler.js (createSampler factory + singleton, 5-step backoff). Added lib/provider-maps.js (PROVIDER_KEYS + PROVIDER_DEFAULTS, 17 providers, derived from BRANDS+auto-chain). freddie: switched to file:../acptoapi dep, model-sampler.js replaced with 13-line re-export shim, llm_resolver.js simplified 131→95L (OPENAI_COMPAT removed, PROVIDER_KEYS/DEFAULTS from SDK, buildAutoChain() for auto-scan, sdkChat() adapter for OpenAI→freddie format). test.js 12/12 green. Both repos pushed. AGENTS.md updated with acptoapi dep pattern, re-export shim pattern, CJS/ESM bridge via createRequire.
264
269
 
265
270
  ## Dashboard web UI caveats
266
271
 
267
- - **anentrypoint-design v0.0.27 source/dist skew** — Published npm package lags behind source in C:/dev/anentrypoint-design. New components (EmptyState, etc.) present in source but missing from dist/247420.js until rebuild. Run `node scripts/build.mjs` in the design repo (emits dist/247420.js ~441KB + 247420.css; build ~150ms); warning "[247420] missing css: vendor/rippleui-1.12.1.css" is benign. Skip rebuild and browser-witness new component usage: silent pageerror "component is not a function" kills app mount with no output in #app. freddie/package.json uses `file:../anentrypoint-design` so npm install always mirrors rebuilt dist without publish cycles.
272
+ - **anentrypoint-design source/dist pattern** (2026-05-10) No vendor dir in freddie (`src/web/vendor/` removed, commit 52d0732). `server.js` always serves SDK from `node_modules/anentrypoint-design/dist/`. freddie's `package.json` pins `"anentrypoint-design": "file:../anentrypoint-design"` so `npm install` symlinks directly to the live source no publish cycle needed. Dist is rebuilt by CI on push to the SDK repo; rebuild locally after SDK edits via `node scripts/build.mjs` in `C:/dev/anentrypoint-design` (emits dist/247420.js ~441KB + 247420.css; build ~150ms; warning "[247420] missing css: vendor/rippleui-1.12.1.css" is benign). Skip rebuild before using a new component and the pageerror "component is not a function" kills app mount silently.
268
273
  - **Live page rerender caveat** — AppState.body caching (page computed once at navigation, body saved) breaks for live routes like #/chat where AppState is mutated mid-flight (SSE pushes new messages). Fix: detect live routes in rerender(), recompute body: `if (AppState.hash === '#/chat') { Promise.resolve(PAGES['#/chat']()).then(b => { AppState.body = b; _mount() }); return }`. Any future live-streaming pages (cron output, traces) need the same treatment.
269
274
  - **libuv spawn caveat** — Spawning createDashboard() from exec:nodejs and keeping process alive triggers libuv UV_HANDLE_CLOSING crash on shutdown. Reliable alternative: boot via `node bin/freddie.js dashboard --port <port>`. Liveness checks: exec:browser → page.goto → window.__debug.dashboard() returns {booted, ts, framework, route}; window.__debug.chat() exposes {messages, streaming, draft}; window.__debug.sendChat(text) drives round-trips.
270
275
  - **anentrypoint-design theme token descendant selector fix** (2026-05-04) — SDK CSS scopes light/dark theme variables under descendant selectors `.ds-247420 [data-theme="dark"]` and `.ds-247420 [data-theme="light"]`. Placing both `class="ds-247420"` and `data-theme="dark"` on the same node (e.g. `<html>`) breaks theming: the descendant selector does NOT match when both attributes are on the same element. Fix: scope `class="ds-247420"` on `<html>` and `data-theme="dark"` on `<body>`. Theme toggle must write to `document.body`, not `document.documentElement`. Browser-witnessed commit 17dfce0: pre-fix dark/light backgrounds identical (rgb 26,27,30); post-fix dark=rgb(26,27,30), light=rgb(245,240,228). Symptom: toggle theme in dashboard, page background does not change.
@@ -273,7 +278,7 @@ All 12 test.js named groups passing: home+config+skin, sessions+FTS5, tools+tool
273
278
 
274
279
  - **Structured-YAML rendering** — `website/theme.mjs` (164L) renders structured YAML via 247420 design vocabulary, not raw markdown. Consumes `page.hero` (heading/subheading/accent/body/badges/ctas), `page.sections[]` (rotating rail color green→purple→mascot→sun→flame→sky by section index, optional `lede` + per-item `benefit` italic), `page.examples[]` (railed link list with mono numeric ranks + ↗ glyph). Falls back to `page.body` markdown for prose. Style block inlined so rail/dot/chip/btn classes work without ds-247420 SDK CSS loading first. To get a specific rail color, reorder sections. Prefer enriching hero+sections+examples over expanding body markdown; copy existing YAML structure as template for new pages.
275
280
  - **YAML colon-space trap** — In `website/content/pages/*.yaml`, any value containing `: ` outside backticks (e.g. `[linux, macos, windows]`, `requiresEnv: ['MY_KEY']` code snippets) MUST be double-quoted. The parser otherwise interprets the embedded colon as a mapping and the file fails to load. Hit twice: tools.yaml line 72, skills.yaml line 40. Fix is wrapping the whole value in `"..."`.
276
- - **SSR innerHTML injection beats client dispatch** — anentrypoint-design v0.0.27 exposes Hero/HomeView/Panel/Row/Section/WorksList, but pre-mounted SSR injection via innerHTML is more reliable than dispatching components client-side at build — avoids depending on SDK loading before the static HTML paints. Emitted HTML carries rail/dot/chip/btn classes with inline styles to be self-sufficient.
281
+ - **SSR innerHTML injection beats client dispatch** — anentrypoint-design exposes Hero/HomeView/Panel/Row/Section/WorksList (and more), but pre-mounted SSR injection via innerHTML is more reliable than dispatching components client-side at build — avoids depending on SDK loading before the static HTML paints. Emitted HTML carries rail/dot/chip/btn classes with inline styles to be self-sufficient.
277
282
 
278
283
  ## Residual complement (NOT ported this session)
279
284
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ ## [Unreleased]
2
+
3
+ ### Fixed
4
+ - `src/agent/llm_resolver.js`: fixed tool-calling for all openai-compat providers (groq, mistral, cerebras, openrouter, nvidia, etc.) — `sdk.chat()` was internally using `from:'openai'` format which stripped `url` and `apiKey` before sending to the provider, causing "Failed to parse URL from undefined"; replaced with direct `fetch()` for openai-compat, bypassing the sdk format pipeline entirely
5
+ - `src/agent/llm_resolver.js`: tool schemas (from `getEnabledToolSchemas`) were never passed to the LLM API — `tools: undefined` was hardcoded; now converts freddie tool schemas to OpenAI `{type:'function', function:{...}}` format and passes them in the request body
6
+
7
+ ### Changed
8
+ - `src/web/vendor/anentrypoint-design/` removed; `server.js` now always serves SDK from `node_modules`; `file:../anentrypoint-design` dep keeps node_modules in sync with live SDK source
9
+ - `src/web/app.js` refactored from 548L monolith to 59L shim; state/host helpers extracted to `src/web/state.js` (131L); all page components extracted to `src/web/routes.js` (289L)
10
+ - `acptoapi` dep switched to `file:../acptoapi` — always tracks local SDK, same pattern as `anentrypoint-design`
11
+ - `src/agent/model-sampler.js` is now a thin re-export shim; sampler logic lives in acptoapi `lib/sampler.js`
12
+ - `src/agent/llm_resolver.js` simplified to 95 lines: `PROVIDER_KEYS`/`DEFAULTS` imported from acptoapi, `OPENAI_COMPAT` block removed, auto-scan uses acptoapi `buildAutoChain()`, preference failover calls acptoapi `chat()`
13
+ - `resolveCallLLM`: acptoapi is now priority 1 (before direct API keys and preference list); direct-key providers remain as fallback when acptoapi is unreachable
14
+
15
+ ### Added
16
+ - Per-provider model list probing: `POST /api/providers/:name/probe` fetches live model list; `/api/providers` includes cached `models`/`modelsError`; models page shows model list per provider with probe-all button
17
+ - `hasKey()` fixed to use `resolveKey()` 4-source chain (env → auth-store → freddie.env → acptoapi.env); previously only checked `process.env`, causing keys stored via auth-store to be invisible to the LLM resolver
18
+ - Expanded LLM provider support: cerebras, google, mistral, codestral, cloudflare-workers-ai, xai, zai, opencode, nvidia, sambanova, qwen (15 providers total)
19
+ - `src/agent/model-sampler.js`: background availability sampler with exponential backoff (30s→60s→120s→240s→480s cap)
20
+ - `agent.model_preference` config key (ordered list of {provider, model} objects) for user-defined failover priority
21
+ - `resolveCallLLM` now walks preference list, skips unavailable providers via sampler, marks failed providers on error
22
+ - Config schema version bumped to 2 with automatic migration
23
+
1
24
  # Changelog
2
25
 
3
26
  ## [0.0.51] - 2026-05-04
package/README.md CHANGED
@@ -134,6 +134,8 @@ v0.1.1 complete and witnessed: 12/12 named tests passing, dashboard + website bo
134
134
  - **Full context compressor** (`src/agent/compress/*`) with handoff-framed summary prefix, structured summarizer prompt, head/middle/tail policy, tool-output pre-pruning, summary-budget ratio, iterative summary update, and 600s failure cooldown
135
135
  - **Documentation site** at `website/` powered by `flatspace` (NOT docusaurus). Build with `cd website && node ../node_modules/flatspace/bin/flatspace.js build` — output to `website/docs/` for GitHub Pages.
136
136
 
137
+ **LLM providers**: anthropic, openai, groq, openrouter, cerebras, google, mistral, codestral, cloudflare-workers-ai, xai, zai, opencode, nvidia, sambanova, qwen — plus acptoapi localhost bridge. Set `agent.model_preference` in `~/.freddie/config.yaml` for ordered failover with exponential backoff.
138
+
137
139
  What's not in the box yet (residual, see AGENTS.md): real credentials per platform / memory backend; modal / daytona / singularity environments; bedrock / codex provider adapters.
138
140
 
139
141
  ## Testing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freddie",
3
- "version": "0.0.76",
3
+ "version": "0.0.78",
4
4
  "type": "module",
5
5
  "description": "Open JS agent harness built on pi-mono, floosie, xstate, and anentrypoint-design",
6
6
  "bin": {
@@ -26,7 +26,8 @@
26
26
  "plugsdk": "^1.0.15",
27
27
  "xstate": "^5.31.0",
28
28
  "zod": "^4.0.0",
29
- "anentrypoint-design": "^0.0.63"
29
+ "anentrypoint-design": "^0.0.67",
30
+ "acptoapi": "^1.0.48"
30
31
  },
31
32
  "optionalDependencies": {
32
33
  "@libsql/darwin-arm64": "0.3.19",
@@ -1,20 +1,27 @@
1
1
  import { runTurn } from '../../src/agent/machine.js'
2
+ import { createSession } from '../../src/sessions.js'
2
3
  export default {
3
4
  name: 'gui-chat', surfaces: 'gui',
4
5
  register({ gui }) {
5
6
  gui.route('POST', '/api/chat', async (req, res) => {
6
- const { prompt, sessionId = null } = req.body || {}
7
+ const { prompt, sessionId: incomingSessionId = null, cwd, skill, provider, model } = req.body || {}
7
8
  if (!prompt) return res.status(400).json({ error: 'prompt required' })
8
9
  res.setHeader('Content-Type', 'text/event-stream')
9
10
  res.setHeader('Cache-Control', 'no-cache')
10
11
  res.setHeader('Connection', 'keep-alive')
11
12
  const send = (event, data) => res.write('event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n')
13
+ let sessionId = incomingSessionId
14
+ if (!sessionId) {
15
+ try {
16
+ sessionId = await createSession({ platform: 'web', title: prompt.slice(0, 80), cwd: cwd || null, skill: skill || null, model: model || null })
17
+ } catch (_) { sessionId = null }
18
+ }
12
19
  send('start', { ts: Date.now(), sessionId })
13
20
  try {
14
- const out = await runTurn({ prompt, timeoutMs: 30000 })
21
+ const out = await runTurn({ prompt, timeoutMs: 120000, cwd, skill, provider, model })
15
22
  if (out.error) { send('error', { error: out.error }); res.end(); return }
16
23
  for (const m of out.messages) send('message', m)
17
- send('done', { result: out.result || '', iterations: out.iterations })
24
+ send('done', { result: out.result || '', iterations: out.iterations, sessionId })
18
25
  } catch (e) { send('error', { error: String(e.message || e) }) }
19
26
  res.end()
20
27
  })
@@ -1,11 +1,37 @@
1
+ import { createRequire } from 'module'
1
2
  import { listAllProfiles } from '../../src/commands/profile.js'
2
3
  import { COMMAND_REGISTRY } from '../../src/commands/registry.js'
3
4
  import { getFreddieHome } from '../../src/home.js'
5
+ import { PROVIDER_KEYS, DEFAULTS } from '../../src/agent/llm_resolver.js'
6
+ import { getStatus } from '../../src/agent/model-sampler.js'
7
+ import { resolveKey } from '../../src/agent/credential_sources.js'
8
+ const _require = createRequire(import.meta.url)
9
+ const { probeModels, getCachedModels } = _require('acptoapi')
4
10
  export default {
5
11
  name: 'gui-profiles-commands-health', surfaces: 'gui',
6
12
  register({ gui }) {
7
13
  gui.route('GET', '/api/profiles', (_, res) => res.json(listAllProfiles()))
8
14
  gui.route('GET', '/api/commands', (_, res) => res.json(COMMAND_REGISTRY))
9
15
  gui.route('GET', '/api/health', (_, res) => res.json({ ok: true, ts: Date.now(), freddie_home: getFreddieHome() }))
16
+ gui.route('GET', '/api/providers', async (_, res) => {
17
+ const status = getStatus()
18
+ const providers = await Promise.all(Object.keys(PROVIDER_KEYS).map(async name => {
19
+ const envKey = PROVIDER_KEYS[name]
20
+ const resolved = await resolveKey(name).catch(() => ({ value: null }))
21
+ const configured = !!(resolved.value || (envKey && process.env[envKey]))
22
+ const s = status.find(x => x.provider === name)
23
+ const available = configured && (s ? s.ok !== false : true)
24
+ const cached = getCachedModels(name)
25
+ return { name, configured, available, defaultModel: DEFAULTS[name] || '', models: cached?.models || null, modelsError: cached?.error || null }
26
+ }))
27
+ res.json(providers)
28
+ })
29
+ gui.route('POST', '/api/providers/:name/probe', async (req, res) => {
30
+ const { name } = req.params
31
+ const resolved = await resolveKey(name).catch(() => ({ value: null }))
32
+ if (!resolved.value) return res.status(400).json({ error: 'no key' })
33
+ const result = await probeModels(name, resolved.value)
34
+ res.json(result)
35
+ })
10
36
  },
11
37
  }
@@ -1,17 +1,33 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { createRequire } from 'node:module'
3
4
  import { getFreddieHome } from '../home.js'
4
5
  import { getAuthStore } from '../auth.js'
5
6
  const ENV_MAP = { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', groq: 'GROQ_API_KEY', xai: 'XAI_API_KEY', openrouter: 'OPENROUTER_API_KEY', mistral: 'MISTRAL_API_KEY', deepseek: 'DEEPSEEK_API_KEY' }
7
+ function parseDotEnv(filePath, envKey) {
8
+ if (!fs.existsSync(filePath)) return null
9
+ const m = fs.readFileSync(filePath, 'utf8').match(new RegExp('^' + envKey + '=(.+)$', 'm'))
10
+ return m ? m[1].replace(/^["']|["']$/g, '') : null
11
+ }
12
+ function acptoApiEnvPath() {
13
+ try {
14
+ const req = createRequire(import.meta.url)
15
+ const pkgMain = req.resolve('acptoapi')
16
+ return path.join(path.dirname(pkgMain), '.env')
17
+ } catch { return null }
18
+ }
6
19
  export async function resolveKey(provider) {
7
20
  const env = ENV_MAP[provider] || (provider.toUpperCase() + '_API_KEY')
8
21
  if (process.env[env]) return { source: 'env', value: process.env[env] }
9
22
  const stored = await getAuthStore().getCredential(env)
10
23
  if (stored?.value) return { source: 'auth-store', value: stored.value }
11
- const dotEnv = path.join(getFreddieHome(), '.env')
12
- if (fs.existsSync(dotEnv)) {
13
- const m = fs.readFileSync(dotEnv, 'utf8').match(new RegExp('^' + env + '=(.+)$', 'm'))
14
- if (m) return { source: 'dotenv', value: m[1].replace(/^["']|["']$/g, '') }
24
+ const frederickDotEnv = path.join(getFreddieHome(), '.env')
25
+ const fromFreddie = parseDotEnv(frederickDotEnv, env)
26
+ if (fromFreddie) return { source: 'dotenv', value: fromFreddie }
27
+ const acpPath = acptoApiEnvPath()
28
+ if (acpPath) {
29
+ const fromAcp = parseDotEnv(acpPath, env)
30
+ if (fromAcp) return { source: 'acptoapi-dotenv', value: fromAcp }
15
31
  }
16
32
  return { source: 'none', value: null }
17
33
  }
@@ -1,22 +1,124 @@
1
+ import { createRequire } from 'module'
1
2
  import { callLLM as acptoapiCall, isReachable as acptoapiReachable } from './acptoapi-bridge.js'
2
- import { callLLM as piCall } from './pi-bridge.js'
3
+ import { isAvailable, markFailed, getStatus } from './model-sampler.js'
4
+ import { getConfigValue } from '../config.js'
5
+ import { resolveKey } from './credential_sources.js'
3
6
 
4
- const KEYS = { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', groq: 'GROQ_API_KEY', openrouter: 'OPENROUTER_API_KEY' }
7
+ const _require = createRequire(import.meta.url)
8
+ const sdk = _require('acptoapi')
9
+
10
+ export const PROVIDER_KEYS = sdk.PROVIDER_KEYS
11
+ export const DEFAULTS = sdk.PROVIDER_DEFAULTS
12
+
13
+ function toOpenAITools(schemas) {
14
+ if (!schemas?.length) return undefined
15
+ return schemas.map(s => ({ type: 'function', function: { name: s.name, description: s.description || '', parameters: s.parameters || { type: 'object', properties: {} } } }))
16
+ }
17
+
18
+ async function directOpenAICompatChat(url, apiKey, model, messages, tools) {
19
+ const body = { model, messages, ...(tools?.length ? { tools } : {}) }
20
+ const res = await fetch(url, {
21
+ method: 'POST',
22
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
23
+ body: JSON.stringify(body),
24
+ signal: AbortSignal.timeout(120000),
25
+ })
26
+ if (!res.ok) { const t = await res.text(); throw new Error(`${res.status} ${t.slice(0, 200)}`) }
27
+ return res.json()
28
+ }
29
+
30
+ async function sdkChat(provider, model, input) {
31
+ const { resolveModel } = sdk
32
+ const r = resolveModel(`${provider}/${model}`)
33
+ const resolved = await resolveKey(provider).catch(() => ({ value: null }))
34
+ const apiKey = resolved.value || (r.env ? process.env[r.env] : undefined)
35
+ const openaiTools = toOpenAITools(input.tools)
36
+ let result
37
+ if (r.provider === 'openai-compat') {
38
+ result = await directOpenAICompatChat(r.url, apiKey, r.model, input.messages, openaiTools)
39
+ } else {
40
+ const { buffer: sdkBuffer } = sdk
41
+ result = await sdkBuffer({ from: null, to: 'openai', provider: r.provider, model: r.model, messages: input.messages, apiKey, ...(openaiTools ? { tools: openaiTools } : {}) })
42
+ }
43
+ const choice = result?.choices?.[0]?.message || {}
44
+ const content = typeof choice.content === 'string' ? choice.content : ''
45
+ const tool_calls = Array.isArray(choice.tool_calls)
46
+ ? choice.tool_calls.map(tc => ({ id: tc.id, name: tc.function?.name, arguments: tryParseJson(tc.function?.arguments) }))
47
+ : []
48
+ return { content, tool_calls, raw: result }
49
+ }
50
+
51
+ function tryParseJson(s) { try { return typeof s === 'string' ? JSON.parse(s) : (s || {}) } catch { return {} } }
52
+
53
+ async function hasKey(provider) {
54
+ const resolved = await resolveKey(provider).catch(() => ({ value: null }))
55
+ return !!resolved.value
56
+ }
57
+
58
+ function defaultModel(provider) {
59
+ return DEFAULTS[provider] || ''
60
+ }
5
61
 
6
62
  export function resolveCallLLM({ provider, model } = {}) {
7
63
  return async (input) => {
8
64
  const explicitProvider = provider || input.provider
9
- const explicitKey = explicitProvider && KEYS[explicitProvider] ? process.env[KEYS[explicitProvider]] : null
10
- if (explicitProvider && explicitKey) {
11
- return await piCall({ ...input, provider: explicitProvider, model: model || input.model })
65
+
66
+ if (explicitProvider && await hasKey(explicitProvider)) {
67
+ const m = model || input.model || defaultModel(explicitProvider)
68
+ if (!isAvailable(explicitProvider)) {
69
+ const status = getStatus().map(s => `${s.provider}(ok=${s.ok},fails=${s.failCount})`).join(', ')
70
+ throw new Error(`provider ${explicitProvider} is in backoff | sampler: ${status}`)
71
+ }
72
+ try {
73
+ return await sdkChat(explicitProvider, m, input)
74
+ } catch (e) {
75
+ markFailed(explicitProvider)
76
+ throw e
77
+ }
12
78
  }
79
+
13
80
  if (await acptoapiReachable()) {
14
81
  return await acptoapiCall({ ...input, model: model || input.model })
15
82
  }
16
- const DEFAULTS = { anthropic: 'claude-3-5-haiku-20241022', openai: 'gpt-4o-mini', groq: 'llama3-8b-8192', openrouter: 'openai/gpt-4o-mini' }
17
- for (const [p, k] of Object.entries(KEYS)) {
18
- if (process.env[k]) return await piCall({ ...input, provider: p, model: model || input.model || DEFAULTS[p] })
83
+
84
+ const preference = getConfigValue('agent.model_preference', [])
85
+ if (Array.isArray(preference) && preference.length > 0) {
86
+ const errors = []
87
+ for (const pref of preference) {
88
+ const p = pref.provider
89
+ const m = pref.model || model || input.model || defaultModel(p)
90
+ if (!await hasKey(p)) continue
91
+ if (!isAvailable(p)) continue
92
+ try {
93
+ return await sdkChat(p, m, input)
94
+ } catch (e) {
95
+ markFailed(p)
96
+ errors.push(`${p}: ${e.message}`)
97
+ }
98
+ }
99
+ if (errors.length) {
100
+ const status = getStatus().map(s => `${s.provider}(ok=${s.ok},fails=${s.failCount})`).join(', ')
101
+ throw new Error(`all preference providers failed: ${errors.join('; ')} | sampler: ${status}`)
102
+ }
19
103
  }
20
- throw new Error('no LLM backend reachable: start acptoapi (http://127.0.0.1:4800/v1) or set ANTHROPIC_API_KEY/OPENAI_API_KEY/GROQ_API_KEY/OPENROUTER_API_KEY')
104
+
105
+ const links = sdk.buildAutoChain(model || input.model)
106
+ const availableLinks = (await Promise.all(links.map(async l => {
107
+ const prefix = l.model.split('/')[0]
108
+ return (await hasKey(prefix)) && isAvailable(prefix) ? l : null
109
+ }))).filter(Boolean)
110
+
111
+ for (const link of availableLinks) {
112
+ const prefix = link.model.split('/')[0]
113
+ const m = link.model.replace(/^[^/]+\//, '')
114
+ try {
115
+ return await sdkChat(prefix, m, input)
116
+ } catch (e) {
117
+ markFailed(prefix)
118
+ }
119
+ }
120
+
121
+ const status = getStatus().map(s => `${s.provider}(ok=${s.ok},fails=${s.failCount})`).join(', ')
122
+ throw new Error('no LLM backend reachable: set a provider API key or start acptoapi (http://127.0.0.1:4800/v1)' + (status ? ' | sampler: ' + status : ''))
21
123
  }
22
124
  }
@@ -85,9 +85,18 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
85
85
  })
86
86
  }
87
87
 
88
- export async function runTurn({ prompt, messages = [], model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations = 90, timeoutMs = 30000 } = {}) {
88
+ export async function runTurn({ prompt, messages = [], model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations = 90, timeoutMs = 30000, cwd, skill } = {}) {
89
+ const initMessages = [...messages]
90
+ const systemParts = []
91
+ if (cwd) systemParts.push(`Working directory: ${cwd}. Always pass cwd="${cwd}" to bash tool calls. When reading or writing files use paths relative to this directory or absolute paths under it.`)
92
+ if (skill) {
93
+ const h = await bootHost()
94
+ const skillDef = h.pi.skills.get(skill)
95
+ if (skillDef?.content) systemParts.push('Skill context:\n' + skillDef.content)
96
+ }
97
+ if (systemParts.length > 0) initMessages.unshift({ role: 'user', content: systemParts.join('\n\n') })
89
98
  const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations })
90
- const actor = createActor(machine, { input: { messages } })
99
+ const actor = createActor(machine, { input: { messages: initMessages } })
91
100
  actor.start()
92
101
  actor.send({ type: 'SUBMIT', prompt })
93
102
  return await new Promise((resolve, reject) => {
@@ -0,0 +1,13 @@
1
+ import { createRequire } from 'module'
2
+ const _require = createRequire(import.meta.url)
3
+ const _sdk = _require('acptoapi')
4
+
5
+ export const isAvailable = _sdk.isAvailable
6
+ export const markFailed = _sdk.markFailed
7
+ export const markOk = _sdk.markOk
8
+ export const resetAvailability = _sdk.resetAvailability
9
+ export const getStatus = _sdk.getStatus
10
+ export const probe = _sdk.probe
11
+ export const startSampler = _sdk.startSampler
12
+ export const stopSampler = _sdk.stopSampler
13
+ export const createSampler = _sdk.createSampler
package/src/config.js CHANGED
@@ -4,9 +4,9 @@ import yaml from 'js-yaml'
4
4
  import { getFreddieHome } from './home.js'
5
5
 
6
6
  export const DEFAULT_CONFIG = {
7
- _config_version: 1,
7
+ _config_version: 2,
8
8
  display: { skin: 'default', tool_progress_command: false, background_process_notifications: 'all' },
9
- agent: { provider: 'anthropic', model: '', max_iterations: 90, fallback_model: null, save_trajectories: false },
9
+ agent: { provider: 'anthropic', model: '', max_iterations: 90, fallback_model: null, save_trajectories: false, model_preference: [] },
10
10
  memory: { provider: null },
11
11
  skills: { config: {} },
12
12
  terminal: { cwd: null },
@@ -17,6 +17,7 @@ export const DEFAULT_CONFIG = {
17
17
 
18
18
  const MIGRATIONS = {
19
19
  1: cfg => cfg,
20
+ 2: cfg => { if (!cfg.agent) cfg.agent = {}; if (!Array.isArray(cfg.agent.model_preference)) cfg.agent.model_preference = []; return cfg },
20
21
  }
21
22
 
22
23
  export function configPath() { return path.join(getFreddieHome(), 'config.yaml') }
package/src/sessions.js CHANGED
@@ -12,7 +12,8 @@ async function initDb() {
12
12
  CREATE TABLE IF NOT EXISTS sessions (
13
13
  id TEXT PRIMARY KEY,
14
14
  platform TEXT, user_id TEXT, chat_id TEXT, thread_id TEXT,
15
- title TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, model TEXT
15
+ title TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, model TEXT,
16
+ cwd TEXT, skill TEXT
16
17
  );
17
18
  CREATE TABLE IF NOT EXISTS messages (
18
19
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -23,6 +24,11 @@ async function initDb() {
23
24
  CREATE INDEX IF NOT EXISTS idx_msg_session ON messages(session_id, ts);
24
25
  `)
25
26
 
27
+ // migrate: add cwd and skill columns if absent
28
+ for (const col of ['cwd', 'skill']) {
29
+ try { await d.exec(`ALTER TABLE sessions ADD COLUMN ${col} TEXT`) } catch {}
30
+ }
31
+
26
32
  // libsql supports FTS5 natively; create FTS virtual table
27
33
  try {
28
34
  await d.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(content, session_id UNINDEXED, content='messages', content_rowid='id')`)
@@ -38,12 +44,12 @@ async function db() {
38
44
  return await initDb()
39
45
  }
40
46
 
41
- export async function createSession({ platform = 'cli', userId = null, chatId = null, threadId = null, title = null, model = null } = {}) {
47
+ export async function createSession({ platform = 'cli', userId = null, chatId = null, threadId = null, title = null, model = null, cwd = null, skill = null } = {}) {
42
48
  const d = await db()
43
49
  const id = randomUUID()
44
50
  const now = Date.now()
45
- await d.prepare(`INSERT INTO sessions (id, platform, user_id, chat_id, thread_id, title, created_at, updated_at, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
46
- .run(id, platform, userId, chatId, threadId, title, now, now, model)
51
+ await d.prepare(`INSERT INTO sessions (id, platform, user_id, chat_id, thread_id, title, created_at, updated_at, model, cwd, skill) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
52
+ .run(id, platform, userId, chatId, threadId, title, now, now, model, cwd, skill)
47
53
  return id
48
54
  }
49
55
 
@@ -64,7 +70,7 @@ export async function getMessages(sessionId) {
64
70
 
65
71
  export async function listSessions(limit = 50) {
66
72
  const d = await db()
67
- return await d.prepare(`SELECT id, platform, title, created_at, updated_at, model FROM sessions ORDER BY updated_at DESC LIMIT ?`).all(limit)
73
+ return await d.prepare(`SELECT id, platform, title, created_at, updated_at, model, cwd, skill FROM sessions ORDER BY updated_at DESC LIMIT ?`).all(limit)
68
74
  }
69
75
 
70
76
  export async function search(query, limit = 20) {
package/src/web/app.js CHANGED
@@ -1,80 +1,64 @@
1
- import { createFreddieDashboard } from '/vendor/anentrypoint-design/desktop/freddie-dashboard.js';
2
- import { installStyles } from 'anentrypoint-design';
1
+ import { h, applyDiff, installStyles, components } from 'anentrypoint-design';
2
+ import { fetchHost, ROUTES } from './state.js';
3
+ import { PAGES } from './routes.js';
4
+
5
+ const { AppShell, Topbar, Side, Crumb, Status, EmptyState, Chip } = components;
3
6
 
4
7
  await installStyles();
5
8
 
6
- const link = document.createElement('link');
7
- link.rel = 'stylesheet';
8
- link.href = '/vendor/anentrypoint-design/desktop/freddie-dashboard.css';
9
- document.head.appendChild(link);
9
+ const root = document.getElementById('app');
10
+ root.textContent = 'loading…';
11
+ const host0 = await fetchHost();
12
+ root.innerHTML = '';
10
13
 
11
- const j = async (u, opts) => { const r = await fetch(u, opts); if (!r.ok) throw new Error(r.status + ' ' + r.statusText); return await r.json(); };
12
- const post = (u, body) => j(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
14
+ const state = { active: 'home', ts: new Date().toLocaleTimeString(), body: null, error: null };
13
15
 
14
- function regFromArray(arr, keyFn = (x) => x.name) {
15
- const m = new Map();
16
- for (const it of arr || []) m.set(keyFn(it), it);
17
- return { get: (k) => m.get(k), has: (k) => m.has(k), list: () => [...m.values()], values: () => m.values(), get size() { return m.size; } };
16
+ function buildSide() {
17
+ return Side({ sections: [{ group: 'FREDDIE', items: ROUTES.map(r => ({
18
+ glyph: r.glyph, label: r.label, href: '#fd-' + r.path,
19
+ active: state.active === r.path,
20
+ onClick: ev => { ev.preventDefault(); setActive(r.path); },
21
+ })) }] });
18
22
  }
19
23
 
20
- async function fetchHost() {
21
- const [tools, skillsResp, cron, projectsResp, env, gateway, health, commands] = await Promise.all([
22
- j('/api/tools/detail').catch(() => []),
23
- j('/api/skills').catch(() => ({ home: [], bundled: [] })),
24
- j('/api/cron').catch(() => []),
25
- j('/api/projects').catch(() => ({ active: null, projects: [] })),
26
- j('/api/env').catch(() => []),
27
- j('/api/gateway').catch(() => ({ platforms: [] })),
28
- j('/api/health').catch(() => ({ ok: false })),
29
- j('/api/commands').catch(() => []),
30
- ]);
31
- const skillList = [...(skillsResp.home || []), ...(skillsResp.bundled || [])];
32
- const projList = projectsResp.projects || [];
33
- return {
34
- kind: 'freddie-web', version: 'web',
35
- pi: {
36
- tools: regFromArray(tools),
37
- skills: regFromArray(skillList, (s) => s.name || s.id),
38
- cli: regFromArray(commands),
39
- projects: {
40
- list: () => projList,
41
- active: () => projectsResp.active,
42
- create: ({ name, path }) => post('/api/projects', { name, path }).then(() => location.reload()),
43
- remove: (name) => fetch('/api/projects/' + encodeURIComponent(name), { method: 'DELETE' }).then(() => location.reload()),
44
- setActive: (name) => post('/api/projects/active', { name }).then(() => location.reload()),
45
- },
46
- sessions: {
47
- list: () => j('/api/sessions').catch(() => []),
48
- getMessages: (id) => j('/api/sessions/' + encodeURIComponent(id) + '/messages').catch(() => []),
49
- search: (q) => j('/api/search?q=' + encodeURIComponent(q)).catch(() => []),
50
- },
51
- cron: {
52
- list: () => Promise.resolve(cron),
53
- create: (job) => post('/api/cron', job),
54
- delete: (id) => fetch('/api/cron/' + id, { method: 'DELETE' }),
55
- },
56
- env: { list: () => env, isSet: (k) => (env.find(e => e.key === k) || {}).set || false },
57
- gateway: { platforms: () => gateway.platforms || [] },
58
- agents: () => j('/api/agents').catch(() => ({ count: 0, turns: 0, active: null })),
59
- health: () => health,
60
- config: {
61
- load: () => j('/api/config').catch(() => ({})),
62
- saveValue: (path, value) => post('/api/config', { path, value }),
63
- },
64
- chat: { send: (text) => post('/api/chat', { text }) },
65
- batch: { run: (prompts, conc) => post('/api/batch', { prompts, concurrency: conc }) },
66
- hooks: {},
67
- },
68
- };
24
+ function view() {
25
+ const route = ROUTES.find(r => r.path === state.active) || ROUTES[0];
26
+ const body = state.body || EmptyState({ text: 'loading…', glyph: '◌' });
27
+ const main = h('div', { key: state.active, class: 'fd-page' }, ...(Array.isArray(body) ? body : [body]));
28
+ return AppShell({
29
+ topbar: Topbar({ brand: 'freddie', leaf: 'dashboard', items: [], active: '' }),
30
+ crumb: Crumb({ trail: ['freddie'], leaf: route.path, right: state.error ? Chip({ tone: 'miss', children: 'error' }) : Chip({ tone: 'ok', children: 'live' }) }),
31
+ side: buildSide(),
32
+ main,
33
+ status: Status({ left: ['ds-247420 · webjsx · ' + ROUTES.length + ' routes'], right: [state.ts] }),
34
+ });
69
35
  }
70
36
 
71
- const root = document.getElementById('app');
72
- root.textContent = 'loading…';
73
- const host = await fetchHost();
74
- root.innerHTML = '';
75
- const inst = { id: 'web', fs: { list: () => Promise.resolve([]) }, host };
76
- const { node } = createFreddieDashboard({ instance: inst });
77
- root.appendChild(node);
37
+ function rerender() { applyDiff(root, view()); }
38
+
39
+ function setActive(p) { state.active = p; state.body = null; rerender(); loadActive(); }
40
+ if (typeof window !== 'undefined') window.__fd_nav = setActive;
41
+
42
+ async function loadActive() {
43
+ const active = state.active;
44
+ try {
45
+ const page = PAGES[active] || PAGES.home;
46
+ const body = await page(host0);
47
+ if (state.active !== active) return;
48
+ state.body = body;
49
+ state.error = null;
50
+ } catch (e) {
51
+ if (state.active !== active) return;
52
+ state.error = String(e && e.stack || e);
53
+ const { Panel } = components;
54
+ state.body = Panel({ title: 'page error', children: h('pre', { class: 'fd-pre', style: 'max-height:200px;overflow-y:auto' }, state.error) });
55
+ }
56
+ state.ts = new Date().toLocaleTimeString();
57
+ applyDiff(root, view());
58
+ }
59
+
60
+ applyDiff(root, view());
61
+ loadActive();
78
62
 
79
63
  if (!window.__debug) window.__debug = {};
80
- window.__debug.dashboard = () => ({ booted: true, mode: 'fetchHost', tools: host.pi.tools.size, skills: host.pi.skills.size });
64
+ window.__debug.dashboard = () => ({ booted: true, tools: host0.pi.tools.size, skills: host0.pi.skills.size, active: state.active });
@@ -4,16 +4,16 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Freddie Dashboard</title>
7
+ <link rel="stylesheet" href="/vendor/anentrypoint-design/247420.css">
7
8
  <script type="importmap">
8
9
  { "imports": { "anentrypoint-design": "/vendor/anentrypoint-design/247420.js" } }
9
10
  </script>
10
11
  <style>
11
- /* Freddie-only overrides all generic primitives ship via SDK installStyles() */
12
- html, body { margin: 0; padding: 0; min-height: 100vh; }
13
- #app { padding: 16px 20px; box-sizing: border-box; }
12
+ html, body { margin: 0; padding: 0; height: 100%; }
13
+ #app { height: 100%; }
14
14
  </style>
15
15
  </head>
16
- <body data-theme="dark">
16
+ <body data-theme="light">
17
17
  <div id="app"></div>
18
18
  <script type="module" src="./app.js"></script>
19
19
  </body>
@@ -0,0 +1,2 @@
1
+ import { FREDDIE_PAGES } from 'anentrypoint-design';
2
+ export const PAGES = FREDDIE_PAGES;