Compare commits
33 Commits
v2.7.20-ar
...
v2.8.7-lla
| Author | SHA1 | Date | |
|---|---|---|---|
| a8e475fdf4 | |||
| 02063072ab | |||
| ec48066a80 | |||
| 876c9bcd02 | |||
| c132215064 | |||
| a72f7954b4 | |||
| 31d8efe66a | |||
| c935687725 | |||
| 0d6e9a2413 | |||
| 6344105877 | |||
| 028c08b4cd | |||
| fb52eb3efa | |||
| 648a59a563 | |||
| 7f59f30f2d | |||
| f436021bf9 | |||
| bef6bef504 | |||
| 87923cb07b | |||
| c6ecd984c5 | |||
| 2a83f61070 | |||
| 44874f0097 | |||
| 1b70d41996 | |||
| b64941ad4b | |||
| cdc782e044 | |||
| 02bb355a09 | |||
| b8b2666fdc | |||
| ee749d8698 | |||
| bc83475a3d | |||
| 214cc32ac2 | |||
| 6b7c2bab1e | |||
| 373ba86e5d | |||
| 9106334e70 | |||
| cce685b1a7 | |||
| dbf1662982 |
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)
|
||||
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
26
CHANGELOG.md
26
CHANGELOG.md
@@ -2,6 +2,32 @@
|
||||
|
||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||
|
||||
## v2.8.0-fork-lifts — 2026-06-07
|
||||
|
||||
Completes the eight fork-lift integrations from `/opt/forks` into BooCode: boocontext sidecar upgrade, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol enhancements, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards (truncation + dropped imports) and the TokenScope analyzer/persist module. Closes the fork-lifts-mit epic.
|
||||
|
||||
**boocontext sidecar (Phase 3):** Upgrades the `codecontext` container from the old Go MCP server to the boocontext Node.js MCP aggregator. Multi-stage Dockerfile builds boocontext from `/opt/forks/boocontext` alongside the HTTP shim. `shim.go` gains `CODECONTEXT_CHILD` env-var support and three new HTTP routes for symbols, callgraph, and blast radius. Three TypeScript tool wrappers (`get_symbol_details`, `get_call_graph`, `get_blast_radius`) registered on the server, with blast radius added to the synthesis pipeline. Docker-compose env vars configure child MCP paths (tree-sitter-analyzer, type-inject).
|
||||
|
||||
**LSP integration (Phase 4):** Six-file `lsp/` module in the coder with config, JSON-RPC stdio client, lazy server-manager (per-project pool, 5-min idle shutdown), and operations (diagnostics, goto-definition, find-references). Three read-only agent tools registered — `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`. TypeScript/JavaScript only in v1.
|
||||
|
||||
**DCP clean-room (Phase 5):** Seven-file `dcp/` module in the server inference pipeline. Consecutive identical tool_call+tool_result pairs are deduplicated; failed/empty tool results are purged via configurable window. Orchestrated by `transformMessages()` running before `buildMessagesPayload` in `turn.ts`. Clean-room reimplementation — AGPL source was referenced for behavior only. 10 unit tests.
|
||||
|
||||
**Institutional memory (Phase 6):** Eight-file `memory/` module with file-based recall. Hierarchical 4-scope scan (global → home → project → session) under `.boocode/memory/`. Keyword/tag relevance matching at prompt assembly. Injected as a `<boocode-memory>` block in the system prompt. v1 recall-only — extract/dream deferred.
|
||||
|
||||
**Subagent protocol (Phase 7):** `AgentCapabilitiesSchema` in contracts with `supportsStreaming`, `supportsReasoningStream`, `supportsBackgroundExecution` flags. `ProviderSnapshotEntry` gains the two streaming capability fields. `new_task` tool gets a `background` mode flag for non-blocking dispatch. Flow-runner already supported per-step model override.
|
||||
|
||||
**Plugin host (Phase 8):** Typed hook registry in `plugins/host.ts` with `registerHook`/`emitHook` for five lifecycle events: `tool.execute.before`, `tool.execute.after`, `turn.start`, `turn.end`, `task.terminal`. Patterns-only from oh-my-openagent (SUL — no code copy).
|
||||
|
||||
**Inference reliability (Phase 9):** `tool-shim.ts` recovers XML/JSON tool calls from plain-text model output (e.g. Qwen inline format). `loop-detectors.ts` catches content-repeat and tool-loop patterns. Existing doom-loop detection remains — detectors are additive.
|
||||
|
||||
**Edit safety guards (Wave 1):** `edit-guards.ts` rejects catastrophic truncation (>60% chars AND >50% lines). `edit-guards-imports.ts` detects dropped import statements. Both run in `pending_changes.ts` immediately before `writeFileAtomic`.
|
||||
|
||||
**TokenScope (Wave 2):** `TokenBreakdownSchema` in contracts with system/user/assistant/tools/reasoning categories. `token-analysis/` module with analyzer and DB persistence. `ContestantShape.token_breakdown` field and `token_breakdown` JSONB column on `contestants`/`tasks` tables. Arena `computeBenchmark` accepts and returns token breakdown.
|
||||
|
||||
**Build:** Server 649 ✅ Coder 471 ✅ Contracts ✅ — all green.
|
||||
|
||||
Adds the **Arena** pane for running the same prompt against 2–6 AI competitors simultaneously and picking the best result. A Battle is one Arena run: pick a battle type (Coding — backend+model with git worktrees producing diffs; or Q&A — BooChat persona+model producing text), write or generate a prompt, add contestants, and hit Start. Contestants are scheduled in two concurrent lanes — the local lane (llama-swap models, serial) and the cloud lane (Claude Code, OpenCode-on-cloud, parallel). The lane scheduler captures wall-clock duration for every contestant and tokens/sec for local models. When all contestants finish, a two-stage analysis (digest then judge) auto-runs on the DEFAULT_MODEL, writing `analysis.md` naming a winner; the user can override the winner per-row or trigger cross-examination. Results land in `/<project-root>/Arena/<dated-battle>/` with per-contestant `result.md`, diff patches for coding, and `manifest.json`. Replaces the old API-only `POST /api/arena` with dedicated `battles`/`contestants`/`cross_examinations` tables and full UI. Also adds a `DiffView` component with line-by-line colored unified diff and a per-row dropdown for winner override. Built on `v2.7.18-permission-modes`; pairs conceptually with the earlier `v2.7.17-orchestrator` multi-agent work (both share the pane kind pattern and `onTaskTerminal` hook).
|
||||
|
||||
## v2.7.18-permission-modes — 2026-06-05
|
||||
|
||||
Adds a unified **permission picker** to the BooCoder composer — Plan / Ask Permission / Bypass — replacing the old raw per-agent mode dropdown that exposed each agent's full native vocabulary with inconsistent labels. The three options map generically onto every provider's existing mode metadata: the `plan`-id mode → Plan, the default mode → Ask, the `isUnattended` mode → Bypass (claude `bypassPermissions`, qwen `yolo`, opencode `full-access`); goose has no modes so it shows no picker, exactly as before. `modeId` stays the single wire field — the active unified mode is derived from it, so no contracts change was needed. Native BooCode gains its own mode set (registered in the manifest and exposed by the snapshot): **Ask** stages edits to the pending-changes queue as today, **Bypass** auto-applies the queue to disk after the turn (both the interactive messages path and the task-based dispatcher path), and **Plan** falls back to Ask — the shared `apps/server` inference engine is deliberately left untouched. A supporting fix preserves the `isUnattended` flag on live-probed ACP modes (`acp-derive.ts`) so opencode's bypass mode is still detectable from the wire. Coder 373 tests green, coder + web typecheck clean. Built on `v2.7.17-orchestrator`.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Current focus
|
||||
|
||||
Last updated: 2026-06-05
|
||||
Last updated: 2026-06-07
|
||||
|
||||
- **Last shipped:** `v2.7.18-permission-modes` (2026-06-05) — unified Plan/Ask/Bypass permission picker in the BooCoder composer (incl. native-BooCode auto-apply on Bypass).
|
||||
- **Last shipped:** `v2.8.0-fork-lifts` (2026-06-07) — eight fork-lift integrations from `/opt/forks`: boocontext sidecar, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards and TokenScope analyzer/persist module.
|
||||
- **Branch:** `main`
|
||||
- **In progress:** nothing committed — dogfooding the Orchestrator to surface the next real backlog. Claude Agent-SDK backend enabled (`CLAUDE_SDK_BACKEND`). Optional/exploratory: verify-gate ensembler over pending changes.
|
||||
- **In progress:** nothing committed — all phases 3-9 of fork-lifts-mit epic are shipped. Optional/exploratory: verify-gate ensembler over pending changes; web Arena token UI display.
|
||||
|
||||
See `CHANGELOG.md` for the full shipped history. That file is always authoritative; this file is a quick orientation pointer only.
|
||||
|
||||
@@ -37,3 +37,10 @@
|
||||
|
||||
- **In-app multi-agent conductor**: `services/flow-runner.ts` runs a flow by inserting each step as a `tasks` row (the existing dispatcher runs it) and advancing on a new `onTaskTerminal` dispatcher-deps hook; persisted in `flow_runs`/`flow_steps` (resumed at startup via `initResume`). The 22 conductor flow defs + Spine factory are re-homed under `src/conductor/`. Pure scheduler/resume helpers in `flow-runner-decisions.ts`. Full design: `openspec/changes/archived/orchestrator/`.
|
||||
- **Read-only is load-bearing — don't add a dispatch path that bypasses it.** Every step dispatches `agent='qwen', mode_id='plan'`; `dispatcher.ts` force-routes qwen+plan to the PTY `--approval-mode plan` gate and HARD-FAILS the task (never falls to write-capable native inference) when qwen is unavailable (`shouldFailOnMissingAgent`). `BOOCODE_TOOLS` gates BooChat's NATIVE inference tools only — it does NOT govern an external CLI agent (qwen/opencode bring their own write tools); read-only for a dispatched agent is the agent-layer mode (PTY `--approval-mode plan`; ACP `setSessionMode` is fail-OPEN by default, fail-CLOSED for `plan` via `READ_ONLY_MODE_IDS` in `acp-dispatch.ts`).
|
||||
|
||||
## Edit safety guards (v2.8)
|
||||
|
||||
- **`services/edit-guards.ts`** — `validateEditResult(original, updated, filePath)` runs in `pending_changes.ts` immediately before `writeFileAtomic`. Rejects catastrophic truncation (>60% char loss AND >50% line loss). Throws a `formatGuardError` message that percolates to the agent as a visible error.
|
||||
- **`services/edit-guards-imports.ts`** — `checkDroppedImports(original, updated, filePath)` detects removed import/require lines. Called alongside the truncation guard.
|
||||
- Both guards run on the `/apply` path only (not on queue). Re-queued identical edits re-validate at apply time.
|
||||
- Guard functions are pure — no DB or filesystem access. Easy to unit-test.
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from './planning.js';
|
||||
import { adr, codingStandard, runbook, tdd, stakeholderSummary } from './authoring.js';
|
||||
import { codeReview } from './code-review.js';
|
||||
import { parallelResearch } from './parallel-research.js';
|
||||
|
||||
const spines: Spine[] = [
|
||||
// analysis / research
|
||||
@@ -53,7 +54,7 @@ const spines: Spine[] = [
|
||||
stakeholderSummary,
|
||||
];
|
||||
|
||||
const bespoke: Flow[] = [codeReview];
|
||||
const bespoke: Flow[] = [codeReview, parallelResearch];
|
||||
|
||||
const ALL: Flow[] = [...spines.map(buildSpineFlow), ...bespoke];
|
||||
|
||||
|
||||
59
apps/coder/src/conductor/flows/parallel-research.ts
Normal file
59
apps/coder/src/conductor/flows/parallel-research.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Flow, Step, StepContext } from '../types.js';
|
||||
|
||||
const q = (ctx: StepContext) => String(ctx.input.question);
|
||||
|
||||
/**
|
||||
* Parallel research flow — dispatches 3 research agents simultaneously,
|
||||
* then synthesizes the result on the first one to complete.
|
||||
*/
|
||||
export const parallelResearch: Flow = {
|
||||
name: 'parallel-research',
|
||||
description: 'Research from 3 angles in parallel, synthesize results on first completion',
|
||||
steps: [
|
||||
{
|
||||
id: 'angle-web',
|
||||
kind: 'agent',
|
||||
agent: 'research-analyst',
|
||||
run: (ctx) =>
|
||||
`Research the following question from a web / prior-art perspective:\n\n${q(ctx)}`,
|
||||
},
|
||||
{
|
||||
id: 'angle-code',
|
||||
kind: 'agent',
|
||||
agent: 'codebase-explorer',
|
||||
deps: [],
|
||||
run: (ctx) =>
|
||||
`Research the following question from a codebase analysis perspective:\n\n${q(ctx)}`,
|
||||
},
|
||||
{
|
||||
id: 'angle-security',
|
||||
kind: 'agent',
|
||||
agent: 'adversarial-security-analyst',
|
||||
deps: [],
|
||||
run: (ctx) =>
|
||||
`Research the following question from a security perspective:\n\n${q(ctx)}`,
|
||||
},
|
||||
{
|
||||
id: 'synthesize',
|
||||
kind: 'code',
|
||||
deps: ['angle-web', 'angle-code', 'angle-security'],
|
||||
trigger_rule: 'one_success',
|
||||
run: (ctx) => {
|
||||
const web = ctx.results['angle-web'];
|
||||
const code = ctx.results['angle-code'];
|
||||
const security = ctx.results['angle-security'];
|
||||
const parts = [
|
||||
'# Parallel Research Synthesis',
|
||||
'',
|
||||
web ? `## Web Angle\n${web}` : '## Web Angle\n*(not yet completed)*',
|
||||
code ? `## Code Angle\n${code}` : '## Code Angle\n*(not yet completed)*',
|
||||
security ? `## Security Angle\n${security}` : '## Security Angle\n*(not yet completed)*',
|
||||
];
|
||||
return parts.join('\n\n');
|
||||
},
|
||||
},
|
||||
],
|
||||
render: (ctx) => {
|
||||
return ctx.results['synthesize'] ?? 'No synthesis produced.';
|
||||
},
|
||||
};
|
||||
@@ -38,7 +38,9 @@ export interface StepContext {
|
||||
readonly model?: string;
|
||||
}
|
||||
|
||||
export type StepKind = 'agent' | 'code';
|
||||
export type StepKind = 'agent' | 'code' | 'approval';
|
||||
|
||||
export type TriggerRule = 'all_success' | 'one_success' | 'all_done';
|
||||
|
||||
export interface Step {
|
||||
/** unique id within the flow; other steps depend on it by this id */
|
||||
@@ -46,6 +48,8 @@ export interface Step {
|
||||
kind: StepKind;
|
||||
/** ids that must complete (or skip) before this step runs */
|
||||
deps?: string[];
|
||||
/** how dependency satisfaction is evaluated (default: all_success) */
|
||||
trigger_rule?: TriggerRule;
|
||||
/** for kind:'agent' — the persona file name under conductor/agents (no .md) */
|
||||
agent?: string;
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
|
||||
import { WRITE_TOOLS } from './services/tools/index.js';
|
||||
import { adaptWriteTool } from './services/tools/adapter.js';
|
||||
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
|
||||
import { runWithInferenceContext } from './services/tools/inference_context.js';
|
||||
// Routes
|
||||
import { registerMessageRoutes } from './routes/messages.js';
|
||||
import { registerSkillRoutes } from './routes/skills.js';
|
||||
@@ -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';
|
||||
@@ -174,22 +175,27 @@ async function main() {
|
||||
}
|
||||
);
|
||||
|
||||
// Wrap the inference runner to set/clear the write-tool context around each run.
|
||||
// The inference runner calls enqueue() which fires asynchronously — we hook
|
||||
// into the enqueue to set context before the run starts.
|
||||
// Wrap the inference runner to bind the write-tool context around each run.
|
||||
// enqueue() starts its async loop synchronously, so wrapping the call in
|
||||
// runWithInferenceContext propagates the per-run context (sql, sessionId, the
|
||||
// Plan/Ask/Bypass gate) through every awaited tool execution — and concurrent
|
||||
// runs (a user message racing a dispatcher-polled native task) each get their
|
||||
// own, instead of clobbering a shared global.
|
||||
const inferenceApi = {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => {
|
||||
// Set the inference context so write tools can access sql + sessionId.
|
||||
// The context persists for the duration of the inference run. Since
|
||||
// BooCoder is single-user and runs one inference at a time per session,
|
||||
// this module-level state is safe.
|
||||
setInferenceContext({ sql, sessionId, taskId: null });
|
||||
enqueue: (
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
assistantId: string,
|
||||
user: string,
|
||||
permissionMode?: 'plan' | 'ask' | 'bypass',
|
||||
) => {
|
||||
runWithInferenceContext({ sql, sessionId, taskId: null, permissionMode }, () => {
|
||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||
});
|
||||
},
|
||||
cancel: async (sessionId: string, chatId: string) => {
|
||||
const result = await inference.cancel(sessionId, chatId);
|
||||
clearInferenceContext();
|
||||
return result;
|
||||
// No context to clear — AsyncLocalStorage scopes it to each run's own chain.
|
||||
return inference.cancel(sessionId, chatId);
|
||||
},
|
||||
hasActive: (chatId: string) => inference.hasActive(chatId),
|
||||
};
|
||||
@@ -377,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
|
||||
|
||||
42
apps/coder/src/plugins/host.ts
Normal file
42
apps/coder/src/plugins/host.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type HookName =
|
||||
| 'tool.execute.before'
|
||||
| 'tool.execute.after'
|
||||
| 'turn.start'
|
||||
| 'turn.end'
|
||||
| 'task.terminal';
|
||||
|
||||
export interface ToolHookContext {
|
||||
tool: string;
|
||||
args: Record<string, unknown>;
|
||||
projectRoot: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface ToolResultContext extends ToolHookContext {
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
export type PluginHook = (ctx: any) => Promise<any>;
|
||||
|
||||
const hooks = new Map<HookName, PluginHook[]>();
|
||||
|
||||
export function registerHook(name: HookName, fn: PluginHook): void {
|
||||
const list = hooks.get(name) || [];
|
||||
list.push(fn);
|
||||
hooks.set(name, list);
|
||||
}
|
||||
|
||||
export async function emitHook(name: HookName, ctx: any): Promise<any> {
|
||||
const list = hooks.get(name);
|
||||
if (!list) return ctx;
|
||||
let current = ctx;
|
||||
for (const fn of list) {
|
||||
const result = await fn(current);
|
||||
if (result !== undefined) current = result;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function clearHooks(): void {
|
||||
hooks.clear();
|
||||
}
|
||||
78
apps/coder/src/routes/analytics.ts
Normal file
78
apps/coder/src/routes/analytics.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
// token-analyzer-ui: aggregate token/cost analytics across all agent_sessions.
|
||||
// v1 — global view only (no per-project or per-user filtering).
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cost: number;
|
||||
session_count: number;
|
||||
}
|
||||
|
||||
export interface SessionAnalyticsRow {
|
||||
session_id: string;
|
||||
session_name: string;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cost: number;
|
||||
last_active_at: string | null;
|
||||
}
|
||||
|
||||
export interface TokenBreakdownAgg {
|
||||
category: string;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
// GET /api/analytics/summary — aggregate totals across all agent_sessions.
|
||||
app.get('/api/analytics/summary', async () => {
|
||||
const [row] = await sql<AnalyticsSummary[]>`
|
||||
SELECT
|
||||
COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens,
|
||||
COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens,
|
||||
COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost,
|
||||
COUNT(DISTINCT c.session_id)::INT AS session_count
|
||||
FROM agent_sessions a
|
||||
JOIN chats c ON c.id = a.chat_id
|
||||
`;
|
||||
return row ?? { total_input_tokens: 0, total_output_tokens: 0, total_cost: 0, session_count: 0 };
|
||||
});
|
||||
|
||||
// GET /api/analytics/sessions — per-session token/cost breakdown.
|
||||
app.get('/api/analytics/sessions', async () => {
|
||||
const rows = await sql<SessionAnalyticsRow[]>`
|
||||
SELECT
|
||||
c.session_id AS session_id,
|
||||
s.name AS session_name,
|
||||
COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens,
|
||||
COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens,
|
||||
COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost,
|
||||
MAX(a.last_active_at) AS last_active_at
|
||||
FROM agent_sessions a
|
||||
JOIN chats c ON c.id = a.chat_id
|
||||
JOIN sessions s ON s.id = c.session_id
|
||||
GROUP BY c.session_id, s.name
|
||||
ORDER BY MAX(a.last_active_at) DESC NULLS LAST
|
||||
`;
|
||||
return { sessions: rows };
|
||||
});
|
||||
|
||||
// GET /api/analytics/token-breakdown — aggregate token_breakdown categories
|
||||
// across all tasks that carry the JSONB field.
|
||||
app.get('/api/analytics/token-breakdown', async () => {
|
||||
const rows = await sql<{ category: string; total_tokens: number }[]>`
|
||||
SELECT
|
||||
key AS category,
|
||||
SUM((value->>0)::BIGINT)::BIGINT AS total_tokens
|
||||
FROM tasks,
|
||||
LATERAL jsonb_each(token_breakdown)
|
||||
WHERE token_breakdown IS NOT NULL
|
||||
AND jsonb_typeof(token_breakdown) = 'object'
|
||||
GROUP BY key
|
||||
ORDER BY total_tokens DESC
|
||||
`;
|
||||
return { categories: rows };
|
||||
});
|
||||
}
|
||||
@@ -205,7 +205,7 @@ export function registerArenaRoutes(
|
||||
|
||||
const contestants = await sql`
|
||||
SELECT id, battle_id, identity, model, lane, task_id, worktree_id,
|
||||
status, duration_ms, tokens_per_sec, cost_tokens, result_path, error,
|
||||
status, duration_ms, tokens_per_sec, cost_tokens, token_breakdown, result_path, error,
|
||||
created_at, updated_at
|
||||
FROM contestants
|
||||
WHERE battle_id = ${id}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Sql } from '../db.js';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import { resolveChatId } from './chat-resolve.js';
|
||||
import { applyAll } from '../services/pending_changes.js';
|
||||
import { asPermissionMode } from '../services/tools/types.js';
|
||||
|
||||
const AnswerUserInputBody = z.object({
|
||||
tool_call_id: z.string().min(1),
|
||||
@@ -44,7 +44,13 @@ const SendBody = z.object({
|
||||
});
|
||||
|
||||
interface InferenceApi {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||
enqueue: (
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
assistantId: string,
|
||||
user: string,
|
||||
permissionMode?: 'plan' | 'ask' | 'bypass',
|
||||
) => void;
|
||||
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
hasActive: (chatId: string) => boolean;
|
||||
}
|
||||
@@ -246,36 +252,16 @@ export function registerMessageRoutes(
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default');
|
||||
|
||||
// Bypass permission mode (native BooCode): auto-apply staged edits to disk
|
||||
// once the turn settles. `enqueue` registers synchronously, so hasActive is
|
||||
// true immediately; poll until it clears, apply, then re-publish
|
||||
// message_complete so the DiffPanel reflects the now-applied (non-pending)
|
||||
// state. Best-effort — failures stay in the pending queue for manual apply.
|
||||
if (mode_id === 'bypass') {
|
||||
const projectId = sessionRows[0]!.project_id;
|
||||
const assistantId = assistantMsg!.id;
|
||||
void (async () => {
|
||||
try {
|
||||
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${projectId}`;
|
||||
if (!proj?.path) return;
|
||||
for (let i = 0; i < 1200 && inference.hasActive(chatId); i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
const applied = await applyAll(sql, sessionId, proj.path);
|
||||
if (applied.length > 0) {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
} as unknown as WsFrame);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort auto-apply — leave staged changes for manual apply */
|
||||
}
|
||||
})();
|
||||
}
|
||||
// Native BooCode permission gate (plan/ask/bypass) — threaded into the
|
||||
// write-tool context so create/edit/delete and apply_pending honor it.
|
||||
// Plan = read-only, Ask = stage to the queue (agent can't self-apply),
|
||||
// Bypass = apply each write immediately. Other mode ids (e.g. an external
|
||||
// fallback's native mode) leave the gate undefined = legacy behavior.
|
||||
req.log.info(
|
||||
{ provider, mode_id, permissionMode: asPermissionMode(mode_id), chatId },
|
||||
'native enqueue — permission gate',
|
||||
);
|
||||
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default', asPermissionMode(mode_id));
|
||||
|
||||
reply.code(202);
|
||||
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
||||
|
||||
@@ -423,3 +423,18 @@ CREATE INDEX IF NOT EXISTS contestants_task_id_idx ON contestants(task_id);
|
||||
|
||||
-- Cross-examination listing per battle.
|
||||
CREATE INDEX IF NOT EXISTS cross_examinations_battle_idx ON cross_examinations(battle_id);
|
||||
|
||||
-- TokenScope: per-category token breakdown on arena contestants and tasks.
|
||||
ALTER TABLE contestants ADD COLUMN IF NOT EXISTS token_breakdown JSONB;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS token_breakdown JSONB;
|
||||
|
||||
-- Orchestrator flow step events (append-only event log for resume/replay).
|
||||
CREATE TABLE IF NOT EXISTS flow_step_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
run_id UUID NOT NULL REFERENCES flow_runs(id),
|
||||
step_id VARCHAR(64) NOT NULL,
|
||||
event VARCHAR(32) NOT NULL,
|
||||
payload JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS flow_step_events_run_idx ON flow_step_events(run_id);
|
||||
|
||||
@@ -162,6 +162,24 @@ describe('computeBenchmark', () => {
|
||||
expect(bench.durationMs).toBe(0);
|
||||
expect(bench.tokensPerSec).toBeNull();
|
||||
});
|
||||
|
||||
it('includes token breakdown when provided', () => {
|
||||
const breakdown = {
|
||||
system: 10,
|
||||
user: 20,
|
||||
assistant: 30,
|
||||
tools: 40,
|
||||
reasoning: 5,
|
||||
total: 105,
|
||||
};
|
||||
const bench = computeBenchmark(t0, t1, 500, 'local', breakdown);
|
||||
expect(bench.tokenBreakdown).toEqual(breakdown);
|
||||
});
|
||||
|
||||
it('defaults token breakdown to null when omitted', () => {
|
||||
const bench = computeBenchmark(t0, t1, 500, 'local');
|
||||
expect(bench.tokenBreakdown).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── sanitizeSlug ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -161,6 +161,52 @@ describe('locateMatch — strategy 4: Levenshtein', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('locateMatch — strategy 4: fail-closed on ambiguity (corruption guard)', () => {
|
||||
it('refuses (ambiguous) when two equally-similar anchored blocks both clear the bar', () => {
|
||||
// The repetitive-file case that duplicated blocks: two blocks share the same
|
||||
// first+last anchor lines and their middle lines are EQUALLY similar to the
|
||||
// (drifted) needle. Tier 4 must refuse rather than splice over one of them.
|
||||
const content = [
|
||||
'const x = {',
|
||||
' total = aa;',
|
||||
'};',
|
||||
'const x = {',
|
||||
' total = bb;',
|
||||
'};',
|
||||
].join('\n');
|
||||
const needle = ['const x = {', ' total = ab;', '};'].join('\n');
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result.kind).toBe('ambiguous');
|
||||
});
|
||||
|
||||
it('refuses a below-threshold near-miss that the old 0.66 floor would have spliced', () => {
|
||||
// ~0.7 similar: under the raised 0.85 floor this is now not_found, so the
|
||||
// caller surfaces a correctable error instead of corrupting the file.
|
||||
const content = 'const grandTotalAmount = a + b;\n';
|
||||
const needle = 'const totalValue = a + b;';
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result).toEqual({ kind: 'not_found' });
|
||||
});
|
||||
|
||||
it('still matches a single genuine high-similarity drift uniquely', () => {
|
||||
const content = 'const total = sum + tax;\n';
|
||||
const needle = 'const totals = sum + tax;'; // one-char typo, ~0.96
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result.kind).toBe('fuzzy');
|
||||
const { start, end } = span(result);
|
||||
expect(content.slice(start, end)).toBe('const total = sum + tax;');
|
||||
});
|
||||
|
||||
it('requires an exact first+last line anchor for multi-line needles', () => {
|
||||
// First line drifted too far to anchor → no window is scored → not_found,
|
||||
// even though the middle lines are identical.
|
||||
const content = ['function compute() {', ' return a + b;', ' return done;', '}'].join('\n');
|
||||
const needle = ['totally different opener', ' return a + b;', '}'].join('\n');
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result).toEqual({ kind: 'not_found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('locateMatch — edge cases', () => {
|
||||
it('returns not_found for an empty needle', () => {
|
||||
expect(locateMatch('anything', '')).toEqual({ kind: 'not_found' });
|
||||
|
||||
@@ -83,6 +83,53 @@ describe.runIf(!!process.env.DATABASE_URL)('pending_changes integration', () =>
|
||||
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(false);
|
||||
});
|
||||
|
||||
it('re-emitted identical edits dedupe at queue and never duplicate on apply', async () => {
|
||||
// Regression: the 2-3x block-stamping corruption. An anchored insert queued
|
||||
// three times (a local model re-emitting the same tool call) must collapse to
|
||||
// ONE pending row and apply exactly once.
|
||||
await queueCreate(sql, testSessionId, null, 'dup.js', '<script>\nrender();\n', projectRoot)
|
||||
.then((c) => applyOne(sql, c.id, projectRoot));
|
||||
|
||||
const oldStr = '<script>';
|
||||
const newStr = '<script>\nconst recordFormats = ["gif"];';
|
||||
const a = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
const b = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
const c = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
// All three calls return the SAME pending row (deduped).
|
||||
expect(b.id).toBe(a.id);
|
||||
expect(c.id).toBe(a.id);
|
||||
|
||||
await applyOne(sql, a.id, projectRoot);
|
||||
let content = await readFile(resolve(testDir, 'dup.js'), 'utf8');
|
||||
expect((content.match(/const recordFormats/g) || []).length).toBe(1);
|
||||
|
||||
// Even a fresh, separately-queued identical edit re-applied is a no-op, not a stamp.
|
||||
const again = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
const res = await applyOne(sql, again.id, projectRoot);
|
||||
expect(res.success).toBe(true);
|
||||
content = await readFile(resolve(testDir, 'dup.js'), 'utf8');
|
||||
expect((content.match(/const recordFormats/g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves CRLF line endings on edit', async () => {
|
||||
await queueCreate(sql, testSessionId, null, 'crlf.txt', 'line one\r\nline two\r\nline three\r\n', projectRoot)
|
||||
.then((c) => applyOne(sql, c.id, projectRoot));
|
||||
const edit = await queueEdit(sql, testSessionId, null, 'crlf.txt', 'line two', 'line TWO', projectRoot);
|
||||
const res = await applyOne(sql, edit.id, projectRoot);
|
||||
expect(res.success).toBe(true);
|
||||
const content = await readFile(resolve(testDir, 'crlf.txt'), 'utf8');
|
||||
expect(content).toBe('line one\r\nline TWO\r\nline three\r\n');
|
||||
});
|
||||
|
||||
it('refuses an edit that matches multiple locations instead of corrupting', async () => {
|
||||
await queueCreate(sql, testSessionId, null, 'ambig.js', 'x=1;\ny=2;\nx=1;\n', projectRoot)
|
||||
.then((ch) => applyOne(sql, ch.id, projectRoot));
|
||||
const edit = await queueEdit(sql, testSessionId, null, 'ambig.js', 'x=1;', 'x=9;', projectRoot);
|
||||
const res = await applyOne(sql, edit.id, projectRoot);
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/matches 2 locations/);
|
||||
});
|
||||
|
||||
it('rewindOne → verify reverted', async () => {
|
||||
// Setup: create and apply a file
|
||||
const createChange = await queueCreate(sql, testSessionId, null, 'rewindable.txt', 'initial', projectRoot);
|
||||
|
||||
69
apps/coder/src/services/__tests__/plan-edit.test.ts
Normal file
69
apps/coder/src/services/__tests__/plan-edit.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { planEdit } from '../pending_changes.js';
|
||||
|
||||
// planEdit is the pure core of applyOne's edit splice. These tests pin the
|
||||
// idempotency guards that stop the "block stamped 2-3x" corruption: applying the
|
||||
// same queued edit more than once must be a no-op, never a duplicate.
|
||||
|
||||
describe('planEdit — normal application', () => {
|
||||
it('applies a unique exact edit', () => {
|
||||
const content = 'a\nfoo\nb\n';
|
||||
const plan = planEdit(content, 'foo', 'bar');
|
||||
expect(plan).toEqual({ kind: 'apply', updated: 'a\nbar\nb\n' });
|
||||
});
|
||||
|
||||
it('reports ambiguous when old_string occurs more than once', () => {
|
||||
const content = 'foo\nx\nfoo\n';
|
||||
const plan = planEdit(content, 'foo', 'bar');
|
||||
expect(plan).toEqual({ kind: 'ambiguous', count: 2 });
|
||||
});
|
||||
|
||||
it('reports not_found when old_string is absent and new is not present', () => {
|
||||
const content = 'alpha\nbeta\n';
|
||||
const plan = planEdit(content, 'gamma that is clearly nowhere', 'delta');
|
||||
expect(plan).toEqual({ kind: 'not_found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('planEdit — idempotency (the corruption guard)', () => {
|
||||
it('treats a re-applied anchored insert as already-applied (no duplicate)', () => {
|
||||
// The exact mechanism that tripled `const recordFormats` in settings.html:
|
||||
// an anchored insert (old=anchor, new=anchor+block) where the anchor still
|
||||
// matches uniquely after the first apply.
|
||||
const oldStr = '<script>';
|
||||
const newStr = '<script>\nconst recordFormats = ["gif","mp4"];';
|
||||
const before = '<script>\nfunction render() {}\n</script>\n';
|
||||
|
||||
const first = planEdit(before, oldStr, newStr);
|
||||
expect(first.kind).toBe('apply');
|
||||
const after = first.kind === 'apply' ? first.updated : '';
|
||||
expect((after.match(/const recordFormats/g) || []).length).toBe(1);
|
||||
|
||||
// Re-applying the identical edit to the already-edited content is a no-op.
|
||||
const second = planEdit(after, oldStr, newStr);
|
||||
expect(second).toEqual({ kind: 'noop', reason: 'already-applied' });
|
||||
});
|
||||
|
||||
it('treats an edit whose old_string is gone but new_string is present as already-applied', () => {
|
||||
const content = 'const total = sum + tax;\n';
|
||||
const plan = planEdit(content, 'const subtotal = sum;', 'const total = sum + tax;');
|
||||
expect(plan).toEqual({ kind: 'noop', reason: 'already-applied' });
|
||||
});
|
||||
|
||||
it('treats a no-change splice as a noop', () => {
|
||||
const content = 'a\nfoo\nb\n';
|
||||
const plan = planEdit(content, 'foo', 'foo');
|
||||
expect(plan).toEqual({ kind: 'noop', reason: 'identical' });
|
||||
});
|
||||
|
||||
it('does not duplicate across three repeated applications', () => {
|
||||
const oldStr = 'function f() {';
|
||||
const newStr = 'function f() {\n const x = 1;';
|
||||
let content = 'function f() {\n return x;\n}\n';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const plan = planEdit(content, oldStr, newStr);
|
||||
if (plan.kind === 'apply') content = plan.updated;
|
||||
}
|
||||
expect((content.match(/const x = 1;/g) || []).length).toBe(1);
|
||||
});
|
||||
});
|
||||
31
apps/coder/src/services/__tests__/trigger-rules.test.ts
Normal file
31
apps/coder/src/services/__tests__/trigger-rules.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { evaluateTriggerRule } from '../flow-runner-decisions.js';
|
||||
|
||||
describe('evaluateTriggerRule', () => {
|
||||
it('all_success requires all deps done', () => {
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a', 'b']), new Set(), new Set())).toBe(true);
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set())).toBe(false);
|
||||
});
|
||||
|
||||
it('one_success fires on first completion', () => {
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set(), 'one_success')).toBe(true);
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(), new Set(), new Set(), 'one_success')).toBe(false);
|
||||
});
|
||||
|
||||
it('all_done includes skipped deps', () => {
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(['b']), new Set(), 'all_done')).toBe(true);
|
||||
});
|
||||
|
||||
it('all_success treats excluded deps as satisfied', () => {
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set(['b']))).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults to all_success', () => {
|
||||
expect(evaluateTriggerRule(['a'], new Set(['a']), new Set(), new Set())).toBe(true);
|
||||
expect(evaluateTriggerRule(['a'], new Set(), new Set(), new Set())).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for empty deps', () => {
|
||||
expect(evaluateTriggerRule([], new Set(), new Set(), new Set())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
* A contestant's status lifecycle:
|
||||
* queued → running → done | error
|
||||
*/
|
||||
import type { BattleType, ContestantLane } from '@boocode/contracts/arena';
|
||||
import type { BattleType, ContestantLane, TokenBreakdown } from '@boocode/contracts/arena';
|
||||
|
||||
// ─── Lane classification ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -73,6 +73,7 @@ export function isBattleComplete(contestants: readonly { status: string }[]): bo
|
||||
export interface Benchmark {
|
||||
durationMs: number;
|
||||
tokensPerSec: number | null;
|
||||
tokenBreakdown: TokenBreakdown | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,13 +87,14 @@ export function computeBenchmark(
|
||||
endedAt: Date,
|
||||
costTokens: number | null,
|
||||
lane: ContestantLane,
|
||||
tokenBreakdown: TokenBreakdown | null = null,
|
||||
): Benchmark {
|
||||
const durationMs = Math.max(0, endedAt.getTime() - startedAt.getTime());
|
||||
const tokensPerSec =
|
||||
lane === 'local' && costTokens !== null && durationMs > 0
|
||||
? (costTokens / durationMs) * 1000
|
||||
: null;
|
||||
return { durationMs, tokensPerSec };
|
||||
return { durationMs, tokensPerSec, tokenBreakdown };
|
||||
}
|
||||
|
||||
// ─── Slug / path helpers ──────────────────────────────────────────────────────
|
||||
|
||||
747
apps/coder/src/services/audit-session.ts
Normal file
747
apps/coder/src/services/audit-session.ts
Normal file
@@ -0,0 +1,747 @@
|
||||
import { mkdir, readFile, writeFile, readdir, rm, appendFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
export const RUNS_REL = '.boo/runs';
|
||||
export const DAILY_REL = '.boo/runs/daily';
|
||||
export const GUIDELINES_REL = '.boo/guidelines';
|
||||
|
||||
export interface SessionJson {
|
||||
session_id: string;
|
||||
task: string;
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
status: 'in_progress' | 'completed';
|
||||
expected_record_types: string[];
|
||||
}
|
||||
|
||||
export interface AuditTrailEntry {
|
||||
timestamp: string;
|
||||
record_type: string;
|
||||
action_type: string;
|
||||
tool?: string;
|
||||
files?: string[];
|
||||
detail?: string;
|
||||
input?: string;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export interface IndexEntry {
|
||||
id: string;
|
||||
task: string;
|
||||
status: string;
|
||||
record_count: number;
|
||||
start_time: string;
|
||||
max_anomaly_level?: string;
|
||||
}
|
||||
|
||||
export interface IndexJson {
|
||||
entries: IndexEntry[];
|
||||
}
|
||||
|
||||
export interface StartSessionResult {
|
||||
sessionId: string;
|
||||
contextSummary: {
|
||||
recentActivity: IndexEntry[];
|
||||
userCorrections: UserCorrectionRecord[];
|
||||
unfinishedSessions: SessionJson[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface EndSessionResult {
|
||||
sessionId: string;
|
||||
integrity: IntegrityCheck[];
|
||||
correctionCount: number;
|
||||
summaryPath: string;
|
||||
}
|
||||
|
||||
export interface IntegrityCheck {
|
||||
check: string;
|
||||
passed: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface RecoverResult {
|
||||
level: number;
|
||||
sessionId?: string;
|
||||
task?: string;
|
||||
recentActivity: IndexEntry[];
|
||||
lastTrailEntries: AuditTrailEntry[];
|
||||
userCorrections: UserCorrectionRecord[];
|
||||
conclusions: string[];
|
||||
dailyAnomalies: string[];
|
||||
dailyBacklog: string[];
|
||||
fullTrail?: AuditTrailEntry[];
|
||||
anomalies?: string[];
|
||||
}
|
||||
|
||||
export interface DailyReport {
|
||||
date: string;
|
||||
sections: {
|
||||
taskOverview: string;
|
||||
operationStats: { label: string; count: number }[];
|
||||
changes: { time: string; target: string; detail: string }[];
|
||||
userFeedback: { feedback: string; resolution: string; persistedTo: string }[];
|
||||
anomalyAlerts: string[];
|
||||
backlogTracking: string[];
|
||||
integritySummary: string;
|
||||
};
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface UserCorrectionRecord {
|
||||
record_type: 'conversation';
|
||||
action_type: 'user_correction';
|
||||
priority: 'critical_for_recovery';
|
||||
timestamp: string;
|
||||
original_claim: string;
|
||||
correction: string;
|
||||
principle_extracted: string;
|
||||
persisted_to: string[];
|
||||
}
|
||||
|
||||
function runsDir(basePath?: string): string {
|
||||
return resolve(basePath ?? process.cwd(), RUNS_REL);
|
||||
}
|
||||
|
||||
function dailyDir(basePath?: string): string {
|
||||
return resolve(basePath ?? process.cwd(), DAILY_REL);
|
||||
}
|
||||
|
||||
function sessionDir(sessionId: string, basePath?: string): string {
|
||||
return join(runsDir(basePath), sessionId);
|
||||
}
|
||||
|
||||
function currentSessionPath(basePath?: string): string {
|
||||
return join(runsDir(basePath), '.current_session');
|
||||
}
|
||||
|
||||
function indexJsonPath(basePath?: string): string {
|
||||
return join(runsDir(basePath), 'index.json');
|
||||
}
|
||||
|
||||
function auditBufferPath(basePath?: string): string {
|
||||
return join(runsDir(basePath), 'audit_buffer.jsonl');
|
||||
}
|
||||
|
||||
function auditPendingPath(basePath?: string): string {
|
||||
return join(runsDir(basePath), 'audit_pending.jsonl');
|
||||
}
|
||||
|
||||
function trailPath(sessionId: string, basePath?: string): string {
|
||||
return join(sessionDir(sessionId, basePath), 'audit_trail.jsonl');
|
||||
}
|
||||
|
||||
function sessionJsonPath(sessionId: string, basePath?: string): string {
|
||||
return join(sessionDir(sessionId, basePath), 'session.json');
|
||||
}
|
||||
|
||||
function summaryPath(sessionId: string, basePath?: string): string {
|
||||
return join(sessionDir(sessionId, basePath), 'session_summary.md');
|
||||
}
|
||||
|
||||
export function generateSessionId(): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
return `adhoc_${y}${m}${d}_${hh}${mm}`;
|
||||
}
|
||||
|
||||
function isoNow(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function isoDate(d?: Date): string {
|
||||
const dt = d ?? new Date();
|
||||
return `${dt.getFullYear()}${String(dt.getMonth() + 1).padStart(2, '0')}${String(dt.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function isTodayIso(iso: string): boolean {
|
||||
return iso.startsWith(new Date().toISOString().slice(0, 10));
|
||||
}
|
||||
|
||||
function tryParseJson<T>(raw: string): T | null {
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDir(p: string): Promise<void> {
|
||||
if (!existsSync(p)) {
|
||||
await mkdir(p, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function readLines(p: string): Promise<string[]> {
|
||||
try {
|
||||
const content = await readFile(p, 'utf-8');
|
||||
return content.split('\n').filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(p: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await readFile(p, 'utf-8');
|
||||
return tryParseJson<T>(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appendLine(p: string, line: string): Promise<void> {
|
||||
return appendFile(p, line + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
async function clearFile(p: string): Promise<void> {
|
||||
try {
|
||||
await writeFile(p, '', 'utf-8');
|
||||
} catch {
|
||||
// File may not exist
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentSession(basePath?: string): Promise<string | null> {
|
||||
try {
|
||||
const raw = await readFile(currentSessionPath(basePath), 'utf-8');
|
||||
return raw.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessionJson(sessionId: string, basePath?: string): Promise<SessionJson | null> {
|
||||
return readJsonFile<SessionJson>(sessionJsonPath(sessionId, basePath));
|
||||
}
|
||||
|
||||
export async function getIndex(basePath?: string): Promise<IndexJson | null> {
|
||||
return readJsonFile<IndexJson>(indexJsonPath(basePath));
|
||||
}
|
||||
|
||||
async function writeIndex(entries: IndexEntry[], basePath?: string): Promise<void> {
|
||||
await ensureDir(runsDir(basePath));
|
||||
await writeFile(indexJsonPath(basePath), JSON.stringify({ entries }, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
async function appendIndex(sessionId: string, task: string, basePath?: string): Promise<void> {
|
||||
const existing = await getIndex(basePath);
|
||||
const entry: IndexEntry = {
|
||||
id: sessionId,
|
||||
task,
|
||||
status: 'in_progress',
|
||||
record_count: 0,
|
||||
start_time: isoNow(),
|
||||
};
|
||||
const entries = [entry, ...(existing?.entries ?? [])].slice(0, 100);
|
||||
await writeIndex(entries, basePath);
|
||||
}
|
||||
|
||||
async function updateIndexStatus(sessionId: string, status: string, basePath?: string): Promise<void> {
|
||||
const idx = await getIndex(basePath);
|
||||
if (!idx) return;
|
||||
for (const e of idx.entries) {
|
||||
if (e.id === sessionId) {
|
||||
e.status = status;
|
||||
}
|
||||
}
|
||||
await writeIndex(idx.entries, basePath);
|
||||
}
|
||||
|
||||
export async function startSession(task: string, basePath?: string): Promise<StartSessionResult> {
|
||||
const sessionId = generateSessionId();
|
||||
const sDir = sessionDir(sessionId, basePath);
|
||||
await ensureDir(sDir);
|
||||
|
||||
const session: SessionJson = {
|
||||
session_id: sessionId,
|
||||
task,
|
||||
start_time: isoNow(),
|
||||
status: 'in_progress',
|
||||
expected_record_types: ['data', 'change', 'conversation'],
|
||||
};
|
||||
|
||||
await writeFile(sessionJsonPath(sessionId, basePath), JSON.stringify(session, null, 2), 'utf-8');
|
||||
await writeFile(currentSessionPath(basePath), sessionId, 'utf-8');
|
||||
await appendIndex(sessionId, task, basePath);
|
||||
|
||||
// L0 context recovery
|
||||
const idx = await getIndex(basePath);
|
||||
const recentActivity = idx?.entries.slice(0, 5) ?? [];
|
||||
|
||||
// L2 user correction scan
|
||||
const allCorrections = await scanAllTrailsForCorrections(basePath);
|
||||
|
||||
// Check for unfinished sessions
|
||||
const unfinishedSessions = await findUnfinishedSessions(basePath);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
contextSummary: {
|
||||
recentActivity,
|
||||
userCorrections: allCorrections,
|
||||
unfinishedSessions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function findUnfinishedSessions(basePath?: string): Promise<SessionJson[]> {
|
||||
const rDir = runsDir(basePath);
|
||||
if (!existsSync(rDir)) return [];
|
||||
|
||||
const entries = await readdir(rDir, { withFileTypes: true });
|
||||
const unfinished: SessionJson[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const sess = await getSessionJson(entry.name, basePath);
|
||||
if (sess && sess.status === 'in_progress') {
|
||||
unfinished.push(sess);
|
||||
}
|
||||
}
|
||||
|
||||
return unfinished;
|
||||
}
|
||||
|
||||
async function scanAllTrailsForCorrections(basePath?: string): Promise<UserCorrectionRecord[]> {
|
||||
const rDir = runsDir(basePath);
|
||||
if (!existsSync(rDir)) return [];
|
||||
|
||||
const entries = await readdir(rDir, { withFileTypes: true });
|
||||
const corrections: UserCorrectionRecord[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const lines = await readLines(trailPath(entry.name, basePath));
|
||||
for (const line of lines) {
|
||||
const record = tryParseJson<UserCorrectionRecord>(line);
|
||||
if (record?.action_type === 'user_correction') {
|
||||
corrections.push(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also scan audit_pending.jsonl
|
||||
const pendingLines = await readLines(auditPendingPath(basePath));
|
||||
for (const line of pendingLines) {
|
||||
const record = tryParseJson<UserCorrectionRecord>(line);
|
||||
if (record?.action_type === 'user_correction') {
|
||||
corrections.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
return corrections;
|
||||
}
|
||||
|
||||
export async function endSession(basePath?: string): Promise<EndSessionResult | null> {
|
||||
const sessionId = await getCurrentSession(basePath);
|
||||
if (!sessionId) return null;
|
||||
|
||||
const sDir = sessionDir(sessionId, basePath);
|
||||
await ensureDir(sDir);
|
||||
|
||||
// Collect remaining buffer data
|
||||
const bufferLines = await readLines(auditBufferPath(basePath));
|
||||
const pendingLines = await readLines(auditPendingPath(basePath));
|
||||
const allRemaining = [...bufferLines, ...pendingLines];
|
||||
|
||||
// Append to audit_trail.jsonl
|
||||
const trail = trailPath(sessionId, basePath);
|
||||
if (allRemaining.length > 0) {
|
||||
await appendFile(trail, allRemaining.join('\n') + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
// Clear buffer files
|
||||
await clearFile(auditBufferPath(basePath));
|
||||
await clearFile(auditPendingPath(basePath));
|
||||
|
||||
// Read current trail for stats
|
||||
const trailLines = await readLines(trail);
|
||||
|
||||
// Extract user_correction records
|
||||
const corrections: UserCorrectionRecord[] = [];
|
||||
for (const line of trailLines) {
|
||||
const record = tryParseJson<UserCorrectionRecord>(line);
|
||||
if (record?.action_type === 'user_correction') {
|
||||
corrections.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
// Integrity checks
|
||||
const integrity: IntegrityCheck[] = [
|
||||
{
|
||||
check: 'Audit records exist',
|
||||
passed: trailLines.length > 0,
|
||||
detail: trailLines.length > 0 ? `${trailLines.length} records` : 'No audit records found',
|
||||
},
|
||||
{
|
||||
check: 'File modifications tracked',
|
||||
passed: trailLines.some((l) => {
|
||||
const r = tryParseJson<AuditTrailEntry>(l);
|
||||
return r && (r.tool === 'Write' || r.tool === 'Edit');
|
||||
}),
|
||||
detail: 'Checking for Write/Edit tool entries',
|
||||
},
|
||||
{
|
||||
check: 'User corrections persisted',
|
||||
passed: corrections.every((c) => (c.persisted_to?.length ?? 0) > 0),
|
||||
detail: corrections.length > 0
|
||||
? `${corrections.length} corrections found, ${corrections.filter((c) => (c.persisted_to?.length ?? 0) > 0).length} persisted`
|
||||
: 'No corrections to persist',
|
||||
},
|
||||
];
|
||||
|
||||
// Generate session summary
|
||||
const summaryContent = generateSessionSummary(sessionId, trailLines, corrections);
|
||||
const summaryFile = summaryPath(sessionId, basePath);
|
||||
await writeFile(summaryFile, summaryContent, 'utf-8');
|
||||
|
||||
// Update session.json
|
||||
const session = await getSessionJson(sessionId, basePath);
|
||||
if (session) {
|
||||
session.status = 'completed';
|
||||
session.end_time = isoNow();
|
||||
await writeFile(sessionJsonPath(sessionId, basePath), JSON.stringify(session, null, 2), 'utf-8');
|
||||
await updateIndexStatus(sessionId, 'completed', basePath);
|
||||
}
|
||||
|
||||
// Update index.json record count
|
||||
const idx = await getIndex(basePath);
|
||||
if (idx) {
|
||||
for (const e of idx.entries) {
|
||||
if (e.id === sessionId) {
|
||||
e.record_count = trailLines.length;
|
||||
e.status = 'completed';
|
||||
}
|
||||
}
|
||||
await writeIndex(idx.entries, basePath);
|
||||
}
|
||||
|
||||
// Clear .current_session
|
||||
try {
|
||||
await rm(currentSessionPath(basePath));
|
||||
} catch {
|
||||
// Ok if already gone
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
integrity,
|
||||
correctionCount: corrections.length,
|
||||
summaryPath: summaryFile,
|
||||
};
|
||||
}
|
||||
|
||||
function generateSessionSummary(
|
||||
sessionId: string,
|
||||
trailLines: string[],
|
||||
corrections: UserCorrectionRecord[],
|
||||
): string {
|
||||
const actions: string[] = [];
|
||||
const outputs: string[] = [];
|
||||
|
||||
for (const line of trailLines) {
|
||||
const record = tryParseJson<AuditTrailEntry>(line);
|
||||
if (record) {
|
||||
if (record.action_type) actions.push(record.action_type);
|
||||
if (record.output) outputs.push(record.output);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
`# Session Summary | ${sessionId}`,
|
||||
'',
|
||||
`## Time: ${isoNow()}`,
|
||||
`## Status: completed`,
|
||||
'',
|
||||
'## Completed work',
|
||||
...actions.map((a) => `- ${a}`),
|
||||
'',
|
||||
'## Key conclusions',
|
||||
...outputs.map((o) => `- ${o}`),
|
||||
'',
|
||||
'## User corrections',
|
||||
...(corrections.length > 0
|
||||
? corrections.map((c) => `- ${c.original_claim} → ${c.correction} (${c.principle_extracted})`)
|
||||
: ['- None']),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function recoverSession(
|
||||
level: number,
|
||||
specificSessionId?: string,
|
||||
basePath?: string,
|
||||
): Promise<RecoverResult> {
|
||||
const result: RecoverResult = { level, recentActivity: [], lastTrailEntries: [], userCorrections: [], conclusions: [], dailyAnomalies: [], dailyBacklog: [] };
|
||||
|
||||
// L0: index summary
|
||||
const idx = await getIndex(basePath);
|
||||
result.recentActivity = idx?.entries.slice(0, 5) ?? [];
|
||||
|
||||
if (level === 0) return result;
|
||||
|
||||
// L1: current session + last 3 trail entries
|
||||
let activeSessionId = specificSessionId ?? await getCurrentSession(basePath);
|
||||
if (activeSessionId) {
|
||||
result.sessionId = activeSessionId;
|
||||
const session = await getSessionJson(activeSessionId, basePath);
|
||||
if (session) {
|
||||
result.task = session.task;
|
||||
}
|
||||
|
||||
const trailLines = await readLines(trailPath(activeSessionId, basePath));
|
||||
result.lastTrailEntries = trailLines.slice(-3).map((l) => {
|
||||
const r = tryParseJson<AuditTrailEntry>(l);
|
||||
return r ?? { timestamp: '', record_type: '', action_type: '', input: l, output: l };
|
||||
});
|
||||
}
|
||||
|
||||
if (level === 1) return result;
|
||||
|
||||
// L2: user corrections + conclusions + daily anomalies
|
||||
result.userCorrections = await scanAllTrailsForCorrections(basePath);
|
||||
|
||||
// Extract conclusions from trail entries
|
||||
const allTrailLines = await readLines(trailPath(activeSessionId ?? '', basePath));
|
||||
for (const line of allTrailLines) {
|
||||
const record = tryParseJson<AuditTrailEntry>(line);
|
||||
if (record?.output) {
|
||||
result.conclusions.push(record.output);
|
||||
}
|
||||
}
|
||||
|
||||
// Read daily reports for anomalies + backlog
|
||||
const dDir = dailyDir(basePath);
|
||||
if (existsSync(dDir)) {
|
||||
const dailyFiles = (await readdir(dDir)).filter((f) => f.endsWith('_daily.md')).sort().reverse();
|
||||
if (dailyFiles.length > 0) {
|
||||
const latest = await readFile(join(dDir, dailyFiles[0]!), 'utf-8');
|
||||
const anomalies = latest.match(/## (?:四|4).*?[\s\S]*?(?=##|$)/);
|
||||
if (anomalies) result.dailyAnomalies.push(anomalies[0]);
|
||||
const backlog = latest.match(/## (?:六|6).*?[\s\S]*?(?=##|$)/);
|
||||
if (backlog) result.dailyBacklog.push(backlog[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (level === 2) return result;
|
||||
|
||||
// L3: full trail + pending
|
||||
if (level >= 3) {
|
||||
if (activeSessionId) {
|
||||
const fullLines = await readLines(trailPath(activeSessionId, basePath));
|
||||
result.fullTrail = fullLines.map((l) => {
|
||||
const r = tryParseJson<AuditTrailEntry>(l);
|
||||
return r ?? { timestamp: '', record_type: '', action_type: '', input: l, output: l };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function generateDailyReport(
|
||||
targetDate?: string,
|
||||
review?: boolean,
|
||||
basePath?: string,
|
||||
): Promise<DailyReport> {
|
||||
const date = targetDate ?? isoDate();
|
||||
const idx = await getIndex(basePath);
|
||||
const rDir = runsDir(basePath);
|
||||
|
||||
const todayEntries = (idx?.entries ?? []).filter((e) => e.start_time.startsWith(date.slice(0, 4) + '-' + date.slice(4, 6) + '-' + date.slice(6, 8)));
|
||||
|
||||
let totalWriteEdit = 0;
|
||||
let totalBash = 0;
|
||||
let totalAuditBlocks = 0;
|
||||
const changes: { time: string; target: string; detail: string }[] = [];
|
||||
const feedback: { feedback: string; resolution: string; persistedTo: string }[] = [];
|
||||
const anomalies: string[] = [];
|
||||
|
||||
for (const entry of todayEntries) {
|
||||
const lines = await readLines(trailPath(entry.id, basePath));
|
||||
for (const line of lines) {
|
||||
const record = tryParseJson<AuditTrailEntry>(line);
|
||||
if (!record) continue;
|
||||
if (record.tool === 'Write' || record.tool === 'Edit') totalWriteEdit++;
|
||||
if (record.tool === 'Bash') totalBash++;
|
||||
if (record.action_type === 'audit_block') totalAuditBlocks++;
|
||||
if (record.tool && (record.tool === 'Write' || record.tool === 'Edit') && record.files) {
|
||||
changes.push({ time: record.timestamp, target: record.files.join(', '), detail: record.detail ?? '' });
|
||||
}
|
||||
if (record.action_type === 'user_correction') {
|
||||
const uc = record as unknown as UserCorrectionRecord;
|
||||
feedback.push({ feedback: uc.original_claim, resolution: uc.correction, persistedTo: (uc.persisted_to ?? []).join(', ') });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for anomalies.json
|
||||
if (existsSync(rDir)) {
|
||||
const sessionDirs = await readdir(rDir, { withFileTypes: true });
|
||||
for (const d of sessionDirs) {
|
||||
if (!d.isDirectory()) continue;
|
||||
const anomPath = join(rDir, d.name, 'anomalies.json');
|
||||
if (existsSync(anomPath)) {
|
||||
const anomContent = await readFile(anomPath, 'utf-8');
|
||||
anomalies.push(`[${d.name}] ${anomContent.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read previous day backlog
|
||||
const prevDate = isoDate(new Date(Date.now() - 86400000));
|
||||
let backlog: string[] = [];
|
||||
const prevDailyPath = join(dailyDir(basePath), `${prevDate}_daily.md`);
|
||||
if (existsSync(prevDailyPath)) {
|
||||
const prevContent = await readFile(prevDailyPath, 'utf-8');
|
||||
const m = prevContent.match(/## (?:六|6|明日待办)[\s\S]*?(?=##|$)/);
|
||||
if (m) backlog = m[0].split('\n').filter((l) => l.trim().startsWith('-')).map((l) => l.replace(/^-\s*/, ''));
|
||||
}
|
||||
|
||||
const reportPath = join(dailyDir(basePath), `${date}_daily.md`);
|
||||
await ensureDir(dailyDir(basePath));
|
||||
|
||||
const sections = {
|
||||
taskOverview: todayEntries.length > 0
|
||||
? todayEntries.map((e) => `| ${e.id} | ${e.task} | ${e.status} | ${e.record_count} |`).join('\n')
|
||||
: 'No activity',
|
||||
operationStats: [
|
||||
{ label: 'Write/Edit operations', count: totalWriteEdit },
|
||||
{ label: 'Bash executions', count: totalBash },
|
||||
{ label: 'Audit blocks', count: totalAuditBlocks },
|
||||
],
|
||||
changes,
|
||||
userFeedback: feedback,
|
||||
anomalyAlerts: anomalies,
|
||||
backlogTracking: backlog,
|
||||
integritySummary: [
|
||||
`| All sessions have audit records | ${todayEntries.every((e) => e.record_count > 0) ? '✅' : '⚠️'} |`,
|
||||
`| Audit blocks persisted | ${totalAuditBlocks > 0 ? '✅' : '⚠️'} |`,
|
||||
`| User corrections persisted | ${feedback.every((f) => f.persistedTo.length > 0) ? '✅' : '⚠️'} |`,
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
const reportContent = generateDailyReportContent(date, sections);
|
||||
await writeFile(reportPath, reportContent, 'utf-8');
|
||||
|
||||
// If review mode, also generate morning review
|
||||
if (review) {
|
||||
const reviewPath = join(dailyDir(basePath), `${date}_morning_review.md`);
|
||||
const reviewContent = generateMorningReview(sections, date);
|
||||
await writeFile(reviewPath, reviewContent, 'utf-8');
|
||||
}
|
||||
|
||||
return { date, sections, path: reportPath };
|
||||
}
|
||||
|
||||
function generateDailyReportContent(date: string, sections: DailyReport['sections']): string {
|
||||
return [
|
||||
`# Work Report | ${date}`,
|
||||
'',
|
||||
`> Auto-generated: ${isoNow()}`,
|
||||
`> Data source: .boo/runs/index.json + session audit_trail`,
|
||||
`> Coverage: ${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)} 00:00 — 23:59`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## I. Task Overview',
|
||||
'',
|
||||
'| Session ID | Task | Status | Records |',
|
||||
'|-----------|------|--------|---------|',
|
||||
sections.taskOverview,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## II. Operation Stats',
|
||||
'',
|
||||
'| Metric | Count |',
|
||||
'|--------|-------|',
|
||||
...sections.operationStats.map((s) => `| ${s.label} | ${s.count} |`),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## III. Change Records',
|
||||
'',
|
||||
...(sections.changes.length > 0
|
||||
? ['| Time | Target | Detail |', '|------|--------|--------|', ...sections.changes.map((c) => `| ${c.time} | ${c.target} | ${c.detail} |`)]
|
||||
: ['No changes recorded today.']),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## IV. User Feedback & Corrections',
|
||||
'',
|
||||
...(sections.userFeedback.length > 0
|
||||
? ['| Feedback | Resolution | Persisted To |', '|---------|------------|--------------|', ...sections.userFeedback.map((f) => `| ${f.feedback} | ${f.resolution} | ${f.persistedTo} |`)]
|
||||
: ['None.']),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## V. Anomaly Alerts',
|
||||
'',
|
||||
...(sections.anomalyAlerts.length > 0 ? sections.anomalyAlerts.map((a) => `- ${a}`) : ['None.']),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## VI. Backlog Tracking',
|
||||
'',
|
||||
...(sections.backlogTracking.length > 0 ? sections.backlogTracking.map((b) => `- ${b}`) : ['None.']),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## VII. Integrity Summary',
|
||||
'',
|
||||
'| Check | Result |',
|
||||
'|-------|--------|',
|
||||
sections.integritySummary,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function generateMorningReview(sections: DailyReport['sections'], date: string): string {
|
||||
const anomalies = sections.anomalyAlerts;
|
||||
const hasUnhandledAnomalies = anomalies.some((a) => !a.includes('resolved'));
|
||||
const hasUnpersistedFeedback = sections.userFeedback.some((f) => !f.persistedTo);
|
||||
const hasIncompleteBacklog = sections.backlogTracking.length > 0;
|
||||
|
||||
return [
|
||||
`# Morning Self-Review | ${date}`,
|
||||
'',
|
||||
`> Generated: ${isoNow()}`,
|
||||
'',
|
||||
'## Self-Correction Check',
|
||||
'',
|
||||
`- Unresolved anomalies: ${hasUnhandledAnomalies ? '⚠️ Yes — needs attention' : '✅ None'}`,
|
||||
`- Unpersisted user feedback: ${hasUnpersistedFeedback ? '⚠️ Needs documentation' : '✅ All persisted'}`,
|
||||
`- Outstanding backlog: ${hasIncompleteBacklog ? '⚠️ Carry-over items' : '✅ Clean slate'}`,
|
||||
'',
|
||||
'## Today\'s Recommended Priorities',
|
||||
'',
|
||||
...(sections.backlogTracking.length > 0
|
||||
? sections.backlogTracking.map((b) => `- [ ] ${b} (carry-over)`)
|
||||
: []),
|
||||
'- [ ] Review yesterday\'s user feedback and persist any remaining corrections',
|
||||
'- [ ] Continue highest-priority task from session overview',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function ensureBooDirs(basePath?: string): Promise<void> {
|
||||
await ensureDir(runsDir(basePath));
|
||||
await ensureDir(dailyDir(basePath));
|
||||
}
|
||||
|
||||
export async function writeAuditBuffer(entry: AuditTrailEntry, basePath?: string): Promise<void> {
|
||||
await ensureDir(runsDir(basePath));
|
||||
await appendLine(auditBufferPath(basePath), JSON.stringify(entry));
|
||||
}
|
||||
|
||||
export async function writeAuditPending(entry: AuditTrailEntry, basePath?: string): Promise<void> {
|
||||
await ensureDir(runsDir(basePath));
|
||||
await appendLine(auditPendingPath(basePath), JSON.stringify(entry));
|
||||
}
|
||||
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');
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import type { Config } from '../config.js';
|
||||
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
|
||||
import { applyAll } from './pending_changes.js';
|
||||
import { asPermissionMode } from './tools/types.js';
|
||||
import { createCheckpoint } from './checkpoints.js';
|
||||
import { makeDcpStreamStripper } from './dcp-strip.js';
|
||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||
@@ -32,7 +32,13 @@ import {
|
||||
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
|
||||
|
||||
interface InferenceRunner {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||
enqueue: (
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
assistantId: string,
|
||||
user: string,
|
||||
permissionMode?: 'plan' | 'ask' | 'bypass',
|
||||
) => void;
|
||||
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
hasActive: (chatId: string) => boolean;
|
||||
}
|
||||
@@ -358,8 +364,9 @@ export function createDispatcher(deps: Deps): {
|
||||
`;
|
||||
const assistantId = assistantMsg!.id;
|
||||
|
||||
// Enqueue inference
|
||||
inference.enqueue(sessionId, chatId, assistantId, 'default');
|
||||
// Enqueue inference — pass the native permission gate (plan/ask/bypass)
|
||||
// through to the write-tool context. Non-unified mode ids → undefined.
|
||||
inference.enqueue(sessionId, chatId, assistantId, 'default', asPermissionMode(task.mode_id));
|
||||
|
||||
// Wait for inference to complete (poll message status)
|
||||
const finalStatus = await waitForCompletion(assistantId);
|
||||
@@ -392,22 +399,6 @@ export function createDispatcher(deps: Deps): {
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
|
||||
// Bypass permission mode: auto-apply the staged edits to disk after the
|
||||
// turn. Ask/Plan leave them in the pending-changes queue for review.
|
||||
if (task.mode_id === 'bypass') {
|
||||
try {
|
||||
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${task.project_id}`;
|
||||
if (proj?.path) {
|
||||
const applied = await applyAll(sql, sessionId, proj.path);
|
||||
log.info({ taskId, applied: applied.length }, 'dispatcher: native bypass auto-applied pending changes');
|
||||
}
|
||||
} catch (applyErr) {
|
||||
log.warn(
|
||||
{ taskId, err: applyErr instanceof Error ? applyErr.message : String(applyErr) },
|
||||
'dispatcher: native bypass auto-apply failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const [msg] = await sql<{ content: string | null }[]>`
|
||||
SELECT content FROM messages WHERE id = ${assistantId}
|
||||
|
||||
47
apps/coder/src/services/edit-guards-imports.ts
Normal file
47
apps/coder/src/services/edit-guards-imports.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// edit-guards-imports — detects dropped imports in edited files.
|
||||
// Ported from opencode-morph-fast-apply (MIT).
|
||||
|
||||
export interface ImportCheckResult {
|
||||
ok: boolean;
|
||||
missingImports: string[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const IMPORT_PATTERNS = [
|
||||
/^import\s+(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"][^'"]+['"]\s*;?$/m,
|
||||
/^import\s+['"][^'"]+['"]\s*;?$/m,
|
||||
/^export\s+.*\s+from\s+['"][^'"]+['"]\s*;?$/m,
|
||||
/^require\s*\(\s*['"][^'"]+['"]\s*\)\s*;?$/m,
|
||||
/^import\s+type\s+\{[^}]*\}\s+from\s+['"][^'"]+['"]\s*;?$/m,
|
||||
];
|
||||
|
||||
function extractImportLines(content: string): string[] {
|
||||
return content.split('\n').filter((line) =>
|
||||
IMPORT_PATTERNS.some((p) => p.test(line.trim())),
|
||||
);
|
||||
}
|
||||
|
||||
export function checkDroppedImports(
|
||||
original: string,
|
||||
updated: string,
|
||||
filePath: string,
|
||||
): ImportCheckResult {
|
||||
const originalImports = extractImportLines(original);
|
||||
const updatedImports = extractImportLines(updated);
|
||||
|
||||
if (originalImports.length === 0) {
|
||||
return { ok: true, missingImports: [] };
|
||||
}
|
||||
|
||||
const missing = originalImports.filter((imp) => !updatedImports.includes(imp));
|
||||
|
||||
if (missing.length > 0 && originalImports.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
missingImports: missing,
|
||||
reason: `Edit would drop ${missing.length} import(s) from ${filePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, missingImports: [] };
|
||||
}
|
||||
42
apps/coder/src/services/edit-guards.ts
Normal file
42
apps/coder/src/services/edit-guards.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// v2.8 Morph safety guards — prevents catastrophic truncation, marker leakage,
|
||||
// and accidental import deletion during native edit_file application.
|
||||
// Ported from opencode-morph-fast-apply (MIT) with threshold values preserved.
|
||||
|
||||
export interface GuardResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
charLoss?: number;
|
||||
lineLoss?: number;
|
||||
}
|
||||
|
||||
const TRUNCATION_CHAR_THRESHOLD = 0.6;
|
||||
const TRUNCATION_LINE_THRESHOLD = 0.5;
|
||||
|
||||
export function validateEditResult(
|
||||
original: string,
|
||||
updated: string,
|
||||
filePath: string,
|
||||
): GuardResult {
|
||||
// Check for catastrophic content truncation
|
||||
if (original.length > 0 && updated.length > 0) {
|
||||
const charLoss = 1 - updated.length / original.length;
|
||||
const originalLines = original.split('\n').length;
|
||||
const updatedLines = updated.split('\n').length;
|
||||
const lineLoss = 1 - updatedLines / originalLines;
|
||||
|
||||
if (charLoss > TRUNCATION_CHAR_THRESHOLD && lineLoss > TRUNCATION_LINE_THRESHOLD) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Edit would truncate ${Math.round(charLoss * 100)}% of characters and ${Math.round(lineLoss * 100)}% of lines`,
|
||||
charLoss,
|
||||
lineLoss,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function formatGuardError(guard: GuardResult, filePath: string): string {
|
||||
return `Edit guard rejected change to ${filePath}: ${guard.reason ?? 'unknown error'}`;
|
||||
}
|
||||
23
apps/coder/src/services/flow-artifacts.ts
Normal file
23
apps/coder/src/services/flow-artifacts.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const ARTIFACTS_ROOT = 'data/flow-artifacts';
|
||||
|
||||
export function getArtifactPath(flowRunId: string, stepId: string): string {
|
||||
return join(ARTIFACTS_ROOT, flowRunId, `${stepId}.md`);
|
||||
}
|
||||
|
||||
export async function writeFlowArtifact(
|
||||
flowRunId: string,
|
||||
stepId: string,
|
||||
content: string,
|
||||
): Promise<string> {
|
||||
const dir = join(ARTIFACTS_ROOT, flowRunId);
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
const path = getArtifactPath(flowRunId, stepId);
|
||||
await writeFile(path, content, 'utf8');
|
||||
return path;
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
* "Settled" = done ∪ skipped ∪ excluded. Only settled deps unblock a step;
|
||||
* an inFlight dep does NOT (the runner waits for its terminal callback).
|
||||
*/
|
||||
import type { Flow, Step, StepContext } from '../conductor/types.js';
|
||||
import type { Flow, Step, StepContext, TriggerRule } from '../conductor/types.js';
|
||||
|
||||
export interface SchedulerState {
|
||||
/** step ids that completed successfully (results available) */
|
||||
@@ -62,7 +62,7 @@ export function readySteps(flow: Flow, state: SchedulerState): Step[] {
|
||||
!state.skipped.has(s.id) &&
|
||||
!state.inFlight.has(s.id) &&
|
||||
!state.excluded.has(s.id) &&
|
||||
(s.deps ?? []).every((d) => isSatisfied(state, d)),
|
||||
((s.deps ?? []).length === 0 || evaluateTriggerRule(s.deps ?? [], state.done, state.skipped, state.excluded, s.trigger_rule)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,6 +167,32 @@ export function shouldFailOnMissingAgent(agent: string, modeId: string | null):
|
||||
return agent === 'qwen' && modeId === 'plan';
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a trigger rule against dependency results.
|
||||
* - all_success: every dep must be done (not skipped/failed)
|
||||
* - one_success: at least one dep must be done
|
||||
* - all_done: every dep must be settled regardless of outcome
|
||||
*/
|
||||
export function evaluateTriggerRule(
|
||||
deps: string[],
|
||||
done: ReadonlySet<string>,
|
||||
skipped: ReadonlySet<string>,
|
||||
excluded: ReadonlySet<string>,
|
||||
rule?: TriggerRule,
|
||||
): boolean {
|
||||
if (deps.length === 0) return true;
|
||||
const satisfied = new Set([...done, ...skipped, ...excluded]);
|
||||
|
||||
switch (rule ?? 'all_success') {
|
||||
case 'all_success':
|
||||
return deps.every((d) => done.has(d) || skipped.has(d) || excluded.has(d));
|
||||
case 'one_success':
|
||||
return deps.some((d) => done.has(d));
|
||||
case 'all_done':
|
||||
return deps.every((d) => satisfied.has(d));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile every step of an in-flight run for startup resume. Returns one
|
||||
* decision per step. Pure — no IO.
|
||||
|
||||
@@ -346,6 +346,20 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
continue; // re-evaluate — code output can unblock the next wave
|
||||
}
|
||||
|
||||
// Approval gate steps: pause and wait for human decision.
|
||||
const approvalReady = toRun.filter((s) => s.kind === 'approval');
|
||||
if (approvalReady.length > 0) {
|
||||
for (const s of approvalReady) {
|
||||
await sql`
|
||||
UPDATE flow_steps SET status = 'blocked', updated_at = clock_timestamp()
|
||||
WHERE run_id = ${runId} AND step_id = ${s.id}
|
||||
`;
|
||||
await appendStepEvent(sql, runId, s.id, 'paused', { reason: 'awaiting approval' });
|
||||
publishStep(runId, s.id, 'blocked');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only agent steps remain ready → dispatch the whole parallel wave, then wait.
|
||||
for (const s of toRun) {
|
||||
await dispatchAgentStep(runId, run.project_id, model, s, ctx);
|
||||
@@ -378,7 +392,8 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
// flow's step.run already bakes in the evidence/YAGNI contracts.
|
||||
const persona = step.agent ? await loadPersona(step.agent) : '';
|
||||
const taskPrompt = await step.run(ctx);
|
||||
const fullPrompt = persona ? `${persona}\n\n---\n\n${taskPrompt}` : taskPrompt;
|
||||
const resolvedPrompt = resolveVariables(taskPrompt, ctx.results);
|
||||
const fullPrompt = persona ? `${persona}\n\n---\n\n${resolvedPrompt}` : resolvedPrompt;
|
||||
|
||||
// READ-ONLY (D-4): agent='qwen', mode_id='plan' are hardcoded, never
|
||||
// user-overridable. The dispatcher's qwen+plan rule forces the PTY hard gate.
|
||||
@@ -392,6 +407,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
SET task_id = ${task!.id}, status = 'running', input = ${fullPrompt}, updated_at = clock_timestamp()
|
||||
WHERE run_id = ${runId} AND step_id = ${step.id}
|
||||
`;
|
||||
await appendStepEvent(sql, runId, step.id, 'started', { taskId: task!.id });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -438,6 +454,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
WHERE run_id = ${runId} AND step_id = ${stepId}
|
||||
`;
|
||||
}
|
||||
await appendStepEvent(sql, runId, stepId, status, output ? { outputLength: output.length } : undefined);
|
||||
}
|
||||
|
||||
// ─── run completion ─────────────────────────────────────────────────────────
|
||||
@@ -483,6 +500,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
if (updated.count === 0) return;
|
||||
const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run');
|
||||
log.warn({ runId, error }, 'flow-runner: run failed');
|
||||
await appendStepEvent(sql, runId, stepId, 'failed', { error });
|
||||
publishStep(runId, stepId, 'failed', { run_status: 'failed' });
|
||||
}
|
||||
|
||||
@@ -522,7 +540,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
function publishStep(
|
||||
runId: string,
|
||||
stepId: string,
|
||||
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled',
|
||||
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'blocked',
|
||||
extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string },
|
||||
): void {
|
||||
publishUser({
|
||||
@@ -763,3 +781,40 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
// ─── Event log ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function appendStepEvent(
|
||||
sql: Sql,
|
||||
runId: string,
|
||||
stepId: string,
|
||||
event: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await sql`
|
||||
INSERT INTO flow_step_events (run_id, step_id, event, payload)
|
||||
VALUES (${runId}, ${stepId}, ${event}, ${payload ? sql.json(payload as never) : null})
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── Variable substitution ───────────────────────────────────────────────────
|
||||
|
||||
const VAR_PATTERN = /\$(\w+)\.output(?:\.(\w+(?:\.\w+)*))?/g;
|
||||
|
||||
export function resolveVariables(prompt: string, results: Record<string, string>): string {
|
||||
return prompt.replace(VAR_PATTERN, (match, stepId, fieldPath) => {
|
||||
const output = results[stepId];
|
||||
if (!output) return match;
|
||||
if (!fieldPath) return output;
|
||||
try {
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
const parsed = line.match(new RegExp(`^${fieldPath}:\\s*(.+)$`, 'i'));
|
||||
if (parsed) return parsed[1]!.trim();
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +21,16 @@
|
||||
// punctuation to ASCII on both sides; the match is
|
||||
// mapped back to original offsets.
|
||||
// 4. levenshtein — best line-window by normalized edit-distance
|
||||
// similarity; accepted only at >= SIMILARITY_THRESHOLD.
|
||||
// similarity; accepted only at >= SIMILARITY_THRESHOLD,
|
||||
// anchored on an exact first+last line for multi-line
|
||||
// needles, and REFUSED (ambiguous) when a second window
|
||||
// scores within AMBIGUITY_EPSILON of the best. Like the
|
||||
// exact/whitespace tiers, this tier fails CLOSED — it
|
||||
// never splices over a merely-plausible guess, because a
|
||||
// wrong-window splice corrupts the file (it leaves the
|
||||
// real target intact and duplicates it). This mirrors
|
||||
// opencode/cline/qwen, whose fuzzy tiers all keep the
|
||||
// unique-match requirement rather than picking a winner.
|
||||
//
|
||||
// Pure and dependency-free (Levenshtein is the standard iterative two-row DP),
|
||||
// reimplemented from the general technique — no vendored source.
|
||||
@@ -31,8 +40,31 @@ export type MatchResult =
|
||||
| { kind: 'ambiguous'; count: number }
|
||||
| { kind: 'not_found' };
|
||||
|
||||
/** Levenshtein similarity floor for the final fuzzy fallback (strategy 4). */
|
||||
export const SIMILARITY_THRESHOLD = 0.66;
|
||||
/**
|
||||
* Levenshtein similarity floor for the final fuzzy fallback (strategy 4).
|
||||
* 0.66 was far too low — at two-thirds similarity a structurally-wrong window
|
||||
* (e.g. one of three near-identical form blocks) clears the bar and gets spliced
|
||||
* over, leaving the real target intact and duplicated. Competent agents anchor
|
||||
* far tighter (opencode's BlockAnchor needs an exact anchor; cline needs exact
|
||||
* first+last lines). 0.85 keeps genuine quantized-model drift (a typo, an indent
|
||||
* shift) while refusing a different block.
|
||||
*/
|
||||
export const SIMILARITY_THRESHOLD = 0.85;
|
||||
|
||||
/**
|
||||
* If a second candidate window scores within this of the best, the match is
|
||||
* ambiguous and tier 4 refuses rather than guessing — the same fail-closed
|
||||
* stance the exact and whitespace tiers take on multiple hits. Repetitive files
|
||||
* (the duplicate-block corruption case) produce near-tied windows; this is what
|
||||
* turns that into a clean "add more context" error instead of a wrong splice.
|
||||
*/
|
||||
export const AMBIGUITY_EPSILON = 0.05;
|
||||
|
||||
/** Multi-line needles at or above this length must anchor on an exact (after
|
||||
* trim + unicode-fold) first AND last line before similarity is even scored —
|
||||
* the cline/opencode block-anchor rule. Below it, threshold + uniqueness alone
|
||||
* guard the match. */
|
||||
const ANCHOR_MIN_LINES = 3;
|
||||
|
||||
export function locateMatch(content: string, needle: string): MatchResult {
|
||||
// Empty needle has no meaningful match.
|
||||
@@ -252,20 +284,39 @@ function locateByLevenshtein(content: string, needle: string): MatchResult | nul
|
||||
|
||||
const needleJoined = needleLines.map((l) => l.trim()).join('\n');
|
||||
|
||||
let best = -1;
|
||||
let bestSpan: { start: number; end: number } | null = null;
|
||||
// Block-anchor gate for multi-line needles: the first and last lines must match
|
||||
// exactly (after trim + unicode-fold) or the window is not even scored. This
|
||||
// stops a high interior-similarity from dragging a structurally-wrong window
|
||||
// over the threshold — the failure that duplicates blocks in repetitive files.
|
||||
const anchored = n >= ANCHOR_MIN_LINES;
|
||||
const needleFirst = canonicalize(needleLines[0]!.trim());
|
||||
const needleLast = canonicalize(needleLines[n - 1]!.trim());
|
||||
|
||||
const scored: Array<{ score: number; start: number; end: number }> = [];
|
||||
for (let i = 0; i + n <= contentLines.length; i++) {
|
||||
const window = contentLines.slice(i, i + n);
|
||||
const windowJoined = window.map((l) => l.text.trim()).join('\n');
|
||||
const score = similarity(windowJoined, needleJoined);
|
||||
if (score > best) {
|
||||
best = score;
|
||||
bestSpan = { start: window[0]!.start, end: window[n - 1]!.end };
|
||||
if (anchored) {
|
||||
const winFirst = canonicalize(window[0]!.text.trim());
|
||||
const winLast = canonicalize(window[n - 1]!.text.trim());
|
||||
if (winFirst !== needleFirst || winLast !== needleLast) continue;
|
||||
}
|
||||
const windowJoined = window.map((l) => l.text.trim()).join('\n');
|
||||
scored.push({
|
||||
score: similarity(windowJoined, needleJoined),
|
||||
start: window[0]!.start,
|
||||
end: window[n - 1]!.end,
|
||||
});
|
||||
}
|
||||
|
||||
if (bestSpan && best >= SIMILARITY_THRESHOLD) {
|
||||
return { kind: 'fuzzy', start: bestSpan.start, end: bestSpan.end };
|
||||
}
|
||||
return null;
|
||||
if (scored.length === 0) return null;
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const best = scored[0]!;
|
||||
if (best.score < SIMILARITY_THRESHOLD) return null;
|
||||
|
||||
// Uniqueness guard: refuse when a second window is within epsilon of the best.
|
||||
// Fail closed (ambiguous) rather than silently splicing one of several lookalikes.
|
||||
const tied = scored.filter((s) => s.score >= best.score - AMBIGUITY_EPSILON);
|
||||
if (tied.length > 1) return { kind: 'ambiguous', count: tied.length };
|
||||
|
||||
return { kind: 'fuzzy', start: best.start, end: best.end };
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
75
apps/coder/src/services/lsp/client.ts
Normal file
75
apps/coder/src/services/lsp/client.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createInterface } from 'node:readline';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
interface RpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
id: number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
interface RpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
|
||||
export class LspClient {
|
||||
private nextId = 1;
|
||||
private pending = new Map<number, { resolve: (v: RpcResponse) => void; reject: (e: Error) => void }>();
|
||||
private buffer = '';
|
||||
|
||||
constructor(
|
||||
private stdin: Writable,
|
||||
private stdout: Readable,
|
||||
) {
|
||||
const rl = createInterface({ input: stdout, crlfDelay: Infinity });
|
||||
rl.on('line', (line) => this.handleLine(line));
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
this.buffer += line + '\n';
|
||||
const match = this.buffer.match(/Content-Length: (\d+)\r?\n\r?\n/);
|
||||
if (!match || !match[1]) return;
|
||||
const len = parseInt(match[1], 10);
|
||||
const headerEnd = match.index! + match[0].length;
|
||||
const body = this.buffer.slice(headerEnd, headerEnd + len);
|
||||
if (body.length < len) return;
|
||||
this.buffer = this.buffer.slice(headerEnd + len);
|
||||
try {
|
||||
const msg: RpcResponse = JSON.parse(body);
|
||||
const cb = this.pending.get(msg.id);
|
||||
if (cb) {
|
||||
this.pending.delete(msg.id);
|
||||
cb.resolve(msg);
|
||||
}
|
||||
} catch {
|
||||
// Malformed JSON, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async request(method: string, params?: unknown): Promise<unknown> {
|
||||
const id = this.nextId++;
|
||||
const req: RpcRequest = { jsonrpc: '2.0', id, method, params };
|
||||
const body = JSON.stringify(req);
|
||||
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, {
|
||||
resolve: (resp) => {
|
||||
if (resp.error) reject(new Error(resp.error.message));
|
||||
else resolve(resp.result);
|
||||
},
|
||||
reject,
|
||||
});
|
||||
this.stdin.write(header + body);
|
||||
});
|
||||
}
|
||||
|
||||
async notify(method: string, params?: unknown): Promise<void> {
|
||||
const body = JSON.stringify({ jsonrpc: '2.0', method, params });
|
||||
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
|
||||
this.stdin.write(header + body);
|
||||
}
|
||||
}
|
||||
19
apps/coder/src/services/lsp/config.ts
Normal file
19
apps/coder/src/services/lsp/config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface LspServerConfig {
|
||||
command: string;
|
||||
args: string[];
|
||||
rootPatterns: string[];
|
||||
}
|
||||
|
||||
const TS_CONFIG: LspServerConfig = {
|
||||
command: 'typescript-language-server',
|
||||
args: ['--stdio'],
|
||||
rootPatterns: ['package.json', 'tsconfig.json'],
|
||||
};
|
||||
|
||||
const SUPPORTED_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs']);
|
||||
|
||||
export function getServerConfig(filePath: string): LspServerConfig | null {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
if (ext && SUPPORTED_EXTS.has(ext)) return TS_CONFIG;
|
||||
return null;
|
||||
}
|
||||
86
apps/coder/src/services/lsp/operations.ts
Normal file
86
apps/coder/src/services/lsp/operations.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { LspClient } from './client.js';
|
||||
import type { Diagnostic, Location } from './types.js';
|
||||
|
||||
function fileUri(filePath: string): string {
|
||||
return `file://${filePath.startsWith('/') ? '' : '/'}${filePath}`;
|
||||
}
|
||||
|
||||
export async function openDocument(
|
||||
client: LspClient,
|
||||
filePath: string,
|
||||
content: string,
|
||||
version: number = 1,
|
||||
): Promise<void> {
|
||||
const uri = fileUri(filePath);
|
||||
await client.notify('textDocument/didOpen', {
|
||||
textDocument: { uri, languageId: 'typescript', version, text: content },
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeDocument(client: LspClient, filePath: string): Promise<void> {
|
||||
await client.notify('textDocument/didClose', {
|
||||
textDocument: { uri: fileUri(filePath) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDiagnostics(
|
||||
client: LspClient,
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<Diagnostic[]> {
|
||||
const uri = fileUri(filePath);
|
||||
await openDocument(client, filePath, content);
|
||||
const result: any = await client.request('textDocument/diagnostic', {
|
||||
textDocument: { uri },
|
||||
});
|
||||
await closeDocument(client, filePath);
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
if (result?.diagnostics) {
|
||||
for (const d of result.diagnostics) {
|
||||
diagnostics.push({
|
||||
range: d.range,
|
||||
severity: d.severity ?? 1,
|
||||
message: d.message,
|
||||
source: d.source,
|
||||
});
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export async function gotoDefinition(
|
||||
client: LspClient,
|
||||
filePath: string,
|
||||
content: string,
|
||||
line: number,
|
||||
character: number,
|
||||
): Promise<Location | null> {
|
||||
const uri = fileUri(filePath);
|
||||
await openDocument(client, filePath, content);
|
||||
const result: any = await client.request('textDocument/definition', {
|
||||
textDocument: { uri },
|
||||
position: { line, character },
|
||||
});
|
||||
await closeDocument(client, filePath);
|
||||
if (!result) return null;
|
||||
const loc = Array.isArray(result) ? result[0] : result;
|
||||
return loc ? { uri: loc.uri, range: loc.range } : null;
|
||||
}
|
||||
|
||||
export async function findReferences(
|
||||
client: LspClient,
|
||||
filePath: string,
|
||||
content: string,
|
||||
line: number,
|
||||
character: number,
|
||||
): Promise<Location[]> {
|
||||
const uri = fileUri(filePath);
|
||||
await openDocument(client, filePath, content);
|
||||
const result: any = await client.request('textDocument/references', {
|
||||
textDocument: { uri },
|
||||
position: { line, character },
|
||||
context: { includeDeclaration: true },
|
||||
});
|
||||
await closeDocument(client, filePath);
|
||||
return (result ?? []).map((loc: any) => ({ uri: loc.uri, range: loc.range }));
|
||||
}
|
||||
119
apps/coder/src/services/lsp/server-manager.ts
Normal file
119
apps/coder/src/services/lsp/server-manager.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { LspClient } from './client.js';
|
||||
import { getServerConfig } from './config.js';
|
||||
|
||||
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const SWEEP_INTERVAL_MS = 30_000;
|
||||
|
||||
interface LspInstance {
|
||||
client: LspClient;
|
||||
proc: ChildProcess;
|
||||
lastUsed: number;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class LspServerManager {
|
||||
private instances = new Map<string, LspInstance>();
|
||||
private sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.startSweeper();
|
||||
}
|
||||
|
||||
private startSweeper(): void {
|
||||
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
|
||||
this.sweepTimer.unref?.();
|
||||
}
|
||||
|
||||
private findProjectRoot(filePath: string): string | null {
|
||||
let dir = filePath;
|
||||
const config = getServerConfig(filePath);
|
||||
if (!config) return null;
|
||||
while (true) {
|
||||
for (const pattern of config.rootPatterns) {
|
||||
if (existsSync(join(dir, pattern))) return dir;
|
||||
}
|
||||
const parent = join(dir, '..');
|
||||
if (parent === dir) return dir;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
async getClient(filePath: string): Promise<LspClient | null> {
|
||||
const config = getServerConfig(filePath);
|
||||
if (!config) return null;
|
||||
const projectRoot = this.findProjectRoot(filePath);
|
||||
if (!projectRoot) return null;
|
||||
|
||||
const existing = this.instances.get(projectRoot);
|
||||
if (existing) {
|
||||
existing.lastUsed = Date.now();
|
||||
clearTimeout(existing.timer);
|
||||
existing.timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
|
||||
existing.timer.unref?.();
|
||||
return existing.client;
|
||||
}
|
||||
|
||||
return this.spawn(projectRoot, config.command, config.args);
|
||||
}
|
||||
|
||||
private async spawn(projectRoot: string, command: string, args: string[]): Promise<LspClient> {
|
||||
const proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: projectRoot });
|
||||
const client = new LspClient(proc.stdin!, proc.stdout!);
|
||||
|
||||
await client.request('initialize', {
|
||||
processId: process.pid,
|
||||
rootUri: `file://${projectRoot}`,
|
||||
capabilities: {
|
||||
textDocument: {
|
||||
diagnostic: { dynamicRegistration: false },
|
||||
definition: { dynamicRegistration: false },
|
||||
references: { dynamicRegistration: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.notify('initialized', {});
|
||||
|
||||
const timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
|
||||
this.instances.set(projectRoot, { client, proc, lastUsed: Date.now(), timer });
|
||||
proc.on('exit', () => this.instances.delete(projectRoot));
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private kill(projectRoot: string): void {
|
||||
const inst = this.instances.get(projectRoot);
|
||||
if (!inst) return;
|
||||
this.instances.delete(projectRoot);
|
||||
inst.proc.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (inst.proc.exitCode === null) inst.proc.kill('SIGKILL');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private sweep(): void {
|
||||
const now = Date.now();
|
||||
for (const [root, inst] of this.instances) {
|
||||
if (now - inst.lastUsed > IDLE_TIMEOUT_MS) {
|
||||
this.kill(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
||||
for (const root of [...this.instances.keys()]) {
|
||||
this.kill(root);
|
||||
}
|
||||
}
|
||||
|
||||
getActiveCount(): number {
|
||||
return this.instances.size;
|
||||
}
|
||||
}
|
||||
|
||||
export const lspManager = new LspServerManager();
|
||||
28
apps/coder/src/services/lsp/types.ts
Normal file
28
apps/coder/src/services/lsp/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface Position {
|
||||
line: number;
|
||||
character: number;
|
||||
}
|
||||
|
||||
export interface Range {
|
||||
start: Position;
|
||||
end: Position;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
uri: string;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export interface Diagnostic {
|
||||
range: Range;
|
||||
severity: number;
|
||||
message: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface TextDocumentItem {
|
||||
uri: string;
|
||||
languageId: string;
|
||||
version: number;
|
||||
text: string;
|
||||
}
|
||||
@@ -1,9 +1,120 @@
|
||||
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { readFile, writeFile, unlink, mkdir, rename, realpath } from 'node:fs/promises';
|
||||
import { dirname, join, basename } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { Sql } from '../db.js';
|
||||
import { resolveWritePath } from './write_guard.js';
|
||||
import { locateMatch } from './fuzzy-match.js';
|
||||
|
||||
/**
|
||||
* Write a file atomically: stage to a sibling temp file, then rename over the
|
||||
* target. rename(2) on the same filesystem is atomic, so a crash mid-write can
|
||||
* never leave a half-written (truncated/corrupt) source file — readers see
|
||||
* either the old content or the complete new content. The temp lives in the same
|
||||
* directory to guarantee a same-filesystem rename.
|
||||
*
|
||||
* Symlinks: a plain writeFile FOLLOWS a symlink and writes through to its target;
|
||||
* a bare rename would REPLACE the link with a regular file. We realpath an
|
||||
* existing target first so the rename lands on the real file and the link
|
||||
* survives — preserving the prior follow-through behavior. A missing target
|
||||
* (create, or a broken link) just writes the literal path.
|
||||
*/
|
||||
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
|
||||
let target = filePath;
|
||||
try {
|
||||
target = await realpath(filePath);
|
||||
} catch {
|
||||
// ENOENT (new file) or broken link — write the literal path.
|
||||
}
|
||||
const tmp = join(dirname(target), `.${basename(target)}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`);
|
||||
await writeFile(tmp, content, 'utf8');
|
||||
try {
|
||||
await rename(tmp, target);
|
||||
} catch (err) {
|
||||
await unlink(tmp).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect a file's dominant line ending so an edit can preserve it. */
|
||||
function detectEol(text: string): '\r\n' | '\n' {
|
||||
return text.includes('\r\n') ? '\r\n' : '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the read-modify-write of a single file so two concurrent applies
|
||||
* (e.g. two chat tabs sharing one worktree, or a Bypass write racing an
|
||||
* apply_pending) can't lose an update. In-process keying is sufficient —
|
||||
* BooCoder is a single Fastify process. One Map entry per distinct path.
|
||||
*/
|
||||
const fileLocks = new Map<string, Promise<void>>();
|
||||
async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = fileLocks.get(filePath) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((r) => { release = r; });
|
||||
fileLocks.set(filePath, prev.then(() => current));
|
||||
await prev.catch(() => {});
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edit-apply planning (pure, unit-tested) ---------------------------------
|
||||
|
||||
/**
|
||||
* Decision for applying one queued edit to a file's current content. Pulled out
|
||||
* of `applyOne` so the splice — the part that actually corrupted files — is pure
|
||||
* and testable without a DB or filesystem. Mirrors how opencode/cline/qwen keep
|
||||
* their matchers fail-closed and idempotent.
|
||||
*/
|
||||
export type EditPlan =
|
||||
| { kind: 'apply'; updated: string }
|
||||
| { kind: 'noop'; reason: 'identical' | 'already-applied' }
|
||||
| { kind: 'ambiguous'; count: number }
|
||||
| { kind: 'not_found' };
|
||||
|
||||
/**
|
||||
* Decide how (or whether) to apply an `old → new` edit to `content`.
|
||||
*
|
||||
* Idempotency is the whole point here: a queued edit can legitimately be
|
||||
* re-applied (a local model re-emits the same tool call; a turn is retried; the
|
||||
* same change sits in the queue twice). A naive splice stamps the new text again
|
||||
* each time — the 2–3× block duplication. Two guards make re-application a no-op:
|
||||
*
|
||||
* - already-applied (anchored insert): when `new` is `old` + an appended block
|
||||
* (`old="anchor"`, `new="anchor\n<block>"`), `old` still matches uniquely after
|
||||
* the first apply, so a second apply would duplicate `<block>`. If the full
|
||||
* `new` text is already present at the match site, the edit is already applied.
|
||||
* - already-applied (old gone): if `old` can't be located but `new` is already
|
||||
* in the file, the change landed on a prior pass — treat as a no-op, not an error.
|
||||
* - identical: the splice would not change the file.
|
||||
*
|
||||
* Anything ambiguous or genuinely absent fails CLOSED so the caller surfaces a
|
||||
* correctable error instead of writing a guess.
|
||||
*/
|
||||
export function planEdit(content: string, oldStr: string, newStr: string): EditPlan {
|
||||
const match = locateMatch(content, oldStr);
|
||||
|
||||
if (match.kind === 'ambiguous') return { kind: 'ambiguous', count: match.count };
|
||||
|
||||
if (match.kind === 'not_found') {
|
||||
if (newStr.length > 0 && content.includes(newStr)) {
|
||||
return { kind: 'noop', reason: 'already-applied' };
|
||||
}
|
||||
return { kind: 'not_found' };
|
||||
}
|
||||
|
||||
const updated = content.slice(0, match.start) + newStr + content.slice(match.end);
|
||||
// No-change splice first (covers old === new), then the anchored re-stamp guard:
|
||||
// the full replacement already sits at the match site (re-emitted anchored insert).
|
||||
if (updated === content) return { kind: 'noop', reason: 'identical' };
|
||||
if (content.slice(match.start, match.start + newStr.length) === newStr) {
|
||||
return { kind: 'noop', reason: 'already-applied' };
|
||||
}
|
||||
return { kind: 'apply', updated };
|
||||
}
|
||||
|
||||
// --- Types -------------------------------------------------------------------
|
||||
|
||||
export interface PendingChange {
|
||||
@@ -47,6 +158,13 @@ export async function queueEdit(
|
||||
const resolved = resolveWritePath(projectRoot, filePath);
|
||||
const diff = JSON.stringify({ old: oldString, new: newString });
|
||||
|
||||
// Idempotent queue: collapse an identical edit that is still pending. Local
|
||||
// quantized models re-emit the same edit_file call within a turn, and a retried
|
||||
// turn re-queues — each duplicate row would apply and stamp another copy. One
|
||||
// pending row per (session, file, operation, diff) is enough.
|
||||
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'edit', diff);
|
||||
if (existing) return existing;
|
||||
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent})
|
||||
@@ -55,6 +173,28 @@ export async function queueEdit(
|
||||
return row!;
|
||||
}
|
||||
|
||||
/** Return an identical still-pending change for this (session, file, op, diff),
|
||||
* or undefined. Used to keep the queue idempotent against re-emitted edits. */
|
||||
async function findPendingDuplicate(
|
||||
sql: Sql,
|
||||
sessionId: string,
|
||||
resolvedPath: string,
|
||||
operation: 'create' | 'edit' | 'delete',
|
||||
diff: string,
|
||||
): Promise<PendingChange | undefined> {
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
SELECT * FROM pending_changes
|
||||
WHERE session_id = ${sessionId}
|
||||
AND file_path = ${resolvedPath}
|
||||
AND operation = ${operation}
|
||||
AND diff = ${diff}
|
||||
AND status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function queueCreate(
|
||||
sql: Sql,
|
||||
sessionId: string,
|
||||
@@ -68,6 +208,9 @@ export async function queueCreate(
|
||||
): Promise<PendingChange> {
|
||||
const resolved = resolveWritePath(projectRoot, filePath);
|
||||
|
||||
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'create', content);
|
||||
if (existing) return existing;
|
||||
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent})
|
||||
@@ -87,6 +230,9 @@ export async function queueDelete(
|
||||
): Promise<PendingChange> {
|
||||
const resolved = resolveWritePath(projectRoot, filePath);
|
||||
|
||||
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'delete', '');
|
||||
if (existing) return existing;
|
||||
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent})
|
||||
@@ -110,31 +256,42 @@ export async function applyOne(
|
||||
}
|
||||
|
||||
try {
|
||||
return await withFileLock(change.file_path, async () => {
|
||||
// Re-validate path in case projectRoot has shifted
|
||||
resolveWritePath(projectRoot, change.file_path);
|
||||
|
||||
switch (change.operation) {
|
||||
case 'create': {
|
||||
await mkdir(dirname(change.file_path), { recursive: true });
|
||||
await writeFile(change.file_path, change.diff, 'utf8');
|
||||
await writeFileAtomic(change.file_path, change.diff);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||
const content = await readFile(change.file_path, 'utf8');
|
||||
const match = locateMatch(content, oldStr);
|
||||
if (match.kind === 'ambiguous') {
|
||||
const raw = await readFile(change.file_path, 'utf8');
|
||||
// Normalize to LF for matching, then write back in the file's native EOL
|
||||
// so an LF-emitting model doesn't leave a CRLF file with mixed endings.
|
||||
const eol = detectEol(raw);
|
||||
const toLf = (t: string) => t.replaceAll('\r\n', '\n');
|
||||
const plan = planEdit(toLf(raw), toLf(oldStr), toLf(newStr));
|
||||
if (plan.kind === 'ambiguous') {
|
||||
throw new Error(
|
||||
`old_string matches ${match.count} locations — add surrounding context to disambiguate`,
|
||||
`old_string matches ${plan.count} locations — add surrounding context to disambiguate`,
|
||||
);
|
||||
}
|
||||
if (match.kind === 'not_found') {
|
||||
if (plan.kind === 'not_found') {
|
||||
throw new Error(
|
||||
'old_string not found in file (even fuzzily) — file may have changed since the edit was queued',
|
||||
);
|
||||
}
|
||||
const updated = content.slice(0, match.start) + newStr + content.slice(match.end);
|
||||
await writeFile(change.file_path, updated, 'utf8');
|
||||
if (plan.kind === 'apply') {
|
||||
const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated;
|
||||
await writeFileAtomic(change.file_path, out);
|
||||
} else {
|
||||
// noop: the edit is already applied (re-emitted / retried) or a no-change.
|
||||
// Mark it applied without rewriting so it can't stamp a duplicate.
|
||||
console.log(`[pending] edit ${change.file_path} is a no-op (${plan.reason}) — not rewriting`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
@@ -152,6 +309,7 @@ export async function applyOne(
|
||||
|
||||
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
|
||||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
||||
@@ -220,13 +378,13 @@ export async function rewindOne(
|
||||
);
|
||||
}
|
||||
const reverted = content.slice(0, match.start) + oldStr + content.slice(match.end);
|
||||
await writeFile(change.file_path, reverted, 'utf8');
|
||||
await writeFileAtomic(change.file_path, reverted);
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
// Reverse a delete: recreate the file (diff holds the original content stashed at apply time)
|
||||
await mkdir(dirname(change.file_path), { recursive: true });
|
||||
await writeFile(change.file_path, change.diff, 'utf8');
|
||||
await writeFileAtomic(change.file_path, change.diff);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { analyzeMessages } from '../analyzer.js';
|
||||
|
||||
describe('analyzeMessages', () => {
|
||||
it('classifies user messages', () => {
|
||||
const breakdown = analyzeMessages([{ role: 'user', content: 'hello world' }]);
|
||||
expect(breakdown.user).toBeGreaterThan(0);
|
||||
expect(breakdown.total).toBe(breakdown.user);
|
||||
});
|
||||
|
||||
it('counts tool calls', () => {
|
||||
const parts = [
|
||||
{ role: 'assistant', content: 'using grep', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: '{"files":[]}', tool_call_id: '1' },
|
||||
];
|
||||
const breakdown = analyzeMessages(parts);
|
||||
expect(breakdown.tools).toBeGreaterThan(0);
|
||||
expect(breakdown.assistant).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('separates reasoning tokens', () => {
|
||||
const parts = [
|
||||
{ role: 'assistant', content: 'short answer', reasoning_parts: [{ text: 'long chain of thought reasoning here' }] },
|
||||
];
|
||||
const breakdown = analyzeMessages(parts);
|
||||
expect(breakdown.reasoning).toBeGreaterThan(0);
|
||||
expect(breakdown.assistant).toBeLessThan(breakdown.reasoning);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('persistTaskBreakdown', () => {
|
||||
it('exports functions', async () => {
|
||||
const mod = await import('../persist.js');
|
||||
expect(typeof mod.persistTaskBreakdown).toBe('function');
|
||||
expect(typeof mod.getTaskBreakdown).toBe('function');
|
||||
expect(typeof mod.analyzeAndPersistTaskBreakdown).toBe('function');
|
||||
});
|
||||
});
|
||||
60
apps/coder/src/services/token-analysis/analyzer.ts
Normal file
60
apps/coder/src/services/token-analysis/analyzer.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// TokenScope analyzer — classifies message parts into category breakdown.
|
||||
// Ported from opencode-tokenscope (MIT).
|
||||
|
||||
export interface TokenBreakdown {
|
||||
system: number;
|
||||
user: number;
|
||||
assistant: number;
|
||||
tools: number;
|
||||
reasoning: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
export function analyzeMessages(parts: any[]): TokenBreakdown {
|
||||
const breakdown: TokenBreakdown = { system: 0, user: 0, assistant: 0, tools: 0, reasoning: 0, total: 0 };
|
||||
|
||||
for (const part of parts) {
|
||||
const role = part.role ?? '';
|
||||
const content = part.content ?? '';
|
||||
const tokens = estimateTokens(content);
|
||||
|
||||
switch (role) {
|
||||
case 'system':
|
||||
breakdown.system += tokens;
|
||||
break;
|
||||
case 'user':
|
||||
breakdown.user += tokens;
|
||||
break;
|
||||
case 'assistant':
|
||||
breakdown.assistant += tokens;
|
||||
if (part.tool_calls) {
|
||||
for (const tc of part.tool_calls) {
|
||||
breakdown.tools += estimateTokens(JSON.stringify(tc));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'tool':
|
||||
breakdown.tools += tokens;
|
||||
break;
|
||||
default:
|
||||
breakdown.assistant += tokens;
|
||||
}
|
||||
|
||||
if (part.reasoning_parts) {
|
||||
for (const rp of part.reasoning_parts) {
|
||||
const rTokens = estimateTokens(rp.text ?? '');
|
||||
breakdown.reasoning += rTokens;
|
||||
breakdown.assistant -= rTokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
breakdown.total = breakdown.system + breakdown.user + breakdown.assistant + breakdown.tools + breakdown.reasoning;
|
||||
return breakdown;
|
||||
}
|
||||
35
apps/coder/src/services/token-analysis/persist.ts
Normal file
35
apps/coder/src/services/token-analysis/persist.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// TokenScope persistence — writes breakdown to task records.
|
||||
import type { Sql } from '../../db.js';
|
||||
import type { TokenBreakdown } from './analyzer.js';
|
||||
|
||||
export async function persistTaskBreakdown(
|
||||
sql: Sql,
|
||||
taskId: string,
|
||||
breakdown: TokenBreakdown,
|
||||
): Promise<void> {
|
||||
await sql`
|
||||
UPDATE tasks SET token_breakdown = ${sql.json(breakdown as never)}
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getTaskBreakdown(
|
||||
sql: Sql,
|
||||
taskId: string,
|
||||
): Promise<TokenBreakdown | null> {
|
||||
const rows = await sql<{ token_breakdown: any }[]>`
|
||||
SELECT token_breakdown FROM tasks WHERE id = ${taskId}
|
||||
`;
|
||||
return rows[0]?.token_breakdown ?? null;
|
||||
}
|
||||
|
||||
export async function analyzeAndPersistTaskBreakdown(
|
||||
sql: Sql,
|
||||
taskId: string,
|
||||
parts: any[],
|
||||
): Promise<TokenBreakdown> {
|
||||
const { analyzeMessages } = await import('./analyzer.js');
|
||||
const breakdown = analyzeMessages(parts);
|
||||
await persistTaskBreakdown(sql, taskId, breakdown);
|
||||
return breakdown;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runWithInferenceContext, getInferenceContext } from '../inference_context.js';
|
||||
import type { Sql } from '../../../db.js';
|
||||
|
||||
const fakeSql = {} as unknown as Sql;
|
||||
|
||||
describe('inference context (AsyncLocalStorage isolation)', () => {
|
||||
it('throws when read outside a run', () => {
|
||||
expect(() => getInferenceContext()).toThrow(/outside inference context/);
|
||||
});
|
||||
|
||||
it('keeps each run its own context across overlapping awaits', async () => {
|
||||
// The race the global `let current` had: run B starts (and would overwrite a
|
||||
// shared global) while run A is awaiting. After A resumes it must still read
|
||||
// its OWN sessionId, not B's.
|
||||
const run = (id: string, delay: number) =>
|
||||
runWithInferenceContext({ sql: fakeSql, sessionId: id, taskId: null }, async () => {
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
return getInferenceContext().sessionId;
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([run('A', 20), run('B', 5)]);
|
||||
expect(a).toBe('A');
|
||||
expect(b).toBe('B');
|
||||
});
|
||||
|
||||
it('carries permissionMode and taskId per run', async () => {
|
||||
const result = await runWithInferenceContext(
|
||||
{ sql: fakeSql, sessionId: 's1', taskId: 't1', permissionMode: 'bypass' },
|
||||
async () => {
|
||||
await Promise.resolve();
|
||||
const ctx = getInferenceContext();
|
||||
return { taskId: ctx.taskId, mode: ctx.permissionMode };
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ taskId: 't1', mode: 'bypass' });
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,15 @@ export const applyPendingTool: ToolDef<ApplyPendingInputT> = {
|
||||
},
|
||||
},
|
||||
async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
// Under Ask (and Plan) the human approves via the Pending Changes panel — the
|
||||
// agent must not auto-apply. Bypass and legacy (undefined) may apply.
|
||||
if (context.permissionMode === 'ask' || context.permissionMode === 'plan') {
|
||||
return {
|
||||
status: 'denied',
|
||||
message:
|
||||
'Permission mode is Ask — staged changes must be approved by the user in the Pending Changes panel, not applied by the agent.',
|
||||
};
|
||||
}
|
||||
const results = await applyAll(context.sql, context.sessionId, projectRoot);
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueCreate } from '../pending_changes.js';
|
||||
import { denyReadOnly, finalizeWrite } from './write-gate.js';
|
||||
|
||||
const CreateFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
@@ -32,6 +33,7 @@ export const createFileTool: ToolDef<CreateFileInputT> = {
|
||||
},
|
||||
},
|
||||
async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
if (context.permissionMode === 'plan') return denyReadOnly('create_file');
|
||||
const change = await queueCreate(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
@@ -40,12 +42,11 @@ export const createFileTool: ToolDef<CreateFileInputT> = {
|
||||
input.content,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'create',
|
||||
message: `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
return finalizeWrite(
|
||||
context,
|
||||
projectRoot,
|
||||
change,
|
||||
`File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueDelete } from '../pending_changes.js';
|
||||
import { denyReadOnly, finalizeWrite } from './write-gate.js';
|
||||
|
||||
const DeleteFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
@@ -30,6 +31,7 @@ export const deleteFileTool: ToolDef<DeleteFileInputT> = {
|
||||
},
|
||||
},
|
||||
async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
if (context.permissionMode === 'plan') return denyReadOnly('delete_file');
|
||||
const change = await queueDelete(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
@@ -37,12 +39,11 @@ export const deleteFileTool: ToolDef<DeleteFileInputT> = {
|
||||
input.file_path,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'delete',
|
||||
message: `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
return finalizeWrite(
|
||||
context,
|
||||
projectRoot,
|
||||
change,
|
||||
`File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueEdit } from '../pending_changes.js';
|
||||
import { denyReadOnly, finalizeWrite } from './write-gate.js';
|
||||
|
||||
const EditFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
@@ -34,6 +35,7 @@ export const editFileTool: ToolDef<EditFileInputT> = {
|
||||
},
|
||||
},
|
||||
async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
if (context.permissionMode === 'plan') return denyReadOnly('edit_file');
|
||||
const change = await queueEdit(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
@@ -43,12 +45,11 @@ export const editFileTool: ToolDef<EditFileInputT> = {
|
||||
input.new_string,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'edit',
|
||||
message: `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
return finalizeWrite(
|
||||
context,
|
||||
projectRoot,
|
||||
change,
|
||||
`Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@ import { rewindTool } from './rewind.js';
|
||||
import { newTaskTool } from './new_task.js';
|
||||
import { listTasksTool } from './list_tasks.js';
|
||||
import { checkTaskStatusTool } from './check_task_status.js';
|
||||
import { lspDiagnosticsTool } from './lsp_diagnostics.js';
|
||||
import { lspGotoDefinitionTool } from './lsp_goto_definition.js';
|
||||
import { lspFindReferencesTool } from './lsp_find_references.js';
|
||||
|
||||
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
|
||||
|
||||
@@ -26,4 +29,16 @@ export const WRITE_TOOLS: readonly ToolDef<any>[] = [
|
||||
checkTaskStatusTool,
|
||||
];
|
||||
|
||||
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool };
|
||||
// Read-only agent tools for code intelligence.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const READ_TOOLS: readonly ToolDef<any>[] = [
|
||||
lspDiagnosticsTool,
|
||||
lspGotoDefinitionTool,
|
||||
lspFindReferencesTool,
|
||||
];
|
||||
|
||||
export {
|
||||
editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool,
|
||||
newTaskTool, listTasksTool, checkTaskStatusTool,
|
||||
lspDiagnosticsTool, lspGotoDefinitionTool, lspFindReferencesTool,
|
||||
};
|
||||
|
||||
@@ -1,36 +1,49 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import type { Sql } from '../../db.js';
|
||||
import type { PermissionMode } from './types.js';
|
||||
|
||||
/**
|
||||
* Module-level inference context for write tools.
|
||||
* Per-run inference context for write tools.
|
||||
*
|
||||
* Set via `setInferenceContext()` before each inference run starts.
|
||||
* Write tools read it via `getInferenceContext()` during execute.
|
||||
* Same pattern as BooChat's `loadConfig()` singleton — tools need
|
||||
* ambient state that can't be threaded through the tool-phase execute
|
||||
* signature (which is `execute(input, projectRoot, extraRoots?)`).
|
||||
* Write tools need ambient state (sql, sessionId, the permission gate) that the
|
||||
* BooChat tool-phase `execute(input, projectRoot, extraRoots?)` signature can't
|
||||
* carry. This used to be a single module-level `let current` — but the inference
|
||||
* runner's `enqueue()` is fire-and-forget, so two overlapping runs (a user
|
||||
* message racing a dispatcher-polled native task; two chat tabs streaming) would
|
||||
* clobber each other's context, and `cancel()` cleared it for ALL in-flight runs.
|
||||
*
|
||||
* AsyncLocalStorage gives each run its own context: `enqueue()` starts its async
|
||||
* loop synchronously inside `runWithInferenceContext`, so the store propagates
|
||||
* through every awaited tool execution in that run — and only that run.
|
||||
*/
|
||||
|
||||
export interface InferenceContext {
|
||||
sql: Sql;
|
||||
sessionId: string;
|
||||
taskId: string | null;
|
||||
/** Native-BooCode permission gate, set per run from the request/task mode. */
|
||||
permissionMode?: PermissionMode;
|
||||
}
|
||||
|
||||
let current: InferenceContext | null = null;
|
||||
const storage = new AsyncLocalStorage<InferenceContext>();
|
||||
|
||||
export function setInferenceContext(ctx: InferenceContext): void {
|
||||
current = ctx;
|
||||
}
|
||||
|
||||
export function clearInferenceContext(): void {
|
||||
current = null;
|
||||
/**
|
||||
* Bind `ctx` for the duration of the (possibly detached) async chain `fn` starts.
|
||||
* The inference runner kicks off its loop synchronously within this call, so all
|
||||
* downstream `await`s — including write-tool `execute` via the adapter — read the
|
||||
* same store. Concurrent runs each get their own; nothing is shared or cleared
|
||||
* out from under an in-flight run.
|
||||
*/
|
||||
export function runWithInferenceContext<T>(ctx: InferenceContext, fn: () => T): T {
|
||||
return storage.run(ctx, fn);
|
||||
}
|
||||
|
||||
export function getInferenceContext(): InferenceContext {
|
||||
if (!current) {
|
||||
const ctx = storage.getStore();
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'Write tool called outside inference context — setInferenceContext() was not called before this run',
|
||||
'Write tool called outside inference context — runWithInferenceContext() did not wrap this run',
|
||||
);
|
||||
}
|
||||
return current;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
48
apps/coder/src/services/tools/lsp_diagnostics.ts
Normal file
48
apps/coder/src/services/tools/lsp_diagnostics.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { resolveWritePath } from '../write_guard.js';
|
||||
import { lspManager } from '../lsp/server-manager.js';
|
||||
import { getDiagnostics } from '../lsp/operations.js';
|
||||
|
||||
const LspDiagnosticsInput = z.object({
|
||||
file_path: z.string().describe('Path to the file to check for diagnostics'),
|
||||
});
|
||||
|
||||
type InputT = z.infer<typeof LspDiagnosticsInput>;
|
||||
|
||||
export const lspDiagnosticsTool: ToolDef<InputT> = {
|
||||
name: 'lsp_diagnostics',
|
||||
description: 'Get TypeScript/JavaScript diagnostics (errors, warnings) for a file. Returns diagnostic messages with severity and location.',
|
||||
inputSchema: LspDiagnosticsInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lsp_diagnostics',
|
||||
description: 'Get TypeScript/JavaScript diagnostics for a file',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: 'Path to the file' },
|
||||
},
|
||||
required: ['file_path'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
|
||||
const resolved = await resolveWritePath(projectRoot, input.file_path);
|
||||
const content = await readFile(resolved, 'utf8');
|
||||
const client = await lspManager.getClient(resolved);
|
||||
if (!client) return { error: 'Unsupported file type for LSP diagnostics' };
|
||||
|
||||
const diagnostics = await getDiagnostics(client, resolved, content);
|
||||
if (diagnostics.length === 0) return { result: 'No diagnostics found.' };
|
||||
|
||||
const lines = diagnostics.map((d) => {
|
||||
const sev = ['', 'error', 'warning', 'info', 'hint'][d.severity] ?? 'unknown';
|
||||
return `[${sev}] line ${d.range.start.line + 1}:${d.range.start.character + 1} - ${d.message}`;
|
||||
});
|
||||
return { result: lines.join('\n') };
|
||||
},
|
||||
};
|
||||
49
apps/coder/src/services/tools/lsp_find_references.ts
Normal file
49
apps/coder/src/services/tools/lsp_find_references.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { z } from 'zod';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { resolveWritePath } from '../write_guard.js';
|
||||
import { lspManager } from '../lsp/server-manager.js';
|
||||
import { findReferences } from '../lsp/operations.js';
|
||||
|
||||
const LspFindReferencesInput = z.object({
|
||||
file_path: z.string().describe('Path to the source file'),
|
||||
line: z.number().int().nonnegative().describe('0-based line number'),
|
||||
character: z.number().int().nonnegative().describe('0-based character offset'),
|
||||
});
|
||||
|
||||
type InputT = z.infer<typeof LspFindReferencesInput>;
|
||||
|
||||
export const lspFindReferencesTool: ToolDef<InputT> = {
|
||||
name: 'lsp_find_references',
|
||||
description: 'Find all references to a symbol at a given position in a file.',
|
||||
inputSchema: LspFindReferencesInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lsp_find_references',
|
||||
description: 'Find all references to symbol at position',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string' },
|
||||
line: { type: 'number' },
|
||||
character: { type: 'number' },
|
||||
},
|
||||
required: ['file_path', 'line', 'character'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
|
||||
const resolved = await resolveWritePath(projectRoot, input.file_path);
|
||||
const content = await readFile(resolved, 'utf8');
|
||||
const client = await lspManager.getClient(resolved);
|
||||
if (!client) return { error: 'Unsupported file type' };
|
||||
|
||||
const refs = await findReferences(client, resolved, content, input.line, input.character);
|
||||
if (refs.length === 0) return { result: 'No references found.' };
|
||||
|
||||
const lines = refs.map((r) => `${r.uri}:${r.range.start.line + 1}:${r.range.start.character + 1}`);
|
||||
return { result: `Found ${refs.length} reference(s):\n${lines.join('\n')}` };
|
||||
},
|
||||
};
|
||||
48
apps/coder/src/services/tools/lsp_goto_definition.ts
Normal file
48
apps/coder/src/services/tools/lsp_goto_definition.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { resolveWritePath } from '../write_guard.js';
|
||||
import { lspManager } from '../lsp/server-manager.js';
|
||||
import { gotoDefinition } from '../lsp/operations.js';
|
||||
|
||||
const LspGotoDefinitionInput = z.object({
|
||||
file_path: z.string().describe('Path to the source file'),
|
||||
line: z.number().int().nonnegative().describe('0-based line number'),
|
||||
character: z.number().int().nonnegative().describe('0-based character offset'),
|
||||
});
|
||||
|
||||
type InputT = z.infer<typeof LspGotoDefinitionInput>;
|
||||
|
||||
export const lspGotoDefinitionTool: ToolDef<InputT> = {
|
||||
name: 'lsp_goto_definition',
|
||||
description: 'Find the definition of a symbol at a given position in a file.',
|
||||
inputSchema: LspGotoDefinitionInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lsp_goto_definition',
|
||||
description: 'Find definition of symbol at position',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string' },
|
||||
line: { type: 'number' },
|
||||
character: { type: 'number' },
|
||||
},
|
||||
required: ['file_path', 'line', 'character'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
|
||||
const resolved = await resolveWritePath(projectRoot, input.file_path);
|
||||
const content = await readFile(resolved, 'utf8');
|
||||
const client = await lspManager.getClient(resolved);
|
||||
if (!client) return { error: 'Unsupported file type' };
|
||||
|
||||
const loc = await gotoDefinition(client, resolved, content, input.line, input.character);
|
||||
if (!loc) return { result: 'No definition found.' };
|
||||
|
||||
return { result: `Defined at ${loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}` };
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,7 @@ const NewTaskInput = z.object({
|
||||
input: z.string().min(1).describe('Task description for the child subtask'),
|
||||
agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
|
||||
model: z.string().optional().describe('Optional: model override for the subtask'),
|
||||
background: z.boolean().optional().describe('If true, return immediately without blocking on completion'),
|
||||
});
|
||||
|
||||
type NewTaskInputT = z.infer<typeof NewTaskInput>;
|
||||
@@ -30,6 +31,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
||||
input: { type: 'string', description: 'Task description for the child subtask' },
|
||||
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
|
||||
model: { type: 'string', description: 'Optional: model override for the subtask' },
|
||||
background: { type: 'boolean', description: 'If true, returns immediately without waiting' },
|
||||
},
|
||||
required: ['input'],
|
||||
},
|
||||
@@ -50,6 +52,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
||||
return { error: 'Cannot determine project_id from current session' };
|
||||
}
|
||||
|
||||
const isBg = input.background === true;
|
||||
const [task] = await sql<{ id: string; state: string }[]>`
|
||||
INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
|
||||
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
|
||||
@@ -57,9 +60,12 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
||||
`;
|
||||
|
||||
return {
|
||||
message: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
|
||||
message: isBg
|
||||
? `Background subtask created (id: ${task!.id}). It will continue independently.`
|
||||
: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
|
||||
task_id: task!.id,
|
||||
state: task!.state,
|
||||
background: isBg,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import type { z } from 'zod';
|
||||
import type { Sql } from '../../db.js';
|
||||
|
||||
/**
|
||||
* Unified permission ladder for native BooCode inference. Gates the write tools:
|
||||
* plan — read-only: create/edit/delete are denied (no staging).
|
||||
* ask — stage to the pending-changes queue; `apply_pending` is denied so the
|
||||
* agent cannot self-apply (the human approves via the Diff panel).
|
||||
* bypass — apply each write immediately (no queue, no approval).
|
||||
* Undefined preserves the historical behavior (stage + `apply_pending` allowed).
|
||||
*/
|
||||
export type PermissionMode = 'plan' | 'ask' | 'bypass';
|
||||
|
||||
/** Narrow a raw task/request mode id to a unified PermissionMode, else undefined
|
||||
* (e.g. an external agent's native mode id, or null). */
|
||||
export function asPermissionMode(id: string | null | undefined): PermissionMode | undefined {
|
||||
return id === 'plan' || id === 'ask' || id === 'bypass' ? id : undefined;
|
||||
}
|
||||
|
||||
export interface ToolJsonSchema {
|
||||
type: 'function';
|
||||
function: {
|
||||
@@ -21,6 +37,8 @@ export interface ToolContext {
|
||||
sql: Sql;
|
||||
sessionId: string;
|
||||
taskId: string | null;
|
||||
/** Native-BooCode permission gate for write tools (undefined = legacy behavior). */
|
||||
permissionMode?: PermissionMode;
|
||||
}
|
||||
|
||||
export interface ToolDef<TInput> {
|
||||
|
||||
53
apps/coder/src/services/tools/write-gate.ts
Normal file
53
apps/coder/src/services/tools/write-gate.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Permission-gate helpers for native BooCode write tools. The gate comes from
|
||||
* the per-run inference context (`ToolContext.permissionMode`):
|
||||
* plan — deny the write (read-only); nothing is staged.
|
||||
* bypass — apply the staged change immediately (no queue, no approval).
|
||||
* ask / undefined — leave it in the pending-changes queue for review.
|
||||
*/
|
||||
import type { ToolContext } from './types.js';
|
||||
import { applyOne } from '../pending_changes.js';
|
||||
|
||||
/** Result returned when a write is denied under Plan (read-only) mode. */
|
||||
export function denyReadOnly(operation: string): unknown {
|
||||
return {
|
||||
status: 'denied',
|
||||
operation,
|
||||
message: `Read-only (Plan) permission mode — ${operation} is not permitted. Switch to Ask or Bypass to make changes.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Finalize a just-staged change per the permission gate: apply now under Bypass,
|
||||
* otherwise return it as queued for the human to approve. */
|
||||
export async function finalizeWrite(
|
||||
context: ToolContext,
|
||||
projectRoot: string,
|
||||
change: { id: string; file_path: string; operation: string },
|
||||
queuedHint: string,
|
||||
): Promise<unknown> {
|
||||
if (context.permissionMode === 'bypass') {
|
||||
const res = await applyOne(context.sql, change.id, projectRoot);
|
||||
console.log(
|
||||
`[write-gate] bypass apply ${change.operation} ${change.file_path} -> ${res.success ? 'applied' : 'FAILED: ' + (res.error ?? '?')}`,
|
||||
);
|
||||
return {
|
||||
status: res.success ? 'applied' : 'failed',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: change.operation,
|
||||
message: res.success
|
||||
? `${change.operation} applied to ${change.file_path}.`
|
||||
: `Apply failed for ${change.file_path}: ${res.error ?? 'unknown error'}. Left in the pending queue.`,
|
||||
};
|
||||
}
|
||||
console.log(
|
||||
`[write-gate] ${context.permissionMode ?? 'legacy'} queued ${change.operation} ${change.file_path}`,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: change.operation,
|
||||
message: queuedHint,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
- **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop — only `description` + `inputSchema: jsonSchema(parameters)`.
|
||||
- **`includeUsage: true` MUST be set on `createOpenAICompatible`** in `provider.ts`. The adapter defaults it false → no `stream_options.include_usage` → llama-swap emits no usage block → `result.usage` resolves `undefined` (NULL token counts). Don't remove during refactor.
|
||||
- **Tool-call-only turns may emit a leading `\n` text-delta.** `MessageList.flatten`'s `hasText` and `MessageBubble`'s `hasContent` both `.trim()` before the length check, else whitespace-only content renders an empty bubble + ActionRow between tool calls. `buildMessagesPayload` also skips `status='failed'` and complete-but-empty assistant rows (avoids "Cannot have 2 or more assistant messages at the end of the list" upstream rejection after cap-hit + Continue).
|
||||
- **`services/inference/tool-shim.ts`** — Recovers structured tool calls from plain-text model output. Some models (notably Qwen) emit `<tool_call><name>...</name><arguments>...</arguments></tool_call>` inline text instead of structured JSON. `extractToolCalls(text)` parses both XML and JSON inline formats. `hasToolCallMarkup(text)` is a fast pre-check. Used as a fallback in the stream phase when structured `tool_calls` parse fails. Does NOT require `FAST_MODEL` — operates on the existing turn's output text.
|
||||
- **`services/inference/loop-detectors.ts`** — Six detectors that catch repetitive model behavior: `detectContentRepeat` (same content N times), `detectToolLoop` (same tool called consecutively). `detectDoomLoop` combines both. These are additive to the existing `sentinels.ts` doom-loop detection.
|
||||
- **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart`; BooCode's OpenAI-shape history lacks it, so a forward-scan builds a `tool_call_id → toolName` map from prior assistant `tool_calls`. Tool outputs wrapped as `{ type: 'json' | 'text', value }` (v6 `ToolResultOutput`). Reasoning emits a `ReasoningPart` first in the content array.
|
||||
- **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn.
|
||||
- **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deduplicate } from '../strategies/deduplication.js';
|
||||
import type { DcpMessage } from '../messages.js';
|
||||
|
||||
describe('deduplicate', () => {
|
||||
it('removes consecutive identical tool_call+tool_result pairs', () => {
|
||||
const messages: DcpMessage[] = [
|
||||
{ role: 'user', content: 'search for x' },
|
||||
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: 'result1', tool_call_id: '1' },
|
||||
// Duplicate pair
|
||||
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: 'result1', tool_call_id: '2' },
|
||||
];
|
||||
|
||||
const { messages: result, stats } = deduplicate(messages);
|
||||
expect(result).toHaveLength(3); // user + first pair
|
||||
expect(stats.removedCount).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves non-duplicate content', () => {
|
||||
const messages: DcpMessage[] = [
|
||||
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: 'result1', tool_call_id: '1' },
|
||||
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: 'result2', tool_call_id: '2' }, // Different result
|
||||
];
|
||||
|
||||
const { messages: result, stats } = deduplicate(messages);
|
||||
expect(result).toHaveLength(4);
|
||||
expect(stats.removedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { toDcpMessages, fromDcpMessages } from '../messages.js';
|
||||
|
||||
describe('toDcpMessages', () => {
|
||||
it('converts user messages', () => {
|
||||
const result = toDcpMessages([{ role: 'user', content: 'hello' }]);
|
||||
expect(result[0].role).toBe('user');
|
||||
expect(result[0].content).toBe('hello');
|
||||
});
|
||||
|
||||
it('marks Error: content as isError', () => {
|
||||
const result = toDcpMessages([{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' }]);
|
||||
expect(result[0].isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromDcpMessages', () => {
|
||||
it('round-trips messages', () => {
|
||||
const original = [{ role: 'user', content: 'hello' }];
|
||||
expect(fromDcpMessages(toDcpMessages(original))).toEqual(original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { purgeErrors } from '../strategies/purge-errors.js';
|
||||
import type { DcpMessage } from '../messages.js';
|
||||
|
||||
describe('purgeErrors', () => {
|
||||
it('removes tool results where content starts with Error:', () => {
|
||||
const messages: DcpMessage[] = [
|
||||
{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' },
|
||||
{ role: 'tool', content: '{"files":[]}', tool_call_id: '2' },
|
||||
];
|
||||
const { messages: result, stats } = purgeErrors(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(stats.removedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('removes empty tool results', () => {
|
||||
const messages: DcpMessage[] = [
|
||||
{ role: 'tool', content: '', tool_call_id: '1' },
|
||||
];
|
||||
const { messages: result, stats } = purgeErrors(messages);
|
||||
expect(result).toHaveLength(0);
|
||||
expect(stats.removedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves valid tool results', () => {
|
||||
const messages: DcpMessage[] = [
|
||||
{ role: 'tool', content: '{"files":["a.ts"]}', tool_call_id: '1' },
|
||||
];
|
||||
const { messages: result, stats } = purgeErrors(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(stats.removedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { transformMessages } from '../transform.js';
|
||||
import type { DcpMessage } from '../messages.js';
|
||||
|
||||
describe('transformMessages', () => {
|
||||
it('applies dedup then purge in order', () => {
|
||||
const input: DcpMessage[] = [
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: 'result', tool_call_id: '1' },
|
||||
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: 'result', tool_call_id: '2' }, // Dup
|
||||
];
|
||||
|
||||
const { messages, stats } = transformMessages('test-chat', input);
|
||||
expect(stats.removedCount).toBeGreaterThan(0);
|
||||
expect(messages.length).toBeLessThan(input.length);
|
||||
});
|
||||
|
||||
it('handles empty input', () => {
|
||||
const { messages, stats } = transformMessages('empty', []);
|
||||
expect(messages).toHaveLength(0);
|
||||
expect(stats.removedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
4
apps/server/src/services/inference/dcp/index.ts
Normal file
4
apps/server/src/services/inference/dcp/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { transformMessages } from './transform.js';
|
||||
export type { DcpMessage } from './messages.js';
|
||||
export { toDcpMessages, fromDcpMessages } from './messages.js';
|
||||
export { getDcpState, clearDcpState } from './state.js';
|
||||
34
apps/server/src/services/inference/dcp/messages.ts
Normal file
34
apps/server/src/services/inference/dcp/messages.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// DCP message shape adapter.
|
||||
// Converts between BooCode MessagePart[] and the DCP internal shape.
|
||||
// Clean-room implementation — no AGPL source copied.
|
||||
|
||||
export interface DcpMessage {
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: Array<{ id: string; name: string; arguments: string }>;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export function toDcpMessages(parts: any[]): DcpMessage[] {
|
||||
return parts.map((p: any) => {
|
||||
const msg: DcpMessage = { role: p.role, content: p.content ?? '' };
|
||||
if (p.tool_call_id) msg.tool_call_id = p.tool_call_id;
|
||||
if (p.tool_calls) msg.tool_calls = p.tool_calls;
|
||||
if (p.isError) msg.isError = true;
|
||||
if (p.role === 'tool' && p.content && p.content.startsWith('Error:')) {
|
||||
msg.isError = true;
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
}
|
||||
|
||||
export function fromDcpMessages(msgs: DcpMessage[]): any[] {
|
||||
return msgs.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
||||
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
|
||||
...(m.isError ? { isError: true } : {}),
|
||||
}));
|
||||
}
|
||||
27
apps/server/src/services/inference/dcp/state.ts
Normal file
27
apps/server/src/services/inference/dcp/state.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Per-chat session state for DCP.
|
||||
// Tracks last transform timestamp and message count to avoid re-processing.
|
||||
|
||||
interface ChatDcpState {
|
||||
lastTransformAt: number;
|
||||
lastMessageCount: number;
|
||||
}
|
||||
|
||||
const chatStates = new Map<string, ChatDcpState>();
|
||||
|
||||
export function getDcpState(chatId: string): ChatDcpState | undefined {
|
||||
return chatStates.get(chatId);
|
||||
}
|
||||
|
||||
export function setDcpState(chatId: string, messageCount: number): void {
|
||||
chatStates.set(chatId, { lastTransformAt: Date.now(), lastMessageCount: messageCount });
|
||||
}
|
||||
|
||||
export function clearDcpState(chatId: string): void {
|
||||
chatStates.delete(chatId);
|
||||
}
|
||||
|
||||
export function shouldTransform(chatId: string, messageCount: number): boolean {
|
||||
const state = chatStates.get(chatId);
|
||||
if (!state) return true;
|
||||
return state.lastMessageCount !== messageCount;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { DcpMessage } from '../messages.js';
|
||||
|
||||
export function deduplicate(messages: DcpMessage[]): { messages: DcpMessage[]; stats: { removedCount: number; freedTokens: number } } {
|
||||
const result: DcpMessage[] = [];
|
||||
let removedCount = 0;
|
||||
let freedTokens = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < messages.length) {
|
||||
const current: DcpMessage = messages[i]!;
|
||||
const next = messages[i + 1];
|
||||
|
||||
if (
|
||||
current.role === 'assistant' &&
|
||||
current.tool_calls &&
|
||||
next &&
|
||||
next.role === 'tool' &&
|
||||
next.tool_call_id === current.tool_calls[0]?.id
|
||||
) {
|
||||
const nextNext = messages[i + 2];
|
||||
const nextNextNext = messages[i + 3];
|
||||
|
||||
if (
|
||||
nextNext &&
|
||||
nextNext.role === 'assistant' &&
|
||||
nextNext.tool_calls &&
|
||||
nextNextNext &&
|
||||
nextNextNext.role === 'tool' &&
|
||||
nextNextNext.tool_call_id === nextNext.tool_calls[0]?.id &&
|
||||
nextNext.tool_calls[0]?.name === current.tool_calls[0]?.name &&
|
||||
nextNext.tool_calls[0]?.arguments === current.tool_calls[0]?.arguments &&
|
||||
nextNextNext.content === next.content
|
||||
) {
|
||||
result.push(current, next);
|
||||
i += 4;
|
||||
removedCount += 2;
|
||||
freedTokens += Math.ceil(nextNext.content.length / 4);
|
||||
freedTokens += Math.ceil(current.content.length / 4);
|
||||
} else {
|
||||
result.push(current);
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
result.push(current);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return { messages: result, stats: { removedCount, freedTokens } };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Purge-errors strategy — removes failed/empty tool_result entries.
|
||||
// Clean-room implementation.
|
||||
|
||||
import type { DcpMessage } from '../messages.js';
|
||||
|
||||
const ERROR_PREFIXES = ['Error:', 'error:', 'Error: '];
|
||||
const DEFAULT_WINDOW = 5;
|
||||
|
||||
export function purgeErrors(
|
||||
messages: DcpMessage[],
|
||||
windowSize: number = DEFAULT_WINDOW,
|
||||
): { messages: DcpMessage[]; stats: { removedCount: number; freedTokens: number } } {
|
||||
const result: DcpMessage[] = [];
|
||||
let removedCount = 0;
|
||||
let freedTokens = 0;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'tool') {
|
||||
const shouldRemove =
|
||||
msg.isError ||
|
||||
ERROR_PREFIXES.some((p) => msg.content.startsWith(p)) ||
|
||||
msg.content.trim() === '';
|
||||
|
||||
if (shouldRemove) {
|
||||
removedCount++;
|
||||
freedTokens += Math.ceil(msg.content.length / 4);
|
||||
continue; // Skip this message
|
||||
}
|
||||
}
|
||||
result.push(msg);
|
||||
}
|
||||
|
||||
return { messages: result, stats: { removedCount, freedTokens } };
|
||||
}
|
||||
52
apps/server/src/services/inference/dcp/transform.ts
Normal file
52
apps/server/src/services/inference/dcp/transform.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Transform orchestrator — runs DCP strategies in sequence.
|
||||
// Clean-room implementation.
|
||||
|
||||
import type { DcpMessage } from './messages.js';
|
||||
import { deduplicate } from './strategies/deduplication.js';
|
||||
import { purgeErrors } from './strategies/purge-errors.js';
|
||||
import { getDcpState, setDcpState, shouldTransform } from './state.js';
|
||||
|
||||
export interface TransformStats {
|
||||
removedCount: number;
|
||||
freedTokens: number;
|
||||
dedupRemoved: number;
|
||||
purgeRemoved: number;
|
||||
}
|
||||
|
||||
export interface TransformResult {
|
||||
messages: DcpMessage[];
|
||||
stats: TransformStats;
|
||||
}
|
||||
|
||||
export function transformMessages(chatId: string, messages: DcpMessage[]): TransformResult {
|
||||
if (!shouldTransform(chatId, messages.length)) {
|
||||
return { messages, stats: { removedCount: 0, freedTokens: 0, dedupRemoved: 0, purgeRemoved: 0 } };
|
||||
}
|
||||
|
||||
let m = messages;
|
||||
|
||||
// Step 1: Deduplicate
|
||||
const dedupResult = deduplicate(m);
|
||||
m = dedupResult.messages;
|
||||
const dedupRemoved = dedupResult.stats.removedCount;
|
||||
|
||||
// Step 2: Purge errors
|
||||
const purgeResult = purgeErrors(m);
|
||||
m = purgeResult.messages;
|
||||
const purgeRemoved = purgeResult.stats.removedCount;
|
||||
|
||||
const totalRemoved = dedupRemoved + purgeRemoved;
|
||||
const totalFreed = dedupResult.stats.freedTokens + purgeResult.stats.freedTokens;
|
||||
|
||||
setDcpState(chatId, messages.length);
|
||||
|
||||
return {
|
||||
messages: m,
|
||||
stats: {
|
||||
removedCount: totalRemoved,
|
||||
freedTokens: totalFreed,
|
||||
dedupRemoved,
|
||||
purgeRemoved,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
68
apps/server/src/services/inference/loop-detectors.ts
Normal file
68
apps/server/src/services/inference/loop-detectors.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// Loop detectors — detects repetitive patterns in assistant output
|
||||
// that indicate a model is stuck in a loop.
|
||||
|
||||
export interface LoopDetectionResult {
|
||||
isLoop: boolean;
|
||||
reason?: string;
|
||||
confidence: number; // 0-1
|
||||
}
|
||||
|
||||
const REPEATED_PHRASE_MIN_COUNT = 4;
|
||||
const REPEATED_TOOL_MIN_COUNT = 3;
|
||||
|
||||
export function detectContentRepeat(messages: string[]): LoopDetectionResult {
|
||||
if (messages.length < REPEATED_PHRASE_MIN_COUNT) {
|
||||
return { isLoop: false, confidence: 0 };
|
||||
}
|
||||
|
||||
const recent = messages.slice(-REPEATED_PHRASE_MIN_COUNT);
|
||||
const unique = new Set(recent);
|
||||
|
||||
if (unique.size === 1) {
|
||||
return {
|
||||
isLoop: true,
|
||||
reason: `Same content repeated ${REPEATED_PHRASE_MIN_COUNT} times`,
|
||||
confidence: 0.9,
|
||||
};
|
||||
}
|
||||
|
||||
if (unique.size <= 2 && recent.length >= 4) {
|
||||
return {
|
||||
isLoop: true,
|
||||
reason: 'Content oscillating between two variants',
|
||||
confidence: 0.7,
|
||||
};
|
||||
}
|
||||
|
||||
return { isLoop: false, confidence: 0 };
|
||||
}
|
||||
|
||||
export function detectToolLoop(toolNames: string[]): LoopDetectionResult {
|
||||
if (toolNames.length < REPEATED_TOOL_MIN_COUNT) return { isLoop: false, confidence: 0 };
|
||||
|
||||
const recent = toolNames.slice(-REPEATED_TOOL_MIN_COUNT);
|
||||
const unique = new Set(recent);
|
||||
|
||||
if (unique.size === 1) {
|
||||
return {
|
||||
isLoop: true,
|
||||
reason: `Same tool "${recent[0]}" called ${REPEATED_TOOL_MIN_COUNT} times consecutively`,
|
||||
confidence: 0.85,
|
||||
};
|
||||
}
|
||||
|
||||
return { isLoop: false, confidence: 0 };
|
||||
}
|
||||
|
||||
export function detectDoomLoop(
|
||||
messages: string[],
|
||||
toolNames: string[],
|
||||
): LoopDetectionResult {
|
||||
const contentResult = detectContentRepeat(messages);
|
||||
if (contentResult.isLoop) return contentResult;
|
||||
|
||||
const toolResult = detectToolLoop(toolNames);
|
||||
if (toolResult.isLoop) return toolResult;
|
||||
|
||||
return { isLoop: false, confidence: 0 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
45
apps/server/src/services/inference/tool-shim.ts
Normal file
45
apps/server/src/services/inference/tool-shim.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// ToolShim — recovers structured tool calls from plain-text model output.
|
||||
// When the model emits tool calls as plain text instead of structured JSON,
|
||||
// this shim attempts to parse and recover them.
|
||||
|
||||
export interface ParsedToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
}
|
||||
|
||||
const TOOL_CALL_PATTERN = /<tool_call>\s*<name>(.+?)<\/name>\s*<arguments>(.+?)<\/arguments>\s*<\/tool_call>/gs;
|
||||
const JSON_TOOL_PATTERN = /\{\s*"name":\s*"([^"]+)",\s*"arguments":\s*({.+?})\s*\}/gs;
|
||||
|
||||
export function extractToolCalls(text: string): ParsedToolCall[] {
|
||||
const calls: ParsedToolCall[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Try XML-style tool calls (common in Qwen output)
|
||||
const xmlRegex = new RegExp(TOOL_CALL_PATTERN);
|
||||
while ((match = xmlRegex.exec(text)) !== null) {
|
||||
calls.push({
|
||||
id: `call_${calls.length}`,
|
||||
name: match[1]!.trim(),
|
||||
arguments: match[2]!.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
if (calls.length > 0) return calls;
|
||||
|
||||
// Try JSON-style tool calls
|
||||
const jsonRegex = new RegExp(JSON_TOOL_PATTERN);
|
||||
while ((match = jsonRegex.exec(text)) !== null) {
|
||||
calls.push({
|
||||
id: `call_${calls.length}`,
|
||||
name: match[1]!.trim(),
|
||||
arguments: match[2]!.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
export function hasToolCallMarkup(text: string): boolean {
|
||||
return TOOL_CALL_PATTERN.test(text) || JSON_TOOL_PATTERN.test(text);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
buildMessagesPayload,
|
||||
loadContext,
|
||||
} from './payload.js';
|
||||
import { toDcpMessages, transformMessages, fromDcpMessages } from './dcp/index.js';
|
||||
import {
|
||||
finalizeCompletion,
|
||||
finalizeEmpty,
|
||||
@@ -156,9 +157,20 @@ export async function runAssistantTurn(
|
||||
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
|
||||
break;
|
||||
}
|
||||
const { session: iterSession, project: iterProject, history } = loaded;
|
||||
let { session: iterSession, project: iterProject, history } = loaded;
|
||||
const projectRoot = await resolveProjectRoot(iterProject.path);
|
||||
|
||||
try {
|
||||
const dcpMsgs = toDcpMessages(history);
|
||||
const { messages: pruned, stats } = transformMessages(chatId, dcpMsgs);
|
||||
if (stats.removedCount > 0) {
|
||||
ctx.log.info({ chatId, ...stats }, 'dcp: transform removed messages');
|
||||
history = fromDcpMessages(pruned) as typeof history;
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.log.warn({ err: err instanceof Error ? err.message : String(err), chatId }, 'dcp: transform skipped');
|
||||
}
|
||||
|
||||
// v1.14.0: log step boundary for instrumentation. step_start parts are in
|
||||
// the schema CHECK but not emitted here — writing to the assistant message
|
||||
// before the stream phase creates a sequence-0 collision with
|
||||
|
||||
37
apps/server/src/services/memory/__tests__/bm25.test.ts
Normal file
37
apps/server/src/services/memory/__tests__/bm25.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Bm25Ranker } from '../bm25.js';
|
||||
|
||||
describe('Bm25Ranker', () => {
|
||||
it('scores documents by term frequency', () => {
|
||||
const ranker = new Bm25Ranker();
|
||||
ranker.fit(['the cat sat on the mat', 'the dog chased the cat', 'the bird flew over the mat']);
|
||||
const results = ranker.rank('cat mat');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]!.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns empty for no matches', () => {
|
||||
const ranker = new Bm25Ranker();
|
||||
ranker.fit(['aaa bbb', 'ccc ddd']);
|
||||
const results = ranker.rank('zzz');
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles single document corpus', () => {
|
||||
const ranker = new Bm25Ranker();
|
||||
ranker.fit(['only document here']);
|
||||
const results = ranker.rank('document');
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ranks relevant docs higher', () => {
|
||||
const ranker = new Bm25Ranker();
|
||||
ranker.fit([
|
||||
'javascript is a programming language',
|
||||
'python is also a programming language',
|
||||
'the weather is nice today',
|
||||
]);
|
||||
const results = ranker.rank('javascript programming');
|
||||
expect(results[0]!.index).toBe(0);
|
||||
});
|
||||
});
|
||||
31
apps/server/src/services/memory/__tests__/entries.test.ts
Normal file
31
apps/server/src/services/memory/__tests__/entries.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseMemoryEntries } from '../entries.js';
|
||||
|
||||
describe('parseMemoryEntries', () => {
|
||||
it('parses a single entry with tags', () => {
|
||||
const md = '## project: Indentation\n> tags: style\n\nUse two-space indentation\n';
|
||||
const entries = parseMemoryEntries('style.md', md);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].title).toBe('Indentation');
|
||||
expect(entries[0].topic).toBe('project');
|
||||
expect(entries[0].tags).toEqual(['style']);
|
||||
expect(entries[0].content).toContain('two-space');
|
||||
});
|
||||
|
||||
it('parses multiple entries', () => {
|
||||
const md = [
|
||||
'## project: Style',
|
||||
'',
|
||||
'Use tab indentation',
|
||||
'',
|
||||
'## user: Preference',
|
||||
'',
|
||||
'Prefer pnpm',
|
||||
'',
|
||||
].join('\n');
|
||||
const entries = parseMemoryEntries('mem.md', md);
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0].topic).toBe('project');
|
||||
expect(entries[1].topic).toBe('user');
|
||||
});
|
||||
});
|
||||
14
apps/server/src/services/memory/__tests__/paths.test.ts
Normal file
14
apps/server/src/services/memory/__tests__/paths.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getMemoryRoot, getTopicDir } from '../paths.js';
|
||||
|
||||
describe('getMemoryRoot', () => {
|
||||
it('returns .boocode/memory under project root', () => {
|
||||
expect(getMemoryRoot('/proj')).toBe('/proj/.boocode/memory');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTopicDir', () => {
|
||||
it('returns project/ under memory root', () => {
|
||||
expect(getTopicDir('/r/.boocode/memory', 'project')).toBe('/r/.boocode/memory/project');
|
||||
});
|
||||
});
|
||||
15
apps/server/src/services/memory/__tests__/prompt.test.ts
Normal file
15
apps/server/src/services/memory/__tests__/prompt.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatMemoryBlock } from '../prompt.js';
|
||||
|
||||
describe('formatMemoryBlock', () => {
|
||||
it('wraps entries in boocode-memory tags', () => {
|
||||
const block = formatMemoryBlock(['Use pnpm', 'Tests in vitest']);
|
||||
expect(block).toContain('<boocode-memory>');
|
||||
expect(block).toContain('Use pnpm');
|
||||
expect(block).toContain('</boocode-memory>');
|
||||
});
|
||||
|
||||
it('returns empty string for no entries', () => {
|
||||
expect(formatMemoryBlock([])).toBe('');
|
||||
});
|
||||
});
|
||||
27
apps/server/src/services/memory/__tests__/recall.test.ts
Normal file
27
apps/server/src/services/memory/__tests__/recall.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { rankByRelevance } from '../recall.js';
|
||||
import type { MemoryEntry } from '../entries.js';
|
||||
|
||||
describe('rankByRelevance', () => {
|
||||
it('returns entries matching query keywords', () => {
|
||||
const entries: MemoryEntry[] = [
|
||||
{ id: '1', topic: 'project', title: 'Style', content: 'Use two-space indentation', tags: ['style'] },
|
||||
{ id: '2', topic: 'project', title: 'Tests', content: 'Use vitest for testing', tags: ['testing'] },
|
||||
];
|
||||
const result = rankByRelevance('what indentation?', entries);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Style');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankByHybrid', () => {
|
||||
it('falls back to BM25 when embeddings unavailable', async () => {
|
||||
const entries: MemoryEntry[] = [
|
||||
{ id: '1', topic: 'project', title: 'Style', content: 'Use two-space indentation', tags: ['style'] },
|
||||
{ id: '2', topic: 'project', title: 'Tests', content: 'Use vitest for testing', tags: ['testing'] },
|
||||
];
|
||||
const { rankByHybrid } = await import('../recall.js');
|
||||
const result = await rankByHybrid('indentation style', entries);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
67
apps/server/src/services/memory/bm25.ts
Normal file
67
apps/server/src/services/memory/bm25.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// BM25 ranker — pure Okapi BM25 scoring. No external deps.
|
||||
|
||||
interface Bm25Config {
|
||||
k1?: number;
|
||||
b?: number;
|
||||
}
|
||||
|
||||
export class Bm25Ranker {
|
||||
private k1: number;
|
||||
private b: number;
|
||||
private corpus: string[];
|
||||
private avgDocLen: number;
|
||||
private idfCache: Map<string, number>;
|
||||
private docCount: number;
|
||||
|
||||
constructor(config?: Bm25Config) {
|
||||
this.k1 = config?.k1 ?? 1.5;
|
||||
this.b = config?.b ?? 0.75;
|
||||
this.corpus = [];
|
||||
this.avgDocLen = 0;
|
||||
this.idfCache = new Map();
|
||||
this.docCount = 0;
|
||||
}
|
||||
|
||||
fit(docs: string[]): void {
|
||||
this.corpus = docs;
|
||||
this.docCount = docs.length;
|
||||
const lengths = docs.map((d) => d.split(/\s+/).length);
|
||||
this.avgDocLen = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
||||
this.idfCache.clear();
|
||||
}
|
||||
|
||||
private tokenize(text: string): string[] {
|
||||
return text.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
private idf(term: string): number {
|
||||
const cached = this.idfCache.get(term);
|
||||
if (cached !== undefined) return cached;
|
||||
const docsWithTerm = this.corpus.filter((d) => this.tokenize(d).includes(term)).length;
|
||||
const idf = Math.log(1 + (this.docCount - docsWithTerm + 0.5) / (docsWithTerm + 0.5));
|
||||
this.idfCache.set(term, idf);
|
||||
return idf;
|
||||
}
|
||||
|
||||
score(query: string, docIndex: number): number {
|
||||
if (docIndex < 0 || docIndex >= this.corpus.length) return 0;
|
||||
const doc = this.corpus[docIndex]!;
|
||||
const queryTerms = this.tokenize(query);
|
||||
const docTokens = this.tokenize(doc);
|
||||
const docLen = docTokens.length;
|
||||
|
||||
let total = 0;
|
||||
for (const term of queryTerms) {
|
||||
const tf = docTokens.filter((t) => t === term).length;
|
||||
if (tf === 0) continue;
|
||||
const idfVal = this.idf(term);
|
||||
total += idfVal * ((tf * (this.k1 + 1)) / (tf + this.k1 * (1 - this.b + this.b * docLen / this.avgDocLen)));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
rank(query: string, topN: number = 10): Array<{ index: number; score: number }> {
|
||||
const scores = this.corpus.map((_, i) => ({ index: i, score: this.score(query, i) }));
|
||||
return scores.sort((a, b) => b.score - a.score).slice(0, topN).filter((s) => s.score > 0);
|
||||
}
|
||||
}
|
||||
55
apps/server/src/services/memory/embeddings.ts
Normal file
55
apps/server/src/services/memory/embeddings.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Embedding module — ONNX-based local embeddings.
|
||||
// Falls back gracefully when the model file is not available.
|
||||
|
||||
let model: any = null;
|
||||
let ortModule: any = null;
|
||||
|
||||
export function isEmbeddingAvailable(): boolean {
|
||||
return model !== null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const dynamicRequire = typeof require !== 'undefined' ? require : null;
|
||||
|
||||
export async function initEmbeddings(modelPath?: string): Promise<boolean> {
|
||||
try {
|
||||
if (dynamicRequire) {
|
||||
try { ortModule = dynamicRequire('onnxruntime-node'); } catch { ortModule = null; }
|
||||
}
|
||||
if (!ortModule) {
|
||||
try { ortModule = await import('onnxruntime-node' as any); } catch { ortModule = null; }
|
||||
}
|
||||
if (!ortModule) return false;
|
||||
const path = modelPath ?? process.env['EMBEDDING_MODEL_PATH'] ?? '';
|
||||
if (!path) return false;
|
||||
model = await ortModule.InferenceSession.create(path);
|
||||
return true;
|
||||
} catch {
|
||||
model = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function embed(texts: string[]): Promise<number[][] | null> {
|
||||
if (!model) return null;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const ort: { Tensor: new (...args: unknown[]) => unknown } | null = ortModule || null;
|
||||
if (!ort) return null;
|
||||
const input = new ort.Tensor('string', texts, [texts.length]);
|
||||
const feeds: Record<string, any> = {};
|
||||
feeds[model.inputNames[0]] = input;
|
||||
const results = await model.run(feeds);
|
||||
const output = results[model.outputNames[0]];
|
||||
if (!output || !output.data) return null;
|
||||
const dim = output.dims?.[1] ?? 384;
|
||||
const data = output.data as Float32Array;
|
||||
const vectors: number[][] = [];
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
vectors.push(Array.from(data.slice(i * dim, (i + 1) * dim)));
|
||||
}
|
||||
return vectors;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
54
apps/server/src/services/memory/entries.ts
Normal file
54
apps/server/src/services/memory/entries.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface MemoryEntry {
|
||||
id: string;
|
||||
topic: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export function parseMemoryEntries(fileName: string, markdown: string): MemoryEntry[] {
|
||||
const entries: MemoryEntry[] = [];
|
||||
const lines = markdown.split('\n');
|
||||
let currentEntry: Partial<MemoryEntry> | null = null;
|
||||
let currentContent: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const headingMatch = line.match(/^##\s+(.+):\s+(.+)$/);
|
||||
if (headingMatch && headingMatch[1] && headingMatch[2]) {
|
||||
if (currentEntry && currentEntry.title) {
|
||||
entries.push({
|
||||
id: `${fileName}-${entries.length}`,
|
||||
topic: currentEntry.topic ?? '',
|
||||
title: currentEntry.title,
|
||||
content: currentContent.join('\n').trim(),
|
||||
tags: currentEntry.tags ?? [],
|
||||
});
|
||||
}
|
||||
currentEntry = { topic: headingMatch[1].trim(), title: headingMatch[2].trim(), tags: [] };
|
||||
currentContent = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagsMatch = line.match(/^>\s*tags:\s*(.+)$/i);
|
||||
if (tagsMatch && tagsMatch[1] && currentEntry) {
|
||||
currentEntry.tags = tagsMatch[1].split(',').map((t) => t.trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentEntry) {
|
||||
currentContent.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEntry && currentEntry.title) {
|
||||
entries.push({
|
||||
id: `${fileName}-${entries.length}`,
|
||||
topic: currentEntry.topic ?? '',
|
||||
title: currentEntry.title,
|
||||
content: currentContent.join('\n').trim(),
|
||||
tags: currentEntry.tags ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user