saeeol 1.0.9 → 1.1.1

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 (63) hide show
  1. package/npm/bin/saeeol +42 -0
  2. package/npm/package.json +39 -0
  3. package/npm/postinstall.js +162 -0
  4. package/package.json +2 -2
  5. package/src/cli/cmd/mcp-refresh.ts +47 -0
  6. package/src/cli/cmd/mcp.ts +3 -1
  7. package/src/cli/cmd/tui/app-commands-core.tsx +11 -0
  8. package/src/cli/cmd/tui/app-commands-system.tsx +20 -0
  9. package/src/cli/cmd/tui/app-events.ts +43 -0
  10. package/src/cli/cmd/tui/app.tsx +4 -0
  11. package/src/cli/cmd/tui/component/dialog-model.tsx +2 -2
  12. package/src/cli/cmd/tui/component/prompt/use-prompt-memos.ts +1 -1
  13. package/src/cli/cmd/tui/component/use-connected.tsx +1 -1
  14. package/src/cli/cmd/tui/context/local.tsx +10 -3
  15. package/src/cli/cmd/tui/context/route.tsx +5 -1
  16. package/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +1 -1
  17. package/src/cli/cmd/tui/plugin/api.tsx +7 -3
  18. package/src/cli/cmd/tui/routes/local-models.tsx +151 -0
  19. package/src/cli/cmd/tui/routes/session/subagent-footer.tsx +1 -1
  20. package/src/cli/cmd/tui/util/model.ts +1 -1
  21. package/src/config/config-schema.ts +44 -0
  22. package/src/ltm/config.ts +124 -0
  23. package/src/ltm/events.ts +50 -0
  24. package/src/ltm/index.ts +12 -0
  25. package/src/ltm/memory/episodic.ts +83 -0
  26. package/src/ltm/memory/procedural.ts +102 -0
  27. package/src/ltm/memory/semantic.ts +80 -0
  28. package/src/ltm/pipeline.ts +155 -0
  29. package/src/ltm/retrieval.ts +62 -0
  30. package/src/ltm/scheduler.ts +55 -0
  31. package/src/ltm/store.ts +150 -0
  32. package/src/ltm/types.ts +108 -0
  33. package/src/mcp/index.ts +32 -1
  34. package/src/provider/custom-loaders.ts +12 -0
  35. package/src/provider/loader-local.ts +185 -0
  36. package/src/provider/local/embedder.ts +220 -0
  37. package/src/provider/local/events.ts +74 -0
  38. package/src/provider/local/gpu.ts +93 -0
  39. package/src/provider/local/hub.ts +174 -0
  40. package/src/provider/local/index.ts +10 -0
  41. package/src/provider/local/model-manager.ts +113 -0
  42. package/src/provider/local/orchestrator.ts +301 -0
  43. package/src/provider/local/rag.ts +112 -0
  44. package/src/provider/local/types.ts +142 -0
  45. package/src/provider/provider-conversion.ts +2 -0
  46. package/src/provider/provider-schema.ts +17 -2
  47. package/src/provider/provider-schemas.ts +10 -3
  48. package/src/provider/provider-state.ts +10 -2
  49. package/src/provider/provider.ts +2 -1
  50. package/src/saeeol/plugins/sidebar-usage.tsx +1 -1
  51. package/src/server/routes/instance/config.ts +1 -1
  52. package/src/server/routes/instance/httpapi/api.ts +2 -0
  53. package/src/server/routes/instance/httpapi/groups/local.ts +87 -0
  54. package/src/server/routes/instance/httpapi/groups/mcp.ts +10 -0
  55. package/src/server/routes/instance/httpapi/handlers/local.ts +95 -0
  56. package/src/server/routes/instance/httpapi/handlers/mcp.ts +5 -0
  57. package/src/server/routes/instance/httpapi/handlers/provider.ts +1 -1
  58. package/src/server/routes/instance/httpapi/server.ts +2 -0
  59. package/src/server/routes/instance/provider.ts +2 -2
  60. package/src/session/prompt-reminders.ts +29 -0
  61. package/test/fake/provider.ts +1 -0
  62. package/test/provider/local.test.ts +208 -0
  63. package/test/provider/provider-category.test.ts +190 -0
