freddie 0.0.121 → 0.0.123

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.
@@ -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 ''
@@ -1,10 +1,15 @@
1
1
  import readline from 'node:readline'
2
2
  import { runTurn } from '../agent/machine.js'
3
- import { resolveCommand, COMMAND_REGISTRY, COMMANDS_BY_CATEGORY } from '../commands/registry.js'
3
+ import { resolveCommand, COMMANDS_BY_CATEGORY } from '../commands/registry.js'
4
4
  import { getActiveSkin } from '../skin/engine.js'
5
- import { createSession, appendMessage } from '../sessions.js'
5
+ import { createSession, appendMessage, listSessions, getMessages } from '../sessions.js'
6
6
  import { listAllProfiles, switchProfile } from '../commands/profile.js'
7
+ import { listAuthProviders, hasUsableSecret, envForProvider } from '../auth.js'
8
+ import { listProjects, getActiveProject, setActiveProject } from '../projects.js'
7
9
 
10
+ // REPL slash-command handlers. Each returns a string to print (or sets
11
+ // state.exit / mutates state for resume). Handlers may be async — the line
12
+ // loop awaits them.
8
13
  const HANDLERS = {
9
14
  help: () => {
10
15
  const out = []
@@ -12,6 +17,7 @@ const HANDLERS = {
12
17
  out.push(`\n# ${cat}`)
13
18
  for (const c of cmds) out.push(` /${c.name}${c.args_hint ? ' ' + c.args_hint : ''}\t${c.description}`)
14
19
  }
20
+ out.push('\n# Conversation\n /sessions\tList recent conversations\n /resume <id>\tContinue a past conversation\n /keys\tShow which provider keys are set\n /project [name]\tShow or switch active project')
15
21
  return out.join('\n')
16
22
  },
17
23
  quit: (state) => { state.exit = true; return 'bye.' },
@@ -20,13 +26,55 @@ const HANDLERS = {
20
26
  if (args[0] === 'switch' && args[1]) { switchProfile(args[1]); return 'switched: ' + args[1] }
21
27
  return 'usage: /profile [list|switch <name>]'
22
28
  },
23
- sessions: () => 'Use the CLI: freddie sessions',
29
+ sessions: async () => {
30
+ const rows = await listSessions(20)
31
+ if (!rows.length) return '(no sessions yet)'
32
+ return rows.map(s => ` ${s.id.slice(0, 8)} ${new Date(s.updated_at).toISOString().slice(0, 16).replace('T', ' ')} ${s.title || '(untitled)'}`).join('\n')
33
+ },
34
+ resume: async (state, args) => {
35
+ const wanted = args[0]
36
+ const rows = await listSessions(50)
37
+ if (!rows.length) return '(no sessions to resume)'
38
+ const target = wanted
39
+ ? rows.find(s => s.id === wanted || s.id.startsWith(wanted))
40
+ : rows[0]
41
+ if (!target) return `no session matching: ${wanted}`
42
+ const msgs = await getMessages(target.id)
43
+ state.session = target.id
44
+ state.messages = msgs.map(m => ({ role: m.role, content: m.content, tool_calls: m.tool_calls || undefined, tool_call_id: m.tool_call_id || undefined }))
45
+ return `resumed ${target.id.slice(0, 8)} (${msgs.length} messages) — ${target.title || '(untitled)'}`
46
+ },
47
+ keys: async () => {
48
+ const lines = []
49
+ for (const p of listAuthProviders()) {
50
+ const ok = await hasUsableSecret(p)
51
+ lines.push(` ${p.padEnd(12)} ${envForProvider(p) || ''}\t${ok ? '[set]' : '[--]'}`)
52
+ }
53
+ return lines.join('\n')
54
+ },
55
+ project: (_s, args) => {
56
+ if (!args[0]) {
57
+ const active = getActiveProject()
58
+ return listProjects().map(p => ` ${p.name === active.name ? '[*]' : '[ ]'} ${p.name.padEnd(16)} ${p.path}`).join('\n')
59
+ }
60
+ try { const p = setActiveProject(args[0]); return `switched to project: ${p.name} (${p.path})\nrestart the REPL to load this project's plugins` }
61
+ catch (e) { return 'error: ' + e.message }
62
+ },
24
63
  clear: (state) => { state.messages = []; return 'cleared.' },
25
64
  }
