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 +20 -1
- package/package.json +2 -2
- package/plugins/gui-projects/plugin.js +29 -0
- package/src/home.js +7 -0
- package/src/host/index.js +2 -0
- package/src/projects.js +79 -0
- package/src/web/app.js +61 -1
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.
|
|
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.
|
|
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)
|
package/src/projects.js
ADDED
|
@@ -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 || '#/
|
|
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))
|