freddie 0.0.72 → 0.0.74

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
@@ -44,10 +44,26 @@ Witness 2026-05-03: test.js 12/12 green @ 195L (asserts `host.plugins().length>=
44
44
 
45
45
  **gm-cc plugin integration** (2026-05-04) — gm-cc npm package (v2.0.727) successfully integrated via `plugins/gm-cc/plugin.js`. Plugin auto-discovers 12 SKILL.md files from gm-cc package, extracts name/description from YAML frontmatter, registers via `pi.skills.register({name: 'gm:'+name, description, content, source:'gm-cc'})`. Skills: browser, code-search, create-lang-plugin, gm, gm-complete, gm-emit, gm-execute, governance, pages, planning, ssh, update-docs. All accessible via `gm:*` namespace in pi.skills registry.
46
46
 
47
+ ## Multi-project workspace system (2026-05-04)
48
+
49
+ Freddie supports multiple isolated projects, each with its own home directory and plugin set. Registry at `~/.freddie/projects.json` stores `{ active, projects: [{name, path, created_at}] }`. Default project (`~/.freddie`) is protected from deletion.
50
+
51
+ Code:
52
+ - `src/projects.js` — CRUD: `loadRegistry()`, `listProjects()`, `getActiveProject()`, `createProject({name, projectPath})`, `deleteProject(name)`, `setActiveProject(name)`, `applyActiveProjectFromRegistry()`.
53
+ - `src/home.js` — added `applyHomeOverride(absPath)` to set `FREDDIE_HOME` env and clear cached home.
54
+ - `src/host/index.js` — `bootHost()` calls `applyActiveProjectFromRegistry()` before plugin discovery, so plugins resolve against active project root.
55
+ - `plugins/gui-projects/plugin.js` — GUI plugin exposing `GET /api/projects`, `POST /api/projects` (create), `DELETE /api/projects/:name`, `POST /api/projects/active` (switch).
56
+ - `src/web/app.js` — `#/projects` route, project pill in topbar, full CRUD UI.
57
+
58
+ Isolation boundary: Each project gets its own sessions DB, config.json, skills/, plugins/, cron.db, batches/, logs/, auth.json (all under `getFreddieHome()`). Plugins re-read paths per-request via `getFreddieHome()`.
59
+
60
+ **Runtime switch caveat** — Switching active project calls `resetHostForTests()` and clears caches but does NOT re-discover plugins in the running dashboard. UI alerts user to restart dashboard for plugin reload. New process auto-picks up active project via `applyActiveProjectFromRegistry()` on `bootHost()`. Gap: if user switches project then uses a plugin-registered tool before restarting, the OLD project's tool set loads. `/api/health` returns `{ freddie_home: "<active-project-path>" }` after project switch. Needs improvement: in-process plugin re-discovery on project switch.
61
+
47
62
  ## Layout
48
63
 
49
64
  ```
50
- src/home.js # getFreddieHome, applyProfileOverride
65
+ src/home.js # getFreddieHome, applyProfileOverride, applyHomeOverride
66
+ src/projects.js # Multi-project registry CRUD (loadRegistry, createProject, deleteProject, setActiveProject)
51
67
  src/config.js # loadConfig, saveConfigValue, DEFAULT_CONFIG, _config_version migrations
52
68
  src/sessions.js # better-sqlite3 + FTS5
53
69
  src/auth.js # FileAuthStore for credentials
@@ -176,6 +192,7 @@ One `test.js` at project root. ≤200 lines. Plain assertions, real data, real s
176
192
  | Toolsets | `src/toolsets.js` |
177
193
  | Session store | `src/sessions.js` (better-sqlite3 + FTS5) |
178
194
  | Home + profiles | `src/home.js` |
195
+ | Multi-project registry | `src/projects.js` (isolated FREDDIE_HOME per project) |
179
196
  | Structured logging | `src/observability/log.js` |
180
197
  | Config | `src/config.js` |
181
198
  | Commands | `src/commands/registry.js` |
@@ -234,6 +251,8 @@ All 12 test.js named groups passing: home+config+skin, sessions+FTS5, tools+tool
234
251
 
235
252
  ## Learning audit
236
253
 
254
+ - 2026-05-04 (session 1): Multi-project workspace system documented. Registry, CRUD ops, isolation boundaries, GUI plugin, and runtime plugin switch caveat added. 4 facts ingested to rs-learn: project/freddie-multi-project-registry, reference/freddie-projects-module, reference/freddie-gui-projects-endpoints, feedback/freddie-project-switch-reload-limitation. Reach check passed (in-reach). AGENTS.md updated with new subsystem section + Layout entries + subsystem guide row.
255
+ - 2026-05-04 (session 2): Audit cycle — 5 queries fired (pi-ai env keys, profile safe paths, libsql async debt, browser syntax errors, plugin architecture contract). rs-learn store still returning "No recall results" or off-topic trajectory entries. All 5 recalls failed. 0 items migrated; all AGENTS.md facts retained (safe default). Ingest path confirmed live (4 facts accepted), but retrieval side empty. Likely requires backend indexing rebuild or cross-session propagation.
237
256
  - 2026-05-01: 5 items queried (pi-ai keys, profile paths, cache safety, floosie composition, browser errors); rs-learn store unavailable (exec:recall returned no results). 0 items migrated. New facts (anentrypoint-design build, dashboard live-rerender caveat, libuv spawn caveat) ingested directly into rs-learn; audit will retry in future sessions.
238
257
  - 2026-05-01 (session 2): 5 items queried (pi-ai env keys, profile safe paths, cache safety, floosie composition, browser syntax errors). rs-learn store still empty. 0 items migrated. Refined anentrypoint-design source/dist skew entry in AGENTS.md to include silent-failure pageerror diagnostic. New fact `reference/anentrypoint-design-dist-rebuild` ingested.
239
258
  - 2026-05-03: Pre-rename validation snapshot recorded (all 12 test.js groups, CLI, tools, 284 files, version drift). Baseline stored to isolate post-rename regressions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freddie",
3
- "version": "0.0.72",
3
+ "version": "0.0.74",
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.53"
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))