freddie 0.0.41

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.
Files changed (307) hide show
  1. package/AGENTS.md +180 -0
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +130 -0
  4. package/bin/freddie.js +116 -0
  5. package/package.json +59 -0
  6. package/skills/creative/README.md +3 -0
  7. package/skills/creative/architecture-diagram/SKILL.md +52 -0
  8. package/skills/creative/ascii-video/SKILL.md +60 -0
  9. package/skills/creative/concept-diagrams/SKILL.md +65 -0
  10. package/skills/data/README.md +3 -0
  11. package/skills/data/etl-pipelines/SKILL.md +60 -0
  12. package/skills/data/sql-explainer/SKILL.md +60 -0
  13. package/skills/ops/README.md +3 -0
  14. package/skills/ops/incident-response/SKILL.md +74 -0
  15. package/skills/ops/log-triage/SKILL.md +79 -0
  16. package/skills/planning/README.md +3 -0
  17. package/skills/planning/okr-drafter/SKILL.md +60 -0
  18. package/skills/planning/weekly-review/SKILL.md +64 -0
  19. package/skills/software-development/README.md +3 -0
  20. package/skills/software-development/code-review/SKILL.md +70 -0
  21. package/skills/software-development/rfc-writer/SKILL.md +68 -0
  22. package/skills/software-development/systematic-debugging/SKILL.md +80 -0
  23. package/src/acp/auth.js +21 -0
  24. package/src/acp/entry.js +2 -0
  25. package/src/acp/events.js +10 -0
  26. package/src/acp/main.js +8 -0
  27. package/src/acp/permissions.js +29 -0
  28. package/src/acp/server.js +84 -0
  29. package/src/acp/session.js +26 -0
  30. package/src/acp/tools.js +17 -0
  31. package/src/agent/account_usage.js +19 -0
  32. package/src/agent/acptoapi-bridge.js +80 -0
  33. package/src/agent/anthropic_adapter.js +10 -0
  34. package/src/agent/auxiliary_client.js +20 -0
  35. package/src/agent/bedrock_adapter.js +11 -0
  36. package/src/agent/codex_responses_adapter.js +10 -0
  37. package/src/agent/compress/compressor.js +55 -0
  38. package/src/agent/compress/fallback.js +14 -0
  39. package/src/agent/compress/index.js +6 -0
  40. package/src/agent/compress/policy.js +47 -0
  41. package/src/agent/compress/prompt.js +46 -0
  42. package/src/agent/compress/prune.js +16 -0
  43. package/src/agent/compress/tokens.js +31 -0
  44. package/src/agent/context_references.js +40 -0
  45. package/src/agent/copilot_acp_client.js +6 -0
  46. package/src/agent/credential_pool.js +30 -0
  47. package/src/agent/credential_sources.js +18 -0
  48. package/src/agent/curator.js +5 -0
  49. package/src/agent/display.js +23 -0
  50. package/src/agent/error_classifier.js +15 -0
  51. package/src/agent/file_safety.js +9 -0
  52. package/src/agent/gemini_cloudcode_adapter.js +9 -0
  53. package/src/agent/gemini_native_adapter.js +11 -0
  54. package/src/agent/gemini_schema.js +19 -0
  55. package/src/agent/google_code_assist.js +8 -0
  56. package/src/agent/google_oauth.js +21 -0
  57. package/src/agent/image_gen_provider.js +8 -0
  58. package/src/agent/image_gen_registry.js +6 -0
  59. package/src/agent/image_routing.js +13 -0
  60. package/src/agent/insights.js +9 -0
  61. package/src/agent/llm_resolver.js +21 -0
  62. package/src/agent/lmstudio_reasoning.js +13 -0
  63. package/src/agent/machine.js +102 -0
  64. package/src/agent/manual_compression_feedback.js +5 -0
  65. package/src/agent/memory_manager.js +14 -0
  66. package/src/agent/memory_provider.js +1 -0
  67. package/src/agent/model_metadata.js +28 -0
  68. package/src/agent/models_dev.js +13 -0
  69. package/src/agent/moonshot_schema.js +11 -0
  70. package/src/agent/oauth_endpoints.js +79 -0
  71. package/src/agent/onboarding.js +16 -0
  72. package/src/agent/pi-bridge.js +37 -0
  73. package/src/agent/prompt_builder.js +12 -0
  74. package/src/agent/prompt_caching.js +24 -0
  75. package/src/agent/rate_limit_tracker.js +12 -0
  76. package/src/agent/redact.js +25 -0
  77. package/src/agent/retry_utils.js +17 -0
  78. package/src/agent/shell_hooks.js +16 -0
  79. package/src/agent/skill_commands.js +16 -0
  80. package/src/agent/skill_preprocessing.js +12 -0
  81. package/src/agent/skill_utils.js +14 -0
  82. package/src/agent/subdirectory_hints.js +17 -0
  83. package/src/agent/title_generator.js +13 -0
  84. package/src/agent/trajectory.js +9 -0
  85. package/src/agent/usage_pricing.js +16 -0
  86. package/src/auth.js +84 -0
  87. package/src/batch.js +32 -0
  88. package/src/cli/auth_commands.js +17 -0
  89. package/src/cli/azure_detect.js +9 -0
  90. package/src/cli/backup.js +17 -0
  91. package/src/cli/banner.js +13 -0
  92. package/src/cli/browser_connect.js +11 -0
  93. package/src/cli/callbacks.js +5 -0
  94. package/src/cli/claw.js +8 -0
  95. package/src/cli/cli_output.js +19 -0
  96. package/src/cli/clipboard.js +24 -0
  97. package/src/cli/codex_models.js +8 -0
  98. package/src/cli/colors.js +13 -0
  99. package/src/cli/completer.js +98 -0
  100. package/src/cli/completion.js +21 -0
  101. package/src/cli/copilot_auth.js +9 -0
  102. package/src/cli/curator_cli.js +5 -0
  103. package/src/cli/curses.js +15 -0
  104. package/src/cli/debug.js +6 -0
  105. package/src/cli/default_soul.js +20 -0
  106. package/src/cli/dingtalk_auth.js +12 -0
  107. package/src/cli/doctor.js +15 -0
  108. package/src/cli/dump.js +11 -0
  109. package/src/cli/env_loader.js +25 -0
  110. package/src/cli/fallback_cmd.js +9 -0
  111. package/src/cli/gateway_cli.js +17 -0
  112. package/src/cli/hooks.js +9 -0
  113. package/src/cli/interactive.js +61 -0
  114. package/src/cli/logs.js +32 -0
  115. package/src/cli/main.js +7 -0
  116. package/src/cli/mcp_config.js +9 -0
  117. package/src/cli/memory_setup.js +12 -0
  118. package/src/cli/model_catalog.js +23 -0
  119. package/src/cli/model_normalize.js +12 -0
  120. package/src/cli/model_switch.js +11 -0
  121. package/src/cli/models.js +13 -0
  122. package/src/cli/nous_subscription.js +12 -0
  123. package/src/cli/oneshot.js +6 -0
  124. package/src/cli/pairing.js +21 -0
  125. package/src/cli/platforms.js +14 -0
  126. package/src/cli/plugins.js +4 -0
  127. package/src/cli/plugins_cmd.js +21 -0
  128. package/src/cli/profiles_cli.js +6 -0
  129. package/src/cli/providers.js +18 -0
  130. package/src/cli/pty_bridge.js +16 -0
  131. package/src/cli/relaunch.js +7 -0
  132. package/src/cli/runtime_provider.js +9 -0
  133. package/src/cli/setup.js +131 -0
  134. package/src/cli/skills_config.js +6 -0
  135. package/src/cli/skills_hub.js +8 -0
  136. package/src/cli/slack_cli.js +17 -0
  137. package/src/cli/status.js +10 -0
  138. package/src/cli/timeouts.js +5 -0
  139. package/src/cli/tips.js +14 -0
  140. package/src/cli/tools_config.js +15 -0
  141. package/src/cli/uninstall.js +8 -0
  142. package/src/cli/vercel_auth.js +13 -0
  143. package/src/cli/voice.js +6 -0
  144. package/src/cli/web_server.js +13 -0
  145. package/src/cli/webhook.js +12 -0
  146. package/src/commands/profile.js +72 -0
  147. package/src/commands/registry.js +94 -0
  148. package/src/config.js +125 -0
  149. package/src/context/engine.js +42 -0
  150. package/src/cron/cron-parse.js +27 -0
  151. package/src/cron/scheduler.js +63 -0
  152. package/src/db.js +178 -0
  153. package/src/gateway/base.js +13 -0
  154. package/src/gateway/builtin_hooks/boot.js +5 -0
  155. package/src/gateway/builtin_hooks/broadcast.js +3 -0
  156. package/src/gateway/builtin_hooks/deny.js +6 -0
  157. package/src/gateway/builtin_hooks/index.js +17 -0
  158. package/src/gateway/builtin_hooks/presence.js +4 -0
  159. package/src/gateway/builtin_hooks/routing.js +7 -0
  160. package/src/gateway/helpers.js +27 -0
  161. package/src/gateway/platforms/api_server.js +21 -0
  162. package/src/gateway/platforms/bluebubbles.js +32 -0
  163. package/src/gateway/platforms/dingtalk.js +32 -0
  164. package/src/gateway/platforms/discord.js +24 -0
  165. package/src/gateway/platforms/email.js +51 -0
  166. package/src/gateway/platforms/feishu.js +32 -0
  167. package/src/gateway/platforms/feishu_comment.js +12 -0
  168. package/src/gateway/platforms/feishu_comment_rules.js +11 -0
  169. package/src/gateway/platforms/homeassistant.js +32 -0
  170. package/src/gateway/platforms/matrix.js +40 -0
  171. package/src/gateway/platforms/mattermost.js +29 -0
  172. package/src/gateway/platforms/qqbot.js +32 -0
  173. package/src/gateway/platforms/signal.js +33 -0
  174. package/src/gateway/platforms/slack.js +34 -0
  175. package/src/gateway/platforms/sms.js +34 -0
  176. package/src/gateway/platforms/telegram.js +38 -0
  177. package/src/gateway/platforms/telegram_network.js +17 -0
  178. package/src/gateway/platforms/webhook.js +19 -0
  179. package/src/gateway/platforms/wecom.js +32 -0
  180. package/src/gateway/platforms/wecom_callback.js +15 -0
  181. package/src/gateway/platforms/wecom_crypto.js +16 -0
  182. package/src/gateway/platforms/weixin.js +32 -0
  183. package/src/gateway/platforms/whatsapp.js +40 -0
  184. package/src/gateway/platforms/yuanbao.js +9 -0
  185. package/src/gateway/platforms/yuanbao_media.js +5 -0
  186. package/src/gateway/platforms/yuanbao_proto.js +9 -0
  187. package/src/gateway/platforms/yuanbao_sticker.js +6 -0
  188. package/src/gateway/run.js +42 -0
  189. package/src/gateway/service.js +143 -0
  190. package/src/home.js +44 -0
  191. package/src/index.js +47 -0
  192. package/src/mcp/server.js +49 -0
  193. package/src/observability/debug.js +31 -0
  194. package/src/observability/log.js +38 -0
  195. package/src/plugins/achievements/index.js +9 -0
  196. package/src/plugins/cockpit/index.js +8 -0
  197. package/src/plugins/context_engine/index.js +13 -0
  198. package/src/plugins/disk_cleanup/index.js +22 -0
  199. package/src/plugins/google_meet/index.js +19 -0
  200. package/src/plugins/image_gen/index.js +5 -0
  201. package/src/plugins/manager.js +66 -0
  202. package/src/plugins/memory/_index.js +8 -0
  203. package/src/plugins/memory/byterover.js +25 -0
  204. package/src/plugins/memory/hindsight.js +25 -0
  205. package/src/plugins/memory/holographic.js +31 -0
  206. package/src/plugins/memory/honcho.js +25 -0
  207. package/src/plugins/memory/mem0.js +25 -0
  208. package/src/plugins/memory/openviking.js +25 -0
  209. package/src/plugins/memory/provider.js +35 -0
  210. package/src/plugins/memory/retaindb.js +25 -0
  211. package/src/plugins/memory/supermemory.js +25 -0
  212. package/src/plugins/observability/index.js +18 -0
  213. package/src/plugins/platforms/index.js +20 -0
  214. package/src/plugins/spotify/index.js +22 -0
  215. package/src/rl/atropos.js +22 -0
  216. package/src/rl/cli.js +18 -0
  217. package/src/sessions.js +84 -0
  218. package/src/skills/index.js +49 -0
  219. package/src/skin/engine.js +81 -0
  220. package/src/swe/runner.js +26 -0
  221. package/src/time.js +25 -0
  222. package/src/tools/ansi_strip.js +8 -0
  223. package/src/tools/approval.js +15 -0
  224. package/src/tools/bash.js +35 -0
  225. package/src/tools/binary_extensions.js +22 -0
  226. package/src/tools/browser.js +48 -0
  227. package/src/tools/budget_config.js +13 -0
  228. package/src/tools/checkpoint.js +29 -0
  229. package/src/tools/clarify.js +15 -0
  230. package/src/tools/code_execution.js +27 -0
  231. package/src/tools/credential_files.js +16 -0
  232. package/src/tools/cronjob.js +16 -0
  233. package/src/tools/debug_helpers.js +9 -0
  234. package/src/tools/delegate.js +28 -0
  235. package/src/tools/discord_tool.js +13 -0
  236. package/src/tools/edit.js +31 -0
  237. package/src/tools/env_passthrough.js +15 -0
  238. package/src/tools/environments/base.js +26 -0
  239. package/src/tools/environments/daytona.js +48 -0
  240. package/src/tools/environments/docker.js +14 -0
  241. package/src/tools/environments/file_sync.js +60 -0
  242. package/src/tools/environments/index.js +36 -0
  243. package/src/tools/environments/local.js +31 -0
  244. package/src/tools/environments/modal.js +33 -0
  245. package/src/tools/environments/singularity.js +38 -0
  246. package/src/tools/environments/ssh.js +14 -0
  247. package/src/tools/environments/vercel_sandbox.js +47 -0
  248. package/src/tools/feishu_doc.js +15 -0
  249. package/src/tools/feishu_drive.js +14 -0
  250. package/src/tools/file_operations.js +17 -0
  251. package/src/tools/file_state.js +16 -0
  252. package/src/tools/file_tools.js +23 -0
  253. package/src/tools/fuzzy_match.js +8 -0
  254. package/src/tools/grep.js +51 -0
  255. package/src/tools/homeassistant_tool.js +15 -0
  256. package/src/tools/image_gen.js +33 -0
  257. package/src/tools/interrupt.js +18 -0
  258. package/src/tools/managed_tool_gateway.js +11 -0
  259. package/src/tools/mcp_oauth.js +21 -0
  260. package/src/tools/mcp_oauth_manager.js +20 -0
  261. package/src/tools/mcp_tool.js +36 -0
  262. package/src/tools/memory.js +66 -0
  263. package/src/tools/mixture_of_agents.js +14 -0
  264. package/src/tools/neutts_synth.js +13 -0
  265. package/src/tools/openrouter_client.js +13 -0
  266. package/src/tools/osv_check.js +11 -0
  267. package/src/tools/patch_parser.js +42 -0
  268. package/src/tools/path_security.js +16 -0
  269. package/src/tools/process_registry.js +17 -0
  270. package/src/tools/read.js +26 -0
  271. package/src/tools/registry.js +54 -0
  272. package/src/tools/rl_training.js +13 -0
  273. package/src/tools/schema_sanitizer.js +18 -0
  274. package/src/tools/send_message.js +32 -0
  275. package/src/tools/session_search.js +23 -0
  276. package/src/tools/skill_manager.js +17 -0
  277. package/src/tools/skill_usage.js +20 -0
  278. package/src/tools/skills_guard.js +17 -0
  279. package/src/tools/skills_hub.js +31 -0
  280. package/src/tools/skills_index.js +14 -0
  281. package/src/tools/skills_sync.js +19 -0
  282. package/src/tools/skills_tool.js +11 -0
  283. package/src/tools/slash_confirm.js +16 -0
  284. package/src/tools/terminal.js +29 -0
  285. package/src/tools/tirith_security.js +25 -0
  286. package/src/tools/todo.js +54 -0
  287. package/src/tools/tool_backend_helpers.js +26 -0
  288. package/src/tools/tool_output_limits.js +15 -0
  289. package/src/tools/tool_result_storage.js +20 -0
  290. package/src/tools/transcription.js +19 -0
  291. package/src/tools/tts.js +19 -0
  292. package/src/tools/url_safety.js +15 -0
  293. package/src/tools/vision.js +18 -0
  294. package/src/tools/voice_mode.js +10 -0
  295. package/src/tools/web_search.js +37 -0
  296. package/src/tools/web_tools.js +18 -0
  297. package/src/tools/website_policy.js +14 -0
  298. package/src/tools/write.js +25 -0
  299. package/src/tools/xai_http.js +13 -0
  300. package/src/tools/yuanbao_tools.js +13 -0
  301. package/src/toolset_distributions.js +18 -0
  302. package/src/toolsets.js +26 -0
  303. package/src/tui/index.js +26 -0
  304. package/src/utils.js +54 -0
  305. package/src/web/app.js +547 -0
  306. package/src/web/index.html +167 -0
  307. package/src/web/server.js +109 -0
