freddie 0.0.91 → 0.0.93

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
@@ -177,6 +177,9 @@ One `test.js` at project root. ≤200 lines. Plain assertions, real data, real s
177
177
  - **src/web/app.js 200-line policy violation** — File is 548 lines, violating gm hard cap (2.7× over). Only file in 283-file codebase over limit. Likely waived intentionally or is drift to fix. When touching app.js, prefer splitting into `{app,routes,components,state}.js` over expanding further. Do not add 50+ more lines without addressing the split.
178
178
  - **libsql async debt class** — `src/sessions.js` (listSessions/search/getMessages/createSession/appendMessage) and `src/cron/scheduler.js` (listJobs/createJob/cancelJob/deleteJob) are async after the libsql migration. Sync callsites silently wrap each call in a Promise that rejects on iteration, surfacing as `TypeError: ... is not iterable` via `node bin/freddie.js sessions` or `freddie cron list`. Rule: every call into those modules must be awaited; tool ACTIONS inner functions async + handler awaits dispatched fn. Fixed 2026-05-03 across bin/freddie.js, src/web/server.js, src/cli/dump.js, src/cli/status.js, src/tools/session_search.js, src/tools/cronjob.js, src/acp/session.js. test.js can pass while CLI is broken — exercise the cli verb in test.js or smoke `node bin/freddie.js <verb>` after changes.
179
179
  - **Bulk-rename: git grep is case-sensitive on literal patterns** — `git grep -lI <name>` only matches lowercase. For case-variant sweep during rename refactors, use `git grep -liI -e <lower> -e <Title> -e <UPPER>` (per-pattern `-i` requires `-e` form). Single-form check is a false-clean trap.
180
+ - **codeinsight detector limits — regex-only** (2026-05-12) — `🔐 hardcoded secrets` and `🔐 SQL injection` flags are pure regex matches on identifier substrings, not value or AST analysis. Confirmed false positives across this codebase: env-var names like `DAYTONA_API_KEY` in `process.env.X` references; function param names like `secret` in HMAC helpers; URL query keys like `?appkey=&appsecret=` (DingTalk API spec); error-message string literals containing `_API_KEY`; HTTP `DELETE` URL paths flagged as SQL `DELETE FROM`. Treat hits as starting points, not findings — always read the actual line. Recurrence tell: if the flagged line is a `process.env.X` reference, a `fetch(...)` URL, or a function-signature parameter name, it's almost certainly an FP.
181
+ - **codeinsight orphan detector blind spots** (2026-05-12) — Misses three reachability paths: (1) `await import('./path/' + variable + '.js')` dynamic strings (test.js line 143 enumerates 10 agent adapters this way); (2) plugin auto-discovery walking `plugins/<dir>/plugin.js` from `discoverPlugins()` in `src/host/host.js`; (3) HTTP-served static files like `src/web/app.js` referenced only from `src/web/index.html`. Real audit needs: scan dynamic-import strings in test.js, exempt plugin/handler files, exempt browser-served paths. 2026-05-12 cleanup: 19 confirmed-dead files deleted (zero references anywhere) — `src/cli/{mcp_config,auth_commands,voice,tips,skills_config,env_loader,plugins_cmd}.js` and `src/agent/{onboarding,skill_preprocessing,skill_utils,subdirectory_hints,lmstudio_reasoning,manual_compression_feedback,memory_manager,insights,prompt_builder,shell_hooks,moonshot_schema,copilot_acp_client}.js`. All were pre-plugin-migration CLI files no longer wired.
182
+ - **createHost decomposition** (2026-05-12) — `src/host/host.js` was 197L with a 111L `createHost` body. Refactored: helper factories `makePi`, `makeGui`, `makeCcHooks`, `makeHooksRegistry`, `makeCcLoaders` (+ `reg`, `guard`, `scopedCfg`, `nullStore`) extracted to sibling `src/host/host_helpers.js` (152L). host.js shrinks to 64L; createHost body ~24L. Both well under 200L cap. Public API unchanged: `import { createHost, discoverPlugins } from './host/host.js'` still works. test.js 12/12 green post-refactor.
180
183
  - **freddie exec command Windows invocation** — `plugins/core-cli/plugin.js` registers the `exec` command (commit e5fb1b7) for non-interactive scripted use. Correct invocation on Windows: `bun run bin/freddie.js exec --prompt "..."`. Do NOT use `bun x freddie` — it hangs on Windows due to npm registry fetch timeouts. The command takes `--prompt` (required), `--model` (default ''), `--timeout` (default 60000ms) and is the validated entry point for CI pipelines.
181
184
  - **acptoapi-bridge max_tokens silent truncation** — `src/agent/acptoapi-bridge.js` line 20 controls max_tokens passed to the LLM. Prior to commit e5fb1b7, this was set to 1024, which silently truncated responses on generation tasks. Raised to 4096 to prevent hidden content loss. If generation output appears incomplete, verify max_tokens is 4096 or higher.
182
185
  - **GitHub Actions deploy-pages@v5 duplicate artifact rejection** — When using `actions/deploy-pages@v5` in a workflow, the action rejects if 2+ artifacts are named "github-pages" (e.g. from a previous failed run re-uploaded by `gh run rerun --failed`). Symptom: deployment step fails with "artifact with name github-pages already exists" or similar. Fix: trigger a fresh run via empty commit instead of rerunning the failed deploy step. `gh run rerun` can silently re-upload transient failures; avoid for deploy failures in particular.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Added
