Skip to content

Commit dbd6866

Browse files
feat(assistant): self-configuring assistant mode (#244)
* test(backend): replace better-sqlite3 test mock with node:sqlite Reimplement the bun:sqlite test mock on top of node:sqlite with an explicit BEGIN/COMMIT/ROLLBACK transaction wrapper. * feat(assistant): self-configuring assistant mode Add a self-editable assistant workspace and the APIs that support it: - POST /assistant/reload disposes and re-bootstraps the assistant instance (directory-scoped, rate-limited) so agent.md edits apply. - Internal settings API accepts non-secret TTS/STT patches via dedicated partial schemas. - Strip the generated agent persona from opencode.json on repair so user-customized agents are preserved across config recovery. - Cache the last assistant session id to avoid redundant session listing on launch. * style(repo): align worktree and workspace tabs with primary/orange theme
1 parent eedbbfb commit dbd6866

22 files changed

Lines changed: 733 additions & 156 deletions

‎backend/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"build": "bun build src/index.ts --outdir=dist --target=bun",
1010
"typecheck": "tsc --noEmit",
1111
"test": "pnpm run test:bun && pnpm run test:vitest",
12-
"test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts src/db/model-state.test.ts src/routes/providers.test.ts src/routes/repos.test.ts",
12+
"test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts test/routes/internal-assistant.test.ts src/db/model-state.test.ts src/routes/providers.test.ts src/routes/repos.test.ts",
1313
"test:vitest": "vitest run",
1414
"test:ui": "vitest --ui",
1515
"test:watch": "vitest --watch",

‎backend/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ app.route('/api/auth-info', createAuthInfoRoutes(auth, db))
317317
app.route('/api/health', createHealthRoutes(db, openCodeSupervisor))
318318

319319
app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth))
320-
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService))
320+
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient))
321321
app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService))
322322

323323
const protectedApi = new Hono()
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Hono } from 'hono'
2+
import type { OpenCodeClient } from '../../services/opencode/client'
3+
import { getAssistantModeDirectory } from '../../services/assistant-mode'
4+
import { TokenBucketRateLimiter } from '../../utils/rate-limit'
5+
6+
export function createInternalAssistantRoutes(openCodeClient: OpenCodeClient) {
7+
const app = new Hono()
8+
const limiter = new TokenBucketRateLimiter({ capacity: 5, refillPerMs: 60_000 })
9+
10+
app.post('/reload', async (c) => {
11+
const token = (c.req.header('authorization') ?? '').slice('Bearer '.length)
12+
const limit = limiter.tryConsume(token || 'anon')
13+
if (!limit.allowed) {
14+
c.header('Retry-After', String(Math.ceil(limit.retryAfterMs / 1000)))
15+
return c.json({ error: 'Rate limit exceeded' }, 429)
16+
}
17+
18+
const directory = getAssistantModeDirectory()
19+
const response = await openCodeClient.forward({ method: 'POST', path: '/instance/dispose', directory })
20+
if (!response.ok) {
21+
return c.json({ error: 'Failed to reload assistant workspace' }, 502)
22+
}
23+
24+
return c.json({ success: true })
25+
})
26+
27+
return app
28+
}

‎backend/src/routes/internal/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Database } from 'bun:sqlite'
33
import type { ScheduleService } from '../../services/schedules'
44
import type { NotificationService } from '../../services/notification'
55
import type { SettingsService } from '../../services/settings'
6+
import type { OpenCodeClient } from '../../services/opencode/client'
67
import { createScheduleRoutes } from '../schedules'
78
import { createInternalTokenMiddleware } from '../../auth/internal-token-middleware'
89
import { createInternalNotificationRoutes } from './notifications'
@@ -11,12 +12,14 @@ import { createInternalRepoRoutes } from './repos'
1112
import { createInternalRepoSyncRoutes } from './repo-sync'
1213
import { createInternalRepoMirrorRoutes as mirrorRoutes } from './repo-mirror'
1314
import { createInternalOpenCodeWorkspacesRoutes } from './opencode-workspaces'
15+
import { createInternalAssistantRoutes } from './assistant'
1416

