freddie 0.0.126 → 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.126",
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) }) }
@@ -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