package/npm/bin/saeeol ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+
3
+ // saeeol bin wrapper — delegates to the downloaded platform binary
4
+ const { existsSync, chmodSync } = require("fs")
5
+ const { join, resolve } = require("path")
6
+ const { spawn } = require("child_process")
7
+
8
+ const binDir = join(__dirname, "..", "download")
9
+ function getBinaryName() {
10
+ const archMap = { x64: "x64", arm64: "arm64" }
11
+ const platformMap = { win32: "windows", darwin: "darwin", linux: "linux" }
12
+ const p = platformMap[process.platform] || process.platform
13
+ const a = archMap[process.arch] || arch
14
+ const ext = process.platform === "win32" ? ".exe" : ""
15
+ return `saeeol-${p}-${a}${ext}`
16
+ }
17
+
18
+ const binaryName = getBinaryName()
19
+ const binaryPath = join(binDir, binaryName)
20
+
21
+ if (!existsSync(binaryPath)) {
22
+ console.error(`saeeol binary not found: ${binaryPath}`)
23
+ console.error(`Run: npm rebuild saeeol`)
24
+ console.error(`Or: https://github.com/byfabulist/saeeol/releases`)
25
+ process.exit(1)
26
+ }
27
+
28
+ if (process.platform !== "win32") {
29
+ try { chmodSync(binaryPath, 0o755) } catch {}
30
+ }
31
+
32
+ const child = spawn(binaryPath, process.argv.slice(2), {
33
+ stdio: "inherit",
34
+ env: { ...process.env },
35
+ windowsHide: true,
36
+ })
37
+
38
+ child.on("exit", (code) => process.exit(code || 0))
39
+ child.on("error", (err) => {
40
+ console.error(`Failed to start saeeol: ${err.message}`)
41
+ process.exit(1)
42
+ })
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "saeeol",
3
+ "version": "1.1.0",
4
+ "description": "AI agent engine for SAEEOL",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/byfabulist/saeeol"
9
+ },
10
+ "bin": {
11
+ "saeeol": "./bin/saeeol"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "postinstall.js"
16
+ ],
17
+ "scripts": {
18
+ "postinstall": "node postinstall.js"
19
+ },
20
+ "dependencies": {},
21
+ "devDependencies": {},
22
+ "peerDependencies": {},
23
+ "overrides": {},
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org"
27
+ },
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "keywords": [
32
+ "ai",
33
+ "agent",
34
+ "cli",
35
+ "coding",
36
+ "assistant",
37
+ "saeeol"
38
+ ]
39
+ }
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+
3
+ // saeeol postinstall — downloads platform binary from GitHub Releases
4
+ const https = require("https")
5
+ const http = require("http")
6
+ const fs = require("fs")
7
+ const path = require("path")
8
+ const zlib = require("zlib")
9
+ const { execSync } = require("child_process")
10
+
11
+ const REPO = "byfabulist/saeeol"
12
+ const VERSION = require("./package.json").version
13
+
14
+ function getPlatformInfo() {
15
+ const archMap = { x64: "x64", arm64: "arm64" }
16
+ const platformMap = { win32: "windows", darwin: "darwin", linux: "linux" }
17
+ const p = platformMap[process.platform] || process.platform
18
+ const a = archMap[process.arch] || process.arch
19
+ const ext = process.platform === "win32" ? ".zip" : ".tar.gz"
20
+ const archiveName = `saeeol-${p}-${a}${ext}`
21
+ const binaryName = process.platform === "win32" ? "saeeol.exe" : "saeeol"
22
+ return { archiveName, binaryName, platform: p, arch: a }
23
+ }
24
+
25
+ function fetch(url, redirects = 5) {
26
+ return new Promise((resolve, reject) => {
27
+ const mod = url.startsWith("https") ? https : http
28
+ mod.get(url, { timeout: 60000 }, (res) => {
29
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
30
+ if (redirects <= 0) return reject(new Error("Too many redirects"))
31
+ return fetch(res.headers.location, redirects - 1).then(resolve, reject)
32
+ }
33
+ if (res.statusCode !== 200) {
34
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`))
35
+ }
36
+ const chunks = []
37
+ res.on("data", (chunk) => chunks.push(chunk))
38
+ res.on("end", () => resolve(Buffer.concat(chunks)))
39
+ res.on("error", reject)
40
+ }).on("error", reject)
41
+ })
42
+ }
43
+
44
+ async function extractTarGz(buffer, destDir, binaryName) {
45
+ // Use tar on non-windows, or platform tar on windows (git bash)
46
+ const tmpFile = path.join(destDir, "download.tar.gz")
47
+ fs.writeFileSync(tmpFile, buffer)
48
+ try {
49
+ if (process.platform === "win32") {
50
+ // Try PowerShell Expand-Archive for .tar.gz or just use tar if available
51
+ try {
52
+ execSync(`tar -xzf "${tmpFile}" -C "${destDir}"`, { stdio: "pipe", windowsHide: true })
53
+ } catch {
54
+ // Fallback: use zlib + tar parsing manually not practical, try powershell
55
+ throw new Error("tar command not available on Windows. Install Git Bash or add tar to PATH.")
56
+ }
57
+ } else {
58
+ execSync(`tar -xzf "${tmpFile}" -C "${destDir}"`, { stdio: "pipe" })
59
+ }
60
+ } finally {
61
+ try { fs.unlinkSync(tmpFile) } catch {}
62
+ }
63
+ }
64
+
65
+ async function extractZip(buffer, destDir, binaryName) {
66
+ if (process.platform === "win32") {
67
+ const tmpFile = path.join(destDir, "download.zip")
68
+ fs.writeFileSync(tmpFile, buffer)
69
+ try {
70
+ execSync(`powershell -Command "Expand-Archive -Path '${tmpFile}' -DestinationPath '${destDir}' -Force"`, {
71
+ stdio: "pipe",
72
+ windowsHide: true,
73
+ })
74
+ } finally {
75
+ try { fs.unlinkSync(tmpFile) } catch {}
76
+ }
77
+ } else {
78
+ // On non-windows, try unzip
79
+ const tmpFile = path.join(destDir, "download.zip")
80
+ fs.writeFileSync(tmpFile, buffer)
81
+ try {
82
+ execSync(`unzip -o "${tmpFile}" -d "${destDir}"`, { stdio: "pipe" })
83
+ } finally {
84
+ try { fs.unlinkSync(tmpFile) } catch {}
85
+ }
86
+ }
87
+ }
88
+
89
+ async function main() {
90
+ const { archiveName, binaryName, platform: p, arch: a } = getPlatformInfo()
91
+ const binDir = path.join(__dirname, "download")
92
+
93
+ // Check if binary already exists
94
+ const binaryPath = path.join(binDir, binaryName)
95
+ if (fs.existsSync(binaryPath)) {
96
+ console.log(`saeeol binary already exists at ${binaryPath}`)
97
+ return
98
+ }
99
+
100
+ fs.mkdirSync(binDir, { recursive: true })
101
+
102
+ const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`
103
+ console.log(`Downloading saeeol v${VERSION} for ${p}-${a}...`)
104
+ console.log(` ${url}`)
105
+
106
+ let buffer
107
+ try {
108
+ buffer = await fetch(url)
109
+ } catch (err) {
110
+ // Fallback: try without version tag (latest)
111
+ const fallbackUrl = `https://github.com/${REPO}/releases/latest/download/${archiveName}`
112
+ console.log(`Version-specific download failed: ${err.message}`)
113
+ console.log(`Trying latest: ${fallbackUrl}`)
114
+ try {
115
+ buffer = await fetch(fallbackUrl)
116
+ } catch (err2) {
117
+ console.error(`Failed to download saeeol binary: ${err2.message}`)
118
+ console.error(`Please download manually from: https://github.com/${REPO}/releases`)
119
+ process.exit(0) // Don't fail npm install
120
+ }
121
+ }
122
+
123
+ console.log(`Downloaded ${(buffer.length / 1024 / 1024).toFixed(1)} MB`)
124
+
125
+ // Extract
126
+ if (archiveName.endsWith(".tar.gz")) {
127
+ await extractTarGz(buffer, binDir, binaryName)
128
+ } else {
129
+ await extractZip(buffer, binDir, binaryName)
130
+ }
131
+
132
+ // Verify
133
+ if (!fs.existsSync(binaryPath)) {
134
+ // The archive might extract to a subdirectory, search for the binary
135
+ const files = fs.readdirSync(binDir, { recursive: true })
136
+ const found = files.find((f) => {
137
+ const basename = path.basename(f)
138
+ return basename === "saeeol" || basename === "saeeol.exe"
139
+ })
140
+ if (found) {
141
+ const fullPath = path.join(binDir, found)
142
+ fs.copyFileSync(fullPath, binaryPath)
143
+ }
144
+ }
145
+
146
+ if (fs.existsSync(binaryPath)) {
147
+ if (process.platform !== "win32") {
148
+ fs.chmodSync(binaryPath, 0o755)
149
+ }
150
+ console.log(`saeeol v${VERSION} installed successfully!`)
151
+ console.log(` Binary: ${binaryPath}`)
152
+ } else {
153
+ console.warn(`Warning: binary not found after extraction at ${binaryPath}`)
154
+ console.warn(`You may need to install manually from https://github.com/${REPO}/releases`)
155
+ }
156
+ }
157
+
158
+ main().catch((err) => {
159
+ console.error(`saeeol postinstall failed: ${err.message}`)
160
+ console.error(`Install manually: https://github.com/${REPO}/releases`)
161
+ process.exit(0) // Don't fail npm install
162
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "1.0.9",
3
+ "version": "1.1.1",
4
4
  "name": "saeeol",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -134,8 +134,8 @@
134
134
  "@pierre/diffs": "1.1.0-beta.18",
135
135
  "@saeeol/boxes": "workspace:*",
136
136
  "@saeeol/core": "workspace:*",
137
- "@saeeol/i18n": "workspace:*",
138
137
  "@saeeol/gateway": "workspace:*",
138
+ "@saeeol/i18n": "workspace:*",
139
139
  "@saeeol/indexing": "workspace:*",
140
140
  "@saeeol/plugin": "workspace:*",
141
141
  "@saeeol/script": "workspace:*",
@@ -0,0 +1,47 @@
1
+ import { cmd } from "./cmd"
2
+ import * as prompts from "@clack/prompts"
3
+ import { UI } from "../ui"
4
+ import { Instance } from "../../project/instance"
5
+ import { configuredServers, listState } from "./mcp-shared"
6
+ import { MCP } from "../../mcp"
7
+ import { AppRuntime } from "../../effect/app-runtime"
8
+ import { Effect } from "effect"
9
+
10
+ export const McpRefreshCommand = cmd({
11
+ command: "refresh",
12
+ aliases: ["rf"],
13
+ describe: "refresh MCP server list from config",
14
+ async handler() {
15
+ await Instance.provide({
16
+ directory: process.cwd(),
17
+ async fn() {
18
+ UI.empty()
19
+ prompts.intro("Refresh MCP Servers")
20
+
21
+ const spinner = prompts.spinner()
22
+ spinner.start("Reconnecting...")
23
+
24
+ const statuses = await AppRuntime.runPromise(
25
+ Effect.gen(function* () {
26
+ const mcp = yield* MCP.Service
27
+ return yield* mcp.refresh()
28
+ }),
29
+ )
30
+
31
+ spinner.stop("Done")
32
+
33
+ const { config } = await listState()
34
+ const servers = configuredServers(config)
35
+
36
+ for (const [name] of servers) {
37
+ const s = statuses[name]
38
+ if (!s) continue
39
+ const icon = s.status === "connected" ? "✓" : s.status === "disabled" ? "○" : "✗"
40
+ prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${s.status}`)
41
+ }
42
+
43
+ prompts.outro(`${servers.length} server(s) refreshed`)
44
+ },
45
+ })
46
+ },
47
+ })
@@ -3,6 +3,7 @@ import { McpListCommand } from "./mcp-list"
3
3
  import { McpAuthCommand, McpAuthListCommand } from "./mcp-auth"
4
4
  import { McpLogoutCommand } from "./mcp-logout"
5
5
  import { McpAddCommand } from "./mcp-add"
6
+ import { McpRefreshCommand } from "./mcp-refresh"
6
7
  import { McpDebugCommand } from "./mcp-debug"
7
8
 
8
9
  export const McpCommand = cmd({
@@ -12,6 +13,7 @@ export const McpCommand = cmd({
12
13
  yargs
13
14
  .command(McpAddCommand)
14
15
  .command(McpListCommand)
16
+ .command(McpRefreshCommand)
15
17
  .command(McpAuthCommand)
16
18
  .command(McpLogoutCommand)
17
19
  .command(McpDebugCommand)
@@ -19,4 +21,4 @@ export const McpCommand = cmd({
19
21
  async handler() {},
20
22
  })
21
23
 
22
- export { McpListCommand, McpAuthCommand, McpAuthListCommand, McpLogoutCommand, McpAddCommand, McpDebugCommand }
24
+ export { McpListCommand, McpAuthCommand, McpAuthListCommand, McpLogoutCommand, McpAddCommand, McpRefreshCommand, McpDebugCommand }
@@ -132,6 +132,17 @@ export function registerCoreCommands(deps: CoreCommandDeps): CommandOption[] {
132
132
  dialog.replace(() => <DialogMcp />)
133
133
  },
134
134
  },
135
+ {
136
+ title: "Refresh MCP Servers",
137
+ value: "mcp.refresh",
138
+ category: t("cmd.cat.agent"),
139
+ slash: {
140
+ name: "mcp-refresh",
141
+ },
142
+ onSelect: async () => {
143
+ await local.mcp.refresh()
144
+ },
145
+ },
135
146
  {
136
147
  title: t("cmd.agent.cycle"),
137
148
  value: "agent.cycle",
@@ -5,6 +5,7 @@ import open from "open"
5
5
  import { DialogStatus } from "@tui/component/dialog-status"
6
6
  import { DialogThemeList } from "@tui/component/dialog-theme-list"
7
7
  import { DialogHelp } from "@tui/ui/dialog-help"
8
+ import * as Log from "@saeeol/core/util/log"
8
9
 
9
10
  type Dialog = ReturnType<typeof import("@tui/ui/dialog").useDialog>
10
11
  type KV = ReturnType<typeof import("@tui/context/kv").useKV>
@@ -111,6 +112,25 @@ export function registerSystemCommands(deps: SystemCommandDeps): CommandOption[]
111
112
  },
112
113
  category: t("cmd.cat.system"),
113
114
  },
115
+ {
116
+ title: "Open Log File",
117
+ value: "app.logs",
118
+ slash: {
119
+ name: "logs",
120
+ },
121
+ onSelect: () => {
122
+ const logPath = Log.file()
123
+ if (logPath) {
124
+ open(logPath).catch(() => {
125
+ toast.show({ variant: "error", message: logPath, duration: 10000 })
126
+ })
127
+ } else {
128
+ toast.show({ variant: "error", message: "No log file available", duration: 3000 })
129
+ }
130
+ dialog.clear()
131
+ },
132
+ category: t("cmd.cat.system"),
133
+ },
114
134
  {
115
135
  title: t("cmd.system.docs"),
116
136
  value: "docs.open",
@@ -5,6 +5,7 @@ import { DialogAlert } from "@tui/ui/dialog-alert"
5
5
  import { errorMessage } from "@tui/app-config"
6
6
  import * as SaeeolApp from "@/saeeol/cli/cmd/tui/app"
7
7
  import { ProviderInstallEvent } from "@/provider/provider-events"
8
+ import * as Log from "@saeeol/core/util/log"
8
9
 
9
10
  type Event = ReturnType<typeof import("@tui/context/event").useEvent>
10
11
  type Command = ReturnType<typeof import("@tui/component/dialog-command").useCommandDialog>
@@ -26,6 +27,18 @@ export type EventDeps = {
26
27
  exit: Exit
27
28
  }
28
29
 
30
+ // Session error auto-recovery state
31
+ const MAX_SESSION_ERRORS = 3
32
+ const ERROR_WINDOW_MS = 60_000 // 1 minute window
33
+ const sessionErrors: { time: number; message: string }[] = []
34
+
35
+ function trimOldErrors() {
36
+ const cutoff = Date.now() - ERROR_WINDOW_MS
37
+ while (sessionErrors.length > 0 && sessionErrors[0].time < cutoff) {
38
+ sessionErrors.shift()
39
+ }
40
+ }
41
+
29
42
  export function registerAppEvents(deps: EventDeps) {
30
43
  const { event, command, toast, route, kv, dialog, sdk, exit } = deps
31
44
  event.on(TuiEvent.CommandExecute.type, (evt) => {
@@ -59,6 +72,36 @@ export function registerAppEvents(deps: EventDeps) {
59
72
  if (error && typeof error === "object" && error.name === "MessageAbortedError") return
60
73
  if (SaeeolApp.handleSessionError(error, toast)) return
61
74
  const message = errorMessage(error)
75
+
76
+ // Track error for auto-recovery
77
+ sessionErrors.push({ time: Date.now(), message })
78
+ trimOldErrors()
79
+
80
+ if (sessionErrors.length >= MAX_SESSION_ERRORS) {
81
+ // Auto-recovery: too many errors, start fresh session
82
+ sessionErrors.length = 0
83
+ const logPath = Log.file()
84
+ toast.show({
85
+ variant: "warning",
86
+ message: `Session unstable (${MAX_SESSION_ERRORS} errors in 1min). Starting new session.`,
87
+ duration: 5000,
88
+ })
89
+ route.navigate({ type: "home" })
90
+ dialog.clear()
91
+ // If still failing after auto-recovery, show log path
92
+ setTimeout(() => {
93
+ if (logPath) {
94
+ toast.show({
95
+ variant: "info",
96
+ title: "Log File",
97
+ message: logPath,
98
+ duration: 10000,
99
+ })
100
+ }
101
+ }, 6000)
102
+ return
103
+ }
104
+
62
105
  toast.show({
63
106
  variant: "error",
64
107
  message,
@@ -30,6 +30,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
30
30
  import { KeybindProvider, useKeybind } from "@tui/context/keybind"
31
31
  import { ThemeProvider, useTheme } from "@tui/context/theme"
32
32
  import { Home } from "@tui/routes/home"
33
+ import { LocalModels } from "@tui/routes/local-models"
33
34
  import { Session } from "@tui/routes/session"
34
35
  import { PromptHistoryProvider } from "@tui/component/prompt/history"
35
36
  import { FrecencyProvider } from "@tui/component/prompt/frecency"
@@ -270,6 +271,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
270
271
  <Match when={route.data.type === "saeeolclaw"}>
271
272
  <SaeeolApp.SaeeolClawView />
272
273
  </Match>
274
+ <Match when={route.data.type === "local-models"}>
275
+ <LocalModels />
276
+ </Match>
273
277
  </Switch>
274
278
  </Show>
275
279
  {plugin()}
@@ -41,7 +41,7 @@ export function DialogModel(props: { providerID?: string }) {
41
41
 
42
42
  const lookup = (providerID: string, modelID: string) => {
43
43
  const provider = sync.data.provider.find((x) => x.id === providerID)
44
- const model = provider?.models[modelID]
44
+ const model = provider?.models?.[modelID]
45
45
  if (!provider || !model) return
46
46
  return {
47
47
  model,
@@ -71,7 +71,7 @@ export function DialogModel(props: { providerID?: string }) {
71
71
  return items.flatMap((item) => {
72
72
  const provider = sync.data.provider.find((x) => x.id === item.providerID)
73
73
  if (!provider) return []
74
- const model = provider.models[item.modelID]
74
+ const model = provider.models?.[item.modelID]
75
75
  if (!model) return []
76
76
  return [
77
77
  {
@@ -142,7 +142,7 @@ export function usePromptMemos(deps: MemoDeps): PromptMemos {
142
142
  if (!last) return
143
143
  const tokens = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
144
144
  if (tokens <= 0) return
145
- const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
145
+ const model = sync.data.provider.find((item) => item.id === last.providerID)?.models?.[last.modelID]
146
146
  const contextLimit = model?.limit.context ?? 0
147
147
  const pct = contextLimit ? Math.round((tokens / contextLimit) * 100) : 0
148
148
  const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)
@@ -5,7 +5,7 @@ export function useConnected() {
5
5
  const sync = useSync()
6
6
  return createMemo(() =>
7
7
  sync.data.provider.some(
8
- (x) => (x.id !== "saeeol" && x.id !== "saeeol") || Object.values(x.models).some((y) => y.cost?.input !== 0),
8
+ (x) => (x.id !== "saeeol" && x.id !== "saeeol") || Object.values(x.models ?? {}).some((y) => y.cost?.input !== 0),
9
9
  ),
10
10
  )
11
11
  }
@@ -32,7 +32,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
32
32
 
33
33
  function isModelValid(model: { providerID: string; modelID: string }) {
34
34
  const provider = sync.data.provider.find((x) => x.id === model.providerID)
35
- return !!provider?.models[model.modelID]
35
+ return !!provider?.models?.[model.modelID]
36
36
  }
37
37
 
38
38
  function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) {
@@ -280,7 +280,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
280
280
  }
281
281
  }
282
282
  const provider = sync.data.provider.find((x) => x.id === value.providerID)
283
- const info = provider?.models[value.modelID]
283
+ const info = provider?.models?.[value.modelID]
284
284
  return {
285
285
  provider: provider?.name ?? value.providerID,
286
286
  model: info?.name ?? value.modelID,
@@ -402,7 +402,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
402
402
  const m = currentModel()
403
403
  if (!m) return []
404
404
  const provider = sync.data.provider.find((x) => x.id === m.providerID)
405
- const info = provider?.models[m.modelID]
405
+ const info = provider?.models?.[m.modelID]
406
406
  if (!info?.variants) return []
407
407
  return Object.keys(info.variants)
408
408
  },
@@ -447,6 +447,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
447
447
  await sdk.client.mcp.connect({ name })
448
448
  }
449
449
  },
450
+ async refresh() {
451
+ const workspace = project.workspace.current()
452
+ await (sdk.client as any).mcp._client.post({
453
+ url: "/mcp/refresh",
454
+ ...(workspace ? { workspace } : {}),
455
+ })
456
+ },
450
457
  }
451
458
  createEffect(() => {
452
459
  if (!model.ready) return
@@ -16,13 +16,17 @@ export type SaeeolClawRoute = {
16
16
  type: "saeeolclaw"
17
17
  }
18
18
 
19
+ export type LocalModelsRoute = {
20
+ type: "local-models"
21
+ }
22
+
19
23
  export type PluginRoute = {
20
24
  type: "plugin"
21
25
  id: string
22
26
  data?: Record<string, unknown>
23
27
  }
24
28
 
25
- export type Route = HomeRoute | SessionRoute | PluginRoute | SaeeolClawRoute
29
+ export type Route = HomeRoute | SessionRoute | PluginRoute | SaeeolClawRoute | LocalModelsRoute
26
30
 
27
31
  export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
28
32
  name: "Route",
@@ -26,7 +26,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
26
26
 
27
27
  const tokens =
28
28
  last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
29
- const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
29
+ const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models?.[last.modelID]
30
30
  return {
31
31
  tokens,
32
32
  percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
@@ -96,11 +96,15 @@ function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]
96
96
  }
97
97
  }
98
98
  if (route.data.type === "saeeolclaw") return { name: "saeeolclaw" }
99
+ if (route.data.type === "local-models") return { name: "local-models" }
99
100
 
100
- return {
101
- name: route.data.id,
102
- params: route.data.data,
101
+ if (route.data.type === "plugin") {
102
+ return {
103
+ name: route.data.id,
104
+ params: route.data.data,
105
+ }
103
106
  }
107
+ return { name: "unknown" }
104
108
  }
105
109
 
106
110
  function mapOption<Value>(item: TuiDialogSelectOption<Value>): SelectOption<Value> {