1517
export function createInternalRoutes(
1618
db: Database,
1719
scheduleService: ScheduleService,
1820
notificationService: NotificationService,
1921
settingsService: SettingsService,
22+
openCodeClient: OpenCodeClient,
2023
) {
2124
const app = new Hono()
2225
app.use('/*', createInternalTokenMiddleware(db))
@@ -30,5 +33,6 @@ export function createInternalRoutes(
3033
repos.route('/', mirrorRoutes(db))
3134
app.route('/repos', repos)
3235
app.route('/opencode-workspaces', createInternalOpenCodeWorkspacesRoutes(db))
36+
app.route('/assistant', createInternalAssistantRoutes(openCodeClient))
3337
return app
3438
}

‎backend/src/routes/internal/settings.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,28 @@ export function createInternalSettingsRoutes(settingsService: SettingsService) {
2727
return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400)
2828
}
2929

30-
const updated = settingsService.updateSettings(parsed.data as Partial<UserPreferences>, userId)
30+
const patch = parsed.data
31+
const currentPrefs = settingsService.getSettings(userId).preferences
32+
const updates: Partial<UserPreferences> = {}
33+
for (const key of Object.keys(patch)) {
34+
if (key !== 'tts' && key !== 'stt') {
35+
(updates as Record<string, unknown>)[key] = (patch as Record<string, unknown>)[key]
36+
}
37+
}
38+
if (patch.tts) {
39+
if (!currentPrefs.tts?.apiKey) {
40+
return c.json({ error: 'TTS is not configured. Set up TTS (including credentials) in the UI before adjusting it.' }, 400)
41+
}
42+
updates.tts = { ...currentPrefs.tts, ...patch.tts }
43+
}
44+
if (patch.stt) {
45+
if (!currentPrefs.stt?.apiKey) {
46+
return c.json({ error: 'STT is not configured. Set up STT (including credentials) in the UI before adjusting it.' }, 400)
47+
}
48+
updates.stt = { ...currentPrefs.stt, ...patch.stt }
49+
}
50+
51+
const updated = settingsService.updateSettings(updates as Partial<UserPreferences>, userId)
3152
return c.json(updated)
3253
})
3354

‎backend/src/services/assistant-mode.ts‎

Lines changed: 98 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,7 @@ function buildLegacyAssistantAgentPrompt(): string {
150150
].join('\n')
151151
}
152152

