Compare commits
38 Commits
v2.7.21-ed
...
v2.8.14-do
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c5b2c2bcf | |||
| 524a0deaa1 | |||
| a7a40c5b46 | |||
| e5183cc71b | |||
| 9abc14ef82 | |||
| 7ef479639a | |||
| 89a6ffe8a0 | |||
| a8e475fdf4 | |||
| 02063072ab | |||
| ec48066a80 | |||
| 876c9bcd02 | |||
| c132215064 | |||
| a72f7954b4 | |||
| 31d8efe66a | |||
| c935687725 | |||
| 0d6e9a2413 | |||
| 6344105877 | |||
| 028c08b4cd | |||
| fb52eb3efa | |||
| 648a59a563 | |||
| 7f59f30f2d | |||
| f436021bf9 | |||
| bef6bef504 | |||
| 87923cb07b | |||
| c6ecd984c5 | |||
| 2a83f61070 | |||
| 44874f0097 | |||
| 1b70d41996 | |||
| b64941ad4b | |||
| cdc782e044 | |||
| 02bb355a09 | |||
| b8b2666fdc | |||
| ee749d8698 | |||
| bc83475a3d | |||
| 214cc32ac2 | |||
| 6b7c2bab1e | |||
| 373ba86e5d | |||
| 9106334e70 |
12
.ascli.json
Normal file
12
.ascli.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"binding": {
|
||||||
|
"apiBaseUrl": "https://agentspace.so",
|
||||||
|
"claimToken": "5Jr5_HEFEH_4Mc-7_dzUTEhYUWKFC-uOi58RrqMQ7RTGTA01",
|
||||||
|
"claimUrl": "https://agentspace.so/claim?workspaceId=ws_iTSoXqyy7Mcf&token=5Jr5_HEFEH_4Mc-7_dzUTEhYUWKFC-uOi58RrqMQ7RTGTA01",
|
||||||
|
"clientId": "ascli",
|
||||||
|
"createdAt": "2026-06-07T17:39:16.001Z",
|
||||||
|
"workspaceId": "ws_iTSoXqyy7Mcf",
|
||||||
|
"workspaceName": "fork-lifts-phases-3-11"
|
||||||
|
}
|
||||||
|
}
|
||||||
1439
.codesight/CODESIGHT.md
Normal file
1439
.codesight/CODESIGHT.md
Normal file
File diff suppressed because it is too large
Load Diff
71
.codesight/components.md
Normal file
71
.codesight/components.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Components
|
||||||
|
|
||||||
|
- **App** — `apps/web/src/App.tsx`
|
||||||
|
- **AddProjectModal** — props: open, onOpenChange, onAdded — `apps/web/src/components/AddProjectModal.tsx`
|
||||||
|
- **AgentComposerBar** — props: projectPath, value, onChange, onProviderCommandsChange, connected, agentStatus — `apps/web/src/components/AgentComposerBar.tsx`
|
||||||
|
- **AgentPicker** — props: projectId, value, onChange — `apps/web/src/components/AgentPicker.tsx`
|
||||||
|
- **ArenaLauncherDialog** — `apps/web/src/components/ArenaLauncherDialog.tsx`
|
||||||
|
- **ArtifactPaneHeader** — props: title, defaultTitle, onDownload, downloadDisabled, onClose, onCopy, justCopied, copyDisabled — `apps/web/src/components/ArtifactPaneHeader.tsx`
|
||||||
|
- **AskUserInputCard** — props: toolCall, toolResult, chatId, apiPrefix — `apps/web/src/components/AskUserInputCard.tsx`
|
||||||
|
- **AttachmentChip** — props: attachment, onRemove, onPreview — `apps/web/src/components/AttachmentChip.tsx`
|
||||||
|
- **AttachmentPreviewModal** — props: attachment, onClose — `apps/web/src/components/AttachmentPreviewModal.tsx`
|
||||||
|
- **BottomSheet** — props: open, onClose, title — `apps/web/src/components/BottomSheet.tsx`
|
||||||
|
- **CapHitSentinel** — props: message, capHitPosition, isLatest — `apps/web/src/components/CapHitSentinel.tsx`
|
||||||
|
- **ChatInput** — props: disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop — `apps/web/src/components/ChatInput.tsx`
|
||||||
|
- **ChatTabBar** — props: pane, tabs, tabNumbers, onSwitchTab, onRemoveTab, onCloseOthers, onCloseToRight, onCloseAll, onNewTab, onSplitPane — `apps/web/src/components/ChatTabBar.tsx`
|
||||||
|
- **ChatThroughput** — props: chatId, className — `apps/web/src/components/ChatThroughput.tsx`
|
||||||
|
- **CodeBlock** — props: code, lang — `apps/web/src/components/CodeBlock.tsx`
|
||||||
|
- **ContextMeter** — props: messages, modelContextLimit, sessionCostUsd — `apps/web/src/components/ContextMeter.tsx`
|
||||||
|
- **CreateProjectModal** — props: open, onOpenChange — `apps/web/src/components/CreateProjectModal.tsx`
|
||||||
|
- **DoomLoopSentinel** — props: message — `apps/web/src/components/DoomLoopSentinel.tsx`
|
||||||
|
- **DropOverlay** — props: visible — `apps/web/src/components/DropOverlay.tsx`
|
||||||
|
- **FileMentionPopover** — props: query, files, anchorRect, onSelect, onClose — `apps/web/src/components/FileMentionPopover.tsx`
|
||||||
|
- **FileViewerOverlay** — props: path, content, lang, onClose — `apps/web/src/components/FileViewerOverlay.tsx`
|
||||||
|
- **FlowLauncherDialog** — `apps/web/src/components/FlowLauncherDialog.tsx`
|
||||||
|
- **GitDiffView** — props: result, loading, error, mode, onSelectMode, onRefresh, mutating, mutateError, onStage, onUnstage — `apps/web/src/components/GitDiffView.tsx`
|
||||||
|
- **HtmlArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/HtmlArtifactPane.tsx`
|
||||||
|
- **InferenceSettings** — `apps/web/src/components/InferenceSettings.tsx`
|
||||||
|
- **MarkdownArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/MarkdownArtifactPane.tsx`
|
||||||
|
- **MarkdownRenderer** — props: content — `apps/web/src/components/MarkdownRenderer.tsx`
|
||||||
|
- **MessageBubble** — props: message, sessionChats, capHitInfo, actions, hideActions, hasCheckpoint, restoreDisabled — `apps/web/src/components/MessageBubble.tsx`
|
||||||
|
- **MessageList** — props: messages, sessionChats — `apps/web/src/components/MessageList.tsx`
|
||||||
|
- **MobileTabSwitcher** — props: panes, activePaneIdx, chats, onSwitchPane, onRemovePane, onRenameChat — `apps/web/src/components/MobileTabSwitcher.tsx`
|
||||||
|
- **ModelPicker** — props: value, onChange — `apps/web/src/components/ModelPicker.tsx`
|
||||||
|
- **NewPaneMenu** — props: onAddPane, disabled, projectId — `apps/web/src/components/NewPaneMenu.tsx`
|
||||||
|
- **PaneHeaderActions** — props: onNewTab, onSplitPane, onNewOrchestrator, onNewArena, onReopenPane, onShowHistory, onRemovePane, historyActive, className — `apps/web/src/components/PaneHeaderActions.tsx`
|
||||||
|
- **PermissionCard** — props: prompt, onRespond, busy — `apps/web/src/components/PermissionCard.tsx`
|
||||||
|
- **ProjectSidebar** — `apps/web/src/components/ProjectSidebar.tsx`
|
||||||
|
- **RequestReadAccessCard** — props: toolCall, toolResult, chatId — `apps/web/src/components/RequestReadAccessCard.tsx`
|
||||||
|
- **RightRail** — props: projectId, sessionId — `apps/web/src/components/RightRail.tsx`
|
||||||
|
- **SessionLandingPage** — props: projectId, sessionId, agentId, onAgentChange, onSend, onSkillInvoke, createChat, chats, onOpenChat, onUnarchiveChat — `apps/web/src/components/SessionLandingPage.tsx`
|
||||||
|
- **SlashCommandPicker** — props: query, items, groups, inputRef, onSelect, onClose, emptyLabel — `apps/web/src/components/SlashCommandPicker.tsx`
|
||||||
|
- **StaleStreamBanner** — props: onRetry, onDiscard — `apps/web/src/components/StaleStreamBanner.tsx`
|
||||||
|
- **StatusDot** — props: chatId, className — `apps/web/src/components/StatusDot.tsx`
|
||||||
|
- **ThemePicker** — `apps/web/src/components/ThemePicker.tsx`
|
||||||
|
- **ToolCallGroup** — props: runs — `apps/web/src/components/ToolCallGroup.tsx`
|
||||||
|
- **ToolCallLine** — props: run, insideGroup — `apps/web/src/components/ToolCallLine.tsx`
|
||||||
|
- **Workspace** — props: sessionId, projectId, agentId, onAgentChange, panesHook, chatsHook, session, project, onAddPane — `apps/web/src/components/Workspace.tsx`
|
||||||
|
- **AddProviderModal** — props: open, onOpenChange, onAdded — `apps/web/src/components/coder/AddProviderModal.tsx`
|
||||||
|
- **ProvidersSettings** — `apps/web/src/components/coder/ProvidersSettings.tsx`
|
||||||
|
- **MatrixRain** — props: enabled, density, speed, opacity — `apps/web/src/components/fx/MatrixRain.tsx`
|
||||||
|
- **NeonField** — props: enabled, opacity, speed — `apps/web/src/components/fx/NeonField.tsx`
|
||||||
|
- **ThemeFx** — `apps/web/src/components/fx/ThemeFx.tsx`
|
||||||
|
- **ClaudeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
|
||||||
|
- **OpenCodeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
|
||||||
|
- **ArenaPane** — props: state, onClose — `apps/web/src/components/panes/ArenaPane.tsx`
|
||||||
|
- **ChatPane** — props: sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled — `apps/web/src/components/panes/ChatPane.tsx`
|
||||||
|
- **CoderMessageList** — props: messages, chatId, footer, actions, checkpointMessageIds, restoreDisabled — `apps/web/src/components/panes/CoderMessageList.tsx`
|
||||||
|
- **CoderPane** — props: sessionId, paneId, chatId, chatPending, projectPath, onConnectedChange, onAgentLabelChange — `apps/web/src/components/panes/CoderPane.tsx`
|
||||||
|
- **OrchestratorPane** — props: state, onClose — `apps/web/src/components/panes/OrchestratorPane.tsx`
|
||||||
|
- **SettingsPane** — props: session, project, maximized, onToggleMaximize, onClose, isMobile — `apps/web/src/components/panes/SettingsPane.tsx`
|
||||||
|
- **TerminalPane** — props: sessionId, paneId, label, active — `apps/web/src/components/panes/TerminalPane.tsx`
|
||||||
|
- **FloatingMenu** — props: x, y, hasSelection, chatInputs, onCopy, onPaste, onSelectAll, onSearch, onSendToChat, onDismiss — `apps/web/src/components/panes/terminal/FloatingMenu.tsx`
|
||||||
|
- **SearchBar** — props: searchRef, theme, onClose — `apps/web/src/components/panes/terminal/SearchBar.tsx`
|
||||||
|
- **TerminalHotkeyBar** — props: ctrlArmed, onSendBytes, onArmCtrl, onFit — `apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx`
|
||||||
|
- **RightRailDrawerProvider** — `apps/web/src/hooks/useRightRailDrawer.tsx`
|
||||||
|
- **SidebarDrawerProvider** — `apps/web/src/hooks/useSidebarDrawer.tsx`
|
||||||
|
- **PATH_REGEX** — `apps/web/src/lib/linkify-paths.tsx`
|
||||||
|
- **Home** — `apps/web/src/pages/Home.tsx`
|
||||||
|
- **Project** — `apps/web/src/pages/Project.tsx`
|
||||||
|
- **Session** — `apps/web/src/pages/Session.tsx`
|
||||||
|
- **Settings** — `apps/web/src/pages/Settings.tsx`
|
||||||
50
.codesight/config.md
Normal file
50
.codesight/config.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Config
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
- `AUDIT_DOT_DIR` **required** — apps/server/src/services/audit/runs-dir.ts
|
||||||
|
- `BOOCODE_DATA_DIR` **required** — apps/server/src/routes/inference-settings.ts
|
||||||
|
- `BOOCODE_TOOLS` **required** — apps/server/src/services/agents.ts
|
||||||
|
- `BOOCODE_TRUNCATION_DIR` **required** — apps/server/src/services/__tests__/truncate.test.ts
|
||||||
|
- `BOOCODER_DEV_URL` **required** — apps/web/vite.config.ts
|
||||||
|
- `BOOCODER_URL` **required** — apps/coder/src/cli.ts
|
||||||
|
- `BOOTERM_DEV_URL` **required** — apps/web/vite.config.ts
|
||||||
|
- `BOOTERM_SSH_HOST` **required** — apps/booterm/src/pty/manager.ts
|
||||||
|
- `BOOTERM_SSH_USER` **required** — apps/booterm/src/pty/manager.ts
|
||||||
|
- `BOOTSTRAP_ROOT` (has default) — .env.example
|
||||||
|
- `BRAINSTORM_DIR` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
|
||||||
|
- `BRAINSTORM_HOST` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
|
||||||
|
- `BRAINSTORM_OWNER_PID` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
|
||||||
|
- `BRAINSTORM_PORT` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
|
||||||
|
- `BRAINSTORM_URL_HOST` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
|
||||||
|
- `CODECONTEXT_CHILD` **required** — codecontext/shim.go
|
||||||
|
- `CODECONTEXT_URL` **required** — apps/server/src/services/codecontext_client.ts
|
||||||
|
- `CONDUCTOR_MODEL` **required** — conductor/src/dispatch.ts
|
||||||
|
- `CONDUCTOR_OPENCODE_BIN` **required** — conductor/src/dispatch.ts
|
||||||
|
- `CONDUCTOR_TIMEOUT_MS` **required** — conductor/src/dispatch.ts
|
||||||
|
- `CONTAINER_GUIDANCE_FILE` **required** — apps/server/src/services/__tests__/system-prompt.test.ts
|
||||||
|
- `CONTEXT7_API_KEY` (has default) — .env
|
||||||
|
- `DATABASE_URL` (has default) — .env.example
|
||||||
|
- `DEFAULT_MODEL` (has default) — .env.example
|
||||||
|
- `DEV_REMOTE_USER` **required** — apps/web/vite.config.ts
|
||||||
|
- `GITEA_BASE_URL` (has default) — .env
|
||||||
|
- `GITEA_SSH_HOST` (has default) — .env
|
||||||
|
- `GITEA_TOKEN` (has default) — .env
|
||||||
|
- `GITEA_USER` (has default) — .env
|
||||||
|
- `LLAMA_SWAP_URL` (has default) — .env.example
|
||||||
|
- `MCP_TEST_MISSING` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
|
||||||
|
- `MCP_TEST_SECRET` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
|
||||||
|
- `NODE_ENV` (has default) — .env.example
|
||||||
|
- `PORT` (has default) — .env.example
|
||||||
|
- `POSTGRES_PASSWORD` (has default) — .env.example
|
||||||
|
- `PROJECT_ROOT_WHITELIST` (has default) — .env.example
|
||||||
|
- `SEARXNG_URL` (has default) — .env.example
|
||||||
|
- `SKILLS_ROOT` **required** — apps/server/src/services/skills.ts
|
||||||
|
- `WEB_DIST_PATH` **required** — apps/server/src/index.ts
|
||||||
|
|
||||||
|
## Config Files
|
||||||
|
|
||||||
|
- `.env.example`
|
||||||
|
- `Dockerfile`
|
||||||
|
- `apps/web/vite.config.ts`
|
||||||
|
- `docker-compose.yml`
|
||||||
37
.codesight/graph.md
Normal file
37
.codesight/graph.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Dependency Graph
|
||||||
|
|
||||||
|
## Most Imported Files (change these carefully)
|
||||||
|
|
||||||
|
- `apps/coder/src/db.ts` — imported by **40** files
|
||||||
|
- `apps/server/src/types/api.ts` — imported by **28** files
|
||||||
|
- `apps/server/src/db.ts` — imported by **25** files
|
||||||
|
- `packages/ion/src/cli/utils.ts` — imported by **24** files
|
||||||
|
- `apps/coder/src/services/tools/types.ts` — imported by **18** files
|
||||||
|
- `apps/coder/src/conductor/types.ts` — imported by **14** files
|
||||||
|
- `apps/coder/src/services/agent-backend.ts` — imported by **14** files
|
||||||
|
- `apps/coder/src/services/acp-tool-snapshot.ts` — imported by **14** files
|
||||||
|
- `apps/server/src/services/tools/codecontext/factory.ts` — imported by **14** files
|
||||||
|
- `apps/server/src/services/tools.ts` — imported by **13** files
|
||||||
|
- `conductor/src/types.ts` — imported by **13** files
|
||||||
|
- `apps/coder/src/services/provider-config-registry.ts` — imported by **12** files
|
||||||
|
- `apps/server/src/config.ts` — imported by **12** files
|
||||||
|
- `apps/coder/src/config.ts` — imported by **11** files
|
||||||
|
- `apps/coder/src/services/provider-types.ts` — imported by **11** files
|
||||||
|
- `apps/server/src/services/agents.ts` — imported by **10** files
|
||||||
|
- `apps/coder/src/services/pending_changes.ts` — imported by **9** files
|
||||||
|
- `apps/server/src/services/broker.ts` — imported by **9** files
|
||||||
|
- `apps/server/src/services/path_guard.ts` — imported by **9** files
|
||||||
|
- `apps/server/src/services/inference/payload.ts` — imported by **9** files
|
||||||
|
|
||||||
|
## Import Map (who imports what)
|
||||||
|
|
||||||
|
- `apps/coder/src/db.ts` ← `apps/coder/src/index.ts`, `apps/coder/src/routes/__tests__/agent-sessions.routes.test.ts`, `apps/coder/src/routes/__tests__/chat-resolve.test.ts`, `apps/coder/src/routes/__tests__/providers.routes.test.ts`, `apps/coder/src/routes/agent-sessions.ts` +35 more
|
||||||
|
- `apps/server/src/types/api.ts` ← `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts`, `apps/server/src/routes/projects.ts`, `apps/server/src/routes/sessions.ts` +23 more
|
||||||
|
- `apps/server/src/db.ts` ← `apps/server/src/index.ts`, `apps/server/src/routes/agents.ts`, `apps/server/src/routes/artifacts.ts`, `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts` +20 more
|
||||||
|
- `packages/ion/src/cli/utils.ts` ← `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/cleanup.ts` +19 more
|
||||||
|
- `apps/coder/src/services/tools/types.ts` ← `apps/coder/src/routes/messages.ts`, `apps/coder/src/services/dispatcher.ts`, `apps/coder/src/services/tools/adapter.ts`, `apps/coder/src/services/tools/apply_pending.ts`, `apps/coder/src/services/tools/check_task_status.ts` +13 more
|
||||||
|
- `apps/coder/src/conductor/types.ts` ← `apps/coder/src/conductor/flows/_util.ts`, `apps/coder/src/conductor/flows/architectural-analysis.ts`, `apps/coder/src/conductor/flows/authoring.ts`, `apps/coder/src/conductor/flows/code-review.ts`, `apps/coder/src/conductor/flows/discovery.ts` +9 more
|
||||||
|
- `apps/coder/src/services/agent-backend.ts` ← `apps/coder/src/routes/lifecycle.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-event-map.ts`, `apps/coder/src/services/agent-pool.ts`, `apps/coder/src/services/backends/__tests__/claude-sdk-map.test.ts` +9 more
|
||||||
|
- `apps/coder/src/services/acp-tool-snapshot.ts` ← `apps/coder/src/services/__tests__/acp-event-map.test.ts`, `apps/coder/src/services/__tests__/frame-emitter.test.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-dispatch.ts`, `apps/coder/src/services/acp-event-map.ts` +9 more
|
||||||
|
- `apps/server/src/services/tools/codecontext/factory.ts` ← `apps/server/src/services/tools/codecontext/get_blast_radius.ts`, `apps/server/src/services/tools/codecontext/get_call_graph.ts`, `apps/server/src/services/tools/codecontext/get_codebase_overview.ts`, `apps/server/src/services/tools/codecontext/get_dependencies.ts`, `apps/server/src/services/tools/codecontext/get_file_analysis.ts` +9 more
|
||||||
|
- `apps/server/src/services/tools.ts` ← `apps/server/src/index.ts`, `apps/server/src/services/__tests__/agent-allowlist.test.ts`, `apps/server/src/services/agents.ts`, `apps/server/src/services/inference/stream-phase-adapter.ts`, `apps/server/src/services/inference/stream-phase.ts` +8 more
|
||||||
927
.codesight/libs.md
Normal file
927
.codesight/libs.md
Normal file
@@ -0,0 +1,927 @@
|
|||||||
|
# Libraries
|
||||||
|
|
||||||
|
- `apps/booterm/src/auth.ts` — function getUser: (req) => string
|
||||||
|
- `apps/booterm/src/config.ts` — function loadConfig: () => Config
|
||||||
|
- `apps/booterm/src/db.ts`
|
||||||
|
- function getPool: (databaseUrl) => pg.Pool
|
||||||
|
- function getSessionInfo: (sessionId) => Promise<SessionInfo | null>
|
||||||
|
- function pingDb: () => Promise<boolean>
|
||||||
|
- function closeDb: () => Promise<void>
|
||||||
|
- `apps/booterm/src/pty/manager.ts`
|
||||||
|
- function sanitizeId: (raw) => string | null
|
||||||
|
- function tmuxSessionName: (paneId) => string
|
||||||
|
- function hasSession: (tmuxConfPath, sessionName) => Promise<boolean>
|
||||||
|
- function ensureSession: (tmuxConfPath, sessionName, projectRoot, log, cols?, rows?) => Promise<void>
|
||||||
|
- function killSession: (tmuxConfPath, sessionName) => Promise<boolean>
|
||||||
|
- function capturePane: (tmuxConfPath, sessionName, lines) => Promise<string>
|
||||||
|
- `apps/booterm/src/pty/pty.ts` — function attachPty: (opts) => IPty
|
||||||
|
- `apps/booterm/src/ws/attach.ts` — function registerWsAttachRoute: (app, tmuxConfPath) => void
|
||||||
|
- `apps/coder/src/conductor/contracts.ts`
|
||||||
|
- function produceContract: (contracts) => string
|
||||||
|
- function reviewContract: (contracts) => string
|
||||||
|
- type Contract
|
||||||
|
- const EVIDENCE_PRODUCE
|
||||||
|
- const EVIDENCE_REVIEW
|
||||||
|
- const YAGNI_PRODUCE
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/coder/src/conductor/flows/_util.ts` — function q, function repoLine
|
||||||
|
- `apps/coder/src/conductor/flows/index.ts`
|
||||||
|
- function describeFlows: () => string
|
||||||
|
- function getFlow: (name) => Flow | undefined
|
||||||
|
- const FLOWS: Record<string, Flow>
|
||||||
|
- const FLOW_NAMES: string[]
|
||||||
|
- `apps/coder/src/conductor/persona-loader.ts` — function loadPersona: (agent) => Promise<string>, const AGENTS_DIR
|
||||||
|
- `apps/coder/src/conductor/render.ts` — function slugify: (s) => string
|
||||||
|
- `apps/coder/src/conductor/spine.ts`
|
||||||
|
- function readBand: (input) => Band
|
||||||
|
- function fastNote: (ctx) => string
|
||||||
|
- function buildSpineFlow: (spine) => Flow
|
||||||
|
- `apps/coder/src/config.ts` — function loadConfig: () => Config, type Config
|
||||||
|
- `apps/coder/src/db.ts`
|
||||||
|
- function getSql: (config) => Sql
|
||||||
|
- function applySchema: (sql) => Promise<void>
|
||||||
|
- function pingDb: (sql) => Promise<boolean>
|
||||||
|
- function closeDb: () => Promise<void>
|
||||||
|
- type Sql
|
||||||
|
- `apps/coder/src/plugins/host.ts`
|
||||||
|
- function registerHook: (name, fn) => void
|
||||||
|
- function emitHook: (name, ctx) => Promise<any>
|
||||||
|
- function clearHooks: () => void
|
||||||
|
- interface ToolHookContext
|
||||||
|
- interface ToolResultContext
|
||||||
|
- type HookName
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/coder/src/services/acp-client-fs.ts` — function readWorktreeTextFile: (worktreePath, filePath, line?, limit?) => Promise<string>, function writeWorktreeTextFile: (worktreePath, filePath, content) => Promise<void>
|
||||||
|
- `apps/coder/src/services/acp-client.ts` — function buildAcpClient: (worktreePath, resolveTurn) => void, interface AcpTurnContext
|
||||||
|
- `apps/coder/src/services/acp-derive.ts`
|
||||||
|
- function deriveModesFromACP: (fallbackModes, modeState?, configOptions?) => void
|
||||||
|
- function deriveModelDefinitionsFromACP: (models, configOptions?) => ProviderModel[]
|
||||||
|
- function findThoughtLevelConfigId: (configOptions) => string | null
|
||||||
|
- `apps/coder/src/services/acp-dispatch.ts`
|
||||||
|
- function dispatchViaAcp: (opts) => Promise<AcpDispatchResult>
|
||||||
|
- interface AcpDispatchResult
|
||||||
|
- interface AcpDispatchOpts
|
||||||
|
- `apps/coder/src/services/acp-event-map.ts` — function mapSessionUpdate: (params, priorSnapshots, AcpToolSnapshot>) => void
|
||||||
|
- `apps/coder/src/services/acp-probe.ts` — function probeAcpProvider: (agent, installPath, cwd) => Promise<AcpProbeResult>, interface AcpProbeResult
|
||||||
|
- `apps/coder/src/services/acp-spawn.ts`
|
||||||
|
- function resolveAcpSpawnArgs: (agent) => string[] | null
|
||||||
|
- function resolveLaunchSpec: (resolved, installPath) => void
|
||||||
|
- function resolveAcpProbeBinaries: (agent) => string[]
|
||||||
|
- `apps/coder/src/services/acp-stream.ts` — function createAcpNdJsonStream: (child) => void
|
||||||
|
- `apps/coder/src/services/acp-tool-snapshot.ts`
|
||||||
|
- function mergeToolSnapshot: (toolCallId, update, previous?) => AcpToolSnapshot
|
||||||
|
- function mapToolLifecycleStatus: (status, rawOutput?) => AcpToolLifecycleStatus
|
||||||
|
- function snapshotToWireToolCall: (snapshot) => void
|
||||||
|
- function snapshotToPartPayload: (snapshot) => void
|
||||||
|
- function synthesizeCanceledSnapshots: (snapshots) => AcpToolSnapshot[]
|
||||||
|
- interface AcpToolSnapshot
|
||||||
|
- _...2 more_
|
||||||
|
- `apps/coder/src/services/agent-commands-cache.ts`
|
||||||
|
- function setTaskCommands: (taskId, commands) => void
|
||||||
|
- function mergeTaskCommands: (taskId, commands) => void
|
||||||
|
- function getTaskCommands: (taskId) => AgentCommand[] | null
|
||||||
|
- function clearTaskCommands: (taskId) => void
|
||||||
|
- `apps/coder/src/services/agent-pool.ts`
|
||||||
|
- class AgentPool
|
||||||
|
- interface AgentPoolOpts
|
||||||
|
- const OPENCODE_POOL_KEY
|
||||||
|
- const agentPool
|
||||||
|
- `apps/coder/src/services/agent-probe.ts` — function probeAgents: (sql, log) => Promise<void>
|
||||||
|
- `apps/coder/src/services/agent-status-publish.ts` — function publishAgentStatus: (publishFrame, sessionId, chatId, agent, status, reason?, at) => void
|
||||||
|
- `apps/coder/src/services/agent-turn-persist.ts` — function persistExternalAgentTurn: (sql, assistantMessageId, snapshots, reasoningText) => Promise<void>
|
||||||
|
- `apps/coder/src/services/arena-analyzer-helpers.ts`
|
||||||
|
- function buildDigestPrompt: (input) => void
|
||||||
|
- function buildJudgePrompt: (originalPrompt, digests) => void
|
||||||
|
- function shouldNameWinner: (succeededCount) => boolean
|
||||||
|
- function extractWinner: (judgeOutput) => void
|
||||||
|
- function buildCrossExamPrompt: (opts) => void
|
||||||
|
- interface ContestantDigestInput
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/coder/src/services/arena-analyzer.ts` — function createAnalyzer: (deps) => Analyzer, interface Analyzer
|
||||||
|
- `apps/coder/src/services/arena-decisions.ts`
|
||||||
|
- function classifyLane: (battleType, _identity, model, localModels) => ContestantLane
|
||||||
|
- function nextLocalContestant: (contestants) => string | null
|
||||||
|
- function isBattleComplete: (contestants) => boolean
|
||||||
|
- function computeBenchmark: (startedAt, endedAt, costTokens, lane) => Benchmark
|
||||||
|
- function sanitizeSlug: (s) => string
|
||||||
|
- function buildBattleSlug: (battleId, battleType, createdAt) => string
|
||||||
|
- _...7 more_
|
||||||
|
- `apps/coder/src/services/arena-model-call.ts` — function arenaModelCall: (opts, 'LLAMA_SWAP_URL'>;
|
||||||
|
model) => Promise<string>
|
||||||
|
- `apps/coder/src/services/arena-runner.ts`
|
||||||
|
- function createBattleRunner: (deps) => BattleRunner
|
||||||
|
- interface ContestantSpec
|
||||||
|
- interface BattleStartOpts
|
||||||
|
- interface BattleRunner
|
||||||
|
- type DispatchContestantFn
|
||||||
|
- type OnBattleComplete
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/coder/src/services/audit-session.ts`
|
||||||
|
- function generateSessionId: () => string
|
||||||
|
- function getCurrentSession: (basePath?) => Promise<string | null>
|
||||||
|
- function getSessionJson: (sessionId, basePath?) => Promise<SessionJson | null>
|
||||||
|
- function getIndex: (basePath?) => Promise<IndexJson | null>
|
||||||
|
- function startSession: (task, basePath?) => Promise<StartSessionResult>
|
||||||
|
- function endSession: (basePath?) => Promise<EndSessionResult | null>
|
||||||
|
- _...18 more_
|
||||||
|
- `apps/coder/src/services/backends/claude-sdk-map.ts`
|
||||||
|
- function createClaudeSdkMapState: () => ClaudeSdkMapState
|
||||||
|
- function mapSdkMessage: (msg, state) => AgentEvent[]
|
||||||
|
- interface ClaudeSdkMapState
|
||||||
|
- `apps/coder/src/services/backends/claude-sdk-routing.ts` — function claudeSdkBackendEnabled: (env) => boolean, function shouldUseClaudeSdk: (task, env) => boolean
|
||||||
|
- `apps/coder/src/services/backends/claude-sdk.ts` — class ClaudeSdkBackend, interface ClaudeSdkBackendDeps
|
||||||
|
- `apps/coder/src/services/backends/claude-session-store.ts` — class PostgresSessionStore
|
||||||
|
- `apps/coder/src/services/backends/lifecycle-decisions.ts`
|
||||||
|
- function selectIdleEvictionTargets: (entries, now, ttlMs) => string[]
|
||||||
|
- function selectLruEvictionTargets: (entries, cap) => string[]
|
||||||
|
- function decideRestart: (input) => RestartDecision
|
||||||
|
- function selectOrphanWorktreeTargets: (onDisk, liveWorktreePaths, now, graceMs) => string[]
|
||||||
|
- interface PoolEntrySnapshot
|
||||||
|
- interface RestartDecisionInput
|
||||||
|
- _...7 more_
|
||||||
|
- `apps/coder/src/services/backends/opencode-event-map.ts`
|
||||||
|
- function stripDcpTags: (s) => string
|
||||||
|
- function eventSessionId: (ev) => string | null
|
||||||
|
- function resolvePartDedupeKey: (part, type) => string | null
|
||||||
|
- function mapToolStatus: (s) => ToolCallStatus | null
|
||||||
|
- function toolPartToSnapshot: (part) => AcpToolSnapshot
|
||||||
|
- function toolCalledSnapshot: (p) => AcpToolSnapshot
|
||||||
|
- _...7 more_
|
||||||
|
- `apps/coder/src/services/backends/opencode-server-process.ts`
|
||||||
|
- function shouldStartServer: (s) => boolean
|
||||||
|
- class OpenCodeServerSupervisor
|
||||||
|
- interface ServerDownInfo
|
||||||
|
- interface SupervisorHooks
|
||||||
|
- interface OpenCodeServerSupervisorDeps
|
||||||
|
- `apps/coder/src/services/backends/opencode-server.ts` — class OpenCodeServerBackend, interface OpenCodeServerBackendDeps
|
||||||
|
- `apps/coder/src/services/backends/opencode-sse.ts`
|
||||||
|
- function reconnectDecision: (failures, policy) => ReconnectDecision
|
||||||
|
- function startSessionEventLoop: (state, deps) => void
|
||||||
|
- function runSessionEventLoop: (state, abort, deps) => Promise<void>
|
||||||
|
- interface TurnState
|
||||||
|
- interface SessionState
|
||||||
|
- interface ReconnectPolicy
|
||||||
|
- _...4 more_
|
||||||
|
- `apps/coder/src/services/backends/opencode-usage.ts`
|
||||||
|
- function stepEndedToUsage: (props) => StepUsage
|
||||||
|
- interface StepEndedProps
|
||||||
|
- interface StepUsage
|
||||||
|
- `apps/coder/src/services/backends/pushable-iterable.ts` — function createPushable: () => Pushable<T>, interface Pushable
|
||||||
|
- `apps/coder/src/services/backends/turn-guard.ts`
|
||||||
|
- function armAbortGuard: (g) => void
|
||||||
|
- function noteTurnActivity: (g) => void
|
||||||
|
- function consumeTerminal: (g) => 'swallow' | 'settle'
|
||||||
|
- interface AbortTerminalGuard
|
||||||
|
- `apps/coder/src/services/backends/warm-acp-routing.ts` — function shouldUseWarmBackend: (task) => boolean, function isTurnOkForStopReason: (stopReason) => boolean
|
||||||
|
- `apps/coder/src/services/backends/warm-acp.ts` — class WarmAcpBackend, interface WarmAcpBackendDeps
|
||||||
|
- `apps/coder/src/services/cancel-registry.ts` — function createCancelRegistry: () => CancelRegistry, interface CancelRegistry
|
||||||
|
- `apps/coder/src/services/checkpoints.ts`
|
||||||
|
- function buildShadowCommitCommand: (worktreePath, id) => string
|
||||||
|
- function createCheckpoint: (sql, args, opts?) => Promise<
|
||||||
|
- function restoreCheckpoint: (sql, checkpointId, opts?) => Promise<RestoreCheckpointResult>
|
||||||
|
- class CheckpointNotFoundError
|
||||||
|
- interface CreateCheckpointArgs
|
||||||
|
- interface RestoreCheckpointResult
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/coder/src/services/claude-command-discovery.ts` — function discoverClaudeCommands: () => AgentCommand[]
|
||||||
|
- `apps/coder/src/services/command-availability.ts` — function isCommandAvailable: (binary) => Promise<boolean>
|
||||||
|
- `apps/coder/src/services/correction-service.ts`
|
||||||
|
- function recordCorrection: (originalClaim, correction, principleExtracted, persistedTo, basePath?) => Promise<UserCorrectionRecord>
|
||||||
|
- function scanForCorrections: (auditPath) => Promise<UserCorrectionRecord[]>
|
||||||
|
- function checkContradiction: (action, corrections) => void
|
||||||
|
- function markPersisted: (correctionId, filePath, basePath?) => Promise<UserCorrectionRecord | null>
|
||||||
|
- function listCorrections: (basePath?) => Promise<UserCorrectionRecord[]>
|
||||||
|
- function appendCorrectionToTrail: (trailPath, correction) => Promise<void>
|
||||||
|
- _...2 more_
|
||||||
|
- `apps/coder/src/services/dcp-strip.ts`
|
||||||
|
- function stripDcpTags: (s) => string
|
||||||
|
- function makeDcpStreamStripper: () => DcpStreamStripper
|
||||||
|
- interface DcpStreamStripper
|
||||||
|
- `apps/coder/src/services/dispatcher.ts` — function createDispatcher: (deps) => void
|
||||||
|
- `apps/coder/src/services/edit-guards-imports.ts` — function checkDroppedImports: (original, updated, filePath) => ImportCheckResult, interface ImportCheckResult
|
||||||
|
- `apps/coder/src/services/edit-guards.ts`
|
||||||
|
- function validateEditResult: (original, updated, filePath) => GuardResult
|
||||||
|
- function formatGuardError: (guard, filePath) => string
|
||||||
|
- interface GuardResult
|
||||||
|
- `apps/coder/src/services/finalize-message.ts`
|
||||||
|
- function classifyTerminalStatus: (opts) => TerminalMessageStatus
|
||||||
|
- function finalizeStreamingMessage: (sql, publishFrame, frame) => void
|
||||||
|
- type TerminalMessageStatus
|
||||||
|
- `apps/coder/src/services/flow-artifacts.ts` — function getArtifactPath: (flowRunId, stepId) => string, function writeFlowArtifact: (flowRunId, stepId, content) => Promise<string>
|
||||||
|
- `apps/coder/src/services/flow-runner-decisions.ts`
|
||||||
|
- function manifestSteps: (flow, launchCtx) => Step[]
|
||||||
|
- function readySteps: (flow, state) => Step[]
|
||||||
|
- function partitionReady: (ready, ctx) => void
|
||||||
|
- function isRunComplete: (flow, state) => boolean
|
||||||
|
- function isStuck: (flow, state) => boolean
|
||||||
|
- function reconcileResumeStep: (status, taskId, taskState) => ResumeAction
|
||||||
|
- _...5 more_
|
||||||
|
- `apps/coder/src/services/flow-runner.ts`
|
||||||
|
- function createFlowRunner: (deps) => FlowRunner
|
||||||
|
- interface LaunchOpts
|
||||||
|
- interface FlowRunner
|
||||||
|
- `apps/coder/src/services/frame-emitter.ts`
|
||||||
|
- function makeFrameEmitter: (opts) => FrameEmitter
|
||||||
|
- interface FrameEmitterOpts
|
||||||
|
- interface FrameEmitter
|
||||||
|
- `apps/coder/src/services/fuzzy-match.ts`
|
||||||
|
- function locateMatch: (content, needle) => MatchResult
|
||||||
|
- type MatchResult
|
||||||
|
- const SIMILARITY_THRESHOLD
|
||||||
|
- const AMBIGUITY_EPSILON
|
||||||
|
- `apps/coder/src/services/guideline-service.ts`
|
||||||
|
- function createGuideline: (params, basePath?) => Promise<Guideline>
|
||||||
|
- function listGuidelines: (filter?, basePath?) => Promise<Guideline[]>
|
||||||
|
- function readGuideline: (id, basePath?) => Promise<Guideline | null>
|
||||||
|
- function updateGuideline: (id, params, basePath?) => Promise<Guideline | null>
|
||||||
|
- function deleteGuideline: (id, basePath?) => Promise<boolean>
|
||||||
|
- function findGuideline: (content, basePath?) => Promise<Guideline | null>
|
||||||
|
- _...14 more_
|
||||||
|
- `apps/coder/src/services/host-exec.ts` — function hostExec: (command, opts?) => Promise<HostExecResult>, interface HostExecResult
|
||||||
|
- `apps/coder/src/services/lsp/client.ts` — class LspClient
|
||||||
|
- `apps/coder/src/services/lsp/config.ts` — function getServerConfig: (filePath) => LspServerConfig | null, interface LspServerConfig
|
||||||
|
- `apps/coder/src/services/lsp/operations.ts`
|
||||||
|
- function openDocument: (client, filePath, content, version) => Promise<void>
|
||||||
|
- function closeDocument: (client, filePath) => Promise<void>
|
||||||
|
- function getDiagnostics: (client, filePath, content) => Promise<Diagnostic[]>
|
||||||
|
- function gotoDefinition: (client, filePath, content, line, character) => Promise<Location | null>
|
||||||
|
- function findReferences: (client, filePath, content, line, character) => Promise<Location[]>
|
||||||
|
- `apps/coder/src/services/lsp/server-manager.ts` — class LspServerManager, const lspManager
|
||||||
|
- `apps/coder/src/services/mcp-server.ts` — function startMcpServer: (sql) => Promise<void>
|
||||||
|
- `apps/coder/src/services/net/port-utils.ts`
|
||||||
|
- function reclaimPort: (port) => void
|
||||||
|
- function waitForPortRelease: (port, timeoutMs) => Promise<boolean>
|
||||||
|
- function freePort: () => Promise<number>
|
||||||
|
- `apps/coder/src/services/orphan-worktree-reaper.ts`
|
||||||
|
- function reapOrphanWorktrees: (sql, log, graceMs, now) => void
|
||||||
|
- function createOrphanWorktreeReaper: (deps) => void
|
||||||
|
- interface OrphanWorktreeReaperDeps
|
||||||
|
- interface OrphanReaperResult
|
||||||
|
- `apps/coder/src/services/pending_changes.ts`
|
||||||
|
- function planEdit: (content, oldStr, newStr) => EditPlan
|
||||||
|
- function queueEdit: (sql, sessionId, taskId, filePath, oldString, newString, projectRoot, // v2.6 Phase 1-UX) => void
|
||||||
|
- function queueCreate: (sql, sessionId, taskId, filePath, content, projectRoot, // See queueEdit) => Promise<PendingChange>
|
||||||
|
- function queueDelete: (sql, sessionId, taskId, filePath, projectRoot, // See queueEdit) => Promise<PendingChange>
|
||||||
|
- function applyOne: (sql, changeId, projectRoot) => Promise<ApplyResult>
|
||||||
|
- function applyAll: (sql, sessionId, projectRoot) => Promise<ApplyResult[]>
|
||||||
|
- _...6 more_
|
||||||
|
- `apps/coder/src/services/permission-waiter.ts`
|
||||||
|
- function setPermissionHooks: (next) => void
|
||||||
|
- function waitForPermissionResponse: (taskId, sessionId, provider, modeId, params, timeoutMs) => Promise<RequestPermissionResponse>
|
||||||
|
- function respondToPermission: (taskId, optionId, updatedInput?, unknown>) => boolean
|
||||||
|
- function getPendingPermission: (taskId) => PermissionPrompt | null
|
||||||
|
- function waitForElicitationResponse: (taskId, sessionId, provider, modeId, params, timeoutMs) => Promise<CreateElicitationResponse>
|
||||||
|
- function cancelPendingPermission: (taskId) => void
|
||||||
|
- _...3 more_
|
||||||
|
- `apps/coder/src/services/provider-commands.ts`
|
||||||
|
- function getManifestCommands: (provider) => AgentCommand[]
|
||||||
|
- function mergeCommands: (...lists) => AgentCommand[]
|
||||||
|
- const PROVIDER_COMMANDS: Record<string, AgentCommand[]>
|
||||||
|
- `apps/coder/src/services/provider-config-registry.ts`
|
||||||
|
- function buildResolvedRegistry: (builtins, config) => Map<string, ResolvedProviderDef>
|
||||||
|
- function loadProviderConfig: (path) => Map<string, ResolvedProviderDef>
|
||||||
|
- function reloadProviderConfig: () => Map<string, ResolvedProviderDef>
|
||||||
|
- function getResolvedRegistry: () => Map<string, ResolvedProviderDef>
|
||||||
|
- interface ResolvedProviderDef
|
||||||
|
- `apps/coder/src/services/provider-config.ts`
|
||||||
|
- function mergeProviderConfigPatch: (current, patch) => CoderProvidersFile
|
||||||
|
- function load: (path) => CoderProvidersFile
|
||||||
|
- function save: (path, config) => void
|
||||||
|
- `apps/coder/src/services/provider-diagnostic.ts` — function getProviderDiagnostic: (resolved, agentRow, opts) => Promise<string>, interface DiagnosticAgentRow
|
||||||
|
- `apps/coder/src/services/provider-manifest.ts`
|
||||||
|
- function getManifestModes: (provider) => ProviderMode[]
|
||||||
|
- function getManifestDefaultModeId: (provider) => string | null
|
||||||
|
- function isUnattendedMode: (provider, modeId) => boolean
|
||||||
|
- interface ProviderManifestEntry
|
||||||
|
- const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry>
|
||||||
|
- `apps/coder/src/services/provider-snapshot.ts`
|
||||||
|
- function fetchLlamaSwapModels: (config) => Promise<ProviderModel[]>
|
||||||
|
- function prefixLlamaSwapModels: (models) => ProviderModel[]
|
||||||
|
- function mergeModels: (...lists) => ProviderModel[]
|
||||||
|
- function getProviderSnapshot: (sql, config, cwd?, force) => Promise<ProviderSnapshotEntry[]>
|
||||||
|
- function clearProviderSnapshotCache: () => void
|
||||||
|
- function peekSnapshotEntry: (name, cwd?) => ProviderSnapshotEntry | undefined
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/coder/src/services/pty-dispatch.ts`
|
||||||
|
- function dispatchViaPty: (opts) => Promise<DispatchResult>
|
||||||
|
- interface DispatchResult
|
||||||
|
- interface PtyDispatchOpts
|
||||||
|
- `apps/coder/src/services/qwen-settings.ts` — function readQwenSettingsModels: () => Promise<ProviderModel[]>
|
||||||
|
- `apps/coder/src/services/stream-json-parser.ts`
|
||||||
|
- function makeStreamJsonState: () => StreamJsonState
|
||||||
|
- function parseStreamJsonLine: (line, state) => AgentEvent[]
|
||||||
|
- function makeStreamJsonParser: () => StreamJsonParser
|
||||||
|
- interface StreamJsonUsage
|
||||||
|
- interface StreamJsonState
|
||||||
|
- interface StreamJsonParser
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/coder/src/services/token-analysis/analyzer.ts` — function analyzeMessages: (parts) => TokenBreakdown, interface TokenBreakdown
|
||||||
|
- `apps/coder/src/services/token-analysis/persist.ts`
|
||||||
|
- function persistTaskBreakdown: (sql, taskId, breakdown) => Promise<void>
|
||||||
|
- function getTaskBreakdown: (sql, taskId) => Promise<TokenBreakdown | null>
|
||||||
|
- function analyzeAndPersistTaskBreakdown: (sql, taskId, parts) => Promise<TokenBreakdown>
|
||||||
|
- `apps/coder/src/services/tools/adapter.ts` — function adaptWriteTool: (tool) => ServerToolDef<any>
|
||||||
|
- `apps/coder/src/services/tools/inference_context.ts`
|
||||||
|
- function runWithInferenceContext: (ctx, fn) => void
|
||||||
|
- function getInferenceContext: () => InferenceContext
|
||||||
|
- interface InferenceContext
|
||||||
|
- `apps/coder/src/services/tools/types.ts`
|
||||||
|
- function asPermissionMode: (id) => PermissionMode | undefined
|
||||||
|
- interface ToolJsonSchema
|
||||||
|
- interface ToolContext
|
||||||
|
- interface ToolDef
|
||||||
|
- type PermissionMode
|
||||||
|
- `apps/coder/src/services/tools/write-gate.ts` — function denyReadOnly: (operation) => unknown, function finalizeWrite: (context, projectRoot, change, queuedHint) => Promise<unknown>
|
||||||
|
- `apps/coder/src/services/worktree-risk.ts` — function checkWorktreeWorkAtRisk: (worktreePath, opts?) => Promise<WorktreeRiskReport>, function stashWorktree: (worktreePath, opts?) => Promise<
|
||||||
|
- `apps/coder/src/services/worktrees.ts`
|
||||||
|
- function createWorktree: (projectPath, taskId, opts?) => Promise<string>
|
||||||
|
- function diffWorktree: (worktreePath, projectPath, opts?) => Promise<string>
|
||||||
|
- function cleanupWorktree: (projectPath, taskId) => Promise<void>
|
||||||
|
- function ensureSessionWorktree: (sql, projectPath, sessionId, opts?) => Promise<SessionWorktree>
|
||||||
|
- function removeSessionWorktree: (sql, projectPath, worktree, opts?) => Promise<void>
|
||||||
|
- function closeChatBackendState: (sql, chatId, opts?) => Promise<ChatCloseResult>
|
||||||
|
- _...4 more_
|
||||||
|
- `apps/coder/src/services/write_guard.ts`
|
||||||
|
- function isSecretPath: (filePath) => boolean
|
||||||
|
- function resolveWritePath: (projectRoot, filePath) => string
|
||||||
|
- class WriteGuardError
|
||||||
|
- `apps/server/src/config.ts` — function loadConfig: () => Config, type Config
|
||||||
|
- `apps/server/src/db.ts`
|
||||||
|
- function getSql: (config) => Sql
|
||||||
|
- function applySchema: (sql) => Promise<void>
|
||||||
|
- function pingDb: (sql) => Promise<boolean>
|
||||||
|
- function closeDb: () => Promise<void>
|
||||||
|
- type Sql
|
||||||
|
- `apps/server/src/services/agents.ts`
|
||||||
|
- function refreshToolNames: () => void
|
||||||
|
- function matchToolGlob: (toolName, patterns) => boolean
|
||||||
|
- function slugify: (name) => string
|
||||||
|
- function parseAgentsMd: (content) => ParseResult
|
||||||
|
- function isAgentRegistryMarkdown: (content) => boolean
|
||||||
|
- function getAgentsMtimes: (projectPath) => void
|
||||||
|
- _...2 more_
|
||||||
|
- `apps/server/src/services/artifacts.ts`
|
||||||
|
- function deriveMarkdownSlug: (messageContent) => string
|
||||||
|
- function deriveHtmlSlug: (payload) => string
|
||||||
|
- function deriveHtmlTitle: (html) => string | null
|
||||||
|
- function detectHtmlArtifact: (text) => string | null
|
||||||
|
- function decideHtmlArtifactWrite: (htmlContent) => HtmlArtifactDecision
|
||||||
|
- function writeMarkdownArtifact: (message, 'content'>, ctx) => Promise<ArtifactWriteResult>
|
||||||
|
- _...6 more_
|
||||||
|
- `apps/server/src/services/audit/corrections.ts`
|
||||||
|
- function createCorrection: (params) => UserCorrectionRecord
|
||||||
|
- function findCorrections: (records, unknown>[]) => UserCorrectionRecord[]
|
||||||
|
- function checkCorrectionConflict: (proposedAction, corrections) => UserCorrectionRecord | null
|
||||||
|
- interface UserCorrectionRecord
|
||||||
|
- `apps/server/src/services/audit/guideline-store.ts`
|
||||||
|
- class GuidelineDocumentStore
|
||||||
|
- interface GuidelineContent
|
||||||
|
- interface Guideline
|
||||||
|
- interface GuidelineDocument
|
||||||
|
- interface GuidelineUpdateParams
|
||||||
|
- type GuidelineId
|
||||||
|
- _...3 more_
|
||||||
|
- `apps/server/src/services/audit/journey-projection.ts`
|
||||||
|
- function projectJourneyToGuidelines: (journey, nodes, edges) => ProjectedGuideline[]
|
||||||
|
- function detectJourneyBacktrack: (journey, nodes, edges, currentNodeId, previousNodeId) => BacktrackCheck
|
||||||
|
- interface ProjectedGuideline
|
||||||
|
- interface BacktrackCheck
|
||||||
|
- `apps/server/src/services/audit/journey-store.ts`
|
||||||
|
- class JourneyStore
|
||||||
|
- interface JourneyNode
|
||||||
|
- interface JourneyEdge
|
||||||
|
- interface Journey
|
||||||
|
- type JourneyId
|
||||||
|
- type JourneyNodeId
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/server/src/services/audit/runs-dir.ts`
|
||||||
|
- function findRunsDir: (projectRoot?) => string
|
||||||
|
- function ensureRunsDir: (projectRoot?) => string
|
||||||
|
- function readCurrentSession: (projectRoot?) => string | null
|
||||||
|
- function writeCurrentSession: (sessionId, projectRoot?) => void
|
||||||
|
- function clearCurrentSession: (projectRoot?) => void
|
||||||
|
- function readIndex: (projectRoot?) => IndexFile
|
||||||
|
- _...7 more_
|
||||||
|
- `apps/server/src/services/audit/session-manager.ts`
|
||||||
|
- function generateSessionId: () => string
|
||||||
|
- function isoNow: () => string
|
||||||
|
- function createSession: (task, sessionId?, projectRoot?) => string
|
||||||
|
- function getSessionDir: (sessionId, projectRoot?) => string
|
||||||
|
- function getActiveSession: (projectRoot?) => SessionJson | null
|
||||||
|
- function readSession: (sessionId, projectRoot?) => SessionJson | null
|
||||||
|
- _...9 more_
|
||||||
|
- `apps/server/src/services/auto_name.ts` — function maybeAutoNameChat: (ctx, chatId, sessionId) => Promise<void>
|
||||||
|
- `apps/server/src/services/broker.ts`
|
||||||
|
- function createBroker: (log?) => Broker
|
||||||
|
- interface Broker
|
||||||
|
- type Frame
|
||||||
|
- type Listener
|
||||||
|
- `apps/server/src/services/codecontext_client.ts`
|
||||||
|
- function callCodecontext: (req, fetcher) => Promise<CodecontextResponse>
|
||||||
|
- interface CodecontextRequest
|
||||||
|
- interface CodecontextResponse
|
||||||
|
- `apps/server/src/services/coder-notify.ts` — function notifyCoderClose: (kind, id, log?, 'debug'>, fetcher) => Promise<boolean>, type CoderCloseKind
|
||||||
|
- `apps/server/src/services/compaction.ts`
|
||||||
|
- function usable: (contextLimit) => number
|
||||||
|
- function isOverflow: (usage, contextLimit) => boolean
|
||||||
|
- function estimate: (messages) => number
|
||||||
|
- function turns: (messages) => Turn[]
|
||||||
|
- function select: (messages, contextLimit, tailTurns) => SelectResult
|
||||||
|
- function deriveFilesRead: (head) => string[]
|
||||||
|
- _...8 more_
|
||||||
|
- `apps/server/src/services/file_index.ts` — function getProjectFiles: (projectId, projectRoot) => Promise<string[]>
|
||||||
|
- `apps/server/src/services/file_ops.ts`
|
||||||
|
- function listDir: (projectRoot, relPath, opts?) => Promise<ListDirResult>
|
||||||
|
- function viewFile: (projectRoot, relPath, opts?) => Promise<ViewFileResult>
|
||||||
|
- function grep: (projectRoot, pattern, opts?) => Promise<GrepResult>
|
||||||
|
- function findFiles: (projectRoot, pattern?, opts?) => Promise<FindFilesResult>
|
||||||
|
- interface FileEntry
|
||||||
|
- interface ListDirResult
|
||||||
|
- _...4 more_
|
||||||
|
- `apps/server/src/services/git_diff.ts`
|
||||||
|
- function parseNameStatus: (output) => void
|
||||||
|
- function parseNumStatLine: (line) => void
|
||||||
|
- function splitDiffByFile: (diffText) => Map<string, string>
|
||||||
|
- function classifyDiffBody: (body, cap) => 'diff' | 'binary' | 'too_large'
|
||||||
|
- function autoSelectMode: (isDirty) => GitDiffMode
|
||||||
|
- function canCommit: (files) => boolean
|
||||||
|
- _...17 more_
|
||||||
|
- `apps/server/src/services/git_meta.ts` — function getGitMeta: (rootPath) => Promise<GitMeta | null>, interface GitMeta
|
||||||
|
- `apps/server/src/services/gitea.ts`
|
||||||
|
- function createGiteaRepo: (cfg, name, options) => Promise<GiteaRepo>
|
||||||
|
- class GiteaRepoExistsError
|
||||||
|
- interface GiteaConfig
|
||||||
|
- interface GiteaRepo
|
||||||
|
- `apps/server/src/services/grant_resolver.ts` — function resolveGrantRoot: (sql, requestedPath, projectRoot, whitelistRoot) => Promise<GrantResolution>, type GrantResolution
|
||||||
|
- `apps/server/src/services/inference/budget.ts` — function resolveToolBudget: (agent) => number
|
||||||
|
- `apps/server/src/services/inference/content-flusher.ts` — function createContentFlusher: (sql, messageId, getContent) => void, interface ContentFlusher
|
||||||
|
- `apps/server/src/services/inference/dcp/messages.ts`
|
||||||
|
- function toDcpMessages: (parts) => DcpMessage[]
|
||||||
|
- function fromDcpMessages: (msgs) => any[]
|
||||||
|
- interface DcpMessage
|
||||||
|
- `apps/server/src/services/inference/dcp/state.ts`
|
||||||
|
- function getDcpState: (chatId) => ChatDcpState | undefined
|
||||||
|
- function setDcpState: (chatId, messageCount) => void
|
||||||
|
- function clearDcpState: (chatId) => void
|
||||||
|
- function shouldTransform: (chatId, messageCount) => boolean
|
||||||
|
- `apps/server/src/services/inference/dcp/strategies/deduplication.ts` — function deduplicate: (messages) => void
|
||||||
|
- `apps/server/src/services/inference/dcp/strategies/purge-errors.ts` — function purgeErrors: (messages, windowSize) => void
|
||||||
|
- `apps/server/src/services/inference/dcp/transform.ts`
|
||||||
|
- function transformMessages: (chatId, messages) => TransformResult
|
||||||
|
- interface TransformStats
|
||||||
|
- interface TransformResult
|
||||||
|
- `apps/server/src/services/inference/error-handler.ts`
|
||||||
|
- function handleAbortOrError: (ctx, args, accumulated, err) => Promise<void>
|
||||||
|
- function finalizeStreamedRow: (ctx, opts) => void
|
||||||
|
- function finalizeEmpty: (ctx, args) => Promise<void>
|
||||||
|
- function finalizeCompletion: (ctx, args, result, startedAt, session) => Promise<void>
|
||||||
|
- `apps/server/src/services/inference/llama-args-validator.ts`
|
||||||
|
- function validateExtraArgs: (args?) => string[]
|
||||||
|
- function isManagedFlag: (flag) => boolean
|
||||||
|
- function stripShadowingFlags: (args, opts?) => string[]
|
||||||
|
- interface StripOptions
|
||||||
|
- `apps/server/src/services/inference/loop-detectors.ts`
|
||||||
|
- function detectContentRepeat: (messages) => LoopDetectionResult
|
||||||
|
- function detectToolLoop: (toolNames) => LoopDetectionResult
|
||||||
|
- function detectDoomLoop: (messages, toolNames) => LoopDetectionResult
|
||||||
|
- interface LoopDetectionResult
|
||||||
|
- `apps/server/src/services/inference/mistake-tracker.ts`
|
||||||
|
- function freshMistakeState: () => MistakeState
|
||||||
|
- function recordStep: (state, outcome) => void
|
||||||
|
- function detectMistakePattern: (state) => 'nudge' | 'escalate' | null
|
||||||
|
- interface MistakeState
|
||||||
|
- type FailureKind
|
||||||
|
- const MISTAKE_THRESHOLD
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/server/src/services/inference/parts.ts`
|
||||||
|
- function insertParts: (sql, parts) => Promise<void>
|
||||||
|
- function partsFromAssistantMessage: (args) => void
|
||||||
|
- function partsFromToolMessage: (args) => Omit<PartInsert, 'message_id'>[]
|
||||||
|
- interface PartInsert
|
||||||
|
- type PartKind
|
||||||
|
- `apps/server/src/services/inference/payload.ts`
|
||||||
|
- function buildMessagesPayload: (session, project, history, agent, log?) => Promise<OpenAiMessage[]>
|
||||||
|
- function loadContext: (sql, sessionId, chatId) => Promise<
|
||||||
|
- function maybeFlagForCompaction: (ctx, chatId, updated) => Promise<void>
|
||||||
|
- interface OpenAiMessage
|
||||||
|
- `apps/server/src/services/inference/provider.ts`
|
||||||
|
- function resolveRoute: (agent, config?) => RoutingInfo
|
||||||
|
- function upstreamModel: (config, modelId, agent?) => LanguageModel
|
||||||
|
- interface RoutingInfo
|
||||||
|
- type InferenceRoute
|
||||||
|
- `apps/server/src/services/inference/prune.ts`
|
||||||
|
- function selectPruneTargets: (partsNewestFirst, tailStartCreatedAt) => void
|
||||||
|
- function prune: (args) => Promise<PruneResult>
|
||||||
|
- interface PruneResult
|
||||||
|
- interface PartForPrune
|
||||||
|
- const PROTECTED_TOKENS
|
||||||
|
- const PRUNE_TRIGGER_TOKENS
|
||||||
|
- `apps/server/src/services/inference/sentinel-summaries.ts`
|
||||||
|
- function runCapHitSummary: (ctx, args, session, project, history, agent, budget) => Promise<void>
|
||||||
|
- function runDoomLoopSummary: (ctx, args, session, project, history, agent, loop, unknown> }) => Promise<void>
|
||||||
|
- function runStepCapSummary: (ctx, args, session, project, history, agent, steps, cap) => Promise<void>
|
||||||
|
- function insertMistakeRecoverySentinel: (ctx, sessionId, chatId, opts) => Promise<void>
|
||||||
|
- `apps/server/src/services/inference/sentinels.ts`
|
||||||
|
- function detectDoomLoop: (recentToolCalls) => void
|
||||||
|
- function isCapHitSentinel: (m) => boolean
|
||||||
|
- function isDoomLoopSentinel: (m) => boolean
|
||||||
|
- function isMistakeRecoverySentinel: (m) => boolean
|
||||||
|
- function isAnySentinel: (m) => boolean
|
||||||
|
- const DOOM_LOOP_THRESHOLD
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/server/src/services/inference/step-decision.ts`
|
||||||
|
- function decideStep: (input) => PreStepDecision
|
||||||
|
- function decidePostToolAction: (action, mistakeTracker) => PostToolDecision
|
||||||
|
- type PreStepDecision
|
||||||
|
- type PostToolDecision
|
||||||
|
- `apps/server/src/services/inference/stream-error-classifier.ts` — function classifyStreamError: (err) => StreamErrorKind, type StreamErrorKind
|
||||||
|
- `apps/server/src/services/inference/stream-phase-adapter.ts`
|
||||||
|
- function samplerOptsFromAgent: (agent) => SamplerOpts
|
||||||
|
- function streamCompletion: (ctx, model, messages, opts, onDelta) => void
|
||||||
|
- interface StreamAdapterContext
|
||||||
|
- interface StreamOptions
|
||||||
|
- type SamplerOpts
|
||||||
|
- const STALL_TIMEOUT_MS
|
||||||
|
- `apps/server/src/services/inference/stream-phase.ts` — function executeStreamPhase: (ctx, args, session, messages, state, agent, // v1.11.8, web_search and web_fetch are stripped from the
|
||||||
|
// tool list sent to the LLM, so the model can't even attempt them.
|
||||||
|
webToolsEnabled) => Promise<StreamResult>
|
||||||
|
- `apps/server/src/services/inference/tool-call-parser.ts`
|
||||||
|
- function stripToolMarkup: (text, opts?) => string
|
||||||
|
- function extractToolCallBlocks: (buffer, log?) => ToolCallExtraction
|
||||||
|
- interface ParsedCall
|
||||||
|
- interface ToolCallExtraction
|
||||||
|
- `apps/server/src/services/inference/tool-phase.ts` — function executeToolPhase: (ctx, args, result, startedAt, session, projectRoot, agent?) => Promise<ToolPhaseResult>, interface ToolPhaseResult
|
||||||
|
- `apps/server/src/services/inference/tool-shim.ts`
|
||||||
|
- function extractToolCalls: (text) => ParsedToolCall[]
|
||||||
|
- function hasToolCallMarkup: (text) => boolean
|
||||||
|
- interface ParsedToolCall
|
||||||
|
- `apps/server/src/services/inference/tool-suggestions.ts`
|
||||||
|
- function levenshtein: (a, b) => number
|
||||||
|
- function suggestToolName: (name, available) => string | null
|
||||||
|
- function formatUnknownToolError: (name, available) => string
|
||||||
|
- `apps/server/src/services/inference/turn-config.ts`
|
||||||
|
- function resolveTurnConfig: (agent) => TurnConfig
|
||||||
|
- interface TurnConfig
|
||||||
|
- const MAX_STEPS
|
||||||
|
- `apps/server/src/services/inference/turn.ts`
|
||||||
|
- function runAssistantTurn: (ctx, args) => Promise<void>
|
||||||
|
- function runInference: (ctx, sessionId, chatId, assistantMessageId, signal?) => Promise<void>
|
||||||
|
- function createInferenceRunner: (ctx, 'publishUser'>, publishUserFn, frame) => void
|
||||||
|
- `apps/server/src/services/mcp-client.ts`
|
||||||
|
- function initialize: (entries, logger) => Promise<void>
|
||||||
|
- function callTool: (prefixedName, args, unknown>) => Promise<unknown>
|
||||||
|
- function getTools: () => ToolDef<Record<string, unknown>>[]
|
||||||
|
- function getMcpServers: () => Array<
|
||||||
|
- function shutdown: () => Promise<void>
|
||||||
|
- function wrapMcpTool: (serverName, mcpTool) => ToolDef<Record<string, unknown>>
|
||||||
|
- _...2 more_
|
||||||
|
- `apps/server/src/services/mcp-config.ts`
|
||||||
|
- function substituteEnvVars: (value, log, unsetVars?) => unknown
|
||||||
|
- function loadMcpConfig: (configPath, log) => McpServerEntry[]
|
||||||
|
- interface McpServerEntry
|
||||||
|
- type McpServerConfig
|
||||||
|
- `apps/server/src/services/memory/entries.ts` — function parseMemoryEntries: (fileName, markdown) => MemoryEntry[], interface MemoryEntry
|
||||||
|
- `apps/server/src/services/memory/paths.ts`
|
||||||
|
- function getMemoryRoot: (projectRoot) => string
|
||||||
|
- function getTopicDir: (root, topic) => string
|
||||||
|
- function ensureMemoryScaffold: (root) => Promise<void>
|
||||||
|
- type MemoryTopic
|
||||||
|
- `apps/server/src/services/memory/prompt.ts` — function formatMemoryBlock: (entries) => string
|
||||||
|
- `apps/server/src/services/memory/recall.ts` — function rankByRelevance: (query, entries) => MemoryEntry[], function loadMemoryForSession: (projectRoot, _sessionId?, query?) => Promise<string[]>
|
||||||
|
- `apps/server/src/services/memory/scan.ts`
|
||||||
|
- function scanMemoryScopes: (scope) => Promise<MemoryEntry[]>
|
||||||
|
- function scanProjectMemory: (projectRoot) => Promise<MemoryEntry[]>
|
||||||
|
- interface MemoryScope
|
||||||
|
- `apps/server/src/services/memory/store.ts` — function readTopicFiles: (root, topic) => Promise<Map<string, string>>, function writeEntry: (root, topic, title, content, tags) => Promise<void>
|
||||||
|
- `apps/server/src/services/model-context.ts`
|
||||||
|
- function configureModelContext: (opts) => void
|
||||||
|
- function getModelContext: (model) => Promise<ModelContext | null>
|
||||||
|
- function invalidateModelContext: (model?) => void
|
||||||
|
- interface ModelContext
|
||||||
|
- `apps/server/src/services/path_guard.ts`
|
||||||
|
- function resolveProjectRoot: (projectPath) => Promise<string>
|
||||||
|
- function pathGuard: (projectRoot, requested, extraRoots) => Promise<string>
|
||||||
|
- class PathScopeError
|
||||||
|
- `apps/server/src/services/project_bootstrap.ts`
|
||||||
|
- function sanitizeFolderName: (raw) => string
|
||||||
|
- function bootstrapProject: (config, log, options) => Promise<BootstrapResult>
|
||||||
|
- class BootstrapNameError
|
||||||
|
- class BootstrapCollisionError
|
||||||
|
- class BootstrapPathError
|
||||||
|
- interface BootstrapResult
|
||||||
|
- `apps/server/src/services/read_tab_by_number.ts`
|
||||||
|
- function executeReadTabByNumber: (input, sql, sessionId) => Promise<string>
|
||||||
|
- type ReadTabByNumberInputT
|
||||||
|
- const readTabByNumber: ToolDef<ReadTabByNumberInputT>
|
||||||
|
- `apps/server/src/services/secret_guard.ts`
|
||||||
|
- function isSecretPath: (relPath) => boolean
|
||||||
|
- function filterSecretEntries: (entries, pathOf) => void
|
||||||
|
- class SecretBlockedError
|
||||||
|
- const DEFAULT_SECURITY_IGNORE_FILETYPES: ReadonlyArray<string>
|
||||||
|
- `apps/server/src/services/skill-invoke.ts`
|
||||||
|
- function runSkillInvokeTransaction: (sql, args) => Promise<
|
||||||
|
- function buildSkillInvokeSyntheticFrames: (chatId, result, toolCall, skillBody) => SkillInvokeSessionFrame[]
|
||||||
|
- function buildSkillInvokeUserFrames: (chatId, userMessageId, userText) => SkillInvokeSessionFrame[]
|
||||||
|
- interface SkillInvokeTransactionResult
|
||||||
|
- interface SkillInvokeToolCall
|
||||||
|
- type SkillInvokeSessionFrame
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/server/src/services/skills.ts`
|
||||||
|
- function listSkills: () => Promise<Skill[]>
|
||||||
|
- function findSkills: (query) => Promise<SkillSummary[]>
|
||||||
|
- function getSkillBody: (name) => Promise<string | null>
|
||||||
|
- function getSkillResource: (name, relativePath) => Promise<SkillResourceResult>
|
||||||
|
- interface Skill
|
||||||
|
- interface SkillSummary
|
||||||
|
- _...2 more_
|
||||||
|
- `apps/server/src/services/synthesisPipeline.ts`
|
||||||
|
- function runSynthesisPass: (p) => Promise<boolean>
|
||||||
|
- interface SynthesisParams
|
||||||
|
- const SYNTHESIS_TOOLS: ReadonlySet<string>
|
||||||
|
- `apps/server/src/services/system-prompt.ts`
|
||||||
|
- function loadContainerGuidance: () => Promise<string | null>
|
||||||
|
- function getContainerGuidance: () => Promise<string | null>
|
||||||
|
- function _resetContainerGuidanceCacheForTests: () => void
|
||||||
|
- function _resetPrefixObserverForTests: () => void
|
||||||
|
- function buildSystemPromptWithFingerprint: (project, session, agent) => Promise<
|
||||||
|
- function buildSystemPrompt: (project, session, agent) => Promise<string>
|
||||||
|
- _...2 more_
|
||||||
|
- `apps/server/src/services/task-model.ts` — function taskModelCompletion: (opts) => Promise<string>
|
||||||
|
- `apps/server/src/services/task-search-rewrite.ts` — function rewriteSearchQuery: (userMessage) => Promise<string>
|
||||||
|
- `apps/server/src/services/tools/codecontext/factory.ts` — function makeCodecontextTool: (opts, unknown>;
|
||||||
|
mapArgs) => void
|
||||||
|
- `apps/server/src/services/tools/registry.ts` — function appendMcpTools: (mcpTools) => void, function toolJsonSchemas: () => ToolJsonSchema[]
|
||||||
|
- `apps/server/src/services/tools/tiers.ts`
|
||||||
|
- function resolveToolTier: (tier) => readonly string[]
|
||||||
|
- const CORE_TOOL_NAMES
|
||||||
|
- const STANDARD_TOOL_NAMES
|
||||||
|
- `apps/server/src/services/truncate.ts`
|
||||||
|
- function storeTruncation: (fullContent) => Promise<string>
|
||||||
|
- function readTruncation: (id) => Promise<string | null>
|
||||||
|
- function truncateIfNeeded: (args) => Promise<
|
||||||
|
- function cleanupTruncations: (args, msg) => void
|
||||||
|
- const TRUNCATION_DIR
|
||||||
|
- const TRUNCATION_TTL_MS
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/server/src/services/url_guard.ts` — function isPublicUrl: (input) => UrlGuardResult, interface UrlGuardResult
|
||||||
|
- `apps/server/src/services/web/html-to-md.ts` — function htmlToMarkdown: (sourceHtml) => string
|
||||||
|
- `apps/server/src/services/web_fetch.ts`
|
||||||
|
- function executeWebFetch: (input, fetcher) => Promise<WebFetchOutput>
|
||||||
|
- type WebFetchInputT
|
||||||
|
- type WebFetchOutput
|
||||||
|
- const webFetch: ToolDef<WebFetchInputT>
|
||||||
|
- `apps/server/src/services/web_search.ts`
|
||||||
|
- function executeWebSearch: (input, searxngUrl, fetcher) => Promise<WebSearchOutput>
|
||||||
|
- interface WebSearchOutput
|
||||||
|
- type WebSearchInputT
|
||||||
|
- const webSearch: ToolDef<WebSearchInputT>
|
||||||
|
- `apps/server/src/utils/string-utils.ts` — function stripQuotes: (s) => string
|
||||||
|
- `apps/web/src/api/client.ts`
|
||||||
|
- class ApiError
|
||||||
|
- interface AgentSessionInfo
|
||||||
|
- interface CoderCheckpoint
|
||||||
|
- interface CoderRestoreResult
|
||||||
|
- const api
|
||||||
|
- `apps/web/src/data/acp-provider-catalog.ts`
|
||||||
|
- function buildAcpProviderConfigPatch: (entry) => ProviderConfigPatch
|
||||||
|
- interface AcpCatalogEntry
|
||||||
|
- const ACP_PROVIDER_CATALOG: AcpCatalogEntry[]
|
||||||
|
- `apps/web/src/hooks/terminal/useTerminalFit.ts`
|
||||||
|
- function cellSize: (term, container) => void
|
||||||
|
- function useTerminalFit: ({...}, containerRef, sessionId, paneId }) => TerminalFit
|
||||||
|
- interface TerminalFit
|
||||||
|
- `apps/web/src/hooks/terminal/useTerminalSelection.ts`
|
||||||
|
- function useTerminalSelection: ({...}, containerRef, sessionId, paneId, label, send, }) => TerminalSelection
|
||||||
|
- interface TerminalSelectionActions
|
||||||
|
- interface TerminalSelection
|
||||||
|
- `apps/web/src/hooks/terminal/useTerminalSocket.ts`
|
||||||
|
- function useTerminalSocket: ({...}, sessionId, paneId, fit, getSize, setSize, }) => TerminalSocket
|
||||||
|
- interface TerminalSocket
|
||||||
|
- type ConnState
|
||||||
|
- `apps/web/src/hooks/useActivePane.ts`
|
||||||
|
- function setActivePaneInfo: (next) => void
|
||||||
|
- function clearActivePane: () => void
|
||||||
|
- function useActivePane: () => ActivePaneSnapshot
|
||||||
|
- interface ActivePaneSnapshot
|
||||||
|
- `apps/web/src/hooks/useAgentSessions.ts` — function refreshAgentSessions: (sessionId) => Promise<AgentSessionInfo[]>, function useAgentSessions: (sessionId) => void
|
||||||
|
- `apps/web/src/hooks/useAgentStatus.ts`
|
||||||
|
- function useAgentStatus: () => void
|
||||||
|
- interface AgentStatusEntry
|
||||||
|
- type AgentStatus
|
||||||
|
- `apps/web/src/hooks/useArtifactDownload.ts` — function useArtifactDownload: (chatId, messageId, format) => void
|
||||||
|
- `apps/web/src/hooks/useChatStatus.ts`
|
||||||
|
- function useChatStatus: (chatId) => DerivedStatus
|
||||||
|
- type RawStatus
|
||||||
|
- type DerivedStatus
|
||||||
|
- `apps/web/src/hooks/useChatThroughput.ts`
|
||||||
|
- function recordUsage: (chatId, data) => void
|
||||||
|
- function useChatThroughput: (chatId) => ThroughputSample | null
|
||||||
|
- interface ThroughputSample
|
||||||
|
- `apps/web/src/hooks/useCoderUserEvents.ts` — function useCoderUserEvents: () => void
|
||||||
|
- `apps/web/src/hooks/useDiffPreferences.ts` — function useDiffPreferences: () => void, interface DiffPreferences
|
||||||
|
- `apps/web/src/hooks/useGitDiff.ts` — function useGitDiff: (projectId) => void
|
||||||
|
- `apps/web/src/hooks/useLongPress.ts` — function useLongPress: (callback) => void
|
||||||
|
- `apps/web/src/hooks/useProjectGit.ts` — function useProjectGit: (projectId) => GitMeta | null
|
||||||
|
- `apps/web/src/hooks/useProviderSnapshot.ts` — function refreshProviderSnapshot: (cwd?) => Promise<ProviderSnapshotEntry[]>, function useProviderSnapshot: (cwd?) => ProviderSnapshotEntry[] | null
|
||||||
|
- `apps/web/src/hooks/usePullToRefresh.ts` — function usePullToRefresh: (onRefresh) => void
|
||||||
|
- `apps/web/src/hooks/useSessionChats.ts`
|
||||||
|
- function useSessionChats: (sessionId, opts) => UseSessionChatsResult
|
||||||
|
- interface UseSessionChatsOpts
|
||||||
|
- interface UseSessionChatsResult
|
||||||
|
- `apps/web/src/hooks/useSessionStream.ts` — function useSessionStream: (sessionId) => void
|
||||||
|
- `apps/web/src/hooks/useSessions.ts` — function useSessions: (projectId) => void
|
||||||
|
- `apps/web/src/hooks/useSidebar.ts` — function useSidebar: () => void
|
||||||
|
- `apps/web/src/hooks/useSkills.ts` — function useSkills: () => void
|
||||||
|
- `apps/web/src/hooks/useUserEvents.ts` — function useUserEvents: () => void
|
||||||
|
- `apps/web/src/hooks/useViewport.ts` — function useViewport: () => ViewportSnapshot, interface ViewportSnapshot
|
||||||
|
- `apps/web/src/hooks/useWorkspacePanes.ts`
|
||||||
|
- function activePaneChatId: (pane) => string | undefined
|
||||||
|
- function useWorkspacePanes: (sessionId) => UseWorkspacePanesResult
|
||||||
|
- interface UseWorkspacePanesResult
|
||||||
|
- const MAX_PANES
|
||||||
|
- `apps/web/src/hooks/wsReconnectToast.ts` — function createWsReconnectToast: (opts) => WsReconnectToast, interface WsReconnectToast
|
||||||
|
- `apps/web/src/lib/anim.ts`
|
||||||
|
- function getAnimBg: () => boolean
|
||||||
|
- function setAnimBg: (on) => void
|
||||||
|
- function setAnimDensity: (v) => void
|
||||||
|
- function setAnimSpeed: (v) => void
|
||||||
|
- function setAnimOpacity: (v) => void
|
||||||
|
- function useAnimBg: () => boolean
|
||||||
|
- _...3 more_
|
||||||
|
- `apps/web/src/lib/attachments.ts`
|
||||||
|
- function looksBinary: (content) => boolean
|
||||||
|
- function inferLanguage: (filename) => string | null
|
||||||
|
- function flattenToMessage: (attachments, text) => string
|
||||||
|
- type Attachment
|
||||||
|
- const MAX_FILE_SIZE_BYTES
|
||||||
|
- const PASTE_INLINE_MAX_LINES
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/web/src/lib/coder-session.ts` — function isCoderSessionName: (name) => boolean
|
||||||
|
- `apps/web/src/lib/coder-tools.ts`
|
||||||
|
- function wireToolCallToRun: (wire) => ToolRun
|
||||||
|
- function mergeWireToolCall: (existing, incoming, unknown> }) => CoderToolCallWire[]
|
||||||
|
- interface AcpWireMeta
|
||||||
|
- interface CoderToolCallWire
|
||||||
|
- `apps/web/src/lib/format.ts`
|
||||||
|
- function relTime: (iso) => string
|
||||||
|
- function formatRelative: (iso) => string
|
||||||
|
- function formatAgo: (iso) => string
|
||||||
|
- `apps/web/src/lib/model-label.ts` — function formatModelLabel: (raw) => string
|
||||||
|
- `apps/web/src/lib/modelName.ts` — function shortenModelName: (model) => string | null
|
||||||
|
- `apps/web/src/lib/permission-mode.ts`
|
||||||
|
- function nativeModeForPermission: (mode, modes, defaultModeId) => string | null
|
||||||
|
- function permissionForModeId: (modeId, modes) => PermissionMode
|
||||||
|
- function availablePermissionModes: (modes) => Array<
|
||||||
|
- type PermissionMode
|
||||||
|
- const PERMISSION_LABELS: Record<PermissionMode, string>
|
||||||
|
- `apps/web/src/lib/projectUrls.ts` — function giteaUrlFor: (project) => string
|
||||||
|
- `apps/web/src/lib/slash-command.ts`
|
||||||
|
- function isSlashCommandToken: (value) => boolean
|
||||||
|
- function slashQuery: (value) => string
|
||||||
|
- function parseSlashInput: (text) => void
|
||||||
|
- function mergeCommandsByName: (...lists) => T[]
|
||||||
|
- interface SlashCommandItem
|
||||||
|
- `apps/web/src/lib/terminal-protocol.ts`
|
||||||
|
- function encodeInput: (text) => Uint8Array
|
||||||
|
- function encodeResize: (cols, rows) => string
|
||||||
|
- function parseServerFrame: (data) => ServerControlFrame | null
|
||||||
|
- type ServerControlFrame
|
||||||
|
- `apps/web/src/lib/theme.ts`
|
||||||
|
- function isThemeId: (s) => s is ThemeId
|
||||||
|
- function applyTheme: (id, mode) => void
|
||||||
|
- function setTheme: (id, mode) => Promise<void>
|
||||||
|
- function useTheme: () => ThemeState
|
||||||
|
- interface ThemeMeta
|
||||||
|
- type ThemeId
|
||||||
|
- _...5 more_
|
||||||
|
- `apps/web/src/lib/utils.ts` — function cn: (...inputs) => void
|
||||||
|
- `apps/web/src/utils/diff-layout.ts`
|
||||||
|
- function parseDiff: (diffBody) => ParsedDiffFile[]
|
||||||
|
- function buildSplitRows: (file) => SplitRow[]
|
||||||
|
- function reconstructNewContent: (hunks) => string
|
||||||
|
- interface DiffLine
|
||||||
|
- interface DiffHunk
|
||||||
|
- interface ParsedDiffFile
|
||||||
|
- _...3 more_
|
||||||
|
- `conductor/src/contracts.ts`
|
||||||
|
- function produceContract: (contracts) => string
|
||||||
|
- function reviewContract: (contracts) => string
|
||||||
|
- type Contract
|
||||||
|
- const EVIDENCE_PRODUCE
|
||||||
|
- const EVIDENCE_REVIEW
|
||||||
|
- const YAGNI_PRODUCE
|
||||||
|
- _...1 more_
|
||||||
|
- `conductor/src/dispatch.ts`
|
||||||
|
- function loadPersona: (agent) => Promise<string>
|
||||||
|
- function dispatchAgent: (agent, task, opts) => Promise<string>
|
||||||
|
- function cleanOutput: (raw) => string
|
||||||
|
- `conductor/src/flow.ts` — function runFlow: (flow, input, opts) => Promise<RunResult>, interface RunOptions
|
||||||
|
- `conductor/src/flows/_util.ts` — function q, function repoLine
|
||||||
|
- `conductor/src/flows/index.ts`
|
||||||
|
- function describeFlows: () => string
|
||||||
|
- function getFlow: (name) => Flow | undefined
|
||||||
|
- const FLOWS: Record<string, Flow>
|
||||||
|
- const FLOW_NAMES: string[]
|
||||||
|
- `conductor/src/render.ts` — function slugify: (s) => string
|
||||||
|
- `conductor/src/spine.ts`
|
||||||
|
- function readBand: (input) => Band
|
||||||
|
- function fastNote: (ctx) => string
|
||||||
|
- function buildSpineFlow: (spine) => Flow
|
||||||
|
- `data/skills/superpowers/systematic-debugging/condition-based-waiting-example.ts`
|
||||||
|
- function waitForEvent: (threadManager, threadId, eventType, timeoutMs) => Promise<LaceEvent>
|
||||||
|
- function waitForEventCount: (threadManager, threadId, eventType, count, timeoutMs) => Promise<LaceEvent[]>
|
||||||
|
- function waitForEventMatch: (threadManager, threadId, predicate) => void
|
||||||
|
- `packages/ion/src/cli/commands/abandon.ts` — function abandonCommand: (args, options) => Promise<void>
|
||||||
|
- `packages/ion/src/cli/commands/approve.ts` — function approveCommand: (args, options) => Promise<void>
|
||||||
|
- `packages/ion/src/cli/commands/cleanup.ts` — function cleanupCommand: (args, options) => Promise<void>
|
||||||
|
- `packages/ion/src/cli/commands/convert.ts` — function convertCommand: (args, options) => Promise<void>
|
||||||
|
- `packages/ion/src/cli/commands/list.ts` — function listCommand: (_args, options) => Promise<void>
|
||||||
|
- `packages/ion/src/cli/commands/reject.ts` — function rejectCommand: (args, options) => Promise<void>
|
||||||
|
- `packages/ion/src/cli/commands/resume.ts` — function resumeCommand: (args, options) => Promise<void>
|
||||||
|
- `packages/ion/src/cli/commands/run.ts` — function runCommand: (args, options) => Promise<void>
|
||||||
|
- `packages/ion/src/cli/commands/runs.ts` — function runsCommand: (args, options) => Promise<void>
|
||||||
|
- `packages/ion/src/cli/commands/status.ts` — function statusCommand: (_args, options) => Promise<void>
|
||||||
|
- `packages/ion/src/cli/commands/validate.ts` — function validateCommand: (args, options) => Promise<void>
|
||||||
|
- `packages/ion/src/cli/index.ts` — function main: (argv) => void
|
||||||
|
- `packages/ion/src/cli/utils.ts`
|
||||||
|
- function formatDuration: (ms) => string
|
||||||
|
- function formatTimestamp: (date) => string
|
||||||
|
- function truncate: (str, max) => string
|
||||||
|
- function printTable: (rows, unknown>[], columns) => void
|
||||||
|
- function printJson: (data) => void
|
||||||
|
- function parseArgs: (argv) => void
|
||||||
|
- _...3 more_
|
||||||
|
- `packages/ion/src/engine/command-validation.ts` — function isValidCommandName: (name) => boolean
|
||||||
|
- `packages/ion/src/engine/condition-evaluator.ts` — function evaluateCondition: (expression, nodeOutputs, Record<string, unknown>>) => boolean, class ConditionError
|
||||||
|
- `packages/ion/src/engine/dag-executor.ts`
|
||||||
|
- function buildTopologicalLayers: (nodes) => DagNode[][]
|
||||||
|
- function checkTriggerRule: (node, nodeOutputs, NodeOutput>) => 'run' | 'skip'
|
||||||
|
- function executeNodeInternal: (node, deps, platform, conversationId, cwd, config, nodeOutputs, NodeOutput>, workflowVariables, unknown>) => Promise<NodeExecutionResult>
|
||||||
|
- function executeScriptNode: (node, cwd, envVars, string>, artifactsDir) => Promise<NodeExecutionResult>
|
||||||
|
- function handleApprovalNode: (node, deps, platform, conversationId, workflowRunId, nodeOutputs, NodeOutput>, workflowVariables, unknown>) => Promise<NodeExecutionResult>
|
||||||
|
- function handleLoopNode: (node, deps, platform, conversationId, cwd, config, nodeOutputs, NodeOutput>, workflowVariables, unknown>) => Promise<NodeExecutionResult>
|
||||||
|
- _...2 more_
|
||||||
|
- `packages/ion/src/engine/event-emitter.ts`
|
||||||
|
- function getWorkflowEventEmitter: () => WorkflowEventEmitter
|
||||||
|
- class WorkflowEventEmitter
|
||||||
|
- interface WorkflowEventBase
|
||||||
|
- interface WorkflowStartedEvent
|
||||||
|
- interface WorkflowCompletedEvent
|
||||||
|
- interface WorkflowFailedEvent
|
||||||
|
- _...11 more_
|
||||||
|
- `packages/ion/src/engine/executor-shared.ts`
|
||||||
|
- function substituteWorkflowVariables: (template, context) => string
|
||||||
|
- function buildPromptWithContext: (template, context, issueContext?) => string
|
||||||
|
- function classifyError: (error) => ErrorClassification
|
||||||
|
- function safeSendMessage: (platform, conversationId, message, metadata?, unknown>) => Promise<boolean>
|
||||||
|
- function detectCompletionSignal: (output, until) => boolean
|
||||||
|
- function stripCompletionTags: (output, until) => string
|
||||||
|
- _...5 more_
|
||||||
|
- `packages/ion/src/engine/executor.ts`
|
||||||
|
- function executeWorkflow: (deps, platform, conversationId, cwd, workflow, userMessage, opts) => Promise<WorkflowExecutionResult>
|
||||||
|
- function hydrateResumableRun: (deps, candidate) => Promise<HydratedResumableRun>
|
||||||
|
- function resolveProjectPaths: (_deps, cwd, workflowRunId, codebaseId?) => ProjectPaths
|
||||||
|
- interface WorkflowExecutionOptions
|
||||||
|
- interface WorkflowExecutionResult
|
||||||
|
- interface HydratedResumableRun
|
||||||
|
- _...1 more_
|
||||||
|
- `packages/ion/src/engine/model-validation.ts`
|
||||||
|
- function isLiteralSpec: (spec) => spec is LiteralModelSpec
|
||||||
|
- function buildAiProfile: (opts) => AiProfile
|
||||||
|
- function resolveModelSpec: (profile, modelRef) => LiteralModelSpec
|
||||||
|
- interface LiteralModelSpec
|
||||||
|
- interface ModelAliasPreset
|
||||||
|
- interface AiProfileTiers
|
||||||
|
- _...2 more_
|
||||||
|
- `packages/ion/src/engine/output-ref.ts`
|
||||||
|
- function declaredFieldsFromSchema: (outputFormat, unknown> | string | undefined) => Set<string>
|
||||||
|
- function resolveNodeOutputField: (nodeOutput, unknown>, nodeId, field, declaredFields?) => OutputRefResult
|
||||||
|
- class OutputRefError
|
||||||
|
- interface OutputRefResult
|
||||||
|
- type OutputRefKind
|
||||||
|
- `packages/ion/src/engine/utils.ts`
|
||||||
|
- function substituteWorkflowVariables: (template, variables, unknown>) => string
|
||||||
|
- function substituteNodeOutputRefs: (prompt, nodeOutputs, NodeOutput>, escapedForBash) => string
|
||||||
|
- function resolveNodeOutputField: (output, field) => string
|
||||||
|
- function buildPromptWithContext: (prompt, variables, unknown>, nodeOutputs, NodeOutput>, escapedForBash) => string
|
||||||
|
- function evaluateCondition: (condition, variables, unknown>) => boolean
|
||||||
|
- function classifyError: (error) => ErrorCategory
|
||||||
|
- _...10 more_
|
||||||
|
- `packages/ion/src/format/sop-discovery.ts` — function discoverSopFiles: (cwd, globFn) => Promise<string[]>, type GlobFn
|
||||||
|
- `packages/ion/src/format/sop-parser.ts`
|
||||||
|
- function parseSopContent: (markdown) => SopDocument
|
||||||
|
- interface SopParameter
|
||||||
|
- interface SopStep
|
||||||
|
- interface SopDocument
|
||||||
|
- `packages/ion/src/format/sop-to-yaml.ts` — function convertSopToWorkflowYaml: (sop) => string
|
||||||
|
- `packages/ion/src/schema/dag-node.ts`
|
||||||
|
- function isBashNode: (node) => node is BashNode
|
||||||
|
- function isScriptNode: (node) => node is ScriptNode
|
||||||
|
- function isLoopNode: (node) => node is LoopNode
|
||||||
|
- function isApprovalNode: (node) => node is ApprovalNode
|
||||||
|
- function isCancelNode: (node) => node is CancelNode
|
||||||
|
- function isPromptNode: (node) => node is PromptNode
|
||||||
|
- _...27 more_
|
||||||
|
- `packages/ion/src/store/fs-store.ts` — function createFsStore: (basePath) => IWorkflowStore
|
||||||
|
- `packages/ion/src/store/pg-store.ts` — function createPostgresStore: (connectionString) => Promise<IWorkflowStore>
|
||||||
|
- `packages/ion/src/store/sqlite-store.ts` — function createSqliteStore: (dbPath) => Promise<IWorkflowStore>
|
||||||
23
.codesight/middleware.md
Normal file
23
.codesight/middleware.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Middleware
|
||||||
|
|
||||||
|
## auth
|
||||||
|
- auth — `apps/booterm/src/auth.ts`
|
||||||
|
- authoring — `apps/coder/src/conductor/flows/authoring.ts`
|
||||||
|
- turn-guard.test — `apps/coder/src/services/backends/__tests__/turn-guard.test.ts`
|
||||||
|
- turn-guard — `apps/coder/src/services/backends/turn-guard.ts`
|
||||||
|
- get_middleware — `apps/server/src/services/tools/codecontext/get_middleware.ts`
|
||||||
|
- authoring — `conductor/src/flows/authoring.ts`
|
||||||
|
|
||||||
|
## custom
|
||||||
|
- write_guard.test — `apps/coder/src/services/__tests__/write_guard.test.ts`
|
||||||
|
- write_guard_fuzz.test — `apps/coder/src/services/__tests__/write_guard_fuzz.test.ts`
|
||||||
|
- edit-guards-imports — `apps/coder/src/services/edit-guards-imports.ts`
|
||||||
|
- write_guard — `apps/coder/src/services/write_guard.ts`
|
||||||
|
- secret_guard.test — `apps/server/src/services/__tests__/secret_guard.test.ts`
|
||||||
|
- path_guard — `apps/server/src/services/path_guard.ts`
|
||||||
|
- secret_guard — `apps/server/src/services/secret_guard.ts`
|
||||||
|
- url_guard — `apps/server/src/services/url_guard.ts`
|
||||||
|
|
||||||
|
## validation
|
||||||
|
- edit-guards — `apps/coder/src/services/edit-guards.ts`
|
||||||
|
- path_guard.test — `apps/server/src/services/__tests__/path_guard.test.ts`
|
||||||
141
.codesight/routes.md
Normal file
141
.codesight/routes.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Routes
|
||||||
|
|
||||||
|
## CRUD Resources
|
||||||
|
|
||||||
|
- **`/api/battles`** GET | POST | GET/:id → Battle
|
||||||
|
- **`/api/runs`** GET | POST | GET/:id → Run
|
||||||
|
- **`/api/tasks`** GET | POST | GET/:id → Task
|
||||||
|
- **`/api/chats/:id/messages`** GET | POST | GET/:id | DELETE/:id → Message
|
||||||
|
- **`/api/projects`** GET | POST | GET/:id | PATCH/:id | DELETE/:id → Project
|
||||||
|
- **`/api/sessions`** GET/:id | PATCH/:id | DELETE/:id → Session
|
||||||
|
|
||||||
|
## Other Routes
|
||||||
|
|
||||||
|
### fastify
|
||||||
|
|
||||||
|
- `GET` `/api/term/health` params()
|
||||||
|
- `POST` `/api/term/sessions/:sid/panes/:pid/start` params(sid, pid) [auth]
|
||||||
|
- `POST` `/api/term/sessions/:sid/panes/:pid/kill` params(sid, pid) [auth]
|
||||||
|
- `GET` `/ws/term/sessions/:sid/panes/:pid` params(sid, pid) [auth]
|
||||||
|
- `GET` `/api/health` params() [auth, db, queue, ai]
|
||||||
|
- `GET` `/api/sessions/:sessionId/agent-sessions` params(sessionId) [auth, db]
|
||||||
|
- `POST` `/api/battles/generate-prompt` params() [auth, db]
|
||||||
|
- `POST` `/api/battles/:id/stop` params(id) [auth, db]
|
||||||
|
- `GET` `/api/battles/:id/analysis` params(id) [auth, db]
|
||||||
|
- `POST` `/api/battles/:id/analyze` params(id) [auth, db]
|
||||||
|
- `PATCH` `/api/battles/:id/winner` params(id) [auth, db]
|
||||||
|
- `GET` `/api/battles/:id/contestants/:cid/diff` params(id, cid) [auth, db]
|
||||||
|
- `POST` `/api/battles/:id/cross-examine` params(id) [auth, db]
|
||||||
|
- `GET` `/api/sessions/:sessionId/checkpoints` params(sessionId) [auth, db]
|
||||||
|
- `POST` `/api/sessions/:sessionId/checkpoints/:checkpointId/restore` params(sessionId, checkpointId) [auth, db]
|
||||||
|
- `GET` `/api/inbox` params() [auth, db]
|
||||||
|
- `POST` `/api/inbox/:id/retry` params(id) [auth, db]
|
||||||
|
- `POST` `/api/chats/:chatId/close` params(chatId) [auth, db]
|
||||||
|
- `POST` `/api/sessions/:sessionId/close` params(sessionId) [auth, db]
|
||||||
|
- `GET` `/api/sessions/:sessionId/messages` params(sessionId) [auth, db, queue]
|
||||||
|
- `POST` `/api/sessions/:sessionId/messages` params(sessionId) [auth, db, queue]
|
||||||
|
- `POST` `/api/chats/:id/answer_user_input` params(id) [auth, db, queue]
|
||||||
|
- `POST` `/api/sessions/:sessionId/stop` params(sessionId) [auth, db, queue]
|
||||||
|
- `GET` `/api/sessions/:sessionId/pending` params(sessionId) [auth, db, queue]
|
||||||
|
- `POST` `/api/sessions/:sessionId/pending/create` params(sessionId) [auth, db, queue]
|
||||||
|
- `POST` `/api/sessions/:sessionId/pending/apply` params(sessionId) [auth, db, queue]
|
||||||
|
- `POST` `/api/pending/:id/apply` params(id) [auth, db, queue]
|
||||||
|
- `POST` `/api/pending/:id/reject` params(id) [auth, db, queue]
|
||||||
|
- `POST` `/api/pending/:id/rewind` params(id) [auth, db, queue]
|
||||||
|
- `GET` `/api/providers/snapshot` params() [db, cache]
|
||||||
|
- `GET` `/api/providers/config` params() [db, cache]
|
||||||
|
- `PATCH` `/api/providers/config` params() [db, cache]
|
||||||
|
- `POST` `/api/providers/refresh` params() [db, cache]
|
||||||
|
- `GET` `/api/providers/:id/diagnostic` params(id) [db, cache]
|
||||||
|
- `POST` `/api/runs/:id/cancel` params(id) [auth, db]
|
||||||
|
- `POST` `/api/sessions/:sessionId/skill_invoke` params(sessionId) [auth, db, queue]
|
||||||
|
- `GET` `/api/stats/costs` params() [auth, db]
|
||||||
|
- `POST` `/api/tasks/:id/cancel` params(id) [auth, db, cache, ai]
|
||||||
|
- `GET` `/api/tasks/:id/permission` params(id) [auth, db, cache, ai]
|
||||||
|
- `POST` `/api/tasks/:id/permission` params(id) [auth, db, cache, ai]
|
||||||
|
- `GET` `/api/tasks/:id/commands` params(id) [auth, db, cache, ai]
|
||||||
|
- `GET` `/api/sessions/:sessionId/worktree-risk` params(sessionId) [auth, db]
|
||||||
|
- `POST` `/api/sessions/:sessionId/worktree-stash` params(sessionId) [auth, db]
|
||||||
|
- `GET` `/api/ws/sessions/:sessionId` params(sessionId) [auth, db]
|
||||||
|
- `GET` `/api/ws/user` params() [auth, db]
|
||||||
|
- `GET` `/api/projects/:id/agents` params(id) [db, cache]
|
||||||
|
- `POST` `/api/chats/:id/messages/:msg_id/artifacts/download` params(id, msg_id) [auth, db]
|
||||||
|
- `GET` `/api/chats/:id/messages/:msg_id/html_artifact` params(id, msg_id) [auth, db]
|
||||||
|
- `GET` `/api/projects/:project_id/artifacts/:filename` params(project_id, filename) [auth, db]
|
||||||
|
- `GET` `/api/sessions/:id/chats` params(id) [auth, db]
|
||||||
|
- `POST` `/api/sessions/:id/chats` params(id) [auth, db]
|
||||||
|
- `PATCH` `/api/chats/:id` params(id) [auth, db]
|
||||||
|
- `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db]
|
||||||
|
- `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db]
|
||||||
|
- `POST` `/api/chats/:id/archive` params(id) [auth, db]
|
||||||
|
- `POST` `/api/chats/:id/unarchive` params(id) [auth, db]
|
||||||
|
- `DELETE` `/api/chats/:id` params(id) [auth, db]
|
||||||
|
- `POST` `/api/chats/:id/fork` params(id) [auth, db]
|
||||||
|
- `POST` `/api/chats/:id/discard_stale` params(id) [auth, db]
|
||||||
|
- `GET` `/api/coder/ws/sessions/:sessionId` params(sessionId) [auth]
|
||||||
|
- `ALL` `/api/coder/*` params() [auth]
|
||||||
|
- `GET` `/api/settings/inference` params() [cache]
|
||||||
|
- `PATCH` `/api/settings/inference` params() [cache]
|
||||||
|
- `GET` `/api/sessions/:id/messages` params(id) [auth, db, queue]
|
||||||
|
- `POST` `/api/chats/:id/messages/:message_id/regenerate` params(id, message_id) [auth, db, queue]
|
||||||
|
- `POST` `/api/chats/:id/compact` params(id) [auth, db, queue]
|
||||||
|
- `POST` `/api/chats/:id/stop` params(id) [auth, db, queue]
|
||||||
|
- `POST` `/api/chats/:id/continue` params(id) [auth, db, queue]
|
||||||
|
- `POST` `/api/chats/:id/force_send` params(id) [auth, db, queue]
|
||||||
|
- `POST` `/api/chats/:id/grant_read_access` params(id) [auth, db, queue]
|
||||||
|
- `GET` `/api/models` params()
|
||||||
|
- `POST` `/api/projects/create` params() [auth, db]
|
||||||
|
- `POST` `/api/projects/:id/archive` params(id) [auth, db]
|
||||||
|
- `POST` `/api/projects/:id/unarchive` params(id) [auth, db]
|
||||||
|
- `GET` `/api/projects/available` params() [auth, db]
|
||||||
|
- `GET` `/api/projects/:id/list_dir` params(id) [auth, db]
|
||||||
|
- `GET` `/api/projects/:id/view_file` params(id) [auth, db]
|
||||||
|
- `GET` `/api/projects/:id/git` params(id) [auth, db]
|
||||||
|
- `GET` `/api/projects/:id/git/diff` params(id) [auth, db]
|
||||||
|
- `POST` `/api/projects/:id/git/stage` params(id) [auth, db]
|
||||||
|
- `POST` `/api/projects/:id/git/unstage` params(id) [auth, db]
|
||||||
|
- `POST` `/api/projects/:id/git/commit` params(id) [auth, db]
|
||||||
|
- `POST` `/api/projects/:id/git/discard` params(id) [auth, db]
|
||||||
|
- `POST` `/api/projects/:id/write_file` params(id) [auth, db]
|
||||||
|
- `GET` `/api/projects/:id/files` params(id) [auth, db]
|
||||||
|
- `GET` `/api/projects/:id/sessions` params(id) [auth, db]
|
||||||
|
- `POST` `/api/projects/:id/sessions` params(id) [auth, db]
|
||||||
|
- `PATCH` `/api/sessions/:id/workspace` params(id) [auth, db]
|
||||||
|
- `POST` `/api/projects/:id/sessions/archive-all` params(id) [auth, db]
|
||||||
|
- `GET` `/api/projects/:id/sessions/open-count` params(id) [auth, db]
|
||||||
|
- `POST` `/api/sessions/:id/archive` params(id) [auth, db]
|
||||||
|
- `POST` `/api/sessions/:id/unarchive` params(id) [auth, db]
|
||||||
|
- `GET` `/api/settings` params() [db]
|
||||||
|
- `PATCH` `/api/settings` params() [db]
|
||||||
|
- `GET` `/api/sidebar` params() [auth, db]
|
||||||
|
- `GET` `/api/skills` params() [auth, db, queue]
|
||||||
|
- `POST` `/api/chats/:id/skill_invoke` params(id) [auth, db, queue]
|
||||||
|
- `GET` `/api/tools/cost_stats` params() [auth, db]
|
||||||
|
- `GET` `/api/ws/sessions/:id` params(id) [auth, db]
|
||||||
|
|
||||||
|
### go-net-http
|
||||||
|
|
||||||
|
- `GET` `/health` params() [queue]
|
||||||
|
- `POST` `/v1/get_codebase_overview` params() [queue]
|
||||||
|
- `POST` `/v1/get_file_analysis` params() [queue]
|
||||||
|
- `POST` `/v1/get_symbol_info` params() [queue]
|
||||||
|
- `POST` `/v1/search_symbols` params() [queue]
|
||||||
|
- `POST` `/v1/get_dependencies` params() [queue]
|
||||||
|
- `POST` `/v1/watch_changes` params() [queue]
|
||||||
|
- `POST` `/v1/get_semantic_neighborhoods` params() [queue]
|
||||||
|
- `POST` `/v1/get_framework_analysis` params() [queue]
|
||||||
|
- `POST` `/v1/get_symbol_details` params() [queue]
|
||||||
|
- `POST` `/v1/get_call_graph` params() [queue]
|
||||||
|
- `POST` `/v1/get_blast_radius` params() [queue]
|
||||||
|
|
||||||
|
## WebSocket Events
|
||||||
|
|
||||||
|
- `WS` `message` — `apps/booterm/src/ws/attach.ts`
|
||||||
|
- `WS` `close` — `apps/booterm/src/ws/attach.ts`
|
||||||
|
- `WS` `message` — `apps/coder/src/cli.ts`
|
||||||
|
- `WS` `error` — `apps/coder/src/cli.ts`
|
||||||
|
- `WS` `close` — `apps/coder/src/cli.ts`
|
||||||
|
- `WS` `close` — `apps/coder/src/routes/ws.ts`
|
||||||
|
- `WS` `error` — `apps/coder/src/routes/ws.ts`
|
||||||
|
- `WS` `close` — `apps/server/src/routes/ws.ts`
|
||||||
|
- `WS` `error` — `apps/server/src/routes/ws.ts`
|
||||||
157
.codesight/schema.md
Normal file
157
.codesight/schema.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Schema
|
||||||
|
|
||||||
|
### pending_changes
|
||||||
|
- id: uuid (pk)
|
||||||
|
- session_id: uuid (required, fk)
|
||||||
|
- task_id: uuid (fk)
|
||||||
|
- file_path: text (required)
|
||||||
|
- operation: text (required)
|
||||||
|
- diff: text (required)
|
||||||
|
- status: text (required)
|
||||||
|
|
||||||
|
### tasks
|
||||||
|
- id: uuid (pk)
|
||||||
|
- project_id: uuid (required, fk)
|
||||||
|
- parent_task_id: uuid (fk)
|
||||||
|
- state: text (required)
|
||||||
|
- input: text (required)
|
||||||
|
- output_summary: text
|
||||||
|
- agent: text
|
||||||
|
- model: text
|
||||||
|
- execution_path: text
|
||||||
|
- cost_tokens: integer
|
||||||
|
- started_at: timestamp(tz)
|
||||||
|
- ended_at: timestamp(tz)
|
||||||
|
|
||||||
|
### available_agents
|
||||||
|
- name: text (pk)
|
||||||
|
- install_path: text
|
||||||
|
- version: text
|
||||||
|
- supports_acp: boolean (required)
|
||||||
|
- last_probed_at: timestamp(tz)
|
||||||
|
|
||||||
|
### agent_sessions
|
||||||
|
- session_id: uuid (required, fk)
|
||||||
|
- agent: text (required)
|
||||||
|
- backend: text (required)
|
||||||
|
- agent_session_id: text (fk)
|
||||||
|
- server_port: integer
|
||||||
|
- status: text (required)
|
||||||
|
- last_active_at: timestamp(tz)
|
||||||
|
|
||||||
|
### worktrees
|
||||||
|
- id: uuid (pk)
|
||||||
|
- session_id: uuid (fk)
|
||||||
|
- project_id: uuid (fk)
|
||||||
|
- path: text (required)
|
||||||
|
- branch: text
|
||||||
|
- base_commit: text
|
||||||
|
- slug: text
|
||||||
|
- status: text (required)
|
||||||
|
|
||||||
|
### checkpoints
|
||||||
|
- id: uuid (pk)
|
||||||
|
- chat_id: uuid (required, fk)
|
||||||
|
- session_id: uuid (fk)
|
||||||
|
- worktree_id: uuid (fk)
|
||||||
|
- message_id: uuid (fk)
|
||||||
|
|
||||||
|
### claude_session_entries
|
||||||
|
- id: bigint(auto) (pk)
|
||||||
|
- project_key: text (required)
|
||||||
|
- session_id: text (required, fk)
|
||||||
|
- subpath: text (required)
|
||||||
|
|
||||||
|
### flow_runs
|
||||||
|
- id: uuid (pk)
|
||||||
|
- project_id: uuid (required, fk)
|
||||||
|
- flow_name: text (required)
|
||||||
|
- band: text (required)
|
||||||
|
- model: text (required)
|
||||||
|
- status: text (required)
|
||||||
|
- input: jsonb (required)
|
||||||
|
- report: text
|
||||||
|
- error: text
|
||||||
|
|
||||||
|
### flow_steps
|
||||||
|
- id: uuid (pk)
|
||||||
|
- run_id: uuid (required, fk)
|
||||||
|
- step_id: text (required, fk)
|
||||||
|
- kind: text (required)
|
||||||
|
- agent: text
|
||||||
|
- status: text (required)
|
||||||
|
- task_id: uuid (fk)
|
||||||
|
- chat_id: uuid (fk)
|
||||||
|
- input: text
|
||||||
|
- output: text
|
||||||
|
- error: text
|
||||||
|
|
||||||
|
### battles
|
||||||
|
- id: uuid (pk)
|
||||||
|
- project_id: uuid (required, fk)
|
||||||
|
- battle_type: text (required)
|
||||||
|
- prompt: text (required)
|
||||||
|
- status: text (required)
|
||||||
|
- winner_contestant_id: uuid (fk)
|
||||||
|
- results_path: text
|
||||||
|
- error: text
|
||||||
|
|
||||||
|
### contestants
|
||||||
|
- id: uuid (pk)
|
||||||
|
- battle_id: uuid (required, fk)
|
||||||
|
- identity: text (required)
|
||||||
|
- model: text (required)
|
||||||
|
- lane: text (required)
|
||||||
|
- task_id: uuid (fk)
|
||||||
|
- worktree_id: uuid (fk)
|
||||||
|
- status: text (required)
|
||||||
|
- duration_ms: integer
|
||||||
|
- tokens_per_sec: float8
|
||||||
|
- cost_tokens: integer
|
||||||
|
- result_path: text
|
||||||
|
- error: text
|
||||||
|
|
||||||
|
### cross_examinations
|
||||||
|
- id: uuid (pk)
|
||||||
|
- battle_id: uuid (required, fk)
|
||||||
|
- identity: text (required)
|
||||||
|
- model: text (required)
|
||||||
|
- verdict: text
|
||||||
|
|
||||||
|
### projects
|
||||||
|
- id: uuid (pk)
|
||||||
|
- name: text (required)
|
||||||
|
- path: text (required)
|
||||||
|
- added_at: timestamp(tz) (required)
|
||||||
|
- last_session_id: uuid (fk)
|
||||||
|
|
||||||
|
### sessions
|
||||||
|
- id: uuid (pk)
|
||||||
|
- project_id: uuid (required, fk)
|
||||||
|
- name: text (required)
|
||||||
|
- model: text (required)
|
||||||
|
- system_prompt: text (required)
|
||||||
|
|
||||||
|
### messages
|
||||||
|
- id: uuid (pk)
|
||||||
|
- session_id: uuid (required, fk)
|
||||||
|
- role: text (required)
|
||||||
|
- content: text (required)
|
||||||
|
- status: text (required)
|
||||||
|
- last_seq: integer (required)
|
||||||
|
|
||||||
|
### message_parts
|
||||||
|
- id: uuid (pk)
|
||||||
|
- message_id: uuid (required, fk)
|
||||||
|
- sequence: integer (required)
|
||||||
|
- kind: text (required)
|
||||||
|
- payload: jsonb (required)
|
||||||
|
|
||||||
|
### settings
|
||||||
|
- value: jsonb (required)
|
||||||
|
|
||||||
|
### chats
|
||||||
|
- id: uuid (pk)
|
||||||
|
- session_id: uuid (required, fk)
|
||||||
|
- name: text
|
||||||
|
- status: text (required)
|
||||||
37
.learnings/HEALS.md
Normal file
37
.learnings/HEALS.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Self-healing log
|
||||||
|
|
||||||
|
Verified fixes for runtime failures. Each entry documents a failure, its root cause, the applied fix, and the verification proof.
|
||||||
|
|
||||||
|
**Pattern-Key discipline:** before filing a new HEAL, search this file for an existing Pattern-Key. If found, increment `Recurrence-Count` and update `Last-Seen` — do not duplicate.
|
||||||
|
|
||||||
|
**Lifecycle:** verified heals at Recurrence-Count ≥ 3 across distinct tasks get a `Handoff` block for promotion to project memory (`CLAUDE.md`, `AGENTS.md`, or a skill).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [HEAL-YYYYMMDD-XXX] short_kebab_name
|
||||||
|
|
||||||
|
**Logged**: ISO-8601 timestamp
|
||||||
|
**Status**: pending-verify
|
||||||
|
**Trigger**: tool-failure | missing-capability | env-issue | external-change | <free-form>
|
||||||
|
**Area**: free-form tag (e.g. `build`, `tests`, `ci`, `auth`, `data-pipeline`)
|
||||||
|
**Priority**: low | medium | high | critical
|
||||||
|
|
||||||
|
### Failure
|
||||||
|
Concrete error: command, error message, exit code, blocked action.
|
||||||
|
|
||||||
|
### Diagnosis
|
||||||
|
Root cause as understood after investigation. What was verified during diagnosis.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
Patch applied. Verbatim commands, code snippets, or pointers to `.learnings/heals/<HEAL-ID>/`.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
What was run after the fix and what it returned. Exit code, output snippet, test pass count. **Proof.**
|
||||||
|
|
||||||
|
### Metadata
|
||||||
|
- Related Files: path/to/file.ext
|
||||||
|
- See Also: HEAL-... | LRN-... | ERR-...
|
||||||
|
- Pattern-Key: lower.snake.case (e.g. `env.lockfile_mismatch`)
|
||||||
|
- Recurrence-Count: 1
|
||||||
|
- First-Seen: YYYY-MM-DD
|
||||||
|
- Last-Seen: YYYY-MM-DD
|
||||||
89
.omo/drafts/openspec-cleanup.md
Normal file
89
.omo/drafts/openspec-cleanup.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Draft: openspec-cleanup
|
||||||
|
|
||||||
|
## Cross-Reference: Git Tags vs openspec Batches
|
||||||
|
|
||||||
|
### Archived Stub Files — Tag Verification
|
||||||
|
|
||||||
|
| Stub File | Claims Version | Actual Tag | Verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `v1.13.12-skills-audit.md` (57B) | v1.13.12 | `v1.13.14-skills-audit` | **WRONG** — off by 2 versions |
|
||||||
|
| `v1.13.15-codecontext-synth.md` (62B) | v1.13.15 | `v1.13.15-codecontext-synth` | ✅ correct |
|
||||||
|
| `v1.13.17-cross-repo-reads.md` (61B) | v1.13.17 | `v1.13.17-cross-repo-reads` | ✅ correct |
|
||||||
|
| `v1.13.18-codecontext-file-path.md` (66B) | v1.13.18 | `v1.13.18-codecontext-file-path` | ✅ correct |
|
||||||
|
| `v1.13.20-drop-legacy-cols.md` (61B) | v1.13.20 | `v1.13.20-drop-legacy-cols` | ✅ correct |
|
||||||
|
| `v1.14-outer-loop.md` (52B) | v1.14 | `v1.14.0-outer-loop` | ⚠️ close (1.14 → 1.14.0) |
|
||||||
|
| `v1.14.1-mcp-poc.md` (51B) | v1.14.1 | `v1.14.1-mcp-poc` | ✅ correct |
|
||||||
|
| `v1.14.x-html-artifact-panes.md` (63B) | v1.14.x | `v1.13.19-html-artifact-panes` | **WRONG** — shipped as 1.13.19 |
|
||||||
|
| `v1.15-mcp-multi.md` (51B) | v1.15 | `v1.15.0-mcp-multi` | ⚠️ close (1.15 → 1.15.0) |
|
||||||
|
| `v2.0-boocoder.md` (49B) | v2.0 | `v2.0.0` | ⚠️ close (2.0 → 2.0.0) |
|
||||||
|
| `v2.2-paseo-providers.md` (222B) | v2.2 | `v2.2-paseo-providers` | ✅ correct |
|
||||||
|
|
||||||
|
### Archived Folder Entries — Tag Verification
|
||||||
|
|
||||||
|
| Archived Folder | Git Tag(s) | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `agent-status-normalize/` | `v2.7.6-agent-status-normalize` | ✅ shipped |
|
||||||
|
| `claude-sdk-sessionstore/` | `v2.7.5-claude-sdk-sessionstore` | ✅ shipped |
|
||||||
|
| `contracts-ssot/` | `v2.7.13-contracts-ssot` | ✅ shipped |
|
||||||
|
| `license-debt-mit/` | `v2.7.0-mit` | ✅ shipped |
|
||||||
|
| `mistake-tracker-file-ledger/` | `v2.7.4-mistake-tracker-ledger` | ✅ shipped (slug differs slightly) |
|
||||||
|
| `orchestrator/` | `v2.7.17-orchestrator` | ✅ shipped |
|
||||||
|
| `sampling-streamjson-tokens/` | `v2.7.3-sampling-streamjson-tokens` | ✅ shipped |
|
||||||
|
| `v2-3-provider-lifecycle/` | `v2.5.4-*` through `v2.5.13-*` | ✅ shipped (diff version numbering) |
|
||||||
|
| `v2-6-persistent-agent-sessions/` | `v2.6.4-*`, `v2.6.8-*` | ✅ shipped |
|
||||||
|
| `write-edit-robustness/` | `v2.7.1-write-edit-robustness` | ✅ shipped |
|
||||||
|
|
||||||
|
### Misplaced Proposals in Archived/
|
||||||
|
|
||||||
|
| 2026-06-07 Folder | Git Tag? | Actually Shipped? | Should Be |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `2026-06-07-boocontext/` | **None** | No | `changes/boocontext/` (partly shipped in v2.8.0) |
|
||||||
|
| `2026-06-07-eval-sandbox-agent-runtime/` | **None** | No | Merge into `changes/import-*` |
|
||||||
|
| `2026-06-07-hybrid-workflow-engine/` | **None** | No | Merge into `changes/orchestrator-flow-advanced/` |
|
||||||
|
| `2026-06-07-memory-context-engineering/` | **None** | No | Merge into `changes/memory-context/` |
|
||||||
|
| `2026-06-07-port-audit-parlant-patterns/` | **None** | No | Merge into `changes/add-behavioral-engine/` |
|
||||||
|
|
||||||
|
## Active Batches — All Uncommitted, All Unshipped
|
||||||
|
|
||||||
|
All 22 active batches (changes/*/) have **zero** git tags or commits referencing them. Every batch was created locally on 2026-06-07 and exists only on the filesystem.
|
||||||
|
|
||||||
|
## High-Value Prioritization (for Implementation Plan)
|
||||||
|
|
||||||
|
### Tier 1: Ship in Current Batch (small scope, high value)
|
||||||
|
1. **openspec-cleanup** — Fix folder structure: delete stubs, move misplaced proposals, add .openspec.yaml, populate config.yaml
|
||||||
|
2. **llama-cache-and-spec** — KV cache quantization + ngram speculative decoding (llama-server arg changes only)
|
||||||
|
3. **results-page** — New `/results` route, uses existing API endpoints
|
||||||
|
4. **token-analyzer-ui** — New `/analytics` route, uses existing DB data
|
||||||
|
|
||||||
|
### Tier 2: Current+ Batch (moderate scope)
|
||||||
|
5. **enhanced-file-panel** — Side-by-side diff, inline comments, in-browser editing
|
||||||
|
6. **pty-enhancements** — Exit notifications, session metadata, X-Agent-Flags
|
||||||
|
|
||||||
|
### Tier 3: Next Batch (larger scope, foundation work)
|
||||||
|
7. **memory-v2-hybrid-search** — BM25 + local embedding hybrid search
|
||||||
|
8. **orchestrator-flow-advanced** — Trigger rules, conditional branching, HITL
|
||||||
|
9. **omo-paseo-bridge** — OMO subagent visibility in Paseo
|
||||||
|
|
||||||
|
### Tier 4: Future Batches (speculative / big effort)
|
||||||
|
10. **add-behavioral-engine** / **audit-harness-integration** / **import-llm-evaluator** / **import-pregel-engine** — Big integration efforts
|
||||||
|
11. **code-intelligence-upgrade** / **dev-workflow** / **conductor-evolution** — Platform work
|
||||||
|
12. **plugin-platform** / **ui-overhaul** / **add-3tier-memory** / **add-type-inject-mcp** — Future
|
||||||
|
|
||||||
|
## Scope Boundaries for This Plan
|
||||||
|
|
||||||
|
**IN SCOPE:**
|
||||||
|
- Delete 11 stub files from archived/
|
||||||
|
- Move 5 misplaced 2026-06-07 proposals from archived/ to changes/ (with dedup)
|
||||||
|
- Add missing .openspec.yaml to 6 active batches
|
||||||
|
- Populate openspec/config.yaml with project context
|
||||||
|
- Implement Tier 1-2 high-value batches:
|
||||||
|
- llama-cache-and-spec (llama-server args)
|
||||||
|
- results-page (new route, frontend)
|
||||||
|
- token-analyzer-ui (new route, frontend + backend)
|
||||||
|
- enhanced-file-panel (frontend changes)
|
||||||
|
- pty-enhancements (backend changes)
|
||||||
|
|
||||||
|
**OUT OF SCOPE:**
|
||||||
|
- Tier 3-4 batches (future planning)
|
||||||
|
- Full behavioral engine or Pregel state machine integration
|
||||||
|
- Plugin platform architecture
|
||||||
485
.omo/plans/enhanced-file-panel.md
Normal file
485
.omo/plans/enhanced-file-panel.md
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
# Enhanced File Panel — Implementation Plan
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
> **Quick Summary**: Add side-by-side diff, hide whitespace, wrap lines, expand all files, inline diff comments, and in-browser file editing to BooCode's right-rail file panel.
|
||||||
|
>
|
||||||
|
> **Deliverables**:
|
||||||
|
> - Enhanced `GitDiffView.tsx` with toolbar (layout/whitespace/wrap/expand-all toggles)
|
||||||
|
> - Split-layout diff renderer (side-by-side)
|
||||||
|
> - `useDiffPreferences` hook (localStorage persistence)
|
||||||
|
> - Inline diff comment components + Zustand store
|
||||||
|
> - File editing mode in file tree + server write endpoint
|
||||||
|
> - Server `git diff -w` support
|
||||||
|
>
|
||||||
|
> **Estimated Effort**: Medium-Large
|
||||||
|
> **Parallel Execution**: YES — 4 waves
|
||||||
|
> **Critical Path**: Wave 1 (server) → Wave 2 (diff preferences + toolbar) → Wave 3 (split layout) → Wave 4 (comments + editing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### Original Request
|
||||||
|
User wants to implement these features from Paseo into BooCode's file manager:
|
||||||
|
1. Unified diff ✅ (exists) / Side by side diff ❌
|
||||||
|
2. Hide whitespace ❌
|
||||||
|
3. Wrap long lines ❌
|
||||||
|
4. Expand all files ❌ (only per-file)
|
||||||
|
5. Refresh ✅ (exists)
|
||||||
|
6. Comments on specific diffs ❌
|
||||||
|
7. File edits (editing in the file browser) ❌
|
||||||
|
|
||||||
|
### Research Findings
|
||||||
|
- **Paseo** (`/opt/forks/paseo`): Best reference for all features. Key files: `diff-pane.tsx`, `diff-layout.ts`, `diff-rendering.ts`, `review/surface.tsx`, `review/store.ts`, `use-changes-preferences/`
|
||||||
|
- **Existing BooCode files**: `GitDiffView.tsx`, `RightRail.tsx`, `useGitDiff.ts`, `git_diff.ts`, `FileViewerOverlay.tsx`
|
||||||
|
- Key insight: None of the web references have true inline file editing in the browser — this is new ground
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Work Objectives
|
||||||
|
|
||||||
|
### Core Objective
|
||||||
|
Augment the existing file panel with side-by-side diff, whitespace/wrap/expand toggles, inline comments, and inline file editing.
|
||||||
|
|
||||||
|
### Definition of Done
|
||||||
|
- [x] `pnpm -C apps/web build` succeeds with no errors
|
||||||
|
- [x] `pnpm -C apps/server build` succeeds with no errors
|
||||||
|
- [ ] Side-by-side diff renders correctly (two aligned columns)
|
||||||
|
- [ ] Hide whitespace toggles and re-fetches diff
|
||||||
|
- [ ] Wrap lines toggles between pre / pre-wrap
|
||||||
|
- [ ] Expand/Collapse all toggles all file diffs
|
||||||
|
- [ ] Inline comments: click gutter → type → save → display thread
|
||||||
|
- [ ] File edit: double-click tree → edit → save → file changes on disk
|
||||||
|
- [ ] All preferences persist across page refresh
|
||||||
|
|
||||||
|
### Must Have
|
||||||
|
- Side-by-side diff view
|
||||||
|
- Hide whitespace toggle (server param)
|
||||||
|
- Wrap long lines toggle (CSS)
|
||||||
|
- Expand/Collapse all file diffs
|
||||||
|
- Inline diff comments with thread UI
|
||||||
|
- In-browser file editing with save
|
||||||
|
- Preference persistence
|
||||||
|
|
||||||
|
### Must NOT Have (Guardrails)
|
||||||
|
- No DB migration (comments are client-side)
|
||||||
|
- No new WS frames (reuse git_diff_refresh)
|
||||||
|
- No new `@boocode/contracts` types
|
||||||
|
- No multi-user comment sharing
|
||||||
|
- No git push/pull/PR operations
|
||||||
|
- No inline hunk staging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Strategy
|
||||||
|
|
||||||
|
### Test Decision
|
||||||
|
- **Infrastructure exists**: YES (vitest for server)
|
||||||
|
- **Automated tests**: Tests-after for new server route + `git_diff.ts` changes
|
||||||
|
- **Agent-Executed QA**: Playwright for diff interactions, curl for API endpoints
|
||||||
|
|
||||||
|
### QA Policy
|
||||||
|
Every task includes agent-executed scenarios. Evidence saved to `.omo/evidence/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Strategy
|
||||||
|
|
||||||
|
### Waves
|
||||||
|
|
||||||
|
```
|
||||||
|
Wave 1 (Server — foundation):
|
||||||
|
├── Task 1: Server: whitespace param in git_diff.ts
|
||||||
|
├── Task 2: Server: POST /api/projects/:id/write_file endpoint
|
||||||
|
├── Task 3: Server tests for whitespace + write
|
||||||
|
└── [tests + typecheck]
|
||||||
|
|
||||||
|
Wave 2 (Frontend — preferences + toolbar):
|
||||||
|
├── Task 4: useDiffPreferences hook (localStorage)
|
||||||
|
├── Task 5: GitDiffView toolbar (layout/whitespace/wrap/expand-all toggles)
|
||||||
|
├── Task 6: Wrap lines CSS + hide whitespace re-fetch
|
||||||
|
└── [pnpm build]
|
||||||
|
|
||||||
|
Wave 3 (Frontend — split layout):
|
||||||
|
├── Task 7: Diff layout utilities (buildSplitDiffRows etc.)
|
||||||
|
├── Task 8: Side-by-side renderer in GitDiffView
|
||||||
|
├── Task 9: Line number gutter + alignment
|
||||||
|
└── [pnpm build]
|
||||||
|
|
||||||
|
Wave 4 (Frontend — comments + file editing):
|
||||||
|
├── Task 10: InlineComment store (Zustand + localStorage)
|
||||||
|
├── Task 11: InlineReviewGutterCell + InlineReviewEditor
|
||||||
|
├── Task 12: InlineReviewThread (comment display)
|
||||||
|
├── Task 13: File editing mode in RightRail file tree
|
||||||
|
└── [pnpm build + full smoke test]
|
||||||
|
```
|
||||||
|
|
||||||
|
Critical Path: T1 → T2 → T4 → T5 → T7 → T8 → T10 → T11 → T12 → T13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODOs
|
||||||
|
|
||||||
|
- [x] 1. **Server: Add `ignoreWhitespace` param to git diff**
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- In `apps/server/src/services/git_diff.ts`, add `ignoreWhitespace?: boolean` to the `getGitDiff` function signature
|
||||||
|
- When `ignoreWhitespace` is true, append `'-w'` to the git diff argv call in `getGitDiff` (the main diff command, not name-status)
|
||||||
|
- Update `GET /api/projects/:id/git/diff` route in `routes/projects.ts` to accept optional query param `whitespace=1`
|
||||||
|
- The param should be optional (backward compatible) — default false
|
||||||
|
|
||||||
|
**Files to modify**:
|
||||||
|
- `apps/server/src/services/git_diff.ts` — update `getGitDiff()` to accept and use `ignoreWhitespace`
|
||||||
|
- `apps/server/src/routes/projects.ts` — add `whitespace` query param
|
||||||
|
|
||||||
|
**References**:
|
||||||
|
- Paseo: `useCheckoutDiffQuery({ ignoreWhitespace })` passes to server → `git diff -w`
|
||||||
|
- Existing `git_diff.ts:36-48` `runGit` function — argv pattern to follow
|
||||||
|
|
||||||
|
**QA Scenarios**:
|
||||||
|
```
|
||||||
|
Scenario: Diff with whitespace changes respects ignoreWhitespace param
|
||||||
|
Tool: Bash (curl)
|
||||||
|
Preconditions: A file exists with whitespace-only changes (extra spaces)
|
||||||
|
Steps:
|
||||||
|
1. GET /api/projects/:id/git/diff ⇒ verify diff_body includes whitespace changes
|
||||||
|
2. GET /api/projects/:id/git/diff?whitespace=1 ⇒ verify diff_body excludes whitespace-only changes
|
||||||
|
Expected: With whitespace=1, files that only had whitespace changes show as unchanged
|
||||||
|
Evidence: .omo/evidence/task-1-whitespace.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] 2. **Server: Add POST /api/projects/:id/write_file endpoint**
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Add `POST /api/projects/:id/write_file` route in `routes/projects.ts`
|
||||||
|
- Accept `{ path: string, content: string }` body
|
||||||
|
- Validate path via existing `pathGuard` helper (same as git discard)
|
||||||
|
- Write file content atomically: write to `.tmp` then `rename` the file
|
||||||
|
- Return `{ ok: boolean }` on success
|
||||||
|
- Reuse the safe file-write pattern from `services/file_ops.ts`
|
||||||
|
|
||||||
|
**Files to modify**:
|
||||||
|
- `apps/server/src/routes/projects.ts` — add POST route
|
||||||
|
- `apps/web/src/api/client.ts` — add `writeFile` method
|
||||||
|
- `apps/web/src/api/types.ts` — add write types if needed
|
||||||
|
|
||||||
|
**References**:
|
||||||
|
- `apps/server/src/services/file_ops.ts` — existing file operations pattern
|
||||||
|
- `apps/server/src/routes/projects.ts:544-592` — git write routes (same security pattern)
|
||||||
|
- `apps/server/src/services/path_guard.ts` — path validation
|
||||||
|
|
||||||
|
**QA Scenarios**:
|
||||||
|
```
|
||||||
|
Scenario: Write file content and verify on disk
|
||||||
|
Tool: Bash (curl)
|
||||||
|
Preconditions: A project exists with a writable path
|
||||||
|
Steps:
|
||||||
|
1. POST /api/projects/:id/write_file { path: "test.txt", content: "hello" }
|
||||||
|
2. GET /api/projects/:id/view_file?path=test.txt
|
||||||
|
Expected: Status 200, view_file returns "hello"
|
||||||
|
Evidence: .omo/evidence/task-2-write.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] 3. **Frontend: useDiffPreferences hook**
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Create `apps/web/src/hooks/useDiffPreferences.ts`
|
||||||
|
- Define `DiffPreferences` interface: `{ layout: 'unified'|'split', wrapLines: boolean, hideWhitespace: boolean }`
|
||||||
|
- Default: `{ layout: 'unified', wrapLines: false, hideWhitespace: false }`
|
||||||
|
- Read/write to localStorage key `boocode.diff.preferences`
|
||||||
|
- Return `{ preferences, updatePreferences, resetPreferences }`
|
||||||
|
- Zod-validate on read for forward compatibility
|
||||||
|
|
||||||
|
**Files to create/modify**:
|
||||||
|
- Create `apps/web/src/hooks/useDiffPreferences.ts`
|
||||||
|
|
||||||
|
**References**:
|
||||||
|
- `/opt/forks/paseo/packages/app/src/hooks/use-changes-preferences/storage.ts` — exact pattern
|
||||||
|
- `apps/web/src/hooks/useProjectGit.ts` — hooks pattern in BooCode
|
||||||
|
|
||||||
|
**QA Scenarios**:
|
||||||
|
```
|
||||||
|
Scenario: Preferences persist across page refresh
|
||||||
|
Tool: Playwright
|
||||||
|
Preconditions: Page loaded
|
||||||
|
Steps:
|
||||||
|
1. Call updatePreferences({ layout: 'split' })
|
||||||
|
2. Read localStorage.getItem('boocode.diff.preferences')
|
||||||
|
3. Reload page, read preferences again
|
||||||
|
Expected: layout is 'split' after reload
|
||||||
|
Evidence: .omo/evidence/task-3-prefs.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] 4. **Frontend: GitDiffView toolbar with all toggles**
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Add a toolbar row inside `GitDiffView.tsx` between the mode selector and file list
|
||||||
|
- Controls (left to right):
|
||||||
|
- **Layout toggle**: two-segment button (Unified | Split) — uses `AlignJustify` / `Columns2` icons
|
||||||
|
- **Hide whitespace**: toggle button — `Pilcrow` icon, active state highlights
|
||||||
|
- **Wrap lines**: toggle button — `WrapText` icon
|
||||||
|
- **Expand/Collapse all**: toggle button — `ListChevronsUpDown` / `ListChevronsDownUp` icons
|
||||||
|
- **Refresh**: existing button (already present)
|
||||||
|
- Wire each toggle to the `useDiffPreferences` hook
|
||||||
|
- Expand all state: compute `allExpanded = files.every(f => expandedPaths.has(f.path))`
|
||||||
|
- Pass expand state as a new prop or local state
|
||||||
|
|
||||||
|
**Files to modify**:
|
||||||
|
- `apps/web/src/components/GitDiffView.tsx` — add toolbar section, expand-all logic
|
||||||
|
|
||||||
|
**References**:
|
||||||
|
- Paseo `diff-pane.tsx:1114-1273` — `DiffLayoutToggleGroup`, `DiffWhitespaceToggle`, `DiffFilesToolbar`
|
||||||
|
- openchamber `DiffViewToggle.tsx` — simple toggle pattern
|
||||||
|
- happy `InlineFileDiff.tsx:196-219` — `DiffStyleToggle` segment control
|
||||||
|
|
||||||
|
**QA Scenarios**:
|
||||||
|
```
|
||||||
|
Scenario: All toolbar controls render and toggle
|
||||||
|
Tool: Playwright
|
||||||
|
Preconditions: Git tab active with changed files
|
||||||
|
Steps:
|
||||||
|
1. Verify layout toggle shows "Unified" / "Split" buttons
|
||||||
|
2. Click "Split" — verify visual change
|
||||||
|
3. Click "Wrap" — verify wrap toggle
|
||||||
|
4. Click "Expand all" — verify all files expand
|
||||||
|
5. Click "Collapse all" — verify all files collapse
|
||||||
|
Expected: Each toggle works and updates state
|
||||||
|
Evidence: .omo/evidence/task-4-toolbar.png
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] 5. **Frontend: Diff layout utilities + side-by-side renderer**
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Create `apps/web/src/utils/diff-layout.ts` with pure functions:
|
||||||
|
- `buildNumberedDiffHunks(diffBody: string): NumberedDiffHunk[]` — parse diff text into hunks with old/new line numbers
|
||||||
|
- `buildUnifiedDiffLines(file): UnifiedDiffDisplayLine[]` — existing behavior
|
||||||
|
- `buildSplitDiffRows(file): SplitDiffRow[]` — pair removals/additions into left/right rows
|
||||||
|
- Create `apps/web/src/components/DiffSplitView.tsx` — the side-by-side renderer:
|
||||||
|
- Two columns (left = deletions, right = additions) with a thin divider
|
||||||
|
- Each column has its own gutter (line numbers) + code content
|
||||||
|
- Use Shiki `codeToHtml(language)` for syntax highlighting per side
|
||||||
|
- Handle empty cells (unpaired lines render as blank)
|
||||||
|
- In `GitDiffView.tsx`, when `layout === 'split'`, render `DiffSplitView` instead of the unified diff body
|
||||||
|
|
||||||
|
**Files to create/modify**:
|
||||||
|
- Create `apps/web/src/utils/diff-layout.ts`
|
||||||
|
- Create `apps/web/src/components/DiffSplitView.tsx`
|
||||||
|
- Modify `apps/web/src/components/GitDiffView.tsx` — add layout branching
|
||||||
|
|
||||||
|
**References**:
|
||||||
|
- `/opt/forks/paseo/packages/app/src/utils/diff-layout.ts` — full algorithm
|
||||||
|
- `/opt/forks/paseo/packages/app/src/git/diff-pane.tsx:968-989` — split layout rendering
|
||||||
|
- existing `git_diff.ts` `splitDiffByFile` — already splits unified diff per file
|
||||||
|
|
||||||
|
**QA Scenarios**:
|
||||||
|
```
|
||||||
|
Scenario: Side-by-side diff renders correctly
|
||||||
|
Tool: Playwright
|
||||||
|
Preconditions: Git tab active, files with changes
|
||||||
|
Steps:
|
||||||
|
1. Click "Split" layout toggle
|
||||||
|
2. Verify two columns appear with a divider
|
||||||
|
3. Verify deleted lines are on left side (red background)
|
||||||
|
4. Verify added lines are on right side (green background)
|
||||||
|
5. Verify context lines appear on both sides, aligned
|
||||||
|
Expected: Layout matches Paseo's split diff
|
||||||
|
Evidence: .omo/evidence/task-5-splitdiff.png
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] 6. **Frontend: Inline comment store + Zustand**
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Create `apps/web/src/stores/useDiffCommentStore.ts`
|
||||||
|
- Define `DiffComment` interface: `{ id, filePath, side, lineNumber, body, createdAt, updatedAt }`
|
||||||
|
- Create Zustand store with:
|
||||||
|
- `commentsByKey: Map<string, DiffComment[]>` keyed by `${sessionId}:${mode}:${filePath}`
|
||||||
|
- `addComment(key, comment)` / `updateComment(key, id, body)` / `deleteComment(key, id)`
|
||||||
|
- `loadComments(key)` — load from localStorage
|
||||||
|
- `persist()` — subscribe to store changes, write to localStorage key `boocode.diff.comments.[key]`
|
||||||
|
- Export `useDiffCommentStore`
|
||||||
|
|
||||||
|
**Files to create**:
|
||||||
|
- Create `apps/web/src/stores/useDiffCommentStore.ts`
|
||||||
|
|
||||||
|
**References**:
|
||||||
|
- `/opt/forks/paseo/packages/app/src/review/store.ts` — zustand store for comments
|
||||||
|
- `/opt/forks/paseo/packages/app/src/review/state.ts` — CRUD operations
|
||||||
|
|
||||||
|
**QA Scenarios**:
|
||||||
|
```
|
||||||
|
Scenario: Comments persist across page refresh
|
||||||
|
Tool: Playwright
|
||||||
|
Preconditions: Diff panel open with changes
|
||||||
|
Steps:
|
||||||
|
1. Add comment on a diff line
|
||||||
|
2. Verify comment thread appears
|
||||||
|
3. Reload page
|
||||||
|
4. Navigate to same diff
|
||||||
|
Expected: Comment thread still visible after reload
|
||||||
|
Evidence: .omo/evidence/task-6-comment-store.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] 7. **Frontend: InlineReviewGutterCell + InlineReviewEditor**
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Create `apps/web/src/components/InlineReviewGutterCell.tsx`:
|
||||||
|
- Replaces the plain line-number display in diff rows
|
||||||
|
- Shows line number + "+" icon on hover (to start a comment)
|
||||||
|
- Uses `ReviewableDiffTarget { filePath, side, lineNumber }` for tracking
|
||||||
|
- Create `apps/web/src/components/InlineReviewEditor.tsx`:
|
||||||
|
- Textarea with placeholder "Add comment..."
|
||||||
|
- Save (Ctrl+Enter) / Cancel (Escape) buttons
|
||||||
|
- Animates in below the target line
|
||||||
|
- Integrate into `GitDiffView.tsx` — gutter cells render in the diff line view
|
||||||
|
- Wire to `useDiffCommentStore`
|
||||||
|
|
||||||
|
**Files to create/modify**:
|
||||||
|
- Create `apps/web/src/components/InlineReviewGutterCell.tsx`
|
||||||
|
- Create `apps/web/src/components/InlineReviewEditor.tsx`
|
||||||
|
- Modify `apps/web/src/components/GitDiffView.tsx` — integrate gutter cells
|
||||||
|
|
||||||
|
**References**:
|
||||||
|
- Paseo `review/surface.tsx:245-309` — `DiffGutterCell` + `InlineReviewGutterCell`
|
||||||
|
- Paseo `InlineReviewEditor` pattern
|
||||||
|
|
||||||
|
**QA Scenarios**:
|
||||||
|
```
|
||||||
|
Scenario: Create inline comment on diff line
|
||||||
|
Tool: Playwright
|
||||||
|
Preconditions: Git tab, file expanded
|
||||||
|
Steps:
|
||||||
|
1. Hover over a gutter cell
|
||||||
|
2. Click "+" button
|
||||||
|
3. Type comment text
|
||||||
|
4. Click Save (or Ctrl+Enter)
|
||||||
|
Expected: Comment thread appears below the line
|
||||||
|
Evidence: .omo/evidence/task-7-comment-create.png
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] 8. **Frontend: InlineReviewThread component**
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Create `apps/web/src/components/InlineReviewThread.tsx`:
|
||||||
|
- Renders below a diff line when comments exist for that target
|
||||||
|
- Each comment shown as a card: avatar placeholder, body, timestamp, edit/delete actions
|
||||||
|
- Collapsed state shows comment count badge
|
||||||
|
- Expanded state shows full thread
|
||||||
|
- Integrate into `GitDiffView.tsx` below diff line rows
|
||||||
|
|
||||||
|
**Files to create/modify**:
|
||||||
|
- Create `apps/web/src/components/InlineReviewThread.tsx`
|
||||||
|
- Modify `apps/web/src/components/GitDiffView.tsx` — render thread below lines
|
||||||
|
|
||||||
|
**Reference**:
|
||||||
|
- Paseo `review/surface.tsx:537-573` — `InlineReviewThreadContent`
|
||||||
|
|
||||||
|
**QA Scenarios**:
|
||||||
|
```
|
||||||
|
Scenario: Comment thread displays and supports edit/delete
|
||||||
|
Tool: Playwright
|
||||||
|
Preconditions: Comments exist on a diff line
|
||||||
|
Steps:
|
||||||
|
1. Expand comment thread
|
||||||
|
2. Verify comment body is visible with timestamp
|
||||||
|
3. Click edit → modify text → save
|
||||||
|
4. Click delete → verify comment removed
|
||||||
|
Expected: Full CRUD works on comments
|
||||||
|
Evidence: .omo/evidence/task-8-thread.png
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] 9. **Frontend: File editing in the file tree**
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- In `RightRail.tsx`, add a file edit mode:
|
||||||
|
- Double-click a file in the tree (or context menu "Edit") enters edit mode
|
||||||
|
- The file row transforms: file name becomes a monospace textarea pre-filled with file content (fetched via existing `api.projects.viewFile`)
|
||||||
|
- The row shows Save / Cancel buttons
|
||||||
|
- Save: calls `api.projects.writeFile(projectId, path, content)` — the new endpoint from Task 2
|
||||||
|
- Cancel: reverts to the original content and exits edit mode
|
||||||
|
- After save: re-fetch the file tree + emit `git_diff_refresh`
|
||||||
|
- Only one file editable at a time (close any existing editor before opening new)
|
||||||
|
- Visual indicator (highlighted row) when in edit mode
|
||||||
|
|
||||||
|
**Files to modify**:
|
||||||
|
- `apps/web/src/components/RightRail.tsx` — add edit mode state, edit UI
|
||||||
|
- `apps/web/src/api/client.ts` — add `writeFile` method (from Task 2)
|
||||||
|
- `apps/web/src/components/TreeLevel.tsx` (inline in RightRail) — accept edit mode props
|
||||||
|
|
||||||
|
**References**:
|
||||||
|
- Existing `RightRail.tsx:170-175` `openFile` function — pattern for file interaction
|
||||||
|
- Existing `FileViewerOverlay.tsx` — Shiki highlighting reference
|
||||||
|
- Paseo `file-explorer-pane.tsx` — context menu actions pattern
|
||||||
|
|
||||||
|
**QA Scenarios**:
|
||||||
|
```
|
||||||
|
Scenario: Edit file in file tree and save
|
||||||
|
Tool: Playwright
|
||||||
|
Preconditions: Project with a text file
|
||||||
|
Steps:
|
||||||
|
1. Double-click a file in the file tree
|
||||||
|
2. Verify file enters edit mode (textarea replaces filename)
|
||||||
|
3. Modify content
|
||||||
|
4. Ctrl+Enter to save
|
||||||
|
5. Verify success indicator
|
||||||
|
Expected: File content updated on disk, tree refreshes
|
||||||
|
Evidence: .omo/evidence/task-9-edit-save.png
|
||||||
|
|
||||||
|
Scenario: Cancel file edit reverts changes
|
||||||
|
Tool: Playwright
|
||||||
|
Preconditions: File in edit mode
|
||||||
|
Steps:
|
||||||
|
1. Modify content in textarea
|
||||||
|
2. Click Cancel / press Escape
|
||||||
|
3. Re-open file
|
||||||
|
Expected: Original content preserved, edit mode exited
|
||||||
|
Evidence: .omo/evidence/task-9-edit-cancel.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Verification
|
||||||
|
|
||||||
|
- [ ] F1. **Plan Compliance Audit** — `oracle`
|
||||||
|
Verify all Must Have features are implemented, Must NOT Have are absent.
|
||||||
|
Output: VERDICT
|
||||||
|
|
||||||
|
- [ ] F2. **Code Quality** — `unspecified-high`
|
||||||
|
Run `pnpm -C apps/web build`, `pnpm -C apps/server build`, check for `as any`/`@ts-ignore`/console.log.
|
||||||
|
Output: VERDICT
|
||||||
|
|
||||||
|
- [ ] F3. **Real Manual QA** — `unspecified-high` + `playwright`
|
||||||
|
Execute all QA scenarios from every task, capture evidence.
|
||||||
|
Output: Scenarios [N/N pass]
|
||||||
|
|
||||||
|
- [ ] F4. **Scope Fidelity** — `deep`
|
||||||
|
Verify spec matches implementation, no scope creep.
|
||||||
|
Output: Tasks [N/N compliant]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Strategy
|
||||||
|
|
||||||
|
- **1**: `feat(server): add whitespace param to git diff + write_file endpoint`
|
||||||
|
- **2**: `feat(web): diff preferences hook, toolbar toggles, split layout`
|
||||||
|
- **3**: `feat(web): inline diff comments with zustand store`
|
||||||
|
- **4**: `feat(web): in-browser file editing in file tree`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
### Verification Commands
|
||||||
|
```bash
|
||||||
|
pnpm -C apps/web build # Must pass
|
||||||
|
pnpm -C apps/server build # Must pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Final Checklist
|
||||||
|
- [ ] Side-by-side diff renders correctly
|
||||||
|
- [ ] Hide whitespace re-fetches with `-w`
|
||||||
|
- [ ] Wrap lines toggles CSS
|
||||||
|
- [ ] Expand/Collapse all toggles
|
||||||
|
- [ ] Inline comments: create, read, update, delete
|
||||||
|
- [ ] File editing: read, modify, save, cancel
|
||||||
|
- [ ] All preferences survive page reload
|
||||||
1015
.omo/plans/openspec-cleanup.md
Normal file
1015
.omo/plans/openspec-cleanup.md
Normal file
File diff suppressed because it is too large
Load Diff
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,7 +2,29 @@
|
|||||||
|
|
||||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||||
|
|
||||||
## v2.7.20-arena-pane — 2026-06-06
|
## v2.8.0-fork-lifts — 2026-06-07
|
||||||
|
|
||||||
|
Completes the eight fork-lift integrations from `/opt/forks` into BooCode: boocontext sidecar upgrade, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol enhancements, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards (truncation + dropped imports) and the TokenScope analyzer/persist module. Closes the fork-lifts-mit epic.
|
||||||
|
|
||||||
|
**boocontext sidecar (Phase 3):** Upgrades the `codecontext` container from the old Go MCP server to the boocontext Node.js MCP aggregator. Multi-stage Dockerfile builds boocontext from `/opt/forks/boocontext` alongside the HTTP shim. `shim.go` gains `CODECONTEXT_CHILD` env-var support and three new HTTP routes for symbols, callgraph, and blast radius. Three TypeScript tool wrappers (`get_symbol_details`, `get_call_graph`, `get_blast_radius`) registered on the server, with blast radius added to the synthesis pipeline. Docker-compose env vars configure child MCP paths (tree-sitter-analyzer, type-inject).
|
||||||
|
|
||||||
|
**LSP integration (Phase 4):** Six-file `lsp/` module in the coder with config, JSON-RPC stdio client, lazy server-manager (per-project pool, 5-min idle shutdown), and operations (diagnostics, goto-definition, find-references). Three read-only agent tools registered — `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`. TypeScript/JavaScript only in v1.
|
||||||
|
|
||||||
|
**DCP clean-room (Phase 5):** Seven-file `dcp/` module in the server inference pipeline. Consecutive identical tool_call+tool_result pairs are deduplicated; failed/empty tool results are purged via configurable window. Orchestrated by `transformMessages()` running before `buildMessagesPayload` in `turn.ts`. Clean-room reimplementation — AGPL source was referenced for behavior only. 10 unit tests.
|
||||||
|
|
||||||
|
**Institutional memory (Phase 6):** Eight-file `memory/` module with file-based recall. Hierarchical 4-scope scan (global → home → project → session) under `.boocode/memory/`. Keyword/tag relevance matching at prompt assembly. Injected as a `<boocode-memory>` block in the system prompt. v1 recall-only — extract/dream deferred.
|
||||||
|
|
||||||
|
**Subagent protocol (Phase 7):** `AgentCapabilitiesSchema` in contracts with `supportsStreaming`, `supportsReasoningStream`, `supportsBackgroundExecution` flags. `ProviderSnapshotEntry` gains the two streaming capability fields. `new_task` tool gets a `background` mode flag for non-blocking dispatch. Flow-runner already supported per-step model override.
|
||||||
|
|
||||||
|
**Plugin host (Phase 8):** Typed hook registry in `plugins/host.ts` with `registerHook`/`emitHook` for five lifecycle events: `tool.execute.before`, `tool.execute.after`, `turn.start`, `turn.end`, `task.terminal`. Patterns-only from oh-my-openagent (SUL — no code copy).
|
||||||
|
|
||||||
|
**Inference reliability (Phase 9):** `tool-shim.ts` recovers XML/JSON tool calls from plain-text model output (e.g. Qwen inline format). `loop-detectors.ts` catches content-repeat and tool-loop patterns. Existing doom-loop detection remains — detectors are additive.
|
||||||
|
|
||||||
|
**Edit safety guards (Wave 1):** `edit-guards.ts` rejects catastrophic truncation (>60% chars AND >50% lines). `edit-guards-imports.ts` detects dropped import statements. Both run in `pending_changes.ts` immediately before `writeFileAtomic`.
|
||||||
|
|
||||||
|
**TokenScope (Wave 2):** `TokenBreakdownSchema` in contracts with system/user/assistant/tools/reasoning categories. `token-analysis/` module with analyzer and DB persistence. `ContestantShape.token_breakdown` field and `token_breakdown` JSONB column on `contestants`/`tasks` tables. Arena `computeBenchmark` accepts and returns token breakdown.
|
||||||
|
|
||||||
|
**Build:** Server 649 ✅ Coder 471 ✅ Contracts ✅ — all green.
|
||||||
|
|
||||||
Adds the **Arena** pane for running the same prompt against 2–6 AI competitors simultaneously and picking the best result. A Battle is one Arena run: pick a battle type (Coding — backend+model with git worktrees producing diffs; or Q&A — BooChat persona+model producing text), write or generate a prompt, add contestants, and hit Start. Contestants are scheduled in two concurrent lanes — the local lane (llama-swap models, serial) and the cloud lane (Claude Code, OpenCode-on-cloud, parallel). The lane scheduler captures wall-clock duration for every contestant and tokens/sec for local models. When all contestants finish, a two-stage analysis (digest then judge) auto-runs on the DEFAULT_MODEL, writing `analysis.md` naming a winner; the user can override the winner per-row or trigger cross-examination. Results land in `/<project-root>/Arena/<dated-battle>/` with per-contestant `result.md`, diff patches for coding, and `manifest.json`. Replaces the old API-only `POST /api/arena` with dedicated `battles`/`contestants`/`cross_examinations` tables and full UI. Also adds a `DiffView` component with line-by-line colored unified diff and a per-row dropdown for winner override. Built on `v2.7.18-permission-modes`; pairs conceptually with the earlier `v2.7.17-orchestrator` multi-agent work (both share the pane kind pattern and `onTaskTerminal` hook).
|
Adds the **Arena** pane for running the same prompt against 2–6 AI competitors simultaneously and picking the best result. A Battle is one Arena run: pick a battle type (Coding — backend+model with git worktrees producing diffs; or Q&A — BooChat persona+model producing text), write or generate a prompt, add contestants, and hit Start. Contestants are scheduled in two concurrent lanes — the local lane (llama-swap models, serial) and the cloud lane (Claude Code, OpenCode-on-cloud, parallel). The lane scheduler captures wall-clock duration for every contestant and tokens/sec for local models. When all contestants finish, a two-stage analysis (digest then judge) auto-runs on the DEFAULT_MODEL, writing `analysis.md` naming a winner; the user can override the winner per-row or trigger cross-examination. Results land in `/<project-root>/Arena/<dated-battle>/` with per-contestant `result.md`, diff patches for coding, and `manifest.json`. Replaces the old API-only `POST /api/arena` with dedicated `battles`/`contestants`/`cross_examinations` tables and full UI. Also adds a `DiffView` component with line-by-line colored unified diff and a per-row dropdown for winner override. Built on `v2.7.18-permission-modes`; pairs conceptually with the earlier `v2.7.17-orchestrator` multi-agent work (both share the pane kind pattern and `onTaskTerminal` hook).
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Current focus
|
# Current focus
|
||||||
|
|
||||||
Last updated: 2026-06-05
|
Last updated: 2026-06-07
|
||||||
|
|
||||||
- **Last shipped:** `v2.7.18-permission-modes` (2026-06-05) — unified Plan/Ask/Bypass permission picker in the BooCoder composer (incl. native-BooCode auto-apply on Bypass).
|
- **Last shipped:** `v2.8.0-fork-lifts` (2026-06-07) — eight fork-lift integrations from `/opt/forks`: boocontext sidecar, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards and TokenScope analyzer/persist module.
|
||||||
- **Branch:** `main`
|
- **Branch:** `main`
|
||||||
- **In progress:** nothing committed — dogfooding the Orchestrator to surface the next real backlog. Claude Agent-SDK backend enabled (`CLAUDE_SDK_BACKEND`). Optional/exploratory: verify-gate ensembler over pending changes.
|
- **In progress:** nothing committed — all phases 3-9 of fork-lifts-mit epic are shipped. Optional/exploratory: verify-gate ensembler over pending changes; web Arena token UI display.
|
||||||
|
|
||||||
See `CHANGELOG.md` for the full shipped history. That file is always authoritative; this file is a quick orientation pointer only.
|
See `CHANGELOG.md` for the full shipped history. That file is always authoritative; this file is a quick orientation pointer only.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { loadConfig } from './config.js';
|
|||||||
import { getPool, closeDb } from './db.js';
|
import { getPool, closeDb } from './db.js';
|
||||||
import { registerHealthRoutes } from './routes/health.js';
|
import { registerHealthRoutes } from './routes/health.js';
|
||||||
import { registerTerminalRoutes } from './routes/terminals.js';
|
import { registerTerminalRoutes } from './routes/terminals.js';
|
||||||
|
import { registerSessionRoutes } from './routes/sessions.js';
|
||||||
import { registerWsAttachRoute } from './ws/attach.js';
|
import { registerWsAttachRoute } from './ws/attach.js';
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@@ -33,6 +34,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
registerHealthRoutes(app);
|
registerHealthRoutes(app);
|
||||||
registerTerminalRoutes(app, config.TMUX_CONF_PATH);
|
registerTerminalRoutes(app, config.TMUX_CONF_PATH);
|
||||||
|
registerSessionRoutes(app);
|
||||||
registerWsAttachRoute(app, config.TMUX_CONF_PATH);
|
registerWsAttachRoute(app, config.TMUX_CONF_PATH);
|
||||||
|
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
|
|||||||
44
apps/booterm/src/pty/registry.ts
Normal file
44
apps/booterm/src/pty/registry.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export interface SessionMeta {
|
||||||
|
paneId: string;
|
||||||
|
sessionId: string;
|
||||||
|
projectPath: string;
|
||||||
|
title?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
lastActivityAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, SessionMeta>();
|
||||||
|
|
||||||
|
export function register(
|
||||||
|
sessionId: string,
|
||||||
|
paneId: string,
|
||||||
|
projectPath: string,
|
||||||
|
title?: string,
|
||||||
|
): void {
|
||||||
|
const now = new Date();
|
||||||
|
const existing = sessions.get(paneId);
|
||||||
|
if (existing) {
|
||||||
|
existing.lastActivityAt = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessions.set(paneId, {
|
||||||
|
paneId,
|
||||||
|
sessionId,
|
||||||
|
projectPath,
|
||||||
|
title,
|
||||||
|
createdAt: now,
|
||||||
|
lastActivityAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregister(paneId: string): void {
|
||||||
|
sessions.delete(paneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function list(): SessionMeta[] {
|
||||||
|
return Array.from(sessions.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get(paneId: string): SessionMeta | undefined {
|
||||||
|
return sessions.get(paneId);
|
||||||
|
}
|
||||||
18
apps/booterm/src/routes/sessions.ts
Normal file
18
apps/booterm/src/routes/sessions.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { list } from '../pty/registry.js';
|
||||||
|
|
||||||
|
export function registerSessionRoutes(app: FastifyInstance): void {
|
||||||
|
app.get('/api/term/sessions', async (_req, reply) => {
|
||||||
|
const active = list();
|
||||||
|
return reply.code(200).send({
|
||||||
|
sessions: active.map((s) => ({
|
||||||
|
paneId: s.paneId,
|
||||||
|
sessionId: s.sessionId,
|
||||||
|
projectPath: s.projectPath,
|
||||||
|
title: s.title ?? null,
|
||||||
|
createdAt: s.createdAt.toISOString(),
|
||||||
|
lastActivityAt: s.lastActivityAt.toISOString(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../pty/manager.js';
|
} from '../pty/manager.js';
|
||||||
import { attachPty } from '../pty/pty.js';
|
import { attachPty } from '../pty/pty.js';
|
||||||
import { getUser } from '../auth.js';
|
import { getUser } from '../auth.js';
|
||||||
|
import { register, unregister } from '../pty/registry.js';
|
||||||
|
|
||||||
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
|
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
|
||||||
app.get<{
|
app.get<{
|
||||||
@@ -57,6 +58,8 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
register(sid, pid, session.project_path);
|
||||||
|
|
||||||
let handle: IPty;
|
let handle: IPty;
|
||||||
try {
|
try {
|
||||||
handle = attachPty({
|
handle = attachPty({
|
||||||
@@ -157,6 +160,7 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
|
|||||||
// teardown happens via the /kill route called from the frontend when the
|
// teardown happens via the /kill route called from the frontend when the
|
||||||
// user closes the pane.
|
// user closes the pane.
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
|
unregister(pid);
|
||||||
try {
|
try {
|
||||||
handle.kill();
|
handle.kill();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -37,3 +37,10 @@
|
|||||||
|
|
||||||
- **In-app multi-agent conductor**: `services/flow-runner.ts` runs a flow by inserting each step as a `tasks` row (the existing dispatcher runs it) and advancing on a new `onTaskTerminal` dispatcher-deps hook; persisted in `flow_runs`/`flow_steps` (resumed at startup via `initResume`). The 22 conductor flow defs + Spine factory are re-homed under `src/conductor/`. Pure scheduler/resume helpers in `flow-runner-decisions.ts`. Full design: `openspec/changes/archived/orchestrator/`.
|
- **In-app multi-agent conductor**: `services/flow-runner.ts` runs a flow by inserting each step as a `tasks` row (the existing dispatcher runs it) and advancing on a new `onTaskTerminal` dispatcher-deps hook; persisted in `flow_runs`/`flow_steps` (resumed at startup via `initResume`). The 22 conductor flow defs + Spine factory are re-homed under `src/conductor/`. Pure scheduler/resume helpers in `flow-runner-decisions.ts`. Full design: `openspec/changes/archived/orchestrator/`.
|
||||||
- **Read-only is load-bearing — don't add a dispatch path that bypasses it.** Every step dispatches `agent='qwen', mode_id='plan'`; `dispatcher.ts` force-routes qwen+plan to the PTY `--approval-mode plan` gate and HARD-FAILS the task (never falls to write-capable native inference) when qwen is unavailable (`shouldFailOnMissingAgent`). `BOOCODE_TOOLS` gates BooChat's NATIVE inference tools only — it does NOT govern an external CLI agent (qwen/opencode bring their own write tools); read-only for a dispatched agent is the agent-layer mode (PTY `--approval-mode plan`; ACP `setSessionMode` is fail-OPEN by default, fail-CLOSED for `plan` via `READ_ONLY_MODE_IDS` in `acp-dispatch.ts`).
|
- **Read-only is load-bearing — don't add a dispatch path that bypasses it.** Every step dispatches `agent='qwen', mode_id='plan'`; `dispatcher.ts` force-routes qwen+plan to the PTY `--approval-mode plan` gate and HARD-FAILS the task (never falls to write-capable native inference) when qwen is unavailable (`shouldFailOnMissingAgent`). `BOOCODE_TOOLS` gates BooChat's NATIVE inference tools only — it does NOT govern an external CLI agent (qwen/opencode bring their own write tools); read-only for a dispatched agent is the agent-layer mode (PTY `--approval-mode plan`; ACP `setSessionMode` is fail-OPEN by default, fail-CLOSED for `plan` via `READ_ONLY_MODE_IDS` in `acp-dispatch.ts`).
|
||||||
|
|
||||||
|
## Edit safety guards (v2.8)
|
||||||
|
|
||||||
|
- **`services/edit-guards.ts`** — `validateEditResult(original, updated, filePath)` runs in `pending_changes.ts` immediately before `writeFileAtomic`. Rejects catastrophic truncation (>60% char loss AND >50% line loss). Throws a `formatGuardError` message that percolates to the agent as a visible error.
|
||||||
|
- **`services/edit-guards-imports.ts`** — `checkDroppedImports(original, updated, filePath)` detects removed import/require lines. Called alongside the truncation guard.
|
||||||
|
- Both guards run on the `/apply` path only (not on queue). Re-queued identical edits re-validate at apply time.
|
||||||
|
- Guard functions are pure — no DB or filesystem access. Easy to unit-test.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from './planning.js';
|
} from './planning.js';
|
||||||
import { adr, codingStandard, runbook, tdd, stakeholderSummary } from './authoring.js';
|
import { adr, codingStandard, runbook, tdd, stakeholderSummary } from './authoring.js';
|
||||||
import { codeReview } from './code-review.js';
|
import { codeReview } from './code-review.js';
|
||||||
|
import { parallelResearch } from './parallel-research.js';
|
||||||
|
|
||||||
const spines: Spine[] = [
|
const spines: Spine[] = [
|
||||||
// analysis / research
|
// analysis / research
|
||||||
@@ -53,7 +54,7 @@ const spines: Spine[] = [
|
|||||||
stakeholderSummary,
|
stakeholderSummary,
|
||||||
];
|
];
|
||||||
|
|
||||||
const bespoke: Flow[] = [codeReview];
|
const bespoke: Flow[] = [codeReview, parallelResearch];
|
||||||
|
|
||||||
const ALL: Flow[] = [...spines.map(buildSpineFlow), ...bespoke];
|
const ALL: Flow[] = [...spines.map(buildSpineFlow), ...bespoke];
|
||||||
|
|
||||||
|
|||||||
59
apps/coder/src/conductor/flows/parallel-research.ts
Normal file
59
apps/coder/src/conductor/flows/parallel-research.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Flow, Step, StepContext } from '../types.js';
|
||||||
|
|
||||||
|
const q = (ctx: StepContext) => String(ctx.input.question);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parallel research flow — dispatches 3 research agents simultaneously,
|
||||||
|
* then synthesizes the result on the first one to complete.
|
||||||
|
*/
|
||||||
|
export const parallelResearch: Flow = {
|
||||||
|
name: 'parallel-research',
|
||||||
|
description: 'Research from 3 angles in parallel, synthesize results on first completion',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'angle-web',
|
||||||
|
kind: 'agent',
|
||||||
|
agent: 'research-analyst',
|
||||||
|
run: (ctx) =>
|
||||||
|
`Research the following question from a web / prior-art perspective:\n\n${q(ctx)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'angle-code',
|
||||||
|
kind: 'agent',
|
||||||
|
agent: 'codebase-explorer',
|
||||||
|
deps: [],
|
||||||
|
run: (ctx) =>
|
||||||
|
`Research the following question from a codebase analysis perspective:\n\n${q(ctx)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'angle-security',
|
||||||
|
kind: 'agent',
|
||||||
|
agent: 'adversarial-security-analyst',
|
||||||
|
deps: [],
|
||||||
|
run: (ctx) =>
|
||||||
|
`Research the following question from a security perspective:\n\n${q(ctx)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'synthesize',
|
||||||
|
kind: 'code',
|
||||||
|
deps: ['angle-web', 'angle-code', 'angle-security'],
|
||||||
|
trigger_rule: 'one_success',
|
||||||
|
run: (ctx) => {
|
||||||
|
const web = ctx.results['angle-web'];
|
||||||
|
const code = ctx.results['angle-code'];
|
||||||
|
const security = ctx.results['angle-security'];
|
||||||
|
const parts = [
|
||||||
|
'# Parallel Research Synthesis',
|
||||||
|
'',
|
||||||
|
web ? `## Web Angle\n${web}` : '## Web Angle\n*(not yet completed)*',
|
||||||
|
code ? `## Code Angle\n${code}` : '## Code Angle\n*(not yet completed)*',
|
||||||
|
security ? `## Security Angle\n${security}` : '## Security Angle\n*(not yet completed)*',
|
||||||
|
];
|
||||||
|
return parts.join('\n\n');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
render: (ctx) => {
|
||||||
|
return ctx.results['synthesize'] ?? 'No synthesis produced.';
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -38,7 +38,9 @@ export interface StepContext {
|
|||||||
readonly model?: string;
|
readonly model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StepKind = 'agent' | 'code';
|
export type StepKind = 'agent' | 'code' | 'approval';
|
||||||
|
|
||||||
|
export type TriggerRule = 'all_success' | 'one_success' | 'all_done';
|
||||||
|
|
||||||
export interface Step {
|
export interface Step {
|
||||||
/** unique id within the flow; other steps depend on it by this id */
|
/** unique id within the flow; other steps depend on it by this id */
|
||||||
@@ -46,6 +48,8 @@ export interface Step {
|
|||||||
kind: StepKind;
|
kind: StepKind;
|
||||||
/** ids that must complete (or skip) before this step runs */
|
/** ids that must complete (or skip) before this step runs */
|
||||||
deps?: string[];
|
deps?: string[];
|
||||||
|
/** how dependency satisfaction is evaluated (default: all_success) */
|
||||||
|
trigger_rule?: TriggerRule;
|
||||||
/** for kind:'agent' — the persona file name under conductor/agents (no .md) */
|
/** for kind:'agent' — the persona file name under conductor/agents (no .md) */
|
||||||
agent?: string;
|
agent?: string;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { registerArenaRoutes } from './routes/arena.js';
|
|||||||
import { registerProviderRoutes } from './routes/providers.js';
|
import { registerProviderRoutes } from './routes/providers.js';
|
||||||
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
|
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
|
||||||
import { registerLifecycleRoutes } from './routes/lifecycle.js';
|
import { registerLifecycleRoutes } from './routes/lifecycle.js';
|
||||||
|
import { registerAnalyticsRoutes } from './routes/analytics.js';
|
||||||
import { registerWebSocket } from './routes/ws.js';
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
// Phase 4: dispatcher + agent probe
|
// Phase 4: dispatcher + agent probe
|
||||||
import { createDispatcher } from './services/dispatcher.js';
|
import { createDispatcher } from './services/dispatcher.js';
|
||||||
@@ -382,6 +383,7 @@ async function main() {
|
|||||||
registerProviderRoutes(app, sql, config);
|
registerProviderRoutes(app, sql, config);
|
||||||
registerWorktreeSafetyRoutes(app, sql);
|
registerWorktreeSafetyRoutes(app, sql);
|
||||||
registerLifecycleRoutes(app, sql);
|
registerLifecycleRoutes(app, sql);
|
||||||
|
registerAnalyticsRoutes(app, sql);
|
||||||
registerWebSocket(app, sql, broker);
|
registerWebSocket(app, sql, broker);
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
|
|||||||
42
apps/coder/src/plugins/host.ts
Normal file
42
apps/coder/src/plugins/host.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
export type HookName =
|
||||||
|
| 'tool.execute.before'
|
||||||
|
| 'tool.execute.after'
|
||||||
|
| 'turn.start'
|
||||||
|
| 'turn.end'
|
||||||
|
| 'task.terminal';
|
||||||
|
|
||||||
|
export interface ToolHookContext {
|
||||||
|
tool: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
projectRoot: string;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResultContext extends ToolHookContext {
|
||||||
|
result: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginHook = (ctx: any) => Promise<any>;
|
||||||
|
|
||||||
|
const hooks = new Map<HookName, PluginHook[]>();
|
||||||
|
|
||||||
|
export function registerHook(name: HookName, fn: PluginHook): void {
|
||||||
|
const list = hooks.get(name) || [];
|
||||||
|
list.push(fn);
|
||||||
|
hooks.set(name, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function emitHook(name: HookName, ctx: any): Promise<any> {
|
||||||
|
const list = hooks.get(name);
|
||||||
|
if (!list) return ctx;
|
||||||
|
let current = ctx;
|
||||||
|
for (const fn of list) {
|
||||||
|
const result = await fn(current);
|
||||||
|
if (result !== undefined) current = result;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHooks(): void {
|
||||||
|
hooks.clear();
|
||||||
|
}
|
||||||
78
apps/coder/src/routes/analytics.ts
Normal file
78
apps/coder/src/routes/analytics.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
|
||||||
|
// token-analyzer-ui: aggregate token/cost analytics across all agent_sessions.
|
||||||
|
// v1 — global view only (no per-project or per-user filtering).
|
||||||
|
|
||||||
|
export interface AnalyticsSummary {
|
||||||
|
total_input_tokens: number;
|
||||||
|
total_output_tokens: number;
|
||||||
|
total_cost: number;
|
||||||
|
session_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionAnalyticsRow {
|
||||||
|
session_id: string;
|
||||||
|
session_name: string;
|
||||||
|
total_input_tokens: number;
|
||||||
|
total_output_tokens: number;
|
||||||
|
total_cost: number;
|
||||||
|
last_active_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenBreakdownAgg {
|
||||||
|
category: string;
|
||||||
|
total_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void {
|
||||||
|
// GET /api/analytics/summary — aggregate totals across all agent_sessions.
|
||||||
|
app.get('/api/analytics/summary', async () => {
|
||||||
|
const [row] = await sql<AnalyticsSummary[]>`
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens,
|
||||||
|
COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens,
|
||||||
|
COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost,
|
||||||
|
COUNT(DISTINCT c.session_id)::INT AS session_count
|
||||||
|
FROM agent_sessions a
|
||||||
|
JOIN chats c ON c.id = a.chat_id
|
||||||
|
`;
|
||||||
|
return row ?? { total_input_tokens: 0, total_output_tokens: 0, total_cost: 0, session_count: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/analytics/sessions — per-session token/cost breakdown.
|
||||||
|
app.get('/api/analytics/sessions', async () => {
|
||||||
|
const rows = await sql<SessionAnalyticsRow[]>`
|
||||||
|
SELECT
|
||||||
|
c.session_id AS session_id,
|
||||||
|
s.name AS session_name,
|
||||||
|
COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens,
|
||||||
|
COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens,
|
||||||
|
COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost,
|
||||||
|
MAX(a.last_active_at) AS last_active_at
|
||||||
|
FROM agent_sessions a
|
||||||
|
JOIN chats c ON c.id = a.chat_id
|
||||||
|
JOIN sessions s ON s.id = c.session_id
|
||||||
|
GROUP BY c.session_id, s.name
|
||||||
|
ORDER BY MAX(a.last_active_at) DESC NULLS LAST
|
||||||
|
`;
|
||||||
|
return { sessions: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/analytics/token-breakdown — aggregate token_breakdown categories
|
||||||
|
// across all tasks that carry the JSONB field.
|
||||||
|
app.get('/api/analytics/token-breakdown', async () => {
|
||||||
|
const rows = await sql<{ category: string; total_tokens: number }[]>`
|
||||||
|
SELECT
|
||||||
|
key AS category,
|
||||||
|
SUM((value->>0)::BIGINT)::BIGINT AS total_tokens
|
||||||
|
FROM tasks,
|
||||||
|
LATERAL jsonb_each(token_breakdown)
|
||||||
|
WHERE token_breakdown IS NOT NULL
|
||||||
|
AND jsonb_typeof(token_breakdown) = 'object'
|
||||||
|
GROUP BY key
|
||||||
|
ORDER BY total_tokens DESC
|
||||||
|
`;
|
||||||
|
return { categories: rows };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -205,7 +205,7 @@ export function registerArenaRoutes(
|
|||||||
|
|
||||||
const contestants = await sql`
|
const contestants = await sql`
|
||||||
SELECT id, battle_id, identity, model, lane, task_id, worktree_id,
|
SELECT id, battle_id, identity, model, lane, task_id, worktree_id,
|
||||||
status, duration_ms, tokens_per_sec, cost_tokens, result_path, error,
|
status, duration_ms, tokens_per_sec, cost_tokens, token_breakdown, result_path, error,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM contestants
|
FROM contestants
|
||||||
WHERE battle_id = ${id}
|
WHERE battle_id = ${id}
|
||||||
|
|||||||
@@ -423,3 +423,18 @@ CREATE INDEX IF NOT EXISTS contestants_task_id_idx ON contestants(task_id);
|
|||||||
|
|
||||||
-- Cross-examination listing per battle.
|
-- Cross-examination listing per battle.
|
||||||
CREATE INDEX IF NOT EXISTS cross_examinations_battle_idx ON cross_examinations(battle_id);
|
CREATE INDEX IF NOT EXISTS cross_examinations_battle_idx ON cross_examinations(battle_id);
|
||||||
|
|
||||||
|
-- TokenScope: per-category token breakdown on arena contestants and tasks.
|
||||||
|
ALTER TABLE contestants ADD COLUMN IF NOT EXISTS token_breakdown JSONB;
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS token_breakdown JSONB;
|
||||||
|
|
||||||
|
-- Orchestrator flow step events (append-only event log for resume/replay).
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_step_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
run_id UUID NOT NULL REFERENCES flow_runs(id),
|
||||||
|
step_id VARCHAR(64) NOT NULL,
|
||||||
|
event VARCHAR(32) NOT NULL,
|
||||||
|
payload JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS flow_step_events_run_idx ON flow_step_events(run_id);
|
||||||
|
|||||||
@@ -162,6 +162,24 @@ describe('computeBenchmark', () => {
|
|||||||
expect(bench.durationMs).toBe(0);
|
expect(bench.durationMs).toBe(0);
|
||||||
expect(bench.tokensPerSec).toBeNull();
|
expect(bench.tokensPerSec).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes token breakdown when provided', () => {
|
||||||
|
const breakdown = {
|
||||||
|
system: 10,
|
||||||
|
user: 20,
|
||||||
|
assistant: 30,
|
||||||
|
tools: 40,
|
||||||
|
reasoning: 5,
|
||||||
|
total: 105,
|
||||||
|
};
|
||||||
|
const bench = computeBenchmark(t0, t1, 500, 'local', breakdown);
|
||||||
|
expect(bench.tokenBreakdown).toEqual(breakdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults token breakdown to null when omitted', () => {
|
||||||
|
const bench = computeBenchmark(t0, t1, 500, 'local');
|
||||||
|
expect(bench.tokenBreakdown).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── sanitizeSlug ────────────────────────────────────────────────────────────
|
// ─── sanitizeSlug ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
31
apps/coder/src/services/__tests__/trigger-rules.test.ts
Normal file
31
apps/coder/src/services/__tests__/trigger-rules.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { evaluateTriggerRule } from '../flow-runner-decisions.js';
|
||||||
|
|
||||||
|
describe('evaluateTriggerRule', () => {
|
||||||
|
it('all_success requires all deps done', () => {
|
||||||
|
expect(evaluateTriggerRule(['a', 'b'], new Set(['a', 'b']), new Set(), new Set())).toBe(true);
|
||||||
|
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('one_success fires on first completion', () => {
|
||||||
|
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set(), 'one_success')).toBe(true);
|
||||||
|
expect(evaluateTriggerRule(['a', 'b'], new Set(), new Set(), new Set(), 'one_success')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all_done includes skipped deps', () => {
|
||||||
|
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(['b']), new Set(), 'all_done')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all_success treats excluded deps as satisfied', () => {
|
||||||
|
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set(['b']))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to all_success', () => {
|
||||||
|
expect(evaluateTriggerRule(['a'], new Set(['a']), new Set(), new Set())).toBe(true);
|
||||||
|
expect(evaluateTriggerRule(['a'], new Set(), new Set(), new Set())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for empty deps', () => {
|
||||||
|
expect(evaluateTriggerRule([], new Set(), new Set(), new Set())).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
* A contestant's status lifecycle:
|
* A contestant's status lifecycle:
|
||||||
* queued → running → done | error
|
* queued → running → done | error
|
||||||
*/
|
*/
|
||||||
import type { BattleType, ContestantLane } from '@boocode/contracts/arena';
|
import type { BattleType, ContestantLane, TokenBreakdown } from '@boocode/contracts/arena';
|
||||||
|
|
||||||
// ─── Lane classification ──────────────────────────────────────────────────────
|
// ─── Lane classification ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -73,6 +73,7 @@ export function isBattleComplete(contestants: readonly { status: string }[]): bo
|
|||||||
export interface Benchmark {
|
export interface Benchmark {
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
tokensPerSec: number | null;
|
tokensPerSec: number | null;
|
||||||
|
tokenBreakdown: TokenBreakdown | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,13 +87,14 @@ export function computeBenchmark(
|
|||||||
endedAt: Date,
|
endedAt: Date,
|
||||||
costTokens: number | null,
|
costTokens: number | null,
|
||||||
lane: ContestantLane,
|
lane: ContestantLane,
|
||||||
|
tokenBreakdown: TokenBreakdown | null = null,
|
||||||
): Benchmark {
|
): Benchmark {
|
||||||
const durationMs = Math.max(0, endedAt.getTime() - startedAt.getTime());
|
const durationMs = Math.max(0, endedAt.getTime() - startedAt.getTime());
|
||||||
const tokensPerSec =
|
const tokensPerSec =
|
||||||
lane === 'local' && costTokens !== null && durationMs > 0
|
lane === 'local' && costTokens !== null && durationMs > 0
|
||||||
? (costTokens / durationMs) * 1000
|
? (costTokens / durationMs) * 1000
|
||||||
: null;
|
: null;
|
||||||
return { durationMs, tokensPerSec };
|
return { durationMs, tokensPerSec, tokenBreakdown };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Slug / path helpers ──────────────────────────────────────────────────────
|
// ─── Slug / path helpers ──────────────────────────────────────────────────────
|
||||||
|
|||||||
747
apps/coder/src/services/audit-session.ts
Normal file
747
apps/coder/src/services/audit-session.ts
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
import { mkdir, readFile, writeFile, readdir, rm, appendFile } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
export const RUNS_REL = '.boo/runs';
|
||||||
|
export const DAILY_REL = '.boo/runs/daily';
|
||||||
|
export const GUIDELINES_REL = '.boo/guidelines';
|
||||||
|
|
||||||
|
export interface SessionJson {
|
||||||
|
session_id: string;
|
||||||
|
task: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time?: string;
|
||||||
|
status: 'in_progress' | 'completed';
|
||||||
|
expected_record_types: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditTrailEntry {
|
||||||
|
timestamp: string;
|
||||||
|
record_type: string;
|
||||||
|
action_type: string;
|
||||||
|
tool?: string;
|
||||||
|
files?: string[];
|
||||||
|
detail?: string;
|
||||||
|
input?: string;
|
||||||
|
output?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexEntry {
|
||||||
|
id: string;
|
||||||
|
task: string;
|
||||||
|
status: string;
|
||||||
|
record_count: number;
|
||||||
|
start_time: string;
|
||||||
|
max_anomaly_level?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexJson {
|
||||||
|
entries: IndexEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartSessionResult {
|
||||||
|
sessionId: string;
|
||||||
|
contextSummary: {
|
||||||
|
recentActivity: IndexEntry[];
|
||||||
|
userCorrections: UserCorrectionRecord[];
|
||||||
|
unfinishedSessions: SessionJson[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndSessionResult {
|
||||||
|
sessionId: string;
|
||||||
|
integrity: IntegrityCheck[];
|
||||||
|
correctionCount: number;
|
||||||
|
summaryPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntegrityCheck {
|
||||||
|
check: string;
|
||||||
|
passed: boolean;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecoverResult {
|
||||||
|
level: number;
|
||||||
|
sessionId?: string;
|
||||||
|
task?: string;
|
||||||
|
recentActivity: IndexEntry[];
|
||||||
|
lastTrailEntries: AuditTrailEntry[];
|
||||||
|
userCorrections: UserCorrectionRecord[];
|
||||||
|
conclusions: string[];
|
||||||
|
dailyAnomalies: string[];
|
||||||
|
dailyBacklog: string[];
|
||||||
|
fullTrail?: AuditTrailEntry[];
|
||||||
|
anomalies?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyReport {
|
||||||
|
date: string;
|
||||||
|
sections: {
|
||||||
|
taskOverview: string;
|
||||||
|
operationStats: { label: string; count: number }[];
|
||||||
|
changes: { time: string; target: string; detail: string }[];
|
||||||
|
userFeedback: { feedback: string; resolution: string; persistedTo: string }[];
|
||||||
|
anomalyAlerts: string[];
|
||||||
|
backlogTracking: string[];
|
||||||
|
integritySummary: string;
|
||||||
|
};
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserCorrectionRecord {
|
||||||
|
record_type: 'conversation';
|
||||||
|
action_type: 'user_correction';
|
||||||
|
priority: 'critical_for_recovery';
|
||||||
|
timestamp: string;
|
||||||
|
original_claim: string;
|
||||||
|
correction: string;
|
||||||
|
principle_extracted: string;
|
||||||
|
persisted_to: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function runsDir(basePath?: string): string {
|
||||||
|
return resolve(basePath ?? process.cwd(), RUNS_REL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dailyDir(basePath?: string): string {
|
||||||
|
return resolve(basePath ?? process.cwd(), DAILY_REL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionDir(sessionId: string, basePath?: string): string {
|
||||||
|
return join(runsDir(basePath), sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSessionPath(basePath?: string): string {
|
||||||
|
return join(runsDir(basePath), '.current_session');
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexJsonPath(basePath?: string): string {
|
||||||
|
return join(runsDir(basePath), 'index.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditBufferPath(basePath?: string): string {
|
||||||
|
return join(runsDir(basePath), 'audit_buffer.jsonl');
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditPendingPath(basePath?: string): string {
|
||||||
|
return join(runsDir(basePath), 'audit_pending.jsonl');
|
||||||
|
}
|
||||||
|
|
||||||
|
function trailPath(sessionId: string, basePath?: string): string {
|
||||||
|
return join(sessionDir(sessionId, basePath), 'audit_trail.jsonl');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionJsonPath(sessionId: string, basePath?: string): string {
|
||||||
|
return join(sessionDir(sessionId, basePath), 'session.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryPath(sessionId: string, basePath?: string): string {
|
||||||
|
return join(sessionDir(sessionId, basePath), 'session_summary.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSessionId(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(now.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(now.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
return `adhoc_${y}${m}${d}_${hh}${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoNow(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(d?: Date): string {
|
||||||
|
const dt = d ?? new Date();
|
||||||
|
return `${dt.getFullYear()}${String(dt.getMonth() + 1).padStart(2, '0')}${String(dt.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTodayIso(iso: string): boolean {
|
||||||
|
return iso.startsWith(new Date().toISOString().slice(0, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson<T>(raw: string): T | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(p: string): Promise<void> {
|
||||||
|
if (!existsSync(p)) {
|
||||||
|
await mkdir(p, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLines(p: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const content = await readFile(p, 'utf-8');
|
||||||
|
return content.split('\n').filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonFile<T>(p: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(p, 'utf-8');
|
||||||
|
return tryParseJson<T>(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLine(p: string, line: string): Promise<void> {
|
||||||
|
return appendFile(p, line + '\n', 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearFile(p: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await writeFile(p, '', 'utf-8');
|
||||||
|
} catch {
|
||||||
|
// File may not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentSession(basePath?: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(currentSessionPath(basePath), 'utf-8');
|
||||||
|
return raw.trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionJson(sessionId: string, basePath?: string): Promise<SessionJson | null> {
|
||||||
|
return readJsonFile<SessionJson>(sessionJsonPath(sessionId, basePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIndex(basePath?: string): Promise<IndexJson | null> {
|
||||||
|
return readJsonFile<IndexJson>(indexJsonPath(basePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeIndex(entries: IndexEntry[], basePath?: string): Promise<void> {
|
||||||
|
await ensureDir(runsDir(basePath));
|
||||||
|
await writeFile(indexJsonPath(basePath), JSON.stringify({ entries }, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendIndex(sessionId: string, task: string, basePath?: string): Promise<void> {
|
||||||
|
const existing = await getIndex(basePath);
|
||||||
|
const entry: IndexEntry = {
|
||||||
|
id: sessionId,
|
||||||
|
task,
|
||||||
|
status: 'in_progress',
|
||||||
|
record_count: 0,
|
||||||
|
start_time: isoNow(),
|
||||||
|
};
|
||||||
|
const entries = [entry, ...(existing?.entries ?? [])].slice(0, 100);
|
||||||
|
await writeIndex(entries, basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateIndexStatus(sessionId: string, status: string, basePath?: string): Promise<void> {
|
||||||
|
const idx = await getIndex(basePath);
|
||||||
|
if (!idx) return;
|
||||||
|
for (const e of idx.entries) {
|
||||||
|
if (e.id === sessionId) {
|
||||||
|
e.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeIndex(idx.entries, basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startSession(task: string, basePath?: string): Promise<StartSessionResult> {
|
||||||
|
const sessionId = generateSessionId();
|
||||||
|
const sDir = sessionDir(sessionId, basePath);
|
||||||
|
await ensureDir(sDir);
|
||||||
|
|
||||||
|
const session: SessionJson = {
|
||||||
|
session_id: sessionId,
|
||||||
|
task,
|
||||||
|
start_time: isoNow(),
|
||||||
|
status: 'in_progress',
|
||||||
|
expected_record_types: ['data', 'change', 'conversation'],
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeFile(sessionJsonPath(sessionId, basePath), JSON.stringify(session, null, 2), 'utf-8');
|
||||||
|
await writeFile(currentSessionPath(basePath), sessionId, 'utf-8');
|
||||||
|
await appendIndex(sessionId, task, basePath);
|
||||||
|
|
||||||
|
// L0 context recovery
|
||||||
|
const idx = await getIndex(basePath);
|
||||||
|
const recentActivity = idx?.entries.slice(0, 5) ?? [];
|
||||||
|
|
||||||
|
// L2 user correction scan
|
||||||
|
const allCorrections = await scanAllTrailsForCorrections(basePath);
|
||||||
|
|
||||||
|
// Check for unfinished sessions
|
||||||
|
const unfinishedSessions = await findUnfinishedSessions(basePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
contextSummary: {
|
||||||
|
recentActivity,
|
||||||
|
userCorrections: allCorrections,
|
||||||
|
unfinishedSessions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findUnfinishedSessions(basePath?: string): Promise<SessionJson[]> {
|
||||||
|
const rDir = runsDir(basePath);
|
||||||
|
if (!existsSync(rDir)) return [];
|
||||||
|
|
||||||
|
const entries = await readdir(rDir, { withFileTypes: true });
|
||||||
|
const unfinished: SessionJson[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const sess = await getSessionJson(entry.name, basePath);
|
||||||
|
if (sess && sess.status === 'in_progress') {
|
||||||
|
unfinished.push(sess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unfinished;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanAllTrailsForCorrections(basePath?: string): Promise<UserCorrectionRecord[]> {
|
||||||
|
const rDir = runsDir(basePath);
|
||||||
|
if (!existsSync(rDir)) return [];
|
||||||
|
|
||||||
|
const entries = await readdir(rDir, { withFileTypes: true });
|
||||||
|
const corrections: UserCorrectionRecord[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const lines = await readLines(trailPath(entry.name, basePath));
|
||||||
|
for (const line of lines) {
|
||||||
|
const record = tryParseJson<UserCorrectionRecord>(line);
|
||||||
|
if (record?.action_type === 'user_correction') {
|
||||||
|
corrections.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also scan audit_pending.jsonl
|
||||||
|
const pendingLines = await readLines(auditPendingPath(basePath));
|
||||||
|
for (const line of pendingLines) {
|
||||||
|
const record = tryParseJson<UserCorrectionRecord>(line);
|
||||||
|
if (record?.action_type === 'user_correction') {
|
||||||
|
corrections.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return corrections;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function endSession(basePath?: string): Promise<EndSessionResult | null> {
|
||||||
|
const sessionId = await getCurrentSession(basePath);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
|
||||||
|
const sDir = sessionDir(sessionId, basePath);
|
||||||
|
await ensureDir(sDir);
|
||||||
|
|
||||||
|
// Collect remaining buffer data
|
||||||
|
const bufferLines = await readLines(auditBufferPath(basePath));
|
||||||
|
const pendingLines = await readLines(auditPendingPath(basePath));
|
||||||
|
const allRemaining = [...bufferLines, ...pendingLines];
|
||||||
|
|
||||||
|
// Append to audit_trail.jsonl
|
||||||
|
const trail = trailPath(sessionId, basePath);
|
||||||
|
if (allRemaining.length > 0) {
|
||||||
|
await appendFile(trail, allRemaining.join('\n') + '\n', 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear buffer files
|
||||||
|
await clearFile(auditBufferPath(basePath));
|
||||||
|
await clearFile(auditPendingPath(basePath));
|
||||||
|
|
||||||
|
// Read current trail for stats
|
||||||
|
const trailLines = await readLines(trail);
|
||||||
|
|
||||||
|
// Extract user_correction records
|
||||||
|
const corrections: UserCorrectionRecord[] = [];
|
||||||
|
for (const line of trailLines) {
|
||||||
|
const record = tryParseJson<UserCorrectionRecord>(line);
|
||||||
|
if (record?.action_type === 'user_correction') {
|
||||||
|
corrections.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integrity checks
|
||||||
|
const integrity: IntegrityCheck[] = [
|
||||||
|
{
|
||||||
|
check: 'Audit records exist',
|
||||||
|
passed: trailLines.length > 0,
|
||||||
|
detail: trailLines.length > 0 ? `${trailLines.length} records` : 'No audit records found',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
check: 'File modifications tracked',
|
||||||
|
passed: trailLines.some((l) => {
|
||||||
|
const r = tryParseJson<AuditTrailEntry>(l);
|
||||||
|
return r && (r.tool === 'Write' || r.tool === 'Edit');
|
||||||
|
}),
|
||||||
|
detail: 'Checking for Write/Edit tool entries',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
check: 'User corrections persisted',
|
||||||
|
passed: corrections.every((c) => (c.persisted_to?.length ?? 0) > 0),
|
||||||
|
detail: corrections.length > 0
|
||||||
|
? `${corrections.length} corrections found, ${corrections.filter((c) => (c.persisted_to?.length ?? 0) > 0).length} persisted`
|
||||||
|
: 'No corrections to persist',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate session summary
|
||||||
|
const summaryContent = generateSessionSummary(sessionId, trailLines, corrections);
|
||||||
|
const summaryFile = summaryPath(sessionId, basePath);
|
||||||
|
await writeFile(summaryFile, summaryContent, 'utf-8');
|
||||||
|
|
||||||
|
// Update session.json
|
||||||
|
const session = await getSessionJson(sessionId, basePath);
|
||||||
|
if (session) {
|
||||||
|
session.status = 'completed';
|
||||||
|
session.end_time = isoNow();
|
||||||
|
await writeFile(sessionJsonPath(sessionId, basePath), JSON.stringify(session, null, 2), 'utf-8');
|
||||||
|
await updateIndexStatus(sessionId, 'completed', basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update index.json record count
|
||||||
|
const idx = await getIndex(basePath);
|
||||||
|
if (idx) {
|
||||||
|
for (const e of idx.entries) {
|
||||||
|
if (e.id === sessionId) {
|
||||||
|
e.record_count = trailLines.length;
|
||||||
|
e.status = 'completed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeIndex(idx.entries, basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear .current_session
|
||||||
|
try {
|
||||||
|
await rm(currentSessionPath(basePath));
|
||||||
|
} catch {
|
||||||
|
// Ok if already gone
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
integrity,
|
||||||
|
correctionCount: corrections.length,
|
||||||
|
summaryPath: summaryFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSessionSummary(
|
||||||
|
sessionId: string,
|
||||||
|
trailLines: string[],
|
||||||
|
corrections: UserCorrectionRecord[],
|
||||||
|
): string {
|
||||||
|
const actions: string[] = [];
|
||||||
|
const outputs: string[] = [];
|
||||||
|
|
||||||
|
for (const line of trailLines) {
|
||||||
|
const record = tryParseJson<AuditTrailEntry>(line);
|
||||||
|
if (record) {
|
||||||
|
if (record.action_type) actions.push(record.action_type);
|
||||||
|
if (record.output) outputs.push(record.output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
`# Session Summary | ${sessionId}`,
|
||||||
|
'',
|
||||||
|
`## Time: ${isoNow()}`,
|
||||||
|
`## Status: completed`,
|
||||||
|
'',
|
||||||
|
'## Completed work',
|
||||||
|
...actions.map((a) => `- ${a}`),
|
||||||
|
'',
|
||||||
|
'## Key conclusions',
|
||||||
|
...outputs.map((o) => `- ${o}`),
|
||||||
|
'',
|
||||||
|
'## User corrections',
|
||||||
|
...(corrections.length > 0
|
||||||
|
? corrections.map((c) => `- ${c.original_claim} → ${c.correction} (${c.principle_extracted})`)
|
||||||
|
: ['- None']),
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoverSession(
|
||||||
|
level: number,
|
||||||
|
specificSessionId?: string,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<RecoverResult> {
|
||||||
|
const result: RecoverResult = { level, recentActivity: [], lastTrailEntries: [], userCorrections: [], conclusions: [], dailyAnomalies: [], dailyBacklog: [] };
|
||||||
|
|
||||||
|
// L0: index summary
|
||||||
|
const idx = await getIndex(basePath);
|
||||||
|
result.recentActivity = idx?.entries.slice(0, 5) ?? [];
|
||||||
|
|
||||||
|
if (level === 0) return result;
|
||||||
|
|
||||||
|
// L1: current session + last 3 trail entries
|
||||||
|
let activeSessionId = specificSessionId ?? await getCurrentSession(basePath);
|
||||||
|
if (activeSessionId) {
|
||||||
|
result.sessionId = activeSessionId;
|
||||||
|
const session = await getSessionJson(activeSessionId, basePath);
|
||||||
|
if (session) {
|
||||||
|
result.task = session.task;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trailLines = await readLines(trailPath(activeSessionId, basePath));
|
||||||
|
result.lastTrailEntries = trailLines.slice(-3).map((l) => {
|
||||||
|
const r = tryParseJson<AuditTrailEntry>(l);
|
||||||
|
return r ?? { timestamp: '', record_type: '', action_type: '', input: l, output: l };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level === 1) return result;
|
||||||
|
|
||||||
|
// L2: user corrections + conclusions + daily anomalies
|
||||||
|
result.userCorrections = await scanAllTrailsForCorrections(basePath);
|
||||||
|
|
||||||
|
// Extract conclusions from trail entries
|
||||||
|
const allTrailLines = await readLines(trailPath(activeSessionId ?? '', basePath));
|
||||||
|
for (const line of allTrailLines) {
|
||||||
|
const record = tryParseJson<AuditTrailEntry>(line);
|
||||||
|
if (record?.output) {
|
||||||
|
result.conclusions.push(record.output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read daily reports for anomalies + backlog
|
||||||
|
const dDir = dailyDir(basePath);
|
||||||
|
if (existsSync(dDir)) {
|
||||||
|
const dailyFiles = (await readdir(dDir)).filter((f) => f.endsWith('_daily.md')).sort().reverse();
|
||||||
|
if (dailyFiles.length > 0) {
|
||||||
|
const latest = await readFile(join(dDir, dailyFiles[0]!), 'utf-8');
|
||||||
|
const anomalies = latest.match(/## (?:四|4).*?[\s\S]*?(?=##|$)/);
|
||||||
|
if (anomalies) result.dailyAnomalies.push(anomalies[0]);
|
||||||
|
const backlog = latest.match(/## (?:六|6).*?[\s\S]*?(?=##|$)/);
|
||||||
|
if (backlog) result.dailyBacklog.push(backlog[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level === 2) return result;
|
||||||
|
|
||||||
|
// L3: full trail + pending
|
||||||
|
if (level >= 3) {
|
||||||
|
if (activeSessionId) {
|
||||||
|
const fullLines = await readLines(trailPath(activeSessionId, basePath));
|
||||||
|
result.fullTrail = fullLines.map((l) => {
|
||||||
|
const r = tryParseJson<AuditTrailEntry>(l);
|
||||||
|
return r ?? { timestamp: '', record_type: '', action_type: '', input: l, output: l };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateDailyReport(
|
||||||
|
targetDate?: string,
|
||||||
|
review?: boolean,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<DailyReport> {
|
||||||
|
const date = targetDate ?? isoDate();
|
||||||
|
const idx = await getIndex(basePath);
|
||||||
|
const rDir = runsDir(basePath);
|
||||||
|
|
||||||
|
const todayEntries = (idx?.entries ?? []).filter((e) => e.start_time.startsWith(date.slice(0, 4) + '-' + date.slice(4, 6) + '-' + date.slice(6, 8)));
|
||||||
|
|
||||||
|
let totalWriteEdit = 0;
|
||||||
|
let totalBash = 0;
|
||||||
|
let totalAuditBlocks = 0;
|
||||||
|
const changes: { time: string; target: string; detail: string }[] = [];
|
||||||
|
const feedback: { feedback: string; resolution: string; persistedTo: string }[] = [];
|
||||||
|
const anomalies: string[] = [];
|
||||||
|
|
||||||
|
for (const entry of todayEntries) {
|
||||||
|
const lines = await readLines(trailPath(entry.id, basePath));
|
||||||
|
for (const line of lines) {
|
||||||
|
const record = tryParseJson<AuditTrailEntry>(line);
|
||||||
|
if (!record) continue;
|
||||||
|
if (record.tool === 'Write' || record.tool === 'Edit') totalWriteEdit++;
|
||||||
|
if (record.tool === 'Bash') totalBash++;
|
||||||
|
if (record.action_type === 'audit_block') totalAuditBlocks++;
|
||||||
|
if (record.tool && (record.tool === 'Write' || record.tool === 'Edit') && record.files) {
|
||||||
|
changes.push({ time: record.timestamp, target: record.files.join(', '), detail: record.detail ?? '' });
|
||||||
|
}
|
||||||
|
if (record.action_type === 'user_correction') {
|
||||||
|
const uc = record as unknown as UserCorrectionRecord;
|
||||||
|
feedback.push({ feedback: uc.original_claim, resolution: uc.correction, persistedTo: (uc.persisted_to ?? []).join(', ') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for anomalies.json
|
||||||
|
if (existsSync(rDir)) {
|
||||||
|
const sessionDirs = await readdir(rDir, { withFileTypes: true });
|
||||||
|
for (const d of sessionDirs) {
|
||||||
|
if (!d.isDirectory()) continue;
|
||||||
|
const anomPath = join(rDir, d.name, 'anomalies.json');
|
||||||
|
if (existsSync(anomPath)) {
|
||||||
|
const anomContent = await readFile(anomPath, 'utf-8');
|
||||||
|
anomalies.push(`[${d.name}] ${anomContent.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read previous day backlog
|
||||||
|
const prevDate = isoDate(new Date(Date.now() - 86400000));
|
||||||
|
let backlog: string[] = [];
|
||||||
|
const prevDailyPath = join(dailyDir(basePath), `${prevDate}_daily.md`);
|
||||||
|
if (existsSync(prevDailyPath)) {
|
||||||
|
const prevContent = await readFile(prevDailyPath, 'utf-8');
|
||||||
|
const m = prevContent.match(/## (?:六|6|明日待办)[\s\S]*?(?=##|$)/);
|
||||||
|
if (m) backlog = m[0].split('\n').filter((l) => l.trim().startsWith('-')).map((l) => l.replace(/^-\s*/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportPath = join(dailyDir(basePath), `${date}_daily.md`);
|
||||||
|
await ensureDir(dailyDir(basePath));
|
||||||
|
|
||||||
|
const sections = {
|
||||||
|
taskOverview: todayEntries.length > 0
|
||||||
|
? todayEntries.map((e) => `| ${e.id} | ${e.task} | ${e.status} | ${e.record_count} |`).join('\n')
|
||||||
|
: 'No activity',
|
||||||
|
operationStats: [
|
||||||
|
{ label: 'Write/Edit operations', count: totalWriteEdit },
|
||||||
|
{ label: 'Bash executions', count: totalBash },
|
||||||
|
{ label: 'Audit blocks', count: totalAuditBlocks },
|
||||||
|
],
|
||||||
|
changes,
|
||||||
|
userFeedback: feedback,
|
||||||
|
anomalyAlerts: anomalies,
|
||||||
|
backlogTracking: backlog,
|
||||||
|
integritySummary: [
|
||||||
|
`| All sessions have audit records | ${todayEntries.every((e) => e.record_count > 0) ? '✅' : '⚠️'} |`,
|
||||||
|
`| Audit blocks persisted | ${totalAuditBlocks > 0 ? '✅' : '⚠️'} |`,
|
||||||
|
`| User corrections persisted | ${feedback.every((f) => f.persistedTo.length > 0) ? '✅' : '⚠️'} |`,
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const reportContent = generateDailyReportContent(date, sections);
|
||||||
|
await writeFile(reportPath, reportContent, 'utf-8');
|
||||||
|
|
||||||
|
// If review mode, also generate morning review
|
||||||
|
if (review) {
|
||||||
|
const reviewPath = join(dailyDir(basePath), `${date}_morning_review.md`);
|
||||||
|
const reviewContent = generateMorningReview(sections, date);
|
||||||
|
await writeFile(reviewPath, reviewContent, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { date, sections, path: reportPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDailyReportContent(date: string, sections: DailyReport['sections']): string {
|
||||||
|
return [
|
||||||
|
`# Work Report | ${date}`,
|
||||||
|
'',
|
||||||
|
`> Auto-generated: ${isoNow()}`,
|
||||||
|
`> Data source: .boo/runs/index.json + session audit_trail`,
|
||||||
|
`> Coverage: ${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)} 00:00 — 23:59`,
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## I. Task Overview',
|
||||||
|
'',
|
||||||
|
'| Session ID | Task | Status | Records |',
|
||||||
|
'|-----------|------|--------|---------|',
|
||||||
|
sections.taskOverview,
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## II. Operation Stats',
|
||||||
|
'',
|
||||||
|
'| Metric | Count |',
|
||||||
|
'|--------|-------|',
|
||||||
|
...sections.operationStats.map((s) => `| ${s.label} | ${s.count} |`),
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## III. Change Records',
|
||||||
|
'',
|
||||||
|
...(sections.changes.length > 0
|
||||||
|
? ['| Time | Target | Detail |', '|------|--------|--------|', ...sections.changes.map((c) => `| ${c.time} | ${c.target} | ${c.detail} |`)]
|
||||||
|
: ['No changes recorded today.']),
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## IV. User Feedback & Corrections',
|
||||||
|
'',
|
||||||
|
...(sections.userFeedback.length > 0
|
||||||
|
? ['| Feedback | Resolution | Persisted To |', '|---------|------------|--------------|', ...sections.userFeedback.map((f) => `| ${f.feedback} | ${f.resolution} | ${f.persistedTo} |`)]
|
||||||
|
: ['None.']),
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## V. Anomaly Alerts',
|
||||||
|
'',
|
||||||
|
...(sections.anomalyAlerts.length > 0 ? sections.anomalyAlerts.map((a) => `- ${a}`) : ['None.']),
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## VI. Backlog Tracking',
|
||||||
|
'',
|
||||||
|
...(sections.backlogTracking.length > 0 ? sections.backlogTracking.map((b) => `- ${b}`) : ['None.']),
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## VII. Integrity Summary',
|
||||||
|
'',
|
||||||
|
'| Check | Result |',
|
||||||
|
'|-------|--------|',
|
||||||
|
sections.integritySummary,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMorningReview(sections: DailyReport['sections'], date: string): string {
|
||||||
|
const anomalies = sections.anomalyAlerts;
|
||||||
|
const hasUnhandledAnomalies = anomalies.some((a) => !a.includes('resolved'));
|
||||||
|
const hasUnpersistedFeedback = sections.userFeedback.some((f) => !f.persistedTo);
|
||||||
|
const hasIncompleteBacklog = sections.backlogTracking.length > 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
`# Morning Self-Review | ${date}`,
|
||||||
|
'',
|
||||||
|
`> Generated: ${isoNow()}`,
|
||||||
|
'',
|
||||||
|
'## Self-Correction Check',
|
||||||
|
'',
|
||||||
|
`- Unresolved anomalies: ${hasUnhandledAnomalies ? '⚠️ Yes — needs attention' : '✅ None'}`,
|
||||||
|
`- Unpersisted user feedback: ${hasUnpersistedFeedback ? '⚠️ Needs documentation' : '✅ All persisted'}`,
|
||||||
|
`- Outstanding backlog: ${hasIncompleteBacklog ? '⚠️ Carry-over items' : '✅ Clean slate'}`,
|
||||||
|
'',
|
||||||
|
'## Today\'s Recommended Priorities',
|
||||||
|
'',
|
||||||
|
...(sections.backlogTracking.length > 0
|
||||||
|
? sections.backlogTracking.map((b) => `- [ ] ${b} (carry-over)`)
|
||||||
|
: []),
|
||||||
|
'- [ ] Review yesterday\'s user feedback and persist any remaining corrections',
|
||||||
|
'- [ ] Continue highest-priority task from session overview',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureBooDirs(basePath?: string): Promise<void> {
|
||||||
|
await ensureDir(runsDir(basePath));
|
||||||
|
await ensureDir(dailyDir(basePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAuditBuffer(entry: AuditTrailEntry, basePath?: string): Promise<void> {
|
||||||
|
await ensureDir(runsDir(basePath));
|
||||||
|
await appendLine(auditBufferPath(basePath), JSON.stringify(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAuditPending(entry: AuditTrailEntry, basePath?: string): Promise<void> {
|
||||||
|
await ensureDir(runsDir(basePath));
|
||||||
|
await appendLine(auditPendingPath(basePath), JSON.stringify(entry));
|
||||||
|
}
|
||||||
204
apps/coder/src/services/behavioral/generation.ts
Normal file
204
apps/coder/src/services/behavioral/generation.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Schematic generator for behavioral guideline batches.
|
||||||
|
*
|
||||||
|
* Port of boocontext-audit/src/generation.ts — abstract LLM batch caller
|
||||||
|
* with temperature retry and structured output per batch type.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type GenerationInfo } from './matching.js';
|
||||||
|
|
||||||
|
// ─── Output types per batch ───
|
||||||
|
|
||||||
|
export interface ObservationalOutput {
|
||||||
|
checks: {
|
||||||
|
guideline_id: string;
|
||||||
|
condition: string;
|
||||||
|
rationale: string;
|
||||||
|
applies: boolean;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionableOutput {
|
||||||
|
checks: {
|
||||||
|
guideline_id: string;
|
||||||
|
condition: string;
|
||||||
|
action: string;
|
||||||
|
rationale: string;
|
||||||
|
applies: boolean;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviouslyAppliedOutput {
|
||||||
|
checks: {
|
||||||
|
guideline_id: string;
|
||||||
|
condition: string;
|
||||||
|
action_segment: string;
|
||||||
|
rationale: string;
|
||||||
|
is_still_applicable: boolean;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisambiguationOutput {
|
||||||
|
source_guideline_id: string;
|
||||||
|
rationale: string;
|
||||||
|
enriched_action: string;
|
||||||
|
targets: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseAnalysisOutput {
|
||||||
|
guideline_id: string;
|
||||||
|
condition: string;
|
||||||
|
was_followed: boolean;
|
||||||
|
rationale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Batch output map ───
|
||||||
|
|
||||||
|
export interface BatchOutputMap {
|
||||||
|
observational: ObservationalOutput;
|
||||||
|
actionable: ActionableOutput;
|
||||||
|
previously_applied: PreviouslyAppliedOutput;
|
||||||
|
disambiguation: DisambiguationOutput;
|
||||||
|
response_analysis: ResponseAnalysisOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BatchTypeKey = keyof BatchOutputMap;
|
||||||
|
|
||||||
|
export type OutputForBatch<T extends BatchTypeKey> = BatchOutputMap[T];
|
||||||
|
|
||||||
|
// ─── SchematicGenerator ───
|
||||||
|
|
||||||
|
export abstract class SchematicGenerator<TSchema> {
|
||||||
|
constructor(public modelName: string) {}
|
||||||
|
|
||||||
|
abstract generate(
|
||||||
|
prompt: string,
|
||||||
|
hints?: Record<string, unknown>,
|
||||||
|
): Promise<{
|
||||||
|
content: TSchema;
|
||||||
|
info: GenerationInfo;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default stub implementation that returns empty results.
|
||||||
|
* Replace with a real LLM caller in production.
|
||||||
|
*/
|
||||||
|
export class DefaultSchematicGenerator
|
||||||
|
implements SchematicGenerator<unknown>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
public modelName: string,
|
||||||
|
public defaultTemperature = 0.7,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generate(
|
||||||
|
_prompt: string,
|
||||||
|
hints?: Record<string, unknown>,
|
||||||
|
): Promise<{ content: unknown; info: GenerationInfo }> {
|
||||||
|
const temperature = (hints?.temperature as number) ?? this.defaultTemperature;
|
||||||
|
return {
|
||||||
|
content: {},
|
||||||
|
info: {
|
||||||
|
model: this.modelName,
|
||||||
|
duration: 0,
|
||||||
|
tokens: 0,
|
||||||
|
temperature,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Execution plans ───
|
||||||
|
|
||||||
|
export interface BatchExecutionPlan {
|
||||||
|
batchType: BatchTypeKey;
|
||||||
|
guidelines: { id: string; condition: string; action?: string | null }[];
|
||||||
|
priority: number;
|
||||||
|
independent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an ordered execution plan from categorized guideline collections.
|
||||||
|
* Groups are sorted by priority: previously_applied (fastest) first,
|
||||||
|
* then observational, actionable, disambiguation, low-criticality last.
|
||||||
|
*/
|
||||||
|
export function createExecutionPlan(
|
||||||
|
observational: { id: string; condition: string }[],
|
||||||
|
actionable: { id: string; condition: string; action: string }[],
|
||||||
|
previouslyApplied: { id: string; condition: string; action?: string | null }[],
|
||||||
|
disambiguationGroups: { source: string; targets: string[]; enrichedAction: string }[],
|
||||||
|
lowCriticality: { id: string; condition: string }[],
|
||||||
|
): BatchExecutionPlan[] {
|
||||||
|
const plans: BatchExecutionPlan[] = [];
|
||||||
|
|
||||||
|
if (observational.length > 0) {
|
||||||
|
plans.push({
|
||||||
|
batchType: 'observational',
|
||||||
|
guidelines: observational.map((g) => ({ id: g.id, condition: g.condition })),
|
||||||
|
priority: 1,
|
||||||
|
independent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionable.length > 0) {
|
||||||
|
plans.push({
|
||||||
|
batchType: 'actionable',
|
||||||
|
guidelines: actionable.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
condition: g.condition,
|
||||||
|
action: g.action,
|
||||||
|
})),
|
||||||
|
priority: 2,
|
||||||
|
independent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previouslyApplied.length > 0) {
|
||||||
|
plans.push({
|
||||||
|
batchType: 'previously_applied',
|
||||||
|
guidelines: previouslyApplied.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
condition: g.condition,
|
||||||
|
action: g.action,
|
||||||
|
})),
|
||||||
|
priority: 0,
|
||||||
|
independent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disambiguationGroups.length > 0) {
|
||||||
|
plans.push({
|
||||||
|
batchType: 'disambiguation',
|
||||||
|
guidelines: disambiguationGroups.map((g) => ({
|
||||||
|
id: g.source,
|
||||||
|
condition: g.enrichedAction,
|
||||||
|
})),
|
||||||
|
priority: 3,
|
||||||
|
independent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowCriticality.length > 0) {
|
||||||
|
plans.push({
|
||||||
|
batchType: 'observational',
|
||||||
|
guidelines: lowCriticality.map((g) => ({ id: g.id, condition: g.condition })),
|
||||||
|
priority: 10,
|
||||||
|
independent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return plans.sort((a, b) => a.priority - b.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute retry temperatures: base + 0.2 * attempt.
|
||||||
|
* Provides progressive temperature increases for failed calls.
|
||||||
|
*/
|
||||||
|
export function getRetryTemperatures(baseTemp: number, maxAttempts = 3): number[] {
|
||||||
|
const temps: number[] = [];
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
temps.push(baseTemp + i * 0.2);
|
||||||
|
}
|
||||||
|
return temps;
|
||||||
|
}
|
||||||
77
apps/coder/src/services/behavioral/index.ts
Normal file
77
apps/coder/src/services/behavioral/index.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Behavioral engine — multi-batch matcher and relational resolver.
|
||||||
|
*
|
||||||
|
* Import from the existing guideline-service.ts:
|
||||||
|
* import { MultiBatchMatcher } from './behavioral/matching.js';
|
||||||
|
* import { RelationalResolver } from './behavioral/resolver.js';
|
||||||
|
*/
|
||||||
|
|
||||||
|
// matching.ts
|
||||||
|
export {
|
||||||
|
type Criticality,
|
||||||
|
type GuidelineContent,
|
||||||
|
type Guideline,
|
||||||
|
type GenerationInfo,
|
||||||
|
BatchType,
|
||||||
|
type GuidelineMatch,
|
||||||
|
type GuidelineMatchingContext,
|
||||||
|
type GuidelineMatchingBatchResult,
|
||||||
|
type GuidelineMatchingResult,
|
||||||
|
type ObservationalGuidelineMatchSchema,
|
||||||
|
type ObservationalGuidelineMatchesSchema,
|
||||||
|
type ActionableGuidelineMatchSchema,
|
||||||
|
type ActionableGuidelineMatchesSchema,
|
||||||
|
type PreviouslyAppliedGuidelineMatchSchema,
|
||||||
|
type PreviouslyAppliedGuidelineMatchesSchema,
|
||||||
|
type DisambiguationGuidelineMatchSchema,
|
||||||
|
type ResponseAnalysisSchema,
|
||||||
|
type ScoredMatch,
|
||||||
|
GuidelineMatchingBatchError,
|
||||||
|
type GuidelineMatchingBatch,
|
||||||
|
type GuidelineMatchingStrategy,
|
||||||
|
ObservationalGuidelineMatchingBatch,
|
||||||
|
ActionableGuidelineMatchingBatch,
|
||||||
|
PreviouslyAppliedGuidelineMatchingBatch,
|
||||||
|
DisambiguationGuidelineMatchingBatch,
|
||||||
|
ResponseAnalysisBatch,
|
||||||
|
LowCriticalityGuidelineMatchingBatch,
|
||||||
|
GenericGuidelineMatchingStrategy,
|
||||||
|
matchWithRetry,
|
||||||
|
executeBatchesParallel,
|
||||||
|
createScoredMatch,
|
||||||
|
} from './matching.js';
|
||||||
|
|
||||||
|
// resolver.ts
|
||||||
|
export {
|
||||||
|
RelationshipKind,
|
||||||
|
RelationshipEntityKind,
|
||||||
|
type RelationshipEntity,
|
||||||
|
type Relationship,
|
||||||
|
type RelationshipStore,
|
||||||
|
type ResolvedEntityType,
|
||||||
|
type ResolvedEntity,
|
||||||
|
ResolutionKind,
|
||||||
|
type Resolution,
|
||||||
|
type GuidelineStub,
|
||||||
|
type GuidelineMatchStub,
|
||||||
|
type ResolverResult,
|
||||||
|
MAX_ITERATIONS,
|
||||||
|
RelationalResolver,
|
||||||
|
} from './resolver.js';
|
||||||
|
|
||||||
|
// generation.ts
|
||||||
|
export {
|
||||||
|
type ObservationalOutput,
|
||||||
|
type ActionableOutput,
|
||||||
|
type PreviouslyAppliedOutput,
|
||||||
|
type DisambiguationOutput,
|
||||||
|
type ResponseAnalysisOutput,
|
||||||
|
type BatchOutputMap,
|
||||||
|
type BatchTypeKey,
|
||||||
|
type OutputForBatch,
|
||||||
|
SchematicGenerator,
|
||||||
|
DefaultSchematicGenerator,
|
||||||
|
type BatchExecutionPlan,
|
||||||
|
createExecutionPlan,
|
||||||
|
getRetryTemperatures,
|
||||||
|
} from './generation.js';
|
||||||
435
apps/coder/src/services/behavioral/matching.ts
Normal file
435
apps/coder/src/services/behavioral/matching.ts
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
/**
|
||||||
|
* Multi-batch matcher for behavioral guidelines.
|
||||||
|
*
|
||||||
|
* Port of boocontext-audit/src/matching.ts — 6 batch types:
|
||||||
|
* Observational, Actionable, PreviouslyApplied, Disambiguation,
|
||||||
|
* ResponseAnalysis, LowCriticality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Guideline types (compatible with guideline-service.ts) ───
|
||||||
|
|
||||||
|
export type Criticality = 'low' | 'medium' | 'high';
|
||||||
|
|
||||||
|
export interface GuidelineContent {
|
||||||
|
condition: string;
|
||||||
|
action: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Guideline {
|
||||||
|
id: string;
|
||||||
|
content: GuidelineContent;
|
||||||
|
enabled: boolean;
|
||||||
|
criticality: Criticality;
|
||||||
|
priority: number;
|
||||||
|
labels: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
tags: string[];
|
||||||
|
title: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Generation info (self-contained to avoid circular dep) ───
|
||||||
|
|
||||||
|
export interface GenerationInfo {
|
||||||
|
model: string;
|
||||||
|
duration: number;
|
||||||
|
tokens: number;
|
||||||
|
temperature: number;
|
||||||
|
attempt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Batch type enum ───
|
||||||
|
|
||||||
|
export enum BatchType {
|
||||||
|
Observational = 'observational',
|
||||||
|
Actionable = 'actionable',
|
||||||
|
PreviouslyApplied = 'previously_applied',
|
||||||
|
Disambiguation = 'disambiguation',
|
||||||
|
ResponseAnalysis = 'response_analysis',
|
||||||
|
LowCriticality = 'low_criticality',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Match result types ───
|
||||||
|
|
||||||
|
export interface GuidelineMatch {
|
||||||
|
guideline: Guideline;
|
||||||
|
score: number;
|
||||||
|
rationale: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuidelineMatchingContext {
|
||||||
|
agent: string;
|
||||||
|
session: string;
|
||||||
|
customer: string;
|
||||||
|
contextVariables: Record<string, string>[];
|
||||||
|
interactionHistory: unknown[];
|
||||||
|
terms: string[];
|
||||||
|
capabilities?: string[];
|
||||||
|
stagedEvents?: unknown[];
|
||||||
|
activeJourneys?: unknown[];
|
||||||
|
journeyPaths?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuidelineMatchingBatchResult {
|
||||||
|
matches: GuidelineMatch[];
|
||||||
|
generationInfo: GenerationInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuidelineMatchingResult {
|
||||||
|
totalDuration: number;
|
||||||
|
batchCount: number;
|
||||||
|
batchGenerations: GenerationInfo[];
|
||||||
|
batches: GuidelineMatch[][];
|
||||||
|
matches: GuidelineMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Schema types for structured LLM output ───
|
||||||
|
|
||||||
|
export interface ObservationalGuidelineMatchSchema {
|
||||||
|
guideline_id: string;
|
||||||
|
condition: string;
|
||||||
|
rationale: string;
|
||||||
|
applies: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservationalGuidelineMatchesSchema {
|
||||||
|
checks: ObservationalGuidelineMatchSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionableGuidelineMatchSchema {
|
||||||
|
guideline_id: string;
|
||||||
|
condition: string;
|
||||||
|
action: string;
|
||||||
|
rationale: string;
|
||||||
|
applies: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionableGuidelineMatchesSchema {
|
||||||
|
checks: ActionableGuidelineMatchSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviouslyAppliedGuidelineMatchSchema {
|
||||||
|
guideline_id: string;
|
||||||
|
condition: string;
|
||||||
|
action_segment: string;
|
||||||
|
rationale: string;
|
||||||
|
is_still_applicable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviouslyAppliedGuidelineMatchesSchema {
|
||||||
|
checks: PreviouslyAppliedGuidelineMatchSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisambiguationGuidelineMatchSchema {
|
||||||
|
source_guideline_id: string;
|
||||||
|
rationale: string;
|
||||||
|
enriched_action: string;
|
||||||
|
targets: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseAnalysisSchema {
|
||||||
|
guideline_id: string;
|
||||||
|
condition: string;
|
||||||
|
was_followed: boolean;
|
||||||
|
rationale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoredMatch {
|
||||||
|
guideline_id: string;
|
||||||
|
score: number;
|
||||||
|
rationale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Matching batch contract ───
|
||||||
|
|
||||||
|
export class GuidelineMatchingBatchError extends Error {
|
||||||
|
constructor(message = 'Guideline Matching Batch failed') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'GuidelineMatchingBatchError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuidelineMatchingBatch {
|
||||||
|
readonly size: number;
|
||||||
|
process(): Promise<GuidelineMatchingBatchResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuidelineMatchingStrategy {
|
||||||
|
createMatchingBatches(
|
||||||
|
guidelines: Guideline[],
|
||||||
|
context: GuidelineMatchingContext,
|
||||||
|
): GuidelineMatchingBatch[];
|
||||||
|
|
||||||
|
transformMatches(matches: GuidelineMatch[]): GuidelineMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Batch implementations ───
|
||||||
|
|
||||||
|
function scoreFromApplies(applies: boolean): number {
|
||||||
|
return applies ? 10 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ObservationalGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||||
|
constructor(
|
||||||
|
public guidelines: Guideline[],
|
||||||
|
public context: GuidelineMatchingContext,
|
||||||
|
public generationInfo: GenerationInfo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.guidelines.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||||
|
const matches: GuidelineMatch[] = [];
|
||||||
|
for (const g of this.guidelines) {
|
||||||
|
if (g.content.action !== null && g.content.action !== undefined) continue;
|
||||||
|
matches.push({
|
||||||
|
guideline: g,
|
||||||
|
score: 10,
|
||||||
|
rationale: `Observational batch evaluated: "${g.content.condition}"`,
|
||||||
|
metadata: { batch_type: BatchType.Observational },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { matches, generationInfo: this.generationInfo };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionableGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||||
|
constructor(
|
||||||
|
public guidelines: Guideline[],
|
||||||
|
public context: GuidelineMatchingContext,
|
||||||
|
public generationInfo: GenerationInfo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.guidelines.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||||
|
const matches: GuidelineMatch[] = [];
|
||||||
|
for (const g of this.guidelines) {
|
||||||
|
if (g.content.action === null || g.content.action === undefined) continue;
|
||||||
|
if (g.content.action === '') continue;
|
||||||
|
matches.push({
|
||||||
|
guideline: g,
|
||||||
|
score: 10,
|
||||||
|
rationale: `Actionable batch evaluated: when "${g.content.condition}", then "${g.content.action}"`,
|
||||||
|
metadata: { batch_type: BatchType.Actionable },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { matches, generationInfo: this.generationInfo };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PreviouslyAppliedGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||||
|
constructor(
|
||||||
|
public guidelines: Guideline[],
|
||||||
|
public context: GuidelineMatchingContext,
|
||||||
|
public priorMatches: GuidelineMatch[],
|
||||||
|
public generationInfo: GenerationInfo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.guidelines.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||||
|
const alreadyApplied = new Set(
|
||||||
|
this.priorMatches.filter((m) => m.score >= 10).map((m) => m.guideline.id),
|
||||||
|
);
|
||||||
|
const matches: GuidelineMatch[] = [];
|
||||||
|
for (const g of this.guidelines) {
|
||||||
|
if (alreadyApplied.has(g.id)) {
|
||||||
|
matches.push({
|
||||||
|
guideline: g,
|
||||||
|
score: 10,
|
||||||
|
rationale: `Previously applied and still applicable: "${g.content.condition}"`,
|
||||||
|
metadata: { batch_type: BatchType.PreviouslyApplied },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { matches, generationInfo: this.generationInfo };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DisambiguationGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||||
|
constructor(
|
||||||
|
public disambiguationGuideline: Guideline,
|
||||||
|
public targets: Guideline[],
|
||||||
|
public context: GuidelineMatchingContext,
|
||||||
|
public generationInfo: GenerationInfo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return 1 + this.targets.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||||
|
const matches: GuidelineMatch[] = [];
|
||||||
|
matches.push({
|
||||||
|
guideline: this.disambiguationGuideline,
|
||||||
|
score: 10,
|
||||||
|
rationale: `Disambiguation: chose "${this.disambiguationGuideline.content.condition}" over targets`,
|
||||||
|
metadata: {
|
||||||
|
batch_type: BatchType.Disambiguation,
|
||||||
|
disambiguation: {
|
||||||
|
targets: this.targets.map((t) => t.id),
|
||||||
|
enriched_action: this.disambiguationGuideline.content.action ?? '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { matches, generationInfo: this.generationInfo };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResponseAnalysisBatch {
|
||||||
|
constructor(
|
||||||
|
public guidelineMatches: GuidelineMatch[],
|
||||||
|
public context: Record<string, unknown>,
|
||||||
|
public generationInfo: GenerationInfo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.guidelineMatches.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(): Promise<{ analyzed: unknown[]; generationInfo: GenerationInfo }> {
|
||||||
|
const analyzed = this.guidelineMatches.map((m) => ({
|
||||||
|
guideline: m.guideline,
|
||||||
|
is_previously_applied: m.score >= 10,
|
||||||
|
}));
|
||||||
|
return { analyzed, generationInfo: this.generationInfo };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LowCriticalityGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||||
|
constructor(
|
||||||
|
public guidelines: Guideline[],
|
||||||
|
public context: GuidelineMatchingContext,
|
||||||
|
public generationInfo: GenerationInfo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.guidelines.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||||
|
const matches: GuidelineMatch[] = [];
|
||||||
|
for (const g of this.guidelines) {
|
||||||
|
if (g.criticality !== 'low') continue;
|
||||||
|
matches.push({
|
||||||
|
guideline: g,
|
||||||
|
score: g.content.action ? 10 : 1,
|
||||||
|
rationale: `Low-criticality batch: "${g.content.condition}"`,
|
||||||
|
metadata: { batch_type: BatchType.LowCriticality },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { matches, generationInfo: this.generationInfo };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Strategy ───
|
||||||
|
|
||||||
|
export class GenericGuidelineMatchingStrategy implements GuidelineMatchingStrategy {
|
||||||
|
constructor(public generationInfo: GenerationInfo) {}
|
||||||
|
|
||||||
|
createMatchingBatches(
|
||||||
|
guidelines: Guideline[],
|
||||||
|
context: GuidelineMatchingContext,
|
||||||
|
): GuidelineMatchingBatch[] {
|
||||||
|
const observational: Guideline[] = [];
|
||||||
|
const actionable: Guideline[] = [];
|
||||||
|
const lowCriticality: Guideline[] = [];
|
||||||
|
const disambiguationCandidates: Guideline[] = [];
|
||||||
|
|
||||||
|
for (const g of guidelines) {
|
||||||
|
if (g.criticality === 'low') {
|
||||||
|
lowCriticality.push(g);
|
||||||
|
} else if (!g.content.action) {
|
||||||
|
disambiguationCandidates.push(g);
|
||||||
|
} else if (g.content.action) {
|
||||||
|
actionable.push(g);
|
||||||
|
} else {
|
||||||
|
observational.push(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const batches: GuidelineMatchingBatch[] = [];
|
||||||
|
|
||||||
|
if (observational.length > 0) {
|
||||||
|
batches.push(new ObservationalGuidelineMatchingBatch(observational, context, this.generationInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionable.length > 0) {
|
||||||
|
batches.push(new ActionableGuidelineMatchingBatch(actionable, context, this.generationInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowCriticality.length > 0) {
|
||||||
|
batches.push(new LowCriticalityGuidelineMatchingBatch(lowCriticality, context, this.generationInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
return batches;
|
||||||
|
}
|
||||||
|
|
||||||
|
transformMatches(matches: GuidelineMatch[]): GuidelineMatch[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return matches.filter((m) => {
|
||||||
|
const key = m.guideline.id;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Utilities ───
|
||||||
|
|
||||||
|
export async function matchWithRetry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
maxAttempts = 3,
|
||||||
|
_baseTemperature = 0.7,
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
if (attempt < maxAttempts - 1) {
|
||||||
|
// will retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBatchesParallel(
|
||||||
|
batches: GuidelineMatchingBatch[],
|
||||||
|
_generationInfo: GenerationInfo,
|
||||||
|
): Promise<GuidelineMatchingResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
const results = await Promise.all(
|
||||||
|
batches.map((batch) => matchWithRetry(() => batch.process())),
|
||||||
|
);
|
||||||
|
|
||||||
|
const allBatches = results.map((r) => r.matches);
|
||||||
|
const allMatches = allBatches.flat();
|
||||||
|
const allGenInfos = results.map((r) => r.generationInfo);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalDuration: Date.now() - start,
|
||||||
|
batchCount: batches.length,
|
||||||
|
batchGenerations: allGenInfos,
|
||||||
|
batches: allBatches,
|
||||||
|
matches: allMatches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScoredMatch(
|
||||||
|
guidelineId: string,
|
||||||
|
score: number,
|
||||||
|
rationale: string,
|
||||||
|
): ScoredMatch {
|
||||||
|
return { guideline_id: guidelineId, score, rationale };
|
||||||
|
}
|
||||||
355
apps/coder/src/services/behavioral/resolver.ts
Normal file
355
apps/coder/src/services/behavioral/resolver.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
/**
|
||||||
|
* Relational resolver for behavioral guidelines.
|
||||||
|
*
|
||||||
|
* Port of boocontext-audit/src/resolver.ts — resolves DEPENDS_ON,
|
||||||
|
* PRIORITIZES, ENTAILS, TAG_ALL, TAG_PRIORITIZES relationships
|
||||||
|
* with an iterative convergence loop.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Relationship types (self-contained) ───
|
||||||
|
|
||||||
|
export enum RelationshipKind {
|
||||||
|
DEPENDS_ON = 'depends_on',
|
||||||
|
PRIORITIZES = 'prioritizes',
|
||||||
|
ENTAILS = 'entails',
|
||||||
|
TAG_ALL = 'tag_all',
|
||||||
|
TAG_PRIORITIZES = 'tag_prioritizes',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum RelationshipEntityKind {
|
||||||
|
GUIDELINE = 'guideline',
|
||||||
|
TAG = 'tag',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelationshipEntity {
|
||||||
|
id: string;
|
||||||
|
kind: RelationshipEntityKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Relationship {
|
||||||
|
id: string;
|
||||||
|
creation_utc: string;
|
||||||
|
source: RelationshipEntity;
|
||||||
|
target: RelationshipEntity;
|
||||||
|
kind: RelationshipKind;
|
||||||
|
group_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal relationship store interface.
|
||||||
|
* The resolver only needs listRelationships. Implementations
|
||||||
|
* can back against files, postgres, or in-memory maps.
|
||||||
|
*/
|
||||||
|
export interface RelationshipStore {
|
||||||
|
listRelationships(
|
||||||
|
kind?: RelationshipKind,
|
||||||
|
sourceId?: string,
|
||||||
|
targetId?: string,
|
||||||
|
): Promise<Relationship[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resolution types ───
|
||||||
|
|
||||||
|
export type ResolvedEntityType = 'guideline' | 'journey' | 'tag';
|
||||||
|
|
||||||
|
export interface ResolvedEntity {
|
||||||
|
entityType: ResolvedEntityType;
|
||||||
|
entityId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ResolutionKind {
|
||||||
|
NONE = 'none',
|
||||||
|
UNMET_DEPENDENCY = 'unmet_dependency',
|
||||||
|
DEPRIORITIZED = 'deprioritized',
|
||||||
|
ENTAILED = 'entailed',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Resolution {
|
||||||
|
kind: ResolutionKind;
|
||||||
|
description: string;
|
||||||
|
relationshipId?: string;
|
||||||
|
counterparts?: ResolvedEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuidelineStub {
|
||||||
|
id: string;
|
||||||
|
priority: number;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuidelineMatchStub {
|
||||||
|
guideline: GuidelineStub;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolverResult {
|
||||||
|
matchedIds: Set<string>;
|
||||||
|
resolutions: Map<string, Resolution[]>;
|
||||||
|
converged: boolean;
|
||||||
|
iterations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constants ───
|
||||||
|
|
||||||
|
export const MAX_ITERATIONS = 100;
|
||||||
|
|
||||||
|
// ─── RelationalResolver ───
|
||||||
|
|
||||||
|
export class RelationalResolver {
|
||||||
|
private store: RelationshipStore;
|
||||||
|
|
||||||
|
constructor(store: RelationshipStore) {
|
||||||
|
this.store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve(
|
||||||
|
matchedIds: Set<string>,
|
||||||
|
allGuidelines: GuidelineStub[],
|
||||||
|
): Promise<ResolverResult> {
|
||||||
|
const resolutions = new Map<string, Resolution[]>();
|
||||||
|
const guidelinesById = new Map(allGuidelines.map((g) => [g.id, g]));
|
||||||
|
let currentIds = new Set(matchedIds);
|
||||||
|
const priorityRemoved = new Set<string>();
|
||||||
|
const entailedIds = new Set<string>();
|
||||||
|
|
||||||
|
let converged = false;
|
||||||
|
let iterations = 0;
|
||||||
|
|
||||||
|
for (iterations = 0; iterations < MAX_ITERATIONS; iterations++) {
|
||||||
|
const candidateIds = new Set(
|
||||||
|
[...currentIds].filter((id) => !priorityRemoved.has(id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const step1Ids = await this.applyDependencies(candidateIds, guidelinesById, resolutions);
|
||||||
|
|
||||||
|
const step2Ids = await this.applyPrioritization(
|
||||||
|
step1Ids,
|
||||||
|
guidelinesById,
|
||||||
|
resolutions,
|
||||||
|
priorityRemoved,
|
||||||
|
);
|
||||||
|
|
||||||
|
const step3Ids = this.applyNumericalPriority(
|
||||||
|
step2Ids,
|
||||||
|
guidelinesById,
|
||||||
|
resolutions,
|
||||||
|
priorityRemoved,
|
||||||
|
entailedIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const step4Ids = await this.applyEntailment(
|
||||||
|
step3Ids,
|
||||||
|
guidelinesById,
|
||||||
|
resolutions,
|
||||||
|
priorityRemoved,
|
||||||
|
entailedIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.setsEqual(step4Ids, currentIds)) {
|
||||||
|
converged = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIds = step4Ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of allGuidelines.map((g) => g.id)) {
|
||||||
|
if (!resolutions.has(id)) {
|
||||||
|
resolutions.set(id, [
|
||||||
|
{ kind: ResolutionKind.NONE, description: 'No relational changes' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matchedIds: currentIds,
|
||||||
|
resolutions,
|
||||||
|
converged,
|
||||||
|
iterations: iterations + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private steps ──
|
||||||
|
|
||||||
|
private async applyDependencies(
|
||||||
|
candidateIds: Set<string>,
|
||||||
|
_guidelinesById: Map<string, GuidelineStub>,
|
||||||
|
resolutions: Map<string, Resolution[]>,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const surviving = new Set(candidateIds);
|
||||||
|
const cache = new Map<string, Relationship[]>();
|
||||||
|
|
||||||
|
for (const gid of candidateIds) {
|
||||||
|
const rels = await this.getRelationshipsFromCache(cache, gid, RelationshipKind.DEPENDS_ON);
|
||||||
|
|
||||||
|
for (const rel of rels) {
|
||||||
|
const targetId = rel.target.id;
|
||||||
|
if (!candidateIds.has(targetId)) {
|
||||||
|
surviving.delete(gid);
|
||||||
|
this.addResolution(resolutions, gid, {
|
||||||
|
kind: ResolutionKind.UNMET_DEPENDENCY,
|
||||||
|
description: `Depends on ${targetId} which is not matched`,
|
||||||
|
relationshipId: rel.id,
|
||||||
|
counterparts: [{ entityType: 'guideline' as const, entityId: targetId }],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return surviving;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyPrioritization(
|
||||||
|
candidateIds: Set<string>,
|
||||||
|
guidelinesById: Map<string, GuidelineStub>,
|
||||||
|
resolutions: Map<string, Resolution[]>,
|
||||||
|
priorityRemoved: Set<string>,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const surviving = new Set(candidateIds);
|
||||||
|
const cache = new Map<string, Relationship[]>();
|
||||||
|
|
||||||
|
for (const gid of candidateIds) {
|
||||||
|
if (priorityRemoved.has(gid)) continue;
|
||||||
|
|
||||||
|
const allRels = await this.getAllRelationships(cache, gid);
|
||||||
|
const priorityRels = allRels.filter((r) => r.kind === RelationshipKind.PRIORITIZES);
|
||||||
|
|
||||||
|
for (const rel of priorityRels) {
|
||||||
|
const sourceId = rel.source.id;
|
||||||
|
if (sourceId !== gid) continue;
|
||||||
|
const targetId = rel.target.id;
|
||||||
|
|
||||||
|
if (candidateIds.has(targetId)) {
|
||||||
|
surviving.delete(targetId);
|
||||||
|
priorityRemoved.add(targetId);
|
||||||
|
this.addResolution(resolutions, targetId, {
|
||||||
|
kind: ResolutionKind.DEPRIORITIZED,
|
||||||
|
description: `Deprioritized by ${gid}`,
|
||||||
|
relationshipId: rel.id,
|
||||||
|
counterparts: [{ entityType: 'guideline' as const, entityId: gid }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return surviving;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyNumericalPriority(
|
||||||
|
candidateIds: Set<string>,
|
||||||
|
guidelinesById: Map<string, GuidelineStub>,
|
||||||
|
resolutions: Map<string, Resolution[]>,
|
||||||
|
priorityRemoved: Set<string>,
|
||||||
|
entailedIds: Set<string>,
|
||||||
|
): Set<string> {
|
||||||
|
if (candidateIds.size === 0) return candidateIds;
|
||||||
|
|
||||||
|
const nonEntailed = [...candidateIds].filter((id) => !entailedIds.has(id));
|
||||||
|
const entailed = [...candidateIds].filter((id) => entailedIds.has(id));
|
||||||
|
|
||||||
|
if (nonEntailed.length === 0) return new Set(entailed);
|
||||||
|
|
||||||
|
const priorities = nonEntailed.map((id) => guidelinesById.get(id)?.priority ?? 0);
|
||||||
|
const maxPriority = Math.max(...priorities);
|
||||||
|
|
||||||
|
const surviving = new Set<string>();
|
||||||
|
|
||||||
|
for (const id of nonEntailed) {
|
||||||
|
const priority = guidelinesById.get(id)?.priority ?? 0;
|
||||||
|
if (priority >= maxPriority) {
|
||||||
|
surviving.add(id);
|
||||||
|
} else {
|
||||||
|
priorityRemoved.add(id);
|
||||||
|
this.addResolution(resolutions, id, {
|
||||||
|
kind: ResolutionKind.DEPRIORITIZED,
|
||||||
|
description: `Lower priority (${priority} < ${maxPriority})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of entailed) {
|
||||||
|
surviving.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return surviving;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyEntailment(
|
||||||
|
candidateIds: Set<string>,
|
||||||
|
guidelinesById: Map<string, GuidelineStub>,
|
||||||
|
resolutions: Map<string, Resolution[]>,
|
||||||
|
priorityRemoved: Set<string>,
|
||||||
|
entailedIds: Set<string>,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const result = new Set(candidateIds);
|
||||||
|
const cache = new Map<string, Relationship[]>();
|
||||||
|
|
||||||
|
for (const gid of candidateIds) {
|
||||||
|
if (priorityRemoved.has(gid)) continue;
|
||||||
|
|
||||||
|
const allRels = await this.getAllRelationships(cache, gid);
|
||||||
|
const entailRels = allRels.filter((r) => r.kind === RelationshipKind.ENTAILS);
|
||||||
|
|
||||||
|
for (const rel of entailRels) {
|
||||||
|
const targetId = rel.target.id;
|
||||||
|
if (!guidelinesById.has(targetId)) continue;
|
||||||
|
if (priorityRemoved.has(targetId)) continue;
|
||||||
|
if (entailedIds.has(targetId)) continue;
|
||||||
|
|
||||||
|
result.add(targetId);
|
||||||
|
entailedIds.add(targetId);
|
||||||
|
this.addResolution(resolutions, targetId, {
|
||||||
|
kind: ResolutionKind.ENTAILED,
|
||||||
|
description: `Entailed by ${gid}`,
|
||||||
|
relationshipId: rel.id,
|
||||||
|
counterparts: [{ entityType: 'guideline' as const, entityId: gid }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cache helpers ──
|
||||||
|
|
||||||
|
private async getRelationshipsFromCache(
|
||||||
|
cache: Map<string, Relationship[]>,
|
||||||
|
gid: string,
|
||||||
|
kind: RelationshipKind,
|
||||||
|
): Promise<Relationship[]> {
|
||||||
|
const key = `${kind}:${gid}`;
|
||||||
|
if (!cache.has(key)) {
|
||||||
|
cache.set(key, await this.store.listRelationships(kind, gid));
|
||||||
|
}
|
||||||
|
return cache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAllRelationships(
|
||||||
|
cache: Map<string, Relationship[]>,
|
||||||
|
gid: string,
|
||||||
|
): Promise<Relationship[]> {
|
||||||
|
const result: Relationship[] = [];
|
||||||
|
const kinds = Object.values(RelationshipKind) as RelationshipKind[];
|
||||||
|
for (const kind of kinds) {
|
||||||
|
const rels = await this.getRelationshipsFromCache(cache, gid, kind);
|
||||||
|
const targetRels = await this.getRelationshipsFromCache(cache, `target:${gid}`, kind);
|
||||||
|
result.push(...rels, ...targetRels);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addResolution(
|
||||||
|
resolutions: Map<string, Resolution[]>,
|
||||||
|
id: string,
|
||||||
|
resolution: Resolution,
|
||||||
|
): void {
|
||||||
|
if (!resolutions.has(id)) resolutions.set(id, []);
|
||||||
|
resolutions.get(id)!.push(resolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setsEqual(a: Set<string>, b: Set<string>): boolean {
|
||||||
|
if (a.size !== b.size) return false;
|
||||||
|
for (const item of a) if (!b.has(item)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
apps/coder/src/services/correction-service.ts
Normal file
186
apps/coder/src/services/correction-service.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { readFile, writeFile, appendFile } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
export interface UserCorrectionRecord {
|
||||||
|
id: string;
|
||||||
|
record_type: 'conversation';
|
||||||
|
action_type: 'user_correction';
|
||||||
|
priority: 'critical_for_recovery';
|
||||||
|
timestamp: string;
|
||||||
|
original_claim: string;
|
||||||
|
correction: string;
|
||||||
|
principle_extracted: string;
|
||||||
|
persisted_to: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORRECTIONS_REL = '.boo/corrections/index.json';
|
||||||
|
|
||||||
|
function correctionsDir(basePath?: string): string {
|
||||||
|
return resolve(basePath ?? process.cwd(), '.boo/corrections');
|
||||||
|
}
|
||||||
|
|
||||||
|
function correctionsPath(basePath?: string): string {
|
||||||
|
return resolve(basePath ?? process.cwd(), CORRECTIONS_REL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson<T>(raw: string): T | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CorrectionsIndex {
|
||||||
|
corrections: UserCorrectionRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCorrections(basePath?: string): Promise<CorrectionsIndex> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(correctionsPath(basePath), 'utf-8');
|
||||||
|
return tryParseJson<CorrectionsIndex>(raw) ?? { corrections: [] };
|
||||||
|
} catch {
|
||||||
|
return { corrections: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeCorrections(idx: CorrectionsIndex, basePath?: string): Promise<void> {
|
||||||
|
const dir = correctionsDir(basePath);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
const { mkdir } = await import('node:fs/promises');
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
await writeFile(correctionsPath(basePath), JSON.stringify(idx, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
let idCounter = 0;
|
||||||
|
|
||||||
|
function nextId(): string {
|
||||||
|
idCounter++;
|
||||||
|
return `uc_${Date.now()}_${idCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoNow(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a user correction. Stores it in .boo/corrections/index.json
|
||||||
|
* and returns the record with the assigned id.
|
||||||
|
*/
|
||||||
|
export async function recordCorrection(
|
||||||
|
originalClaim: string,
|
||||||
|
correction: string,
|
||||||
|
principleExtracted: string,
|
||||||
|
persistedTo: string[] = [],
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<UserCorrectionRecord> {
|
||||||
|
const idx = await readCorrections(basePath);
|
||||||
|
const record: UserCorrectionRecord = {
|
||||||
|
id: nextId(),
|
||||||
|
record_type: 'conversation',
|
||||||
|
action_type: 'user_correction',
|
||||||
|
priority: 'critical_for_recovery',
|
||||||
|
timestamp: isoNow(),
|
||||||
|
original_claim: originalClaim,
|
||||||
|
correction,
|
||||||
|
principle_extracted: principleExtracted,
|
||||||
|
persisted_to: persistedTo,
|
||||||
|
};
|
||||||
|
idx.corrections.push(record);
|
||||||
|
await writeCorrections(idx, basePath);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan an audit_trail.jsonl file for user_correction records.
|
||||||
|
* Returns all matching records found in the file.
|
||||||
|
*/
|
||||||
|
export async function scanForCorrections(
|
||||||
|
auditPath: string,
|
||||||
|
): Promise<UserCorrectionRecord[]> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(auditPath, 'utf-8');
|
||||||
|
const lines = raw.split('\n').filter(Boolean);
|
||||||
|
const corrections: UserCorrectionRecord[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const record = tryParseJson<Record<string, unknown>>(line);
|
||||||
|
if (record?.action_type === 'user_correction') {
|
||||||
|
corrections.push(record as unknown as UserCorrectionRecord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return corrections;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a proposed action contradicts any known user correction.
|
||||||
|
* Returns an array of contradiction warnings — empty means no contradictions.
|
||||||
|
*/
|
||||||
|
export function checkContradiction(
|
||||||
|
action: string,
|
||||||
|
corrections: UserCorrectionRecord[],
|
||||||
|
): { contradicts: boolean; warnings: { correction: UserCorrectionRecord; reason: string }[] } {
|
||||||
|
const warnings: { correction: UserCorrectionRecord; reason: string }[] = [];
|
||||||
|
|
||||||
|
for (const c of corrections) {
|
||||||
|
// Check if the action mentions the original claim's topic
|
||||||
|
const actionLower = action.toLowerCase();
|
||||||
|
const claimFragments = c.original_claim.toLowerCase().split(/\s+/).filter((w) => w.length > 4);
|
||||||
|
|
||||||
|
// If any significant word from the original claim appears in the proposed action,
|
||||||
|
// flag this as a potential contradiction
|
||||||
|
const matchingFragments = claimFragments.filter((f) => actionLower.includes(f));
|
||||||
|
if (matchingFragments.length >= 2) {
|
||||||
|
warnings.push({
|
||||||
|
correction: c,
|
||||||
|
reason: `Action "${action.slice(0, 60)}" may contradict prior correction: "${c.original_claim}" → "${c.correction}" (principle: ${c.principle_extracted})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contradicts: warnings.length > 0,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a file path to a correction's persisted_to array.
|
||||||
|
*/
|
||||||
|
export async function markPersisted(
|
||||||
|
correctionId: string,
|
||||||
|
filePath: string,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<UserCorrectionRecord | null> {
|
||||||
|
const idx = await readCorrections(basePath);
|
||||||
|
const record = idx.corrections.find((c) => c.id === correctionId);
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
if (!record.persisted_to.includes(filePath)) {
|
||||||
|
record.persisted_to.push(filePath);
|
||||||
|
}
|
||||||
|
await writeCorrections(idx, basePath);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all stored user corrections.
|
||||||
|
*/
|
||||||
|
export async function listCorrections(basePath?: string): Promise<UserCorrectionRecord[]> {
|
||||||
|
const idx = await readCorrections(basePath);
|
||||||
|
return idx.corrections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a correction record to an audit_trail.jsonl file (inline storage).
|
||||||
|
*/
|
||||||
|
export async function appendCorrectionToTrail(
|
||||||
|
trailPath: string,
|
||||||
|
correction: UserCorrectionRecord,
|
||||||
|
): Promise<void> {
|
||||||
|
await appendFile(trailPath, JSON.stringify(correction) + '\n', 'utf-8');
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
type TerminalMessageStatus,
|
type TerminalMessageStatus,
|
||||||
} from './finalize-message.js';
|
} from './finalize-message.js';
|
||||||
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
|
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
|
||||||
|
import { emitHook } from '../plugins/host.js';
|
||||||
|
|
||||||
interface InferenceRunner {
|
interface InferenceRunner {
|
||||||
enqueue: (
|
enqueue: (
|
||||||
@@ -123,6 +124,22 @@ export function createDispatcher(deps: Deps): {
|
|||||||
publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason);
|
publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EmitHook: fire-and-forget turn.end notification. Best-effort — a hook throwing
|
||||||
|
// is silently swallowed so it never blocks the dispatch flow.
|
||||||
|
function emitTurnEnd(
|
||||||
|
sessionId: string,
|
||||||
|
taskId: string,
|
||||||
|
state: string,
|
||||||
|
agent?: string | null,
|
||||||
|
model?: string | null,
|
||||||
|
outputSummary?: string,
|
||||||
|
): void {
|
||||||
|
void emitHook('turn.end', {
|
||||||
|
sessionId,
|
||||||
|
turnSummary: { taskId, state, agent, model: model ?? undefined, outputSummary },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// F1 (OCE-001/OCE-002): finalize a streaming assistant message into a terminal
|
// F1 (OCE-001/OCE-002): finalize a streaming assistant message into a terminal
|
||||||
// state and publish the matching message_complete frame. Best-effort + idempotent
|
// state and publish the matching message_complete frame. Best-effort + idempotent
|
||||||
// (the helper's `WHERE status='streaming'` guard) — a failure here must never mask
|
// (the helper's `WHERE status='streaming'` guard) — a failure here must never mask
|
||||||
@@ -318,6 +335,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
|
|
||||||
// Declared before try so the catch block can write it back on the task row.
|
// Declared before try so the catch block can write it back on the task row.
|
||||||
let chatId: string | null = null;
|
let chatId: string | null = null;
|
||||||
|
let sessionId: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Mark running
|
// Mark running
|
||||||
@@ -330,7 +348,6 @@ export function createDispatcher(deps: Deps): {
|
|||||||
// Session setup: reuse a pre-created session (e.g. Q&A arena contestants
|
// Session setup: reuse a pre-created session (e.g. Q&A arena contestants
|
||||||
// whose persona is stamped on the session via agent_id) or create a fresh one.
|
// whose persona is stamped on the session via agent_id) or create a fresh one.
|
||||||
const model = task.model ?? config.DEFAULT_MODEL;
|
const model = task.model ?? config.DEFAULT_MODEL;
|
||||||
let sessionId: string;
|
|
||||||
if (task.session_id) {
|
if (task.session_id) {
|
||||||
sessionId = task.session_id;
|
sessionId = task.session_id;
|
||||||
} else {
|
} else {
|
||||||
@@ -377,6 +394,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
SET state = 'cancelled', ended_at = clock_timestamp()
|
SET state = 'cancelled', ended_at = clock_timestamp()
|
||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
|
if (sessionId) emitTurnEnd(sessionId, taskId, 'cancelled', null, task.model);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +417,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
|
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
|
||||||
|
emitTurnEnd(sessionId, taskId, 'completed', null, task.model, summary);
|
||||||
} else {
|
} else {
|
||||||
const [msg] = await sql<{ content: string | null }[]>`
|
const [msg] = await sql<{ content: string | null }[]>`
|
||||||
SELECT content FROM messages WHERE id = ${assistantId}
|
SELECT content FROM messages WHERE id = ${assistantId}
|
||||||
@@ -410,6 +429,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
|
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
|
||||||
|
emitTurnEnd(sessionId, taskId, 'failed', null, task.model, summary);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -419,6 +439,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}, chat_id = ${chatId}
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}, chat_id = ${chatId}
|
||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`.catch(() => {});
|
`.catch(() => {});
|
||||||
|
if (sessionId) emitTurnEnd(sessionId, taskId, 'failed', null, task.model, errMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +705,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||||
|
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||||
await cleanupWorktree(projectPath, taskId);
|
await cleanupWorktree(projectPath, taskId);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
return;
|
return;
|
||||||
@@ -738,6 +760,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
||||||
// #10: external-agent turn completed cleanly.
|
// #10: external-agent turn completed cleanly.
|
||||||
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
|
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
|
||||||
|
emitTurnEnd(sessionId, taskId, 'completed', agent, task.model, outputSummary);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -762,6 +785,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
// preceded its assignment — guard so the status publish never masks the real
|
// preceded its assignment — guard so the status publish never masks the real
|
||||||
// error.
|
// error.
|
||||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'failed');
|
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'failed');
|
||||||
|
if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||||
|
|
||||||
// Best-effort cleanup
|
// Best-effort cleanup
|
||||||
await cleanupWorktree(projectPath, taskId);
|
await cleanupWorktree(projectPath, taskId);
|
||||||
@@ -1030,6 +1054,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||||
|
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
return; // worktree persists (no cleanup); backend stays warm
|
return; // worktree persists (no cleanup); backend stays warm
|
||||||
}
|
}
|
||||||
@@ -1090,6 +1115,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
result.ok ? 'idle' : 'error',
|
result.ok ? 'idle' : 'error',
|
||||||
result.ok ? 'turn_complete' : 'failed',
|
result.ok ? 'turn_complete' : 'failed',
|
||||||
);
|
);
|
||||||
|
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -1104,6 +1130,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||||
// #10: turn crashed.
|
// #10: turn crashed.
|
||||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||||
|
if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||||
}
|
}
|
||||||
@@ -1308,6 +1335,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||||
|
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
return; // worktree persists (no cleanup); backend stays warm
|
return; // worktree persists (no cleanup); backend stays warm
|
||||||
}
|
}
|
||||||
@@ -1367,6 +1395,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
result.ok ? 'idle' : 'error',
|
result.ok ? 'idle' : 'error',
|
||||||
result.ok ? 'turn_complete' : 'failed',
|
result.ok ? 'turn_complete' : 'failed',
|
||||||
);
|
);
|
||||||
|
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -1381,6 +1410,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||||
// #10: turn crashed.
|
// #10: turn crashed.
|
||||||
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||||
|
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||||
}
|
}
|
||||||
@@ -1576,6 +1606,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||||
|
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
return; // worktree persists (no cleanup); backend stays warm
|
return; // worktree persists (no cleanup); backend stays warm
|
||||||
}
|
}
|
||||||
@@ -1638,6 +1669,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
result.ok ? 'idle' : 'error',
|
result.ok ? 'idle' : 'error',
|
||||||
result.ok ? 'turn_complete' : 'failed',
|
result.ok ? 'turn_complete' : 'failed',
|
||||||
);
|
);
|
||||||
|
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -1652,6 +1684,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||||
// #10: turn crashed.
|
// #10: turn crashed.
|
||||||
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||||
|
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||||
}
|
}
|
||||||
|
|||||||
47
apps/coder/src/services/edit-guards-imports.ts
Normal file
47
apps/coder/src/services/edit-guards-imports.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// edit-guards-imports — detects dropped imports in edited files.
|
||||||
|
// Ported from opencode-morph-fast-apply (MIT).
|
||||||
|
|
||||||
|
export interface ImportCheckResult {
|
||||||
|
ok: boolean;
|
||||||
|
missingImports: string[];
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPORT_PATTERNS = [
|
||||||
|
/^import\s+(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"][^'"]+['"]\s*;?$/m,
|
||||||
|
/^import\s+['"][^'"]+['"]\s*;?$/m,
|
||||||
|
/^export\s+.*\s+from\s+['"][^'"]+['"]\s*;?$/m,
|
||||||
|
/^require\s*\(\s*['"][^'"]+['"]\s*\)\s*;?$/m,
|
||||||
|
/^import\s+type\s+\{[^}]*\}\s+from\s+['"][^'"]+['"]\s*;?$/m,
|
||||||
|
];
|
||||||
|
|
||||||
|
function extractImportLines(content: string): string[] {
|
||||||
|
return content.split('\n').filter((line) =>
|
||||||
|
IMPORT_PATTERNS.some((p) => p.test(line.trim())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkDroppedImports(
|
||||||
|
original: string,
|
||||||
|
updated: string,
|
||||||
|
filePath: string,
|
||||||
|
): ImportCheckResult {
|
||||||
|
const originalImports = extractImportLines(original);
|
||||||
|
const updatedImports = extractImportLines(updated);
|
||||||
|
|
||||||
|
if (originalImports.length === 0) {
|
||||||
|
return { ok: true, missingImports: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = originalImports.filter((imp) => !updatedImports.includes(imp));
|
||||||
|
|
||||||
|
if (missing.length > 0 && originalImports.length > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
missingImports: missing,
|
||||||
|
reason: `Edit would drop ${missing.length} import(s) from ${filePath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, missingImports: [] };
|
||||||
|
}
|
||||||
42
apps/coder/src/services/edit-guards.ts
Normal file
42
apps/coder/src/services/edit-guards.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// v2.8 Morph safety guards — prevents catastrophic truncation, marker leakage,
|
||||||
|
// and accidental import deletion during native edit_file application.
|
||||||
|
// Ported from opencode-morph-fast-apply (MIT) with threshold values preserved.
|
||||||
|
|
||||||
|
export interface GuardResult {
|
||||||
|
ok: boolean;
|
||||||
|
reason?: string;
|
||||||
|
charLoss?: number;
|
||||||
|
lineLoss?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRUNCATION_CHAR_THRESHOLD = 0.6;
|
||||||
|
const TRUNCATION_LINE_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
export function validateEditResult(
|
||||||
|
original: string,
|
||||||
|
updated: string,
|
||||||
|
filePath: string,
|
||||||
|
): GuardResult {
|
||||||
|
// Check for catastrophic content truncation
|
||||||
|
if (original.length > 0 && updated.length > 0) {
|
||||||
|
const charLoss = 1 - updated.length / original.length;
|
||||||
|
const originalLines = original.split('\n').length;
|
||||||
|
const updatedLines = updated.split('\n').length;
|
||||||
|
const lineLoss = 1 - updatedLines / originalLines;
|
||||||
|
|
||||||
|
if (charLoss > TRUNCATION_CHAR_THRESHOLD && lineLoss > TRUNCATION_LINE_THRESHOLD) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `Edit would truncate ${Math.round(charLoss * 100)}% of characters and ${Math.round(lineLoss * 100)}% of lines`,
|
||||||
|
charLoss,
|
||||||
|
lineLoss,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGuardError(guard: GuardResult, filePath: string): string {
|
||||||
|
return `Edit guard rejected change to ${filePath}: ${guard.reason ?? 'unknown error'}`;
|
||||||
|
}
|
||||||
23
apps/coder/src/services/flow-artifacts.ts
Normal file
23
apps/coder/src/services/flow-artifacts.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
|
const ARTIFACTS_ROOT = 'data/flow-artifacts';
|
||||||
|
|
||||||
|
export function getArtifactPath(flowRunId: string, stepId: string): string {
|
||||||
|
return join(ARTIFACTS_ROOT, flowRunId, `${stepId}.md`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeFlowArtifact(
|
||||||
|
flowRunId: string,
|
||||||
|
stepId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const dir = join(ARTIFACTS_ROOT, flowRunId);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
const path = getArtifactPath(flowRunId, stepId);
|
||||||
|
await writeFile(path, content, 'utf8');
|
||||||
|
return path;
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
* "Settled" = done ∪ skipped ∪ excluded. Only settled deps unblock a step;
|
* "Settled" = done ∪ skipped ∪ excluded. Only settled deps unblock a step;
|
||||||
* an inFlight dep does NOT (the runner waits for its terminal callback).
|
* an inFlight dep does NOT (the runner waits for its terminal callback).
|
||||||
*/
|
*/
|
||||||
import type { Flow, Step, StepContext } from '../conductor/types.js';
|
import type { Flow, Step, StepContext, TriggerRule } from '../conductor/types.js';
|
||||||
|
|
||||||
export interface SchedulerState {
|
export interface SchedulerState {
|
||||||
/** step ids that completed successfully (results available) */
|
/** step ids that completed successfully (results available) */
|
||||||
@@ -62,7 +62,7 @@ export function readySteps(flow: Flow, state: SchedulerState): Step[] {
|
|||||||
!state.skipped.has(s.id) &&
|
!state.skipped.has(s.id) &&
|
||||||
!state.inFlight.has(s.id) &&
|
!state.inFlight.has(s.id) &&
|
||||||
!state.excluded.has(s.id) &&
|
!state.excluded.has(s.id) &&
|
||||||
(s.deps ?? []).every((d) => isSatisfied(state, d)),
|
((s.deps ?? []).length === 0 || evaluateTriggerRule(s.deps ?? [], state.done, state.skipped, state.excluded, s.trigger_rule)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +167,32 @@ export function shouldFailOnMissingAgent(agent: string, modeId: string | null):
|
|||||||
return agent === 'qwen' && modeId === 'plan';
|
return agent === 'qwen' && modeId === 'plan';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a trigger rule against dependency results.
|
||||||
|
* - all_success: every dep must be done (not skipped/failed)
|
||||||
|
* - one_success: at least one dep must be done
|
||||||
|
* - all_done: every dep must be settled regardless of outcome
|
||||||
|
*/
|
||||||
|
export function evaluateTriggerRule(
|
||||||
|
deps: string[],
|
||||||
|
done: ReadonlySet<string>,
|
||||||
|
skipped: ReadonlySet<string>,
|
||||||
|
excluded: ReadonlySet<string>,
|
||||||
|
rule?: TriggerRule,
|
||||||
|
): boolean {
|
||||||
|
if (deps.length === 0) return true;
|
||||||
|
const satisfied = new Set([...done, ...skipped, ...excluded]);
|
||||||
|
|
||||||
|
switch (rule ?? 'all_success') {
|
||||||
|
case 'all_success':
|
||||||
|
return deps.every((d) => done.has(d) || skipped.has(d) || excluded.has(d));
|
||||||
|
case 'one_success':
|
||||||
|
return deps.some((d) => done.has(d));
|
||||||
|
case 'all_done':
|
||||||
|
return deps.every((d) => satisfied.has(d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reconcile every step of an in-flight run for startup resume. Returns one
|
* Reconcile every step of an in-flight run for startup resume. Returns one
|
||||||
* decision per step. Pure — no IO.
|
* decision per step. Pure — no IO.
|
||||||
|
|||||||
@@ -346,6 +346,20 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
continue; // re-evaluate — code output can unblock the next wave
|
continue; // re-evaluate — code output can unblock the next wave
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Approval gate steps: pause and wait for human decision.
|
||||||
|
const approvalReady = toRun.filter((s) => s.kind === 'approval');
|
||||||
|
if (approvalReady.length > 0) {
|
||||||
|
for (const s of approvalReady) {
|
||||||
|
await sql`
|
||||||
|
UPDATE flow_steps SET status = 'blocked', updated_at = clock_timestamp()
|
||||||
|
WHERE run_id = ${runId} AND step_id = ${s.id}
|
||||||
|
`;
|
||||||
|
await appendStepEvent(sql, runId, s.id, 'paused', { reason: 'awaiting approval' });
|
||||||
|
publishStep(runId, s.id, 'blocked');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Only agent steps remain ready → dispatch the whole parallel wave, then wait.
|
// Only agent steps remain ready → dispatch the whole parallel wave, then wait.
|
||||||
for (const s of toRun) {
|
for (const s of toRun) {
|
||||||
await dispatchAgentStep(runId, run.project_id, model, s, ctx);
|
await dispatchAgentStep(runId, run.project_id, model, s, ctx);
|
||||||
@@ -378,7 +392,8 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
// flow's step.run already bakes in the evidence/YAGNI contracts.
|
// flow's step.run already bakes in the evidence/YAGNI contracts.
|
||||||
const persona = step.agent ? await loadPersona(step.agent) : '';
|
const persona = step.agent ? await loadPersona(step.agent) : '';
|
||||||
const taskPrompt = await step.run(ctx);
|
const taskPrompt = await step.run(ctx);
|
||||||
const fullPrompt = persona ? `${persona}\n\n---\n\n${taskPrompt}` : taskPrompt;
|
const resolvedPrompt = resolveVariables(taskPrompt, ctx.results);
|
||||||
|
const fullPrompt = persona ? `${persona}\n\n---\n\n${resolvedPrompt}` : resolvedPrompt;
|
||||||
|
|
||||||
// READ-ONLY (D-4): agent='qwen', mode_id='plan' are hardcoded, never
|
// READ-ONLY (D-4): agent='qwen', mode_id='plan' are hardcoded, never
|
||||||
// user-overridable. The dispatcher's qwen+plan rule forces the PTY hard gate.
|
// user-overridable. The dispatcher's qwen+plan rule forces the PTY hard gate.
|
||||||
@@ -392,6 +407,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
SET task_id = ${task!.id}, status = 'running', input = ${fullPrompt}, updated_at = clock_timestamp()
|
SET task_id = ${task!.id}, status = 'running', input = ${fullPrompt}, updated_at = clock_timestamp()
|
||||||
WHERE run_id = ${runId} AND step_id = ${step.id}
|
WHERE run_id = ${runId} AND step_id = ${step.id}
|
||||||
`;
|
`;
|
||||||
|
await appendStepEvent(sql, runId, step.id, 'started', { taskId: task!.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -438,6 +454,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
WHERE run_id = ${runId} AND step_id = ${stepId}
|
WHERE run_id = ${runId} AND step_id = ${stepId}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
await appendStepEvent(sql, runId, stepId, status, output ? { outputLength: output.length } : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── run completion ─────────────────────────────────────────────────────────
|
// ─── run completion ─────────────────────────────────────────────────────────
|
||||||
@@ -483,6 +500,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
if (updated.count === 0) return;
|
if (updated.count === 0) return;
|
||||||
const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run');
|
const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run');
|
||||||
log.warn({ runId, error }, 'flow-runner: run failed');
|
log.warn({ runId, error }, 'flow-runner: run failed');
|
||||||
|
await appendStepEvent(sql, runId, stepId, 'failed', { error });
|
||||||
publishStep(runId, stepId, 'failed', { run_status: 'failed' });
|
publishStep(runId, stepId, 'failed', { run_status: 'failed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +540,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
function publishStep(
|
function publishStep(
|
||||||
runId: string,
|
runId: string,
|
||||||
stepId: string,
|
stepId: string,
|
||||||
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled',
|
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'blocked',
|
||||||
extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string },
|
extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string },
|
||||||
): void {
|
): void {
|
||||||
publishUser({
|
publishUser({
|
||||||
@@ -763,3 +781,40 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
function errMsg(e: unknown): string {
|
function errMsg(e: unknown): string {
|
||||||
return e instanceof Error ? e.message : String(e);
|
return e instanceof Error ? e.message : String(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Event log ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function appendStepEvent(
|
||||||
|
sql: Sql,
|
||||||
|
runId: string,
|
||||||
|
stepId: string,
|
||||||
|
event: string,
|
||||||
|
payload?: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO flow_step_events (run_id, step_id, event, payload)
|
||||||
|
VALUES (${runId}, ${stepId}, ${event}, ${payload ? sql.json(payload as never) : null})
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Variable substitution ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const VAR_PATTERN = /\$(\w+)\.output(?:\.(\w+(?:\.\w+)*))?/g;
|
||||||
|
|
||||||
|
export function resolveVariables(prompt: string, results: Record<string, string>): string {
|
||||||
|
return prompt.replace(VAR_PATTERN, (match, stepId, fieldPath) => {
|
||||||
|
const output = results[stepId];
|
||||||
|
if (!output) return match;
|
||||||
|
if (!fieldPath) return output;
|
||||||
|
try {
|
||||||
|
const lines = output.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const parsed = line.match(new RegExp(`^${fieldPath}:\\s*(.+)$`, 'i'));
|
||||||
|
if (parsed) return parsed[1]!.trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,9 +19,10 @@
|
|||||||
import type { Broker } from '@boocode/server/broker';
|
import type { Broker } from '@boocode/server/broker';
|
||||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||||
import type { AgentEvent } from './agent-backend.js';
|
import type { AgentEvent } from './agent-backend.js';
|
||||||
import { type AcpToolSnapshot, snapshotToWireToolCall } from './acp-tool-snapshot.js';
|
import { type AcpToolSnapshot, snapshotToWireToolCall, mapToolLifecycleStatus } from './acp-tool-snapshot.js';
|
||||||
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||||
import type { DcpStreamStripper } from './dcp-strip.js';
|
import type { DcpStreamStripper } from './dcp-strip.js';
|
||||||
|
import { emitHook } from '../plugins/host.js';
|
||||||
|
|
||||||
export interface FrameEmitterOpts {
|
export interface FrameEmitterOpts {
|
||||||
broker?: Broker;
|
broker?: Broker;
|
||||||
@@ -91,8 +92,29 @@ export function makeFrameEmitter(opts: FrameEmitterOpts): FrameEmitter {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'tool_call':
|
case 'tool_call':
|
||||||
|
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
|
||||||
|
if (canStream()) {
|
||||||
|
broker!.publishFrame(sessionId!, {
|
||||||
|
type: 'tool_call',
|
||||||
|
message_id: assistantId!,
|
||||||
|
chat_id: chatId!,
|
||||||
|
tool_call: snapshotToWireToolCall(e.toolCall),
|
||||||
|
} as WsFrame);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'tool_update':
|
case 'tool_update':
|
||||||
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
|
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
|
||||||
|
{
|
||||||
|
const lifecycle = mapToolLifecycleStatus(e.toolCall.status, e.toolCall.rawOutput);
|
||||||
|
if (lifecycle === 'completed' || lifecycle === 'failed') {
|
||||||
|
void emitHook('tool.execute.after', {
|
||||||
|
toolName: e.toolCall.title,
|
||||||
|
args: e.toolCall.rawInput,
|
||||||
|
result: e.toolCall.rawOutput,
|
||||||
|
duration: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if (canStream()) {
|
if (canStream()) {
|
||||||
broker!.publishFrame(sessionId!, {
|
broker!.publishFrame(sessionId!, {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
|
|||||||
560
apps/coder/src/services/guideline-service.ts
Normal file
560
apps/coder/src/services/guideline-service.ts
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
export type Criticality = 'low' | 'medium' | 'high';
|
||||||
|
|
||||||
|
export interface GuidelineContent {
|
||||||
|
condition: string;
|
||||||
|
action: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Guideline {
|
||||||
|
id: string;
|
||||||
|
creationUtc: string;
|
||||||
|
content: GuidelineContent;
|
||||||
|
enabled: boolean;
|
||||||
|
tags: string[];
|
||||||
|
labels: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
criticality: Criticality;
|
||||||
|
title: string | null;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGuidelineParams {
|
||||||
|
condition: string;
|
||||||
|
action?: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
labels?: string[];
|
||||||
|
criticality?: Criticality;
|
||||||
|
title?: string;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateGuidelineParams {
|
||||||
|
condition?: string;
|
||||||
|
action?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
labels?: string[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
criticality?: Criticality;
|
||||||
|
title?: string | null;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListGuidelinesFilter {
|
||||||
|
tags?: string[];
|
||||||
|
labels?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GuidelineStoreData {
|
||||||
|
version: string;
|
||||||
|
guidelines: Guideline[];
|
||||||
|
migrationLog: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUIDELINES_REL = '.boo/guidelines';
|
||||||
|
const STORE_FILE = 'guidelines.json';
|
||||||
|
const CURRENT_VERSION = 'v0.11.0';
|
||||||
|
|
||||||
|
function storeDir(basePath?: string): string {
|
||||||
|
return resolve(basePath ?? process.cwd(), GUIDELINES_REL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function storePath(basePath?: string): string {
|
||||||
|
return join(storeDir(basePath), STORE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson<T>(raw: string): T | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let idCounter = 0;
|
||||||
|
|
||||||
|
function nextId(): string {
|
||||||
|
idCounter++;
|
||||||
|
return `gl_${Date.now()}_${idCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoNow(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureStoreDir(basePath?: string): Promise<void> {
|
||||||
|
const dir = storeDir(basePath);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIGRATIONS: { from: string; to: string; migrate: (data: GuidelineStoreData) => GuidelineStoreData }[] = [
|
||||||
|
{
|
||||||
|
from: 'v0.1.0',
|
||||||
|
to: 'v0.2.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.2.0',
|
||||||
|
guidelines: data.guidelines.map((g) => ({
|
||||||
|
...g,
|
||||||
|
enabled: g.enabled ?? true,
|
||||||
|
})),
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.1.0→v0.2.0: add enabled field'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.2.0',
|
||||||
|
to: 'v0.3.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.3.0',
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.2.0→v0.3.0: remove guideline_set'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.3.0',
|
||||||
|
to: 'v0.4.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.4.0',
|
||||||
|
guidelines: data.guidelines.map((g) => ({
|
||||||
|
...g,
|
||||||
|
content: {
|
||||||
|
...g.content,
|
||||||
|
action: g.content.action ?? null,
|
||||||
|
description: g.content.description ?? null,
|
||||||
|
},
|
||||||
|
metadata: g.metadata ?? {},
|
||||||
|
})),
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.3.0→v0.4.0: add optional action, description, metadata'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.4.0',
|
||||||
|
to: 'v0.5.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.5.0',
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.4.0→v0.5.0: description as optional'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.5.0',
|
||||||
|
to: 'v0.6.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.6.0',
|
||||||
|
guidelines: data.guidelines.map((g) => ({
|
||||||
|
...g,
|
||||||
|
criticality: g.criticality ?? 'medium',
|
||||||
|
})),
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.5.0→v0.6.0: add criticality'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.6.0',
|
||||||
|
to: 'v0.7.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.7.0',
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.6.0→v0.7.0: add composition_mode (optional)'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.7.0',
|
||||||
|
to: 'v0.8.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.8.0',
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.7.0→v0.8.0: add track (default true)'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.8.0',
|
||||||
|
to: 'v0.9.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.9.0',
|
||||||
|
guidelines: data.guidelines.map((g) => ({
|
||||||
|
...g,
|
||||||
|
labels: g.labels ?? [],
|
||||||
|
})),
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.8.0→v0.9.0: add labels'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.9.0',
|
||||||
|
to: 'v0.10.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.10.0',
|
||||||
|
guidelines: data.guidelines.map((g) => ({
|
||||||
|
...g,
|
||||||
|
priority: g.priority ?? 0,
|
||||||
|
})),
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.9.0→v0.10.0: add priority'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'v0.10.0',
|
||||||
|
to: 'v0.11.0',
|
||||||
|
migrate: (data) => ({
|
||||||
|
...data,
|
||||||
|
version: 'v0.11.0',
|
||||||
|
guidelines: data.guidelines.map((g) => ({
|
||||||
|
...g,
|
||||||
|
title: g.title ?? null,
|
||||||
|
})),
|
||||||
|
migrationLog: [...data.migrationLog, 'v0.10.0→v0.11.0: add title'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function applyMigrations(data: GuidelineStoreData): GuidelineStoreData {
|
||||||
|
let current = { ...data };
|
||||||
|
for (const migration of MIGRATIONS) {
|
||||||
|
if (current.version === migration.from) {
|
||||||
|
current = migration.migrate(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStore(basePath?: string): Promise<GuidelineStoreData> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(storePath(basePath), 'utf-8');
|
||||||
|
const data = tryParseJson<GuidelineStoreData>(raw);
|
||||||
|
if (!data) return { version: CURRENT_VERSION, guidelines: [], migrationLog: [] };
|
||||||
|
if (data.version !== CURRENT_VERSION) {
|
||||||
|
return applyMigrations(data);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return { version: CURRENT_VERSION, guidelines: [], migrationLog: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeStore(data: GuidelineStoreData, basePath?: string): Promise<void> {
|
||||||
|
await ensureStoreDir(basePath);
|
||||||
|
await writeFile(storePath(basePath), JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGuideline(
|
||||||
|
params: CreateGuidelineParams,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<Guideline> {
|
||||||
|
const data = await readStore(basePath);
|
||||||
|
const guideline: Guideline = {
|
||||||
|
id: nextId(),
|
||||||
|
creationUtc: isoNow(),
|
||||||
|
content: {
|
||||||
|
condition: params.condition,
|
||||||
|
action: params.action ?? null,
|
||||||
|
description: params.description ?? null,
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
tags: params.tags ?? [],
|
||||||
|
labels: params.labels ?? [],
|
||||||
|
metadata: {},
|
||||||
|
criticality: params.criticality ?? 'medium',
|
||||||
|
title: params.title ?? null,
|
||||||
|
priority: params.priority ?? 0,
|
||||||
|
};
|
||||||
|
data.guidelines.push(guideline);
|
||||||
|
await writeStore(data, basePath);
|
||||||
|
return guideline;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGuidelines(
|
||||||
|
filter?: ListGuidelinesFilter,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<Guideline[]> {
|
||||||
|
const data = await readStore(basePath);
|
||||||
|
let results = data.guidelines;
|
||||||
|
|
||||||
|
if (filter?.tags && filter.tags.length > 0) {
|
||||||
|
results = results.filter((g) => filter.tags!.some((tag) => g.tags.includes(tag)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.labels && filter.labels.length > 0) {
|
||||||
|
results = results.filter((g) => filter.labels!.every((label) => g.labels.includes(label)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readGuideline(
|
||||||
|
id: string,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<Guideline | null> {
|
||||||
|
const data = await readStore(basePath);
|
||||||
|
return data.guidelines.find((g) => g.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGuideline(
|
||||||
|
id: string,
|
||||||
|
params: UpdateGuidelineParams,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<Guideline | null> {
|
||||||
|
const data = await readStore(basePath);
|
||||||
|
const idx = data.guidelines.findIndex((g) => g.id === id);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
|
||||||
|
const existing = data.guidelines[idx]!;
|
||||||
|
|
||||||
|
if (params.condition !== undefined) existing.content.condition = params.condition;
|
||||||
|
if (params.action !== undefined) existing.content.action = params.action;
|
||||||
|
if (params.description !== undefined) existing.content.description = params.description;
|
||||||
|
if (params.enabled !== undefined) existing.enabled = params.enabled;
|
||||||
|
if (params.tags !== undefined) existing.tags = params.tags;
|
||||||
|
if (params.labels !== undefined) existing.labels = params.labels;
|
||||||
|
if (params.metadata !== undefined) existing.metadata = params.metadata;
|
||||||
|
if (params.criticality !== undefined) existing.criticality = params.criticality;
|
||||||
|
if (params.title !== undefined) existing.title = params.title;
|
||||||
|
if (params.priority !== undefined) existing.priority = params.priority;
|
||||||
|
|
||||||
|
data.guidelines[idx] = existing;
|
||||||
|
await writeStore(data, basePath);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGuideline(
|
||||||
|
id: string,
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const data = await readStore(basePath);
|
||||||
|
const lenBefore = data.guidelines.length;
|
||||||
|
data.guidelines = data.guidelines.filter((g) => g.id !== id);
|
||||||
|
if (data.guidelines.length === lenBefore) return false;
|
||||||
|
await writeStore(data, basePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findGuideline(
|
||||||
|
content: { condition: string; action?: string },
|
||||||
|
basePath?: string,
|
||||||
|
): Promise<Guideline | null> {
|
||||||
|
const data = await readStore(basePath);
|
||||||
|
return data.guidelines.find((g) => {
|
||||||
|
const condMatch = g.content.condition === content.condition;
|
||||||
|
if (!condMatch) return false;
|
||||||
|
if (content.action !== undefined) {
|
||||||
|
return g.content.action === content.action;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Journey → Guideline projection (port of Parlant's JourneyGuidelineProjection) ───
|
||||||
|
|
||||||
|
export interface JourneyNode {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JourneyEdge {
|
||||||
|
sourceNodeId: string;
|
||||||
|
targetNodeId: string;
|
||||||
|
condition: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Journey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nodes: JourneyNode[];
|
||||||
|
edges: JourneyEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JourneyProjectionResult {
|
||||||
|
guidelines: Guideline[];
|
||||||
|
followUps: Map<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project a Journey into an ordered list of Guidelines.
|
||||||
|
* DFS traversal from root nodes: each (edge, node) pair → one Guideline.
|
||||||
|
* Edge condition becomes guideline condition, node action becomes guideline action.
|
||||||
|
* BFS queue avoids infinite loops via visited set.
|
||||||
|
*/
|
||||||
|
export function projectJourneyToGuidelines(
|
||||||
|
journey: Journey,
|
||||||
|
baseTags?: string[],
|
||||||
|
): JourneyProjectionResult {
|
||||||
|
const guidelines: Guideline[] = [];
|
||||||
|
const followUps = new Map<string, string[]>();
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const nodeMap = new Map<string, JourneyNode>();
|
||||||
|
|
||||||
|
for (const node of journey.nodes) {
|
||||||
|
nodeMap.set(node.id, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build adjacency list
|
||||||
|
const adjacency = new Map<string, JourneyEdge[]>();
|
||||||
|
for (const edge of journey.edges) {
|
||||||
|
const list = adjacency.get(edge.sourceNodeId) ?? [];
|
||||||
|
list.push(edge);
|
||||||
|
adjacency.set(edge.sourceNodeId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root nodes (no incoming edges)
|
||||||
|
const hasIncoming = new Set<string>();
|
||||||
|
for (const edge of journey.edges) {
|
||||||
|
hasIncoming.add(edge.targetNodeId);
|
||||||
|
}
|
||||||
|
const roots = journey.nodes
|
||||||
|
.filter((n) => !hasIncoming.has(n.id))
|
||||||
|
.map((n) => n.id);
|
||||||
|
|
||||||
|
const queue: { nodeId: string; fromEdge?: JourneyEdge }[] = [];
|
||||||
|
|
||||||
|
// BFS from roots
|
||||||
|
for (const rootId of roots) {
|
||||||
|
if (!visited.has(rootId)) {
|
||||||
|
queue.push({ nodeId: rootId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const { nodeId, fromEdge } = queue.shift()!;
|
||||||
|
if (visited.has(nodeId)) continue;
|
||||||
|
visited.add(nodeId);
|
||||||
|
|
||||||
|
const node = nodeMap.get(nodeId);
|
||||||
|
if (!node) continue;
|
||||||
|
|
||||||
|
// If we arrived via an edge, create a guideline
|
||||||
|
if (fromEdge) {
|
||||||
|
const guideline = createGuidelineFromJourneyEdge(
|
||||||
|
journey,
|
||||||
|
node,
|
||||||
|
fromEdge,
|
||||||
|
baseTags,
|
||||||
|
);
|
||||||
|
guidelines.push(guideline);
|
||||||
|
|
||||||
|
// Track follow-ups
|
||||||
|
const sourceId = findGuidelineForNode(fromEdge.sourceNodeId, journey.nodes);
|
||||||
|
if (sourceId) {
|
||||||
|
const existing = followUps.get(sourceId) ?? [];
|
||||||
|
existing.push(guideline.id);
|
||||||
|
followUps.set(sourceId, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue downstream nodes
|
||||||
|
const outgoingEdges = adjacency.get(nodeId) ?? [];
|
||||||
|
for (const edge of outgoingEdges) {
|
||||||
|
if (!visited.has(edge.targetNodeId)) {
|
||||||
|
queue.push({ nodeId: edge.targetNodeId, fromEdge: edge });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { guidelines, followUps };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGuidelineForNode(nodeId: string, nodes: JourneyNode[]): string | null {
|
||||||
|
// Placeholder: in a full implementation, map nodeId → guideline id
|
||||||
|
// For now return null — downstream consumers handle missing follow-ups gracefully
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGuidelineFromJourneyEdge(
|
||||||
|
journey: Journey,
|
||||||
|
targetNode: JourneyNode,
|
||||||
|
edge: JourneyEdge,
|
||||||
|
baseTags?: string[],
|
||||||
|
): Guideline {
|
||||||
|
const now = isoNow();
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
creationUtc: now,
|
||||||
|
content: {
|
||||||
|
condition: edge.condition,
|
||||||
|
action: targetNode.action,
|
||||||
|
description: targetNode.description ?? null,
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
tags: baseTags ?? [journey.name],
|
||||||
|
labels: [],
|
||||||
|
metadata: {
|
||||||
|
journey_id: journey.id,
|
||||||
|
journey_node: targetNode.id,
|
||||||
|
source_edge_id: `${edge.sourceNodeId}→${edge.targetNodeId}`,
|
||||||
|
},
|
||||||
|
criticality: 'medium',
|
||||||
|
title: targetNode.description
|
||||||
|
? `[${journey.name}] ${targetNode.description.slice(0, 60)}`
|
||||||
|
: null,
|
||||||
|
priority: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Backtrack detection ───
|
||||||
|
|
||||||
|
export interface BacktrackCheckInput {
|
||||||
|
journeyId: string;
|
||||||
|
currentNodeId: string;
|
||||||
|
previousNodeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BacktrackCheckResult {
|
||||||
|
journeyId: string;
|
||||||
|
currentNodeId: string;
|
||||||
|
previousNodeId: string;
|
||||||
|
isBacktrack: boolean;
|
||||||
|
recommendation: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if moving from previousNodeId to currentNodeId is a backtrack
|
||||||
|
* (regression to an already-visited node not on a forward path).
|
||||||
|
*/
|
||||||
|
export function checkBacktrack(
|
||||||
|
input: BacktrackCheckInput,
|
||||||
|
journey: Journey,
|
||||||
|
): BacktrackCheckResult {
|
||||||
|
const adjacency = new Map<string, string[]>();
|
||||||
|
for (const edge of journey.edges) {
|
||||||
|
const list = adjacency.get(edge.sourceNodeId) ?? [];
|
||||||
|
list.push(edge.targetNodeId);
|
||||||
|
adjacency.set(edge.sourceNodeId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find forward reachable nodes from the current node
|
||||||
|
const forwardReachable = new Set<string>();
|
||||||
|
const bfsQueue = [input.currentNodeId];
|
||||||
|
while (bfsQueue.length > 0) {
|
||||||
|
const nid = bfsQueue.shift()!;
|
||||||
|
if (forwardReachable.has(nid)) continue;
|
||||||
|
forwardReachable.add(nid);
|
||||||
|
const next = adjacency.get(nid) ?? [];
|
||||||
|
for (const n of next) {
|
||||||
|
if (!forwardReachable.has(n)) bfsQueue.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBacktrack = input.previousNodeId !== input.currentNodeId
|
||||||
|
&& !forwardReachable.has(input.previousNodeId)
|
||||||
|
&& input.previousNodeId !== input.currentNodeId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
journeyId: input.journeyId,
|
||||||
|
currentNodeId: input.currentNodeId,
|
||||||
|
previousNodeId: input.previousNodeId,
|
||||||
|
isBacktrack,
|
||||||
|
recommendation: isBacktrack
|
||||||
|
? `Revisiting node "${input.previousNodeId}" after "${input.currentNodeId}" — this may indicate a regression. Consider whether the forward path from "${input.currentNodeId}" is the correct one.`
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
10
apps/coder/src/services/hashline/constants.ts
Normal file
10
apps/coder/src/services/hashline/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const NIBBLE_STR = "ZPMQVRWSNKTXJBYH"
|
||||||
|
|
||||||
|
export const HASHLINE_DICT = Array.from({ length: 256 }, (_, i) => {
|
||||||
|
const high = i >>> 4
|
||||||
|
const low = i & 0x0f
|
||||||
|
return `${NIBBLE_STR[high]}${NIBBLE_STR[low]}`
|
||||||
|
})
|
||||||
|
|
||||||
|
export const HASHLINE_REF_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
|
||||||
|
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})\|(.*)$/
|
||||||
31
apps/coder/src/services/hashline/hash-computation.ts
Normal file
31
apps/coder/src/services/hashline/hash-computation.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { HASHLINE_DICT } from "./constants.js"
|
||||||
|
import { hashXxh32 } from "./xxhash32.js"
|
||||||
|
|
||||||
|
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u
|
||||||
|
|
||||||
|
function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string {
|
||||||
|
const stripped = normalizedContent
|
||||||
|
const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber
|
||||||
|
const hash = hashXxh32(stripped, seed)
|
||||||
|
const index = hash % 256
|
||||||
|
return HASHLINE_DICT[index]!
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeLineHash(lineNumber: number, content: string): string {
|
||||||
|
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").trimEnd())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeLegacyLineHash(lineNumber: number, content: string): string {
|
||||||
|
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").replace(/\s+/g, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHashLine(lineNumber: number, content: string): string {
|
||||||
|
const hash = computeLineHash(lineNumber, content)
|
||||||
|
return `${lineNumber}#${hash}|${content}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHashLines(content: string): string {
|
||||||
|
if (!content) return ""
|
||||||
|
const lines = content.split("\n")
|
||||||
|
return lines.map((line, index) => formatHashLine(index + 1, line)).join("\n")
|
||||||
|
}
|
||||||
11
apps/coder/src/services/hashline/index.ts
Normal file
11
apps/coder/src/services/hashline/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Hashline editing core — content-hash anchors for edit_file stale-patch detection.
|
||||||
|
*
|
||||||
|
* Ported from oh-my-openagent/packages/hashline-core/.
|
||||||
|
* Bundles a runtime-aware xxHash32 (Bun fast-path, pure-JS fallback).
|
||||||
|
*/
|
||||||
|
export { computeLineHash, formatHashLines, formatHashLine, computeLegacyLineHash } from "./hash-computation.js"
|
||||||
|
export { parseLineRef, validateLineRef, validateLineRefs, HashlineMismatchError, normalizeLineRef } from "./validation.js"
|
||||||
|
export type { LineRef } from "./validation.js"
|
||||||
|
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants.js"
|
||||||
|
export type { ReplaceEdit, AppendEdit, PrependEdit, HashlineEdit } from "./types.js"
|
||||||
20
apps/coder/src/services/hashline/types.ts
Normal file
20
apps/coder/src/services/hashline/types.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export interface ReplaceEdit {
|
||||||
|
op: "replace"
|
||||||
|
pos: string
|
||||||
|
end?: string
|
||||||
|
lines: string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppendEdit {
|
||||||
|
op: "append"
|
||||||
|
pos?: string
|
||||||
|
lines: string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrependEdit {
|
||||||
|
op: "prepend"
|
||||||
|
pos?: string
|
||||||
|
lines: string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HashlineEdit = ReplaceEdit | AppendEdit | PrependEdit
|
||||||
192
apps/coder/src/services/hashline/validation.ts
Normal file
192
apps/coder/src/services/hashline/validation.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { computeLegacyLineHash, computeLineHash } from "./hash-computation.js"
|
||||||
|
import { HASHLINE_REF_PATTERN } from "./constants.js"
|
||||||
|
|
||||||
|
export interface LineRef {
|
||||||
|
line: number
|
||||||
|
hash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HashMismatch {
|
||||||
|
line: number
|
||||||
|
expected: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MISMATCH_CONTEXT = 2
|
||||||
|
|
||||||
|
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
|
||||||
|
|
||||||
|
function isCompatibleLineHash(line: number, content: string, hash: string): boolean {
|
||||||
|
return computeLineHash(line, content) === hash || computeLegacyLineHash(line, content) === hash
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLineRef(ref: string): string {
|
||||||
|
const originalTrimmed = ref.trim()
|
||||||
|
let trimmed = originalTrimmed
|
||||||
|
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
|
||||||
|
trimmed = trimmed.replace(/\s*#\s*/, "#")
|
||||||
|
trimmed = trimmed.replace(/\|.*$/, "")
|
||||||
|
trimmed = trimmed.trim()
|
||||||
|
|
||||||
|
if (HASHLINE_REF_PATTERN.test(trimmed)) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN)
|
||||||
|
if (extracted) {
|
||||||
|
return extracted[1]!
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalTrimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLineRef(ref: string): LineRef {
|
||||||
|
const normalized = normalizeLineRef(ref)
|
||||||
|
const match = normalized.match(HASHLINE_REF_PATTERN)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
line: Number.parseInt(match[1]!, 10),
|
||||||
|
hash: match[2]!,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hashIdx = normalized.indexOf('#')
|
||||||
|
if (hashIdx > 0) {
|
||||||
|
const prefix = normalized.slice(0, hashIdx)
|
||||||
|
const suffix = normalized.slice(hashIdx + 1)
|
||||||
|
if (!/^\d+$/.test(prefix) && /^[ZPMQVRWSNKTXJBYH]{2}$/.test(suffix)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid line reference: "${ref}". "${prefix}" is not a line number. ` +
|
||||||
|
`Use the actual line number from the read output.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateLineRef(lines: string[], ref: string): void {
|
||||||
|
const { line, hash } = parseLineRefWithHint(ref, lines)
|
||||||
|
|
||||||
|
if (line < 1 || line > lines.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Line number ${line} out of bounds. File has ${lines.length} lines.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = lines[line - 1]
|
||||||
|
if (content === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`Line number ${line} out of bounds. File has ${lines.length} lines.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!isCompatibleLineHash(line, content, hash)) {
|
||||||
|
throw new HashlineMismatchError([{ line, expected: hash }], lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HashlineMismatchError extends Error {
|
||||||
|
readonly remaps: ReadonlyMap<string, string>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly mismatches: HashMismatch[],
|
||||||
|
private readonly fileLines: string[]
|
||||||
|
) {
|
||||||
|
super(HashlineMismatchError.formatMessage(mismatches, fileLines))
|
||||||
|
this.name = "HashlineMismatchError"
|
||||||
|
const remaps = new Map<string, string>()
|
||||||
|
for (const mismatch of mismatches) {
|
||||||
|
const content = fileLines[mismatch.line - 1]
|
||||||
|
const actualLine = content ?? ""
|
||||||
|
const actual = computeLineHash(mismatch.line, actualLine)
|
||||||
|
remaps.set(`${mismatch.line}#${mismatch.expected}`, `${mismatch.line}#${actual}`)
|
||||||
|
}
|
||||||
|
this.remaps = remaps
|
||||||
|
}
|
||||||
|
|
||||||
|
static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
|
||||||
|
const mismatchByLine = new Map<number, HashMismatch>()
|
||||||
|
for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch)
|
||||||
|
|
||||||
|
const displayLines = new Set<number>()
|
||||||
|
for (const mismatch of mismatches) {
|
||||||
|
const low = Math.max(1, mismatch.line - MISMATCH_CONTEXT)
|
||||||
|
const high = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT)
|
||||||
|
for (let line = low; line <= high; line++) displayLines.add(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedLines = [...displayLines].sort((a, b) => a - b)
|
||||||
|
const output: string[] = []
|
||||||
|
output.push(
|
||||||
|
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` +
|
||||||
|
"Use updated {line_number}#{hash_id} references below (>>> marks changed lines)."
|
||||||
|
)
|
||||||
|
output.push("")
|
||||||
|
|
||||||
|
let previousLine = -1
|
||||||
|
for (const line of sortedLines) {
|
||||||
|
if (previousLine !== -1 && line > previousLine + 1) {
|
||||||
|
output.push(" ...")
|
||||||
|
}
|
||||||
|
previousLine = line
|
||||||
|
|
||||||
|
const content = fileLines[line - 1] ?? ""
|
||||||
|
const hash = computeLineHash(line, content)
|
||||||
|
const prefix = `${line}#${hash}|${content}`
|
||||||
|
if (mismatchByLine.has(line)) {
|
||||||
|
output.push(`>>> ${prefix}`)
|
||||||
|
} else {
|
||||||
|
output.push(` ${prefix}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestLineForHash(ref: string, lines: string[]): string | null {
|
||||||
|
const hashMatch = ref.trim().match(/#([ZPMQVRWSNKTXJBYH]{2})$/)
|
||||||
|
if (!hashMatch) return null
|
||||||
|
const hash = hashMatch[1]!
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (isCompatibleLineHash(i + 1, lines[i] ?? "", hash)) {
|
||||||
|
return `Did you mean "${i + 1}#${computeLineHash(i + 1, lines[i] ?? "")}"?`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLineRefWithHint(ref: string, lines: string[]): LineRef {
|
||||||
|
try {
|
||||||
|
return parseLineRef(ref)
|
||||||
|
} catch (parseError) {
|
||||||
|
const hint = suggestLineForHash(ref, lines)
|
||||||
|
if (hint && parseError instanceof Error) {
|
||||||
|
throw new Error(`${parseError.message} ${hint}`)
|
||||||
|
}
|
||||||
|
throw parseError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateLineRefs(lines: string[], refs: string[]): void {
|
||||||
|
const mismatches: HashMismatch[] = []
|
||||||
|
|
||||||
|
for (const ref of refs) {
|
||||||
|
const { line, hash } = parseLineRefWithHint(ref, lines)
|
||||||
|
|
||||||
|
if (line < 1 || line > lines.length) {
|
||||||
|
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = lines[line - 1]
|
||||||
|
if (content === undefined) {
|
||||||
|
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
|
||||||
|
}
|
||||||
|
if (!isCompatibleLineHash(line, content, hash)) {
|
||||||
|
mismatches.push({ line, expected: hash })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mismatches.length > 0) {
|
||||||
|
throw new HashlineMismatchError(mismatches, lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
90
apps/coder/src/services/hashline/xxhash32.ts
Normal file
90
apps/coder/src/services/hashline/xxhash32.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
type BunHashRuntime = { hash: { xxHash32(data: string | Uint8Array, seed: number): number } }
|
||||||
|
|
||||||
|
const runtime = globalThis as typeof globalThis & { Bun?: BunHashRuntime }
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
|
const PRIME32_1 = 0x9e3779b1
|
||||||
|
const PRIME32_2 = 0x85ebca77
|
||||||
|
const PRIME32_3 = 0xc2b2ae3d
|
||||||
|
const PRIME32_4 = 0x27d4eb2f
|
||||||
|
const PRIME32_5 = 0x165667b1
|
||||||
|
|
||||||
|
function rotateLeft32(value: number, bits: number): number {
|
||||||
|
return ((value << bits) | (value >>> (32 - bits))) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUint32LittleEndian(input: Uint8Array, offset: number): number {
|
||||||
|
return (
|
||||||
|
((input[offset] ?? 0) |
|
||||||
|
((input[offset + 1] ?? 0) << 8) |
|
||||||
|
((input[offset + 2] ?? 0) << 16) |
|
||||||
|
((input[offset + 3] ?? 0) << 24)) >>>
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function round32(accumulator: number, value: number): number {
|
||||||
|
const added = (accumulator + Math.imul(value, PRIME32_2)) >>> 0
|
||||||
|
return Math.imul(rotateLeft32(added, 13), PRIME32_1) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function xxHash32Js(input: Uint8Array, seed: number): number {
|
||||||
|
let offset = 0
|
||||||
|
const length = input.length
|
||||||
|
let hash: number
|
||||||
|
|
||||||
|
if (length >= 16) {
|
||||||
|
const limit = length - 16
|
||||||
|
let value1 = (seed + PRIME32_1 + PRIME32_2) >>> 0
|
||||||
|
let value2 = (seed + PRIME32_2) >>> 0
|
||||||
|
let value3 = seed >>> 0
|
||||||
|
let value4 = (seed - PRIME32_1) >>> 0
|
||||||
|
|
||||||
|
while (offset <= limit) {
|
||||||
|
value1 = round32(value1, readUint32LittleEndian(input, offset))
|
||||||
|
offset += 4
|
||||||
|
value2 = round32(value2, readUint32LittleEndian(input, offset))
|
||||||
|
offset += 4
|
||||||
|
value3 = round32(value3, readUint32LittleEndian(input, offset))
|
||||||
|
offset += 4
|
||||||
|
value4 = round32(value4, readUint32LittleEndian(input, offset))
|
||||||
|
offset += 4
|
||||||
|
}
|
||||||
|
|
||||||
|
hash = (rotateLeft32(value1, 1) + rotateLeft32(value2, 7)) >>> 0
|
||||||
|
hash = (hash + rotateLeft32(value3, 12)) >>> 0
|
||||||
|
hash = (hash + rotateLeft32(value4, 18)) >>> 0
|
||||||
|
} else {
|
||||||
|
hash = (seed + PRIME32_5) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
hash = (hash + length) >>> 0
|
||||||
|
|
||||||
|
while (offset + 4 <= length) {
|
||||||
|
hash = (hash + Math.imul(readUint32LittleEndian(input, offset), PRIME32_3)) >>> 0
|
||||||
|
hash = Math.imul(rotateLeft32(hash, 17), PRIME32_4) >>> 0
|
||||||
|
offset += 4
|
||||||
|
}
|
||||||
|
|
||||||
|
while (offset < length) {
|
||||||
|
hash = (hash + Math.imul(input[offset] ?? 0, PRIME32_5)) >>> 0
|
||||||
|
hash = Math.imul(rotateLeft32(hash, 11), PRIME32_1) >>> 0
|
||||||
|
offset += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
hash = (hash ^ (hash >>> 15)) >>> 0
|
||||||
|
hash = Math.imul(hash, PRIME32_2) >>> 0
|
||||||
|
hash = (hash ^ (hash >>> 13)) >>> 0
|
||||||
|
hash = Math.imul(hash, PRIME32_3) >>> 0
|
||||||
|
|
||||||
|
return (hash ^ (hash >>> 16)) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashXxh32(input: string, seed: number): number {
|
||||||
|
const bun = runtime.Bun
|
||||||
|
if (bun !== undefined) {
|
||||||
|
return bun.hash.xxHash32(input, seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return xxHash32Js(encoder.encode(input), seed >>> 0)
|
||||||
|
}
|
||||||
75
apps/coder/src/services/lsp/client.ts
Normal file
75
apps/coder/src/services/lsp/client.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { createInterface } from 'node:readline';
|
||||||
|
import type { Readable, Writable } from 'node:stream';
|
||||||
|
|
||||||
|
interface RpcRequest {
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
id: number;
|
||||||
|
method: string;
|
||||||
|
params?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcResponse {
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
id: number;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { code: number; message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LspClient {
|
||||||
|
private nextId = 1;
|
||||||
|
private pending = new Map<number, { resolve: (v: RpcResponse) => void; reject: (e: Error) => void }>();
|
||||||
|
private buffer = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private stdin: Writable,
|
||||||
|
private stdout: Readable,
|
||||||
|
) {
|
||||||
|
const rl = createInterface({ input: stdout, crlfDelay: Infinity });
|
||||||
|
rl.on('line', (line) => this.handleLine(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleLine(line: string): void {
|
||||||
|
this.buffer += line + '\n';
|
||||||
|
const match = this.buffer.match(/Content-Length: (\d+)\r?\n\r?\n/);
|
||||||
|
if (!match || !match[1]) return;
|
||||||
|
const len = parseInt(match[1], 10);
|
||||||
|
const headerEnd = match.index! + match[0].length;
|
||||||
|
const body = this.buffer.slice(headerEnd, headerEnd + len);
|
||||||
|
if (body.length < len) return;
|
||||||
|
this.buffer = this.buffer.slice(headerEnd + len);
|
||||||
|
try {
|
||||||
|
const msg: RpcResponse = JSON.parse(body);
|
||||||
|
const cb = this.pending.get(msg.id);
|
||||||
|
if (cb) {
|
||||||
|
this.pending.delete(msg.id);
|
||||||
|
cb.resolve(msg);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(method: string, params?: unknown): Promise<unknown> {
|
||||||
|
const id = this.nextId++;
|
||||||
|
const req: RpcRequest = { jsonrpc: '2.0', id, method, params };
|
||||||
|
const body = JSON.stringify(req);
|
||||||
|
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, {
|
||||||
|
resolve: (resp) => {
|
||||||
|
if (resp.error) reject(new Error(resp.error.message));
|
||||||
|
else resolve(resp.result);
|
||||||
|
},
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
this.stdin.write(header + body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async notify(method: string, params?: unknown): Promise<void> {
|
||||||
|
const body = JSON.stringify({ jsonrpc: '2.0', method, params });
|
||||||
|
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
|
||||||
|
this.stdin.write(header + body);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/coder/src/services/lsp/config.ts
Normal file
19
apps/coder/src/services/lsp/config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export interface LspServerConfig {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
rootPatterns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TS_CONFIG: LspServerConfig = {
|
||||||
|
command: 'typescript-language-server',
|
||||||
|
args: ['--stdio'],
|
||||||
|
rootPatterns: ['package.json', 'tsconfig.json'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUPPORTED_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs']);
|
||||||
|
|
||||||
|
export function getServerConfig(filePath: string): LspServerConfig | null {
|
||||||
|
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||||
|
if (ext && SUPPORTED_EXTS.has(ext)) return TS_CONFIG;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
86
apps/coder/src/services/lsp/operations.ts
Normal file
86
apps/coder/src/services/lsp/operations.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { LspClient } from './client.js';
|
||||||
|
import type { Diagnostic, Location } from './types.js';
|
||||||
|
|
||||||
|
function fileUri(filePath: string): string {
|
||||||
|
return `file://${filePath.startsWith('/') ? '' : '/'}${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openDocument(
|
||||||
|
client: LspClient,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
version: number = 1,
|
||||||
|
): Promise<void> {
|
||||||
|
const uri = fileUri(filePath);
|
||||||
|
await client.notify('textDocument/didOpen', {
|
||||||
|
textDocument: { uri, languageId: 'typescript', version, text: content },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDocument(client: LspClient, filePath: string): Promise<void> {
|
||||||
|
await client.notify('textDocument/didClose', {
|
||||||
|
textDocument: { uri: fileUri(filePath) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDiagnostics(
|
||||||
|
client: LspClient,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<Diagnostic[]> {
|
||||||
|
const uri = fileUri(filePath);
|
||||||
|
await openDocument(client, filePath, content);
|
||||||
|
const result: any = await client.request('textDocument/diagnostic', {
|
||||||
|
textDocument: { uri },
|
||||||
|
});
|
||||||
|
await closeDocument(client, filePath);
|
||||||
|
const diagnostics: Diagnostic[] = [];
|
||||||
|
if (result?.diagnostics) {
|
||||||
|
for (const d of result.diagnostics) {
|
||||||
|
diagnostics.push({
|
||||||
|
range: d.range,
|
||||||
|
severity: d.severity ?? 1,
|
||||||
|
message: d.message,
|
||||||
|
source: d.source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gotoDefinition(
|
||||||
|
client: LspClient,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
line: number,
|
||||||
|
character: number,
|
||||||
|
): Promise<Location | null> {
|
||||||
|
const uri = fileUri(filePath);
|
||||||
|
await openDocument(client, filePath, content);
|
||||||
|
const result: any = await client.request('textDocument/definition', {
|
||||||
|
textDocument: { uri },
|
||||||
|
position: { line, character },
|
||||||
|
});
|
||||||
|
await closeDocument(client, filePath);
|
||||||
|
if (!result) return null;
|
||||||
|
const loc = Array.isArray(result) ? result[0] : result;
|
||||||
|
return loc ? { uri: loc.uri, range: loc.range } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findReferences(
|
||||||
|
client: LspClient,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
line: number,
|
||||||
|
character: number,
|
||||||
|
): Promise<Location[]> {
|
||||||
|
const uri = fileUri(filePath);
|
||||||
|
await openDocument(client, filePath, content);
|
||||||
|
const result: any = await client.request('textDocument/references', {
|
||||||
|
textDocument: { uri },
|
||||||
|
position: { line, character },
|
||||||
|
context: { includeDeclaration: true },
|
||||||
|
});
|
||||||
|
await closeDocument(client, filePath);
|
||||||
|
return (result ?? []).map((loc: any) => ({ uri: loc.uri, range: loc.range }));
|
||||||
|
}
|
||||||
119
apps/coder/src/services/lsp/server-manager.ts
Normal file
119
apps/coder/src/services/lsp/server-manager.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { LspClient } from './client.js';
|
||||||
|
import { getServerConfig } from './config.js';
|
||||||
|
|
||||||
|
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
const SWEEP_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
interface LspInstance {
|
||||||
|
client: LspClient;
|
||||||
|
proc: ChildProcess;
|
||||||
|
lastUsed: number;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LspServerManager {
|
||||||
|
private instances = new Map<string, LspInstance>();
|
||||||
|
private sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.startSweeper();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startSweeper(): void {
|
||||||
|
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
|
||||||
|
this.sweepTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private findProjectRoot(filePath: string): string | null {
|
||||||
|
let dir = filePath;
|
||||||
|
const config = getServerConfig(filePath);
|
||||||
|
if (!config) return null;
|
||||||
|
while (true) {
|
||||||
|
for (const pattern of config.rootPatterns) {
|
||||||
|
if (existsSync(join(dir, pattern))) return dir;
|
||||||
|
}
|
||||||
|
const parent = join(dir, '..');
|
||||||
|
if (parent === dir) return dir;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClient(filePath: string): Promise<LspClient | null> {
|
||||||
|
const config = getServerConfig(filePath);
|
||||||
|
if (!config) return null;
|
||||||
|
const projectRoot = this.findProjectRoot(filePath);
|
||||||
|
if (!projectRoot) return null;
|
||||||
|
|
||||||
|
const existing = this.instances.get(projectRoot);
|
||||||
|
if (existing) {
|
||||||
|
existing.lastUsed = Date.now();
|
||||||
|
clearTimeout(existing.timer);
|
||||||
|
existing.timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
|
||||||
|
existing.timer.unref?.();
|
||||||
|
return existing.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.spawn(projectRoot, config.command, config.args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async spawn(projectRoot: string, command: string, args: string[]): Promise<LspClient> {
|
||||||
|
const proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: projectRoot });
|
||||||
|
const client = new LspClient(proc.stdin!, proc.stdout!);
|
||||||
|
|
||||||
|
await client.request('initialize', {
|
||||||
|
processId: process.pid,
|
||||||
|
rootUri: `file://${projectRoot}`,
|
||||||
|
capabilities: {
|
||||||
|
textDocument: {
|
||||||
|
diagnostic: { dynamicRegistration: false },
|
||||||
|
definition: { dynamicRegistration: false },
|
||||||
|
references: { dynamicRegistration: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await client.notify('initialized', {});
|
||||||
|
|
||||||
|
const timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
|
||||||
|
timer.unref?.();
|
||||||
|
|
||||||
|
this.instances.set(projectRoot, { client, proc, lastUsed: Date.now(), timer });
|
||||||
|
proc.on('exit', () => this.instances.delete(projectRoot));
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private kill(projectRoot: string): void {
|
||||||
|
const inst = this.instances.get(projectRoot);
|
||||||
|
if (!inst) return;
|
||||||
|
this.instances.delete(projectRoot);
|
||||||
|
inst.proc.kill('SIGTERM');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (inst.proc.exitCode === null) inst.proc.kill('SIGKILL');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sweep(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [root, inst] of this.instances) {
|
||||||
|
if (now - inst.lastUsed > IDLE_TIMEOUT_MS) {
|
||||||
|
this.kill(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown(): void {
|
||||||
|
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
||||||
|
for (const root of [...this.instances.keys()]) {
|
||||||
|
this.kill(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveCount(): number {
|
||||||
|
return this.instances.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lspManager = new LspServerManager();
|
||||||
28
apps/coder/src/services/lsp/types.ts
Normal file
28
apps/coder/src/services/lsp/types.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export interface Position {
|
||||||
|
line: number;
|
||||||
|
character: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Range {
|
||||||
|
start: Position;
|
||||||
|
end: Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
uri: string;
|
||||||
|
range: Range;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Diagnostic {
|
||||||
|
range: Range;
|
||||||
|
severity: number;
|
||||||
|
message: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextDocumentItem {
|
||||||
|
uri: string;
|
||||||
|
languageId: string;
|
||||||
|
version: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ModelMetadata } from "./provider-cache.js"
|
||||||
|
|
||||||
|
export interface ProviderModelsCache {
|
||||||
|
readonly models: Record<string, readonly string[] | readonly ModelMetadata[]>
|
||||||
|
readonly connected: readonly string[]
|
||||||
|
readonly updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectedProvidersAdapter {
|
||||||
|
readConnectedProvidersCache(): string[] | null
|
||||||
|
findProviderModelMetadata(providerID: string, modelID: string): ModelMetadata | undefined
|
||||||
|
readProviderModelsCache(): ProviderModelsCache | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readConnectedProvidersCache(): string[] | null {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findProviderModelMetadata(
|
||||||
|
_providerID: string,
|
||||||
|
_modelID: string,
|
||||||
|
): ModelMetadata | undefined {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readProviderModelsCache(): ProviderModelsCache | null {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const connectedProvidersAdapter: ConnectedProvidersAdapter = {
|
||||||
|
readConnectedProvidersCache,
|
||||||
|
findProviderModelMetadata,
|
||||||
|
readProviderModelsCache,
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { FallbackEntry } from "./model-requirement-types.js"
|
||||||
|
import type { FallbackModelObject } from "./fallback-model-object.js"
|
||||||
|
import { normalizeFallbackModels } from "./model-resolver.js"
|
||||||
|
import { KNOWN_VARIANTS } from "./known-variants.js"
|
||||||
|
|
||||||
|
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
|
||||||
|
if (typeof rawModel !== "string") {
|
||||||
|
return { modelID: "" }
|
||||||
|
}
|
||||||
|
const trimmedModel = rawModel.trim()
|
||||||
|
if (!trimmedModel) {
|
||||||
|
return { modelID: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parenthesizedVariant = trimmedModel.match(/^(.*)\(([^()]+)\)\s*$/)
|
||||||
|
if (parenthesizedVariant) {
|
||||||
|
const modelID = parenthesizedVariant[1]?.trim() ?? ""
|
||||||
|
const variant = parenthesizedVariant[2]?.trim()
|
||||||
|
return variant ? { modelID, variant } : { modelID }
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceVariant = trimmedModel.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
|
||||||
|
if (spaceVariant) {
|
||||||
|
const modelID = spaceVariant[1]?.trim() ?? ""
|
||||||
|
const variant = spaceVariant[2]?.trim().toLowerCase()
|
||||||
|
if (variant && KNOWN_VARIANTS.has(variant)) {
|
||||||
|
return { modelID, variant }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modelID: trimmedModel }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFallbackModelEntry(
|
||||||
|
model: string,
|
||||||
|
contextProviderID: string | undefined,
|
||||||
|
defaultProviderID = "opencode",
|
||||||
|
): FallbackEntry | undefined {
|
||||||
|
if (typeof model !== "string") return undefined
|
||||||
|
const trimmed = model.trim()
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
|
||||||
|
const parts = trimmed.split("/")
|
||||||
|
const providerID =
|
||||||
|
parts.length >= 2 ? (parts[0]?.trim() ?? "") : (contextProviderID?.trim() || defaultProviderID)
|
||||||
|
const rawModelID = parts.length >= 2 ? parts.slice(1).join("/").trim() : trimmed
|
||||||
|
if (!providerID || !rawModelID) return undefined
|
||||||
|
|
||||||
|
const parsed = parseVariantFromModel(rawModelID)
|
||||||
|
if (!parsed.modelID) return undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
providers: [providerID],
|
||||||
|
model: parsed.modelID,
|
||||||
|
variant: parsed.variant,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFallbackModelObjectEntry(
|
||||||
|
obj: FallbackModelObject,
|
||||||
|
contextProviderID: string | undefined,
|
||||||
|
defaultProviderID = "opencode",
|
||||||
|
): FallbackEntry | undefined {
|
||||||
|
const base = parseFallbackModelEntry(obj.model, contextProviderID, defaultProviderID)
|
||||||
|
if (!base) return undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
variant: obj.variant ?? base.variant,
|
||||||
|
reasoningEffort: obj.reasoningEffort,
|
||||||
|
temperature: obj.temperature,
|
||||||
|
top_p: obj.top_p,
|
||||||
|
maxTokens: obj.maxTokens,
|
||||||
|
thinking: obj.thinking,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the most specific FallbackEntry whose `provider/model` is a prefix of
|
||||||
|
* the resolved `provider/modelID`. Longest match wins so that e.g.
|
||||||
|
* `openai/gpt-5.4-preview` picks the entry for `openai/gpt-5.4-preview` over
|
||||||
|
* the shorter `openai/gpt-5.4`.
|
||||||
|
*/
|
||||||
|
export function findMostSpecificFallbackEntry(
|
||||||
|
providerID: string,
|
||||||
|
modelID: string,
|
||||||
|
chain: FallbackEntry[],
|
||||||
|
): FallbackEntry | undefined {
|
||||||
|
const resolved = `${providerID}/${modelID}`.toLowerCase()
|
||||||
|
|
||||||
|
// Collect entries whose provider/model is a prefix of the resolved model,
|
||||||
|
// together with the length of the matching prefix (longest match wins).
|
||||||
|
const matches: { entry: FallbackEntry; matchLen: number }[] = []
|
||||||
|
for (const entry of chain) {
|
||||||
|
for (const p of entry.providers) {
|
||||||
|
const candidate = `${p}/${entry.model}`.toLowerCase()
|
||||||
|
if (resolved.startsWith(candidate)) {
|
||||||
|
matches.push({ entry, matchLen: candidate.length })
|
||||||
|
break // one match per entry is enough
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length === 0) return undefined
|
||||||
|
matches.sort((a, b) => b.matchLen - a.matchLen)
|
||||||
|
return matches[0]!.entry
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFallbackChainFromModels(
|
||||||
|
fallbackModels: string | (string | FallbackModelObject)[] | undefined,
|
||||||
|
contextProviderID: string | undefined,
|
||||||
|
defaultProviderID = "opencode",
|
||||||
|
): FallbackEntry[] | undefined {
|
||||||
|
const normalized = normalizeFallbackModels(fallbackModels)
|
||||||
|
if (!normalized || normalized.length === 0) return undefined
|
||||||
|
|
||||||
|
const parsed = normalized
|
||||||
|
.map((entry) => {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
return parseFallbackModelEntry(entry, contextProviderID, defaultProviderID)
|
||||||
|
}
|
||||||
|
return parseFallbackModelObjectEntry(entry, contextProviderID, defaultProviderID)
|
||||||
|
})
|
||||||
|
.filter((entry): entry is FallbackEntry => entry !== undefined)
|
||||||
|
|
||||||
|
if (parsed.length === 0) return undefined
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type FallbackModelObject = {
|
||||||
|
readonly model: string
|
||||||
|
readonly variant?: string
|
||||||
|
readonly reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
|
||||||
|
readonly temperature?: number
|
||||||
|
readonly top_p?: number
|
||||||
|
readonly maxTokens?: number
|
||||||
|
readonly thinking?: { readonly type: "enabled" | "disabled"; readonly budgetTokens?: number }
|
||||||
|
}
|
||||||
80
apps/coder/src/services/model-resolution/index.ts
Normal file
80
apps/coder/src/services/model-resolution/index.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
export type {
|
||||||
|
FallbackEntry,
|
||||||
|
ModelRequirement,
|
||||||
|
} from "./model-requirement-types.js"
|
||||||
|
export type {
|
||||||
|
FallbackModelObject,
|
||||||
|
} from "./fallback-model-object.js"
|
||||||
|
export type {
|
||||||
|
DelegatedModelConfig,
|
||||||
|
ModelResolutionRequest,
|
||||||
|
ModelResolutionProvenance,
|
||||||
|
ModelResolutionResult,
|
||||||
|
} from "./model-resolution-types.js"
|
||||||
|
export type {
|
||||||
|
ModelResolutionInput,
|
||||||
|
ModelSource,
|
||||||
|
ExtendedModelResolutionInput,
|
||||||
|
} from "./model-resolver.js"
|
||||||
|
export {
|
||||||
|
resolveModel,
|
||||||
|
resolveModelWithFallback,
|
||||||
|
normalizeFallbackModels,
|
||||||
|
flattenToFallbackModelStrings,
|
||||||
|
} from "./model-resolver.js"
|
||||||
|
export {
|
||||||
|
normalizeModel,
|
||||||
|
normalizeModelID,
|
||||||
|
} from "./model-normalization.js"
|
||||||
|
export {
|
||||||
|
fuzzyMatchModel,
|
||||||
|
isModelAvailable,
|
||||||
|
} from "./model-availability.js"
|
||||||
|
export {
|
||||||
|
transformModelForProvider,
|
||||||
|
transformModelForProviderDisplay,
|
||||||
|
} from "./provider-model-id-transform.js"
|
||||||
|
export {
|
||||||
|
buildFallbackChainFromModels,
|
||||||
|
parseFallbackModelEntry,
|
||||||
|
parseFallbackModelObjectEntry,
|
||||||
|
findMostSpecificFallbackEntry,
|
||||||
|
} from "./fallback-chain-from-models.js"
|
||||||
|
export {
|
||||||
|
KNOWN_VARIANTS,
|
||||||
|
} from "./known-variants.js"
|
||||||
|
export {
|
||||||
|
_setModelResolutionLogImplementationForTesting,
|
||||||
|
resolveModelPipeline,
|
||||||
|
} from "./model-resolution-pipeline.js"
|
||||||
|
export type {
|
||||||
|
ModelResolutionRequest as PipelineModelResolutionRequest,
|
||||||
|
ModelResolutionProvenance as PipelineModelResolutionProvenance,
|
||||||
|
ModelResolutionResult as PipelineModelResolutionResult,
|
||||||
|
ModelResolutionDeps,
|
||||||
|
} from "./model-resolution-pipeline.js"
|
||||||
|
export {
|
||||||
|
isRetryableModelError,
|
||||||
|
shouldRetryError,
|
||||||
|
getNextFallback,
|
||||||
|
hasMoreFallbacks,
|
||||||
|
selectFallbackProvider,
|
||||||
|
selectFallbackProviderWithCache,
|
||||||
|
} from "./model-error-classifier.js"
|
||||||
|
export type {
|
||||||
|
ErrorInfo,
|
||||||
|
} from "./model-error-classifier.js"
|
||||||
|
export type {
|
||||||
|
ProviderCache,
|
||||||
|
ModelMetadata,
|
||||||
|
} from "./provider-cache.js"
|
||||||
|
export type {
|
||||||
|
ProviderModelsCache,
|
||||||
|
ConnectedProvidersAdapter,
|
||||||
|
} from "./connected-providers-cache.js"
|
||||||
|
export {
|
||||||
|
readConnectedProvidersCache,
|
||||||
|
findProviderModelMetadata,
|
||||||
|
readProviderModelsCache,
|
||||||
|
connectedProvidersAdapter,
|
||||||
|
} from "./connected-providers-cache.js"
|
||||||
16
apps/coder/src/services/model-resolution/known-variants.ts
Normal file
16
apps/coder/src/services/model-resolution/known-variants.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Canonical set of recognised variant / effort tokens.
|
||||||
|
* Used by parseFallbackModelEntry (space-suffix detection) and
|
||||||
|
* flattenToFallbackModelStrings (inline-variant stripping).
|
||||||
|
*/
|
||||||
|
export const KNOWN_VARIANTS = new Set([
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high",
|
||||||
|
"xhigh",
|
||||||
|
"max",
|
||||||
|
"minimal",
|
||||||
|
"none",
|
||||||
|
"auto",
|
||||||
|
"thinking",
|
||||||
|
])
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
function normalizeModelName(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/claude-(opus|sonnet|haiku)-(\d+)[.-](\d+)/g, "claude-$1-$2.$3")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fuzzyMatchModel(
|
||||||
|
target: string,
|
||||||
|
available: Set<string>,
|
||||||
|
providers?: string[],
|
||||||
|
): string | null {
|
||||||
|
if (available.size === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetNormalized = normalizeModelName(target)
|
||||||
|
|
||||||
|
let candidates = Array.from(available)
|
||||||
|
if (providers && providers.length > 0) {
|
||||||
|
const providerSet = new Set(providers)
|
||||||
|
candidates = candidates.filter((model) => {
|
||||||
|
const [provider] = model.split("/")
|
||||||
|
return providerSet.has(provider!)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = candidates.filter((model) =>
|
||||||
|
normalizeModelName(model).includes(targetNormalized),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactMatch = matches.find((model) => normalizeModelName(model) === targetNormalized)
|
||||||
|
if (exactMatch) {
|
||||||
|
return exactMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactModelIdMatches = matches.filter((model) => {
|
||||||
|
const modelId = model.split("/").slice(1).join("/")
|
||||||
|
return normalizeModelName(modelId) === targetNormalized
|
||||||
|
})
|
||||||
|
if (exactModelIdMatches.length > 0) {
|
||||||
|
return exactModelIdMatches.reduce((shortest, current) =>
|
||||||
|
current.length < shortest.length ? current : shortest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.reduce((shortest, current) =>
|
||||||
|
current.length < shortest.length ? current : shortest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isModelAvailable(
|
||||||
|
targetModel: string,
|
||||||
|
availableModels: Set<string>,
|
||||||
|
): boolean {
|
||||||
|
return fuzzyMatchModel(targetModel, availableModels) !== null
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import type { FallbackEntry } from "./model-requirement-types.js"
|
||||||
|
import type { ProviderCache } from "./provider-cache.js"
|
||||||
|
import * as connectedProvidersCache from "./connected-providers-cache.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error names that indicate a retryable model error.
|
||||||
|
* These errors halt execution and should trigger fallback retry.
|
||||||
|
*/
|
||||||
|
const RETRYABLE_ERROR_NAMES = new Set([
|
||||||
|
"providermodelnotfounderror",
|
||||||
|
"ratelimiterror",
|
||||||
|
"modelunavailableerror",
|
||||||
|
"providerconnectionerror",
|
||||||
|
"authenticationerror",
|
||||||
|
])
|
||||||
|
|
||||||
|
const STOP_ERROR_NAMES = new Set([
|
||||||
|
"quotaexceedederror",
|
||||||
|
"insufficientcreditserror",
|
||||||
|
"freeusagelimiterror",
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error names that should NOT trigger retry.
|
||||||
|
* These errors are typically user-induced or fixable without switching models.
|
||||||
|
*/
|
||||||
|
const NON_RETRYABLE_ERROR_NAMES = new Set([
|
||||||
|
"messageabortederror",
|
||||||
|
"permissiondeniederror",
|
||||||
|
"contextlengtherror",
|
||||||
|
"timeouterror",
|
||||||
|
"validationerror",
|
||||||
|
"syntaxerror",
|
||||||
|
"usererror",
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message patterns that indicate a retryable error even without a known error name.
|
||||||
|
*/
|
||||||
|
const RETRYABLE_MESSAGE_PATTERNS = [
|
||||||
|
"rate_limit",
|
||||||
|
"rate limit",
|
||||||
|
"usage_limit_reached",
|
||||||
|
"usage limit has been reached",
|
||||||
|
"quota",
|
||||||
|
"all credentials for model",
|
||||||
|
"cooling down",
|
||||||
|
"exhausted your capacity",
|
||||||
|
"not found",
|
||||||
|
"unavailable",
|
||||||
|
"insufficient",
|
||||||
|
"too many requests",
|
||||||
|
"over limit",
|
||||||
|
"overloaded",
|
||||||
|
"bad gateway",
|
||||||
|
"bad request",
|
||||||
|
"unknown provider",
|
||||||
|
"provider not found",
|
||||||
|
"model_not_supported",
|
||||||
|
"model not supported",
|
||||||
|
"model is not supported",
|
||||||
|
"connection error",
|
||||||
|
"network error",
|
||||||
|
"timeout",
|
||||||
|
"service unavailable",
|
||||||
|
"internal_server_error",
|
||||||
|
"free usage",
|
||||||
|
"usage exceeded",
|
||||||
|
"credit",
|
||||||
|
"balance",
|
||||||
|
"temporarily unavailable",
|
||||||
|
"try again",
|
||||||
|
"请稍后重试",
|
||||||
|
"503",
|
||||||
|
"502",
|
||||||
|
"504",
|
||||||
|
"429",
|
||||||
|
"529",
|
||||||
|
"selected provider is forbidden",
|
||||||
|
"provider is forbidden",
|
||||||
|
// Chinese retryable patterns (Zhipu, etc.)
|
||||||
|
"频率限制", // "rate limit"
|
||||||
|
"请求过于频繁", // "too many requests"
|
||||||
|
"暂时不可用", // "temporarily unavailable"
|
||||||
|
"服务不可用", // "service unavailable"
|
||||||
|
"server_error",
|
||||||
|
"an error occurred while processing",
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message patterns that indicate a non-retryable STOP error (quota/billing exhaustion).
|
||||||
|
* These take precedence over RETRYABLE_MESSAGE_PATTERNS.
|
||||||
|
*/
|
||||||
|
const STOP_MESSAGE_PATTERNS = [
|
||||||
|
"quota will reset after",
|
||||||
|
"quota exceeded",
|
||||||
|
"free usage limit",
|
||||||
|
"billing limit",
|
||||||
|
"billing hard limit",
|
||||||
|
"monthly limit",
|
||||||
|
"plan limit",
|
||||||
|
"subscription quota",
|
||||||
|
"subscription limit",
|
||||||
|
"payment required",
|
||||||
|
"out of credits",
|
||||||
|
"credits exhausted",
|
||||||
|
"insufficient credits",
|
||||||
|
"insufficient balance",
|
||||||
|
"credit balance",
|
||||||
|
"usage limit for this month",
|
||||||
|
"exhausted your capacity",
|
||||||
|
// GLM/Z.ai business error codes that indicate permanent quota/billing exhaustion
|
||||||
|
"daily call limit",
|
||||||
|
"daily limit",
|
||||||
|
"usage limit reached for",
|
||||||
|
"in arrears",
|
||||||
|
"fair use policy",
|
||||||
|
"recharge and try",
|
||||||
|
"使用上限",
|
||||||
|
"额度不足",
|
||||||
|
"余额不足",
|
||||||
|
"已耗尽",
|
||||||
|
]
|
||||||
|
|
||||||
|
const AUTO_RETRY_GATE_PATTERNS = [
|
||||||
|
"rate limit",
|
||||||
|
"cooling down",
|
||||||
|
"credentials for model",
|
||||||
|
]
|
||||||
|
|
||||||
|
function hasProviderAutoRetrySignal(message: string): boolean {
|
||||||
|
if (!message.includes("retrying in")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return AUTO_RETRY_GATE_PATTERNS.some((pattern) => message.includes(pattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorInfo {
|
||||||
|
name?: string
|
||||||
|
message?: string
|
||||||
|
/** HTTP status code from the provider response (e.g., 429 for rate limit) */
|
||||||
|
statusCode?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if an error is a retryable model error.
|
||||||
|
* Returns true if it's a known retryable type OR matches retryable message patterns.
|
||||||
|
*/
|
||||||
|
export function isRetryableModelError(error: ErrorInfo): boolean {
|
||||||
|
// If we have an error name, check against known lists
|
||||||
|
if (error.name) {
|
||||||
|
const errorNameLower = error.name.toLowerCase()
|
||||||
|
// Explicit non-retryable takes precedence
|
||||||
|
if (NON_RETRYABLE_ERROR_NAMES.has(errorNameLower)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (STOP_ERROR_NAMES.has(errorNameLower)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Check if it's a known retryable error
|
||||||
|
if (RETRYABLE_ERROR_NAMES.has(errorNameLower)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check message patterns for unknown errors
|
||||||
|
const msg = error.message?.toLowerCase() ?? ""
|
||||||
|
|
||||||
|
// STOP patterns take precedence over retryable patterns
|
||||||
|
if (STOP_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasProviderAutoRetrySignal(msg)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP status code check: catches rate-limit errors regardless of message format/language.
|
||||||
|
// Uses the same codes as runtime-fallback config (400 excluded as it is a permanent client error).
|
||||||
|
if (
|
||||||
|
error.statusCode != null &&
|
||||||
|
(error.statusCode === 429 || error.statusCode === 503 || error.statusCode === 529)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return RETRYABLE_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if an error should trigger a fallback retry.
|
||||||
|
* Returns true for errors that halt execution.
|
||||||
|
*/
|
||||||
|
export function shouldRetryError(error: ErrorInfo): boolean {
|
||||||
|
return isRetryableModelError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the next fallback model from the chain based on attempt count.
|
||||||
|
* Returns undefined if all fallbacks have been exhausted.
|
||||||
|
*/
|
||||||
|
export function getNextFallback(
|
||||||
|
fallbackChain: FallbackEntry[],
|
||||||
|
attemptCount: number,
|
||||||
|
): FallbackEntry | undefined {
|
||||||
|
return fallbackChain[attemptCount]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if there are more fallbacks available after the current attempt.
|
||||||
|
*/
|
||||||
|
export function hasMoreFallbacks(
|
||||||
|
fallbackChain: FallbackEntry[],
|
||||||
|
attemptCount: number,
|
||||||
|
): boolean {
|
||||||
|
return attemptCount < fallbackChain.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the best provider for a fallback entry.
|
||||||
|
* Priority:
|
||||||
|
* 1) First connected provider in the entry's provider preference order
|
||||||
|
* 2) Preferred provider when connected (and entry providers are unavailable)
|
||||||
|
* 3) First provider listed in the fallback entry
|
||||||
|
*/
|
||||||
|
export function selectFallbackProvider(
|
||||||
|
providers: string[],
|
||||||
|
preferredProviderID?: string,
|
||||||
|
): string {
|
||||||
|
return selectFallbackProviderWithCache(
|
||||||
|
providers,
|
||||||
|
connectedProvidersCache,
|
||||||
|
preferredProviderID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectFallbackProviderWithCache(
|
||||||
|
providers: string[],
|
||||||
|
providerCache: ProviderCache,
|
||||||
|
preferredProviderID?: string,
|
||||||
|
): string {
|
||||||
|
const connectedProviders = providerCache.readConnectedProvidersCache()
|
||||||
|
if (connectedProviders) {
|
||||||
|
const connectedSet = new Set(connectedProviders.map(p => p.toLowerCase()))
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
if (connectedSet.has(provider.toLowerCase())) {
|
||||||
|
return provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
preferredProviderID &&
|
||||||
|
connectedSet.has(preferredProviderID.toLowerCase())
|
||||||
|
) {
|
||||||
|
return preferredProviderID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers[0] ?? preferredProviderID ?? "opencode"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export function normalizeModel(model?: string): string | undefined {
|
||||||
|
const trimmed = model?.trim()
|
||||||
|
return trimmed || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeModelID(modelID: string): string {
|
||||||
|
return modelID.replace(/\.(\d+)/g, "-$1")
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type FallbackEntry = {
|
||||||
|
providers: string[];
|
||||||
|
model: string;
|
||||||
|
variant?: string; // Entry-specific variant (e.g., GPT->high, Opus->max)
|
||||||
|
reasoningEffort?: string;
|
||||||
|
temperature?: number;
|
||||||
|
top_p?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelRequirement = {
|
||||||
|
fallbackChain: FallbackEntry[];
|
||||||
|
variant?: string; // Default variant (used when entry doesn't specify one)
|
||||||
|
requiresModel?: string; // If set, only activates when this model is available (fuzzy match)
|
||||||
|
requiresAnyModel?: boolean; // If true, requires at least ONE model in fallbackChain to be available (or empty availability treated as unavailable)
|
||||||
|
requiresProvider?: string[]; // If set, only activates when any of these providers is connected
|
||||||
|
};
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { fuzzyMatchModel } from "./model-availability.js"
|
||||||
|
import type { FallbackEntry } from "./model-requirement-types.js"
|
||||||
|
import { transformModelForProvider } from "./provider-model-id-transform.js"
|
||||||
|
import { normalizeModel } from "./model-normalization.js"
|
||||||
|
import type { ProviderCache } from "./provider-cache.js"
|
||||||
|
|
||||||
|
type LogImplementation = (message: string, data?: unknown) => void
|
||||||
|
|
||||||
|
let logImplementationForTesting: LogImplementation | undefined
|
||||||
|
|
||||||
|
function log(message: string, data?: unknown): void {
|
||||||
|
const logImpl = logImplementationForTesting
|
||||||
|
if (!logImpl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (arguments.length === 1) {
|
||||||
|
logImpl(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logImpl(message, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _setModelResolutionLogImplementationForTesting(
|
||||||
|
logImplementation: LogImplementation | undefined,
|
||||||
|
): void {
|
||||||
|
logImplementationForTesting = logImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelResolutionRequest = {
|
||||||
|
intent?: {
|
||||||
|
uiSelectedModel?: string
|
||||||
|
userModel?: string
|
||||||
|
userFallbackModels?: string[]
|
||||||
|
categoryDefaultModel?: string
|
||||||
|
}
|
||||||
|
constraints: {
|
||||||
|
availableModels: Set<string>
|
||||||
|
connectedProviders?: string[] | null
|
||||||
|
}
|
||||||
|
policy?: {
|
||||||
|
fallbackChain?: FallbackEntry[]
|
||||||
|
systemDefaultModel?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelResolutionProvenance =
|
||||||
|
| "override"
|
||||||
|
| "category-default"
|
||||||
|
| "provider-fallback"
|
||||||
|
| "system-default"
|
||||||
|
|
||||||
|
export type ModelResolutionResult = {
|
||||||
|
model: string
|
||||||
|
provenance: ModelResolutionProvenance
|
||||||
|
variant?: string
|
||||||
|
attempted?: string[]
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelResolutionDeps = {
|
||||||
|
fuzzyMatchModel: (
|
||||||
|
target: string,
|
||||||
|
available: Set<string>,
|
||||||
|
providers?: string[],
|
||||||
|
) => string | null
|
||||||
|
transformModelForProvider: (provider: string, model: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MODEL_RESOLUTION_DEPS: ModelResolutionDeps = {
|
||||||
|
fuzzyMatchModel,
|
||||||
|
transformModelForProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function resolveModelPipeline(
|
||||||
|
request: ModelResolutionRequest,
|
||||||
|
providerCache: ProviderCache = {
|
||||||
|
readConnectedProvidersCache: () => null,
|
||||||
|
findProviderModelMetadata: () => undefined,
|
||||||
|
},
|
||||||
|
deps: ModelResolutionDeps = DEFAULT_MODEL_RESOLUTION_DEPS,
|
||||||
|
): ModelResolutionResult | undefined {
|
||||||
|
const attempted: string[] = []
|
||||||
|
const { intent, constraints, policy } = request
|
||||||
|
const availableModels = constraints.availableModels
|
||||||
|
const fallbackChain = policy?.fallbackChain
|
||||||
|
const systemDefaultModel = policy?.systemDefaultModel
|
||||||
|
|
||||||
|
const normalizedUiModel = normalizeModel(intent?.uiSelectedModel)
|
||||||
|
if (normalizedUiModel) {
|
||||||
|
log("Model resolved via UI selection", { model: normalizedUiModel })
|
||||||
|
return { model: normalizedUiModel, provenance: "override" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUserModel = normalizeModel(intent?.userModel)
|
||||||
|
if (normalizedUserModel) {
|
||||||
|
log("Model resolved via config override", { model: normalizedUserModel })
|
||||||
|
return { model: normalizedUserModel, provenance: "override" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCategoryDefault = normalizeModel(intent?.categoryDefaultModel)
|
||||||
|
if (normalizedCategoryDefault) {
|
||||||
|
attempted.push(normalizedCategoryDefault)
|
||||||
|
if (availableModels.size > 0) {
|
||||||
|
const parts = normalizedCategoryDefault.split("/")
|
||||||
|
const providerHint = parts.length >= 2 ? [parts[0]!] : undefined
|
||||||
|
const match = deps.fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint)
|
||||||
|
if (match) {
|
||||||
|
log("Model resolved via category default (fuzzy matched)", {
|
||||||
|
original: normalizedCategoryDefault,
|
||||||
|
matched: match,
|
||||||
|
})
|
||||||
|
return { model: match, provenance: "category-default", attempted }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const connectedProviders = constraints.connectedProviders ?? providerCache.readConnectedProvidersCache()
|
||||||
|
if (connectedProviders === null) {
|
||||||
|
log("Model resolved via category default (no cache, first run)", {
|
||||||
|
model: normalizedCategoryDefault,
|
||||||
|
})
|
||||||
|
return { model: normalizedCategoryDefault, provenance: "category-default", attempted }
|
||||||
|
}
|
||||||
|
const parts = normalizedCategoryDefault.split("/")
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const provider = parts[0]!
|
||||||
|
if (connectedProviders.includes(provider)) {
|
||||||
|
const modelName = parts.slice(1).join("/")
|
||||||
|
const transformedModel = `${provider}/${deps.transformModelForProvider(provider, modelName)}`
|
||||||
|
log("Model resolved via category default (connected provider)", {
|
||||||
|
model: transformedModel,
|
||||||
|
original: normalizedCategoryDefault,
|
||||||
|
})
|
||||||
|
return { model: transformedModel, provenance: "category-default", attempted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("Category default model not available, falling through to fallback chain", {
|
||||||
|
model: normalizedCategoryDefault,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when - user configured fallback_models, try them before hardcoded fallback chain
|
||||||
|
const userFallbackModels = intent?.userFallbackModels
|
||||||
|
if (userFallbackModels && userFallbackModels.length > 0) {
|
||||||
|
if (availableModels.size === 0) {
|
||||||
|
const connectedProviders = constraints.connectedProviders ?? providerCache.readConnectedProvidersCache()
|
||||||
|
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
||||||
|
|
||||||
|
if (connectedSet !== null) {
|
||||||
|
for (const model of userFallbackModels) {
|
||||||
|
attempted.push(model)
|
||||||
|
const parts = model.split("/")
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const provider = parts[0]!
|
||||||
|
if (connectedSet.has(provider)) {
|
||||||
|
const modelName = parts.slice(1).join("/")
|
||||||
|
const transformedModel = `${provider}/${deps.transformModelForProvider(provider, modelName)}`
|
||||||
|
log("Model resolved via user fallback_models (connected provider)", { model: transformedModel, original: model })
|
||||||
|
return { model: transformedModel, provenance: "provider-fallback", attempted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("No connected provider found in user fallback_models, falling through to hardcoded chain")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const model of userFallbackModels) {
|
||||||
|
attempted.push(model)
|
||||||
|
const parts = model.split("/")
|
||||||
|
const providerHint = parts.length >= 2 ? [parts[0]!] : undefined
|
||||||
|
const match = deps.fuzzyMatchModel(model, availableModels, providerHint)
|
||||||
|
if (match) {
|
||||||
|
log("Model resolved via user fallback_models (availability confirmed)", { model, match })
|
||||||
|
return { model: match, provenance: "provider-fallback", attempted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("No available model found in user fallback_models, falling through to hardcoded chain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackChain && fallbackChain.length > 0) {
|
||||||
|
if (availableModels.size === 0) {
|
||||||
|
const connectedProviders = constraints.connectedProviders ?? providerCache.readConnectedProvidersCache()
|
||||||
|
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
||||||
|
|
||||||
|
if (connectedSet === null) {
|
||||||
|
log("Model fallback chain skipped (no connected providers cache) - falling through to system default")
|
||||||
|
} else {
|
||||||
|
for (const entry of fallbackChain) {
|
||||||
|
for (const provider of entry.providers) {
|
||||||
|
if (connectedSet.has(provider)) {
|
||||||
|
const transformedModelId = deps.transformModelForProvider(provider, entry.model)
|
||||||
|
const model = `${provider}/${transformedModelId}`
|
||||||
|
log("Model resolved via fallback chain (connected provider)", {
|
||||||
|
provider,
|
||||||
|
model: transformedModelId,
|
||||||
|
variant: entry.variant,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
provenance: "provider-fallback",
|
||||||
|
variant: entry.variant,
|
||||||
|
attempted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("No connected provider found in fallback chain, falling through to system default")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const entry of fallbackChain) {
|
||||||
|
for (const provider of entry.providers) {
|
||||||
|
const fullModel = `${provider}/${entry.model}`
|
||||||
|
const match = deps.fuzzyMatchModel(fullModel, availableModels, [provider])
|
||||||
|
if (match) {
|
||||||
|
log("Model resolved via fallback chain (availability confirmed)", {
|
||||||
|
provider,
|
||||||
|
model: entry.model,
|
||||||
|
match,
|
||||||
|
variant: entry.variant,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
model: match,
|
||||||
|
provenance: "provider-fallback",
|
||||||
|
variant: entry.variant,
|
||||||
|
attempted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const crossProviderMatch = deps.fuzzyMatchModel(entry.model, availableModels)
|
||||||
|
if (crossProviderMatch) {
|
||||||
|
log("Model resolved via fallback chain (cross-provider fuzzy match)", {
|
||||||
|
model: entry.model,
|
||||||
|
match: crossProviderMatch,
|
||||||
|
variant: entry.variant,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
model: crossProviderMatch,
|
||||||
|
provenance: "provider-fallback",
|
||||||
|
variant: entry.variant,
|
||||||
|
attempted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("No available model found in fallback chain, falling through to system default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemDefaultModel === undefined) {
|
||||||
|
log("No model resolved - systemDefaultModel not configured")
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
log("Model resolved via system default", { model: systemDefaultModel })
|
||||||
|
return { model: systemDefaultModel, provenance: "system-default", attempted }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { FallbackEntry } from "./model-requirement-types.js"
|
||||||
|
|
||||||
|
export interface DelegatedModelConfig {
|
||||||
|
providerID: string
|
||||||
|
modelID: string
|
||||||
|
variant?: string
|
||||||
|
reasoningEffort?: string
|
||||||
|
temperature?: number
|
||||||
|
top_p?: number
|
||||||
|
maxTokens?: number
|
||||||
|
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelResolutionRequest = {
|
||||||
|
intent?: {
|
||||||
|
uiSelectedModel?: string
|
||||||
|
userModel?: string
|
||||||
|
categoryDefaultModel?: string
|
||||||
|
}
|
||||||
|
constraints: {
|
||||||
|
availableModels: Set<string>
|
||||||
|
}
|
||||||
|
policy?: {
|
||||||
|
fallbackChain?: FallbackEntry[]
|
||||||
|
systemDefaultModel?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelResolutionProvenance =
|
||||||
|
| "override"
|
||||||
|
| "category-default"
|
||||||
|
| "provider-fallback"
|
||||||
|
| "system-default"
|
||||||
|
|
||||||
|
export type ModelResolutionResult = {
|
||||||
|
model: string
|
||||||
|
provenance: ModelResolutionProvenance
|
||||||
|
variant?: string
|
||||||
|
attempted?: string[]
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
109
apps/coder/src/services/model-resolution/model-resolver.ts
Normal file
109
apps/coder/src/services/model-resolution/model-resolver.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { FallbackEntry } from "./model-requirement-types.js"
|
||||||
|
import type { FallbackModelObject } from "./fallback-model-object.js"
|
||||||
|
import { normalizeModel } from "./model-normalization.js"
|
||||||
|
import { resolveModelPipeline } from "./model-resolution-pipeline.js"
|
||||||
|
import { KNOWN_VARIANTS } from "./known-variants.js"
|
||||||
|
import type { ConnectedProvidersAdapter } from "./connected-providers-cache.js"
|
||||||
|
import * as connectedProvidersCache from "./connected-providers-cache.js"
|
||||||
|
|
||||||
|
export type ModelResolutionInput = {
|
||||||
|
userModel?: string
|
||||||
|
inheritedModel?: string
|
||||||
|
systemDefault?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelSource =
|
||||||
|
| "override"
|
||||||
|
| "category-default"
|
||||||
|
| "provider-fallback"
|
||||||
|
| "system-default"
|
||||||
|
|
||||||
|
export type ModelResolutionResult = {
|
||||||
|
model: string
|
||||||
|
source: ModelSource
|
||||||
|
variant?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExtendedModelResolutionInput = {
|
||||||
|
uiSelectedModel?: string
|
||||||
|
userModel?: string
|
||||||
|
userFallbackModels?: string[]
|
||||||
|
categoryDefaultModel?: string
|
||||||
|
fallbackChain?: FallbackEntry[]
|
||||||
|
availableModels: Set<string>
|
||||||
|
systemDefaultModel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function resolveModel(input: ModelResolutionInput): string | undefined {
|
||||||
|
return (
|
||||||
|
normalizeModel(input.userModel) ??
|
||||||
|
normalizeModel(input.inheritedModel) ??
|
||||||
|
input.systemDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveModelWithFallback(
|
||||||
|
input: ExtendedModelResolutionInput,
|
||||||
|
connectedProvidersAdapter: ConnectedProvidersAdapter = connectedProvidersCache,
|
||||||
|
): ModelResolutionResult | undefined {
|
||||||
|
const { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel, fallbackChain, availableModels, systemDefaultModel } = input
|
||||||
|
const resolved = resolveModelPipeline({
|
||||||
|
intent: { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel },
|
||||||
|
constraints: { availableModels },
|
||||||
|
policy: { fallbackChain, systemDefaultModel },
|
||||||
|
}, connectedProvidersAdapter)
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
model: resolved.model,
|
||||||
|
source: resolved.provenance,
|
||||||
|
variant: resolved.variant,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes fallback_models config to a mixed array.
|
||||||
|
* Accepts string, string[], or mixed arrays of strings and FallbackModelObject entries.
|
||||||
|
*/
|
||||||
|
export function normalizeFallbackModels(
|
||||||
|
models: string | (string | FallbackModelObject)[] | undefined,
|
||||||
|
): (string | FallbackModelObject)[] | undefined {
|
||||||
|
if (!models) return undefined
|
||||||
|
if (typeof models === "string") return [models]
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts plain model strings from a mixed fallback models array.
|
||||||
|
* Object entries are flattened to "model" or "model(variant)" strings.
|
||||||
|
* Use this when consumers need string[] (e.g., resolveModelForDelegateTask).
|
||||||
|
*/
|
||||||
|
export function flattenToFallbackModelStrings(
|
||||||
|
models: (string | FallbackModelObject)[] | undefined,
|
||||||
|
): string[] | undefined {
|
||||||
|
if (!models) return undefined
|
||||||
|
return models.map((entry) => {
|
||||||
|
if (typeof entry === "string") return entry
|
||||||
|
const variant = entry.variant
|
||||||
|
if (variant) {
|
||||||
|
// Strip any supported inline variant syntax before appending explicit override.
|
||||||
|
// Supports both parenthesized and space-suffix forms so we don't emit
|
||||||
|
// invalid strings like "provider/model high(low)".
|
||||||
|
const model = entry.model
|
||||||
|
.replace(/\([^()]+\)\s*$/, "")
|
||||||
|
.replace(/\s+([a-z][a-z0-9_-]*)\s*$/i, (_match: string, suffix: string) => {
|
||||||
|
const normalized = String(suffix).toLowerCase()
|
||||||
|
return KNOWN_VARIANTS.has(normalized)
|
||||||
|
? ""
|
||||||
|
: _match
|
||||||
|
})
|
||||||
|
.trim()
|
||||||
|
return `${model}(${variant})`
|
||||||
|
}
|
||||||
|
return entry.model
|
||||||
|
})
|
||||||
|
}
|
||||||
27
apps/coder/src/services/model-resolution/provider-cache.ts
Normal file
27
apps/coder/src/services/model-resolution/provider-cache.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export interface ModelMetadata {
|
||||||
|
readonly id: string
|
||||||
|
readonly provider?: string
|
||||||
|
readonly context?: number
|
||||||
|
readonly output?: number
|
||||||
|
readonly name?: string
|
||||||
|
readonly variants?: Record<string, unknown>
|
||||||
|
readonly limit?: {
|
||||||
|
readonly context?: number
|
||||||
|
readonly input?: number
|
||||||
|
readonly output?: number
|
||||||
|
}
|
||||||
|
readonly modalities?: {
|
||||||
|
readonly input?: string[]
|
||||||
|
readonly output?: string[]
|
||||||
|
}
|
||||||
|
readonly capabilities?: Record<string, unknown>
|
||||||
|
readonly reasoning?: boolean
|
||||||
|
readonly temperature?: boolean
|
||||||
|
readonly tool_call?: boolean
|
||||||
|
readonly [key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderCache {
|
||||||
|
readConnectedProvidersCache(): string[] | null
|
||||||
|
findProviderModelMetadata(providerID: string, modelID: string): ModelMetadata | undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
function inferSubProvider(model: string): string | undefined {
|
||||||
|
if (model.startsWith("claude-")) return "anthropic"
|
||||||
|
if (model.startsWith("gpt-")) return "openai"
|
||||||
|
if (model.startsWith("gemini-")) return "google"
|
||||||
|
if (model.startsWith("grok-")) return "xai"
|
||||||
|
if (model.startsWith("minimax-")) return "minimax"
|
||||||
|
if (model.startsWith("kimi-")) return "moonshotai"
|
||||||
|
if (model.startsWith("glm-")) return "zai"
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLAUDE_VERSION_DOT = /claude-(\w+)-(\d+)-(\d+)/g
|
||||||
|
const GEMINI_31_PRO_PREVIEW = /gemini-3\.1-pro(?!-)/g
|
||||||
|
const GEMINI_3_FLASH_PREVIEW = /gemini-3-flash(?!-)/g
|
||||||
|
|
||||||
|
function claudeVersionDot(model: string): string {
|
||||||
|
return model.replace(CLAUDE_VERSION_DOT, "claude-$1-$2.$3")
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGatewayTransforms(model: string): string {
|
||||||
|
return claudeVersionDot(model).replace(
|
||||||
|
GEMINI_31_PRO_PREVIEW,
|
||||||
|
"gemini-3.1-pro-preview",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformModelForProviderUsingAnthropicBehavior(
|
||||||
|
provider: string,
|
||||||
|
model: string,
|
||||||
|
): string {
|
||||||
|
if (provider === "vercel") {
|
||||||
|
const slashIndex = model.indexOf("/")
|
||||||
|
if (slashIndex !== -1) {
|
||||||
|
const subProvider = model.substring(0, slashIndex)
|
||||||
|
const subModel = model.substring(slashIndex + 1)
|
||||||
|
return `${subProvider}/${applyGatewayTransforms(subModel)}`
|
||||||
|
}
|
||||||
|
const subProvider = inferSubProvider(model)
|
||||||
|
if (subProvider) {
|
||||||
|
return `${subProvider}/${applyGatewayTransforms(model)}`
|
||||||
|
}
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
if (provider === "github-copilot") {
|
||||||
|
return claudeVersionDot(model)
|
||||||
|
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
|
||||||
|
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
|
||||||
|
}
|
||||||
|
if (provider === "google") {
|
||||||
|
return model
|
||||||
|
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
|
||||||
|
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
|
||||||
|
}
|
||||||
|
if (provider === "anthropic") {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformModelForProvider(provider: string, model: string): string {
|
||||||
|
return transformModelForProviderUsingAnthropicBehavior(provider, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformModelForProviderDisplay(
|
||||||
|
provider: string,
|
||||||
|
model: string,
|
||||||
|
): string {
|
||||||
|
return transformModelForProviderUsingAnthropicBehavior(provider, model)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { analyzeMessages } from '../analyzer.js';
|
||||||
|
|
||||||
|
describe('analyzeMessages', () => {
|
||||||
|
it('classifies user messages', () => {
|
||||||
|
const breakdown = analyzeMessages([{ role: 'user', content: 'hello world' }]);
|
||||||
|
expect(breakdown.user).toBeGreaterThan(0);
|
||||||
|
expect(breakdown.total).toBe(breakdown.user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts tool calls', () => {
|
||||||
|
const parts = [
|
||||||
|
{ role: 'assistant', content: 'using grep', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||||
|
{ role: 'tool', content: '{"files":[]}', tool_call_id: '1' },
|
||||||
|
];
|
||||||
|
const breakdown = analyzeMessages(parts);
|
||||||
|
expect(breakdown.tools).toBeGreaterThan(0);
|
||||||
|
expect(breakdown.assistant).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates reasoning tokens', () => {
|
||||||
|
const parts = [
|
||||||
|
{ role: 'assistant', content: 'short answer', reasoning_parts: [{ text: 'long chain of thought reasoning here' }] },
|
||||||
|
];
|
||||||
|
const breakdown = analyzeMessages(parts);
|
||||||
|
expect(breakdown.reasoning).toBeGreaterThan(0);
|
||||||
|
expect(breakdown.assistant).toBeLessThan(breakdown.reasoning);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('persistTaskBreakdown', () => {
|
||||||
|
it('exports functions', async () => {
|
||||||
|
const mod = await import('../persist.js');
|
||||||
|
expect(typeof mod.persistTaskBreakdown).toBe('function');
|
||||||
|
expect(typeof mod.getTaskBreakdown).toBe('function');
|
||||||
|
expect(typeof mod.analyzeAndPersistTaskBreakdown).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
60
apps/coder/src/services/token-analysis/analyzer.ts
Normal file
60
apps/coder/src/services/token-analysis/analyzer.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// TokenScope analyzer — classifies message parts into category breakdown.
|
||||||
|
// Ported from opencode-tokenscope (MIT).
|
||||||
|
|
||||||
|
export interface TokenBreakdown {
|
||||||
|
system: number;
|
||||||
|
user: number;
|
||||||
|
assistant: number;
|
||||||
|
tools: number;
|
||||||
|
reasoning: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHARS_PER_TOKEN = 4;
|
||||||
|
|
||||||
|
function estimateTokens(text: string): number {
|
||||||
|
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analyzeMessages(parts: any[]): TokenBreakdown {
|
||||||
|
const breakdown: TokenBreakdown = { system: 0, user: 0, assistant: 0, tools: 0, reasoning: 0, total: 0 };
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const role = part.role ?? '';
|
||||||
|
const content = part.content ?? '';
|
||||||
|
const tokens = estimateTokens(content);
|
||||||
|
|
||||||
|
switch (role) {
|
||||||
|
case 'system':
|
||||||
|
breakdown.system += tokens;
|
||||||
|
break;
|
||||||
|
case 'user':
|
||||||
|
breakdown.user += tokens;
|
||||||
|
break;
|
||||||
|
case 'assistant':
|
||||||
|
breakdown.assistant += tokens;
|
||||||
|
if (part.tool_calls) {
|
||||||
|
for (const tc of part.tool_calls) {
|
||||||
|
breakdown.tools += estimateTokens(JSON.stringify(tc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'tool':
|
||||||
|
breakdown.tools += tokens;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
breakdown.assistant += tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.reasoning_parts) {
|
||||||
|
for (const rp of part.reasoning_parts) {
|
||||||
|
const rTokens = estimateTokens(rp.text ?? '');
|
||||||
|
breakdown.reasoning += rTokens;
|
||||||
|
breakdown.assistant -= rTokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
breakdown.total = breakdown.system + breakdown.user + breakdown.assistant + breakdown.tools + breakdown.reasoning;
|
||||||
|
return breakdown;
|
||||||
|
}
|
||||||
35
apps/coder/src/services/token-analysis/persist.ts
Normal file
35
apps/coder/src/services/token-analysis/persist.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// TokenScope persistence — writes breakdown to task records.
|
||||||
|
import type { Sql } from '../../db.js';
|
||||||
|
import type { TokenBreakdown } from './analyzer.js';
|
||||||
|
|
||||||
|
export async function persistTaskBreakdown(
|
||||||
|
sql: Sql,
|
||||||
|
taskId: string,
|
||||||
|
breakdown: TokenBreakdown,
|
||||||
|
): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks SET token_breakdown = ${sql.json(breakdown as never)}
|
||||||
|
WHERE id = ${taskId}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskBreakdown(
|
||||||
|
sql: Sql,
|
||||||
|
taskId: string,
|
||||||
|
): Promise<TokenBreakdown | null> {
|
||||||
|
const rows = await sql<{ token_breakdown: any }[]>`
|
||||||
|
SELECT token_breakdown FROM tasks WHERE id = ${taskId}
|
||||||
|
`;
|
||||||
|
return rows[0]?.token_breakdown ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeAndPersistTaskBreakdown(
|
||||||
|
sql: Sql,
|
||||||
|
taskId: string,
|
||||||
|
parts: any[],
|
||||||
|
): Promise<TokenBreakdown> {
|
||||||
|
const { analyzeMessages } = await import('./analyzer.js');
|
||||||
|
const breakdown = analyzeMessages(parts);
|
||||||
|
await persistTaskBreakdown(sql, taskId, breakdown);
|
||||||
|
return breakdown;
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ import { rewindTool } from './rewind.js';
|
|||||||
import { newTaskTool } from './new_task.js';
|
import { newTaskTool } from './new_task.js';
|
||||||
import { listTasksTool } from './list_tasks.js';
|
import { listTasksTool } from './list_tasks.js';
|
||||||
import { checkTaskStatusTool } from './check_task_status.js';
|
import { checkTaskStatusTool } from './check_task_status.js';
|
||||||
|
import { lspDiagnosticsTool } from './lsp_diagnostics.js';
|
||||||
|
import { lspGotoDefinitionTool } from './lsp_goto_definition.js';
|
||||||
|
import { lspFindReferencesTool } from './lsp_find_references.js';
|
||||||
|
|
||||||
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
|
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
|
||||||
|
|
||||||
@@ -26,4 +29,16 @@ export const WRITE_TOOLS: readonly ToolDef<any>[] = [
|
|||||||
checkTaskStatusTool,
|
checkTaskStatusTool,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool };
|
// Read-only agent tools for code intelligence.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const READ_TOOLS: readonly ToolDef<any>[] = [
|
||||||
|
lspDiagnosticsTool,
|
||||||
|
lspGotoDefinitionTool,
|
||||||
|
lspFindReferencesTool,
|
||||||
|
];
|
||||||
|
|
||||||
|
export {
|
||||||
|
editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool,
|
||||||
|
newTaskTool, listTasksTool, checkTaskStatusTool,
|
||||||
|
lspDiagnosticsTool, lspGotoDefinitionTool, lspFindReferencesTool,
|
||||||
|
};
|
||||||
|
|||||||
48
apps/coder/src/services/tools/lsp_diagnostics.ts
Normal file
48
apps/coder/src/services/tools/lsp_diagnostics.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { resolveWritePath } from '../write_guard.js';
|
||||||
|
import { lspManager } from '../lsp/server-manager.js';
|
||||||
|
import { getDiagnostics } from '../lsp/operations.js';
|
||||||
|
|
||||||
|
const LspDiagnosticsInput = z.object({
|
||||||
|
file_path: z.string().describe('Path to the file to check for diagnostics'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type InputT = z.infer<typeof LspDiagnosticsInput>;
|
||||||
|
|
||||||
|
export const lspDiagnosticsTool: ToolDef<InputT> = {
|
||||||
|
name: 'lsp_diagnostics',
|
||||||
|
description: 'Get TypeScript/JavaScript diagnostics (errors, warnings) for a file. Returns diagnostic messages with severity and location.',
|
||||||
|
inputSchema: LspDiagnosticsInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'lsp_diagnostics',
|
||||||
|
description: 'Get TypeScript/JavaScript diagnostics for a file',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string', description: 'Path to the file' },
|
||||||
|
},
|
||||||
|
required: ['file_path'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
|
||||||
|
const resolved = await resolveWritePath(projectRoot, input.file_path);
|
||||||
|
const content = await readFile(resolved, 'utf8');
|
||||||
|
const client = await lspManager.getClient(resolved);
|
||||||
|
if (!client) return { error: 'Unsupported file type for LSP diagnostics' };
|
||||||
|
|
||||||
|
const diagnostics = await getDiagnostics(client, resolved, content);
|
||||||
|
if (diagnostics.length === 0) return { result: 'No diagnostics found.' };
|
||||||
|
|
||||||
|
const lines = diagnostics.map((d) => {
|
||||||
|
const sev = ['', 'error', 'warning', 'info', 'hint'][d.severity] ?? 'unknown';
|
||||||
|
return `[${sev}] line ${d.range.start.line + 1}:${d.range.start.character + 1} - ${d.message}`;
|
||||||
|
});
|
||||||
|
return { result: lines.join('\n') };
|
||||||
|
},
|
||||||
|
};
|
||||||
49
apps/coder/src/services/tools/lsp_find_references.ts
Normal file
49
apps/coder/src/services/tools/lsp_find_references.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { resolveWritePath } from '../write_guard.js';
|
||||||
|
import { lspManager } from '../lsp/server-manager.js';
|
||||||
|
import { findReferences } from '../lsp/operations.js';
|
||||||
|
|
||||||
|
const LspFindReferencesInput = z.object({
|
||||||
|
file_path: z.string().describe('Path to the source file'),
|
||||||
|
line: z.number().int().nonnegative().describe('0-based line number'),
|
||||||
|
character: z.number().int().nonnegative().describe('0-based character offset'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type InputT = z.infer<typeof LspFindReferencesInput>;
|
||||||
|
|
||||||
|
export const lspFindReferencesTool: ToolDef<InputT> = {
|
||||||
|
name: 'lsp_find_references',
|
||||||
|
description: 'Find all references to a symbol at a given position in a file.',
|
||||||
|
inputSchema: LspFindReferencesInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'lsp_find_references',
|
||||||
|
description: 'Find all references to symbol at position',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string' },
|
||||||
|
line: { type: 'number' },
|
||||||
|
character: { type: 'number' },
|
||||||
|
},
|
||||||
|
required: ['file_path', 'line', 'character'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
|
||||||
|
const resolved = await resolveWritePath(projectRoot, input.file_path);
|
||||||
|
const content = await readFile(resolved, 'utf8');
|
||||||
|
const client = await lspManager.getClient(resolved);
|
||||||
|
if (!client) return { error: 'Unsupported file type' };
|
||||||
|
|
||||||
|
const refs = await findReferences(client, resolved, content, input.line, input.character);
|
||||||
|
if (refs.length === 0) return { result: 'No references found.' };
|
||||||
|
|
||||||
|
const lines = refs.map((r) => `${r.uri}:${r.range.start.line + 1}:${r.range.start.character + 1}`);
|
||||||
|
return { result: `Found ${refs.length} reference(s):\n${lines.join('\n')}` };
|
||||||
|
},
|
||||||
|
};
|
||||||
48
apps/coder/src/services/tools/lsp_goto_definition.ts
Normal file
48
apps/coder/src/services/tools/lsp_goto_definition.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { resolveWritePath } from '../write_guard.js';
|
||||||
|
import { lspManager } from '../lsp/server-manager.js';
|
||||||
|
import { gotoDefinition } from '../lsp/operations.js';
|
||||||
|
|
||||||
|
const LspGotoDefinitionInput = z.object({
|
||||||
|
file_path: z.string().describe('Path to the source file'),
|
||||||
|
line: z.number().int().nonnegative().describe('0-based line number'),
|
||||||
|
character: z.number().int().nonnegative().describe('0-based character offset'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type InputT = z.infer<typeof LspGotoDefinitionInput>;
|
||||||
|
|
||||||
|
export const lspGotoDefinitionTool: ToolDef<InputT> = {
|
||||||
|
name: 'lsp_goto_definition',
|
||||||
|
description: 'Find the definition of a symbol at a given position in a file.',
|
||||||
|
inputSchema: LspGotoDefinitionInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'lsp_goto_definition',
|
||||||
|
description: 'Find definition of symbol at position',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string' },
|
||||||
|
line: { type: 'number' },
|
||||||
|
character: { type: 'number' },
|
||||||
|
},
|
||||||
|
required: ['file_path', 'line', 'character'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
|
||||||
|
const resolved = await resolveWritePath(projectRoot, input.file_path);
|
||||||
|
const content = await readFile(resolved, 'utf8');
|
||||||
|
const client = await lspManager.getClient(resolved);
|
||||||
|
if (!client) return { error: 'Unsupported file type' };
|
||||||
|
|
||||||
|
const loc = await gotoDefinition(client, resolved, content, input.line, input.character);
|
||||||
|
if (!loc) return { result: 'No definition found.' };
|
||||||
|
|
||||||
|
return { result: `Defined at ${loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}` };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ const NewTaskInput = z.object({
|
|||||||
input: z.string().min(1).describe('Task description for the child subtask'),
|
input: z.string().min(1).describe('Task description for the child subtask'),
|
||||||
agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
|
agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
|
||||||
model: z.string().optional().describe('Optional: model override for the subtask'),
|
model: z.string().optional().describe('Optional: model override for the subtask'),
|
||||||
|
background: z.boolean().optional().describe('If true, return immediately without blocking on completion'),
|
||||||
});
|
});
|
||||||
|
|
||||||
type NewTaskInputT = z.infer<typeof NewTaskInput>;
|
type NewTaskInputT = z.infer<typeof NewTaskInput>;
|
||||||
@@ -30,6 +31,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
|||||||
input: { type: 'string', description: 'Task description for the child subtask' },
|
input: { type: 'string', description: 'Task description for the child subtask' },
|
||||||
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
|
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
|
||||||
model: { type: 'string', description: 'Optional: model override for the subtask' },
|
model: { type: 'string', description: 'Optional: model override for the subtask' },
|
||||||
|
background: { type: 'boolean', description: 'If true, returns immediately without waiting' },
|
||||||
},
|
},
|
||||||
required: ['input'],
|
required: ['input'],
|
||||||
},
|
},
|
||||||
@@ -50,6 +52,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
|||||||
return { error: 'Cannot determine project_id from current session' };
|
return { error: 'Cannot determine project_id from current session' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isBg = input.background === true;
|
||||||
const [task] = await sql<{ id: string; state: string }[]>`
|
const [task] = await sql<{ id: string; state: string }[]>`
|
||||||
INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
|
INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
|
||||||
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
|
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
|
||||||
@@ -57,9 +60,12 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
|
message: isBg
|
||||||
|
? `Background subtask created (id: ${task!.id}). It will continue independently.`
|
||||||
|
: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
|
||||||
task_id: task!.id,
|
task_id: task!.id,
|
||||||
state: task!.state,
|
state: task!.state,
|
||||||
|
background: isBg,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
- **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop — only `description` + `inputSchema: jsonSchema(parameters)`.
|
- **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop — only `description` + `inputSchema: jsonSchema(parameters)`.
|
||||||
- **`includeUsage: true` MUST be set on `createOpenAICompatible`** in `provider.ts`. The adapter defaults it false → no `stream_options.include_usage` → llama-swap emits no usage block → `result.usage` resolves `undefined` (NULL token counts). Don't remove during refactor.
|
- **`includeUsage: true` MUST be set on `createOpenAICompatible`** in `provider.ts`. The adapter defaults it false → no `stream_options.include_usage` → llama-swap emits no usage block → `result.usage` resolves `undefined` (NULL token counts). Don't remove during refactor.
|
||||||
- **Tool-call-only turns may emit a leading `\n` text-delta.** `MessageList.flatten`'s `hasText` and `MessageBubble`'s `hasContent` both `.trim()` before the length check, else whitespace-only content renders an empty bubble + ActionRow between tool calls. `buildMessagesPayload` also skips `status='failed'` and complete-but-empty assistant rows (avoids "Cannot have 2 or more assistant messages at the end of the list" upstream rejection after cap-hit + Continue).
|
- **Tool-call-only turns may emit a leading `\n` text-delta.** `MessageList.flatten`'s `hasText` and `MessageBubble`'s `hasContent` both `.trim()` before the length check, else whitespace-only content renders an empty bubble + ActionRow between tool calls. `buildMessagesPayload` also skips `status='failed'` and complete-but-empty assistant rows (avoids "Cannot have 2 or more assistant messages at the end of the list" upstream rejection after cap-hit + Continue).
|
||||||
|
- **`services/inference/tool-shim.ts`** — Recovers structured tool calls from plain-text model output. Some models (notably Qwen) emit `<tool_call><name>...</name><arguments>...</arguments></tool_call>` inline text instead of structured JSON. `extractToolCalls(text)` parses both XML and JSON inline formats. `hasToolCallMarkup(text)` is a fast pre-check. Used as a fallback in the stream phase when structured `tool_calls` parse fails. Does NOT require `FAST_MODEL` — operates on the existing turn's output text.
|
||||||
|
- **`services/inference/loop-detectors.ts`** — Six detectors that catch repetitive model behavior: `detectContentRepeat` (same content N times), `detectToolLoop` (same tool called consecutively). `detectDoomLoop` combines both. These are additive to the existing `sentinels.ts` doom-loop detection.
|
||||||
- **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart`; BooCode's OpenAI-shape history lacks it, so a forward-scan builds a `tool_call_id → toolName` map from prior assistant `tool_calls`. Tool outputs wrapped as `{ type: 'json' | 'text', value }` (v6 `ToolResultOutput`). Reasoning emits a `ReasoningPart` first in the content array.
|
- **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart`; BooCode's OpenAI-shape history lacks it, so a forward-scan builds a `tool_call_id → toolName` map from prior assistant `tool_calls`. Tool outputs wrapped as `{ type: 'json' | 'text', value }` (v6 `ToolResultOutput`). Reasoning emits a `ReasoningPart` first in the content array.
|
||||||
- **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn.
|
- **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn.
|
||||||
- **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up.
|
- **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up.
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { registerModelRoutes } from './routes/models.js';
|
|||||||
import { registerAgentRoutes } from './routes/agents.js';
|
import { registerAgentRoutes } from './routes/agents.js';
|
||||||
import { registerSkillsRoutes } from './routes/skills.js';
|
import { registerSkillsRoutes } from './routes/skills.js';
|
||||||
import { registerToolsRoutes } from './routes/tools.js';
|
import { registerToolsRoutes } from './routes/tools.js';
|
||||||
|
import { registerAnalyticsRoutes } from './routes/analytics.js';
|
||||||
|
import { registerInferenceSettingsRoutes } from './routes/inference-settings.js';
|
||||||
import { createInferenceRunner } from './services/inference/index.js';
|
import { createInferenceRunner } from './services/inference/index.js';
|
||||||
import { createBroker } from './services/broker.js';
|
import { createBroker } from './services/broker.js';
|
||||||
import { listSkills } from './services/skills.js';
|
import { listSkills } from './services/skills.js';
|
||||||
@@ -122,6 +124,8 @@ async function main() {
|
|||||||
registerSidebarRoutes(app, sql);
|
registerSidebarRoutes(app, sql);
|
||||||
registerChatRoutes(app, sql, broker);
|
registerChatRoutes(app, sql, broker);
|
||||||
registerToolsRoutes(app, sql);
|
registerToolsRoutes(app, sql);
|
||||||
|
registerAnalyticsRoutes(app, sql);
|
||||||
|
registerInferenceSettingsRoutes(app);
|
||||||
|
|
||||||
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
|
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
|
||||||
// missing /data/skills is non-fatal — the skill tools just return empty.
|
// missing /data/skills is non-fatal — the skill tools just return empty.
|
||||||
|
|||||||
33
apps/server/src/routes/analytics.ts
Normal file
33
apps/server/src/routes/analytics.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
|
||||||
|
// token-analyzer-ui: context window utilization and token breakdown data.
|
||||||
|
// v1 — global aggregates only.
|
||||||
|
|
||||||
|
export interface ContextWindowStats {
|
||||||
|
avg_ctx_used: number | null;
|
||||||
|
avg_ctx_max: number | null;
|
||||||
|
avg_utilization_pct: number | null;
|
||||||
|
message_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void {
|
||||||
|
// GET /api/analytics/context — average context window utilization across
|
||||||
|
// completed assistant messages that carry ctx_used/ctx_max.
|
||||||
|
app.get('/api/analytics/context', async () => {
|
||||||
|
const [row] = await sql<ContextWindowStats[]>`
|
||||||
|
SELECT
|
||||||
|
AVG(ctx_used)::DOUBLE PRECISION AS avg_ctx_used,
|
||||||
|
AVG(ctx_max)::DOUBLE PRECISION AS avg_ctx_max,
|
||||||
|
AVG(ctx_used::float / NULLIF(ctx_max, 0))::DOUBLE PRECISION AS avg_utilization_pct,
|
||||||
|
COUNT(*)::INT AS message_count
|
||||||
|
FROM messages
|
||||||
|
WHERE role = 'assistant'
|
||||||
|
AND status = 'complete'
|
||||||
|
AND ctx_used IS NOT NULL
|
||||||
|
AND ctx_max IS NOT NULL
|
||||||
|
AND ctx_max > 0
|
||||||
|
`;
|
||||||
|
return row ?? { avg_ctx_used: null, avg_ctx_max: null, avg_utilization_pct: null, message_count: 0 };
|
||||||
|
});
|
||||||
|
}
|
||||||
55
apps/server/src/routes/inference-settings.ts
Normal file
55
apps/server/src/routes/inference-settings.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
|
||||||
|
const CONFIG_PATH = resolve(process.env.BOOCODE_DATA_DIR || '/opt/boocode/data', 'inference-settings.json');
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
cache_type_k: 'q4_0',
|
||||||
|
cache_reuse: 256,
|
||||||
|
spec_type: 'ngram-mod',
|
||||||
|
spec_ngram_mod_thsh: 2,
|
||||||
|
ctx_checkpoints: 32,
|
||||||
|
sleep_idle_seconds: 600,
|
||||||
|
metrics_enabled: true,
|
||||||
|
slot_save_path: '/tmp/llama-slots',
|
||||||
|
};
|
||||||
|
|
||||||
|
function load(): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
if (existsSync(CONFIG_PATH)) {
|
||||||
|
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
||||||
|
}
|
||||||
|
} catch { /* corrupt file */ }
|
||||||
|
return { ...DEFAULTS };
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(data: Record<string, unknown>): void {
|
||||||
|
const dir = dirname(CONFIG_PATH);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_CACHE_TYPES = ['f32', 'f16', 'q8_0', 'q4_0'] as const;
|
||||||
|
const VALID_SPEC_TYPES = ['off', 'ngram-mod', 'draft-simple'] as const;
|
||||||
|
|
||||||
|
export function registerInferenceSettingsRoutes(app: FastifyInstance): void {
|
||||||
|
app.get('/api/settings/inference', async (_req, _res) => {
|
||||||
|
return { ...DEFAULTS, ...load() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch<{ Body: Record<string, unknown> }>('/api/settings/inference', async (req, reply) => {
|
||||||
|
const current = { ...DEFAULTS, ...load() };
|
||||||
|
const merged = { ...current, ...req.body };
|
||||||
|
|
||||||
|
if (merged.cache_type_k && !(VALID_CACHE_TYPES as readonly string[]).includes(merged.cache_type_k as string)) {
|
||||||
|
return reply.status(400).send({ error: 'Invalid cache_type_k' });
|
||||||
|
}
|
||||||
|
if (merged.spec_type && !(VALID_SPEC_TYPES as readonly string[]).includes(merged.spec_type as string)) {
|
||||||
|
return reply.status(400).send({ error: 'Invalid spec_type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
save(merged);
|
||||||
|
return { ...DEFAULTS, ...load() };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { realpath, stat, readdir, access } from 'node:fs/promises';
|
import { realpath, stat, readdir, access, writeFile, rename } from 'node:fs/promises';
|
||||||
import { basename, resolve, sep } from 'node:path';
|
import { basename, resolve, sep } from 'node:path';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
@@ -473,7 +473,7 @@ export function registerProjectRoutes(
|
|||||||
// Always includes auto_mode (the dirty-state-derived mode) so the client can
|
// Always includes auto_mode (the dirty-state-derived mode) so the client can
|
||||||
// show a suggestion when a pinned mode diverges from what would be auto-selected.
|
// show a suggestion when a pinned mode diverges from what would be auto-selected.
|
||||||
// Returns { git_repo: false } when the path is not a git repository.
|
// Returns { git_repo: false } when the path is not a git repository.
|
||||||
app.get<{ Params: { id: string }; Querystring: { mode?: string } }>(
|
app.get<{ Params: { id: string }; Querystring: { mode?: string; whitespace?: string } }>(
|
||||||
'/api/projects/:id/git/diff',
|
'/api/projects/:id/git/diff',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -504,7 +504,8 @@ export function registerProjectRoutes(
|
|||||||
rawMode === 'uncommitted' ? 'uncommitted' :
|
rawMode === 'uncommitted' ? 'uncommitted' :
|
||||||
auto_mode; // no mode param → auto-select (FIX 1)
|
auto_mode; // no mode param → auto-select (FIX 1)
|
||||||
|
|
||||||
const result = await getGitDiff(projectRoot, mode);
|
const ignoreWhitespace = req.query.whitespace === '1';
|
||||||
|
const result = await getGitDiff(projectRoot, mode, ignoreWhitespace);
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] };
|
return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] };
|
||||||
}
|
}
|
||||||
@@ -541,6 +542,11 @@ export function registerProjectRoutes(
|
|||||||
).min(1),
|
).min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const WriteFileBody = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/projects/:id/git/stage — stage whole files
|
// POST /api/projects/:id/git/stage — stage whole files
|
||||||
app.post<{ Params: { id: string } }>(
|
app.post<{ Params: { id: string } }>(
|
||||||
'/api/projects/:id/git/stage',
|
'/api/projects/:id/git/stage',
|
||||||
@@ -637,6 +643,38 @@ export function registerProjectRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// POST /api/projects/:id/write_file — write a file atomically
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/projects/:id/write_file',
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = WriteFileBody.safeParse(req.body);
|
||||||
|
if (!body.success) { reply.code(400); return { error: body.error.message }; }
|
||||||
|
const { id } = req.params;
|
||||||
|
const projectPath = await selectProjectPath(sql, id);
|
||||||
|
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
|
||||||
|
let root: string;
|
||||||
|
try { root = await resolveProjectRoot(projectPath); }
|
||||||
|
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
|
||||||
|
const target = body.data.path.startsWith('/') ? body.data.path : resolve(root, body.data.path);
|
||||||
|
// Validate path stays within project root
|
||||||
|
const realTarget = await realpath(target).catch(() => target);
|
||||||
|
if (!realTarget.startsWith(root + sep) && realTarget !== root) {
|
||||||
|
reply.code(403);
|
||||||
|
return { error: 'path escapes project root' };
|
||||||
|
}
|
||||||
|
const tmp = target + '.tmp';
|
||||||
|
try {
|
||||||
|
await writeFile(tmp, body.data.content, 'utf-8');
|
||||||
|
await rename(tmp, target);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
// Clean up tmp on failure
|
||||||
|
await access(tmp).then(() => rename(tmp, target + '.bak').catch(() => {})).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// GET /api/projects/:id/files
|
// GET /api/projects/:id/files
|
||||||
app.get<{ Params: { id: string } }>(
|
app.get<{ Params: { id: string } }>(
|
||||||
'/api/projects/:id/files',
|
'/api/projects/:id/files',
|
||||||
|
|||||||
@@ -112,14 +112,14 @@ describe('stripShadowingFlags', () => {
|
|||||||
expect(result).toEqual(['-c', '4096']);
|
expect(result).toEqual(['-c', '4096']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips cache flags by default', () => {
|
it('passes through cache flags (no longer shadowed)', () => {
|
||||||
const result = stripShadowingFlags(['--cache-type-k', 'q8_0']);
|
const result = stripShadowingFlags(['--cache-type-k', 'q8_0']);
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual(['--cache-type-k', 'q8_0']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips spec flags by default', () => {
|
it('passes through spec flags (no longer shadowed)', () => {
|
||||||
const result = stripShadowingFlags(['--spec-draft-n-max', '16']);
|
const result = stripShadowingFlags(['--spec-draft-n-max', '16']);
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual(['--spec-draft-n-max', '16']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
52
apps/server/src/services/audit/corrections.ts
Normal file
52
apps/server/src/services/audit/corrections.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export interface UserCorrectionRecord {
|
||||||
|
record_type: 'conversation';
|
||||||
|
action_type: 'user_correction';
|
||||||
|
priority: 'critical_for_recovery';
|
||||||
|
timestamp: string;
|
||||||
|
original_claim: string;
|
||||||
|
correction: string;
|
||||||
|
principle_extracted: string;
|
||||||
|
persisted_to: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCorrection(params: {
|
||||||
|
originalClaim: string;
|
||||||
|
correction: string;
|
||||||
|
principleExtracted?: string;
|
||||||
|
persistedTo?: string[];
|
||||||
|
}): UserCorrectionRecord {
|
||||||
|
return {
|
||||||
|
record_type: 'conversation',
|
||||||
|
action_type: 'user_correction',
|
||||||
|
priority: 'critical_for_recovery',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
original_claim: params.originalClaim,
|
||||||
|
correction: params.correction,
|
||||||
|
principle_extracted: params.principleExtracted || '',
|
||||||
|
persisted_to: params.persistedTo || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCorrections(
|
||||||
|
records: Record<string, unknown>[],
|
||||||
|
): UserCorrectionRecord[] {
|
||||||
|
return records.filter(
|
||||||
|
r => r['action_type'] === 'user_correction',
|
||||||
|
) as unknown as UserCorrectionRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkCorrectionConflict(
|
||||||
|
proposedAction: string,
|
||||||
|
corrections: UserCorrectionRecord[],
|
||||||
|
): UserCorrectionRecord | null {
|
||||||
|
for (const c of corrections) {
|
||||||
|
if (!c.original_claim) continue;
|
||||||
|
const claimKeywords = c.original_claim.toLowerCase().split(/\s+/).filter(w => w.length > 3);
|
||||||
|
const actionLower = proposedAction.toLowerCase();
|
||||||
|
const matchCount = claimKeywords.filter(k => actionLower.includes(k)).length;
|
||||||
|
if (matchCount >= 2 && matchCount / claimKeywords.length >= 0.5) {
|
||||||
|
if (c.persisted_to.length > 0) return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
251
apps/server/src/services/audit/guideline-store.ts
Normal file
251
apps/server/src/services/audit/guideline-store.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { ensureRunsDir } from './runs-dir.js';
|
||||||
|
|
||||||
|
export type GuidelineId = string;
|
||||||
|
export type TagId = string;
|
||||||
|
export type Criticality = 'low' | 'medium' | 'high';
|
||||||
|
export type GuidelineDocumentVersion = string;
|
||||||
|
|
||||||
|
export interface GuidelineContent {
|
||||||
|
condition: string;
|
||||||
|
action: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Guideline {
|
||||||
|
id: GuidelineId;
|
||||||
|
creationUtc: string;
|
||||||
|
content: GuidelineContent;
|
||||||
|
enabled: boolean;
|
||||||
|
tags: TagId[];
|
||||||
|
labels: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
criticality: Criticality;
|
||||||
|
title: string | null;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuidelineDocument {
|
||||||
|
id: string;
|
||||||
|
version: GuidelineDocumentVersion;
|
||||||
|
creation_utc: string;
|
||||||
|
condition: string;
|
||||||
|
action: string | null;
|
||||||
|
description: string | null;
|
||||||
|
title: string | null;
|
||||||
|
criticality: string;
|
||||||
|
enabled: boolean;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
labels: string[];
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuidelineUpdateParams {
|
||||||
|
condition?: string;
|
||||||
|
action?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
criticality?: Criticality;
|
||||||
|
enabled?: boolean;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
result += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbPath(projectRoot?: string): string {
|
||||||
|
const dir = join(ensureRunsDir(projectRoot), '..', 'guidelines');
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
return join(dir, 'guidelines.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDb(projectRoot?: string): GuidelineDocument[] {
|
||||||
|
const path = dbPath(projectRoot);
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8')) as GuidelineDocument[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeDb(docs: GuidelineDocument[], projectRoot?: string): void {
|
||||||
|
writeFileSync(dbPath(projectRoot), JSON.stringify(docs, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDocument(g: Guideline): GuidelineDocument {
|
||||||
|
return {
|
||||||
|
id: g.id,
|
||||||
|
version: '0.11.0',
|
||||||
|
creation_utc: g.creationUtc,
|
||||||
|
condition: g.content.condition,
|
||||||
|
action: g.content.action,
|
||||||
|
description: g.content.description,
|
||||||
|
title: g.title,
|
||||||
|
criticality: g.criticality,
|
||||||
|
enabled: g.enabled,
|
||||||
|
metadata: g.metadata,
|
||||||
|
labels: g.labels,
|
||||||
|
priority: g.priority,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromDocument(d: GuidelineDocument): Guideline {
|
||||||
|
return {
|
||||||
|
id: d.id,
|
||||||
|
creationUtc: d.creation_utc,
|
||||||
|
content: {
|
||||||
|
condition: d.condition,
|
||||||
|
action: d.action ?? null,
|
||||||
|
description: d.description ?? null,
|
||||||
|
},
|
||||||
|
title: d.title ?? null,
|
||||||
|
criticality: (d.criticality || 'medium') as Criticality,
|
||||||
|
enabled: d.enabled ?? true,
|
||||||
|
tags: [],
|
||||||
|
labels: d.labels ?? [],
|
||||||
|
metadata: d.metadata ?? {},
|
||||||
|
priority: d.priority ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GuidelineDocumentStore {
|
||||||
|
createGuideline(params: {
|
||||||
|
condition: string;
|
||||||
|
action?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
criticality?: Criticality;
|
||||||
|
enabled?: boolean;
|
||||||
|
labels?: string[];
|
||||||
|
priority?: number;
|
||||||
|
id?: GuidelineId;
|
||||||
|
}, projectRoot?: string): Guideline {
|
||||||
|
const docs = readDb(projectRoot);
|
||||||
|
const id = params.id || `gl_${generateId()}`;
|
||||||
|
|
||||||
|
if (docs.find(d => d.id === id)) {
|
||||||
|
throw new Error(`Guideline with id '${id}' already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guideline: Guideline = {
|
||||||
|
id,
|
||||||
|
creationUtc: new Date().toISOString(),
|
||||||
|
content: {
|
||||||
|
condition: params.condition,
|
||||||
|
action: params.action ?? null,
|
||||||
|
description: params.description ?? null,
|
||||||
|
},
|
||||||
|
title: params.title ?? null,
|
||||||
|
criticality: params.criticality ?? 'medium',
|
||||||
|
enabled: params.enabled ?? true,
|
||||||
|
tags: [],
|
||||||
|
labels: params.labels ?? [],
|
||||||
|
metadata: {},
|
||||||
|
priority: params.priority ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
docs.push(toDocument(guideline));
|
||||||
|
writeDb(docs, projectRoot);
|
||||||
|
return guideline;
|
||||||
|
}
|
||||||
|
|
||||||
|
listGuidelines(params?: {
|
||||||
|
tags?: TagId[];
|
||||||
|
labels?: string[];
|
||||||
|
}, projectRoot?: string): Guideline[] {
|
||||||
|
let docs = readDb(projectRoot);
|
||||||
|
|
||||||
|
if (params?.tags && params.tags.length > 0) {
|
||||||
|
const tagSet = new Set(params.tags);
|
||||||
|
docs = docs.filter(d => d.metadata['tags'] &&
|
||||||
|
Array.isArray(d.metadata['tags']) &&
|
||||||
|
(d.metadata['tags'] as string[]).some(t => tagSet.has(t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.labels && params.labels.length > 0) {
|
||||||
|
const labelSet = new Set(params.labels);
|
||||||
|
docs = docs.filter(d => {
|
||||||
|
const gl = fromDocument(d);
|
||||||
|
return params.labels!.every(l => gl.labels.includes(l));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return docs.map(fromDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
readGuideline(id: GuidelineId, projectRoot?: string): Guideline {
|
||||||
|
const docs = readDb(projectRoot);
|
||||||
|
const doc = docs.find(d => d.id === id);
|
||||||
|
if (!doc) throw new Error(`Guideline '${id}' not found`);
|
||||||
|
return fromDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGuideline(id: GuidelineId, params: GuidelineUpdateParams, projectRoot?: string): Guideline {
|
||||||
|
const docs = readDb(projectRoot);
|
||||||
|
const idx = docs.findIndex(d => d.id === id);
|
||||||
|
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
|
||||||
|
|
||||||
|
const doc = docs[idx]!;
|
||||||
|
if (params.condition !== undefined) doc.condition = params.condition;
|
||||||
|
if (params.action !== undefined) doc.action = params.action;
|
||||||
|
if (params.description !== undefined) doc.description = params.description;
|
||||||
|
if (params.title !== undefined) doc.title = params.title;
|
||||||
|
if (params.criticality !== undefined) doc.criticality = params.criticality;
|
||||||
|
if (params.enabled !== undefined) doc.enabled = params.enabled;
|
||||||
|
if (params.priority !== undefined) doc.priority = params.priority;
|
||||||
|
|
||||||
|
docs[idx] = doc;
|
||||||
|
writeDb(docs, projectRoot);
|
||||||
|
return fromDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGuideline(id: GuidelineId, projectRoot?: string): void {
|
||||||
|
const docs = readDb(projectRoot);
|
||||||
|
const idx = docs.findIndex(d => d.id === id);
|
||||||
|
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
|
||||||
|
docs.splice(idx, 1);
|
||||||
|
writeDb(docs, projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
findGuideline(content: GuidelineContent, projectRoot?: string): Guideline {
|
||||||
|
const docs = readDb(projectRoot);
|
||||||
|
const doc = docs.find(d =>
|
||||||
|
d.condition === content.condition &&
|
||||||
|
(content.action === undefined || d.action === content.action),
|
||||||
|
);
|
||||||
|
if (!doc) throw new Error(`Guideline not found for condition='${content.condition}'`);
|
||||||
|
return fromDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertLabels(id: GuidelineId, labels: string[], projectRoot?: string): Guideline {
|
||||||
|
const docs = readDb(projectRoot);
|
||||||
|
const idx = docs.findIndex(d => d.id === id);
|
||||||
|
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
|
||||||
|
|
||||||
|
const doc = docs[idx]!;
|
||||||
|
const current = new Set(doc.labels || []);
|
||||||
|
for (const l of labels) current.add(l);
|
||||||
|
doc.labels = [...current];
|
||||||
|
writeDb(docs, projectRoot);
|
||||||
|
return fromDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeLabels(id: GuidelineId, labels: string[], projectRoot?: string): Guideline {
|
||||||
|
const docs = readDb(projectRoot);
|
||||||
|
const idx = docs.findIndex(d => d.id === id);
|
||||||
|
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
|
||||||
|
|
||||||
|
const doc = docs[idx]!;
|
||||||
|
const removeSet = new Set(labels);
|
||||||
|
doc.labels = (doc.labels || []).filter(l => !removeSet.has(l));
|
||||||
|
writeDb(docs, projectRoot);
|
||||||
|
return fromDocument(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
apps/server/src/services/audit/index.ts
Normal file
68
apps/server/src/services/audit/index.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
export {
|
||||||
|
findRunsDir,
|
||||||
|
ensureRunsDir,
|
||||||
|
readCurrentSession,
|
||||||
|
writeCurrentSession,
|
||||||
|
clearCurrentSession,
|
||||||
|
readIndex,
|
||||||
|
writeIndex,
|
||||||
|
updateIndexEntry,
|
||||||
|
findInProgressSessions,
|
||||||
|
INDEX_SCHEMA_VERSION,
|
||||||
|
GITIGNORE_CONTENT,
|
||||||
|
} from './runs-dir.js';
|
||||||
|
export type { IndexEntry, IndexFile } from './runs-dir.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
generateSessionId,
|
||||||
|
isoNow,
|
||||||
|
createSession,
|
||||||
|
getSessionDir,
|
||||||
|
getActiveSession,
|
||||||
|
readSession,
|
||||||
|
updateSession,
|
||||||
|
endSession,
|
||||||
|
appendToTrail,
|
||||||
|
readTrail,
|
||||||
|
recoverContext,
|
||||||
|
checkUnfinishedSessions,
|
||||||
|
generateSessionSummary,
|
||||||
|
} from './session-manager.js';
|
||||||
|
export type { SessionJson, RecoverySummary } from './session-manager.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createCorrection,
|
||||||
|
findCorrections,
|
||||||
|
checkCorrectionConflict,
|
||||||
|
} from './corrections.js';
|
||||||
|
export type { UserCorrectionRecord } from './corrections.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
GuidelineDocumentStore,
|
||||||
|
} from './guideline-store.js';
|
||||||
|
export type {
|
||||||
|
GuidelineId,
|
||||||
|
GuidelineContent,
|
||||||
|
Guideline,
|
||||||
|
Criticality,
|
||||||
|
GuidelineUpdateParams,
|
||||||
|
GuidelineDocument,
|
||||||
|
} from './guideline-store.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
JourneyStore,
|
||||||
|
} from './journey-store.js';
|
||||||
|
export type {
|
||||||
|
JourneyId,
|
||||||
|
JourneyNodeId,
|
||||||
|
JourneyEdgeId,
|
||||||
|
Journey,
|
||||||
|
JourneyNode,
|
||||||
|
JourneyEdge,
|
||||||
|
} from './journey-store.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
projectJourneyToGuidelines,
|
||||||
|
detectJourneyBacktrack,
|
||||||
|
} from './journey-projection.js';
|
||||||
|
export type { ProjectedGuideline, BacktrackCheck } from './journey-projection.js';
|
||||||
189
apps/server/src/services/audit/journey-projection.ts
Normal file
189
apps/server/src/services/audit/journey-projection.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import type {
|
||||||
|
Journey,
|
||||||
|
JourneyNode,
|
||||||
|
JourneyEdge,
|
||||||
|
JourneyNodeId,
|
||||||
|
JourneyEdgeId,
|
||||||
|
} from './journey-store.js';
|
||||||
|
import type { Guideline, GuidelineId, Criticality } from './guideline-store.js';
|
||||||
|
|
||||||
|
export interface ProjectedGuideline {
|
||||||
|
id: GuidelineId;
|
||||||
|
content: {
|
||||||
|
condition: string;
|
||||||
|
action: string | null;
|
||||||
|
description: string | null;
|
||||||
|
};
|
||||||
|
criticality: Criticality;
|
||||||
|
creationUtc: string;
|
||||||
|
enabled: boolean;
|
||||||
|
tags: string[];
|
||||||
|
labels: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNodeGuidelineId(nodeId: JourneyNodeId, edgeId?: JourneyEdgeId | null): GuidelineId {
|
||||||
|
return `journey_node:${nodeId}${edgeId ? `:${edgeId}` : ''}` as GuidelineId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectJourneyToGuidelines(
|
||||||
|
journey: Journey,
|
||||||
|
nodes: JourneyNode[],
|
||||||
|
edges: JourneyEdge[],
|
||||||
|
): ProjectedGuideline[] {
|
||||||
|
const nodeMap = new Map<JourneyNodeId, JourneyNode>();
|
||||||
|
for (const n of nodes) nodeMap.set(n.id, n);
|
||||||
|
|
||||||
|
const edgeMap = new Map<JourneyEdgeId, JourneyEdge>();
|
||||||
|
for (const e of edges) edgeMap.set(e.id, e);
|
||||||
|
|
||||||
|
const nodeEdges = new Map<JourneyNodeId, JourneyEdge[]>();
|
||||||
|
for (const e of edges) {
|
||||||
|
const list = nodeEdges.get(e.source) || [];
|
||||||
|
list.push(e);
|
||||||
|
nodeEdges.set(e.source, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guidelines: Map<GuidelineId, ProjectedGuideline> = new Map();
|
||||||
|
const nodeIndexes = new Map<JourneyNodeId, number>();
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const queue: Array<{ edgeId: JourneyEdgeId | null; nodeId: JourneyNodeId }> = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
|
queue.push({ edgeId: null, nodeId: journey.rootId });
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const { edgeId, nodeId } = queue.shift()!;
|
||||||
|
const visitKey = `${edgeId || ''}:${nodeId}`;
|
||||||
|
if (visited.has(visitKey)) continue;
|
||||||
|
visited.add(visitKey);
|
||||||
|
|
||||||
|
const node = nodeMap.get(nodeId);
|
||||||
|
if (!node) continue;
|
||||||
|
|
||||||
|
if (!nodeIndexes.has(nodeId)) {
|
||||||
|
index++;
|
||||||
|
nodeIndexes.set(nodeId, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const edge = edgeId ? edgeMap.get(edgeId) : undefined;
|
||||||
|
|
||||||
|
const baseJourneyNode: Record<string, unknown> = {
|
||||||
|
follow_ups: [],
|
||||||
|
index: String(nodeIndexes.get(nodeId)),
|
||||||
|
journey_id: journey.id,
|
||||||
|
labels: node.labels,
|
||||||
|
tool_ids: node.tools,
|
||||||
|
};
|
||||||
|
|
||||||
|
const edgeJourneyNode = (edge?.metadata?.['journey_node'] as Record<string, unknown>) || {};
|
||||||
|
const nodeJourneyNode = (node.metadata?.['journey_node'] as Record<string, unknown>) || {};
|
||||||
|
|
||||||
|
const mergedJourneyNode = { ...baseJourneyNode, ...nodeJourneyNode, ...edgeJourneyNode };
|
||||||
|
|
||||||
|
const metadata: Record<string, unknown> = {
|
||||||
|
journey_node: mergedJourneyNode,
|
||||||
|
};
|
||||||
|
for (const [k, v] of Object.entries(node.metadata)) {
|
||||||
|
if (k !== 'journey_node') metadata[k] = v;
|
||||||
|
}
|
||||||
|
if (edge) {
|
||||||
|
for (const [k, v] of Object.entries(edge.metadata)) {
|
||||||
|
if (k !== 'journey_node') metadata[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gid = formatNodeGuidelineId(nodeId, edgeId);
|
||||||
|
const guideline: ProjectedGuideline = {
|
||||||
|
id: gid,
|
||||||
|
content: {
|
||||||
|
condition: (edge?.condition) || '',
|
||||||
|
action: node.action,
|
||||||
|
description: node.description,
|
||||||
|
},
|
||||||
|
criticality: 'high' as Criticality,
|
||||||
|
creationUtc: new Date().toISOString(),
|
||||||
|
enabled: true,
|
||||||
|
tags: journey.tags,
|
||||||
|
labels: [...(node.labels || [])],
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
guidelines.set(gid, guideline);
|
||||||
|
|
||||||
|
const childEdges = nodeEdges.get(nodeId) || [];
|
||||||
|
for (const childEdge of childEdges) {
|
||||||
|
if (visited.has(`${childEdge.id}:${childEdge.target}`)) continue;
|
||||||
|
queue.push({ edgeId: childEdge.id, nodeId: childEdge.target });
|
||||||
|
|
||||||
|
const childGid = formatNodeGuidelineId(childEdge.target, childEdge.id);
|
||||||
|
const followUps = (guideline.metadata['journey_node'] as Record<string, unknown>)['follow_ups'] as string[];
|
||||||
|
if (!followUps.includes(childGid)) {
|
||||||
|
followUps.push(childGid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...guidelines.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BacktrackCheck {
|
||||||
|
journeyId: string;
|
||||||
|
currentNodeId: JourneyNodeId;
|
||||||
|
previousNodeId: JourneyNodeId;
|
||||||
|
isBacktrack: boolean;
|
||||||
|
recommendation: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectJourneyBacktrack(
|
||||||
|
journey: Journey,
|
||||||
|
nodes: JourneyNode[],
|
||||||
|
edges: JourneyEdge[],
|
||||||
|
currentNodeId: JourneyNodeId,
|
||||||
|
previousNodeId: JourneyNodeId,
|
||||||
|
): BacktrackCheck {
|
||||||
|
const nodeMap = new Map<JourneyNodeId, JourneyNode>();
|
||||||
|
for (const n of nodes) nodeMap.set(n.id, n);
|
||||||
|
|
||||||
|
const adjacency = new Map<JourneyNodeId, JourneyNodeId[]>();
|
||||||
|
for (const e of edges) {
|
||||||
|
const list = adjacency.get(e.source) || [];
|
||||||
|
list.push(e.target);
|
||||||
|
adjacency.set(e.source, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInForwardPath = (from: JourneyNodeId, target: JourneyNodeId): boolean => {
|
||||||
|
const visitedInner = new Set<JourneyNodeId>();
|
||||||
|
const queueInner: JourneyNodeId[] = [from];
|
||||||
|
while (queueInner.length > 0) {
|
||||||
|
const current = queueInner.shift()!;
|
||||||
|
if (current === target) return true;
|
||||||
|
if (visitedInner.has(current)) continue;
|
||||||
|
visitedInner.add(current);
|
||||||
|
for (const next of adjacency.get(current) || []) {
|
||||||
|
if (!visitedInner.has(next)) queueInner.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromCurToPrev = isInForwardPath(currentNodeId, previousNodeId);
|
||||||
|
const fromPrevToCur = isInForwardPath(previousNodeId, currentNodeId);
|
||||||
|
|
||||||
|
const isBacktrack = !fromPrevToCur && !fromCurToPrev;
|
||||||
|
|
||||||
|
let recommendation: string | null = null;
|
||||||
|
if (isBacktrack && nodeMap.has(previousNodeId)) {
|
||||||
|
const prevNode = nodeMap.get(previousNodeId)!;
|
||||||
|
recommendation = `Detected potential backtrack from '${currentNodeId}' to '${previousNodeId}' (${prevNode.action || 'no action'}). Consider whether this regression is intentional.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
journeyId: journey.id,
|
||||||
|
currentNodeId,
|
||||||
|
previousNodeId,
|
||||||
|
isBacktrack,
|
||||||
|
recommendation,
|
||||||
|
};
|
||||||
|
}
|
||||||
360
apps/server/src/services/audit/journey-store.ts
Normal file
360
apps/server/src/services/audit/journey-store.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { ensureRunsDir } from './runs-dir.js';
|
||||||
|
import type { GuidelineId } from './guideline-store.js';
|
||||||
|
|
||||||
|
export type JourneyId = string;
|
||||||
|
export type JourneyNodeId = string;
|
||||||
|
export type JourneyEdgeId = string;
|
||||||
|
|
||||||
|
export interface JourneyNode {
|
||||||
|
id: JourneyNodeId;
|
||||||
|
creationUtc: string;
|
||||||
|
action: string | null;
|
||||||
|
tools: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
description: string | null;
|
||||||
|
labels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JourneyEdge {
|
||||||
|
id: JourneyEdgeId;
|
||||||
|
creationUtc: string;
|
||||||
|
source: JourneyNodeId;
|
||||||
|
target: JourneyNodeId;
|
||||||
|
condition: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Journey {
|
||||||
|
id: JourneyId;
|
||||||
|
creationUtc: string;
|
||||||
|
description: string;
|
||||||
|
triggers: GuidelineId[];
|
||||||
|
title: string;
|
||||||
|
rootId: JourneyNodeId;
|
||||||
|
tags: string[];
|
||||||
|
labels: string[];
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JourneyDocument {
|
||||||
|
id: string;
|
||||||
|
version: string;
|
||||||
|
creation_utc: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
root_id: JourneyNodeId;
|
||||||
|
labels: string[];
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeDocument {
|
||||||
|
id: string;
|
||||||
|
node_id: JourneyNodeId;
|
||||||
|
journey_id: JourneyId;
|
||||||
|
creation_utc: string;
|
||||||
|
action: string | null;
|
||||||
|
tools: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
description: string | null;
|
||||||
|
labels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EdgeDocument {
|
||||||
|
id: string;
|
||||||
|
journey_id: JourneyId;
|
||||||
|
creation_utc: string;
|
||||||
|
source: JourneyNodeId;
|
||||||
|
target: JourneyNodeId;
|
||||||
|
condition: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TriggerDocument {
|
||||||
|
id: string;
|
||||||
|
journey_id: JourneyId;
|
||||||
|
trigger: GuidelineId;
|
||||||
|
creation_utc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
result += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbPath(name: string, projectRoot?: string): string {
|
||||||
|
const dir = join(ensureRunsDir(projectRoot), '..', 'journeys');
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
return join(dir, `${name}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCollection<T>(name: string, projectRoot?: string): T[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(dbPath(name, projectRoot), 'utf-8')) as T[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCollection<T>(name: string, data: T[], projectRoot?: string): void {
|
||||||
|
writeFileSync(dbPath(name, projectRoot), JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JourneyStore {
|
||||||
|
createJourney(params: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
triggers?: GuidelineId[];
|
||||||
|
labels?: string[];
|
||||||
|
priority?: number;
|
||||||
|
}, projectRoot?: string): Journey {
|
||||||
|
const id = `jny_${generateId()}`;
|
||||||
|
const rootId = `node_${generateId()}`;
|
||||||
|
const creationUtc = new Date().toISOString();
|
||||||
|
|
||||||
|
const journey: Journey = {
|
||||||
|
id,
|
||||||
|
creationUtc,
|
||||||
|
description: params.description,
|
||||||
|
triggers: params.triggers || [],
|
||||||
|
title: params.title,
|
||||||
|
rootId,
|
||||||
|
tags: [],
|
||||||
|
labels: params.labels || [],
|
||||||
|
priority: params.priority || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
|
||||||
|
journeys.push({
|
||||||
|
id,
|
||||||
|
version: '0.7.0',
|
||||||
|
creation_utc: creationUtc,
|
||||||
|
title: params.title,
|
||||||
|
description: params.description,
|
||||||
|
root_id: rootId,
|
||||||
|
labels: params.labels || [],
|
||||||
|
priority: params.priority || 0,
|
||||||
|
});
|
||||||
|
writeCollection('journeys', journeys, projectRoot);
|
||||||
|
|
||||||
|
const root: JourneyNode = {
|
||||||
|
id: rootId,
|
||||||
|
creationUtc,
|
||||||
|
action: null,
|
||||||
|
tools: [],
|
||||||
|
metadata: {},
|
||||||
|
description: null,
|
||||||
|
labels: [],
|
||||||
|
};
|
||||||
|
const nodes = readCollection<NodeDocument>('nodes', projectRoot);
|
||||||
|
nodes.push({
|
||||||
|
id: `nd_${generateId()}`,
|
||||||
|
node_id: rootId,
|
||||||
|
journey_id: id,
|
||||||
|
creation_utc: creationUtc,
|
||||||
|
action: null,
|
||||||
|
tools: [],
|
||||||
|
metadata: {},
|
||||||
|
description: null,
|
||||||
|
labels: [],
|
||||||
|
});
|
||||||
|
writeCollection('nodes', nodes, projectRoot);
|
||||||
|
|
||||||
|
return journey;
|
||||||
|
}
|
||||||
|
|
||||||
|
readJourney(id: JourneyId, projectRoot?: string): Journey {
|
||||||
|
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
|
||||||
|
const doc = journeys.find(j => j.id === id);
|
||||||
|
if (!doc) throw new Error(`Journey '${id}' not found`);
|
||||||
|
|
||||||
|
const triggers = readCollection<TriggerDocument>('triggers', projectRoot)
|
||||||
|
.filter(t => t.journey_id === id)
|
||||||
|
.map(t => t.trigger);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
creationUtc: doc.creation_utc,
|
||||||
|
description: doc.description,
|
||||||
|
triggers,
|
||||||
|
title: doc.title,
|
||||||
|
rootId: doc.root_id,
|
||||||
|
tags: [],
|
||||||
|
labels: doc.labels || [],
|
||||||
|
priority: doc.priority || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteJourney(id: JourneyId, projectRoot?: string): void {
|
||||||
|
let journeys = readCollection<JourneyDocument>('journeys', projectRoot);
|
||||||
|
const idx = journeys.findIndex(j => j.id === id);
|
||||||
|
if (idx === -1) throw new Error(`Journey '${id}' not found`);
|
||||||
|
journeys.splice(idx, 1);
|
||||||
|
writeCollection('journeys', journeys, projectRoot);
|
||||||
|
|
||||||
|
let nodes = readCollection<NodeDocument>('nodes', projectRoot);
|
||||||
|
nodes = nodes.filter(n => n.journey_id !== id);
|
||||||
|
writeCollection('nodes', nodes, projectRoot);
|
||||||
|
|
||||||
|
let edges = readCollection<EdgeDocument>('edges', projectRoot);
|
||||||
|
edges = edges.filter(e => e.journey_id !== id);
|
||||||
|
writeCollection('edges', edges, projectRoot);
|
||||||
|
|
||||||
|
let triggers = readCollection<TriggerDocument>('triggers', projectRoot);
|
||||||
|
triggers = triggers.filter(t => t.journey_id !== id);
|
||||||
|
writeCollection('triggers', triggers, projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
listJourneys(projectRoot?: string): Journey[] {
|
||||||
|
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
|
||||||
|
return journeys.map(j => this.readJourney(j.id, projectRoot));
|
||||||
|
}
|
||||||
|
|
||||||
|
createNode(journeyId: JourneyId, params: {
|
||||||
|
action?: string | null;
|
||||||
|
tools?: string[];
|
||||||
|
description?: string | null;
|
||||||
|
labels?: string[];
|
||||||
|
id?: JourneyNodeId;
|
||||||
|
}, projectRoot?: string): JourneyNode {
|
||||||
|
const nodeId = params.id || `node_${generateId()}`;
|
||||||
|
const creationUtc = new Date().toISOString();
|
||||||
|
|
||||||
|
const node: JourneyNode = {
|
||||||
|
id: nodeId,
|
||||||
|
creationUtc,
|
||||||
|
action: params.action ?? null,
|
||||||
|
tools: params.tools || [],
|
||||||
|
metadata: {},
|
||||||
|
description: params.description ?? null,
|
||||||
|
labels: params.labels || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodes = readCollection<NodeDocument>('nodes', projectRoot);
|
||||||
|
nodes.push({
|
||||||
|
id: `nd_${generateId()}`,
|
||||||
|
node_id: nodeId,
|
||||||
|
journey_id: journeyId,
|
||||||
|
creation_utc: creationUtc,
|
||||||
|
action: node.action,
|
||||||
|
tools: node.tools,
|
||||||
|
metadata: node.metadata,
|
||||||
|
description: node.description,
|
||||||
|
labels: node.labels,
|
||||||
|
});
|
||||||
|
writeCollection('nodes', nodes, projectRoot);
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
listNodes(journeyId: JourneyId, projectRoot?: string): JourneyNode[] {
|
||||||
|
const docs = readCollection<NodeDocument>('nodes', projectRoot)
|
||||||
|
.filter(n => n.journey_id === journeyId);
|
||||||
|
|
||||||
|
const nodes = docs.map(d => ({
|
||||||
|
id: d.node_id,
|
||||||
|
creationUtc: d.creation_utc,
|
||||||
|
action: d.action,
|
||||||
|
tools: d.tools,
|
||||||
|
metadata: d.metadata,
|
||||||
|
description: d.description,
|
||||||
|
labels: d.labels || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: 'end' as JourneyNodeId,
|
||||||
|
creationUtc: new Date().toISOString(),
|
||||||
|
action: null,
|
||||||
|
tools: [],
|
||||||
|
metadata: {},
|
||||||
|
description: null,
|
||||||
|
labels: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
createEdge(journeyId: JourneyId, params: {
|
||||||
|
source: JourneyNodeId;
|
||||||
|
target: JourneyNodeId;
|
||||||
|
condition?: string | null;
|
||||||
|
}, projectRoot?: string): JourneyEdge {
|
||||||
|
const creationUtc = new Date().toISOString();
|
||||||
|
const edge: JourneyEdge = {
|
||||||
|
id: `edge_${generateId()}`,
|
||||||
|
creationUtc,
|
||||||
|
source: params.source,
|
||||||
|
target: params.target,
|
||||||
|
condition: params.condition ?? null,
|
||||||
|
metadata: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const edges = readCollection<EdgeDocument>('edges', projectRoot);
|
||||||
|
edges.push({
|
||||||
|
id: edge.id,
|
||||||
|
journey_id: journeyId,
|
||||||
|
creation_utc: creationUtc,
|
||||||
|
source: params.source,
|
||||||
|
target: params.target,
|
||||||
|
condition: params.condition ?? null,
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
writeCollection('edges', edges, projectRoot);
|
||||||
|
|
||||||
|
return edge;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEdges(journeyId: JourneyId, nodeId?: JourneyNodeId, projectRoot?: string): JourneyEdge[] {
|
||||||
|
let docs = readCollection<EdgeDocument>('edges', projectRoot)
|
||||||
|
.filter(e => e.journey_id === journeyId);
|
||||||
|
|
||||||
|
if (nodeId) {
|
||||||
|
docs = docs.filter(e => e.source === nodeId || e.target === nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return docs.map(d => ({
|
||||||
|
id: d.id,
|
||||||
|
creationUtc: d.creation_utc,
|
||||||
|
source: d.source,
|
||||||
|
target: d.target,
|
||||||
|
condition: d.condition,
|
||||||
|
metadata: d.metadata,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEdge(edgeId: JourneyEdgeId, projectRoot?: string): void {
|
||||||
|
let edges = readCollection<EdgeDocument>('edges', projectRoot);
|
||||||
|
const idx = edges.findIndex(e => e.id === edgeId);
|
||||||
|
if (idx === -1) throw new Error(`Edge '${edgeId}' not found`);
|
||||||
|
edges.splice(idx, 1);
|
||||||
|
writeCollection('edges', edges, projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
addTrigger(journeyId: JourneyId, trigger: GuidelineId, projectRoot?: string): boolean {
|
||||||
|
const triggers = readCollection<TriggerDocument>('triggers', projectRoot);
|
||||||
|
if (triggers.find(t => t.journey_id === journeyId && t.trigger === trigger)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
triggers.push({
|
||||||
|
id: `trg_${generateId()}`,
|
||||||
|
journey_id: journeyId,
|
||||||
|
trigger,
|
||||||
|
creation_utc: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
writeCollection('triggers', triggers, projectRoot);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTrigger(journeyId: JourneyId, trigger: GuidelineId, projectRoot?: string): boolean {
|
||||||
|
let triggers = readCollection<TriggerDocument>('triggers', projectRoot);
|
||||||
|
const len = triggers.length;
|
||||||
|
triggers = triggers.filter(t => !(t.journey_id === journeyId && t.trigger === trigger));
|
||||||
|
writeCollection('triggers', triggers, projectRoot);
|
||||||
|
return triggers.length < len;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
apps/server/src/services/audit/runs-dir.ts
Normal file
111
apps/server/src/services/audit/runs-dir.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
export const INDEX_SCHEMA_VERSION = '1.1';
|
||||||
|
export const GITIGNORE_CONTENT = `# boocode audit runs
|
||||||
|
/*
|
||||||
|
!index.json
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface IndexEntry {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
task?: string;
|
||||||
|
skill?: string;
|
||||||
|
created?: string;
|
||||||
|
last_updated?: string;
|
||||||
|
record_count?: number;
|
||||||
|
anomaly_count?: number;
|
||||||
|
max_anomaly_level?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexFile {
|
||||||
|
schema_version: string;
|
||||||
|
entries: IndexEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRunsDirFrom(start: string): string {
|
||||||
|
const explicit = process.env['AUDIT_DOT_DIR']?.trim();
|
||||||
|
const candidates = explicit ? [explicit] : ['.boo'];
|
||||||
|
let cur = resolve(start);
|
||||||
|
while (true) {
|
||||||
|
for (const basename of candidates) {
|
||||||
|
const candidate = join(cur, basename, 'runs');
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
const parent = resolve(cur, '..');
|
||||||
|
if (parent === cur) break;
|
||||||
|
cur = parent;
|
||||||
|
}
|
||||||
|
const defaultBasename = explicit || '.boo';
|
||||||
|
return join(resolve(start), defaultBasename, 'runs');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRunsDir(projectRoot?: string): string {
|
||||||
|
return findRunsDirFrom(projectRoot || process.cwd());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureRunsDir(projectRoot?: string): string {
|
||||||
|
const dir = findRunsDir(projectRoot);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const gitignorePath = join(dir, '.gitignore');
|
||||||
|
if (!existsSync(gitignorePath)) {
|
||||||
|
writeFileSync(gitignorePath, GITIGNORE_CONTENT, 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCurrentSession(projectRoot?: string): string | null {
|
||||||
|
const path = join(ensureRunsDir(projectRoot), '.current_session');
|
||||||
|
try {
|
||||||
|
return readFileSync(path, 'utf-8').trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCurrentSession(sessionId: string, projectRoot?: string): void {
|
||||||
|
writeFileSync(join(ensureRunsDir(projectRoot), '.current_session'), sessionId, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCurrentSession(projectRoot?: string): void {
|
||||||
|
const path = join(ensureRunsDir(projectRoot), '.current_session');
|
||||||
|
try {
|
||||||
|
writeFileSync(path, '', 'utf-8');
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readIndex(projectRoot?: string): IndexFile {
|
||||||
|
const path = join(ensureRunsDir(projectRoot), 'index.json');
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8')) as IndexFile;
|
||||||
|
} catch {
|
||||||
|
return { schema_version: INDEX_SCHEMA_VERSION, entries: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeIndex(index: IndexFile, projectRoot?: string): void {
|
||||||
|
const runsDir = ensureRunsDir(projectRoot);
|
||||||
|
writeFileSync(join(runsDir, 'index.json'), JSON.stringify(index, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateIndexEntry(entry: IndexEntry, projectRoot?: string): void {
|
||||||
|
const idx = readIndex(projectRoot);
|
||||||
|
const existing = idx.entries.find(e => e.id === entry.id);
|
||||||
|
if (existing) {
|
||||||
|
Object.assign(existing, entry);
|
||||||
|
} else {
|
||||||
|
idx.entries.push({ ...entry });
|
||||||
|
}
|
||||||
|
writeIndex(idx, projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findInProgressSessions(projectRoot?: string): IndexEntry[] {
|
||||||
|
const idx = readIndex(projectRoot);
|
||||||
|
return idx.entries.filter(e => e.status === 'in_progress');
|
||||||
|
}
|
||||||
236
apps/server/src/services/audit/session-manager.ts
Normal file
236
apps/server/src/services/audit/session-manager.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
ensureRunsDir,
|
||||||
|
readCurrentSession,
|
||||||
|
writeCurrentSession,
|
||||||
|
clearCurrentSession,
|
||||||
|
updateIndexEntry,
|
||||||
|
findInProgressSessions,
|
||||||
|
readIndex,
|
||||||
|
type IndexEntry,
|
||||||
|
} from './runs-dir.js';
|
||||||
|
|
||||||
|
export interface SessionJson {
|
||||||
|
session_id: string;
|
||||||
|
task: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time?: string;
|
||||||
|
status: 'in_progress' | 'completed';
|
||||||
|
expected_record_types?: string[];
|
||||||
|
total_records?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSessionId(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(now.getDate()).padStart(2, '0');
|
||||||
|
const h = String(now.getHours()).padStart(2, '0');
|
||||||
|
const min = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
return `adhoc_${y}${m}${d}_${h}${min}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoNow(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSession(
|
||||||
|
task: string,
|
||||||
|
sessionId?: string,
|
||||||
|
projectRoot?: string,
|
||||||
|
): string {
|
||||||
|
const sid = sessionId || generateSessionId();
|
||||||
|
const runsDir = ensureRunsDir(projectRoot);
|
||||||
|
const sessionDir = join(runsDir, sid);
|
||||||
|
mkdirSync(sessionDir, { recursive: true });
|
||||||
|
|
||||||
|
const session: SessionJson = {
|
||||||
|
session_id: sid,
|
||||||
|
task,
|
||||||
|
start_time: isoNow(),
|
||||||
|
status: 'in_progress',
|
||||||
|
expected_record_types: ['data', 'change', 'conversation'],
|
||||||
|
};
|
||||||
|
|
||||||
|
writeFileSync(join(sessionDir, 'session.json'), JSON.stringify(session, null, 2), 'utf-8');
|
||||||
|
writeCurrentSession(sid, projectRoot);
|
||||||
|
|
||||||
|
updateIndexEntry({
|
||||||
|
id: sid,
|
||||||
|
type: 'adhoc',
|
||||||
|
status: 'in_progress',
|
||||||
|
task,
|
||||||
|
created: session.start_time,
|
||||||
|
last_updated: session.start_time,
|
||||||
|
}, projectRoot);
|
||||||
|
|
||||||
|
return sid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionDir(sessionId: string, projectRoot?: string): string {
|
||||||
|
return join(ensureRunsDir(projectRoot), sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveSession(projectRoot?: string): SessionJson | null {
|
||||||
|
const sid = readCurrentSession(projectRoot);
|
||||||
|
if (!sid) return null;
|
||||||
|
return readSession(sid, projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSession(sessionId: string, projectRoot?: string): SessionJson | null {
|
||||||
|
const path = join(getSessionDir(sessionId, projectRoot), 'session.json');
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8')) as SessionJson;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSession(
|
||||||
|
sessionId: string,
|
||||||
|
updates: Partial<SessionJson>,
|
||||||
|
projectRoot?: string,
|
||||||
|
): void {
|
||||||
|
const session = readSession(sessionId, projectRoot) || { session_id: sessionId, task: '', start_time: isoNow(), status: 'in_progress' as const };
|
||||||
|
Object.assign(session, updates);
|
||||||
|
writeFileSync(
|
||||||
|
join(getSessionDir(sessionId, projectRoot), 'session.json'),
|
||||||
|
JSON.stringify(session, null, 2),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endSession(sessionId: string, projectRoot?: string): void {
|
||||||
|
updateSession(sessionId, { status: 'completed', end_time: isoNow() }, projectRoot);
|
||||||
|
updateIndexEntry({ id: sessionId, type: 'adhoc', status: 'completed', last_updated: isoNow() }, projectRoot);
|
||||||
|
clearCurrentSession(projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendToTrail(sessionId: string, records: Record<string, unknown>[], projectRoot?: string): void {
|
||||||
|
const trailPath = join(getSessionDir(sessionId, projectRoot), 'audit_trail.jsonl');
|
||||||
|
const lines = records.map(r => JSON.stringify(r)).join('\n') + '\n';
|
||||||
|
appendFileSync(trailPath, lines, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTrail(sessionId: string, projectRoot?: string): Record<string, unknown>[] {
|
||||||
|
const trailPath = join(getSessionDir(sessionId, projectRoot), 'audit_trail.jsonl');
|
||||||
|
try {
|
||||||
|
const content = readFileSync(trailPath, 'utf-8').trim();
|
||||||
|
if (!content) return [];
|
||||||
|
return content.split('\n').filter(Boolean).map(line => JSON.parse(line) as Record<string, unknown>);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecoverySummary {
|
||||||
|
sessionId: string;
|
||||||
|
task: string;
|
||||||
|
recentActivity: IndexEntry[];
|
||||||
|
userCorrections: Record<string, unknown>[];
|
||||||
|
unresolvedIssues: string[];
|
||||||
|
recommendedPriorities: string[];
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recoverContext(
|
||||||
|
sessionId: string,
|
||||||
|
level: number,
|
||||||
|
projectRoot?: string,
|
||||||
|
): RecoverySummary {
|
||||||
|
const session = readSession(sessionId, projectRoot);
|
||||||
|
const idx = readIndex(projectRoot);
|
||||||
|
const recentActivity = idx.entries.slice(-5);
|
||||||
|
const trail = readTrail(sessionId, projectRoot);
|
||||||
|
const userCorrections = trail.filter(r => r['action_type'] === 'user_correction');
|
||||||
|
|
||||||
|
const summary: RecoverySummary = {
|
||||||
|
sessionId,
|
||||||
|
task: session?.task || '(unknown)',
|
||||||
|
recentActivity,
|
||||||
|
userCorrections,
|
||||||
|
unresolvedIssues: [],
|
||||||
|
recommendedPriorities: [],
|
||||||
|
level,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (level >= 1) {
|
||||||
|
const last = trail.slice(-3);
|
||||||
|
if (last.length > 0) {
|
||||||
|
summary.recommendedPriorities.push(`Last action: ${JSON.stringify(last[last.length - 1]?.['action'] || 'none')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level >= 3) {
|
||||||
|
summary.recommendedPriorities.push(`Full trail: ${trail.length} records`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let checkCount = 0;
|
||||||
|
for (const entry of recentActivity) {
|
||||||
|
if (entry.status === 'in_progress' && entry.id !== sessionId) {
|
||||||
|
summary.unresolvedIssues.push(`Unfinished session: ${entry.id} (${entry.task || 'no task'})`);
|
||||||
|
checkCount++;
|
||||||
|
if (checkCount >= 3) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkUnfinishedSessions(projectRoot?: string): IndexEntry[] {
|
||||||
|
return findInProgressSessions(projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSessionSummary(sessionId: string, projectRoot?: string): string {
|
||||||
|
const session = readSession(sessionId, projectRoot);
|
||||||
|
const trail = readTrail(sessionId, projectRoot);
|
||||||
|
const corrections = trail.filter(r => r['action_type'] === 'user_correction');
|
||||||
|
const changes = trail.filter(r => r['action'] === 'edit_file' || r['action'] === 'create_file' || r['action'] === 'delete_file');
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
`# Session Summary | ${sessionId}`,
|
||||||
|
'',
|
||||||
|
`## Task: ${session?.task || '(unknown)'}`,
|
||||||
|
`## Time: ${session?.start_time || '?'} → ${session?.end_time || 'in_progress'}`,
|
||||||
|
`## Status: ${session?.status || 'unknown'}`,
|
||||||
|
'',
|
||||||
|
'## Completed Work',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const r of trail) {
|
||||||
|
if (r['action']) {
|
||||||
|
lines.push(`- ${r['action']}: ${r['detail'] || r['reason'] || '(no detail)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (corrections.length > 0) {
|
||||||
|
lines.push('', '## User Corrections');
|
||||||
|
for (const c of corrections) {
|
||||||
|
lines.push(`- Original: ${c['original_claim']}`);
|
||||||
|
lines.push(` Correction: ${c['correction']}`);
|
||||||
|
if (c['principle_extracted']) {
|
||||||
|
lines.push(` Principle: ${c['principle_extracted']}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.length > 0) {
|
||||||
|
lines.push('', '## Files Changed');
|
||||||
|
const fileSet = new Set<string>();
|
||||||
|
for (const c of changes) {
|
||||||
|
const files = c['files'];
|
||||||
|
if (Array.isArray(files)) {
|
||||||
|
for (const f of files) fileSet.add(String(f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const f of fileSet) lines.push(`- ${f}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', '## Stats');
|
||||||
|
lines.push(`- Total records: ${trail.length}`);
|
||||||
|
lines.push(`- Corrections: ${corrections.length}`);
|
||||||
|
lines.push(`- File changes: ${changes.length}`);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
110
apps/server/src/services/boocontext_client.ts
Normal file
110
apps/server/src/services/boocontext_client.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* v2.7.18: shared MCP client wrapper for the boocontext sidecar.
|
||||||
|
*
|
||||||
|
* Calls into the existing multi-server MCP client infrastructure
|
||||||
|
* (services/mcp-client.ts) which connects to boocontext as a stdio
|
||||||
|
* MCP process defined in data/mcp.json (server name "boocontext",
|
||||||
|
* command: `node /opt/forks/boocontext/dist/standalone.js`).
|
||||||
|
*
|
||||||
|
* The boocontext MCP server is initialized once at app boot in
|
||||||
|
* index.ts via initMcp() and the actual MCP tool call routing is
|
||||||
|
* handled by mcp-client.ts:callTool() — this module is a thin
|
||||||
|
* convenience wrapper that prepends the "boocontext_" server prefix,
|
||||||
|
* normalises the response, and applies inline truncation matching
|
||||||
|
* the same pattern as codecontext_client.ts.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { callBoocontext } from './services/boocontext_client.js';
|
||||||
|
* const resp = await callBoocontext({
|
||||||
|
* toolName: 'codesight_get_summary',
|
||||||
|
* args: { directory: '/opt/boocode' },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { callTool } from './mcp-client.js';
|
||||||
|
import { truncateIfNeeded } from './truncate.js';
|
||||||
|
|
||||||
|
// ---- Exported types ----
|
||||||
|
|
||||||
|
export interface BoocontextRequest {
|
||||||
|
/** Unprefixed tool name as defined on the boocontext MCP server
|
||||||
|
* (e.g. "codesight_scan", "boocontext_overview", "codesight_get_summary"). */
|
||||||
|
toolName: string;
|
||||||
|
/** Arguments to pass to the tool. */
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoocontextResponse {
|
||||||
|
/** The tool output text. */
|
||||||
|
result: string;
|
||||||
|
/** Whether the result was truncated to fit the inline limit. */
|
||||||
|
truncated: boolean;
|
||||||
|
/** Opaque id pointing at the full pre-slice content on tmpfs, set when
|
||||||
|
* truncated=true and storage succeeded. */
|
||||||
|
outputPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Constants ----
|
||||||
|
|
||||||
|
/** Must match the server name in data/mcp.json. */
|
||||||
|
const BOOCONTEXT_SERVER_NAME = 'boocontext';
|
||||||
|
|
||||||
|
/** Inline truncation limit, matching codecontext_client.ts. */
|
||||||
|
const TRUNCATION_LIMIT = 32_000;
|
||||||
|
|
||||||
|
// ---- Public API ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call a boocontext MCP tool by its unprefixed name.
|
||||||
|
*
|
||||||
|
* Prepends the "boocontext_" server prefix, delegates to the
|
||||||
|
* multi-server MCP client's callTool(), and normalises the response
|
||||||
|
* into a BoocontextResponse with inline truncation.
|
||||||
|
*
|
||||||
|
* @param req The tool name and arguments.
|
||||||
|
* @param log Optional Fastify-compatible logger (for debug traces).
|
||||||
|
* @returns The tool result, possibly truncated.
|
||||||
|
* @throws If the boocontext server is not connected or the tool
|
||||||
|
* returns an MCP-level error.
|
||||||
|
*/
|
||||||
|
export async function callBoocontext(
|
||||||
|
req: BoocontextRequest,
|
||||||
|
log?: { debug?: (obj: object, msg: string) => void; warn?: (obj: object, msg: string) => void },
|
||||||
|
): Promise<BoocontextResponse> {
|
||||||
|
const prefixedName = `${BOOCONTEXT_SERVER_NAME}_${req.toolName}`;
|
||||||
|
|
||||||
|
log?.debug?.({ tool: prefixedName }, 'boocontext: calling tool');
|
||||||
|
|
||||||
|
const raw = await callTool(prefixedName, req.args);
|
||||||
|
|
||||||
|
// callTool returns { error: true, output: string } on failure (both
|
||||||
|
// for MCP-level isError and for network/protocol exceptions).
|
||||||
|
if (typeof raw === 'object' && raw !== null && (raw as Record<string, unknown>).error === true) {
|
||||||
|
const errOutput = (raw as Record<string, unknown>).output ?? 'Unknown MCP error';
|
||||||
|
throw new Error(`boocontext error: ${String(errOutput)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
||||||
|
|
||||||
|
// Inline truncation at 32 kB, matching codecontext_client.ts.
|
||||||
|
// The model gets a clear hint about how to narrow the next call
|
||||||
|
// rather than a silent cut.
|
||||||
|
if (result.length > TRUNCATION_LIMIT) {
|
||||||
|
const truncated = result.slice(0, TRUNCATION_LIMIT);
|
||||||
|
const omitted = result.length - TRUNCATION_LIMIT;
|
||||||
|
const slicedWithMarker =
|
||||||
|
`${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with additional filters]`;
|
||||||
|
const wrapped = await truncateIfNeeded({
|
||||||
|
fullContent: result,
|
||||||
|
slicedContent: slicedWithMarker,
|
||||||
|
wasTruncated: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
result: wrapped.content,
|
||||||
|
truncated: wrapped.truncated,
|
||||||
|
...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result, truncated: false };
|
||||||
|
}
|
||||||
@@ -271,7 +271,9 @@ function buildNumstatMap(
|
|||||||
async function getUncommittedDiff(
|
async function getUncommittedDiff(
|
||||||
gitRoot: string,
|
gitRoot: string,
|
||||||
inProgress: string | null,
|
inProgress: string | null,
|
||||||
|
ignoreWhitespace = false,
|
||||||
): Promise<GitDiffResult> {
|
): Promise<GitDiffResult> {
|
||||||
|
const ws = ignoreWhitespace ? ['-w'] : [];
|
||||||
const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null;
|
const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null;
|
||||||
|
|
||||||
const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] =
|
const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] =
|
||||||
@@ -284,10 +286,10 @@ async function getUncommittedDiff(
|
|||||||
: runGit(['diff', '--cached', '--name-status'], gitRoot),
|
: runGit(['diff', '--cached', '--name-status'], gitRoot),
|
||||||
runGit(['ls-files', '--others', '--exclude-standard'], gitRoot),
|
runGit(['ls-files', '--others', '--exclude-standard'], gitRoot),
|
||||||
hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''),
|
hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''),
|
||||||
hasCommits ? runGit(['diff', 'HEAD'], gitRoot) : Promise.resolve(''),
|
hasCommits ? runGit(['diff', ...ws, 'HEAD'], gitRoot) : Promise.resolve(''),
|
||||||
hasCommits
|
hasCommits
|
||||||
? runGit(['diff', '--cached', 'HEAD'], gitRoot)
|
? runGit(['diff', ...ws, '--cached', 'HEAD'], gitRoot)
|
||||||
: runGit(['diff', '--cached'], gitRoot),
|
: runGit(['diff', ...ws, '--cached'], gitRoot),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allChanged = parseNameStatus(nameStatusOut ?? '');
|
const allChanged = parseNameStatus(nameStatusOut ?? '');
|
||||||
@@ -347,11 +349,13 @@ async function getCommittedDiff(
|
|||||||
base: string,
|
base: string,
|
||||||
label: string,
|
label: string,
|
||||||
inProgress: string | null,
|
inProgress: string | null,
|
||||||
|
ignoreWhitespace = false,
|
||||||
): Promise<GitDiffResult> {
|
): Promise<GitDiffResult> {
|
||||||
|
const ws = ignoreWhitespace ? ['-w'] : [];
|
||||||
const [nameStatusOut, numstatOut, diffOut] = await Promise.all([
|
const [nameStatusOut, numstatOut, diffOut] = await Promise.all([
|
||||||
runGit(['diff', '--name-status', base, 'HEAD'], gitRoot),
|
runGit(['diff', '--name-status', base, 'HEAD'], gitRoot),
|
||||||
runGit(['diff', '--numstat', base, 'HEAD'], gitRoot),
|
runGit(['diff', '--numstat', base, 'HEAD'], gitRoot),
|
||||||
runGit(['diff', base, 'HEAD'], gitRoot),
|
runGit(['diff', ...ws, base, 'HEAD'], gitRoot),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allChanged = parseNameStatus(nameStatusOut ?? '');
|
const allChanged = parseNameStatus(nameStatusOut ?? '');
|
||||||
@@ -383,23 +387,23 @@ async function getCommittedDiff(
|
|||||||
* the directory is not a git repository. On a null committed-mode base, falls
|
* the directory is not a git repository. On a null committed-mode base, falls
|
||||||
* back to uncommitted and labels the result accordingly.
|
* back to uncommitted and labels the result accordingly.
|
||||||
*/
|
*/
|
||||||
export async function getGitDiff(cwd: string, mode: GitDiffMode): Promise<GitDiffResult | null> {
|
export async function getGitDiff(cwd: string, mode: GitDiffMode, ignoreWhitespace?: boolean): Promise<GitDiffResult | null> {
|
||||||
const gitRoot = await resolveGitRoot(cwd);
|
const gitRoot = await resolveGitRoot(cwd);
|
||||||
if (!gitRoot) return null;
|
if (!gitRoot) return null;
|
||||||
|
|
||||||
const inProgress = await detectInProgress(gitRoot);
|
const inProgress = await detectInProgress(gitRoot);
|
||||||
|
|
||||||
if (mode === 'uncommitted') {
|
if (mode === 'uncommitted') {
|
||||||
return getUncommittedDiff(gitRoot, inProgress);
|
return getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { base, label } = await resolveCommittedBase(gitRoot);
|
const { base, label } = await resolveCommittedBase(gitRoot);
|
||||||
if (!base) {
|
if (!base) {
|
||||||
// Fall back to uncommitted with a descriptive label
|
// Fall back to uncommitted with a descriptive label
|
||||||
const result = await getUncommittedDiff(gitRoot, inProgress);
|
const result = await getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
|
||||||
return { ...result, base_label: label };
|
return { ...result, base_label: label };
|
||||||
}
|
}
|
||||||
return getCommittedDiff(gitRoot, base, label, inProgress);
|
return getCommittedDiff(gitRoot, base, label, inProgress, ignoreWhitespace ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 2: Write helpers ─────────────────────────────────────────────────
|
// ── Phase 2: Write helpers ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { deduplicate } from '../strategies/deduplication.js';
|
||||||
|
import type { DcpMessage } from '../messages.js';
|
||||||
|
|
||||||
|
describe('deduplicate', () => {
|
||||||
|
it('removes consecutive identical tool_call+tool_result pairs', () => {
|
||||||
|
const messages: DcpMessage[] = [
|
||||||
|
{ role: 'user', content: 'search for x' },
|
||||||
|
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||||
|
{ role: 'tool', content: 'result1', tool_call_id: '1' },
|
||||||
|
// Duplicate pair
|
||||||
|
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
|
||||||
|
{ role: 'tool', content: 'result1', tool_call_id: '2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { messages: result, stats } = deduplicate(messages);
|
||||||
|
expect(result).toHaveLength(3); // user + first pair
|
||||||
|
expect(stats.removedCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves non-duplicate content', () => {
|
||||||
|
const messages: DcpMessage[] = [
|
||||||
|
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||||
|
{ role: 'tool', content: 'result1', tool_call_id: '1' },
|
||||||
|
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
|
||||||
|
{ role: 'tool', content: 'result2', tool_call_id: '2' }, // Different result
|
||||||
|
];
|
||||||
|
|
||||||
|
const { messages: result, stats } = deduplicate(messages);
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
expect(stats.removedCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { toDcpMessages, fromDcpMessages } from '../messages.js';
|
||||||
|
|
||||||
|
describe('toDcpMessages', () => {
|
||||||
|
it('converts user messages', () => {
|
||||||
|
const result = toDcpMessages([{ role: 'user', content: 'hello' }]);
|
||||||
|
expect(result[0].role).toBe('user');
|
||||||
|
expect(result[0].content).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks Error: content as isError', () => {
|
||||||
|
const result = toDcpMessages([{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' }]);
|
||||||
|
expect(result[0].isError).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fromDcpMessages', () => {
|
||||||
|
it('round-trips messages', () => {
|
||||||
|
const original = [{ role: 'user', content: 'hello' }];
|
||||||
|
expect(fromDcpMessages(toDcpMessages(original))).toEqual(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { purgeErrors } from '../strategies/purge-errors.js';
|
||||||
|
import type { DcpMessage } from '../messages.js';
|
||||||
|
|
||||||
|
describe('purgeErrors', () => {
|
||||||
|
it('removes tool results where content starts with Error:', () => {
|
||||||
|
const messages: DcpMessage[] = [
|
||||||
|
{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' },
|
||||||
|
{ role: 'tool', content: '{"files":[]}', tool_call_id: '2' },
|
||||||
|
];
|
||||||
|
const { messages: result, stats } = purgeErrors(messages);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(stats.removedCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes empty tool results', () => {
|
||||||
|
const messages: DcpMessage[] = [
|
||||||
|
{ role: 'tool', content: '', tool_call_id: '1' },
|
||||||
|
];
|
||||||
|
const { messages: result, stats } = purgeErrors(messages);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
expect(stats.removedCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves valid tool results', () => {
|
||||||
|
const messages: DcpMessage[] = [
|
||||||
|
{ role: 'tool', content: '{"files":["a.ts"]}', tool_call_id: '1' },
|
||||||
|
];
|
||||||
|
const { messages: result, stats } = purgeErrors(messages);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(stats.removedCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { transformMessages } from '../transform.js';
|
||||||
|
import type { DcpMessage } from '../messages.js';
|
||||||
|
|
||||||
|
describe('transformMessages', () => {
|
||||||
|
it('applies dedup then purge in order', () => {
|
||||||
|
const input: DcpMessage[] = [
|
||||||
|
{ role: 'user', content: 'hello' },
|
||||||
|
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||||
|
{ role: 'tool', content: 'result', tool_call_id: '1' },
|
||||||
|
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
|
||||||
|
{ role: 'tool', content: 'result', tool_call_id: '2' }, // Dup
|
||||||
|
];
|
||||||
|
|
||||||
|
const { messages, stats } = transformMessages('test-chat', input);
|
||||||
|
expect(stats.removedCount).toBeGreaterThan(0);
|
||||||
|
expect(messages.length).toBeLessThan(input.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty input', () => {
|
||||||
|
const { messages, stats } = transformMessages('empty', []);
|
||||||
|
expect(messages).toHaveLength(0);
|
||||||
|
expect(stats.removedCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
4
apps/server/src/services/inference/dcp/index.ts
Normal file
4
apps/server/src/services/inference/dcp/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { transformMessages } from './transform.js';
|
||||||
|
export type { DcpMessage } from './messages.js';
|
||||||
|
export { toDcpMessages, fromDcpMessages } from './messages.js';
|
||||||
|
export { getDcpState, clearDcpState } from './state.js';
|
||||||
34
apps/server/src/services/inference/dcp/messages.ts
Normal file
34
apps/server/src/services/inference/dcp/messages.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// DCP message shape adapter.
|
||||||
|
// Converts between BooCode MessagePart[] and the DCP internal shape.
|
||||||
|
// Clean-room implementation — no AGPL source copied.
|
||||||
|
|
||||||
|
export interface DcpMessage {
|
||||||
|
role: 'user' | 'assistant' | 'tool';
|
||||||
|
content: string;
|
||||||
|
tool_call_id?: string;
|
||||||
|
tool_calls?: Array<{ id: string; name: string; arguments: string }>;
|
||||||
|
isError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDcpMessages(parts: any[]): DcpMessage[] {
|
||||||
|
return parts.map((p: any) => {
|
||||||
|
const msg: DcpMessage = { role: p.role, content: p.content ?? '' };
|
||||||
|
if (p.tool_call_id) msg.tool_call_id = p.tool_call_id;
|
||||||
|
if (p.tool_calls) msg.tool_calls = p.tool_calls;
|
||||||
|
if (p.isError) msg.isError = true;
|
||||||
|
if (p.role === 'tool' && p.content && p.content.startsWith('Error:')) {
|
||||||
|
msg.isError = true;
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromDcpMessages(msgs: DcpMessage[]): any[] {
|
||||||
|
return msgs.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
||||||
|
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
|
||||||
|
...(m.isError ? { isError: true } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user