freddie 0.0.121 → 0.0.122

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.
@@ -1,4 +1,10 @@
1
1
  import { EventEmitter } from 'node:events'
2
+ import WebSocket from 'ws'
3
+
4
+ // Discord gateway opcodes
5
+ const OP = { DISPATCH: 0, HEARTBEAT: 1, IDENTIFY: 2, RESUME: 6, RECONNECT: 7, INVALID_SESSION: 9, HELLO: 10, HEARTBEAT_ACK: 11 }
6
+ // GUILD_MESSAGES(1<<9) + DIRECT_MESSAGES(1<<12) + MESSAGE_CONTENT(1<<15)
7
+ const DEFAULT_INTENTS = (1 << 9) | (1 << 12) | (1 << 15)
2
8
 
3
9
  export class DiscordAdapter extends EventEmitter {
4
10
  constructor(opts = {}) {
@@ -6,16 +12,92 @@ export class DiscordAdapter extends EventEmitter {
6
12
  this.platform = 'discord'
7
13
  this.token = opts.token || process.env.DISCORD_BOT_TOKEN
8
14
  this.api = opts.api || 'https://discord.com/api/v10'
15
+ this.intents = opts.intents ?? DEFAULT_INTENTS
16
+ this.receive = opts.receive !== false // open the gateway WS to receive messages
9
17
  this._ws = null
18
+ this._heartbeat = null
19
+ this._seq = null
20
+ this._sessionId = null
21
+ this._resumeUrl = null
22
+ this._acked = true
23
+ this._closed = false
10
24
  }
11
25
  getRequiredEnv() { return ['DISCORD_BOT_TOKEN'] }
26
+
12
27
  async start() {
13
28
  if (!this.token) throw new Error('DiscordAdapter: DISCORD_BOT_TOKEN required')
14
29
  const gw = await fetch(`${this.api}/gateway/bot`, { headers: { authorization: `Bot ${this.token}` } }).then(r => r.json())
15
30
  if (!gw.url) throw new Error('DiscordAdapter: gateway lookup failed: ' + JSON.stringify(gw))
16
31
  this.gatewayUrl = gw.url + '/?v=10&encoding=json'
32
+ // Open the gateway WebSocket so inbound messages are emitted as 'message'
33
+ // events { from, text, raw, platform }. Without this the adapter can send
34
+ // but never receives.
35
+ if (this.receive) { this._closed = false; this._connect() }
36
+ }
37
+
38
+ _connect(resume = false) {
39
+ const url = resume && this._resumeUrl ? this._resumeUrl + '/?v=10&encoding=json' : this.gatewayUrl
40
+ const ws = this._ws = new WebSocket(url)
41
+ ws.on('message', (raw) => {
42
+ let p; try { p = JSON.parse(raw.toString()) } catch { return }
43
+ if (p.s != null) this._seq = p.s
44
+ switch (p.op) {
45
+ case OP.HELLO:
46
+ this._startHeartbeat(p.d.heartbeat_interval)
47
+ if (resume && this._sessionId) this._send({ op: OP.RESUME, d: { token: this.token, session_id: this._sessionId, seq: this._seq } })
48
+ else this._identify()
49
+ break
50
+ case OP.HEARTBEAT: this._send({ op: OP.HEARTBEAT, d: this._seq }); break
51
+ case OP.HEARTBEAT_ACK: this._acked = true; break
52
+ case OP.RECONNECT: this._reconnect(true); break
53
+ case OP.INVALID_SESSION: this._sessionId = null; setTimeout(() => this._reconnect(false), 1500); break
54
+ case OP.DISPATCH: this._dispatch(p); break
55
+ }
56
+ })
57
+ ws.on('close', () => {
58
+ clearInterval(this._heartbeat)
59
+ if (!this._closed) setTimeout(() => this._reconnect(true), 2500)
60
+ })
61
+ ws.on('error', () => { /* close handler drives reconnect */ })
62
+ }
63
+
64
+ _dispatch(p) {
65
+ if (p.t === 'READY') { this._sessionId = p.d?.session_id; this._resumeUrl = p.d?.resume_gateway_url; return }
66
+ if (p.t === 'MESSAGE_CREATE') {
67
+ const m = p.d
68
+ 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' })
70
+ }
71
+ }
72
+
73
+ _identify() {
74
+ this._send({ op: OP.IDENTIFY, d: { token: this.token, intents: this.intents, properties: { os: 'linux', browser: 'freddie', device: 'freddie' } } })
17
75
  }
18
- async stop() { try { this._ws?.close?.() } catch {} }
76
+
77
+ _startHeartbeat(interval) {
78
+ clearInterval(this._heartbeat)
79
+ this._acked = true
80
+ this._heartbeat = setInterval(() => {
81
+ if (!this._acked) { try { this._ws.terminate() } catch {} return } // zombie connection
82
+ this._acked = false
83
+ this._send({ op: OP.HEARTBEAT, d: this._seq })
84
+ }, interval)
85
+ }
86
+
87
+ _reconnect(resume) {
88
+ if (this._closed) return
89
+ try { this._ws?.removeAllListeners?.(); this._ws?.close?.() } catch {}
90
+ this._connect(resume)
91
+ }
92
+
93
+ _send(obj) { try { this._ws?.send(JSON.stringify(obj)) } catch {} }
94
+
95
+ async stop() {
96
+ this._closed = true
97
+ clearInterval(this._heartbeat)
98
+ try { this._ws?.close?.() } catch {}
99
+ }
100
+
19
101
  async send(reply) {
20
102
  if (!this.token) throw new Error('DiscordAdapter: token required')
21
103
  const url = `${this.api}/channels/${reply.to}/messages`
@@ -1,4 +1,5 @@
1
1
  import express from 'express'
2
+ import crypto from 'node:crypto'
2
3
  import { EventEmitter } from 'node:events'
3
4
 
4
5
  export class WhatsappAdapter extends EventEmitter {
@@ -8,24 +9,49 @@ export class WhatsappAdapter extends EventEmitter {
8
9
  this.token = opts.token || process.env.WHATSAPP_API_TOKEN
9
10
  this.phoneId = opts.phoneId || process.env.WHATSAPP_PHONE_NUMBER_ID
10
11
  this.verifyToken = opts.verifyToken || process.env.WHATSAPP_VERIFY_TOKEN || 'freddie'
11
- this.port = opts.port || 0
12
+ // App secret enables X-Hub-Signature-256 verification on inbound webhooks.
13
+ // When set, unsigned or wrongly-signed requests are rejected.
14
+ this.appSecret = opts.appSecret || process.env.WHATSAPP_APP_SECRET || ''
15
+ this.port = opts.port ?? Number(process.env.WHATSAPP_WEBHOOK_PORT || 0)
16
+ this.path = opts.path || process.env.WHATSAPP_WEBHOOK_PATH || '/webhook'
12
17
  this.api = opts.api || 'https://graph.facebook.com/v20.0'
13
18
  this._server = null
14
19
  }
15
20
  getRequiredEnv() { return ['WHATSAPP_API_TOKEN', 'WHATSAPP_PHONE_NUMBER_ID'] }
21
+
22
+ // Verify Meta's HMAC-SHA256 signature over the raw request body.
23
+ _verifySignature(req) {
24
+ if (!this.appSecret) return true // verification disabled when no secret
25
+ const sig = req.get('x-hub-signature-256') || ''
26
+ if (!sig.startsWith('sha256=')) return false
27
+ const expected = 'sha256=' + crypto.createHmac('sha256', this.appSecret).update(req.rawBody || Buffer.alloc(0)).digest('hex')
28
+ try { return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) } catch { return false }
29
+ }
30
+
16
31
  async start() {
17
32
  if (!this.token || !this.phoneId) throw new Error('WhatsappAdapter: WHATSAPP_API_TOKEN + WHATSAPP_PHONE_NUMBER_ID required')
18
33
  const app = express()
19
- app.use(express.json())
20
- app.get('/webhook', (req, res) => {
34
+ // Capture the raw body so the signature can be verified over exact bytes.
35
+ app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf } }))
36
+
37
+ app.get(this.path, (req, res) => {
21
38
  if (req.query['hub.verify_token'] === this.verifyToken) return res.send(req.query['hub.challenge'])
22
39
  res.sendStatus(403)
23
40
  })
24
- app.post('/webhook', (req, res) => {
41
+ app.post(this.path, (req, res) => {
42
+ if (!this._verifySignature(req)) return res.sendStatus(401)
25
43
  const entries = req.body?.entry || []
26
44
  for (const e of entries) for (const c of (e.changes || [])) {
27
45
  const msgs = c.value?.messages || []
28
- for (const m of msgs) this.emit('message', { from: m.from, text: m.text?.body || '', raw: m })
46
+ for (const m of msgs) {
47
+ this.emit('message', {
48
+ from: m.from,
49
+ text: m.text?.body || '',
50
+ // surface the platform message id for dedup, and the message type
51
+ // so media-only messages are recognisable upstream.
52
+ raw: { ...m, id: m.id, type: m.type },
53
+ })
54
+ }
29
55
  }
30
56
  res.json({ ok: true })
31
57
  })
package/src/acp/server.js CHANGED
@@ -41,7 +41,7 @@ export class AcpServer extends EventEmitter {
41
41
  this._pendingPerm = new Map()
42
42
  this.machine = createAcpMachine()
43
43
  this.actor = createActor(this.machine)
44
- this.actor.subscribe(() => { persist('acp', 'lifecycle', this.actor.getPersistedSnapshot()).catch(() => {}) })
44
+ this.actor.subscribe(() => { persist('acp', 'lifecycle', this.actor.getPersistedSnapshot()).catch(e => log.error('acp lifecycle persist failed', { err: String(e) })) })
45
45
  this.actor.start()
46
46
  }
47
47
  get state() { return this.actor.getSnapshot().value }
package/src/acp/tools.js CHANGED
@@ -2,7 +2,11 @@ import { bootHost } from '../host/index.js'
2
2
  import { Events } from './events.js'
3
3
  export async function listToolsForAcp() {
4
4
  const h = await bootHost()
5
- return h.pi.tools.list().map(t => ({ name: t.name, toolset: t.toolset, schema: t.schema, requiresEnv: t.requiresEnv || [] }))
5
+ // Advertise only tools that are actually available (checkFn passes), so an ACP
6
+ // client is not told about a tool that will fail at invocation for a missing
7
+ // env var -- upfront honesty over a spurious tool-not-available later.
8
+ const available = h.pi.tools.list().filter(t => !t.checkFn || t.checkFn(t) !== false)
9
+ return available.map(t => ({ name: t.name, toolset: t.toolset, schema: t.schema, requiresEnv: t.requiresEnv || [] }))
6
10
  }
7
11
  export async function dispatchWithEvents({ name, args, send, sessionId = null }) {
8
12
  const h = await bootHost()
@@ -1,4 +1,5 @@
1
1
  import { logger } from '../observability/log.js'
2
+ import { parseTextToolCalls } from './tool_call_text.js'
2
3
 
3
4
  const log = logger('acptoapi')
4
5
 
@@ -9,7 +10,12 @@ const log = logger('acptoapi')
9
10
  // our AbortController be the single source of truth for the overall deadline
10
11
  // (default 240s, override via FREDDIE_LLM_TIMEOUT_MS). Use setGlobalDispatcher
11
12
  // once rather than a per-request Agent so we don't leak a dispatcher per call.
12
- const ACPTOAPI_TIMEOUT_MS = Number(process.env.FREDDIE_LLM_TIMEOUT_MS) || 240000
13
+ // Browser-safe env read: this module evaluates in a plain browser context where
14
+ // `process` is undefined (no node shim yet), so a bare process.env throws
15
+ // "process is not defined" and aborts the whole bundle import. envVal() reads
16
+ // live (picks up a late-installed shim) and never throws.
17
+ const envVal = (k) => { try { return (typeof process !== 'undefined' && process.env) ? process.env[k] : undefined } catch { return undefined } }
18
+ const ACPTOAPI_TIMEOUT_MS = Number(envVal('FREDDIE_LLM_TIMEOUT_MS')) || 240000
13
19
  let _dispatcherSet = false
14
20
  async function ensureLongTimeoutDispatcher() {
15
21
  if (_dispatcherSet) return
@@ -29,20 +35,25 @@ async function ensureLongTimeoutDispatcher() {
29
35
  }
30
36
 
31
37
  export function getAcptoapiUrl() {
32
- return process.env.FREDDIE_LLM_URL || 'http://127.0.0.1:4800/v1'
38
+ return envVal('FREDDIE_LLM_URL') || 'http://127.0.0.1:4800/v1'
33
39
  }
34
40
 
35
41
  export function getAcptoapiModel() {
36
- return process.env.FREDDIE_LLM_MODEL || 'claude/haiku'
42
+ return envVal('FREDDIE_LLM_MODEL') || 'claude/haiku'
37
43
  }
38
44
 
39
- export async function callLLM({ messages, tools = [], model } = {}) {
45
+ export async function callLLM({ messages, tools = [], model, tool_choice, cwd = null } = {}) {
40
46
  const base = getAcptoapiUrl()
41
47
  const useModel = model || getAcptoapiModel()
42
48
  const hasTools = Array.isArray(tools) && tools.length > 0
43
49
  const adaptedMessages = messages.map(adaptMessage)
44
- if (hasTools) {
45
- const cwd = process.cwd()
50
+ // The coder-agent working-directory note is OPT-IN via an explicit `cwd` param.
51
+ // It used to be injected on every tool-bearing call, which polluted NON-coder
52
+ // agents' prompts with "use your built-in tools (Bash, Read, Write)" -- tool
53
+ // hallucination bait plus a filesystem-path leak for hosts (like a contact-facing
54
+ // chat agent) whose toolset has no such tools. runTurn already composes its own
55
+ // cwd note when a caller passes cwd; direct callLLM users opt in the same way.
56
+ if (hasTools && cwd) {
46
57
  const sysIdx = adaptedMessages.findIndex(m => m.role === 'system')
47
58
  const cwdNote = `\nWorking directory: ${cwd}\nUse your built-in tools (Bash, Read, Write) to explore files in this directory when needed.`
48
59
  if (sysIdx >= 0) adaptedMessages[sysIdx] = { ...adaptedMessages[sysIdx], content: (adaptedMessages[sysIdx].content || '') + cwdNote }
@@ -55,9 +66,11 @@ export async function callLLM({ messages, tools = [], model } = {}) {
55
66
  max_tokens: 4096,
56
67
  }
57
68
  if (hasTools) body.tools = tools.map(adaptTool)
69
+ // Forward a forced tool_choice (e.g. 'required') so a caller can compel a tool
70
+ // call on a turn. Only when tools are present; absent -> the model chooses.
71
+ if (hasTools && tool_choice) body.tool_choice = tool_choice
58
72
  const headers = { 'content-type': 'application/json', authorization: 'Bearer none' }
59
- const cwd = process.cwd()
60
- if (Array.isArray(tools) && tools.length) headers['x-cwd'] = cwd
73
+ if (hasTools && cwd) headers['x-cwd'] = cwd
61
74
  // Rely on AbortController timeout. acptoapi v1+ ships CORS + Private
62
75
  // Network Access headers so cross-origin loopback (gh-pages → localhost)
63
76
  // succeeds when acptoapi is running. The earlier preemptive loopback
@@ -78,7 +91,12 @@ export async function callLLM({ messages, tools = [], model } = {}) {
78
91
  const text = await res.text()
79
92
  throw new Error(`acptoapi ${res.status}: ${text.slice(0, 400)}`)
80
93
  }
81
- const json = await res.json()
94
+ let json
95
+ try {
96
+ json = await res.json()
97
+ } catch (e) {
98
+ throw new Error(`acptoapi ${res.status}: invalid JSON response: ${String(e)}`)
99
+ }
82
100
  log.info('completed', { model: useModel, usage: json.usage })
83
101
  return adaptResponse(json)
84
102
  }
@@ -116,6 +134,12 @@ function adaptResponse(r) {
116
134
  const tool_calls = Array.isArray(choice.tool_calls)
117
135
  ? choice.tool_calls.map(tc => ({ id: tc.id, name: tc.function?.name, arguments: tryParseJson(tc.function?.arguments) }))
118
136
  : []
137
+ // Recover text-format tool calls (kimi <|tool_call_begin|> / llama
138
+ // <|python_tag|>) from weak models that don't emit structured tool_calls.
139
+ if (!tool_calls.length) {
140
+ const textTC = parseTextToolCalls(content)
141
+ if (textTC.length) return { content: '', tool_calls: textTC, raw: r }
142
+ }
119
143
  return { content, tool_calls, raw: r }
120
144
  }
121
145
 
@@ -1,6 +1,7 @@
1
1
  import { getConfigValue } from '../config.js'
2
2
  import { MATRIX_FILE } from './model-matrix.js'
3
3
  import { callLLM as bridgeCall, isReachable as bridgeReachable } from './acptoapi-bridge.js'
4
+ import { parseTextToolCalls } from './tool_call_text.js'
4
5
  import * as sdkNs from 'acptoapi'
5
6
  export { matrixUsable } from './model-matrix.js'
6
7
 
@@ -42,7 +43,15 @@ function adapt(result) {
42
43
  const flat = flattenContent(c.content)
43
44
  const openaiTC = Array.isArray(c.tool_calls) ? c.tool_calls.map(tc => ({ id: tc.id, name: tc.function?.name, arguments: tryJson(tc.function?.arguments) })) : []
44
45
  const anthropicTC = flat.toolUses.map(t => ({ id: t.id, name: t.name, arguments: t.input || {} }))
45
- return { content: flat.text, tool_calls: openaiTC.concat(anthropicTC), raw: result }
46
+ const tool_calls = openaiTC.concat(anthropicTC)
47
+ // Weak models may emit tool calls as text (kimi <|tool_call_begin|> / llama
48
+ // <|python_tag|>) instead of structured tool_calls. Recover them so the loop
49
+ // iterates; clear the text content since it was the call, not a reply.
50
+ if (!tool_calls.length) {
51
+ const textTC = parseTextToolCalls(flat.text)
52
+ if (textTC.length) return { content: '', tool_calls: textTC, raw: result }
53
+ }
54
+ return { content: flat.text, tool_calls, raw: result }
46
55
  }
47
56
 
48
57
  // Names callers can use as model= to select a curated acptoapi chain.
@@ -9,7 +9,7 @@ import { randomUUID } from 'node:crypto'
9
9
 
10
10
  const log = logger('agent')
11
11
 
12
- export function createAgentMachine({ provider, model, maxIterations = 90, callLLM, enabledToolsets = ['core'], disabledToolsets = [], events, sessionKey } = {}) {
12
+ export function createAgentMachine({ provider, model, maxIterations = 90, callLLM, enabledToolsets = ['core'], disabledToolsets = [], events, sessionKey, toolCtx = null, tool_choice } = {}) {
13
13
  const baseLLM = callLLM || resolveCallLLM({ provider, model })
14
14
  const llm = events ? async (input) => {
15
15
  const t0 = Date.now()
@@ -36,6 +36,19 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
36
36
  provider, model,
37
37
  enabledToolsets, disabledToolsets,
38
38
  sessionKey,
39
+ // Optional tool_choice policy. A FUNCTION receives the iteration index and
40
+ // returns the tool_choice for that llm call (full caller control). A plain
41
+ // VALUE (e.g. 'required') applies on ITERATION 0 ONLY, then reverts to the
42
+ // model's own choice -- a constant 'required' would make the done
43
+ // transition (which fires only on zero tool_calls) unreachable and exhaust
44
+ // the iteration budget, so first-call-only is the safe value semantics: it
45
+ // nudges a weak model into its first tool call without breaking loop
46
+ // termination. Undefined = model's own choice every call.
47
+ tool_choice,
48
+ // Opaque per-turn context handed to every tool handler (author/role/
49
+ // active-case/store etc.). The agent loop is identity-blind; tools that
50
+ // need who-is-asking read it from here. Null for a plain turn.
51
+ toolCtx,
39
52
  }),
40
53
  states: {
41
54
  idle: {
@@ -54,9 +67,13 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
54
67
  invoke: {
55
68
  src: fromPromise(async ({ input }) => {
56
69
  const schemas = await getEnabledToolSchemas(input.enabledToolsets, input.disabledToolsets)
57
- return await runStep(input.sessionKey, 'llm:' + input.iterations, () => llm({ messages: input.messages, tools: schemas, model: input.model, provider: input.provider }))
70
+ // Resolve the per-iteration tool_choice policy (see context note).
71
+ const tc = typeof input.tool_choice === 'function'
72
+ ? input.tool_choice(input.iterations)
73
+ : (input.iterations === 0 ? input.tool_choice : undefined)
74
+ return await runStep(input.sessionKey, 'llm:' + input.iterations, () => llm({ messages: input.messages, tools: schemas, model: input.model, provider: input.provider, tool_choice: tc }))
58
75
  }),
59
- input: ({ context }) => ({ messages: context.messages, model: context.model, provider: context.provider, enabledToolsets: context.enabledToolsets, disabledToolsets: context.disabledToolsets, sessionKey: context.sessionKey, iterations: context.iterations }),
76
+ input: ({ context }) => ({ messages: context.messages, model: context.model, provider: context.provider, enabledToolsets: context.enabledToolsets, disabledToolsets: context.disabledToolsets, sessionKey: context.sessionKey, iterations: context.iterations, tool_choice: context.tool_choice }),
60
77
  onDone: [
61
78
  { guard: ({ event }) => Array.isArray(event.output?.tool_calls) && event.output.tool_calls.length > 0, target: 'tool_calls', actions: assign({ messages: ({ context, event }) => [...context.messages, { role: 'assistant', content: event.output.content || '', tool_calls: event.output.tool_calls }] }) },
62
79
  { target: 'done', actions: assign({ messages: ({ context, event }) => [...context.messages, { role: 'assistant', content: event.output.content || '' }], lastResult: ({ context, event }) => {
@@ -99,7 +116,7 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
99
116
  const pushExtras = r => { if (r?.systemMessage) callExtras.push({ role: 'system', content: '[hook] ' + r.systemMessage }); if (r?.additionalContext) callExtras.push({ role: 'system', content: r.additionalContext }) }
100
117
  const pre = await h.hooks.invoke('preToolCall', { name: tname, args: targs }); pushExtras(pre)
101
118
  if (pre?.behavior === 'block') { return { content: JSON.stringify({ error: 'tool call denied by plugsdk hook', tool: tname, reason: pre.reason || 'denied' }), extras: callExtras } }
102
- const res = await h.pi.dispatchTool(tname, (pre && pre.args) || targs)
119
+ const res = await h.pi.dispatchTool(tname, (pre && pre.args) || targs, input.toolCtx || {})
103
120
  pushExtras(await h.hooks.invoke('postToolCall', { name: tname, args: targs, result: res }))
104
121
  return { content: res, extras: callExtras }
105
122
  })
@@ -108,7 +125,7 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
108
125
  }
109
126
  return { results, extras }
110
127
  }),
111
- input: ({ context }) => ({ messages: context.messages, sessionKey: context.sessionKey, iterations: context.iterations }),
128
+ input: ({ context }) => ({ messages: context.messages, sessionKey: context.sessionKey, iterations: context.iterations, toolCtx: context.toolCtx }),
112
129
  onDone: { target: 'prompting', actions: assign({
113
130
  messages: ({ context, event }) => [...context.messages, ...event.output.results.map(r => ({ role: 'tool', tool_call_id: r.tool_call_id, content: r.content })), ...event.output.extras],
114
131
  iterations: ({ context }) => context.iterations + 1,
@@ -180,6 +197,26 @@ function mergeHookExtras(messages, r, tag) {
180
197
  }
181
198
 
182
199
  // Drive a started persistent agent actor to its final state, wiring timeout +
200
+ // Auto-learn: distill a salient fact from a completed turn and memorize it into gm rs-learn.
201
+ // Only fires on substantive, non-error outcomes; dedupes against existing near-identical
202
+ // memories so the store does not fill with restatements. Best-effort — never throws.
203
+ const AUTOLEARN_MIN_LEN = 40 // skip trivial one-liners
204
+ const AUTOLEARN_DEDUPE_COS = 0.92 // a hit this similar means we already know it
205
+ async function autoLearnTurn({ prompt, out }) {
206
+ try {
207
+ if (!out || out.error) return
208
+ const result = (out.result || '').toString().trim()
209
+ if (result.length < AUTOLEARN_MIN_LEN) return
210
+ const { memorize, recall, projectNamespace } = await import('../learn/gm-learn.js')
211
+ const namespace = await projectNamespace()
212
+ // Concise salient fact: the user's ask + the outcome, capped to keep recall sharp.
213
+ const fact = `Q: ${(prompt || '').toString().trim().slice(0, 200)}\nA: ${result.slice(0, 600)}`
214
+ const existing = await recall(fact, { limit: 1, namespace })
215
+ if (existing.length && existing[0].score >= AUTOLEARN_DEDUPE_COS) return
216
+ await memorize(fact, { namespace })
217
+ } catch (_) {}
218
+ }
219
+
183
220
  // session-end hooks + trajectory. Shared by runTurn (fresh) and resumeTurn
184
221
  // (rehydrated from a persisted snapshot after a refresh/restart).
185
222
  async function driveAgentActor({ pa, h, events, prompt, provider, model, skill, cwd, witnessPath, timeoutMs, sessionKey }) {
@@ -200,6 +237,9 @@ async function driveAgentActor({ pa, h, events, prompt, provider, model, skill,
200
237
  await h.hooks.invoke('onSessionEnd', { reason: out?.error ? 'error' : 'ok', iterations: out?.iterations })
201
238
  const errorStack = out?.error ? (events.find(e => e.type === 'llm_call' && !e.ok)?.stack || null) : null
202
239
  await writeTrajectory(out, { prompt, provider, model, skill, cwd, events, errorStack, witnessPath })
240
+ // Auto-learn: memorize a salient summary of this turn into gm rs-learn so
241
+ // freddie learns from each substantive turn. Best-effort, deduped, capped.
242
+ await autoLearnTurn({ prompt, out })
203
243
  // Completed turn leaves no step-journal residue.
204
244
  await clearSteps(sessionKey)
205
245
  // Unsubscribe, flush the final snapshot (persistent-actor clears it on
@@ -212,12 +252,19 @@ async function driveAgentActor({ pa, h, events, prompt, provider, model, skill,
212
252
  })
213
253
  }
214
254
 
215
- export async function runTurn({ prompt, messages = [], model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations = 90, timeoutMs = 30000, cwd, skill, witnessPath, sessionKey } = {}) {
255
+ export async function runTurn({ prompt, messages = [], model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations = 90, timeoutMs = 30000, cwd, skill, witnessPath, sessionKey, toolCtx = null, tool_choice } = {}) {
216
256
  const events = []; const h = await bootHost()
217
257
  await h.hooks.invoke('onSessionStart', { prompt, model, provider, skill, cwd })
218
258
  let initMessages = [...messages]; const sysParts = []
219
259
  if (cwd) sysParts.push(`Working directory: ${cwd}. Always pass cwd="${cwd}" to bash tool calls. When reading or writing files use paths relative to this directory or absolute paths under it.`)
220
260
  if (skill) { const sd = h.pi.skills.get(skill); if (sd?.content) sysParts.push('Skill context:\n' + sd.content) }
261
+ // Auto-recall on turn entry: surface salient learned memories for this prompt from gm
262
+ // rs-learn (freddie's primary learning store). Best-effort; never blocks the turn.
263
+ try {
264
+ const { autoRecall, projectNamespace } = await import('../learn/gm-learn.js')
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'))
267
+ } catch (_) {}
221
268
  if (sysParts.length) initMessages.unshift({ role: 'user', content: sysParts.join('\n\n') })
222
269
  const inbound = await h.hooks.invoke('onMessageInbound', { content: prompt })
223
270
  if (inbound?.behavior === 'block') { await h.hooks.invoke('onSessionEnd', { reason: 'prompt_blocked' }); return { messages: initMessages, result: null, error: 'prompt blocked by plugsdk hook: ' + (inbound.reason || 'denied'), iterations: 0 } }
@@ -225,7 +272,7 @@ export async function runTurn({ prompt, messages = [], model, provider, callLLM,
225
272
  // Persist the turn snapshot under kind=agent so an interrupted turn (process
226
273
  // refresh mid-tool-call) resumes exactly where it stopped via resumeTurn.
227
274
  const key = sessionKey || randomUUID()
228
- const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events, sessionKey: key })
275
+ const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events, sessionKey: key, toolCtx, tool_choice })
229
276
  const pa = await createPersistentActor(machine, { kind: 'agent', key, input: { messages: initMessages } })
230
277
  pa.actor.send({ type: 'SUBMIT', prompt })
231
278
  return await driveAgentActor({ pa, h, events, prompt, provider, model, skill, cwd, witnessPath, timeoutMs, sessionKey: key })
@@ -234,14 +281,16 @@ export async function runTurn({ prompt, messages = [], model, provider, callLLM,
234
281
  // Rehydrate an interrupted turn from its persisted snapshot and drive it to
235
282
  // completion. Returns null if no live snapshot exists for the key (already
236
283
  // completed or never persisted) — caller falls back to a fresh runTurn.
237
- export async function resumeTurn({ sessionKey, model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations = 90, timeoutMs = 30000, cwd, skill, witnessPath } = {}) {
284
+ export async function resumeTurn({ sessionKey, model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations = 90, timeoutMs = 30000, cwd, skill, witnessPath, toolCtx = null } = {}) {
238
285
  if (!sessionKey) throw new Error('resumeTurn requires sessionKey')
239
- const { load } = await import('../machines/snapshot-store.js')
240
- if (!(await load('agent', sessionKey))) return null
241
286
  const events = []; const h = await bootHost()
242
- const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events, sessionKey })
287
+ const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events, sessionKey, toolCtx })
288
+ // createPersistentActor.load() already handles a missing/stale snapshot and
289
+ // leaves pa.resumed=false, so the prior pre-check load() was a redundant
290
+ // second read that opened a TOCTOU window (a concurrent delete between the two
291
+ // reads made forget() delete a snapshot we had just confirmed). One read only.
243
292
  const pa = await createPersistentActor(machine, { kind: 'agent', key: sessionKey, input: { messages: [] } })
244
- if (!pa.resumed) { await pa.forget(); return null }
293
+ if (!pa.resumed) return null
245
294
  return await driveAgentActor({ pa, h, events, prompt: '', provider, model, skill, cwd, witnessPath, timeoutMs, sessionKey })
246
295
  }
247
296
 
@@ -0,0 +1,68 @@
1
+ // Some fast/weak models emit tool calls as plain TEXT in their own native token
2
+ // format instead of structured OpenAI `tool_calls` (especially when the system
3
+ // prompt is rich). The agent loop only acts on structured tool_calls, so without
4
+ // recovery it stalls after one turn. parseTextToolCalls() extracts both observed
5
+ // formats from a content string and returns OpenAI-shaped {id,name,arguments}[].
6
+ //
7
+ // Formats handled:
8
+ // - kimi / moonshot section format:
9
+ // <|tool_calls_section_begin|>
10
+ // <|tool_call_begin|> functions.<name>:<idx>
11
+ // <|tool_call_argument_begin|> {json-args}
12
+ // <|tool_call_end|>
13
+ // <|tool_calls_section_end|>
14
+ // - llama python_tag format:
15
+ // <|python_tag|>name({...}) or name("query") or name(key="value")
16
+ //
17
+ // Returns [] when the content holds no recognizable text tool call.
18
+
19
+ function randId() {
20
+ return 'call_' + Math.random().toString(36).slice(2, 10)
21
+ }
22
+
23
+ function parseKimiSection(content) {
24
+ if (!content.includes('<|tool_call_begin|>')) return []
25
+ const re = /<\|tool_call_begin\|>\s*([\s\S]*?)\s*<\|tool_call_argument_begin\|>\s*([\s\S]*?)\s*<\|tool_call_end\|>/g
26
+ const out = []
27
+ let m
28
+ while ((m = re.exec(content)) !== null) {
29
+ const name = (m[1] || '').replace(/^functions\./, '').replace(/:\d+\s*$/, '').trim()
30
+ let args
31
+ try { args = JSON.parse((m[2] || '').trim()) } catch { args = {} }
32
+ if (name) out.push({ id: randId(), name, arguments: args })
33
+ }
34
+ return out
35
+ }
36
+
37
+ function parsePythonTag(content) {
38
+ if (!content.includes('<|python_tag|>')) return []
39
+ const after = content.slice(content.indexOf('<|python_tag|>') + '<|python_tag|>'.length).trim().split('\n')[0]
40
+ const mc = /^([A-Za-z_][A-Za-z0-9_.]*)\s*\(([\s\S]*?)\)\s*$/.exec(after)
41
+ if (!mc) return []
42
+ const name = mc[1].split('.')[0]
43
+ const inner = mc[2].trim()
44
+ let args = {}
45
+ if (/^\{[\s\S]*\}$/.test(inner)) { try { args = JSON.parse(inner) } catch { args = {} } }
46
+ else if (/^"[\s\S]*"$/.test(inner)) { const s = inner.slice(1, -1); args = { query: s, input: s } }
47
+ else if (/=/.test(inner)) {
48
+ const kwRe = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*("([^"\\]|\\.)*"|'([^'\\]|\\.)*'|[\d.]+|true|false|null)/g
49
+ let mm
50
+ while ((mm = kwRe.exec(inner)) !== null) {
51
+ let v = mm[2]
52
+ if (/^["']/.test(v)) v = v.slice(1, -1)
53
+ else if (/^[\d.]+$/.test(v)) v = Number(v)
54
+ else if (v === 'true') v = true
55
+ else if (v === 'false') v = false
56
+ else if (v === 'null') v = null
57
+ args[mm[1]] = v
58
+ }
59
+ } else if (inner) args = { query: inner, input: inner }
60
+ return name ? [{ id: randId(), name, arguments: args }] : []
61
+ }
62
+
63
+ export function parseTextToolCalls(content) {
64
+ if (typeof content !== 'string' || !content) return []
65
+ const kimi = parseKimiSection(content)
66
+ if (kimi.length) return kimi
67
+ return parsePythonTag(content)
68
+ }
package/src/batch.js CHANGED
@@ -5,7 +5,6 @@ import { getFreddieHome } from './home.js'
5
5
  import { randomUUID } from 'node:crypto'
6
6
  import { createMachine, assign, fromPromise } from 'xstate'
7
7
  import { createPersistentActor } from './machines/persistent-actor.js'
8
- import { load } from './machines/snapshot-store.js'
9
8
  import { runStep, clearSteps } from './machines/step-journal.js'
10
9
 
11
10
  // Run one prompt and append its result to the batch jsonl file.
@@ -77,10 +76,12 @@ export async function runBatch({ prompts = [], concurrency = 4, model, callLLM,
77
76
  // yet in context.done get re-run. Returns null if no live snapshot for the id.
78
77
  export async function resumeBatch({ batchId, model, callLLM } = {}) {
79
78
  if (!batchId) throw new Error('resumeBatch requires batchId')
80
- if (!(await load('batch', batchId))) return null
81
79
  const machine = createBatchMachine({ model, callLLM })
80
+ // One read only: createPersistentActor.load() handles the missing-snapshot
81
+ // case (pa.resumed=false), so the prior pre-check load() was a redundant read
82
+ // that opened a TOCTOU window against a concurrent delete.
82
83
  const pa = await createPersistentActor(machine, { kind: 'batch', key: batchId, input: { id: batchId, file: '', model, concurrency: 4, prompts: [] } })
83
- if (!pa.resumed) { await pa.forget(); return null }
84
+ if (!pa.resumed) return null
84
85
  return await driveBatch(pa)
85
86
  }
86
87
 
@@ -11,6 +11,10 @@
11
11
  export { bootHost, host, resetHostForTests } from '../host/index.js'
12
12
  export { createAgentMachine, runTurn } from '../agent/machine.js'
13
13
  export { createActor, createMachine, assign, fromPromise, waitFor } from 'xstate'
14
+ // Text-format tool-call recovery (kimi <|tool_call_begin|> / llama <|python_tag|>).
15
+ // Exported so hosts that supply their own callLLM (e.g. thebird's gateway path)
16
+ // can reuse the same parser instead of duplicating it.
17
+ export { parseTextToolCalls } from '../agent/tool_call_text.js'
14
18
 
15
19
  // Re-export config defaults under the documented browser name.
16
20
  import { DEFAULT_CONFIG } from '../config.js'
@@ -1,12 +1,12 @@
1
1
  import { getActiveSkin } from '../skin/engine.js'
2
2
  import { hex } from './colors.js'
3
3
  export function info(msg) { return hex(getActiveSkin().colors.banner_text, msg) }
4
- export function success(msg) { return hex('#22c55e', ' ' + msg) }
4
+ export function success(msg) { return hex('#22c55e', '[ok] ' + msg) }
5
5
  export function warning(msg) { return hex('#fbbf24', '! ' + msg) }
6
- export function error(msg) { return hex('#ef4444', ' ' + msg) }
6
+ export function error(msg) { return hex('#ef4444', '[x] ' + msg) }
7
7
  export function box(title, body) {
8
- const line = ''.repeat(Math.max(20, title.length + 4))
9
- return '' + line + '┐\n ' + title + ' '.repeat(line.length - title.length - 1) + '│\n' + line + '┤\n' + body.split('\n').map(l => ' ' + l + ' '.repeat(Math.max(0, line.length - l.length - 1)) + '').join('\n') + '\n' + line + ''
8
+ const line = '-'.repeat(Math.max(20, title.length + 4))
9
+ return '+' + line + '+\n| ' + title + ' '.repeat(line.length - title.length - 1) + '|\n+' + line + '+\n' + body.split('\n').map(l => '| ' + l + ' '.repeat(Math.max(0, line.length - l.length - 1)) + '|').join('\n') + '\n+' + line + '+'
10
10
  }
11
11
  export function table(rows) {
12
12
  if (!rows.length) return ''