26
65
 
27
- export async function interactive({ callLLM, input = process.stdin, output = process.stdout } = {}) {
66
+ export async function interactive({ callLLM, resume = null, input = process.stdin, output = process.stdout } = {}) {
28
67
  const skin = getActiveSkin()
29
- const state = { messages: [], session: createSession({ platform: 'cli' }), exit: false }
68
+ const state = { messages: [], session: null, exit: false }
69
+ // Resume a prior conversation when requested (--resume [id]); otherwise start
70
+ // a fresh session. createSession/listSessions are async (libsql) and MUST be
71
+ // awaited — a bare call silently wraps in a rejecting Promise so the row is
72
+ // never persisted and history is lost.
73
+ if (resume !== null && resume !== false) {
74
+ const msg = await HANDLERS.resume(state, typeof resume === 'string' ? [resume] : [])
75
+ output.write(msg + '\n')
76
+ }
77
+ if (!state.session) state.session = await createSession({ platform: 'cli' })
30
78
  output.write(`${skin.branding.welcome}\n`)
31
79
  const rl = readline.createInterface({ input, output, terminal: input.isTTY })
32
80
  const prompt = () => { if (!state.exit) rl.setPrompt(skin.branding.prompt_symbol); rl.prompt() }
@@ -35,21 +83,22 @@ export async function interactive({ callLLM, input = process.stdin, output = pro
35
83
  if (!line) return prompt()
36
84
  if (line.startsWith('/')) {
37
85
  const parts = line.slice(1).split(/\s+/)
38
- const name = resolveCommand('/' + parts[0])
86
+ const name = resolveCommand('/' + parts[0]) || parts[0]
39
87
  const handler = HANDLERS[name]
40
88
  if (!handler) { output.write(`unknown command: /${parts[0]}\n`); return prompt() }
41
- output.write(handler(state, parts.slice(1)) + '\n')
89
+ try { output.write((await handler(state, parts.slice(1))) + '\n') }
90
+ catch (e) { output.write(`error: ${e.message}\n`) }
42
91
  if (state.exit) rl.close()
43
92
  else prompt()
44
93
  return
45
94
  }
46
- appendMessage(state.session, { role: 'user', content: line })
95
+ await appendMessage(state.session, { role: 'user', content: line })
47
96
  try {
48
97
  const out = await runTurn({ prompt: line, messages: state.messages, callLLM, timeoutMs: 60000 })
49
98
  state.messages = out.messages
50
99
  const reply = out.result || out.error || '(no response)'
51
100
  output.write(`${skin.branding.response_label}${reply}\n`)
52
- appendMessage(state.session, { role: 'assistant', content: reply })
101
+ await appendMessage(state.session, { role: 'assistant', content: reply })
53
102
  } catch (e) {
54
103
  output.write(`error: ${e.message}\n`)
55
104
  }
@@ -1,12 +1,26 @@
1
1
  import { saveConfigValue } from '../config.js'
2
2
  import { getAuthStore } from '../auth.js'
3
3
  import { listMemoryProviders } from '../plugins/memory/provider.js'
4
- export const PROVIDER_ENV = { honcho: 'HONCHO_API_KEY', mem0: 'MEM0_API_KEY', supermemory: 'SUPERMEMORY_API_KEY', byterover: 'BYTEROVER_API_KEY', hindsight: 'HINDSIGHT_API_KEY', openviking: 'OPENVIKING_API_KEY', retaindb: 'RETAINDB_API_KEY' }
5
- export function listProviders() { return listMemoryProviders() }
4
+
5
+ // gm rs-learn is freddie's canonical, default learning store — no configuration required.
6
+ // The third-party providers below are LEGACY: opt-in only, kept behind explicit configure.
7
+ export const DEFAULT_PROVIDER = 'gm'
8
+ export const LEGACY_PROVIDER_ENV = { honcho: 'HONCHO_API_KEY', mem0: 'MEM0_API_KEY', supermemory: 'SUPERMEMORY_API_KEY', byterover: 'BYTEROVER_API_KEY', hindsight: 'HINDSIGHT_API_KEY', openviking: 'OPENVIKING_API_KEY', retaindb: 'RETAINDB_API_KEY' }
9
+ // Back-compat alias.
10
+ export const PROVIDER_ENV = LEGACY_PROVIDER_ENV
11
+
12
+ export function listProviders() { return [DEFAULT_PROVIDER, ...listMemoryProviders().filter(n => n !== DEFAULT_PROVIDER)] }
13
+
6
14
  export async function configureProvider(name, apiKey, options = {}) {
15
+ // gm needs no key/config; selecting it just clears any legacy provider override.
16
+ if (name === DEFAULT_PROVIDER || name === 'rs-learn') {
17
+ saveConfigValue('memory.provider', DEFAULT_PROVIDER)
18
+ saveConfigValue('memory.options', options)
19
+ return { configured: DEFAULT_PROVIDER, hasKey: false, default: true }
20
+ }
7
21
  if (!listMemoryProviders().includes(name)) throw new Error('unknown memory provider: ' + name)
8
22
  saveConfigValue('memory.provider', name)
9
23
  saveConfigValue('memory.options', options)
10
- if (apiKey && PROVIDER_ENV[name]) await getAuthStore().setCredential(PROVIDER_ENV[name], apiKey)
11
- return { configured: name, hasKey: Boolean(apiKey) }
24
+ if (apiKey && LEGACY_PROVIDER_ENV[name]) await getAuthStore().setCredential(LEGACY_PROVIDER_ENV[name], apiKey)
25
+ return { configured: name, hasKey: Boolean(apiKey), legacy: true }
12
26
  }
@@ -1,5 +1,5 @@
1
- export function relaunch({ argv = process.argv } = {}) {
2
- const { spawn } = require('node:child_process')
1
+ export async function relaunch({ argv = process.argv } = {}) {
2
+ const { spawn } = await import('node:child_process')
3
3
  const child = spawn(argv[0], argv.slice(1), { detached: true, stdio: 'ignore', env: process.env })
4
4
  child.unref()
5
5
  setTimeout(() => process.exit(0), 100)
@@ -0,0 +1,31 @@
1
+ import readline from 'node:readline'
2
+
3
+ // Read a secret (API key) without it landing in shell history or `ps` output.
4
+ // - When stdin is a pipe (not a TTY), read the first line of stdin verbatim:
5
+ // `echo $KEY | freddie auth set anthropic`.
6
+ // - When stdin is a TTY, prompt and read with the terminal echo masked so the
7
+ // key is not shown on screen.
8
+ // Never accept the secret as an argv argument — argv leaks to history and ps.
9
+ export async function readStdinSecret(promptText = 'secret: ', { input = process.stdin, output = process.stdout } = {}) {
10
+ if (!input.isTTY) {
11
+ // Piped input: take the first non-empty line.
12
+ const rl = readline.createInterface({ input, terminal: false })
13
+ for await (const line of rl) { rl.close(); return line.trim() }
14
+ return ''
15
+ }
16
+ // Interactive TTY: prompt and mask the echo.
17
+ return await new Promise((resolve) => {
18
+ const rl = readline.createInterface({ input, output, terminal: true })
19
+ let masked = false
20
+ const onData = () => { if (masked) output.write('\x1b[2K\r' + promptText + '*'.repeat(rl.line.length)) }
21
+ output.write(promptText)
22
+ masked = true
23
+ input.on('data', onData)
24
+ rl.question('', (answer) => {
25
+ input.off('data', onData)
26
+ output.write('\n')
27
+ rl.close()
28
+ resolve(answer.trim())
29
+ })
30
+ })
31
+ }
@@ -15,11 +15,14 @@ export const ContextPlugins = {
15
15
  skills: async () => {
16
16
  return listSkills().map(s => ({ name: 'skill:' + s.name, body: s.description }))
17
17
  },
18
- memory: async ({ provider } = {}) => {
19
- if (!provider) return []
18
+ memory: async ({ message = '', namespace = null } = {}) => {
19
+ // Query-aware semantic recall from gm rs-learn — freddie's primary learning store.
20
20
  try {
21
- const out = await provider.prefetch('')
22
- return (out.items || []).slice(0, 5).map((it, i) => ({ name: 'memory:' + i, body: typeof it === 'string' ? it : JSON.stringify(it) }))
21
+ const { recall, projectNamespace } = await import('../learn/gm-learn.js')
22
+ const ns = namespace || await projectNamespace()
23
+ const q = (message || '').toString().trim() || 'project notes facts decisions'
24
+ const hits = await recall(q, { limit: 5, namespace: ns })
25
+ return hits.map((h, i) => ({ name: 'memory:' + i, body: h.text }))
23
26
  } catch { return [] }
24
27
  },
25
28
  }
@@ -36,13 +36,13 @@ export class Gateway {
36
36
  this.actor = createActor(this.machine, { input: { platformNames: [...this.platforms.keys()] } })
37
37
  // Persist lifecycle transitions so the gateway's state is observable +
38
38
  // resumable; an active snapshot on boot means the gateway was running.
39
- this.actor.subscribe((snap) => { persist('gateway', 'lifecycle', this.actor.getPersistedSnapshot()).catch(() => {}) })
39
+ this.actor.subscribe((snap) => { persist('gateway', 'lifecycle', this.actor.getPersistedSnapshot()).catch(e => log.error('gateway lifecycle persist failed', { err: String(e) })) })
40
40
  this.actor.start()
41
41
  }
42
42
  get state() { return this.actor.getSnapshot().value }
43
43
  register(name, adapter) {
44
44
  this.platforms.set(name, adapter)
45
- adapter.on?.('message', (m) => this.handleInbound(name, m))
45
+ adapter.on?.('message', (m) => { this.handleInbound(name, m).catch(e => log.error('message listener error', { platform: name, from: m.from, error: String(e) })) })
46
46
  }
47
47
  addHook(stage, fn) { this.hooks[stage].push(fn) }
48
48
  async start() {
@@ -63,16 +63,28 @@ export class Gateway {
63
63
  // sender + content so a refresh mid-processing re-drives it instead of
64
64
  // dropping it. The snapshot is cleared once the reply is sent.
65
65
  const msgKey = msg.id || `${platform}:${msg.from}:${randomUUID()}`
66
- await persist('gateway-msg', msgKey, { status: 'active', value: 'processing', context: { platform, from: msg.from, text: msg.text } })
66
+ // A persist failure must not drop the message: degrade to non-resumable
67
+ // processing (the reply still goes out) rather than throwing it away.
68
+ try {
69
+ await persist('gateway-msg', msgKey, { status: 'active', value: 'processing', context: { platform, from: msg.from, text: msg.text } })
70
+ } catch (e) {
71
+ log.warn('cannot persist gateway-msg, continuing without resumability', { msgKey, err: String(e) })
72
+ }
67
73
  let cur = { ...msg, platform }
68
74
  for (const h of this.hooks.inbound) cur = (await h(cur)) || cur
69
75
  const result = await runStep(msgKey, 'run', () => runTurn({ prompt: cur.text || '', callLLM: this.callLLM }))
70
76
  let reply = { to: msg.from, text: result.result || result.error || '', platform, result }
71
77
  for (const h of this.hooks.outbound) reply = (await h(reply)) || reply
72
78
  const adapter = this.platforms.get(platform)
73
- await adapter.send?.(reply)
74
- await clear('gateway-msg', msgKey)
75
- await clearSteps(msgKey)
79
+ // Clear the in-flight snapshot whether or not the send throws: a send
80
+ // failure must not leave the message to be re-driven (and re-replied) on
81
+ // the next boot. The contract is "cleared once processing completes".
82
+ try {
83
+ await adapter.send?.(reply)
84
+ } finally {
85
+ await clear('gateway-msg', msgKey)
86
+ await clearSteps(msgKey)
87
+ }
76
88
  return reply
77
89
  }
78
90
  }
package/src/index.js CHANGED
@@ -21,6 +21,7 @@ export { createEnvironment, defaultEnvironment, LocalEnvironment, DockerEnvironm
21
21
  export { getAuthStore } from './auth.js'
22
22
  export { buildContext, blocksToSystemMessage, ContextPlugins } from './context/engine.js'
23
23
  export { callLLM as piCallLLM } from './agent/pi-bridge.js'
24
+ export { callLLM as acptoapiCallLLM, isReachable as acptoapiReachable, getAcptoapiUrl, getAcptoapiModel } from './agent/acptoapi-bridge.js'
24
25
  export { interactive } from './cli/interactive.js'
25
26
  export { listMemoryProviders, createMemoryProvider, registerMemoryProvider } from './plugins/memory/provider.js'
26
27
  export { compress, shouldCompress, computeCompressionPlan, SUMMARY_PREFIX, estimateMessagesTokens, pruneOldToolResults, markFailure as compressMarkFailure, shouldRetry as compressShouldRetry, clearFailure as compressClearFailure } from './agent/compress/index.js'
@@ -0,0 +1,179 @@
1
+ // gm rs-learn — freddie's primary learning mechanism.
2
+ //
3
+ // Routes all of freddie's learning (memory tool, turn-context recall, auto-recall on turn
4
+ // entry, auto-learn on turn completion) through gm-plugkit's rs-learn store. One backend is
5
+ // chosen lazily on first use and cached process-wide:
6
+ //
7
+ // - Node: gm-plugkit's wasm wrapper is imported in-process (createPlugkit), resolving
8
+ // .gm/rs-learn.db from CLAUDE_PROJECT_DIR||cwd of this process.
9
+ // - Browser: a host-provided bridge (globalThis.__GM_DISPATCH__) routes verbs to the
10
+ // gm wasm instance the host already loaded in-page (e.g. thebird's
11
+ // window.__debug.gm.dispatch). This is what makes freddie LEARN on gh-pages,
12
+ // where node:module is unavailable and an in-process import would throw.
13
+ //
14
+ // Every call degrades to a no-op (never throws into the agent loop) when no backend is
15
+ // available, so a freddie process/page without gm installed still runs.
16
+
17
+ let _initPromise = null // shared in-flight backend selection (no double cold-load)
18
+ let _failed = false // sticky failure flag — stop retrying a missing/broken install
19
+ let _pk = null // cached backend: { dispatch(verb, body) -> json|Promise<json>, version() }
20
+
21
+ const _isBrowser = typeof window !== 'undefined' || typeof importScripts === 'function'
22
+
23
+ // The host-provided in-page bridge contract. A host (thebird) sets one of:
24
+ // globalThis.__GM_DISPATCH__(verb, body) -> json | Promise<json> (preferred)
25
+ // globalThis.__gm.dispatch(verb, body) -> json | Promise<json> (fallback shape)
26
+ // We probe lazily on every ensure() so a late-loading wasm (149MB cold-load) is picked up
27
+ // once it becomes available rather than being cached as "failed" forever.
28
+ function findBrowserBridge() {
29
+ const g = (typeof globalThis !== 'undefined') ? globalThis : null
30
+ if (!g) return null
31
+ if (typeof g.__GM_DISPATCH__ === 'function') return { dispatch: g.__GM_DISPATCH__ }
32
+ const gm = g.__gm || (g.__debug && g.__debug.gm)
33
+ if (gm && typeof gm.dispatch === 'function') return { dispatch: (v, b) => gm.dispatch(v, b) }
34
+ return null
35
+ }
36
+
37
+ async function ensureNodePlugkit() {
38
+ const { createRequire } = await import('node:module')
39
+ const path = (await import('node:path')).default
40
+ const _require = createRequire(import.meta.url)
41
+ // index.js is CommonJS; the createPlugkit export lives on the ESM wrapper file, so resolve
42
+ // the wrapper path off the package and import it directly.
43
+ const pkgJson = _require.resolve('gm-plugkit/package.json')
44
+ const wrapper = path.join(path.dirname(pkgJson), 'plugkit-wasm-wrapper.js')
45
+ const url = 'file://' + wrapper.replace(/\\/g, '/')
46
+ const mod = await import(url)
47
+ if (typeof mod.createPlugkit !== 'function') throw new Error('gm-plugkit createPlugkit export missing (update gm-plugkit)')
48
+ return mod.createPlugkit()
49
+ }
50
+
51
+ async function ensurePlugkit() {
52
+ if (_pk) return _pk
53
+ // In the browser the bridge can appear AFTER first probe (wasm cold-load). Never set the
54
+ // sticky _failed flag there — just re-probe each call until the host wires the global.
55
+ if (_isBrowser) {
56
+ const bridge = findBrowserBridge()
57
+ if (!bridge) return null
58
+ _pk = { dispatch: bridge.dispatch, version: () => 'browser-bridge' }
59
+ return _pk
60
+ }
61
+ if (_failed) return null
62
+ if (_initPromise) return _initPromise
63
+ _initPromise = (async () => {
64
+ try {
65
+ _pk = await ensureNodePlugkit()
66
+ return _pk
67
+ } catch (e) {
68
+ _failed = true
69
+ try { console.error('[gm-learn] disabled (gm rs-learn unavailable):', e && e.message) } catch (_) {}
70
+ return null
71
+ } finally {
72
+ _initPromise = null
73
+ }
74
+ })()
75
+ return _initPromise
76
+ }
77
+
78
+ export function learnAvailable() { return Boolean(_pk) || Boolean(_isBrowser && findBrowserBridge()) }
79
+
80
+ // Per-project namespace isolation, matching gm's namespace model.
81
+ // - Browser: the host sets globalThis.__GM_NAMESPACE__ (a string, or a fn returning one)
82
+ // to the active workspace/instance so memories isolate per thebird instance.
83
+ // - Node: derive from the freddie project registry (src/projects.js).
84
+ // Falls back to 'default' if neither is resolvable (e.g. early boot).
85
+ export async function projectNamespace() {
86
+ if (_isBrowser) {
87
+ try {
88
+ const g = globalThis
89
+ const ns = typeof g.__GM_NAMESPACE__ === 'function' ? g.__GM_NAMESPACE__() : g.__GM_NAMESPACE__
90
+ const s = (ns == null ? '' : String(ns)).trim()
91
+ return s || 'default'
92
+ } catch (_) { return 'default' }
93
+ }
94
+ try {
95
+ const mod = await import('../projects.js')
96
+ const p = mod.getActiveProject && mod.getActiveProject()
97
+ return (p && p.name) || 'default'
98
+ } catch (_) { return 'default' }
99
+ }
100
+
101
+ // Normalize a recall response into a flat hit list: [{ text, score, key, namespace }].
102
+ function normalizeHits(resp) {
103
+ const hits = (resp && resp.data && Array.isArray(resp.data.hits)) ? resp.data.hits
104
+ : (resp && Array.isArray(resp.hits)) ? resp.hits
105
+ : []
106
+ return hits.map(h => ({
107
+ text: h.text != null ? String(h.text) : '',
108
+ score: typeof h.score === 'number' ? h.score : (typeof h.cos === 'number' ? h.cos : 0),
109
+ key: h.key || null,
110
+ namespace: h.namespace || 'default',
111
+ })).filter(h => h.text)
112
+ }
113
+
114
+ // Persist a fact into rs-learn. Returns the stored key, or null on no-op/degrade.
115
+ export async function memorize(text, { namespace = 'default', key = null } = {}) {
116
+ const t = (text || '').toString().trim()
117
+ if (!t) return null
118
+ const pk = await ensurePlugkit()
119
+ if (!pk) return null
120
+ try {
121
+ const body = { text: t, namespace }
122
+ if (key) body.key = key
123
+ const r = await pk.dispatch('memorize-fire', body)
124
+ if (r && r.ok === false) return null
125
+ return (r && r.data && r.data.key) || (r && r.key) || null
126
+ } catch (e) {
127
+ try { console.error('[gm-learn] memorize failed:', e && e.message) } catch (_) {}
128
+ return null
129
+ }
130
+ }
131
+
132
+ // Semantic recall against rs-learn. Returns [{ text, score, key, namespace }] (possibly []).
133
+ export async function recall(query, { limit = 5, namespace = 'default' } = {}) {
134
+ const q = (query || '').toString().trim()
135
+ if (!q) return []
136
+ const pk = await ensurePlugkit()
137
+ if (!pk) return []
138
+ try {
139
+ const r = await pk.dispatch('recall', { query: q, limit, namespace })
140
+ if (r && r.ok === false) return []
141
+ return normalizeHits(r).slice(0, limit)
142
+ } catch (e) {
143
+ try { console.error('[gm-learn] recall failed:', e && e.message) } catch (_) {}
144
+ return []
145
+ }
146
+ }
147
+
148
+ // Turn-entry auto-recall for a raw user prompt. Same store as recall(); kept distinct so the
149
+ // turn-entry pack can use the auto-recall verb's query derivation when present.
150
+ export async function autoRecall(prompt, { limit = 5, namespace = 'default' } = {}) {
151
+ const p = (prompt || '').toString().trim()
152
+ if (!p) return []
153
+ const pk = await ensurePlugkit()
154
+ if (!pk) return []
155
+ try {
156
+ const r = await pk.dispatch('auto-recall', p)
157
+ // auto-recall may return {hits} directly or under data; fall back to plain recall.
158
+ let hits = normalizeHits(r)
159
+ if (!hits.length) hits = await recall(p, { limit, namespace })
160
+ return hits.slice(0, limit)
161
+ } catch (_) {
162
+ return recall(p, { limit, namespace })
163
+ }
164
+ }
165
+
166
+ // Remove a memory by explicit key (never blind similarity-delete).
167
+ export async function prune(keys) {
168
+ const list = Array.isArray(keys) ? keys.filter(Boolean) : (keys ? [keys] : [])
169
+ if (!list.length) return { pruned: 0 }
170
+ const pk = await ensurePlugkit()
171
+ if (!pk) return { pruned: 0 }
172
+ try {
173
+ const r = await pk.dispatch('memorize-prune', { keys: list })
174
+ return (r && r.data) || r || { pruned: list.length }
175
+ } catch (e) {
176
+ try { console.error('[gm-learn] prune failed:', e && e.message) } catch (_) {}
177
+ return { pruned: 0 }
178
+ }
179
+ }
@@ -0,0 +1,28 @@
1
+ // freddie "case" plugin -- registers the case/enquiry toolset into the host so any
2
+ // runTurn can use it. Agentic code per the layering mandate; the data store and the
3
+ // field/enum/role config are injected by the host application (via toolCtx on each
4
+ // turn and plugins.case config), so this plugin is application-agnostic.
5
+ //
6
+ // The store is resolved PER TURN from toolCtx (ctx.store), never a global, so the
7
+ // agent answers for the asking worker. A configured fallback store
8
+ // (plugins.case.resolveStore, a function) is used only when a turn carries none
9
+ // (e.g. tests). Config is read via ctx.config (scopedCfg 'case').
10
+
11
+ import { buildCaseToolset } from './toolset.js'
12
+
13
+ export default {
14
+ name: 'case',
15
+ surfaces: 'pi',
16
+ register({ pi, config }) {
17
+ const resolveStore = config?.get('resolveStore', null)
18
+ const slimCase = config?.get('slimCase', (c) => c)
19
+ const slimEvent = config?.get('slimEvent', (e) => e)
20
+ const tools = buildCaseToolset({
21
+ resolveStore: typeof resolveStore === 'function' ? resolveStore : null,
22
+ config,
23
+ slimCase: typeof slimCase === 'function' ? slimCase : (c) => c,
24
+ slimEvent: typeof slimEvent === 'function' ? slimEvent : (e) => e,
25
+ })
26
+ for (const tool of tools) pi.tools.register(tool)
27
+ },
28
+ }