From aa6680040d26b9b8835e0e6b9df174581d3b95e5 Mon Sep 17 00:00:00 2001 From: KK Date: Wed, 20 May 2026 13:58:40 -0500 Subject: [PATCH 01/12] feat: add code intelligence overview --- .../plans/2026-05-20-code-intelligence-mvp.md | 471 ++++++++++++++++++ .../src/api/hermes/code-intelligence.ts | 32 ++ .../src/components/layout/AppSidebar.vue | 7 + packages/client/src/i18n/locales/en.ts | 24 + packages/client/src/i18n/locales/zh-TW.ts | 24 + packages/client/src/router/index.ts | 5 + .../src/views/hermes/CodeIntelligenceView.vue | 244 +++++++++ .../controllers/hermes/code-intelligence.ts | 14 + .../src/routes/hermes/code-intelligence.ts | 6 + packages/server/src/routes/index.ts | 2 + .../hermes/code-intelligence/scanner.ts | 236 +++++++++ tests/server/code-intelligence-routes.test.ts | 34 ++ .../server/code-intelligence-scanner.test.ts | 54 ++ 13 files changed, 1153 insertions(+) create mode 100644 docs/plans/2026-05-20-code-intelligence-mvp.md create mode 100644 packages/client/src/api/hermes/code-intelligence.ts create mode 100644 packages/client/src/views/hermes/CodeIntelligenceView.vue create mode 100644 packages/server/src/controllers/hermes/code-intelligence.ts create mode 100644 packages/server/src/routes/hermes/code-intelligence.ts create mode 100644 packages/server/src/services/hermes/code-intelligence/scanner.ts create mode 100644 tests/server/code-intelligence-routes.test.ts create mode 100644 tests/server/code-intelligence-scanner.test.ts diff --git a/docs/plans/2026-05-20-code-intelligence-mvp.md b/docs/plans/2026-05-20-code-intelligence-mvp.md new file mode 100644 index 000000000..a06b625e8 --- /dev/null +++ b/docs/plans/2026-05-20-code-intelligence-mvp.md @@ -0,0 +1,471 @@ +# Code Intelligence MVP Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Add a read-only Code Intelligence page to Hermes Web UI that summarizes the current repository, detected languages, key manifests, available skills/agent workflows, and safe next actions. + +**Architecture:** Implement the first version as a local server-side scanner plus a Vue dashboard. Keep the scanner read-only: no installs, no git writes, no agent execution. The API returns deterministic JSON generated from the configured/current workspace path, and the client renders it with refresh/error states. + +**Tech Stack:** Vue 3, TypeScript, Koa routes/controllers, Node `fs/promises`, Vitest, vue-i18n, existing sidebar/router patterns. + +--- + +## Scope Decisions + +- MVP supports TypeScript, Vue, Python, JavaScript, Markdown, JSON, shell, and C/C++ detection. +- C++ is detection-only in MVP: show `not detected` unless `.cpp`, `.hpp`, `.h`, `.cc`, `.cxx`, `CMakeLists.txt`, `compile_commands.json`, `.sln`, or `.vcxproj` exists. +- Python is detection + Hermes bridge awareness: show whether Python files and common manifests exist. +- No agent auto-execution in MVP. The page only recommends skills/actions. +- No dependency installation. +- No deep AST parsing. Use simple file extension + manifest scanning first. + +## Current Evidence + +- Router lives at `packages/client/src/router/index.ts`. +- Sidebar navigation lives at `packages/client/src/components/layout/AppSidebar.vue`. +- Server route registration lives at `packages/server/src/routes/index.ts`. +- Monaco editor already exists at `packages/client/src/components/hermes/files/FileEditor.vue`. +- Chat code highlighting exists at `packages/client/src/components/hermes/chat/highlight.ts`. +- Python bridge exists at `packages/server/src/services/hermes/agent-bridge/hermes_bridge.py`. +- Current repo has many `.ts`/`.vue` files, one `.py`, and no detected C++/CMake files. + +--- + +### Task 1: Add pure repository scanner tests + +**Objective:** Define the expected read-only scanner output before adding production scanner code. + +**Files:** +- Create: `tests/server/code-intelligence-scanner.test.ts` +- Later create: `packages/server/src/services/hermes/code-intelligence/scanner.ts` + +**Step 1: Write failing test** + +Create `tests/server/code-intelligence-scanner.test.ts` with temp-dir fixtures that assert: + +```ts +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { mkdtempSync, rmSync } from 'node:fs' +import { describe, expect, it, afterEach } from 'vitest' +import { scanCodeIntelligence } from '../../packages/server/src/services/hermes/code-intelligence/scanner' + +const roots: string[] = [] + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'hermes-code-intel-')) + roots.push(root) + return root +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('scanCodeIntelligence', () => { + it('summarizes TypeScript, Vue, Python, and C++ detection without reading dependency folders', async () => { + const root = fixture() + mkdirSync(join(root, 'src'), { recursive: true }) + mkdirSync(join(root, 'node_modules', 'ignored'), { recursive: true }) + writeFileSync(join(root, 'package.json'), JSON.stringify({ scripts: { test: 'vitest run' } })) + writeFileSync(join(root, 'src', 'App.vue'), ' + + diff --git a/packages/server/src/controllers/hermes/code-intelligence.ts b/packages/server/src/controllers/hermes/code-intelligence.ts new file mode 100644 index 000000000..95d4e41ff --- /dev/null +++ b/packages/server/src/controllers/hermes/code-intelligence.ts @@ -0,0 +1,14 @@ +import type { Context } from 'koa' +import { scanCodeIntelligence } from '../../services/hermes/code-intelligence/scanner' + +export async function summary(ctx: Context) { + try { + ctx.body = await scanCodeIntelligence(process.cwd()) + } catch (error) { + ctx.status = 500 + ctx.body = { + error: 'Failed to scan code intelligence', + message: error instanceof Error ? error.message : String(error), + } + } +} diff --git a/packages/server/src/routes/hermes/code-intelligence.ts b/packages/server/src/routes/hermes/code-intelligence.ts new file mode 100644 index 000000000..a9298be12 --- /dev/null +++ b/packages/server/src/routes/hermes/code-intelligence.ts @@ -0,0 +1,6 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/code-intelligence' + +export const codeIntelligenceRoutes = new Router() + +codeIntelligenceRoutes.get('/api/hermes/code-intelligence/summary', ctrl.summary) diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 419e91c9f..32577319e 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -32,6 +32,7 @@ import { cronHistoryRoutes } from './hermes/cron-history' import { kanbanRoutes } from './hermes/kanban' import { ttsRoutes } from './hermes/tts' import { mediaRoutes } from './hermes/media' +import { codeIntelligenceRoutes } from './hermes/code-intelligence' import { proxyRoutes, proxyMiddleware } from './hermes/proxy' import { groupChatRoutes, setGroupChatServer } from './hermes/group-chat' import { performanceMonitorRoutes } from './hermes/performance-monitor' @@ -82,6 +83,7 @@ export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, ne app.use(mediaRoutes.routes()) // Must be before proxy app.use(performanceMonitorRoutes.routes()) // Must be before proxy app.use(mcpRoutes.routes()) // MCP management + app.use(codeIntelligenceRoutes.routes()) // Must be before proxy app.use(proxyRoutes.routes()) // Proxy catch-all middleware (must be last) diff --git a/packages/server/src/services/hermes/code-intelligence/scanner.ts b/packages/server/src/services/hermes/code-intelligence/scanner.ts new file mode 100644 index 000000000..36b7bf1df --- /dev/null +++ b/packages/server/src/services/hermes/code-intelligence/scanner.ts @@ -0,0 +1,236 @@ +import { readdir, readFile, stat } from 'node:fs/promises' +import { join, relative } from 'node:path' + +export type CodeLanguageStatus = 'detected' | 'not_detected' | 'partial' + +export type CodeLanguageSummary = { + files: number + lines: number + status: CodeLanguageStatus +} + +export type CodeManifestSummary = { + name: string + path: string +} + +export type CodeCapabilitySummary = { + status: CodeLanguageStatus + reason: string +} + +export type CodeIntelligenceSummary = { + root: string + languages: Record + manifests: CodeManifestSummary[] + capabilities: Record + recommendedSkills: string[] + generatedAt: string +} + +const SKIPPED_DIRECTORIES = new Set([ + '.git', + 'node_modules', + 'dist', + 'build', + '.next', + '.cache', + '.runtime', + 'coverage', + 'venv', + '.venv', + '__pycache__', +]) + +const MANIFEST_NAMES = new Set([ + 'package.json', + 'pyproject.toml', + 'requirements.txt', + 'pnpm-lock.yaml', + 'package-lock.json', + 'yarn.lock', + 'CMakeLists.txt', + 'compile_commands.json', + 'Dockerfile', + 'docker-compose.yml', +]) + +const LANGUAGE_BY_EXTENSION: Record = { + '.ts': 'TypeScript', + '.tsx': 'TypeScript', + '.vue': 'Vue', + '.py': 'Python', + '.js': 'JavaScript', + '.jsx': 'JavaScript', + '.md': 'Markdown', + '.json': 'JSON', + '.sh': 'Shell', + '.bash': 'Shell', + '.zsh': 'Shell', + '.c': 'C/C++', + '.cc': 'C/C++', + '.cpp': 'C/C++', + '.cxx': 'C/C++', + '.h': 'C/C++', + '.hh': 'C/C++', + '.hpp': 'C/C++', + '.hxx': 'C/C++', +} + +const BASE_LANGUAGES = [ + 'TypeScript', + 'Vue', + 'Python', + 'JavaScript', + 'Markdown', + 'JSON', + 'Shell', + 'C/C++', +] + +function createEmptyLanguage(): CodeLanguageSummary { + return { files: 0, lines: 0, status: 'not_detected' } +} + +function extensionFor(name: string): string { + const dot = name.lastIndexOf('.') + if (dot <= 0) return '' + return name.slice(dot).toLowerCase() +} + +async function countLines(path: string): Promise { + try { + const content = await readFile(path, 'utf8') + if (!content) return 0 + return content.split('\n').length - (content.endsWith('\n') ? 1 : 0) + } catch { + return 0 + } +} + +function addRecommendedSkill(skills: Set, skill: string) { + skills.add(skill) +} + +async function walk( + root: string, + current: string, + languages: Record, + manifests: CodeManifestSummary[], +) { + const entries = await readdir(current, { withFileTypes: true }) + + for (const entry of entries) { + if (entry.isDirectory()) { + if (SKIPPED_DIRECTORIES.has(entry.name)) continue + await walk(root, join(current, entry.name), languages, manifests) + continue + } + + if (!entry.isFile()) continue + + const absolutePath = join(current, entry.name) + const relPath = relative(root, absolutePath) || entry.name + + if (MANIFEST_NAMES.has(entry.name)) { + manifests.push({ name: entry.name, path: relPath }) + } + + const language = LANGUAGE_BY_EXTENSION[extensionFor(entry.name)] + if (!language) continue + + if (!languages[language]) { + languages[language] = createEmptyLanguage() + } + languages[language].files += 1 + languages[language].lines += await countLines(absolutePath) + } +} + +function finalizeLanguageStatuses(languages: Record) { + for (const language of Object.values(languages)) { + language.status = language.files > 0 ? 'detected' : 'not_detected' + } +} + +function detectCppCapability(languages: Record, manifests: CodeManifestSummary[]): CodeCapabilitySummary { + const cppManifest = manifests.find((manifest) => + ['CMakeLists.txt', 'compile_commands.json'].includes(manifest.name) + || manifest.name.endsWith('.sln') + || manifest.name.endsWith('.vcxproj'), + ) + + if (cppManifest) { + return { status: 'detected', reason: `${cppManifest.name} detected` } + } + + if (languages['C/C++']?.files > 0) { + return { status: 'detected', reason: 'C/C++ source files detected' } + } + + return { status: 'not_detected', reason: 'No C/C++ source files or build manifests detected' } +} + +function detectPythonCapability(languages: Record, manifests: CodeManifestSummary[]): CodeCapabilitySummary { + const pythonManifest = manifests.find((manifest) => ['pyproject.toml', 'requirements.txt'].includes(manifest.name)) + if (pythonManifest) { + return { status: 'detected', reason: `${pythonManifest.name} detected` } + } + if (languages.Python?.files > 0) { + return { status: 'partial', reason: 'Python files detected without Python project manifest' } + } + return { status: 'not_detected', reason: 'No Python files or manifests detected' } +} + +function detectWebUiCapability(languages: Record, manifests: CodeManifestSummary[]): CodeCapabilitySummary { + const hasPackageJson = manifests.some((manifest) => manifest.name === 'package.json') + const hasWebCode = languages.TypeScript.files > 0 || languages.Vue.files > 0 || languages.JavaScript.files > 0 + if (hasPackageJson && hasWebCode) { + return { status: 'detected', reason: 'package.json and web source files detected' } + } + if (hasPackageJson || hasWebCode) { + return { status: 'partial', reason: 'Partial web UI signals detected' } + } + return { status: 'not_detected', reason: 'No web UI manifest or source files detected' } +} + +export async function scanCodeIntelligence(root: string): Promise { + await stat(root) + + const languages = Object.fromEntries(BASE_LANGUAGES.map((language) => [language, createEmptyLanguage()])) as Record + const manifests: CodeManifestSummary[] = [] + + await walk(root, root, languages, manifests) + finalizeLanguageStatuses(languages) + manifests.sort((a, b) => a.path.localeCompare(b.path)) + + const capabilities = { + webUi: detectWebUiCapability(languages, manifests), + python: detectPythonCapability(languages, manifests), + cpp: detectCppCapability(languages, manifests), + } + + const skills = new Set() + addRecommendedSkill(skills, 'codebase-inspection') + addRecommendedSkill(skills, 'hermes-agent') + + if (languages.TypeScript.files > 0 || languages.Vue.files > 0) { + addRecommendedSkill(skills, 'test-driven-development') + addRecommendedSkill(skills, 'github-pr-workflow') + } + if (languages.Python.files > 0) { + addRecommendedSkill(skills, 'systematic-debugging') + } + if (capabilities.cpp.status === 'detected') { + addRecommendedSkill(skills, 'llama-cpp') + } + + return { + root, + languages, + manifests, + capabilities, + recommendedSkills: Array.from(skills), + generatedAt: new Date().toISOString(), + } +} diff --git a/tests/server/code-intelligence-routes.test.ts b/tests/server/code-intelligence-routes.test.ts new file mode 100644 index 000000000..859736c7e --- /dev/null +++ b/tests/server/code-intelligence-routes.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const summaryMock = vi.fn(async (ctx: any) => { + ctx.body = { root: '/repo', languages: {}, manifests: [], capabilities: {}, recommendedSkills: [], generatedAt: '2026-05-20T00:00:00.000Z' } +}) + +vi.mock('../../packages/server/src/controllers/hermes/code-intelligence', () => ({ + summary: summaryMock, +})) + +describe('code intelligence routes', () => { + beforeEach(() => { + vi.resetModules() + summaryMock.mockClear() + }) + + it('registers the summary route', async () => { + const { codeIntelligenceRoutes } = await import('../../packages/server/src/routes/hermes/code-intelligence') + const paths = codeIntelligenceRoutes.stack.map((entry: any) => entry.path) + + expect(paths).toEqual(expect.arrayContaining(['/api/hermes/code-intelligence/summary'])) + }) + + it('delegates summary requests to the controller', async () => { + const { codeIntelligenceRoutes } = await import('../../packages/server/src/routes/hermes/code-intelligence') + const layer = codeIntelligenceRoutes.stack.find((entry: any) => entry.path === '/api/hermes/code-intelligence/summary') + const ctx: any = { body: null, params: {}, query: {} } + + await layer.stack[0](ctx) + + expect(summaryMock).toHaveBeenCalledWith(ctx) + expect(ctx.body.root).toBe('/repo') + }) +}) diff --git a/tests/server/code-intelligence-scanner.test.ts b/tests/server/code-intelligence-scanner.test.ts new file mode 100644 index 000000000..133318c26 --- /dev/null +++ b/tests/server/code-intelligence-scanner.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { scanCodeIntelligence } from '../../packages/server/src/services/hermes/code-intelligence/scanner' + +const roots: string[] = [] + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'hermes-code-intel-')) + roots.push(root) + return root +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('scanCodeIntelligence', () => { + it('summarizes TypeScript, Vue, Python, and C++ detection without reading dependency folders', async () => { + const root = fixture() + mkdirSync(join(root, 'src'), { recursive: true }) + mkdirSync(join(root, 'node_modules', 'ignored'), { recursive: true }) + writeFileSync(join(root, 'package.json'), JSON.stringify({ scripts: { test: 'vitest run' } })) + writeFileSync(join(root, 'src', 'App.vue'), '