diff --git a/README.md b/README.md
index 5cf9f6143..bd4745e00 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,36 @@
+## 🔌 Provider Matrix
+
+The stack is provider-agnostic, but a practical low-cost deployment can split work by capability instead of forcing one model to do everything.
+
+### Example deployment matrix (Orange Pi / RISC-V)
+
+| Capability | Provider / API | Model / Path | Notes |
+| ---------- | -------------- | ------------ | ----- |
+| Chat / main conversation | OpenRouter | `openrouter/free` | Default text model path |
+| Text fallback 1 | OpenRouter | `openai/gpt-oss-20b:free` | Free text fallback |
+| Text fallback 2 | DeepSeek API | `deepseek-chat` | Extra text fallback |
+| Text fallback 3 | Gemini API | `gemini-2.5-flash` | Last text fallback |
+| Image understanding | Gemini API | `gemini-2.5-flash` | Used for photo / image turns |
+| Voice transcription | ElevenLabs | `scribe_v1` | First speech-to-text provider |
+| Voice transcription fallback 1 | Gemini API | `gemini-2.5-flash` | Speech understanding fallback |
+| Voice transcription fallback 2 | Groq API | Whisper-compatible STT | Final transcription fallback |
+| Voice reply synthesis | ElevenLabs | TTS API | Primary voice-note output |
+| Voice reply synthesis fallback | Gemini API | `gemini-2.5-flash-preview-tts` | Fallback speech output |
+| Gmail / Calendar / Drive actions | Google Workspace APIs via `gws` CLI | Gmail, Calendar, Drive | Executed as tool calls, not normal chat completions |
+| Telegram transport | Telegram Bot API | Bot token | Messaging channel |
+| WhatsApp transport | WhatsApp native (`whatsmeow`) | local session store | Not Meta Cloud API |
+| Preview hosting | Built-in preview server | `http://:3002/preview/site/` | One stable preview URL can be reused across site updates |
+
+### Why split providers by capability?
+
+- Text chat can stay on cheap or free routing.
+- Image turns can go directly to a vision-capable model.
+- Voice can use STT/TTS providers that are better than a general-purpose chat model.
+- Workspace actions like Gmail or Drive are usually better handled as tool/API calls than as LLM-only reasoning.
+
## 🦾 Demonstration
### 🛠️ Standard Assistant Workflows
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 3b526cd82..c57a9e72a 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -2979,11 +2979,12 @@ func (al *AgentLoop) shouldRecallRecentPreview(lower string, agent *AgentInstanc
wantsURL := strings.Contains(lower, "url") || strings.Contains(lower, "urls") || strings.Contains(lower, "link") || strings.Contains(lower, "links")
wantsRecent := strings.Contains(lower, "recent") || strings.Contains(lower, "latest") || strings.Contains(lower, "most recent") || strings.Contains(lower, "current")
wantsHost := strings.Contains(lower, "host") || strings.Contains(lower, "serve") || strings.Contains(lower, "open")
- if !wantsURL && !(wantsRecent && wantsHost) {
+ wantsPreview := strings.Contains(lower, "preview") || strings.Contains(lower, "site") || strings.Contains(lower, "website") || strings.Contains(lower, "app") || strings.Contains(lower, "build")
+ wantsStatus := strings.Contains(lower, "status") || strings.Contains(lower, "going") || strings.Contains(lower, "howd") || strings.Contains(lower, "hows") || strings.Contains(lower, "how's")
+ if !wantsPreview {
return false
}
- wantsPreview := strings.Contains(lower, "preview") || strings.Contains(lower, "site") || strings.Contains(lower, "website") || strings.Contains(lower, "app") || strings.Contains(lower, "build")
- if !wantsPreview {
+ if !wantsURL && !(wantsRecent && wantsHost) && !wantsStatus {
return false
}
return len(al.recentPreviewInfos(agent)) > 0
@@ -2996,10 +2997,12 @@ func (al *AgentLoop) shouldRecallRecentPreviewFromConversation(lower string, age
wantsURL := strings.Contains(lower, "url") || strings.Contains(lower, "urls") || strings.Contains(lower, "link") || strings.Contains(lower, "links")
wantsRecent := strings.Contains(lower, "recent") || strings.Contains(lower, "latest") || strings.Contains(lower, "most recent") || strings.Contains(lower, "current")
wantsHost := strings.Contains(lower, "host") || strings.Contains(lower, "serve") || strings.Contains(lower, "open")
- if !wantsURL && !(wantsRecent && wantsHost) {
+ wantsPreview := strings.Contains(lower, "preview") || strings.Contains(lower, "site") || strings.Contains(lower, "website") || strings.Contains(lower, "app") || strings.Contains(lower, "build")
+ wantsStatus := wantsPreview && (strings.Contains(lower, "status") || strings.Contains(lower, "going") || strings.Contains(lower, "howd") || strings.Contains(lower, "hows") || strings.Contains(lower, "how's"))
+ if !wantsURL && !(wantsRecent && wantsHost) && !wantsStatus {
return false
}
- hints := strings.Contains(lower, "talked") || strings.Contains(lower, "previous") || strings.Contains(lower, "built") || strings.Contains(lower, "that site") || strings.Contains(lower, "that website") || wantsRecent
+ hints := strings.Contains(lower, "talked") || strings.Contains(lower, "previous") || strings.Contains(lower, "built") || strings.Contains(lower, "that site") || strings.Contains(lower, "that website") || wantsRecent || wantsStatus
if !hints {
return false
}
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index a99e167a4..c09b20b41 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -665,7 +665,9 @@ func (m *Manager) PublishPreview(root, entry, slug string) (string, string, stri
if existing, ok := m.previews[slug]; ok {
createdAt = existing.CreatedAt
}
- m.previews[slug] = previewMount{Root: absRoot, Entry: entry, CreatedAt: createdAt, UpdatedAt: now}
+ m.previews = map[string]previewMount{
+ slug: {Root: absRoot, Entry: entry, CreatedAt: createdAt, UpdatedAt: now},
+ }
m.mu.Unlock()
tailscaleBase, localBase := m.previewBases()
diff --git a/pkg/tools/preview.go b/pkg/tools/preview.go
index 8c6cb38dd..0409661d8 100644
--- a/pkg/tools/preview.go
+++ b/pkg/tools/preview.go
@@ -31,6 +31,7 @@ type HostPreviewTool struct {
}
const recentPreviewsStatePath = "state/recent_previews.json"
+const defaultHostedPreviewSlug = "site"
func NewHostPreviewTool(workspace string, restrict bool, publish PreviewPublisher) *HostPreviewTool {
return &HostPreviewTool{workspace: workspace, restrict: restrict, publish: publish}
@@ -41,7 +42,7 @@ func (t *HostPreviewTool) Name() string {
}
func (t *HostPreviewTool) Description() string {
- return "Host a local site/app directory on the built-in preview server and return Tailscale/local URLs for review."
+ return "Host a local site/app directory on the built-in preview server and return one stable Tailscale/local preview URL for review."
}
func (t *HostPreviewTool) Parameters() map[string]any {
@@ -90,6 +91,9 @@ func (t *HostPreviewTool) Execute(ctx context.Context, args map[string]any) *Too
entry = strings.TrimSpace(entry)
slug, _ := args["slug"].(string)
slug = strings.TrimSpace(slug)
+ if slug == "" {
+ slug = defaultHostedPreviewSlug
+ }
root := resolved
if info.IsDir() {
@@ -106,6 +110,10 @@ func (t *HostPreviewTool) Execute(ctx context.Context, args map[string]any) *Too
entry = "index.html"
}
} else {
+ ext := strings.ToLower(filepath.Ext(resolved))
+ if ext != ".html" && ext != ".htm" {
+ return ErrorResult("path must be a site directory or an HTML entry file")
+ }
root = filepath.Dir(resolved)
if entry == "" {
entry = filepath.Base(resolved)
@@ -172,21 +180,7 @@ func SaveRecentPreview(workspace string, preview *HostedPreview) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
- previews, err := LoadRecentPreviews(workspace)
- if err != nil {
- return err
- }
- updated := make([]HostedPreview, 0, len(previews)+1)
- updated = append(updated, *preview)
- for _, item := range previews {
- if strings.TrimSpace(item.Slug) == strings.TrimSpace(preview.Slug) {
- continue
- }
- updated = append(updated, item)
- if len(updated) >= 8 {
- break
- }
- }
+ updated := []HostedPreview{*preview}
data, err := json.MarshalIndent(updated, "", " ")
if err != nil {
return err