153-
function buildLegacyAssistantDefaultAgentMd(): string {
154-
const prompt = buildLegacyAssistantAgentPrompt()
153+
function buildAssistantDefaultAgentMdFromPrompt(prompt: string): string {
155154
const permission = buildAssistantAgentPermission()
156155

157156
return `---
@@ -171,6 +170,10 @@ ${prompt}
171170
`
172171
}
173172

173+
function buildLegacyAssistantDefaultAgentMd(): string {
174+
return buildAssistantDefaultAgentMdFromPrompt(buildLegacyAssistantAgentPrompt())
175+
}
176+
174177
function matchesGeneratedAssistantAgentsMd(content: string): boolean {
175178
const currentHash = hashContent(buildAssistantAgentsMd())
176179
const legacyHash = hashContent(buildLegacyAssistantAgentsMd())
@@ -180,17 +183,19 @@ function matchesGeneratedAssistantAgentsMd(content: string): boolean {
180183

181184
function matchesGeneratedAssistantDefaultAgentMd(content: string): boolean {
182185
const currentHash = hashContent(buildAssistantDefaultAgentMd())
186+
const previousHash = hashContent(buildPreviousAssistantDefaultAgentMd())
183187
const legacyHash = hashContent(buildLegacyAssistantDefaultAgentMd())
184188
const contentHash = hashContent(content)
185-
return contentHash === currentHash || contentHash === legacyHash
189+
return contentHash === currentHash || contentHash === previousHash || contentHash === legacyHash
186190
}
187191

188192
function matchesGeneratedAssistantAgentPrompt(content: unknown): content is string {
189193
if (typeof content !== 'string') return false
190194
const currentHash = hashContent(buildAssistantAgentPrompt())
195+
const previousHash = hashContent(buildPreviousAssistantAgentPrompt())
191196
const legacyHash = hashContent(buildLegacyAssistantAgentPrompt())
192197
const contentHash = hashContent(content)
193-
return contentHash === currentHash || contentHash === legacyHash
198+
return contentHash === currentHash || contentHash === previousHash || contentHash === legacyHash
194199
}
195200

196201
function containsLegacyAssistantAgentsGuidance(content: string): boolean {
@@ -215,6 +220,30 @@ Assistant-specific instructions belong in \`.opencode/agents/assistant.md\`.
215220
`
216221
}
217222

223+
function buildPreviousAssistantAgentPrompt(): string {
224+
return [
225+
'You are the default Assistant Mode agent for OpenCode Manager.',
226+
'',
227+
'This workspace is the shared assistant workspace for OpenCode Manager. Help the user manage repos, schedules, notifications, settings, and assistant behavior safely.',
228+
'',
229+
'## Self-Editing Rules',
230+
'',
231+
'Durable assistant instructions, behavior, and preferences belong in `.opencode/agents/assistant.md`. Edit that file when the user expresses lasting preferences or when you need to refine your behavior.',
232+
'',
233+
'The workspace directory explanation belongs in `AGENTS.md`. Keep that file focused on describing the directory contents and pointing to managed files.',
234+
'',
235+
'Preserve user-customized workspace files unless the user explicitly asks you to change them. Ask before making significant, destructive, or out-of-workspace changes.',
236+
'',
237+
'## Skill Usage',
238+
'',
239+
'Use the workspace skills when relevant:',
240+
'- Load `repo-management` before `schedule-management` when you need a repo ID.',
241+
'- Load `schedule-management` for schedule jobs and runs.',
242+
'- Load `notifications` when the user should be notified about important events.',
243+
'- Load `manager-settings` when reading or safely updating UI preferences.',
244+
].join('\n')
245+
}
246+
218247
function buildAssistantAgentPrompt(): string {
219248
return [
220249
'You are the default Assistant Mode agent for OpenCode Manager.',
@@ -229,6 +258,8 @@ function buildAssistantAgentPrompt(): string {
229258
'',
230259
'Preserve user-customized workspace files unless the user explicitly asks you to change them. Ask before making significant, destructive, or out-of-workspace changes.',
231260
'',
261+
'After editing `.opencode/agents/assistant.md`, load `manager-settings` and call `POST /assistant/reload` to apply changes. Always ask the user before reloading.',
262+
'',
232263
'## Skill Usage',
233264
'',
234265
'Use the workspace skills when relevant:',
@@ -251,34 +282,12 @@ function buildAssistantAgentPermission(): { read: 'allow'; edit: 'allow'; glob:
251282
}
252283
}
253284

254-
export function buildAssistantDefaultAgentConfig() {
255-
return {
256-
description: 'Default OpenCode Manager assistant workspace agent',
257-
mode: 'primary',
258-
prompt: buildAssistantAgentPrompt(),
259-
permission: buildAssistantAgentPermission(),
260-
}
285+
function buildPreviousAssistantDefaultAgentMd(): string {
286+
return buildAssistantDefaultAgentMdFromPrompt(buildPreviousAssistantAgentPrompt())
261287
}
262288

263289
export function buildAssistantDefaultAgentMd(): string {
264-
const prompt = buildAssistantAgentPrompt()
265-
const permission = buildAssistantAgentPermission()
266-
267-
return `---
268-
description: Default OpenCode Manager assistant workspace agent
269-
mode: primary
270-
permission:
271-
read: ${permission.read}
272-
edit: ${permission.edit}
273-
glob: ${permission.glob}
274-
grep: ${permission.grep}
275-
list: ${permission.list}
276-
bash: ${permission.bash}
277-
external_directory: ${permission.external_directory}
278-
---
279-
280-
${prompt}
281-
`
290+
return buildAssistantDefaultAgentMdFromPrompt(buildAssistantAgentPrompt())
282291
}
283292

284293
function toLocalhostInternalBaseUrl(baseUrl: string): string {
@@ -559,12 +568,16 @@ The following preference keys can be modified:
559568
- \`simpleChatMode\`, \`leaderKey\`, \`directShortcuts\`
560569
- \`keyboardShortcuts\`, \`customCommands\`, \`notifications\`
561570
- \`repoOrder\`, \`repoSortMode\`
571+
- \`tts\` — Non-secret TTS preferences (\`enabled\`, \`provider\`, \`autoPlay\`, \`voice\`, \`model\`, \`speed\`). TTS must already be configured in the UI (the endpoint returns 400 otherwise).
572+
- \`stt\` — Non-secret STT preferences (\`enabled\`, \`provider\`, \`model\`, \`language\`). STT must already be configured in the UI (the endpoint returns 400 otherwise).
562573
563574
**DO NOT attempt to set:**
564575
- \`gitCredentials\` - Git credentials must be managed via the full UI
565576
- \`gitIdentity\` - Git identity must be managed via the full UI
566577
- \`tts.apiKey\` - TTS credentials must be managed via the full UI
578+
- \`tts.endpoint\` - TTS endpoint must be managed via the full UI
567579
- \`stt.apiKey\` - STT credentials must be managed via the full UI
580+
- \`stt.endpoint\` - STT endpoint must be managed via the full UI
568581
- \`lastKnownGoodConfig\` - Internal state, do not modify
569582
- Any other keys not in the allowed list above
570583
@@ -582,6 +595,25 @@ curl -X PATCH -H "Authorization: Bearer <token>" \\
582595
**Response:**
583596
Returns the updated settings object with the same structure as GET.
584597
598+
### POST /assistant/reload
599+
600+
Reload the assistant workspace by disposing the current OpenCode instance. Use this after editing \`.opencode/agents/assistant.md\` or \`opencode.json\` so changes take effect on the next message.
601+
602+
**Note:** Always confirm with the user before reloading, as it re-bootstraps the workspace.
603+
604+
**Rate Limiting:** 5 requests per minute per token. Returns \`429 Too Many Requests\` with \`Retry-After\` header when exceeded.
605+
606+
**Example:**
607+
\`\`\`bash
608+
curl -X POST -H "Authorization: Bearer <token>" \\
609+
"${internalBaseUrl}/assistant/reload"
610+
\`\`\`
611+
612+
**Response:**
613+
\`\`\`ts
614+
{ "success": true }
615+
\`\`\`
616+
585617
## Safety
586618
587619
- This API intentionally rejects any attempt to modify credentials, API keys, or other sensitive settings
@@ -660,12 +692,10 @@ curl -H "Authorization: Bearer <token>" "${internalBaseUrl}/repos"
660692
export function buildAssistantOpenCodeConfig(): OpenCodeConfigInput {
661693
const config: OpenCodeConfigInput = {
662694
default_agent: ASSISTANT_DEFAULT_AGENT_NAME,
663-
instructions: [
664-
'AGENTS.md',
665-
],
695+
instructions: ['AGENTS.md'],
666696
permission: buildAssistantAgentPermission(),
667697
agent: {
668-
[ASSISTANT_DEFAULT_AGENT_NAME]: buildAssistantDefaultAgentConfig(),
698+
[ASSISTANT_DEFAULT_AGENT_NAME]: { mode: 'primary' },
669699
},
670700
}
671701

@@ -731,8 +761,8 @@ export async function ensureAssistantMode(
731761
const existingContent = await readFileContent(opencodeJsonPath)
732762
const existingConfig = JSON.parse(existingContent) as OpenCodeConfigInput
733763
const mergedConfig = mergeAssistantOpenCodeConfig(existingConfig)
734-
return assistantOpenCodeConfigPromptNeedsMigration(mergedConfig)
735-
? migrateGeneratedAssistantOpenCodePrompt(mergedConfig)
764+
return assistantOpenCodeConfigHasGeneratedAgentPersona(mergedConfig)
765+
? stripGeneratedAssistantAgentPersona(mergedConfig)
736766
: mergedConfig
737767
} catch {
738768
return buildAssistantOpenCodeConfig()
@@ -748,8 +778,8 @@ export async function ensureAssistantMode(
748778
const repairedConfig = assistantOpenCodeConfigNeedsRepair(existingConfig)
749779
? mergeAssistantOpenCodeConfig(existingConfig)
750780
: existingConfig
751-
const updatedConfig = assistantOpenCodeConfigPromptNeedsMigration(repairedConfig)
752-
? migrateGeneratedAssistantOpenCodePrompt(repairedConfig)
781+
const updatedConfig = assistantOpenCodeConfigHasGeneratedAgentPersona(repairedConfig)
782+
? stripGeneratedAssistantAgentPersona(repairedConfig)
753783
: repairedConfig
754784

755785
if (updatedConfig !== existingConfig) {
@@ -890,41 +920,55 @@ function assistantOpenCodeConfigNeedsRepair(config: OpenCodeConfigInput): boolea
890920
const mode = (assistantAgent as { mode?: unknown }).mode
891921
if (mode !== 'primary' && mode !== 'all') return true
892922
if ((assistantAgent as { disable?: unknown }).disable === true) return true
923+
if (assistantOpenCodeConfigHasGeneratedAgentPersona(config)) return true
893924
return false
894925
}
895926

896-
function assistantOpenCodeConfigPromptNeedsMigration(config: OpenCodeConfigInput): boolean {
897-
const prompt = (config.agent?.[ASSISTANT_DEFAULT_AGENT_NAME] as { prompt?: unknown } | undefined)?.prompt
898-
return matchesGeneratedAssistantAgentPrompt(prompt) && prompt !== buildAssistantAgentPrompt()
927+
function assistantOpenCodeConfigHasGeneratedAgentPersona(config: OpenCodeConfigInput): boolean {
928+
const agent = config.agent?.[ASSISTANT_DEFAULT_AGENT_NAME]
929+
if (typeof agent !== 'object' || agent === null) return false
930+
const prompt = (agent as { prompt?: unknown }).prompt
931+
return matchesGeneratedAssistantAgentPrompt(prompt)
899932
}
900933

901-
function migrateGeneratedAssistantOpenCodePrompt(config: OpenCodeConfigInput): OpenCodeConfigInput {
934+
function resolveValidAssistantMode(agent: unknown): 'primary' | 'all' {
935+
const mode = (agent as { mode?: unknown } | undefined)?.mode
936+
return mode === 'primary' || mode === 'all' ? mode : 'primary'
937+
}
938+
939+
function stripGeneratedAssistantAgentPersona(config: OpenCodeConfigInput): OpenCodeConfigInput {
902940
const existingAssistantAgent = config.agent?.[ASSISTANT_DEFAULT_AGENT_NAME]
903-
if (typeof existingAssistantAgent !== 'object' || existingAssistantAgent === null) return config
941+
const validMode = resolveValidAssistantMode(existingAssistantAgent)
904942

905943
return {
906944
...config,
907945
agent: {
908946
...(config.agent ?? {}),
909-
[ASSISTANT_DEFAULT_AGENT_NAME]: {
910-
...existingAssistantAgent,
911-
prompt: buildAssistantAgentPrompt(),
912-
},
947+
[ASSISTANT_DEFAULT_AGENT_NAME]: { mode: validMode },
913948
},
914949
}
915950
}
916951

917952
function mergeAssistantOpenCodeConfig(existing?: OpenCodeConfigInput): OpenCodeConfigInput {
918953
const generated = buildAssistantOpenCodeConfig()
919954
const existingAssistantAgent = existing?.agent?.[ASSISTANT_DEFAULT_AGENT_NAME]
920-
const existingMode = (existingAssistantAgent as { mode?: 'primary' | 'all' | unknown } | undefined)?.mode
921-
const validMode = existingMode === 'primary' || existingMode === 'all' ? existingMode : 'primary'
922-
923-
const mergedAssistantAgent = {
924-
...generated.agent?.[ASSISTANT_DEFAULT_AGENT_NAME],
925-
...(typeof existingAssistantAgent === 'object' && existingAssistantAgent !== null ? existingAssistantAgent : {}),
926-
mode: validMode,
927-
disable: false,
955+
const validMode = resolveValidAssistantMode(existingAssistantAgent)
956+
957+
const existingIsGenerated = existingAssistantAgent != null &&
958+
typeof existingAssistantAgent === 'object' &&
959+
matchesGeneratedAssistantAgentPrompt(
960+
(existingAssistantAgent as { prompt?: unknown }).prompt,
961+
)
962+
963+
let mergedAssistantAgent: Record<string, unknown>
964+
if (existingIsGenerated) {
965+
mergedAssistantAgent = { mode: validMode }
966+
} else {
967+
mergedAssistantAgent = {
968+
...(typeof existingAssistantAgent === 'object' && existingAssistantAgent !== null ? existingAssistantAgent : {}),
969+
mode: validMode,
970+
disable: false,
971+
}
928972
}
929973

930974
return {

0 commit comments

Comments
 (0)