4
+ - Dynamic per-(provider × model × access_mode) availability matrix system. New `scripts/build-model-availability.js` enumerates models via existing `discoverModels()` and cross-probes each (model, mode) cell across 7 modes: `direct_api`, `acptoapi_passthrough` (:4800), `freddie_v1` (:4900), `kilo_acp` (:4780), `opencode_acp` (:4790), `claude_cli`, `freddie_agent_loop`. Sampler-aware (`markFailed` on per-cell failure), per-cell timeout 15s, per-provider model cap 5 (both env-tunable). Output: `.gm/model-availability.json` with `{timestamp, config, daemons, providers[].models[].modes{}, sampler, summary}`. Every cell is one of `{ok:true, latency_ms, excerpt}`, `{ok:false, latency_ms, error}`, or `{ok:false, skipped:true, reason}` — no blanks. Witnessed 2026-05-13: 23 providers × up to 5 models × 7 modes; 3 models green-in-any-mode (groq/gpt-oss-20b + claude-cli/haiku + claude-cli/sonnet); 8 individual cells green.
5
+ - `src/agent/model-matrix.js` (28L): `loadMatrix()` + `matrixUsable(provider, model)`. 24h TTL on consumption.
6
+ - `src/agent/llm_resolver.js`: `buildAutoChain` now consults `matrixUsable` and drops links marked `ok:false in all modes`. Matrix-absent path unchanged (preserves existing behavior). Side effect: stale defaults like `nvidia/deepseek-r1` (410 Gone) auto-drop without editing static lists.
7
+ - `plugins/gui-models-discover/plugin.js`: 3 new endpoints — `GET /api/models/availability` (200 with full matrix, 404 if absent), `GET /api/models/availability/summary` (lightweight), `POST /api/models/availability/rebuild` (202 background spawn, 409 if rebuild in flight).
8
+ - `scripts/validate-llm-providers.js`: now invokes the matrix builder by default. `--single-shot` retains the legacy one-model-per-provider behavior; `--with-single-shot` runs both.
9
+ - `test.js`: asserts both `scripts/build-model-availability.js` exists and the `/api/models/availability` endpoint returns 200|404 with valid schema when present.
10
+
11
+ ### Refactored
12
+ - `src/host/host.js`: createHost split from 111L → 24L body. Helper factories (`makePi`, `makeGui`, `makeCcHooks`, `makeHooksRegistry`, `makeCcLoaders`, plus `reg`/`guard`/`scopedCfg`/`nullStore`) extracted to new `src/host/host_helpers.js`. host.js drops from 197L → 64L, host_helpers.js is 152L. Both well under the 200L hard cap. Witnessed: test.js 12/12 green, plugins>=100, platforms>=18, memory>=8, surface guard + cycle errors still throw.
13
+ - `test.js`: trimmed from 202L → 199L (within the 200L cap) by collapsing redundant blank lines and joining the final two control statements. Every assertion preserved. Witnessed 12/12 green.
14
+
15
+ ### Removed (dead code, post-plugin-migration cleanup)
16
+ - 19 zero-import orphan files deleted after exhaustive reachability audit (no static import, no `await import()` string, no test reference, no AGENTS/CHANGELOG mention, no plugin handler call). Files: `src/cli/{mcp_config,auth_commands,voice,tips,skills_config,env_loader,plugins_cmd}.js`, `src/agent/{onboarding,skill_preprocessing,skill_utils,subdirectory_hints,lmstudio_reasoning,manual_compression_feedback,memory_manager,insights,prompt_builder,shell_hooks,moonshot_schema,copilot_acp_client}.js`. test.js 12/12 still green post-delete.
17
+
18
+ ### Witnessed false positives (codeinsight detector limits, documented for future runs)
19
+ - "Hardcoded secrets" flags at `src/agent/auxiliary_client.js:6`, `src/cli/dingtalk_auth.js:10`, `plugins/platform-dingtalk/handler.js:{1,9,31}`, `src/gateway/helpers.js:17` are all detector regex matches on env-var identifiers, function parameter names (`secret`), URL query keys (`?appkey=&appsecret=` — DingTalk API spec), and error-message string literals. Zero actual secrets in source. Detector is regex-only; treat hits as identifier-substring matches, not values.
20
+ - "SQL injection" flags at `src/tools/environments/{daytona,vercel_sandbox}.js`, `src/web/state.js:{35,46}`, `test.js:190` are HTTP DELETE/PUT URL templates in REST clients / browser fetch calls. No SQL anywhere in any of these files. Detector matches the literal `DELETE` keyword in URL paths.
21
+
22
+ ### Dependencies
23
+ - `plugsdk`: ^1.0.15 → ^1.0.16 via `scripts/sync-upstream.mjs`. `acptoapi ^1.0.56`, `anentrypoint-design ^0.0.94`, `gm-cc ^2.0.727` already current.
24
+
25
+ ### Provider witness (2026-05-12, post acptoapi 1.0.56)
26
+ - `.gm/llm-validation.json` regenerated: 5/15 pass — groq, mistral, **cloudflare (NEW, ACCOUNT_ID guard fix worked)**, sambanova, claude-cli all REAL_OK. openrouter regressed to backoff status due to upstream sampler chain ordering after nvidia (deepseek 410 Gone) failed first. kilo + opencode daemons not running (expected).
27
+
3
28
  ### Fixed
4
29
  - `src/agent/llm_resolver.js`: assistant tool_calls returning to provider on the second turn were missing OpenAI's required `type:"function"` and `function:{name,arguments:string}` wrapping. Added `toOpenAIMessages()` that wraps assistant tool_calls and stringifies tool message content. Witnessed: mistral previously errored 422 `messages.2.tool_calls.0.type : property "type" is missing`; after fix returns `Emperor Penguin` through full PLAN → EXECUTE → VERIFY → COMPLETE loop. Trajectory artifact at `penguins/.freddie/trajectories/2026-05-12T15-54-49-902-...json`.
5
30
  - `plugins/core-cli/plugin.js`: `freddie exec` now uses `resolveCallLLM` instead of hardcoded acptoapi `callLLM` — previously failed `fetch failed` when acptoapi daemon wasn't running, even with valid `--model` and provider key. Added `--provider`, `--skill`, `--cwd` flags; auto-parses `provider/model` from `--model`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freddie",
3
- "version": "0.0.91",
3
+ "version": "0.0.93",
4
4
  "type": "module",
5
5
  "description": "Open JS agent harness built on pi-mono, floosie, xstate, and anentrypoint-design",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  "floosie": "^0.6.14",
24
24
  "gm-cc": "^2.0.727",
25
25
  "js-yaml": "^4.1.0",
26
- "plugsdk": "^1.0.15",
26
+ "plugsdk": "^1.0.16",
27
27
  "xstate": "^5.31.0",
28
28
  "zod": "^4.0.0",
29
29
  "anentrypoint-design": "^0.0.94",
@@ -2,6 +2,12 @@ import { discoverAndPersist, listKnownProviders } from '../../src/agent/model-di
2
2
  import { PROVIDER_KEYS, DEFAULTS } from '../../src/agent/llm_resolver.js'
3
3
  import { getConfigValue, saveConfigValue } from '../../src/config.js'
4
4
  import { getStatus } from '../../src/agent/model-sampler.js'
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { spawn } from 'node:child_process'
8
+
9
+ const MATRIX_PATH = path.resolve(new URL('.', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'), '..', '..', '.gm', 'model-availability.json')
10
+ let _rebuildInFlight = null
5
11
 
6
12
  export default {
7
13
  name: 'gui-models-discover', surfaces: 'gui',
@@ -28,5 +34,22 @@ export default {
28
34
  res.json({ ok: true })
29
35
  })
30
36
  gui.route('GET', '/api/models/sampler', (_, res) => res.json({ status: getStatus() }))
37
+ gui.route('GET', '/api/models/availability', (_, res) => {
38
+ if (!fs.existsSync(MATRIX_PATH)) return res.status(404).json({ error: 'not_found', hint: 'run: node scripts/build-model-availability.js' })
39
+ try { res.json(JSON.parse(fs.readFileSync(MATRIX_PATH, 'utf8'))) }
40
+ catch (e) { res.status(500).json({ error: String(e.message || e) }) }
41
+ })
42
+ gui.route('GET', '/api/models/availability/summary', (_, res) => {
43
+ if (!fs.existsSync(MATRIX_PATH)) return res.status(404).json({ error: 'not_found' })
44
+ try { const j = JSON.parse(fs.readFileSync(MATRIX_PATH, 'utf8')); res.json({ timestamp: j.timestamp, daemons: j.daemons, summary: j.summary }) }
45
+ catch (e) { res.status(500).json({ error: String(e.message || e) }) }
46
+ })
47
+ gui.route('POST', '/api/models/availability/rebuild', (_, res) => {
48
+ if (_rebuildInFlight && !_rebuildInFlight.killed) return res.status(409).json({ error: 'rebuild_in_progress', pid: _rebuildInFlight.pid })
49
+ const script = path.resolve(path.dirname(MATRIX_PATH), '..', 'scripts', 'build-model-availability.js')
50
+ _rebuildInFlight = spawn(process.execPath, [script], { detached: true, stdio: 'ignore', cwd: path.dirname(path.dirname(MATRIX_PATH)) })
51
+ _rebuildInFlight.unref()
52
+ res.status(202).json({ ok: true, pid: _rebuildInFlight.pid, jobId: String(Date.now()) })
53
+ })
31
54
  },
32
55
  }
