diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c94fd98 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# Developer Guide: Smart-Codebase (Refactored) + +This guide provides an overview of the SKILL-centric architecture for Smart-Codebase. + +## Architecture Overview + +The plugin is designed to be lightweight and agent-driven. + +- **`src/index.ts`**: Plugin entry point. Registers the `sc-update` command and context injector hooks. +- **`src/commands/update.ts`**: Implements the `/sc-update` command. It triggers the skill update process. +- **`src/extraction/skill-updater.ts`**: The core logic for knowledge extraction. It creates a child AI session and provides it with a specialized system prompt. +- **`src/extraction/system-prompt.ts`**: Defines the system prompt that guides the child AI agent to autonomously read, analyze, and update SKILL files. +- **`src/hooks/context-injector.ts`**: Injects a prompt into new sessions to help the AI discover the project's SKILL files. +- **`src/utils/skill-helpers.ts`**: Utility functions for managing SKILL file names and paths. + +## Key Design Decisions + +1. **AI-Agentic Extraction**: Instead of the plugin code trying to parse conversations and write markdown, it delegates this to a child AI session. This agent has full access to project files and conversation history, allowing it to autonomously organize knowledge. +2. **SKILL-Centric Storage**: All knowledge is stored in `.opencode/skills//` using the standard OpenCode SKILL format. This ensures that the knowledge is automatically discoverable by any OpenCode session. +3. **Single Manual Command**: We replaced multiple complex commands with a single `/sc-update` command. This gives users total control over when extraction happens and reduces background noise. +4. **Minimal Configuration**: The configuration is stripped down to 3 essential fields: `enabled`, `extractionModel`, and `extractionMaxTokens`. + +## How sc-update Works + +When you run `/sc-update [focus?]`: +1. The plugin retrieves the current session's message history. +2. It builds a text-based summary of the conversation. +3. It creates a child AI session (a sub-session of your current one). +4. The child AI is given a specialized system prompt and a set of tools (`read`, `write`, `edit`, `glob`, `grep`, `bash`). +5. The child AI autonomously: + - Reads existing `SKILL.md` and `reference/*.md` files. + - Browses source code to verify facts or understand context. + - Writes or updates the main `SKILL.md` (frontmatter, principles, workflows). + - Manages detailed knowledge in the `reference/` directory. +6. Once the AI completes its tasks, the child session is deleted. + +## Adding Features + +- **Keep it minimal**: Avoid adding background listeners or automatic triggers. +- **Agent-first**: If you need to extract new types of data, update the `system-prompt.ts` instead of writing new TypeScript logic to handle files. +- **Compatibility**: Ensure any changes to the SKILL file format remain compatible with OpenCode's auto-discovery mechanism. + +## Testing + +We use [Bun](https://bun.sh/) for testing. + +- **`src/__tests__`**: Contains unit and integration tests. +- **Mocks**: We mock the OpenCode SDK to test command registration and child session creation without needing a live OpenCode environment. + +Run tests with: +```bash +bun test +``` + +## Development Setup + +1. **Prerequisites**: [Bun](https://bun.sh/) installed. +2. **Installation**: `bun install` +3. **Build**: `bun run build` (outputs to `dist/`) +4. **Type Check**: `bun run typecheck` diff --git a/README.md b/README.md index 6467d0e..a0febab 100644 --- a/README.md +++ b/README.md @@ -18,24 +18,31 @@ Every time you start a new session, AI starts from scratch. It doesn't remember: ## ✨ The Solution -smart-codebase automatically captures knowledge from your sessions and makes it available to future sessions. +Smart-Codebase gives your AI permanent memory through SKILL files. It uses an AI agent to autonomously capture knowledge from your conversations and project files. ```mermaid graph TB Start([Session Work]) - Extractor[AI Extractor Analyzes] - SkillFile[.knowledge/SKILL.md
Per Module] - ProjectSkill[.opencode/skills/project/SKILL.md
OpenCode Auto-Discovery] + Init[sc-init command] + Update[sc-update command] + InitAgent[AI Scans Source Code] + UpdateAgent[AI Analyzes Session] + SkillFile[.opencode/skills/project/SKILL.md
OpenCode Auto-Discovery] + RefFiles[.opencode/skills/project/reference/*.md
Deep Dive Docs] NewSession([New Session Starts]) - Injector[Knowledge Injector] + Injector[Context Injector] - Start -->|idle| Extractor - Extractor -->|write| SkillFile - Extractor -->|update index| ProjectSkill + Start --> Init + Start --> Update + Init --> InitAgent + Update --> UpdateAgent + InitAgent -->|writes| SkillFile + InitAgent -->|writes| RefFiles + UpdateAgent -->|updates| SkillFile + UpdateAgent -->|updates| RefFiles NewSession --> Injector - Injector -->|inject hint| ProjectSkill - ProjectSkill -.->|references| SkillFile + Injector -->|inject hint| SkillFile ``` --- @@ -47,29 +54,26 @@ graph TB - [⚡ Commands](#-commands) - [⚙️ Configuration](#️-configuration) - [📁 File Structure](#-file-structure) -- [📊 Usage Statistics](#-usage-statistics) -- [🧹 Cleanup Command](#-cleanup-command) - [🛠️ Development](#️-development) --- ## ⚙️ How It Works -1. **You work normally** - Edit files, debug issues, make decisions -2. **Session goes idle** - After 60 seconds of inactivity, toast notification appears -3. **You can interrupt** - Send a message to cancel extraction and continue working -4. **Extractor analyzes** - AI examines what changed and why (with progress notifications) -5. **Knowledge captured** - Stored in `.opencode/skills//modules/.md` -6. **Index updated** - Global index at `.opencode/skills//SKILL.md` -7. **Next session starts** - AI reads project skill, then discovers relevant module skills +1. **You work normally** - Edit files, debug issues, and make architectural decisions. +2. **First time setup** - Run `/sc-init` once to scan your source code and generate comprehensive SKILL files. +3. **Ongoing capture** - When you reach a milestone, run `/sc-update` to extract knowledge from the current session. +4. **AI Agent analyzes** - A child AI session examines your conversation or project code to understand what changed and why. +5. **Knowledge distilled** - The agent autonomously writes or updates SKILL files in standard OpenCode format. +6. **Next session starts** - New sessions auto-discover your project SKILLs, giving the AI immediate context. -**The plugin works silently in the background. Toast notifications keep you informed without interrupting your flow.** +**Manual control means you decide exactly when to preserve knowledge. The AI agent handles the heavy lifting of writing documentation.** --- ## 📦 Installation -Navigate to `~/.config/opencode` directory: +Navigate to your `~/.config/opencode` directory: ```bash # Using bun @@ -93,122 +97,46 @@ Add to your `opencode.json`: | Command | Description | |---------|-------------| -| `/sc-status` | Show knowledge base status and usage statistics | -| `/sc-extract` | Manually trigger knowledge extraction | -| `/sc-rebuild-index` | Rebuild `.knowledge/KNOWLEDGE.md` from all SKILL.md files | -| `/sc-cleanup` | Clean up low-usage SKILL files (preview mode) | -| `/sc-cleanup --confirm` | Actually delete low-usage SKILL files | +| `/sc-init [focus?]` | Initialize project SKILL files by scanning source code. Run once before your first `/sc-update`. | +| `/sc-update [focus?]` | Trigger the AI agent to extract knowledge from the current session. | --- ## ⚙️ Configuration -No configuration required by default. To customize, create `~/.config/opencode/smart-codebase.json` (or `.jsonc`): +Create `~/.config/opencode/smart-codebase.json` (or `.jsonc`) to customize: ```jsonc { "enabled": true, - "debounceMs": 30000, - "autoExtract": true, - "autoInject": true, - "extractionModel": "minimax/MiniMax-M2.1", - "disabledCommands": ["sc-rebuild-index"] + "extractionModel": "openai/gpt-4o", + "extractionMaxTokens": 16000 } ``` | Option | Default | Description | |--------|---------|-------------| -| `enabled` | `true` | Enable/disable the plugin entirely | -| `debounceMs` | `60000` | Wait time (ms) after session idle before extraction | -| `autoExtract` | `true` | Automatically extract knowledge on idle | -| `autoInject` | `true` | Inject knowledge hint at session start | -| `extractionModel` | - | Model for extraction, format: `providerID/modelID` | -| `extractionMaxTokens` | `8000` | Max token budget for extraction context | -| `disabledCommands` | `[]` | Commands to disable, e.g. `["sc-rebuild-index"]` | -| `cleanupThresholds` | See below | Thresholds for cleanup command | - -#### cleanupThresholds - -| Option | Default | Description | -|--------|---------|-------------| -| `cleanupThresholds.minAgeDays` | `60` | Minimum age in days for cleanup eligibility | -| `cleanupThresholds.minAccessCount` | `5` | Maximum access count for cleanup eligibility | -| `cleanupThresholds.maxInactiveDays` | `60` | Maximum days since last access for cleanup eligibility | - ---- - -## 📁 File Structure Example - -``` -project/ -├── .opencode/ -│ └── skills/ -│ └── / -│ ├── SKILL.md # Project skill (main index) -│ └── modules/ -│ ├── src-auth.md # Auth module knowledge -│ └── src-api.md # API module knowledge -│ -├── src/ -│ ├── auth/ -│ │ ├── session.ts -│ │ └── jwt.ts -│ │ -│ └── api/ -│ └── routes.ts -``` - -The project skill at `.opencode/skills//SKILL.md` serves as the global index and is auto-discovered by OpenCode. Module-level knowledge is stored in `.opencode/skills//modules/.md`. +| `enabled` | `true` | Enable or disable the plugin entirely. | +| `extractionModel` | - | Model for the AI agent (e.g., `providerID/modelID`). | +| `extractionMaxTokens` | `16000` | Token budget for the extraction context. | --- -### Usage Statistics +## 📁 File Structure -The `/sc-status` command now displays: -- Total SKILL count -- Total access count across all SKILLs -- Low-frequency SKILL count (based on cleanupThresholds) -- Usage breakdown (high/medium/low) +Knowledge is stored in your project directory using the standard OpenCode SKILL format: -Example output: -``` -📊 Usage Statistics: -Total SKILLs: 15 -Total accesses: 234 -Low-frequency SKILLs (< 5 accesses): 3 - -Usage breakdown: - - High usage (≥10 accesses): 8 SKILLs - - Medium usage (5-10): 4 SKILLs - - Low usage (<5): 3 SKILLs ``` - ---- - -### Cleanup Command - -Remove low-usage SKILL files based on configurable thresholds. - -**Preview mode (default)**: -```bash -/sc-cleanup -``` - -Lists eligible SKILLs without deleting them. - -**Confirm mode**: -```bash -/sc-cleanup --confirm +project/ +└── .opencode/ + └── skills/ + └── / + ├── SKILL.md # Main project skill and index + └── reference/ # Detailed documentation files + ├── architecture.md + └── api-patterns.md ``` -Actually deletes files and updates the main index. - -**Cleanup Criteria (AND logic)**: -A SKILL is eligible for cleanup when ALL conditions are met: -1. Age ≥ `minAgeDays` (default: 60 days) -2. Access count < `minAccessCount` (default: 5) -3. Days since last access ≥ `maxInactiveDays` (default: 60 days) - --- ## 🛠️ Development @@ -217,11 +145,14 @@ A SKILL is eligible for cleanup when ALL conditions are met: # Install dependencies bun install -# Build +# Build the plugin bun run build -# Type check +# Run type checks bun run typecheck + +# Run tests +bun test ``` --- diff --git a/README.zh-cn.md b/README.zh-cn.md index 29e15cc..7bedaf8 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -18,24 +18,31 @@ ## ✨ 解决方案 -smart-codebase 自动从会话中捕获知识,并使其可供未来会话使用。 +Smart-Codebase 通过 SKILL 文件为你的 AI 提供永久记忆。它利用 AI Agent 自主从你的对话和项目文件中提取并沉淀知识。 ```mermaid graph TB Start([会话工作]) - Extractor[AI 提取器分析] - SkillFile[.knowledge/SKILL.md
模块知识] - ProjectSkill[.opencode/skills/project/SKILL.md
OpenCode 自动发现] + Init[sc-init 命令] + Update[sc-update 命令] + InitAgent[AI 扫描源代码] + UpdateAgent[AI 分析会话] + SkillFile[.opencode/skills/project/SKILL.md
OpenCode 自动发现] + RefFiles[.opencode/skills/project/reference/*.md
深度文档] NewSession([新会话开始]) - Injector[知识注入器] + Injector[上下文注入器] - Start -->|空闲| Extractor - Extractor -->|写入| SkillFile - Extractor -->|更新索引| ProjectSkill + Start --> Init + Start --> Update + Init --> InitAgent + Update --> UpdateAgent + InitAgent -->|写入| SkillFile + InitAgent -->|写入| RefFiles + UpdateAgent -->|更新| SkillFile + UpdateAgent -->|更新| RefFiles NewSession --> Injector - Injector -->|注入提示| ProjectSkill - ProjectSkill -.->|引用| SkillFile + Injector -->|注入提示| SkillFile ``` --- @@ -47,23 +54,20 @@ graph TB - [⚡ 命令](#-命令) - [⚙️ 配置](#️-配置) - [📁 文件结构](#-文件结构) -- [📊 使用统计](#-使用统计) -- [🧹 清理命令](#-清理命令) - [🛠️ 开发](#️-开发) --- ## ⚙️ 工作原理 -1. **你正常工作** - 编辑文件、调试问题、做决策 -2. **会话空闲** - 60 秒无活动后,出现 toast 通知 -3. **你可以打断** - 发送消息即可取消提取并继续工作 -4. **提取器分析** - AI 检查发生了什么变化以及为什么(带进度通知) -5. **知识被捕获** - 存储在 `.opencode/skills/<项目>/modules/<模块>.md` 中 -6. **索引更新** - 全局索引位于 `.opencode/skills/<项目>/SKILL.md` -7. **下次会话开始** - AI 读取项目 skill,然后发现相关模块 skill +1. **你正常工作** - 编辑文件、调试问题、做架构决策。 +2. **首次初始化** - 运行一次 `/sc-init`,扫描源代码并生成完整的 SKILL 文件。 +3. **持续更新** - 达到里程碑时,运行 `/sc-update` 从当前会话中提取知识。 +4. **AI Agent 分析** - 子 AI 会话会分析你的对话记录或项目代码,理解发生了什么变化以及为什么。 +5. **知识沉淀** - Agent 会自主编写或更新符合 OpenCode 标准格式的 SKILL 文件。 +6. **下次会话开始** - 新会话会自动发现项目 SKILLs,让 AI 立即获得项目上下文。 -**插件在后台静默工作。Toast 通知让你知情,而不打断你的工作流。** +**手动控制意味着你决定何时保存知识,而 AI Agent 则承担了编写文档的繁重工作。** --- @@ -93,122 +97,46 @@ npm install smart-codebase | 命令 | 描述 | |------|------| -| `/sc-status` | 显示知识库状态和使用统计 | -| `/sc-extract` | 手动触发知识沉淀 | -| `/sc-rebuild-index` | 从所有 SKILL.md 文件重建 `.knowledge/KNOWLEDGE.md` | -| `/sc-cleanup` | 清理低使用率 SKILL 文件(预览模式) | -| `/sc-cleanup --confirm` | 实际删除低使用率 SKILL 文件 | +| `/sc-init [focus?]` | 通过扫描源代码初始化项目 SKILL 文件。在首次使用 `/sc-update` 之前运行一次。 | +| `/sc-update [focus?]` | 触发 AI Agent 从当前会话中提取知识。可以使用可选的 focus 参数来引导 Agent。 | --- ## ⚙️ 配置 -默认无须配置,如需改变默认配置,创建 `~/.config/opencode/smart-codebase.json`(或 `.jsonc`): +创建 `~/.config/opencode/smart-codebase.json` (或 `.jsonc`) 进行自定义配置: ```jsonc { "enabled": true, - "debounceMs": 30000, - "autoExtract": true, - "autoInject": true, - "extractionModel": "minimax/MiniMax-M2.1", - "disabledCommands": ["sc-rebuild-index"] + "extractionModel": "openai/gpt-4o", + "extractionMaxTokens": 16000 } ``` | 选项 | 默认值 | 描述 | |------|--------|------| -| `enabled` | `true` | 完全启用/禁用插件 | -| `debounceMs` | `60000` | 会话空闲后等待多久(毫秒)才提取 | -| `autoExtract` | `true` | 空闲时自动提取知识 | -| `autoInject` | `true` | 会话开始时注入知识提示 | -| `extractionModel` | - | 知识提取使用的模型,格式:`providerID/modelID` | -| `extractionMaxTokens` | `8000` | 提取上下文的最大 token 预算 | -| `disabledCommands` | `[]` | 要禁用的命令,如 `["sc-rebuild-index"]` | -| `cleanupThresholds` | 见下方 | 清理命令的阈值 | - -#### cleanupThresholds - -| 选项 | 默认值 | 描述 | -|------|--------|------| -| `cleanupThresholds.minAgeDays` | `60` | 清理合格的最小年龄(天) | -| `cleanupThresholds.minAccessCount` | `5` | 清理合格的最大访问次数 | -| `cleanupThresholds.maxInactiveDays` | `60` | 清理合格的最大未访问天数 | - ---- - -## 📁 文件结构示例 - -``` -project/ -├── .opencode/ -│ └── skills/ -│ └── <项目名>/ -│ ├── SKILL.md # 项目 skill(主索引) -│ └── modules/ -│ ├── src-auth.md # 认证模块知识 -│ └── src-api.md # API 模块知识 -│ -├── src/ -│ ├── auth/ -│ │ ├── session.ts -│ │ └── jwt.ts -│ │ -│ └── api/ -│ └── routes.ts -``` - -`.opencode/skills/<项目>/SKILL.md` 作为全局索引,会被 OpenCode 自动发现。模块级别的知识存储在 `.opencode/skills/<项目>/modules/<模块名>.md` 中。 +| `enabled` | `true` | 完全启用或禁用插件。 | +| `extractionModel` | - | AI Agent 使用的模型 (例如 `providerID/modelID`)。 | +| `extractionMaxTokens` | `16000` | 提取上下文的最大 token 预算。 | --- -### 📊 使用统计 +## 📁 文件结构 -`/sc-status` 命令现在显示: -- 总 SKILL 数量 -- 所有 SKILL 的总访问次数 -- 低频 SKILL 数量(基于 cleanupThresholds) -- 使用情况分解(高/中/低) +知识以标准 OpenCode SKILL 格式存储在项目目录中: -输出示例: -``` -📊 使用统计: -总 SKILL 数:15 -总访问次数:234 -低频 SKILL(< 5 次访问):3 - -使用情况分解: - - 高频使用(≥10 次访问):8 个 SKILL - - 中频使用(5-10 次):4 个 SKILL - - 低频使用(<5 次):3 个 SKILL ``` - ---- - -### 🧹 清理命令 - -根据可配置的阈值删除低使用率的 SKILL 文件。 - -**预览模式(默认)**: -```bash -/sc-cleanup -``` - -列出合格的 SKILL 而不删除它们。 - -**确认模式**: -```bash -/sc-cleanup --confirm +project/ +└── .opencode/ + └── skills/ + └── <项目名>/ + ├── SKILL.md # 项目主 SKILL 和索引 + └── reference/ # 详细的知识文档文件 + ├── architecture.md + └── api-patterns.md ``` -实际删除文件并更新主索引。 - -**清理条件(AND 逻辑)**: -当满足以下所有条件时,SKILL 即符合清理条件: -1. 年龄 ≥ `minAgeDays`(默认:60 天) -2. 访问次数 < `minAccessCount`(默认:5) -3. 距离最后访问 ≥ `maxInactiveDays`(默认:60 天) - --- ## 🛠️ 开发 @@ -217,11 +145,14 @@ project/ # 安装依赖 bun install -# 构建 +# 构建插件 bun run build -# 类型检查 +# 运行类型检查 bun run typecheck + +# 运行测试 +bun test ``` --- diff --git a/src/__tests__/cleanup.test.ts b/src/__tests__/cleanup.test.ts deleted file mode 100644 index f812acf..0000000 --- a/src/__tests__/cleanup.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { test, expect } from "bun:test"; -import { mkdtemp, rm } from "fs/promises"; -import { tmpdir } from "os"; -import { join } from "path"; -import { writeTextFile, fileExists } from "../utils/fs-compat"; -import { cleanupCommand } from "../commands/cleanup"; -import { mkdir } from "fs/promises"; - -function createMockContext(tmpDir: string): any { - return { - directory: tmpDir, - worktree: tmpDir, - sessionID: "test-session", - messageID: "test-message", - agent: "test-agent", - abort: new AbortController().signal, - metadata: () => {}, - }; -} - -/** - * TDD RED Phase: Tests for cleanup command - * - * These tests define expected behavior BEFORE implementation: - * 1. Preview mode: Lists eligible skills without deleting - * 2. Confirm mode: Deletes files and updates main index - * 3. Cleanup criteria uses AND logic (all conditions must be met) - * 4. Custom thresholds from config are respected - * 5. Empty result when no files are eligible - */ - -test("cleanup: preview mode lists eligible skills without deleting", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "cleanup-test-")); - - try { - // Create project structure - const modulesDir = join(tmpDir, ".opencode", "skills", "test-project", "modules"); - await mkdir(modulesDir, { recursive: true }); - - // Create old, low-usage skill (eligible) - const oldDate = new Date(Date.now() - 95 * 24 * 60 * 60 * 1000).toISOString(); - const oldSkillPath = join(modulesDir, "src-old.md"); - await writeTextFile(oldSkillPath, `--- -name: src-old -description: Old module -usage: - created_at: ${oldDate} - last_accessed: ${oldDate} - access_count: 2 - last_updated: ${oldDate} ---- - -# Old Module -Content here -`); - - // Create new skill (not eligible - too young) - const newDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days ago - const newSkillPath = join(modulesDir, "src-new.md"); - await writeTextFile(newSkillPath, `--- -name: src-new -description: New module -usage: - created_at: ${newDate} - last_accessed: ${newDate} - access_count: 1 - last_updated: ${newDate} ---- - -# New Module -Content here -`); - - // Execute command in preview mode (default) - const ctx = createMockContext(tmpDir); - const result = await cleanupCommand.execute({}, ctx); - - // Verify result includes eligible skill - expect(result).toContain("src-old"); - expect(result).not.toContain("src-new"); - expect(result).toContain("Run with --confirm to delete"); - - // Verify files still exist (not deleted in preview mode) - expect(await fileExists(oldSkillPath)).toBe(true); - expect(await fileExists(newSkillPath)).toBe(true); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("cleanup: confirm mode deletes eligible skills", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "cleanup-test-")); - - try { - // Create project structure - const modulesDir = join(tmpDir, ".opencode", "skills", "test-project", "modules"); - await mkdir(modulesDir, { recursive: true }); - - // Create old, low-usage skill (eligible) - const oldDate = new Date(Date.now() - 95 * 24 * 60 * 60 * 1000).toISOString(); - const oldSkillPath = join(modulesDir, "src-old.md"); - await writeTextFile(oldSkillPath, `--- -name: src-old -description: Old module -usage: - created_at: ${oldDate} - last_accessed: ${oldDate} - access_count: 2 - last_updated: ${oldDate} ---- - -# Old Module -Content here -`); - - // Execute command in confirm mode - const ctx = createMockContext(tmpDir); - const result = await cleanupCommand.execute({ confirm: true }, ctx); - - // Verify result confirms deletion - expect(result).toContain("Deleted"); - expect(result).toContain("src-old"); - - // Verify file was actually deleted - expect(await fileExists(oldSkillPath)).toBe(false); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("cleanup: confirm mode updates main index (removes deleted entries)", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "cleanup-test-")); - - try { - // Create project structure - const skillDir = join(tmpDir, ".opencode", "skills", "test-project"); - const modulesDir = join(skillDir, "modules"); - await mkdir(modulesDir, { recursive: true }); - - // Create main index with entry - const indexPath = join(skillDir, "SKILL.md"); - await writeTextFile(indexPath, `--- -name: test-project-conventions -description: Project conventions ---- - -# Project Knowledge - -### src-old -Old module knowledge -- **Location**: \`modules/src-old.md\` - -### src-active -Active module knowledge -- **Location**: \`modules/src-active.md\` -`); - - // Create old, low-usage skill (eligible) - const oldDate = new Date(Date.now() - 95 * 24 * 60 * 60 * 1000).toISOString(); - const oldSkillPath = join(modulesDir, "src-old.md"); - await writeTextFile(oldSkillPath, `--- -name: src-old -description: Old module -usage: - created_at: ${oldDate} - last_accessed: ${oldDate} - access_count: 2 - last_updated: ${oldDate} ---- - -# Old Module -`); - - // Create active skill (not eligible - high access count) - const activeDate = new Date(Date.now() - 95 * 24 * 60 * 60 * 1000).toISOString(); - const activeSkillPath = join(modulesDir, "src-active.md"); - await writeTextFile(activeSkillPath, `--- -name: src-active -description: Active module -usage: - created_at: ${activeDate} - last_accessed: ${new Date().toISOString()} - access_count: 20 - last_updated: ${new Date().toISOString()} ---- - -# Active Module -`); - - // Execute command in confirm mode - const ctx = createMockContext(tmpDir); - await cleanupCommand.execute({ confirm: true }, ctx); - - // Verify main index was updated - const indexContent = await fileExists(indexPath) ? await require("fs/promises").readFile(indexPath, "utf-8") : ""; - expect(indexContent).not.toContain("src-old"); - expect(indexContent).toContain("src-active"); // Should keep active entry - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("cleanup: uses AND logic for criteria (all conditions must be met)", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "cleanup-test-")); - - try { - // Create project structure - const modulesDir = join(tmpDir, ".opencode", "skills", "test-project", "modules"); - await mkdir(modulesDir, { recursive: true }); - - const now = Date.now(); - const DAY_MS = 24 * 60 * 60 * 1000; - - // Skill 1: Old but high access count (NOT eligible - fails minAccessCount) - const skill1Path = join(modulesDir, "skill1.md"); - await writeTextFile(skill1Path, `--- -name: skill1 -description: Old but popular -usage: - created_at: ${new Date(now - 95 * DAY_MS).toISOString()} - last_accessed: ${new Date(now - 65 * DAY_MS).toISOString()} - access_count: 10 - last_updated: ${new Date(now - 65 * DAY_MS).toISOString()} ---- -Content -`); - - // Skill 2: Young but low access (NOT eligible - fails minAgeDays) - const skill2Path = join(modulesDir, "skill2.md"); - await writeTextFile(skill2Path, `--- -name: skill2 -description: Young with low access -usage: - created_at: ${new Date(now - 30 * DAY_MS).toISOString()} - last_accessed: ${new Date(now - 65 * DAY_MS).toISOString()} - access_count: 2 - last_updated: ${new Date(now - 30 * DAY_MS).toISOString()} ---- -Content -`); - - // Skill 3: Old, low access, but recently active (NOT eligible - fails maxInactiveDays) - const skill3Path = join(modulesDir, "skill3.md"); - await writeTextFile(skill3Path, `--- -name: skill3 -description: Old but recently accessed -usage: - created_at: ${new Date(now - 95 * DAY_MS).toISOString()} - last_accessed: ${new Date(now - 30 * DAY_MS).toISOString()} - access_count: 2 - last_updated: ${new Date(now - 30 * DAY_MS).toISOString()} ---- -Content -`); - - // Skill 4: Meets ALL criteria (ELIGIBLE) - const skill4Path = join(modulesDir, "skill4.md"); - await writeTextFile(skill4Path, `--- -name: skill4 -description: Old, inactive, low access -usage: - created_at: ${new Date(now - 95 * DAY_MS).toISOString()} - last_accessed: ${new Date(now - 65 * DAY_MS).toISOString()} - access_count: 2 - last_updated: ${new Date(now - 65 * DAY_MS).toISOString()} ---- -Content -`); - - // Execute preview mode - const ctx = createMockContext(tmpDir); - const result = await cleanupCommand.execute({}, ctx); - - // Only skill4 should be eligible (meets all criteria) - expect(result).toContain("skill4"); - expect(result).not.toContain("skill1"); - expect(result).not.toContain("skill2"); - expect(result).not.toContain("skill3"); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("cleanup: handles empty result (no eligible skills)", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "cleanup-test-")); - - try { - // Create project structure - const modulesDir = join(tmpDir, ".opencode", "skills", "test-project", "modules"); - await mkdir(modulesDir, { recursive: true }); - - // Create only active, recent skills (none eligible) - const recentDate = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(); - const activeSkillPath = join(modulesDir, "src-active.md"); - await writeTextFile(activeSkillPath, `--- -name: src-active -description: Active module -usage: - created_at: ${recentDate} - last_accessed: ${new Date().toISOString()} - access_count: 50 - last_updated: ${new Date().toISOString()} ---- - -# Active Module -`); - - // Execute preview mode - const ctx = createMockContext(tmpDir); - const result = await cleanupCommand.execute({}, ctx); - - // Should indicate no skills found - expect(result).toContain("No skills"); - expect(result).not.toContain("Run with --confirm"); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("cleanup: respects custom thresholds from config", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "cleanup-test-")); - - try { - // Create project structure - const modulesDir = join(tmpDir, ".opencode", "skills", "test-project", "modules"); - await mkdir(modulesDir, { recursive: true }); - - // Create skill that would be eligible with default thresholds but not with custom - const now = Date.now(); - const DAY_MS = 24 * 60 * 60 * 1000; - - // Age: 50 days (< default 60, but eligible with custom 30) - // Access: 3 (< default 5, eligible) - // Inactive: 50 days (< default 60, but eligible with custom 30) - const skillPath = join(modulesDir, "skill.md"); - await writeTextFile(skillPath, `--- -name: skill -description: Test skill -usage: - created_at: ${new Date(now - 50 * DAY_MS).toISOString()} - last_accessed: ${new Date(now - 50 * DAY_MS).toISOString()} - access_count: 3 - last_updated: ${new Date(now - 50 * DAY_MS).toISOString()} ---- -Content -`); - - // Execute with custom thresholds (more aggressive cleanup) - const baseCtx = createMockContext(tmpDir); - const ctx = { - ...baseCtx, - // Simulate custom config injection - cleanupThresholds: { - minAgeDays: 30, - minAccessCount: 5, - maxInactiveDays: 30, - } - }; - const result = await cleanupCommand.execute({}, ctx as any); - - // Should be eligible with these stricter thresholds - expect(result).toContain("skill"); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("cleanup: handles missing usage metadata gracefully", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "cleanup-test-")); - - try { - // Create project structure - const modulesDir = join(tmpDir, ".opencode", "skills", "test-project", "modules"); - await mkdir(modulesDir, { recursive: true }); - - // Create skill without usage metadata (legacy format) - const skillPath = join(modulesDir, "legacy.md"); - await writeTextFile(skillPath, `--- -name: legacy -description: Legacy skill without usage metadata ---- - -# Legacy Module -Content here -`); - - // Execute preview mode - const ctx = createMockContext(tmpDir); - const result = await cleanupCommand.execute({}, ctx); - - // Should not crash, just skip skills without usage metadata - expect(result).toBeDefined(); - expect(result).not.toContain("legacy"); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts new file mode 100644 index 0000000..492d687 --- /dev/null +++ b/src/__tests__/config.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +const ORIGINAL_ENV = { ...process.env }; +const existsSyncMock = mock(() => false); +const readFileSyncMock = mock(() => ""); + +mock.module("fs", () => ({ + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, +})); + +describe("loadConfig", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV, XDG_CONFIG_HOME: "/tmp/test-opencode-config-base" }; + existsSyncMock.mockReset(); + readFileSyncMock.mockReset(); + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + test("returns default config when no file exists", async () => { + existsSyncMock.mockReturnValue(false); + + const { loadConfig } = await import("../config"); + const config = loadConfig(); + + expect(config.enabled).toBe(true); + expect(config.extractionMaxTokens).toBeGreaterThan(0); + }); + + test("default config has required fields", async () => { + existsSyncMock.mockReturnValue(false); + const { loadConfig } = await import("../config"); + const config = loadConfig(); + + expect(config).toHaveProperty("enabled"); + expect(config).toHaveProperty("extractionMaxTokens"); + expect(config.enabled).toBe(true); + }); + + test("merges user config with defaults", async () => { + existsSyncMock.mockImplementation((path: string) => path.endsWith("/opencode/smart-codebase.json")); + readFileSyncMock.mockReturnValue( + JSON.stringify({ + enabled: false, + extractionModel: "openai/gpt-4o", + }) + ); + + const { loadConfig } = await import("../config"); + const config = loadConfig(); + + expect(config.enabled).toBe(false); + expect(config.extractionModel).toBe("openai/gpt-4o"); + expect(config.extractionMaxTokens).toBe(16000); + }); + + test("supports jsonc comments", async () => { + existsSyncMock.mockImplementation((path: string) => path.endsWith("/opencode/smart-codebase.jsonc")); + readFileSyncMock.mockReturnValue(`{\n// comment\n"enabled": true,\n"extractionMaxTokens": 8000\n}`); + + const { loadConfig } = await import("../config"); + const config = loadConfig(); + + expect(config.enabled).toBe(true); + expect(config.extractionMaxTokens).toBe(8000); + }); +}); diff --git a/src/__tests__/context-injector.test.ts b/src/__tests__/context-injector.test.ts new file mode 100644 index 0000000..055f223 --- /dev/null +++ b/src/__tests__/context-injector.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const fileExistsMock = mock(async () => true); + +mock.module("../utils/fs-compat", () => ({ + fileExists: fileExistsMock, +})); + +describe("createContextInjectorHook", () => { + beforeEach(() => { + fileExistsMock.mockReset(); + fileExistsMock.mockResolvedValue(true); + }); + + test("injects skill hint into first message", async () => { + const { createContextInjectorHook } = await import("../hooks/context-injector"); + + const hook = createContextInjectorHook({ directory: "/repo/my-project" } as any); + const output = { + parts: [{ type: "text", text: "Original assistant message." }], + } as any; + + await hook["chat.message"]({ sessionID: "s1" } as any, output); + + expect(output.parts[0].text).toContain( + 'Use skill(name="my-project") to load project knowledge.' + ); + expect(output.parts[0].text).toContain("Original assistant message."); + expect(fileExistsMock).toHaveBeenCalledTimes(1); + }); + + test("deduplicates injection per session", async () => { + const { createContextInjectorHook } = await import("../hooks/context-injector"); + + const hook = createContextInjectorHook({ directory: "/repo/my-project" } as any); + const output = { + parts: [{ type: "text", text: "Hello" }], + } as any; + + await hook["chat.message"]({ sessionID: "s2" } as any, output); + const afterFirst = output.parts[0].text; + + await hook["chat.message"]({ sessionID: "s2" } as any, output); + + expect(output.parts[0].text).toBe(afterFirst); + expect( + (output.parts[0].text.match(/Use skill\(name="my-project"\) to load project knowledge\./g) ?? []) + .length + ).toBe(1); + expect(fileExistsMock).toHaveBeenCalledTimes(1); + }); + + test("cleans dedupe state on session.deleted event", async () => { + const { createContextInjectorHook } = await import("../hooks/context-injector"); + + const hook = createContextInjectorHook({ directory: "/repo/my-project" } as any); + const output = { + parts: [{ type: "text", text: "Hi" }], + } as any; + + await hook["chat.message"]({ sessionID: "s3" } as any, output); + await hook.event({ + event: { + type: "session.deleted", + properties: { info: { id: "s3" } }, + }, + } as any); + + const secondOutput = { + parts: [{ type: "text", text: "After delete" }], + } as any; + + await hook["chat.message"]({ sessionID: "s3" } as any, secondOutput); + + expect(fileExistsMock).toHaveBeenCalledTimes(2); + expect(secondOutput.parts[0].text).toContain( + 'Use skill(name="my-project") to load project knowledge.' + ); + }); +}); diff --git a/src/__tests__/example.test.ts b/src/__tests__/example.test.ts deleted file mode 100644 index e74d5b9..0000000 --- a/src/__tests__/example.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { test, expect } from "bun:test"; - -test("example test - basic arithmetic", () => { - expect(1 + 1).toBe(2); -}); - -test("example test - string operations", () => { - const greeting = "Hello, World!"; - expect(greeting).toContain("World"); -}); - -test("example test - array operations", () => { - const numbers = [1, 2, 3, 4, 5]; - expect(numbers).toHaveLength(5); - expect(numbers[0]).toBe(1); -}); diff --git a/src/__tests__/extraction-trigger.test.ts b/src/__tests__/extraction-trigger.test.ts deleted file mode 100644 index 5b150c3..0000000 --- a/src/__tests__/extraction-trigger.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { cancelPendingExtraction } from "../hooks/knowledge-extractor"; - -describe("cancelPendingExtraction", () => { - it("should return false when no timer exists", () => { - const result = cancelPendingExtraction("non-existent-session"); - expect(result).toBe(false); - }); - - it("should return false for empty session ID", () => { - const result = cancelPendingExtraction(""); - expect(result).toBe(false); - }); - - it("should return false for multiple non-existent sessions", () => { - const result1 = cancelPendingExtraction("session-1"); - const result2 = cancelPendingExtraction("session-2"); - const result3 = cancelPendingExtraction("session-3"); - - expect(result1).toBe(false); - expect(result2).toBe(false); - expect(result3).toBe(false); - }); -}); diff --git a/src/__tests__/index-entry-location.test.ts b/src/__tests__/index-entry-location.test.ts deleted file mode 100644 index cc32e8a..0000000 --- a/src/__tests__/index-entry-location.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, test, expect } from "bun:test"; -import { toSkillName, getProjectSkillName } from "../storage/knowledge-writer"; - -describe("IndexEntry location construction", () => { - test("module path should use modules/ directory with skill name", () => { - const modulePath = "src/auth"; - const skillName = toSkillName(modulePath); - const location = `modules/${skillName}.md`; - - expect(location).toBe("modules/src-auth.md"); - expect(location).not.toContain("/.knowledge/"); - }); - - test("nested module path should flatten to skill name", () => { - const modulePath = "src/main/services/config"; - const skillName = toSkillName(modulePath); - const location = `modules/${skillName}.md`; - - expect(location).toBe("modules/src-main-services-config.md"); - }); - - test("root module should use absolute path to main SKILL.md", () => { - const projectRoot = "/home/user/myproject"; - const projectName = getProjectSkillName(projectRoot); - const location = `.opencode/skills/${projectName}/SKILL.md`; - - expect(location).toBe(".opencode/skills/myproject/SKILL.md"); - }); - - test("location should start with modules/ prefix", () => { - const modulePath = "src/api"; - const skillName = toSkillName(modulePath); - const location = `modules/${skillName}.md`; - - expect(location.startsWith("modules/")).toBe(true); - expect(location.endsWith(".md")).toBe(true); - }); - - test("regression: should NOT use old .knowledge path", () => { - const modulePath = "src/payments"; - const skillName = toSkillName(modulePath); - - const oldLocation = `${modulePath}/.knowledge/SKILL.md`; - const newLocation = `modules/${skillName}.md`; - - expect(newLocation).not.toBe(oldLocation); - expect(newLocation).toBe("modules/src-payments.md"); - }); -}); diff --git a/src/__tests__/init-system-prompt.test.ts b/src/__tests__/init-system-prompt.test.ts new file mode 100644 index 0000000..ac076ef --- /dev/null +++ b/src/__tests__/init-system-prompt.test.ts @@ -0,0 +1,46 @@ +// @ts-ignore bun test runtime import +import { describe, test, expect } from "bun:test"; +import { buildInitSystemPrompt } from "../extraction/init-system-prompt"; + +describe("buildInitSystemPrompt", () => { + const baseOptions = { + projectName: "test-project", + skillName: "test-project", + skillDir: "/test/.opencode/skills/test-project", + }; + + test("includes project name", () => { + const prompt = buildInitSystemPrompt(baseOptions); + expect(prompt).toContain("test-project"); + }); + + test("includes skill directory path", () => { + const prompt = buildInitSystemPrompt(baseOptions); + expect(prompt).toContain(baseOptions.skillDir); + }); + + test("includes focus when provided", () => { + const prompt = buildInitSystemPrompt({ ...baseOptions, focus: "auth module" }); + expect(prompt).toContain("auth module"); + }); + + test("omits focus section when not provided", () => { + const prompt = buildInitSystemPrompt(baseOptions); + expect(prompt).not.toMatch(/Focus especially on:\s*$/m); + }); + + test("includes scanning strategy instructions", () => { + const prompt = buildInitSystemPrompt(baseOptions); + expect(prompt).toMatch(/Scanning strategy|bash/); + }); + + test("includes exclusion list", () => { + const prompt = buildInitSystemPrompt(baseOptions); + expect(prompt).toContain("node_modules"); + }); + + test("returns non-empty string", () => { + const prompt = buildInitSystemPrompt(baseOptions); + expect(prompt.length).toBeGreaterThan(100); + }); +}); diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts deleted file mode 100644 index 15d86df..0000000 --- a/src/__tests__/integration.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { test, expect } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { writeModuleSkill, getProjectSkillName } from "../storage/knowledge-writer"; -import { trackSkillAccess } from "../storage/usage-tracker"; -import { cleanupCommand } from "../commands/cleanup"; -import { statusCommand } from "../commands/status"; -import { readTextFile, writeTextFile } from "../utils/fs-compat"; -import { mkdir } from "node:fs/promises"; - -function createMockContext(tmpDir: string): any { - return { - directory: tmpDir, - worktree: tmpDir, - sessionID: "test-session", - messageID: "test-message", - agent: "test-agent", - abort: new AbortController().signal, - metadata: () => {}, - }; -} - -/** - * Integration Tests - Full Workflow Coverage - * - * These tests verify the complete SKILL lifecycle: - * 1. Write SKILL → Read SKILL → Verify access_count incremented - * 2. Multiple reads → Verify incremental access counting - * 3. Write old low-usage SKILL → Cleanup preview → Verify in list - * 4. Write multiple SKILLs → Call status → Verify statistics displayed - */ - -test("integration: write SKILL → read (track) → verify access_count = 1", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "sc-integration-")); - - try { - const skillData = { - metadata: { - name: "src-auth", - description: "Authentication patterns", - }, - sections: [ - { - heading: "JWT", - content: "Use JWT tokens with 15min expiry", - } - ], - }; - - await writeModuleSkill(tmpDir, "src/auth", skillData); - - // Get the actual path where the skill was written - const projectName = getProjectSkillName(tmpDir); - const skillPath = join(tmpDir, ".opencode", "skills", projectName, "modules", "src-auth.md"); - - // Simulate read by calling trackSkillAccess - await trackSkillAccess(skillPath, tmpDir); - - // Verify access_count = 1 - const content = await readTextFile(skillPath); - expect(content).toContain("access_count: 1"); - expect(content).toContain("last_accessed:"); - - // Verify created_at and last_updated are present - expect(content).toContain("created_at:"); - expect(content).toContain("last_updated:"); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("integration: write SKILL → read 3 times → verify access_count = 3", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "sc-integration-")); - - try { - const skillData = { - metadata: { - name: "src-api", - description: "API patterns", - }, - sections: [ - { - heading: "REST", - content: "Use RESTful design", - } - ], - }; - - await writeModuleSkill(tmpDir, "src/api", skillData); - - // Get the actual path - const projectName = getProjectSkillName(tmpDir); - const skillPath = join(tmpDir, ".opencode", "skills", projectName, "modules", "src-api.md"); - - // Simulate 3 reads - await trackSkillAccess(skillPath, tmpDir); - await trackSkillAccess(skillPath, tmpDir); - await trackSkillAccess(skillPath, tmpDir); - - // Verify access_count = 3 - const content = await readTextFile(skillPath); - expect(content).toContain("access_count: 3"); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("integration: write old low-usage SKILL → cleanup preview → verify in list", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "sc-integration-")); - - try { - // Create project structure - const projectName = getProjectSkillName(tmpDir); - const modulesDir = join(tmpDir, ".opencode", "skills", projectName, "modules"); - await mkdir(modulesDir, { recursive: true }); - - // Create old, low-usage skill (eligible for cleanup) - // Age: 95 days, Access: 2, Inactive: 95 days (meets all cleanup criteria) - const now = Date.now(); - const DAY_MS = 24 * 60 * 60 * 1000; - const oldDate = new Date(now - 95 * DAY_MS).toISOString(); - - const skillPath = join(modulesDir, "src-legacy.md"); - await writeTextFile(skillPath, `--- -name: src-legacy -description: Legacy module -usage: - created_at: ${oldDate} - last_accessed: ${oldDate} - access_count: 2 - last_updated: ${oldDate} ---- - -# Legacy Module -Old patterns here -`); - - // Execute cleanup preview - const ctx = createMockContext(tmpDir); - const result = await cleanupCommand.execute({}, ctx); - - // Verify skill appears in preview list - expect(result).toContain("src-legacy"); - expect(result).toContain("Run with --confirm to delete"); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("integration: write multiple SKILLs with varying usage → status → verify stats", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "sc-integration-")); - - try { - const projectName = getProjectSkillName(tmpDir); - const modulesDir = join(tmpDir, ".opencode", "skills", projectName, "modules"); - await mkdir(modulesDir, { recursive: true }); - - const now = Date.now(); - const DAY_MS = 24 * 60 * 60 * 1000; - - // Create 3 skills with different access patterns - // High usage: 15 accesses - await writeTextFile(join(modulesDir, "src-high.md"), `--- -name: src-high -description: High usage module -usage: - created_at: ${new Date(now - 30 * DAY_MS).toISOString()} - last_accessed: ${new Date().toISOString()} - access_count: 15 - last_updated: ${new Date().toISOString()} ---- - -# High Usage Module -`); - - // Medium usage: 7 accesses - await writeTextFile(join(modulesDir, "src-medium.md"), `--- -name: src-medium -description: Medium usage module -usage: - created_at: ${new Date(now - 20 * DAY_MS).toISOString()} - last_accessed: ${new Date().toISOString()} - access_count: 7 - last_updated: ${new Date().toISOString()} ---- - -# Medium Usage Module -`); - - // Low usage: 2 accesses (< 5 threshold) - await writeTextFile(join(modulesDir, "src-low.md"), `--- -name: src-low -description: Low usage module -usage: - created_at: ${new Date(now - 10 * DAY_MS).toISOString()} - last_accessed: ${new Date().toISOString()} - access_count: 2 - last_updated: ${new Date().toISOString()} ---- - -# Low Usage Module -`); - - // Execute status command - const ctx = createMockContext(tmpDir); - const result = await statusCommand.execute({}, ctx); - - // Verify usage statistics are displayed - expect(result).toContain("Usage Statistics"); - expect(result).toContain("Total SKILLs: 3"); - expect(result).toContain("Total accesses: 24"); // 15 + 7 + 2 - expect(result).toContain("Low-frequency SKILLs"); // Should show count - - // Verify breakdown by usage level - expect(result).toContain("High usage"); - expect(result).toContain("Medium usage"); - expect(result).toContain("Low usage"); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("integration: status command handles empty skills directory gracefully", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "sc-integration-")); - - try { - // Create empty project structure (no skills) - const projectName = getProjectSkillName(tmpDir); - const modulesDir = join(tmpDir, ".opencode", "skills", projectName, "modules"); - await mkdir(modulesDir, { recursive: true }); - - // Execute status command - const ctx = createMockContext(tmpDir); - const result = await statusCommand.execute({}, ctx); - - // Should not crash, should show 0 counts - expect(result).toBeDefined(); - expect(result).toContain("Total SKILLs: 0"); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("integration: status command counts only module skills (not main index)", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "sc-integration-")); - - try { - const projectName = getProjectSkillName(tmpDir); - const skillsDir = join(tmpDir, ".opencode", "skills", projectName); - const modulesDir = join(skillsDir, "modules"); - await mkdir(modulesDir, { recursive: true }); - - // Create main index (should NOT be counted) - await writeTextFile(join(skillsDir, "SKILL.md"), `--- -name: project-conventions -description: Project conventions ---- - -# Main Index -`); - - // Create module skill (should be counted) - await writeTextFile(join(modulesDir, "src-utils.md"), `--- -name: src-utils -description: Utilities -usage: - created_at: ${new Date().toISOString()} - last_accessed: ${new Date().toISOString()} - access_count: 5 - last_updated: ${new Date().toISOString()} ---- - -# Utils -`); - - // Execute status command - const ctx = createMockContext(tmpDir); - const result = await statusCommand.execute({}, ctx); - - // Should count only 1 skill (the module skill, not the main index) - expect(result).toContain("Total SKILLs: 1"); - expect(result).toContain("Total accesses: 5"); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); diff --git a/src/__tests__/knowledge-writer.test.ts b/src/__tests__/knowledge-writer.test.ts deleted file mode 100644 index a558f4b..0000000 --- a/src/__tests__/knowledge-writer.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { test, expect } from "bun:test"; -import { join } from "path"; -import { mkdtemp, rm } from "fs/promises"; -import { tmpdir } from "os"; -import { fileExists, readTextFile } from "../utils/fs-compat"; -import { - writeModuleSkill, - updateSkillIndex, - toSkillName, - getProjectSkillName, - type SkillContent, - type IndexEntry, -} from "../storage/knowledge-writer"; - -/** - * TDD RED PHASE - Tests for New Storage Structure - * - * These tests define the expected behavior for the refactored storage system: - * 1. Module skills write to .opencode/skills//modules/.md - * 2. Frontmatter includes usage metadata (created_at, last_updated) - * 3. Index entries reference correct module paths - */ - -test("writeModuleSkill() writes to new path structure", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "test-knowledge-")); - const modulePath = "src/auth"; - const skillName = toSkillName(modulePath); - - try { - const skill: SkillContent = { - metadata: { - name: skillName, - description: "Authentication module patterns", - }, - sections: [ - { - heading: "Session Management", - content: "Use JWT tokens with 15min expiry", - }, - ], - }; - - const skillPath = await writeModuleSkill(tmpDir, modulePath, skill); - - // Expected path: .opencode/skills//modules/.md - const expectedPath = join( - tmpDir, - ".opencode", - "skills", - getProjectSkillName(tmpDir), - "modules", - `${skillName}.md` - ); - - expect(skillPath).toBe(expectedPath); - expect(await fileExists(expectedPath)).toBe(true); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("writeModuleSkill() includes usage metadata in frontmatter", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "test-knowledge-")); - const modulePath = "src/api"; - const skillName = toSkillName(modulePath); - - try { - const skill: SkillContent = { - metadata: { - name: skillName, - description: "API patterns", - }, - sections: [ - { - heading: "REST Design", - content: "Use RESTful endpoints", - }, - ], - }; - - const skillPath = await writeModuleSkill(tmpDir, modulePath, skill); - const content = await readTextFile(skillPath); - - // Verify frontmatter contains usage metadata - expect(content).toContain("usage:"); - expect(content).toContain("created_at:"); - expect(content).toContain("last_updated:"); - - // Extract and verify ISO 8601 timestamp format - const createdMatch = content.match(/created_at:\s*([^\s]+)/); - const updatedMatch = content.match(/last_updated:\s*([^\s]+)/); - - expect(createdMatch).not.toBeNull(); - expect(updatedMatch).not.toBeNull(); - - if (createdMatch && updatedMatch) { - const createdAt = createdMatch[1]; - const lastUpdated = updatedMatch[1]; - - // Verify ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ) - const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; - expect(iso8601Regex.test(createdAt)).toBe(true); - expect(iso8601Regex.test(lastUpdated)).toBe(true); - - // Verify timestamps are valid dates - expect(new Date(createdAt).toString()).not.toBe("Invalid Date"); - expect(new Date(lastUpdated).toString()).not.toBe("Invalid Date"); - } - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("module name conversion: src/auth -> src-auth.md", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "test-knowledge-")); - const modulePath = "src/auth"; - - try { - const skill: SkillContent = { - metadata: { - name: toSkillName(modulePath), - description: "Test", - }, - sections: [], - }; - - const skillPath = await writeModuleSkill(tmpDir, modulePath, skill); - - // Verify filename is src-auth.md - expect(skillPath).toContain("src-auth.md"); - expect(await fileExists(skillPath)).toBe(true); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("updateSkillIndex() creates correct entry location for module skills", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "test-knowledge-")); - const projectName = getProjectSkillName(tmpDir); - const modulePath = "src/payments"; - const skillName = toSkillName(modulePath); // "src-payments" - - try { - const entry: IndexEntry = { - name: skillName, - description: "Payment processing patterns", - location: `modules/${skillName}.md`, // Relative path from SKILL.md - }; - - await updateSkillIndex(tmpDir, entry); - - const indexPath = join(tmpDir, ".opencode", "skills", projectName, "SKILL.md"); - expect(await fileExists(indexPath)).toBe(true); - - const content = await readTextFile(indexPath); - - // Verify entry exists - expect(content).toContain(`### ${skillName}`); - expect(content).toContain("Payment processing patterns"); - - // Verify location points to modules/.md - expect(content).toContain(`**Location**: \`modules/${skillName}.md\``); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("lock mechanism still works with new path structure", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "test-knowledge-")); - const modulePath = "src/utils"; - - try { - const skill: SkillContent = { - metadata: { - name: toSkillName(modulePath), - description: "Utility patterns", - }, - sections: [], - }; - - // Write skill twice - lock should prevent race conditions - const [path1, path2] = await Promise.all([ - writeModuleSkill(tmpDir, modulePath, skill), - writeModuleSkill(tmpDir, modulePath, skill), - ]); - - // Both should succeed and return same path - expect(path1).toBe(path2); - expect(await fileExists(path1)).toBe(true); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("frontmatter structure validation", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "test-knowledge-")); - const modulePath = "src/core"; - const skillName = toSkillName(modulePath); - - try { - const skill: SkillContent = { - metadata: { - name: skillName, - description: "Core module patterns", - }, - sections: [ - { - heading: "Architecture", - content: "Use layered architecture", - }, - ], - }; - - const skillPath = await writeModuleSkill(tmpDir, modulePath, skill); - const content = await readTextFile(skillPath); - - // Verify frontmatter structure: - // --- - // name: src-core - // description: Core module patterns - // usage: - // created_at: 2026-01-29T12:58:55.736Z - // last_updated: 2026-01-29T12:58:55.736Z - // --- - - const lines = content.split("\n"); - expect(lines[0]).toBe("---"); - - // Find the closing --- index - const closingIndex = lines.indexOf("---", 1); - expect(closingIndex).toBeGreaterThan(0); - - const frontmatter = lines.slice(1, closingIndex).join("\n"); - - // Verify name field - expect(frontmatter).toContain(`name: ${skillName}`); - - // Verify description field - expect(frontmatter).toContain("description: Core module patterns"); - - // Verify usage nested structure - expect(frontmatter).toContain("usage:"); - expect(frontmatter).toMatch(/\s{2}created_at:\s*\d{4}-/); // Indented with 2 spaces - expect(frontmatter).toMatch(/\s{2}last_updated:\s*\d{4}-/); // Indented with 2 spaces - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("writeModuleSkill() updates last_updated on subsequent writes", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "test-knowledge-")); - const modulePath = "src/db"; - const skillName = toSkillName(modulePath); - - try { - const skill: SkillContent = { - metadata: { - name: skillName, - description: "Database patterns", - }, - sections: [ - { - heading: "Queries", - content: "Use prepared statements", - }, - ], - }; - - // Write first time - const skillPath1 = await writeModuleSkill(tmpDir, modulePath, skill); - const content1 = await readTextFile(skillPath1); - const created1Match = content1.match(/created_at:\s*([^\s]+)/); - const updated1Match = content1.match(/last_updated:\s*([^\s]+)/); - - expect(created1Match).not.toBeNull(); - expect(updated1Match).not.toBeNull(); - - // Wait 10ms to ensure different timestamp - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Update skill content - skill.sections[0].content = "Use prepared statements and connection pooling"; - - // Write second time - const skillPath2 = await writeModuleSkill(tmpDir, modulePath, skill); - const content2 = await readTextFile(skillPath2); - const created2Match = content2.match(/created_at:\s*([^\s]+)/); - const updated2Match = content2.match(/last_updated:\s*([^\s]+)/); - - expect(created2Match).not.toBeNull(); - expect(updated2Match).not.toBeNull(); - - if ( - created1Match && - updated1Match && - created2Match && - updated2Match - ) { - const created1 = created1Match[1]; - const updated1 = updated1Match[1]; - const created2 = created2Match[1]; - const updated2 = updated2Match[1]; - - // created_at should remain the same - expect(created2).toBe(created1); - - // last_updated should be newer - expect(new Date(updated2).getTime()).toBeGreaterThan( - new Date(updated1).getTime() - ); - } - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); diff --git a/src/__tests__/skill-helpers.test.ts b/src/__tests__/skill-helpers.test.ts new file mode 100644 index 0000000..603538e --- /dev/null +++ b/src/__tests__/skill-helpers.test.ts @@ -0,0 +1,22 @@ +import { describe, test, expect } from "bun:test"; +import { getProjectSkillName, toSkillName } from "../utils/skill-helpers"; + +describe("getProjectSkillName", () => { + test("returns basename of project root", () => { + expect(getProjectSkillName("/home/user/my-project")).toBe("my-project"); + }); + + test("handles trailing slash", () => { + expect(getProjectSkillName("/home/user/my-project/")).toBe("my-project"); + }); +}); + +describe("toSkillName", () => { + test("converts path separators to dashes", () => { + expect(toSkillName("src/auth")).toBe("src-auth"); + }); + + test("handles deeper paths", () => { + expect(toSkillName("src/api/routes")).toBe("src-api-routes"); + }); +}); diff --git a/src/__tests__/skill-initializer.test.ts b/src/__tests__/skill-initializer.test.ts new file mode 100644 index 0000000..023b32b --- /dev/null +++ b/src/__tests__/skill-initializer.test.ts @@ -0,0 +1,134 @@ +// @ts-ignore bun test runtime import +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +let currentPluginInput: any; + +mock.module("../plugin-context", () => ({ + getPluginInput: () => currentPluginInput, + setPluginInput: mock(() => {}), +})); + +describe("initSkills", () => { + beforeEach(() => { + currentPluginInput = undefined; + }); + + test("creates child session with SKILL Init title", async () => { + const createMock = mock(() => Promise.resolve({ data: { id: "child-id" } })); + const promptMock = mock(() => Promise.resolve({ data: { parts: [{ type: "text", text: "ok" }] } })); + const deleteMock = mock(() => Promise.resolve({ data: {} })); + + currentPluginInput = { + directory: "/test/project", + client: { + session: { + create: createMock, + prompt: promptMock, + delete: deleteMock, + }, + }, + }; + + const { initSkills } = await import("../extraction/skill-initializer"); + await initSkills("test-session-id", { enabled: true, extractionMaxTokens: 8000 }); + + expect(createMock).toHaveBeenCalledWith({ + body: { title: "SKILL Init", parentID: "test-session-id" }, + }); + }); + + test("sends scan instruction to child session", async () => { + const createMock = mock(() => Promise.resolve({ data: { id: "child-id" } })); + const promptMock = mock(() => Promise.resolve({ data: { parts: [{ type: "text", text: "ok" }] } })); + const deleteMock = mock(() => Promise.resolve({ data: {} })); + + currentPluginInput = { + directory: "/test/project", + client: { + session: { + create: createMock, + prompt: promptMock, + delete: deleteMock, + }, + }, + }; + + const { initSkills } = await import("../extraction/skill-initializer"); + await initSkills("test-session-id", { enabled: true, extractionMaxTokens: 8000 }); + + expect(promptMock).toHaveBeenCalled(); + const firstCall = promptMock.mock.calls[0]?.[0]; + expect(firstCall.body.parts[0].text).toContain("Scan the project source code"); + }); + + test("always deletes child session in finally block", async () => { + const createMock = mock(() => Promise.resolve({ data: { id: "child-id" } })); + const promptMock = mock(() => Promise.reject(new Error("AI failed"))); + const deleteMock = mock(() => Promise.resolve({ data: {} })); + + currentPluginInput = { + directory: "/test/project", + client: { + session: { + create: createMock, + prompt: promptMock, + delete: deleteMock, + }, + }, + }; + + const { initSkills } = await import("../extraction/skill-initializer"); + + await expect( + initSkills("test-session-id", { enabled: true, extractionMaxTokens: 8000 }) + ).rejects.toThrow("AI failed"); + + expect(deleteMock).toHaveBeenCalledWith({ path: { id: "child-id" } }); + }); + + test("returns text from response parts", async () => { + const createMock = mock(() => Promise.resolve({ data: { id: "child-id" } })); + const promptMock = mock(() => + Promise.resolve({ data: { parts: [{ type: "text", text: "SKILL initialized." }] } }) + ); + const deleteMock = mock(() => Promise.resolve({ data: {} })); + + currentPluginInput = { + directory: "/test/project", + client: { + session: { + create: createMock, + prompt: promptMock, + delete: deleteMock, + }, + }, + }; + + const { initSkills } = await import("../extraction/skill-initializer"); + const result = await initSkills("test-session-id", { enabled: true, extractionMaxTokens: 8000 }); + + expect(result).toBe("SKILL initialized."); + }); + + test("does not call session.messages", async () => { + const createMock = mock(() => Promise.resolve({ data: { id: "child-id" } })); + const promptMock = mock(() => Promise.resolve({ data: { parts: [{ type: "text", text: "ok" }] } })); + const deleteMock = mock(() => Promise.resolve({ data: {} })); + + currentPluginInput = { + directory: "/test/project", + client: { + session: { + create: createMock, + prompt: promptMock, + delete: deleteMock, + }, + }, + }; + + const { initSkills } = await import("../extraction/skill-initializer"); + await expect( + initSkills("test-session-id", { enabled: true, extractionMaxTokens: 8000 }) + ).resolves.toBeString(); + }); +}); diff --git a/src/__tests__/skill-updater.test.ts b/src/__tests__/skill-updater.test.ts new file mode 100644 index 0000000..3982677 --- /dev/null +++ b/src/__tests__/skill-updater.test.ts @@ -0,0 +1,124 @@ +// @ts-ignore bun test runtime import +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import * as fs from "node:fs"; + +let currentPluginInput: any; +let mockExistsSync = true; + +mock.module("../plugin-context", () => ({ + getPluginInput: () => currentPluginInput, + setPluginInput: mock(() => {}), +})); + +mock.module("node:fs", () => { + return { + ...fs, + existsSync: () => mockExistsSync, + }; +}); + +describe("updateSkills", () => { + beforeEach(() => { + currentPluginInput = undefined; + mockExistsSync = true; + }); + + test("calls session.messages with correct sessionID", async () => { + const mockMessages = [ + { info: { role: "user" }, parts: [{ type: "text", text: "Help me with auth" }] }, + { info: { role: "assistant" }, parts: [{ type: "text", text: "I'll implement JWT" }] }, + ]; + + const mockSessionDelete = mock(() => Promise.resolve({ data: {} })); + const messagesMock = mock(() => Promise.resolve({ data: mockMessages })); + const createMock = mock(() => Promise.resolve({ data: { id: "child-session-id" } })); + const promptMock = mock(() => + Promise.resolve({ data: { parts: [{ type: "text", text: "SKILL updated." }] } }) + ); + + currentPluginInput = { + directory: "/test/project", + client: { + session: { + messages: messagesMock, + create: createMock, + prompt: promptMock, + delete: mockSessionDelete, + }, + }, + }; + + const { updateSkills } = await import("../extraction/skill-updater"); + const result = await updateSkills("test-session-id", { + enabled: true, + extractionMaxTokens: 8000, + }); + + expect(messagesMock).toHaveBeenCalledWith({ + path: { id: "test-session-id" }, + }); + expect(createMock).toHaveBeenCalled(); + expect(promptMock).toHaveBeenCalled(); + expect(typeof result).toBe("string"); + }); + + test("always deletes child session in finally block", async () => { + const mockSessionDelete = mock(() => Promise.resolve({ data: {} })); + + currentPluginInput = { + directory: "/test/project", + client: { + session: { + messages: mock(() => Promise.resolve({ data: [] })), + create: mock(() => Promise.resolve({ data: { id: "child-id" } })), + prompt: mock(() => Promise.reject(new Error("AI failed"))), + delete: mockSessionDelete, + }, + }, + }; + + const { updateSkills } = await import("../extraction/skill-updater"); + + await expect( + updateSkills("session-id", { enabled: true, extractionMaxTokens: 8000 }) + ).rejects.toThrow("AI failed"); + + expect(mockSessionDelete).toHaveBeenCalledWith({ path: { id: "child-id" } }); + }); + + test("prepends hint when SKILL.md does not exist", async () => { + mockExistsSync = false; + const mockMessages = [ + { info: { role: "user" }, parts: [{ type: "text", text: "Help me with auth" }] }, + { info: { role: "assistant" }, parts: [{ type: "text", text: "I'll implement JWT" }] }, + ]; + + const mockSessionDelete = mock(() => Promise.resolve({ data: {} })); + const messagesMock = mock(() => Promise.resolve({ data: mockMessages })); + const createMock = mock(() => Promise.resolve({ data: { id: "child-session-id" } })); + const promptMock = mock(() => + Promise.resolve({ data: { parts: [{ type: "text", text: "SKILL updated." }] } }) + ); + + currentPluginInput = { + directory: "/test/project", + client: { + session: { + messages: messagesMock, + create: createMock, + prompt: promptMock, + delete: mockSessionDelete, + }, + }, + }; + + const { updateSkills } = await import("../extraction/skill-updater"); + const result = await updateSkills("test-session-id", { + enabled: true, + extractionMaxTokens: 8000, + }); + + expect(result).toMatch(/^💡 Tip: Run \/sc-init first/); + mockExistsSync = true; + }); +}); diff --git a/src/__tests__/system-prompt.test.ts b/src/__tests__/system-prompt.test.ts new file mode 100644 index 0000000..0bb54b9 --- /dev/null +++ b/src/__tests__/system-prompt.test.ts @@ -0,0 +1,51 @@ +// @ts-ignore bun test runtime import +import { describe, test, expect } from "bun:test"; +import { buildExtractionSystemPrompt } from "../extraction/system-prompt"; + +describe("buildExtractionSystemPrompt", () => { + const baseOptions = { + projectName: "test-project", + skillName: "test-project", + skillDir: "/test/.opencode/skills/test-project", + conversationSummary: "User asked about auth. AI implemented JWT.", + }; + + test("includes project name", () => { + const prompt = buildExtractionSystemPrompt(baseOptions); + expect(prompt).toContain("test-project"); + }); + + test("includes skill directory path", () => { + const prompt = buildExtractionSystemPrompt(baseOptions); + expect(prompt).toContain("/test/.opencode/skills/test-project"); + }); + + test("includes conversation summary", () => { + const prompt = buildExtractionSystemPrompt(baseOptions); + expect(prompt).toContain("User asked about auth"); + }); + + test("includes focus when provided", () => { + const prompt = buildExtractionSystemPrompt({ + ...baseOptions, + focus: "authentication module", + }); + expect(prompt).toContain("authentication module"); + }); + + test("omits focus section when not provided", () => { + const prompt = buildExtractionSystemPrompt(baseOptions); + expect(prompt).not.toMatch(/Focus especially on:\s*$/m); + }); + + test("returns non-empty string", () => { + const prompt = buildExtractionSystemPrompt(baseOptions); + expect(prompt.length).toBeGreaterThan(100); + }); + + test("includes value check rule that prevents unnecessary updates", () => { + const prompt = buildExtractionSystemPrompt(baseOptions); + expect(prompt).toContain("Value check"); + expect(prompt).toContain("no valuable"); + }); +}); diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts index da474ed..d3114a3 100644 --- a/src/__tests__/types.test.ts +++ b/src/__tests__/types.test.ts @@ -1,106 +1,15 @@ -import { test, expect } from "bun:test"; -import type { - UsageMetadata, - CleanupThresholds, - PluginConfig, -} from "../types"; - -test("UsageMetadata interface exists and has required fields", () => { - // This test verifies the type structure at compile time - // The following would fail to compile if the interface doesn't exist or lacks fields - const metadata: UsageMetadata = { - created_at: "2024-01-01T00:00:00Z", - last_accessed: "2024-01-02T00:00:00Z", - access_count: 5, - last_updated: "2024-01-03T00:00:00Z", - }; - - expect(metadata.created_at).toBe("2024-01-01T00:00:00Z"); - expect(metadata.last_accessed).toBe("2024-01-02T00:00:00Z"); - expect(metadata.access_count).toBe(5); - expect(metadata.last_updated).toBe("2024-01-03T00:00:00Z"); -}); - -test("UsageMetadata fields are correct types", () => { - const metadata: UsageMetadata = { - created_at: "2024-01-01T00:00:00Z", - last_accessed: "2024-01-02T00:00:00Z", - access_count: 42, - last_updated: "2024-01-03T00:00:00Z", - }; - - expect(typeof metadata.created_at).toBe("string"); - expect(typeof metadata.last_accessed).toBe("string"); - expect(typeof metadata.access_count).toBe("number"); - expect(typeof metadata.last_updated).toBe("string"); -}); - -test("CleanupThresholds interface exists and has required fields", () => { - const thresholds: CleanupThresholds = { - minAgeDays: 30, - minAccessCount: 1, - maxInactiveDays: 90, - }; - - expect(thresholds.minAgeDays).toBe(30); - expect(thresholds.minAccessCount).toBe(1); - expect(thresholds.maxInactiveDays).toBe(90); -}); - -test("CleanupThresholds fields are correct types", () => { - const thresholds: CleanupThresholds = { - minAgeDays: 30, - minAccessCount: 1, - maxInactiveDays: 90, - }; - - expect(typeof thresholds.minAgeDays).toBe("number"); - expect(typeof thresholds.minAccessCount).toBe("number"); - expect(typeof thresholds.maxInactiveDays).toBe("number"); -}); - -test("PluginConfig can include optional cleanupThresholds field", () => { - const configWithCleanup: PluginConfig = { - enabled: true, - cleanupThresholds: { - minAgeDays: 30, - minAccessCount: 1, - maxInactiveDays: 90, - }, - }; - - expect(configWithCleanup.enabled).toBe(true); - expect(configWithCleanup.cleanupThresholds).toBeDefined(); - expect(configWithCleanup.cleanupThresholds?.minAgeDays).toBe(30); -}); - -test("PluginConfig allows cleanupThresholds to be undefined", () => { - const configWithoutCleanup: PluginConfig = { - enabled: true, - }; - - expect(configWithoutCleanup.enabled).toBe(true); - expect(configWithoutCleanup.cleanupThresholds).toBeUndefined(); -}); - -test("PluginConfig preserves existing fields alongside cleanupThresholds", () => { - const fullConfig: PluginConfig = { - enabled: true, - debounceMs: 60000, - autoExtract: true, - autoInject: true, - extractionModel: "openai/gpt-4o", - cleanupThresholds: { - minAgeDays: 30, - minAccessCount: 1, - maxInactiveDays: 90, - }, - }; - - expect(fullConfig.enabled).toBe(true); - expect(fullConfig.debounceMs).toBe(60000); - expect(fullConfig.autoExtract).toBe(true); - expect(fullConfig.autoInject).toBe(true); - expect(fullConfig.extractionModel).toBe("openai/gpt-4o"); - expect(fullConfig.cleanupThresholds?.minAgeDays).toBe(30); +import { describe, test, expect } from "bun:test"; +import type { PluginConfig } from "../types"; + +describe("PluginConfig", () => { + test("accepts minimal config", () => { + const config: PluginConfig = { enabled: true, extractionMaxTokens: 8000 }; + expect(config.enabled).toBe(true); + expect(config.extractionMaxTokens).toBe(8000); + }); + + test("extractionModel is optional", () => { + const config: PluginConfig = { enabled: true, extractionMaxTokens: 8000 }; + expect(config.extractionModel).toBeUndefined(); + }); }); diff --git a/src/__tests__/usage-tracker.test.ts b/src/__tests__/usage-tracker.test.ts deleted file mode 100644 index 7a0fa81..0000000 --- a/src/__tests__/usage-tracker.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { test, expect } from "bun:test"; -import { join } from "path"; -import { mkdtemp, rm, mkdir } from "fs/promises"; -import { tmpdir } from "os"; -import { fileExists, readTextFile, writeTextFile } from "../utils/fs-compat"; -import { - trackSkillAccess, - shouldTrackPath, -} from "../storage/usage-tracker"; -import { getProjectSkillName } from "../storage/knowledge-writer"; - -/** - * TDD RED PHASE - Tests for Usage Tracker - * - * These tests define the expected behavior: - * 1. shouldTrackPath() matches module SKILL files - * 2. shouldTrackPath() excludes main index SKILL.md - * 3. trackSkillAccess() increments access_count - * 4. trackSkillAccess() updates last_accessed timestamp - * 5. trackSkillAccess() preserves created_at and last_updated - * 6. trackSkillAccess() handles missing usage metadata - * 7. trackSkillAccess() uses lock mechanism for concurrent safety - */ - -test("shouldTrackPath() matches module SKILL files", () => { - const projectRoot = "/home/user/project"; - - // Should match module SKILL files - expect(shouldTrackPath( - "/home/user/project/.opencode/skills/project/modules/src-auth.md", - projectRoot - )).toBe(true); - - expect(shouldTrackPath( - "/home/user/project/.opencode/skills/smart-codebase/modules/api-routes.md", - projectRoot - )).toBe(true); -}); - -test("shouldTrackPath() excludes main index SKILL.md", () => { - const projectRoot = "/home/user/project"; - - // Should NOT match main index - expect(shouldTrackPath( - "/home/user/project/.opencode/skills/project/SKILL.md", - projectRoot - )).toBe(false); - - expect(shouldTrackPath( - "/home/user/project/.opencode/skills/smart-codebase/SKILL.md", - projectRoot - )).toBe(false); -}); - -test("shouldTrackPath() excludes non-SKILL files", () => { - const projectRoot = "/home/user/project"; - - // Should NOT match non-SKILL files - expect(shouldTrackPath( - "/home/user/project/src/auth.ts", - projectRoot - )).toBe(false); - - expect(shouldTrackPath( - "/home/user/project/.knowledge/SKILL.md", - projectRoot - )).toBe(false); -}); - -test("trackSkillAccess() initializes usage metadata if missing", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "test-usage-")); - const projectName = getProjectSkillName(tmpDir); - const skillDir = join(tmpDir, ".opencode", "skills", projectName, "modules"); - const skillPath = join(skillDir, "src-auth.md"); - - try { - // Create a skill file without usage metadata - await mkdir(skillDir, { recursive: true }); - const initialContent = `--- -name: src-auth -description: Authentication patterns ---- - -## Session Management -Use JWT tokens with 15min expiry -`; - await writeTextFile(skillPath, initialContent); - - // Track access - await trackSkillAccess(skillPath, tmpDir); - - // Verify metadata was added - const content = await readTextFile(skillPath); - expect(content).toContain("usage:"); - expect(content).toContain("access_count: 1"); - expect(content).toContain("last_accessed:"); - - // Verify timestamp format (ISO 8601) - const lastAccessedMatch = content.match(/last_accessed:\s*([^\s]+)/); - expect(lastAccessedMatch).not.toBeNull(); - if (lastAccessedMatch) { - const timestamp = lastAccessedMatch[1]; - const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; - expect(iso8601Regex.test(timestamp)).toBe(true); - } - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("trackSkillAccess() increments access_count", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "test-usage-")); - const projectName = getProjectSkillName(tmpDir); - const skillDir = join(tmpDir, ".opencode", "skills", projectName, "modules"); - const skillPath = join(skillDir, "src-api.md"); - - try { - await mkdir(skillDir, { recursive: true }); - const initialContent = `--- -name: src-api -description: API patterns -usage: - created_at: 2026-01-29T10:00:00.000Z - last_updated: 2026-01-29T10:00:00.000Z - access_count: 5 - last_accessed: 2026-01-29T10:00:00.000Z ---- - -## REST Design -Use RESTful endpoints -`; - await writeTextFile(skillPath, initialContent); - - // Track access - await trackSkillAccess(skillPath, tmpDir); - - // Verify access_count incremented - const content = await readTextFile(skillPath); - expect(content).toContain("access_count: 6"); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("trackSkillAccess() updates last_accessed timestamp", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "test-usage-")); - const projectName = getProjectSkillName(tmpDir); - const skillDir = join(tmpDir, ".opencode", "skills", projectName, "modules"); - const skillPath = join(skillDir, "src-db.md"); - - try { - await mkdir(skillDir, { recursive: true }); - const oldTimestamp = "2026-01-29T10:00:00.000Z"; - const initialContent = `--- -name: src-db -description: Database patterns -usage: - created_at: 2026-01-29T09:00:00.000Z - last_updated: 2026-01-29T09:30:00.000Z - access_count: 3 - last_accessed: ${oldTimestamp} ---- - -## Queries -Use prepared statements -`; - await writeTextFile(skillPath, initialContent); - - // Wait 10ms to ensure different timestamp - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Track access - await trackSkillAccess(skillPath, tmpDir); - - // Verify timestamp updated - const content = await readTextFile(skillPath); - const lastAccessedMatch = content.match(/last_accessed:\s*([^\s]+)/); - - expect(lastAccessedMatch).not.toBeNull(); - if (lastAccessedMatch) { - const newTimestamp = lastAccessedMatch[1]; - expect(newTimestamp).not.toBe(oldTimestamp); - expect(new Date(newTimestamp).getTime()).toBeGreaterThan( - new Date(oldTimestamp).getTime() - ); - } - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("trackSkillAccess() preserves created_at and last_updated", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "test-usage-")); - const projectName = getProjectSkillName(tmpDir); - const skillDir = join(tmpDir, ".opencode", "skills", projectName, "modules"); - const skillPath = join(skillDir, "src-core.md"); - - try { - await mkdir(skillDir, { recursive: true }); - const createdAt = "2026-01-29T08:00:00.000Z"; - const lastUpdated = "2026-01-29T09:00:00.000Z"; - const initialContent = `--- -name: src-core -description: Core patterns -usage: - created_at: ${createdAt} - last_updated: ${lastUpdated} - access_count: 2 - last_accessed: 2026-01-29T09:00:00.000Z ---- - -## Architecture -Use layered architecture -`; - await writeTextFile(skillPath, initialContent); - - // Track access - await trackSkillAccess(skillPath, tmpDir); - - // Verify timestamps preserved - const content = await readTextFile(skillPath); - expect(content).toContain(`created_at: ${createdAt}`); - expect(content).toContain(`last_updated: ${lastUpdated}`); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("trackSkillAccess() handles concurrent access safely", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "test-usage-")); - const projectName = getProjectSkillName(tmpDir); - const skillDir = join(tmpDir, ".opencode", "skills", projectName, "modules"); - const skillPath = join(skillDir, "src-utils.md"); - - try { - await mkdir(skillDir, { recursive: true }); - const initialContent = `--- -name: src-utils -description: Utility patterns -usage: - created_at: 2026-01-29T10:00:00.000Z - last_updated: 2026-01-29T10:00:00.000Z - access_count: 0 - last_accessed: 2026-01-29T10:00:00.000Z ---- - -## Helpers -Common utility functions -`; - await writeTextFile(skillPath, initialContent); - - await trackSkillAccess(skillPath, tmpDir); - await trackSkillAccess(skillPath, tmpDir); - await trackSkillAccess(skillPath, tmpDir); - - const content = await readTextFile(skillPath); - const countMatch = content.match(/access_count:\s*(\d+)/); - - expect(countMatch).not.toBeNull(); - if (countMatch) { - const count = parseInt(countMatch[1], 10); - expect(count).toBe(3); - } - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); - -test("trackSkillAccess() handles invalid paths gracefully", async () => { - const tmpDir = await mkdtemp(join(tmpdir(), "test-usage-")); - const nonExistentPath = join(tmpDir, "non-existent.md"); - - try { - await trackSkillAccess(nonExistentPath, tmpDir); - expect(true).toBe(true); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } -}); diff --git a/src/commands/cleanup.ts b/src/commands/cleanup.ts deleted file mode 100644 index 358f573..0000000 --- a/src/commands/cleanup.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { tool } from "@opencode-ai/plugin"; -import { join } from "path"; -import { unlink } from "fs/promises"; -import { findFiles, fileExists, readTextFile, writeTextFile } from "../utils/fs-compat"; -import { loadConfig } from "../config"; -import type { UsageMetadata, CleanupThresholds } from "../types"; - -interface EligibleSkill { - name: string; - path: string; - ageDays: number; - accessCount: number; - inactiveDays: number; -} - -interface SkillFrontmatter { - name: string; - description: string; - usage?: UsageMetadata; -} - -export const cleanupCommand = tool({ - description: "Clean up low-usage SKILL files", - args: { - confirm: tool.schema.boolean().optional().describe("Actually delete files (default is preview mode)"), - }, - async execute(input, ctx) { - const confirm = input.confirm ?? false; - const projectRoot = ctx.directory; - - try { - const config = loadConfig(); - const thresholds = config.cleanupThresholds || { - minAgeDays: 60, - minAccessCount: 5, - maxInactiveDays: 60, - }; - - const eligible = await findEligibleSkills(projectRoot, thresholds); - - if (eligible.length === 0) { - return "No skills eligible for cleanup."; - } - - if (!confirm) { - return formatPreviewResult(eligible); - } - - return await performCleanup(projectRoot, eligible); - } catch (error) { - console.error('[smart-codebase] Cleanup command failed:', error); - return `❌ Failed to cleanup: ${error instanceof Error ? error.message : String(error)}`; - } - }, -}); - -async function findEligibleSkills( - projectRoot: string, - thresholds: CleanupThresholds -): Promise { - const skillsDir = join(projectRoot, '.opencode', 'skills'); - - if (!(await fileExists(skillsDir))) { - return []; - } - - const pattern = ".opencode/skills/*/modules/*.md"; - const skillFiles = await findFiles(pattern, { - cwd: projectRoot, - absolute: true, - }); - - const now = Date.now(); - const DAY_MS = 24 * 60 * 60 * 1000; - const eligible: EligibleSkill[] = []; - - for (const skillPath of skillFiles) { - try { - const content = await readTextFile(skillPath); - const frontmatter = extractFrontmatter(content); - - if (!frontmatter.usage) { - continue; - } - - const usage = frontmatter.usage; - const createdAt = usage.created_at ? new Date(usage.created_at).getTime() : now; - const lastAccessed = usage.last_accessed ? new Date(usage.last_accessed).getTime() : createdAt; - const accessCount = usage.access_count ?? 0; - - const ageDays = Math.floor((now - createdAt) / DAY_MS); - const inactiveDays = Math.floor((now - lastAccessed) / DAY_MS); - - const isEligible = - ageDays >= thresholds.minAgeDays && - accessCount < thresholds.minAccessCount && - inactiveDays >= thresholds.maxInactiveDays; - - if (isEligible) { - eligible.push({ - name: frontmatter.name, - path: skillPath, - ageDays, - accessCount, - inactiveDays, - }); - } - } catch (error) { - console.error(`[cleanup] Failed to process ${skillPath}:`, error); - continue; - } - } - - return eligible; -} - -function extractFrontmatter(content: string): SkillFrontmatter { - const match = content.match(/^---\n([\s\S]*?)\n---/); - if (!match) { - return { name: "unknown", description: "" }; - } - - const raw = match[1]; - const lines = raw.split('\n'); - const frontmatter: any = {}; - let currentKey: string | null = null; - let nestedObject: any = null; - - for (const line of lines) { - if (!line.trim()) continue; - - const indent = line.search(/\S/); - const trimmed = line.trim(); - - if (indent === 0 && trimmed.includes(':')) { - const [key, ...valueParts] = trimmed.split(':'); - const value = valueParts.join(':').trim(); - currentKey = key.trim(); - - if (value) { - frontmatter[currentKey] = value; - nestedObject = null; - } else { - frontmatter[currentKey] = {}; - nestedObject = frontmatter[currentKey]; - } - } else if (nestedObject && trimmed.includes(':')) { - const [key, ...valueParts] = trimmed.split(':'); - const value = valueParts.join(':').trim(); - const nestedKey = key.trim(); - - if (nestedKey === 'access_count') { - nestedObject[nestedKey] = parseInt(value, 10) || 0; - } else { - nestedObject[nestedKey] = value; - } - } - } - - return frontmatter as SkillFrontmatter; -} - -function formatPreviewResult(eligible: EligibleSkill[]): string { - const lines: string[] = []; - - lines.push(`Found ${eligible.length} skill${eligible.length !== 1 ? 's' : ''} eligible for cleanup:\n`); - lines.push('| Skill | Age | Access Count | Last Access |'); - lines.push('|-------|-----|--------------|-------------|'); - - for (const skill of eligible) { - const lastAccess = skill.inactiveDays === 0 ? 'today' : `${skill.inactiveDays} days ago`; - lines.push(`| ${skill.name} | ${skill.ageDays} days | ${skill.accessCount} | ${lastAccess} |`); - } - - lines.push(''); - lines.push('Run with --confirm to delete these skills.'); - - return lines.join('\n'); -} - -async function performCleanup( - projectRoot: string, - eligible: EligibleSkill[] -): Promise { - const deletedNames: string[] = []; - - for (const skill of eligible) { - try { - await unlink(skill.path); - deletedNames.push(skill.name); - } catch (error) { - console.error(`[cleanup] Failed to delete ${skill.path}:`, error); - } - } - - await updateMainIndex(projectRoot, deletedNames); - - const lines: string[] = []; - lines.push(`Deleted ${deletedNames.length} low-usage skill${deletedNames.length !== 1 ? 's' : ''}:`); - for (const name of deletedNames) { - lines.push(`- ${name}`); - } - lines.push(''); - lines.push('Updated main index.'); - - return lines.join('\n'); -} - -async function updateMainIndex(projectRoot: string, deletedNames: string[]): Promise { - const skillsDir = join(projectRoot, '.opencode', 'skills'); - - if (!(await fileExists(skillsDir))) { - return; - } - - const projectDirs = await findFiles('*/SKILL.md', { - cwd: skillsDir, - absolute: false, - }); - - if (projectDirs.length === 0) { - return; - } - - const indexPath = join(skillsDir, projectDirs[0]); - - if (!(await fileExists(indexPath))) { - return; - } - - let content = await readTextFile(indexPath); - - for (const name of deletedNames) { - const entryRegex = new RegExp( - `### ${escapeRegex(name)}[\\s\\S]*?(?=\\n### |$)`, - 'g' - ); - content = content.replace(entryRegex, '').replace(/\n{3,}/g, '\n\n'); - } - - await writeTextFile(indexPath, content.trim() + '\n'); -} - -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/src/commands/extract.ts b/src/commands/extract.ts deleted file mode 100644 index 0b2f836..0000000 --- a/src/commands/extract.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { tool } from "@opencode-ai/plugin"; -import { extractKnowledge } from "../hooks/knowledge-extractor"; -import { displayExtractionResult } from "../display/feedback"; -import { getPluginInput } from "../plugin-context"; -import { loadConfig } from "../config"; - -export const extractCommand = tool({ - description: "Manually trigger knowledge extraction from codebase", - args: {}, - async execute(_input, ctx) { - try { - const pluginInput = getPluginInput(); - const config = loadConfig(); - const result = await extractKnowledge(pluginInput, ctx.sessionID, config); - - return displayExtractionResult(result); - } catch (error) { - console.error('[smart-codebase] Extract command failed:', error); - return `❌ Extraction failed: ${error instanceof Error ? error.message : String(error)}`; - } - }, -}); diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..bc5016d --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,21 @@ +import { tool } from "@opencode-ai/plugin"; +import { initSkills } from "../extraction/skill-initializer"; +import { loadConfig } from "../config"; + +export const initCommand = tool({ + description: + "Initialize project SKILL files by scanning source code (AI-agentic: scans project files and generates comprehensive SKILL.md and reference/ files)", + args: { + focus: tool.schema + .string() + .describe( + "Optional focus area for initial scan (e.g. 'auth module', 'API design', 'build pipeline')" + ) + .optional(), + }, + async execute(input, ctx) { + const config = loadConfig(); + const result = await initSkills(ctx.sessionID, config, input.focus); + return result; + }, +}); diff --git a/src/commands/rebuild-index.ts b/src/commands/rebuild-index.ts deleted file mode 100644 index 0355ab2..0000000 --- a/src/commands/rebuild-index.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { tool } from "@opencode-ai/plugin"; -import { join, dirname } from "path"; -import { findFiles, readTextFile, writeTextFile } from "../utils/fs-compat"; - -export const rebuildIndexCommand = tool({ - description: "Rebuild global knowledge base index from all SKILL.md files", - args: {}, - async execute(_input, ctx) { - try { - const skillFiles = await findFiles('**/.knowledge/SKILL.md', { - cwd: ctx.directory, - absolute: true, - }); - - if (skillFiles.length === 0) { - return `📭 No module knowledge files found (.knowledge/SKILL.md)`; - } - - const entries: string[] = []; - - for (const skillPath of skillFiles) { - try { - const content = await readTextFile(skillPath); - const modulePath = dirname(dirname(skillPath)).replace(ctx.directory + '/', ''); - - const nameMatch = content.match(/^name:\s*(.+)$/m); - const name = nameMatch ? nameMatch[1].trim() : modulePath; - - const descMatch = content.match(/^description:\s*(.+)$/m); - const description = descMatch ? descMatch[1].trim() : `Handles ${name} module.`; - - entries.push(`### ${name} -${description} -- **Location**: \`${modulePath}/.knowledge/SKILL.md\` -`); - } catch (error) { - console.warn(`[smart-codebase] Failed to parse ${skillPath}:`, error); - } - } - - const indexContent = `# Project Knowledge - -> Project knowledge index. Read this first to understand available domain knowledge, then read relevant module SKILLs as needed. - -${entries.join('\n')}`; - - const indexPath = join(ctx.directory, '.knowledge', 'KNOWLEDGE.md'); - await writeTextFile(indexPath, indexContent); - - return `🔄 Knowledge index rebuilt - -Scanned modules: ${skillFiles.length} -Successfully parsed: ${entries.length} -Index location: .knowledge/KNOWLEDGE.md`; - - } catch (error) { - console.error('[smart-codebase] Rebuild index command failed:', error); - return `❌ Rebuild failed: ${error instanceof Error ? error.message : String(error)}`; - } - }, -}); diff --git a/src/commands/status.ts b/src/commands/status.ts deleted file mode 100644 index cf002b8..0000000 --- a/src/commands/status.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { tool } from "@opencode-ai/plugin"; -import { join } from "path"; -import type { KnowledgeStats } from "../types"; -import { fileExists, findFiles, readTextFile } from "../utils/fs-compat"; -import { loadConfig } from "../config"; - -export const statusCommand = tool({ - description: "Display smart-codebase knowledge base status", - args: {}, - async execute(_input, ctx) { - try { - const stats = await getKnowledgeStats(ctx.directory); - const usageStats = await getUsageStats(ctx.directory); - - const indexStatus = stats.hasGlobalIndex ? '✅ exists' : '❌ not created'; - const moduleList = stats.modules.length > 0 - ? stats.modules.map(m => ` - ${m}`).join('\n') - : ' (none)'; - - let output = `📚 smart-codebase Knowledge Status - -Global index (.knowledge/KNOWLEDGE.md): ${indexStatus} -Module count: ${stats.moduleCount} - -Modules with knowledge: -${moduleList}`; - - if (usageStats.totalSkills > 0) { - output += ` - -📊 Usage Statistics: -Total SKILLs: ${usageStats.totalSkills} -Total accesses: ${usageStats.totalAccesses} -Low-frequency SKILLs (< ${usageStats.minAccessThreshold} accesses): ${usageStats.lowFrequencyCount} - -Usage breakdown: - - High usage (≥10 accesses): ${usageStats.highUsageCount} SKILLs - - Medium usage (5-10): ${usageStats.mediumUsageCount} SKILLs - - Low usage (<5): ${usageStats.lowUsageCount} SKILLs`; - } else { - output += ` - -📊 Usage Statistics: -Total SKILLs: 0 -Total accesses: 0 -Low-frequency SKILLs (< ${usageStats.minAccessThreshold} accesses): 0 - -Usage breakdown: - - High usage (≥10 accesses): 0 SKILLs - - Medium usage (5-10): 0 SKILLs - - Low usage (<5): 0 SKILLs`; - } - - return output; - - } catch (error) { - console.error('[smart-codebase] Status command failed:', error); - return `❌ Failed to get status: ${error instanceof Error ? error.message : String(error)}`; - } - }, -}); - -async function getKnowledgeStats(projectRoot: string): Promise { - const indexPath = join(projectRoot, '.knowledge', 'KNOWLEDGE.md'); - const hasGlobalIndex = await fileExists(indexPath); - - const skillFiles = await findFiles('**/.knowledge/SKILL.md', { - cwd: projectRoot, - absolute: false, - }); - - const modules = skillFiles.map(f => f.replace('/.knowledge/SKILL.md', '')); - - return { - hasGlobalIndex, - moduleCount: modules.length, - modules, - }; -} - -interface UsageStats { - totalSkills: number; - totalAccesses: number; - lowFrequencyCount: number; - minAccessThreshold: number; - highUsageCount: number; - mediumUsageCount: number; - lowUsageCount: number; -} - -async function getUsageStats(projectRoot: string): Promise { - const config = loadConfig(); - const minAccessThreshold = config.cleanupThresholds?.minAccessCount || 5; - - const skillsDir = join(projectRoot, '.opencode', 'skills'); - - if (!(await fileExists(skillsDir))) { - return { - totalSkills: 0, - totalAccesses: 0, - lowFrequencyCount: 0, - minAccessThreshold, - highUsageCount: 0, - mediumUsageCount: 0, - lowUsageCount: 0, - }; - } - - const moduleSkills = await findFiles('*/modules/*.md', { - cwd: skillsDir, - absolute: true, - }); - - let totalAccesses = 0; - let lowFrequencyCount = 0; - let highUsageCount = 0; - let mediumUsageCount = 0; - let lowUsageCount = 0; - - for (const skillPath of moduleSkills) { - try { - const content = await readTextFile(skillPath); - const accessCount = extractAccessCount(content); - - totalAccesses += accessCount; - - if (accessCount < minAccessThreshold) { - lowFrequencyCount++; - } - - if (accessCount >= 10) { - highUsageCount++; - } else if (accessCount >= 5) { - mediumUsageCount++; - } else { - lowUsageCount++; - } - } catch (error) { - continue; - } - } - - return { - totalSkills: moduleSkills.length, - totalAccesses, - lowFrequencyCount, - minAccessThreshold, - highUsageCount, - mediumUsageCount, - lowUsageCount, - }; -} - -function extractAccessCount(content: string): number { - const match = content.match(/access_count:\s*(\d+)/); - if (!match) return 0; - return parseInt(match[1], 10); -} - diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..2463c24 --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,21 @@ +import { tool } from "@opencode-ai/plugin"; +import { updateSkills } from "../extraction/skill-updater"; +import { loadConfig } from "../config"; + +export const updateCommand = tool({ + description: + "Update project SKILL files from current session knowledge (AI-agentic: analyzes session and writes/updates SKILL.md and reference/ files)", + args: { + focus: tool.schema + .string() + .describe( + "Optional focus area for knowledge extraction (e.g. 'auth module', 'build pipeline', 'API design decisions')" + ) + .optional(), + }, + async execute(input, ctx) { + const config = loadConfig(); + const result = await updateSkills(ctx.sessionID, config, input.focus); + return result; + }, +}); diff --git a/src/config.ts b/src/config.ts index f2c427e..b8d1153 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,32 +4,25 @@ import type { PluginConfig } from "./types"; import { stripJsonComments } from "./utils/jsonc"; import { getOpenCodeConfigDir } from "./utils/paths"; +export type { PluginConfig } from "./types"; + const CONFIG_FILE_NAMES = ["smart-codebase.jsonc", "smart-codebase.json"]; const DEFAULT_CONFIG: PluginConfig = { enabled: true, - debounceMs: 60000, - autoExtract: true, - autoInject: true, - disabledCommands: [], - extractionMaxTokens: 8000, - cleanupThresholds: { - minAgeDays: 60, - minAccessCount: 5, - maxInactiveDays: 60, - }, + extractionMaxTokens: 16000, }; export function loadConfig(): PluginConfig { const configDir = getOpenCodeConfigDir(); - + for (const fileName of CONFIG_FILE_NAMES) { const configPath = join(configDir, fileName); - + if (!fs.existsSync(configPath)) { continue; } - + try { const content = fs.readFileSync(configPath, "utf-8"); const cleanJson = stripJsonComments(content); @@ -40,6 +33,6 @@ export function loadConfig(): PluginConfig { return DEFAULT_CONFIG; } } - + return DEFAULT_CONFIG; } diff --git a/src/display/feedback.ts b/src/display/feedback.ts deleted file mode 100644 index 0fb765b..0000000 --- a/src/display/feedback.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ExtractionResult } from '../hooks/knowledge-extractor'; - -export function displayExtractionResult(result: ExtractionResult): string { - if (result.modulesUpdated === 0) { - return "No new knowledge extracted"; - } - - const modulesText = result.modulesUpdated === 1 - ? `1 module` - : `${result.modulesUpdated} modules`; - - const sectionsText = result.sectionsAdded > 0 - ? `, ${result.sectionsAdded} sections added` - : ''; - - const indexText = result.indexUpdated ? ', index updated' : ''; - - return `✨ Updated ${modulesText}${sectionsText}${indexText}`; -} diff --git a/src/extraction/init-system-prompt.ts b/src/extraction/init-system-prompt.ts new file mode 100644 index 0000000..0a73195 --- /dev/null +++ b/src/extraction/init-system-prompt.ts @@ -0,0 +1,81 @@ +export function buildInitSystemPrompt(params: { + projectName: string; + skillName: string; + skillDir: string; + focus?: string; +}): string { + const { projectName, skillName, skillDir, focus } = params; + const focusLine = focus ? `\nFocus especially on: ${focus}\n` : "\n"; + + return `You are a project scanning agent for the ${projectName} project. Analyze the project structure and create comprehensive SKILL documentation. + +Scope: +- Skill name: ${skillName} +- Skill directory: ${skillDir} + +Scanning strategy (layered approach): +1. First: run bash to get project directory tree overview (exclude noise directories) +2. Then: read key files — README, package.json/Cargo.toml/go.mod/pyproject.toml, config files, entry points +3. Then: selectively read core source modules — representative files, NOT every file +4. Focus on: non-obvious patterns, deviations from standard, gotchas, anti-patterns, project-specific decisions +5. Skip: generic advice that applies to all projects + +Exclusion list (must be explicit): +Exclude: node_modules/, dist/, build/, .git/, *.lock, bun.lockb, package-lock.json, yarn.lock, binary files, images, fonts, minified files + +Merge instructions: +Read existing SKILL.md first if it exists at ${skillDir}/SKILL.md. Preserve valuable content. Update stale sections. Merge new scan findings with existing knowledge. + +Scanning budget guidance: +Be selective. Read representative files, not every file. Focus on architecture, patterns, key decisions. Maximum depth: understand the project, not memorize it. + +Operating rules: +1) Read first: use the read tool to inspect ${skillDir}/SKILL.md if it exists before writing anything. +2) SKILL.md format must follow hfins-dev architecture: + - YAML frontmatter block delimited by --- + - frontmatter must include name: and description: + - Core principles / key patterns section + - Reference table when multiple reference files exist + - Workflow or usage section +3) Use tools read, write, edit, glob, grep, bash freely to: + - read existing SKILL.md and any reference/ files + - browse project files for context + - run bash commands to understand directory structure + - write or edit SKILL.md and reference/*.md files +4) Safety boundary: ONLY write files within ${skillDir}/ . Do not modify source code outside that directory. +5) Language rule: write prose in the user's language inferred from the project. Keep technical identifiers, file paths, and code symbols in English. +6) No JSON output: do NOT output JSON. Perform direct file updates with tools. +7) Reference files: you may create/update ${skillDir}/reference/*.md for detailed knowledge, maximum 10 files. +8) Preserve good content: merge new findings with existing SKILL content; keep valuable information and update stale sections. + +Telegraphic style guidance: +Write in concise, practical, telegraphic style. No filler phrases. No generic best practices. Only what's unique to THIS project. +${focusLine} +Target SKILL.md format example: +--- +name: +description: +--- + +## Core Patterns +[key architectural patterns, gotchas, decisions] + +## Reference Files +| File | Content | When to load | +|------|---------|--------------| +| \`reference/api-patterns.md\` | ... | ... | + +## Key Workflows +[step-by-step workflows] + +Execution checklist: +- Run bash to get directory structure overview +- Read README.md if exists +- Read package.json / Cargo.toml / go.mod / pyproject.toml if exists +- Read config files (tsconfig, vite.config, etc.) if exists +- Read entry points (src/index.ts, main.py, etc.) +- Selectively read 3-8 representative core source files +- Check if ${skillDir}/SKILL.md exists — read and merge if it does +- Write SKILL.md and reference/*.md files +- Keep edits concise, durable, and practical`; +} diff --git a/src/extraction/skill-initializer.ts b/src/extraction/skill-initializer.ts new file mode 100644 index 0000000..ebfedc5 --- /dev/null +++ b/src/extraction/skill-initializer.ts @@ -0,0 +1,65 @@ +import { join } from "path"; +import { getPluginInput } from "../plugin-context"; +import { loadConfig } from "../config"; +import type { PluginConfig } from "../types"; +import type { Part } from "@opencode-ai/sdk"; +import { buildInitSystemPrompt } from "./init-system-prompt"; +import { parseModelConfig } from "./skill-updater"; +import { getProjectSkillName } from "../utils/skill-helpers"; +import { unwrapData, extractTextFromParts, withTimeout } from "../utils/sdk-helpers"; + +export async function initSkills( + sessionID: string, + config?: PluginConfig, + focus?: string +): Promise { + const pluginInput = getPluginInput(); + const resolvedConfig = config ?? loadConfig(); + + const projectRoot = pluginInput.directory; + const skillName = getProjectSkillName(projectRoot); + const skillDir = join(projectRoot, ".opencode", "skills", skillName); + + const systemPrompt = buildInitSystemPrompt({ + projectName: skillName, + skillName, + skillDir, + focus, + }); + + const createResult = await pluginInput.client.session.create({ + body: { title: `SKILL Init`, parentID: sessionID }, + }); + const childSession = unwrapData(createResult as { data?: { id: string }; error?: Error }); + const childSessionID = childSession.id; + + try { + const model = parseModelConfig(resolvedConfig.extractionModel); + const promptResult = await withTimeout( + pluginInput.client.session.prompt({ + path: { id: childSessionID }, + body: { + system: systemPrompt, + parts: [ + { + type: "text", + text: "Scan the project source code and generate comprehensive SKILL documentation.", + }, + ], + tools: { read: true, write: true, edit: true, glob: true, grep: true, bash: true }, + ...(model && { model }), + }, + }), + 600000 + ); + const responseMessage = unwrapData( + promptResult as { data?: { parts: Part[] }; error?: Error } + ); + return extractTextFromParts(responseMessage.parts) || "SKILL initialization complete."; + } finally { + try { + await pluginInput.client.session.delete({ path: { id: childSessionID } }); + } catch { + } + } +} diff --git a/src/extraction/skill-updater.ts b/src/extraction/skill-updater.ts new file mode 100644 index 0000000..2f30df0 --- /dev/null +++ b/src/extraction/skill-updater.ts @@ -0,0 +1,108 @@ +import { join } from "path"; +import { existsSync } from "node:fs"; +import type { Part } from "@opencode-ai/sdk"; +import { getPluginInput } from "../plugin-context"; +import { loadConfig } from "../config"; +import type { PluginConfig } from "../types"; +import { buildExtractionSystemPrompt } from "./system-prompt"; +import { getProjectSkillName } from "../utils/skill-helpers"; +import { unwrapData, extractTextFromParts, withTimeout } from "../utils/sdk-helpers"; + +export function parseModelConfig(modelString?: string) { + if (!modelString) return undefined; + const [providerID, ...rest] = modelString.split("/"); + const modelID = rest.join("/"); + if (!providerID || !modelID) return undefined; + return { providerID, modelID }; +} + +function extractTextFromMessageParts(parts: Array<{ type: string; text?: string }>): string { + return parts + .filter((p) => p.type === "text" && p.text) + .map((p) => p.text!) + .join("\n"); +} + +function buildConversationSummary( + messages: Array<{ info: { role: string }; parts: Array<{ type: string; text?: string }> }> +): string { + if (!messages || messages.length === 0) return "(No conversation messages found)"; + return messages + .map((msg) => { + const role = msg.info?.role ?? "unknown"; + const text = extractTextFromMessageParts(msg.parts ?? []); + if (!text.trim()) return null; + return `[${role.toUpperCase()}]\n${text}`; + }) + .filter(Boolean) + .join("\n\n---\n\n"); +} + +export async function updateSkills( + sessionID: string, + config?: PluginConfig, + focus?: string +): Promise { + const pluginInput = getPluginInput(); + const resolvedConfig = config ?? loadConfig(); + + const messagesResult = await pluginInput.client.session.messages({ path: { id: sessionID } }); + const messages = unwrapData( + messagesResult as { data?: Array<{ info: { role: string }; parts: Array<{ type: string; text?: string }> }>; error?: Error } + ); + const conversationSummary = buildConversationSummary(messages as any); + + const projectRoot = pluginInput.directory; + const skillName = getProjectSkillName(projectRoot); + const skillDir = join(projectRoot, ".opencode", "skills", skillName); + + const skillFileExists = existsSync(join(skillDir, "SKILL.md")); + const noSkillHint = skillFileExists + ? "" + : "💡 Tip: Run /sc-init first to generate comprehensive project knowledge from source code.\n\n"; + + const systemPrompt = buildExtractionSystemPrompt({ + projectName: skillName, + skillName, + skillDir, + conversationSummary, + focus, + }); + + const createResult = await pluginInput.client.session.create({ + body: { title: `SKILL Update`, parentID: sessionID }, + }); + const childSession = unwrapData(createResult as { data?: { id: string }; error?: Error }); + const childSessionID = childSession.id; + + try { + const model = parseModelConfig(resolvedConfig.extractionModel); + const promptResult = await withTimeout( + pluginInput.client.session.prompt({ + path: { id: childSessionID }, + body: { + system: systemPrompt, + parts: [ + { + type: "text", + text: "Analyze the session and update the project SKILL files based on what was learned.", + }, + ], + tools: { read: true, write: true, edit: true, glob: true, grep: true, bash: true }, + ...(model && { model }), + }, + }), + 300000 + ); + const responseMessage = unwrapData( + promptResult as { data?: { parts: Part[] }; error?: Error } + ); + return noSkillHint + (extractTextFromParts(responseMessage.parts) || "SKILL update complete."); + } finally { + try { + await pluginInput.client.session.delete({ path: { id: childSessionID } }); + } catch { + // ignore cleanup errors + } + } +} diff --git a/src/extraction/system-prompt.ts b/src/extraction/system-prompt.ts new file mode 100644 index 0000000..1850505 --- /dev/null +++ b/src/extraction/system-prompt.ts @@ -0,0 +1,61 @@ +export function buildExtractionSystemPrompt(params: { + projectName: string; + skillName: string; + skillDir: string; // e.g. ".opencode/skills/my-project/" + conversationSummary: string; + focus?: string; +}): string { + const { projectName, skillName, skillDir, conversationSummary, focus } = params; + const focusLine = focus ? `\nFocus especially on: ${focus}\n` : "\n"; + + return `You are a knowledge distillation agent for the ${projectName} project. Analyze the conversation and update the SKILL files. + +Scope: +- Skill name: ${skillName} +- Skill directory: ${skillDir} + +Operating rules: +1) Read first: use the read tool to inspect ${skillDir}/SKILL.md if it exists before writing anything. +2) SKILL.md format must follow hfins-dev architecture: + - YAML frontmatter block delimited by --- + - frontmatter must include name: and description: + - Core principles / key patterns section + - Reference table when multiple reference files exist + - Workflow or usage section +3) Use tools read, write, edit, glob, grep freely to: + - read existing SKILL.md and any reference/ files + - browse project files for context + - write or edit SKILL.md and reference/*.md files +4) Safety boundary: ONLY write files within ${skillDir}/ . Do not modify source code outside that directory. +5) Language rule: write prose in the user's language inferred from the conversation. Keep technical identifiers, file paths, and code symbols in English. +6) No JSON output: do NOT output JSON. Perform direct file updates with tools. +7) Reference files: you may create/update ${skillDir}/reference/*.md for detailed knowledge, maximum 10 files. +8) Preserve good content: merge new findings with existing SKILL content; keep valuable information and update stale sections. +9) Value check: Before making any file changes, evaluate the conversation first. If the conversation contains no valuable, durable knowledge worth extracting — for example, it is trivial small talk, asks only simple questions, or contains no architectural decisions, patterns, gotchas, or project-specific knowledge — respond with a brief explanation of why no update is needed, and do NOT modify any files. +${focusLine}Conversation summary to extract from: +${conversationSummary} + +Target SKILL.md format example: +--- +name: +description: +--- + +## Core Patterns +[key architectural patterns, gotchas, decisions] + +## Reference Files +| File | Content | When to load | +|------|---------|--------------| +| \`reference/api-patterns.md\` | ... | ... | + +## Key Workflows +[step-by-step workflows] + +Execution checklist: +- First read ${skillDir}/SKILL.md (if present). +- Inspect relevant existing reference/ files. +- Update SKILL.md frontmatter and sections to match the format. +- Add or revise reference/*.md files only when needed. +- Keep edits concise, durable, and practical for future sessions.`; +} diff --git a/src/hooks/context-injector.ts b/src/hooks/context-injector.ts index b5d70f0..0cc5bae 100644 --- a/src/hooks/context-injector.ts +++ b/src/hooks/context-injector.ts @@ -1,24 +1,19 @@ import type { PluginInput, Hooks } from "@opencode-ai/plugin"; -import type { PluginConfig } from "../types"; import { join } from "path"; import { fileExists } from "../utils/fs-compat"; -import { getProjectSkillName } from "../storage/knowledge-writer"; +import { getProjectSkillName } from "../utils/skill-helpers"; type ChatMessageInput = Parameters>[0]; type ChatMessageOutput = Parameters>[1]; type EventInput = Parameters>[0]; -export function createContextInjectorHook(ctx: PluginInput, config?: PluginConfig) { +export function createContextInjectorHook(ctx: PluginInput) { const sessionKnowledgeInjected = new Set(); const chatMessage = async ( input: ChatMessageInput, output: ChatMessageOutput, ) => { - if (config?.autoInject === false) { - return; - } - if (sessionKnowledgeInjected.has(input.sessionID)) { return; } @@ -28,32 +23,25 @@ export function createContextInjectorHook(ctx: PluginInput, config?: PluginConfi const skillPath = join(ctx.directory, '.opencode', 'skills', skillName, 'SKILL.md'); const hasSkill = await fileExists(skillPath); + sessionKnowledgeInjected.add(input.sessionID); + if (!hasSkill) { - sessionKnowledgeInjected.add(input.sessionID); return; } - const knowledgeHint = ` - ---- -**[REQUIRED FIRST STEP]** Before any other action, read \`.opencode/skills/${skillName}/SKILL.md\` to discover available project knowledge. Then read relevant \`.knowledge/SKILL.md\` files for modules you'll modify. Do NOT skip this step. ---- + const hint = `Use skill(name="${skillName}") to load project knowledge.`; -`; - - const textParts = output.parts.filter( - (p): p is typeof p & { type: 'text'; text: string } => - p.type === 'text' && typeof (p as any).text === 'string' + const textPart = output.parts.find( + (p): p is Extract => p.type === 'text' ); - if (textParts.length > 0) { - (textParts[0] as any).text = knowledgeHint + (textParts[0] as any).text; + if (textPart && 'text' in textPart) { + (textPart as { type: 'text'; text: string }).text = hint + '\n\n' + (textPart as { type: 'text'; text: string }).text; } - sessionKnowledgeInjected.add(input.sessionID); - console.log(`[smart-codebase] Injected knowledge hint for session ${input.sessionID}`); + console.log(`[smart-codebase] Injected skill hint for session ${input.sessionID}`); } catch (error) { - console.error('[smart-codebase] Failed to inject knowledge hint:', error); + console.error('[smart-codebase] Failed to inject skill hint:', error); sessionKnowledgeInjected.add(input.sessionID); } }; diff --git a/src/hooks/knowledge-extractor.ts b/src/hooks/knowledge-extractor.ts deleted file mode 100644 index a3ea29a..0000000 --- a/src/hooks/knowledge-extractor.ts +++ /dev/null @@ -1,394 +0,0 @@ -import type { PluginInput, Hooks } from "@opencode-ai/plugin"; -import type { PluginConfig, ToolCallRecord } from "../types"; -import { join } from "path"; -import { - writeModuleSkill, - updateSkillIndex, - getModulePath, - getProjectSkillName, - toSkillName, - type SkillContent, - type IndexEntry -} from "../storage/knowledge-writer"; -import { unwrapData, extractTextFromParts, withTimeout } from "../utils/sdk-helpers"; -import { fileExists, readTextFile } from "../utils/fs-compat"; -import { displayExtractionResult } from "../display/feedback"; -import { preprocessSessionSummary } from "../preprocessing/session-summary"; - -type ToolExecuteAfterInput = Parameters>[0]; -type ToolExecuteAfterOutput = Parameters>[1]; -type EventInput = Parameters>[0]; - -const sessionDebounceTimers = new Map(); -const sessionToolCalls = new Map(); -const sessionExtractionInProgress = new Map(); -const sessionToastShown = new Map(); - -function getToolCalls(sessionID: string): ToolCallRecord[] { - if (!sessionToolCalls.has(sessionID)) { - sessionToolCalls.set(sessionID, []); - } - return sessionToolCalls.get(sessionID)!; -} - -function parseModelConfig(modelString?: string): { providerID: string; modelID: string } | undefined { - if (!modelString) return undefined; - const [providerID, ...rest] = modelString.split('/'); - const modelID = rest.join('/'); - if (!providerID || !modelID) return undefined; - return { providerID, modelID }; -} - -export interface ExtractionResult { - modulesUpdated: number; - sectionsAdded: number; - indexUpdated: boolean; -} - -export async function extractKnowledge( - ctx: PluginInput, - sessionID: string, - config?: PluginConfig -): Promise { - if (sessionExtractionInProgress.get(sessionID)) { - console.log(`[smart-codebase] Extraction already in progress for session ${sessionID}, skipping`); - return { modulesUpdated: 0, sectionsAdded: 0, indexUpdated: false }; - } - - sessionExtractionInProgress.set(sessionID, true); - - let extractionSessionID: string | undefined; - const result: ExtractionResult = { modulesUpdated: 0, sectionsAdded: 0, indexUpdated: false }; - - try { - const toolCalls = sessionToolCalls.get(sessionID); - - if (!toolCalls || toolCalls.length === 0) { - console.log(`[smart-codebase] No tool calls tracked in session ${sessionID}, skipping extraction`); - return result; - } - - const modifiedFiles = new Set(toolCalls.map(tc => tc.target).filter((t): t is string => !!t)); - - console.log(`[smart-codebase] Knowledge extraction triggered for session ${sessionID}`); - console.log(`[smart-codebase] Tool calls tracked (${toolCalls.length}), files involved (${modifiedFiles.size}):`, Array.from(modifiedFiles)); - - const preprocessed = await preprocessSessionSummary(ctx, sessionID, toolCalls, { - maxTokens: config?.extractionMaxTokens, - }); - console.log(`[smart-codebase] Pre-processed summary: ${preprocessed.totalTokens} tokens${preprocessed.truncated ? ' (truncated)' : ''}`); - - const createResult = await ctx.client.session.create({ - body: { - title: 'Knowledge Extraction', - parentID: sessionID, - } - }); - - if (createResult.error) { - console.error('[smart-codebase] Failed to create extraction session:', createResult.error); - return result; - } - - extractionSessionID = createResult.data.id; - console.log(`[smart-codebase] Created extraction session: ${extractionSessionID}`); - - // Show toast when subsession is created - await ctx.client.tui.showToast({ - body: { - title: "smart-codebase", - message: "Creating knowledge extraction subsession, starting analysis...", - variant: "info", - duration: 5000, - }, - }).catch(() => {}); - - const primaryFile = Array.from(modifiedFiles)[0]; - const primaryModulePath = getModulePath(primaryFile, ctx.directory); - - const existingSkillPath = join(ctx.directory, primaryModulePath, '.knowledge', 'SKILL.md'); - let existingSkillContent = ''; - if (await fileExists(existingSkillPath)) { - existingSkillContent = await readTextFile(existingSkillPath); - console.log(`[smart-codebase] Found existing SKILL.md at ${existingSkillPath}, will merge`); - } - - const existingSkillSection = existingSkillContent - ? `\nEXISTING SKILL.md (merge with this):\n\`\`\`markdown\n${existingSkillContent}\n\`\`\`\n` - : '\nNo existing SKILL.md found. Create new.\n'; - - const systemContext = `You are smart-codebase: a knowledge distillation agent that writes/updates module-level SKILL.md files. - -PRIMARY SIGNAL - CONVERSATION: -${preprocessed.conversation || '(No conversation)'} - -SECONDARY SIGNALS: -- Files Modified: ${preprocessed.modifiedFiles || '(none)'} -- Git Diff: ${preprocessed.gitDiff || '(none)'} -- Tool Calls: ${preprocessed.toolCallsSummary || '(none)'} -- Code Snippets: ${preprocessed.codeSnippets || '(none)'} -${existingSkillSection} - -YOUR TASK: Extract durable, project-specific knowledge for future AI sessions and Human developers. - -EXTRACT when: implementation patterns, design decisions, gotchas, bug fixes with explanations, user-described features. -SKIP when: pure config changes, trivial edits (typos, formatting), no actionable knowledge. - -MERGE with existing SKILL.md: preserve valuable content, update outdated info, add new sections, remove redundant content. - -OUTPUT FORMAT: -{ - "skill": { - "modulePath": "src/invoice", - "name": "invoice-processing", - "description": "Invoice form validation. Use Decimal for amounts to avoid precision issues, format INV-YYYYMMDD-XXXX. Use when modifying invoice forms or validation logic.", - "sections": [{"heading": "Form Validation", "content": "Amount field uses Decimal type to avoid precision issues.\\nInvoice number format: INV-YYYYMMDD-XXXX"}], - "relatedFiles": ["src/invoice/form.tsx"] - } -} - -RULES: -- name: lowercase-hyphens, max 64 chars. ALWAYS in English. -- description: Max 300 chars. Include: what it does + key knowledge/gotchas + "Use when..." trigger. This serves as the index summary for skill discovery. MUST be in user's language. -- sections: Complete merged list with heading + content. -- content: No verbose explanations. Be Concise. -- Language: Write description/headings/content in USER'S LANGUAGE (detect from conversation). Keep name field, code snippets, file paths, technical identifiers in English. -- relatedFiles: COMPLETE list after merging. -Return ONLY valid JSON. No knowledge: {"skill": null}`; - - const extractionPrompt = `Output the merged SKILL JSON now. Return ONLY valid JSON.`; - - const model = parseModelConfig(config?.extractionModel); - console.log(`[smart-codebase] Sending extraction prompt to AI...${model ? ` (model: ${config?.extractionModel})` : ''}`); - const promptResult = await withTimeout( - ctx.client.session.prompt({ - path: { id: extractionSessionID }, - body: { - ...(model && { model }), - system: systemContext, - parts: [{ type: 'text', text: extractionPrompt }] - } - }), - 120000 - ); - - const response = unwrapData(promptResult as any) as { parts: any[] }; - const text = extractTextFromParts(response.parts); - console.log(`[smart-codebase] Received AI response (${text.length} chars)`); - - let extracted: { skill: any } | null = null; - try { - let cleanText = text.trim(); - if (cleanText.startsWith('```')) { - cleanText = cleanText.replace(/```json\n?/g, '').replace(/```\n?/g, ''); - } - extracted = JSON.parse(cleanText); - } catch (error) { - console.error('[smart-codebase] Failed to parse AI response as JSON:', error); - return result; - } - - if (!extracted?.skill) { - console.log('[smart-codebase] No significant knowledge extracted'); - return result; - } - - const s = extracted.skill; - const modulePath = s.modulePath || '.'; - - const skillContent: SkillContent = { - metadata: { - name: s.name || toSkillName(modulePath), - description: s.description || `Handles ${modulePath} module. Use when working on related files.` - }, - sections: (s.sections || []).map((sec: any) => ({ - heading: sec.heading, - content: sec.content - })), - relatedFiles: s.relatedFiles || [] - }; - - // Only write to module .knowledge/ if not root level - // Root level knowledge goes directly to .opencode/skills// - if (modulePath !== '.') { - const skillPath = await writeModuleSkill( - ctx.directory, - modulePath, - skillContent - ); - console.log(`[smart-codebase] Updated module skill: ${skillPath}`); - result.modulesUpdated = 1; - } else { - console.log(`[smart-codebase] Root-level knowledge, writing directly to OpenCode skill index`); - } - result.sectionsAdded = skillContent.sections.length; - - const indexEntry: IndexEntry = { - name: skillContent.metadata.name, - description: skillContent.metadata.description, - location: modulePath === '.' - ? `.opencode/skills/${getProjectSkillName(ctx.directory)}/SKILL.md` - : `modules/${toSkillName(modulePath)}.md` - }; - - await updateSkillIndex(ctx.directory, indexEntry); - console.log(`[smart-codebase] Updated OpenCode skill index`); - result.indexUpdated = true; - - sessionToolCalls.delete(sessionID); - - return result; - } catch (error) { - console.error(`[smart-codebase] Failed to extract knowledge for session ${sessionID}:`, error); - return result; - } finally { - sessionExtractionInProgress.delete(sessionID); - - if (extractionSessionID) { - try { - await ctx.client.session.delete({ - path: { id: extractionSessionID } - }); - console.log(`[smart-codebase] Cleaned up extraction session: ${extractionSessionID}`); - } catch (error) { - console.error(`[smart-codebase] Failed to cleanup extraction session:`, error); - } - } - } -} - -export function cancelPendingExtraction(sessionID: string): boolean { - const timer = sessionDebounceTimers.get(sessionID); - if (timer) { - clearTimeout(timer); - sessionDebounceTimers.delete(sessionID); - console.log(`[smart-codebase] Cancelled pending extraction for session ${sessionID}`); - return true; - } - return false; -} - -export function createKnowledgeExtractorHook(ctx: PluginInput, config?: PluginConfig) { - const toolExecuteAfter = async ( - input: ToolExecuteAfterInput, - output: ToolExecuteAfterOutput, - ) => { - const toolName = input.tool.toLowerCase(); - - // Filter out config and tui operations - if (toolName.startsWith('config.') || toolName.startsWith('tui.')) { - return; - } - - // Skip tracking for extraction sessions (child sessions) - // Extraction sessions have parentID set - try { - const session = await ctx.client.session.get({ path: { id: input.sessionID } }); - if (session.data?.parentID) { - return; // Don't track tool calls from extraction sessions - } - } catch (error) { - console.error(`[smart-codebase] Failed to check session parentID:`, error); - return; - } - - try { - const target = output.title as string | undefined; - const toolCalls = getToolCalls(input.sessionID); - - const record: ToolCallRecord = { - tool: toolName, - target, - timestamp: Date.now() - }; - - toolCalls.push(record); - console.log(`[smart-codebase] Tracked tool call: ${toolName}${target ? ` on ${target}` : ''}`); - } catch (error) { - console.error(`[smart-codebase] Failed to track tool call:`, error); - } - }; - - const eventHandler = async ({ event }: EventInput) => { - const props = event.properties as Record | undefined; - - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined; - if (!sessionID) return; - - if (config?.autoExtract === false) { - // Check if we should show toast - const toolCalls = sessionToolCalls.get(sessionID); - if (toolCalls && toolCalls.length > 0 && !sessionToastShown.get(sessionID)) { - await ctx.client.tui.showToast({ - body: { - title: "smart-codebase", - message: "Run /sc-extract to extract knowledge", - variant: "info", - duration: 5000, - }, - }).catch(() => {}); - sessionToastShown.set(sessionID, true); - console.log(`[smart-codebase] Toast notification triggered for session ${sessionID}`); - } - return; - } - - const existingTimer = sessionDebounceTimers.get(sessionID); - if (existingTimer) { - clearTimeout(existingTimer); - } - - const debounceMs = config?.debounceMs ?? 60000; - - await ctx.client.tui.showToast({ - body: { - title: "smart-codebase", - message: `Session idle, knowledge extraction starting in ${debounceMs / 1000} seconds...`, - variant: "info", - duration: 5000, - }, - }).catch(() => {}); - console.log(`[smart-codebase] Countdown toast shown for session ${sessionID}`); - - const timer = setTimeout(async () => { - const extractionResult = await extractKnowledge(ctx, sessionID, config); - - const message = displayExtractionResult(extractionResult); - - await ctx.client.tui.showToast({ - body: { - title: "smart-codebase", - message, - variant: "success", - duration: 5000, - }, - }).catch(() => {}); - - sessionDebounceTimers.delete(sessionID); - }, debounceMs); - - sessionDebounceTimers.set(sessionID, timer); - console.log(`[smart-codebase] Session ${sessionID} idle, extraction scheduled in ${debounceMs}ms`); - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - const timer = sessionDebounceTimers.get(sessionInfo.id); - if (timer) { - clearTimeout(timer); - } - sessionDebounceTimers.delete(sessionInfo.id); - sessionToolCalls.delete(sessionInfo.id); - sessionToastShown.delete(sessionInfo.id); - console.log(`[smart-codebase] Cleaned up session ${sessionInfo.id}`); - } - } - }; - - return { - "tool.execute.after": toolExecuteAfter, - event: eventHandler, - }; -} diff --git a/src/index.ts b/src/index.ts index 5d352ef..8ea7265 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,117 +1,44 @@ import type { Plugin } from "@opencode-ai/plugin"; -import { extractCommand } from "./commands/extract"; -import { statusCommand } from "./commands/status"; -import { rebuildIndexCommand } from "./commands/rebuild-index"; -import { cleanupCommand } from "./commands/cleanup"; +import { updateCommand } from "./commands/update"; +import { initCommand } from "./commands/init"; import { createContextInjectorHook } from "./hooks/context-injector"; -import { createKnowledgeExtractorHook, cancelPendingExtraction } from "./hooks/knowledge-extractor"; import { setPluginInput } from "./plugin-context"; import { loadConfig } from "./config"; -import { trackSkillAccess, shouldTrackPath } from "./storage/usage-tracker"; - -const ALL_COMMANDS = { - "sc-extract": extractCommand, - "sc-status": statusCommand, - "sc-rebuild-index": rebuildIndexCommand, - "sc-cleanup": cleanupCommand, -} as const; - -const COMMAND_CONFIGS = { - "sc-extract": { - template: "Use sc-extract to manually trigger knowledge extraction. Analyzes modified files in current session and extracts valuable knowledge.", - description: "Manually trigger knowledge extraction", - }, - "sc-status": { - template: "Use sc-status to display knowledge base status. Shows module count and index status.", - description: "Display knowledge base status", - }, - "sc-rebuild-index": { - template: "Use sc-rebuild-index to rebuild global knowledge index. Scans all .knowledge/ directories and rebuilds .knowledge/KNOWLEDGE.md.", - description: "Rebuild knowledge index", - }, - "sc-cleanup": { - template: "Use sc-cleanup to identify and remove low-usage SKILL files. Default is preview mode. Use --confirm to actually delete.", - description: "Clean up low-usage SKILL files", - }, -} as const; const SmartCodebasePlugin: Plugin = async (input) => { try { setPluginInput(input); - const config = loadConfig(); - + if (!config.enabled) { console.log("[smart-codebase] Plugin disabled via config"); return {}; } - const disabledCommands = new Set(config.disabledCommands || []); - - const enabledTools: Record = {}; - const enabledCommandConfigs: Record = {}; - - for (const [name, command] of Object.entries(ALL_COMMANDS)) { - if (!disabledCommands.has(name)) { - enabledTools[name] = command; - enabledCommandConfigs[name] = COMMAND_CONFIGS[name as keyof typeof COMMAND_CONFIGS]; - } - } - - const contextInjector = createContextInjectorHook(input, config); - const knowledgeExtractor = createKnowledgeExtractorHook(input, config); - - let hasShownWelcomeToast = false; + const contextInjector = createContextInjectorHook(input); return { - tool: enabledTools, - "tool.execute.after": async (hookInput, output) => { - await knowledgeExtractor["tool.execute.after"]?.(hookInput, output); - - if (hookInput.tool === "read" && output.title) { - const filePath = output.title; - const projectRoot = input.directory; - - if (shouldTrackPath(filePath, projectRoot)) { - await trackSkillAccess(filePath, projectRoot); - } - } + tool: { + "sc-update": updateCommand, + "sc-init": initCommand, + }, + "chat.message": async (hookInput, output) => { + await contextInjector["chat.message"]?.(hookInput, output); }, - "chat.message": async (hookInput, output) => { - const wasCancelled = cancelPendingExtraction(hookInput.sessionID); - if (wasCancelled) { - await input.client.tui.showToast({ - body: { - title: "smart-codebase", - message: "Knowledge extraction cancelled, continuing work...", - variant: "info", - duration: 5000, - }, - }).catch(() => {}); - } - - await contextInjector["chat.message"]?.(hookInput, output); - }, event: async (hookInput) => { - if (!hasShownWelcomeToast && hookInput.event.type === "session.created") { - hasShownWelcomeToast = true; - await input.client.tui.showToast({ - body: { - title: "smart-codebase", - message: "Knowledge base active", - variant: "info", - duration: 5000, - }, - }).catch(() => {}); - } - await contextInjector.event?.(hookInput); - await knowledgeExtractor.event?.(hookInput); }, config: async (cfg) => { cfg.command = { ...cfg.command, - ...enabledCommandConfigs, + "sc-update": { + template: "sc-update $ARGUMENTS", + description: "Update project SKILL files from session knowledge", + }, + "sc-init": { + template: "sc-init $ARGUMENTS", + description: "Initialize project SKILL files from source code scan", + }, }; }, }; diff --git a/src/preprocessing/session-summary.ts b/src/preprocessing/session-summary.ts deleted file mode 100644 index 9fa9e9d..0000000 --- a/src/preprocessing/session-summary.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin"; -import type { ToolCallRecord, PreprocessedSummary } from "../types"; - -const BINARY_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.pdf', '.zip', '.tar', '.gz']; -const DEFAULT_MAX_TOKENS = 8000; -const MAX_SNIPPET_LINES = 200; - -function estimateTokens(text: string): number { - return Math.ceil(text.length / 4); -} - -function isBinaryFile(filePath: string): boolean { - const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase(); - return BINARY_EXTENSIONS.includes(ext); -} - -function extractTextFromMessageParts(parts: any[] | undefined): string { - if (!Array.isArray(parts)) return ''; - return parts - .filter((p: any) => p?.type === 'text' && typeof p?.text === 'string') - .map((p: any) => p.text) - .join('\n') - .trim(); -} - -function getMessageRole(msg: any): string | undefined { - return msg?.role ?? msg?.info?.role; -} - -function getMessageCreatedTime(msg: any): number | undefined { - const t = msg?.time?.created ?? msg?.info?.time?.created; - return typeof t === 'number' ? t : undefined; -} - -async function fetchConversation(ctx: PluginInput, sessionID: string): Promise { - const messagesResult = await ctx.client.session.messages({ - path: { id: sessionID } - }); - - if (messagesResult.error) { - console.error('[smart-codebase] Failed to fetch messages:', messagesResult.error); - return ''; - } - - const messages = messagesResult.data; - const transcriptLines: string[] = []; - - for (let i = 0; i < messages.length; i++) { - const msg: any = (messages as any)[i]; - const role = getMessageRole(msg); - if (role !== 'user' && role !== 'assistant') continue; - - const text = extractTextFromMessageParts(msg?.parts); - if (!text) continue; - - const created = getMessageCreatedTime(msg); - const when = created ? ` @ ${new Date(created).toISOString()}` : ''; - const who = role === 'assistant' ? 'Assistant' : 'User'; - transcriptLines.push(`[${i + 1}] ${who}${when}\n${text}`); - } - - return transcriptLines.join('\n\n'); -} - -async function getGitDiff(ctx: PluginInput): Promise { - try { - const { execSync } = await import('child_process'); - return execSync('git diff HEAD', { - cwd: ctx.directory, - encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024 - }); - } catch (error) { - console.error('[smart-codebase] Failed to get git diff:', error); - return `Failed to get git diff: ${error}`; - } -} - -function formatToolCallsSummary(toolCalls: ToolCallRecord[]): string { - return toolCalls - .map(tc => { - const timestamp = new Date(tc.timestamp).toISOString(); - const target = tc.target ? ` on ${tc.target}` : ''; - return `[${timestamp}] ${tc.tool}${target}`; - }) - .join('\n'); -} - -async function extractCodeSnippets(ctx: PluginInput, toolCalls: ToolCallRecord[]): Promise { - const readToolCalls = toolCalls.filter(tc => tc.tool === 'read' && tc.target && !isBinaryFile(tc.target)); - - if (readToolCalls.length === 0) { - return ''; - } - - const snippets: string[] = []; - - for (const tc of readToolCalls) { - if (!tc.target) continue; - - try { - const { readFileSync } = await import('fs'); - const { join } = await import('path'); - const filePath = join(ctx.directory, tc.target); - const content = readFileSync(filePath, 'utf-8'); - const lines = content.split('\n').slice(0, MAX_SNIPPET_LINES); - const snippet = `\n--- ${tc.target} (first ${MAX_SNIPPET_LINES} lines) ---\n${lines.join('\n')}`; - snippets.push(snippet); - } catch (error) { - continue; - } - } - - return snippets.join('\n\n'); -} - -type Section = { name: string; content: string; importance: number }; - -function truncateSections( - sections: Section[], - maxTokens: number -): { sections: Section[]; truncated: boolean; originalTokens: number } { - let totalTokens = sections.reduce((sum, s) => sum + estimateTokens(s.content), 0); - const originalTokens = totalTokens; - - if (totalTokens <= maxTokens) { - return { sections, truncated: false, originalTokens }; - } - - const originalOrder = sections.map(s => s.name); - const byName = new Map(sections.map(s => [s.name, { ...s }])); - const working: Section[] = sections.map(s => ({ ...s })); - - while (totalTokens > maxTokens && working.length > 0) { - working.sort((a, b) => a.importance - b.importance); - const victim = working[0]; - const tokensToRemove = totalTokens - maxTokens; - const sectionTokens = estimateTokens(victim.content); - - if (sectionTokens <= tokensToRemove) { - byName.set(victim.name, { ...victim, content: '' }); - totalTokens -= sectionTokens; - working.shift(); - } else { - const targetLength = Math.floor((sectionTokens - tokensToRemove) * 4); - const truncatedContent = victim.content.substring(0, targetLength) + '\n... [truncated]'; - byName.set(victim.name, { ...victim, content: truncatedContent }); - totalTokens = maxTokens; - break; - } - } - - const finalSections = originalOrder.map((name) => byName.get(name)!).filter(Boolean); - return { sections: finalSections, truncated: true, originalTokens }; -} - -export async function preprocessSessionSummary( - ctx: PluginInput, - sessionID: string, - toolCalls: ToolCallRecord[], - options?: { maxTokens?: number } -): Promise { - const maxTokens = options?.maxTokens ?? DEFAULT_MAX_TOKENS; - const conversationContent = await fetchConversation(ctx, sessionID); - - const modifiedFiles = new Set( - toolCalls - .map(tc => tc.target) - .filter((t): t is string => !!t && !isBinaryFile(t)) - ); - - const diffContent = modifiedFiles.size > 0 ? await getGitDiff(ctx) : ''; - - const modifiedFilesContent = Array.from(modifiedFiles) - .slice(0, 20) - .map(f => `- ${f}`) - .join('\n'); - - const toolCallsContent = formatToolCallsSummary(toolCalls); - const snippetsContent = await extractCodeSnippets(ctx, toolCalls); - - const sections: Section[] = [ - { name: 'Conversation', content: conversationContent, importance: 100 }, - { name: 'Diff', content: diffContent, importance: 80 }, - { name: 'ToolCalls', content: toolCallsContent, importance: 60 }, - { name: 'Snippets', content: snippetsContent, importance: 40 } - ]; - - const { sections: truncatedSections, truncated, originalTokens } = truncateSections(sections, maxTokens); - - const finalConversation = truncatedSections.find(s => s.name === 'Conversation')?.content || ''; - const finalDiff = truncatedSections.find(s => s.name === 'Diff')?.content || ''; - const finalToolCalls = truncatedSections.find(s => s.name === 'ToolCalls')?.content || ''; - const finalSnippets = truncatedSections.find(s => s.name === 'Snippets')?.content || ''; - - const conversationTokens = estimateTokens(finalConversation); - const diffTokens = estimateTokens(finalDiff); - const toolCallTokens = estimateTokens(finalToolCalls); - const snippetTokens = estimateTokens(finalSnippets); - const totalTokens = conversationTokens + diffTokens + toolCallTokens + snippetTokens; - - console.log(`[smart-codebase] Pre-processed summary: ~${totalTokens} tokens`); - console.log(`[smart-codebase] Sections: Conversation=${conversationTokens}, Diff=${diffTokens}, ToolCalls=${toolCallTokens}, Snippets=${snippetTokens}`); - - if (truncated) { - console.log(`[smart-codebase] Truncated to ${maxTokens} tokens (original: ${originalTokens})`); - } - - return { - conversation: finalConversation, - modifiedFiles: modifiedFilesContent, - gitDiff: finalDiff, - toolCallsSummary: finalToolCalls, - codeSnippets: finalSnippets, - totalTokens, - truncated - }; -} diff --git a/src/storage/knowledge-writer.ts b/src/storage/knowledge-writer.ts deleted file mode 100644 index 4d84207..0000000 --- a/src/storage/knowledge-writer.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { mkdir } from 'fs/promises'; -import { join, dirname, relative, resolve, isAbsolute, basename } from 'path'; -import { fileExists, readTextFile, writeTextFile, sleep, removeFile } from '../utils/fs-compat'; - -export interface SkillMetadata { - name: string; - description: string; -} - -export interface SkillContent { - metadata: SkillMetadata; - sections: SkillSection[]; - relatedFiles?: string[]; -} - -export interface SkillSection { - heading: string; - content: string; -} - -export interface IndexEntry { - name: string; - description: string; - location: string; -} - -export async function writeModuleSkill( - projectRoot: string, - modulePath: string, - skill: SkillContent -): Promise { - const projectName = getProjectSkillName(projectRoot); - const skillName = toSkillName(modulePath); - const skillDir = join(projectRoot, '.opencode', 'skills', projectName, 'modules'); - const skillPath = join(skillDir, `${skillName}.md`); - const lockFile = join(skillDir, '.lock'); - - await mkdir(skillDir, { recursive: true }); - - const lock = await acquireLock(lockFile, 5000); - - try { - let existingContent = ''; - if (await fileExists(skillPath)) { - existingContent = await readTextFile(skillPath); - } - - const content = formatSkillContent(skill, existingContent); - await writeTextFile(skillPath, content); - return skillPath; - } finally { - await releaseLock(lock); - } -} - -function formatSkillContent(skill: SkillContent, existingContent?: string): string { - const lines: string[] = []; - - let createdAt = new Date().toISOString(); - const lastUpdated = new Date().toISOString(); - - if (existingContent) { - const createdMatch = existingContent.match(/created_at:\s*([^\s]+)/); - if (createdMatch) { - createdAt = createdMatch[1]; - } - } - - lines.push('---'); - lines.push(`name: ${skill.metadata.name}`); - lines.push(`description: ${skill.metadata.description}`); - lines.push('usage:'); - lines.push(` created_at: ${createdAt}`); - lines.push(` last_updated: ${lastUpdated}`); - lines.push('---'); - lines.push(''); - - for (const section of skill.sections) { - lines.push(`## ${section.heading}`); - lines.push(''); - lines.push(section.content); - lines.push(''); - } - - if (skill.relatedFiles && skill.relatedFiles.length > 0) { - lines.push('## Related files'); - lines.push(''); - for (const file of skill.relatedFiles) { - lines.push(`- \`${file}\``); - } - lines.push(''); - } - - return lines.join('\n').trim() + '\n'; -} - -export async function updateGlobalIndex( - projectRoot: string, - entry: IndexEntry -): Promise { - const knowledgeDir = join(projectRoot, '.knowledge'); - const indexPath = join(knowledgeDir, 'KNOWLEDGE.md'); - const lockFile = join(projectRoot, '.knowledge.lock'); - - await mkdir(knowledgeDir, { recursive: true }); - - const lock = await acquireLock(lockFile, 5000); - - try { - let content = ''; - if (await fileExists(indexPath)) { - content = await readTextFile(indexPath); - } - - if (!content.includes('# Project Knowledge')) { - content = `# Project Knowledge - -> Project knowledge index. Read this first to understand available domain knowledge, then read relevant module SKILLs as needed. - -`; - } - - const entryMarker = `### ${entry.name}`; - if (content.includes(entryMarker)) { - const entryRegex = new RegExp( - `### ${escapeRegex(entry.name)}[\\s\\S]*?(?=\\n### |$)`, - 'g' - ); - content = content.replace(entryRegex, formatIndexEntry(entry)); - } else { - content = content.trimEnd() + '\n\n' + formatIndexEntry(entry); - } - - await writeTextFile(indexPath, content); - } finally { - await releaseLock(lock); - } -} - -function formatIndexEntry(entry: IndexEntry): string { - return `### ${entry.name} -${entry.description} -- **Location**: \`${entry.location}\` -`; -} - -export function toSkillName(modulePath: string): string { - if (modulePath === '.') return 'project-root'; - - return modulePath - .replace(/[\/\\]/g, '-') - .replace(/[^a-z0-9-]/gi, '') - .toLowerCase() - .slice(0, 64); -} - -export function getProjectSkillName(projectRoot: string): string { - const folderName = basename(projectRoot); - - return folderName - .replace(/[^a-z0-9-]/gi, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - .toLowerCase() - .slice(0, 64) || 'project'; -} - -export async function updateSkillIndex( - projectRoot: string, - entry: IndexEntry -): Promise { - const skillName = getProjectSkillName(projectRoot); - const skillDir = join(projectRoot, '.opencode', 'skills', skillName); - const skillPath = join(skillDir, 'SKILL.md'); - const lockFile = join(skillDir, '.lock'); - - await mkdir(skillDir, { recursive: true }); - - const lock = await acquireLock(lockFile, 5000); - - try { - let content = ''; - if (await fileExists(skillPath)) { - content = await readTextFile(skillPath); - } - - if (!content.startsWith('---')) { - content = `--- -name: ${skillName}-conventions -description: Development conventions and patterns for ${basename(projectRoot)} project ---- - -# Project Knowledge - -> Project knowledge index. Read this first to understand available domain knowledge, then read relevant module SKILLs as needed. - -`; - } - - const entryMarker = `### ${entry.name}`; - if (content.includes(entryMarker)) { - const entryRegex = new RegExp( - `### ${escapeRegex(entry.name)}[\\s\\S]*?(?=\\n### |$)`, - 'g' - ); - content = content.replace(entryRegex, formatIndexEntry(entry)); - } else { - content = content.trimEnd() + '\n\n' + formatIndexEntry(entry); - } - - await writeTextFile(skillPath, content); - } finally { - await releaseLock(lock); - } -} - -// Directories that should not be treated as modules -const EXCLUDED_DIRS = [ - // Version control - '.git', '.svn', '.hg', - // Dependencies - 'node_modules', 'bower_components', 'jspm_packages', 'vendor', - // Build outputs - 'dist', 'build', 'out', 'output', '.output', - // Framework build directories - '.next', '.nuxt', '.vuepress', '.docusaurus', '.svelte-kit', - // Test coverage - 'coverage', '.nyc_output', - // IDE/Editor config - '.vscode', '.idea', '.eclipse', '.settings', - // Git hooks - '.husky', - // Temporary/cache - 'tmp', 'temp', '.cache', '.parcel-cache', '.turbo', - // Package manager - '.pnpm', '.yarn', '.npm', -]; - -export function getModulePath(filePath: string, projectRoot: string): string { - const absolutePath = isAbsolute(filePath) - ? filePath - : resolve(projectRoot, filePath); - - const relativePath = relative(projectRoot, dirname(absolutePath)); - const parts = relativePath.split(/[/\\]/).filter(p => p && p !== '.'); - - if (parts.length === 0) return '.'; - - if (parts.length >= 1 && EXCLUDED_DIRS.includes(parts[0])) { - return '.'; - } - - if (parts.length === 1) return parts[0]; - - return parts.slice(0, 2).join('/'); -} - -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -async function acquireLock(lockFile: string, timeoutMs: number): Promise<{ file: string }> { - const startTime = Date.now(); - const dir = dirname(lockFile); - - await mkdir(dir, { recursive: true }); - - while (true) { - try { - if (await fileExists(lockFile)) { - throw { code: 'EEXIST' }; - } - await writeTextFile(lockFile, process.pid.toString()); - return { file: lockFile }; - } catch (error: any) { - if (error.code === 'EEXIST') { - if (Date.now() - startTime > timeoutMs) { - throw new Error(`Failed to acquire lock on ${lockFile} within ${timeoutMs}ms`); - } - await sleep(50); - continue; - } - throw error; - } - } -} - -async function releaseLock(lock: { file: string }): Promise { - try { - if (await fileExists(lock.file)) { - await removeFile(lock.file); - } - } catch (error) { - console.error(`Failed to release lock ${lock.file}:`, error); - } -} diff --git a/src/storage/usage-tracker.ts b/src/storage/usage-tracker.ts deleted file mode 100644 index fb7051b..0000000 --- a/src/storage/usage-tracker.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { join, dirname } from 'path'; -import { mkdir } from 'fs/promises'; -import { fileExists, readTextFile, writeTextFile, sleep, removeFile } from '../utils/fs-compat'; - -interface UsageMetadata { - created_at?: string; - last_updated?: string; - access_count?: number; - last_accessed?: string; -} - -interface SkillFrontmatter { - name: string; - description: string; - usage?: UsageMetadata; - [key: string]: any; -} - -export function shouldTrackPath(filePath: string, projectRoot: string): boolean { - const pattern = /\.opencode\/skills\/[^\/]+\/modules\/[^\/]+\.md$/; - return pattern.test(filePath); -} - -export async function trackSkillAccess(skillPath: string, projectRoot: string): Promise { - try { - if (!(await fileExists(skillPath))) { - return; - } - - const lockFile = join(dirname(skillPath), '.usage-lock'); - const lock = await acquireLock(lockFile, 5000); - - try { - const content = await readTextFile(skillPath); - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); - - if (!frontmatterMatch) { - return; - } - - const frontmatterRaw = frontmatterMatch[1]; - const frontmatter = parseFrontmatter(frontmatterRaw); - - if (!frontmatter.usage) { - frontmatter.usage = {}; - } - - frontmatter.usage.access_count = (frontmatter.usage.access_count || 0) + 1; - frontmatter.usage.last_accessed = new Date().toISOString(); - - const bodyContent = content.slice(frontmatterMatch[0].length).trim(); - const updatedContent = formatSkillWithFrontmatter(frontmatter, bodyContent); - - await writeTextFile(skillPath, updatedContent); - } finally { - await releaseLock(lock); - } - } catch (error) { - console.error(`[usage-tracker] Failed to track access for ${skillPath}:`, error); - } -} - -function parseFrontmatter(raw: string): SkillFrontmatter { - const lines = raw.split('\n'); - const frontmatter: any = {}; - let currentKey: string | null = null; - let currentIndent = 0; - let nestedObject: any = null; - - for (const line of lines) { - if (!line.trim()) continue; - - const indent = line.search(/\S/); - const trimmed = line.trim(); - - if (indent === 0 && trimmed.includes(':')) { - const [key, ...valueParts] = trimmed.split(':'); - const value = valueParts.join(':').trim(); - currentKey = key.trim(); - - if (value) { - frontmatter[currentKey] = value; - nestedObject = null; - } else { - frontmatter[currentKey] = {}; - nestedObject = frontmatter[currentKey]; - currentIndent = indent; - } - } else if (indent > currentIndent && nestedObject && trimmed.includes(':')) { - const [key, ...valueParts] = trimmed.split(':'); - const value = valueParts.join(':').trim(); - const nestedKey = key.trim(); - - if (nestedKey === 'access_count') { - nestedObject[nestedKey] = parseInt(value, 10) || 0; - } else { - nestedObject[nestedKey] = value; - } - } - } - - return frontmatter as SkillFrontmatter; -} - -function formatSkillWithFrontmatter(frontmatter: SkillFrontmatter, body: string): string { - const lines: string[] = ['---']; - - lines.push(`name: ${frontmatter.name}`); - lines.push(`description: ${frontmatter.description}`); - - if (frontmatter.usage) { - lines.push('usage:'); - if (frontmatter.usage.created_at) { - lines.push(` created_at: ${frontmatter.usage.created_at}`); - } - if (frontmatter.usage.last_updated) { - lines.push(` last_updated: ${frontmatter.usage.last_updated}`); - } - if (frontmatter.usage.access_count !== undefined) { - lines.push(` access_count: ${frontmatter.usage.access_count}`); - } - if (frontmatter.usage.last_accessed) { - lines.push(` last_accessed: ${frontmatter.usage.last_accessed}`); - } - } - - lines.push('---'); - lines.push(''); - lines.push(body); - - return lines.join('\n'); -} - -async function acquireLock(lockFile: string, timeoutMs: number): Promise<{ file: string }> { - const startTime = Date.now(); - const dir = dirname(lockFile); - - await mkdir(dir, { recursive: true }); - - while (true) { - const lockExists = await fileExists(lockFile); - - if (lockExists) { - if (Date.now() - startTime > timeoutMs) { - throw new Error(`Failed to acquire lock on ${lockFile} within ${timeoutMs}ms`); - } - await sleep(50); - continue; - } - - try { - await writeTextFile(lockFile, process.pid.toString()); - - await sleep(10); - - const stillExists = await fileExists(lockFile); - if (stillExists) { - const lockContent = await readTextFile(lockFile); - if (lockContent === process.pid.toString()) { - return { file: lockFile }; - } - } - - await sleep(50); - } catch (error: any) { - if (Date.now() - startTime > timeoutMs) { - throw new Error(`Failed to acquire lock on ${lockFile} within ${timeoutMs}ms`); - } - await sleep(50); - } - } -} - -async function releaseLock(lock: { file: string }): Promise { - try { - if (await fileExists(lock.file)) { - await removeFile(lock.file); - } - } catch (error) { - console.error(`Failed to release lock ${lock.file}:`, error); - } -} diff --git a/src/types.ts b/src/types.ts index 8488af2..2f12061 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,55 +1,14 @@ -export interface UsageMetadata { - created_at: string; - last_accessed: string; - access_count: number; - last_updated: string; -} - -export interface CleanupThresholds { - minAgeDays: number; - minAccessCount: number; - maxInactiveDays: number; -} - export interface PluginConfig { enabled: boolean; - debounceMs?: number; - autoExtract?: boolean; - autoInject?: boolean; - disabledCommands?: string[]; - /** - * Max token budget for the extraction context preprocessor (conversation + diff + evidence). - * Approx tokens are estimated as chars/4. Default: 8000 - */ - extractionMaxTokens?: number; /** * Model to use for knowledge extraction. Format: "providerID/modelID" * Example: "minimax/MiniMax-M2.1", "openai/gpt-4o" * If not specified, uses OpenCode's default model. */ extractionModel?: string; - cleanupThresholds?: CleanupThresholds; -} - -export interface ToolCallRecord { - tool: string; - target?: string; - timestamp: number; -} - -export interface KnowledgeStats { - hasGlobalIndex: boolean; - moduleCount: number; - modules: string[]; -} - -export interface PreprocessedSummary { - /** Full transcript: user + assistant turns (text parts only). */ - conversation: string; - modifiedFiles: string; - gitDiff: string; - toolCallsSummary: string; - codeSnippets: string; - totalTokens: number; - truncated: boolean; + /** + * Max token budget for extraction context. + * Default: 16000 + */ + extractionMaxTokens: number; } diff --git a/src/utils/skill-helpers.ts b/src/utils/skill-helpers.ts new file mode 100644 index 0000000..638b7a8 --- /dev/null +++ b/src/utils/skill-helpers.ts @@ -0,0 +1,22 @@ +import { basename } from "path"; + +export function toSkillName(modulePath: string): string { + if (modulePath === '.') return 'project-root'; + + return modulePath + .replace(/[\/\\]/g, '-') + .replace(/[^a-z0-9-]/gi, '') + .toLowerCase() + .slice(0, 64); +} + +export function getProjectSkillName(projectRoot: string): string { + const folderName = basename(projectRoot); + + return folderName + .replace(/[^a-z0-9-]/gi, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .toLowerCase() + .slice(0, 64) || 'project'; +} diff --git a/tsconfig.json b/tsconfig.json index 00128ef..5821de2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "moduleResolution": "bundler" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/__tests__"] }