From 7bbcccabf17a16823d0dfad3a9462e28015b5d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 6 Mar 2026 07:27:19 +0000 Subject: [PATCH] wip: remove opencode, searxng, resources; fix user settings read Co-Authored-By: Claude Opus 4.6 --- scripts/migrate-user-settings-to-pg.ts | 67 ---- seed/resources/GUIDE.md | 51 --- .../SERVICE_optical_character_recognition.md | 6 - seed/resources/SERVICE_speech_to_text.md | 6 - seed/resources/SERVICE_text_to_speech.md | 6 - .../optical-character-recognition/RESOURCE.md | 5 - .../optical-character-recognition/config.json | 7 - seed/resources/speech-to-text/RESOURCE.md | 5 - seed/resources/speech-to-text/config.json | 6 - seed/resources/text-to-speech/RESOURCE.md | 5 - seed/resources/text-to-speech/config.json | 9 - src/apps/officer-web/App.tsx | 4 - .../OnboardingAdmin/AIHarnessesCard.tsx | 200 +++------- .../Screens/Dashboard/Settings/AISettings.tsx | 1 - .../ResourceSettings/Applications.tsx | 162 -------- .../ResourceSettings/ResourceSidebar.tsx | 127 ------- .../Settings/ResourceSettings/Resources.tsx | 349 ------------------ .../Settings/ResourceSettings/index.tsx | 32 -- .../ServerSettings/AIHarnessesSection.tsx | 1 - .../Screens/Dashboard/Settings/index.tsx | 1 - .../officer_db/src/schema/agent-items.ts | 243 ++++++------ src/databases/officer_db/src/types.ts | 5 - src/servers/api/file-browser/router.ts | 57 ++- src/servers/api/pi/pi-bridge.ts | 165 +-------- src/servers/api/scrape/scrape.ts | 2 +- src/servers/api/server-settings/ocr.ts | 4 - src/servers/api/server-settings/opencode.ts | 76 ---- src/servers/api/server-settings/resources.ts | 235 ------------ src/servers/api/server-settings/searxng.ts | 39 -- .../api/server-settings/server-settings.ts | 6 - src/servers/api/server-settings/stt.ts | 4 - src/servers/api/server-settings/tts.ts | 16 +- src/servers/api/sessions/sessions.ts | 166 ++------- src/servers/api/upload/upload.ts | 2 +- src/servers/bootstrap.ts | 7 - src/servers/channels/send-and-await.ts | 2 +- src/servers/data-path.ts | 24 +- src/servers/generate-container-context.ts | 8 - src/servers/migrate-resources.ts | 53 --- src/servers/sidecar/pi-manager.ts | 110 +++--- src/servers/sync-resources.ts | 9 - .../apps/Chat/components/ModelSelector.tsx | 13 +- .../officerdev/src/apps/Chat/types.ts | 2 +- src/workspaces/state/src/index.ts | 2 - src/workspaces/state/src/useResources.ts | 61 --- src/workspaces/state/src/useServerSettings.ts | 1 - 46 files changed, 325 insertions(+), 2037 deletions(-) delete mode 100644 scripts/migrate-user-settings-to-pg.ts delete mode 100644 seed/resources/GUIDE.md delete mode 100644 seed/resources/SERVICE_optical_character_recognition.md delete mode 100644 seed/resources/SERVICE_speech_to_text.md delete mode 100644 seed/resources/SERVICE_text_to_speech.md delete mode 100644 seed/resources/optical-character-recognition/RESOURCE.md delete mode 100644 seed/resources/optical-character-recognition/config.json delete mode 100644 seed/resources/speech-to-text/RESOURCE.md delete mode 100644 seed/resources/speech-to-text/config.json delete mode 100644 seed/resources/text-to-speech/RESOURCE.md delete mode 100644 seed/resources/text-to-speech/config.json delete mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Applications.tsx delete mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx delete mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx delete mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx delete mode 100644 src/servers/api/server-settings/opencode.ts delete mode 100644 src/servers/api/server-settings/resources.ts delete mode 100644 src/servers/api/server-settings/searxng.ts delete mode 100644 src/servers/migrate-resources.ts delete mode 100644 src/servers/sync-resources.ts delete mode 100644 src/workspaces/state/src/useResources.ts diff --git a/scripts/migrate-user-settings-to-pg.ts b/scripts/migrate-user-settings-to-pg.ts deleted file mode 100644 index a702613d..00000000 --- a/scripts/migrate-user-settings-to-pg.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Migration script: per-user settings.json and state.json → PostgreSQL - * - * Reads from $DATA_PATH/{email}/settings/settings.json and state/state.json - * Writes to user_settings and user_state tables - * - * Usage: bun run scripts/migrate-user-settings-to-pg.ts - */ - -import { join } from 'node:path'; -import { readdirSync } from 'node:fs'; -import { getUserByEmail, setUserSettings, patchUserState } from 'officerdb'; - -const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); - -async function readJson(path: string): Promise { - try { - const file = Bun.file(path); - if (!(await file.exists())) return null; - return (await file.json()) as T; - } catch { - return null; - } -} - -async function migrate() { - console.log(`[migrate] Scanning ${DATA_PATH} for user data dirs`); - - // User data dirs are named by email (contain @) - const entries = readdirSync(DATA_PATH, { withFileTypes: true }); - const userDirs = entries.filter((e) => e.isDirectory() && e.name.includes('@')); - - console.log(`[migrate] Found ${userDirs.length} user dirs: ${userDirs.map((d) => d.name).join(', ')}`); - - for (const dir of userDirs) { - const email = dir.name; - const dbUser = await getUserByEmail(email); - if (!dbUser) { - console.warn(` [skip] ${email} — no matching user in DB`); - continue; - } - - // Settings - const settingsPath = join(DATA_PATH, email, 'settings', 'settings.json'); - const settings = await readJson>(settingsPath); - if (settings && Object.keys(settings).length > 0) { - await setUserSettings(dbUser.id, settings); - console.log(` [settings] ${email} — ${Object.keys(settings).length} keys`); - } - - // State - const statePath = join(DATA_PATH, email, 'state', 'state.json'); - const state = await readJson>(statePath); - if (state && Object.keys(state).length > 0) { - await patchUserState(dbUser.id, state); - console.log(` [state] ${email} — ${Object.keys(state).length} keys`); - } - } - - console.log('[migrate] Done!'); - process.exit(0); -} - -migrate().catch((err) => { - console.error('[migrate] Failed:', err); - process.exit(1); -}); diff --git a/seed/resources/GUIDE.md b/seed/resources/GUIDE.md deleted file mode 100644 index 44a4a355..00000000 --- a/seed/resources/GUIDE.md +++ /dev/null @@ -1,51 +0,0 @@ -# Resource Configuration Guide - -You are an AI assistant helping users configure **resources** — external services and APIs that Officer connects to. - -## What is a Resource? - -A resource represents an external service (e.g., a TTS server, an STT API, an OCR endpoint). Each resource has: -- A `RESOURCE.md` file with frontmatter metadata (name, description) and a markdown body -- A `config.json` file with flat key-value connection settings (all strings) - -## config.json Format - -The config is a flat JSON object where every value is a string. Empty string means "not set". - -Base fields (always present): -- `url` — the service endpoint URL -- `api_key` — API key or token for authentication -- `username` — username for basic auth -- `password` — password for basic auth - -Additional fields vary per resource (e.g., `provider`, `model`, `voice`). - -Example: -```json -{ - "url": "http://localhost:8000", - "api_key": "", - "username": "", - "password": "", - "provider": "openai", - "model": "tts-1", - "voice": "alloy" -} -``` - -## How to Help the User - -1. **Ask what service they're connecting to** — provider name, URL, auth method -2. **Fill in the config.json** — write the file with the values they provide -3. **Explain each field** — tell them what each key does and what format it expects -4. **For new resources**, also write the `RESOURCE.md` with an appropriate name and description in the frontmatter - -## RESOURCE.md Format - -```markdown ---- -name: Human Readable Name -description: One-line description of what this resource does ---- -Optional longer markdown body with usage notes, compatible providers, etc. -``` diff --git a/seed/resources/SERVICE_optical_character_recognition.md b/seed/resources/SERVICE_optical_character_recognition.md deleted file mode 100644 index 2f399f19..00000000 --- a/seed/resources/SERVICE_optical_character_recognition.md +++ /dev/null @@ -1,6 +0,0 @@ -# Optical Character Recognition — OCR API - -- **Type:** OCR -- **Port:** 8082 -- **Description:** HTTP server for optical character recognition. Compatible with OpenAI-style vision APIs. Configure the URL and model in the connection settings below. -- **Verify:** `curl -sf {url}/v1/models` diff --git a/seed/resources/SERVICE_speech_to_text.md b/seed/resources/SERVICE_speech_to_text.md deleted file mode 100644 index ba47db5a..00000000 --- a/seed/resources/SERVICE_speech_to_text.md +++ /dev/null @@ -1,6 +0,0 @@ -# Speech to Text — Transcription API - -- **Type:** STT -- **Port:** 8080 -- **Description:** HTTP server for speech-to-text transcription. Compatible with the whisper.cpp server API. Configure the URL in the connection settings below. -- **Verify:** `curl -sf {url}/health` diff --git a/seed/resources/SERVICE_text_to_speech.md b/seed/resources/SERVICE_text_to_speech.md deleted file mode 100644 index df7ef8a1..00000000 --- a/seed/resources/SERVICE_text_to_speech.md +++ /dev/null @@ -1,6 +0,0 @@ -# Text to Speech — Synthesis API - -- **Type:** TTS -- **Port:** 8000 -- **Description:** HTTP server for text-to-speech synthesis. Compatible with the OpenAI audio/speech API (mlx-audio, Kokoro, etc.) and ElevenLabs. Configure the URL and credentials in the connection settings below. -- **Verify:** `curl -sf {url}/v1/models` diff --git a/seed/resources/optical-character-recognition/RESOURCE.md b/seed/resources/optical-character-recognition/RESOURCE.md deleted file mode 100644 index 1d59577c..00000000 --- a/seed/resources/optical-character-recognition/RESOURCE.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: Optical Character Recognition -description: HTTP server for optical character recognition ---- -Compatible with OpenAI-style vision APIs for extracting text from images. diff --git a/seed/resources/optical-character-recognition/config.json b/seed/resources/optical-character-recognition/config.json deleted file mode 100644 index 50da5443..00000000 --- a/seed/resources/optical-character-recognition/config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "url": "", - "api_key": "", - "username": "", - "password": "", - "model": "" -} diff --git a/seed/resources/speech-to-text/RESOURCE.md b/seed/resources/speech-to-text/RESOURCE.md deleted file mode 100644 index 48be579f..00000000 --- a/seed/resources/speech-to-text/RESOURCE.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: Speech to Text -description: HTTP server for speech-to-text transcription ---- -Compatible with the whisper.cpp server API and OpenAI-compatible transcription endpoints. diff --git a/seed/resources/speech-to-text/config.json b/seed/resources/speech-to-text/config.json deleted file mode 100644 index 8d209abe..00000000 --- a/seed/resources/speech-to-text/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "url": "", - "api_key": "", - "username": "", - "password": "" -} diff --git a/seed/resources/text-to-speech/RESOURCE.md b/seed/resources/text-to-speech/RESOURCE.md deleted file mode 100644 index c883040a..00000000 --- a/seed/resources/text-to-speech/RESOURCE.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: Text to Speech -description: HTTP server for text-to-speech synthesis ---- -Compatible with OpenAI audio/speech API (mlx-audio, Kokoro, etc.) and ElevenLabs. diff --git a/seed/resources/text-to-speech/config.json b/seed/resources/text-to-speech/config.json deleted file mode 100644 index 8e068f2f..00000000 --- a/seed/resources/text-to-speech/config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "url": "", - "api_key": "", - "username": "", - "password": "", - "provider": "", - "model": "", - "voice": "" -} diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 93fd02d7..5fd227ee 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -44,10 +44,6 @@ export function App() { path="/settings/system" element={user?.role !== 'Member' ? : } /> - : } - /> : } diff --git a/src/apps/officer-web/Screens/Dashboard/OnboardingAdmin/AIHarnessesCard.tsx b/src/apps/officer-web/Screens/Dashboard/OnboardingAdmin/AIHarnessesCard.tsx index a3792fc4..dd36461e 100644 --- a/src/apps/officer-web/Screens/Dashboard/OnboardingAdmin/AIHarnessesCard.tsx +++ b/src/apps/officer-web/Screens/Dashboard/OnboardingAdmin/AIHarnessesCard.tsx @@ -2,15 +2,9 @@ import { useState } from 'react'; import { Terminal, Copy, Check } from 'lucide-react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; import { Card } from '@/components/Card'; import { useClient } from 'hooks/useClient'; -type Harnesses = { - claudeCode: boolean; - opencode: boolean; -}; - type AIHarnessesCardProps = { onNext: () => void; onBack: () => void; @@ -20,30 +14,14 @@ type AIHarnessesCardProps = { export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCardProps) => { const client = useClient(); const queryClient = useQueryClient(); - const [harnesses, setHarnesses] = useState({ claudeCode: false, opencode: false }); - const [installing, setInstalling] = useState<{ claudeCode: boolean; opencode: boolean }>({ - claudeCode: false, - opencode: false, - }); + const [installing, setInstalling] = useState(false); type VersionInfo = { version: string | null; path: string | null; globalPath: string | null }; type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string }; - type OpencodeAuthInfo = { authenticated: boolean; providers: string[] }; const { data: claudeVersion, isLoading: claudeLoading } = useQuery({ queryKey: ['CLAUDE_CODE_VERSION'], queryFn: () => client.get('/server-settings/claude-code/version'), - enabled: harnesses.claudeCode, - refetchInterval: (query) => { - const data = query.state.data; - return data?.version && !data?.globalPath ? 1000 : false; - }, - }); - - const { data: opencodeVersion, isLoading: opencodeLoading } = useQuery({ - queryKey: ['OPENCODE_VERSION'], - queryFn: () => client.get('/server-settings/opencode/version'), - enabled: harnesses.opencode, refetchInterval: (query) => { const data = query.state.data; return data?.version && !data?.globalPath ? 1000 : false; @@ -57,30 +35,13 @@ export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCar refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false), }); - const { data: opencodeAuth } = useQuery({ - queryKey: ['OPENCODE_AUTH'], - queryFn: () => client.get('/server-settings/opencode/auth'), - enabled: !!opencodeVersion?.version, - refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false), - }); - const installClaude = async () => { - setInstalling((prev) => ({ ...prev, claudeCode: true })); + setInstalling(true); try { const result = await client.post('/server-settings/claude-code/install'); queryClient.setQueryData(['CLAUDE_CODE_VERSION'], result); } finally { - setInstalling((prev) => ({ ...prev, claudeCode: false })); - } - }; - - const installOpencode = async () => { - setInstalling((prev) => ({ ...prev, opencode: true })); - try { - const result = await client.post('/server-settings/opencode/install'); - queryClient.setQueryData(['OPENCODE_VERSION'], result); - } finally { - setInstalling((prev) => ({ ...prev, opencode: false })); + setInstalling(false); } }; @@ -112,15 +73,10 @@ export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCar ); - const claudeReady = - !harnesses.claudeCode || (!!claudeVersion?.version && !!claudeVersion?.globalPath && !!claudeAuth?.authenticated); - const opencodeReady = - !harnesses.opencode || - (!!opencodeVersion?.version && !!opencodeVersion?.globalPath && !!opencodeAuth?.authenticated); - const canProceed = (harnesses.claudeCode || harnesses.opencode) && claudeReady && opencodeReady; + const claudeReady = !!claudeVersion?.version && !!claudeVersion?.globalPath && !!claudeAuth?.authenticated; const handleNext = async () => { - await saveSettings({ aiHarnesses: harnesses, onboardingComplete: true }); + await saveSettings({ aiHarnesses: { claudeCode: true }, onboardingComplete: true }); onNext(); }; @@ -130,111 +86,51 @@ export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCar

AI Harnesses

-

Which AI coding tools do you use?

+

Set up Claude Code for AI-assisted development.

- - {harnesses.opencode && ( -
- {opencodeLoading ? ( - 'Checking version...' - ) : opencodeVersion?.version ? ( - <> -
{opencodeVersion.version}
-
{opencodeVersion.path}
- {opencodeAuth && ( -
- {opencodeAuth.authenticated ? ( - `Logged in (${opencodeAuth.providers.join(', ')})` - ) : ( -
- Not logged in - -
- )} -
- )} - {!opencodeVersion.globalPath && opencodeVersion.path && ( - - )} - - ) : ( - - )} -
- )} -
- -
- - {harnesses.claudeCode && ( -
- {claudeLoading ? ( - 'Checking version...' - ) : claudeVersion?.version ? ( - <> -
{claudeVersion.version}
-
{claudeVersion.path}
- {claudeAuth && ( -
- {claudeAuth.authenticated ? ( - `Logged in (${claudeAuth.subscriptionType ?? 'unknown plan'})` - ) : ( -
- Not logged in - -
- )} -
- )} - {!claudeVersion.globalPath && claudeVersion.path && ( - - )} - - ) : ( - - )} -
- )} + Claude Code +
+ {claudeLoading ? ( + 'Checking version...' + ) : claudeVersion?.version ? ( + <> +
{claudeVersion.version}
+
{claudeVersion.path}
+ {claudeAuth && ( +
+ {claudeAuth.authenticated ? ( + `Logged in (${claudeAuth.subscriptionType ?? 'unknown plan'})` + ) : ( +
+ Not logged in + +
+ )} +
+ )} + {!claudeVersion.globalPath && claudeVersion.path && ( + + )} + + ) : ( + + )} +
@@ -242,7 +138,7 @@ export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCar - diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/AISettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/AISettings.tsx index 2ad5c33e..e70854fc 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/AISettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/AISettings.tsx @@ -19,7 +19,6 @@ import { AIModels } from './ProfileSettings/AIModels'; const PROVIDER_DISPLAY: Record = { anthropic: 'Anthropic', openai: 'OpenAI', - opencode: 'OpenCode Zen', zai: 'ZAI', google: 'Google', groq: 'Groq', diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Applications.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Applications.tsx deleted file mode 100644 index c4f436bf..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Applications.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useState } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { RefreshCw, Download, Circle, Copy, Check } from 'lucide-react'; -import { toast } from 'sonner'; -import { Button } from '@/components/ui/button'; -import { useClient } from 'hooks/useClient'; - -type AppStatus = { - id: string; - name: string; - description: string; - installed: boolean; - version: string | null; - running: boolean | null; - hasInstall: boolean; - hasUpdate: boolean; - manualInstallCommand: string | null; - manualUpdateCommand: string | null; -}; - -const CopyCommand = ({ command }: { command: string }) => { - const [copied, setCopied] = useState(false); - - const copy = () => { - navigator.clipboard.writeText(command); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }; - - return ( -
- {command} - -
- ); -}; - -export const Applications = () => { - const client = useClient(); - const queryClient = useQueryClient(); - const [actionInProgress, setActionInProgress] = useState(null); - - const { data: apps, isLoading } = useQuery({ - queryKey: ['APPLICATIONS'], - queryFn: () => client.get('/server-settings/applications'), - }); - - const runAction = async (id: string, action: 'install' | 'update') => { - setActionInProgress(id); - try { - await client.post(`/server-settings/applications/${id}/${action}`); - await queryClient.invalidateQueries({ queryKey: ['APPLICATIONS'] }); - toast.success(`${action === 'install' ? 'Installed' : 'Updated'} successfully`); - } catch (err) { - const message = err instanceof Error ? err.message : `${action} failed`; - toast.error(message); - } finally { - setActionInProgress(null); - } - }; - - const getManualCommand = (app: AppStatus): string | null => { - if (!app.installed) return app.manualInstallCommand; - return app.manualUpdateCommand ?? app.manualInstallCommand; - }; - - const hasAutoAction = (app: AppStatus): boolean => { - if (!app.installed) return app.hasInstall && !app.manualInstallCommand; - return app.hasUpdate && !(app.manualUpdateCommand ?? app.manualInstallCommand); - }; - - return ( -
-

Applications

- - {isLoading &&

Checking applications...

} - - {apps && ( -
- {apps.map((app: AppStatus) => { - const manualCmd = getManualCommand(app); - const canAutoRun = hasAutoAction(app); - - return ( -
-
-
-
- {app.name} - {app.installed && ( - - {app.version} - - )} - {!app.installed && ( - - Not installed - - )} - {app.running !== null && ( - - )} -
-

{app.description}

-
- -
- {canAutoRun && !app.installed && ( - - )} - {canAutoRun && app.installed && ( - - )} -
-
- - {manualCmd && ( -
- {app.installed ? 'Update' : 'Install'} manually: - -
- )} -
- ); - })} -
- )} -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx deleted file mode 100644 index e0ab80ef..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useState } from 'react'; -import { Server, Plus, Check, X, Search } from 'lucide-react'; -import { useGlobal } from 'hooks/useGlobal'; -import { useResources, type ResourceSummary } from 'state/useResources'; - -export const ResourceSidebar = () => { - const { resources, isLoading, createResource } = useResources(); - const [selectedName, setSelectedName] = useGlobal('RESOURCE_SELECTED', null); - const [search, setSearch] = useState(''); - const [creating, setCreating] = useState(false); - const [newName, setNewName] = useState(''); - - const query = search.toLowerCase(); - const filtered = resources?.filter( - (r: ResourceSummary) => - !query || r.name.toLowerCase().includes(query) || r.description?.toLowerCase().includes(query), - ) ?? []; - - const handleSelect = (dirName: string) => { - setSelectedName(dirName); - }; - - const handleCreate = async () => { - const name = newName.trim(); - if (!name) return; - try { - const result = await createResource(name); - setCreating(false); - setNewName(''); - setSelectedName(result.dirName); - } catch { - // error handled by client - } - }; - - return ( -
-
- Resources - {!creating && ( - - )} -
- {creating && ( -
- setNewName(ev.target.value)} - onKeyDown={(ev) => { - if (ev.key === 'Enter') { - ev.preventDefault(); - handleCreate(); - } - if (ev.key === 'Escape') { - setCreating(false); - setNewName(''); - } - }} - placeholder="Resource name..." - className="flex-1 min-w-0 rounded border border-duck-dark/20 bg-background px-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30" - autoFocus - /> - - -
- )} -
-
- - setSearch(ev.target.value)} - placeholder="Search resources..." - className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30" - /> -
-
-
- {isLoading &&

Loading...

} - {filtered.map((r: ResourceSummary) => ( - - ))} - {!isLoading && resources && resources.length === 0 && ( -

No resources found

- )} - {!isLoading && resources && resources.length > 0 && filtered.length === 0 && ( -

No matches

- )} -
-
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx deleted file mode 100644 index 82b976ee..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import { useState, useEffect, useRef } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import rehypeRaw from 'rehype-raw'; -import { toast } from 'sonner'; -import { Pencil, Trash2, Plus, X, Loader2, ArrowLeft } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; -import { useGlobal } from 'hooks/useGlobal'; -import { useClient } from 'hooks/useClient'; -import { usePiChat, EmbeddableChat } from 'officerdev'; -import { useResources, type ResourceDetail, type PingResult } from 'state/useResources'; -import { FrontmatterBlock } from '../../CapabilityPage'; - -const isSensitiveKey = (key: string) => /key|secret|password|token/i.test(key); - -type ConfigEditorProps = { - resourceName: string; - config: Record; - onSaved: () => void; -}; - -const ConfigEditor = ({ resourceName, config, onSaved }: ConfigEditorProps) => { - const { saveConfig, pingResource } = useResources(); - const configEntries = Object.entries(config); - const [fields, setFields] = useState<[string, string][]>(configEntries); - const [newKey, setNewKey] = useState(''); - const [newValue, setNewValue] = useState(''); - const [pinging, setPinging] = useState(false); - const [pingResult, setPingResult] = useState(null); - const [saving, setSaving] = useState(false); - - useEffect(() => { - setFields(Object.entries(config)); - }, [config]); - - const hasUrl = fields.some(([k, v]) => k === 'url' && v); - - const handleAddField = () => { - const key = newKey.trim(); - if (!key || fields.some(([k]) => k === key)) return; - setFields([...fields, [key, newValue]]); - setNewKey(''); - setNewValue(''); - }; - - const handleRemoveField = (index: number) => { - setFields(fields.filter((_, i) => i !== index)); - }; - - const handleFieldValue = (index: number, value: string) => { - setFields(fields.map((f, i) => (i === index ? [f[0]!, value] : f))); - }; - - const handlePing = async () => { - const url = fields.find(([k]) => k === 'url')?.[1]; - if (!url) return; - setPinging(true); - setPingResult(null); - try { - const result = await pingResource(resourceName, url); - setPingResult(result); - } catch { - setPingResult({ reachable: false, latencyMs: null }); - } finally { - setPinging(false); - } - }; - - const handleSave = async () => { - setSaving(true); - try { - const oldKeys = Object.keys(config); - const newKeys = new Set(fields.map(([k]) => k)); - const patch: Record = {}; - for (const [key, value] of fields) { - patch[key] = value; - } - for (const key of oldKeys) { - if (!newKeys.has(key)) patch[key] = null; - } - await saveConfig(resourceName, patch); - onSaved(); - toast.success('Configuration saved'); - } catch { - toast.error('Failed to save configuration'); - } finally { - setSaving(false); - } - }; - - return ( -
-

Configuration

-
- {fields.map(([key, value], index) => ( -
- - handleFieldValue(index, ev.target.value)} - type={isSensitiveKey(key) ? 'password' : 'text'} - className="h-8 text-xs flex-1" - /> - -
- ))} -
- setNewKey(ev.target.value)} - placeholder="key" - className="h-8 text-xs w-28 shrink-0" - onKeyDown={(ev) => ev.key === 'Enter' && handleAddField()} - /> - setNewValue(ev.target.value)} - placeholder="value" - className="h-8 text-xs flex-1" - onKeyDown={(ev) => ev.key === 'Enter' && handleAddField()} - /> - -
-
- {hasUrl && ( - - )} - - {pingResult && ( - - {pingResult.reachable ? `Reachable (${pingResult.latencyMs}ms)` : 'Unreachable'} - - )} -
-
-
- ); -}; - -type ResourceChatProps = { - detail: ResourceDetail; - isNew?: boolean; - onResponseEnd: () => void; -}; - -const ResourceChat = ({ detail, isNew, onResponseEnd }: ResourceChatProps) => { - const promptFrontmatter = `\nconfig file: ${detail.configPath}\nresource file: ${detail.filePath}\nguide: ${detail.guidePath}\n\nYou are helping configure a resource. Read the GUIDE.md for instructions on how to help. Read the RESOURCE.md for context about what this resource is. Write config values to the config.json file.\n`; - const defaultInput = isNew - ? 'Help me set up this new resource' - : 'Help me configure this resource'; - - const pi = usePiChat(undefined, undefined, { replaceUrl: false }); - - const onResponseEndRef = useRef(onResponseEnd); - onResponseEndRef.current = onResponseEnd; - - const wasGenerating = useRef(false); - useEffect(() => { - if (wasGenerating.current && !pi.isGenerating) { - onResponseEndRef.current(); - } - wasGenerating.current = pi.isGenerating; - }, [pi.isGenerating]); - - return ( - - ); -}; - -export const Resources = () => { - const client = useClient(); - const qc = useQueryClient(); - const [selectedName] = useGlobal('RESOURCE_SELECTED', null); - const [editing, setEditing] = useState(false); - const [isNew, setIsNew] = useState(false); - const [deleteConfirm, setDeleteConfirm] = useState(false); - const [showDetail, setShowDetail] = useState(false); - - const { data: detail, refetch } = useQuery({ - queryKey: ['RESOURCES', selectedName], - queryFn: () => client.get(`/server-settings/resources/${selectedName}`), - enabled: !!selectedName, - }); - - useEffect(() => { - if (selectedName) { - setShowDetail(true); - setEditing(false); - } - }, [selectedName]); - - const handleDelete = async () => { - if (!selectedName) return; - try { - await client.delete(`/server-settings/resources/${selectedName}`); - setDeleteConfirm(false); - setEditing(false); - await qc.invalidateQueries({ queryKey: ['RESOURCES'] }); - } catch { - toast.error('Failed to delete resource'); - } - }; - - const canDelete = detail && detail.scope === 'global' && !detail.filePath.includes('/seed/'); - - if (!selectedName || !detail) { - return ( -
-

Select a resource to view its configuration

-
- ); - } - - return ( - <> -
- {/* Detail panel */} -
-
- - {detail.name} - - {detail.scope} - - - {canDelete && ( - - )} -
-
- {detail.rawFrontmatter && } - {detail.body && ( -
- - {detail.body} - -
- )} - refetch()} - /> -
-
- - {/* Chat panel */} - {editing && ( -
-
- - {detail.name} — Chat - -
- { - refetch(); - qc.invalidateQueries({ queryKey: ['RESOURCES'] }); - }} - /> -
- )} -
- - - - - Delete Resource - - Are you sure you want to delete "{detail.name}"? This action cannot be undone. - - -
- - -
-
-
- - ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx deleted file mode 100644 index 84493f0a..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useMemo } from 'react'; -import type { LayoutNode, PanelComponents } from 'officerdev'; -import { WorkspaceLayout } from 'officerdev'; - -import { Resources } from './Resources'; -import { ResourceSidebar } from './ResourceSidebar'; - -const layout: LayoutNode = { - type: 'group', - id: 'resources-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'resources-left', appType: null }, size: 20 }, - { node: { type: 'panel', id: 'resources-right', appType: null }, size: 80 }, - ], -}; - -export const ResourceSettings = () => { - const panelComponents: PanelComponents = useMemo( - () => ({ - 'resources-left': ResourceSidebar, - 'resources-right': Resources, - }), - [], - ); - - return ( -
- {}} components={panelComponents} /> -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx index 0ee8b4b0..88d51ae5 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx @@ -44,7 +44,6 @@ const PI_PROVIDERS: { key: string; piId: string }[] = [ { key: 'MiniMax', piId: 'minimax' }, { key: 'Hugging Face', piId: 'huggingface' }, { key: 'Azure OpenAI', piId: 'azure-openai-responses' }, - { key: 'OpenCode Zen', piId: 'opencode' }, { key: 'ZAI', piId: 'zai' }, { key: 'Cerebras', piId: 'cerebras' }, ]; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx index f652a760..eccceebc 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx @@ -1,7 +1,6 @@ export * from './ProfileSettings'; export * from './SystemSettings'; export * from './AISettings'; -export * from './ResourceSettings'; export * from './UserSettings'; export * from './IntegrationsSettings'; export * from './AppsSettings'; diff --git a/src/databases/officer_db/src/schema/agent-items.ts b/src/databases/officer_db/src/schema/agent-items.ts index 791f6fa1..1cca46f6 100644 --- a/src/databases/officer_db/src/schema/agent-items.ts +++ b/src/databases/officer_db/src/schema/agent-items.ts @@ -8,143 +8,148 @@ import { users } from './auth'; // ── Tasks ── // Complex frontmatter: inputs, outputs, dependencies, triggers, config, tags, tools, skills -export const tasks = pgTable('tasks', { - id: serial('id').primaryKey(), - scope: text('scope').notNull(), - userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), - dirName: text('dir_name').notNull(), - name: text('name').notNull(), - description: text('description'), - body: text('body'), - version: integer('version').notNull().default(1), - tags: jsonb('tags').$type(), - tools: jsonb('tools').$type(), - skills: jsonb('skills').$type(), - inputs: jsonb('inputs'), - outputs: jsonb('outputs'), - dependencies: jsonb('dependencies'), - config: jsonb('config'), - trigger: jsonb('trigger'), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}, (table) => [ - unique('uq_tasks_scope_user_dir').on(table.scope, table.userId, table.dirName), - index('idx_tasks_scope').on(table.scope), - index('idx_tasks_user').on(table.userId), -]); +export const tasks = pgTable( + 'tasks', + { + id: serial('id').primaryKey(), + scope: text('scope').notNull(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), + dirName: text('dir_name').notNull(), + name: text('name').notNull(), + description: text('description'), + body: text('body'), + version: integer('version').notNull().default(1), + tags: jsonb('tags').$type(), + tools: jsonb('tools').$type(), + skills: jsonb('skills').$type(), + inputs: jsonb('inputs'), + outputs: jsonb('outputs'), + dependencies: jsonb('dependencies'), + config: jsonb('config'), + trigger: jsonb('trigger'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique('uq_tasks_scope_user_dir').on(table.scope, table.userId, table.dirName), + index('idx_tasks_scope').on(table.scope), + index('idx_tasks_user').on(table.userId), + ], +); // ── Skills ── // Minimal frontmatter: name, description only. Rich markdown body. -export const skills = pgTable('skills', { - id: serial('id').primaryKey(), - scope: text('scope').notNull(), - userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), - dirName: text('dir_name').notNull(), - name: text('name').notNull(), - description: text('description'), - body: text('body'), - version: integer('version').notNull().default(1), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}, (table) => [ - unique('uq_skills_scope_user_dir').on(table.scope, table.userId, table.dirName), - index('idx_skills_scope').on(table.scope), - index('idx_skills_user').on(table.userId), -]); +export const skills = pgTable( + 'skills', + { + id: serial('id').primaryKey(), + scope: text('scope').notNull(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), + dirName: text('dir_name').notNull(), + name: text('name').notNull(), + description: text('description'), + body: text('body'), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique('uq_skills_scope_user_dir').on(table.scope, table.userId, table.dirName), + index('idx_skills_scope').on(table.scope), + index('idx_skills_user').on(table.userId), + ], +); // ── Processes ── // Same shape as skills. Represents documented workflows. -export const processes = pgTable('processes', { - id: serial('id').primaryKey(), - scope: text('scope').notNull(), - userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), - dirName: text('dir_name').notNull(), - name: text('name').notNull(), - description: text('description'), - body: text('body'), - version: integer('version').notNull().default(1), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}, (table) => [ - unique('uq_processes_scope_user_dir').on(table.scope, table.userId, table.dirName), - index('idx_processes_scope').on(table.scope), - index('idx_processes_user').on(table.userId), -]); - -// ── Resources ── -// Has separate config (key-value for external service settings). No user scope. - -export const resources = pgTable('resources', { - id: serial('id').primaryKey(), - scope: text('scope').notNull(), - dirName: text('dir_name').notNull(), - name: text('name').notNull(), - description: text('description'), - body: text('body'), - version: integer('version').notNull().default(1), - config: jsonb('config').$type>().notNull().default({}), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}, (table) => [ - unique('uq_resources_scope_dir').on(table.scope, table.dirName), - index('idx_resources_scope').on(table.scope), -]); +export const processes = pgTable( + 'processes', + { + id: serial('id').primaryKey(), + scope: text('scope').notNull(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), + dirName: text('dir_name').notNull(), + name: text('name').notNull(), + description: text('description'), + body: text('body'), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique('uq_processes_scope_user_dir').on(table.scope, table.userId, table.dirName), + index('idx_processes_scope').on(table.scope), + index('idx_processes_user').on(table.userId), + ], +); // ── Tools ── // Has implementation code, language, structured input params, label. -export const tools = pgTable('tools', { - id: serial('id').primaryKey(), - scope: text('scope').notNull(), - userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), - dirName: text('dir_name').notNull(), - name: text('name').notNull(), - label: text('label'), - description: text('description'), - body: text('body'), - version: integer('version').notNull().default(1), - language: text('language'), - inputs: jsonb('inputs'), - implementation: text('implementation'), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}, (table) => [ - unique('uq_tools_scope_user_dir').on(table.scope, table.userId, table.dirName), - index('idx_tools_scope').on(table.scope), - index('idx_tools_user').on(table.userId), -]); +export const tools = pgTable( + 'tools', + { + id: serial('id').primaryKey(), + scope: text('scope').notNull(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), + dirName: text('dir_name').notNull(), + name: text('name').notNull(), + label: text('label'), + description: text('description'), + body: text('body'), + version: integer('version').notNull().default(1), + language: text('language'), + inputs: jsonb('inputs'), + implementation: text('implementation'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique('uq_tools_scope_user_dir').on(table.scope, table.userId, table.dirName), + index('idx_tools_scope').on(table.scope), + index('idx_tools_user').on(table.userId), + ], +); // ── Extensions ── // Code-only, no markdown, no chat. Just implementation. -export const extensions = pgTable('extensions', { - id: serial('id').primaryKey(), - scope: text('scope').notNull(), - userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), - dirName: text('dir_name').notNull(), - name: text('name').notNull(), - implementation: text('implementation'), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}, (table) => [ - unique('uq_extensions_scope_user_dir').on(table.scope, table.userId, table.dirName), - index('idx_extensions_scope').on(table.scope), - index('idx_extensions_user').on(table.userId), -]); +export const extensions = pgTable( + 'extensions', + { + id: serial('id').primaryKey(), + scope: text('scope').notNull(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), + dirName: text('dir_name').notNull(), + name: text('name').notNull(), + implementation: text('implementation'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique('uq_extensions_scope_user_dir').on(table.scope, table.userId, table.dirName), + index('idx_extensions_scope').on(table.scope), + index('idx_extensions_user').on(table.userId), + ], +); // ── Item Chats ── // Chat history for tasks, skills, processes, resources, tools. // Uses polymorphic reference (item_type + item_id) instead of per-table FKs. -export const itemChats = pgTable('item_chats', { - id: text('id').primaryKey(), - itemType: text('item_type').notNull(), - itemId: integer('item_id').notNull(), - messages: jsonb('messages').notNull().default([]), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}, (table) => [ - unique('uq_item_chats_type_item').on(table.itemType, table.itemId), - index('idx_item_chats_type_item').on(table.itemType, table.itemId), -]); +export const itemChats = pgTable( + 'item_chats', + { + id: text('id').primaryKey(), + itemType: text('item_type').notNull(), + itemId: integer('item_id').notNull(), + messages: jsonb('messages').notNull().default([]), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique('uq_item_chats_type_item').on(table.itemType, table.itemId), + index('idx_item_chats_type_item').on(table.itemType, table.itemId), + ], +); diff --git a/src/databases/officer_db/src/types.ts b/src/databases/officer_db/src/types.ts index dc682d72..aac768ca 100644 --- a/src/databases/officer_db/src/types.ts +++ b/src/databases/officer_db/src/types.ts @@ -74,11 +74,6 @@ export type SkillInsert = typeof Schema.skills.$inferInsert; export type ProcessSelect = typeof Schema.processes.$inferSelect; export type ProcessInsert = typeof Schema.processes.$inferInsert; -// ── Resources ── - -export type ResourceSelect = typeof Schema.resources.$inferSelect; -export type ResourceInsert = typeof Schema.resources.$inferInsert; - // ── Tools ── export type ToolSelect = typeof Schema.tools.$inferSelect; diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index 655e1a50..b30217ce 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -2,7 +2,7 @@ import { createRouter } from '@@/create-router'; import { resolve, dirname, join, parse as parsePath } from 'node:path'; import { readdir, stat, mkdir, rm, rename, readFile, cp, unlink } from 'node:fs/promises'; import { existsSync } from 'node:fs'; -import { getHomeDir, DATA_PATH, getUserSettingsFile } from '@@/data-path'; +import { getHomeDir, DATA_PATH } from '@@/data-path'; import * as errors from '@@/custom-errors'; import { readTtsConfig } from '@@/api/server-settings/tts'; import { readSttConfig } from '@@/api/server-settings/stt'; @@ -11,7 +11,7 @@ import { getUserSettings } from 'officerdb'; async function getUserTtsVoice(userId: number): Promise { try { - const settings = await getUserSettings(userId) as { tts?: { voice?: string | null } }; + const settings = (await getUserSettings(userId)) as { tts?: { voice?: string | null } }; return settings.tts?.voice ?? null; } catch {} return null; @@ -282,11 +282,23 @@ router.get('/transcode', async (ctx) => { const proc = Bun.spawn( [ - 'ffmpeg', '-i', absPath, - '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23', - '-c:a', 'aac', '-b:a', '128k', - '-movflags', '+faststart', - '-y', tmpPath, + 'ffmpeg', + '-i', + absPath, + '-c:v', + 'libx264', + '-preset', + 'ultrafast', + '-crf', + '23', + '-c:a', + 'aac', + '-b:a', + '128k', + '-movflags', + '+faststart', + '-y', + tmpPath, ], { stdout: 'ignore', stderr: 'pipe' }, ); @@ -343,10 +355,10 @@ router.get('/transcode-audio', async (ctx) => { const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcode a directory'); - const proc = Bun.spawn( - ['ffmpeg', '-i', absPath, '-c:a', 'libmp3lame', '-q:a', '2', '-f', 'mp3', 'pipe:1'], - { stdout: 'pipe', stderr: 'ignore' }, - ); + const proc = Bun.spawn(['ffmpeg', '-i', absPath, '-c:a', 'libmp3lame', '-q:a', '2', '-f', 'mp3', 'pipe:1'], { + stdout: 'pipe', + stderr: 'ignore', + }); return new Response(proc.stdout as ReadableStream, { headers: { @@ -799,13 +811,10 @@ router.post('/transcribe', async (ctx) => { // Step 2: Check user's spoken languages to decide if translation is needed let shouldTranslate = false; - const settingsFile = Bun.file(getUserSettingsFile(user.email)); - if (await settingsFile.exists()) { - const settings = (await settingsFile.json()) as { languages?: { spoken?: string[] } }; - const spokenLanguages = settings.languages?.spoken ?? []; - if (spokenLanguages.length > 0 && !spokenLanguages.includes(detectedLang)) { - shouldTranslate = true; - } + const settings = await getUserSettings(user.id); + const spokenLanguages = (settings.languages as { spoken?: string[] })?.spoken ?? []; + if (spokenLanguages.length > 0 && !spokenLanguages.includes(detectedLang)) { + shouldTranslate = true; } // Step 3: Full transcription @@ -997,7 +1006,17 @@ router.post('/download-video', async (ctx) => { const absPath = resolveUserPath(rootDir, path); await mkdir(absPath, { recursive: true }); const ytdlp = Bun.which('yt-dlp') ?? `${process.env.HOME}/.local/bin/yt-dlp`; - const args = [ytdlp, '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--cookies-from-browser', 'brave', '-o', '%(title)s.%(ext)s']; + const args = [ + ytdlp, + '--remote-components', + 'ejs:github', + '--js-runtimes', + 'node', + '--cookies-from-browser', + 'brave', + '-o', + '%(title)s.%(ext)s', + ]; if (audioOnly) args.push('-x', '--audio-format', 'mp3'); args.push(url); diff --git a/src/servers/api/pi/pi-bridge.ts b/src/servers/api/pi/pi-bridge.ts index dc6ca898..6a84cdbf 100644 --- a/src/servers/api/pi/pi-bridge.ts +++ b/src/servers/api/pi/pi-bridge.ts @@ -1,8 +1,7 @@ import { join } from 'path'; -import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { readdirSync, existsSync, mkdirSync } from 'node:fs'; import type { Subprocess } from 'bun'; import type { PiEvent, MessageCost } from './types'; -import { readSearxngConfig } from '../server-settings/searxng'; import { PI_CONFIG_DIR, DATA_PATH, @@ -13,13 +12,10 @@ import { getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, - getNativeResourcesDir, - getGlobalResourcesDir, toShellUsername, } from '../../data-path'; -import { getServerIntegration, getUserIntegration } from 'officerdb'; +import { getServerIntegration, getUserIntegration, readConfigValue } from 'officerdb'; import { logger } from './logger'; -import { parseFrontmatter } from '../skills/skills'; import { getRelayPort } from '../browser/relay'; import { registerUserToken } from '../browser/relay-auth'; @@ -75,125 +71,6 @@ function collectExtensionFlags(email: string): string[] { return flags; } -export function generateResourceSkill(outputDir: string): string | null { - const nativeDir = getNativeResourcesDir(); - const globalDir = getGlobalResourcesDir(); - - // Collect all resource dirs (global overrides native) - const resourceDirs = new Map(); - for (const dir of [nativeDir, globalDir]) { - if (!existsSync(dir)) continue; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - if (existsSync(join(dir, entry.name, 'RESOURCE.md'))) { - resourceDirs.set(entry.name, dir); - } - } - } - - if (resourceDirs.size === 0) return null; - - const sections: string[] = []; - for (const [name, baseDir] of resourceDirs) { - const resourceMd = join(baseDir, name, 'RESOURCE.md'); - let mdContent = ''; - try { - mdContent = readFileSync(resourceMd, 'utf-8'); - } catch { - continue; - } - const { frontmatter } = parseFrontmatter(mdContent); - - // Merge native + global config - let nativeConfig: Record = {}; - let globalConfig: Record = {}; - try { - nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); - } catch {} - try { - globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); - } catch {} - - const config: Record = {}; - for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!; - for (const key of Object.keys(globalConfig)) if (!(key in config)) config[key] = globalConfig[key]!; - - const hasValues = Object.values(config).some((v) => v !== ''); - const configLines = Object.entries(config) - .filter(([, v]) => v) - .map(([k, v]) => (/key|secret|password|token/i.test(k) ? `- **${k}**: (configured)` : `- **${k}**: ${v}`)); - - sections.push( - [ - `### ${frontmatter.name || name}`, - hasValues ? 'Status: **configured**' : 'Status: not configured', - ...configLines, - ].join('\n'), - ); - } - - const skillContent = [ - '---', - 'name: Available Resources', - 'description: External services and APIs configured on this Officer instance', - '---', - '', - 'These are external services available to you. Use their configured URLs directly via HTTP requests.', - 'Do NOT try to install local alternatives (like tesseract, whisper, etc.) — use the configured HTTP APIs instead.', - '', - ...sections, - ].join('\n'); - - const skillDir = join(outputDir, '.generated', 'available-resources'); - try { - mkdirSync(skillDir, { recursive: true }); - writeFileSync(join(skillDir, 'SKILL.md'), skillContent, 'utf-8'); - return skillDir; - } catch { - logger.error(`Failed to write resource skill to ${skillDir}`); - return null; - } -} - -function buildResourcesEnv(): string { - const nativeDir = getNativeResourcesDir(); - const globalDir = getGlobalResourcesDir(); - - const resourceDirs = new Map(); - for (const dir of [nativeDir, globalDir]) { - if (!existsSync(dir)) continue; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - if (existsSync(join(dir, entry.name, 'RESOURCE.md')) || existsSync(join(dir, entry.name, 'config.json'))) { - resourceDirs.set(entry.name, dir); - } - } - } - - const result: Record> = {}; - for (const [name] of resourceDirs) { - let nativeConfig: Record = {}; - let globalConfig: Record = {}; - try { - nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); - } catch {} - try { - globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); - } catch {} - - const config: Record = {}; - for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!; - for (const key of Object.keys(globalConfig)) if (!(key in config)) config[key] = globalConfig[key]!; - - // Only include resources that have at least one non-empty value - if (Object.values(config).some((v) => v !== '')) { - result[name] = config; - } - } - - return JSON.stringify(result); -} - async function getApifyToken(): Promise { try { const integration = await getServerIntegration('apify'); @@ -248,13 +125,10 @@ export async function spawnPi( onEvent: PiEventHandler, options?: SpawnPiOptions, ): Promise { - const searxng = await readSearxngConfig(); + const searxngUrl = await readConfigValue('searxng-url', ''); const skillFlags = collectSkillFlags(email); const extensionFlags = collectExtensionFlags(email); - const resourceSkillDir = generateResourceSkill(DATA_PATH); - const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : []; - const piArgs = [ ...PI_CMD, '--mode', @@ -264,7 +138,6 @@ export async function spawnPi( '--no-themes', ...skillFlags, ...extensionFlags, - ...resourceSkillFlags, ]; if (model) piArgs.push('--model', model); if (options?.sessionFile) piArgs.push('--session', options.sessionFile); @@ -290,8 +163,7 @@ export async function spawnPi( OFFICER_USER_ROOT: join(DATA_PATH, email), PI_CODING_AGENT_DIR: isServiceUser ? PI_CONFIG_DIR : join(homeDir, '.pi', 'agent'), PI_TOOLS_DIRS: toolsDirs, - PI_SEARXNG_URL: searxng.url, - OFFICER_RESOURCES: buildResourcesEnv(), + PI_SEARXNG_URL: searxngUrl, OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'), TERM: 'xterm-256color', PATH: process.env.PATH ?? '', @@ -465,12 +337,14 @@ function parsePiEvent(event: Record, currentStreamBuffer: strin const toolName = (event.toolName as string) ?? 'unknown'; const args = (event.args as Record) ?? {}; - return [{ - type: 'tool:start', - toolCallId, - toolName, - toolInput: args, - }]; + return [ + { + type: 'tool:start', + toolCallId, + toolName, + toolInput: args, + }, + ]; } case 'tool_execution_end': { @@ -489,12 +363,14 @@ function parsePiEvent(event: Record, currentStreamBuffer: strin const isError = (event.isError as boolean) ?? (resultObj?.isError as boolean) ?? false; const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : ''; - return [{ - type: 'tool:result', - toolCallId, - output, - isError, - }]; + return [ + { + type: 'tool:result', + toolCallId, + output, + isError, + }, + ]; } case 'agent_end': { @@ -586,7 +462,6 @@ export async function buildHostToolEnv(userId: number, email: string, role?: str HOME: homeDir, OFFICER_USER_HOME: homeDir, OFFICER_USER_ROOT: join(DATA_PATH, email), - OFFICER_RESOURCES: buildResourcesEnv(), OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'), ...(apifyToken ? { OFFICER_APIFY_TOKEN: apifyToken } : {}), ...browserRelayEnv, diff --git a/src/servers/api/scrape/scrape.ts b/src/servers/api/scrape/scrape.ts index 8c8c5201..55ecc0d7 100644 --- a/src/servers/api/scrape/scrape.ts +++ b/src/servers/api/scrape/scrape.ts @@ -33,7 +33,7 @@ scrapeRouter.post('/', async (ctx) => { const { url, sessionId, provider } = ctx.get('body') as { url: string; sessionId?: string; - provider?: 'claude' | 'opencode' | 'pi-mono'; + provider?: 'claude' | 'pi-mono'; }; if (!url) return ctx.json({ error: 'url is required' }, 400); diff --git a/src/servers/api/server-settings/ocr.ts b/src/servers/api/server-settings/ocr.ts index 644c5602..671d5148 100644 --- a/src/servers/api/server-settings/ocr.ts +++ b/src/servers/api/server-settings/ocr.ts @@ -1,6 +1,5 @@ import { createRouter } from '../../create-router'; import { readServerSettings, writeServerSettings } from 'officerdb'; -import { readResourceConfig } from './resources'; type OcrConfig = { url: string; @@ -8,9 +7,6 @@ type OcrConfig = { }; export async function readOcrConfig(): Promise { - const config = await readResourceConfig('optical-character-recognition'); - if (config.url) return { url: config.url, model: config.model ?? '' }; - // Fallback to legacy DB settings const settings = await readServerSettings(); return settings.ocr as OcrConfig | undefined; } diff --git a/src/servers/api/server-settings/opencode.ts b/src/servers/api/server-settings/opencode.ts deleted file mode 100644 index b6ba9936..00000000 --- a/src/servers/api/server-settings/opencode.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { createRouter } from '../../create-router'; - -export const opencodeRouter = createRouter(); - -const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin']; - -const getPaths = async () => { - try { - const proc = Bun.spawn(['which', '-a', 'opencode'], { stdout: 'pipe', stderr: 'pipe' }); - const output = await new Response(proc.stdout).text(); - await proc.exited; - if (proc.exitCode !== 0) return { path: null, globalPath: null }; - const paths = [...new Set(output.trim().split('\n'))]; - const path = paths[0] ?? null; - const globalPath = paths.find((p) => GLOBAL_DIRS.some((dir) => p.startsWith(dir))) ?? null; - return { path, globalPath }; - } catch { - return { path: null, globalPath: null }; - } -}; - -opencodeRouter.post('/auth/login', async (ctx) => { - try { - Bun.spawn(['opencode', 'auth', 'login'], { stdout: 'ignore', stderr: 'ignore' }); - return ctx.json({ started: true }); - } catch { - return ctx.json({ started: false, error: 'Failed to start login' }, 500); - } -}); - -opencodeRouter.get('/auth', async (ctx) => { - try { - const authPath = `${process.env.HOME}/.local/share/opencode/auth.json`; - const file = Bun.file(authPath); - if (!(await file.exists())) return ctx.json({ authenticated: false, providers: [] }); - const auth = await file.json(); - const providers = Object.keys(auth); - return ctx.json({ authenticated: providers.length > 0, providers }); - } catch { - return ctx.json({ authenticated: false, providers: [] }); - } -}); - -opencodeRouter.post('/install', async (ctx) => { - try { - const proc = Bun.spawn(['bash', '-c', 'curl -fsSL https://opencode.ai/install | bash'], { - stdout: 'pipe', - stderr: 'pipe', - }); - await proc.exited; - if (proc.exitCode !== 0) { - const stderr = await new Response(proc.stderr).text(); - return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500); - } - const versionProc = Bun.spawn(['opencode', '--version'], { stdout: 'pipe', stderr: 'pipe' }); - const output = await new Response(versionProc.stdout).text(); - await versionProc.exited; - const { path, globalPath } = await getPaths(); - return ctx.json({ version: output.trim(), path, globalPath }); - } catch { - return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500); - } -}); - -opencodeRouter.get('/version', async (ctx) => { - try { - const proc = Bun.spawn(['opencode', '--version'], { stdout: 'pipe', stderr: 'pipe' }); - const output = await new Response(proc.stdout).text(); - await proc.exited; - if (proc.exitCode !== 0) return ctx.json({ version: null, path: null, globalPath: null }); - const { path, globalPath } = await getPaths(); - return ctx.json({ version: output.trim(), path, globalPath }); - } catch { - return ctx.json({ version: null, path: null, globalPath: null }); - } -}); diff --git a/src/servers/api/server-settings/resources.ts b/src/servers/api/server-settings/resources.ts deleted file mode 100644 index b140c1bf..00000000 --- a/src/servers/api/server-settings/resources.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { readdir, mkdir, rm } from 'node:fs/promises'; -import { join, dirname } from 'node:path'; -import { createRouter } from '../../create-router'; -import { getNativeResourcesDir, getGlobalResourcesDir } from '../../data-path'; -import { parseFrontmatter } from '../skills/skills'; - -async function readResourceDirs(dir: string): Promise> { - const result = new Map(); - try { - const entries = await readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const resourceFile = join(dir, entry.name, 'RESOURCE.md'); - if (await Bun.file(resourceFile).exists()) { - result.set(entry.name, resourceFile); - } - } - } catch { - // directory doesn't exist yet - } - return result; -} - -async function readConfigFile(dir: string): Promise> { - try { - return await Bun.file(join(dir, 'config.json')).json(); - } catch { - return {}; - } -} - -function mergeConfig(native: Record, global: Record): Record { - const merged: Record = {}; - for (const key of Object.keys(native)) { - merged[key] = global[key] ?? native[key]!; - } - for (const key of Object.keys(global)) { - if (!(key in merged)) merged[key] = global[key]!; - } - return merged; -} - -function isPrivileged(role: string) { - return role === 'Super Admin'; -} - -export async function readResourceConfig(name: string): Promise> { - const nativeConfig = await readConfigFile(join(getNativeResourcesDir(), name)); - const globalConfig = await readConfigFile(join(getGlobalResourcesDir(), name)); - return mergeConfig(nativeConfig, globalConfig); -} - -const CHECK_TIMEOUT_MS = 3_000; - -export const resourcesRouter = createRouter(); - -resourcesRouter.get('/', async (ctx) => { - const nativeResources = await readResourceDirs(getNativeResourcesDir()); - const globalResources = await readResourceDirs(getGlobalResourcesDir()); - - const merged = new Map(nativeResources); - for (const [name, path] of globalResources) merged.set(name, path); - - const resources = await Promise.all( - Array.from(merged.entries()).map(async ([dirName, filePath]) => { - const raw = await Bun.file(filePath).text(); - const { frontmatter } = parseFrontmatter(raw); - const scope = globalResources.has(dirName) && !nativeResources.has(dirName) ? 'global' as const : nativeResources.has(dirName) ? 'native' as const : 'global' as const; - const config = await readResourceConfig(dirName); - return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, scope, config }; - }), - ); - - return ctx.json(resources); -}); - -resourcesRouter.get('/:name', async (ctx) => { - const name = ctx.req.param('name'); - - const nativeResources = await readResourceDirs(getNativeResourcesDir()); - const globalResources = await readResourceDirs(getGlobalResourcesDir()); - - const filePath = globalResources.get(name) ?? nativeResources.get(name); - if (!filePath) return ctx.text('Not found', 404); - - const scope = globalResources.has(name) && !nativeResources.has(name) ? 'global' as const : 'native' as const; - const raw = await Bun.file(filePath).text(); - const { frontmatter, body, rawYaml } = parseFrontmatter(raw); - const config = await readResourceConfig(name); - - const globalConfigPath = join(getGlobalResourcesDir(), name, 'config.json'); - const chatMeta = join(dirname(filePath), 'chat', 'meta.json'); - const chatSessionId = await Bun.file(chatMeta).json().then((m: { id: string }) => m.id).catch(() => null); - const guidePath = join(getNativeResourcesDir(), 'GUIDE.md'); - - return ctx.json({ - dirName: name, - name: frontmatter.name || name, - description: frontmatter.description, - scope, - body, - rawFrontmatter: rawYaml, - filePath, - config, - configPath: globalConfigPath, - chatSessionId, - guidePath, - }); -}); - -resourcesRouter.patch('/:name/config', async (ctx) => { - const user = ctx.get('user'); - if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403); - - const name = ctx.req.param('name'); - const body = await ctx.req.json>(); - - const globalDir = join(getGlobalResourcesDir(), name); - await mkdir(globalDir, { recursive: true }); - - const existing = await readConfigFile(globalDir); - for (const [key, value] of Object.entries(body)) { - if (value === null) delete existing[key]; - else existing[key] = value; - } - - await Bun.write(join(globalDir, 'config.json'), JSON.stringify(existing, null, 2)); - - const merged = await readResourceConfig(name); - return ctx.json(merged); -}); - -resourcesRouter.post('/', async (ctx) => { - const user = ctx.get('user'); - if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403); - - const { name } = await ctx.req.json<{ name: string }>(); - if (!name?.trim()) return ctx.text('Name is required', 400); - - const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); - if (!dirName) return ctx.text('Invalid name', 400); - - const dir = join(getGlobalResourcesDir(), dirName); - const filePath = join(dir, 'RESOURCE.md'); - - if (await Bun.file(filePath).exists()) { - return ctx.text('Resource already exists', 409); - } - - await mkdir(dir, { recursive: true }); - await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`); - await Bun.write(join(dir, 'config.json'), JSON.stringify({ url: '', api_key: '', username: '', password: '' }, null, 2)); - - return ctx.json({ name: name.trim(), dirName, filePath, scope: 'global' }); -}); - -resourcesRouter.delete('/:name', async (ctx) => { - const user = ctx.get('user'); - if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403); - - const name = ctx.req.param('name'); - const nativeResources = await readResourceDirs(getNativeResourcesDir()); - - if (nativeResources.has(name)) return ctx.text('Cannot delete native resource', 400); - - const dir = join(getGlobalResourcesDir(), name); - const filePath = join(dir, 'RESOURCE.md'); - if (!(await Bun.file(filePath).exists())) return ctx.text('Not found', 404); - - await rm(dir, { recursive: true }); - return ctx.json({ ok: true }); -}); - -resourcesRouter.get('/:name/chat', async (ctx) => { - const name = ctx.req.param('name'); - - const nativeResources = await readResourceDirs(getNativeResourcesDir()); - const globalResources = await readResourceDirs(getGlobalResourcesDir()); - - const filePath = globalResources.get(name) ?? nativeResources.get(name); - if (!filePath) return ctx.text('Not found', 404); - - const chatDir = join(getGlobalResourcesDir(), name, 'chat'); - const sessionId = await Bun.file(join(chatDir, 'meta.json')).json().then((m: { id: string }) => m.id).catch(() => null); - const messages = await Bun.file(join(chatDir, 'messages.json')).json().catch(() => []); - - return ctx.json({ sessionId, messages }); -}); - -resourcesRouter.put('/:name/chat', async (ctx) => { - const user = ctx.get('user'); - if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403); - - const name = ctx.req.param('name'); - const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>(); - - const chatDir = join(getGlobalResourcesDir(), name, 'chat'); - await mkdir(chatDir, { recursive: true }); - await Bun.write(join(chatDir, 'messages.json'), JSON.stringify(messages)); - if (sessionId) await Bun.write(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId })); - - return ctx.json({ ok: true }); -}); - -resourcesRouter.delete('/:name/chat', async (ctx) => { - const user = ctx.get('user'); - if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403); - - const name = ctx.req.param('name'); - const chatDir = join(getGlobalResourcesDir(), name, 'chat'); - await rm(chatDir, { recursive: true, force: true }); - - return ctx.json({ ok: true }); -}); - -resourcesRouter.post('/:name/ping', async (ctx) => { - const name = ctx.req.param('name'); - const body = await ctx.req.json<{ url?: string }>().catch((): { url?: string } => ({})); - const config = await readResourceConfig(name); - - const url = body.url ?? config.url; - if (!url) return ctx.json({ error: 'No URL configured' }, 400); - - try { - const start = performance.now(); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS); - await fetch(url, { signal: controller.signal }); - clearTimeout(timeout); - const latencyMs = Math.round(performance.now() - start); - return ctx.json({ reachable: true, latencyMs }); - } catch { - return ctx.json({ reachable: false, latencyMs: null }); - } -}); diff --git a/src/servers/api/server-settings/searxng.ts b/src/servers/api/server-settings/searxng.ts deleted file mode 100644 index 85f37f50..00000000 --- a/src/servers/api/server-settings/searxng.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { join } from 'node:path'; -import { createRouter } from '../../create-router'; -import { DATA_PATH } from '../../data-path'; - -export const searxngRouter = createRouter(); - -const SEARXNG_FILE = join(DATA_PATH, 'searxng.json'); - -const DEFAULT_URL = 'https://searxng.home.pastilhas.eu'; - -export type SearxngConfig = { - url: string; -}; - -export async function readSearxngConfig(): Promise { - try { - const file = Bun.file(SEARXNG_FILE); - if (!(await file.exists())) return { url: DEFAULT_URL }; - return (await file.json()) as SearxngConfig; - } catch { - return { url: DEFAULT_URL }; - } -} - -async function writeSearxngConfig(config: SearxngConfig) { - await Bun.write(SEARXNG_FILE, JSON.stringify(config, null, 2)); -} - -searxngRouter.get('/', async (ctx) => { - return ctx.json(await readSearxngConfig()); -}); - -searxngRouter.put('/', async (ctx) => { - const { url } = await ctx.req.json<{ url: string }>(); - if (!url?.trim()) return ctx.json({ error: 'URL is required' }, 400); - const config: SearxngConfig = { url: url.trim().replace(/\/+$/, '') }; - await writeSearxngConfig(config); - return ctx.json(config); -}); diff --git a/src/servers/api/server-settings/server-settings.ts b/src/servers/api/server-settings/server-settings.ts index 6ee22086..d2580510 100644 --- a/src/servers/api/server-settings/server-settings.ts +++ b/src/servers/api/server-settings/server-settings.ts @@ -3,28 +3,22 @@ import { readdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { readServerSettings, writeServerSettings } from 'officerdb'; import { claudeCodeRouter } from './claude-code'; -import { opencodeRouter } from './opencode'; import { piMonoRouter } from './pi-mono'; import { applicationsRouter } from './applications'; -import { resourcesRouter } from './resources'; import { smtpRouter } from './smtp'; import { ttsRouter } from './tts'; import { sttRouter } from './stt'; import { ocrRouter } from './ocr'; -import { searxngRouter } from './searxng'; export const serverSettingsRouter = createRouter(); serverSettingsRouter.route('/claude-code', claudeCodeRouter); -serverSettingsRouter.route('/opencode', opencodeRouter); serverSettingsRouter.route('/pi-mono', piMonoRouter); serverSettingsRouter.route('/applications', applicationsRouter); -serverSettingsRouter.route('/resources', resourcesRouter); serverSettingsRouter.route('/smtp', smtpRouter); serverSettingsRouter.route('/tts', ttsRouter); serverSettingsRouter.route('/stt', sttRouter); serverSettingsRouter.route('/ocr', ocrRouter); -serverSettingsRouter.route('/searxng', searxngRouter); export { readServerSettings as readSettings }; diff --git a/src/servers/api/server-settings/stt.ts b/src/servers/api/server-settings/stt.ts index 603f14c1..1d8b9910 100644 --- a/src/servers/api/server-settings/stt.ts +++ b/src/servers/api/server-settings/stt.ts @@ -1,15 +1,11 @@ import { createRouter } from '../../create-router'; import { readServerSettings, writeServerSettings } from 'officerdb'; -import { readResourceConfig } from './resources'; type SttConfig = { url: string; }; export async function readSttConfig(): Promise { - const config = await readResourceConfig('speech-to-text'); - if (config.url) return { url: config.url }; - // Fallback to legacy DB settings const settings = await readServerSettings(); return settings.stt as SttConfig | undefined; } diff --git a/src/servers/api/server-settings/tts.ts b/src/servers/api/server-settings/tts.ts index a7db4818..66ec434d 100644 --- a/src/servers/api/server-settings/tts.ts +++ b/src/servers/api/server-settings/tts.ts @@ -1,6 +1,5 @@ import { createRouter } from '../../create-router'; import { readServerSettings, writeServerSettings } from 'officerdb'; -import { readResourceConfig } from './resources'; type TtsConfig = { provider: 'openai' | 'elevenlabs'; @@ -16,19 +15,8 @@ function maskSecret(value: string | undefined): string | undefined { } export async function readTtsConfig(): Promise { - const config = await readResourceConfig('text-to-speech'); - if (!config.url && !config.provider) { - // Fallback to legacy DB settings - const settings = await readServerSettings(); - return settings.tts as TtsConfig | undefined; - } - return { - provider: (config.provider || 'openai') as 'openai' | 'elevenlabs', - url: config.url ?? '', - apiKey: config.api_key || undefined, - model: config.model ?? '', - voice: config.voice ?? '', - }; + const settings = await readServerSettings(); + return settings.tts as TtsConfig | undefined; } export const ttsRouter = createRouter(); diff --git a/src/servers/api/sessions/sessions.ts b/src/servers/api/sessions/sessions.ts index 8a69bacc..518c0b4c 100644 --- a/src/servers/api/sessions/sessions.ts +++ b/src/servers/api/sessions/sessions.ts @@ -1,12 +1,9 @@ import { Hono } from 'hono'; import { mkdir, readdir, rename, rm } from 'node:fs/promises'; import { join } from 'node:path'; -import { getClaudeDir, getSessionDir, getArchivedSessionDir, getOpencodeDir, getOpencodeSessionDir, getPiMonoDir, getPiMonoSessionDir } from '@@/data-path'; +import { getClaudeDir, getSessionDir, getArchivedSessionDir, getPiMonoDir, getPiMonoSessionDir } from '@@/data-path'; import type { HonoVariables } from '@@/create-router'; -const OPENCODE_PORT = process.env.OPENCODE_PORT ?? '10006'; -const OPENCODE_BASE = `http://localhost:${OPENCODE_PORT}`; - export const sessionsRouter = new Hono<{ Variables: HonoVariables }>(); // --- List all sessions (merged from both providers) --- @@ -14,13 +11,9 @@ export const sessionsRouter = new Hono<{ Variables: HonoVariables }>(); sessionsRouter.get('/sessions', async (ctx) => { const { email } = ctx.get('user'); - const [claudeSessions, opencodeSessions, piMonoSessions] = await Promise.all([ - fetchClaudeSessions(email), - fetchOpencodeSessions(email), - fetchPiMonoSessions(email), - ]); + const [claudeSessions, piMonoSessions] = await Promise.all([fetchClaudeSessions(email), fetchPiMonoSessions(email)]); - const merged = [...claudeSessions, ...opencodeSessions, ...piMonoSessions].sort((a, b) => b.createdAt - a.createdAt); + const merged = [...claudeSessions, ...piMonoSessions].sort((a, b) => b.createdAt - a.createdAt); return ctx.json(merged); }); @@ -34,17 +27,21 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => { if (provider === 'claude') { const file = Bun.file(join(getSessionDir(email, id), 'messages.json')); if (!(await file.exists())) return ctx.json([]); - try { return ctx.json(await file.json()); } catch { return ctx.json([]); } - } - - if (provider === 'opencode') { - return ctx.json(await fetchOpencodeMessages(id)); + try { + return ctx.json(await file.json()); + } catch { + return ctx.json([]); + } } if (provider === 'pi-mono') { const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json')); if (!(await file.exists())) return ctx.json([]); - try { return ctx.json(await file.json()); } catch { return ctx.json([]); } + try { + return ctx.json(await file.json()); + } catch { + return ctx.json([]); + } } return ctx.json({ error: 'invalid provider' }, 400); @@ -55,7 +52,6 @@ sessionsRouter.put('/sessions/:provider/:id/messages', async (ctx) => { const provider = ctx.req.param('provider'); const id = ctx.req.param('id'); - if (provider === 'opencode') return ctx.json({ error: 'opencode sessions are read-only' }, 400); if (provider !== 'claude' && provider !== 'pi-mono') return ctx.json({ error: 'invalid provider' }, 400); const messages = ctx.get('body'); @@ -79,37 +75,26 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => { const metaFile = Bun.file(join(dir, 'meta.json')); if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404); let meta: Record; - try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); } + try { + meta = await metaFile.json(); + } catch { + return ctx.json({ error: 'corrupted session' }, 500); + } meta.title = body.title.slice(0, 200); await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta)); return ctx.json({ ok: true }); } - if (provider === 'opencode') { - const dir = getOpencodeSessionDir(email, id); - const metaFile = Bun.file(join(dir, 'meta.json')); - if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404); - let meta: Record; - try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); } - meta.title = body.title.slice(0, 200); - await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta)); - - // Best-effort sync to OpenCode API - fetch(`${OPENCODE_BASE}/session/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: meta.title }), - }).catch(() => {}); - - return ctx.json({ ok: true }); - } - if (provider === 'pi-mono') { const dir = getPiMonoSessionDir(email, id); const metaFile = Bun.file(join(dir, 'meta.json')); if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404); let meta: Record; - try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); } + try { + meta = await metaFile.json(); + } catch { + return ctx.json({ error: 'corrupted session' }, 500); + } meta.title = body.title.slice(0, 200); await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta)); return ctx.json({ ok: true }); @@ -135,18 +120,6 @@ sessionsRouter.delete('/sessions/:provider/:id', async (ctx) => { return ctx.json({ ok: true }); } - if (provider === 'opencode') { - const dir = getOpencodeSessionDir(email, id); - try { - await rm(dir, { recursive: true }); - } catch { - // dir may not exist - } - // Best-effort sync to OpenCode API - fetch(`${OPENCODE_BASE}/session/${id}`, { method: 'DELETE' }).catch(() => {}); - return ctx.json({ ok: true }); - } - if (provider === 'pi-mono') { const dir = getPiMonoSessionDir(email, id); try { @@ -167,7 +140,7 @@ sessionsRouter.post('/sessions/:provider/:id/archive', async (ctx) => { const provider = ctx.req.param('provider'); const id = ctx.req.param('id'); - if (provider === 'opencode' || provider === 'pi-mono') return ctx.json({ error: `${provider} sessions cannot be archived` }, 400); + if (provider === 'pi-mono') return ctx.json({ error: 'pi-mono sessions cannot be archived' }, 400); if (provider !== 'claude') return ctx.json({ error: 'invalid provider' }, 400); const src = getSessionDir(email, id); @@ -183,7 +156,7 @@ type SessionMeta = { id: string; title: string; createdAt: number; - provider: 'claude' | 'opencode' | 'pi-mono'; + provider: 'claude' | 'pi-mono'; model?: string | null; }; @@ -211,28 +184,6 @@ async function fetchClaudeSessions(email: string): Promise { } } -async function fetchOpencodeSessions(email: string): Promise { - const dir = getOpencodeDir(email); - try { - const entries = await readdir(dir); - const sessions = await Promise.all( - entries.map(async (id) => { - try { - const metaFile = Bun.file(join(dir, id, 'meta.json')); - if (!(await metaFile.exists())) return null; - const meta = await metaFile.json(); - return { ...meta, provider: 'opencode' as const }; - } catch { - return null; - } - }), - ); - return sessions.filter((s): s is SessionMeta => s !== null); - } catch { - return []; - } -} - async function fetchPiMonoSessions(email: string): Promise { const dir = getPiMonoDir(email); try { @@ -254,70 +205,3 @@ async function fetchPiMonoSessions(email: string): Promise { return []; } } - -async function fetchOpencodeMessages(id: string) { - try { - const res = await fetch(`${OPENCODE_BASE}/session/${id}/message`); - if (!res.ok) return []; - - const data = (await res.json()) as any[]; - const messages = Array.isArray(data) ? data : Object.values(data); - - const chatMessages: any[] = []; - for (const msg of messages) { - const role = msg.info?.role ?? msg.role; - if (role === 'user') { - const text = Array.isArray(msg.parts) - ? msg.parts - .filter((p: any) => p.type === 'text') - .map((p: any) => p.text ?? p.content ?? '') - .join('') - : typeof msg.content === 'string' - ? msg.content - : ''; - if (text) chatMessages.push({ role: 'user', text }); - } else if (role === 'assistant') { - if (Array.isArray(msg.parts)) { - for (const part of msg.parts) { - if (part.type === 'text' && (part.text || part.content)) { - chatMessages.push({ role: 'assistant', text: part.text ?? part.content ?? '' }); - } else if (part.type === 'tool') { - chatMessages.push({ - role: 'tool', - toolName: part.tool ?? 'unknown', - toolInput: part.state?.input ?? {}, - toolUseId: part.callID ?? part.id ?? '', - output: - part.state?.output != null - ? typeof part.state.output === 'string' - ? part.state.output - : JSON.stringify(part.state.output) - : undefined, - isError: part.state?.status === 'error', - }); - } else if (part.type === 'tool-invocation') { - const inv = part.toolInvocation ?? part; - chatMessages.push({ - role: 'tool', - toolName: inv.toolName ?? 'unknown', - toolInput: inv.args ?? {}, - toolUseId: inv.toolCallId ?? part.id ?? '', - output: - inv.result != null - ? typeof inv.result === 'string' - ? inv.result - : JSON.stringify(inv.result) - : undefined, - isError: !!part.isError, - }); - } - } - } - } - } - - return chatMessages; - } catch { - return []; - } -} diff --git a/src/servers/api/upload/upload.ts b/src/servers/api/upload/upload.ts index 3a292a1e..7d869767 100644 --- a/src/servers/api/upload/upload.ts +++ b/src/servers/api/upload/upload.ts @@ -13,7 +13,7 @@ uploadRouter.post('/', async (ctx) => { const file = body.file as File | null; const sessionId = (body.sessionId as string) || null; - const provider = (body.provider as 'claude' | 'opencode' | 'pi-mono') || null; + const provider = (body.provider as 'claude' | 'pi-mono') || null; if (!file || !(file instanceof File)) { return ctx.json({ error: 'file is required' }, 400); diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts index 417e150d..ee3c630c 100644 --- a/src/servers/bootstrap.ts +++ b/src/servers/bootstrap.ts @@ -5,9 +5,6 @@ import { DATA_PATH } from './data-path'; import { syncSeedSkills } from './sync-skills'; import { syncSeedTools } from './sync-tools'; import { syncSeedExtensions } from './sync-extensions'; -import { syncSeedResources } from './sync-resources'; -import { migrateSettingsToResources } from './migrate-resources'; -import { generateResourceSkill } from './api/pi/pi-bridge'; // Queue is now owned by the sidecar process import { startDiscordBotIfConfigured } from './channels/discord/bot'; import { startTelegramBotIfConfigured } from './channels/telegram/bot'; @@ -74,10 +71,6 @@ async function installPi(): Promise { syncSeedSkills(); syncSeedTools(); syncSeedExtensions(); - syncSeedResources(); - await migrateSettingsToResources(); - generateResourceSkill(DATA_PATH); - // Queue is initialized by the sidecar process await startDiscordBotIfConfigured().catch((err) => { diff --git a/src/servers/channels/send-and-await.ts b/src/servers/channels/send-and-await.ts index 8005ef03..d999b1d6 100644 --- a/src/servers/channels/send-and-await.ts +++ b/src/servers/channels/send-and-await.ts @@ -9,7 +9,7 @@ import { getUserSettings } from 'officerdb'; import { logger } from '@@/api/pi/logger'; import { sendClaudeCode, clearClaudeCodeSession } from './send-claude-code'; -const DEFAULT_MODEL = 'opencode/big-pickle'; +const DEFAULT_MODEL = 'anthropic/claude-sonnet-4-20250514'; const IDLE_TIMEOUT_MS = 60 * 60 * 1000; const SEND_TIMEOUT_MS = 5 * 60 * 1000; diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index 9e50031d..eb9a77f3 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -16,11 +16,6 @@ export const getClaudeDir = (email: string) => join(DATA_PATH, email, 'chat_sess export const getSessionDir = (email: string, sessionId: string) => join(DATA_PATH, email, 'chat_sessions', 'claude', sessionId); -export const getOpencodeDir = (email: string) => join(DATA_PATH, email, 'chat_sessions', 'opencode'); - -export const getOpencodeSessionDir = (email: string, sessionId: string) => - join(DATA_PATH, email, 'chat_sessions', 'opencode', sessionId); - export const getPiMonoDir = (email: string) => join(DATA_PATH, email, 'chat_sessions', 'pi-mono'); export const getPiMonoSessionDir = (email: string, sessionId: string) => @@ -38,8 +33,6 @@ export const getUserPiConfigDir = (email: string) => join(DATA_PATH, email, 'hom export const getUserSettingsDir = (email: string) => join(DATA_PATH, email, 'settings'); -export const getUserSettingsFile = (email: string) => join(DATA_PATH, email, 'settings', 'settings.json'); - export const getUserStateDir = (email: string) => join(DATA_PATH, email, 'state'); export const getUserStateFile = (email: string) => join(DATA_PATH, email, 'state', 'state.json'); @@ -80,20 +73,13 @@ export const getGlobalProcessesDir = () => join(DATA_PATH, 'processes'); export const getUserProcessesDir = (email: string) => join(DATA_PATH, email, 'processes'); -export const getNativeResourcesDir = () => join(SEED_PATH, 'resources'); - -export const getGlobalResourcesDir = () => join(DATA_PATH, 'resources'); - -export const getResourcesDir = () => join(DATA_PATH, 'resources'); - export const getTaskLogsDir = (email: string) => join(DATA_PATH, email, 'logs', 'tasks'); export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'chat_sessions', 'tmp_attachments'); -export const getAttachmentsDir = (email: string, provider: 'claude' | 'opencode' | 'pi-mono', sessionId: string) => +export const getAttachmentsDir = (email: string, provider: 'claude' | 'pi-mono', sessionId: string) => join(DATA_PATH, email, 'chat_sessions', provider, sessionId, 'attachments'); - export const getUserEmailDir = (email: string) => join(DATA_PATH, email, 'Gmail', 'emails'); export const getMaildirPath = (email: string) => join(DATA_PATH, email, 'Gmail', 'Maildir'); @@ -102,7 +88,13 @@ export const getMaildirPath = (email: string) => join(DATA_PATH, email, 'Gmail', export const toShellUsername = (username: string, email: string): string => { const raw = username || email.split('@')[0]!; // Replace invalid chars, lowercase, truncate to 32 chars - return raw.replace(/@.*$/, '').replace(/[^a-zA-Z0-9._-]/g, '_').toLowerCase().slice(0, 32) || 'officer'; + return ( + raw + .replace(/@.*$/, '') + .replace(/[^a-zA-Z0-9._-]/g, '_') + .toLowerCase() + .slice(0, 32) || 'officer' + ); }; export const getUserAppsDir = (email: string) => join(DATA_PATH, email, 'apps'); diff --git a/src/servers/generate-container-context.ts b/src/servers/generate-container-context.ts index a7fd8bde..91db6767 100644 --- a/src/servers/generate-container-context.ts +++ b/src/servers/generate-container-context.ts @@ -7,7 +7,6 @@ import { getUserSkillsDir, getGlobalTasksDir, getUserTasksDir, - getGlobalResourcesDir, getHomeDir, DATA_PATH, } from '@@/data-path'; @@ -61,7 +60,6 @@ export function generateContainerContext(email: string): string { const tools = dedup([...scanDir(getGlobalToolsDir(), 'TOOL.md'), ...scanDir(getUserToolsDir(email), 'TOOL.md')]); const skills = dedup([...scanDir(getGlobalSkillsDir(), 'SKILL.md'), ...scanDir(getUserSkillsDir(email), 'SKILL.md')]); const tasks = dedup([...scanDir(getGlobalTasksDir(), 'TASK.md'), ...scanDir(getUserTasksDir(email), 'TASK.md')]); - const resources = scanDir(getGlobalResourcesDir(), 'RESOURCE.md'); const globalToolsDir = getGlobalToolsDir(); const userToolsDir = getUserToolsDir(email); @@ -100,11 +98,6 @@ ${formatList(skills)} Tasks are predefined instruction sets the AI agent can execute. ${formatList(tasks)} -## Configured Resources - -Resources are external service integrations (TTS, STT, OCR, etc.) configured in Settings. - -${formatList(resources)} ## Creating New Tools Create a directory in \`${userToolsDir}//\` with two files: @@ -139,7 +132,6 @@ export async function execute(_toolCallId: string, params: Record = { - stt: 'speech-to-text', - tts: 'text-to-speech', - ocr: 'optical-character-recognition', -}; - -export async function migrateSettingsToResources(): Promise { - let settings: Record>; - try { - settings = (await readServerSettings()) as Record>; - } catch { - return; - } - - for (const [settingsKey, resourceName] of Object.entries(SETTINGS_TO_RESOURCE)) { - const raw = settings[settingsKey]; - if (!raw || typeof raw !== 'object') continue; - - const globalDir = join(DATA_PATH, 'resources', resourceName); - const globalConfigPath = join(globalDir, 'config.json'); - - if (existsSync(globalConfigPath)) continue; - - // Read seed config for schema (all keys) - let seedConfig: Record = {}; - try { - seedConfig = JSON.parse(readFileSync(join(SEED_PATH, 'resources', resourceName, 'config.json'), 'utf-8')); - } catch { - // no seed config - } - - // Merge settings values into seed schema - const merged: Record = {}; - for (const key of Object.keys(seedConfig)) { - merged[key] = seedConfig[key]!; - } - for (const [key, value] of Object.entries(raw)) { - if (typeof value === 'string') { - const mapped = key === 'apiKey' ? 'api_key' : key; - merged[mapped] = value; - } - } - - mkdirSync(globalDir, { recursive: true }); - writeFileSync(globalConfigPath, JSON.stringify(merged, null, 2), 'utf-8'); - console.log(`[resources] Migrated ${settingsKey} settings to ${resourceName}/config.json`); - } -} diff --git a/src/servers/sidecar/pi-manager.ts b/src/servers/sidecar/pi-manager.ts index b01aa01a..206a6cec 100644 --- a/src/servers/sidecar/pi-manager.ts +++ b/src/servers/sidecar/pi-manager.ts @@ -1,5 +1,5 @@ import { join } from 'node:path'; -import { readdirSync, existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { readdirSync, existsSync, mkdirSync } from 'node:fs'; import type { Subprocess } from 'bun'; import type { PiEvent, MessageCost } from '../api/pi/types'; import type { PiSpawnParams, PiSessionInfo } from './protocol'; @@ -7,8 +7,6 @@ import { isPidAlive } from './state'; const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); const PI_CONFIG_DIR = join(require('node:os').homedir(), '.pi', 'agent'); -const SEED_PATH = join(import.meta.dir, '../../../seed'); - const getHomeDir = (email: string) => join(DATA_PATH, email, 'home'); const getHomeDirForRole = (email: string, role: string | null): string => role === 'Super Admin' && process.env.HOME_DIR ? process.env.HOME_DIR : getHomeDir(email); @@ -18,12 +16,15 @@ const getGlobalExtensionsDir = () => join(DATA_PATH, 'extensions'); const getUserExtensionsDir = (email: string) => join(DATA_PATH, email, 'extensions'); const getGlobalToolsDir = () => join(DATA_PATH, 'tools'); const getUserToolsDir = (email: string) => join(DATA_PATH, email, 'tools'); -const getNativeResourcesDir = () => join(SEED_PATH, 'resources'); -const getGlobalResourcesDir = () => join(DATA_PATH, 'resources'); - const toShellUsername = (username: string, email: string): string => { const raw = username || email.split('@')[0]!; - return raw.replace(/@.*$/, '').replace(/[^a-zA-Z0-9._-]/g, '_').toLowerCase().slice(0, 32) || 'officer'; + return ( + raw + .replace(/@.*$/, '') + .replace(/[^a-zA-Z0-9._-]/g, '_') + .toLowerCase() + .slice(0, 32) || 'officer' + ); }; // Resolve pi as [node, cli.js] @@ -85,35 +86,6 @@ function collectExtensionFlags(email: string): string[] { return flags; } -function buildResourcesEnv(): string { - const nativeDir = getNativeResourcesDir(); - const globalDir = getGlobalResourcesDir(); - const resourceDirs = new Map(); - for (const dir of [nativeDir, globalDir]) { - if (!existsSync(dir)) continue; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - if (existsSync(join(dir, entry.name, 'RESOURCE.md')) || existsSync(join(dir, entry.name, 'config.json'))) { - resourceDirs.set(entry.name, dir); - } - } - } - const result: Record> = {}; - for (const [name] of resourceDirs) { - let nativeConfig: Record = {}; - let globalConfig: Record = {}; - try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {} - try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {} - const config: Record = {}; - for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!; - for (const key of Object.keys(globalConfig)) if (!(key in config)) config[key] = globalConfig[key]!; - if (Object.values(config).some((v) => v !== '')) { - result[name] = config; - } - } - return JSON.stringify(result); -} - async function resolveApiKeyForModel(model: string): Promise { const provider = model.split('/')[0]; if (!provider) return null; @@ -134,7 +106,9 @@ function parseErrorMessage(raw: string): string { const parsed = JSON.parse(raw.replace(/^\d+\s*/, '')); const inner = parsed?.error; if (inner?.message) return inner.message; - } catch { /* not JSON */ } + } catch { + /* not JSON */ + } return raw; } @@ -181,12 +155,14 @@ function parsePiEvent(event: Record, currentStreamBuffer: strin } case 'tool_execution_start': - return [{ - type: 'tool:start', - toolCallId: (event.toolCallId as string) ?? '', - toolName: (event.toolName as string) ?? 'unknown', - toolInput: (event.args as Record) ?? {}, - }]; + return [ + { + type: 'tool:start', + toolCallId: (event.toolCallId as string) ?? '', + toolName: (event.toolName as string) ?? 'unknown', + toolInput: (event.args as Record) ?? {}, + }, + ]; case 'tool_execution_end': { const toolCallId = (event.toolCallId as string) ?? ''; @@ -195,7 +171,11 @@ function parsePiEvent(event: Record, currentStreamBuffer: strin if (typeof result === 'object' && result !== null) { resultObj = result as Record; } else if (typeof result === 'string') { - try { resultObj = JSON.parse(result); } catch { /* not JSON */ } + try { + resultObj = JSON.parse(result); + } catch { + /* not JSON */ + } } const isError = (event.isError as boolean) ?? (resultObj?.isError as boolean) ?? false; const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : ''; @@ -260,18 +240,15 @@ export async function spawnPi(options: PiSpawnOptions): Promise { const skillFlags = collectSkillFlags(email); const extensionFlags = collectExtensionFlags(email); - // Generate resource skill - const { generateResourceSkill } = await import('../api/pi/pi-bridge'); - const resourceSkillDir = generateResourceSkill(DATA_PATH); - const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : []; - const piArgs = [ ...PI_CMD, - '--mode', 'rpc', - '--no-skills', '--no-prompt-templates', '--no-themes', + '--mode', + 'rpc', + '--no-skills', + '--no-prompt-templates', + '--no-themes', ...skillFlags, ...extensionFlags, - ...resourceSkillFlags, ]; if (model) piArgs.push('--model', model); if (sessionFile) piArgs.push('--session', sessionFile); @@ -294,7 +271,6 @@ export async function spawnPi(options: PiSpawnOptions): Promise { OFFICER_USER_ROOT: join(DATA_PATH, email), PI_CODING_AGENT_DIR: isServiceUser ? PI_CONFIG_DIR : join(homeDir, '.pi', 'agent'), PI_TOOLS_DIRS: toolsDirs, - OFFICER_RESOURCES: buildResourcesEnv(), OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'), TERM: 'xterm-256color', PATH: process.env.PATH ?? '', @@ -302,10 +278,12 @@ export async function spawnPi(options: PiSpawnOptions): Promise { const proc = isServiceUser ? Bun.spawn(piArgs, { cwd, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...env } }) - : Bun.spawn( - ['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...piArgs], - { cwd, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, - ); + : Bun.spawn(['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...piArgs], { + cwd, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); const session: PiSession = { sessionId, email, userId, model, cwd, proc, onEvent }; sessions.set(sessionId, session); @@ -340,10 +318,14 @@ export async function spawnPi(options: PiSpawnOptions): Promise { } onEvent(piEvent); } - } catch { /* skip */ } + } catch { + /* skip */ + } } } - } catch { /* process ended */ } + } catch { + /* process ended */ + } })(); // Stderr → log @@ -358,7 +340,9 @@ export async function spawnPi(options: PiSpawnOptions): Promise { const text = stderrDecoder.decode(value, { stream: true }); if (text.trim()) console.log(`[sidecar:pi:stderr] ${text.trim()}`); } - } catch { /* process ended */ } + } catch { + /* process ended */ + } })(); // Handle exit @@ -394,7 +378,11 @@ export function setThinkingLevel(sessionId: string, level: string): boolean { export function killPiSession(sessionId: string): boolean { const session = sessions.get(sessionId); if (!session) return false; - try { session.proc.kill(); } catch { /* already dead */ } + try { + session.proc.kill(); + } catch { + /* already dead */ + } sessions.delete(sessionId); return true; } diff --git a/src/servers/sync-resources.ts b/src/servers/sync-resources.ts deleted file mode 100644 index 0e33c209..00000000 --- a/src/servers/sync-resources.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { mkdirSync } from 'node:fs'; -import { DATA_PATH } from './data-path'; -import { join } from 'node:path'; - -export function syncSeedResources(): void { - // Native resources are read directly from seed/ at runtime. - // Only ensure the global resources directory exists. - mkdirSync(join(DATA_PATH, 'resources'), { recursive: true }); -} diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx index 982a0632..ff30fa5d 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx @@ -6,7 +6,6 @@ import type { ChatMessage } from '../types'; const PROVIDER_DISPLAY: Record = { anthropic: 'Anthropic', openai: 'OpenAI', - opencode: 'OpenCode Zen', google: 'Google', groq: 'Groq', mistral: 'Mistral', @@ -50,7 +49,7 @@ export function ModelSelector({ // Determine which model to display: selectedModel takes precedence, then model (from server), then fallback const displayModel = selectedModel || model; - + const activeProvider = availableModels.find((m) => m.id === displayModel)?.provider ?? providers[0]; const providerModels = availableModels.filter((m) => m.provider === activeProvider); const fallbackModelId = providerModels[0]?.id ?? null; @@ -106,7 +105,11 @@ export function ModelSelector({ )}
{availableModels.find((m) => m.id === displayModel)?.reasoning && ( - onThinkingChange(thinkingLevel === 'high' ? 'off' : 'high')} disabled={isGenerating} /> + onThinkingChange(thinkingLevel === 'high' ? 'off' : 'high')} + disabled={isGenerating} + /> )}
{providerModels.length > 0 ? ( @@ -150,9 +153,7 @@ const ThinkingToggle = ({ enabled, onToggle, disabled }: ThinkingToggleProps) => disabled={disabled} title={enabled ? 'Thinking enabled (click to disable)' : 'Thinking disabled (click to enable)'} className={`rounded-md px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed ${ - enabled - ? 'bg-duck-teal/15 text-duck-teal' - : 'text-duck-dark/40 hover:text-duck-dark/60' + enabled ? 'bg-duck-teal/15 text-duck-teal' : 'text-duck-dark/40 hover:text-duck-dark/60' }`} > {enabled ? 'think' : 'no think'} diff --git a/src/workspaces/officerdev/src/apps/Chat/types.ts b/src/workspaces/officerdev/src/apps/Chat/types.ts index c8708656..3455a09b 100644 --- a/src/workspaces/officerdev/src/apps/Chat/types.ts +++ b/src/workspaces/officerdev/src/apps/Chat/types.ts @@ -102,7 +102,7 @@ export type LegacySessionEntry = { id: string; title: string; createdAt: number; - provider: 'claude' | 'opencode' | 'pi-mono'; + provider: 'claude' | 'pi-mono'; model?: string | null; }; diff --git a/src/workspaces/state/src/index.ts b/src/workspaces/state/src/index.ts index 6bf340d6..6f6370fa 100644 --- a/src/workspaces/state/src/index.ts +++ b/src/workspaces/state/src/index.ts @@ -9,8 +9,6 @@ export { useRecentModels } from './useRecentModels'; export { usePlans } from './usePlans'; export { useLandingPage } from './useLandingPage'; export { useServerSettings } from './useServerSettings'; -export { useResources } from './useResources'; -export type { ResourceSummary, ResourceDetail, PingResult } from './useResources'; export { useChatSessions } from './useChatSessions'; export type { UseChatSessionsType } from './useChatSessions'; export { useChatGroups } from './useChatGroups'; diff --git a/src/workspaces/state/src/useResources.ts b/src/workspaces/state/src/useResources.ts deleted file mode 100644 index 230c8476..00000000 --- a/src/workspaces/state/src/useResources.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useClient } from 'hooks/useClient'; - -export type ResourceSummary = { - dirName: string; - name: string; - description: string; - scope: 'native' | 'global'; - config: Record; -}; - -export type ResourceDetail = ResourceSummary & { - body: string; - rawFrontmatter: string; - filePath: string; - configPath: string; - chatSessionId: string | null; - guidePath: string; -}; - -export type PingResult = { - reachable: boolean; - latencyMs: number | null; -}; - -const RESOURCES_KEY = ['RESOURCES']; - -export const useResources = () => { - const client = useClient(); - const queryClient = useQueryClient(); - - const { data: resources, isLoading } = useQuery({ - queryKey: RESOURCES_KEY, - queryFn: () => client.get('/server-settings/resources'), - }); - - const getDetail = (name: string) => - client.get(`/server-settings/resources/${name}`); - - const saveConfig = async (name: string, config: Record) => { - await client.patch(`/server-settings/resources/${name}/config`, config); - queryClient.invalidateQueries({ queryKey: RESOURCES_KEY }); - }; - - const createResource = async (name: string) => { - const result = await client.post<{ name: string; dirName: string }>('/server-settings/resources', { name }); - queryClient.invalidateQueries({ queryKey: RESOURCES_KEY }); - return result; - }; - - const deleteResource = async (name: string) => { - await client.delete(`/server-settings/resources/${name}`); - queryClient.invalidateQueries({ queryKey: RESOURCES_KEY }); - }; - - const pingResource = async (name: string, url?: string) => { - return client.post(`/server-settings/resources/${name}/ping`, { url }); - }; - - return { resources, isLoading, getDetail, saveConfig, createResource, deleteResource, pingResource }; -}; diff --git a/src/workspaces/state/src/useServerSettings.ts b/src/workspaces/state/src/useServerSettings.ts index 924f8387..359b200c 100644 --- a/src/workspaces/state/src/useServerSettings.ts +++ b/src/workspaces/state/src/useServerSettings.ts @@ -4,7 +4,6 @@ import { useClient } from 'hooks/useClient'; type AIHarnesses = { claudeCode: boolean; - opencode: boolean; piMono: boolean; };