Compare commits
15 Commits
v2.8.1-ope
...
v2.8.16-om
| Author | SHA1 | Date | |
|---|---|---|---|
| e0feb53437 | |||
| 3c5b2c2bcf | |||
| 524a0deaa1 | |||
| a7a40c5b46 | |||
| e5183cc71b | |||
| 9abc14ef82 | |||
| 7ef479639a | |||
| 89a6ffe8a0 | |||
| a8e475fdf4 | |||
| 02063072ab | |||
| ec48066a80 | |||
| 876c9bcd02 | |||
| c132215064 | |||
| a72f7954b4 | |||
| 31d8efe66a |
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
@@ -4,6 +4,7 @@ import { loadConfig } from './config.js';
|
||||
import { getPool, closeDb } from './db.js';
|
||||
import { registerHealthRoutes } from './routes/health.js';
|
||||
import { registerTerminalRoutes } from './routes/terminals.js';
|
||||
import { registerSessionRoutes } from './routes/sessions.js';
|
||||
import { registerWsAttachRoute } from './ws/attach.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -33,6 +34,7 @@ async function main(): Promise<void> {
|
||||
|
||||
registerHealthRoutes(app);
|
||||
registerTerminalRoutes(app, config.TMUX_CONF_PATH);
|
||||
registerSessionRoutes(app);
|
||||
registerWsAttachRoute(app, config.TMUX_CONF_PATH);
|
||||
|
||||
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';
|
||||
import { attachPty } from '../pty/pty.js';
|
||||
import { getUser } from '../auth.js';
|
||||
import { register, unregister } from '../pty/registry.js';
|
||||
|
||||
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
|
||||
app.get<{
|
||||
@@ -57,6 +58,8 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
|
||||
return;
|
||||
}
|
||||
|
||||
register(sid, pid, session.project_path);
|
||||
|
||||
let handle: IPty;
|
||||
try {
|
||||
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
|
||||
// user closes the pane.
|
||||
socket.on('close', () => {
|
||||
unregister(pid);
|
||||
try {
|
||||
handle.kill();
|
||||
} catch {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { registerArenaRoutes } from './routes/arena.js';
|
||||
import { registerProviderRoutes } from './routes/providers.js';
|
||||
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
|
||||
import { registerLifecycleRoutes } from './routes/lifecycle.js';
|
||||
import { registerAnalyticsRoutes } from './routes/analytics.js';
|
||||
import { registerWebSocket } from './routes/ws.js';
|
||||
// Phase 4: dispatcher + agent probe
|
||||
import { createDispatcher } from './services/dispatcher.js';
|
||||
@@ -382,6 +383,7 @@ async function main() {
|
||||
registerProviderRoutes(app, sql, config);
|
||||
registerWorktreeSafetyRoutes(app, sql);
|
||||
registerLifecycleRoutes(app, sql);
|
||||
registerAnalyticsRoutes(app, sql);
|
||||
registerWebSocket(app, sql, broker);
|
||||
|
||||
// Graceful shutdown
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
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,
|
||||
} from './finalize-message.js';
|
||||
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
|
||||
import { emitHook } from '../plugins/host.js';
|
||||
|
||||
interface InferenceRunner {
|
||||
enqueue: (
|
||||
@@ -123,6 +124,22 @@ export function createDispatcher(deps: Deps): {
|
||||
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
|
||||
// state and publish the matching message_complete frame. Best-effort + idempotent
|
||||
// (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.
|
||||
let chatId: string | null = null;
|
||||
let sessionId: string | undefined;
|
||||
|
||||
try {
|
||||
// Mark running
|
||||
@@ -330,7 +348,6 @@ export function createDispatcher(deps: Deps): {
|
||||
// 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.
|
||||
const model = task.model ?? config.DEFAULT_MODEL;
|
||||
let sessionId: string;
|
||||
if (task.session_id) {
|
||||
sessionId = task.session_id;
|
||||
} else {
|
||||
@@ -377,6 +394,7 @@ export function createDispatcher(deps: Deps): {
|
||||
SET state = 'cancelled', ended_at = clock_timestamp()
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
if (sessionId) emitTurnEnd(sessionId, taskId, 'cancelled', null, task.model);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -399,6 +417,7 @@ export function createDispatcher(deps: Deps): {
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
|
||||
emitTurnEnd(sessionId, taskId, 'completed', null, task.model, summary);
|
||||
} else {
|
||||
const [msg] = await sql<{ content: string | null }[]>`
|
||||
SELECT content FROM messages WHERE id = ${assistantId}
|
||||
@@ -410,6 +429,7 @@ export function createDispatcher(deps: Deps): {
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
|
||||
emitTurnEnd(sessionId, taskId, 'failed', null, task.model, summary);
|
||||
}
|
||||
} catch (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}
|
||||
WHERE id = ${taskId}
|
||||
`.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 sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
clearTaskCommands(taskId);
|
||||
return;
|
||||
@@ -738,6 +760,7 @@ export function createDispatcher(deps: Deps): {
|
||||
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
||||
// #10: external-agent turn completed cleanly.
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
|
||||
emitTurnEnd(sessionId, taskId, 'completed', agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
|
||||
} catch (err) {
|
||||
@@ -762,6 +785,7 @@ export function createDispatcher(deps: Deps): {
|
||||
// preceded its assignment — guard so the status publish never masks the real
|
||||
// error.
|
||||
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
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
@@ -1030,6 +1054,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
@@ -1090,6 +1115,7 @@ export function createDispatcher(deps: Deps): {
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (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);
|
||||
// #10: turn 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);
|
||||
// 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 sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
@@ -1367,6 +1395,7 @@ export function createDispatcher(deps: Deps): {
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (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);
|
||||
// #10: turn crashed.
|
||||
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||
clearTaskCommands(taskId);
|
||||
// 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 sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
@@ -1638,6 +1669,7 @@ export function createDispatcher(deps: Deps): {
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (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);
|
||||
// #10: turn crashed.
|
||||
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
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 type { DcpStreamStripper } from './dcp-strip.js';
|
||||
import { emitHook } from '../plugins/host.js';
|
||||
|
||||
export interface FrameEmitterOpts {
|
||||
broker?: Broker;
|
||||
@@ -91,8 +92,29 @@ export function makeFrameEmitter(opts: FrameEmitterOpts): FrameEmitter {
|
||||
}
|
||||
break;
|
||||
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':
|
||||
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()) {
|
||||
broker!.publishFrame(sessionId!, {
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { randomBytes } from 'node:crypto';
|
||||
import type { Sql } from '../db.js';
|
||||
import { resolveWritePath } from './write_guard.js';
|
||||
import { locateMatch } from './fuzzy-match.js';
|
||||
import { validateEditResult, formatGuardError } from './edit-guards.js';
|
||||
|
||||
/**
|
||||
* Write a file atomically: stage to a sibling temp file, then rename over the
|
||||
@@ -286,10 +285,6 @@ export async function applyOne(
|
||||
);
|
||||
}
|
||||
if (plan.kind === 'apply') {
|
||||
const guard = validateEditResult(toLf(raw), plan.updated, change.file_path);
|
||||
if (!guard.ok) {
|
||||
throw new Error(formatGuardError(guard, change.file_path));
|
||||
}
|
||||
const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated;
|
||||
await writeFileAtomic(change.file_path, out);
|
||||
} else {
|
||||
|
||||
@@ -19,6 +19,8 @@ import { registerModelRoutes } from './routes/models.js';
|
||||
import { registerAgentRoutes } from './routes/agents.js';
|
||||
import { registerSkillsRoutes } from './routes/skills.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 { createBroker } from './services/broker.js';
|
||||
import { listSkills } from './services/skills.js';
|
||||
@@ -122,6 +124,8 @@ async function main() {
|
||||
registerSidebarRoutes(app, sql);
|
||||
registerChatRoutes(app, sql, broker);
|
||||
registerToolsRoutes(app, sql);
|
||||
registerAnalyticsRoutes(app, sql);
|
||||
registerInferenceSettingsRoutes(app);
|
||||
|
||||
// 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.
|
||||
|
||||
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 { 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 type { Sql } from '../db.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
|
||||
// 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.
|
||||
app.get<{ Params: { id: string }; Querystring: { mode?: string } }>(
|
||||
app.get<{ Params: { id: string }; Querystring: { mode?: string; whitespace?: string } }>(
|
||||
'/api/projects/:id/git/diff',
|
||||
async (req, reply) => {
|
||||
const { id } = req.params;
|
||||
@@ -504,7 +504,8 @@ export function registerProjectRoutes(
|
||||
rawMode === 'uncommitted' ? 'uncommitted' :
|
||||
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) {
|
||||
return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] };
|
||||
}
|
||||
@@ -541,6 +542,11 @@ export function registerProjectRoutes(
|
||||
).min(1),
|
||||
});
|
||||
|
||||
const WriteFileBody = z.object({
|
||||
path: z.string().min(1),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
// POST /api/projects/:id/git/stage — stage whole files
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/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
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/files',
|
||||
|
||||
@@ -112,14 +112,14 @@ describe('stripShadowingFlags', () => {
|
||||
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']);
|
||||
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']);
|
||||
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(
|
||||
gitRoot: string,
|
||||
inProgress: string | null,
|
||||
ignoreWhitespace = false,
|
||||
): Promise<GitDiffResult> {
|
||||
const ws = ignoreWhitespace ? ['-w'] : [];
|
||||
const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null;
|
||||
|
||||
const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] =
|
||||
@@ -284,10 +286,10 @@ async function getUncommittedDiff(
|
||||
: runGit(['diff', '--cached', '--name-status'], gitRoot),
|
||||
runGit(['ls-files', '--others', '--exclude-standard'], gitRoot),
|
||||
hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''),
|
||||
hasCommits ? runGit(['diff', 'HEAD'], gitRoot) : Promise.resolve(''),
|
||||
hasCommits ? runGit(['diff', ...ws, 'HEAD'], gitRoot) : Promise.resolve(''),
|
||||
hasCommits
|
||||
? runGit(['diff', '--cached', 'HEAD'], gitRoot)
|
||||
: runGit(['diff', '--cached'], gitRoot),
|
||||
? runGit(['diff', ...ws, '--cached', 'HEAD'], gitRoot)
|
||||
: runGit(['diff', ...ws, '--cached'], gitRoot),
|
||||
]);
|
||||
|
||||
const allChanged = parseNameStatus(nameStatusOut ?? '');
|
||||
@@ -347,11 +349,13 @@ async function getCommittedDiff(
|
||||
base: string,
|
||||
label: string,
|
||||
inProgress: string | null,
|
||||
ignoreWhitespace = false,
|
||||
): Promise<GitDiffResult> {
|
||||
const ws = ignoreWhitespace ? ['-w'] : [];
|
||||
const [nameStatusOut, numstatOut, diffOut] = await Promise.all([
|
||||
runGit(['diff', '--name-status', base, 'HEAD'], gitRoot),
|
||||
runGit(['diff', '--numstat', base, 'HEAD'], gitRoot),
|
||||
runGit(['diff', base, 'HEAD'], gitRoot),
|
||||
runGit(['diff', ...ws, base, 'HEAD'], gitRoot),
|
||||
]);
|
||||
|
||||
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
|
||||
* 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);
|
||||
if (!gitRoot) return null;
|
||||
|
||||
const inProgress = await detectInProgress(gitRoot);
|
||||
|
||||
if (mode === 'uncommitted') {
|
||||
return getUncommittedDiff(gitRoot, inProgress);
|
||||
return getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
|
||||
}
|
||||
|
||||
const { base, label } = await resolveCommittedBase(gitRoot);
|
||||
if (!base) {
|
||||
// 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 getCommittedDiff(gitRoot, base, label, inProgress);
|
||||
return getCommittedDiff(gitRoot, base, label, inProgress, ignoreWhitespace ?? false);
|
||||
}
|
||||
|
||||
// ── Phase 2: Write helpers ─────────────────────────────────────────────────
|
||||
|
||||
@@ -131,23 +131,13 @@ export function isManagedFlag(flag: string): boolean {
|
||||
|
||||
const SHADOW_CONTEXT = ['-c', '--ctx-size'];
|
||||
|
||||
const SHADOW_CACHE = ['-ctk', '--cache-type-k', '-ctv', '--cache-type-v'];
|
||||
// Empty: agents should be able to opt into cache-type flags (lift analysis
|
||||
// found these are high-value features, not safety concerns).
|
||||
const SHADOW_CACHE: string[] = [];
|
||||
|
||||
const SHADOW_SPEC = [
|
||||
'--spec-default',
|
||||
'--spec-type',
|
||||
'--spec-ngram-size-n',
|
||||
'--spec-ngram-size',
|
||||
'--draft-min',
|
||||
'--draft-max',
|
||||
'--spec-draft-n-max',
|
||||
'--spec-draft-n-min',
|
||||
'--spec-draft-p-min',
|
||||
'--spec-draft-p-split',
|
||||
'--spec-ngram-mod-n-match',
|
||||
'--spec-ngram-mod-n-min',
|
||||
'--spec-ngram-mod-n-max',
|
||||
];
|
||||
// Empty: ngram speculative decoding is a performance feature agents should
|
||||
// be able to enable.
|
||||
const SHADOW_SPEC: string[] = [];
|
||||
|
||||
const SHADOW_TEMPLATE = [
|
||||
'--chat-template',
|
||||
@@ -160,7 +150,6 @@ const SHADOW_TEMPLATE = [
|
||||
// Shadowing flags that take no value — a boolean switch — so the stripper must
|
||||
// not also drop the following token.
|
||||
const VALUELESS_SHADOW_FLAGS: ReadonlySet<string> = new Set([
|
||||
'--spec-default',
|
||||
'--jinja',
|
||||
'--no-jinja',
|
||||
]);
|
||||
|
||||
@@ -57,11 +57,21 @@ interface ConfigLike {
|
||||
LLAMA_SIDECAR_URL?: string;
|
||||
}
|
||||
|
||||
export function resolveRoute(agent: AgentLike | null): RoutingInfo {
|
||||
export function resolveRoute(
|
||||
agent: AgentLike | null,
|
||||
config?: ConfigLike,
|
||||
): RoutingInfo {
|
||||
// When llama_extra_args are explicitly set, route through sidecar with them.
|
||||
const flags = agent?.llama_extra_args;
|
||||
if (flags && flags.length > 0) {
|
||||
return { route: 'sidecar', flags };
|
||||
}
|
||||
// When LLAMA_SIDECAR_URL is configured (even without per-agent flags),
|
||||
// route through sidecar to pick up the default base args (cache quant,
|
||||
// spec decoding, slot save, etc.). Fall back to llama-swap otherwise.
|
||||
if (config?.LLAMA_SIDECAR_URL) {
|
||||
return { route: 'sidecar', flags: [] };
|
||||
}
|
||||
return { route: 'swap', flags: null };
|
||||
}
|
||||
|
||||
@@ -70,15 +80,13 @@ export function upstreamModel(
|
||||
modelId: string,
|
||||
agent?: AgentLike | null,
|
||||
): LanguageModel {
|
||||
const { route, flags } = resolveRoute(agent ?? null);
|
||||
const { route, flags } = resolveRoute(agent ?? null, config);
|
||||
if (route === 'sidecar') {
|
||||
const url = config.LLAMA_SIDECAR_URL;
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
`Agent has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
|
||||
);
|
||||
throw new Error(`Sidecar route selected but LLAMA_SIDECAR_URL is not set`);
|
||||
}
|
||||
return sidecarProvider(url, flags!).chatModel(modelId);
|
||||
return sidecarProvider(url, (flags ?? [])).chatModel(modelId);
|
||||
}
|
||||
return getSwapProvider(config.LLAMA_SWAP_URL).chatModel(modelId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../types.js';
|
||||
import { callBoocontext } from '../../boocontext_client.js';
|
||||
|
||||
export const GetCodeHealthInput = z.object({
|
||||
directory: z.string().optional().describe('Directory to analyze (defaults to project root)'),
|
||||
file: z.string().optional().describe('Optional: specific file to analyze'),
|
||||
});
|
||||
export type GetCodeHealthInputT = z.infer<typeof GetCodeHealthInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'Code health analysis. Returns A–F grades per file across 7 dimensions ' +
|
||||
'(cohesion, coupling, complexity, documentation, duplication, unit size, test coverage). ' +
|
||||
'Includes project health summary and refactoring candidates.';
|
||||
|
||||
/**
|
||||
* Standalone execute function — calls the boocontext MCP server's
|
||||
* boocontext_health tool and returns the raw report text.
|
||||
*
|
||||
* Structured for direct test access: accepts input + projectPath,
|
||||
* no side effects beyond the MCP call.
|
||||
*/
|
||||
export async function executeGetCodeHealth(
|
||||
input: GetCodeHealthInputT,
|
||||
projectPath: string,
|
||||
): Promise<string> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (input.directory) args['directory'] = input.directory;
|
||||
if (input.file) args['file'] = input.file;
|
||||
const resp = await callBoocontext({ toolName: 'boocontext_health', args });
|
||||
return resp.result;
|
||||
}
|
||||
|
||||
export const getCodeHealth: ToolDef<GetCodeHealthInputT> = {
|
||||
name: 'get_code_health',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetCodeHealthInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_code_health',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
directory: {
|
||||
type: 'string',
|
||||
description: 'Directory to analyze (defaults to project root)',
|
||||
},
|
||||
file: {
|
||||
type: 'string',
|
||||
description: 'Optional: specific file to analyze',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return executeGetCodeHealth(input, projectRoot);
|
||||
},
|
||||
};
|
||||
228
apps/server/src/services/tools/codecontext/get_code_impact.ts
Normal file
228
apps/server/src/services/tools/codecontext/get_code_impact.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../types.js';
|
||||
import type { CodecontextResponse } from '../../codecontext_client.js';
|
||||
|
||||
// ======================= MCP Client =======================
|
||||
|
||||
const BOOCONTEXT_PATH = resolve('/opt/forks/boocontext/dist/standalone.js');
|
||||
const TOOL_CALL_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface JsonRpcMessage {
|
||||
jsonrpc: '2.0';
|
||||
id?: number | string;
|
||||
result?: {
|
||||
content?: Array<{ type: string; text: string }>;
|
||||
};
|
||||
error?: { code?: number; message: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-shot MCP JSON-RPC client for boocontext.
|
||||
* Spawns the process, sends initialize + tools/call over NDJSON, returns the
|
||||
* text result from the content array. The boocontext MCP server auto-detects
|
||||
* newline-delimited JSON transport when the first input lacks Content-Length
|
||||
* headers, which is exactly what we send.
|
||||
*/
|
||||
async function callBoocontext(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
return new Promise<string>((resolvePromise, reject) => {
|
||||
const child = spawn(process.execPath, [BOOCONTEXT_PATH], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: TOOL_CALL_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let resolved = false;
|
||||
|
||||
function finalize(err?: Error, result?: string): void {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (err) reject(err);
|
||||
else resolvePromise(result!);
|
||||
child.kill();
|
||||
}
|
||||
|
||||
child.stdout!.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr!.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (err: Error) => {
|
||||
finalize(new Error(`boocontext spawn error: ${err.message}`));
|
||||
});
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
if (resolved) return;
|
||||
|
||||
// Parse newline-delimited JSON responses from stdout
|
||||
const lines = stdout.split('\n').filter((l) => l.trim().length > 0);
|
||||
let toolText: string | undefined;
|
||||
let toolError: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const msg = JSON.parse(line) as JsonRpcMessage;
|
||||
if (msg.id === 2) {
|
||||
if (msg.error) {
|
||||
toolError = msg.error.message ?? 'boocontext tool call failed';
|
||||
} else if (msg.result?.content?.[0]?.text !== undefined) {
|
||||
toolText = msg.result.content[0].text;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip malformed JSON lines
|
||||
}
|
||||
}
|
||||
|
||||
if (toolError) {
|
||||
finalize(new Error(toolError));
|
||||
} else if (toolText !== undefined) {
|
||||
finalize(undefined, toolText);
|
||||
} else {
|
||||
const errSuffix =
|
||||
stderr.length > 0 ? ` stderr: ${stderr.slice(0, 500)}` : '';
|
||||
finalize(
|
||||
new Error(`boocontext MCP call failed (exit ${code})${errSuffix}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 1: initialize — establishes MCP protocol version + capabilities
|
||||
child.stdin!.write(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'boocode-server', version: '1.0.0' },
|
||||
},
|
||||
}) + '\n',
|
||||
);
|
||||
|
||||
// Step 2: tools/call — invoke the named boocontext tool
|
||||
child.stdin!.write(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { name: toolName, arguments: args },
|
||||
}) + '\n',
|
||||
);
|
||||
|
||||
child.stdin!.end();
|
||||
|
||||
// Safety timeout — prevent hung processes
|
||||
setTimeout(() => {
|
||||
finalize(
|
||||
new Error(
|
||||
`boocontext call timed out after ${TOOL_CALL_TIMEOUT_MS}ms`,
|
||||
),
|
||||
);
|
||||
}, TOOL_CALL_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
// ======================= Tool Definition =======================
|
||||
|
||||
const TRUNCATION_LIMIT = 32_000;
|
||||
|
||||
export const GetCodeImpactInput = z.object({
|
||||
symbol: z.string().min(1).describe('Symbol name for TSA trace_impact'),
|
||||
file: z.string().optional().describe('File path for codesight blast_radius'),
|
||||
directory: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Directory (defaults to project root)'),
|
||||
depth: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(5)
|
||||
.optional()
|
||||
.describe('Max blast-radius traversal depth (default 1)'),
|
||||
});
|
||||
export type GetCodeImpactInputT = z.infer<typeof GetCodeImpactInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'Impact analysis. Merges symbol-level call trace with file-level blast radius. ' +
|
||||
'Use before making changes to understand change propagation. ' +
|
||||
'Single call replaces separate get_symbol_info + get_blast_radius steps.';
|
||||
|
||||
/**
|
||||
* Standalone execute function — calls the boocontext MCP `boocontext_impact`
|
||||
* tool via a short-lived child process, then wraps the result in the standard
|
||||
* CodecontextResponse shape with inline truncation at 32 KB.
|
||||
*/
|
||||
export async function executeGetCodeImpact(
|
||||
input: GetCodeImpactInputT,
|
||||
projectPath: string,
|
||||
): Promise<CodecontextResponse> {
|
||||
const args: Record<string, unknown> = {
|
||||
symbol: input.symbol,
|
||||
directory: input.directory ?? projectPath,
|
||||
};
|
||||
if (input.file) args['file'] = input.file;
|
||||
|
||||
const text = await callBoocontext('boocontext_impact', args);
|
||||
|
||||
// Inline truncation matching codecontext_client.ts patterns (32 KB ceiling).
|
||||
if (text.length > TRUNCATION_LIMIT) {
|
||||
const sliced = text.slice(0, TRUNCATION_LIMIT);
|
||||
const omitted = text.length - TRUNCATION_LIMIT;
|
||||
return {
|
||||
result: `${sliced}\n\n[truncated, ${omitted} chars omitted; narrow with symbol or file parameters]`,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { result: text, truncated: false };
|
||||
}
|
||||
|
||||
export const getCodeImpact: ToolDef<GetCodeImpactInputT> = {
|
||||
name: 'get_code_impact',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetCodeImpactInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_code_impact',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
symbol: {
|
||||
type: 'string',
|
||||
description: 'Symbol name for TSA trace_impact',
|
||||
},
|
||||
file: {
|
||||
type: 'string',
|
||||
description: 'File path for codesight blast_radius',
|
||||
},
|
||||
directory: {
|
||||
type: 'string',
|
||||
description: 'Directory (defaults to project root)',
|
||||
},
|
||||
depth: {
|
||||
type: 'number',
|
||||
description: 'Max blast-radius traversal depth (default 1)',
|
||||
},
|
||||
},
|
||||
required: ['symbol'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
execute(input, projectRoot) {
|
||||
return executeGetCodeImpact(input, projectRoot);
|
||||
},
|
||||
};
|
||||
192
apps/server/src/services/tools/codecontext/get_code_map.ts
Normal file
192
apps/server/src/services/tools/codecontext/get_code_map.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../types.js';
|
||||
|
||||
export const GetCodeMapInput = z.object({
|
||||
directory: z.string().optional().describe('Directory to scan (defaults to project root)'),
|
||||
compress: z.boolean().optional().describe('Apply DCP compression if payload exceeds threshold (default: true)'),
|
||||
});
|
||||
export type GetCodeMapInputT = z.infer<typeof GetCodeMapInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'DCP-compressed codebase context map. Returns filenames, sizes, import relationships in a compressed format. ' +
|
||||
'Use compress=false for full detail, compress=true (default) for token-efficient overview.';
|
||||
|
||||
const BOOCONTEXT_PATH = '/opt/forks/boocontext/dist/standalone.js';
|
||||
const TOOL_TIMEOUT_MS = 30_000;
|
||||
const MAX_RESULT_BYTES = 32_768;
|
||||
|
||||
export interface CodeMapResponse {
|
||||
result: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the boocontext MCP server over stdio JSON-RPC to invoke
|
||||
* the boocontext_map tool. Spawns the standalone binary, sends
|
||||
* initialize + tools/call, collects NDJSON responses, and kills
|
||||
* the child process.
|
||||
*/
|
||||
function callBoocontextMap(args: Record<string, unknown>): Promise<CodeMapResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', [BOOCONTEXT_PATH], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdoutBuf = '';
|
||||
const lines: string[] = [];
|
||||
let timedOut = false;
|
||||
let resolved = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error(`boocontext MCP call timed out after ${TOOL_TIMEOUT_MS}ms`));
|
||||
}, TOOL_TIMEOUT_MS);
|
||||
|
||||
function tryParse(): void {
|
||||
if (resolved || timedOut) return;
|
||||
|
||||
// Accumulate complete NDJSON lines
|
||||
const parts = stdoutBuf.split('\n');
|
||||
stdoutBuf = parts.pop()! ?? '';
|
||||
for (const p of parts) {
|
||||
const t = p.trim();
|
||||
if (t) lines.push(t);
|
||||
}
|
||||
|
||||
// Need at least 2 responses: initialize + tools/call
|
||||
if (lines.length < 2) return;
|
||||
|
||||
resolved = true;
|
||||
clearTimeout(timer);
|
||||
child.kill();
|
||||
|
||||
try {
|
||||
const callResponse = JSON.parse(lines[1]!);
|
||||
if (callResponse.error) {
|
||||
reject(new Error(`MCP error: ${callResponse.error.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const content = callResponse.result?.content;
|
||||
if (!content?.[0]?.text) {
|
||||
reject(new Error('Unexpected MCP response shape — missing content[0].text'));
|
||||
return;
|
||||
}
|
||||
|
||||
// content[0].text is JSON-stringified VerdictEnvelope from boocontext
|
||||
const envelope = JSON.parse(content[0].text as string);
|
||||
const details = envelope.details;
|
||||
|
||||
let result: string;
|
||||
if (details && typeof details === 'object' && 'data' in details) {
|
||||
// DcpEnvelope shape: { compressed, originalLength, compressedLength, data }
|
||||
if (details.compressed) {
|
||||
// Return the full DcpEnvelope as JSON so the LLM can pass it
|
||||
// transparently to a decompression step
|
||||
result = JSON.stringify(details);
|
||||
} else {
|
||||
// Uncompressed — data is the raw output
|
||||
result = details.data;
|
||||
}
|
||||
} else {
|
||||
result = JSON.stringify(details ?? envelope);
|
||||
}
|
||||
|
||||
const truncated = Buffer.byteLength(result, 'utf-8') > MAX_RESULT_BYTES;
|
||||
if (truncated) {
|
||||
result = result.substring(0, MAX_RESULT_BYTES);
|
||||
}
|
||||
|
||||
resolve({ result, truncated });
|
||||
} catch (e: any) {
|
||||
reject(new Error(`Failed to parse boocontext response: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout!.on('data', (chunk: Buffer) => {
|
||||
if (timedOut) return;
|
||||
stdoutBuf += chunk.toString('utf-8');
|
||||
tryParse();
|
||||
});
|
||||
|
||||
child.stderr!.on('data', (_chunk: Buffer) => {
|
||||
// Captured but not surfaced — logged only on parse failure
|
||||
});
|
||||
|
||||
child.on('error', (err: Error) => {
|
||||
clearTimeout(timer);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`boocontext spawn failed: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
if (!resolved && !timedOut) {
|
||||
tryParse();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error('boocontext process closed without producing a valid response'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Step 1: initialize
|
||||
child.stdin!.write(
|
||||
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }) + '\n',
|
||||
);
|
||||
|
||||
// Step 2: tools/call for boocontext_map
|
||||
child.stdin!.write(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { name: 'boocontext_map', arguments: args },
|
||||
}) + '\n',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const getCodeMap: ToolDef<GetCodeMapInputT> = {
|
||||
name: 'get_code_map',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetCodeMapInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_code_map',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
directory: { type: 'string', description: 'Directory to scan (defaults to project root)' },
|
||||
compress: {
|
||||
type: 'boolean',
|
||||
description: 'Apply DCP compression if payload exceeds threshold (default: true)',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot): Promise<CodeMapResponse> {
|
||||
return callBoocontextMap({
|
||||
directory: input.directory ?? projectRoot,
|
||||
compress: input.compress ?? true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export async function executeGetCodeMap(
|
||||
input: GetCodeMapInputT,
|
||||
projectRoot: string,
|
||||
): Promise<CodeMapResponse> {
|
||||
return callBoocontextMap({
|
||||
directory: input.directory ?? projectRoot,
|
||||
compress: input.compress ?? true,
|
||||
});
|
||||
}
|
||||
262
apps/server/src/services/tools/codecontext/get_type_info.ts
Normal file
262
apps/server/src/services/tools/codecontext/get_type_info.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { z } from 'zod';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { ToolDef } from '../types.js';
|
||||
import type { CodecontextResponse } from '../../codecontext_client.js';
|
||||
|
||||
const BOOCONTEXT_PATH = '/opt/forks/boocontext/dist/standalone.js';
|
||||
const TRUNCATION_LIMIT = 32_000;
|
||||
|
||||
export const GetTypeInfoInput = z.object({
|
||||
file: z.string().min(1).describe('File path to resolve types in'),
|
||||
symbol: z.string().optional().describe('Symbol name to resolve (supports regex)'),
|
||||
directory: z.string().optional().describe('Project directory for type resolution context'),
|
||||
});
|
||||
export type GetTypeInfoInputT = z.infer<typeof GetTypeInfoInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'TypeScript type recovery. Returns type signatures, interface definitions, ' +
|
||||
'generic constraints, and JSDoc for symbols in a file. Uses type-inject MCP server.';
|
||||
|
||||
// ---- JSON-RPC-over-stdio MCP caller for boocontext --------------------------
|
||||
|
||||
async function callBoocontext(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<CodecontextResponse> {
|
||||
const child = spawn(process.execPath, [BOOCONTEXT_PATH], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
let stderrBuf = '';
|
||||
child.stderr!.on('data', (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString('utf-8');
|
||||
});
|
||||
|
||||
let killed = false;
|
||||
const killChild = () => {
|
||||
if (killed) return;
|
||||
killed = true;
|
||||
child.kill();
|
||||
};
|
||||
|
||||
try {
|
||||
// Read one complete JSON-RPC response from stdout (handles both
|
||||
// Content-Length framed and newline-delimited transport).
|
||||
async function readResponse(timeoutMs = 30_000): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('Timeout reading boocontext response'));
|
||||
}, timeoutMs);
|
||||
|
||||
let buf = '';
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
child.stdout!.removeListener('data', onData);
|
||||
child.stdout!.removeListener('end', onEnd);
|
||||
child.stdout!.removeListener('error', onError);
|
||||
};
|
||||
|
||||
const onData = (chunk: Buffer) => {
|
||||
buf += chunk.toString('utf-8');
|
||||
|
||||
const msg = tryExtractMessage(buf);
|
||||
if (msg !== null) {
|
||||
cleanup();
|
||||
resolve(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (buf.length > 1_024 * 1_024) {
|
||||
cleanup();
|
||||
reject(new Error('Boocontext response exceeded 1 MB'));
|
||||
}
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
cleanup();
|
||||
if (buf.trim()) {
|
||||
try {
|
||||
resolve(JSON.parse(buf.trim()));
|
||||
} catch {
|
||||
reject(new Error('Boocontext stream ended with incomplete data'));
|
||||
}
|
||||
} else {
|
||||
reject(new Error('Boocontext stream ended unexpectedly'));
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
child.stdout!.on('data', onData);
|
||||
child.stdout!.on('end', onEnd);
|
||||
child.stdout!.on('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for the process to be fully spawned.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('spawn', () => resolve());
|
||||
});
|
||||
|
||||
// Step 1 — MCP initialize
|
||||
let reqId = 0;
|
||||
reqId++;
|
||||
child.stdin!.write(
|
||||
JSON.stringify({ jsonrpc: '2.0', id: reqId, method: 'initialize' }) + '\n',
|
||||
);
|
||||
|
||||
const initResp = await readResponse() as { error?: { message: string } };
|
||||
if (initResp.error) {
|
||||
throw new Error(`Boocontext init failed: ${initResp.error.message}`);
|
||||
}
|
||||
|
||||
// Step 2 — tools/call
|
||||
reqId++;
|
||||
child.stdin!.write(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: reqId,
|
||||
method: 'tools/call',
|
||||
params: { name: toolName, arguments: args },
|
||||
}) + '\n',
|
||||
);
|
||||
|
||||
const callResp = await readResponse() as {
|
||||
error?: { message: string };
|
||||
result?: { content?: Array<{ type: string; text: string }> };
|
||||
};
|
||||
if (callResp.error) {
|
||||
throw new Error(`Boocontext tool call failed: ${callResp.error.message}`);
|
||||
}
|
||||
|
||||
// Extract text from the MCP tool result shape:
|
||||
// { content: [{ type: "text", text: "…" }] }
|
||||
const content = callResp.result?.content;
|
||||
let text: string;
|
||||
if (Array.isArray(content) && content.length > 0 && content[0]!.type === 'text') {
|
||||
text = content[0]!.text;
|
||||
} else {
|
||||
text = JSON.stringify(callResp.result);
|
||||
}
|
||||
|
||||
// Inline truncation at 32 KB.
|
||||
if (text.length > TRUNCATION_LIMIT) {
|
||||
const omitted = text.length - TRUNCATION_LIMIT;
|
||||
return {
|
||||
result:
|
||||
text.slice(0, TRUNCATION_LIMIT) +
|
||||
`\n\n[truncated, ${omitted} chars omitted; narrow with file or symbol filter]`,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { result: text, truncated: false };
|
||||
} finally {
|
||||
killChild();
|
||||
// Give the process a moment to release resources.
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, 2_000);
|
||||
child.on('exit', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to extract one complete JSON-RPC message from the head of a
|
||||
* buffer. Handles both Content-Length framed and newline-delimited
|
||||
* formats. Returns `null` when more data is needed.
|
||||
*/
|
||||
function tryExtractMessage(buf: string): unknown | null {
|
||||
// --- Content-Length framed ---
|
||||
const headerEnd = buf.indexOf('\r\n\r\n');
|
||||
if (headerEnd !== -1) {
|
||||
const header = buf.substring(0, headerEnd);
|
||||
const lengthMatch = header.match(/Content-Length:\s*(\d+)/i);
|
||||
if (lengthMatch) {
|
||||
const contentLength = parseInt(lengthMatch[1]!, 10);
|
||||
const bodyStart = headerEnd + 4;
|
||||
if (buf.length >= bodyStart + contentLength) {
|
||||
const jsonStr = buf.substring(bodyStart, bodyStart + contentLength);
|
||||
return JSON.parse(jsonStr);
|
||||
}
|
||||
return null; // need more data
|
||||
}
|
||||
// Has \r\n\r\n but no Content-Length — junk segment; skip and retry.
|
||||
return tryExtractMessage(buf.substring(headerEnd + 4));
|
||||
}
|
||||
|
||||
// --- Newline-delimited ---
|
||||
const nlIndex = buf.indexOf('\n');
|
||||
if (nlIndex !== -1) {
|
||||
const line = buf.substring(0, nlIndex).trim();
|
||||
if (line && line.startsWith('{')) {
|
||||
return JSON.parse(line);
|
||||
}
|
||||
// Non-JSON line (e.g. stderr echo), skip and continue.
|
||||
return tryExtractMessage(buf.substring(nlIndex + 1));
|
||||
}
|
||||
|
||||
return null; // need more data
|
||||
}
|
||||
|
||||
// ---- ToolDef ----------------------------------------------------------------
|
||||
|
||||
export const getTypeInfo: ToolDef<GetTypeInfoInputT> = {
|
||||
name: 'get_type_info',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetTypeInfoInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_type_info',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', description: 'File path to resolve types in' },
|
||||
symbol: {
|
||||
type: 'string',
|
||||
description: 'Symbol name to resolve (supports regex)',
|
||||
},
|
||||
directory: {
|
||||
type: 'string',
|
||||
description: 'Project directory for type resolution context',
|
||||
},
|
||||
},
|
||||
required: ['file'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input): Promise<CodecontextResponse> {
|
||||
const args: Record<string, unknown> = { file: input.file };
|
||||
if (input.symbol) args['symbol'] = input.symbol;
|
||||
return callBoocontext('boocontext_types', args);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Standalone execute function matching the `execute` shape returned by
|
||||
* `makeCodecontextTool` — useful for direct callers and tests.
|
||||
*
|
||||
* Note: unlike the HTTP-backed codecontext tools this does NOT accept a
|
||||
* `fetcher` override because it communicates over stdio rather than HTTP.
|
||||
*/
|
||||
export async function executeGetTypeInfo(
|
||||
input: GetTypeInfoInputT,
|
||||
_projectPath?: string,
|
||||
): Promise<CodecontextResponse> {
|
||||
const args: Record<string, unknown> = { file: input.file };
|
||||
if (input.symbol) args['symbol'] = input.symbol;
|
||||
return callBoocontext('boocontext_types', args);
|
||||
}
|
||||
@@ -13,3 +13,8 @@ export { getBlastRadius } from './get_blast_radius.js';
|
||||
export { getHotFiles } from './get_hot_files.js';
|
||||
export { getRoutes } from './get_routes.js';
|
||||
export { getMiddleware } from './get_middleware.js';
|
||||
// v2.8.14-domain2-phase1: boocontext-backed tools.
|
||||
export { getCodeHealth } from './get_code_health.js';
|
||||
export { getCodeImpact } from './get_code_impact.js';
|
||||
export { getTypeInfo } from './get_type_info.js';
|
||||
export { getCodeMap } from './get_code_map.js';
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
getHotFiles,
|
||||
getRoutes,
|
||||
getMiddleware,
|
||||
getCodeHealth,
|
||||
getCodeImpact,
|
||||
getTypeInfo,
|
||||
getCodeMap,
|
||||
} from './codecontext/index.js';
|
||||
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
|
||||
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||
@@ -75,6 +79,12 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
|
||||
// v2.6.x: read a tab's transcript by its session-scoped tab number.
|
||||
// Read-only; uses the ToolExecCtx 4th arg for DB/session access.
|
||||
readTabByNumber as ToolDef<unknown>,
|
||||
// v2.8.14-domain2-phase1: boocontext-backed tools. Backed by the boocontext
|
||||
// MCP server. All read-only. Health, impact, types, map analysis.
|
||||
getCodeHealth as ToolDef<unknown>,
|
||||
getCodeImpact as ToolDef<unknown>,
|
||||
getTypeInfo as ToolDef<unknown>,
|
||||
getCodeMap as ToolDef<unknown>,
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Home } from '@/pages/Home';
|
||||
import { Project } from '@/pages/Project';
|
||||
import { Session } from '@/pages/Session';
|
||||
import { Settings } from '@/pages/Settings';
|
||||
import { Analytics } from '@/pages/Analytics';
|
||||
import { Results } from '@/pages/Results';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { useUserEvents } from '@/hooks/useUserEvents';
|
||||
import { useCoderUserEvents } from '@/hooks/useCoderUserEvents';
|
||||
@@ -95,6 +97,8 @@ function AppShell() {
|
||||
<Route path="/project/:id" element={<Project />} />
|
||||
<Route path="/session/:id" element={<Session />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/results" element={<Results />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<MobileRightRailBackdrop />
|
||||
|
||||
@@ -30,6 +30,10 @@ import type {
|
||||
BattleShape,
|
||||
ContestantShape,
|
||||
CrossExaminationShape,
|
||||
AnalyticsSummary,
|
||||
SessionAnalyticsRow,
|
||||
ContextWindowStats,
|
||||
TokenBreakdownAgg,
|
||||
} from './types';
|
||||
|
||||
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by
|
||||
@@ -159,12 +163,13 @@ export const api = {
|
||||
request<{ files: string[] }>(`/api/projects/${id}/files`),
|
||||
git: (id: string) =>
|
||||
request<GitMeta>(`/api/projects/${id}/git`),
|
||||
gitDiff: (id: string, mode: GitDiffMode | null) =>
|
||||
request<GitDiffResult>(
|
||||
mode !== null
|
||||
? `/api/projects/${id}/git/diff?mode=${mode}`
|
||||
: `/api/projects/${id}/git/diff`,
|
||||
),
|
||||
gitDiff: (id: string, mode: GitDiffMode | null, hideWhitespace?: boolean) => {
|
||||
const params: string[] = [];
|
||||
if (mode !== null) params.push(`mode=${mode}`);
|
||||
if (hideWhitespace) params.push('whitespace=1');
|
||||
const qs = params.length > 0 ? `?${params.join('&')}` : '';
|
||||
return request<GitDiffResult>(`/api/projects/${id}/git/diff${qs}`);
|
||||
},
|
||||
gitStage: (id: string, files: string[]) =>
|
||||
request<{ ok: boolean }>(`/api/projects/${id}/git/stage`, {
|
||||
method: 'POST',
|
||||
@@ -185,6 +190,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ files }),
|
||||
}),
|
||||
writeFile: (id: string, filePath: string, content: string) =>
|
||||
request<{ ok: boolean }>(`/api/projects/${id}/write_file`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: filePath, content }),
|
||||
}),
|
||||
},
|
||||
|
||||
sessions: {
|
||||
@@ -590,6 +600,14 @@ export const api = {
|
||||
costStats: () => request<{ stats: ToolCostStat[] }>('/api/tools/cost_stats'),
|
||||
},
|
||||
|
||||
// token-analyzer-ui: analytics aggregate endpoints.
|
||||
analytics: {
|
||||
summary: () => request<AnalyticsSummary>('/api/coder/analytics/summary'),
|
||||
sessions: () => request<{ sessions: SessionAnalyticsRow[] }>('/api/coder/analytics/sessions'),
|
||||
context: () => request<ContextWindowStats>('/api/analytics/context'),
|
||||
tokenBreakdown: () => request<{ categories: TokenBreakdownAgg[] }>('/api/coder/analytics/token-breakdown'),
|
||||
},
|
||||
|
||||
settings: {
|
||||
get: () => request<Record<string, unknown>>('/api/settings'),
|
||||
patch: (body: Record<string, unknown>) =>
|
||||
|
||||
@@ -627,3 +627,32 @@ export type WsFrame =
|
||||
analysis_ready?: boolean;
|
||||
cross_exam_id?: string;
|
||||
};
|
||||
|
||||
// token-analyzer-ui: aggregate token/cost analytics types.
|
||||
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 ContextWindowStats {
|
||||
avg_ctx_used: number | null;
|
||||
avg_ctx_max: number | null;
|
||||
avg_utilization_pct: number | null;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface TokenBreakdownAgg {
|
||||
category: string;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
206
apps/web/src/components/DiffSplitView.tsx
Normal file
206
apps/web/src/components/DiffSplitView.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useMemo, useRef, useEffect, useState } from 'react';
|
||||
import { codeToHtml } from 'shiki';
|
||||
import type { GitDiffFile } from '@/api/types';
|
||||
import { parseDiff, buildSplitRows, reconstructNewContent, type SplitRow } from '@/utils/diff-layout';
|
||||
import { inferLanguage } from '@/lib/attachments';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DiffSplitViewProps {
|
||||
file: GitDiffFile;
|
||||
wrapLines?: boolean;
|
||||
}
|
||||
|
||||
/** Side-by-side split diff renderer. Left = deletions, right = additions. */
|
||||
export function DiffSplitView({ file, wrapLines = false }: DiffSplitViewProps) {
|
||||
// ── Edge cases (rendered before hooks) ──────────────────────────────────
|
||||
if (file.is_binary) {
|
||||
return <p className="text-xs text-muted-foreground italic px-2 py-1">Binary file</p>;
|
||||
}
|
||||
if (file.is_too_large) {
|
||||
return <p className="text-xs text-muted-foreground italic px-2 py-1">Diff too large to display</p>;
|
||||
}
|
||||
if (file.change_type === 'untracked' && !file.diff_body) {
|
||||
return <p className="text-xs text-muted-foreground italic px-2 py-1">Untracked file</p>;
|
||||
}
|
||||
if (!file.diff_body) {
|
||||
return <p className="text-xs text-muted-foreground italic px-2 py-1">No diff content</p>;
|
||||
}
|
||||
|
||||
return <DiffSplitViewInner file={file} wrapLines={wrapLines} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component — assumes file.diff_body is non-null.
|
||||
* Separated so the early-return edge cases above don't violate rules of hooks.
|
||||
*/
|
||||
function DiffSplitViewInner({ file, wrapLines }: { file: GitDiffFile; wrapLines: boolean }) {
|
||||
// ── Parse diff ───────────────────────────────────────────────────────────
|
||||
const parsed = useMemo(() => parseDiff(file.diff_body!), [file.diff_body]);
|
||||
const parsedFile = parsed[0];
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!parsedFile) return [] as SplitRow[];
|
||||
return buildSplitRows(parsedFile);
|
||||
}, [parsedFile]);
|
||||
|
||||
const newContent = useMemo(() => {
|
||||
if (!parsedFile) return '';
|
||||
return reconstructNewContent(parsedFile.hunks);
|
||||
}, [parsedFile]);
|
||||
|
||||
// ── Syntax highlighting ──────────────────────────────────────────────────
|
||||
const [highlightedLines, setHighlightedLines] = useState<string[] | null>(null);
|
||||
const [highlighting, setHighlighting] = useState(false);
|
||||
const highlightKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!newContent) return;
|
||||
if (highlightKeyRef.current === newContent) return;
|
||||
highlightKeyRef.current = newContent;
|
||||
|
||||
let cancelled = false;
|
||||
setHighlighting(true);
|
||||
setHighlightedLines(null);
|
||||
|
||||
const lang = inferLanguage(file.path) ?? 'plaintext';
|
||||
|
||||
void codeToHtml(newContent, { lang, theme: 'github-dark' })
|
||||
.then((html) => {
|
||||
if (cancelled) return;
|
||||
const container = document.createElement('div');
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
container.innerHTML = html;
|
||||
const codeEl = container.querySelector('code');
|
||||
if (codeEl) {
|
||||
const lineSpans = codeEl.querySelectorAll('.line');
|
||||
setHighlightedLines(Array.from(lineSpans, (span) => span.innerHTML));
|
||||
} else {
|
||||
setHighlightedLines(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHighlightedLines(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setHighlighting(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [newContent, file.path]);
|
||||
|
||||
// ── Build new-line-number → highlighted-HTML map ───────────────────────
|
||||
// Walk the hunks counting only add/context lines (which form the new file)
|
||||
// and map each 1-based new-line-number to its highlighted HTML string.
|
||||
const newLineHtmlMap = useMemo(() => {
|
||||
if (!highlightedLines || !parsedFile) return new Map<number, string>();
|
||||
const map = new Map<number, string>();
|
||||
let idx = 0;
|
||||
for (const hunk of parsedFile.hunks) {
|
||||
let newLineNo = hunk.newStart;
|
||||
for (const line of hunk.lines) {
|
||||
if (line.type === 'header') continue;
|
||||
if (line.type === 'add' || line.type === 'context') {
|
||||
if (idx < highlightedLines.length) {
|
||||
map.set(newLineNo, highlightedLines[idx]!);
|
||||
}
|
||||
idx++;
|
||||
newLineNo++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [highlightedLines, parsedFile]);
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className={cn('text-[11px] font-mono overflow-x-auto', wrapLines && 'break-all')}>
|
||||
{highlighting && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting…</p>
|
||||
)}
|
||||
<table className="w-full border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-[40px]" />
|
||||
<col />
|
||||
<col className="w-px" />
|
||||
<col className="w-[40px]" />
|
||||
<col />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{rows.map((row, idx) => {
|
||||
if (row.kind === 'header') {
|
||||
return (
|
||||
<tr key={`h-${idx}`} className="bg-muted/30">
|
||||
<td
|
||||
colSpan={5}
|
||||
className="text-muted-foreground text-[11px] px-2 py-0.5 select-none"
|
||||
>
|
||||
{row.content}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const left = row.left;
|
||||
const right = row.right;
|
||||
|
||||
const leftBg = left?.type === 'remove' ? 'bg-red-950/30' : '';
|
||||
const rightBg = right?.type === 'add' ? 'bg-green-950/30' : '';
|
||||
|
||||
const leftHtml = left?.lineNumber != null ? newLineHtmlMap.get(left.lineNumber) : undefined;
|
||||
const rightHtml = right?.lineNumber != null ? newLineHtmlMap.get(right.lineNumber) : undefined;
|
||||
|
||||
return (
|
||||
<tr key={`p-${idx}`} className="hover:bg-muted/10">
|
||||
<td className={cn(leftBg, 'border-r border-border/20 align-top')}>
|
||||
<span className="text-muted-foreground text-right pr-1 select-none text-[11px] block">
|
||||
{left?.lineNumber ?? ''}
|
||||
</span>
|
||||
</td>
|
||||
<td className={cn(leftBg, 'align-top')}>
|
||||
<div
|
||||
className={cn(
|
||||
'pl-2 text-[11px]',
|
||||
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
|
||||
)}
|
||||
>
|
||||
{left ? (
|
||||
leftHtml ? (
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
<span dangerouslySetInnerHTML={{ __html: leftHtml }} />
|
||||
) : (
|
||||
<span>{left.content}</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="border-l border-border/30 w-px p-0" />
|
||||
<td className={cn(rightBg, 'border-r border-border/20 align-top')}>
|
||||
<span className="text-muted-foreground text-right pr-1 select-none text-[11px] block">
|
||||
{right?.lineNumber ?? ''}
|
||||
</span>
|
||||
</td>
|
||||
<td className={cn(rightBg, 'align-top')}>
|
||||
<div
|
||||
className={cn(
|
||||
'pl-2 text-[11px]',
|
||||
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
|
||||
)}
|
||||
>
|
||||
{right ? (
|
||||
rightHtml ? (
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
<span dangerouslySetInnerHTML={{ __html: rightHtml }} />
|
||||
) : (
|
||||
<span>{right.content}</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, GitBranch, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Columns2, GitBranch, ListChevronsDownUp, ListChevronsUpDown, AlignJustify, Pilcrow, RefreshCw, Trash2, WrapText } from 'lucide-react';
|
||||
import { codeToHtml } from 'shiki';
|
||||
import type { GitDiffFile, GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DiffSplitView } from './DiffSplitView';
|
||||
import { InlineReviewGutterCell } from './InlineReviewGutterCell';
|
||||
import { InlineReviewEditor } from './InlineReviewEditor';
|
||||
import { InlineReviewThread } from './InlineReviewThread';
|
||||
import { useDiffComments } from '@/stores/useDiffCommentStore';
|
||||
|
||||
interface WriteProps {
|
||||
mutating: boolean;
|
||||
@@ -18,12 +23,19 @@ interface Props extends WriteProps {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
mode: GitDiffMode;
|
||||
sessionId?: string;
|
||||
onSelectMode: (m: GitDiffMode) => void;
|
||||
onRefresh: () => void;
|
||||
/** FIX 4: non-null when the repo's dirty state suggests a different mode than the pinned one. */
|
||||
modeSuggestion?: GitDiffMode | null;
|
||||
/** FIX 5: pending-changes count from the Coder pane — shown in empty state as a hint. */
|
||||
pendingCount?: number;
|
||||
layout: 'unified' | 'split';
|
||||
wrapLines: boolean;
|
||||
hideWhitespace: boolean;
|
||||
onLayoutChange: (layout: 'unified' | 'split') => void;
|
||||
onWrapLinesChange: (wrap: boolean) => void;
|
||||
onHideWhitespaceChange: (hide: boolean) => void;
|
||||
}
|
||||
|
||||
const CHANGE_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -99,6 +111,12 @@ function FileDiffRow({
|
||||
onStage,
|
||||
onUnstage,
|
||||
onDiscardRequest,
|
||||
layout,
|
||||
wrapLines,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
sessionId,
|
||||
diffMode,
|
||||
}: {
|
||||
file: GitDiffFile;
|
||||
uncommitted: boolean;
|
||||
@@ -106,11 +124,21 @@ function FileDiffRow({
|
||||
onStage: (path: string) => void;
|
||||
onUnstage: (path: string) => void;
|
||||
onDiscardRequest: (file: GitDiffFile) => void;
|
||||
layout: 'unified' | 'split';
|
||||
wrapLines: boolean;
|
||||
expanded: boolean;
|
||||
onToggleExpand: (path: string) => void;
|
||||
sessionId?: string;
|
||||
diffMode?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const [highlighting, setHighlighting] = useState(false);
|
||||
const highlightRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const commentKey = `${file.path}:${file.change_type}`;
|
||||
const diffModeVal = diffMode ?? '';
|
||||
const { comments, addComment, updateComment, deleteComment } = useDiffComments(sessionId ?? '', diffModeVal);
|
||||
const fileComments = comments.get(commentKey) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded || !file.diff_body) return;
|
||||
@@ -136,13 +164,27 @@ function FileDiffRow({
|
||||
const typeColor = CHANGE_TYPE_COLORS[file.change_type] ?? 'text-muted-foreground';
|
||||
const displayPath = file.old_path ? `${file.old_path} → ${file.path}` : file.path;
|
||||
|
||||
const handleAddComment = (body: string) => {
|
||||
const comment = { id: crypto.randomUUID(), body, createdAt: Date.now(), updatedAt: Date.now() };
|
||||
addComment(commentKey, comment);
|
||||
setShowEditor(false);
|
||||
};
|
||||
|
||||
const handleEditComment = (id: string, body: string) => {
|
||||
updateComment(commentKey, id, body);
|
||||
};
|
||||
|
||||
const handleDeleteComment = (id: string) => {
|
||||
deleteComment(commentKey, id);
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="border-b border-border/30 last:border-0">
|
||||
<div className="flex items-center group">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center gap-1.5 px-2 py-1.5 text-xs hover:bg-muted/40 text-left max-md:min-h-[44px] min-w-0"
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
onClick={() => onToggleExpand(file.path)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded
|
||||
@@ -203,23 +245,54 @@ function FileDiffRow({
|
||||
<p className="text-xs text-muted-foreground italic px-2 py-1">Untracked — not yet staged</p>
|
||||
)}
|
||||
{!file.is_binary && !file.is_too_large && file.diff_body && (
|
||||
<>
|
||||
{highlighting && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting…</p>
|
||||
)}
|
||||
{!highlighting && html !== null ? (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
className="text-[11px] overflow-x-auto rounded bg-[#0d1117] [&_pre]:!p-2 [&_pre]:!m-0 [&_pre]:overflow-x-auto"
|
||||
layout === 'split' ? (
|
||||
<DiffSplitView file={file} wrapLines={wrapLines} />
|
||||
) : (
|
||||
<>
|
||||
{highlighting && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting…</p>
|
||||
)}
|
||||
{!highlighting && html !== null ? (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
className="text-[11px] overflow-x-auto rounded bg-[#0d1117] [&_pre]:!p-2 [&_pre]:!m-0 [&_pre]:overflow-x-auto"
|
||||
/>
|
||||
) : (
|
||||
!highlighting && (
|
||||
<pre className={cn(
|
||||
'text-[11px] overflow-x-auto rounded bg-muted/30 p-2',
|
||||
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
|
||||
)}>
|
||||
{file.diff_body}
|
||||
</pre>
|
||||
)
|
||||
)}
|
||||
{/* Comment button */}
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEditor(!showEditor)}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-0.5 px-1 py-0.5 rounded hover:bg-muted/40"
|
||||
>
|
||||
<span>{showEditor ? 'Cancel' : 'Comment'}</span>
|
||||
</button>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{fileComments.length > 0 && `${fileComments.length} comment${fileComments.length > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
{showEditor && (
|
||||
<InlineReviewEditor
|
||||
onSave={handleAddComment}
|
||||
onCancel={() => setShowEditor(false)}
|
||||
/>
|
||||
)}
|
||||
<InlineReviewThread
|
||||
comments={fileComments}
|
||||
onEditComment={handleEditComment}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
/>
|
||||
) : (
|
||||
!highlighting && (
|
||||
<pre className="text-[11px] overflow-x-auto rounded bg-muted/30 p-2 whitespace-pre">
|
||||
{file.diff_body}
|
||||
</pre>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -242,11 +315,41 @@ export function GitDiffView({
|
||||
onDiscard,
|
||||
modeSuggestion,
|
||||
pendingCount,
|
||||
layout,
|
||||
wrapLines,
|
||||
hideWhitespace,
|
||||
onLayoutChange,
|
||||
onWrapLinesChange,
|
||||
onHideWhitespaceChange,
|
||||
sessionId,
|
||||
}: Props) {
|
||||
const [commitMessage, setCommitMessage] = useState('');
|
||||
const [discardTarget, setDiscardTarget] = useState<DiscardConfirmState | null>(null);
|
||||
const [lastAction, setLastAction] = useState<string | null>(null);
|
||||
const lastActionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
const allExpandedComputed = useMemo(
|
||||
() => result !== null && (result.files?.length ?? 0) > 0 && result.files.every((f) => expandedFiles.has(f.path)),
|
||||
[result, expandedFiles],
|
||||
);
|
||||
|
||||
const handleExpandAllChange = useCallback((expand: boolean) => {
|
||||
if (expand && result?.files) {
|
||||
setExpandedFiles(new Set(result.files.map((f) => f.path)));
|
||||
} else {
|
||||
setExpandedFiles(new Set());
|
||||
}
|
||||
}, [result?.files]);
|
||||
|
||||
const handleToggleExpand = useCallback((path: string) => {
|
||||
setExpandedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
function flashAction(msg: string) {
|
||||
setLastAction(msg);
|
||||
@@ -378,6 +481,83 @@ export function GitDiffView({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Diff toolbar */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLayoutChange('unified')}
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded flex items-center gap-1 max-md:min-h-[44px]',
|
||||
layout === 'unified'
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title="Unified diff"
|
||||
>
|
||||
<AlignJustify size={12} />
|
||||
Unified
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLayoutChange('split')}
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded flex items-center gap-1 max-md:min-h-[44px]',
|
||||
layout === 'split'
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title="Split diff"
|
||||
>
|
||||
<Columns2 size={12} />
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHideWhitespaceChange(!hideWhitespace)}
|
||||
className={cn(
|
||||
'p-1 rounded max-md:min-h-[44px] max-md:min-w-[44px]',
|
||||
hideWhitespace
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={hideWhitespace ? 'Show whitespace' : 'Hide whitespace'}
|
||||
>
|
||||
<Pilcrow size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWrapLinesChange(!wrapLines)}
|
||||
className={cn(
|
||||
'p-1 rounded max-md:min-h-[44px] max-md:min-w-[44px]',
|
||||
wrapLines
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={wrapLines ? 'Unwrap lines' : 'Wrap lines'}
|
||||
>
|
||||
<WrapText size={12} />
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleExpandAllChange(!allExpandedComputed)}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
title={allExpandedComputed ? 'Collapse all' : 'Expand all'}
|
||||
>
|
||||
{allExpandedComputed ? <ListChevronsDownUp size={12} /> : <ListChevronsUpDown size={12} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={loading || mutating}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Refresh diff"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Committed-mode base label */}
|
||||
{result.mode === 'committed' && base_label && (
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b flex items-center gap-1 shrink-0">
|
||||
@@ -445,6 +625,12 @@ export function GitDiffView({
|
||||
onStage={handleStage}
|
||||
onUnstage={handleUnstage}
|
||||
onDiscardRequest={handleDiscardRequest}
|
||||
layout={layout}
|
||||
wrapLines={wrapLines}
|
||||
expanded={expandedFiles.has(file.path)}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
sessionId={sessionId}
|
||||
diffMode={mode}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
271
apps/web/src/components/InferenceSettings.tsx
Normal file
271
apps/web/src/components/InferenceSettings.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Database, Zap, Clock, BarChart3, Folder } from 'lucide-react';
|
||||
|
||||
interface InferenceConfig {
|
||||
cache_type_k: string;
|
||||
cache_reuse: number;
|
||||
spec_type: string;
|
||||
spec_ngram_mod_thsh: number;
|
||||
ctx_checkpoints: number;
|
||||
sleep_idle_seconds: number;
|
||||
metrics_enabled: boolean;
|
||||
slot_save_path: string;
|
||||
}
|
||||
|
||||
const DEFAULTS: InferenceConfig = {
|
||||
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 Switch({ checked, onCheckedChange, id }: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (v: boolean) => void;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors ${
|
||||
checked ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||
checked ? 'translate-x-[1.125rem]' : 'translate-x-0.5'
|
||||
}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Loader() {
|
||||
return <div className="text-sm text-muted-foreground py-8 text-center">Loading inference settings...</div>;
|
||||
}
|
||||
|
||||
export function InferenceSettings() {
|
||||
const [config, setConfig] = useState<InferenceConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings/inference')
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((data) => setConfig(data as InferenceConfig))
|
||||
.catch(() => {
|
||||
setConfig({ ...DEFAULTS });
|
||||
toast.error('Could not load inference config — loading defaults');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function update<K extends keyof InferenceConfig>(key: K, value: InferenceConfig[K]) {
|
||||
setConfig((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!config || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch('/api/settings/inference', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
if (!res.ok) throw new Error('Save failed');
|
||||
const updated = (await res.json()) as InferenceConfig;
|
||||
setConfig(updated);
|
||||
toast.success('Inference settings saved');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loader />;
|
||||
if (!config) return <div className="text-sm text-destructive py-8 text-center">Failed to load</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="size-3.5 text-muted-foreground" />
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
KV Cache Quantization
|
||||
</label>
|
||||
</div>
|
||||
<select
|
||||
value={config.cache_type_k}
|
||||
onChange={(e) => update('cache_type_k', e.target.value)}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||
>
|
||||
<option value="f32">f32 (full precision)</option>
|
||||
<option value="f16">f16 (half)</option>
|
||||
<option value="q8_0">q8_0 (8-bit)</option>
|
||||
<option value="q4_0">q4_0 (4-bit) — recommended</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
Format for the attention KV cache. Lower = less VRAM. q4_0 gives ~4x savings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="size-3.5 text-muted-foreground" />
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Prompt Caching
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={4096}
|
||||
value={config.cache_reuse}
|
||||
onChange={(e) => update('cache_reuse', Number(e.target.value))}
|
||||
className="w-32 bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{config.cache_reuse > 0 ? 'On (min chunk size in tokens)' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
Reuses KV cache across turns when prompt prefix matches. 256 is a good default.
|
||||
0 = disabled. The local equivalent of prompt caching.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="size-3.5 text-muted-foreground" />
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Speculative Decoding
|
||||
</label>
|
||||
</div>
|
||||
<select
|
||||
value={config.spec_type}
|
||||
onChange={(e) => update('spec_type', e.target.value)}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="ngram-mod">N-gram (lightweight, ~16MB)</option>
|
||||
<option value="draft-simple">Draft model (requires separate model)</option>
|
||||
</select>
|
||||
{config.spec_type === 'ngram-mod' && (
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={config.spec_ngram_mod_thsh}
|
||||
onChange={(e) => update('spec_ngram_mod_thsh', Number(e.target.value))}
|
||||
className="w-24 bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Match threshold (2 = default)</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
Predicts tokens ahead with a small model; main model verifies in batch.
|
||||
2-3x speedup on repetitive/code tasks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Context Checkpoints
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={128}
|
||||
value={config.ctx_checkpoints}
|
||||
onChange={(e) => update('ctx_checkpoints', Number(e.target.value))}
|
||||
className="w-24 bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{config.ctx_checkpoints > 0 ? `Max ${config.ctx_checkpoints} checkpoints per slot` : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
Prevents context overflow on long conversations. Default: 32.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="size-3.5 text-muted-foreground" />
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Auto-sleep Timeout
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min={-1}
|
||||
max={86400}
|
||||
value={config.sleep_idle_seconds}
|
||||
onChange={(e) => update('sleep_idle_seconds', Number(e.target.value))}
|
||||
className="w-24 bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">seconds</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
GPU auto-sleeps after N seconds idle. -1 = disabled. 600 = 10 min.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="size-3.5 text-muted-foreground" />
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Prometheus Metrics
|
||||
</label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.metrics_enabled}
|
||||
onCheckedChange={(v) => update('metrics_enabled', v)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
Enable /metrics endpoint for Prometheus monitoring (token rates, latency).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="size-3.5 text-muted-foreground" />
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Slot KV Cache Path
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={config.slot_save_path}
|
||||
onChange={(e) => update('slot_save_path', e.target.value)}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm font-mono outline-none focus:border-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
Directory for disk-persistent KV cache. Idle slot caches are saved here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t pt-4">
|
||||
<Button onClick={() => void save()} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
apps/web/src/components/InlineReviewEditor.tsx
Normal file
60
apps/web/src/components/InlineReviewEditor.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface InlineReviewEditorProps {
|
||||
initialBody?: string;
|
||||
onSave: (body: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function InlineReviewEditor({ initialBody = '', onSave, onCancel }: InlineReviewEditorProps) {
|
||||
const [text, setText] = useState(initialBody);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && text.trim()) {
|
||||
onSave(text.trim());
|
||||
}
|
||||
},
|
||||
[onCancel, onSave, text],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-2 my-1 rounded border border-border/80 bg-popover p-2 shadow-sm">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add a comment..."
|
||||
rows={3}
|
||||
className="w-full resize-none bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/60 outline-none"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-1.5 mt-1.5 border-t border-border/40 pt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-xs px-2 py-1 rounded hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!text.trim()}
|
||||
onClick={() => onSave(text.trim())}
|
||||
className="text-xs px-2 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/components/InlineReviewGutterCell.tsx
Normal file
43
apps/web/src/components/InlineReviewGutterCell.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
interface InlineReviewGutterCellProps {
|
||||
lineNumber: number | null;
|
||||
type: 'add' | 'remove' | 'context' | 'header' | null;
|
||||
hasComments: boolean;
|
||||
canComment: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function InlineReviewGutterCell({
|
||||
lineNumber,
|
||||
type,
|
||||
hasComments,
|
||||
canComment,
|
||||
onClick,
|
||||
}: InlineReviewGutterCellProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-end pr-1 min-w-[40px] h-5 text-[11px] font-mono select-none',
|
||||
type === 'add' && 'bg-green-950/30',
|
||||
type === 'remove' && 'bg-red-950/30',
|
||||
type === 'context' && 'bg-muted/10',
|
||||
canComment && 'cursor-pointer group',
|
||||
)}
|
||||
onClick={canComment ? onClick : undefined}
|
||||
>
|
||||
<span className="text-muted-foreground/70">
|
||||
{lineNumber != null ? lineNumber : ''}
|
||||
</span>
|
||||
{canComment && (
|
||||
<span className="absolute left-0.5 hidden group-hover:flex items-center justify-center w-4 h-4 rounded text-muted-foreground hover:text-foreground">
|
||||
<Plus size={12} />
|
||||
</span>
|
||||
)}
|
||||
{hasComments && (
|
||||
<span className="absolute left-0.5 w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
apps/web/src/components/InlineReviewThread.tsx
Normal file
92
apps/web/src/components/InlineReviewThread.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import { MessageSquare, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { DiffComment } from '@/stores/useDiffCommentStore';
|
||||
import { InlineReviewEditor } from './InlineReviewEditor';
|
||||
|
||||
interface InlineReviewThreadProps {
|
||||
comments: DiffComment[];
|
||||
onEditComment: (id: string, body: string) => void;
|
||||
onDeleteComment: (id: string) => void;
|
||||
}
|
||||
|
||||
export function InlineReviewThread({
|
||||
comments,
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
}: InlineReviewThreadProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editBody, setEditBody] = useState('');
|
||||
|
||||
if (comments.length === 0) return null;
|
||||
|
||||
const handleStartEdit = (id: string, body: string) => {
|
||||
setEditingId(id);
|
||||
setEditBody(body);
|
||||
};
|
||||
|
||||
const handleSaveEdit = (body: string) => {
|
||||
if (editingId) {
|
||||
onEditComment(editingId, body);
|
||||
setEditingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ml-1 border-l-2 border-blue-400/40 pl-2 my-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground mb-0.5"
|
||||
>
|
||||
<MessageSquare size={10} />
|
||||
<span>{comments.length} comment{comments.length > 1 ? 's' : ''}</span>
|
||||
<span className="text-[9px]">{expanded ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="space-y-1">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="text-xs">
|
||||
{editingId === comment.id ? (
|
||||
<InlineReviewEditor
|
||||
initialBody={editBody}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-start gap-1 group">
|
||||
<span className="flex-1 text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||
{comment.body}
|
||||
</span>
|
||||
<div className="hidden group-hover:flex items-center gap-0.5 shrink-0 mt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartEdit(comment.id, comment.body)}
|
||||
className="p-0.5 rounded hover:bg-muted text-muted-foreground"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteComment(comment.id)}
|
||||
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-destructive"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X, Code } from 'lucide-react';
|
||||
import { BarChart3, ChevronRight, ExternalLink, Folder, MessageSquare, Plus, ScrollText, Settings as SettingsIcon, X, Code } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import mascot from '@/assets/brand/banner-mascot.png';
|
||||
@@ -519,11 +519,40 @@ export function ProjectSidebar() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
|
||||
workspace settings pane via the sessionEvents bus (Session.tsx owns
|
||||
the panesHook). Outside a session there's no workspace to mount the
|
||||
pane in, so we navigate to /settings (themes page) instead. */}
|
||||
<div className="border-t shrink-0 p-2">
|
||||
{/* bottom-pinned nav buttons. Results → Analytics → Settings. */}
|
||||
<div className="border-t shrink-0 p-2 space-y-0.5">
|
||||
<NavLink
|
||||
to="/results"
|
||||
onClick={() => { if (isMobile) setDrawerOpen(false); }}
|
||||
className={({ isActive }) =>
|
||||
`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground ${
|
||||
isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : ''
|
||||
}`
|
||||
}
|
||||
aria-label="Results"
|
||||
>
|
||||
<ScrollText className="size-3.5 shrink-0 opacity-70" />
|
||||
<span className="flex-1 text-left">Results</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/analytics"
|
||||
onClick={() => { if (isMobile) setDrawerOpen(false); }}
|
||||
className={({ isActive }) =>
|
||||
`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground ${
|
||||
isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : ''
|
||||
}`
|
||||
}
|
||||
aria-label="Token Analytics"
|
||||
>
|
||||
<BarChart3 className="size-3.5 shrink-0 opacity-70" />
|
||||
<span className="flex-1 text-left">Token Analytics</span>
|
||||
</NavLink>
|
||||
|
||||
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
|
||||
workspace settings pane via the sessionEvents bus (Session.tsx owns
|
||||
the panesHook). Outside a session there's no workspace to mount the
|
||||
pane in, so we navigate to /settings (themes page) instead. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { useProjectGit } from '@/hooks/useProjectGit';
|
||||
import { useGitDiff } from '@/hooks/useGitDiff';
|
||||
import { useDiffPreferences } from '@/hooks/useDiffPreferences';
|
||||
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
||||
import { GitDiffView } from '@/components/GitDiffView';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -90,6 +91,15 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
// Diff toolbar state (integration with expandedPaths pending)
|
||||
const { preferences: diffPrefs, updatePreferences: updateDiffPrefs } = useDiffPreferences();
|
||||
|
||||
// File editing state
|
||||
const [editingFile, setEditingFile] = useState<string | null>(null);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
|
||||
const openNewFile = useCallback(() => {
|
||||
setNewFilePath('');
|
||||
setNewFileContent('');
|
||||
@@ -167,6 +177,44 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
});
|
||||
}
|
||||
|
||||
async function startEdit(path: string) {
|
||||
setEditingFile(path);
|
||||
setEditLoading(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
const result = await api.projects.viewFile(projectId, path);
|
||||
setEditContent(result.content);
|
||||
} catch {
|
||||
setEditError('Failed to load file');
|
||||
setEditingFile(null);
|
||||
} finally {
|
||||
setEditLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editingFile) return;
|
||||
try {
|
||||
await api.projects.writeFile(projectId, editingFile, editContent);
|
||||
setEditingFile(null);
|
||||
setEditContent('');
|
||||
sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
} catch {
|
||||
setEditError('Failed to save file');
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingFile(null);
|
||||
setEditContent('');
|
||||
setEditError(null);
|
||||
}
|
||||
|
||||
// Cancel edit when switching tabs
|
||||
useEffect(() => {
|
||||
if (tab !== 'files') cancelEdit();
|
||||
}, [tab]);
|
||||
|
||||
async function openFile(path: string) {
|
||||
try {
|
||||
const result = await api.projects.viewFile(projectId, path);
|
||||
@@ -323,6 +371,30 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground px-2 py-4 text-center">No matches</div>
|
||||
)
|
||||
) : editingFile ? (
|
||||
<div className="flex flex-col flex-1 overflow-hidden p-2 gap-2">
|
||||
<div className="text-xs font-mono truncate text-muted-foreground">{editingFile}</div>
|
||||
{editLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="flex-1 font-mono text-xs p-2 rounded border bg-background resize-none outline-none focus:ring-1 focus:ring-ring"
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') saveEdit();
|
||||
if (e.key === 'Escape') cancelEdit();
|
||||
}}
|
||||
/>
|
||||
{editError && <p className="text-xs text-destructive">{editError}</p>}
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<button type="button" onClick={cancelEdit} className="text-xs px-2 py-1 rounded border hover:bg-muted">Cancel</button>
|
||||
<button type="button" onClick={saveEdit} className="text-xs px-3 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90">Save</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<TreeLevel
|
||||
parentPath=""
|
||||
@@ -332,6 +404,7 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
depth={0}
|
||||
onToggleDir={toggleDir}
|
||||
onSelectFile={(path) => void openFile(path)}
|
||||
onEditFile={startEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -345,6 +418,7 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
loading={gitLoading}
|
||||
error={gitError}
|
||||
mode={gitMode}
|
||||
sessionId={sessionId}
|
||||
onSelectMode={selectMode}
|
||||
onRefresh={refreshDiff}
|
||||
mutating={gitMutating}
|
||||
@@ -355,6 +429,12 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
onDiscard={gitDiscard}
|
||||
modeSuggestion={gitModeSuggestion}
|
||||
pendingCount={pendingCount}
|
||||
layout={diffPrefs.layout}
|
||||
wrapLines={diffPrefs.wrapLines}
|
||||
hideWhitespace={diffPrefs.hideWhitespace}
|
||||
onLayoutChange={(layout) => updateDiffPrefs({ layout })}
|
||||
onWrapLinesChange={(wrapLines) => updateDiffPrefs({ wrapLines })}
|
||||
onHideWhitespaceChange={(hideWhitespace) => updateDiffPrefs({ hideWhitespace })}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
@@ -421,9 +501,10 @@ interface TreeLevelProps {
|
||||
depth: number;
|
||||
onToggleDir: (dirPath: string) => void;
|
||||
onSelectFile: (path: string) => void;
|
||||
onEditFile?: (path: string) => void;
|
||||
}
|
||||
|
||||
function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, onSelectFile }: TreeLevelProps) {
|
||||
function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, onSelectFile, onEditFile }: TreeLevelProps) {
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...entries];
|
||||
copy.sort((a, b) => {
|
||||
@@ -447,6 +528,9 @@ function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, o
|
||||
if (entry.kind === 'dir') onToggleDir(fullPath);
|
||||
else onSelectFile(fullPath);
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (entry.kind === 'file') onEditFile?.(fullPath);
|
||||
}}
|
||||
>
|
||||
{entry.kind === 'dir' ? (
|
||||
isExpanded ? <ChevronDown size={10} className="shrink-0" /> : <ChevronRight size={10} className="shrink-0" />
|
||||
@@ -469,6 +553,7 @@ function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, o
|
||||
depth={depth + 1}
|
||||
onToggleDir={onToggleDir}
|
||||
onSelectFile={onSelectFile}
|
||||
onEditFile={onEditFile}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -423,6 +423,7 @@ export function ArenaPane({ state, onClose }: Props) {
|
||||
duration_ms: null,
|
||||
tokens_per_sec: null,
|
||||
cost_tokens: null,
|
||||
token_breakdown: null,
|
||||
result_path: null,
|
||||
error: null,
|
||||
created_at: new Date().toISOString(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Archive, FolderOpen, Maximize2, Minimize2, Trash2, X } from 'lucide-react';
|
||||
import { Archive, FolderOpen, Maximize2, Minimize2, Trash2, X, Database, Zap, Clock, BarChart3, Folder } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { Project, Session } from '@/api/types';
|
||||
@@ -15,10 +15,11 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { ModelPicker } from '@/components/ModelPicker';
|
||||
import { ThemePicker } from '@/components/ThemePicker';
|
||||
import { InferenceSettings as InferenceSettingsComponent } from '@/components/InferenceSettings';
|
||||
import { ProvidersSettings } from '@/components/coder/ProvidersSettings';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Section = 'session' | 'project' | 'theme' | 'providers';
|
||||
type Section = 'session' | 'project' | 'theme' | 'providers' | 'inference';
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
@@ -74,7 +75,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
{(['session', 'project', 'theme', 'providers'] as const).map((s) => (
|
||||
{(['session', 'project', 'theme', 'providers', 'inference'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
@@ -118,6 +119,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
|
||||
{activeSection === 'project' && <ProjectSection project={project} />}
|
||||
{activeSection === 'theme' && <ThemePicker />}
|
||||
{activeSection === 'providers' && <ProvidersSettings />}
|
||||
{activeSection === 'inference' && <InferenceSettingsComponent />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -599,3 +601,249 @@ function ProjectSection({ project }: { project: Project }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InferenceSettings {
|
||||
cacheTypeK: string;
|
||||
cacheReuse: number;
|
||||
specType: string;
|
||||
ctxCheckpoints: number;
|
||||
sleepIdleSeconds: number;
|
||||
metrics: boolean;
|
||||
slotSavePath: string;
|
||||
}
|
||||
|
||||
const INFERENCE_DEFAULTS: InferenceSettings = {
|
||||
cacheTypeK: 'q4_0',
|
||||
cacheReuse: 256,
|
||||
specType: 'ngram-mod',
|
||||
ctxCheckpoints: 32,
|
||||
sleepIdleSeconds: 600,
|
||||
metrics: true,
|
||||
slotSavePath: '/tmp/llama-slots',
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'boocode-inference-settings';
|
||||
|
||||
function InferenceSettings() {
|
||||
const [settings, setSettings] = useState<InferenceSettings>(INFERENCE_DEFAULTS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
setSettings({ ...INFERENCE_DEFAULTS, ...parsed });
|
||||
}
|
||||
} catch { /* ignore corrupt storage */ }
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
|
||||
const dirty = loaded && JSON.stringify(settings) !== (() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.stringify({ ...INFERENCE_DEFAULTS, ...JSON.parse(stored) }) : JSON.stringify(INFERENCE_DEFAULTS);
|
||||
} catch { return JSON.stringify(INFERENCE_DEFAULTS); }
|
||||
})();
|
||||
|
||||
function update<K extends keyof InferenceSettings>(key: K, value: InferenceSettings[K]) {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
// Simulate API delay
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
toast.success('Inference settings saved. Restart sidecar to apply.');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDefaults() {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
setSettings(INFERENCE_DEFAULTS);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(INFERENCE_DEFAULTS));
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
toast.success('Reset to defaults');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'reset failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!loaded) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="size-3.5 text-muted-foreground" />
|
||||
<label htmlFor="cache-type-k" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
KV Cache Quantization
|
||||
</label>
|
||||
</div>
|
||||
<select
|
||||
id="cache-type-k"
|
||||
value={settings.cacheTypeK}
|
||||
onChange={(e) => update('cacheTypeK', e.target.value)}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||
>
|
||||
<option value="f32">f32 — 32-bit (max quality)</option>
|
||||
<option value="f16">f16 — 16-bit (balanced)</option>
|
||||
<option value="q8_0">q8_0 — 8-bit (efficient)</option>
|
||||
<option value="q4_0">q4_0 — 4-bit (max efficiency)</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Compresses the attention cache. Lower = less VRAM usage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="size-3.5 text-muted-foreground" />
|
||||
<label htmlFor="cache-reuse" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Cache Reuse (Prompt Caching)
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
id="cache-reuse"
|
||||
type="number"
|
||||
min={0}
|
||||
step={64}
|
||||
value={settings.cacheReuse}
|
||||
onChange={(e) => update('cacheReuse', parseInt(e.target.value) || 0)}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Minimum chunk size in tokens to reuse across turns. 0 = disabled.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="size-3.5 text-muted-foreground" />
|
||||
<label htmlFor="spec-type" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Speculative Decoding
|
||||
</label>
|
||||
</div>
|
||||
<select
|
||||
id="spec-type"
|
||||
value={settings.specType}
|
||||
onChange={(e) => update('specType', e.target.value)}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="ngram-mod">ngram-mod — Lightweight (~16MB, no draft model)</option>
|
||||
<option value="draft-simple">draft-simple — Requires separate draft model</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Predicts tokens ahead using a small model. Main model verifies in batch for 2-3x speedup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="size-3.5 text-muted-foreground" />
|
||||
<label htmlFor="ctx-checkpoints" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Context Checkpoints
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
id="ctx-checkpoints"
|
||||
type="number"
|
||||
min={0}
|
||||
max={256}
|
||||
value={settings.ctxCheckpoints}
|
||||
onChange={(e) => update('ctxCheckpoints', parseInt(e.target.value) || 0)}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Max checkpoints per slot. 0 = disabled.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="size-3.5 text-muted-foreground" />
|
||||
<label htmlFor="sleep-idle" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Sleep Idle
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
id="sleep-idle"
|
||||
type="number"
|
||||
min={-1}
|
||||
step={60}
|
||||
value={settings.sleepIdleSeconds}
|
||||
onChange={(e) => update('sleepIdleSeconds', parseInt(e.target.value) || -1)}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Auto-sleep after N seconds idle. -1 = disabled.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="size-3.5 text-muted-foreground" />
|
||||
<label htmlFor="metrics" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Metrics Endpoint
|
||||
</label>
|
||||
</div>
|
||||
<Switch
|
||||
id="metrics"
|
||||
checked={settings.metrics}
|
||||
onCheckedChange={(v) => update('metrics', v)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Exposes Prometheus /metrics endpoint for observability.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="size-3.5 text-muted-foreground" />
|
||||
<label htmlFor="slot-save-path" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Slot Save Path
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
id="slot-save-path"
|
||||
type="text"
|
||||
value={settings.slotSavePath}
|
||||
onChange={(e) => update('slotSavePath', e.target.value)}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm font-mono outline-none focus:border-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Directory for disk-persistent KV cache. Must be writable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 border-t pt-4">
|
||||
<Button variant="outline" onClick={() => void resetDefaults()} disabled={saving}>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
<Button onClick={() => void save()} disabled={!dirty || saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground border-t pt-4">
|
||||
Changes apply to new llama-server processes. Restart the sidecar to apply.
|
||||
These settings are stored locally in your browser.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
68
apps/web/src/hooks/useDiffPreferences.ts
Normal file
68
apps/web/src/hooks/useDiffPreferences.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export interface DiffPreferences {
|
||||
layout: 'unified' | 'split';
|
||||
wrapLines: boolean;
|
||||
hideWhitespace: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PREFERENCES: DiffPreferences = {
|
||||
layout: 'unified',
|
||||
wrapLines: false,
|
||||
hideWhitespace: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'boocode.diff.preferences';
|
||||
|
||||
function loadPreferences(): DiffPreferences {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Partial<DiffPreferences>;
|
||||
return {
|
||||
layout: parsed.layout ?? DEFAULT_PREFERENCES.layout,
|
||||
wrapLines: parsed.wrapLines ?? DEFAULT_PREFERENCES.wrapLines,
|
||||
hideWhitespace: parsed.hideWhitespace ?? DEFAULT_PREFERENCES.hideWhitespace,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return DEFAULT_PREFERENCES;
|
||||
}
|
||||
|
||||
function savePreferences(prefs: DiffPreferences): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function useDiffPreferences(): {
|
||||
preferences: DiffPreferences;
|
||||
updatePreferences: (updates: Partial<DiffPreferences>) => void;
|
||||
resetPreferences: () => void;
|
||||
} {
|
||||
const [preferences, setPreferences] = useState<DiffPreferences>(loadPreferences);
|
||||
|
||||
// Sync from localStorage on mount (handles multi-tab changes if we add a storage listener later)
|
||||
useEffect(() => {
|
||||
setPreferences(loadPreferences());
|
||||
}, []);
|
||||
|
||||
const updatePreferences = useCallback((updates: Partial<DiffPreferences>) => {
|
||||
setPreferences((prev) => {
|
||||
const next = { ...prev, ...updates };
|
||||
savePreferences(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetPreferences = useCallback(() => {
|
||||
setPreferences(DEFAULT_PREFERENCES);
|
||||
savePreferences(DEFAULT_PREFERENCES);
|
||||
}, []);
|
||||
|
||||
return { preferences, updatePreferences, resetPreferences };
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { api } from '@/api/client';
|
||||
import type { GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
|
||||
export function useGitDiff(projectId: string | null | undefined) {
|
||||
export function useGitDiff(projectId: string | null | undefined, hideWhitespace = false) {
|
||||
const [mode, setMode] = useState<GitDiffMode>('uncommitted');
|
||||
const [pinned, setPinned] = useState(false);
|
||||
const [result, setResult] = useState<GitDiffResult | null>(null);
|
||||
@@ -23,7 +23,7 @@ export function useGitDiff(projectId: string | null | undefined) {
|
||||
// FIX 1: when not pinned, omit mode param so the server auto-selects based on
|
||||
// dirty state (dirty → uncommitted, clean → committed).
|
||||
api.projects
|
||||
.gitDiff(projectId, pinned ? mode : null)
|
||||
.gitDiff(projectId, pinned ? mode : null, hideWhitespace)
|
||||
.then((r) => {
|
||||
if (!pinned) {
|
||||
setMode(r.mode);
|
||||
@@ -43,7 +43,7 @@ export function useGitDiff(projectId: string | null | undefined) {
|
||||
inFlightRef.current = false;
|
||||
setLoading(false);
|
||||
});
|
||||
}, [projectId, mode, pinned]);
|
||||
}, [projectId, mode, pinned, hideWhitespace]);
|
||||
|
||||
// Re-run refresh when mode changes (user pinned a new mode).
|
||||
useEffect(() => {
|
||||
@@ -52,7 +52,7 @@ export function useGitDiff(projectId: string | null | undefined) {
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
}, [projectId, mode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [projectId, mode, hideWhitespace]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Subscribe to git_diff_refresh events (tab open, message_complete, manual).
|
||||
useEffect(() => {
|
||||
|
||||
454
apps/web/src/pages/Analytics.tsx
Normal file
454
apps/web/src/pages/Analytics.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ArrowLeft, BarChart3, Wifi, Wrench, Layers } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/api/client';
|
||||
import type {
|
||||
AnalyticsSummary,
|
||||
SessionAnalyticsRow,
|
||||
ToolCostStat,
|
||||
ContextWindowStats,
|
||||
TokenBreakdownAgg,
|
||||
} from '@/api/types';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// --- Independent section data fetcher ---
|
||||
// Each section manages its own loading/error/data state so one failure doesn't
|
||||
// block the rest of the page.
|
||||
|
||||
function useFetch<T>(fetcher: () => Promise<T>): {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
retry: () => void;
|
||||
} {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetcher()
|
||||
.then(setData)
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : 'failed to load data');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { data, loading, error, retry: load };
|
||||
}
|
||||
|
||||
// --- Skeleton pulse placeholder ---
|
||||
function SkeletonBar({ className }: { className?: string }) {
|
||||
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
|
||||
}
|
||||
|
||||
// --- Number formatting ---
|
||||
function formatNumber(n: number | null | undefined): string {
|
||||
if (n == null) return '—';
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function formatCost(n: number | null | undefined): string {
|
||||
if (n == null) return '—';
|
||||
if (n < 0.001) return `$${(n * 1000).toFixed(2)}m`;
|
||||
if (n < 0.01) return `$${n.toFixed(4)}`;
|
||||
return `$${n.toFixed(3)}`;
|
||||
}
|
||||
|
||||
function formatPct(n: number | null | undefined): string {
|
||||
if (n == null) return '—';
|
||||
return `${(n * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// --- Summary Cards ---
|
||||
function SummaryCards({ summary }: { summary: AnalyticsSummary }) {
|
||||
const cards = [
|
||||
{
|
||||
label: 'Total Input Tokens',
|
||||
value: formatNumber(summary.total_input_tokens),
|
||||
icon: BarChart3,
|
||||
color: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: 'Total Output Tokens',
|
||||
value: formatNumber(summary.total_output_tokens),
|
||||
icon: BarChart3,
|
||||
color: 'text-green-500',
|
||||
},
|
||||
{
|
||||
label: 'Total Cost',
|
||||
value: formatCost(summary.total_cost),
|
||||
icon: Wifi,
|
||||
color: 'text-amber-500',
|
||||
},
|
||||
{
|
||||
label: 'Sessions Tracked',
|
||||
value: formatNumber(summary.session_count),
|
||||
icon: Layers,
|
||||
color: 'text-purple-500',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{cards.map((c) => (
|
||||
<Card key={c.label} size="sm">
|
||||
<CardContent className="flex items-start gap-3 pt-3">
|
||||
<c.icon className={cn('size-5 shrink-0 mt-0.5', c.color)} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-lg font-semibold tabular-nums">{c.value}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{c.label}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCardsSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Card key={i} size="sm">
|
||||
<CardContent className="pt-3">
|
||||
<SkeletonBar className="h-5 w-20 mb-2" />
|
||||
<SkeletonBar className="h-3 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Section wrappers ---
|
||||
function SectionCard({
|
||||
title,
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<SkeletonBar className="h-4 w-full" />
|
||||
<SkeletonBar className="h-4 w-3/4" />
|
||||
<SkeletonBar className="h-4 w-1/2" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-destructive">{error}</span>
|
||||
<Button size="sm" variant="outline" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return <p className="text-sm text-muted-foreground py-2">{message}</p>;
|
||||
}
|
||||
|
||||
// --- Per-Session Token Table ---
|
||||
function SessionTable({ sessions }: { sessions: SessionAnalyticsRow[] }) {
|
||||
if (sessions.length === 0) {
|
||||
return <EmptyState message="No session token data available yet. Token data is collected as agent sessions run." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground text-xs uppercase tracking-wide">
|
||||
<th className="py-2 pr-4 font-medium">Session</th>
|
||||
<th className="py-2 pr-4 font-medium tabular-nums text-right">Input</th>
|
||||
<th className="py-2 pr-4 font-medium tabular-nums text-right">Output</th>
|
||||
<th className="py-2 pr-4 font-medium tabular-nums text-right">Cost</th>
|
||||
<th className="py-2 font-medium tabular-nums text-right">Last Active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => (
|
||||
<tr key={s.session_id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="py-2 pr-4 truncate max-w-[200px]" title={s.session_name}>
|
||||
{s.session_name || 'Untitled'}
|
||||
</td>
|
||||
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(s.total_input_tokens)}</td>
|
||||
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(s.total_output_tokens)}</td>
|
||||
<td className="py-2 pr-4 tabular-nums text-right">{formatCost(s.total_cost)}</td>
|
||||
<td className="py-2 tabular-nums text-right text-muted-foreground">{formatDate(s.last_active_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Per-Tool Cost Table ---
|
||||
function ToolTable({ stats }: { stats: ToolCostStat[] }) {
|
||||
if (stats.length === 0) {
|
||||
return <EmptyState message="No tool cost data available yet. Stats accumulate after tool calls are made." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground text-xs uppercase tracking-wide">
|
||||
<th className="py-2 pr-4 font-medium">Tool</th>
|
||||
<th className="py-2 pr-4 font-medium tabular-nums text-right">Calls</th>
|
||||
<th className="py-2 pr-4 font-medium tabular-nums text-right">Avg Prompt</th>
|
||||
<th className="py-2 pr-4 font-medium tabular-nums text-right">Avg Completion</th>
|
||||
<th className="py-2 font-medium tabular-nums text-right">Avg Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.map((t) => (
|
||||
<tr key={t.tool_name} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="py-2 pr-4 flex items-center gap-2">
|
||||
<Wrench className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate max-w-[200px]" title={t.tool_name}>{t.tool_name}</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 tabular-nums text-right">{t.n_calls}</td>
|
||||
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(t.mean_prompt_tokens)}</td>
|
||||
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(t.mean_completion_tokens)}</td>
|
||||
<td className="py-2 tabular-nums text-right">{formatNumber(t.mean_prompt_tokens + t.mean_completion_tokens)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Context Window Utilization ---
|
||||
function ContextSection({ stats }: { stats: ContextWindowStats }) {
|
||||
if (stats.message_count === 0) {
|
||||
return <EmptyState message="No context window data available yet. Data is captured during inference." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Avg Context Used</div>
|
||||
<div className="text-lg font-semibold tabular-nums mt-1">{formatNumber(Math.round(stats.avg_ctx_used ?? 0))}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Avg Context Limit</div>
|
||||
<div className="text-lg font-semibold tabular-nums mt-1">{formatNumber(Math.round(stats.avg_ctx_max ?? 0))}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Avg Utilization</div>
|
||||
<div className="text-lg font-semibold tabular-nums mt-1">{formatPct(stats.avg_utilization_pct)}</div>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Based on {formatNumber(stats.message_count)} completed assistant messages</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{
|
||||
width: stats.avg_utilization_pct != null
|
||||
? `${Math.min(stats.avg_utilization_pct * 100, 100)}%`
|
||||
: '0%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Token Category Breakdown (CSS stacked bar) ---
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
system: 'bg-blue-500',
|
||||
user: 'bg-green-500',
|
||||
assistant: 'bg-amber-500',
|
||||
tools: 'bg-purple-500',
|
||||
reasoning: 'bg-rose-500',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
system: 'System',
|
||||
user: 'User',
|
||||
assistant: 'Assistant',
|
||||
tools: 'Tools',
|
||||
reasoning: 'Reasoning',
|
||||
};
|
||||
|
||||
function TokenBreakdownSection({ categories }: { categories: TokenBreakdownAgg[] }) {
|
||||
if (categories.length === 0) {
|
||||
return <EmptyState message="No token breakdown data available. Breakdown is captured for arena contestants and certain task types." />;
|
||||
}
|
||||
|
||||
const total = categories.reduce((sum, c) => sum + c.total_tokens, 0);
|
||||
if (total === 0) return <EmptyState message="Token breakdown totals are zero." />;
|
||||
|
||||
// Sort in a consistent order
|
||||
const order = ['system', 'user', 'assistant', 'tools', 'reasoning'];
|
||||
const sorted = [...categories].sort(
|
||||
(a, b) => order.indexOf(a.category) - order.indexOf(b.category),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 rounded-full bg-muted overflow-hidden flex">
|
||||
{sorted.map((c) => {
|
||||
const pct = (c.total_tokens / total) * 100;
|
||||
if (pct < 1) return null;
|
||||
return (
|
||||
<div
|
||||
key={c.category}
|
||||
className={cn('h-full first:rounded-l-full last:rounded-r-full', CATEGORY_COLORS[c.category] ?? 'bg-gray-400')}
|
||||
style={{ width: `${pct}%` }}
|
||||
title={`${CATEGORY_LABELS[c.category] ?? c.category}: ${formatNumber(c.total_tokens)} (${pct.toFixed(1)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
{sorted.map((c) => {
|
||||
const pct = (c.total_tokens / total) * 100;
|
||||
return (
|
||||
<div key={c.category} className="flex items-center gap-1.5">
|
||||
<span className={cn('size-2.5 rounded-sm', CATEGORY_COLORS[c.category] ?? 'bg-gray-400')} />
|
||||
<span className="text-muted-foreground">{CATEGORY_LABELS[c.category] ?? c.category}</span>
|
||||
<span className="font-medium tabular-nums">{pct.toFixed(1)}%</span>
|
||||
<span className="text-muted-foreground tabular-nums">({formatNumber(c.total_tokens)})</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Page ---
|
||||
export function Analytics() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const summary = useFetch(() => api.analytics.summary());
|
||||
const sessions = useFetch(() => api.analytics.sessions().then((r) => r.sessions));
|
||||
const tools = useFetch(() => api.tools.costStats().then((r) => r.stats));
|
||||
const context = useFetch(() => api.analytics.context());
|
||||
const breakdown = useFetch(() => api.analytics.tokenBreakdown().then((r) => r.categories));
|
||||
|
||||
function handleBack() {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-8">
|
||||
<header className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Token Analytics</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Aggregate token usage, cost, and context window data across all sessions.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary.loading ? (
|
||||
<SummaryCardsSkeleton />
|
||||
) : summary.error ? (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-destructive">{summary.error}</span>
|
||||
<Button size="sm" variant="outline" onClick={summary.retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : summary.data ? (
|
||||
<SummaryCards summary={summary.data} />
|
||||
) : null}
|
||||
|
||||
{/* Per-Session Token Breakdown */}
|
||||
<SectionCard
|
||||
title="Per-Session Token Usage"
|
||||
loading={sessions.loading}
|
||||
error={sessions.error}
|
||||
onRetry={sessions.retry}
|
||||
>
|
||||
{sessions.data && <SessionTable sessions={sessions.data} />}
|
||||
</SectionCard>
|
||||
|
||||
{/* Per-Tool Cost Breakdown */}
|
||||
<SectionCard
|
||||
title="Per-Tool Token Cost"
|
||||
loading={tools.loading}
|
||||
error={tools.error}
|
||||
onRetry={tools.retry}
|
||||
>
|
||||
{tools.data && <ToolTable stats={tools.data} />}
|
||||
</SectionCard>
|
||||
|
||||
{/* Context Window Utilization */}
|
||||
<SectionCard
|
||||
title="Context Window Utilization"
|
||||
loading={context.loading}
|
||||
error={context.error}
|
||||
onRetry={context.retry}
|
||||
>
|
||||
{context.data && <ContextSection stats={context.data} />}
|
||||
</SectionCard>
|
||||
|
||||
{/* Token Category Breakdown */}
|
||||
<SectionCard
|
||||
title="Token Breakdown by Category"
|
||||
loading={breakdown.loading}
|
||||
error={breakdown.error}
|
||||
onRetry={breakdown.retry}
|
||||
>
|
||||
{breakdown.data && <TokenBreakdownSection categories={breakdown.data} />}
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
510
apps/web/src/pages/Results.tsx
Normal file
510
apps/web/src/pages/Results.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ArrowLeft, Beaker, CheckCircle2, FileText, ScrollText, Swords, XCircle } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/api/client';
|
||||
import type { BattleShape, FlowRunRow } from '@/api/types';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSidebar } from '@/hooks/useSidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Independent section data fetcher (same pattern as Analytics.tsx) ────────
|
||||
|
||||
function useFetch<T>(fetcher: () => Promise<T>): {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
retry: () => void;
|
||||
} {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetcher()
|
||||
.then(setData)
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : 'failed to load data');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { data, loading, error, retry: load };
|
||||
}
|
||||
|
||||
// ─── Skeleton ────────────────────────────────────────────────────────────────
|
||||
|
||||
function SkeletonBar({ className }: { className?: string }) {
|
||||
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
|
||||
}
|
||||
|
||||
// ─── Formatters ──────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(startIso: string, endIso?: string | null): string {
|
||||
const start = new Date(startIso).getTime();
|
||||
const end = endIso ? new Date(endIso).getTime() : Date.now();
|
||||
const ms = end - start;
|
||||
if (ms < 0) return '—';
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`;
|
||||
return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
|
||||
}
|
||||
|
||||
function truncate(str: string, max: number): string {
|
||||
if (str.length <= max) return str;
|
||||
return str.slice(0, max) + '…';
|
||||
}
|
||||
|
||||
// ─── Status dot (shared visual language with OrchestratorPane/ArenaPane) ──────
|
||||
|
||||
type DotStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'pending';
|
||||
|
||||
function StatusDot({ status }: { status: DotStatus }) {
|
||||
if (status === 'running') {
|
||||
return (
|
||||
<span
|
||||
aria-label="running"
|
||||
className="inline-block w-2.5 h-2.5 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const cls =
|
||||
status === 'completed'
|
||||
? 'bg-emerald-500'
|
||||
: status === 'failed'
|
||||
? 'bg-destructive'
|
||||
: status === 'cancelled'
|
||||
? 'bg-muted-foreground/20'
|
||||
: 'bg-muted-foreground/40'; // pending
|
||||
return <span aria-label={status} className={cn('inline-block w-2 h-2 rounded-full shrink-0', cls)} />;
|
||||
}
|
||||
|
||||
// ─── Tab bar ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type TabId = 'runs' | 'battles';
|
||||
|
||||
function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => void }) {
|
||||
return (
|
||||
<div className="flex gap-1 border-b pb-px">
|
||||
{[
|
||||
{ id: 'runs' as TabId, label: 'Analysis Runs', icon: FileText },
|
||||
{ id: 'battles' as TabId, label: 'Arena Battles', icon: Swords },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-md border border-b-0 -mb-px transition-colors',
|
||||
active === tab.id
|
||||
? 'bg-background border-border text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<tab.icon className="size-3.5" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Empty state ─────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return <p className="text-sm text-muted-foreground py-8 text-center">{message}</p>;
|
||||
}
|
||||
|
||||
// ─── Project selector ────────────────────────────────────────────────────────
|
||||
|
||||
function ProjectSelector({
|
||||
projects,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
projects: Array<{ id: string; name: string }>;
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="text-sm bg-muted/30 border border-border rounded px-2 py-1 text-foreground"
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Analysis Runs tab ───────────────────────────────────────────────────────
|
||||
|
||||
function AnalysisRunsTab({ projectId }: { projectId: string }) {
|
||||
const { data, loading, error, retry } = useFetch(() => api.runs.list(projectId).then((r) => r.runs));
|
||||
|
||||
const [selectedRun, setSelectedRun] = useState<FlowRunRow | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2 pt-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<SkeletonBar key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm pt-4">
|
||||
<span className="text-destructive">{error}</span>
|
||||
<Button size="sm" variant="outline" onClick={retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <EmptyState message="No analysis runs yet. Start one from the Workflow button in any chat." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-2">
|
||||
{data.map((run) => (
|
||||
<div key={run.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedRun(selectedRun?.id === run.id ? null : run)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm text-left transition-colors hover:bg-muted/30',
|
||||
selectedRun?.id === run.id && 'bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<StatusDot status={run.status as DotStatus} />
|
||||
<span className="font-medium min-w-0 flex-1 truncate">
|
||||
{run.flow_name}
|
||||
<span className="text-muted-foreground font-normal ml-1.5 text-xs uppercase">
|
||||
{run.band}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
|
||||
{run.model ? run.model.split('/').pop() : '—'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||
{formatDuration(run.created_at, run.updated_at)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDate(run.created_at)}
|
||||
</span>
|
||||
{run.error && (
|
||||
<span className="text-destructive" title={run.error}>
|
||||
<XCircle className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
{run.status === 'completed' && run.report && (
|
||||
<FileText className="size-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded detail — report preview */}
|
||||
{selectedRun?.id === run.id && run.status === 'completed' && run.report && (
|
||||
<div className="ml-8 mr-2 mb-2 p-3 rounded-md bg-muted/20 border border-border/50 text-xs leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap font-mono">
|
||||
{truncate(run.report, 3000)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Arena Battles tab ───────────────────────────────────────────────────────
|
||||
|
||||
function ArenaBattlesTab({ projectId }: { projectId: string }) {
|
||||
const { data, loading, error, retry } = useFetch(() => api.battles.list(projectId).then((r) => r.battles));
|
||||
|
||||
const [selectedBattle, setSelectedBattle] = useState<BattleShape | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2 pt-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<SkeletonBar key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm pt-4">
|
||||
<span className="text-destructive">{error}</span>
|
||||
<Button size="sm" variant="outline" onClick={retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <EmptyState message="No arena battles yet. Start one from the Arena button in any chat." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-2">
|
||||
{data.map((battle) => {
|
||||
const hasAnalysis = battle.status === 'completed' && battle.results_path;
|
||||
return (
|
||||
<div key={battle.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedBattle(selectedBattle?.id === battle.id ? null : battle)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm text-left transition-colors hover:bg-muted/30',
|
||||
selectedBattle?.id === battle.id && 'bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<StatusDot status={
|
||||
battle.status === 'completed' ? 'completed'
|
||||
: battle.status === 'failed' ? 'failed'
|
||||
: battle.status === 'cancelled' ? 'cancelled'
|
||||
: 'running'
|
||||
} />
|
||||
<span className="font-medium min-w-0 flex-1 truncate">
|
||||
{battle.battle_type === 'coding' ? 'Coding Battle' : 'Q&A Battle'}
|
||||
<span className="text-muted-foreground font-normal ml-1.5 text-xs">
|
||||
{truncate(battle.prompt, 60)}
|
||||
</span>
|
||||
</span>
|
||||
{battle.winner_contestant_id && (
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||
<CheckCircle2 className="size-3" />
|
||||
Winner
|
||||
</span>
|
||||
)}
|
||||
{battle.error && (
|
||||
<span className="text-destructive" title={battle.error}>
|
||||
<XCircle className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap hidden sm:block">
|
||||
{formatDate(battle.created_at)}
|
||||
</span>
|
||||
{hasAnalysis && (
|
||||
<Beaker className="size-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded detail — analysis preview */}
|
||||
{selectedBattle?.id === battle.id && hasAnalysis && (
|
||||
<div className="ml-8 mr-2 mb-2">
|
||||
<AnalysisPreview battleId={battle.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Battle analysis preview (fetches analysis.md on expand) ─────────────────
|
||||
|
||||
function AnalysisPreview({ battleId }: { battleId: string }) {
|
||||
const { data, loading, error, retry } = useFetch(() => api.battles.getAnalysis(battleId).then((r) => r.text));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2 p-3 rounded-md bg-muted/20 border border-border/50">
|
||||
<SkeletonBar className="h-3 w-full" />
|
||||
<SkeletonBar className="h-3 w-3/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 rounded-md bg-muted/20 border border-border/50 text-xs">
|
||||
<span className="text-destructive">{error}</span>
|
||||
<Button size="sm" variant="outline" onClick={retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded-md bg-muted/20 border border-border/50 text-xs leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap font-mono">
|
||||
{data ? truncate(data, 3000) : 'No analysis available.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Summary strip ───────────────────────────────────────────────────────────
|
||||
|
||||
function SummaryCards({
|
||||
runs,
|
||||
battles,
|
||||
}: {
|
||||
runs: FlowRunRow[] | null;
|
||||
battles: BattleShape[] | null;
|
||||
}) {
|
||||
const totalRuns = runs?.length ?? 0;
|
||||
const completedRuns = runs?.filter((r) => r.status === 'completed').length ?? 0;
|
||||
const totalBattles = battles?.length ?? 0;
|
||||
const completedBattles = battles?.filter((b) => b.status === 'completed').length ?? 0;
|
||||
|
||||
const cards = [
|
||||
{ label: 'Total Runs', value: totalRuns, icon: FileText, color: 'text-blue-500' },
|
||||
{ label: 'Completed Runs', value: completedRuns, icon: CheckCircle2, color: 'text-emerald-500' },
|
||||
{ label: 'Total Battles', value: totalBattles, icon: Swords, color: 'text-violet-500' },
|
||||
{ label: 'Completed Battles', value: completedBattles, icon: CheckCircle2, color: 'text-emerald-500' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{cards.map((c) => (
|
||||
<Card key={c.label} size="sm">
|
||||
<CardContent className="flex items-start gap-3 pt-3">
|
||||
<c.icon className={cn('size-4 shrink-0 mt-0.5', c.color)} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-lg font-semibold tabular-nums">{c.value}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{c.label}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCardsSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Card key={i} size="sm">
|
||||
<CardContent className="pt-3">
|
||||
<SkeletonBar className="h-5 w-16 mb-2" />
|
||||
<SkeletonBar className="h-3 w-20" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function Results() {
|
||||
const navigate = useNavigate();
|
||||
const { data: sidebar, activeSession } = useSidebar();
|
||||
|
||||
const [tab, setTab] = useState<TabId>('runs');
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
|
||||
// Derive default project from active session or first project.
|
||||
const projects = useMemo(() => {
|
||||
return sidebar?.projects?.map((p: { id: string; name: string }) => p) ?? [];
|
||||
}, [sidebar]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId && projects.length > 0) {
|
||||
// Prefer active session's project, else first project.
|
||||
const defaultId = activeSession?.project_id ?? projects[0]!.id;
|
||||
setProjectId(defaultId);
|
||||
}
|
||||
}, [projects, activeSession, projectId]);
|
||||
|
||||
function handleBack() {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
|
||||
const runsFetch = useFetch(
|
||||
projectId ? () => api.runs.list(projectId).then((r) => r.runs) : () => Promise.resolve([] as FlowRunRow[]),
|
||||
);
|
||||
const battlesFetch = useFetch(
|
||||
projectId ? () => api.battles.list(projectId).then((r) => r.battles) : () => Promise.resolve([] as BattleShape[]),
|
||||
);
|
||||
|
||||
const summaryLoading = runsFetch.loading && battlesFetch.loading;
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-6">
|
||||
{/* Header */}
|
||||
<header className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<ScrollText className="size-5" />
|
||||
Results
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Completed orchestrator runs and arena battles.
|
||||
</p>
|
||||
</div>
|
||||
{projects.length > 0 && projectId && (
|
||||
<ProjectSelector
|
||||
projects={projects}
|
||||
value={projectId}
|
||||
onChange={setProjectId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summaryLoading ? (
|
||||
<SummaryCardsSkeleton />
|
||||
) : (
|
||||
<SummaryCards runs={runsFetch.data} battles={battlesFetch.data} />
|
||||
)}
|
||||
|
||||
{/* Tab bar */}
|
||||
<TabBar active={tab} onChange={setTab} />
|
||||
|
||||
{/* Tab content */}
|
||||
{!projectId ? (
|
||||
<EmptyState message="Select a project to view results." />
|
||||
) : tab === 'runs' ? (
|
||||
<AnalysisRunsTab projectId={projectId} />
|
||||
) : (
|
||||
<ArenaBattlesTab projectId={projectId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
apps/web/src/stores/useDiffCommentStore.ts
Normal file
92
apps/web/src/stores/useDiffCommentStore.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export interface DiffComment {
|
||||
id: string;
|
||||
body: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface DiffCommentTarget {
|
||||
filePath: string;
|
||||
side: 'old' | 'new';
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
function loadFromStorage(key: string): Map<string, DiffComment[]> {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return new Map();
|
||||
const parsed = JSON.parse(raw);
|
||||
return new Map(Object.entries(parsed));
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage(key: string, map: Map<string, DiffComment[]>) {
|
||||
const obj: Record<string, DiffComment[]> = {};
|
||||
for (const [k, v] of map) obj[k] = v;
|
||||
localStorage.setItem(key, JSON.stringify(obj));
|
||||
}
|
||||
|
||||
export function useDiffComments(sessionId: string, mode: string) {
|
||||
const storageKey = `boocode.diff.comments.${sessionId}.${mode}`;
|
||||
const [comments, setComments] = useState<Map<string, DiffComment[]>>(() =>
|
||||
loadFromStorage(storageKey)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
saveToStorage(storageKey, comments);
|
||||
}, [storageKey, comments]);
|
||||
|
||||
const addComment = useCallback(
|
||||
(key: string, comment: DiffComment) => {
|
||||
setComments((prev) => {
|
||||
const next = new Map(prev);
|
||||
const list = next.get(key) ?? [];
|
||||
next.set(key, [...list, comment]);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const updateComment = useCallback(
|
||||
(key: string, id: string, body: string) => {
|
||||
setComments((prev) => {
|
||||
const next = new Map(prev);
|
||||
const list = next.get(key);
|
||||
if (!list) return prev;
|
||||
next.set(
|
||||
key,
|
||||
list.map((c) =>
|
||||
c.id === id ? { ...c, body, updatedAt: Date.now() } : c
|
||||
)
|
||||
);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const deleteComment = useCallback(
|
||||
(key: string, id: string) => {
|
||||
setComments((prev) => {
|
||||
const next = new Map(prev);
|
||||
const list = next.get(key);
|
||||
if (!list) return prev;
|
||||
const filtered = list.filter((c) => c.id !== id);
|
||||
if (filtered.length === 0) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, filtered);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { comments, addComment, updateComment, deleteComment };
|
||||
}
|
||||
284
apps/web/src/utils/diff-layout.ts
Normal file
284
apps/web/src/utils/diff-layout.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Pure utilities for parsing unified diff text and building display structures
|
||||
* for both unified and side-by-side (split) diff views.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DiffLineType = 'add' | 'remove' | 'context' | 'header';
|
||||
|
||||
export interface DiffLine {
|
||||
type: DiffLineType;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
oldStart: number;
|
||||
oldCount: number;
|
||||
newStart: number;
|
||||
newCount: number;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
export interface ParsedDiffFile {
|
||||
path: string;
|
||||
hunks: DiffHunk[];
|
||||
}
|
||||
|
||||
/** A single cell in the split (side-by-side) view */
|
||||
export interface SplitDisplayLine {
|
||||
type: DiffLineType;
|
||||
content: string;
|
||||
lineNumber: number | null;
|
||||
}
|
||||
|
||||
/** A row in the split view — either a hunk header or a left/right pair */
|
||||
export type SplitRow =
|
||||
| { kind: 'header'; content: string }
|
||||
| { kind: 'pair'; left: SplitDisplayLine | null; right: SplitDisplayLine | null };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseDiff
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse unified diff text into an array of ParsedDiffFile objects.
|
||||
*
|
||||
* Splits on `diff --git` headers, extracts file paths from `+++ b/<path>`
|
||||
* (falling back to `--- a/<path>`), and classifies each line within hunks.
|
||||
*/
|
||||
export function parseDiff(diffBody: string): ParsedDiffFile[] {
|
||||
if (!diffBody || diffBody.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: ParsedDiffFile[] = [];
|
||||
const sections = diffBody.split(/^diff --git /m).filter(Boolean);
|
||||
|
||||
for (const section of sections) {
|
||||
const lines = section.split('\n');
|
||||
const path = extractPath(lines);
|
||||
const hunks = parseSectionBody(lines);
|
||||
files.push({ path, hunks });
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildSplitRows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build side-by-side (split) display rows from a parsed diff file.
|
||||
*
|
||||
* For each hunk:
|
||||
* - Emits a header row (`@@ -... +... @@`)
|
||||
* - Buffers consecutive removals and additions
|
||||
* - On a context line (or hunk end), flushes buffered removals/additions as
|
||||
* paired rows (left = removal or null, right = addition or null)
|
||||
* - Context lines become paired rows with identical content on both sides
|
||||
*/
|
||||
export function buildSplitRows(file: ParsedDiffFile): SplitRow[] {
|
||||
const rows: SplitRow[] = [];
|
||||
|
||||
for (const hunk of file.hunks) {
|
||||
// Header row
|
||||
const headerLine = hunk.lines.find((l) => l.type === 'header');
|
||||
rows.push({ kind: 'header', content: headerLine?.content ?? '@@' });
|
||||
|
||||
let oldLineNo = hunk.oldStart;
|
||||
let newLineNo = hunk.newStart;
|
||||
|
||||
let pendingRemovals: SplitDisplayLine[] = [];
|
||||
let pendingAdditions: SplitDisplayLine[] = [];
|
||||
|
||||
const flushPending = (): void => {
|
||||
const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length);
|
||||
for (let i = 0; i < pairCount; i++) {
|
||||
rows.push({
|
||||
kind: 'pair',
|
||||
left: pendingRemovals[i] ?? null,
|
||||
right: pendingAdditions[i] ?? null,
|
||||
});
|
||||
}
|
||||
pendingRemovals = [];
|
||||
pendingAdditions = [];
|
||||
};
|
||||
|
||||
for (const line of hunk.lines) {
|
||||
if (line.type === 'header') continue;
|
||||
|
||||
if (line.type === 'remove') {
|
||||
pendingRemovals.push({
|
||||
type: 'remove',
|
||||
content: line.content,
|
||||
lineNumber: oldLineNo++,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.type === 'add') {
|
||||
pendingAdditions.push({
|
||||
type: 'add',
|
||||
content: line.content,
|
||||
lineNumber: newLineNo++,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Context line — flush any pending changes first
|
||||
flushPending();
|
||||
|
||||
rows.push({
|
||||
kind: 'pair',
|
||||
left: {
|
||||
type: 'context',
|
||||
content: line.content,
|
||||
lineNumber: oldLineNo++,
|
||||
},
|
||||
right: {
|
||||
type: 'context',
|
||||
content: line.content,
|
||||
lineNumber: newLineNo++,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Flush any trailing removals/additions at hunk end
|
||||
flushPending();
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reconstructNewContent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reconstruct the "new" file content from diff hunks by concatenating
|
||||
* addition and context lines. Useful for syntax-highlighting the split
|
||||
* view's right column.
|
||||
*/
|
||||
export function reconstructNewContent(hunks: DiffHunk[]): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const hunk of hunks) {
|
||||
for (const line of hunk.lines) {
|
||||
if (line.type === 'add' || line.type === 'context') {
|
||||
lines.push(line.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Extract file path from `+++ b/<path>` or `--- a/<path>` metadata lines. */
|
||||
function extractPath(lines: string[]): string {
|
||||
// Try +++ b/<path> first (most reliable for the "new" side)
|
||||
const newLine = lines.find((l) => l.startsWith('+++ '));
|
||||
if (newLine) {
|
||||
const raw = newLine.slice(4).replace(/\t.*$/, '').trimEnd();
|
||||
if (raw !== '/dev/null') {
|
||||
return stripPrefix(raw);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to --- a/<path>
|
||||
const oldLine = lines.find((l) => l.startsWith('--- '));
|
||||
if (oldLine) {
|
||||
const raw = oldLine.slice(4).replace(/\t.*$/, '').trimEnd();
|
||||
if (raw !== '/dev/null') {
|
||||
return stripPrefix(raw);
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: parse the first line (e.g. "a/path b/path")
|
||||
const firstLine = lines[0] ?? '';
|
||||
const match = firstLine.match(/^a\/(.+)\s+b\/(.+)$/);
|
||||
if (match) return match[2]!;
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Strip the `a/` or `b/` prefix that git adds to diff paths. */
|
||||
function stripPrefix(path: string): string {
|
||||
if (path.startsWith('b/') || path.startsWith('a/')) {
|
||||
return path.slice(2);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Parse hunk headers and line content from a diff section body. */
|
||||
function parseSectionBody(lines: string[]): DiffHunk[] {
|
||||
const hunks: DiffHunk[] = [];
|
||||
let currentHunk: DiffHunk | null = null;
|
||||
|
||||
// Start at index 1 to skip the first line (the "a/path b/path" header from
|
||||
// the `diff --git` split)
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
|
||||
if (isMetadataLine(line)) continue;
|
||||
|
||||
const newHunk = parseHunkHeader(line);
|
||||
if (newHunk) {
|
||||
if (currentHunk) hunks.push(currentHunk);
|
||||
currentHunk = newHunk;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentHunk) continue;
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
currentHunk.lines.push({ type: 'add', content: line.slice(1) });
|
||||
} else if (line.startsWith('-')) {
|
||||
currentHunk.lines.push({ type: 'remove', content: line.slice(1) });
|
||||
} else if (line.startsWith(' ')) {
|
||||
currentHunk.lines.push({ type: 'context', content: line.slice(1) });
|
||||
} else if (line.length > 0 && !line.startsWith('\\')) {
|
||||
currentHunk.lines.push({ type: 'context', content: line });
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk) hunks.push(currentHunk);
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/** Parse a `@@ -oldStart,oldCount +newStart,newCount @@` header line. */
|
||||
function parseHunkHeader(line: string): DiffHunk | null {
|
||||
const match = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
oldStart: parseInt(match[1]!, 10),
|
||||
oldCount: parseInt(match[2] ?? '1', 10),
|
||||
newStart: parseInt(match[3]!, 10),
|
||||
newCount: parseInt(match[4] ?? '1', 10),
|
||||
lines: [
|
||||
{
|
||||
type: 'header',
|
||||
content: line.match(/^(@@ .+? @@)/)?.[1] ?? line,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Check if a line is diff metadata (not content). */
|
||||
function isMetadataLine(line: string): boolean {
|
||||
return (
|
||||
line.startsWith('index ') ||
|
||||
line.startsWith('--- ') ||
|
||||
line.startsWith('+++ ') ||
|
||||
line.startsWith('new file mode') ||
|
||||
line.startsWith('deleted file mode')
|
||||
);
|
||||
}
|
||||
@@ -17,18 +17,22 @@ COPY go.mod ./
|
||||
COPY shim.go ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /build/shim-bin ./
|
||||
|
||||
# Stage 2: boocontext MCP builder
|
||||
# Stage 2: boocontext MCP builder (pnpm project)
|
||||
FROM node:20-alpine AS boocontext-builder
|
||||
WORKDIR /build/boocontext
|
||||
RUN apk add --no-cache git python3 make g++ ca-certificates
|
||||
RUN npm install -g pnpm@9 --silent
|
||||
COPY fork.tar.gz /build/fork.tar.gz
|
||||
RUN mkdir -p /build/boocontext && tar -xzf /build/fork.tar.gz -C /build/boocontext
|
||||
WORKDIR /build/boocontext
|
||||
RUN npm ci && npm run build
|
||||
RUN pnpm install --frozen-lockfile && pnpm run build
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates nodejs uv
|
||||
# uv intentionally not installed — container network blocks astral.sh.
|
||||
# tree-sitter-analyzer child server (uvx) won't start in-container, but
|
||||
# boocontext logs a graceful warning; TSA-backed tools fall through.
|
||||
RUN apk add --no-cache ca-certificates nodejs
|
||||
COPY --from=shim-builder /build/shim-bin /usr/local/bin/shim
|
||||
COPY --from=boocontext-builder /build/boocontext/dist /usr/local/lib/boocontext/dist
|
||||
COPY --from=boocontext-builder /build/boocontext/node_modules /usr/local/lib/boocontext/node_modules
|
||||
|
||||
90
codecontext/openspec/codesight-merge.md
Normal file
90
codecontext/openspec/codesight-merge.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# codecontext — codesight feature merge
|
||||
|
||||
Port codesight's highest-value analysis capabilities into codecontext as 4 new MCP tools. All work in `/opt/forks/codecontext` (Go). BooCode wrapper tools in a follow-up batch.
|
||||
|
||||
## New tools
|
||||
|
||||
### 1. `get_blast_radius` (Tier 1)
|
||||
|
||||
**Input:** `file_path` (required), `target_dir` (optional)
|
||||
**Output:** markdown listing all files, routes, and symbols that depend (transitively) on the given file.
|
||||
|
||||
Algorithm: build a reverse adjacency map from `s.graph.Edges` (filter by `type == "imports"`), then BFS outward from the target file's node. Report each affected file with its symbol count and distance from the source.
|
||||
|
||||
Codesight reference: `detectors/blast-radius.ts` (128 lines). The Go port is simpler — codecontext already has the edge graph; codesight had to build its own.
|
||||
|
||||
~50 lines of Go (handler + BFS).
|
||||
|
||||
### 2. `get_hot_files` (Tier 1)
|
||||
|
||||
**Input:** `target_dir` (optional), `limit` (optional, default 20)
|
||||
**Output:** ranked list of most-imported files with import count.
|
||||
|
||||
Algorithm: count incoming `"imports"` edges per file node. Sort descending. Return top N.
|
||||
|
||||
Codesight reference: `detectors/graph.ts` hot-files metric. codecontext's `identifyHotspotFiles()` at `relationships.go:286` already computes this — the tool just needs to expose it.
|
||||
|
||||
~30 lines of Go (handler + sort).
|
||||
|
||||
### 3. `get_routes` (Tier 2)
|
||||
|
||||
**Input:** `target_dir` (optional), `framework` (optional filter — "fastify", "express", etc.)
|
||||
**Output:** structured list of HTTP routes with method, path, file, line number, middleware, tags.
|
||||
|
||||
Algorithm: for each TypeScript/JavaScript file in the graph, re-parse the AST via `gb.parser.ParseFile()` and walk the tree for call expressions matching framework-specific patterns:
|
||||
|
||||
**Fastify patterns** (primary — Sam's stack):
|
||||
- `app.get('/path', handler)` / `app.post(...)` / etc.
|
||||
- `app.route({ method: 'GET', url: '/path', handler })` (object form)
|
||||
- `app.register(plugin)` (plugin registration — note but don't trace into)
|
||||
|
||||
**Express patterns** (secondary — common in analyzed projects):
|
||||
- `router.get('/path', ...middleware, handler)`
|
||||
- `app.use('/prefix', router)`
|
||||
|
||||
Tag inference: scan handler body for common patterns (SQL queries → `db` tag, auth checks → `auth` tag, cache reads → `cache` tag). Simplified version of codesight's 30-framework tagger — only Fastify + Express for now.
|
||||
|
||||
Codesight reference: `detectors/routes.ts` (1969 lines) + `ast/extract-routes.ts` (14690 lines). The Go port is ~200 lines targeting only 2 frameworks.
|
||||
|
||||
### 4. `get_middleware` (Tier 2)
|
||||
|
||||
**Input:** `target_dir` (optional)
|
||||
**Output:** list of detected middleware with type (auth, cors, rate-limit, validation, error-handler, logging), file, line.
|
||||
|
||||
Algorithm: for each file, scan for common middleware registration patterns:
|
||||
- `app.register(fastifyCors, ...)` → CORS
|
||||
- `app.addHook('preHandler', authCheck)` → auth
|
||||
- `app.setErrorHandler(...)` → error-handler
|
||||
- Import-name heuristics: `@fastify/cors` → CORS, `@fastify/rate-limit` → rate-limit
|
||||
|
||||
Codesight reference: `detectors/middleware.ts` (217 lines). Go port: ~80 lines, Fastify-focused.
|
||||
|
||||
## Architecture
|
||||
|
||||
All 4 tools register in `internal/mcp/server.go:registerTools()` following the existing pattern (`mcp.AddTool`).
|
||||
|
||||
Tools 1-2 (blast radius, hot files) operate on the existing `CodeGraph` — no re-parsing needed. They read `s.graph.Edges` and `s.graph.Files` under `s.graphMu.RLock()`.
|
||||
|
||||
Tools 3-4 (routes, middleware) need AST access. The current pipeline discards ASTs after symbol extraction. Two options:
|
||||
- **(a) Re-parse on demand:** when `get_routes` is called, iterate TypeScript files in `s.graph.Files`, call `s.analyzer.parser.ParseFile()` for each, walk the AST. Slower but no structural change.
|
||||
- **(b) Cache route/middleware data during analysis:** modify `processFile()` in `graph_analysis.go` to extract routes alongside symbols, store in a new `FileNode.Routes` field. Faster on repeated calls but requires graph-builder changes.
|
||||
|
||||
**Recommendation: (a) for this batch.** Re-parse is acceptable because route extraction runs on human timescale (one tool call, not per-token), and most projects have <50 route files. Optimize to (b) later if needed.
|
||||
|
||||
New Go files:
|
||||
- `internal/mcp/blast_radius.go` — handler + BFS
|
||||
- `internal/mcp/hot_files.go` — handler + sort
|
||||
- `internal/mcp/routes.go` — handler + AST route extraction for Fastify + Express
|
||||
- `internal/mcp/middleware.go` — handler + middleware pattern detection
|
||||
|
||||
## Hard rules
|
||||
|
||||
- Go code. Tree-sitter for AST parsing (already in the project).
|
||||
- No new Go deps (tree-sitter + MCP SDK already present).
|
||||
- `go build ./...` clean. `go test ./...` passing.
|
||||
- Test coverage: at least one test per new tool exercising the happy path.
|
||||
- Don't modify existing tool behavior.
|
||||
|
||||
## Estimate
|
||||
|
||||
~400 lines of Go across 4 new files + registration in server.go. Blast radius and hot files are trivial (graph queries). Routes and middleware are the bulk (AST walking + pattern matching).
|
||||
@@ -17,7 +17,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output]
|
||||
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
||||
---
|
||||
You review code. Find real problems, not style nits.
|
||||
@@ -56,7 +56,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [ask_user_input, find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
|
||||
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
||||
---
|
||||
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
||||
@@ -82,7 +82,7 @@ top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
steps: 5
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
|
||||
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
||||
---
|
||||
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
||||
@@ -125,7 +125,7 @@ top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 1.5
|
||||
steps: 20
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes, web_fetch, web_search]
|
||||
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
||||
---
|
||||
You design. You produce build plans, not code.
|
||||
@@ -167,7 +167,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output]
|
||||
description: Audits code for security vulnerabilities. Read-only.
|
||||
---
|
||||
You audit for security issues. Concrete findings only, no generic warnings.
|
||||
@@ -212,7 +212,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [view_file, list_dir, grep, find_files]
|
||||
tools: [find_files, get_codebase_overview, grep, list_dir, view_file]
|
||||
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
|
||||
---
|
||||
You write prompts that another coding agent will execute. Your output is the prompt, not the work.
|
||||
@@ -250,7 +250,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
|
||||
description: Discovers and maps unfamiliar codebases. Reads architecture, traces data flow, identifies key symbols.
|
||||
---
|
||||
You map codebases. Start broad, then drill into specifics.
|
||||
@@ -278,7 +278,7 @@ top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
steps: 10
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [ask_user_input, find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, watch_changes]
|
||||
description: Produces actionable step plans from requirements. Read-only — never modifies files.
|
||||
---
|
||||
You produce actionable step plans. You do not modify files.
|
||||
|
||||
@@ -8,16 +8,11 @@
|
||||
},
|
||||
"enabled": false
|
||||
},
|
||||
"boocontext": {
|
||||
"type-inject": {
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": ["/opt/forks/boocontext/dist/index.js"],
|
||||
"env": {
|
||||
"TYPE_INJECT_MCP_PATH": "/opt/forks/type-inject/packages/mcp/dist/index.js",
|
||||
"TREE_SITTER_MCP_CMD": "uvx",
|
||||
"TREE_SITTER_MCP_ARGS": "--from tree-sitter-analyzer[mcp] tree-sitter-analyzer-mcp"
|
||||
},
|
||||
"enabled": false
|
||||
"command": "npx",
|
||||
"args": ["-y", "@nick-vi/type-inject-mcp"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
104
data/skills/boocode/audit-end/SKILL.md
Normal file
104
data/skills/boocode/audit-end/SKILL.md
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: audit-end
|
||||
description: End an audit session with integrity checks and summary. Use when the user says "/end", "done", "pause", or when the current task is complete.
|
||||
---
|
||||
|
||||
# /end — Audit Session End + Integrity Check
|
||||
|
||||
## Trigger
|
||||
|
||||
```
|
||||
/end
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Determine current session
|
||||
|
||||
Read `.boo/runs/.current_session` for session_id.
|
||||
|
||||
If missing:
|
||||
- Check for `auto_` sessions (hook-created)
|
||||
- If none, report "No active session"
|
||||
|
||||
### 2. Collect audit data
|
||||
|
||||
Sources:
|
||||
- `.boo/runs/audit_buffer.jsonl` — hook-recorded Write/Edit/Bash ops
|
||||
- `.boo/runs/audit_pending.jsonl` — agent [AUDIT] blocks
|
||||
- `.boo/runs/{session_id}/audit_trail.jsonl` — previously flushed records
|
||||
|
||||
Steps:
|
||||
1. Read buffer + pending remaining data
|
||||
2. Append to `audit_trail.jsonl`
|
||||
3. Clear buffer + pending files
|
||||
|
||||
### 3. Extract user corrections
|
||||
|
||||
Scan audit_trail for `user_correction` records:
|
||||
|
||||
```json
|
||||
{
|
||||
"record_type": "conversation",
|
||||
"action_type": "user_correction",
|
||||
"priority": "critical_for_recovery",
|
||||
"timestamp": "<ISO 8601>",
|
||||
"original_claim": "<what agent said>",
|
||||
"correction": "<what user corrected>",
|
||||
"principle_extracted": "<general principle>",
|
||||
"persisted_to": ["CLAUDE.md", ".boo/guidelines/..."]
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Integrity checks
|
||||
|
||||
| Check | Condition | Fail |
|
||||
|-------|-----------|------|
|
||||
| Has records | audit_trail lines > 0 | ⚠️ "Zero audit records" |
|
||||
| Files covered | Write/Edit entries exist for modified files | ⚠️ List uncovered files |
|
||||
| Corrections persisted | persisted_to is non-empty for each correction | ⚠️ Remind to persist |
|
||||
|
||||
Output:
|
||||
```
|
||||
=== Session Audit Check ===
|
||||
Session: <id>
|
||||
Task: <task>
|
||||
Duration: <start → end>
|
||||
|
||||
[✅] Records: N
|
||||
[⚠️] Files not in audit: <list>
|
||||
[✅] Corrections persisted: M
|
||||
```
|
||||
|
||||
### 5. Generate session summary
|
||||
|
||||
Write `.boo/runs/{session_id}/session_summary.md`:
|
||||
|
||||
```
|
||||
# Session Summary | <id>
|
||||
## Task: <description>
|
||||
## Time: <start → end>
|
||||
## Status: completed
|
||||
|
||||
## Completed
|
||||
- <action list>
|
||||
|
||||
## User Corrections
|
||||
- <correction records>
|
||||
|
||||
## Stats
|
||||
- Records: N
|
||||
- Corrections: M
|
||||
```
|
||||
|
||||
### 6. Update state
|
||||
|
||||
- Set `session.json status = "completed", end_time = now()`
|
||||
- Update `index.json` entry
|
||||
- Clear `.current_session`
|
||||
|
||||
## Notes
|
||||
|
||||
- Save even if checks find problems — recording > perfection
|
||||
- ⚠️ = don't block save; ❌ = warn user, still save
|
||||
- /end itself may trigger one more Stop hook flush — normal
|
||||
84
data/skills/boocode/audit-recover/SKILL.md
Normal file
84
data/skills/boocode/audit-recover/SKILL.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: audit-recover
|
||||
description: Restore lost context from audit trail. Use when unsure of prior decisions, can't remember what was discussed, or the user says "/recover". Do not guess — check the records.
|
||||
---
|
||||
|
||||
# /recover — Context Recovery
|
||||
|
||||
## Trigger
|
||||
|
||||
```
|
||||
/recover # L0+L1+L2 (current session)
|
||||
/recover full # L3 (full trail)
|
||||
/recover {session_id} # specific session
|
||||
```
|
||||
|
||||
## Core principle
|
||||
|
||||
**When uncertain, check the audit trail. Do not work from memory.**
|
||||
Recovering from records is the only reliable way to avoid repeating corrected mistakes.
|
||||
|
||||
## When to trigger
|
||||
|
||||
| Signal | What to do |
|
||||
|--------|-----------|
|
||||
| Can't recall session details | Run /recover |
|
||||
| Unsure about current task | Run /recover |
|
||||
| About to propose something possibly corrected | Run /recover, check corrections |
|
||||
| Answer is vague, missing specifics | Run /recover full |
|
||||
|
||||
## Steps
|
||||
|
||||
### Graded loading
|
||||
|
||||
**Level 0 — Index (~200t)**
|
||||
|
||||
Read `.boo/runs/index.json` → last 5 entries (id, task, status)
|
||||
|
||||
**Level 1 — Task state (~500t)**
|
||||
|
||||
Read `.current_session` → session_id
|
||||
Read `session.json` → task, start_time
|
||||
Read last 3 `audit_trail.jsonl` entries → "where am I"
|
||||
|
||||
**Level 2 — User corrections (~1000t) ⚠️ HIGHEST PRIORITY**
|
||||
|
||||
Scan all audit_trail files for `user_correction` records
|
||||
Scan for `conclusion` entries
|
||||
Read latest daily report §4 (anomalies) + §6 (backlog)
|
||||
|
||||
**Level 3 — Full context (~3000t, /recover full only)**
|
||||
|
||||
Full `audit_trail.jsonl`
|
||||
Full `audit_pending.jsonl`
|
||||
|
||||
### Output
|
||||
|
||||
```
|
||||
=== Context Recovery Report ===
|
||||
Source: .boo/runs/<session_id>/
|
||||
Level: L2
|
||||
|
||||
Task: <session.task>
|
||||
Status: <last action>
|
||||
|
||||
⚠️ User corrections (must follow):
|
||||
1. <timestamp> Original: "..."
|
||||
Correction: "..."
|
||||
Principle: <principle>
|
||||
|
||||
Key conclusions:
|
||||
- <...>
|
||||
|
||||
Open issues:
|
||||
- <...>
|
||||
|
||||
⚠️ Recovered from audit trail, not memory.
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Corrections have highest priority — don't contradict them
|
||||
- If current plan contradicts corrections, correct the plan
|
||||
- Keep output concise — don't copy entire trail into context
|
||||
- Recover "why" and "don't" before "what was done"
|
||||
100
data/skills/boocode/audit-report-daily/SKILL.md
Normal file
100
data/skills/boocode/audit-report-daily/SKILL.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: audit-report-daily
|
||||
description: Generate a daily work report from audit data. Every number traces to a source file. Use when user says "/report-daily", "daily report", "what did I do today".
|
||||
---
|
||||
|
||||
# /report-daily — Audit-Driven Daily Report
|
||||
|
||||
## Trigger
|
||||
|
||||
```
|
||||
/report-daily # today
|
||||
/report-daily 20260319 # specific date
|
||||
/report-daily review # with morning self-review
|
||||
```
|
||||
|
||||
## Data Sources
|
||||
|
||||
| Section | Source |
|
||||
|---------|--------|
|
||||
| Task overview | `.boo/runs/index.json` |
|
||||
| Operation stats | `*/audit_trail.jsonl` tool records |
|
||||
| Changes | trail entries with edit/create/delete |
|
||||
| User feedback | `user_correction` entries in trail |
|
||||
| Anomalies | `*/anomalies.json` |
|
||||
| Backlog | previous day's daily report §6 |
|
||||
|
||||
Every number must trace to a file. Do not fill from memory.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Collect data
|
||||
|
||||
1. Read index.json, filter sessions for target date
|
||||
2. Read each session's audit_trail.jsonl
|
||||
3. Read pending (unflushed data)
|
||||
4. Read previous day's report §6 (backlog) if exists
|
||||
|
||||
### 2. Generate report
|
||||
|
||||
Write to `.boo/runs/daily/{YYYYMMDD}_daily.md`:
|
||||
|
||||
```
|
||||
# Daily Report | <DATE>
|
||||
|
||||
> Source: .boo/runs/index.json + audit_trails
|
||||
|
||||
## 1. Task Overview
|
||||
| # | Type | Session | Task | Status | Records |
|
||||
|
||||
## 2. Operation Stats
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Write/Edit | N |
|
||||
| Bash | N |
|
||||
| AUDIT blocks | N |
|
||||
|
||||
## 3. Changes
|
||||
| Time | File | Change |
|
||||
|
||||
## 4. User Feedback & Corrections
|
||||
| Feedback | Persisted To |
|
||||
|
||||
## 5. Anomaly Alerts
|
||||
- <alerts from anomalies.json>
|
||||
|
||||
## 6. Backlog
|
||||
- previous day's todos
|
||||
- current status
|
||||
|
||||
## 7. Integrity
|
||||
- All sessions have records: ✅/❌
|
||||
- Corrections persisted: ✅/❌
|
||||
```
|
||||
|
||||
### 3. If /report-daily review
|
||||
|
||||
After report, additionally:
|
||||
1. Check: yesterday's anomalies all addressed?
|
||||
2. Check: user feedback converted to improvements?
|
||||
3. Check: backlog items completed?
|
||||
4. Write `.boo/runs/daily/{YYYYMMDD}_morning_review.md`
|
||||
5. Output recommended priorities for today
|
||||
|
||||
```
|
||||
=== Morning Self-Review ===
|
||||
Trend: <up/down/flat compared to last 3 days>
|
||||
Anomalies resolved: N/M
|
||||
Backlog cleared: N/M
|
||||
|
||||
Recommended priorities:
|
||||
1. <...>
|
||||
2. <...>
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- If no sessions today, generate empty report with "No activity"
|
||||
- Report itself should write one [AUDIT] block
|
||||
- Historical reports are append-only — corrections go in new report
|
||||
- Every number must cite its source file
|
||||
85
data/skills/boocode/audit-start/SKILL.md
Normal file
85
data/skills/boocode/audit-start/SKILL.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: audit-start
|
||||
description: Create an audit session with context recovery. Use when beginning a new task, before making changes, or when the user says "/start". Ensures all subsequent work is tracked in a recoverable session.
|
||||
---
|
||||
|
||||
# /start — Audit Session + Context Recovery
|
||||
|
||||
## Trigger
|
||||
|
||||
```
|
||||
/start "task description"
|
||||
```
|
||||
|
||||
## Why
|
||||
|
||||
Every work session should be tracked. Without a session:
|
||||
- Hooks output to an auto_ session with no task description
|
||||
- /end can't run integrity checks
|
||||
- Daily reports lack task context
|
||||
|
||||
/start costs one directory + one JSON file. The return is traceability.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create the session
|
||||
|
||||
1. Generate `session_id = adhoc_YYYYMMDD_HHMM`
|
||||
2. `mkdir -p .boo/runs/{session_id}`
|
||||
3. Write `session.json`:
|
||||
```json
|
||||
{
|
||||
"session_id": "<id>",
|
||||
"task": "<user description>",
|
||||
"start_time": "<ISO 8601>",
|
||||
"status": "in_progress",
|
||||
"expected_record_types": ["data", "change", "conversation"]
|
||||
}
|
||||
```
|
||||
4. Write `.boo/runs/.current_session` with session_id (hook handshake)
|
||||
|
||||
### 2. Context recovery
|
||||
|
||||
**Level 0 — Index**:
|
||||
- Read `.boo/runs/index.json` → last 5 entries (id, task, status)
|
||||
|
||||
**Level 2 — User corrections (critical)**:
|
||||
- Scan recent `audit_trail.jsonl` files for `user_correction` records
|
||||
- These must be surfaced first — repeating corrected mistakes wastes effort
|
||||
|
||||
**Level 1 — Task state**:
|
||||
- Read latest `.boo/runs/daily/*_daily.md` if it exists (§4 anomalies, §6 backlog)
|
||||
- Read latest `*_morning_review.md` if it exists
|
||||
|
||||
### 3. Check unfinished sessions
|
||||
|
||||
- Scan `.boo/runs/` session dirs for `session.json` with `status: "in_progress"`
|
||||
- If found, propose: continue existing session or start fresh
|
||||
|
||||
### 4. Output recovery summary
|
||||
|
||||
```
|
||||
Audit session: adhoc_20260320_1400
|
||||
Task: <description>
|
||||
|
||||
Context recovery:
|
||||
|
||||
Recent activity:
|
||||
- <last 3 completed tasks>
|
||||
|
||||
⚠️ User corrections (must follow):
|
||||
- <all user_correction records>
|
||||
|
||||
Unresolved:
|
||||
- <unfinished sessions, open alerts>
|
||||
|
||||
Today's priorities:
|
||||
- <recommendations>
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- If `.boo/runs/` doesn't exist, create it
|
||||
- If no history, start clean — no errors
|
||||
- session_id stays constant for the whole session; all [AUDIT] blocks share it
|
||||
- If `.current_session` already points at an active session, ask before replacing
|
||||
61
data/skills/boocode/command-end/SKILL.md
Normal file
61
data/skills/boocode/command-end/SKILL.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: command-end
|
||||
description: End the current audit session, flush remaining buffer data, run integrity checks, and generate a session summary. Use when finishing a task, taking a break, or ending work. Examples: "end", "finish session", "end session", "wrap up", "/end".
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Bash
|
||||
- Write
|
||||
---
|
||||
|
||||
# /end — Audit Session End + Integrity Check
|
||||
|
||||
## Trigger
|
||||
|
||||
```
|
||||
/end
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Find current session
|
||||
|
||||
Read `.boo/runs/.current_session` for the session_id.
|
||||
|
||||
If absent, check for auto-created sessions. If none, report "No active session."
|
||||
|
||||
### 2. Collect remaining audit data
|
||||
|
||||
Read `.boo/runs/audit_buffer.jsonl` and `audit_pending.jsonl` for any data the Stop hook hasn't flushed yet. Append both to `.boo/runs/{session_id}/audit_trail.jsonl`, then clear the buffer files.
|
||||
|
||||
### 3. Extract user corrections
|
||||
|
||||
Scan `audit_trail.jsonl` for `user_correction` entries. Each should have a non-empty `persisted_to` array. If any are unpersisted, flag them.
|
||||
|
||||
### 4. Integrity checks
|
||||
|
||||
| Check | Source | Pass | Fail |
|
||||
|-------|--------|------|------|
|
||||
| Has records | trail line count | > 0 | Warn |
|
||||
| Files tracked | tool=Write/Edit entries | Every changed file has an entry | Warn |
|
||||
| Corrections persisted | user_correction entries | persisted_to non-empty | Warn |
|
||||
|
||||
### 5. Generate summary
|
||||
|
||||
Write `.boo/runs/{session_id}/session_summary.md`:
|
||||
|
||||
```markdown
|
||||
# Session Summary | {session_id}
|
||||
## Task: {description}
|
||||
## Time: {start} → {end}
|
||||
## Status: completed
|
||||
|
||||
Completed work: {action list}
|
||||
Key conclusions: {output entries}
|
||||
User corrections: {correction records}
|
||||
```
|
||||
|
||||
### 6. Close
|
||||
|
||||
Update `session.json`: status=completed, end_time=now. Update `index.json`. Delete `.current_session`.
|
||||
61
data/skills/boocode/command-recover/SKILL.md
Normal file
61
data/skills/boocode/command-recover/SKILL.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: command-recover
|
||||
description: Recover lost context from audit session records. Use when you can't remember earlier discussion, aren't sure about task progress, or need to check what the user has corrected before. Also use when your answers feel vague — don't guess, recover. Examples: "recover", "what was I doing", "recap", "what did we discuss", "/recover".
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
---
|
||||
|
||||
# /recover — Context Recovery
|
||||
|
||||
## Trigger
|
||||
|
||||
```
|
||||
/recover # L0+L1+L2 (current session)
|
||||
/recover full # L3 (full audit_trail)
|
||||
/recover {session_id} # Specific session
|
||||
```
|
||||
|
||||
## When to use
|
||||
|
||||
**Do not work from memory — query the audit trail when:**
|
||||
- You can't recall what was decided earlier
|
||||
- Unsure what phase the task is in
|
||||
- About to propose something the user may have already corrected
|
||||
- Answers feel generic (missing file names, specific numbers)
|
||||
|
||||
## Graded loading
|
||||
|
||||
### L0 — Index (~200t)
|
||||
Read `.boo/runs/index.json` → last 5 entries (id, task, status)
|
||||
|
||||
### L1 — Task state (~500t)
|
||||
Read `.current_session` → session.json → last 3 audit_trail entries
|
||||
|
||||
### L2 — User corrections + decisions (~1000t) ⚠️ MOST IMPORTANT
|
||||
Scan ALL audit_trails for `user_correction` records + conclusions
|
||||
Read daily report §4 (anomalies) + §6 (backlog)
|
||||
|
||||
### L3 — Full context (~3000t, /recover full only)
|
||||
Complete audit_trail.jsonl + audit_pending.jsonl
|
||||
|
||||
## Output
|
||||
|
||||
```
|
||||
=== Context Recovery Report ===
|
||||
Level: L2
|
||||
Source: .boo/runs/{session_id}/
|
||||
|
||||
Current task: {description}
|
||||
Progress: {last action}
|
||||
|
||||
USER CORRECTIONS (must follow):
|
||||
1. [{time}] {original claim} → {correction}
|
||||
Principle: {principle_extracted}
|
||||
|
||||
Key conclusions: ...
|
||||
Unresolved: ...
|
||||
|
||||
Source: audit records (not memory)
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user