freddie 0.0.125 → 0.0.127
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 +2 -1
- package/plugins/edit/handler.js +7 -5
- package/plugins/grep/handler.js +2 -1
- package/plugins/gui-chat/plugin.js +28 -3
- package/plugins/platform-discord/handler.js +47 -1
- package/plugins/platform-whatsapp/handler.js +41 -3
- package/plugins/read/handler.js +6 -4
- package/plugins/write/handler.js +5 -4
- package/src/agent/llm_resolver.js +6 -1
- package/src/agent/machine.js +14 -2
- package/src/cli/doctor.js +3 -1
- package/src/host/index.js +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freddie",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.127",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Open JS agent harness built on pi-mono, floosie, xstate, and anentrypoint-design",
|
|
6
6
|
"bin": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"acptoapi": "^1.0.144",
|
|
29
29
|
"anentrypoint-design": "latest",
|
|
30
30
|
"commander": "^14.0.0",
|
|
31
|
+
"dotenv": "^16.6.1",
|
|
31
32
|
"express": "^5.0.0",
|
|
32
33
|
"flatspace": "^1.0.18",
|
|
33
34
|
"floosie": "^0.6.14",
|
package/plugins/edit/handler.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
2
3
|
export const _tool = ({
|
|
3
4
|
name: 'edit',
|
|
4
5
|
toolset: 'core',
|
|
@@ -16,14 +17,15 @@ export const _tool = ({
|
|
|
16
17
|
required: ['path', 'old_string', 'new_string'],
|
|
17
18
|
},
|
|
18
19
|
},
|
|
19
|
-
handler: async ({ path: p, old_string, new_string, replace_all = false }) => {
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
handler: async ({ path: p, old_string, new_string, replace_all = false }, ctx = {}) => {
|
|
21
|
+
const resolved = ctx.cwd && !path.isAbsolute(p) ? path.join(ctx.cwd, p) : p
|
|
22
|
+
if (!fs.existsSync(resolved)) return { error: `not found: ${resolved}` }
|
|
23
|
+
const src = fs.readFileSync(resolved, 'utf8')
|
|
22
24
|
const occurrences = src.split(old_string).length - 1
|
|
23
25
|
if (occurrences === 0) return { error: 'old_string not found' }
|
|
24
26
|
if (occurrences > 1 && !replace_all) return { error: `old_string matches ${occurrences} times; pass replace_all=true` }
|
|
25
27
|
const out = replace_all ? src.split(old_string).join(new_string) : src.replace(old_string, new_string)
|
|
26
|
-
fs.writeFileSync(
|
|
27
|
-
return { path:
|
|
28
|
+
fs.writeFileSync(resolved, out, 'utf8')
|
|
29
|
+
return { path: resolved, replacements: replace_all ? occurrences : 1 }
|
|
28
30
|
},
|
|
29
31
|
})
|
package/plugins/grep/handler.js
CHANGED
|
@@ -18,7 +18,8 @@ export const _tool = ({
|
|
|
18
18
|
required: ['pattern'],
|
|
19
19
|
},
|
|
20
20
|
},
|
|
21
|
-
handler: async ({ pattern, path: root = '.', head_limit = 200, ignore_case = false, glob }) => {
|
|
21
|
+
handler: async ({ pattern, path: root = '.', head_limit = 200, ignore_case = false, glob }, ctx = {}) => {
|
|
22
|
+
if (ctx.cwd && !path.isAbsolute(root)) root = path.join(ctx.cwd, root)
|
|
22
23
|
const re = new RegExp(pattern, ignore_case ? 'i' : '')
|
|
23
24
|
const out = []
|
|
24
25
|
const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', '.cache'])
|
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import { runTurn } from '../../src/agent/machine.js'
|
|
2
|
-
import { createSession } from '../../src/sessions.js'
|
|
2
|
+
import { createSession, getMessages, appendMessage } from '../../src/sessions.js'
|
|
3
|
+
|
|
4
|
+
// A client-supplied sessionId continues that conversation: prior turns'
|
|
5
|
+
// messages are reloaded and fed back into runTurn (which otherwise starts a
|
|
6
|
+
// brand-new xstate machine with no memory of anything), and the new turn's
|
|
7
|
+
// messages are persisted back so the NEXT call in this session sees them too.
|
|
8
|
+
// Without this, sessionId was purely a display label -- runTurn never saw it
|
|
9
|
+
// and every call was independently stateless regardless of continuity intent.
|
|
10
|
+
async function loadPriorMessages(sessionId) {
|
|
11
|
+
if (!sessionId) return []
|
|
12
|
+
try {
|
|
13
|
+
const rows = await getMessages(sessionId)
|
|
14
|
+
return rows.map(r => ({ role: r.role, content: r.content, ...(r.tool_calls ? { tool_calls: r.tool_calls } : {}), ...(r.tool_call_id ? { tool_call_id: r.tool_call_id } : {}) }))
|
|
15
|
+
} catch (_) { return [] }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function persistNewMessages(sessionId, allMessages, priorCount) {
|
|
19
|
+
if (!sessionId) return
|
|
20
|
+
for (const m of allMessages.slice(priorCount)) {
|
|
21
|
+
try { await appendMessage(sessionId, { role: m.role, content: m.content, toolCalls: m.tool_calls || null, toolCallId: m.tool_call_id || null }) } catch (_) {}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
3
25
|
export default {
|
|
4
26
|
name: 'gui-chat', surfaces: 'gui',
|
|
5
27
|
register({ gui }) {
|
|
@@ -18,11 +40,13 @@ export default {
|
|
|
18
40
|
sessionId = await createSession({ platform: 'web', title: prompt.slice(0, 80), cwd: cwd || null, skill: skill || null, model: model || null })
|
|
19
41
|
} catch (_) { sessionId = null }
|
|
20
42
|
}
|
|
43
|
+
const priorMessages = await loadPriorMessages(sessionId)
|
|
21
44
|
|
|
22
45
|
if (!wantsSse) {
|
|
23
46
|
try {
|
|
24
|
-
const out = await runTurn({ prompt, timeoutMs: 120000, cwd, skill, provider, model })
|
|
47
|
+
const out = await runTurn({ prompt, messages: priorMessages, sessionKey: sessionId || undefined, timeoutMs: 120000, cwd, skill, provider, model })
|
|
25
48
|
if (out.error) return res.status(500).json({ error: out.error, sessionId })
|
|
49
|
+
await persistNewMessages(sessionId, out.messages || [], priorMessages.length)
|
|
26
50
|
return res.json({ result: out.result || '', messages: out.messages || [], iterations: out.iterations, sessionId })
|
|
27
51
|
} catch (e) {
|
|
28
52
|
return res.status(500).json({ error: String(e.message || e), sessionId })
|
|
@@ -35,8 +59,9 @@ export default {
|
|
|
35
59
|
const send = (event, data) => res.write('event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n')
|
|
36
60
|
send('start', { ts: Date.now(), sessionId })
|
|
37
61
|
try {
|
|
38
|
-
const out = await runTurn({ prompt, timeoutMs: 120000, cwd, skill, provider, model })
|
|
62
|
+
const out = await runTurn({ prompt, messages: priorMessages, sessionKey: sessionId || undefined, timeoutMs: 120000, cwd, skill, provider, model })
|
|
39
63
|
if (out.error) { send('error', { error: out.error }); res.end(); return }
|
|
64
|
+
await persistNewMessages(sessionId, out.messages || [], priorMessages.length)
|
|
40
65
|
for (const m of out.messages) send('message', m)
|
|
41
66
|
send('done', { result: out.result || '', iterations: out.iterations, sessionId })
|
|
42
67
|
} catch (e) { send('error', { error: String(e.message || e) }) }
|
|
@@ -5,6 +5,17 @@ import WebSocket from 'ws'
|
|
|
5
5
|
const OP = { DISPATCH: 0, HEARTBEAT: 1, IDENTIFY: 2, RESUME: 6, RECONNECT: 7, INVALID_SESSION: 9, HELLO: 10, HEARTBEAT_ACK: 11 }
|
|
6
6
|
// GUILD_MESSAGES(1<<9) + DIRECT_MESSAGES(1<<12) + MESSAGE_CONTENT(1<<15)
|
|
7
7
|
const DEFAULT_INTENTS = (1 << 9) | (1 << 12) | (1 << 15)
|
|
8
|
+
// Discord CDN attachment URLs are directly fetchable with no auth, but with no
|
|
9
|
+
// auth also comes no trust in the advertised size -- guard against buffering
|
|
10
|
+
// something unreasonably large into memory.
|
|
11
|
+
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
|
12
|
+
const ATTACHMENT_FETCH_TIMEOUT_MS = 10000
|
|
13
|
+
|
|
14
|
+
function contentTypeCategory(contentType) {
|
|
15
|
+
const t = (contentType || '').split('/')[0]
|
|
16
|
+
if (t === 'image' || t === 'audio' || t === 'video') return t
|
|
17
|
+
return 'other'
|
|
18
|
+
}
|
|
8
19
|
|
|
9
20
|
export class DiscordAdapter extends EventEmitter {
|
|
10
21
|
constructor(opts = {}) {
|
|
@@ -66,7 +77,42 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
66
77
|
if (p.t === 'MESSAGE_CREATE') {
|
|
67
78
|
const m = p.d
|
|
68
79
|
if (m.author?.bot) return // ignore bots and our own messages
|
|
69
|
-
|
|
80
|
+
const base = { from: m.author?.id, text: m.content || '', raw: m, platform: 'discord' }
|
|
81
|
+
if (!m.attachments?.length) { this.emit('message', base); return }
|
|
82
|
+
// ws.on('message', ...) below is a sync callback and can't await this,
|
|
83
|
+
// so the fetch-and-emit path runs as a detached async task: the
|
|
84
|
+
// message still emits exactly once, after attachment fetches settle,
|
|
85
|
+
// and one attachment's failure (via allSettled) never blocks the
|
|
86
|
+
// others or drops the message itself.
|
|
87
|
+
this._resolveAttachments(m.attachments).then(media => {
|
|
88
|
+
this.emit('message', { ...base, media })
|
|
89
|
+
})
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async _resolveAttachments(attachments) {
|
|
95
|
+
const results = await Promise.allSettled(attachments.map(a => this._fetchAttachment(a)))
|
|
96
|
+
return results.filter(r => r.status === 'fulfilled' && r.value).map(r => r.value)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async _fetchAttachment(a) {
|
|
100
|
+
if (a.size > MAX_ATTACHMENT_BYTES) {
|
|
101
|
+
console.error(`DiscordAdapter: skipping attachment ${a.filename} (${a.size} bytes exceeds ${MAX_ATTACHMENT_BYTES} byte guard)`)
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
const ac = new AbortController()
|
|
105
|
+
const timer = setTimeout(() => ac.abort(), ATTACHMENT_FETCH_TIMEOUT_MS)
|
|
106
|
+
try {
|
|
107
|
+
const res = await fetch(a.url, { signal: ac.signal })
|
|
108
|
+
if (!res.ok) throw new Error(`attachment fetch failed with status ${res.status}`)
|
|
109
|
+
const buffer = Buffer.from(await res.arrayBuffer())
|
|
110
|
+
return { type: contentTypeCategory(a.content_type), mimeType: a.content_type || '', buffer, filename: a.filename }
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error(`DiscordAdapter: attachment fetch failed for ${a.filename}`, err)
|
|
113
|
+
return null
|
|
114
|
+
} finally {
|
|
115
|
+
clearTimeout(timer)
|
|
70
116
|
}
|
|
71
117
|
}
|
|
72
118
|
|
|
@@ -28,6 +28,29 @@ export class WhatsappAdapter extends EventEmitter {
|
|
|
28
28
|
try { return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) } catch { return false }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// WhatsApp Cloud API media download is a two-step handshake: the webhook
|
|
32
|
+
// payload only ever carries a media id, never a fetchable URL directly.
|
|
33
|
+
// Step 1 resolves that id to a short-lived signed URL (also bearer-authed);
|
|
34
|
+
// step 2 fetches the actual bytes from that URL, still with the same
|
|
35
|
+
// bearer token (Meta requires it on both hops). Each hop is bounded by an
|
|
36
|
+
// AbortController timeout so a slow/hung Meta response can never wedge the
|
|
37
|
+
// webhook handler indefinitely.
|
|
38
|
+
async _downloadMedia(mediaId, timeoutMs = 10000) {
|
|
39
|
+
const authHeader = { authorization: `Bearer ${this.token}` }
|
|
40
|
+
const withTimeout = async (url) => {
|
|
41
|
+
const ac = new AbortController()
|
|
42
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs)
|
|
43
|
+
try { return await fetch(url, { headers: authHeader, signal: ac.signal }) }
|
|
44
|
+
finally { clearTimeout(timer) }
|
|
45
|
+
}
|
|
46
|
+
const meta = await withTimeout(`${this.api}/${mediaId}`).then(r => r.json())
|
|
47
|
+
if (!meta?.url) throw new Error('WhatsappAdapter: media lookup returned no url: ' + JSON.stringify(meta))
|
|
48
|
+
const res = await withTimeout(meta.url)
|
|
49
|
+
if (!res.ok) throw new Error(`WhatsappAdapter: media fetch failed with status ${res.status}`)
|
|
50
|
+
const buffer = Buffer.from(await res.arrayBuffer())
|
|
51
|
+
return { buffer, mimeType: meta.mime_type || res.headers.get('content-type') || '' }
|
|
52
|
+
}
|
|
53
|
+
|
|
31
54
|
async start() {
|
|
32
55
|
if (!this.token || !this.phoneId) throw new Error('WhatsappAdapter: WHATSAPP_API_TOKEN + WHATSAPP_PHONE_NUMBER_ID required')
|
|
33
56
|
const app = express()
|
|
@@ -38,19 +61,34 @@ export class WhatsappAdapter extends EventEmitter {
|
|
|
38
61
|
if (req.query['hub.verify_token'] === this.verifyToken) return res.send(req.query['hub.challenge'])
|
|
39
62
|
res.sendStatus(403)
|
|
40
63
|
})
|
|
41
|
-
app.post(this.path, (req, res) => {
|
|
64
|
+
app.post(this.path, async (req, res) => {
|
|
42
65
|
if (!this._verifySignature(req)) return res.sendStatus(401)
|
|
43
66
|
const entries = req.body?.entry || []
|
|
44
67
|
for (const e of entries) for (const c of (e.changes || [])) {
|
|
45
68
|
const msgs = c.value?.messages || []
|
|
46
69
|
for (const m of msgs) {
|
|
47
|
-
|
|
70
|
+
const event = {
|
|
48
71
|
from: m.from,
|
|
49
72
|
text: m.text?.body || '',
|
|
50
73
|
// surface the platform message id for dedup, and the message type
|
|
51
74
|
// so media-only messages are recognisable upstream.
|
|
52
75
|
raw: { ...m, id: m.id, type: m.type },
|
|
53
|
-
}
|
|
76
|
+
}
|
|
77
|
+
const mediaObj = m.image || m.audio || m.document || m.video
|
|
78
|
+
if (mediaObj?.id) {
|
|
79
|
+
const type = m.image ? 'image' : m.audio ? 'audio' : m.document ? 'document' : 'video'
|
|
80
|
+
try {
|
|
81
|
+
const { buffer, mimeType } = await this._downloadMedia(mediaObj.id)
|
|
82
|
+
event.media = { type, mimeType, buffer }
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// Never let a failed/slow media fetch block the rest of the
|
|
85
|
+
// webhook batch or the ack below -- note media as
|
|
86
|
+
// present-but-unfetched so the pipeline still proceeds.
|
|
87
|
+
console.error('WhatsappAdapter: media download failed', err)
|
|
88
|
+
event.media = { type, mimeType: mediaObj.mime_type || '', buffer: null, error: String(err?.message || err) }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
this.emit('message', event)
|
|
54
92
|
}
|
|
55
93
|
}
|
|
56
94
|
res.json({ ok: true })
|
package/plugins/read/handler.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
2
3
|
export const _tool = ({
|
|
3
4
|
name: 'read',
|
|
4
5
|
toolset: 'core',
|
|
@@ -15,10 +16,11 @@ export const _tool = ({
|
|
|
15
16
|
required: ['path'],
|
|
16
17
|
},
|
|
17
18
|
},
|
|
18
|
-
handler: async ({ path: p, offset = 0, limit = 2000 }) => {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
handler: async ({ path: p, offset = 0, limit = 2000 }, ctx = {}) => {
|
|
20
|
+
const resolved = ctx.cwd && !path.isAbsolute(p) ? path.join(ctx.cwd, p) : p
|
|
21
|
+
if (!fs.existsSync(resolved)) return { error: `not found: ${resolved}` }
|
|
22
|
+
const lines = fs.readFileSync(resolved, 'utf8').split('\n')
|
|
21
23
|
const slice = lines.slice(offset, offset + limit)
|
|
22
|
-
return { path:
|
|
24
|
+
return { path: resolved, total: lines.length, content: slice.map((l, i) => `${(offset + i + 1).toString().padStart(6)}\t${l}`).join('\n') }
|
|
23
25
|
},
|
|
24
26
|
})
|
package/plugins/write/handler.js
CHANGED
|
@@ -15,9 +15,10 @@ export const _tool = ({
|
|
|
15
15
|
required: ['path', 'content'],
|
|
16
16
|
},
|
|
17
17
|
},
|
|
18
|
-
handler: async ({ path: p, content }) => {
|
|
19
|
-
|
|
20
|
-
fs.
|
|
21
|
-
|
|
18
|
+
handler: async ({ path: p, content }, ctx = {}) => {
|
|
19
|
+
const resolved = ctx.cwd && !path.isAbsolute(p) ? path.join(ctx.cwd, p) : p
|
|
20
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true })
|
|
21
|
+
fs.writeFileSync(resolved, content, 'utf8')
|
|
22
|
+
return { path: resolved, bytes: Buffer.byteLength(content, 'utf8') }
|
|
22
23
|
},
|
|
23
24
|
})
|
|
@@ -116,7 +116,12 @@ export function resolveCallLLM({ provider, model } = {}) {
|
|
|
116
116
|
// Without this, comma-separated model lists default to ['error'] and
|
|
117
117
|
// rate-limited / empty / timed-out responses don't trigger chain
|
|
118
118
|
// fallback — the request just throws.
|
|
119
|
-
|
|
119
|
+
// max_tokens: acptoapi passes an unset value through to some providers
|
|
120
|
+
// as a very high implicit default (witnessed: 65536), which a
|
|
121
|
+
// low-credit free-tier account (e.g. openrouter) rejects outright with
|
|
122
|
+
// a 402 before even trying the request at a smaller size. 4096 matches
|
|
123
|
+
// acptoapi-bridge.js's own in-process default (see callLLM above).
|
|
124
|
+
const opts = { model: m, messages: toMsgs(input.messages), tools: toTools(input.tools), max_tokens: input.max_tokens || 4096, onFallback: input.onFallback, output: 'openai', fallbackOn: ['error', 'rate_limit', 'timeout', 'empty'] }
|
|
120
125
|
if (/^queue\//.test(m)) opts.queuesMap = getConfigValue('agent.model_queues', {}) || {}
|
|
121
126
|
if (m.includes(',') || /^queue\//.test(m)) opts.matrixSource = process.env.FREDDIE_MATRIX_URL || MATRIX_FILE
|
|
122
127
|
|
package/src/agent/machine.js
CHANGED
|
@@ -263,7 +263,12 @@ export async function runTurn({ prompt, messages = [], model, provider, callLLM,
|
|
|
263
263
|
try {
|
|
264
264
|
const { autoRecall, projectNamespace } = await import('../learn/gm-learn.js')
|
|
265
265
|
const hits = await autoRecall(prompt, { limit: 5, namespace: await projectNamespace() })
|
|
266
|
-
|
|
266
|
+
// Weak models were witnessed answering FROM this block instead of the new
|
|
267
|
+
// user message below it (asked to remember a number, answered a prior
|
|
268
|
+
// turn's unrelated question instead) -- the plain "Relevant memories:"
|
|
269
|
+
// label gave no signal that this is background reference material, not
|
|
270
|
+
// the current instruction. Explicit priority framing fixes it.
|
|
271
|
+
if (hits.length) sysParts.push('Background context from past conversations (gm rs-learn) -- for reference only, does not describe the current task:\n' + hits.map(h => '- ' + h.text).join('\n') + '\n\nThe user\'s actual request for THIS turn follows below and takes priority over the above.')
|
|
267
272
|
} catch (_) {}
|
|
268
273
|
if (sysParts.length) initMessages.unshift({ role: 'user', content: sysParts.join('\n\n') })
|
|
269
274
|
const inbound = await h.hooks.invoke('onMessageInbound', { content: prompt })
|
|
@@ -272,7 +277,14 @@ export async function runTurn({ prompt, messages = [], model, provider, callLLM,
|
|
|
272
277
|
// Persist the turn snapshot under kind=agent so an interrupted turn (process
|
|
273
278
|
// refresh mid-tool-call) resumes exactly where it stopped via resumeTurn.
|
|
274
279
|
const key = sessionKey || randomUUID()
|
|
275
|
-
|
|
280
|
+
// cwd must reach file-path tool handlers (write/read/edit) via toolCtx, not
|
|
281
|
+
// just the system-prompt text above -- those handlers resolve relative paths
|
|
282
|
+
// with bare fs calls against process.cwd(), so without this every relative
|
|
283
|
+
// path silently lands in the freddie server's own cwd instead of the
|
|
284
|
+
// caller's intended project directory (only `bash` was safe, since it takes
|
|
285
|
+
// cwd as an explicit tool argument the model was told to pass).
|
|
286
|
+
const mergedToolCtx = cwd ? { cwd, ...(toolCtx || {}) } : toolCtx
|
|
287
|
+
const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events, sessionKey: key, toolCtx: mergedToolCtx, tool_choice })
|
|
276
288
|
const pa = await createPersistentActor(machine, { kind: 'agent', key, input: { messages: initMessages } })
|
|
277
289
|
pa.actor.send({ type: 'SUBMIT', prompt })
|
|
278
290
|
return await driveAgentActor({ pa, h, events, prompt, provider, model, skill, cwd, witnessPath, timeoutMs, sessionKey: key })
|
package/src/cli/doctor.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
4
5
|
import { getFreddieHome } from '../home.js'
|
|
6
|
+
const _require = createRequire(import.meta.url)
|
|
5
7
|
const CHECKS = [
|
|
6
8
|
{ name: 'freddie-home', run: () => fs.existsSync(getFreddieHome()) ? { ok: true } : { ok: false, fix: 'mkdir -p ' + getFreddieHome() } },
|
|
7
9
|
{ name: 'node-version', run: () => { const v = process.versions.node; const major = Number(v.split('.')[0]); return major >= 20 ? { ok: true, value: v } : { ok: false, fix: 'install node >=20', value: v } } },
|
|
8
|
-
{ name: '
|
|
10
|
+
{ name: '@libsql/client', run: () => { try { _require.resolve('@libsql/client'); return { ok: true } } catch { return { ok: false, fix: 'npm install' } } } },
|
|
9
11
|
{ name: 'gh-cli', run: () => { const r = spawnSync('gh', ['--version'], { encoding: 'utf8' }); return r.status === 0 ? { ok: true, value: r.stdout.split('\n')[0] } : { ok: false, fix: 'install gh CLI' } } },
|
|
10
12
|
{ name: 'git', run: () => { const r = spawnSync('git', ['--version'], { encoding: 'utf8' }); return r.status === 0 ? { ok: true, value: r.stdout.trim() } : { ok: false, fix: 'install git' } } },
|
|
11
13
|
{ name: 'config-file', run: () => { const p = path.join(getFreddieHome(), 'config.yaml'); return fs.existsSync(p) ? { ok: true } : { ok: false, fix: 'freddie setup' } } },
|
package/src/host/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import dotenv from 'dotenv'
|
|
3
4
|
import { createHost, discoverPlugins } from './host.js'
|
|
4
5
|
import { getFreddieHome } from '../home.js'
|
|
5
6
|
import { applyActiveProjectFromRegistry } from '../projects.js'
|
|
@@ -10,6 +11,13 @@ let _loadPromise = null
|
|
|
10
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
11
12
|
const REPO_PLUGINS = path.resolve(__dirname, '..', '..', 'plugins')
|
|
12
13
|
|
|
14
|
+
// Every process entrypoint (bin/freddie.js, src/web/server.js, src/acp/server.js,
|
|
15
|
+
// src/gateway/run.js) calls bootHost() before touching a provider key, so this
|
|
16
|
+
// is the one place a `.env` in the invoking cwd reaches process.env for every
|
|
17
|
+
// downstream reader (acptoapi's own process.env.GROQ_API_KEY etc reads, pi-ai's
|
|
18
|
+
// findEnvKeys/getEnvApiKey). Silent no-op when no .env file exists.
|
|
19
|
+
dotenv.config()
|
|
20
|
+
|
|
13
21
|
export function host() {
|
|
14
22
|
if (!_host) _host = createHost({ surfaces: ['pi', 'gui'] })
|
|
15
23
|
return _host
|