freddie 0.0.72 → 0.0.73

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freddie",
3
- "version": "0.0.72",
3
+ "version": "0.0.73",
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,7 @@
26
26
  "plugsdk": "^1.0.15",
27
27
  "xstate": "^5.31.0",
28
28
  "zod": "^4.0.0",
29
- "anentrypoint-design": "^0.0.48"
29
+ "anentrypoint-design": "^0.0.49"
30
30
  },
31
31
  "optionalDependencies": {
32
32
  "@libsql/darwin-arm64": "0.3.19",
@@ -0,0 +1,29 @@
1
+ import { listProjects, getActiveProject, createProject, deleteProject, setActiveProject } from '../../src/projects.js'
2
+ import { resetHostForTests } from '../../src/host/index.js'
3
+
4
+ export default {
5
+ name: 'gui-projects',
6
+ surfaces: 'gui',
7
+ register({ gui }) {
8
+ gui.route('GET', '/api/projects', (_, res) => res.json({ active: getActiveProject(), projects: listProjects() }))
9
+ gui.route('POST', '/api/projects', (req, res) => {
10
+ try {
11
+ const { name, path: projectPath } = req.body || {}
12
+ const created = createProject({ name, projectPath })
13
+ res.json(created)
14
+ } catch (e) { res.status(400).json({ error: e.message }) }
15
+ })
16
+ gui.route('DELETE', '/api/projects/:name', (req, res) => {
17
+ try { deleteProject(req.params.name); res.json({ ok: true }) }
18
+ catch (e) { res.status(400).json({ error: e.message }) }
19
+ })
20
+ gui.route('POST', '/api/projects/active', (req, res) => {
21
+ try {
22
+ const { name } = req.body || {}
23
+ const proj = setActiveProject(name)
24
+ resetHostForTests()
25
+ res.json({ ok: true, active: proj, note: 'restart dashboard to load new project plugins' })
26
+ } catch (e) { res.status(400).json({ error: e.message }) }
27
+ })
28
+ },
29
+ }
package/src/home.js CHANGED
@@ -27,6 +27,13 @@ export function applyProfileOverride(name) {
27
27
  _cached = null
28
28
  }
29
29
 
30
+ export function applyHomeOverride(absPath) {
31
+ if (!absPath) { delete process.env.FREDDIE_HOME; _cached = null; return }
32
+ process.env.FREDDIE_HOME = absPath
33
+ _cached = null
34
+ ensure(absPath)
35
+ }
36
+
30
37
  export function getProfilesRoot() {
31
38
  if (process.env.FREDDIE_PROFILES_ROOT) return process.env.FREDDIE_PROFILES_ROOT
32
39
  if (process.env.FREDDIE_HOME) return path.join(process.env.FREDDIE_HOME, 'profiles')
package/src/host/index.js CHANGED
@@ -2,6 +2,7 @@ import path from 'node:path'
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { createHost, discoverPlugins } from './host.js'
4
4
  import { getFreddieHome } from '../home.js'
5
+ import { applyActiveProjectFromRegistry } from '../projects.js'
5
6
 
6
7
  let _host = null
7
8
  let _loaded = false
@@ -18,6 +19,7 @@ export async function bootHost(extraRoots = []) {
18
19
  const h = host()
19
20
  if (_loaded) return h
20
21
  _loaded = true
22
+ if (!process.env.FREDDIE_HOME && !process.env.FREDDIE_PROFILE) applyActiveProjectFromRegistry()
21
23
  const roots = [REPO_PLUGINS, path.join(getFreddieHome(), 'plugins'), path.join(process.cwd(), '.freddie', 'plugins'), ...extraRoots]
22
24
  const plugins = await discoverPlugins(roots)
23
25
  await h.load(plugins)
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import { applyHomeOverride } from './home.js'
5
+
6
+ const REGISTRY_PATH = path.join(os.homedir(), '.freddie', 'projects.json')
7
+
8
+ const DEFAULT_REGISTRY = {
9
+ active: 'default',
10
+ projects: [
11
+ { name: 'default', path: path.join(os.homedir(), '.freddie'), created_at: new Date().toISOString() },
12
+ ],
13
+ }
14
+
15
+ function ensureRegistry() {
16
+ const dir = path.dirname(REGISTRY_PATH)
17
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
18
+ if (!fs.existsSync(REGISTRY_PATH)) {
19
+ fs.writeFileSync(REGISTRY_PATH, JSON.stringify(DEFAULT_REGISTRY, null, 2))
20
+ }
21
+ }
22
+
23
+ export function loadRegistry() {
24
+ ensureRegistry()
25
+ try {
26
+ const raw = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8'))
27
+ if (!raw.projects || !Array.isArray(raw.projects)) return DEFAULT_REGISTRY
28
+ if (!raw.projects.find(p => p.name === 'default')) raw.projects.unshift(DEFAULT_REGISTRY.projects[0])
29
+ if (!raw.active) raw.active = 'default'
30
+ return raw
31
+ } catch { return DEFAULT_REGISTRY }
32
+ }
33
+
34
+ function saveRegistry(reg) {
35
+ ensureRegistry()
36
+ fs.writeFileSync(REGISTRY_PATH, JSON.stringify(reg, null, 2))
37
+ }
38
+
39
+ export function listProjects() { return loadRegistry().projects }
40
+
41
+ export function getActiveProject() {
42
+ const reg = loadRegistry()
43
+ return reg.projects.find(p => p.name === reg.active) || reg.projects[0]
44
+ }
45
+
46
+ export function createProject({ name, projectPath }) {
47
+ if (!name || !projectPath) throw new Error('name and path are required')
48
+ if (!path.isAbsolute(projectPath)) throw new Error('path must be absolute')
49
+ const reg = loadRegistry()
50
+ if (reg.projects.find(p => p.name === name)) throw new Error('project name already exists')
51
+ if (!fs.existsSync(projectPath)) fs.mkdirSync(projectPath, { recursive: true })
52
+ reg.projects.push({ name, path: projectPath, created_at: new Date().toISOString() })
53
+ saveRegistry(reg)
54
+ return reg.projects[reg.projects.length - 1]
55
+ }
56
+
57
+ export function deleteProject(name) {
58
+ if (name === 'default') throw new Error('cannot delete default project')
59
+ const reg = loadRegistry()
60
+ reg.projects = reg.projects.filter(p => p.name !== name)
61
+ if (reg.active === name) reg.active = 'default'
62
+ saveRegistry(reg)
63
+ }
64
+
65
+ export function setActiveProject(name) {
66
+ const reg = loadRegistry()
67
+ const proj = reg.projects.find(p => p.name === name)
68
+ if (!proj) throw new Error('unknown project: ' + name)
69
+ reg.active = name
70
+ saveRegistry(reg)
71
+ applyHomeOverride(proj.path)
72
+ return proj
73
+ }
74
+
75
+ export function applyActiveProjectFromRegistry() {
76
+ const proj = getActiveProject()
77
+ if (proj) applyHomeOverride(proj.path)
78
+ return proj
79
+ }
package/src/web/app.js CHANGED
@@ -13,6 +13,7 @@ window.__debug.agents = () => ({ registered: true, active: AppState.agents?.acti
13
13
  const j = async (u, opts) => { try { const r = await fetch(u, opts); if (!r.ok) throw new Error(r.status + ' ' + r.statusText); return await r.json() } catch (e) { return { __error: String(e) } } }
14
14
 
15
15
  const ROUTES = [
16
+ { path: '#/projects', label: 'Projects', glyph: '◆' },
16
17
  { path: '#/home', label: 'Home', glyph: '⌂' },
17
18
  { path: '#/chat', label: 'Chat', glyph: '⌨' },
18
19
  { path: '#/sessions', label: 'Sessions', glyph: '✉' },
@@ -40,6 +41,7 @@ const AppState = {
40
41
  chat: { messages: [], draft: '', streaming: false },
41
42
  batch: { results: null, running: false },
42
43
  agents: { count: 0, active: null },
44
+ projects: { active: null, all: [] },
43
45
  }
44
46
  function applyTheme() { document.body.setAttribute('data-theme', AppState.theme) }
45
47
  applyTheme()
@@ -77,6 +79,51 @@ function toChatMsg(m, key) {
77
79
  }
78
80
 
79
81
  const PAGES = {
82
+ '#/projects': async () => {
83
+ const data = await j('/api/projects')
84
+ const all = data.projects || []
85
+ const active = data.active || null
86
+ AppState.projects = { active, all }
87
+ return [
88
+ Hero({ title: 'Projects', body: 'Each project is its own ~/.freddie home: separate sessions, agents, skills, config, env, cron, batches. Switch the active project to swap everything.', accent: active ? 'active · ' + active.name : 'no project active' }),
89
+ kpi([[all.length, 'Projects'], [active?.name || '—', 'Active'], [active?.path?.length > 30 ? '…'+active.path.slice(-28) : (active?.path || '—'), 'Path']]),
90
+ Panel({ title: 'Add a project', children: h('form', { class: 'row-form', onsubmit: async (ev) => {
91
+ ev.preventDefault()
92
+ const f = ev.target.elements
93
+ const r = await j('/api/projects', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: f.name.value, path: f.path.value }) })
94
+ if (r.__error || r.error) { alert('Error: ' + (r.error || r.__error)); return }
95
+ f.name.value = ''; f.path.value = ''; rerender()
96
+ } },
97
+ h('input', { name: 'name', placeholder: 'project name (e.g. penguins)', required: true }),
98
+ h('input', { name: 'path', placeholder: 'absolute path (e.g. C:\\dev\\penguins)', required: true, style: 'flex:2' }),
99
+ h('button', { type: 'submit', class: 'primary' }, 'Add')) }),
100
+ Panel({ title: 'All projects', count: all.length, right: active ? Chip({ tone: 'ok', children: 'active: ' + active.name }) : null,
101
+ children: h('div', {}, ...all.map(p => h('div', { class: 'row', style: 'cursor:pointer', onclick: async () => {
102
+ if (p.name === active?.name) return
103
+ const r = await j('/api/projects/active', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: p.name }) })
104
+ if (r.__error || r.error) { alert('Switch failed: ' + (r.error || r.__error)); return }
105
+ alert('Switched to project "' + p.name + '". Restart the dashboard to load its plugins/data, then reload this page.')
106
+ rerender()
107
+ } },
108
+ h('span', { class: 'code' }, p.name === active?.name ? '●' : '○'),
109
+ h('span', { class: 'title' }, p.name + (p.name === active?.name ? ' (active)' : '')),
110
+ h('span', { class: 'meta' }, p.path),
111
+ p.name !== 'default' ? h('button', { class: 'danger', style: 'margin-left:8px',
112
+ onclick: async (ev) => { ev.stopPropagation(); if (!confirm('Remove project "'+p.name+'" from registry? Files on disk are kept.')) return; await fetch('/api/projects/' + p.name, { method: 'DELETE' }); rerender() } }, 'remove') : null,
113
+ ))) }),
114
+ Panel({ title: 'How encapsulation works', children: Receipt({ rows: [
115
+ ['Sessions DB', '<project>/sessions.db'],
116
+ ['Config', '<project>/config.json'],
117
+ ['Skills', '<project>/skills/'],
118
+ ['Plugins', '<project>/plugins/ + repo/plugins'],
119
+ ['Cron jobs', '<project>/cron.db'],
120
+ ['Batches', '<project>/batches/'],
121
+ ['Logs', '<project>/logs/'],
122
+ ['Auth credentials', '<project>/auth.json'],
123
+ ]}) }),
124
+ ]
125
+ },
126
+
80
127
  '#/chat': async () => {
81
128
  const messages = AppState.chat.messages.map((m, i) => toChatMsg(m, 'm' + i))
82
129
  return AICat({
@@ -455,8 +502,15 @@ function render(state) {
455
502
  onkeydown: (ev) => { if (ev.key === 'Enter') doSearch(ev.target.value) },
456
503
  style: 'min-width:240px',
457
504
  })
505
+ const projectPill = h('a', {
506
+ href: '#/projects',
507
+ class: 'project-pill',
508
+ title: state.projects.active ? 'Active project: ' + state.projects.active.name + ' — ' + state.projects.active.path : 'No active project',
509
+ style: 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:var(--panel-2);color:var(--panel-text);text-decoration:none;font-size:12px;font-weight:500',
510
+ }, h('span', { style: 'opacity:0.6' }, '◆'), h('span', {}, state.projects.active?.name || 'default'))
458
511
  const topbarWithControls = h('header', { class: 'app-topbar' },
459
512
  Brand({ name: 'freddie', leaf: 'dashboard' }),
513
+ projectPill,
460
514
  h('div', { style: 'flex:1' }),
461
515
  searchInput,
462
516
  themeBtn,
@@ -480,11 +534,17 @@ function render(state) {
480
534
 
481
535
  let _mount
482
536
 
537
+ async function refreshProject() {
538
+ const data = await j('/api/projects')
539
+ if (!data.__error) AppState.projects = { active: data.active || null, all: data.projects || [] }
540
+ }
541
+
483
542
  async function go() {
484
- AppState.hash = location.hash || '#/home'
543
+ AppState.hash = location.hash || '#/projects'
485
544
  AppState.ts = new Date().toLocaleTimeString()
486
545
  AppState.body = EmptyState({ text: 'loading…', glyph: '◌' })
487
546
  if (_mount) _mount()
547
+ refreshProject()
488
548
  let body
489
549
  if (AppState.hash.startsWith('#/session/')) {
490
550
  body = await pageSessionDetail(AppState.hash.slice('#/session/'.length))