@@ -0,0 +1,37 @@
1
+ import { logger } from '../observability/log.js'
2
+
3
+ const log = logger('pi-bridge')
4
+
5
+ let _piAi = null
6
+ async function pi() {
7
+ if (_piAi) return _piAi
8
+ _piAi = await import('@mariozechner/pi-ai')
9
+ return _piAi
10
+ }
11
+
12
+ export async function callLLM({ messages, tools = [], model, provider = 'anthropic' } = {}) {
13
+ const m = await pi()
14
+ const modelObj = m.getModel ? m.getModel(provider, model) : { provider, id: model }
15
+ const apiKey = m.getEnvApiKey ? m.getEnvApiKey(provider) : process.env[providerEnv(provider)]
16
+ if (!apiKey) throw new Error(`pi-bridge: no API key for ${provider} (set ${providerEnv(provider)})`)
17
+ const result = await m.complete({ model: modelObj, apiKey, messages: messages.map(adaptMessage), tools: tools.map(adaptTool) })
18
+ log.info('completed', { model: model || 'default', usage: result.usage })
19
+ return adaptResponse(result)
20
+ }
21
+
22
+ function providerEnv(p) { return ({ anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', groq: 'GROQ_API_KEY' })[p] || `${String(p).toUpperCase()}_API_KEY` }
23
+
24
+ function adaptMessage(m) {
25
+ if (m.role === 'tool') return { role: 'tool', tool_call_id: m.tool_call_id, content: m.content }
26
+ return { role: m.role, content: m.content || '', tool_calls: m.tool_calls }
27
+ }
28
+
29
+ function adaptTool(t) {
30
+ return { name: t.name, description: t.description, input_schema: t.parameters || t.input_schema || { type: 'object', properties: {} } }
31
+ }
32
+
33
+ function adaptResponse(r) {
34
+ const content = typeof r.content === 'string' ? r.content : (Array.isArray(r.content) ? r.content.filter(c => c.type === 'text').map(c => c.text).join('') : '')
35
+ const tool_calls = (Array.isArray(r.content) ? r.content.filter(c => c.type === 'tool_use').map(c => ({ id: c.id, name: c.name, arguments: c.input })) : (r.tool_calls || []))
36
+ return { content, tool_calls, raw: r }
37
+ }
@@ -0,0 +1,12 @@
1
+ import { SUMMARY_PREFIX } from './compress/index.js'
2
+ export function buildSystemPrompt({ persona = '', skills = [], context = [], cacheBreakpoint = true } = {}) {
3
+ const parts = []
4
+ if (persona) parts.push(persona)
5
+ if (skills.length) parts.push('## Available skills\n' + skills.map(s => '- ' + s.name + ': ' + (s.description || '')).join('\n'))
6
+ if (context.length) parts.push('## Context\n' + context.map(c => '[' + c.name + ']\n' + c.body).join('\n\n'))
7
+ return { content: parts.join('\n\n'), cacheBreakpoint }
8
+ }
9
+ export function injectSummaryHandoff(messages, summary) {
10
+ if (!summary) return messages
11
+ return [...messages, { role: 'user', content: SUMMARY_PREFIX + '\n\n' + summary }]
12
+ }
@@ -0,0 +1,24 @@
1
+ export const CACHE_BREAKPOINT_MAX = 4
2
+ export function annotateBreakpoints(messages, { provider = 'anthropic' } = {}) {
3
+ if (provider !== 'anthropic') return messages
4
+ const out = messages.map(m => ({ ...m }))
5
+ const candidates = []
6
+ for (let i = out.length - 1; i >= 0; i--) {
7
+ const r = out[i].role
8
+ if (r === 'system' || r === 'user') candidates.push(i)
9
+ if (candidates.length >= CACHE_BREAKPOINT_MAX) break
10
+ }
11
+ for (const i of candidates.slice(0, CACHE_BREAKPOINT_MAX)) {
12
+ const m = out[i]
13
+ if (typeof m.content === 'string') m.content = [{ type: 'text', text: m.content, cache_control: { type: 'ephemeral' } }]
14
+ else if (Array.isArray(m.content) && m.content.length) m.content[m.content.length - 1] = { ...m.content[m.content.length - 1], cache_control: { type: 'ephemeral' } }
15
+ }
16
+ return out
17
+ }
18
+ export function countBreakpoints(messages) {
19
+ let n = 0
20
+ for (const m of messages) {
21
+ if (Array.isArray(m.content)) for (const p of m.content) if (p?.cache_control) n++
22
+ }
23
+ return n
24
+ }
@@ -0,0 +1,12 @@
1
+ const _windows = new Map()
2
+ export function record(provider, e) {
3
+ const w = _windows.get(provider) || { count: 0, until: 0 }
4
+ w.count++
5
+ const m = String(e?.message || e || '').match(/retry.?after[:\s]+(\d+)/i) || String(e?.headers?.get?.('retry-after') || '').match(/(\d+)/)
6
+ if (m) w.until = Date.now() + Number(m[1]) * 1000
7
+ _windows.set(provider, w)
8
+ return w
9
+ }
10
+ export function shouldThrottle(provider) { const w = _windows.get(provider); return w ? Date.now() < w.until : false }
11
+ export function clear(provider) { _windows.delete(provider) }
12
+ export function snapshot() { return Object.fromEntries(_windows) }
@@ -0,0 +1,25 @@
1
+ export const SECRET_PATTERNS = [
2
+ [/sk-[A-Za-z0-9-_]{20,}/g, 'openai-key'],
3
+ [/sk-ant-[A-Za-z0-9-_]{20,}/g, 'anthropic-key'],
4
+ [/ghp_[A-Za-z0-9]{36}/g, 'github-pat'],
5
+ [/gho_[A-Za-z0-9]{36}/g, 'github-oauth'],
6
+ [/xox[baprs]-[A-Za-z0-9-]{10,}/g, 'slack-token'],
7
+ [/AKIA[0-9A-Z]{16}/g, 'aws-key-id'],
8
+ [/[a-zA-Z0-9._%+-]+:[^@\s]{4,}@[a-zA-Z0-9.-]+/g, 'url-credentials'],
9
+ [/Bearer\s+[A-Za-z0-9._-]{20,}/gi, 'bearer-token'],
10
+ [/[\w-]{20,}\.[\w-]{6,}\.[\w-]{20,}/g, 'jwt'],
11
+ [/-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----/g, 'private-key'],
12
+ ]
13
+ export function redactSensitive(text) {
14
+ let out = String(text)
15
+ for (const [re] of SECRET_PATTERNS) out = out.replace(re, '[REDACTED]')
16
+ return out
17
+ }
18
+ export function detectSecrets(text) {
19
+ const found = []
20
+ for (const [re, kind] of SECRET_PATTERNS) {
21
+ const m = String(text).match(re)
22
+ if (m) for (const s of m) found.push({ kind, value: s.slice(0, 20) + '…' })
23
+ }
24
+ return found
25
+ }
@@ -0,0 +1,17 @@
1
+ import { isRetryable } from './error_classifier.js'
2
+ export async function retryAsync(fn, { attempts = 3, backoff = 200, factor = 2, max = 10_000, jitter = 0.2 } = {}) {
3
+ let last
4
+ for (let i = 0; i < attempts; i++) {
5
+ try { return await fn(i) } catch (e) {
6
+ last = e
7
+ if (i === attempts - 1) break
8
+ if (!isRetryable(e)) break
9
+ const wait = Math.min(max, backoff * Math.pow(factor, i)) * (1 + (Math.random() * 2 - 1) * jitter)
10
+ await new Promise(r => setTimeout(r, wait))
11
+ }
12
+ }
13
+ throw last
14
+ }
15
+ export async function withTimeout(fn, ms) {
16
+ return await Promise.race([fn(), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout after ' + ms + 'ms')), ms))])
17
+ }
@@ -0,0 +1,16 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { getFophHome } from '../home.js'
4
+ function hookFile() { return path.join(getFophHome(), 'shell-hooks.json') }
5
+ export function loadHooks() { try { return JSON.parse(fs.readFileSync(hookFile(), 'utf8')) } catch { return { pre_run: [], post_run: [] } } }
6
+ export function saveHooks(h) { fs.writeFileSync(hookFile(), JSON.stringify(h, null, 2), 'utf8') }
7
+ export function addHook(stage, command) { const h = loadHooks(); (h[stage] = h[stage] || []).push(command); saveHooks(h); return h }
8
+ export async function runHooks(stage, ctx = {}) {
9
+ const { spawnSync } = await import('node:child_process')
10
+ const out = []
11
+ for (const cmd of (loadHooks()[stage] || [])) {
12
+ const r = spawnSync(process.platform === 'win32' ? 'cmd' : 'sh', [process.platform === 'win32' ? '/c' : '-c', cmd], { encoding: 'utf8', env: { ...process.env, ...ctx.env } })
13
+ out.push({ cmd, exitCode: r.status, stdout: r.stdout, stderr: r.stderr })
14
+ }
15
+ return out
16
+ }
@@ -0,0 +1,16 @@
1
+ import { findSkill } from '../skills/index.js'
2
+
3
+ export function buildSkillUserMessage(name, args = '') {
4
+ const s = findSkill(name)
5
+ if (!s) return null
6
+ const argLine = args ? `\nArguments: ${args}\n` : '\n'
7
+ return { role: 'user', content: `[skill:${name}]${argLine}\n${s.body}` }
8
+ }
9
+ export function isSkillCommand(input) {
10
+ return typeof input === 'string' && /^\/skill\s+\S+/.test(input.trim())
11
+ }
12
+ export function parseSkillCommand(input) {
13
+ const m = String(input).trim().match(/^\/skill\s+(\S+)\s*(.*)$/)
14
+ if (!m) return null
15
+ return { name: m[1], args: m[2] }
16
+ }
@@ -0,0 +1,12 @@
1
+ import { findSkill } from '../skills/index.js'
2
+ const REF_RE = /@skill\/([\w-]+)(?:\s+([^\n@]*))?/g
3
+ export function preprocessSkillRefs(text) {
4
+ if (!text || typeof text !== 'string') return text
5
+ return text.replace(REF_RE, (m, name, args) => {
6
+ const s = findSkill(name)
7
+ return s ? '\n[skill:' + name + ']\n' + s.body + '\n' : m
8
+ })
9
+ }
10
+ export function listSkillRefs(text) {
11
+ return [...String(text || '').matchAll(REF_RE)].map(m => ({ name: m[1], args: m[2]?.trim() || '' }))
12
+ }
@@ -0,0 +1,14 @@
1
+ import { listSkills, findSkill } from '../skills/index.js'
2
+ export function fuzzyFindSkill(needle) {
3
+ const all = listSkills()
4
+ const lower = String(needle).toLowerCase()
5
+ const exact = all.find(s => s.name.toLowerCase() === lower)
6
+ if (exact) return exact
7
+ const starts = all.find(s => s.name.toLowerCase().startsWith(lower))
8
+ if (starts) return starts
9
+ return all.find(s => s.name.toLowerCase().includes(lower)) || null
10
+ }
11
+ export function skillByCategory(category) {
12
+ return listSkills().filter(s => s.frontmatter?.category === category)
13
+ }
14
+ export function skillExists(name) { return Boolean(findSkill(name)) }
@@ -0,0 +1,17 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ const HINT_FILES = ['CLAUDE.md', 'AGENTS.md', '.freddie-context', 'README.md']
4
+ export function collectHints({ cwd = process.cwd(), maxDepth = 3 } = {}) {
5
+ const hints = []
6
+ let dir = cwd
7
+ for (let d = 0; d < maxDepth; d++) {
8
+ for (const f of HINT_FILES) {
9
+ const p = path.join(dir, f)
10
+ if (fs.existsSync(p)) { try { hints.push({ path: p, body: fs.readFileSync(p, 'utf8').slice(0, 4000) }) } catch {} }
11
+ }
12
+ const parent = path.dirname(dir)
13
+ if (parent === dir) break
14
+ dir = parent
15
+ }
16
+ return hints
17
+ }
@@ -0,0 +1,13 @@
1
+ export function generateTitle(prompt, { maxWords = 8 } = {}) {
2
+ const text = String(prompt || '').replace(/[\n\r]+/g, ' ').trim()
3
+ if (!text) return 'untitled'
4
+ const words = text.split(/\s+/).slice(0, maxWords)
5
+ let title = words.join(' ')
6
+ if (title.length > 60) title = title.slice(0, 57) + '…'
7
+ return title.charAt(0).toUpperCase() + title.slice(1)
8
+ }
9
+ export async function llmTitle(messages, { callLLM, model = null } = {}) {
10
+ if (!callLLM) return generateTitle(messages?.[0]?.content || '')
11
+ const out = await callLLM({ model, messages: [{ role: 'user', content: 'Title this conversation in <=8 words. Reply only with the title.\n\n' + JSON.stringify(messages.slice(0, 4)) }] })
12
+ return (out?.content || '').trim().replace(/^["']|["']$/g, '') || generateTitle(messages?.[0]?.content || '')
13
+ }
@@ -0,0 +1,9 @@
1
+ export function compressTrajectory({ messages = [], maxKeep = 20 } = {}) {
2
+ if (messages.length <= maxKeep * 2) return { compressed: messages, removed: 0 }
3
+ const head = messages.slice(0, maxKeep)
4
+ const tail = messages.slice(-maxKeep)
5
+ const middleCount = messages.length - head.length - tail.length
6
+ const summary = { role: 'system', content: `[trajectory.compressed] ${middleCount} middle messages elided` }
7
+ return { compressed: [...head, summary, ...tail], removed: middleCount }
8
+ }
9
+ export function expandTrajectory(trajectory) { return trajectory.compressed }
@@ -0,0 +1,16 @@
1
+ const PRICING = {
2
+ 'claude-opus-4-7': [15, 75], 'claude-sonnet-4-6': [3, 15], 'claude-haiku-4-5': [0.8, 4],
3
+ 'gpt-5': [10, 30], 'gpt-5-mini': [0.25, 2], 'gpt-4o': [2.5, 10], 'gpt-4o-mini': [0.15, 0.6],
4
+ 'o3': [15, 60], 'o3-mini': [1.1, 4.4], 'o1': [15, 60],
5
+ 'gemini-2.5-pro': [1.25, 10], 'gemini-2.5-flash': [0.075, 0.3],
6
+ 'grok-3': [3, 15], 'grok-4': [5, 25], 'deepseek-v3': [0.27, 1.1],
7
+ }
8
+ export function priceFor(model) {
9
+ if (PRICING[model]) return PRICING[model]
10
+ for (const [k, v] of Object.entries(PRICING)) if (model.startsWith(k)) return v
11
+ return [0, 0]
12
+ }
13
+ export function calculateCost({ model, prompt_tokens = 0, completion_tokens = 0 }) {
14
+ const [pIn, pOut] = priceFor(model)
15
+ return (prompt_tokens / 1_000_000) * pIn + (completion_tokens / 1_000_000) * pOut
16
+ }
package/src/auth.js ADDED
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { getFophHome } from './home.js'
4
+
5
+ class FileAuthStore {
6
+ constructor() { this.dir = path.join(getFophHome(), 'auth'); fs.mkdirSync(this.dir, { recursive: true }) }
7
+ _path(name) { return path.join(this.dir, name + '.json') }
8
+ async setCredential(name, value) {
9
+ fs.writeFileSync(this._path(name), JSON.stringify({ name, value, updated: Date.now() }), { encoding: 'utf8', mode: 0o600 })
10
+ return { name, stored: true }
11
+ }
12
+ async getCredential(name) {
13
+ const p = this._path(name)
14
+ if (!fs.existsSync(p)) return null
15
+ return JSON.parse(fs.readFileSync(p, 'utf8'))
16
+ }
17
+ async listCredentials() {
18
+ return fs.readdirSync(this.dir).filter(f => f.endsWith('.json')).map(f => f.replace(/\.json$/, ''))
19
+ }
20
+ async deleteCredential(name) {
21
+ const p = this._path(name)
22
+ if (fs.existsSync(p)) fs.unlinkSync(p)
23
+ return { name, deleted: true }
24
+ }
25
+ }
26
+
27
+ let _store = null
28
+ export function getAuthStore() {
29
+ if (!_store) _store = new FileAuthStore()
30
+ return _store
31
+ }
32
+ export function resetAuthStoreForTests() { _store = null }
33
+
34
+ const PROVIDERS = ['anthropic', 'openai', 'groq', 'openrouter', 'xai', 'gemini', 'bedrock', 'codex', 'kimi', 'zai', 'deepseek', 'mistral', 'perplexity']
35
+ const ENV_OF = { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', groq: 'GROQ_API_KEY', openrouter: 'OPENROUTER_API_KEY', xai: 'XAI_API_KEY', gemini: 'GEMINI_API_KEY', bedrock: 'AWS_ACCESS_KEY_ID', codex: 'OPENAI_API_KEY', kimi: 'KIMI_API_KEY', zai: 'ZAI_API_KEY', deepseek: 'DEEPSEEK_API_KEY', mistral: 'MISTRAL_API_KEY', perplexity: 'PERPLEXITY_API_KEY' }
36
+
37
+ export function isKnownAuthProvider(name) { return PROVIDERS.includes(name) }
38
+ export function listAuthProviders() { return [...PROVIDERS] }
39
+ export function envForProvider(name) { return ENV_OF[name] || null }
40
+
41
+ export async function hasUsableSecret(provider) {
42
+ const env = envForProvider(provider)
43
+ if (!env) return false
44
+ if (process.env[env]) return true
45
+ const cred = await getAuthStore().getCredential(env)
46
+ return Boolean(cred?.value)
47
+ }
48
+
49
+ export async function clearProviderAuth(provider) {
50
+ const env = envForProvider(provider)
51
+ if (!env) return false
52
+ await getAuthStore().deleteCredential(env)
53
+ return true
54
+ }
55
+
56
+ export function isExpiring(token, { skewSeconds = 60 } = {}) {
57
+ if (!token || typeof token !== 'object') return true
58
+ const exp = token.expires_at || token.exp
59
+ if (!exp) return false
60
+ const now = Math.floor(Date.now() / 1000)
61
+ const expSec = typeof exp === 'string' ? Math.floor(new Date(exp).getTime() / 1000) : exp
62
+ return expSec - now < skewSeconds
63
+ }
64
+
65
+ export function decodeJwtClaims(jwt) {
66
+ if (typeof jwt !== 'string') return null
67
+ const parts = jwt.split('.')
68
+ if (parts.length < 2) return null
69
+ try { return JSON.parse(Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')) } catch { return null }
70
+ }
71
+
72
+ export function tokenFingerprint(token) {
73
+ const s = typeof token === 'string' ? token : (token?.access_token || token?.value || '')
74
+ if (!s) return ''
75
+ return s.slice(0, 4) + '…' + s.slice(-4)
76
+ }
77
+
78
+ export async function getProviderAuthState(provider) {
79
+ return {
80
+ provider,
81
+ env: envForProvider(provider),
82
+ hasSecret: await hasUsableSecret(provider),
83
+ }
84
+ }
package/src/batch.js ADDED
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { runTurn } from './agent/machine.js'
4
+ import { getFophHome } from './home.js'
5
+ import { randomUUID } from 'node:crypto'
6
+
7
+ export async function runBatch({ prompts = [], concurrency = 4, model, callLLM } = {}) {
8
+ if (!Array.isArray(prompts) || prompts.length === 0) throw new Error('prompts required')
9
+ const id = randomUUID()
10
+ const dir = path.join(getFophHome(), 'batches')
11
+ fs.mkdirSync(dir, { recursive: true })
12
+ const file = path.join(dir, id + '.jsonl')
13
+ const stream = fs.createWriteStream(file, { flags: 'a' })
14
+ const queue = prompts.map((p, i) => ({ i, p }))
15
+ const results = new Array(prompts.length)
16
+ const workers = Array.from({ length: Math.min(concurrency, prompts.length) }, async () => {
17
+ while (queue.length) {
18
+ const job = queue.shift()
19
+ if (!job) break
20
+ try {
21
+ const out = await runTurn({ prompt: job.p, model, callLLM, timeoutMs: 60000 })
22
+ results[job.i] = { i: job.i, prompt: job.p, result: out.result, error: out.error }
23
+ } catch (e) {
24
+ results[job.i] = { i: job.i, prompt: job.p, error: String(e?.message || e) }
25
+ }
26
+ stream.write(JSON.stringify(results[job.i]) + '\n')
27
+ }
28
+ })
29
+ await Promise.all(workers)
30
+ await new Promise(r => stream.end(r))
31
+ return { id, file, results }
32
+ }
@@ -0,0 +1,17 @@
1
+ import { getAuthStore } from '../auth.js'
2
+ import { listProviders, resolveKey } from '../agent/credential_sources.js'
3
+ export async function login(provider, key) {
4
+ const env = (provider.toUpperCase() + '_API_KEY')
5
+ await getAuthStore().setCredential(env, key)
6
+ return { provider, stored: env }
7
+ }
8
+ export async function logout(provider) {
9
+ const env = (provider.toUpperCase() + '_API_KEY')
10
+ await getAuthStore().deleteCredential(env)
11
+ return { provider, removed: env }
12
+ }
13
+ export async function status() {
14
+ const out = []
15
+ for (const p of listProviders()) { const k = await resolveKey(p); out.push({ provider: p, source: k.source, hasKey: k.value != null }) }
16
+ return out
17
+ }
@@ -0,0 +1,9 @@
1
+ export function isAzureBaseUrl(url) {
2
+ if (!url) return false
3
+ return /\.openai\.azure\.com\b/i.test(String(url)) || /azure-api\.net\b/i.test(String(url))
4
+ }
5
+ export function detectFromEnv() {
6
+ const url = process.env.AZURE_OPENAI_ENDPOINT || process.env.OPENAI_BASE_URL
7
+ if (!url) return { azure: false }
8
+ return { azure: isAzureBaseUrl(url), endpoint: url, deployment: process.env.AZURE_OPENAI_DEPLOYMENT, apiVersion: process.env.AZURE_OPENAI_API_VERSION || '2024-08-01-preview' }
9
+ }
@@ -0,0 +1,17 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { getFophHome } from '../home.js'
4
+ export async function createBackup({ outFile } = {}) {
5
+ const home = getFophHome()
6
+ const out = outFile || path.join(home, 'backups', 'freddie-' + new Date().toISOString().replace(/[:.]/g, '-') + '.tar.gz')
7
+ fs.mkdirSync(path.dirname(out), { recursive: true })
8
+ const { spawnSync } = await import('node:child_process')
9
+ const r = spawnSync('tar', ['-czf', out, '-C', path.dirname(home), path.basename(home)], { encoding: 'utf8' })
10
+ if (r.status === 0) return { ok: true, file: out, size: fs.statSync(out).size }
11
+ return { ok: false, stderr: r.stderr, hint: 'tar may be missing on Windows; install GNU tar or use a different archiver.' }
12
+ }
13
+ export function listBackups() {
14
+ const dir = path.join(getFophHome(), 'backups')
15
+ if (!fs.existsSync(dir)) return []
16
+ return fs.readdirSync(dir).filter(f => f.endsWith('.tar.gz')).map(f => ({ name: f, file: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
17
+ }
@@ -0,0 +1,13 @@
1
+ import { getActiveSkin } from '../skin/engine.js'
2
+ const ART = [
3
+ ' _____ _ _ _ ',
4
+ ' |_ _| |__ ___ | |_| |__ ',
5
+ ' | | | _ \\ / _ \\| __| _ \\ ',
6
+ ' | | | | | | (_)| |_| | | |',
7
+ ' |_| |_| |_|\\___/ \\__|_| |_|',
8
+ ]
9
+ export function renderBanner() {
10
+ const skin = getActiveSkin()
11
+ return ART.join('\n') + '\n' + skin.branding.welcome
12
+ }
13
+ export function printBanner(out = process.stdout) { out.write(renderBanner() + '\n') }
@@ -0,0 +1,11 @@
1
+ let _puppeteer = null
2
+ async function probe() { if (_puppeteer !== null) return _puppeteer; try { _puppeteer = (await import('puppeteer-core')).default } catch { _puppeteer = false } return _puppeteer }
3
+ export async function connectToBrowser({ wsEndpoint, browserURL } = {}) {
4
+ const p = await probe()
5
+ if (!p) return { error: 'puppeteer-core not installed' }
6
+ const browser = wsEndpoint ? await p.connect({ browserWSEndpoint: wsEndpoint }) : await p.connect({ browserURL })
7
+ return { browser, pages: await browser.pages() }
8
+ }
9
+ export async function attachExisting(port = 9222) {
10
+ return await connectToBrowser({ browserURL: 'http://127.0.0.1:' + port })
11
+ }
@@ -0,0 +1,5 @@
1
+ const _callbacks = new Map()
2
+ export function on(event, fn) { if (!_callbacks.has(event)) _callbacks.set(event, new Set()); _callbacks.get(event).add(fn); return () => _callbacks.get(event)?.delete(fn) }
3
+ export async function emit(event, payload) { const out = []; for (const fn of (_callbacks.get(event) || [])) try { out.push(await fn(payload)) } catch (e) { out.push({ error: String(e?.message || e) }) } return out }
4
+ export function listEvents() { return [..._callbacks.keys()] }
5
+ export function clearAll() { _callbacks.clear() }
@@ -0,0 +1,8 @@
1
+ import { paste } from './clipboard.js'
2
+ const HEAVY_THRESHOLD = 4000
3
+ export async function clawIntoMessage({ minLength = 50 } = {}) {
4
+ const text = (await paste()) || ''
5
+ if (text.length < minLength) return null
6
+ const trimmed = text.length > HEAVY_THRESHOLD ? text.slice(0, HEAVY_THRESHOLD) + '\n…[' + (text.length - HEAVY_THRESHOLD) + ' chars truncated]' : text
7
+ return { content: '[pasted]\n\n' + trimmed, originalLength: text.length, truncated: text.length > HEAVY_THRESHOLD }
8
+ }
@@ -0,0 +1,19 @@
1
+ import { getActiveSkin } from '../skin/engine.js'
2
+ import { hex } from './colors.js'
3
+ export function info(msg) { return hex(getActiveSkin().colors.banner_text, msg) }
4
+ export function success(msg) { return hex('#22c55e', '✓ ' + msg) }
5
+ export function warning(msg) { return hex('#fbbf24', '! ' + msg) }
6
+ export function error(msg) { return hex('#ef4444', '✗ ' + msg) }
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 + '┘'
10
+ }
11
+ export function table(rows) {
12
+ if (!rows.length) return ''
13
+ const keys = Object.keys(rows[0])
14
+ const widths = keys.map(k => Math.max(k.length, ...rows.map(r => String(r[k] ?? '').length)))
15
+ const sep = '+-' + widths.map(w => '-'.repeat(w)).join('-+-') + '-+'
16
+ const head = '| ' + keys.map((k, i) => k.padEnd(widths[i])).join(' | ') + ' |'
17
+ const body = rows.map(r => '| ' + keys.map((k, i) => String(r[k] ?? '').padEnd(widths[i])).join(' | ') + ' |').join('\n')
18
+ return [sep, head, sep, body, sep].join('\n')
19
+ }
@@ -0,0 +1,24 @@
1
+ import { spawn } from 'node:child_process'
2
+ function run(cmd, input = null) {
3
+ return new Promise((resolve) => {
4
+ const child = spawn(cmd[0], cmd.slice(1))
5
+ let stdout = ''
6
+ child.stdout?.on('data', d => stdout += d.toString())
7
+ child.on('close', code => resolve({ code, stdout }))
8
+ child.on('error', () => resolve({ code: -1, stdout: '' }))
9
+ if (input != null) { child.stdin?.write(input); child.stdin?.end() }
10
+ })
11
+ }
12
+ export async function copy(text) {
13
+ if (process.platform === 'win32') return run(['clip'], text)
14
+ if (process.platform === 'darwin') return run(['pbcopy'], text)
15
+ const xclip = await run(['xclip', '-selection', 'clipboard'], text)
16
+ if (xclip.code === 0) return xclip
17
+ return run(['xsel', '--clipboard', '--input'], text)
18
+ }
19
+ export async function paste() {
20
+ if (process.platform === 'win32') return (await run(['powershell', '-NoProfile', '-Command', 'Get-Clipboard'])).stdout
21
+ if (process.platform === 'darwin') return (await run(['pbpaste'])).stdout
22
+ const x = await run(['xclip', '-selection', 'clipboard', '-o'])
23
+ return x.code === 0 ? x.stdout : (await run(['xsel', '--clipboard', '--output'])).stdout
24
+ }
@@ -0,0 +1,8 @@
1
+ export const CODEX_MODELS = ['o3', 'o3-mini', 'o1', 'o1-mini', 'gpt-5', 'gpt-5-mini']
2
+ export function isCodexModel(id) {
3
+ if (!id) return false
4
+ return CODEX_MODELS.some(m => id === m || id.startsWith(m))
5
+ }
6
+ export function recommendCodexModel(scenario) {
7
+ return ({ reasoning: 'o3', fast: 'o3-mini', flagship: 'gpt-5', cheap: 'gpt-5-mini' })[scenario] || 'o3-mini'
8
+ }
@@ -0,0 +1,13 @@
1
+ const RESET = '\x1b[0m'
2
+ const FG = { red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, white: 37, gray: 90 }
3
+ const BG = { red: 41, green: 42, yellow: 43, blue: 44, magenta: 45, cyan: 46, white: 47 }
4
+ const ATTR = { bold: 1, dim: 2, italic: 3, underline: 4 }
5
+ function wrap(code, s) { return '\x1b[' + code + 'm' + s + RESET }
6
+ export const fg = Object.fromEntries(Object.entries(FG).map(([k, c]) => [k, (s) => wrap(c, s)]))
7
+ export const bg = Object.fromEntries(Object.entries(BG).map(([k, c]) => [k, (s) => wrap(c, s)]))
8
+ export const attr = Object.fromEntries(Object.entries(ATTR).map(([k, c]) => [k, (s) => wrap(c, s)]))
9
+ export function hex(h, s) {
10
+ const r = parseInt(h.slice(1, 3), 16), g = parseInt(h.slice(3, 5), 16), b = parseInt(h.slice(5, 7), 16)
11
+ return '\x1b[38;2;' + r + ';' + g + ';' + b + 'm' + s + RESET
12
+ }
13
+ export function strip(s) { return String(s).replace(/\x1b\[[0-9;]*m/g, '') }
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { COMMAND_REGISTRY } from '../commands/registry.js'
4
+
5
+ export class SlashCommandCompleter {
6
+ constructor({ commands = COMMAND_REGISTRY } = {}) {
7
+ this.commands = commands
8
+ this.names = []
9
+ for (const c of commands) { this.names.push(c.name); for (const a of c.aliases || []) this.names.push(a) }
10
+ }
11
+ suggest(line) {
12
+ if (!line.startsWith('/')) return []
13
+ const stripped = line.slice(1)
14
+ const space = stripped.indexOf(' ')
15
+ if (space === -1) {
16
+ return this.names.filter(n => n.startsWith(stripped)).map(n => ({ value: '/' + n, kind: 'command', display: '/' + n + this._argHint(n) }))
17
+ }
18
+ const cmd = stripped.slice(0, space)
19
+ const def = this.commands.find(c => c.name === cmd || (c.aliases || []).includes(cmd))
20
+ if (!def) return []
21
+ return [{ value: line, kind: 'args', display: '/' + def.name + ' ' + (def.args_hint || ''), description: def.description }]
22
+ }
23
+ _argHint(name) {
24
+ const def = this.commands.find(c => c.name === name || (c.aliases || []).includes(name))
25
+ return def?.args_hint ? ' ' + def.args_hint : ''
26
+ }
27
+ }
28
+
29
+ export class PathCompleter {
30
+ constructor({ cwd = process.cwd(), maxResults = 20, includeHidden = false } = {}) {
31
+ this.cwd = cwd
32
+ this.maxResults = maxResults
33
+ this.includeHidden = includeHidden
34
+ }
35
+ suggest(input) {
36
+ try {
37
+ const abs = path.isAbsolute(input) ? input : path.resolve(this.cwd, input)
38
+ const dir = input.endsWith('/') || input.endsWith(path.sep) ? abs : path.dirname(abs)
39
+ const stem = input.endsWith('/') || input.endsWith(path.sep) ? '' : path.basename(abs)
40
+ if (!fs.existsSync(dir)) return []
41
+ const ents = fs.readdirSync(dir, { withFileTypes: true })
42
+ const out = []
43
+ for (const e of ents) {
44
+ if (!this.includeHidden && e.name.startsWith('.')) continue
45
+ if (!e.name.toLowerCase().startsWith(stem.toLowerCase())) continue
46
+ const isDir = e.isDirectory()
47
+ const value = path.join(dir, e.name) + (isDir ? path.sep : '')
48
+ out.push({ value, kind: isDir ? 'dir' : 'file', display: e.name + (isDir ? '/' : '') })
49
+ if (out.length >= this.maxResults) break
50
+ }
51
+ return out
52
+ } catch { return [] }
53
+ }
54
+ }
55
+
56
+ export class FuzzyMatcher {
57
+ constructor(items, { keyFn = (x) => String(x) } = {}) {
58
+ this.items = items
59
+ this.keyFn = keyFn
60
+ }
61
+ score(query, target) {
62
+ const q = query.toLowerCase()
63
+ const t = target.toLowerCase()
64
+ if (!q) return 1
65
+ if (t === q) return 1000
66
+ if (t.startsWith(q)) return 500 - (t.length - q.length)
67
+ if (t.includes(q)) return 250 - (t.length - q.length)
68
+ let qi = 0, score = 0, prev = -1
69
+ for (let i = 0; i < t.length && qi < q.length; i++) {
70
+ if (t[i] === q[qi]) { score += (i - prev === 1 ? 5 : 1); prev = i; qi++ }
71
+ }
72
+ return qi === q.length ? score : 0
73
+ }
74
+ match(query, { limit = 10 } = {}) {
75
+ const scored = []
76
+ for (const it of this.items) {
77
+ const s = this.score(query, this.keyFn(it))
78
+ if (s > 0) scored.push({ item: it, score: s })
79
+ }
80
+ scored.sort((a, b) => b.score - a.score)
81
+ return scored.slice(0, limit).map(s => s.item)
82
+ }
83
+ }
84
+
85
+ export function createCompleter({ cwd } = {}) {
86
+ const slash = new SlashCommandCompleter()
87
+ const file = new PathCompleter({ cwd })
88
+ return {
89
+ suggest(line, cursor = line.length) {
90
+ const before = line.slice(0, cursor)
91
+ if (before.startsWith('/')) return slash.suggest(before)
92
+ const lastSpace = before.lastIndexOf(' ')
93
+ const tok = before.slice(lastSpace + 1)
94
+ if (tok.startsWith('./') || tok.startsWith('/') || tok.startsWith('~') || tok.includes('/')) return file.suggest(tok)
95
+ return []
96
+ },
97
+ }
98
+ }