@@ -3,6 +3,8 @@ import { callLLM as acptoapiCall, isReachable as acptoapiReachable } from './acp
3
3
  import { isAvailable, markFailed, getStatus } from './model-sampler.js'
4
4
  import { getConfigValue } from '../config.js'
5
5
  import { resolveKey } from './credential_sources.js'
6
+ import { matrixUsable } from './model-matrix.js'
7
+ export { matrixUsable } from './model-matrix.js'
6
8
 
7
9
  const _require = createRequire(import.meta.url)
8
10
  const sdk = _require('acptoapi')
@@ -174,7 +176,10 @@ export function resolveCallLLM({ provider, model } = {}) {
174
176
  const links = sdk.buildAutoChain(model || input.model)
175
177
  const availableLinks = (await Promise.all(links.map(async l => {
176
178
  const prefix = l.model.split('/')[0]
177
- return (await hasKey(prefix)) && isAvailable(prefix) ? l : null
179
+ if (!(await hasKey(prefix)) || !isAvailable(prefix)) return null
180
+ const mu = matrixUsable(prefix, l.model.replace(/^[^/]+\//, ''))
181
+ if (mu === false) return null
182
+ return l
178
183
  }))).filter(Boolean)
179
184
 
180
185
  for (const link of availableLinks) {
@@ -0,0 +1,27 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ const MATRIX_PATH = path.resolve(new URL('.', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'), '..', '..', '.gm', 'model-availability.json')
5
+ const MATRIX_TTL_MS = 24 * 60 * 60 * 1000
6
+ let _cache = null
7
+
8
+ export function loadMatrix() {
9
+ if (_cache && Date.now() - _cache.loadedAt < 60_000) return _cache.data
10
+ if (!fs.existsSync(MATRIX_PATH)) return null
11
+ try {
12
+ const data = JSON.parse(fs.readFileSync(MATRIX_PATH, 'utf8'))
13
+ if (Date.now() - new Date(data.timestamp).getTime() > MATRIX_TTL_MS) return null
14
+ _cache = { data, loadedAt: Date.now() }
15
+ return data
16
+ } catch { return null }
17
+ }
18
+
19
+ export function matrixUsable(provider, model) {
20
+ const m = loadMatrix(); if (!m) return null
21
+ const p = m.providers.find(x => x.id === provider); if (!p) return null
22
+ if (!model) return p.models.some(mm => mm.usable_in_any_mode)
23
+ const mm = p.models.find(x => x.id === model || x.id === model.replace(/^[^/]+\//, ''))
24
+ return mm ? mm.usable_in_any_mode : null
25
+ }
26
+
27
+ export const MATRIX_FILE = MATRIX_PATH
package/src/host/host.js CHANGED
@@ -1,108 +1,33 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
- import { loadClaudePlugin, createHost as createPluginHost } from 'plugsdk'
5
- import { validatePlugin, topoSort, HOOK_NAMES, PI_VERBS, GUI_VERBS, FREDDIE_TO_NATIVE_HOOK } from './contract.js'
4
+ import { createHost as createPluginHost } from 'plugsdk'
5
+ import { validatePlugin, topoSort, PI_VERBS, GUI_VERBS } from './contract.js'
6
+ import { makePi, makeGui, guard, scopedCfg, nullStore, makeCcHooks, makeHooksRegistry, makeCcLoaders } from './host_helpers.js'
6
7
 
7
- function reg(map, kind) {
8
- return {
9
- register(spec) { if (!spec?.name) throw new Error(`${kind}.name required`); map.set(spec.name, spec) },
10
- get: (n) => map.get(n), list: () => [...map.values()], has: (n) => map.has(n), size: () => map.size,
11
- }
12
- }
13
-
14
- function makePi() {
15
- const m = { tools:new Map(), envs:new Map(), commands:new Map(), crons:new Map(), platforms:new Map(),
16
- memory:new Map(), skills:new Map(), contexts:new Map(), agentExts:new Map(), cli:new Map() }
17
- return {
18
- _state: m,
19
- tools: reg(m.tools, 'tool'), envs: reg(m.envs, 'env'),
20
- commands: reg(m.commands, 'command'), crons: reg(m.crons, 'cron'),
21
- platforms: reg(m.platforms, 'platform'), memory: reg(m.memory, 'memory'),
22
- skills: reg(m.skills, 'skill'), contexts: reg(m.contexts, 'context'),
23
- agentExts: reg(m.agentExts, 'agentExt'), cli: reg(m.cli, 'cli'),
24
- async dispatchTool(name, args = {}, ctx = {}) {
25
- const t = m.tools.get(name)
26
- if (!t) return JSON.stringify({ error: `unknown tool: ${name}` })
27
- if (t.checkFn && t.checkFn(t) === false) return JSON.stringify({ error: `tool unavailable: ${name}`, requires: t.requiresEnv || [] })
28
- try { const r = await t.handler(args, ctx); return typeof r === 'string' ? r : JSON.stringify(r) }
29
- catch (e) { return JSON.stringify({ error: String(e?.message || e), tool: name }) }
30
- },
31
- }
32
- }
33
-
34
- function makeGui() {
35
- const r=[], pages=new Map(), nav=[], debugs=new Map(), apis=new Map(), assets=new Map()
36
- return {
37
- _state: { routes:r, pages, nav, debugs, apis, assets },
38
- route:(method,p,h)=>r.push({method:method.toUpperCase(),path:p,handler:h}),
39
- page:(s,d)=>pages.set(s,d), nav:(i)=>nav.push(i),
40
- debug:(n,fn)=>debugs.set(n,fn), api:(g,d)=>apis.set(g,d), asset:(p,c)=>assets.set(p,c),
41
- routes:{ list:()=>r }, pages:{ get:(s)=>pages.get(s), list:()=>[...pages.values()], has:(s)=>pages.has(s) },
42
- navItems:{ list:()=>nav },
43
- debugs:{ list:()=>[...debugs.entries()].map(([n,f])=>({name:n,snapshot:f})), get:(n)=>debugs.get(n) },
8
+ function makePluginLoader({ surfaces, pi, gui, hooks, configStore, env, host, loaded }) {
9
+ return async function load(plugins) {
10
+ const sorted = topoSort(plugins.map(validatePlugin))
11
+ for (const p of sorted) {
12
+ const want = p.surfaces
13
+ const ctxPi = (want === 'pi' || want === 'both') && surfaces.includes('pi') ? pi : guard(pi, false, p.name, PI_VERBS)
14
+ const ctxGui = (want === 'gui' || want === 'both') && surfaces.includes('gui') ? gui : guard(gui, false, p.name, GUI_VERBS)
15
+ const log = (lv, m, f) => { const line = JSON.stringify({ ts: Date.now(), plugin: p.name, level: lv, msg: m, ...(f || {}) }); if (env.FREDDIE_LOG_STDOUT) console.log(line) }
16
+ const logger = { info:(m,f)=>log('info',m,f), warn:(m,f)=>log('warn',m,f), error:(m,f)=>log('error',m,f) }
17
+ const ctx = { pi: ctxPi, gui: ctxGui, hooks, log: logger, config: scopedCfg(p.name, configStore), host, env }
18
+ await p.register(ctx)
19
+ loaded.push(p)
20
+ }
21
+ return loaded.length
44
22
  }
45
23
  }
46
24
 
47
- function ccPayloadFor(name, payload) {
48
- if (name === 'preToolCall' || name === 'postToolCall')
49
- return { tool_name: payload?.name, tool_input: payload?.args || payload?.input, tool_response: payload?.result }
50
- if (name === 'onMessageInbound' || name === 'onMessageOutbound')
51
- return { prompt: payload?.content || payload?.text || '' }
52
- return payload || {}
53
- }
54
-
55
- function guard(surface, allowed, name, verbs) {
56
- if (allowed) return surface
57
- return new Proxy({}, { get(_, key) {
58
- if (verbs.includes(String(key))) return () => { throw new Error(`plugin ${name}: surface verb '${String(key)}' not allowed (declared surfaces=${name})`) }
59
- return surface[key]
60
- } })
61
- }
62
-
63
- function scopedCfg(name, store) {
64
- const k = `plugins.${name}`
65
- return { get:(kk,d)=>store.get(`${k}.${kk}`,d), set:(kk,v)=>store.set(`${k}.${kk}`,v), all:()=>store.all(k)||{} }
66
- }
67
-
68
- const nullStore = () => { const m=new Map(); return { get:(k,d)=>m.has(k)?m.get(k):d, set:(k,v)=>m.set(k,v), all:(p)=>Object.fromEntries([...m.entries()].filter(([k])=>k.startsWith(p))) } }
69
-
70
25
  export function createHost({ surfaces = ['pi','gui'], configStore = nullStore(), env = process.env } = {}) {
71
26
  const pi = makePi(), gui = makeGui()
72
27
  const binPaths = []
73
28
  const inboundListeners = []
74
- const ccHost = createPluginHost({ env, on: {
75
- onSkill: (p, s) => surfaces.includes('pi') && pi.skills.register({ name: p.manifest.name + ':' + s.name, description: s.description, content: s.body, source: 'cc:' + p.manifest.name, frontmatter: s.fields, file: s.file }),
76
- onAgent: (p, a) => surfaces.includes('pi') && pi.agentExts.register({ name: p.manifest.name + ':' + a.name, description: a.description, frontmatter: a.fields, body: a.body, source: 'cc:' + p.manifest.name, file: a.file }),
77
- onCommand: (p, c) => surfaces.includes('pi') && pi.commands.register({ name: p.manifest.name + ':' + c.name, description: c.description, body: c.body, frontmatter: c.fields, source: 'cc:' + p.manifest.name }),
78
- onTheme: (p, t) => surfaces.includes('pi') && pi.contexts.register({ name: 'theme:' + p.manifest.name + ':' + t.slug, description: t.name, theme: t }),
79
- onOutputStyle: (p, o) => surfaces.includes('pi') && pi.contexts.register({ name: 'style:' + p.manifest.name + ':' + o.name, description: o.description, body: o.body, frontmatter: o.fields }),
80
- onChannel: (p, c) => surfaces.includes('pi') && pi.platforms.register({ name: 'cc:' + p.manifest.name + ':' + c.server, server: c.server, userConfig: c.userConfig || {}, source: 'cc:' + p.manifest.name }),
81
- onSetting: (p, s) => { if (s.agent && surfaces.includes('pi') && !pi.agentExts.has('default')) pi.agentExts.register({ name: 'default', target: p.manifest.name + ':' + s.agent }) },
82
- onBin: (_, dir) => binPaths.push(dir),
83
- onMcpTool: (p, server, tool, call) => surfaces.includes('pi') && pi.tools.register({ name: 'cc:' + p.manifest.name + ':' + server + ':' + tool.name, schema: { name: tool.name, description: tool.description || '', parameters: tool.inputSchema || {} }, handler: (args) => call(args) }),
84
- onMonitorLine: (p, mon, line) => { for (const fn of inboundListeners) fn({ source: 'monitor:' + p.manifest.name + ':' + mon.name, text: line }) },
85
- } })
86
-
87
- const reg2 = Object.fromEntries(HOOK_NAMES.map(n => [n, []]))
88
- const hooks = {
89
- on(name, fn) { if (!HOOK_NAMES.includes(name)) throw new Error(`unknown hook: ${name}`); reg2[name].push(fn) },
90
- async invoke(name, payload) {
91
- let cur = payload
92
- for (const fn of reg2[name] || []) cur = (await fn(cur)) ?? cur
93
- const native = FREDDIE_TO_NATIVE_HOOK[name]
94
- if (native && ccHost.plugins().length) {
95
- const r = await ccHost.dispatch(native, ccPayloadFor(name, cur))
96
- if (r.decision === 'block') return { ...cur, behavior: 'block', reason: r.reason }
97
- const pd = r.hookSpecificOutput?.permissionDecision
98
- if (pd === 'deny') return { ...cur, behavior: 'block', reason: r.hookSpecificOutput?.permissionDecisionReason || 'denied' }
99
- if (r.hookSpecificOutput?.updatedInput) return { ...cur, ...r.hookSpecificOutput.updatedInput }
100
- }
101
- return cur
102
- },
103
- names: () => HOOK_NAMES, listeners: (n) => [...(reg2[n] || [])],
104
- }
105
-
29
+ const ccHost = createPluginHost({ env, on: makeCcHooks({ surfaces, pi, binPaths, inboundListeners }) })
30
+ const hooks = makeHooksRegistry(ccHost)
106
31
  const loaded = []
107
32
  const host = {
108
33
  pi: surfaces.includes('pi') ? pi : null,
@@ -115,67 +40,10 @@ export function createHost({ surfaces = ['pi','gui'], configStore = nullStore(),
115
40
  get: (n) => loaded.find(p => p.name === n) || null,
116
41
  shutdown: () => ccHost.shutdown(),
117
42
  }
118
-
119
- async function load(plugins) {
120
- const sorted = topoSort(plugins.map(validatePlugin))
121
- for (const p of sorted) {
122
- const want = p.surfaces
123
- const ctxPi = (want === 'pi' || want === 'both') && surfaces.includes('pi') ? pi : guard(pi, false, p.name, PI_VERBS)
124
- const ctxGui = (want === 'gui' || want === 'both') && surfaces.includes('gui') ? gui : guard(gui, false, p.name, GUI_VERBS)
125
- const log = (lv, m, f) => { const line = JSON.stringify({ ts: Date.now(), plugin: p.name, level: lv, msg: m, ...(f || {}) }); if (env.FREDDIE_LOG_STDOUT) console.log(line) }
126
- const logger = { info:(m,f)=>log('info',m,f), warn:(m,f)=>log('warn',m,f), error:(m,f)=>log('error',m,f) }
127
- const ctx = { pi: ctxPi, gui: ctxGui, hooks, log: logger, config: scopedCfg(p.name, configStore), host, env }
128
- await p.register(ctx)
129
- loaded.push(p)
130
- }
131
- return loaded.length
132
- }
133
- function isCcPluginDir(dir) {
134
- if (fs.existsSync(path.join(dir, '.claude-plugin', 'plugin.json'))) return true
135
- if (!fs.existsSync(path.join(dir, 'plugin.json'))) return false
136
- return fs.existsSync(path.join(dir, 'hooks', 'hooks.json'))
137
- || fs.existsSync(path.join(dir, 'skills'))
138
- || fs.existsSync(path.join(dir, 'agents'))
139
- }
140
- async function useCcDir(dir) {
141
- try { await ccHost.use(loadClaudePlugin(dir)) }
142
- catch (e) { if (env.FREDDIE_LOG_STDOUT) console.error(`cc-plugin ${dir} failed: ${e.message}`) }
143
- }
144
- async function loadCcPlugins(roots) {
145
- for (const root of roots) {
146
- if (!root || !fs.existsSync(root)) continue
147
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
148
- if (!entry.isDirectory()) continue
149
- const dir = path.join(root, entry.name)
150
- if (isCcPluginDir(dir)) await useCcDir(dir)
151
- }
152
- }
153
- return ccHost.plugins().length
154
- }
155
- async function loadCcFromNodeModules(startDir) {
156
- const seen = new Set(ccHost.plugins().map(p => p.root))
157
- let cur = path.resolve(startDir)
158
- while (true) {
159
- const nm = path.join(cur, 'node_modules')
160
- if (fs.existsSync(nm)) for (const entry of fs.readdirSync(nm, { withFileTypes: true })) {
161
- if (!entry.isDirectory()) continue
162
- const dirs = entry.name.startsWith('@')
163
- ? fs.readdirSync(path.join(nm, entry.name), { withFileTypes: true }).filter(e => e.isDirectory()).map(e => path.join(nm, entry.name, e.name))
164
- : [path.join(nm, entry.name)]
165
- for (const d of dirs) {
166
- if (seen.has(d) || !isCcPluginDir(d)) continue
167
- seen.add(d); await useCcDir(d)
168
- }
169
- }
170
- const parent = path.dirname(cur)
171
- if (parent === cur) break
172
- cur = parent
173
- }
174
- return ccHost.plugins().length
175
- }
176
- host.load = load
177
- host.loadCcPlugins = loadCcPlugins
178
- host.loadCcFromNodeModules = loadCcFromNodeModules
43
+ host.load = makePluginLoader({ surfaces, pi, gui, hooks, configStore, env, host, loaded })
44
+ const cc = makeCcLoaders(ccHost, env)
45
+ host.loadCcPlugins = cc.loadCcPlugins
46
+ host.loadCcFromNodeModules = cc.loadCcFromNodeModules
179
47
  return host
180
48
  }
181
49
 
@@ -0,0 +1,152 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { loadClaudePlugin } from 'plugsdk'
4
+ import { HOOK_NAMES, FREDDIE_TO_NATIVE_HOOK } from './contract.js'
5
+
6
+ export function reg(map, kind) {
7
+ return {
8
+ register(spec) { if (!spec?.name) throw new Error(`${kind}.name required`); map.set(spec.name, spec) },
9
+ get: (n) => map.get(n), list: () => [...map.values()], has: (n) => map.has(n), size: () => map.size,
10
+ }
11
+ }
12
+
13
+ export function makePi() {
14
+ const m = { tools:new Map(), envs:new Map(), commands:new Map(), crons:new Map(), platforms:new Map(),
15
+ memory:new Map(), skills:new Map(), contexts:new Map(), agentExts:new Map(), cli:new Map() }
16
+ return {
17
+ _state: m,
18
+ tools: reg(m.tools, 'tool'), envs: reg(m.envs, 'env'),
19
+ commands: reg(m.commands, 'command'), crons: reg(m.crons, 'cron'),
20
+ platforms: reg(m.platforms, 'platform'), memory: reg(m.memory, 'memory'),
21
+ skills: reg(m.skills, 'skill'), contexts: reg(m.contexts, 'context'),
22
+ agentExts: reg(m.agentExts, 'agentExt'), cli: reg(m.cli, 'cli'),
23
+ async dispatchTool(name, args = {}, ctx = {}) {
24
+ const t = m.tools.get(name)
25
+ if (!t) return JSON.stringify({ error: `unknown tool: ${name}` })
26
+ if (t.checkFn && t.checkFn(t) === false) return JSON.stringify({ error: `tool unavailable: ${name}`, requires: t.requiresEnv || [] })
27
+ try { const r = await t.handler(args, ctx); return typeof r === 'string' ? r : JSON.stringify(r) }
28
+ catch (e) { return JSON.stringify({ error: String(e?.message || e), tool: name }) }
29
+ },
30
+ }
31
+ }
32
+
33
+ export function makeGui() {
34
+ const r=[], pages=new Map(), nav=[], debugs=new Map(), apis=new Map(), assets=new Map()
35
+ return {
36
+ _state: { routes:r, pages, nav, debugs, apis, assets },
37
+ route:(method,p,h)=>r.push({method:method.toUpperCase(),path:p,handler:h}),
38
+ page:(s,d)=>pages.set(s,d), nav:(i)=>nav.push(i),
39
+ debug:(n,fn)=>debugs.set(n,fn), api:(g,d)=>apis.set(g,d), asset:(p,c)=>assets.set(p,c),
40
+ routes:{ list:()=>r }, pages:{ get:(s)=>pages.get(s), list:()=>[...pages.values()], has:(s)=>pages.has(s) },
41
+ navItems:{ list:()=>nav },
42
+ debugs:{ list:()=>[...debugs.entries()].map(([n,f])=>({name:n,snapshot:f})), get:(n)=>debugs.get(n) },
43
+ }
44
+ }
45
+
46
+ function ccPayloadFor(name, payload) {
47
+ if (name === 'preToolCall' || name === 'postToolCall')
48
+ return { tool_name: payload?.name, tool_input: payload?.args || payload?.input, tool_response: payload?.result }
49
+ if (name === 'onMessageInbound' || name === 'onMessageOutbound')
50
+ return { prompt: payload?.content || payload?.text || '' }
51
+ return payload || {}
52
+ }
53
+
54
+ export function guard(surface, allowed, name, verbs) {
55
+ if (allowed) return surface
56
+ return new Proxy({}, { get(_, key) {
57
+ if (verbs.includes(String(key))) return () => { throw new Error(`plugin ${name}: surface verb '${String(key)}' not allowed (declared surfaces=${name})`) }
58
+ return surface[key]
59
+ } })
60
+ }
61
+
62
+ export function scopedCfg(name, store) {
63
+ const k = `plugins.${name}`
64
+ return { get:(kk,d)=>store.get(`${k}.${kk}`,d), set:(kk,v)=>store.set(`${k}.${kk}`,v), all:()=>store.all(k)||{} }
65
+ }
66
+
67
+ export const nullStore = () => { const m=new Map(); return { get:(k,d)=>m.has(k)?m.get(k):d, set:(k,v)=>m.set(k,v), all:(p)=>Object.fromEntries([...m.entries()].filter(([k])=>k.startsWith(p))) } }
68
+
69
+ export function makeCcHooks({ surfaces, pi, binPaths, inboundListeners }) {
70
+ const pi_ok = surfaces.includes('pi')
71
+ return {
72
+ onSkill: (p, s) => pi_ok && pi.skills.register({ name: p.manifest.name + ':' + s.name, description: s.description, content: s.body, source: 'cc:' + p.manifest.name, frontmatter: s.fields, file: s.file }),
73
+ onAgent: (p, a) => pi_ok && pi.agentExts.register({ name: p.manifest.name + ':' + a.name, description: a.description, frontmatter: a.fields, body: a.body, source: 'cc:' + p.manifest.name, file: a.file }),
74
+ onCommand: (p, c) => pi_ok && pi.commands.register({ name: p.manifest.name + ':' + c.name, description: c.description, body: c.body, frontmatter: c.fields, source: 'cc:' + p.manifest.name }),
75
+ onTheme: (p, t) => pi_ok && pi.contexts.register({ name: 'theme:' + p.manifest.name + ':' + t.slug, description: t.name, theme: t }),
76
+ onOutputStyle: (p, o) => pi_ok && pi.contexts.register({ name: 'style:' + p.manifest.name + ':' + o.name, description: o.description, body: o.body, frontmatter: o.fields }),
77
+ onChannel: (p, c) => pi_ok && pi.platforms.register({ name: 'cc:' + p.manifest.name + ':' + c.server, server: c.server, userConfig: c.userConfig || {}, source: 'cc:' + p.manifest.name }),
78
+ onSetting: (p, s) => { if (s.agent && pi_ok && !pi.agentExts.has('default')) pi.agentExts.register({ name: 'default', target: p.manifest.name + ':' + s.agent }) },
79
+ onBin: (_, dir) => binPaths.push(dir),
80
+ onMcpTool: (p, server, tool, call) => pi_ok && pi.tools.register({ name: 'cc:' + p.manifest.name + ':' + server + ':' + tool.name, schema: { name: tool.name, description: tool.description || '', parameters: tool.inputSchema || {} }, handler: (args) => call(args) }),
81
+ onMonitorLine: (p, mon, line) => { for (const fn of inboundListeners) fn({ source: 'monitor:' + p.manifest.name + ':' + mon.name, text: line }) },
82
+ }
83
+ }
84
+
85
+ export function makeHooksRegistry(ccHost) {
86
+ const reg2 = Object.fromEntries(HOOK_NAMES.map(n => [n, []]))
87
+ return {
88
+ on(name, fn) { if (!HOOK_NAMES.includes(name)) throw new Error(`unknown hook: ${name}`); reg2[name].push(fn) },
89
+ async invoke(name, payload) {
90
+ let cur = payload
91
+ for (const fn of reg2[name] || []) cur = (await fn(cur)) ?? cur
92
+ const native = FREDDIE_TO_NATIVE_HOOK[name]
93
+ if (native && ccHost.plugins().length) {
94
+ const r = await ccHost.dispatch(native, ccPayloadFor(name, cur))
95
+ if (r.decision === 'block') return { ...cur, behavior: 'block', reason: r.reason }
96
+ const pd = r.hookSpecificOutput?.permissionDecision
97
+ if (pd === 'deny') return { ...cur, behavior: 'block', reason: r.hookSpecificOutput?.permissionDecisionReason || 'denied' }
98
+ if (r.hookSpecificOutput?.updatedInput) return { ...cur, ...r.hookSpecificOutput.updatedInput }
99
+ }
100
+ return cur
101
+ },
102
+ names: () => HOOK_NAMES, listeners: (n) => [...(reg2[n] || [])],
103
+ }
104
+ }
105
+
106
+ function isCcPluginDir(dir) {
107
+ if (fs.existsSync(path.join(dir, '.claude-plugin', 'plugin.json'))) return true
108
+ if (!fs.existsSync(path.join(dir, 'plugin.json'))) return false
109
+ return fs.existsSync(path.join(dir, 'hooks', 'hooks.json'))
110
+ || fs.existsSync(path.join(dir, 'skills'))
111
+ || fs.existsSync(path.join(dir, 'agents'))
112
+ }
113
+
114
+ export function makeCcLoaders(ccHost, env) {
115
+ async function useCcDir(dir) {
116
+ try { await ccHost.use(loadClaudePlugin(dir)) }
117
+ catch (e) { if (env.FREDDIE_LOG_STDOUT) console.error(`cc-plugin ${dir} failed: ${e.message}`) }
118
+ }
119
+ async function loadCcPlugins(roots) {
120
+ for (const root of roots) {
121
+ if (!root || !fs.existsSync(root)) continue
122
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
123
+ if (!entry.isDirectory()) continue
124
+ const dir = path.join(root, entry.name)
125
+ if (isCcPluginDir(dir)) await useCcDir(dir)
126
+ }
127
+ }
128
+ return ccHost.plugins().length
129
+ }
130
+ async function loadCcFromNodeModules(startDir) {
131
+ const seen = new Set(ccHost.plugins().map(p => p.root))
132
+ let cur = path.resolve(startDir)
133
+ while (true) {
134
+ const nm = path.join(cur, 'node_modules')
135
+ if (fs.existsSync(nm)) for (const entry of fs.readdirSync(nm, { withFileTypes: true })) {
136
+ if (!entry.isDirectory()) continue
137
+ const dirs = entry.name.startsWith('@')
138
+ ? fs.readdirSync(path.join(nm, entry.name), { withFileTypes: true }).filter(e => e.isDirectory()).map(e => path.join(nm, entry.name, e.name))
139
+ : [path.join(nm, entry.name)]
140
+ for (const d of dirs) {
141
+ if (seen.has(d) || !isCcPluginDir(d)) continue
142
+ seen.add(d); await useCcDir(d)
143
+ }
144
+ }
145
+ const parent = path.dirname(cur)
146
+ if (parent === cur) break
147
+ cur = parent
148
+ }
149
+ return ccHost.plugins().length
150
+ }
151
+ return { loadCcPlugins, loadCcFromNodeModules }
152
+ }
@@ -1,6 +0,0 @@
1
- export class CopilotAcpClient {
2
- constructor({ url, token } = {}) { this.url = url || process.env.COPILOT_ACP_URL; this.token = token || process.env.COPILOT_TOKEN }
3
- headers() { return this.token ? { authorization: 'Bearer ' + this.token } : {} }
4
- async listModels() { if (!this.url) throw new Error('COPILOT_ACP_URL required'); return await (await fetch(this.url + '/models', { headers: this.headers() })).json() }
5
- async chat({ messages, model }) { if (!this.url) throw new Error('COPILOT_ACP_URL required'); return await (await fetch(this.url + '/chat', { method: 'POST', headers: { ...this.headers(), 'content-type': 'application/json' }, body: JSON.stringify({ messages, model }) })).json() }
6
- }
@@ -1,9 +0,0 @@
1
- import { db } from '../sessions.js'
2
- import { calculateCost } from './usage_pricing.js'
3
- export function modelInsights() {
4
- const rows = db().prepare(`SELECT model, SUM(prompt_tokens) AS p, SUM(completion_tokens) AS c, SUM(cost_usd) AS cost, COUNT(*) AS calls FROM account_usage GROUP BY model`).all()
5
- return rows.map(r => ({ ...r, has_pricing: calculateCost({ model: r.model, prompt_tokens: 1, completion_tokens: 1 }) > 0 }))
6
- }
7
- export function sessionInsights(sessionId) {
8
- return db().prepare(`SELECT model, SUM(prompt_tokens) AS p, SUM(completion_tokens) AS c, SUM(cost_usd) AS cost FROM account_usage WHERE session_id = ? GROUP BY model`).all(sessionId)
9
- }
@@ -1,13 +0,0 @@
1
- export function extractReasoning(content) {
2
- const tags = [/<think>([\s\S]*?)<\/think>/g, /<reasoning>([\s\S]*?)<\/reasoning>/g]
3
- const reasoning = []
4
- let cleaned = String(content || '')
5
- for (const re of tags) {
6
- for (const m of cleaned.matchAll(re)) reasoning.push(m[1])
7
- cleaned = cleaned.replace(re, '')
8
- }
9
- return { reasoning: reasoning.join('\n').trim(), content: cleaned.trim() }
10
- }
11
- export function isLmStudio(provider, baseUrl) {
12
- return provider === 'lmstudio' || /(^|\.)lmstudio\.|:1234\b|:8000\b/.test(String(baseUrl || ''))
13
- }
@@ -1,5 +0,0 @@
1
- import { db } from '../db.js'
2
- async function init() { const d = await db(); await d.exec(`CREATE TABLE IF NOT EXISTS compression_feedback (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, summary TEXT, rating INTEGER, notes TEXT, ts INTEGER NOT NULL)`); return d }
3
- export async function record({ sessionId, summary, rating, notes = '' }) { await (await init()).prepare(`INSERT INTO compression_feedback (session_id, summary, rating, notes, ts) VALUES (?, ?, ?, ?, ?)`).run(sessionId, summary || '', rating, notes, Date.now()); return { recorded: true } }
4
- export async function listForSession(sessionId) { return await (await init()).prepare(`SELECT * FROM compression_feedback WHERE session_id = ? ORDER BY id DESC`).all(sessionId) }
5
- export async function aggregate() { return await (await init()).prepare(`SELECT AVG(rating) AS avg, COUNT(*) AS n FROM compression_feedback`).get() }
@@ -1,14 +0,0 @@
1
- import { createMemoryProvider, listMemoryProviders } from '../plugins/memory/provider.js'
2
- import { getConfigValue } from '../config.js'
3
- let _provider = null
4
- export function getMemoryProvider() {
5
- if (_provider) return _provider
6
- const name = getConfigValue('memory.provider')
7
- if (!name) return null
8
- try { _provider = createMemoryProvider(name, getConfigValue('memory.options', {})) } catch { _provider = null }
9
- return _provider
10
- }
11
- export async function syncTurnIfConfigured(messages) { const p = getMemoryProvider(); if (p) try { return await p.syncTurn(messages) } catch (e) { return { error: String(e.message || e) } } return null }
12
- export async function prefetchIfConfigured(query) { const p = getMemoryProvider(); if (p) try { return await p.prefetch(query) } catch { return { items: [] } } return { items: [] } }
13
- export function listAvailable() { return listMemoryProviders() }
14
- export function resetForTests() { _provider = null }
@@ -1,11 +0,0 @@
1
- export function adaptToolSchema(tool) {
2
- const s = { type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.input_schema || tool.parameters || { type: 'object', properties: {} } } }
3
- if (s.function.parameters.required && !s.function.parameters.required.length) delete s.function.parameters.required
4
- return s
5
- }
6
- export function adaptMessages(messages) {
7
- return messages.map(m => {
8
- if (m.role === 'tool') return { role: 'tool', tool_call_id: m.tool_call_id, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }
9
- return m
10
- })
11
- }
@@ -1,16 +0,0 @@
1
- import { getConfigValue, saveConfigValue } from '../config.js'
2
- import { getAuthStore } from '../auth.js'
3
- const STEPS = ['provider', 'api-key', 'model', 'skin', 'memory']
4
- export async function isOnboardingComplete() { return Boolean(getConfigValue('onboarding.completed')) }
5
- export async function nextStep() {
6
- if (!getConfigValue('agent.provider')) return 'provider'
7
- const provider = getConfigValue('agent.provider')
8
- const env = (provider || '').toUpperCase() + '_API_KEY'
9
- if (!process.env[env] && !(await getAuthStore().getCredential(env))) return 'api-key'
10
- if (!getConfigValue('agent.model')) return 'model'
11
- if (!getConfigValue('display.skin')) return 'skin'
12
- if (!getConfigValue('memory.provider')) return 'memory'
13
- return null
14
- }
15
- export async function markComplete() { saveConfigValue('onboarding.completed', Date.now()) }
16
- export const ONBOARDING_STEPS = STEPS
@@ -1,12 +0,0 @@
1
- import { SUMMARY_PREFIX } from './compress/index.js'
2
- export function buildSystemPrompt({ persona = '', skills = [], context = [], cacheBreakpoint = true } = {}) {
3
- const parts = []
4
- if (persona) parts.push(persona)
5
- if (skills.length) parts.push('## Available skills\n' + skills.map(s => '- ' + s.name + ': ' + (s.description || '')).join('\n'))
6
- if (context.length) parts.push('## Context\n' + context.map(c => '[' + c.name + ']\n' + c.body).join('\n\n'))
7
- return { content: parts.join('\n\n'), cacheBreakpoint }
8
- }
9
- export function injectSummaryHandoff(messages, summary) {
10
- if (!summary) return messages
11
- return [...messages, { role: 'user', content: SUMMARY_PREFIX + '\n\n' + summary }]
12
- }
@@ -1,16 +0,0 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import { getFreddieHome } from '../home.js'
4
- function hookFile() { return path.join(getFreddieHome(), 'shell-hooks.json') }
5
- export function loadHooks() { try { return JSON.parse(fs.readFileSync(hookFile(), 'utf8')) } catch { return { pre_run: [], post_run: [] } } }
6
- export function saveHooks(h) { fs.writeFileSync(hookFile(), JSON.stringify(h, null, 2), 'utf8') }
7
- export function addHook(stage, command) { const h = loadHooks(); (h[stage] = h[stage] || []).push(command); saveHooks(h); return h }
8
- export async function runHooks(stage, ctx = {}) {
9
- const { spawnSync } = await import('node:child_process')
10
- const out = []
11
- for (const cmd of (loadHooks()[stage] || [])) {
12
- const r = spawnSync(process.platform === 'win32' ? 'cmd' : 'sh', [process.platform === 'win32' ? '/c' : '-c', cmd], { encoding: 'utf8', env: { ...process.env, ...ctx.env } })
13
- out.push({ cmd, exitCode: r.status, stdout: r.stdout, stderr: r.stderr })
14
- }
15
- return out
16
- }
@@ -1,12 +0,0 @@
1
- import { findSkill } from '../skills/index.js'
2
- const REF_RE = /@skill\/([\w-]+)(?:\s+([^\n@]*))?/g
3
- export function preprocessSkillRefs(text) {
4
- if (!text || typeof text !== 'string') return text
5
- return text.replace(REF_RE, (m, name, args) => {
6
- const s = findSkill(name)
7
- return s ? '\n[skill:' + name + ']\n' + s.body + '\n' : m
8
- })
9
- }
10
- export function listSkillRefs(text) {
11
- return [...String(text || '').matchAll(REF_RE)].map(m => ({ name: m[1], args: m[2]?.trim() || '' }))
12
- }
@@ -1,14 +0,0 @@
1
- import { listSkills, findSkill } from '../skills/index.js'
2
- export function fuzzyFindSkill(needle) {
3
- const all = listSkills()
4
- const lower = String(needle).toLowerCase()
5
- const exact = all.find(s => s.name.toLowerCase() === lower)
6
- if (exact) return exact
7
- const starts = all.find(s => s.name.toLowerCase().startsWith(lower))
8
- if (starts) return starts
9
- return all.find(s => s.name.toLowerCase().includes(lower)) || null
10
- }
11
- export function skillByCategory(category) {
12
- return listSkills().filter(s => s.frontmatter?.category === category)
13
- }
14
- export function skillExists(name) { return Boolean(findSkill(name)) }
@@ -1,17 +0,0 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- const HINT_FILES = ['CLAUDE.md', 'AGENTS.md', '.freddie-context', 'README.md']
4
- export function collectHints({ cwd = process.cwd(), maxDepth = 3 } = {}) {
5
- const hints = []
6
- let dir = cwd
7
- for (let d = 0; d < maxDepth; d++) {
8
- for (const f of HINT_FILES) {
9
- const p = path.join(dir, f)
10
- if (fs.existsSync(p)) { try { hints.push({ path: p, body: fs.readFileSync(p, 'utf8').slice(0, 4000) }) } catch {} }
11
- }
12
- const parent = path.dirname(dir)
13
- if (parent === dir) break
14
- dir = parent
15
- }
16
- return hints
17
- }
@@ -1,17 +0,0 @@
1
- import { getAuthStore } from '../auth.js'
2
- import { listProviders, resolveKey } from '../agent/credential_sources.js'
3
- export async function login(provider, key) {
4
- const env = (provider.toUpperCase() + '_API_KEY')
5
- await getAuthStore().setCredential(env, key)
6
- return { provider, stored: env }
7
- }
8
- export async function logout(provider) {
9
- const env = (provider.toUpperCase() + '_API_KEY')
10
- await getAuthStore().deleteCredential(env)
11
- return { provider, removed: env }
12
- }
13
- export async function status() {
14
- const out = []
15
- for (const p of listProviders()) { const k = await resolveKey(p); out.push({ provider: p, source: k.source, hasKey: k.value != null }) }
16
- return out
17
- }
@@ -1,25 +0,0 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import { getFreddieHome } from '../home.js'
4
- const RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/
5
- function parse(text) {
6
- const out = {}
7
- for (const line of String(text).split('\n')) {
8
- const t = line.trim()
9
- if (!t || t.startsWith('#')) continue
10
- const m = t.match(RE)
11
- if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '')
12
- }
13
- return out
14
- }
15
- export function loadEnvFile(file = null) {
16
- const candidates = file ? [file] : [path.join(getFreddieHome(), '.env'), path.join(process.cwd(), '.env')]
17
- const merged = {}
18
- for (const f of candidates) if (fs.existsSync(f)) Object.assign(merged, parse(fs.readFileSync(f, 'utf8')))
19
- return merged
20
- }
21
- export function applyEnvFile(file = null) {
22
- const env = loadEnvFile(file)
23
- for (const [k, v] of Object.entries(env)) if (!(k in process.env)) process.env[k] = v
24
- return Object.keys(env).length
25
- }
@@ -1,9 +0,0 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import { getFreddieHome } from '../home.js'
4
- function file() { return path.join(getFreddieHome(), 'mcp.json') }
5
- export function loadMcpConfig() { try { return JSON.parse(fs.readFileSync(file(), 'utf8')) } catch { return { servers: {} } } }
6
- export function saveMcpConfig(cfg) { fs.writeFileSync(file(), JSON.stringify(cfg, null, 2), 'utf8') }
7
- export function addServer(name, { command, args = [], env = {} }) { const c = loadMcpConfig(); c.servers[name] = { command, args, env }; saveMcpConfig(c); return c.servers[name] }
8
- export function removeServer(name) { const c = loadMcpConfig(); delete c.servers[name]; saveMcpConfig(c); return name }
9
- export function listServers() { return Object.entries(loadMcpConfig().servers).map(([n, s]) => ({ name: n, ...s })) }
@@ -1,21 +0,0 @@
1
- import { listPluginsInstalled, listHooks, listCliCommands } from './plugins.js'
2
- import fs from 'node:fs'
3
- import path from 'node:path'
4
- import { getFreddieHome } from '../home.js'
5
- export async function pluginsSubcommand(action = 'list', { name, body } = {}) {
6
- if (action === 'list') return { plugins: await listPluginsInstalled(), hooks: listHooks(), cliCommands: listCliCommands().length }
7
- if (action === 'install') {
8
- if (!name || !body) return { error: 'name + body required' }
9
- const dir = path.join(getFreddieHome(), 'plugins', name)
10
- fs.mkdirSync(dir, { recursive: true })
11
- fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, main: 'index.js' }, null, 2))
12
- fs.writeFileSync(path.join(dir, 'index.js'), body, 'utf8')
13
- return { installed: dir }
14
- }
15
- if (action === 'uninstall') {
16
- const dir = path.join(getFreddieHome(), 'plugins', name)
17
- if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true, force: true }); return { uninstalled: name } }
18
- return { error: 'not found' }
19
- }
20
- return { error: 'unknown action' }
21
- }
@@ -1,6 +0,0 @@
1
- import { getConfigValue, saveConfigValue } from '../config.js'
2
- export function getSkillsConfig() { return getConfigValue('skills.config', {}) || {} }
3
- export function setSkillConfig(name, opts) { const cfg = getSkillsConfig(); cfg[name] = { ...(cfg[name] || {}), ...opts }; saveConfigValue('skills.config', cfg); return cfg[name] }
4
- export function disableSkill(name) { return setSkillConfig(name, { disabled: true }) }
5
- export function enableSkill(name) { const cfg = getSkillsConfig(); delete cfg[name]?.disabled; saveConfigValue('skills.config', cfg); return cfg[name] }
6
- export function isSkillEnabled(name) { return !getSkillsConfig()[name]?.disabled }
package/src/cli/tips.js DELETED
@@ -1,14 +0,0 @@
1
- export const TIPS = [
2
- 'Use /skill <name> to inject a skill body as a user message — preserves prompt cache.',
3
- 'Profiles isolate state: freddie profile create <name>; FREDDIE_PROFILE=<name> freddie ...',
4
- 'freddie doctor checks env, deps, config — run when something feels off.',
5
- 'freddie dump exports your config + sessions to JSON for backup.',
6
- 'Set FREDDIE_DEBUG=1 to see verbose logs.',
7
- 'freddie dashboard runs a webjsx UI on a local port.',
8
- '/cron add "*/15 * * * *" "your prompt" schedules a recurring run.',
9
- 'freddie batch <file.txt> runs many prompts in parallel.',
10
- 'Skin not for you? freddie skin ares|mono|slate.',
11
- 'Memory provider: freddie memory-setup.',
12
- ]
13
- export function randomTip() { return TIPS[Math.floor(Math.random() * TIPS.length)] }
14
- export function listTips() { return TIPS }
package/src/cli/voice.js DELETED
@@ -1,6 +0,0 @@
1
- import { getConfigValue, saveConfigValue } from '../config.js'
2
- export function voiceState() { return Boolean(getConfigValue('voice.enabled')) }
3
- export function enable() { saveConfigValue('voice.enabled', true); return { enabled: true } }
4
- export function disable() { saveConfigValue('voice.enabled', false); return { enabled: false } }
5
- export function setBackend(name) { saveConfigValue('voice.backend', name); return { backend: name } }
6
- export function getBackend() { return getConfigValue('voice.backend', 'openai') }