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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freddie",
3
- "version": "0.0.125",
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",
@@ -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
- if (!fs.existsSync(p)) return { error: `not found: ${p}` }
21
- const src = fs.readFileSync(p, 'utf8')
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(p, out, 'utf8')
27
- return { path: p, replacements: replace_all ? occurrences : 1 }
28
+ fs.writeFileSync(resolved, out, 'utf8')
29
+ return { path: resolved, replacements: replace_all ? occurrences : 1 }
28
30
  },
29
31
  })
@@ -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
- this.emit('message', { from: m.author?.id, text: m.content || '', raw: m, platform: 'discord' })
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
- this.emit('message', {
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 })
@@ -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
- if (!fs.existsSync(p)) return { error: `not found: ${p}` }
20
- const lines = fs.readFileSync(p, 'utf8').split('\n')
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: p, total: lines.length, content: slice.map((l, i) => `${(offset + i + 1).toString().padStart(6)}\t${l}`).join('\n') }
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
  })
@@ -15,9 +15,10 @@ export const _tool = ({
15
15
  required: ['path', 'content'],
16
16
  },
17
17
  },
18
- handler: async ({ path: p, content }) => {
19
- fs.mkdirSync(path.dirname(p), { recursive: true })
20
- fs.writeFileSync(p, content, 'utf8')
21
- return { path: p, bytes: Buffer.byteLength(content, 'utf8') }
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
- const opts = { model: m, messages: toMsgs(input.messages), tools: toTools(input.tools), onFallback: input.onFallback, output: 'openai', fallbackOn: ['error', 'rate_limit', 'timeout', 'empty'] }
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
 
@@ -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
- if (hits.length) sysParts.push('Relevant memories (gm rs-learn):\n' + hits.map(h => '- ' + h.text).join('\n'))
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
- const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events, sessionKey: key, toolCtx, tool_choice })
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: 'better-sqlite3', run: () => { try { require.resolve('better-sqlite3'); return { ok: true } } catch { return { ok: false, fix: 'npm install' } } } },
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