From e639297d8b30eb6b20cc5c59ffb3286466b8fc62 Mon Sep 17 00:00:00 2001 From: AH Date: Sun, 13 Sep 2026 09:02:45 -0700 Subject: [PATCH 1/8] feat(knowledge): add governed engineering memory system --- packages/knowledge-engine/README.md | 59 +++ packages/knowledge-engine/package.json | 25 ++ packages/knowledge-engine/src/abstention.ts | 86 ++++ packages/knowledge-engine/src/admission.ts | 110 +++++ .../knowledge-engine/src/agent-integration.ts | 136 ++++++ packages/knowledge-engine/src/agents.ts | 126 ++++++ packages/knowledge-engine/src/candidate.ts | 166 ++++++++ packages/knowledge-engine/src/cli.ts | 72 ++++ packages/knowledge-engine/src/embedder.ts | 90 ++++ packages/knowledge-engine/src/extraction.ts | 174 ++++++++ packages/knowledge-engine/src/extractor.ts | 217 ++++++++++ packages/knowledge-engine/src/index.ts | 37 ++ packages/knowledge-engine/src/manager.ts | 85 ++++ packages/knowledge-engine/src/middleware.ts | 127 ++++++ packages/knowledge-engine/src/retriever.ts | 111 +++++ packages/knowledge-engine/src/review.ts | 78 ++++ packages/knowledge-engine/src/staging.ts | 243 +++++++++++ packages/knowledge-engine/src/types.ts | 47 +++ packages/knowledge-engine/src/vector-db.ts | 387 ++++++++++++++++++ .../knowledge-engine/tests/abstention.test.ts | 214 ++++++++++ .../knowledge-engine/tests/admission.test.ts | 362 ++++++++++++++++ .../knowledge-engine/tests/candidate.test.ts | 95 +++++ .../tests/concurrency.test.ts | 131 ++++++ .../knowledge-engine/tests/engine.test.ts | 167 ++++++++ .../knowledge-engine/tests/extraction.test.ts | 117 ++++++ .../knowledge-engine/tests/hygiene.test.ts | 109 +++++ .../tests/integration.test.ts | 81 ++++ .../knowledge-engine/tests/review.test.ts | 180 ++++++++ .../knowledge-engine/tests/staging.test.ts | 148 +++++++ packages/knowledge-engine/tsconfig.json | 7 + 30 files changed, 3987 insertions(+) create mode 100644 packages/knowledge-engine/README.md create mode 100644 packages/knowledge-engine/package.json create mode 100644 packages/knowledge-engine/src/abstention.ts create mode 100644 packages/knowledge-engine/src/admission.ts create mode 100644 packages/knowledge-engine/src/agent-integration.ts create mode 100644 packages/knowledge-engine/src/agents.ts create mode 100644 packages/knowledge-engine/src/candidate.ts create mode 100644 packages/knowledge-engine/src/cli.ts create mode 100644 packages/knowledge-engine/src/embedder.ts create mode 100644 packages/knowledge-engine/src/extraction.ts create mode 100644 packages/knowledge-engine/src/extractor.ts create mode 100644 packages/knowledge-engine/src/index.ts create mode 100644 packages/knowledge-engine/src/manager.ts create mode 100644 packages/knowledge-engine/src/middleware.ts create mode 100644 packages/knowledge-engine/src/retriever.ts create mode 100644 packages/knowledge-engine/src/review.ts create mode 100644 packages/knowledge-engine/src/staging.ts create mode 100644 packages/knowledge-engine/src/types.ts create mode 100644 packages/knowledge-engine/src/vector-db.ts create mode 100644 packages/knowledge-engine/tests/abstention.test.ts create mode 100644 packages/knowledge-engine/tests/admission.test.ts create mode 100644 packages/knowledge-engine/tests/candidate.test.ts create mode 100644 packages/knowledge-engine/tests/concurrency.test.ts create mode 100644 packages/knowledge-engine/tests/engine.test.ts create mode 100644 packages/knowledge-engine/tests/extraction.test.ts create mode 100644 packages/knowledge-engine/tests/hygiene.test.ts create mode 100644 packages/knowledge-engine/tests/integration.test.ts create mode 100644 packages/knowledge-engine/tests/review.test.ts create mode 100644 packages/knowledge-engine/tests/staging.test.ts create mode 100644 packages/knowledge-engine/tsconfig.json diff --git a/packages/knowledge-engine/README.md b/packages/knowledge-engine/README.md new file mode 100644 index 000000000000..d36c27489fe4 --- /dev/null +++ b/packages/knowledge-engine/README.md @@ -0,0 +1,59 @@ +# 🧠 OpenCode Local Knowledge Engine + +محرك معرفي محلي بالكامل (Local & Embedded RAG): `bun:sqlite` + مولد متجهات +محلي، بدون مفاتيح API أو تكاليف خارجية. + +## 🚀 المميزات + +- **محلي 100% (Zero-Cost & Embedded):** يعتمد على `bun:sqlite` ومولد متجهات + كثيفة (Dense Vectors) محلي مدمج. +- **بحث هجين (Hybrid Search):** يجمع بين البحث الدلالي بالمتجهات + (Vector Cosine Similarity) والبحث النصي (SQLite FTS5 BM25)، مع بوابة امتناع + تُرجع قائمة فارغة عند غياب معرفة مرتبطة بدل عرض نتائج غير مرتبطة. +- **دعم متعدد اللغات:** معالجة وتطبيع النصوص العربية والإنجليزية واستخراج + الأقسام والوسوم. +- **تكامل V2 First-Turn Retrieval:** خدمتا `KnowledgeRetrieval` و + `KnowledgeGuidance` تزودان أول دور مزود بالمعرفة المسترجعة عبر الطبقات + المعتمدة فقط. +- **حوكمة صارمة للذاكرة:** Candidate → Staging → مراجعة بشرية إلزامية → + قبول Approved-only → تحقق persistence واسترجاع مع تعويض بالحذف عند الفشل. + قاعدتا staging و knowledge منفصلتان. لا قبول تلقائي، لا إدخال تلقائي. + +## 💻 أوامر الاستخدام + +### 1. أوامر الإنتاج (عبر OpenCode CLI) + +```bash +# البحث في قاعدة المعرفة +bun src/index.ts search "سؤال البحث هنا" + +# دورة الحوكمة: اقتراح ← مراجعة ← قبول ← إدخال +bun src/index.ts learn propose --session --summary-file +bun src/index.ts learn candidates +bun src/index.ts learn show +bun src/index.ts learn approve --note "" +bun src/index.ts learn admit +bun src/index.ts learn retrieve --query "" +bun src/index.ts learn status +``` + +### 2. عمليات الحزمة نفسها + +```bash +cd /mnt/k/opencode/packages/knowledge-engine +bun run src/cli.ts stats +bun run src/cli.ts search --query "سؤال البحث هنا" +bun run src/cli.ts index /path/to/markdown/docs +``` + +### 3. اختبار الوحدة والتكامل + +```bash +cd /mnt/k/opencode/packages/knowledge-engine +bun test +``` + +## ⚠️ حدود الاستخدام + +- الاستخدام المنضبط (Controlled Use) فقط؛ المراجعة البشرية إلزامية قبل أي إدخال. +- لا استعلامات خارج نطاق المشروع: المحرك يمتنع (`results=0`) بدل التخمين. diff --git a/packages/knowledge-engine/package.json b/packages/knowledge-engine/package.json new file mode 100644 index 000000000000..a34a8e7d24a3 --- /dev/null +++ b/packages/knowledge-engine/package.json @@ -0,0 +1,25 @@ +{ + "name": "@opencode-ai/knowledge-engine", + "version": "1.0.0", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "test": "bun test", + "typecheck": "tsgo --noEmit", + "index": "bun run src/cli.ts index", + "search": "bun run src/cli.ts search", + "stats": "bun run src/cli.ts stats" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@types/node": "catalog:" + }, + "exports": { + ".": "./src/index.ts", + "./retriever": "./src/retriever.ts", + "./integration": "./src/agent-integration.ts", + "./manager": "./src/manager.ts" + } +} diff --git a/packages/knowledge-engine/src/abstention.ts b/packages/knowledge-engine/src/abstention.ts new file mode 100644 index 000000000000..3bf316f52e5b --- /dev/null +++ b/packages/knowledge-engine/src/abstention.ts @@ -0,0 +1,86 @@ +import type { RetrievalResult } from './types'; + +/** + * B5 — Retrieval abstention gate (HARDENING-02, OBS-RETRIEVAL-ABSTENTION-01). + * + * Observed defect: an out-of-domain query (e.g. Arabic gardening) returned + * five unrelated programming chunks instead of abstaining, because hybrid + * scores (0.65 vector + 0.35 text) can reach 0.55–0.61 on generic dev docs + * with zero topical overlap. Absolute-score thresholds cannot separate such + * noise: Q6's top hit (0.6097) outscored Q1's genuine top hit (0.6013). + * + * Minimal rule: keep the merged list only when at least one result shares a + * substantive (non-stopword, length >= 3) token with the query, checked over + * title + section + content + tags. Otherwise return []. The gate lives in + * the engine (`LocalVectorDB.hybridSearch`), never in CLI display code, so + * direct engine users and the production CLI abstain identically. + * + * Deliberately whole-list (not per-result filtering): ranking, top-K bounds, + * metadata filters, and authoritative-source preference are untouched. + * Deliberately exact-match (no stemming): predictable, and the frozen + * evaluation set (Q1–Q6) passes without it. + */ + +// English base stopwords (mirrors extraction.ts) plus interrogatives, modals, +// and pronouns that carry no topical evidence. +const EN_STOPWORDS = new Set( + ('with from that this have will when then than into over under about after before between during through ' + + 'while where which what your their they them been were does doing because once such each other onto upon ' + + 'how whose whom should would could shall does did are is was are were been being has had having do did done ' + + 'can may might must ought the and for are but not you all any both few more most out off own same too very ' + + 'just don should now him her its our your isn aren wasn weren hasn haven doesn didn won wouldn couldn ' + + 'shouldn mustn').split(' '), +); + +// Arabic function words, interrogatives, and particles: frequent, never topical. +const AR_STOPWORDS = new Set( + ('في من على إلى عن مع هل ما ماذا كيف أين متى لماذا التي الذي الذين هذا هذه ذلك تلك هو هي هم نحن أن إن أو ثم قد ' + + 'لا لم لن كل بعض بين بعد قبل عند غير ذات دون حتى كما لكن بل أيضا جدا نحو ضد إما إذا لو لما كلما حيث حين بينما ' + + 'إنما لقد ذلك التي والذي وهل وما وكيف وأين ومتى ولماذا وهذه وهذا وذلك وتلك').split(' '), +); + +const MIN_TOKEN_CHARS = 3; + +/** Lowercase Unicode tokens of length >= 3 that are not stopwords. */ +export function substantiveTokens(text: string): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of text.toLowerCase().split(/[^\p{L}\p{N}]+/gu)) { + if (raw.length < MIN_TOKEN_CHARS) continue; + if (EN_STOPWORDS.has(raw) || AR_STOPWORDS.has(raw)) continue; + if (seen.has(raw)) continue; + seen.add(raw); + out.push(raw); + } + return out; +} + +function resultTokens(result: RetrievalResult): Set { + return new Set(substantiveTokens(`${result.title} ${result.section} ${result.content} ${result.tags.join(' ')}`)); +} + +/** + * True when at least one result shares a substantive token with the query. + * An empty substantive query can never have evidence → false (abstain). + */ +export function hasLexicalSupport(query: string, results: ReadonlyArray): boolean { + const queryTokens = new Set(substantiveTokens(query)); + if (queryTokens.size === 0) return false; + return results.some((result) => { + for (const token of resultTokens(result)) { + if (queryTokens.has(token)) return true; + } + return false; + }); +} + +/** + * Abstention gate: return results unchanged when lexically supported, + * otherwise return [] (honest empty, not display hiding). + */ +export function applyAbstentionGate( + query: string, + results: ReadonlyArray, +): RetrievalResult[] { + return hasLexicalSupport(query, results) ? [...results] : []; +} diff --git a/packages/knowledge-engine/src/admission.ts b/packages/knowledge-engine/src/admission.ts new file mode 100644 index 000000000000..36f9b9e9e794 --- /dev/null +++ b/packages/knowledge-engine/src/admission.ts @@ -0,0 +1,110 @@ +import { toChunk, validateCandidate } from './candidate'; +import { LocalEmbedder } from './embedder'; +import { LocalRetriever } from './retriever'; +import { CandidateStore } from './staging'; +import { LocalVectorDB } from './vector-db'; + +/** + * B4 — Approved admission pipeline for Phase 4B (Engineering Memory System). + * + * The ONLY path from staging to knowledge.db: + * + * load approved → revalidate → toChunk → embed → upsert → verify → receipt + * + * Entry requires `status === "approved"` (enforced by B0's `toChunk`; + * pending / rejected / superseded are refused). Admission never writes to + * staging — staging is read-only here; the persisted chunk plus the receipt + * are the durable evidence. Every admission run also sweeps staged + * superseded records out of active retrieval (audit records stay in + * staging). Handles are explicit parameters: the module singletons are + * never used, so tests run against isolated databases. + */ + +export interface AdmissionDeps { + readonly store: CandidateStore; + readonly knowledge: LocalVectorDB; + readonly embedder?: LocalEmbedder; + readonly retriever?: LocalRetriever; +} + +export interface AdmissionReceipt { + readonly candidateId: string; + readonly chunkId: string; + readonly status: 'admitted'; + /** Rank of the chunk in the post-admission retrieval check (0-based). */ + readonly verifiedRank: number; + readonly admittedAt: number; +} + +export interface AdmissionResult { + readonly receipt: AdmissionReceipt; + /** Staged superseded ids whose chunks were retired from active retrieval. */ + readonly swept: string[]; +} + +function resolved(deps: AdmissionDeps): { embedder: LocalEmbedder; retriever: LocalRetriever } { + const embedder = deps.embedder ?? new LocalEmbedder(); + const retriever = deps.retriever ?? new LocalRetriever(deps.knowledge, embedder); + return { embedder, retriever }; +} + +/** + * Admit one approved candidate: embed and upsert its chunk, then prove it + * is persisted (vector self-match) and retrievable (hybrid search finds it). + * Throws — with no receipt and no writes — on every failure: missing + * record, non-approved status, invalid content, embedder or index errors. + */ +export async function admitCandidate(deps: AdmissionDeps, id: string): Promise { + const stored = deps.store.get(id); + if (!stored) throw new Error(`Candidate not found: ${id}`); + const problems = validateCandidate(stored); + if (problems.length > 0) throw new Error(`Cannot admit invalid candidate ${id}: ${problems.join('; ')}`); + // Gate: refuses pending / rejected / superseded by construction. + const chunk = toChunk(stored); + const { embedder, retriever } = resolved(deps); + + const vector = embedder.embed(chunk.content); + deps.knowledge.upsertChunk(chunk, vector); + try { + const selfMatch = deps.knowledge.vectorSearch(vector, 5, 0); + const persisted = selfMatch.some((row) => row.id === chunk.id && row.similarity >= 0.99); + if (!persisted) throw new Error(`Admission verification failed: chunk ${chunk.id} not persisted`); + + const found = await retriever.retrieveRelevant(chunk.content.slice(0, 500), 5, { minSimilarity: 0.1 }); + const rank = found.findIndex((row) => row.id === chunk.id); + if (rank < 0) throw new Error(`Admission verification failed: chunk ${chunk.id} not retrievable`); + + return { candidateId: id, chunkId: chunk.id, status: 'admitted', verifiedRank: rank, admittedAt: Date.now() }; + } catch (error) { + // V1.0.1 compensation: a chunk written by this run must never survive an + // unverified admission. Best-effort removal, then the original error — + // compensation must not mask the admission failure. + try { + deps.knowledge.deleteChunk(chunk.id); + } catch { + // Intentionally silent: the admission error below is the signal. + } + throw error; + } +} + +/** + * Retire superseded knowledge from ACTIVE retrieval. Staging audit records + * are untouched — only chunks disappear. Runs on every admission so a + * supersede decision takes effect even if no new candidate is admitted + * afterwards, and so crash recovery converges on the next run. + */ +export function sweepSuperseded(deps: AdmissionDeps): string[] { + const swept: string[] = []; + for (const candidate of deps.store.list({ status: 'superseded' })) { + if (deps.knowledge.deleteChunk(candidate.id)) swept.push(candidate.id); + } + return swept; +} + +/** Full admission run: admit one candidate, then sweep retired knowledge. */ +export async function admit(deps: AdmissionDeps, id: string): Promise { + const receipt = await admitCandidate(deps, id); + const swept = sweepSuperseded(deps); + return { receipt, swept }; +} diff --git a/packages/knowledge-engine/src/agent-integration.ts b/packages/knowledge-engine/src/agent-integration.ts new file mode 100644 index 000000000000..9f8a292a7755 --- /dev/null +++ b/packages/knowledge-engine/src/agent-integration.ts @@ -0,0 +1,136 @@ +import defaultRetriever, { LocalRetriever } from './retriever'; +import type { RetrievalResult } from './types'; + +export class AgentKnowledgeIntegration { + /** + * Enrich Build Agent context with practical implementation knowledge + */ + public static async enrichBuildContext( + task: string, + currentContext: string = '', + customRetriever?: LocalRetriever + ): Promise { + const activeRetriever = customRetriever || defaultRetriever; + const relevantKnowledge = await activeRetriever.advancedSearch(task, { + agentType: 'build', + topK: 4, + }); + + if (relevantKnowledge.length === 0) { + return currentContext; + } + + const knowledgeText = relevantKnowledge + .map(k => `### [${k.title}] - ${k.section}\n${k.content}`) + .join('\n\n---\n\n'); + + return ` +${currentContext ? `${currentContext}\n\n` : ''}================================================================================ +📚 KNOWLEDGE ENGINE CONTEXT (Local Embedded RAG) +================================================================================ +${knowledgeText} +================================================================================ +💡 GUIDELINES: +- Apply the implementation patterns and guidelines from the retrieved knowledge above. +- Ensure idiomatic, secure, and tested code matching the project specifications. +================================================================================ +`.trim(); + } + + /** + * Enrich Plan Agent context with architectural strategy and best practices + */ + public static async enrichPlanContext( + task: string, + currentContext: string = '', + customRetriever?: LocalRetriever + ): Promise { + const activeRetriever = customRetriever || defaultRetriever; + const [strategyResults, bestPractices] = await Promise.all([ + activeRetriever.advancedSearch(task, { agentType: 'plan', topK: 3 }), + activeRetriever.getBestPractice(task), + ]); + + const items = [...strategyResults, ...bestPractices]; + if (items.length === 0) { + return currentContext; + } + + const strategyText = strategyResults + .map(s => `• 📌 **${s.title} (${s.section})**: ${s.content.substring(0, 250)}...`) + .join('\n'); + + const practicesText = bestPractices + .map(p => `• ✨ **${p.title}**: ${p.content.substring(0, 200)}...`) + .join('\n'); + + return ` +${currentContext ? `${currentContext}\n\n` : ''}================================================================================ +🧠 STRATEGIC KNOWLEDGE ENGINE GUIDANCE +================================================================================ +🎯 Recommended Strategy References: +${strategyText || 'None found.'} + +✨ Best Practices: +${practicesText || 'Standard practices apply.'} +================================================================================ +`.trim(); + } + + /** + * Provide immediate troubleshooting solutions for errors + */ + public static async getErrorGuidance( + errorMessage: string, + customRetriever?: LocalRetriever + ): Promise { + const activeRetriever = customRetriever || defaultRetriever; + const solutions = await activeRetriever.findTroubleshootingSolution(errorMessage); + + if (solutions.length === 0) { + return '⚠️ لم يتم العثور على حل مطابق ومباشر في قاعدة المعرفة المحلية لهذا الخطأ.'; + } + + const guidance = solutions + .map((s, idx) => `### ${idx + 1}. ${s.title} (${s.section})\n${s.content}`) + .join('\n\n'); + + return ` +================================================================================ +🛠️ KNOWLEDGE ENGINE TROUBLESHOOTING GUIDANCE +================================================================================ +${guidance} +================================================================================ +`.trim(); + } + + /** + * Comprehensive guide generation on any topic + */ + public static async generateComprehensiveGuide( + topic: string, + customRetriever?: LocalRetriever + ): Promise { + const activeRetriever = customRetriever || defaultRetriever; + const detailed = await activeRetriever.getDetailedContent(topic); + + const overview = detailed.overview.map(o => `### ${o.section}\n${o.content}`).join('\n\n'); + const practices = detailed.practices.map(p => `### ${p.section}\n${p.content}`).join('\n\n'); + const fixes = detailed.troubleshooting.map(t => `### ${t.section}\n${t.content}`).join('\n\n'); + + return ` +# 📖 دليل شامل: ${topic} + +## 📚 نظرة عامة وشرح +${overview || 'لا يوجد محتوى مسجل.'} + +## 💡 أفضل الممارسات والتنفيذ +${practices || 'لا توجد ممارسات مخصصة.'} + +## ⚠️ استكشاف وحل الأخطاء +${fixes || 'لا توجد أخطاء مسجلة.'} +`.trim(); + } +} + +export default AgentKnowledgeIntegration; diff --git a/packages/knowledge-engine/src/agents.ts b/packages/knowledge-engine/src/agents.ts new file mode 100644 index 000000000000..d14690af712a --- /dev/null +++ b/packages/knowledge-engine/src/agents.ts @@ -0,0 +1,126 @@ +import retriever, { LocalRetriever } from './retriever'; +import middleware, { KnowledgeEnrichmentMiddleware } from './middleware'; +import type { RetrievalResult } from './types'; + +export interface PlanOutput { + plan: string; + objectives: string[]; + bestPractices: RetrievalResult[]; + resources: RetrievalResult[]; +} + +export class BuildAgentKnowledge { + private middleware: KnowledgeEnrichmentMiddleware; + private retriever: LocalRetriever; + + constructor(customRetriever?: LocalRetriever) { + this.retriever = customRetriever || retriever; + this.middleware = new KnowledgeEnrichmentMiddleware(this.retriever); + } + + /** + * Prepares execution payload enriched with implementation and coding knowledge + */ + public async prepareTask(task: string, context?: string): Promise<{ + enrichedPrompt: string; + docs: RetrievalResult[]; + }> { + const result = await this.middleware.before({ content: task, context }, 'build'); + return { + enrichedPrompt: result.enrichedPrompt, + docs: result.retrievedDocs, + }; + } + + /** + * Automatic troubleshooting for execution errors + */ + public async handleExecutionError(error: Error | string): Promise { + const errMsg = typeof error === 'string' ? error : error.message; + const solutions = await this.retriever.findTroubleshootingSolution(errMsg); + + if (solutions.length === 0) { + return `❌ خطأ في التنفيذ: ${errMsg}\n(لم يُعثر على حل مطابق في المعرفة المحلية).`; + } + + const sol = solutions[0]; + return ` +❌ خطأ في التنفيذ: +${errMsg} + +✅ حل موصى به من قاعدة المعرفة: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📌 ${sol.title} - ${sol.section} +${sol.content} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + `.trim(); + } +} + +export class PlanAgentKnowledge { + private retriever: LocalRetriever; + + constructor(customRetriever?: LocalRetriever) { + this.retriever = customRetriever || retriever; + } + + /** + * Synthesizes a structured plan combining task objectives with retrieved best practices + */ + public async generatePlan(task: string): Promise { + const [bestPractices, strategies, relatedDocs] = await Promise.all([ + this.retriever.advancedSearch(task, { agentType: 'plan', topK: 3 }), + this.retriever.getBestPractice(task), + this.retriever.retrieveRelevant(task, 3), + ]); + + const combinedDocs = [...bestPractices, ...strategies, ...relatedDocs]; + // Deduplicate by ID + const uniqueDocs = Array.from(new Map(combinedDocs.map(d => [d.id, d])).values()); + + const objectives = [ + `تحليل متطلبات المهمة بدقة: ${task}`, + 'مراجعة المعايير والأنماط المعمارية في توثيق OpenCode', + 'تحديد خطوات التنفيذ المتتابعة واختبارات السلامة', + 'توقع الأخطاء المحتملة وإعداد حلول استكشاف الأخطاء', + ]; + + const practicesText = uniqueDocs.slice(0, 4).map(d => + ` ✓ [${d.title} > ${d.section}]: ${d.content.replace(/\s+/g, ' ').substring(0, 150)}...` + ).join('\n'); + + const plan = ` +================================================================================ +📋 خطة العمل الذكية لـ: "${task}" +(مدعومة بمحرك المعرفة المحلي لـ OpenCode) +================================================================================ + +🎯 الأهداف المحورية: +${objectives.map(o => ` • ${o}`).join('\n')} + +📚 أفضل الممارسات الموثقة المسترجعة: +${practicesText || ' (المعايير القياسية للمشروع)'} + +🛣️ مسار التنفيذ المقترح: + 1. التهيئة والتحقق من البيئة وإعدادات الموفرين. + 2. كتابة واختبار الوحدات البرمجية وفق المعايير. + 3. الفحص والتكامل مع واجهات OpenCode وأدواتها. + 4. المراجعة النهائية واختبار الجودة. + +💡 المراجع المعرفية المرتبطة: ${uniqueDocs.length} مستند تم الرجوع إليه. +================================================================================ + `.trim(); + + return { + plan, + objectives, + bestPractices: uniqueDocs.filter(d => d.type === 'lesson'), + resources: uniqueDocs, + }; + } +} + +export default { + BuildAgentKnowledge, + PlanAgentKnowledge, +}; diff --git a/packages/knowledge-engine/src/candidate.ts b/packages/knowledge-engine/src/candidate.ts new file mode 100644 index 000000000000..a5f7275b800e --- /dev/null +++ b/packages/knowledge-engine/src/candidate.ts @@ -0,0 +1,166 @@ +import { hashId } from './extractor'; +import type { KnowledgeChunk } from './types'; + +/** + * B0 — Candidate data model for Phase 4B (Engineering Memory System). + * + * A Candidate is proposed knowledge distilled from a session (later: from its + * compaction summary). It lives in a STAGING store, never in knowledge.db, + * until a review decision approves it. + * + * Data only: no database, no I/O, no writes anywhere. The forbidden path + * (Session → knowledge.db directly) is made unrepresentable: `toChunk` + * refuses every candidate whose status is not `approved`. + */ + +export type CandidateStatus = 'pending' | 'approved' | 'rejected' | 'superseded'; + +export type CandidateKind = 'lesson' | 'prompt' | 'practice' | 'troubleshooting'; + +export interface CandidateProvenance { + /** Session the knowledge was distilled from. */ + sourceSession: string; + /** Pipeline that produced the candidate (e.g. 'compaction-summary/v1'). */ + extractor: string; + /** Reference to the source summary (compaction/event id) when known. */ + summaryRef?: string; +} + +export interface Candidate { + /** Deterministic: same (session, title, content) → same id, so re-extraction replaces instead of duplicating. */ + id: string; + title: string; + /** Source summary excerpt — review context, not indexed text. */ + summary: string; + /** The extracted, generalizable knowledge text — what would be indexed after approval. */ + content: string; + type: CandidateKind; + tags: string[]; + language: string; + difficulty: number; + provenance: CandidateProvenance; + createdAt: number; + updatedAt: number; + status: CandidateStatus; + /** Reviewer note recorded at approve/reject time. */ + reviewNote?: string; + /** Approving/superseding candidate id that replaced this one. Set only when superseded. */ + supersededBy?: string; +} + +export interface CandidateInput { + title: string; + summary: string; + content: string; + sourceSession: string; + type?: CandidateKind; + tags?: string[]; + language?: string; + difficulty?: number; + extractor?: string; + summaryRef?: string; +} + +export function candidateId(sourceSession: string, title: string, content: string): string { + return `cand-${hashId(`${sourceSession}\n${title}\n${content}`)}`; +} + +/** Total constructor: fills defaults, always starts as `pending`. Problems are reported by `validateCandidate`, not thrown here. */ +export function createCandidate(input: CandidateInput, now: number = Date.now()): Candidate { + return { + id: candidateId(input.sourceSession, input.title, input.content), + title: input.title, + summary: input.summary, + content: input.content, + type: input.type ?? 'lesson', + tags: input.tags ?? [], + language: input.language ?? 'mixed', + difficulty: input.difficulty ?? 3, + provenance: { + sourceSession: input.sourceSession, + extractor: input.extractor ?? 'manual/v1', + ...(input.summaryRef === undefined ? {} : { summaryRef: input.summaryRef }), + }, + createdAt: now, + updatedAt: now, + status: 'pending', + }; +} + +const KNOWN_STATUSES: ReadonlyArray = ['pending', 'approved', 'rejected', 'superseded']; + +/** Pure validation. Returns a list of problems; empty means the candidate is well-formed. */ +export function validateCandidate(candidate: Candidate): string[] { + const problems: string[] = []; + if (!KNOWN_STATUSES.includes(candidate.status)) problems.push(`unknown status: ${candidate.status}`); + if (candidate.title.trim().length === 0) problems.push('title must not be empty'); + if (candidate.content.trim().length === 0) problems.push('content must not be empty'); + if (candidate.summary.trim().length === 0) problems.push('summary must not be empty'); + if (candidate.provenance.sourceSession.trim().length === 0) problems.push('provenance.sourceSession must not be empty'); + if (candidate.updatedAt < candidate.createdAt) problems.push('updatedAt must not precede createdAt'); + if (candidate.status === 'superseded' && (candidate.supersededBy ?? '').trim().length === 0) { + problems.push('superseded candidates must record supersededBy'); + } + if (candidate.status !== 'superseded' && candidate.supersededBy !== undefined) { + problems.push('only superseded candidates may record supersededBy'); + } + return problems; +} + +export function isReviewable(candidate: Candidate): boolean { + return candidate.status === 'pending'; +} + +function mustBeValid(candidate: Candidate, action: string): void { + const problems = validateCandidate(candidate); + if (problems.length > 0) throw new Error(`Cannot ${action} invalid candidate ${candidate.id}: ${problems.join('; ')}`); +} + +/** pending → approved. Records the reviewer note. Invalid or non-pending candidates throw. */ +export function approveCandidate(candidate: Candidate, note = '', now: number = Date.now()): Candidate { + if (candidate.status !== 'pending') throw new Error(`Cannot approve candidate ${candidate.id} with status ${candidate.status}`); + mustBeValid(candidate, 'approve'); + return { ...candidate, status: 'approved', updatedAt: now, ...(note.length === 0 ? {} : { reviewNote: note }) }; +} + +/** pending → rejected. Rejection always records a reason. */ +export function rejectCandidate(candidate: Candidate, note: string, now: number = Date.now()): Candidate { + if (candidate.status !== 'pending') throw new Error(`Cannot reject candidate ${candidate.id} with status ${candidate.status}`); + if (note.trim().length === 0) throw new Error(`Rejecting candidate ${candidate.id} requires a reason`); + return { ...candidate, status: 'rejected', updatedAt: now, reviewNote: note }; +} + +/** approved → superseded. Only approved knowledge can be superseded; pending proposals are rejected instead. */ +export function supersedeCandidate(candidate: Candidate, byId: string, now: number = Date.now()): Candidate { + if (candidate.status !== 'approved') { + throw new Error(`Cannot supersede candidate ${candidate.id} with status ${candidate.status}`); + } + if (byId.trim().length === 0) throw new Error(`Superseding candidate ${candidate.id} requires the replacing id`); + return { ...candidate, status: 'superseded', updatedAt: now, supersededBy: byId }; +} + +/** + * The B4 gate in miniature: converts an APPROVED candidate into an indexable + * chunk. Every other status throws — there is no code path from a session + * to knowledge.db that bypasses approval. + */ +export function toChunk(candidate: Candidate): KnowledgeChunk { + if (candidate.status !== 'approved') { + throw new Error(`Candidate ${candidate.id} must be approved before indexing (status: ${candidate.status})`); + } + mustBeValid(candidate, 'index'); + return { + id: candidate.id, + title: candidate.title, + stage: 1, + section: 'candidate', + content: candidate.content, + metadata: { + source: `candidate:${candidate.id}`, + type: candidate.type, + tags: candidate.tags, + language: candidate.language, + difficulty: candidate.difficulty, + }, + }; +} diff --git a/packages/knowledge-engine/src/cli.ts b/packages/knowledge-engine/src/cli.ts new file mode 100644 index 000000000000..aa3c42035dc2 --- /dev/null +++ b/packages/knowledge-engine/src/cli.ts @@ -0,0 +1,72 @@ +#!/usr/bin/env bun +import manager from './manager'; +import retriever from './retriever'; + +const args = process.argv.slice(2); +const command = args[0] || 'help'; + +async function main() { + switch (command) { + case 'index': { + const targetPath = args[1]; + const res = manager.buildIndex(targetPath); + console.log('\n📊 إحصائيات الفهرس الحالية:'); + console.log(JSON.stringify(res.stats, null, 2)); + break; + } + + case 'search': { + const query = args.slice(1).join(' '); + if (!query) { + console.error('❌ يرجى تحديد نص البحث: opencode-knowledge search '); + process.exit(1); + } + console.log(`\n🔍 البحث عن: "${query}"...\n`); + const results = await retriever.retrieveRelevant(query, 5); + if (results.length === 0) { + console.log('لم يتم العثور على نتائج.'); + } else { + for (const r of results) { + console.log(`\n[#${r.rank}] 📌 ${r.title} | ${r.section}`); + console.log(`⭐ درجة التطابق: ${(r.similarity * 100).toFixed(1)}% | النوع: ${r.type} | المرحلة: ${r.stage}`); + console.log(`🏷️ الوسوم: ${r.tags.join(', ')}`); + console.log(`📄 المصدر: ${r.source}`); + console.log(`📝 المحتوى:\n${r.content}`); + console.log('─'.repeat(70)); + } + } + break; + } + + case 'test': { + await manager.testRetrieval(); + break; + } + + case 'stats': { + const stats = manager.getFullStats(); + console.log('\n📊 إحصائيات محرك المعرفة المحلي:'); + console.log(JSON.stringify(stats, null, 2)); + break; + } + + case 'help': + default: { + console.log(` +🧠 OpenCode Local Knowledge Engine CLI + +الاستخدام: + bun run src/cli.ts index [path] فهرسة مسار مستندات markdown + bun run src/cli.ts search بحث هجين (Vector + FTS5) في المعرفة + bun run src/cli.ts test تشغيل اختبارات الاسترجاع + bun run src/cli.ts stats عرض إحصائيات قاعدة البيانات المحلية + `); + break; + } + } +} + +main().catch(err => { + console.error('خطأ غير متوقع:', err); + process.exit(1); +}); diff --git a/packages/knowledge-engine/src/embedder.ts b/packages/knowledge-engine/src/embedder.ts new file mode 100644 index 000000000000..778df01a6d1c --- /dev/null +++ b/packages/knowledge-engine/src/embedder.ts @@ -0,0 +1,90 @@ +export class LocalEmbedder { + private readonly dimensions: number; + + constructor(dimensions: number = 384) { + this.dimensions = dimensions; + } + + /** + * Fast hash function (Murmur3-inspired 32-bit integer hash) + */ + private hashString(str: string, seed: number = 0): number { + let h = seed ^ str.length; + for (let i = 0; i < str.length; i++) { + h = Math.imul(h ^ str.charCodeAt(i), 0x5bd1e995); + h ^= h >>> 15; + } + return Math.abs(h); + } + + /** + * Arabic & multilingual text normalization + */ + public normalizeText(text: string): string { + return text + .toLowerCase() + // Normalize Arabic characters + .replace(/[إأآا]/g, 'ا') + .replace(/ة/g, 'ه') + .replace(/ى/g, 'ي') + .replace(/[\u064B-\u065F\u0670]/g, '') // Remove tashkeel/diacritics + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); + } + + /** + * Converts any text into a dense normalized Float32Array vector of fixed dimensions + */ + public embed(text: string): Float32Array { + const vector = new Float32Array(this.dimensions); + const normalized = this.normalizeText(text); + if (!normalized) return vector; + + const words = normalized.split(' ').filter(w => w.length > 1); + + // 1. Word token hashing with frequency + for (const word of words) { + const idx = this.hashString(word, 42) % this.dimensions; + const sign = (this.hashString(word, 137) % 2 === 0) ? 1 : -1; + vector[idx] += sign * 1.5; + + // 2. Character n-grams (3-grams and 4-grams) for robust subword semantics + for (let i = 0; i <= word.length - 3; i++) { + const trigram = word.substring(i, i + 3); + const triIdx = this.hashString(trigram, 99) % this.dimensions; + const triSign = (this.hashString(trigram, 211) % 2 === 0) ? 1 : -1; + vector[triIdx] += triSign * 0.5; + } + } + + // 3. L2 Unit Normalization (so vector A dot vector B = cosine similarity) + let sumSquares = 0; + for (let i = 0; i < this.dimensions; i++) { + sumSquares += vector[i] * vector[i]; + } + + const norm = Math.sqrt(sumSquares); + if (norm > 0) { + for (let i = 0; i < this.dimensions; i++) { + vector[i] /= norm; + } + } + + return vector; + } + + /** + * Compute cosine similarity between two normalized vectors + */ + public static cosineSimilarity(vecA: Float32Array, vecB: Float32Array): number { + if (vecA.length !== vecB.length) return 0; + let dot = 0; + for (let i = 0; i < vecA.length; i++) { + dot += vecA[i] * vecB[i]; + } + return Math.max(0, Math.min(1, (dot + 1) / 2)); // Normalize to [0, 1] range + } +} + +export default new LocalEmbedder(); diff --git a/packages/knowledge-engine/src/extraction.ts b/packages/knowledge-engine/src/extraction.ts new file mode 100644 index 000000000000..dc4808e63c78 --- /dev/null +++ b/packages/knowledge-engine/src/extraction.ts @@ -0,0 +1,174 @@ +import type { CandidateInput, CandidateKind } from './candidate'; + +/** + * B2 — Candidate extraction for Phase 4B (Engineering Memory System). + * + * Pure, deterministic, local: takes a compaction summary text plus the + * source session id and returns candidate inputs ready for + * `CandidateStore.create`. No database, no I/O, no approval, no indexing — + * B2 ends at staging (pending). Every gate below encodes the B2 review + * policy: generalizable, not session-specific, no secrets. + */ + +export interface ExtractionOptions { + /** Units shorter than this (chars) are dropped as too thin. Default 30. */ + readonly minContentChars?: number; + /** Flood protection for staging. Default 10. */ + readonly maxCandidates?: number; +} + +const MIN_CONTENT_CHARS = 30; +const MAX_CANDIDATES = 10; +const MAX_CONTENT_CHARS = 2000; +const MAX_TITLE_CHARS = 80; +const MAX_TAGS = 4; + +interface Unit { + readonly breadcrumb: string; + readonly text: string; +} + +const HEADING = /^\s*#{1,6}\s+(.*\S)\s*$/; +const BULLET = /^\s*(?:[-*•]|\d+[.)])\s+(.*\S)\s*$/; + +function splitUnits(summary: string): Unit[] { + const units: Unit[] = []; + let breadcrumb = ''; + let prose: string[] = []; + const flushProse = () => { + const text = prose.join(' ').replace(/\s+/g, ' ').trim(); + prose = []; + if (text.length > 0) units.push({ breadcrumb, text }); + }; + for (const line of summary.split('\n')) { + const heading = line.match(HEADING); + if (heading) { + flushProse(); + breadcrumb = heading[1].trim(); + continue; + } + const bullet = line.match(BULLET); + if (bullet) { + flushProse(); + units.push({ breadcrumb, text: bullet[1].replace(/\s+/g, ' ').trim() }); + continue; + } + if (line.trim().length === 0) { + flushProse(); + continue; + } + prose.push(line.trim()); + } + flushProse(); + return units.filter((unit) => unit.text.length > 0); +} + +// Gate 3 — secrets. High precision: credentials, tokens, emails, private +// keys, user-home paths. Project-relative paths are kept (they are normal +// in engineering lessons); only home directories are dropped. +const SECRET_PATTERNS: ReadonlyArray = [ + /-----BEGIN [A-Z ]*PRIVATE KEY-----/, + /(password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret)\s*[:=]\s*\S+/i, + /\bsk-[A-Za-z0-9]{8,}\b/, + /\bgh[ops]_[A-Za-z0-9]{8,}\b/, + /\bgithub_pat_[A-Za-z0-9_]{8,}\b/, + /\bAKIA[0-9A-Z]{16}\b/, + /\bxox[baprs]-[A-Za-z0-9-]+\b/, + /\bBearer\s+\S+/i, + /[\w.+-]+@[\w-]+\.[\w.]{2,}/, + /~\/|\/home\/|\/Users\//, +]; + +function hasSecret(text: string): boolean { + return SECRET_PATTERNS.some((pattern) => pattern.test(text)); +} + +// Gates 1+2 — session-specificity. Drops episodic units: references to a +// concrete session, first-person past-tense actions, user requests quoted as +// events, temporal deictics. Timeless statements about components pass. +const EPISODIC_PATTERNS: ReadonlyArray = [ + /\bses_[0-9a-f]{8,}\b/i, + /\bsession\s+(abc\d*|#?\d+|ids?\b)/i, + /\b(this|that|current)\s+session\b/i, + /\bin\s+this\s+session\b/i, + /\buser\s+(asked|said|wants?|requested|reported)\b/i, + /\b(i|we)\s+(tried|fixed|restarted|re-ran|retried|clicked|decided|agreed|noticed)\b/i, + /\bjust now\b/i, + /\b(today|yesterday|this morning)\b.*\b(fixed|tried|restarted|retried|failed)\b/i, +]; + +function isEpisodic(text: string): boolean { + return EPISODIC_PATTERNS.some((pattern) => pattern.test(text)); +} + +function classify(text: string): CandidateKind { + if (/^(prompt template|reusable prompt|template:)/i.test(text)) return 'prompt'; + // Author intent first: a prescriptive norm stays a practice even when it + // mentions failures in passing ("Always prefer explicit error returns"). + if (/^(always|never|prefer|avoid|ensure|make sure)\b/i.test(text)) return 'practice'; + if (/(error|fail|bug|fix|workaround|crash|broken|stack trace)/i.test(text)) return 'troubleshooting'; + if (/(instead of|make sure|ensure)/i.test(text)) return 'practice'; + return 'lesson'; +} + +const STOPWORDS = new Set( + 'with from that this have will when then than into over under about after before between during through while where which what your their they them been were does doing because once such each other into onto upon'.split( + ' ', + ), +); + +function extractTags(title: string, content: string): string[] { + const seen = new Set(); + const tags: string[] = []; + for (const token of `${title} ${content}`.toLowerCase().match(/[a-z0-9][a-z0-9_-]{3,}/g) ?? []) { + if (STOPWORDS.has(token) || seen.has(token)) continue; + seen.add(token); + tags.push(token); + if (tags.length >= MAX_TAGS) break; + } + return tags; +} + +function firstSentence(text: string): string { + const sentence = text.split(/[.!?。\n]/, 1)[0].trim(); + const base = sentence.length > 0 ? sentence : text; + return base.length > MAX_TITLE_CHARS ? `${base.slice(0, MAX_TITLE_CHARS - 1).trimEnd()}…` : base; +} + +function normalize(text: string): string { + return text.toLowerCase().replace(/\s+/g, ' ').trim(); +} + +export function extractCandidates( + summary: string, + sourceSession: string, + options: ExtractionOptions = {}, +): CandidateInput[] { + const minChars = options.minContentChars ?? MIN_CONTENT_CHARS; + const maxCount = options.maxCandidates ?? MAX_CANDIDATES; + if (summary.trim().length === 0 || sourceSession.trim().length === 0) return []; + const seen = new Set(); + const candidates: CandidateInput[] = []; + for (const unit of splitUnits(summary)) { + if (candidates.length >= maxCount) break; + const content = + unit.text.length > MAX_CONTENT_CHARS ? `${unit.text.slice(0, MAX_CONTENT_CHARS).trimEnd()}…` : unit.text; + if (content.length < minChars) continue; + if (hasSecret(content)) continue; + if (isEpisodic(content)) continue; + const key = normalize(content); + if (seen.has(key)) continue; + seen.add(key); + const title = firstSentence(content); + candidates.push({ + title, + summary: unit.breadcrumb.length > 0 ? `${unit.breadcrumb}\n${content.slice(0, 300)}` : content.slice(0, 300), + content, + sourceSession, + type: classify(content), + tags: extractTags(title, content), + extractor: 'compaction-summary/v1', + }); + } + return candidates; +} diff --git a/packages/knowledge-engine/src/extractor.ts b/packages/knowledge-engine/src/extractor.ts new file mode 100644 index 000000000000..853de472f331 --- /dev/null +++ b/packages/knowledge-engine/src/extractor.ts @@ -0,0 +1,217 @@ +import { readdirSync, readFileSync, statSync, existsSync, realpathSync } from 'fs'; +import { join, basename } from 'path'; +import type { KnowledgeChunk, KnowledgeMetadata } from './types'; + +/** + * FNV-1a 32-bit hash → 8 hex chars. Dependency-free deterministic IDs. + */ +export function hashId(input: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(16).padStart(8, '0'); +} + +/** + * Deterministic chunk ID: same (source, section) → same ID across runs, + * so re-indexing REPLACEs instead of duplicating. `occurrence` + * disambiguates repeated headings inside one file. + */ +export function chunkId(source: string, section: string, occurrence: number): string { + return `chunk-${hashId(`${source}\n${section}\n#${occurrence}`)}`; +} + +/** + * Canonical base path: resolve symlinks (e.g. learn-opencode/docs → + * packages/docs) and strip trailing slashes, so the same tree always + * yields the same stored sources — a precondition for orphan cleanup. + */ +export function normalizeBase(targetPath: string): string { + const real = existsSync(targetPath) ? realpathSync(targetPath) : targetPath; + return real.length > 1 ? real.replace(/\/+$/, '') : real; +} + +export class KnowledgeExtractor { + private defaultBasePath: string; + + constructor(defaultBasePath: string = '/mnt/k/opencode/packages/docs') { + this.defaultBasePath = defaultBasePath; + } + + /** + * Recursively collect all markdown/mdx files in a directory + */ + public findMarkdownFiles(dir: string): string[] { + const files: string[] = []; + if (!existsSync(dir)) return files; + + const entries = readdirSync(dir); + for (const entry of entries) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + if (stat.isDirectory()) { + files.push(...this.findMarkdownFiles(fullPath)); + } else if (entry.endsWith('.md') || entry.endsWith('.mdx')) { + files.push(fullPath); + } + } + return files; + } + + /** + * Parse simple frontmatter without requiring external heavy dependencies + */ + public parseFrontmatter(content: string): { data: Record; body: string } { + const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/; + const match = content.match(fmRegex); + if (!match) { + return { data: {}, body: content }; + } + + const rawYaml = match[1]; + const body = match[2]; + const data: Record = {}; + + for (const line of rawYaml.split('\n')) { + const parts = line.split(':'); + if (parts.length >= 2) { + const key = parts[0].trim(); + const value = parts.slice(1).join(':').trim().replace(/^['"](.*)['"]$/, '$1'); + data[key] = value; + } + } + + return { data, body }; + } + + /** + * Split document into logical sections by markdown headings + */ + public splitIntoSections(content: string, fallbackTitle: string): Array<{ heading: string; content: string }> { + const sections: Array<{ heading: string; content: string }> = []; + const lines = content.split('\n'); + + let currentHeading = fallbackTitle; + let currentLines: string[] = []; + + for (const line of lines) { + const match = line.match(/^#{1,3}\s+(.+)$/); + if (match) { + if (currentLines.length > 0 && currentLines.join('\n').trim()) { + sections.push({ + heading: currentHeading, + content: currentLines.join('\n').trim(), + }); + } + currentHeading = match[1].trim(); + currentLines = []; + } else { + currentLines.push(line); + } + } + + if (currentLines.length > 0 && currentLines.join('\n').trim()) { + sections.push({ + heading: currentHeading, + content: currentLines.join('\n').trim(), + }); + } + + return sections.length > 0 ? sections : [{ heading: fallbackTitle, content: content.trim() }]; + } + + /** + * Detect content type + */ + public detectType(filename: string, content: string): KnowledgeMetadata['type'] { + const lowerName = filename.toLowerCase(); + const lowerContent = content.toLowerCase(); + + if (lowerName.includes('troubleshoot') || lowerContent.includes('استكشاف الأخطاء') || lowerContent.includes('error') || lowerContent.includes('حل مشكلة')) { + return 'troubleshooting'; + } + if (lowerContent.includes('تمرين') || lowerContent.includes('exercise') || lowerContent.includes('تدريب') || lowerContent.includes('practice')) { + return 'practice'; + } + if (lowerName.includes('prompt') || lowerContent.includes('قالب') || lowerContent.includes('prompt template')) { + return 'prompt'; + } + return 'lesson'; + } + + /** + * Extract meaningful semantic tags + */ + public extractTags(content: string): string[] { + const tags = new Set(); + const lower = content.toLowerCase(); + + if (lower.includes('plan') || lower.includes('خطة') || lower.includes('تخطيط')) tags.add('plan'); + if (lower.includes('build') || lower.includes('بناء') || lower.includes('تنفيذ')) tags.add('build'); + if (lower.includes('agent') || lower.includes('وكيل')) tags.add('agent'); + if (lower.includes('code') || lower.includes('كود') || lower.includes('برمجة')) tags.add('coding'); + if (lower.includes('tui') || lower.includes('terminal')) tags.add('tui'); + if (lower.includes('mcp') || lower.includes('protocol')) tags.add('mcp'); + if (lower.includes('api') || lower.includes('provider')) tags.add('provider'); + if (lower.includes('error') || lower.includes('خطأ') || lower.includes('bug')) tags.add('troubleshooting'); + if (lower.includes('prompt') || lower.includes('موجه')) tags.add('prompt'); + if (lower.includes('rag') || lower.includes('search') || lower.includes('vector')) tags.add('rag'); + + return Array.from(tags); + } + + /** + * Extract knowledge chunks from a specified path + */ + public extractAll(targetPath?: string): KnowledgeChunk[] { + const basePath = normalizeBase(targetPath || this.defaultBasePath); + const chunks: KnowledgeChunk[] = []; + const files = this.findMarkdownFiles(basePath); + + for (const filePath of files) { + try { + const rawContent = readFileSync(filePath, 'utf-8'); + const { data, body } = this.parseFrontmatter(rawContent); + const fileName = basename(filePath); + const docTitle = data.title || fileName.replace(/\.(md|mdx)$/, ''); + + // Infer stage from directory structure or filename if present + let stage = 1; + const stageMatch = filePath.match(/(\d+)-[a-zA-Z0-9_-]+/); + if (stageMatch) { + stage = parseInt(stageMatch[1], 10); + } + + const occurrence = new Map(); + const sections = this.splitIntoSections(body, docTitle); + for (let i = 0; i < sections.length; i++) { + const sec = sections[i]; + const occ = occurrence.get(sec.heading) || 0; + occurrence.set(sec.heading, occ + 1); + chunks.push({ + id: chunkId(filePath, sec.heading, occ), + title: docTitle, + stage, + section: sec.heading, + content: sec.content, + metadata: { + source: filePath, + type: this.detectType(fileName, sec.content), + tags: this.extractTags(sec.content), + language: /[\u0600-\u06FF]/.test(sec.content) ? 'ar' : 'en', + difficulty: Math.min(100, Math.max(10, stage * 20)), + }, + }); + } + } catch (err) { + console.error(`Error reading ${filePath}:`, err); + } + } + + return chunks; + } +} + +export default new KnowledgeExtractor(); diff --git a/packages/knowledge-engine/src/index.ts b/packages/knowledge-engine/src/index.ts new file mode 100644 index 000000000000..ad260cb93f35 --- /dev/null +++ b/packages/knowledge-engine/src/index.ts @@ -0,0 +1,37 @@ +export * from './types'; +export * from './candidate'; +export { extractCandidates, type ExtractionOptions } from './extraction'; +export { + decide, + pendingReviews, + reviewCandidate, + toReviewView, + type ReviewDecision, + type ReviewView, +} from './review'; +export { + admit, + admitCandidate, + sweepSuperseded, + type AdmissionDeps, + type AdmissionReceipt, + type AdmissionResult, +} from './admission'; +export { CandidateStore, resolveCandidatesDbPath, CANDIDATES_DB_FILENAME } from './staging'; +export { LocalVectorDB, resolveKnowledgeDbPath, KNOWLEDGE_DB_FILENAME } from './vector-db'; +export { LocalEmbedder } from './embedder'; +export { KnowledgeExtractor } from './extractor'; +export { LocalRetriever, LocalRetriever as RetrievalEngine } from './retriever'; +export { applyAbstentionGate, hasLexicalSupport, substantiveTokens } from './abstention'; +export { AgentKnowledgeIntegration } from './agent-integration'; +export { KnowledgeEngineManager } from './manager'; + +import manager from './manager'; +import retriever from './retriever'; +import integration from './agent-integration'; + +export default { + manager, + retriever, + integration, +}; diff --git a/packages/knowledge-engine/src/manager.ts b/packages/knowledge-engine/src/manager.ts new file mode 100644 index 000000000000..c889ac3ced49 --- /dev/null +++ b/packages/knowledge-engine/src/manager.ts @@ -0,0 +1,85 @@ +import extractor, { normalizeBase } from './extractor'; +import { LocalVectorDB } from './vector-db'; +import { LocalEmbedder } from './embedder'; +import { LocalRetriever } from './retriever'; +import type { IndexStats } from './types'; + +export class KnowledgeEngineManager { + private db: LocalVectorDB; + private embedder: LocalEmbedder; + private retriever: LocalRetriever; + + constructor(dbPath?: string) { + this.db = new LocalVectorDB(dbPath); + this.embedder = new LocalEmbedder(); + this.retriever = new LocalRetriever(this.db, this.embedder); + } + + /** + * Build or rebuild the index from a documentation path. + * - No path (default corpus): true rebuild — clear first, no orphans ever. + * - With path: replace that tree only — idempotent, drops its deleted + * files, never touches other indexed trees. + */ + public buildIndex(docsPath?: string): { count: number; stats: IndexStats } { + console.log(`\n🔍 [Knowledge Engine] بدء استخراج المعرفة من: ${docsPath || 'المسار الافتراضي'}...`); + const chunks = extractor.extractAll(docsPath); + console.log(`📄 تم استخراج ${chunks.length} جزء معرفي.`); + + if (chunks.length === 0) { + if (docsPath) this.db.deleteTree(normalizeBase(docsPath)); + console.warn('⚠️ لم يتم العثور على ملفات markdown صالحة في المسار المحدد.'); + return { count: 0, stats: this.db.getStats() }; + } + + if (docsPath) { + this.db.deleteTree(normalizeBase(docsPath)); + } else { + this.db.clear(); + } + + console.log('⚡ توليد الـ Dense Vectors وتحديث الفهرس الهجين (Vector + FTS5)...'); + const vectors = chunks.map(chunk => { + const fullText = `${chunk.title} ${chunk.section} ${chunk.content} ${chunk.metadata.tags.join(' ')}`; + return this.embedder.embed(fullText); + }); + + this.db.upsertBatch(chunks, vectors); + console.log('✅ اكتملت الفهرسة بنجاح وبسرعة فائقة دون الحاجة لأي مفاتيح خارجية!'); + + const stats = this.db.getStats(); + return { count: chunks.length, stats }; + } + + /** + * Test retrieval system with queries + */ + public async testRetrieval(queries?: string[]): Promise { + const testQueries = queries || [ + 'how to use opencode cli', + 'plan agent vs build agent', + 'troubleshooting errors', + 'tui and terminal commands' + ]; + + console.log('\n🧪 [Knowledge Engine] بدء اختبار الاسترجاع الهجين...'); + for (const q of testQueries) { + console.log(`\n❓ البحث عن: "${q}"`); + const results = await this.retriever.retrieveRelevant(q, 3); + if (results.length === 0) { + console.log(' (لا توجد نتائج مطابقة)'); + } else { + results.forEach(r => { + console.log(` [#${r.rank}] ${r.title} > ${r.section} (درجة التطابق: ${(r.similarity * 100).toFixed(1)}%)`); + console.log(` مقتطف: ${r.content.replace(/\s+/g, ' ').substring(0, 120)}...`); + }); + } + } + } + + public getFullStats(): IndexStats { + return this.db.getStats(); + } +} + +export default new KnowledgeEngineManager(); diff --git a/packages/knowledge-engine/src/middleware.ts b/packages/knowledge-engine/src/middleware.ts new file mode 100644 index 000000000000..dc3807ab383a --- /dev/null +++ b/packages/knowledge-engine/src/middleware.ts @@ -0,0 +1,127 @@ +import retriever, { LocalRetriever } from './retriever'; +import type { RetrievalResult } from './types'; + +export interface EnrichmentMessage { + content: string; + context?: string; + metadata?: Record; +} + +export interface EnrichedResult { + enrichedPrompt: string; + agentType: 'build' | 'plan'; + retrievedDocs: RetrievalResult[]; + originalContent: string; +} + +export class KnowledgeEnrichmentMiddleware { + private retriever: LocalRetriever; + + constructor(customRetriever?: LocalRetriever) { + this.retriever = customRetriever || retriever; + } + + /** + * Determine whether task requires Plan or Build agent based on semantic intent + */ + public determineAgent(input: string): 'build' | 'plan' { + const lower = input.toLowerCase(); + const planKeywords = [ + 'خطط', 'خطة', 'حلل', 'قيم', 'استراتيجية', 'معمارية', + 'plan', 'strategy', 'analyze', 'architecture', 'design', 'review' + ]; + return planKeywords.some(k => lower.includes(k)) ? 'plan' : 'build'; + } + + /** + * Pre-execution middleware: retrieves relevant knowledge and enriches agent prompt + */ + public async before( + message: EnrichmentMessage, + explicitAgent?: 'build' | 'plan' + ): Promise { + const agentType = explicitAgent || this.determineAgent(message.content); + + // Retrieve relevant documents using hybrid search tailored to agent role + const docs = await this.retriever.advancedSearch(message.content, { + topK: 3, + agentType, + minSimilarity: 0.2, + }); + + let knowledgeSection = ''; + if (docs.length > 0) { + const items = docs.map((d, i) => ` +[#${i + 1}] 📌 ${d.title} > ${d.section} (تطابق: ${(d.similarity * 100).toFixed(1)}%) +المصدر: ${d.source} +المحتوى: +${d.content} + `.trim()).join('\n\n---\n\n'); + + knowledgeSection = ` +================================================================================ +📚 KNOWLEDGE BASE ENRICHMENT (Local OpenCode Knowledge Engine) +================================================================================ +تم استرجاع المعرفة الموثقة التالية المتعلقة بطلب المستخدم: + +${items} + +💡 توجيهات للوكيل (${agentType.toUpperCase()} AGENT): +• اعتمد على الحقائق والأنماط والمعايير المسترجعة أعلاه كمرجع رسمي. +• لا تختلق معلومات تتعارض مع التوثيق المرفق. +================================================================================ + `.trim(); + } + + const enrichedPrompt = ` +${knowledgeSection ? `${knowledgeSection}\n\n` : ''}${message.context ? `السياق السابق:\n${message.context}\n\n` : ''}طلب المستخدم: +${message.content} + `.trim(); + + return { + enrichedPrompt, + agentType, + retrievedDocs: docs, + originalContent: message.content, + }; + } + + /** + * Post-execution middleware: inspects output for errors and attaches troubleshooting advice + */ + public async after(response: string): Promise<{ + finalResponse: string; + enhancedWithSolution: boolean; + solutionTitle?: string; + }> { + const lower = response.toLowerCase(); + const hasError = lower.includes('error') || lower.includes('خطأ') || lower.includes('failed') || lower.includes('فشل'); + + if (hasError) { + const solutions = await this.retriever.findTroubleshootingSolution(response); + if (solutions.length > 0) { + const bestSolution = solutions[0]; + const attached = ` +\n\n-------------------------------------------------------------------------------- +💡 مقترح استكشاف الأخطاء التلقائي (من محرك المعرفة المحلي): +📌 ${bestSolution.title} - ${bestSolution.section} +${bestSolution.content} +-------------------------------------------------------------------------------- + `.trim(); + + return { + finalResponse: `${response}\n\n${attached}`, + enhancedWithSolution: true, + solutionTitle: bestSolution.title, + }; + } + } + + return { + finalResponse: response, + enhancedWithSolution: false, + }; + } +} + +export default new KnowledgeEnrichmentMiddleware(); diff --git a/packages/knowledge-engine/src/retriever.ts b/packages/knowledge-engine/src/retriever.ts new file mode 100644 index 000000000000..b9106c423ded --- /dev/null +++ b/packages/knowledge-engine/src/retriever.ts @@ -0,0 +1,111 @@ +import { LocalVectorDB } from './vector-db'; +import { LocalEmbedder } from './embedder'; +import type { RetrievalResult, SearchOptions } from './types'; + +export class LocalRetriever { + private db: LocalVectorDB; + private embedder: LocalEmbedder; + + constructor(db?: LocalVectorDB, embedder?: LocalEmbedder) { + this.db = db || new LocalVectorDB(); + this.embedder = embedder || new LocalEmbedder(); + } + + /** + * Main hybrid retrieval function + */ + public async retrieveRelevant( + query: string, + topK: number = 5, + options: Omit = {} + ): Promise { + const queryVector = this.embedder.embed(query); + return this.db.hybridSearch(query, queryVector, { ...options, topK }); + } + + /** + * Unified search interface with flexible options + */ + public async search( + query: string, + options: SearchOptions = {} + ): Promise { + if (options.agentType) { + return this.advancedSearch(query, options); + } + const topK = options.topK ?? 5; + const { topK: _, ...rest } = options; + return this.retrieveRelevant(query, topK, rest); + } + + /** + * Advanced search tailored to Agent roles (Build / Plan) + */ + public async advancedSearch( + query: string, + options: SearchOptions = {} + ): Promise { + const { agentType, tags = [] } = options; + const searchTags = [...tags]; + + if (agentType === 'build') { + searchTags.push('coding', 'build'); + } else if (agentType === 'plan') { + searchTags.push('plan', 'strategy'); + } + + return this.retrieveRelevant(query, options.topK || 5, { + ...options, + tags: searchTags.length > 0 ? searchTags : undefined, + }); + } + + /** + * Find solutions for specific errors / bugs + */ + public async findTroubleshootingSolution(issue: string): Promise { + return this.retrieveRelevant(issue, 3, { + type: 'troubleshooting', + minSimilarity: 0.25, + }); + } + + /** + * Retrieve prompt templates + */ + public async getPromptTemplate(task: string): Promise { + return this.retrieveRelevant(task, 2, { + type: 'prompt', + minSimilarity: 0.2, + }); + } + + /** + * Retrieve best practices and lessons + */ + public async getBestPractice(topic: string): Promise { + return this.retrieveRelevant(topic, 3, { + type: 'lesson', + minSimilarity: 0.25, + }); + } + + /** + * Multi-aspect overview for comprehensive guidance + */ + public async getDetailedContent(topic: string): Promise<{ + overview: RetrievalResult[]; + practices: RetrievalResult[]; + troubleshooting: RetrievalResult[]; + }> { + const [overview, practices, troubleshooting] = await Promise.all([ + this.retrieveRelevant(topic, 3), + this.getBestPractice(topic), + this.findTroubleshootingSolution(topic), + ]); + + return { overview, practices, troubleshooting }; + } +} + +export default new LocalRetriever(); diff --git a/packages/knowledge-engine/src/review.ts b/packages/knowledge-engine/src/review.ts new file mode 100644 index 000000000000..c0672f4046e3 --- /dev/null +++ b/packages/knowledge-engine/src/review.ts @@ -0,0 +1,78 @@ +import { + isReviewable, + validateCandidate, + type Candidate, +} from './candidate'; +import { CandidateStore } from './staging'; + +/** + * B3 — Human review gate for Phase 4B (Engineering Memory System). + * + * This is the workflow spine between staging and approval: + * + * store.list(status = pending) → review(candidate) → decide(id, one of three) + * + * It reads and writes ONLY the staging store and reuses the B0 state + * machine plus `validateCandidate` — no new lifecycle, no knowledge.db, + * no indexing. B3 ends at `status = approved`; admission into knowledge.db + * is B4's job (via B0's `toChunk`, which already refuses the rest). + */ + +export interface ReviewView { + candidate: Candidate; + /** Well-formedness problems (B0 validation). Non-empty means "fix or reject". */ + problems: string[]; + /** True only while pending. */ + reviewable: boolean; + /** True only when reviewable AND well-formed. */ + canApprove: boolean; +} + +export function toReviewView(candidate: Candidate): ReviewView { + const problems = validateCandidate(candidate); + const reviewable = isReviewable(candidate); + return { candidate, problems, reviewable, canApprove: reviewable && problems.length === 0 }; +} + +/** The review inbox: every pending candidate with its validation precomputed. */ +export function pendingReviews(store: CandidateStore): ReviewView[] { + return store.list({ status: 'pending' }).map(toReviewView); +} + +/** A single candidate prepared for a human decision. Throws when missing. */ +export function reviewCandidate(store: CandidateStore, id: string): ReviewView { + const candidate = store.get(id); + if (!candidate) throw new Error(`Candidate not found: ${id}`); + return toReviewView(candidate); +} + +export type ReviewDecision = + | { readonly action: 'approve'; readonly note?: string } + | { readonly action: 'reject'; readonly reason: string } + | { readonly action: 'supersede'; readonly byId: string }; + +/** + * Apply exactly one of the three review outcomes. Status rules stay defined + * in B0 (via the store); B3 adds one workflow policy: a supersede target + * must already exist AND be approved, so provenance never dangles and + * replacement ordering stays explicit (approve the replacement first). + */ +export function decide(store: CandidateStore, id: string, decision: ReviewDecision): Candidate { + switch (decision.action) { + case 'approve': + return store.approve(id, decision.note ?? ''); + case 'reject': + return store.reject(id, decision.reason); + case 'supersede': { + if (decision.byId === id) throw new Error(`Candidate ${id} cannot supersede itself`); + const target = store.get(decision.byId); + if (!target) throw new Error(`Supersede target not found: ${decision.byId}`); + if (target.status !== 'approved') { + throw new Error(`Supersede target ${decision.byId} must be approved (status: ${target.status})`); + } + return store.supersede(id, decision.byId); + } + default: + throw new Error(`Unknown review action: ${(decision as ReviewDecision).action}`); + } +} diff --git a/packages/knowledge-engine/src/staging.ts b/packages/knowledge-engine/src/staging.ts new file mode 100644 index 000000000000..28afe4dea451 --- /dev/null +++ b/packages/knowledge-engine/src/staging.ts @@ -0,0 +1,243 @@ +import { Database } from 'bun:sqlite'; +import { join, dirname } from 'path'; +import { + approveCandidate, + createCandidate, + rejectCandidate, + supersedeCandidate, + type Candidate, + type CandidateInput, + type CandidateStatus, +} from './candidate'; + +/** + * B1 — Staging store for Phase 4B (Engineering Memory System). + * + * Candidates live HERE, in their own database, until review. This store + * never touches knowledge.db: the only path from a Candidate to an + * indexable chunk is B0's `toChunk`, which refuses non-approved candidates. + * + * State transitions delegate to the B0 pure functions, so the lifecycle + * (pending → approved | rejected, approved → superseded) has exactly one + * definition. This store only persists it. + */ + +export const CANDIDATES_DB_FILENAME = 'knowledge-candidates.db'; + +/** + * Single explicit DB location: explicit arg wins, then + * $OPENCODE_KNOWLEDGE_CANDIDATES_DB, otherwise /knowledge-candidates.db. + * Deliberately separate from knowledge.db — staging and production never share a file. + */ +export function resolveCandidatesDbPath(requested?: string): string { + if (requested) return requested; + const fromEnv = process.env.OPENCODE_KNOWLEDGE_CANDIDATES_DB; + if (fromEnv) return fromEnv; + return join(dirname(import.meta.dir), CANDIDATES_DB_FILENAME); +} + +interface CandidateRow { + id: string; + title: string; + summary: string; + content: string; + type: string; + tags: string; + language: string; + difficulty: number; + provenance_source_session: string; + provenance_extractor: string; + provenance_summary_ref: string | null; + created_at: number; + updated_at: number; + status: string; + review_note: string | null; + superseded_by: string | null; +} + +function toRow(candidate: Candidate): CandidateRow { + return { + id: candidate.id, + title: candidate.title, + summary: candidate.summary, + content: candidate.content, + type: candidate.type, + tags: JSON.stringify(candidate.tags), + language: candidate.language, + difficulty: candidate.difficulty, + provenance_source_session: candidate.provenance.sourceSession, + provenance_extractor: candidate.provenance.extractor, + provenance_summary_ref: candidate.provenance.summaryRef ?? null, + created_at: candidate.createdAt, + updated_at: candidate.updatedAt, + status: candidate.status, + review_note: candidate.reviewNote ?? null, + superseded_by: candidate.supersededBy ?? null, + }; +} + +function parseTags(raw: string): string[] { + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((t): t is string => typeof t === 'string') : []; + } catch { + return []; + } +} + +function fromRow(row: CandidateRow): Candidate { + return { + id: row.id, + title: row.title, + summary: row.summary, + content: row.content, + type: row.type as Candidate['type'], + tags: parseTags(row.tags), + language: row.language, + difficulty: row.difficulty, + provenance: { + sourceSession: row.provenance_source_session, + extractor: row.provenance_extractor, + ...(row.provenance_summary_ref === null ? {} : { summaryRef: row.provenance_summary_ref }), + }, + createdAt: row.created_at, + updatedAt: row.updated_at, + status: row.status as CandidateStatus, + ...(row.review_note === null ? {} : { reviewNote: row.review_note }), + ...(row.superseded_by === null ? {} : { supersededBy: row.superseded_by }), + }; +} + +export interface CandidateListFilter { + status?: CandidateStatus | ReadonlyArray; +} + +export class CandidateStore { + private db: Database; + private dbPath: string; + + constructor(dbPath?: string) { + this.dbPath = resolveCandidatesDbPath(dbPath); + this.db = new Database(this.dbPath); + this.init(); + } + + public path(): string { + return this.dbPath; + } + + private init(): void { + this.db.run('PRAGMA journal_mode = WAL;'); + this.db.run('PRAGMA synchronous = NORMAL;'); + // Pilot finding: parallel handles on one staging file fail fast with + // SQLITE_BUSY. Wait instead; CLI invocations stay short-lived and sequential. + this.db.run('PRAGMA busy_timeout = 5000;'); + this.db.run(` + CREATE TABLE IF NOT EXISTS candidates ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + summary TEXT NOT NULL, + content TEXT NOT NULL, + type TEXT NOT NULL, + tags TEXT NOT NULL, + language TEXT NOT NULL, + difficulty INTEGER NOT NULL, + provenance_source_session TEXT NOT NULL, + provenance_extractor TEXT NOT NULL, + provenance_summary_ref TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + status TEXT NOT NULL, + review_note TEXT, + superseded_by TEXT + ); + `); + this.db.run('CREATE INDEX IF NOT EXISTS idx_candidates_status ON candidates(status);'); + } + + /** Build (B0 semantics) and persist. Re-saving the same deterministic id replaces. */ + public create(input: CandidateInput, now?: number): Candidate { + const candidate = createCandidate(input, now); + this.save(candidate); + return candidate; + } + + /** Upsert by deterministic id: re-extraction of the same knowledge updates in place (rowid preserved). */ + public save(candidate: Candidate): void { + const row = toRow(candidate); + this.db.prepare(` + INSERT INTO candidates + (id, title, summary, content, type, tags, language, difficulty, + provenance_source_session, provenance_extractor, provenance_summary_ref, + created_at, updated_at, status, review_note, superseded_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + summary = excluded.summary, + content = excluded.content, + type = excluded.type, + tags = excluded.tags, + language = excluded.language, + difficulty = excluded.difficulty, + provenance_source_session = excluded.provenance_source_session, + provenance_extractor = excluded.provenance_extractor, + provenance_summary_ref = excluded.provenance_summary_ref, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + status = excluded.status, + review_note = excluded.review_note, + superseded_by = excluded.superseded_by + `).run( + row.id, row.title, row.summary, row.content, row.type, row.tags, row.language, row.difficulty, + row.provenance_source_session, row.provenance_extractor, row.provenance_summary_ref, + row.created_at, row.updated_at, row.status, row.review_note, row.superseded_by, + ); + } + + public get(id: string): Candidate | undefined { + const row = this.db.query(`SELECT * FROM candidates WHERE id = ?`).get(id) as CandidateRow | null; + return row === null ? undefined : fromRow(row); + } + + public list(filter: CandidateListFilter = {}): Candidate[] { + const statuses = filter.status === undefined ? [] : Array.isArray(filter.status) ? [...filter.status] : [filter.status]; + const rows = (statuses.length === 0 + ? this.db.query(`SELECT * FROM candidates ORDER BY rowid ASC`).all() + : this.db.query( + `SELECT * FROM candidates WHERE status IN (${statuses.map(() => '?').join(',')}) ORDER BY rowid ASC` + ).all(...statuses)) as CandidateRow[]; + return rows.map(fromRow); + } + + public remove(id: string): boolean { + const result = this.db.query(`DELETE FROM candidates WHERE id = ?`).run(id); + return Number(result.changes ?? 0) > 0; + } + + private transition(id: string, apply: (current: Candidate) => Candidate): Candidate { + const current = this.get(id); + if (!current) throw new Error(`Candidate not found: ${id}`); + const next = apply(current); + this.save(next); + return next; + } + + /** pending → approved (B0 rules enforced), persisted. */ + public approve(id: string, note = '', now?: number): Candidate { + return this.transition(id, (current) => approveCandidate(current, note, now)); + } + + /** pending → rejected with a required reason (B0 rules enforced), persisted. */ + public reject(id: string, note: string, now?: number): Candidate { + return this.transition(id, (current) => rejectCandidate(current, note, now)); + } + + /** approved → superseded (B0 rules enforced), persisted. */ + public supersede(id: string, byId: string, now?: number): Candidate { + return this.transition(id, (current) => supersedeCandidate(current, byId, now)); + } + + public close(): void { + this.db.close(); + } +} diff --git a/packages/knowledge-engine/src/types.ts b/packages/knowledge-engine/src/types.ts new file mode 100644 index 000000000000..4ccdcfd5300d --- /dev/null +++ b/packages/knowledge-engine/src/types.ts @@ -0,0 +1,47 @@ +export interface KnowledgeMetadata { + source: string; + type: 'lesson' | 'prompt' | 'practice' | 'troubleshooting'; + tags: string[]; + language: string; + difficulty: number; + [key: string]: any; +} + +export interface KnowledgeChunk { + id: string; + title: string; + stage: number; + section: string; + content: string; + metadata: KnowledgeMetadata; +} + +export interface RetrievalResult { + rank: number; + id: string; + title: string; + section: string; + content: string; + similarity: number; + stage: number; + type: string; + tags: string[]; + source: string; +} + +export interface SearchOptions { + topK?: number; + stage?: number; + type?: 'lesson' | 'prompt' | 'practice' | 'troubleshooting'; + tags?: string[]; + minSimilarity?: number; + agentType?: 'build' | 'plan'; +} + +export interface IndexStats { + totalChunks: number; + totalSections: number; + dbSizeBytes: number; + stagesCount: Record; + typesCount: Record; +} diff --git a/packages/knowledge-engine/src/vector-db.ts b/packages/knowledge-engine/src/vector-db.ts new file mode 100644 index 000000000000..3ab1c7a3ca0d --- /dev/null +++ b/packages/knowledge-engine/src/vector-db.ts @@ -0,0 +1,387 @@ +import { Database } from 'bun:sqlite'; +import { statSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import type { KnowledgeChunk, RetrievalResult, SearchOptions, IndexStats } from './types'; +import { LocalEmbedder } from './embedder'; +import { applyAbstentionGate } from './abstention'; + +export const KNOWLEDGE_DB_FILENAME = 'knowledge.db'; + +/** + * Single explicit DB location: $OPENCODE_KNOWLEDGE_DB wins when set, + * otherwise /knowledge.db next to this package. + * No silent candidate chain — a wrong path must fail loudly, never attach elsewhere. + */ +export function resolveKnowledgeDbPath(requested?: string): string { + if (requested) return requested; + const fromEnv = process.env.OPENCODE_KNOWLEDGE_DB; + if (fromEnv) return fromEnv; + return join(dirname(import.meta.dir), KNOWLEDGE_DB_FILENAME); +} + +export class LocalVectorDB { + private db: Database; + private dbPath: string; + + constructor(dbPath?: string) { + this.dbPath = resolveKnowledgeDbPath(dbPath); + this.db = new Database(this.dbPath); + this.init(); + } + + private init(): void { + // Enable WAL mode for high concurrent read/write speed + this.db.run('PRAGMA journal_mode = WAL;'); + this.db.run('PRAGMA synchronous = NORMAL;'); + // Pilot finding: parallel handles on one knowledge file fail fast with + // SQLITE_BUSY. Wait instead; CLI invocations stay short-lived and sequential. + this.db.run('PRAGMA busy_timeout = 5000;'); + + // 1. Chunks table with vector BLOB storage + this.db.run(` + CREATE TABLE IF NOT EXISTS chunks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + stage INTEGER NOT NULL, + section TEXT NOT NULL, + content TEXT NOT NULL, + type TEXT NOT NULL, + tags TEXT NOT NULL, + language TEXT NOT NULL, + source TEXT NOT NULL, + difficulty INTEGER NOT NULL, + vector BLOB NOT NULL + ); + `); + + // 2. FTS5 Virtual Table for fast BM25 keyword search + this.db.run(` + CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5( + id UNINDEXED, + title, + section, + content, + tags + ); + `); + + // Create helpful indices + this.db.run('CREATE INDEX IF NOT EXISTS idx_chunks_stage ON chunks(stage);'); + this.db.run('CREATE INDEX IF NOT EXISTS idx_chunks_type ON chunks(type);'); + } + + public upsertChunk(chunk: KnowledgeChunk, vector: Float32Array): void { + const insertChunk = this.db.prepare(` + INSERT OR REPLACE INTO chunks (id, title, stage, section, content, type, tags, language, source, difficulty, vector) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const insertFts = this.db.prepare(` + INSERT OR REPLACE INTO chunks_fts (id, title, section, content, tags) + VALUES (?, ?, ?, ?, ?) + `); + + const tagsStr = chunk.metadata.tags.join(', '); + const vectorBuffer = Buffer.from(vector.buffer); + + this.db.transaction(() => { + insertChunk.run( + chunk.id, + chunk.title, + chunk.stage, + chunk.section, + chunk.content, + chunk.metadata.type, + tagsStr, + chunk.metadata.language, + chunk.metadata.source, + chunk.metadata.difficulty, + vectorBuffer + ); + + // Clean old FTS row if exists + this.db.run('DELETE FROM chunks_fts WHERE id = ?', [chunk.id]); + insertFts.run( + chunk.id, + chunk.title, + chunk.section, + chunk.content, + tagsStr + ); + })(); + } + + public upsertBatch(chunks: KnowledgeChunk[], vectors: Float32Array[]): void { + const insertChunk = this.db.prepare(` + INSERT OR REPLACE INTO chunks (id, title, stage, section, content, type, tags, language, source, difficulty, vector) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const insertFts = this.db.prepare(` + INSERT OR REPLACE INTO chunks_fts (id, title, section, content, tags) + VALUES (?, ?, ?, ?, ?) + `); + + this.db.transaction(() => { + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const vec = vectors[i]; + const tagsStr = chunk.metadata.tags.join(', '); + const vectorBuffer = Buffer.from(vec.buffer); + + insertChunk.run( + chunk.id, + chunk.title, + chunk.stage, + chunk.section, + chunk.content, + chunk.metadata.type, + tagsStr, + chunk.metadata.language, + chunk.metadata.source, + chunk.metadata.difficulty, + vectorBuffer + ); + + this.db.run('DELETE FROM chunks_fts WHERE id = ?', [chunk.id]); + insertFts.run( + chunk.id, + chunk.title, + chunk.section, + chunk.content, + tagsStr + ); + } + })(); + } + + /** + * Delete one chunk (row + FTS) by id. Used by the admission pipeline to + * retire superseded knowledge from active retrieval. Returns true when a + * chunk existed. Staging audit records are untouched (different database). + */ + public deleteChunk(id: string): boolean { + const existing = this.db.query(`SELECT id FROM chunks WHERE id = ?`).get(id) as { id: string } | null; + if (!existing) return false; + this.db.transaction(() => { + this.db.run('DELETE FROM chunks_fts WHERE id = ?', [id]); + this.db.run('DELETE FROM chunks WHERE id = ?', [id]); + })(); + return true; + } + + /** + * Count chunks whose source starts with a prefix (e.g. 'candidate:'). + * Read-only. Used to separate governed admissions from the raw corpus + * without touching any other behavior. + */ + public countBySourcePrefix(prefix: string): number { + const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); + const row = this.db.query( + `SELECT COUNT(*) AS count FROM chunks WHERE source LIKE ? ESCAPE '\\'` + ).get(`${esc(prefix)}%`) as { count: number } | null; + return row ? row.count : 0; + } + + /** + * Full rebuild helper: drop every indexed row (chunks + FTS). + * Makes default-corpus indexing orphan-free by construction. + */ + public clear(): void { + this.db.transaction(() => { + this.db.run('DELETE FROM chunks_fts'); + this.db.run('DELETE FROM chunks'); + })(); + } + + /** + * Delete every row whose source is basePath or lives under it. + * Makes re-indexing one tree idempotent and removes its orphans + * (deleted files) without touching other indexed trees. + */ + public deleteTree(basePath: string): void { + const prefix = basePath.endsWith('/') ? basePath : `${basePath}/`; + const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); + const rows = this.db.query( + `SELECT id FROM chunks WHERE source = ? OR source LIKE ? ESCAPE '\\'` + ).all(basePath, `${esc(prefix)}%`) as Array<{ id: string }>; + if (rows.length === 0) return; + const ids = rows.map(r => r.id); + const placeholders = ids.map(() => '?').join(','); + this.db.transaction(() => { + this.db.run(`DELETE FROM chunks_fts WHERE id IN (${placeholders})`, ids); + this.db.run(`DELETE FROM chunks WHERE id IN (${placeholders})`, ids); + })(); + } + + /** + * Fast Vector Cosine Similarity Search + */ + public vectorSearch(queryVector: Float32Array, topK: number = 10, minScore: number = 0.3): RetrievalResult[] { + const rows = this.db.query(`SELECT * FROM chunks`).all() as any[]; + const scored: Array<{ row: any; similarity: number }> = []; + + for (const row of rows) { + const rowVec = new Float32Array(row.vector.buffer, row.vector.byteOffset, row.vector.byteLength / 4); + const sim = LocalEmbedder.cosineSimilarity(queryVector, rowVec); + if (sim >= minScore) { + scored.push({ row, similarity: sim }); + } + } + + scored.sort((a, b) => b.similarity - a.similarity); + const topResults = scored.slice(0, topK); + + return topResults.map((item, idx) => ({ + rank: idx + 1, + id: item.row.id, + title: item.row.title, + section: item.row.section, + content: item.row.content, + similarity: Number(item.similarity.toFixed(4)), + stage: item.row.stage, + type: item.row.type, + tags: item.row.tags ? item.row.tags.split(',').map((t: string) => t.trim()) : [], + source: item.row.source, + })); + } + + /** + * Native FTS5 BM25 Full-Text Search + */ + public textSearch(queryText: string, topK: number = 10): RetrievalResult[] { + const cleanQuery = queryText.replace(/[^\p{L}\p{N}\s]/gu, ' ').trim(); + if (!cleanQuery) return []; + + const ftsQuery = cleanQuery.split(/\s+/).filter(w => w.length > 1).map(w => `"${w}"*`).join(' OR '); + if (!ftsQuery) return []; + + try { + const rows = this.db.query(` + SELECT c.*, bm25(chunks_fts) as rank_score + FROM chunks_fts f + JOIN chunks c ON c.id = f.id + WHERE chunks_fts MATCH ? + ORDER BY rank_score ASC + LIMIT ? + `).all(ftsQuery, topK) as any[]; + + return rows.map((row, idx) => { + // BM25 is negative lower-is-better in sqlite, convert to normalized 0-1 scale + const sim = Math.max(0.1, Math.min(1.0, 1.0 / (1.0 + Math.abs(row.rank_score || 0) * 0.1))); + return { + rank: idx + 1, + id: row.id, + title: row.title, + section: row.section, + content: row.content, + similarity: Number(sim.toFixed(4)), + stage: row.stage, + type: row.type, + tags: row.tags ? row.tags.split(',').map((t: string) => t.trim()) : [], + source: row.source, + }; + }); + } catch { + return []; + } + } + + /** + * Hybrid Search: Combines Dense Vector + Sparse BM25 + */ + public hybridSearch( + queryText: string, + queryVector: Float32Array, + options: SearchOptions = {} + ): RetrievalResult[] { + const topK = options.topK || 5; + const minScore = options.minSimilarity || 0.3; + + const vecResults = this.vectorSearch(queryVector, topK * 2, 0.1); + const txtResults = this.textSearch(queryText, topK * 2); + + const merged = new Map(); + + // Weight: 0.65 Vector + 0.35 Text + for (const vr of vecResults) { + merged.set(vr.id, { + result: vr, + finalScore: vr.similarity * 0.65, + }); + } + + for (const tr of txtResults) { + if (merged.has(tr.id)) { + const item = merged.get(tr.id)!; + item.finalScore += tr.similarity * 0.35; + } else { + merged.set(tr.id, { + result: tr, + finalScore: tr.similarity * 0.35, + }); + } + } + + let list = Array.from(merged.values()) + .map(item => { + item.result.similarity = Number(item.finalScore.toFixed(4)); + return item.result; + }) + .filter(r => r.similarity >= minScore); + + // Filter by options if provided + if (options.stage !== undefined) { + list = list.filter(r => r.stage === options.stage); + } + if (options.type !== undefined) { + list = list.filter(r => r.type === options.type); + } + if (options.tags && options.tags.length > 0) { + list = list.filter(r => options.tags!.some(t => r.tags.includes(t))); + } + + list.sort((a, b) => b.similarity - a.similarity); + const top = list.slice(0, topK).map((r, idx) => ({ ...r, rank: idx + 1 })); + // B5 abstention gate (HARDENING-02): no substantive lexical overlap + // between query and results means no project knowledge applies — return + // an honest empty list from inside the engine, never display hiding. + return applyAbstentionGate(queryText, top); + } + + public getStats(): IndexStats { + const totalRow = this.db.query('SELECT COUNT(*) as cnt FROM chunks').get() as any; + const totalChunks = totalRow ? totalRow.cnt : 0; + + const stagesRows = this.db.query('SELECT stage, COUNT(*) as cnt FROM chunks GROUP BY stage').all() as any[]; + const stagesCount: Record = {}; + for (const sr of stagesRows) { + stagesCount[sr.stage] = sr.cnt; + } + + const typesRows = this.db.query('SELECT type, COUNT(*) as cnt FROM chunks GROUP BY type').all() as any[]; + const typesCount: Record = {}; + for (const tr of typesRows) { + typesCount[tr.type] = tr.cnt; + } + + let dbSizeBytes = 0; + if (existsSync(this.dbPath)) { + dbSizeBytes = statSync(this.dbPath).size; + } + + return { + totalChunks, + totalSections: totalChunks, + dbSizeBytes, + stagesCount, + typesCount, + }; + } + + public close(): void { + this.db.close(); + } +} + +export default new LocalVectorDB(); diff --git a/packages/knowledge-engine/tests/abstention.test.ts b/packages/knowledge-engine/tests/abstention.test.ts new file mode 100644 index 000000000000..e6bcd79c160b --- /dev/null +++ b/packages/knowledge-engine/tests/abstention.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test, afterEach } from 'bun:test'; +import { existsSync, unlinkSync } from 'fs'; +import { LocalEmbedder } from '../src/embedder'; +import { LocalVectorDB } from '../src/vector-db'; +import { LocalRetriever } from '../src/retriever'; +import { applyAbstentionGate, hasLexicalSupport, substantiveTokens } from '../src/abstention'; +import type { KnowledgeChunk } from '../src/types'; + +/** + * HARDENING-02 frozen retrieval evaluation fixture (OBS-RETRIEVAL-ABSTENTION-01). + * + * Q1 English strong in-domain / Q2 Arabic strong in-domain / + * Q3 governed-memory paraphrase / Q4 weak valid in-domain must survive; + * Q5 Arabic out-of-domain / Q6 English out-of-domain must abstain (results=0). + * + * All databases here are isolated temp files — production knowledge.db is + * never touched by this fixture. + */ + +const Q1 = 'Which project conventions govern composing Effect workflows and naming observable service operations?'; +const Q2 = 'ما قواعد المشروع لبناء تدفقات Effect المركبة وتسمية العمليات التي نريد تتبعها؟'; +const Q3 = 'named traced effects with project domain prefixes and composable workflows'; +const Q4 = 'Effect service workflow conventions'; +const Q5 = 'كيف أختار نوع التربة المناسب لزراعة شجرة فاكهة؟'; +const Q6 = 'How should I water indoor tropical plants during summer?'; + +const GOVERNED_CONTENT = + 'Always use Effect.gen with yield* for composition and Effect.fn with domain prefix for named traced effects in this codebase'; + +const SEEDS: ReadonlyArray & { metadata: KnowledgeChunk['metadata'] }> = [ + { + id: 'cand-711ec7f0', + title: 'Always use Effect', + stage: 1, + section: 'candidate', + content: GOVERNED_CONTENT, + metadata: { + source: 'candidate:cand-711ec7f0', + type: 'practice', + tags: ['always', 'effect', 'yield', 'composition'], + language: 'mixed', + difficulty: 3, + }, + }, + { + id: 'chunk-agents', + title: 'AGENTS', + stage: 1, + section: 'effect', + content: + '- Use `Effect.gen(function* () { ... })` for composition.\n' + + '- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.', + metadata: { source: 'AGENTS.md', type: 'lesson', tags: ['effect', 'composition', 'traced'], language: 'mixed', difficulty: 1 }, + }, + { + id: 'chunk-skill', + title: 'SKILL', + stage: 1, + section: 'effect', + content: + 'Prefer current Effect v4 APIs and project-local patterns over old blog posts. ' + + 'Use `Effect.gen(function* () { ... })` for multi-step workflows. ' + + 'Use `Effect.fn("Name")` for named effects in reusable service methods.', + metadata: { source: 'SKILL.md', type: 'lesson', tags: ['effect', 'workflows', 'service'], language: 'en', difficulty: 1 }, + }, + { + id: 'chunk-generic', + title: 'CONTRIBUTING', + stage: 1, + section: 'dev', + content: + 'During development, `bun dev` is the local equivalent of the built `opencode` command. ' + + 'Both run the same CLI interface.', + metadata: { source: 'CONTRIBUTING.md', type: 'lesson', tags: ['dev', 'cli'], language: 'en', difficulty: 1 }, + }, + { + id: 'chunk-ar-generic', + title: 'QUICKSTART', + stage: 1, + section: 'arabic', + content: 'كيف أكتب API بـ Python؟ ابحث في قاعدة المعرفة عن القوالب والأمثلة العملية.', + metadata: { source: 'QUICKSTART.md', type: 'lesson', tags: ['quickstart'], language: 'ar', difficulty: 1 }, + }, + { + id: 'chunk-stale', + title: 'HANDOFF', + stage: 1, + section: 'snapshot', + content: 'تم إنجاز المشروع بنسبة 100%. الحالة: جاهز للإنتاج والاستخدام المباشر.', + metadata: { source: 'HANDOFF.md', type: 'lesson', tags: ['handoff'], language: 'ar', difficulty: 1 }, + }, +]; + +const tempDbs: string[] = []; +afterEach(() => { + for (const path of tempDbs.splice(0)) { + for (const file of [path, `${path}-wal`, `${path}-shm`, `${path}-journal`]) { + try { + if (existsSync(file)) unlinkSync(file); + } catch { + // best-effort cleanup + } + } + } +}); + +function seedDb(): LocalVectorDB { + const path = `/tmp/test_abstention_${Date.now()}_${Math.floor(Math.random() * 1e9)}.db`; + tempDbs.push(path); + const db = new LocalVectorDB(path); + const embedder = new LocalEmbedder(); + for (const chunk of SEEDS) { + db.upsertChunk(chunk, embedder.embed(`${chunk.title} ${chunk.section} ${chunk.content}`)); + } + return db; +} + +describe('HARDENING-02 abstention gate', () => { + test('tokenizer drops stopwords and short tokens in both languages', () => { + expect(substantiveTokens('How should I water indoor plants?')).toEqual(['water', 'indoor', 'plants']); + expect(substantiveTokens('كيف أختار نوع التربة؟')).toEqual(['أختار', 'نوع', 'التربة']); + expect(substantiveTokens('Effect.gen Effect.fn')).toContain('effect'); + }); + + test('empty or stopword-only queries never have lexical support', () => { + expect(hasLexicalSupport('', [{ id: 'x' } as never])).toBe(false); + expect(hasLexicalSupport('كيف ما هل؟', [{ id: 'x' } as never])).toBe(false); + }); + + test('1. strong English in-domain survives with authoritative evidence', async () => { + const seeded = seedDb(); + try { + const results = await new LocalRetriever(seeded).retrieveRelevant(Q1, 5); + expect(results.length).toBeGreaterThan(0); + expect(results.length).toBeLessThanOrEqual(5); + expect(results.some((r) => ['chunk-agents', 'chunk-skill', 'cand-711ec7f0'].includes(r.id))).toBe(true); + } finally { + seeded.close(); + } + }); + + test('2. strong Arabic in-domain survives via the exact AGENTS rule', async () => { + const seeded = seedDb(); + try { + const results = await new LocalRetriever(seeded).retrieveRelevant(Q2, 5); + expect(results.length).toBeGreaterThan(0); + expect(results.some((r) => r.id === 'chunk-agents')).toBe(true); + } finally { + seeded.close(); + } + }); + + test('3. governed-memory paraphrase retrieves governed or authoritative knowledge', async () => { + const seeded = seedDb(); + try { + const results = await new LocalRetriever(seeded).retrieveRelevant(Q3, 5); + expect(results.length).toBeGreaterThan(0); + expect(results.some((r) => ['cand-711ec7f0', 'chunk-agents', 'chunk-skill'].includes(r.id))).toBe(true); + } finally { + seeded.close(); + } + }); + + test('4. weak valid in-domain is not suppressed', async () => { + const seeded = seedDb(); + try { + const results = await new LocalRetriever(seeded).retrieveRelevant(Q4, 5); + expect(results.length).toBeGreaterThan(0); + } finally { + seeded.close(); + } + }); + + test('5. Arabic out-of-domain abstains with an honest empty list', async () => { + const seeded = seedDb(); + try { + const results = await new LocalRetriever(seeded).retrieveRelevant(Q5, 5); + expect(results).toEqual([]); + } finally { + seeded.close(); + } + }); + + test('6. English out-of-domain abstains with an honest empty list', async () => { + const seeded = seedDb(); + try { + const results = await new LocalRetriever(seeded).retrieveRelevant(Q6, 5); + expect(results).toEqual([]); + } finally { + seeded.close(); + } + }); + + test('7-10. engine and retriever agree; filters and bounds intact', async () => { + const seeded = seedDb(); + try { + const retriever = new LocalRetriever(seeded); + // Direct engine and retriever paths abstain identically. + expect(await retriever.retrieveRelevant(Q5, 5)).toEqual([]); + expect(await retriever.retrieveRelevant(Q6, 5)).toEqual([]); + // Metadata filters still apply on surviving queries. + const filtered = await retriever.retrieveRelevant(Q1, 5, { type: 'practice' }); + expect(filtered.every((r) => r.type === 'practice')).toBe(true); + expect(filtered.some((r) => r.id === 'cand-711ec7f0')).toBe(true); + // Top-K stays bounded. + const top2 = await retriever.retrieveRelevant(Q1, 2); + expect(top2.length).toBeLessThanOrEqual(2); + // Gate is display-independent: engine-level empty, not CLI hiding. + expect(applyAbstentionGate(Q5, [])).toEqual([]); + } finally { + seeded.close(); + } + }); +}); diff --git a/packages/knowledge-engine/tests/admission.test.ts b/packages/knowledge-engine/tests/admission.test.ts new file mode 100644 index 000000000000..cf21ddac5181 --- /dev/null +++ b/packages/knowledge-engine/tests/admission.test.ts @@ -0,0 +1,362 @@ +import { describe, expect, test } from 'bun:test'; +import { existsSync, unlinkSync } from 'fs'; +import { admit, admitCandidate } from '../src/admission'; +import { LocalEmbedder } from '../src/embedder'; +import { decide } from '../src/review'; +import { extractCandidates } from '../src/extraction'; +import { LocalRetriever } from '../src/retriever'; +import { CandidateStore } from '../src/staging'; +import { LocalVectorDB } from '../src/vector-db'; + +function tempDb(prefix: string): string { + return `/tmp/test_${prefix}_${Date.now()}_${Math.floor(Math.random() * 1e9)}.db`; +} + +function cleanup(...paths: string[]): void { + for (const path of paths) { + for (const file of [path, `${path}-wal`, `${path}-shm`, `${path}-journal`]) { + try { + if (existsSync(file)) unlinkSync(file); + } catch { + // best-effort cleanup + } + } + } +} + +function openStaging(path: string): CandidateStore { + return new CandidateStore(path); +} + +function openKnowledge(path: string): LocalVectorDB { + return new LocalVectorDB(path); +} + +class FailingEmbedder extends LocalEmbedder { + override embed(): Float32Array { + throw new Error('embedder down'); + } +} + +class FailingRetriever extends LocalRetriever { + override async retrieveRelevant(): Promise { + throw new Error('retriever down'); + } +} + +const EPOCH = { + title: 'SessionRunner initializes epoch before promotion', + summary: 'Session fixed admission-vs-execution ordering in the runner.', + content: 'SessionRunner initializes the context epoch before promoting steers in the runner lifecycle.', + sourceSession: 'ses_admit1', +}; + +const ALLOWANCE = { + title: 'Provider turn allowance resets once per batch', + summary: 'Session fixed repeated resets of the provider-turn allowance.', + content: 'Provider turn allowance resets once per steer batch in the session coordinator.', + sourceSession: 'ses_admit2', +}; + +describe('Approved admission pipeline (B4)', () => { + test('approved admission: toChunk → index → retrievable, staging untouched', async () => { + const stagingPath = tempDb('staging'); + const knowledgePath = tempDb('knowledge'); + const store = openStaging(stagingPath); + const knowledge = openKnowledge(knowledgePath); + try { + const created = store.create(EPOCH); + const approved = store.approve(created.id, 'verified pattern'); + const before = store.get(created.id); + + const { receipt, swept } = await admit({ store, knowledge }, created.id); + + expect(receipt.candidateId).toBe(created.id); + expect(receipt.chunkId).toBe(created.id); + expect(receipt.status).toBe('admitted'); + expect(receipt.verifiedRank).toBeGreaterThanOrEqual(0); + expect(swept).toEqual([]); + expect(knowledge.getStats().totalChunks).toBe(1); + // Admission is read-only toward staging. + expect(store.get(created.id)).toEqual({ ...approved, updatedAt: before!.updatedAt }); + expect(store.get(created.id)).toEqual(before); + } finally { + store.close(); + knowledge.close(); + cleanup(stagingPath, knowledgePath); + } + }); + + test('pending refusal: nothing staged-for-review reaches the index', async () => { + const stagingPath = tempDb('staging'); + const knowledgePath = tempDb('knowledge'); + const store = openStaging(stagingPath); + const knowledge = openKnowledge(knowledgePath); + try { + const created = store.create(EPOCH); + await expect(admit({ store, knowledge }, created.id)).rejects.toThrow(/must be approved/); + expect(knowledge.getStats().totalChunks).toBe(0); + } finally { + store.close(); + knowledge.close(); + cleanup(stagingPath, knowledgePath); + } + }); + + test('rejected refusal: rejected knowledge stays out of the index', async () => { + const stagingPath = tempDb('staging'); + const knowledgePath = tempDb('knowledge'); + const store = openStaging(stagingPath); + const knowledge = openKnowledge(knowledgePath); + try { + const created = store.create(EPOCH); + store.reject(created.id, 'session-specific, not generalizable'); + await expect(admit({ store, knowledge }, created.id)).rejects.toThrow(/must be approved/); + expect(knowledge.getStats().totalChunks).toBe(0); + } finally { + store.close(); + knowledge.close(); + cleanup(stagingPath, knowledgePath); + } + }); + + test('invalid records are refused even when staged', async () => { + const stagingPath = tempDb('staging'); + const knowledgePath = tempDb('knowledge'); + const store = openStaging(stagingPath); + const knowledge = openKnowledge(knowledgePath); + try { + const created = store.create(EPOCH); + const approved = store.approve(created.id, 'verified'); + store.save({ ...approved, content: ' ' }); + await expect(admit({ store, knowledge }, created.id)).rejects.toThrow(/invalid candidate/); + expect(knowledge.getStats().totalChunks).toBe(0); + } finally { + store.close(); + knowledge.close(); + cleanup(stagingPath, knowledgePath); + } + }); + + test('superseded handling: old chunk leaves active retrieval, audit stays in staging', async () => { + const stagingPath = tempDb('staging'); + const knowledgePath = tempDb('knowledge'); + const store = openStaging(stagingPath); + const knowledge = openKnowledge(knowledgePath); + try { + const old = store.create(EPOCH); + const replacement = store.create(ALLOWANCE); + store.approve(old.id, 'incumbent rule'); + store.approve(replacement.id, 'better version'); + await admit({ store, knowledge }, old.id); + await admit({ store, knowledge }, replacement.id); + + const epochQuery = EPOCH.content.slice(0, 200); + expect((await new LocalRetriever(knowledge).retrieveRelevant(epochQuery, 5)).some((r) => r.id === old.id)).toBe( + true, + ); + + store.supersede(old.id, replacement.id); + const second = await admit({ store, knowledge }, replacement.id); + expect(second.swept).toEqual([old.id]); + + const after = await new LocalRetriever(knowledge).retrieveRelevant(epochQuery, 5); + expect(after.some((r) => r.id === old.id)).toBe(false); + expect(knowledge.textSearch('epoch', 5).some((r) => r.id === old.id)).toBe(false); + const allowanceQuery = ALLOWANCE.content.slice(0, 200); + expect( + (await new LocalRetriever(knowledge).retrieveRelevant(allowanceQuery, 5)).some((r) => r.id === replacement.id), + ).toBe(true); + // Audit intact in staging. + const audit = store.get(old.id)!; + expect(audit.status).toBe('superseded'); + expect(audit.supersededBy).toBe(replacement.id); + expect(audit.content).toBe(EPOCH.content); + } finally { + store.close(); + knowledge.close(); + cleanup(stagingPath, knowledgePath); + } + }); + + test('idempotency: admitting twice does not duplicate chunks', async () => { + const stagingPath = tempDb('staging'); + const knowledgePath = tempDb('knowledge'); + const store = openStaging(stagingPath); + const knowledge = openKnowledge(knowledgePath); + try { + const created = store.create(EPOCH); + store.approve(created.id, 'verified'); + await admit({ store, knowledge }, created.id); + const second = await admit({ store, knowledge }, created.id); + expect(knowledge.getStats().totalChunks).toBe(1); + expect(second.receipt.status).toBe('admitted'); + } finally { + store.close(); + knowledge.close(); + cleanup(stagingPath, knowledgePath); + } + }); + + test('database separation: staging is audit, knowledge is active', async () => { + const stagingPath = tempDb('staging'); + const knowledgePath = tempDb('knowledge'); + expect(stagingPath).not.toBe(knowledgePath); + const store = openStaging(stagingPath); + const knowledge = openKnowledge(knowledgePath); + try { + const created = store.create(EPOCH); + store.approve(created.id, 'verified'); + await admit({ store, knowledge }, created.id); + expect(existsSync(stagingPath)).toBe(true); + expect(existsSync(knowledgePath)).toBe(true); + expect(store.get(created.id)?.status).toBe('approved'); + expect(knowledge.getStats().totalChunks).toBe(1); + } finally { + store.close(); + knowledge.close(); + cleanup(stagingPath, knowledgePath); + } + }); + + test('restart durability: approve → close → reopen → admit → close → reopen → retrieve', async () => { + const stagingPath = tempDb('staging'); + const knowledgePath = tempDb('knowledge'); + const first = openStaging(stagingPath); + let id: string; + try { + id = first.create(EPOCH).id; + first.approve(id, 'verified before restart'); + } finally { + first.close(); + } + const second = openStaging(stagingPath); + const knowledge = openKnowledge(knowledgePath); + try { + expect(second.get(id)?.status).toBe('approved'); + await admit({ store: second, knowledge }, id); + } finally { + second.close(); + knowledge.close(); + } + const third = openKnowledge(knowledgePath); + try { + const found = await new LocalRetriever(third).retrieveRelevant(EPOCH.content.slice(0, 200), 5); + expect(found.some((r) => r.id === id)).toBe(true); + } finally { + third.close(); + cleanup(stagingPath, knowledgePath); + } + }); + + test('atomic failure: embedder error means no claim and intact staging', async () => { + const stagingPath = tempDb('staging'); + const knowledgePath = tempDb('knowledge'); + const store = openStaging(stagingPath); + const knowledge = openKnowledge(knowledgePath); + try { + const created = store.create(EPOCH); + store.approve(created.id, 'verified'); + const before = store.get(created.id); + await expect(admit({ store, knowledge, embedder: new FailingEmbedder() }, created.id)).rejects.toThrow( + /embedder down/, + ); + expect(knowledge.getStats().totalChunks).toBe(0); + expect(store.get(created.id)).toEqual(before); + } finally { + store.close(); + knowledge.close(); + cleanup(stagingPath, knowledgePath); + } + }); + + test('V1.0.1 compensation: retriever failure after a successful write leaves nothing behind', async () => { + const stagingPath = tempDb('staging'); + const knowledgePath = tempDb('knowledge'); + const store = openStaging(stagingPath); + const knowledge = openKnowledge(knowledgePath); + try { + const created = store.create(EPOCH); + store.approve(created.id, 'verified'); + const before = store.get(created.id); + await expect( + admit({ store, knowledge, retriever: new FailingRetriever(knowledge) }, created.id), + ).rejects.toThrow(/retriever down/); + // receipt = none (rejected above), chunk = absent, staging = intact. + expect(knowledge.getStats().totalChunks).toBe(0); + expect(knowledge.textSearch('epoch', 5)).toEqual([]); + expect(store.get(created.id)).toEqual(before); + } finally { + store.close(); + knowledge.close(); + cleanup(stagingPath, knowledgePath); + } + }); + + test('v1 end-to-end: summary → extract → stage → review → admit → new session retrieves it', async () => { + const stagingPath = tempDb('staging'); + const knowledgePath = tempDb('knowledge'); + const summary = [ + '## Session Architecture', + '- SessionRunner initializes the context epoch before promoting steers in the runner lifecycle', + '- User asked to retry the flaky command', + '## Provider Behavior', + '- Provider turn allowance resets once per steer batch in the session coordinator', + ].join('\n'); + + const store = openStaging(stagingPath); + const knowledge = openKnowledge(knowledgePath); + try { + // Extraction → staging (pending). The episodic unit is dropped by B2 gates. + const inputs = extractCandidates(summary, 'ses_e2e1'); + expect(inputs.length).toBe(2); + for (const candidateInput of inputs) store.create(candidateInput); + expect(store.list({ status: 'pending' })).toHaveLength(2); + + // Human review. + for (const view of store.list({ status: 'pending' })) { + decide(store, view.id, { action: 'approve', note: 'e2e verified' }); + } + expect(store.list({ status: 'approved' })).toHaveLength(2); + + // Admission. + for (const approved of store.list({ status: 'approved' })) { + const result = await admit({ store, knowledge }, approved.id); + expect(result.receipt.status).toBe('admitted'); + } + expect(knowledge.getStats().totalChunks).toBe(2); + } finally { + store.close(); + knowledge.close(); + } + + // A new session (fresh handles, same files) retrieves the approved knowledge. + const freshKnowledge = openKnowledge(knowledgePath); + try { + const retriever = new LocalRetriever(freshKnowledge); + const results = await retriever.retrieveRelevant('session runner epoch promotion admission order', 5, { + minSimilarity: 0.1, + }); + const contents = results.map((r) => r.content); + expect(contents.some((c) => c.includes('initializes the context epoch'))).toBe(true); + } finally { + freshKnowledge.close(); + cleanup(stagingPath, knowledgePath); + } + }); + + test('admitCandidate refuses unknown ids without touching the index', async () => { + const stagingPath = tempDb('staging'); + const knowledgePath = tempDb('knowledge'); + const store = openStaging(stagingPath); + const knowledge = openKnowledge(knowledgePath); + try { + await expect(admitCandidate({ store, knowledge }, 'cand-missing')).rejects.toThrow(/not found/); + expect(knowledge.getStats().totalChunks).toBe(0); + } finally { + store.close(); + knowledge.close(); + cleanup(stagingPath, knowledgePath); + } + }); +}); diff --git a/packages/knowledge-engine/tests/candidate.test.ts b/packages/knowledge-engine/tests/candidate.test.ts new file mode 100644 index 000000000000..2c9fff667024 --- /dev/null +++ b/packages/knowledge-engine/tests/candidate.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test'; +import { + approveCandidate, + candidateId, + createCandidate, + isReviewable, + rejectCandidate, + supersedeCandidate, + toChunk, + validateCandidate, + type Candidate, +} from '../src/candidate'; + +const input = { + title: 'Retry budget resets once per batch of steers', + summary: 'Session fixed a runner bug where every steer reset the turn allowance.', + content: 'Promoting any new user input resets the selected agent provider-turn allowance; a batch of steers resets it once.', + sourceSession: 'ses_abc123', +}; + +function pending(): Candidate { + return createCandidate(input, 1000); +} + +describe('Candidate model (B0)', () => { + test('candidateId is deterministic per (session, title, content)', () => { + expect(candidateId('s', 't', 'c')).toBe(candidateId('s', 't', 'c')); + expect(candidateId('s', 't', 'c')).not.toBe(candidateId('s', 't', 'other')); + expect(candidateId('s', 't', 'c')).not.toBe(candidateId('other', 't', 'c')); + expect(candidateId('s', 't', 'c')).toMatch(/^cand-[0-9a-f]{8}$/); + }); + + test('createCandidate starts pending with defaults and matching id', () => { + const c = pending(); + expect(c.status).toBe('pending'); + expect(c.id).toBe(candidateId(input.sourceSession, input.title, input.content)); + expect(c.type).toBe('lesson'); + expect(c.tags).toEqual([]); + expect(c.createdAt).toBe(1000); + expect(c.updatedAt).toBe(1000); + expect(validateCandidate(c)).toEqual([]); + }); + + test('approve moves pending → approved and records the note', () => { + const c = approveCandidate(pending(), 'verified against runner tests', 2000); + expect(c.status).toBe('approved'); + expect(c.reviewNote).toBe('verified against runner tests'); + expect(c.updatedAt).toBe(2000); + expect(isReviewable(c)).toBe(false); + }); + + test('approve throws for non-pending and for invalid candidates', () => { + const approved = approveCandidate(pending(), 'ok'); + expect(() => approveCandidate(approved)).toThrow(); + expect(() => rejectCandidate(approved, 'too late')).toThrow(); + const empty = createCandidate({ ...input, content: ' ' }); + expect(validateCandidate(empty)).toContain('content must not be empty'); + expect(() => approveCandidate(empty)).toThrow(/invalid candidate/); + }); + + test('reject moves pending → rejected and requires a reason', () => { + const c = rejectCandidate(pending(), 'session-specific, not generalizable', 2000); + expect(c.status).toBe('rejected'); + expect(c.reviewNote).toBe('session-specific, not generalizable'); + expect(() => rejectCandidate(pending(), ' ')).toThrow(/requires a reason/); + }); + + test('supersede moves approved → superseded and records the replacement', () => { + const approved = approveCandidate(pending(), 'ok'); + const c = supersedeCandidate(approved, 'cand-00000001', 3000); + expect(c.status).toBe('superseded'); + expect(c.supersededBy).toBe('cand-00000001'); + expect(() => supersedeCandidate(pending(), 'cand-00000001')).toThrow(); + expect(() => supersedeCandidate(approved, ' ')).toThrow(/replacing id/); + }); + + test('validate flags malformed candidates without throwing', () => { + const bad: Candidate = { ...pending(), title: ' ', status: 'superseded' }; + expect(validateCandidate(bad)).toContain('title must not be empty'); + expect(validateCandidate(bad)).toContain('superseded candidates must record supersededBy'); + const stray: Candidate = { ...pending(), supersededBy: 'cand-x' }; + expect(validateCandidate(stray)).toContain('only superseded candidates may record supersededBy'); + }); + + test('toChunk converts approved candidates and refuses everything else', () => { + const chunk = toChunk(approveCandidate(pending(), 'ok')); + expect(chunk.id).toBe(pending().id); + expect(chunk.title).toBe(input.title); + expect(chunk.content).toBe(input.content); + expect(chunk.metadata.source).toBe(`candidate:${pending().id}`); + expect(chunk.metadata.type).toBe('lesson'); + expect(() => toChunk(pending())).toThrow(/must be approved/); + expect(() => toChunk(rejectCandidate(pending(), 'no'))).toThrow(/must be approved/); + }); +}); diff --git a/packages/knowledge-engine/tests/concurrency.test.ts b/packages/knowledge-engine/tests/concurrency.test.ts new file mode 100644 index 000000000000..46471050c809 --- /dev/null +++ b/packages/knowledge-engine/tests/concurrency.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { existsSync, unlinkSync } from 'fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'path'; +import { CandidateStore } from '../src/staging'; + +const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const stagingModule = join(pkgDir, 'src', 'staging.ts'); +const vectorDbModule = join(pkgDir, 'src', 'vector-db.ts'); +const embedderModule = join(pkgDir, 'src', 'embedder.ts'); + +const paths: string[] = []; +function tempDb(prefix: string): string { + const path = `/tmp/test_busy_${prefix}_${Date.now()}_${Math.floor(Math.random() * 1e9)}.db`; + paths.push(path); + return path; +} + +function cleanup(path: string): void { + for (const file of [path, `${path}-wal`, `${path}-shm`, `${path}-journal`]) { + try { + if (existsSync(file)) unlinkSync(file); + } catch { + // best-effort cleanup + } + } +} + +/** + * Pilot finding: two handles on one SQLite file failed fast with + * SQLITE_BUSY. Both stores now set `PRAGMA busy_timeout = 5000`, so a + * blocked writer waits instead. Proven here by holding a write lock in + * this process while a separate process writes: without the pragma the + * child dies instantly; with it, the child waits and succeeds. + */ +async function runWriter(script: string): Promise<{ code: number; stderr: string }> { + const proc = Bun.spawn(['bun', '-e', script], { stdout: 'pipe', stderr: 'pipe' }); + const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]); + return { code, stderr }; +} + +describe('SQLITE_BUSY behavior (pilot hardening)', () => { + test('a blocked CandidateStore writer waits for the lock instead of failing fast', async () => { + const path = tempDb('staging'); + const seed = new CandidateStore(path); + const id = seed.create({ + title: 'Contention probe', + summary: 'Probe summary for lock behavior.', + content: 'Contention probe content for lock behavior testing.', + sourceSession: 'ses_busy1', + }).id; + seed.close(); + + const holder = new Database(path); + holder.run('BEGIN IMMEDIATE'); + try { + const child = runWriter( + `const { CandidateStore } = await import(${JSON.stringify(stagingModule)});` + + `const s = new CandidateStore(${JSON.stringify(path)});` + + `s.approve(${JSON.stringify(id)}, 'contention proof');` + + `s.close();`, + ); + // Let the child boot, open, and reach its blocked write before releasing. + await Bun.sleep(2500); + holder.run('COMMIT'); + const { code, stderr } = await child; + expect(`${code} ${stderr}`).not.toContain('SQLITE_BUSY'); + expect(code).toBe(0); + } finally { + try { + holder.run('COMMIT'); + } catch { + // already committed + } + holder.close(); + } + + const verify = new CandidateStore(path); + try { + expect(verify.get(id)?.status).toBe('approved'); + } finally { + verify.close(); + cleanup(path); + } + }, 30000); + + test('a blocked LocalVectorDB writer waits for the lock instead of failing fast', async () => { + const path = tempDb('knowledge'); + { + // Create the schema first so the child only contends on the write. + const { LocalVectorDB } = await import('../src/vector-db'); + const db = new LocalVectorDB(path); + db.close(); + } + + const holder = new Database(path); + holder.run('BEGIN IMMEDIATE'); + try { + const child = runWriter( + `const { LocalVectorDB } = await import(${JSON.stringify(vectorDbModule)});` + + `const { LocalEmbedder } = await import(${JSON.stringify(embedderModule)});` + + `const db = new LocalVectorDB(${JSON.stringify(path)});` + + `const chunk = { id: 'chunk-busy1', title: 'Busy probe', stage: 1, section: 'probe', content: 'contention probe content', metadata: { source: 'probe', type: 'lesson', tags: [], language: 'mixed', difficulty: 1 } };` + + `db.upsertChunk(chunk, new LocalEmbedder().embed(chunk.content));` + + `db.close();`, + ); + await Bun.sleep(2500); + holder.run('COMMIT'); + const { code, stderr } = await child; + expect(`${code} ${stderr}`).not.toContain('SQLITE_BUSY'); + expect(code).toBe(0); + } finally { + try { + holder.run('COMMIT'); + } catch { + // already committed + } + holder.close(); + } + + const { LocalVectorDB } = await import('../src/vector-db'); + const verify = new LocalVectorDB(path); + try { + expect(verify.getStats().totalChunks).toBe(1); + } finally { + verify.close(); + cleanup(path); + } + }, 30000); +}); diff --git a/packages/knowledge-engine/tests/engine.test.ts b/packages/knowledge-engine/tests/engine.test.ts new file mode 100644 index 000000000000..e9c9e494100e --- /dev/null +++ b/packages/knowledge-engine/tests/engine.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from 'bun:test'; +import { LocalEmbedder } from '../src/embedder'; +import { LocalVectorDB } from '../src/vector-db'; +import { KnowledgeExtractor } from '../src/extractor'; +import { AgentKnowledgeIntegration } from '../src/agent-integration'; +import { LocalRetriever } from '../src/retriever'; +import type { KnowledgeChunk } from '../src/types'; + +describe('Knowledge Engine (Local & Embedded)', () => { + const embedder = new LocalEmbedder(384); + + test('LocalEmbedder normalizes Arabic & English text and outputs 384-dim normalized vector', () => { + const textAr = 'مرحباً بك في أوبن كود OpenCode!'; + const vec = embedder.embed(textAr); + expect(vec.length).toBe(384); + + // Verify L2 norm is ~1.0 + let sumSquares = 0; + for (let i = 0; i < vec.length; i++) { + sumSquares += vec[i] * vec[i]; + } + expect(Math.abs(Math.sqrt(sumSquares) - 1.0)).toBeLessThan(0.01); + + // Cosine similarity between identical vectors should be 1.0 + const sim = LocalEmbedder.cosineSimilarity(vec, vec); + expect(Math.abs(sim - 1.0)).toBeLessThan(0.001); + }); + + test('LocalVectorDB performs hybrid search (Vector + FTS5) with metadata filtering', () => { + const testDbPath = '/tmp/test_knowledge_' + Date.now() + '.db'; + const db = new LocalVectorDB(testDbPath); + + const chunk1: KnowledgeChunk = { + id: 'chunk-1', + title: 'بدء الاستخدام', + stage: 1, + section: 'التثبيت والإعداد', + content: 'لتثبيت OpenCode CLI استخدم Bun أو السكربت المخصص لبناء الأداة.', + metadata: { + source: 'install.md', + type: 'lesson', + tags: ['install', 'setup', 'tui', 'coding', 'build'], + language: 'ar', + difficulty: 20, + }, + }; + + const chunk2: KnowledgeChunk = { + id: 'chunk-2', + title: 'استكشاف الأخطاء', + stage: 2, + section: 'خطأ الاتصال بـ LLM', + content: 'إذا ظهر خطأ Connection error تأكد من صحة المفاتيح وصلاحيات الشبكة.', + metadata: { + source: 'troubleshoot.md', + type: 'troubleshooting', + tags: ['error', 'troubleshooting', 'provider'], + language: 'ar', + difficulty: 40, + }, + }; + + const vec1 = embedder.embed(`${chunk1.title} ${chunk1.section} ${chunk1.content}`); + const vec2 = embedder.embed(`${chunk2.title} ${chunk2.section} ${chunk2.content}`); + + db.upsertChunk(chunk1, vec1); + db.upsertChunk(chunk2, vec2); + + // 1. Text search + const textResults = db.textSearch('التثبيت'); + expect(textResults.length).toBeGreaterThan(0); + expect(textResults[0].id).toBe('chunk-1'); + + // 2. Hybrid search for error + const queryVec = embedder.embed('حل مشكلة خطأ الاتصال'); + const hybridResults = db.hybridSearch('خطأ الاتصال', queryVec, { topK: 2 }); + expect(hybridResults.length).toBeGreaterThan(0); + expect(hybridResults[0].id).toBe('chunk-2'); + + // 3. Metadata filtering by type + const filtered = db.hybridSearch('CLI', queryVec, { type: 'troubleshooting' }); + expect(filtered.every(r => r.type === 'troubleshooting')).toBe(true); + + db.close(); + }); + + test('KnowledgeExtractor parses frontmatter and splits markdown into sections', () => { + const extractor = new KnowledgeExtractor(); + const markdown = `--- +title: دليل OpenCode الشامل +author: OpenCode Team +--- +# مقدمة +هذا هو القسم الأول يشرح المعمارية. + +## التخطيط والبناء +القسم الثاني يغطي وكلاء Plan و Build. +`; + + const { data, body } = extractor.parseFrontmatter(markdown); + expect(data.title).toBe('دليل OpenCode الشامل'); + + const sections = extractor.splitIntoSections(body, data.title); + expect(sections.length).toBe(2); + expect(sections[0].heading).toBe('مقدمة'); + expect(sections[1].heading).toBe('التخطيط والبناء'); + }); + + test('AgentKnowledgeIntegration generates enriched context prompts with custom retriever', async () => { + const testDbPath = '/tmp/test_rag_' + Date.now() + '.db'; + const db = new LocalVectorDB(testDbPath); + const customRetriever = new LocalRetriever(db, embedder); + + const chunk: KnowledgeChunk = { + id: 'chunk-build-1', + title: 'دليل Build Agent', + stage: 3, + section: 'تنفيذ الأوامر البرمجية', + content: 'يقوم وكيل البناء Build بتنفيذ الكود والتحقق من سلامة البيئة البرمجية.', + metadata: { + source: 'build.md', + type: 'lesson', + tags: ['coding', 'build', 'agent'], + language: 'ar', + difficulty: 60, + }, + }; + + const vec = embedder.embed(`${chunk.title} ${chunk.section} ${chunk.content}`); + db.upsertChunk(chunk, vec); + + const enriched = await AgentKnowledgeIntegration.enrichBuildContext( + 'تنفيذ الأوامر البرمجية وتطوير الكود', + 'Context: Initializing agent', + customRetriever + ); + + expect(enriched).toContain('KNOWLEDGE ENGINE CONTEXT'); + expect(enriched).toContain('دليل Build Agent'); + expect(enriched).toContain('تنفيذ الأوامر البرمجية'); + + db.close(); + }); + + test('countBySourcePrefix separates governed admissions from the raw corpus', () => { + const testDbPath = '/tmp/test_countsrc_' + Date.now() + '.db'; + const db = new LocalVectorDB(testDbPath); + const put = (id: string, source: string) => { + const chunk: KnowledgeChunk = { + id, + title: id, + stage: 1, + section: 'probe', + content: `probe content for ${id}`, + metadata: { source, type: 'lesson', tags: [], language: 'mixed', difficulty: 1 }, + }; + db.upsertChunk(chunk, embedder.embed(chunk.content)); + }; + put('chunk-doc-1', 'guide.md'); + put('chunk-doc-2', 'spec.md'); + expect(db.countBySourcePrefix('candidate:')).toBe(0); + put('cand-00000001', 'candidate:cand-00000001'); + expect(db.countBySourcePrefix('candidate:')).toBe(1); + expect(db.getStats().totalChunks).toBe(3); + db.close(); + }); +}); diff --git a/packages/knowledge-engine/tests/extraction.test.ts b/packages/knowledge-engine/tests/extraction.test.ts new file mode 100644 index 000000000000..bd28cbc910b3 --- /dev/null +++ b/packages/knowledge-engine/tests/extraction.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from 'bun:test'; +import { existsSync, unlinkSync } from 'fs'; +import { extractCandidates } from '../src/extraction'; +import { toChunk } from '../src/candidate'; +import { CandidateStore } from '../src/staging'; + +const GENERALIZABLE_A = 'SessionRunner initializes epoch before promotion'; +const GENERALIZABLE_B = 'Provider turn allowance resets once per steer batch'; + +describe('Candidate extraction (B2)', () => { + test('accepts timeless architectural statements', () => { + const found = extractCandidates( + `## Session Architecture\n- ${GENERALIZABLE_A}\n- ${GENERALIZABLE_B}`, + 'ses_abc123', + ); + expect(found.map((c) => c.content)).toEqual([GENERALIZABLE_A, GENERALIZABLE_B]); + expect(found.every((c) => c.sourceSession === 'ses_abc123')).toBe(true); + }); + + test('Gate 1+2: drops session-specific and episodic units', () => { + const found = extractCandidates( + ['Fixed issue in session abc', 'User asked to retry', GENERALIZABLE_A].join('\n- '), + 'ses_abc123', + ); + expect(found.map((c) => c.content)).toEqual([GENERALIZABLE_A]); + }); + + test('Gate 1+2: drops first-person past actions and temporal deictics', () => { + const found = extractCandidates( + ['I tried restarting the runner just now', 'We decided to retry the migration', GENERALIZABLE_B].join( + '\n- ', + ), + 'ses_abc123', + ); + expect(found.map((c) => c.content)).toEqual([GENERALIZABLE_B]); + }); + + test('Gate 3: drops credentials, tokens, emails, keys', () => { + const found = extractCandidates( + [ + 'Deploy with password: s3cr3t-hunter2 value set', + 'Use token sk-abcdefgh12345678 for the API', + 'Contact admin@example.com for access', + 'AKIAIOSFODNN7EXAMPLE is the access key', + GENERALIZABLE_A, + ].join('\n- '), + 'ses_abc123', + ); + expect(found.map((c) => c.content)).toEqual([GENERALIZABLE_A]); + }); + + test('Gate 3: drops home paths but keeps project-relative paths', () => { + const dropped = extractCandidates('- Config was read from /home/alice/.opencode/config', 'ses_1'); + expect(dropped).toEqual([]); + const kept = extractCandidates( + '- Project references live under packages/docs relative to the repo root', + 'ses_1', + ); + expect(kept).toHaveLength(1); + }); + + test('drops thin fragments and empty input', () => { + expect(extractCandidates('- ok\n- fix it', 'ses_1')).toEqual([]); + expect(extractCandidates('', 'ses_1')).toEqual([]); + expect(extractCandidates(' ', 'ses_1')).toEqual([]); + expect(extractCandidates(GENERALIZABLE_A, '')).toEqual([]); + expect(extractCandidates(GENERALIZABLE_A, ' ')).toEqual([]); + }); + + test('is deterministic and deduplicates repeated units', () => { + const summary = `## Findings\n- ${GENERALIZABLE_A}\n- ${GENERALIZABLE_A}\n- ${GENERALIZABLE_B}`; + const first = extractCandidates(summary, 'ses_1'); + const second = extractCandidates(summary, 'ses_1'); + expect(second).toEqual(first); + expect(first).toHaveLength(2); + }); + + test('caps output to protect staging from floods', () => { + const bullets = Array.from({ length: 30 }, (_, i) => `Architecture rule number ${i} about component behavior`).join( + '\n- ', + ); + expect(extractCandidates(bullets, 'ses_1')).toHaveLength(10); + expect(extractCandidates(bullets, 'ses_1', { maxCandidates: 3 })).toHaveLength(3); + }); + + test('classifies troubleshooting and practice units', () => { + const found = extractCandidates( + '- Fixed by clearing the stale cache directory when the build crashes\n- Always prefer explicit error returns over thrown exceptions', + 'ses_1', + ); + expect(found.map((c) => c.type)).toEqual(['troubleshooting', 'practice']); + }); + + test('B2 stop boundary: extract → stage (pending) — staged is not indexable', () => { + const path = `/tmp/test_extract_stage_${Date.now()}_${Math.floor(Math.random() * 1e9)}.db`; + const store = new CandidateStore(path); + try { + const inputs = extractCandidates(`## Findings\n- ${GENERALIZABLE_A}\n- ${GENERALIZABLE_B}`, 'ses_stage1'); + expect(inputs.length).toBeGreaterThan(0); + for (const candidateInput of inputs) store.create(candidateInput); + const staged = store.list(); + expect(staged).toHaveLength(inputs.length); + expect(staged.every((c) => c.status === 'pending')).toBe(true); + // Nothing here can reach knowledge.db: approval has not happened. + for (const candidate of staged) expect(() => toChunk(candidate)).toThrow(/must be approved/); + } finally { + store.close(); + for (const file of [path, `${path}-wal`, `${path}-shm`, `${path}-journal`]) { + try { + if (existsSync(file)) unlinkSync(file); + } catch { + // best-effort cleanup + } + } + } + }); +}); diff --git a/packages/knowledge-engine/tests/hygiene.test.ts b/packages/knowledge-engine/tests/hygiene.test.ts new file mode 100644 index 000000000000..4e528256bca0 --- /dev/null +++ b/packages/knowledge-engine/tests/hygiene.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdirSync, writeFileSync, rmSync, unlinkSync, symlinkSync } from 'fs'; +import { join } from 'path'; +import { LocalVectorDB, resolveKnowledgeDbPath } from '../src/vector-db'; +import { KnowledgeExtractor, chunkId, normalizeBase } from '../src/extractor'; +import { KnowledgeEngineManager } from '../src/manager'; + +function tmpDir(prefix: string): string { + const dir = join('/tmp', `${prefix}_${Date.now()}_${Math.floor(Math.random() * 1e6)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +describe('Phase 2 — Index Hygiene', () => { + test('resolveKnowledgeDbPath is explicit: requested > env > package default, no silent chain', () => { + expect(resolveKnowledgeDbPath('/x/y.db')).toBe('/x/y.db'); + + const prev = process.env.OPENCODE_KNOWLEDGE_DB; + process.env.OPENCODE_KNOWLEDGE_DB = '/tmp/explicit-nowhere.db'; + expect(resolveKnowledgeDbPath()).toBe('/tmp/explicit-nowhere.db'); + if (prev === undefined) delete process.env.OPENCODE_KNOWLEDGE_DB; + else process.env.OPENCODE_KNOWLEDGE_DB = prev; + + const def = resolveKnowledgeDbPath(); + expect(def.endsWith('/packages/knowledge-engine/knowledge.db')).toBe(true); + }); + + test('chunkId is deterministic and unique per (source, section, occurrence)', () => { + expect(chunkId('/a/b.md', 'Intro', 0)).toBe(chunkId('/a/b.md', 'Intro', 0)); + expect(chunkId('/a/b.md', 'Intro', 0)).not.toBe(chunkId('/a/b.md', 'Intro', 1)); + expect(chunkId('/a/b.md', 'Intro', 0)).not.toBe(chunkId('/a/c.md', 'Intro', 0)); + expect(chunkId('/a/b.md', 'Intro', 0)).toMatch(/^chunk-[0-9a-f]{8}$/); + }); + + test('normalizeBase resolves symlinks so one tree has one identity', () => { + const dir = tmpDir('hygiene-norm'); + const link = join('/tmp', `hygiene-link_${Date.now()}`); + writeFileSync(join(dir, 'a.md'), '# T\nbody\n'); + try { + symlinkSync(dir, link); + expect(normalizeBase(link)).toBe(normalizeBase(dir)); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(link, { recursive: true, force: true }); + } + }); + + test('extractAll yields stable ids across runs and unique ids for repeated headings', () => { + const dir = tmpDir('hygiene-ids'); + writeFileSync(join(dir, 'doc.md'), '# One\nfirst body\n## Dup\nx\n## Dup\ny\n'); + try { + const ex = new KnowledgeExtractor(); + const first = ex.extractAll(dir).map(c => c.id); + const second = new KnowledgeExtractor().extractAll(dir).map(c => c.id); + expect(first.length).toBe(3); + expect(first).toEqual(second); + expect(new Set(first).size).toBe(3); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('deleteTree removes one tree only; clear removes everything', () => { + const db = new LocalVectorDB(join('/tmp', `hygiene_tree_${Date.now()}.db`)); + const mk = (id: string, source: string) => ({ + id, + title: 't', + stage: 1, + section: 's', + content: `content ${id}`, + metadata: { source, type: 'lesson' as const, tags: [] as string[], language: 'en', difficulty: 10 }, + }); + const vec = new Float32Array(384).fill(0.1); + db.upsertBatch([mk('a1', '/tmp/treeA/f.md'), mk('b1', '/tmp/treeB/f.md')], [vec, vec]); + + db.deleteTree('/tmp/treeA'); + expect(db.getStats().totalChunks).toBe(1); + + db.deleteTree('/tmp/treeB/'); + expect(db.getStats().totalChunks).toBe(0); + + db.upsertBatch([mk('c1', '/tmp/treeC/f.md')], [vec]); + db.clear(); + expect(db.getStats().totalChunks).toBe(0); + db.close(); + }); + + test('manager.buildIndex is idempotent and drops orphans of deleted files', () => { + const dir = tmpDir('hygiene-manager'); + const dbPath = join('/tmp', `hygiene_mgr_${Date.now()}.db`); + const manager = new KnowledgeEngineManager(dbPath); + writeFileSync(join(dir, 'one.md'), '# Alpha\nbody alpha\n'); + writeFileSync(join(dir, 'two.md'), '# Beta\nbody beta\n'); + try { + const first = manager.buildIndex(dir); + const second = manager.buildIndex(dir); + expect(second.count).toBe(first.count); + expect(second.stats.totalChunks).toBe(first.stats.totalChunks); + + unlinkSync(join(dir, 'two.md')); + const third = manager.buildIndex(dir); + expect(third.stats.totalChunks).toBeLessThan(second.stats.totalChunks); + expect(third.stats.totalChunks).toBe(first.stats.totalChunks - 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(dbPath, { force: true }); + } + }); +}); diff --git a/packages/knowledge-engine/tests/integration.test.ts b/packages/knowledge-engine/tests/integration.test.ts new file mode 100644 index 000000000000..6b6d1be19fe5 --- /dev/null +++ b/packages/knowledge-engine/tests/integration.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'bun:test'; +import { KnowledgeEnrichmentMiddleware } from '../src/middleware'; +import { BuildAgentKnowledge, PlanAgentKnowledge } from '../src/agents'; +import { LocalVectorDB } from '../src/vector-db'; +import { LocalEmbedder } from '../src/embedder'; +import { LocalRetriever } from '../src/retriever'; +import type { KnowledgeChunk } from '../src/types'; + +describe('OpenCode Agents + Knowledge Engine Integration', () => { + const embedder = new LocalEmbedder(384); + const testDb = new LocalVectorDB('/tmp/test_integration_' + Date.now() + '.db'); + const retriever = new LocalRetriever(testDb, embedder); + const middleware = new KnowledgeEnrichmentMiddleware(retriever); + const buildAgent = new BuildAgentKnowledge(retriever); + const planAgent = new PlanAgentKnowledge(retriever); + + // Seed test database with relevant docs + const sampleDocs: KnowledgeChunk[] = [ + { + id: 'doc-plan-1', + title: 'استراتيجيات المعمارية', + stage: 1, + section: 'تخطيط النظم', + content: 'يجب البدء بتقسيم المهام إلى وحدات منفصلة واختبار كل وحدة قبل التكامل.', + metadata: { source: 'arch.md', type: 'lesson', tags: ['plan', 'strategy'], language: 'ar', difficulty: 50 }, + }, + { + id: 'doc-build-1', + title: 'أدوات التنفيذ البرمجي', + stage: 2, + section: 'كتابة الأكواد', + content: 'تأكد من استخدام معالجة الأخطاء وكتابة اختبارات الوحدة للوظائف الحساسة.', + metadata: { source: 'code.md', type: 'lesson', tags: ['build', 'coding'], language: 'ar', difficulty: 30 }, + }, + { + id: 'doc-error-1', + title: 'حلول أعطال الشبكة', + stage: 1, + section: 'انقطاع الاتصال', + content: 'تحقق من المتغير البيئي وصلاحيات الـ API وتأكد من استقرار الإنترنت.', + metadata: { source: 'troubleshoot.md', type: 'troubleshooting', tags: ['error', 'troubleshooting'], language: 'ar', difficulty: 20 }, + }, + ]; + + const vectors = sampleDocs.map(d => embedder.embed(`${d.title} ${d.section} ${d.content}`)); + testDb.upsertBatch(sampleDocs, vectors); + + test('Middleware accurately infers Agent intent (Plan vs Build)', () => { + expect(middleware.determineAgent('خطط لي هيكل مشروع')).toBe('plan'); + expect(middleware.determineAgent('analyze system architecture')).toBe('plan'); + expect(middleware.determineAgent('اكتب كود دالة حسابية')).toBe('build'); + expect(middleware.determineAgent('build API endpoint')).toBe('build'); + }); + + test('Build Agent prepares enriched prompt with knowledge context', async () => { + const result = await buildAgent.prepareTask('اكتب كود معالجة الأخطاء في المشروع'); + expect(result.enrichedPrompt).toContain('KNOWLEDGE BASE ENRICHMENT'); + expect(result.enrichedPrompt).toContain('طلب المستخدم'); + expect(result.docs.length).toBeGreaterThan(0); + }); + + test('Build Agent handles error and suggests relevant troubleshooting solution', async () => { + const solution = await buildAgent.handleExecutionError('حدث انقطاع الاتصال بالشبكة connection error'); + expect(solution).toContain('حل موصى به من قاعدة المعرفة'); + expect(solution).toContain('حلول أعطال الشبكة'); + }); + + test('Plan Agent synthesizes structured plan incorporating best practices', async () => { + const planResult = await planAgent.generatePlan('تخطيط النظم وتقسيم المهام'); + expect(planResult.plan).toContain('خطة العمل الذكية'); + expect(planResult.objectives.length).toBeGreaterThan(0); + expect(planResult.resources.length).toBeGreaterThan(0); + }); + + test('Middleware post-execution intercepts errors and attaches troubleshooting', async () => { + const responseWithError = 'Execution failed: error occurred in connection'; + const postResult = await middleware.after(responseWithError); + expect(postResult.enhancedWithSolution).toBe(true); + expect(postResult.finalResponse).toContain('مقترح استكشاف الأخطاء التلقائي'); + }); +}); diff --git a/packages/knowledge-engine/tests/review.test.ts b/packages/knowledge-engine/tests/review.test.ts new file mode 100644 index 000000000000..eb755ca3d613 --- /dev/null +++ b/packages/knowledge-engine/tests/review.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from 'bun:test'; +import { existsSync, unlinkSync } from 'fs'; +import { toChunk } from '../src/candidate'; +import { extractCandidates } from '../src/extraction'; +import { decide, pendingReviews, reviewCandidate } from '../src/review'; +import { CandidateStore } from '../src/staging'; + +function tempDb(): string { + return `/tmp/test_review_${Date.now()}_${Math.floor(Math.random() * 1e9)}.db`; +} + +function cleanup(path: string): void { + for (const file of [path, `${path}-wal`, `${path}-shm`, `${path}-journal`]) { + try { + if (existsSync(file)) unlinkSync(file); + } catch { + // best-effort cleanup + } + } +} + +const input = { + title: 'SessionRunner initializes epoch before promotion', + summary: 'Session fixed admission-vs-execution ordering in the runner.', + content: 'SessionRunner initializes the context epoch before promoting steers; promotion happens after.', + sourceSession: 'ses_review1', +}; + +describe('Human review gate (B3)', () => { + test('pendingReviews is the inbox: pending only, with validation precomputed', () => { + const path = tempDb(); + const store = new CandidateStore(path); + try { + const good = store.create(input); + const thin = store.create({ ...input, title: 'Thin', content: ' ' }); + const approved = store.create({ ...input, title: 'Old', content: 'old approved content here' }); + store.approve(approved.id, 'verified earlier'); + + const inbox = pendingReviews(store); + expect(inbox.map((view) => view.candidate.id).sort()).toEqual([good.id, thin.id].sort()); + expect(inbox).toHaveLength(2); + const valid = inbox.find((view) => view.candidate.id === good.id)!; + expect(valid.reviewable).toBe(true); + expect(valid.problems).toEqual([]); + expect(valid.canApprove).toBe(true); + const invalid = inbox.find((view) => view.candidate.id !== good.id)!; + expect(invalid.problems).toContain('content must not be empty'); + expect(invalid.canApprove).toBe(false); + } finally { + store.close(); + cleanup(path); + } + }); + + test('reviewCandidate prepares one candidate and throws when missing', () => { + const path = tempDb(); + const store = new CandidateStore(path); + try { + const created = store.create(input); + const view = reviewCandidate(store, created.id); + expect(view.candidate).toEqual(created); + expect(view.reviewable).toBe(true); + expect(() => reviewCandidate(store, 'cand-missing')).toThrow(/not found/); + } finally { + store.close(); + cleanup(path); + } + }); + + test('decide approve records the note and never rewrites the knowledge', () => { + const path = tempDb(); + const store = new CandidateStore(path); + try { + const created = store.create(input); + const approved = decide(store, created.id, { action: 'approve', note: 'verified against runner tests' }); + expect(approved.status).toBe('approved'); + expect(approved.reviewNote).toBe('verified against runner tests'); + expect(approved.title).toBe(created.title); + expect(approved.content).toBe(created.content); + expect(approved.provenance).toEqual(created.provenance); + expect(pendingReviews(store)).toHaveLength(0); + } finally { + store.close(); + cleanup(path); + } + }); + + test('decide approve refuses invalid candidates', () => { + const path = tempDb(); + const store = new CandidateStore(path); + try { + const thin = store.create({ ...input, content: ' ' }); + expect(() => decide(store, thin.id, { action: 'approve' })).toThrow(/invalid candidate/); + expect(store.get(thin.id)?.status).toBe('pending'); + } finally { + store.close(); + cleanup(path); + } + }); + + test('decide reject requires a reason', () => { + const path = tempDb(); + const store = new CandidateStore(path); + try { + const created = store.create(input); + const rejected = decide(store, created.id, { action: 'reject', reason: 'session-specific, not generalizable' }); + expect(rejected.status).toBe('rejected'); + const other = store.create({ ...input, title: 'Other', content: 'other reviewable content' }); + expect(() => decide(store, other.id, { action: 'reject', reason: ' ' })).toThrow(/requires a reason/); + } finally { + store.close(); + cleanup(path); + } + }); + + test('decide supersede needs an existing approved replacement', () => { + const path = tempDb(); + const store = new CandidateStore(path); + try { + const old = store.create({ ...input, title: 'Old rule', content: 'old rule content superseded' }); + const replacement = store.create({ ...input, title: 'New rule', content: 'new rule content replacing' }); + expect(() => decide(store, old.id, { action: 'supersede', byId: replacement.id })).toThrow(/must be approved/); + expect(() => decide(store, old.id, { action: 'supersede', byId: 'cand-missing' })).toThrow(/not found/); + expect(() => decide(store, old.id, { action: 'supersede', byId: old.id })).toThrow(/itself/); + decide(store, old.id, { action: 'approve', note: 'incumbent rule' }); + decide(store, replacement.id, { action: 'approve', note: 'better version' }); + const superseded = decide(store, old.id, { action: 'supersede', byId: replacement.id }); + expect(superseded.status).toBe('superseded'); + expect(superseded.supersededBy).toBe(replacement.id); + } finally { + store.close(); + cleanup(path); + } + }); + + test('decide rejects unknown actions', () => { + const path = tempDb(); + const store = new CandidateStore(path); + try { + const created = store.create(input); + expect(() => decide(store, created.id, { action: 'archive' } as never)).toThrow(/Unknown review action/); + } finally { + store.close(); + cleanup(path); + } + }); + + test('full B3 flow: extract → stage → review → approve/reject; B3 ends at approved', () => { + const path = tempDb(); + const store = new CandidateStore(path); + try { + const inputs = extractCandidates( + '## Findings\n- SessionRunner initializes epoch before promotion\n- User asked to retry\n- Provider turn allowance resets once per steer batch', + 'ses_flow1', + ); + expect(inputs.length).toBe(2); + for (const candidateInput of inputs) store.create(candidateInput); + + expect(pendingReviews(store)).toHaveLength(2); + const [first, second] = pendingReviews(store).map((view) => view.candidate); + decide(store, first.id, { action: 'approve', note: 'verified pattern' }); + decide(store, second.id, { action: 'reject', reason: 'duplicate of existing lesson' }); + + expect(pendingReviews(store)).toHaveLength(0); + expect(store.list({ status: 'approved' })).toHaveLength(1); + expect(store.list({ status: 'rejected' })).toHaveLength(1); + + // B3's output boundary: only the approved candidate is indexable (B4 input). + // No knowledge.db write happens anywhere in this flow. + const approved = store.list({ status: 'approved' })[0]; + expect(toChunk(approved).id).toBe(approved.id); + for (const candidate of store.list({ status: 'rejected' })) { + expect(() => toChunk(candidate)).toThrow(/must be approved/); + } + } finally { + store.close(); + cleanup(path); + } + }); +}); diff --git a/packages/knowledge-engine/tests/staging.test.ts b/packages/knowledge-engine/tests/staging.test.ts new file mode 100644 index 000000000000..9d406acee899 --- /dev/null +++ b/packages/knowledge-engine/tests/staging.test.ts @@ -0,0 +1,148 @@ +import { afterAll, describe, expect, test } from 'bun:test'; +import { existsSync, unlinkSync } from 'fs'; +import { KNOWLEDGE_DB_FILENAME } from '../src/vector-db'; +import { + CANDIDATES_DB_FILENAME, + CandidateStore, + resolveCandidatesDbPath, +} from '../src/staging'; + +const paths: string[] = []; +function tempDb(): string { + const path = `/tmp/test_candidates_${Date.now()}_${Math.floor(Math.random() * 1e9)}.db`; + paths.push(path); + return path; +} +afterAll(() => { + for (const path of paths) { + for (const file of [path, `${path}-wal`, `${path}-shm`, `${path}-journal`]) { + try { + if (existsSync(file)) unlinkSync(file); + } catch { + // best-effort cleanup + } + } + } +}); + +const input = { + title: 'Retry budget resets once per batch of steers', + summary: 'Session fixed a runner bug where every steer reset the turn allowance.', + content: 'Promoting any new user input resets the provider-turn allowance; a batch of steers resets it once.', + sourceSession: 'ses_abc123', +}; + +describe('CandidateStore staging (B1)', () => { + test('staging lives in its own file, never knowledge.db', () => { + expect(CANDIDATES_DB_FILENAME).toBe('knowledge-candidates.db'); + expect(CANDIDATES_DB_FILENAME).not.toBe(KNOWLEDGE_DB_FILENAME); + expect(resolveCandidatesDbPath('/x/y.db')).toBe('/x/y.db'); + expect(resolveCandidatesDbPath()).toContain('knowledge-candidates.db'); + }); + + test('create → get round-trips every field', () => { + const store = new CandidateStore(tempDb()); + try { + const created = store.create({ ...input, tags: ['runner,steers', 'epoch'] }); + const loaded = store.get(created.id); + expect(loaded).toEqual(created); + expect(loaded?.tags).toEqual(['runner,steers', 'epoch']); + expect(loaded?.status).toBe('pending'); + expect(store.get('cand-doesnotexist')).toBeUndefined(); + } finally { + store.close(); + } + }); + + test('saving the same deterministic id replaces instead of duplicating', () => { + const store = new CandidateStore(tempDb()); + try { + const first = store.create(input); + const second = store.create({ ...input, summary: 're-extracted with better summary' }); + expect(second.id).toBe(first.id); + expect(store.list()).toHaveLength(1); + expect(store.get(first.id)?.summary).toBe('re-extracted with better summary'); + } finally { + store.close(); + } + }); + + test('list filters by status in creation order', () => { + const store = new CandidateStore(tempDb()); + try { + const a = store.create({ ...input, title: 'First', content: 'first content' }); + const b = store.create({ ...input, title: 'Second', content: 'second content' }); + store.approve(a.id, 'verified'); + expect(store.list().map((c) => c.id)).toEqual([a.id, b.id]); + expect(store.list({ status: 'pending' }).map((c) => c.id)).toEqual([b.id]); + expect(store.list({ status: 'approved' }).map((c) => c.id)).toEqual([a.id]); + expect(store.list({ status: ['pending', 'approved'] })).toHaveLength(2); + expect(store.list({ status: 'rejected' })).toHaveLength(0); + } finally { + store.close(); + } + }); + + test('remove deletes and reports existence', () => { + const store = new CandidateStore(tempDb()); + try { + const created = store.create(input); + expect(store.remove(created.id)).toBe(true); + expect(store.get(created.id)).toBeUndefined(); + expect(store.remove(created.id)).toBe(false); + } finally { + store.close(); + } + }); + + test('transitions persist B0 semantics and reject invalid moves', () => { + const store = new CandidateStore(tempDb()); + try { + const created = store.create(input); + const approved = store.approve(created.id, 'verified against runner tests'); + expect(approved.status).toBe('approved'); + expect(store.get(created.id)?.status).toBe('approved'); + expect(() => store.approve(created.id)).toThrow(); + expect(() => store.reject(created.id, 'too late')).toThrow(); + const superseded = store.supersede(created.id, 'cand-00000001'); + expect(superseded.status).toBe('superseded'); + expect(store.get(created.id)?.supersededBy).toBe('cand-00000001'); + + const pending = store.create({ ...input, title: 'Other', content: 'other content' }); + expect(() => store.supersede(pending.id, 'cand-00000002')).toThrow(); + const rejected = store.reject(pending.id, 'session-specific, not generalizable'); + expect(store.get(pending.id)?.status).toBe('rejected'); + expect(rejected.reviewNote).toBe('session-specific, not generalizable'); + expect(() => store.reject(pending.id, 'again')).toThrow(); + expect(() => store.approve('cand-missing', 'x')).toThrow(/not found/); + } finally { + store.close(); + } + }); + + test('DoD: persist → close → reload → transition survives restarts', () => { + const path = tempDb(); + const first = new CandidateStore(path); + let id: string; + try { + id = first.create(input).id; + } finally { + first.close(); + } + const second = new CandidateStore(path); + try { + expect(second.get(id)?.status).toBe('pending'); + second.approve(id, 'verified after restart'); + } finally { + second.close(); + } + const third = new CandidateStore(path); + try { + const reloaded = third.get(id); + expect(reloaded?.status).toBe('approved'); + expect(reloaded?.reviewNote).toBe('verified after restart'); + } finally { + third.close(); + } + }); +}); diff --git a/packages/knowledge-engine/tsconfig.json b/packages/knowledge-engine/tsconfig.json new file mode 100644 index 000000000000..fe5c4d217b2e --- /dev/null +++ b/packages/knowledge-engine/tsconfig.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "noUncheckedIndexedAccess": false + } +} From a3ed3e2458036286c275b5ab417c65729e14c25d Mon Sep 17 00:00:00 2001 From: AH Date: Sun, 13 Sep 2026 09:02:57 -0700 Subject: [PATCH 2/8] feat(core): integrate first-turn knowledge retrieval into v2 runtime --- packages/core/package.json | 1 + packages/core/src/flag/flag.ts | 3 + packages/core/src/knowledge/guidance.ts | 123 +++++++ packages/core/src/knowledge/retrieval.ts | 67 ++++ packages/core/src/location-services.ts | 8 +- packages/core/src/session/input.ts | 24 ++ packages/core/src/session/runner/llm.ts | 23 +- .../core/test/knowledge/first-turn.test.ts | 309 ++++++++++++++++++ packages/core/test/knowledge/guidance.test.ts | 252 ++++++++++++++ .../core/test/session-runner-recorded.test.ts | 5 + packages/core/test/session-runner.test.ts | 42 +++ 11 files changed, 849 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/knowledge/guidance.ts create mode 100644 packages/core/src/knowledge/retrieval.ts create mode 100644 packages/core/test/knowledge/first-turn.test.ts create mode 100644 packages/core/test/knowledge/guidance.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index 2b465e5852fd..c3710b78d79c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -91,6 +91,7 @@ "@npmcli/config": "10.8.1", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", + "@opencode-ai/knowledge-engine": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/plugin": "workspace:*", diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a0eb78a13e2a..9292f7efb086 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -57,6 +57,9 @@ export const Flag = { get OPENCODE_EXPERIMENTAL_REFERENCES() { return enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES") }, + get OPENCODE_EXPERIMENTAL_KNOWLEDGE() { + return enabledByExperimental("OPENCODE_EXPERIMENTAL_KNOWLEDGE") + }, get OPENCODE_TUI_CONFIG() { return process.env["OPENCODE_TUI_CONFIG"] }, diff --git a/packages/core/src/knowledge/guidance.ts b/packages/core/src/knowledge/guidance.ts new file mode 100644 index 000000000000..2fd0d5e60f40 --- /dev/null +++ b/packages/core/src/knowledge/guidance.ts @@ -0,0 +1,123 @@ +export * as KnowledgeGuidance from "./guidance" + +import { makeLocationNode } from "../effect/app-node" +import { Database } from "../database/database" +import { Flag } from "../flag/flag" +import { SessionHistory } from "../session/history" +import { SessionInput } from "../session/input" +import { SessionMessage } from "../session/message" +import { SessionSchema } from "../session/schema" +import { SystemContext } from "../system-context/index" +import { KnowledgeRetrieval } from "./retrieval" +import { Context, Effect, Layer, Schema } from "effect" + +export const TOP_K = 3 +export const MIN_QUERY_CHARS = 12 +export const MAX_TOTAL_CHARS = 2500 +export const MAX_PER_DOC_CHARS = 1000 + +const Doc = Schema.Struct({ + title: Schema.String, + section: Schema.String, + content: Schema.String, +}) +type Doc = typeof Doc.Type + +const render = (docs: ReadonlyArray) => + [ + "Project knowledge retrieved for the current request. Apply the documented patterns below and do not contradict them.", + "", + ...docs.flatMap((doc) => [ + " ", + ` ${doc.title}`, + `
${doc.section}
`, + ` ${doc.content}`, + "
", + ]), + "
", + ].join("\n") + +const applyBudget = (docs: ReadonlyArray): Doc[] => { + const truncated = docs.map((doc) => ({ ...doc, content: doc.content.slice(0, MAX_PER_DOC_CHARS) })) + const picked = truncated.reduce<{ docs: Doc[]; used: number }>( + (acc, doc) => + acc.used >= MAX_TOTAL_CHARS + ? acc + : { + docs: [...acc.docs, { title: doc.title, section: doc.section, content: doc.content }], + used: acc.used + doc.content.length, + }, + { docs: [], used: 0 }, + ) + return picked.docs +} + +const emptyMessages: ReadonlyArray = [] +const emptyDocs: ReadonlyArray = [] + +export interface Interface { + readonly load: (sessionID: SessionSchema.ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/KnowledgeGuidance") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const { db } = yield* Database.Service + const retrieval = yield* KnowledgeRetrieval.Service + + return Service.of({ + load: Effect.fn("KnowledgeGuidance.load")(function* (sessionID: SessionSchema.ID) { + if (!Flag.OPENCODE_EXPERIMENTAL_KNOWLEDGE) return SystemContext.empty + // First-turn source: pending input exists in the admission inbox before + // promotion projects it into history, so read it directly when present. + const pending = yield* SessionInput.peekPending(db, sessionID).pipe( + Effect.catch(() => Effect.succeed(undefined)), + Effect.catchDefect(() => Effect.succeed(undefined)), + ) + const pendingQuery = pending?.prompt.text.trim() + const query = + pendingQuery !== undefined && pendingQuery.length > 0 + ? pendingQuery + : yield* Effect.suspend(() => + SessionHistory.load(db, sessionID).pipe( + Effect.catch(() => Effect.succeed(emptyMessages)), + Effect.catchDefect(() => Effect.succeed(emptyMessages)), + Effect.map((messages) => { + const last = messages.findLast((message) => message.type === "user") + return last && last.type === "user" ? last.text.trim() : "" + }), + ), + ) + if (query.length < MIN_QUERY_CHARS) return SystemContext.empty + const budgeted = applyBudget( + yield* retrieval + .search(query, TOP_K) + .pipe( + Effect.catch(() => Effect.succeed(emptyDocs)), + Effect.catchDefect(() => Effect.succeed(emptyDocs)), + ), + ).slice(0, TOP_K) + if (budgeted.length === 0) return SystemContext.empty + return SystemContext.make({ + key: SystemContext.Key.make("knowledge/retrieval"), + codec: Schema.toCodecJson(Schema.Array(Doc)), + load: Effect.succeed(budgeted), + baseline: render, + update: (_previous, current) => + [ + "The retrieved project knowledge has changed. This list supersedes the previous retrieved knowledge list.", + render(current), + ].join("\n"), + removed: () => + "Retrieved project knowledge is no longer available. Do not rely on previously retrieved knowledge.", + }) + }), + }) + }), +) + +export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [Database.node, KnowledgeRetrieval.node] }) diff --git a/packages/core/src/knowledge/retrieval.ts b/packages/core/src/knowledge/retrieval.ts new file mode 100644 index 000000000000..f3d8d98ad3f3 --- /dev/null +++ b/packages/core/src/knowledge/retrieval.ts @@ -0,0 +1,67 @@ +export * as KnowledgeRetrieval from "./retrieval" + +import { makeLocationNode } from "../effect/app-node" +import { Context, Effect, Layer } from "effect" +import type { RetrievalResult } from "@opencode-ai/knowledge-engine" + +/** One retrieved knowledge document, trimmed to the caller-facing shape. */ +export interface Doc { + readonly title: string + readonly section: string + readonly content: string + readonly similarity: number +} + +export interface Interface { + readonly search: (query: string, topK: number) => Effect.Effect> +} + +export class Service extends Context.Service()("@opencode/v2/KnowledgeRetrieval") {} + +export const TOP_K = 3 +export const MIN_SIMILARITY = 0.2 + +const EMPTY: ReadonlyArray = [] + +const toDoc = (row: RetrievalResult): Doc => ({ + title: row.title, + section: row.section, + content: row.content, + similarity: row.similarity, +}) + +type Searcher = (query: string, topK: number) => Effect.Effect> + +const noop: Searcher = () => Effect.succeed(EMPTY) + +const init = Effect.fn("KnowledgeRetrieval.init")(function* () { + const mod = yield* Effect.promise(() => import("@opencode-ai/knowledge-engine/retriever")) + const retriever = yield* Effect.sync(() => new mod.LocalRetriever()) + return (query: string, topK: number): Effect.Effect> => + Effect.promise(() => retriever.retrieveRelevant(query, topK, { minSimilarity: MIN_SIMILARITY })).pipe( + Effect.map((rows) => rows.map(toDoc).slice(0, topK)), + Effect.catch(() => Effect.succeed(EMPTY)), + Effect.catchDefect(() => Effect.succeed(EMPTY)), + ) +}) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const get = yield* Effect.cached( + init().pipe( + Effect.catch(() => Effect.succeed(noop)), + Effect.catchDefect(() => Effect.succeed(noop)), + ), + ) + return Service.of({ + search: (query, topK) => Effect.flatMap(get, (search) => search(query, topK)), + }) + }), +) + +export const noopLayer = Layer.succeed(Service, Service.of({ search: noop })) + +export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 7da67673c319..e96a3ca8a447 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -29,6 +29,8 @@ import { SessionRunnerModel } from "./session/runner/model" import { SessionTodo } from "./session/todo" import { SkillV2 } from "./skill" import { SkillGuidance } from "./skill/guidance" +import { KnowledgeGuidance } from "./knowledge/guidance" +import { KnowledgeRetrieval } from "./knowledge/retrieval" import { Snapshot } from "./snapshot" import { SystemContextBuiltIns } from "./system-context/builtins" import { SystemContextRegistry } from "./system-context/registry" @@ -43,6 +45,8 @@ export const locationServices = LayerNode.group([ Location.node, Policy.node, Config.node, + FileSystemSearch.node, + FileSystem.node, AgentV2.node, CommandV2.node, Reference.node, @@ -53,8 +57,6 @@ export const locationServices = LayerNode.group([ PluginInternal.node, ProjectCopy.node, ProjectCopy.refreshNode, - FileSystemSearch.node, - FileSystem.node, Watcher.node, Pty.node, SkillV2.node, @@ -69,6 +71,8 @@ export const locationServices = LayerNode.group([ Image.node, SkillGuidance.node, ReferenceGuidance.node, + KnowledgeRetrieval.node, + KnowledgeGuidance.node, SessionTodo.node, QuestionV2.node, ReadToolFileSystem.node, diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index 14b613678dbe..6f1a05971905 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -188,6 +188,30 @@ export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( return row !== undefined }) +export const peekPending = Effect.fn("SessionInput.peekPending")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, +) { + for (const delivery of ["steer", "queue"] as const) { + const row = yield* db + .select() + .from(SessionInputTable) + .where( + and( + eq(SessionInputTable.session_id, sessionID), + isNull(SessionInputTable.promoted_seq), + eq(SessionInputTable.delivery, delivery), + ), + ) + .orderBy(asc(SessionInputTable.admitted_seq)) + .limit(1) + .get() + .pipe(Effect.orDie) + if (row !== undefined) return fromRow(row) + } + return undefined +}) + export const equivalent = ( input: Admitted, expected: { diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 874086a06bdb..dac8ff23989b 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -22,6 +22,7 @@ import { SystemContext } from "../../system-context/index" import { SystemContextRegistry } from "../../system-context/registry" import { SkillGuidance } from "../../skill/guidance" import { ReferenceGuidance } from "../../reference/guidance" +import { KnowledgeGuidance } from "../../knowledge/guidance" import { ToolRegistry } from "../../tool/registry" import { ToolOutputStore } from "../../tool-output-store" import { SessionContextEpoch } from "../context-epoch" @@ -103,6 +104,7 @@ const layer = Layer.effect( const systemContext = yield* SystemContextRegistry.Service const skillGuidance = yield* SkillGuidance.Service const referenceGuidance = yield* ReferenceGuidance.Service + const knowledgeGuidance = yield* KnowledgeGuidance.Service const config = yield* Config.Service const snapshots = yield* Snapshot.Service const db = (yield* Database.Service).db @@ -165,10 +167,18 @@ const layer = Layer.effect( const continueAfterOverflowCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step }) - const loadSystemContext = (agent: AgentV2.Selection) => - Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], { - concurrency: "unbounded", - }).pipe(Effect.map(SystemContext.combine)) + const loadSystemContext = (sessionID: SessionSchema.ID, agent: AgentV2.Selection) => + Effect.all( + [ + systemContext.load(), + skillGuidance.load(agent), + referenceGuidance.load(), + knowledgeGuidance.load(sessionID), + ], + { + concurrency: "unbounded", + }, + ).pipe(Effect.map(SystemContext.combine)) const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( sessionID: SessionSchema.ID, @@ -180,7 +190,7 @@ const layer = Layer.effect( if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) return yield* Effect.interrupt const agent = yield* agents.select(session.agent) - const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id) + const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(session.id, agent), session.id) const toolFibers = yield* FiberSet.make() let needsContinuation = false let currentStep = step @@ -195,7 +205,7 @@ const layer = Layer.effect( if (promoted > 0) currentStep = 1 } const system = - initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id)) + initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(session.id, agent), session.id)) const model = yield* models.resolve(session) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) @@ -432,6 +442,7 @@ export const node = makeLocationNode({ SystemContextRegistry.node, SkillGuidance.node, ReferenceGuidance.node, + KnowledgeGuidance.node, Config.node, Snapshot.node, Database.node, diff --git a/packages/core/test/knowledge/first-turn.test.ts b/packages/core/test/knowledge/first-turn.test.ts new file mode 100644 index 000000000000..a8e729377664 --- /dev/null +++ b/packages/core/test/knowledge/first-turn.test.ts @@ -0,0 +1,309 @@ +import { describe, expect } from "bun:test" +import { + LLMClient, + LLMEvent, + Model, + type LLMClientShape, + type LLMRequest, +} from "@opencode-ai/llm" +import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" +import { Database } from "@opencode-ai/core/database/database" +import { makeLocationNode } from "@opencode-ai/core/effect/app-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { QuestionV2 } from "@opencode-ai/core/question" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Location } from "@opencode-ai/core/location" +import { SessionV2 } from "@opencode-ai/core/session" +import { Snapshot } from "@opencode-ai/core/snapshot" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" +import { SessionRunner } from "@opencode-ai/core/session/runner" +import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Config } from "@opencode-ai/core/config" +import { ConfigCompaction } from "@opencode-ai/core/config/compaction" +import { Tool } from "@opencode-ai/core/tool/tool" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionStore } from "@opencode-ai/core/session/store" +import { Prompt } from "@opencode-ai/core/session/prompt" +import { SystemContext } from "@opencode-ai/core/system-context" +import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" +import { SkillGuidance } from "@opencode-ai/core/skill/guidance" +import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" +import { KnowledgeGuidance } from "@opencode-ai/core/knowledge/guidance" +import { KnowledgeRetrieval } from "@opencode-ai/core/knowledge/retrieval" +import { Effect, Layer, Schema, Stream } from "effect" +import { testEffect } from "../lib/effect" + +// Phase 4A.1 Definition of Done: +// Brand New Session → First User Prompt → KnowledgeGuidance → Retrieved Knowledge → LLM +// with no pre-existing SessionHistory. KnowledgeGuidance is REAL here; only the +// retrieval engine is stubbed (deterministic docs + captured queries). + +const FLAG = "OPENCODE_EXPERIMENTAL_KNOWLEDGE" +const withFlag = (value: string | undefined, effect: Effect.Effect) => + Effect.flatMap(Effect.sync(() => process.env[FLAG]), (previous) => + Effect.suspend(() => { + if (value === undefined) delete process.env[FLAG] + else process.env[FLAG] = value + return effect + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env[FLAG] + else process.env[FLAG] = previous + }), + ), + ), + ) + +const requests: LLMRequest[] = [] +let response: LLMEvent[] = [] +const capturedQueries: string[] = [] +let stubDocs: ReadonlyArray = [] + +const client = Layer.succeed( + LLMClient.Service, + LLMClient.Service.of({ + prepare: () => Effect.die("unused"), + stream: ((request: LLMRequest) => { + requests.push(request) + return Stream.fromIterable(response) + }) as unknown as LLMClientShape["stream"], + generate: () => Effect.die("unused"), + }), +) +const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }) +const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) +const permission = Layer.succeed( + PermissionV2.Service, + PermissionV2.Service.of({ + assert: () => Effect.die("unused"), + ask: () => Effect.die("unused"), + reply: () => Effect.die("unused"), + get: () => Effect.die("unused"), + forSession: () => Effect.die("unused"), + list: () => Effect.die("unused"), + }), +) +const echo = Layer.effectDiscard( + ToolRegistry.Service.use((registry) => + registry.register({ + echo: Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }) => Effect.succeed({ text }), + }), + }), + ), +) +const echoNode = makeLocationNode({ name: "test/knowledge-first-turn-tools", layer: echo, deps: [ToolRegistry.node] }) +const systemContextKey = SystemContext.Key.make("test/context") +const systemContext = Layer.effectDiscard( + SystemContextRegistry.Service.pipe( + Effect.flatMap((registry) => + registry.register({ + key: systemContextKey, + load: Effect.sync(() => + SystemContext.make({ + key: systemContextKey, + codec: Schema.toCodecJson(Schema.String), + load: Effect.succeed("Initial context"), + baseline: String, + update: (_previous, current) => current, + removed: () => "System context source removed: test/context", + }), + ), + }), + ), + ), +).pipe(Layer.provideMerge(AppNodeBuilder.build(SystemContextRegistry.node))) +const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const retrievalMock = Layer.mock(KnowledgeRetrieval.Service, { + search: (query: string, topK: number) => + Effect.succeed(capturedQueries.push(query) > 0 ? stubDocs.slice(0, topK) : stubDocs.slice(0, topK)), +}) +const config = Layer.succeed( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: new Config.Info({ + compaction: new ConfigCompaction.Info({ buffer: 3000, keep: new ConfigCompaction.Keep({ tokens: 1000 }) }), + }), + }), + ]), + }), +) +const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ + [Snapshot.node, Snapshot.noopLayer], + [LayerNodePlatform.llmClient, client], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [KnowledgeRetrieval.node, retrievalMock], + [PermissionV2.node, permission], + [Config.node, config], +]) +const execution = Layer.effect( + SessionExecution.Service, + Effect.gen(function* () { + const sessionRunner = yield* SessionRunner.Service + const coordinator = yield* SessionRunCoordinator.make({ + drain: (sessionID, force) => sessionRunner.run({ sessionID, force }), + }) + return SessionExecution.Service.of({ + active: coordinator.active, + resume: coordinator.run, + wake: coordinator.wake, + interrupt: coordinator.interrupt, + }) + }), +).pipe(Layer.provide(runnerLayer)) +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + EventV2.node, + QuestionV2.node, + SessionProjector.node, + SessionStore.node, + ApplicationTools.node, + AgentV2.node, + ToolRegistry.node, + ToolRegistry.toolsNode, + echoNode, + SessionRunnerModel.node, + SystemContextRegistry.node, + SkillGuidance.node, + ReferenceGuidance.node, + KnowledgeRetrieval.node, + KnowledgeGuidance.node, + Config.node, + Snapshot.node, + SessionRunnerLLM.node, + SessionExecution.node, + SessionV2.node, + ]), + [ + [LayerNodePlatform.llmClient, client], + [PermissionV2.node, permission], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [KnowledgeRetrieval.node, retrievalMock], + [Snapshot.node, Snapshot.noopLayer], + [SessionExecution.node, execution], + [Config.node, config], + ], + ), +) + +const sessionID = SessionV2.ID.make("ses_knowledge_first_turn") +const flagOffSessionID = SessionV2.ID.make("ses_knowledge_first_turn_flagoff") + +const insertSession = (id: SessionV2.ID) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id, + project_id: Project.ID.global, + slug: id, + directory: "/project", + title: "first-turn proof", + version: "test", + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + }) + +const setup = Effect.gen(function* () { + response = [] + capturedQueries.length = 0 + stubDocs = [] + yield* insertSession(sessionID) + yield* insertSession(flagOffSessionID) +}) + +describe("Phase 4A.1 first-turn retrieval (DoD)", () => { + it.effect("delivers retrieved knowledge to the LLM on a brand new session first turn", () => + withFlag( + "1", + Effect.gen(function* () { + yield* setup + stubDocs = [ + { + title: "first-turn-doc", + section: "runner", + content: "admission before promotion", + similarity: 0.9, + }, + ] + const session = yield* SessionV2.Service + const promptText = "explain session runner architecture and prompt admission flow" + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: promptText }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + + expect(capturedQueries).toEqual([promptText]) + expect(requests.length).toBeGreaterThan(0) + expect(requests.at(-1)?.system.map((part) => part.text).join("\n\n")).toContain("") + expect(requests.at(-1)?.system.map((part) => part.text).join("\n\n")).toContain("first-turn-doc") + }), + ), + ) + + it.effect("sends no knowledge on the first turn when the flag is off", () => + withFlag( + undefined, + Effect.gen(function* () { + yield* setup + stubDocs = [ + { title: "unused", section: "runner", content: "unused", similarity: 0.9 }, + ] + const session = yield* SessionV2.Service + yield* session.prompt({ + sessionID: flagOffSessionID, + prompt: Prompt.make({ text: "explain session runner architecture and prompt admission flow" }), + resume: false, + }) + + requests.length = 0 + response = [] + yield* session.resume(flagOffSessionID) + + expect(capturedQueries).toEqual([]) + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"]) + }), + ), + ) +}) diff --git a/packages/core/test/knowledge/guidance.test.ts b/packages/core/test/knowledge/guidance.test.ts new file mode 100644 index 000000000000..3aa95edaeae5 --- /dev/null +++ b/packages/core/test/knowledge/guidance.test.ts @@ -0,0 +1,252 @@ +import { describe, expect } from "bun:test" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { KnowledgeGuidance } from "@opencode-ai/core/knowledge/guidance" +import { KnowledgeRetrieval } from "@opencode-ai/core/knowledge/retrieval" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { Prompt } from "@opencode-ai/core/session/prompt" +import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { SystemContext } from "@opencode-ai/core/system-context" +import { Effect, Layer, Schema } from "effect" +import { testEffect } from "../lib/effect" + +const FLAG = "OPENCODE_EXPERIMENTAL_KNOWLEDGE" + +const capturedQueries: string[] = [] +let stubDocs: ReadonlyArray = [] +const retrievalMock = Layer.mock(KnowledgeRetrieval.Service, { + search: (query: string, topK: number) => + Effect.succeed(capturedQueries.push(query) > 0 ? stubDocs.slice(0, topK) : stubDocs.slice(0, topK)), +}) + +const layer = AppNodeBuilder.build(LayerNode.group([Database.node, KnowledgeGuidance.node]), [ + [KnowledgeRetrieval.node, retrievalMock], +]) +const it = testEffect(layer) + +const withFlag = (value: string | undefined, effect: Effect.Effect) => + Effect.flatMap(Effect.sync(() => process.env[FLAG]), (previous) => + Effect.suspend(() => { + if (value === undefined) delete process.env[FLAG] + else process.env[FLAG] = value + return effect + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env[FLAG] + else process.env[FLAG] = previous + }), + ), + ), + ) + +const now = Date.now() +const encodePrompt = Schema.encodeSync(Prompt) + +const insertProject = Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: "global" as any, worktree: "/project" as any, sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) +}) + +const insertSession = (sessionID: string) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* insertProject + yield* db + .insert(SessionTable) + .values({ + id: sessionID as any, + project_id: "global" as any, + slug: sessionID, + directory: "/project" as any, + title: "knowledge guidance test", + version: "test", + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + }) + +let admittedSeq = 1000 +const insertPendingInput = (sessionID: string, text: string, delivery: "steer" | "queue", promoted = false) => + Effect.gen(function* () { + const { db } = yield* Database.Service + const seq = (admittedSeq += 1) + yield* db + .insert(SessionInputTable) + .values({ + id: SessionMessage.ID.create() as any, + session_id: sessionID as any, + prompt: encodePrompt(Prompt.make({ text })) as any, + delivery, + admitted_seq: seq, + ...(promoted ? { promoted_seq: seq } : {}), + }) + .run() + .pipe(Effect.orDie) + }) + +const insertUserMessage = (sessionID: string, text: string, seq: number) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(SessionMessageTable) + .values({ + id: SessionMessage.ID.create() as any, + session_id: sessionID as any, + type: "user" as any, + seq, + time_created: now, + time_updated: now, + data: { text, files: [], agents: [], time: { created: now } } as any, + }) + .run() + .pipe(Effect.orDie) + }) + +const doc = (title: string): KnowledgeRetrieval.Doc => ({ title, section: "test", content: "content", similarity: 0.9 }) + +const freshSession = () => SessionV2.ID.make(`ses_knowledge_${Date.now()}_${Math.floor(Math.random() * 1e9)}`) as string + +const baselineOf = (sessionID: string) => + Effect.gen(function* () { + const guidance = yield* KnowledgeGuidance.Service + return yield* guidance.load(sessionID as any).pipe(Effect.flatMap(SystemContext.initialize)) + }) + +describe("KnowledgeGuidance first-turn retrieval", () => { + it.effect("retrieves from pending steer on the first turn without history", () => + withFlag( + "1", + Effect.gen(function* () { + const sessionID = freshSession() + yield* insertSession(sessionID) + capturedQueries.length = 0 + stubDocs = [doc("pending-steer-doc")] + yield* insertPendingInput(sessionID, "explain session runner architecture and prompt admission", "steer") + + const generation = yield* baselineOf(sessionID) + + expect(capturedQueries).toEqual(["explain session runner architecture and prompt admission"]) + expect(generation.baseline).toContain("") + expect(generation.baseline).toContain("pending-steer-doc") + }), + ), + ) + + it.effect("prefers pending steer over pending queue", () => + withFlag( + "1", + Effect.gen(function* () { + const sessionID = freshSession() + yield* insertSession(sessionID) + capturedQueries.length = 0 + stubDocs = [doc("steer-doc")] + yield* insertPendingInput(sessionID, "queued older question about the database layer schema", "queue") + yield* insertPendingInput(sessionID, "explain session runner architecture and prompt admission", "steer") + + yield* baselineOf(sessionID) + + expect(capturedQueries).toEqual(["explain session runner architecture and prompt admission"]) + }), + ), + ) + + it.effect("falls back to pending queue when no steer is pending", () => + withFlag( + "1", + Effect.gen(function* () { + const sessionID = freshSession() + yield* insertSession(sessionID) + capturedQueries.length = 0 + stubDocs = [doc("queue-doc")] + yield* insertPendingInput(sessionID, "queued question about the database layer schema here", "queue") + + const generation = yield* baselineOf(sessionID) + + expect(capturedQueries).toEqual(["queued question about the database layer schema here"]) + expect(generation.baseline).toContain("") + }), + ), + ) + + it.effect("falls back to history when nothing is pending", () => + withFlag( + "1", + Effect.gen(function* () { + const sessionID = freshSession() + yield* insertSession(sessionID) + capturedQueries.length = 0 + stubDocs = [doc("history-doc")] + yield* insertUserMessage(sessionID, "explain session runner architecture and prompt admission", 1) + + const generation = yield* baselineOf(sessionID) + + expect(capturedQueries).toEqual(["explain session runner architecture and prompt admission"]) + expect(generation.baseline).toContain("") + }), + ), + ) + + it.effect("ignores promoted inputs and uses history", () => + withFlag( + "1", + Effect.gen(function* () { + const sessionID = freshSession() + yield* insertSession(sessionID) + capturedQueries.length = 0 + stubDocs = [doc("history-doc")] + yield* insertPendingInput(sessionID, "already promoted old steer text that should be ignored", "steer", true) + yield* insertUserMessage(sessionID, "explain session runner architecture and prompt admission", 2) + + yield* baselineOf(sessionID) + + expect(capturedQueries).toEqual(["explain session runner architecture and prompt admission"]) + }), + ), + ) + + it.effect("returns empty for a short pending query", () => + withFlag( + "1", + Effect.gen(function* () { + const sessionID = freshSession() + yield* insertSession(sessionID) + capturedQueries.length = 0 + stubDocs = [doc("unused")] + yield* insertPendingInput(sessionID, "fix it", "steer") + + const generation = yield* baselineOf(sessionID) + + expect(capturedQueries).toEqual([]) + expect(generation.baseline).toBe("") + }), + ), + ) + + it.effect("returns empty when the flag is off even with pending input", () => + withFlag( + undefined, + Effect.gen(function* () { + const sessionID = freshSession() + yield* insertSession(sessionID) + capturedQueries.length = 0 + stubDocs = [doc("unused")] + yield* insertPendingInput(sessionID, "explain session runner architecture and prompt admission", "steer") + + const generation = yield* baselineOf(sessionID) + + expect(capturedQueries).toEqual([]) + expect(generation.baseline).toBe("") + }), + ), + ) +}) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index d45cc8c73411..9f726d40027a 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -32,6 +32,7 @@ import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry import { SystemContext } from "@opencode-ai/core/system-context" import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" +import { KnowledgeGuidance } from "@opencode-ai/core/knowledge/guidance" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" @@ -71,6 +72,7 @@ const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) const systemContext = AppNodeBuilder.build(SystemContextRegistry.node) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const knowledgeGuidance = Layer.mock(KnowledgeGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], @@ -80,6 +82,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], [ReferenceGuidance.node, referenceGuidance], + [KnowledgeGuidance.node, knowledgeGuidance], [Config.node, config], [PermissionV2.node, permission], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], @@ -112,6 +115,7 @@ const it = testEffect( SystemContextRegistry.node, SkillGuidance.node, ReferenceGuidance.node, + KnowledgeGuidance.node, Config.node, Snapshot.node, SessionRunnerLLM.node, @@ -126,6 +130,7 @@ const it = testEffect( [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], [ReferenceGuidance.node, referenceGuidance], + [KnowledgeGuidance.node, knowledgeGuidance], [Config.node, config], [Snapshot.node, Snapshot.noopLayer], [SessionExecution.node, execution], diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index cc58b43b2957..f7b588040369 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -52,6 +52,7 @@ import { SystemContext } from "@opencode-ai/core/system-context" import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" +import { KnowledgeGuidance } from "@opencode-ai/core/knowledge/guidance" import { ModelV2 } from "@opencode-ai/core/model" import { Location } from "@opencode-ai/core/location" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -208,6 +209,22 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { ), }) const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const knowledgeBaselines = new Map() +const knowledgeGuidance = Layer.mock(KnowledgeGuidance.Service, { + load: (sessionID) => + Effect.succeed( + knowledgeBaselines.has(sessionID) + ? SystemContext.make({ + key: SystemContext.Key.make("test/knowledge-guidance"), + codec: Schema.toCodecJson(Schema.String), + load: Effect.succeed(knowledgeBaselines.get(sessionID)!), + baseline: String, + update: (_previous, current) => current, + removed: () => "Knowledge guidance removed", + }) + : SystemContext.empty, + ), +}) const config = Layer.succeed( Config.Service, Config.Service.of({ @@ -233,6 +250,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], [ReferenceGuidance.node, referenceGuidance], + [KnowledgeGuidance.node, knowledgeGuidance], [PermissionV2.node, permission], [Config.node, config], ]) @@ -268,6 +286,7 @@ const it = testEffect( SystemContextRegistry.node, SkillGuidance.node, ReferenceGuidance.node, + KnowledgeGuidance.node, Config.node, Snapshot.node, SessionRunnerLLM.node, @@ -282,6 +301,7 @@ const it = testEffect( [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], [ReferenceGuidance.node, referenceGuidance], + [KnowledgeGuidance.node, knowledgeGuidance], [Snapshot.node, Snapshot.noopLayer], [SessionExecution.node, execution], [Config.node, config], @@ -319,6 +339,7 @@ const setup = Effect.gen(function* () { modelResolveHook = Effect.void currentModel = model skillBaselines.clear() + knowledgeBaselines.clear() responses = undefined streamFailure = undefined responseStream = undefined @@ -878,6 +899,27 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("injects retrieved knowledge into the provider system baseline", () => + Effect.gen(function* () { + yield* setup + knowledgeBaselines.set(sessionID, "Retrieved session architecture") + const session = yield* SessionV2.Service + yield* session.prompt({ + sessionID, + prompt: Prompt.make({ text: "fix session bug in the runner" }), + resume: false, + }) + + requests.length = 0 + response = fragmentFixture("text", "text-knowledge", ["Done"]).completeEvents + yield* session.resume(sessionID) + + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([ + "Initial context\n\nRetrieved session architecture", + ]) + }), + ) + it.effect("keeps the sampled agent when selection changes during observation", () => Effect.gen(function* () { yield* setup From 31157f40b698b6189c0d356bf1270c6e8495cb39 Mon Sep 17 00:00:00 2001 From: AH Date: Sun, 13 Sep 2026 09:03:11 -0700 Subject: [PATCH 3/8] feat(cli): add governed memory review and admission commands --- bun.lock | 49 +- packages/opencode/package.json | 1 + packages/opencode/src/cli/cmd/learn.ts | 483 ++++++++++++++++++ packages/opencode/src/cli/cmd/project.ts | 138 +++++ packages/opencode/src/cli/cmd/search.ts | 70 +++ packages/opencode/src/effect/app-runtime.ts | 6 + packages/opencode/src/index.ts | 6 + .../opencode/test/cli/learn-memory.test.ts | 464 +++++++++++++++++ packages/opencode/test/preload.ts | 13 +- 9 files changed, 1192 insertions(+), 38 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/learn.ts create mode 100644 packages/opencode/src/cli/cmd/project.ts create mode 100644 packages/opencode/src/cli/cmd/search.ts create mode 100644 packages/opencode/test/cli/learn-memory.test.ts diff --git a/bun.lock b/bun.lock index 338f7f02342e..8a294f15a8d5 100644 --- a/bun.lock +++ b/bun.lock @@ -324,6 +324,7 @@ "@npmcli/config": "10.8.1", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", + "@opencode-ai/knowledge-engine": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", @@ -540,6 +541,15 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/knowledge-engine": { + "name": "@opencode-ai/knowledge-engine", + "version": "1.0.0", + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@types/node": "catalog:", + }, + }, "packages/llm": { "name": "@opencode-ai/llm", "version": "1.18.31", @@ -599,6 +609,7 @@ "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", "@opencode-ai/codemode": "workspace:*", + "@opencode-ai/knowledge-engine": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -1442,8 +1453,6 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], - "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="], "@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.12.0", "", { "dependencies": { "@bufbuild/protobuf": "2.12.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow=="], @@ -1978,6 +1987,8 @@ "@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"], + "@opencode-ai/knowledge-engine": ["@opencode-ai/knowledge-engine@workspace:packages/knowledge-engine"], + "@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], @@ -2994,8 +3005,6 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="], - "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@vitest/mocker": ["@vitest/mocker@4.1.7", "", { "dependencies": { "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA=="], @@ -3116,8 +3125,6 @@ "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], - "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg=="], - "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], @@ -3602,8 +3609,6 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="], @@ -4126,12 +4131,6 @@ "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], - "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], - - "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], - - "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], - "iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="], "iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="], @@ -4300,8 +4299,6 @@ "magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="], - "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], - "make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="], "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], @@ -6138,10 +6135,6 @@ "@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], - "@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], - - "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], - "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -6170,10 +6163,6 @@ "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="], - - "@vitest/coverage-v8/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], - "@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], "@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], @@ -6234,8 +6223,6 @@ "archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - "ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], "astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -6326,8 +6313,6 @@ "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], - "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], "esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -6378,8 +6363,6 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], - "istanbul-reports/html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], - "js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -7002,18 +6985,12 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], - "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], - "@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="], - "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 5e628b3c7055..7e37a69865ce 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -85,6 +85,7 @@ "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", "@opencode-ai/codemode": "workspace:*", + "@opencode-ai/knowledge-engine": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", diff --git a/packages/opencode/src/cli/cmd/learn.ts b/packages/opencode/src/cli/cmd/learn.ts new file mode 100644 index 000000000000..b757750077d0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/learn.ts @@ -0,0 +1,483 @@ +import type { CommandModule } from "yargs" +import { + CandidateStore, + LocalRetriever, + LocalVectorDB, + RetrievalEngine, + admit, + decide, + extractCandidates, + pendingReviews, + resolveCandidatesDbPath, + resolveKnowledgeDbPath, + reviewCandidate, + toReviewView, + type CandidateStatus, +} from "@opencode-ai/knowledge-engine" +import { resolve as resolvePath } from "path" +import { readFileSync } from "fs" +import { UI, Style } from "../ui" +import readline from "readline" + +interface LearnArgs { + topic?: string +} + +async function teachAboutTopic(retriever: RetrievalEngine, topic: string) { + UI.println(`${Style.TEXT_HIGHLIGHT_BOLD}\n🎓 التعلم عن: ${topic}\n${Style.TEXT_NORMAL}`) + + const [overview, examples, practice] = await Promise.all([ + retriever.search(topic, { topK: 2 }), + retriever.search(`مثال على ${topic}`, { topK: 2 }), + retriever.search(`تمرين ${topic}`, { topK: 1, type: "practice" }), + ]) + + UI.println(`${Style.TEXT_SUCCESS_BOLD}📖 ملخص الموضوع:${Style.TEXT_NORMAL}`) + if (overview.length > 0) { + overview.forEach((o) => UI.println(`\n${o.content}\n`)) + } else { + UI.println(`${Style.TEXT_DIM}لا يوجد ملخص مباشر متاح لهذا العنوان.${Style.TEXT_NORMAL}\n`) + } + + UI.println(`${Style.TEXT_SUCCESS_BOLD}💡 أمثلة عملية:${Style.TEXT_NORMAL}`) + if (examples.length > 0) { + examples.forEach((e) => UI.println(`\n${e.content}\n`)) + } else { + UI.println(`${Style.TEXT_DIM}راجع قسم الدروس والأدلة لمزيد من الأمثلة.${Style.TEXT_NORMAL}\n`) + } + + if (practice.length > 0) { + UI.println(`${Style.TEXT_SUCCESS_BOLD}🧪 تمرين تطبيقي:${Style.TEXT_NORMAL}`) + UI.println(`\n${practice[0].content}\n`) + } + + UI.println( + `${Style.TEXT_WARNING}💬 هل تريد معرفة المزيد؟ اسأل سؤالاً جديداً أو شغّل: opencode search "${topic}"\n${Style.TEXT_NORMAL}` + ) +} + +async function interactiveLearnMode(retriever: RetrievalEngine) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + UI.println(`${Style.TEXT_SUCCESS_BOLD}📚 المراحل والمواضيع المتاحة:\n${Style.TEXT_NORMAL}`) + UI.println(" 1. البدء السريع (المرحلة 1)") + UI.println(" 2. الاستخدام اليومي وسير العمل (المرحلة 2)") + UI.println(" 3. استخدام Plan و Build Agents (المرحلة 3)") + UI.println(" 4. المشاريع العملية وتطوير التطبيقات (المرحلة 4)") + UI.println(" 5. التخصيص المتقدم والنماذج الصينية (المرحلة 5)\n") + + const answer = await new Promise((resolve) => { + rl.question(`${Style.TEXT_HIGHLIGHT}اختر رقماً أو اكتب موضوعاً: ${Style.TEXT_NORMAL}`, (ans) => { + rl.close() + resolve(ans.trim()) + }) + }) + + const topics: Record = { + "1": "البدء السريع مع OpenCode", + "2": "الاستخدام اليومي وسير العمل", + "3": "استخدام Plan و Build Agents", + "4": "مشاريع عملية", + "5": "customization متقدم ونماذج صينية", + } + + const selectedTopic = topics[answer] || answer || "البدء السريع مع OpenCode" + await teachAboutTopic(retriever, selectedTopic) +} + +// --------------------------------------------------------------------------- +// Permanent memory commands (Phase 4B review/admission workflow). +// +// Every function below takes explicit database paths, returns printable text, +// and throws on refusal — handlers only print. Staging and knowledge handles +// are opened, used sequentially, and closed in a finally block per invocation. +// Review decisions and admission are separate commands by design: there is no +// approve-and-admit, and admission never changes staging state. +// --------------------------------------------------------------------------- + +export interface MemoryDbOptions { + /** Test-only override. Defaults to the approved staging resolution path. */ + staging?: string + /** Test-only override. Defaults to the approved knowledge resolution path. */ + knowledge?: string +} + +export function resolveMemoryPaths(options: MemoryDbOptions): { staging: string; knowledge: string } { + const staging = resolvePath(resolveCandidatesDbPath(options.staging)) + const knowledge = resolvePath(resolveKnowledgeDbPath(options.knowledge)) + if (staging === knowledge) { + throw new Error( + `Refusing: staging and knowledge database paths must differ (both resolved to ${staging}). ` + + `Staging is audit storage; knowledge is active retrieval storage.` + ) + } + return { staging, knowledge } +} + +export type CandidateFilter = CandidateStatus | "all" + +const STATUSES: ReadonlyArray = ["pending", "approved", "rejected", "superseded"] + +export async function learnProposeText( + options: MemoryDbOptions & { session: string; summaryFile?: string; summary?: string } +): Promise { + const session = options.session?.trim() ?? "" + if (session.length === 0) throw new Error("propose requires --session .") + const hasFile = (options.summaryFile ?? "").length > 0 + const hasInline = (options.summary ?? "").length > 0 + if (hasFile === hasInline) { + throw new Error('propose requires exactly one of --summary-file or --summary "".') + } + // Compaction summaries only. Missing files throw (no false success). + const summary = hasFile ? readFileSync(options.summaryFile as string, "utf8") : (options.summary as string) + const { staging } = resolveMemoryPaths({ staging: options.staging }) + const inputs = extractCandidates(summary, session) + const store = new CandidateStore(staging) + try { + // B2 gates already filtered secrets and episodic units. Everything staged + // stays pending: no approval, no admission, no knowledge.db writes here. + const ids = inputs.map((candidateInput) => store.create(candidateInput).id) + return [`extracted=${inputs.length}`, ...ids.map((id) => `candidate=${id}`), `staging=${staging}`].join("\n") + } finally { + store.close() + } +} + +export async function learnCandidatesText(options: MemoryDbOptions & { status?: CandidateFilter }): Promise { + const { staging } = resolveMemoryPaths(options) + const filter = options.status ?? "pending" + const store = new CandidateStore(staging) + try { + const views = + filter === "all" + ? store.list().map(toReviewView) + : filter === "pending" + ? pendingReviews(store) + : store.list({ status: filter }).map(toReviewView) + // Brief view only: id, status, type, title. No content, no summary — + // the list must never leak long text or secrets. + return views + .map((view) => { + const flag = view.canApprove ? "" : " ⚠ needs-attention" + return `- ${view.candidate.id} [${view.candidate.status}/${view.candidate.type}] ${view.candidate.title}${flag}` + }) + .join("\n") + } finally { + store.close() + } +} + +export async function learnShowText(options: MemoryDbOptions & { id: string }): Promise { + const { staging } = resolveMemoryPaths(options) + const store = new CandidateStore(staging) + try { + // Read-only: reviewCandidate never mutates state. + return JSON.stringify(reviewCandidate(store, options.id), null, 2) + } finally { + store.close() + } +} + +export async function learnApproveText( + options: MemoryDbOptions & { id: string; note?: string } +): Promise { + const { staging } = resolveMemoryPaths(options) + const store = new CandidateStore(staging) + try { + // decide() enforces pending-only plus B0 validity; reasons surface on refusal. + const updated = decide(store, options.id, { action: "approve", note: options.note ?? "" }) + return `approved ${updated.id}` + } finally { + store.close() + } +} + +export async function learnRejectText( + options: MemoryDbOptions & { id: string; reason: string } +): Promise { + if (options.reason.trim().length === 0) throw new Error("Rejecting a candidate requires a non-empty --reason.") + const { staging } = resolveMemoryPaths(options) + const store = new CandidateStore(staging) + try { + const updated = decide(store, options.id, { action: "reject", reason: options.reason }) + return `rejected ${updated.id}` + } finally { + store.close() + } +} + +export async function learnSupersedeText( + options: MemoryDbOptions & { oldId: string; newId: string } +): Promise { + const { staging } = resolveMemoryPaths(options) + const store = new CandidateStore(staging) + try { + // decide() enforces: both approved, distinct ids. No implicit admission. + const updated = decide(store, options.oldId, { action: "supersede", byId: options.newId }) + return `superseded ${updated.id} -> ${updated.supersededBy}` + } finally { + store.close() + } +} + +export async function learnAdmitText(options: MemoryDbOptions & { id: string }): Promise { + const { staging, knowledge } = resolveMemoryPaths(options) + const store = new CandidateStore(staging) + const db = new LocalVectorDB(knowledge) + try { + // admit() enforces approved-only, verifies persistence and retrieval, + // and compensates post-write failures. It never mutates staging. + const result = await admit({ store, knowledge: db }, options.id) + return JSON.stringify(result, null, 2) + } finally { + store.close() + db.close() + } +} + +export async function learnRetrieveText( + options: MemoryDbOptions & { query: string; topK?: number } +): Promise { + const { knowledge } = resolveMemoryPaths(options) + const db = new LocalVectorDB(knowledge) + try { + const results = await new LocalRetriever(db).retrieveRelevant(options.query, options.topK ?? 5) + if (results.length === 0) return "results=0" + return [ + ...results.flatMap((result, index) => { + const snippet = + result.content.length > 300 ? result.content.substring(0, 300) + "..." : result.content + return [`#${index + 1} [${result.similarity}] ${result.title} (${result.id})`, ` ${snippet}`] + }), + `results=${results.length}`, + ].join("\n") + } finally { + db.close() + } +} + +export async function learnStatusText(options: MemoryDbOptions): Promise { + const { staging, knowledge } = resolveMemoryPaths(options) + const store = new CandidateStore(staging) + try { + const counts: Record = { pending: 0, approved: 0, rejected: 0, superseded: 0 } + let extracted = 0 + for (const candidate of store.list()) { + extracted += 1 + counts[candidate.status] += 1 + } + const db = new LocalVectorDB(knowledge) + try { + // indexed_corpus_chunks counts every indexed row (legacy corpus included). + // admitted_memory_chunks counts only governed admissions, recognized by + // the candidate admission provenance B4 stamps on every admitted chunk. + // The old `admitted` key (total rows) was misleading and is removed. + const admittedMemory = db.countBySourcePrefix("candidate:") + const indexed = db.getStats().totalChunks + return [ + `extracted=${extracted}`, + `pending=${counts.pending}`, + `approved=${counts.approved}`, + `rejected=${counts.rejected}`, + `superseded=${counts.superseded}`, + `indexed_corpus_chunks=${indexed}`, + `admitted_memory_chunks=${admittedMemory}`, + `admission_failures=untracked`, + `admitted_retrieved_later=untracked`, + ].join("\n") + } finally { + db.close() + } + } finally { + store.close() + } +} + +const STAGING_OPTION = { + describe: "staging database path (test override only; defaults to the approved staging path)", + type: "string", +} as const + +const KNOWLEDGE_OPTION = { + describe: "knowledge database path (test override only; defaults to the approved knowledge path)", + type: "string", +} as const + +export const LearnProposeCommand = { + command: "propose", + describe: "propose candidates from a compaction summary into staging (all stay pending)", + builder: (yargs) => + yargs + .option("staging", STAGING_OPTION) + .option("session", { describe: "source session id (required)", type: "string", demandOption: true }) + .option("summary-file", { describe: "path to a compaction summary file", type: "string" }) + .option("summary", { describe: "compaction summary text", type: "string" }), + handler: async (args) => { + UI.println( + await learnProposeText({ + staging: args.staging, + session: args.session as string, + summaryFile: args["summary-file"] as string | undefined, + summary: args.summary as string | undefined, + }) + ) + }, +} satisfies CommandModule + +export const LearnCandidatesCommand = { + command: "candidates", + describe: "list staged knowledge candidates (pending by default)", + builder: (yargs) => + yargs + .option("staging", STAGING_OPTION) + .option("status", { + describe: "filter by review status", + type: "string", + choices: ["pending", "approved", "rejected", "superseded", "all"], + default: "pending", + }), + handler: async (args) => { + UI.println( + await learnCandidatesText({ + staging: args.staging, + status: args.status as CandidateFilter | undefined, + }) + ) + }, +} satisfies CommandModule + +export const LearnShowCommand = { + command: "show ", + describe: "show one staged candidate in full (read-only)", + builder: (yargs) => + yargs + .option("staging", STAGING_OPTION) + .positional("id", { describe: "candidate id", type: "string", demandOption: true }), + handler: async (args) => { + UI.println(await learnShowText({ staging: args.staging, id: args.id as string })) + }, +} satisfies CommandModule + +export const LearnApproveCommand = { + command: "approve ", + describe: "approve a pending candidate (review decision only; does not admit)", + builder: (yargs) => + yargs + .option("staging", STAGING_OPTION) + .positional("id", { describe: "candidate id", type: "string", demandOption: true }) + .option("note", { describe: "reviewer note", type: "string", default: "" }), + handler: async (args) => { + UI.println(await learnApproveText({ staging: args.staging, id: args.id as string, note: args.note })) + }, +} satisfies CommandModule + +export const LearnRejectCommand = { + command: "reject ", + describe: "reject a pending candidate with a required reason", + builder: (yargs) => + yargs + .option("staging", STAGING_OPTION) + .positional("id", { describe: "candidate id", type: "string", demandOption: true }) + .option("reason", { describe: "rejection reason (required)", type: "string", demandOption: true }), + handler: async (args) => { + UI.println(await learnRejectText({ staging: args.staging, id: args.id as string, reason: args.reason as string })) + }, +} satisfies CommandModule + +export const LearnSupersedeCommand = { + command: "supersede ", + describe: "mark an approved candidate superseded by another approved candidate", + builder: (yargs) => + yargs + .option("staging", STAGING_OPTION) + .positional("old-id", { describe: "superseded candidate id", type: "string", demandOption: true }) + .positional("new-id", { describe: "replacement candidate id", type: "string", demandOption: true }), + handler: async (args) => { + UI.println( + await learnSupersedeText({ + staging: args.staging, + oldId: args["old-id"] as string, + newId: args["new-id"] as string, + }) + ) + }, +} satisfies CommandModule + +export const LearnAdmitCommand = { + command: "admit ", + describe: "admit an approved candidate into knowledge (verifies persistence and retrieval)", + builder: (yargs) => + yargs + .option("staging", STAGING_OPTION) + .option("knowledge", KNOWLEDGE_OPTION) + .positional("id", { describe: "candidate id", type: "string", demandOption: true }), + handler: async (args) => { + UI.println(await learnAdmitText({ staging: args.staging, knowledge: args.knowledge, id: args.id as string })) + }, +} satisfies CommandModule + +export const LearnRetrieveCommand = { + command: "retrieve", + describe: "retrieve approved knowledge by query", + builder: (yargs) => + yargs + .option("staging", STAGING_OPTION) + .option("knowledge", KNOWLEDGE_OPTION) + .option("query", { describe: "natural-language query", type: "string", demandOption: true }) + .option("top-k", { describe: "max results", type: "number", default: 5 }), + handler: async (args) => { + UI.println( + await learnRetrieveText({ + staging: args.staging, + knowledge: args.knowledge, + query: args.query as string, + topK: args["top-k"] as number | undefined, + }) + ) + }, +} satisfies CommandModule + +export const LearnStatusCommand = { + command: "status", + describe: "operational counters for staging and knowledge", + builder: (yargs) => yargs.option("staging", STAGING_OPTION).option("knowledge", KNOWLEDGE_OPTION), + handler: async (args) => { + UI.println(await learnStatusText({ staging: args.staging, knowledge: args.knowledge })) + }, +} satisfies CommandModule + +export const LearnCommand = { + command: "learn [topic]", + describe: "📚 interactive knowledge learning mode", + builder: (yargs) => + yargs + .positional("topic", { + describe: "topic or concept to learn about (optional)", + type: "string", + }) + .command(LearnProposeCommand) + .command(LearnCandidatesCommand) + .command(LearnShowCommand) + .command(LearnApproveCommand) + .command(LearnRejectCommand) + .command(LearnSupersedeCommand) + .command(LearnAdmitCommand) + .command(LearnRetrieveCommand) + .command(LearnStatusCommand), + handler: async (args) => { + const retriever = new RetrievalEngine() + + UI.println( + `${Style.TEXT_HIGHLIGHT_BOLD}╔════════════════════════════════════════════╗\n║ 📚 وضع التعلم التفاعلي - OpenCode ║\n╚════════════════════════════════════════════╝${Style.TEXT_NORMAL}` + ) + + if (args.topic) { + await teachAboutTopic(retriever, args.topic) + } else { + await interactiveLearnMode(retriever) + } + }, +} satisfies CommandModule diff --git a/packages/opencode/src/cli/cmd/project.ts b/packages/opencode/src/cli/cmd/project.ts new file mode 100644 index 000000000000..5e47c1c4a78d --- /dev/null +++ b/packages/opencode/src/cli/cmd/project.ts @@ -0,0 +1,138 @@ +import type { CommandModule } from "yargs" +import { RetrievalEngine } from "@opencode-ai/knowledge-engine" +import { UI, Style } from "../ui" +import { mkdirSync, writeFileSync } from "fs" +import path from "path" +import readline from "readline" + +interface ProjectArgs { + name: string + type?: string + difficulty?: string +} + +export const ProjectCommand = { + command: "project ", + describe: "🎯 bootstrap a new project with embedded knowledge guidance", + builder: (yargs) => + yargs + .positional("name", { + describe: "project directory name", + type: "string", + demandOption: true, + }) + .option("type", { + alias: "t", + describe: "project domain/type (coding, writing, automation)", + type: "string", + }) + .option("difficulty", { + alias: "d", + describe: "difficulty level (easy, medium, hard)", + type: "string", + }), + handler: async (args) => { + const retriever = new RetrievalEngine() + UI.println(`${Style.TEXT_HIGHLIGHT_BOLD}\n🎯 إنشاء مشروع جديد: ${args.name}\n${Style.TEXT_NORMAL}`) + + let projectType = args.type + let difficulty = args.difficulty + + if (!projectType || !difficulty) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + if (!projectType) { + projectType = await new Promise((resolve) => { + UI.println(`${Style.TEXT_SUCCESS}أنواع المشاريع:${Style.TEXT_NORMAL}`) + UI.println(" 1. 💻 برمجة (Coding)") + UI.println(" 2. ✍️ كتابة وتوثيق (Documentation)") + UI.println(" 3. 📊 أتمتة وتكامل (Automation)") + rl.question(`${Style.TEXT_HIGHLIGHT}اختر نوع المشروع [1]: ${Style.TEXT_NORMAL}`, (ans) => { + const types: Record = { + "1": "برمجة تطوير برمجيات Coding", + "2": "توثيق وكتابة Documentation", + "3": "أتمتة وسير عمل Automation", + } + resolve(types[ans.trim()] || types["1"]) + }) + }) + } + + if (!difficulty) { + difficulty = await new Promise((resolve) => { + UI.println(`${Style.TEXT_SUCCESS}مستوى الصعوبة:${Style.TEXT_NORMAL}`) + UI.println(" 1. سهل (Beginner)") + UI.println(" 2. متوسط (Intermediate)") + UI.println(" 3. متقدم (Advanced)") + rl.question(`${Style.TEXT_HIGHLIGHT}اختر المستوى [2]: ${Style.TEXT_NORMAL}`, (ans) => { + const diffs: Record = { + "1": "سهل للمبتدئين", + "2": "متوسط", + "3": "متقدم ومعقد", + } + resolve(diffs[ans.trim()] || diffs["2"]) + }) + }) + } + + rl.close() + } + + UI.println( + `${Style.TEXT_DIM}\n⏳ جاري جمع أفضل الممارسات والتوجيهات من محرك المعرفة...\n${Style.TEXT_NORMAL}` + ) + + const guidanceResults = await retriever.search(`${projectType} ${difficulty}`, { topK: 5 }) + + const projectDir = path.resolve(process.cwd(), args.name) + mkdirSync(projectDir, { recursive: true }) + + const guidanceBody = guidanceResults + .map( + (g, i) => `### ${i + 1}. ${g.title} (${g.section}) +- **النوع:** ${g.type} +- **المصدر:** ${g.source} +- **درجة التطابق:** ${(g.similarity * 100).toFixed(0)}% + +${g.content} +` + ) + .join("\n---\n\n") + + const fileContent = `# 🎯 توجيهات مشروع: ${args.name} + +- **النوع:** ${projectType} +- **المستوى:** ${difficulty} +- **تاريخ الإنشاء:** ${new Date().toISOString()} +- **مولد بواسطة:** OpenCode Knowledge Engine + +--- + +## 📋 التوجيهات الهندسية وأفضل الممارسات المسترجعة + +${guidanceBody} + +--- + +## 🚀 كيفية البدء + +\`\`\`bash +cd ${args.name} +opencode . +\`\`\` +` + + const guidancePath = path.join(projectDir, "GUIDANCE.md") + writeFileSync(guidancePath, fileContent, "utf-8") + + UI.println(`${Style.TEXT_SUCCESS_BOLD}✅ تم إنشاء هيكل المشروع وتوجيهاته بنجاح:${Style.TEXT_NORMAL}`) + UI.println(` 📁 ${projectDir}`) + UI.println(` 📄 ${guidancePath}\n`) + UI.println(`${Style.TEXT_HIGHLIGHT}🚀 الخطوة التالية:${Style.TEXT_NORMAL}`) + UI.println(` cd ${args.name}`) + UI.println(` opencode .\n`) + }, +} satisfies CommandModule diff --git a/packages/opencode/src/cli/cmd/search.ts b/packages/opencode/src/cli/cmd/search.ts new file mode 100644 index 000000000000..7f98d554832f --- /dev/null +++ b/packages/opencode/src/cli/cmd/search.ts @@ -0,0 +1,70 @@ +import type { CommandModule } from "yargs" +import { RetrievalEngine } from "@opencode-ai/knowledge-engine" +import { UI, Style } from "../ui" + +interface SearchArgs { + query: string + type?: string + stage?: number + topK?: number +} + +export const SearchCommand = { + command: "search ", + describe: "🔍 search the local embedded knowledge base", + builder: (yargs) => + yargs + .positional("query", { + describe: "search query or topic", + type: "string", + demandOption: true, + }) + .option("type", { + alias: "t", + describe: "content type (lesson, prompt, practice, troubleshooting)", + type: "string", + choices: ["lesson", "prompt", "practice", "troubleshooting"], + }) + .option("stage", { + alias: "s", + describe: "educational stage (1-5)", + type: "number", + }) + .option("topK", { + alias: "k", + describe: "number of results to return", + type: "number", + default: 5, + }), + handler: async (args) => { + const retriever = new RetrievalEngine() + UI.println(`${Style.TEXT_HIGHLIGHT}\n🔍 جاري البحث عن: "${args.query}"\n${Style.TEXT_NORMAL}`) + + const results = await retriever.search(args.query, { + topK: args.topK, + type: args.type as any, + stage: args.stage, + }) + + if (results.length === 0) { + UI.println(`${Style.TEXT_WARNING}❌ لم أجد نتائج. جرّب كلمات أخرى.\n${Style.TEXT_NORMAL}`) + return + } + + UI.println(`${Style.TEXT_SUCCESS_BOLD}✅ وجدت ${results.length} نتيجة:\n${Style.TEXT_NORMAL}`) + + results.forEach((result, i) => { + UI.println(`${Style.TEXT_HIGHLIGHT_BOLD}${i + 1}. ${result.title}${Style.TEXT_NORMAL}`) + UI.println( + `${Style.TEXT_DIM} القسم: ${result.section} | النوع: ${result.type} | المرحلة: ${result.stage} | تطابق: ${(result.similarity * 100).toFixed(0)}%${Style.TEXT_NORMAL}` + ) + UI.println(`${Style.TEXT_DIM} المصدر: ${result.source}${Style.TEXT_NORMAL}`) + const snippet = result.content.length > 300 ? result.content.substring(0, 300) + "..." : result.content + UI.println(`\n ${snippet}\n`) + }) + + UI.println( + `${Style.TEXT_DIM}💡 هل تريد استخدام هذه المعرفة؟ يمكنك توجيه الوكيل الذكي: opencode-agent "${args.query}"\n${Style.TEXT_NORMAL}` + ) + }, +} satisfies CommandModule diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index d17326966f92..db5097221d12 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -133,3 +133,9 @@ export const AppRuntime: Runtime = { }, dispose: () => rt.dispose(), } + +// Module-load marker so test teardown can skip the expensive first import +// when no test ever touched the singleton (pure CLI tests time out otherwise). +// Read by test/preload.ts without importing this module. +;(globalThis as typeof globalThis & { __OPENCODE_APP_RUNTIME_LOADED?: boolean }).__OPENCODE_APP_RUNTIME_LOADED = + true diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 13540a73a36f..49f0355591f8 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -28,6 +28,9 @@ import { SessionCommand } from "./cli/cmd/session" import { DbCommand } from "./cli/cmd/db" import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" +import { SearchCommand } from "./cli/cmd/search" +import { LearnCommand } from "./cli/cmd/learn" +import { ProjectCommand } from "./cli/cmd/project" import { Heap } from "./cli/heap" const args = hideBin(process.argv) @@ -101,6 +104,9 @@ const cli = yargs(args) .command(SessionCommand) .command(PluginCommand) .command(DbCommand) + .command(SearchCommand) + .command(LearnCommand) + .command(ProjectCommand) .fail((msg, err) => { if ( msg?.startsWith("Unknown argument") || diff --git a/packages/opencode/test/cli/learn-memory.test.ts b/packages/opencode/test/cli/learn-memory.test.ts new file mode 100644 index 000000000000..b341a49b45ee --- /dev/null +++ b/packages/opencode/test/cli/learn-memory.test.ts @@ -0,0 +1,464 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { existsSync, unlinkSync, writeFileSync } from "fs" +import yargs from "yargs" +import { CandidateStore } from "@opencode-ai/knowledge-engine" +import { LocalEmbedder } from "@opencode-ai/knowledge-engine" +import { LocalVectorDB } from "@opencode-ai/knowledge-engine" +import type { KnowledgeChunk } from "@opencode-ai/knowledge-engine" +import { + LearnCommand, + learnAdmitText, + learnApproveText, + learnCandidatesText, + learnProposeText, + learnRejectText, + learnRetrieveText, + learnShowText, + learnStatusText, + learnSupersedeText, + resolveMemoryPaths, +} from "../../src/cli/cmd/learn" + +const stagings: string[] = [] +const knowledges: string[] = [] +const looseFiles: string[] = [] +function tempDb(prefix: string): string { + const path = `/tmp/test_learn_${prefix}_${Date.now()}_${Math.floor(Math.random() * 1e9)}.db` + if (prefix === "staging") stagings.push(path) + else knowledges.push(path) + return path +} + +function cleanup(path: string): void { + for (const file of [path, `${path}-wal`, `${path}-shm`, `${path}-journal`]) { + try { + if (existsSync(file)) unlinkSync(file) + } catch { + // best-effort cleanup + } + } +} + +afterEach(() => { + for (const path of [...stagings.splice(0), ...knowledges.splice(0), ...looseFiles.splice(0)]) cleanup(path) +}) + +const input = { + title: "SessionRunner initializes epoch before promotion", + summary: "Session fixed admission-vs-execution ordering.", + content: "SessionRunner initializes the context epoch before promoting steers in the runner lifecycle.", + sourceSession: "ses_learn1", +} + +function seed(statuses: ReadonlyArray<"pending" | "approved" | "rejected"> = ["pending"]): { + staging: string + knowledge: string + ids: string[] +} { + const staging = tempDb("staging") + const knowledge = tempDb("knowledge") + const store = new CandidateStore(staging) + try { + const ids: string[] = [] + statuses.forEach((status, index) => { + const created = store.create({ ...input, title: `Rule ${index}`, content: `reviewable content number ${index}` }) + if (status === "approved") store.approve(created.id, "reviewed") + if (status === "rejected") store.reject(created.id, "not generalizable") + ids.push(created.id) + }) + return { staging, knowledge, ids } + } finally { + store.close() + } +} + +describe("learn memory commands", () => { + test("A. candidates lists pending by default and filters by status", async () => { + const { staging, ids } = seed(["pending", "approved", "rejected"]) + const brief = await learnCandidatesText({ staging }) + expect(brief.split("\n").filter((line) => line.startsWith("- "))).toHaveLength(1) + expect(brief).toContain(ids[0]) + expect(brief).not.toContain(ids[1]) + const all = await learnCandidatesText({ staging, status: "all" }) + expect(all.split("\n").filter((line) => line.startsWith("- "))).toHaveLength(3) + const approved = await learnCandidatesText({ staging, status: "approved" }) + expect(approved).toContain(ids[1]) + expect(approved).not.toContain(ids[0]) + }) + + test("A2. brief view never prints content or summaries", async () => { + const { staging } = seed(["pending"]) + const brief = await learnCandidatesText({ staging }) + expect(brief).not.toContain("reviewable content number 0") + }) + + test("B. show is read-only and prints the full record", async () => { + const { staging, ids } = seed(["pending"]) + const before = new CandidateStore(staging) + const snapshot = before.get(ids[0]) + before.close() + const shown = JSON.parse(await learnShowText({ staging, id: ids[0] })) + expect(shown.candidate.id).toBe(ids[0]) + expect(shown.candidate.provenance.sourceSession).toBe("ses_learn1") + expect(shown.reviewable).toBe(true) + const after = new CandidateStore(staging) + try { + expect(after.get(ids[0])).toEqual(snapshot) + } finally { + after.close() + } + await expect(learnShowText({ staging, id: "cand-missing" })).rejects.toThrow(/not found/) + }) + + test("C. approve moves pending to approved only", async () => { + const { staging, ids } = seed(["pending", "rejected"]) + expect(await learnApproveText({ staging, id: ids[0], note: "verified" })).toBe(`approved ${ids[0]}`) + await expect(learnApproveText({ staging, id: ids[0] })).rejects.toThrow() + await expect(learnApproveText({ staging, id: ids[1] })).rejects.toThrow() + }) + + test("D. reject requires a non-empty reason", async () => { + const { staging, ids } = seed(["pending"]) + expect(await learnRejectText({ staging, id: ids[0], reason: "too narrow" })).toBe(`rejected ${ids[0]}`) + const { staging: staging2, ids: ids2 } = seed(["pending"]) + await expect(learnRejectText({ staging: staging2, id: ids2[0], reason: " " })).rejects.toThrow(/reason/) + }) + + test("E. supersede refuses pending, missing, and self replacement", async () => { + const { staging, ids } = seed(["pending"]) + const store = new CandidateStore(staging) + const replacement = store.create({ ...input, title: "Replacement", content: "replacement content here" }) + store.close() + await expect(learnSupersedeText({ staging, oldId: ids[0], newId: replacement.id })).rejects.toThrow() + await expect(learnSupersedeText({ staging, oldId: ids[0], newId: "cand-missing" })).rejects.toThrow() + await expect(learnSupersedeText({ staging, oldId: ids[0], newId: ids[0] })).rejects.toThrow() + await learnApproveText({ staging, id: ids[0], note: "incumbent" }) + await learnApproveText({ staging, id: replacement.id, note: "better" }) + expect(await learnSupersedeText({ staging, oldId: ids[0], newId: replacement.id })).toBe( + `superseded ${ids[0]} -> ${replacement.id}` + ) + }) + + test("F. admit refuses every non-approved status", async () => { + const { staging, knowledge, ids } = seed(["pending", "approved", "rejected"]) + for (const id of [ids[0], ids[2]]) { + await expect(learnAdmitText({ staging, knowledge, id })).rejects.toThrow(/must be approved/) + } + const db = new LocalVectorDB(knowledge) + try { + expect(db.getStats().totalChunks).toBe(0) + } finally { + db.close() + } + }) + + test("G. admit accepts approved and prints the receipt", async () => { + const { staging, knowledge, ids } = seed(["approved"]) + const receipt = JSON.parse(await learnAdmitText({ staging, knowledge, id: ids[0] })) + expect(receipt.receipt.status).toBe("admitted") + expect(receipt.receipt.candidateId).toBe(ids[0]) + }) + + test("H. failed admission claims no success and leaves staging intact", async () => { + const { staging, ids } = seed(["approved"]) + const store = new CandidateStore(staging) + const before = store.get(ids[0]) + store.close() + // Unwritable knowledge location: the write path fails before any claim. + await expect( + learnAdmitText({ staging, knowledge: "/nonexistent-dir-4b-test/knowledge.db", id: ids[0] }) + ).rejects.toThrow() + const after = new CandidateStore(staging) + try { + expect(after.get(ids[0])).toEqual(before) + } finally { + after.close() + } + }) + + test("I. repeated admit does not duplicate knowledge", async () => { + const { staging, knowledge, ids } = seed(["approved"]) + await learnAdmitText({ staging, knowledge, id: ids[0] }) + await learnAdmitText({ staging, knowledge, id: ids[0] }) + const db = new LocalVectorDB(knowledge) + try { + expect(db.getStats().totalChunks).toBe(1) + } finally { + db.close() + } + }) + + test("J. identical staging and knowledge paths are refused", () => { + const staging = tempDb("staging") + expect(() => resolveMemoryPaths({ staging, knowledge: staging })).toThrow(/must differ/) + }) + + test("K. sequential invocations share files cleanly (no leaked handles)", async () => { + const { staging, ids } = seed(["pending"]) + await learnApproveText({ staging, id: ids[0], note: "first" }) + await learnShowText({ staging, id: ids[0] }) + await learnCandidatesText({ staging, status: "all" }) + await learnStatusText({ staging, knowledge: tempDb("knowledge") }) + const store = new CandidateStore(staging) + try { + expect(store.get(ids[0])?.status).toBe("approved") + } finally { + store.close() + } + }) + + test("L. full cycle: candidates → show → approve → admit → fresh handles retrieve", async () => { + const staging = tempDb("staging") + const knowledge = tempDb("knowledge") + const store = new CandidateStore(staging) + const id = store.create(input).id + store.close() + + expect((await learnCandidatesText({ staging })).split("\n")).toHaveLength(1) + expect(JSON.parse(await learnShowText({ staging, id })).candidate.id).toBe(id) + await learnApproveText({ staging, id, note: "e2e verified" }) + const receipt = JSON.parse(await learnAdmitText({ staging, knowledge, id })) + expect(receipt.receipt.status).toBe("admitted") + + // Fresh handles, as a new process would open them. + const found = await learnRetrieveText({ knowledge, query: "session runner epoch promotion order" }) + expect(found).toContain(id) + const status = await learnStatusText({ staging, knowledge }) + expect(status).toContain("approved=1") + expect(status).toContain("indexed_corpus_chunks=1") + expect(status).toContain("admitted_memory_chunks=1") + }) + + function helpFor(args: ReadonlyArray): Promise { + return new Promise((resolve, reject) => { + yargs() + .command(LearnCommand) + .strict() + .exitProcess(false) + .parse([...args], (error: unknown, _argv: unknown, output: string) => { + if (error) reject(error) + else resolve(output ?? "") + }) + }) + } + + test("routing: subcommand help wins for reserved names, legacy topic preserved", async () => { + // No handler runs under --help on either path, so this cannot hang or touch databases. + expect(await helpFor(["learn", "candidates", "--help"])).toContain("--status") + expect(await helpFor(["learn", "--help"])).toContain("topic") + }) +}) + +function writeSummary(text: string): string { + const path = `/tmp/test_learn_summary_${Date.now()}_${Math.floor(Math.random() * 1e9)}.md` + looseFiles.push(path) + writeFileSync(path, text) + return path +} + +function seedKnowledge(knowledge: string, sources: ReadonlyArray): void { + const db = new LocalVectorDB(knowledge) + try { + const embedder = new LocalEmbedder() + sources.forEach((source, index) => { + const chunk: KnowledgeChunk = { + id: `chunk-seed-${index}`, + title: `Seed document ${index}`, + stage: 1, + section: "seed", + content: `seed corpus content number ${index} about the runner architecture`, + metadata: { source, type: "lesson", tags: [], language: "mixed", difficulty: 1 }, + } + db.upsertChunk(chunk, embedder.embed(chunk.content)) + }) + } finally { + db.close() + } +} + +function knowledgeCount(knowledge: string): number { + const db = new LocalVectorDB(knowledge) + try { + return db.getStats().totalChunks + } finally { + db.close() + } +} + +const GOOD_BULLET = "SessionRunner initializes the context epoch before promoting steers in the runner lifecycle" +const SECOND_BULLET = "Provider turn allowance resets once per steer batch in the session coordinator" +const SECRET_BULLET = "Deploy with password: s3cr3t-hunter2 value set for staging" +const EPISODIC_BULLET = "User asked to retry the flaky command three times" + +describe("learn propose intake", () => { + test("A. propose from --summary-file stages pending candidates", async () => { + const staging = tempDb("staging") + const file = writeSummary(`## Findings\n- ${GOOD_BULLET}\n- ${SECOND_BULLET}`) + const output = await learnProposeText({ staging, session: "ses_intake1", summaryFile: file }) + expect(output).toContain("extracted=2") + expect(output).toContain(`staging=${staging}`) + const store = new CandidateStore(staging) + try { + const listed = store.list() + expect(listed).toHaveLength(2) + expect(listed.every((candidate) => candidate.status === "pending")).toBe(true) + } finally { + store.close() + } + }) + + test("B. propose from --summary works", async () => { + const staging = tempDb("staging") + const output = await learnProposeText({ staging, session: "ses_intake2", summary: GOOD_BULLET }) + expect(output).toContain("extracted=1") + expect(output).toMatch(/candidate=cand-[0-9a-f]{8}/) + }) + + test("C. both summary sources together are refused", async () => { + const staging = tempDb("staging") + const file = writeSummary(GOOD_BULLET) + await expect( + learnProposeText({ staging, session: "ses_intake3", summaryFile: file, summary: GOOD_BULLET }) + ).rejects.toThrow(/exactly one/) + }) + + test("D. neither summary source is refused", async () => { + const staging = tempDb("staging") + await expect(learnProposeText({ staging, session: "ses_intake4" })).rejects.toThrow(/exactly one/) + }) + + test("D2. missing summary file fails loudly, never silently empty", async () => { + const staging = tempDb("staging") + await expect( + learnProposeText({ staging, session: "ses_intake4b", summaryFile: "/nonexistent-dir-4b-test/summary.md" }) + ).rejects.toThrow() + }) + + test("E. missing session is refused", async () => { + const staging = tempDb("staging") + await expect(learnProposeText({ staging, session: " ", summary: GOOD_BULLET })).rejects.toThrow(/--session/) + }) + + test("F. proposing the same summary twice does not duplicate", async () => { + const staging = tempDb("staging") + const file = writeSummary(GOOD_BULLET) + const first = await learnProposeText({ staging, session: "ses_intake6", summaryFile: file }) + const second = await learnProposeText({ staging, session: "ses_intake6", summaryFile: file }) + expect(second).toContain("extracted=1") + const store = new CandidateStore(staging) + try { + expect(store.list()).toHaveLength(1) + } finally { + store.close() + } + expect(first).toContain("candidate=") + }) + + test("G. secrets and episodic units never reach staging", async () => { + const staging = tempDb("staging") + const output = await learnProposeText({ + staging, + session: "ses_intake7", + summary: [GOOD_BULLET, SECRET_BULLET, EPISODIC_BULLET].join("\n- "), + }) + expect(output).toContain("extracted=1") + const store = new CandidateStore(staging) + try { + const listed = store.list() + expect(listed).toHaveLength(1) + expect(listed[0].content).toBe(GOOD_BULLET) + } finally { + store.close() + } + }) + + test("H. propose never writes to knowledge.db", async () => { + const staging = tempDb("staging") + const knowledge = tempDb("knowledge") + seedKnowledge(knowledge, ["guide.md", "spec.md"]) + expect(knowledgeCount(knowledge)).toBe(2) + await learnProposeText({ staging, session: "ses_intake8", summary: GOOD_BULLET }) + expect(knowledgeCount(knowledge)).toBe(2) + }) + + test("I. propose changes nothing to approved", async () => { + const staging = tempDb("staging") + await learnProposeText({ staging, session: "ses_intake9", summary: GOOD_BULLET }) + const store = new CandidateStore(staging) + try { + expect(store.list({ status: "approved" })).toHaveLength(0) + expect(store.list().every((candidate) => candidate.status === "pending")).toBe(true) + } finally { + store.close() + } + }) + + test("J. status separates corpus size from governed admissions", async () => { + const staging = tempDb("staging") + const knowledge = tempDb("knowledge") + seedKnowledge(knowledge, ["guide.md", "spec.md"]) + const status = await learnStatusText({ staging, knowledge }) + expect(status).toContain("indexed_corpus_chunks=2") + expect(status).not.toContain("admitted=") + }) + + test("K. admitted_memory_chunks is zero before any admission", async () => { + const staging = tempDb("staging") + const knowledge = tempDb("knowledge") + seedKnowledge(knowledge, ["guide.md", "spec.md"]) + await learnProposeText({ staging, session: "ses_intake11", summary: GOOD_BULLET }) + const status = await learnStatusText({ staging, knowledge }) + expect(status).toContain("indexed_corpus_chunks=2") + expect(status).toContain("admitted_memory_chunks=0") + }) + + test("L2. one admission moves both counters by exactly one", async () => { + const staging = tempDb("staging") + const knowledge = tempDb("knowledge") + seedKnowledge(knowledge, ["guide.md", "spec.md"]) + await learnProposeText({ staging, session: "ses_intake12", summary: GOOD_BULLET }) + const store = new CandidateStore(staging) + const id = store.list()[0].id + store.close() + await learnApproveText({ staging, id, note: "verified" }) + await learnAdmitText({ staging, knowledge, id }) + const status = await learnStatusText({ staging, knowledge }) + expect(status).toContain("indexed_corpus_chunks=3") + expect(status).toContain("admitted_memory_chunks=1") + }) + + test("M. restart preserves proposed candidates", async () => { + const staging = tempDb("staging") + await learnProposeText({ staging, session: "ses_intake13", summary: GOOD_BULLET }) + const reopened = new CandidateStore(staging) + try { + const listed = reopened.list() + expect(listed).toHaveLength(1) + expect(listed[0].status).toBe("pending") + expect(listed[0].content).toBe(GOOD_BULLET) + } finally { + reopened.close() + } + }) + + test("N. production cycle: propose → candidates → show → approve → admit → retrieve", async () => { + const staging = tempDb("staging") + const knowledge = tempDb("knowledge") + const proposed = await learnProposeText({ staging, session: "ses_intake14", summary: GOOD_BULLET }) + expect(proposed).toContain("extracted=1") + const brief = await learnCandidatesText({ staging }) + expect(brief.split("\n").filter((line) => line.startsWith("- "))).toHaveLength(1) + const store = new CandidateStore(staging) + const id = store.list()[0].id + store.close() + expect(JSON.parse(await learnShowText({ staging, id })).candidate.id).toBe(id) + await learnApproveText({ staging, id, note: "cycle verified" }) + const receipt = JSON.parse(await learnAdmitText({ staging, knowledge, id })) + expect(receipt.receipt.status).toBe("admitted") + // Fresh handles, as a new process would open them. + const found = await learnRetrieveText({ knowledge, query: "session runner epoch promotion order" }) + expect(found).toContain(id) + const status = await learnStatusText({ staging, knowledge }) + expect(status).toContain("admitted_memory_chunks=1") + }) +}) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 16b4789b0725..55f630b0403a 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -10,8 +10,17 @@ import { afterAll } from "bun:test" const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid) await fs.mkdir(dir, { recursive: true }) afterAll(async () => { - const { AppRuntime } = await import("../src/effect/app-runtime") - await AppRuntime.dispose() + // Pure tests (learn-memory, account, …) never import the singleton. Importing + // it here for the first time costs ~12s and trips the hook timeout, while + // there is nothing to dispose. Only pay for import+dispose when a test + // actually loaded the module (marker set at module evaluation). + const loaded = + (globalThis as typeof globalThis & { __OPENCODE_APP_RUNTIME_LOADED?: boolean }) + .__OPENCODE_APP_RUNTIME_LOADED === true + if (loaded) { + const { AppRuntime } = await import("../src/effect/app-runtime") + await AppRuntime.dispose() + } const busy = (error: unknown) => typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY" From b1cd21331cc38fd352fbcb8162d7c03297f414af Mon Sep 17 00:00:00 2001 From: AH Date: Sun, 13 Sep 2026 09:03:15 -0700 Subject: [PATCH 4/8] chore(git): ignore local knowledge database artifacts --- .gitignore | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.gitignore b/.gitignore index 006cab8c276c..610bb0f830ad 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,17 @@ UPCOMING_CHANGELOG.md logs/ *.bun-build tsconfig.tsbuildinfo + +# Local SQLite runtime artifacts — narrow & anchored. +# Verified 2026-09-13: zero *.db tracked; tracked routes/data are *.ts only, unaffected. +/data/ +**/*.db-shm +**/*.db-wal +packages/knowledge-engine/knowledge.db +data/knowledge.db +**/*.sqlite-shm +**/*.sqlite-wal +# Intentionally NOT ignoring **/*.db / **/*.sqlite broadly: future fixtures +# must be allow-listed explicitly instead of being silently hidden. +# If a new runtime DB appears, add its explicit path above + re-run: +# git check-ignore -v ; git status --short From 142308956ae17943dfeb8f5512822ea4dadb436b Mon Sep 17 00:00:00 2001 From: AH Date: Tue, 15 Sep 2026 09:15:09 -0700 Subject: [PATCH 5/8] feat(opencode): enable direct MCP tools and expert skills - support codemode in local and remote MCP configuration - preserve codemode through V2 compatibility conversion - expose direct MCP tools through the tool registry - configure Playwright and Context7 integrations - add regression coverage for direct MCP tool projection - add AI engineering and prompt engineering skills - ignore local Playwright runtime artifacts - document verified MCP capability status --- .gitignore | 3 + .opencode/opencode.jsonc | 39 +++- .opencode/skills/ai-engineer/SKILL.md | 190 ++++++++++++++++++ .opencode/skills/prompt-engineering/SKILL.md | 182 +++++++++++++++++ MCP_FINAL_STATUS.md | 146 ++++++++++++++ packages/core/src/v1/config/mcp.ts | 6 + packages/opencode/src/config/v2-compat.ts | 3 +- packages/opencode/src/tool/registry.ts | 18 +- .../test/tool/mcp-codemode-direct.test.ts | 70 +++++++ 9 files changed, 647 insertions(+), 10 deletions(-) create mode 100644 .opencode/skills/ai-engineer/SKILL.md create mode 100644 .opencode/skills/prompt-engineering/SKILL.md create mode 100644 MCP_FINAL_STATUS.md create mode 100644 packages/opencode/test/tool/mcp-codemode-direct.test.ts diff --git a/.gitignore b/.gitignore index 610bb0f830ad..4e9ac27e7cf6 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ data/knowledge.db # must be allow-listed explicitly instead of being silently hidden. # If a new runtime DB appears, add its explicit path above + re-run: # git check-ignore -v ; git status --short + +# Local Playwright MCP run outputs (console logs + page snapshots) — never commit. +.playwright-mcp/ diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index b0f7d59447db..f50d7ddbbae4 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -5,16 +5,43 @@ "references": { "effect": { "repository": "github.com/Effect-TS/effect-smol", - "description": "Use for Effect v4 and effect-smol implementation details", + "description": "Use for Effect v4 and effect-smol implementation details" }, "opencode-local": { "path": "~/.local/share/opencode", - "description": "Contains opencode logs and data", + "description": "Contains opencode logs and data" + } + }, + "mcp": { + "timeout": { + "catalog": 120000, + "execution": 120000 + }, + "playwright": { + "type": "local", + "command": [ + "npx", + "-y", + "@playwright/mcp@latest", + "--headless", + "--browser", + "chromium", + "--isolated" + ], + "codemode": false, + "timeout": { + "catalog": 120000, + "execution": 120000 + } }, + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + "codemode": false + } }, - "mcp": {}, "tools": { "github-triage": false, - "github-pr-search": false, - }, -} + "github-pr-search": false + } +} \ No newline at end of file diff --git a/.opencode/skills/ai-engineer/SKILL.md b/.opencode/skills/ai-engineer/SKILL.md new file mode 100644 index 000000000000..28480ae67505 --- /dev/null +++ b/.opencode/skills/ai-engineer/SKILL.md @@ -0,0 +1,190 @@ +--- +name: ai-engineer +description: Build production-ready LLM applications, advanced RAG systems, and intelligent agents. Implements vector search, multimodal AI, agent orchestration, and enterprise AI integrations. +risk: critical +source: community +date_added: '2026-02-27' +--- + +You are an AI engineer specializing in production-grade LLM applications, generative AI systems, and intelligent agent architectures. + +## Use this skill when + +- Building or improving LLM features, RAG systems, or AI agents +- Designing production AI architectures and model integration +- Optimizing vector search, embeddings, or retrieval pipelines +- Implementing AI safety, monitoring, or cost controls + +## Do not use this skill when + +- The task is pure data science or traditional ML without LLMs +- You only need a quick UI change unrelated to AI features +- There is no access to data sources or deployment targets + +## Instructions + +1. Clarify use cases, constraints, and success metrics. +2. Design the AI architecture, data flow, and model selection. +3. Implement with monitoring, safety, and cost controls. +4. Validate with tests and staged rollout plans. + +## Safety + +- Avoid sending sensitive data to external models without approval. +- Add guardrails for prompt injection, PII, and policy compliance. + +## Purpose + +Expert AI engineer specializing in LLM application development, RAG systems, and AI agent architectures. Masters both traditional and cutting-edge generative AI patterns, with deep knowledge of the modern AI stack including vector databases, embedding models, agent frameworks, and multimodal AI systems. + +## Capabilities + +### LLM Integration & Model Management + +- OpenAI GPT-4o/4o-mini, o1-preview, o1-mini with function calling and structured outputs +- Anthropic Claude 4.5 Sonnet/Haiku, Claude 4.1 Opus with tool use and computer use +- Open-source models: Llama 3.1/3.2, Mixtral 8x7B/8x22B, Qwen 2.5, DeepSeek-V2 +- Local deployment with Ollama, vLLM, TGI (Text Generation Inference) +- Model serving with TorchServe, MLflow, BentoML for production deployment +- Multi-model orchestration and model routing strategies +- Cost optimization through model selection and caching strategies + +### Advanced RAG Systems + +- Production RAG architectures with multi-stage retrieval pipelines +- Vector databases: Pinecone, Qdrant, Weaviate, Chroma, Milvus, pgvector +- Embedding models: OpenAI text-embedding-3-large/small, Cohere embed-v3, BGE-large +- Chunking strategies: semantic, recursive, sliding window, and document-structure aware +- Hybrid search combining vector similarity and keyword matching (BM25) +- Reranking with Cohere rerank-3, BGE reranker, or cross-encoder models +- Query understanding with query expansion, decomposition, and routing +- Context compression and relevance filtering for token optimization +- Advanced RAG patterns: GraphRAG, HyDE, RAG-Fusion, self-RAG + +### Agent Frameworks & Orchestration + +- LangChain/LangGraph for complex agent workflows and state management +- LlamaIndex for data-centric AI applications and advanced retrieval +- CrewAI for multi-agent collaboration and specialized agent roles +- AutoGen for conversational multi-agent systems +- OpenAI Assistants API with function calling and file search +- Agent memory systems: short-term, long-term, and episodic memory +- Tool integration: web search, code execution, API calls, database queries +- Agent evaluation and monitoring with custom metrics + +### Vector Search & Embeddings + +- Embedding model selection and fine-tuning for domain-specific tasks +- Vector indexing strategies: HNSW, IVF, LSH for different scale requirements +- Similarity metrics: cosine, dot product, Euclidean for various use cases +- Multi-vector representations for complex document structures +- Embedding drift detection and model versioning +- Vector database optimization: indexing, sharding, and caching strategies + +### Prompt Engineering & Optimization + +- Advanced prompting techniques: chain-of-thought, tree-of-thoughts, self-consistency +- Few-shot and in-context learning optimization +- Prompt templates with dynamic variable injection and conditioning +- Constitutional AI and self-critique patterns +- Prompt versioning, A/B testing, and performance tracking +- Safety prompting: jailbreak detection, content filtering, bias mitigation +- Multi-modal prompting for vision and audio models + +### Production AI Systems + +- LLM serving with FastAPI, async processing, and load balancing +- Streaming responses and real-time inference optimization +- Caching strategies: semantic caching, response memoization, embedding caching +- Rate limiting, quota management, and cost controls +- Error handling, fallback strategies, and circuit breakers +- A/B testing frameworks for model comparison and gradual rollouts +- Observability: logging, metrics, tracing with LangSmith, Phoenix, Weights & Biases + +### Multimodal AI Integration + +- Vision models: GPT-4V, Claude 4 Vision, LLaVA, CLIP for image understanding +- Audio processing: Whisper for speech-to-text, ElevenLabs for text-to-speech +- Document AI: OCR, table extraction, layout understanding with models like LayoutLM +- Video analysis and processing for multimedia applications +- Cross-modal embeddings and unified vector spaces + +### AI Safety & Governance + +- Content moderation with OpenAI Moderation API and custom classifiers +- Prompt injection detection and prevention strategies +- PII detection and redaction in AI workflows +- Model bias detection and mitigation techniques +- AI system auditing and compliance reporting +- Responsible AI practices and ethical considerations + +### Data Processing & Pipeline Management + +- Document processing: PDF extraction, web scraping, API integrations +- Data preprocessing: cleaning, normalization, deduplication +- Pipeline orchestration with Apache Airflow, Dagster, Prefect +- Real-time data ingestion with Apache Kafka, Pulsar +- Data versioning with DVC, lakeFS for reproducible AI pipelines +- ETL/ELT processes for AI data preparation + +### Integration & API Development + +- RESTful API design for AI services with FastAPI, Flask +- GraphQL APIs for flexible AI data querying +- Webhook integration and event-driven architectures +- Third-party AI service integration: Azure OpenAI, AWS Bedrock, GCP Vertex AI +- Enterprise system integration: Slack bots, Microsoft Teams apps, Salesforce +- API security: OAuth, JWT, API key management + +## Behavioral Traits + +- Prioritizes production reliability and scalability over proof-of-concept implementations +- Implements comprehensive error handling and graceful degradation +- Focuses on cost optimization and efficient resource utilization +- Emphasizes observability and monitoring from day one +- Considers AI safety and responsible AI practices in all implementations +- Uses structured outputs and type safety wherever possible +- Implements thorough testing including adversarial inputs +- Documents AI system behavior and decision-making processes +- Stays current with rapidly evolving AI/ML landscape +- Balances cutting-edge techniques with proven, stable solutions + +## Knowledge Base + +- Latest LLM developments and model capabilities (GPT-4o, Claude 4.5, Llama 3.2) +- Modern vector database architectures and optimization techniques +- Production AI system design patterns and best practices +- AI safety and security considerations for enterprise deployments +- Cost optimization strategies for LLM applications +- Multimodal AI integration and cross-modal learning +- Agent frameworks and multi-agent system architectures +- Real-time AI processing and streaming inference +- AI observability and monitoring best practices +- Prompt engineering and optimization methodologies + +## Response Approach + +1. **Analyze AI requirements** for production scalability and reliability +2. **Design system architecture** with appropriate AI components and data flow +3. **Implement production-ready code** with comprehensive error handling +4. **Include monitoring and evaluation** metrics for AI system performance +5. **Consider cost and latency** implications of AI service usage +6. **Document AI behavior** and provide debugging capabilities +7. **Implement safety measures** for responsible AI deployment +8. **Provide testing strategies** including adversarial and edge cases + +## Example Interactions + +- "Build a production RAG system for enterprise knowledge base with hybrid search" +- "Implement a multi-agent customer service system with escalation workflows" +- "Design a cost-optimized LLM inference pipeline with caching and load balancing" +- "Create a multimodal AI system for document analysis and question answering" +- "Build an AI agent that can browse the web and perform research tasks" +- "Implement semantic search with reranking for improved retrieval accuracy" +- "Design an A/B testing framework for comparing different LLM prompts" +- "Create a real-time AI content moderation system with custom classifiers" + +## Limitations +- Use this skill only when the task clearly matches the scope described above. +- Do not treat the output as a substitute for environment-specific validation, testing, or expert review. +- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing. diff --git a/.opencode/skills/prompt-engineering/SKILL.md b/.opencode/skills/prompt-engineering/SKILL.md new file mode 100644 index 000000000000..936f09b880ce --- /dev/null +++ b/.opencode/skills/prompt-engineering/SKILL.md @@ -0,0 +1,182 @@ +--- +name: prompt-engineering +description: "Expert guide on prompt engineering patterns, best practices, and optimization techniques. Use when user wants to improve prompts, learn prompting strategies, or debug agent behavior." +risk: none +source: community +date_added: "2026-02-27" +--- + +# Prompt Engineering Patterns + +Advanced prompt engineering techniques to maximize LLM performance, reliability, and controllability. + +## Core Capabilities + +### 1. Few-Shot Learning + +Teach the model by showing examples instead of explaining rules. Include 2-5 input-output pairs that demonstrate the desired behavior. Use when you need consistent formatting, specific reasoning patterns, or handling of edge cases. More examples improve accuracy but consume tokens—balance based on task complexity. + +**Example:** + +```markdown +Extract key information from support tickets: + +Input: "My login doesn't work and I keep getting error 403" +Output: {"issue": "authentication", "error_code": "403", "priority": "high"} + +Input: "Feature request: add dark mode to settings" +Output: {"issue": "feature_request", "error_code": null, "priority": "low"} + +Now process: "Can't upload files larger than 10MB, getting timeout" +``` + +### 2. Chain-of-Thought Prompting + +Request step-by-step reasoning before the final answer. Add "Let's think step by step" (zero-shot) or include example reasoning traces (few-shot). Use for complex problems requiring multi-step logic, mathematical reasoning, or when you need to verify the model's thought process. Improves accuracy on analytical tasks by 30-50%. + +**Example:** + +```markdown +Analyze this bug report and determine root cause. + +Think step by step: + +1. What is the expected behavior? +2. What is the actual behavior? +3. What changed recently that could cause this? +4. What components are involved? +5. What is the most likely root cause? + +Bug: "Users can't save drafts after the cache update deployed yesterday" +``` + +### 3. Prompt Optimization + +Systematically improve prompts through testing and refinement. Start simple, measure performance (accuracy, consistency, token usage), then iterate. Test on diverse inputs including edge cases. Use A/B testing to compare variations. Critical for production prompts where consistency and cost matter. + +**Example:** + +```markdown +Version 1 (Simple): "Summarize this article" +→ Result: Inconsistent length, misses key points + +Version 2 (Add constraints): "Summarize in 3 bullet points" +→ Result: Better structure, but still misses nuance + +Version 3 (Add reasoning): "Identify the 3 main findings, then summarize each" +→ Result: Consistent, accurate, captures key information +``` + +### 4. Template Systems + +Build reusable prompt structures with variables, conditional sections, and modular components. Use for multi-turn conversations, role-based interactions, or when the same pattern applies to different inputs. Reduces duplication and ensures consistency across similar tasks. + +**Example:** + +```python +# Reusable code review template +template = """ +Review this {language} code for {focus_area}. + +Code: +{code_block} + +Provide feedback on: +{checklist} +""" + +# Usage +prompt = template.format( + language="Python", + focus_area="security vulnerabilities", + code_block=user_code, + checklist="1. SQL injection\n2. XSS risks\n3. Authentication" +) +``` + +### 5. System Prompt Design + +Set global behavior and constraints that persist across the conversation. Define the model's role, expertise level, output format, and safety guidelines. Use system prompts for stable instructions that shouldn't change turn-to-turn, freeing up user message tokens for variable content. + +**Example:** + +```markdown +System: You are a senior backend engineer specializing in API design. + +Rules: + +- Always consider scalability and performance +- Suggest RESTful patterns by default +- Flag security concerns immediately +- Provide code examples in Python +- Use early return pattern + +Format responses as: + +1. Analysis +2. Recommendation +3. Code example +4. Trade-offs +``` + +## Key Patterns + +### Progressive Disclosure + +Start with simple prompts, add complexity only when needed: + +1. **Level 1**: Direct instruction + + - "Summarize this article" + +2. **Level 2**: Add constraints + + - "Summarize this article in 3 bullet points, focusing on key findings" + +3. **Level 3**: Add reasoning + + - "Read this article, identify the main findings, then summarize in 3 bullet points" + +4. **Level 4**: Add examples + - Include 2-3 example summaries with input-output pairs + +### Instruction Hierarchy + +``` +[System Context] → [Task Instruction] → [Examples] → [Input Data] → [Output Format] +``` + +### Error Recovery + +Build prompts that gracefully handle failures: + +- Include fallback instructions +- Request confidence scores +- Ask for alternative interpretations when uncertain +- Specify how to indicate missing information + +## Best Practices + +1. **Be Specific**: Vague prompts produce inconsistent results +2. **Show, Don't Tell**: Examples are more effective than descriptions +3. **Test Extensively**: Evaluate on diverse, representative inputs +4. **Iterate Rapidly**: Small changes can have large impacts +5. **Monitor Performance**: Track metrics in production +6. **Version Control**: Treat prompts as code with proper versioning +7. **Document Intent**: Explain why prompts are structured as they are + +## Common Pitfalls + +- **Over-engineering**: Starting with complex prompts before trying simple ones +- **Example pollution**: Using examples that don't match the target task +- **Context overflow**: Exceeding token limits with excessive examples +- **Ambiguous instructions**: Leaving room for multiple interpretations +- **Ignoring edge cases**: Not testing on unusual or boundary inputs + +## When to Use +This skill is applicable to execute the workflow or actions described in the overview. + +## Limitations +- Use this skill only when the task clearly matches the scope described above. +- Do not treat the output as a substitute for environment-specific validation, testing, or expert review. +- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing. diff --git a/MCP_FINAL_STATUS.md b/MCP_FINAL_STATUS.md new file mode 100644 index 000000000000..20e5ef43adbd --- /dev/null +++ b/MCP_FINAL_STATUS.md @@ -0,0 +1,146 @@ +# تقرير الحالة النهائي — MCP Core Capabilities + +**التاريخ:** 2026-09-15 +**الفرع:** `feat/engineering-memory-v1` (فعلي — `git branch --show-current`) +**النطاق:** تفعيل MCP + Skills فقط — دون Build أو تعديل مصدر جديد في هذه الخطوة + +## القدرات المفعلة + +| القدرة | الحالة | الدليل | +|---|---|---| +| GitHub CLI (`gh`) | FUNCTIONALLY_ACTIVE | يعمل بصورة طبيعية | +| GitHub Remote MCP | CLIENT_OAUTH_INCOMPATIBLE | أُزيل من إعداد المشروع | +| Context7 MCP | FUNCTIONALLY_VERIFIED | `resolve-library-id` + `query-docs` مكتملان | +| Playwright MCP | FUNCTIONALLY_VERIFIED | `navigate` + `snapshot` مكتملان | +| AI Engineer Skill | FUNCTIONALLY_VERIFIED | `.opencode/skills/ai-engineer/SKILL.md` | +| Prompt Engineering Skill | FUNCTIONALLY_VERIFIED | `.opencode/skills/prompt-engineering/SKILL.md` | + +## نسخة OpenCode العاملة + +``` +Binary: packages/opencode/dist/opencode-linux-x64/bin/opencode +Active symlink: ~/.local/bin/opencode + → /mnt/k/opencode/packages/opencode/dist/opencode-linux-x64/bin/opencode +Version: 0.0.0-feat/engineering-memory-v1-202609151042 +SHA-256 prefix: f115e763b09d9c87 +``` + +## إصلاح MCP — ثلاث طبقات + +1. دعم `codemode` في MCP schema — `packages/core/src/v1/config/mcp.ts` +2. الاحتفاظ بـ `codemode` عبر V2 compatibility conversion — `packages/opencode/src/config/v2-compat.ts` +3. إسقاط أدوات MCP المباشرة في Tool Registry — `packages/opencode/src/tool/registry.ts` + +اختبار الانحدار: + +``` +packages/opencode/test/tool/mcp-codemode-direct.test.ts +PASS: 1 +FAIL: 0 +``` + +## الدليل الوظيفي + +### Playwright (جلسة واحدة، أداتان بالترتيب) + +``` +playwright_browser_navigate STATUS=completed ERROR=None +playwright_browser_snapshot STATUS=completed ERROR=None +``` + +- المصدر: `/tmp/playwright-two-tools.json` +- الصفحة: `https://demo.playwright.dev/todomvc/#/` — `React • TodoMVC` +- الـ Snapshot رجع `textbox "What needs to be done?"` وعناصر `todos` المتوقعة. + +### Context7 + +- `context7_resolve-library-id`: `React` → `/reactjs/react.dev` +- `context7_query-docs`: أمثلة `useEffect cleanup` حديثة من `react.dev` + +### المتصفحات المثبتة + +``` +Chrome for Testing 154.0.8037.0 +Playwright Chromium v1244 +Chrome Headless Shell v1244 +Linux browser dependencies complete +``` + +## حالة Git + +``` +IMPLEMENTED_LOCALLY_NOT_ADMITTED +COMMIT / PUSH: NOT PERFORMED +``` + +### جاهز لـ Commit منفصل (نطاق MCP الحالي) + +```bash +git add .opencode/opencode.jsonc \ + packages/core/src/v1/config/mcp.ts \ + packages/opencode/src/config/v2-compat.ts \ + packages/opencode/src/tool/registry.ts \ + packages/opencode/test/tool/mcp-codemode-direct.test.ts \ + .opencode/skills/ai-engineer/SKILL.md \ + .opencode/skills/prompt-engineering/SKILL.md +``` + +| الملف | النوع | +|---|---| +| `.opencode/opencode.jsonc` | M — إعداد Playwright + Context7 | +| `packages/core/src/v1/config/mcp.ts` | M — schema `codemode` | +| `packages/opencode/src/config/v2-compat.ts` | M — تمرير `codemode` | +| `packages/opencode/src/tool/registry.ts` | M — أدوات MCP المباشرة | +| `packages/opencode/test/tool/mcp-codemode-direct.test.ts` | ?? — اختبار الانحدار | +| `.opencode/skills/ai-engineer/SKILL.md` | ?? — مهارة موثقة | +| `.opencode/skills/prompt-engineering/SKILL.md` | ?? — مهارة موثقة | + +### خارج النطاق — لا تخلط مع Commit الحالي + +| الملف | السبب | +|---|---| +| `packages/core/src/effect/layer-node.ts` | M — null-guards + `Layer.mergeAll`، نطاق مختلف | +| `packages/opencode/src/session/system.ts` | M — فلترة references، نطاق مختلف | +| `packages/opencode/src/skill/index.ts` | M — فلترة skills، نطاق مختلف | +| `packages/core/test/effect/layer-node/c3-pinning.test.ts` | ?? — من 12 سبتمبر | +| `packages/knowledge-engine/src/interactive.ts` | ?? — من 12 سبتمبر | + +### مستبعد — لا يدخل Commit + +``` +.playwright-mcp/ +├── console-2026-09-15T15-22-43-125Z.log +├── console-2026-09-15T15-26-35-155Z.log +├── console-2026-09-15T15-46-46-904Z.log +├── page-2026-09-15T15-22-45-778Z.yml +├── page-2026-09-15T15-26-36-462Z.yml +└── page-2026-09-15T15-46-48-231Z.yml +``` + +## التحقق من عدم تتبع مخرجات Playwright + +```bash +git ls-files | grep -E "playwright-mcp" || echo "OK: no .playwright-mcp tracked" +# النتيجة: OK: no .playwright-mcp tracked ✅ + +git check-ignore -v .playwright-mcp +# النتيجة: NOT IGNORED ⚠️ — غير متجاهَل في .gitignore بعد +``` + +**الحكم:** المخرجات **غير متتبعة** حاليًا (آمنة ما دام لا يُستخدم `git add .`)، لكنها **غير متجاهلة** رسميًا. التوصية: إضافة `.playwright-mcp/` إلى `.gitignore` في تنظيف Git اللاحق قبل أي `add` واسع، وتحديد سياسة تجاهل صريحة. + +## الحكم النهائي + +``` +PLAYWRIGHT_MCP_FUNCTIONALLY_VERIFIED ✅ +CONTEXT7_MCP_FUNCTIONALLY_VERIFIED ✅ +AI_ENGINEER_SKILL_FUNCTIONALLY_VERIFIED ✅ +PROMPT_ENGINEERING_SKILL_FUNCTIONALLY_VERIFIED ✅ +GITHUB_NATIVE_CLI_FUNCTIONALLY_ACTIVE ✅ + +OPENCODE MCP CORE CAPABILITIES: ACTIVE +REPOSITORY ADMISSION: NOT STARTED +COMMIT / PUSH: NOT PERFORMED +``` + +المتبقي لاحقًا: تنظيف نطاق Git، فصل الملفات السابقة غير المرتبطة، وإعداد Commit مخصص دون خلط العمل الحالي بالتعديلات القديمة. diff --git a/packages/core/src/v1/config/mcp.ts b/packages/core/src/v1/config/mcp.ts index 0a2aeff12fb0..095b8fe276d6 100644 --- a/packages/core/src/v1/config/mcp.ts +++ b/packages/core/src/v1/config/mcp.ts @@ -17,6 +17,9 @@ export const Local = Schema.Struct({ enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable the MCP server on startup", }), + codemode: Schema.optional(Schema.Boolean).annotate({ + description: "Expose MCP tools via codemode (true) or directly (false). Defaults to true.", + }), timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), @@ -53,6 +56,9 @@ export const Remote = Schema.Struct({ oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({ description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", }), + codemode: Schema.optional(Schema.Boolean).annotate({ + description: "Expose MCP tools via codemode (true) or directly (false). Defaults to true.", + }), timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), diff --git a/packages/opencode/src/config/v2-compat.ts b/packages/opencode/src/config/v2-compat.ts index 9e4e0bf54089..85c0954676cf 100644 --- a/packages/opencode/src/config/v2-compat.ts +++ b/packages/opencode/src/config/v2-compat.ts @@ -321,7 +321,6 @@ function isDirectServer(value: Record) { function normalizeServer(input: unknown, path: string[], diagnostics: Diagnostic[]) { const server = decodeValue(Server, input, path, diagnostics) if (server === undefined) return - if (server.codemode !== undefined) unsupported([...path, "codemode"], diagnostics) if (server.timeout && lowerTimeout(server.timeout) === undefined && Object.keys(server.timeout).length) unsupported([...path, "timeout"], diagnostics) const raw = decodeRecord(input) @@ -372,8 +371,8 @@ function lowerServer(input: Schema.Schema.Type) { enabled: input.disabled !== true, } delete result.disabled - delete result.codemode delete result.timeout + if (input.codemode !== undefined) result.codemode = input.codemode if (input.timeout) { const timeout = lowerTimeout(input.timeout) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 9167cb3ea6bc..c9afdd39f2fc 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -267,7 +267,9 @@ const layer = Layer.effect( const filtered = items.filter( (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny", ) - const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name)) + const list = filtered + .filter((item): item is Agent.Info => Boolean(item && typeof item.name === "string")) + .toSorted((a, b) => (a.name ?? "").localeCompare(b.name ?? "")) const description = list .map( (item) => @@ -305,7 +307,19 @@ const layer = Layer.effect( const codeModeDescription = filtered.some((tool) => tool.id === "execute") ? yield* describeCodeMode(input) : undefined - const visible = filtered.filter((tool) => tool.id !== "execute" || codeModeDescription) + const mcpTools = yield* mcp.tools() + const mcpDirectTools: Tool.Def[] = [] + for (const [key, mcpTool] of Object.entries(mcpTools)) { + const converted = McpCatalog.convertTool(mcpTool.def, mcpTool.client, mcpTool.timeout) + mcpDirectTools.push({ + id: key, + description: converted.description, + parameters: (converted as any).inputSchema ?? (converted as any).jsonSchema, + jsonSchema: (converted as any).jsonSchema ?? (converted as any).inputSchema, + execute: (converted as any).execute, + } as unknown as Tool.Def) + } + const visible = [...filtered.filter((tool) => tool.id !== "execute" || codeModeDescription), ...mcpDirectTools] return yield* Effect.forEach( visible, diff --git a/packages/opencode/test/tool/mcp-codemode-direct.test.ts b/packages/opencode/test/tool/mcp-codemode-direct.test.ts new file mode 100644 index 000000000000..256d819961be --- /dev/null +++ b/packages/opencode/test/tool/mcp-codemode-direct.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ToolRegistry } from "@/tool/registry" +import { disposeAllInstances } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { TestConfig } from "../fixture/config" +import { Config } from "@/config/config" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { MCP } from "@/mcp" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { InstanceState } from "@/effect/instance-state" +import { Agent } from "@/agent/agent" +import path from "path" + +const configLayer = TestConfig.layer({ + directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), +}) + +const root = LayerNode.group([ToolRegistry.node, Agent.node]) + +// Mock MCP with a browser tool, codemode:false should expose directly without experimental flag +const withMcpDirect = testEffect( + LayerNode.compile(root, [ + [Config.node, configLayer], + [RuntimeFlags.node, RuntimeFlags.layer({})], + [ + MCP.node, + Layer.mock(MCP.Service, { + tools: () => + Effect.succeed({ + "playwright_browser_navigate": { + def: { + name: "browser_navigate", + description: "navigate browser", + inputSchema: { type: "object", properties: { url: { type: "string" } }, required: ["url"] }, + } as MCPToolDef, + client: {} as any, + }, + }), + clients: () => Effect.succeed({ playwright: {} as any }), + }), + ], + ]), +) + +afterEach(async () => { + await disposeAllInstances() +}) + +describe("mcp direct exposure", () => { + withMcpDirect.instance("exposes playwright tools directly when codemode:false", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agents = yield* Agent.Service + const build = yield* agents.get("build") + if (!build) throw new Error("build agent not found") + const tools = yield* registry.tools({ + providerID: ProviderV2.ID.opencode, + modelID: ModelV2.ID.make("test"), + agent: build, + }) + const ids = tools.map((t) => t.id) + console.log("tool ids", ids) + expect(ids).toContain("playwright_browser_navigate") + }), + ) +}) From 2abe7049c1ff5d9ecd0274be571808afdc07854c Mon Sep 17 00:00:00 2001 From: AH Date: Tue, 15 Sep 2026 17:44:01 -0700 Subject: [PATCH 6/8] fix(core): preserve layer identity when dependencies are unchanged - merge direct dependencies explicitly before providing the layer - preserve node identity when dependency rewrites make no changes - rebuild nodes only when a dependency is actually replaced - replace behavior-pinning cases with dependency contract tests --- packages/core/src/effect/layer-node.ts | 2 +- .../dependency-rewrite-contract.test.ts | 77 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/effect/layer-node/dependency-rewrite-contract.test.ts diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts index 9dbc3d51607b..19dda90e7695 100644 --- a/packages/core/src/effect/layer-node.ts +++ b/packages/core/src/effect/layer-node.ts @@ -262,7 +262,7 @@ export function compile( const implementation = node.implementation! as RuntimeLayer return dependencies.length === 0 ? implementation - : implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]])) + : implementation.pipe(Layer.provide(Layer.mergeAll(...(dependencies as [RuntimeLayer, ...RuntimeLayer[]])))) }, { cache, resolve: (node) => replacementMap.get(node.name) ?? node }, ) diff --git a/packages/core/test/effect/layer-node/dependency-rewrite-contract.test.ts b/packages/core/test/effect/layer-node/dependency-rewrite-contract.test.ts new file mode 100644 index 000000000000..219c330087ce --- /dev/null +++ b/packages/core/test/effect/layer-node/dependency-rewrite-contract.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" + +// Contract tests for LayerNode identity and multi-dependency provision. +// These assert production contracts that hold on HEAD and on the corrected +// tree (HEAD + Layer.mergeAll in compile). They deliberately do NOT cover +// falsy dependency entries: the Node types forbid null/undefined, no +// producer in the repo emits them, and such inputs must fail fast instead +// of being silently dropped. + +class Alpha extends Context.Service()("test/C3Alpha") {} +class Beta extends Context.Service()("test/C3Beta") {} +class Other extends Context.Service()("test/C3Other") {} + +const tags = LayerNode.tags({ app: [] }) +const makeApp = tags.make("app") +const alphaLayer = Layer.succeed(Alpha, Alpha.of({ value: "a" })) + +describe("layer node contracts", () => { + test("hoist preserves the identity of nodes untouched by replacements", () => { + const alpha = LayerNode.make({ service: Alpha, layer: alphaLayer, deps: [] }) + const beta = makeApp({ + service: Beta, + layer: Layer.succeed(Beta, Beta.of({ value: "b" })), + deps: [alpha], + }) + const other = LayerNode.make({ + service: Other, + layer: Layer.succeed(Other, Other.of({ value: "o" })), + deps: [], + }) + const replacement = Layer.succeed(Other, Other.of({ value: "r" })) + const result = LayerNode.hoist(LayerNode.group([beta]), tags.values.app, [[other, replacement]]) + const hoistedBeta = result.hoisted.dependencies[0] + expect(hoistedBeta).toMatchObject({ name: beta.name }) + expect(hoistedBeta).toBe(beta) + }) + + test("hoist rebuilds a node whose dependency is actually replaced", () => { + const alpha = LayerNode.make({ service: Alpha, layer: alphaLayer, deps: [] }) + const beta = makeApp({ + service: Beta, + layer: Layer.succeed(Beta, Beta.of({ value: "b" })), + deps: [alpha], + }) + const replacement = Layer.succeed(Alpha, Alpha.of({ value: "replaced" })) + const result = LayerNode.hoist(LayerNode.group([beta]), tags.values.app, [[alpha, replacement]]) + const hoistedBeta = result.hoisted.dependencies[0] + expect(hoistedBeta).not.toBe(beta) + expect(hoistedBeta?.dependencies[0]).toMatchObject({ name: alpha.name }) + expect(hoistedBeta?.dependencies[0]).not.toBe(alpha) + }) + + test("compile provides all direct dependencies of a node (mergeAll)", async () => { + const alpha = LayerNode.make({ service: Alpha, layer: alphaLayer, deps: [] }) + const beta = LayerNode.make({ + service: Beta, + layer: Layer.succeed(Beta, Beta.of({ value: "b" })), + deps: [], + }) + class Gamma extends Context.Service()("test/C3Gamma") {} + const gammaImpl = Layer.effect( + Gamma, + Effect.gen(function* () { + const a = yield* Alpha + const b = yield* Beta + return Gamma.of({ value: a.value + b.value }) + }), + ) + const gamma = LayerNode.make({ service: Gamma, layer: gammaImpl, deps: [alpha, beta] }) + const program = Effect.map(Gamma, (item) => item.value).pipe( + Effect.provide(LayerNode.compile(LayerNode.group([gamma]))), + ) + expect(await Effect.runPromise(program)).toBe("ab") + }) +}) From 8e2f50258d2fd5371302784178943b96806f7363 Mon Sep 17 00:00:00 2001 From: AH Date: Tue, 15 Sep 2026 23:52:54 -0700 Subject: [PATCH 7/8] fix(knowledge): resolve packaged database paths lazily - resolve packaged database files under the stable user data directory - preserve explicit path and environment overrides - create parent directories only when a store is opened - remove import-time database initialization - keep version and session commands free of knowledge database effects - add packaged-path and lazy-initialization regression tests --- .../knowledge-engine/src/agent-integration.ts | 10 +-- packages/knowledge-engine/src/agents.ts | 8 +- packages/knowledge-engine/src/cli.ts | 10 ++- packages/knowledge-engine/src/index.ts | 10 --- packages/knowledge-engine/src/manager.ts | 2 - packages/knowledge-engine/src/middleware.ts | 6 +- packages/knowledge-engine/src/paths.ts | 47 +++++++++++ packages/knowledge-engine/src/retriever.ts | 11 ++- packages/knowledge-engine/src/staging.ts | 6 +- packages/knowledge-engine/src/vector-db.ts | 11 ++- .../knowledge-engine/tests/lazy-init.test.ts | 59 +++++++++++++ .../tests/packaged-paths.test.ts | 82 +++++++++++++++++++ 12 files changed, 227 insertions(+), 35 deletions(-) create mode 100644 packages/knowledge-engine/src/paths.ts create mode 100644 packages/knowledge-engine/tests/lazy-init.test.ts create mode 100644 packages/knowledge-engine/tests/packaged-paths.test.ts diff --git a/packages/knowledge-engine/src/agent-integration.ts b/packages/knowledge-engine/src/agent-integration.ts index 9f8a292a7755..8e51b55b1c01 100644 --- a/packages/knowledge-engine/src/agent-integration.ts +++ b/packages/knowledge-engine/src/agent-integration.ts @@ -1,4 +1,4 @@ -import defaultRetriever, { LocalRetriever } from './retriever'; +import { defaultRetriever, LocalRetriever } from './retriever'; import type { RetrievalResult } from './types'; export class AgentKnowledgeIntegration { @@ -10,7 +10,7 @@ export class AgentKnowledgeIntegration { currentContext: string = '', customRetriever?: LocalRetriever ): Promise { - const activeRetriever = customRetriever || defaultRetriever; + const activeRetriever = customRetriever ?? defaultRetriever(); const relevantKnowledge = await activeRetriever.advancedSearch(task, { agentType: 'build', topK: 4, @@ -45,7 +45,7 @@ ${knowledgeText} currentContext: string = '', customRetriever?: LocalRetriever ): Promise { - const activeRetriever = customRetriever || defaultRetriever; + const activeRetriever = customRetriever ?? defaultRetriever(); const [strategyResults, bestPractices] = await Promise.all([ activeRetriever.advancedSearch(task, { agentType: 'plan', topK: 3 }), activeRetriever.getBestPractice(task), @@ -84,7 +84,7 @@ ${practicesText || 'Standard practices apply.'} errorMessage: string, customRetriever?: LocalRetriever ): Promise { - const activeRetriever = customRetriever || defaultRetriever; + const activeRetriever = customRetriever ?? defaultRetriever(); const solutions = await activeRetriever.findTroubleshootingSolution(errorMessage); if (solutions.length === 0) { @@ -111,7 +111,7 @@ ${guidance} topic: string, customRetriever?: LocalRetriever ): Promise { - const activeRetriever = customRetriever || defaultRetriever; + const activeRetriever = customRetriever ?? defaultRetriever(); const detailed = await activeRetriever.getDetailedContent(topic); const overview = detailed.overview.map(o => `### ${o.section}\n${o.content}`).join('\n\n'); diff --git a/packages/knowledge-engine/src/agents.ts b/packages/knowledge-engine/src/agents.ts index d14690af712a..19d655d49c14 100644 --- a/packages/knowledge-engine/src/agents.ts +++ b/packages/knowledge-engine/src/agents.ts @@ -1,5 +1,5 @@ -import retriever, { LocalRetriever } from './retriever'; -import middleware, { KnowledgeEnrichmentMiddleware } from './middleware'; +import { defaultRetriever, LocalRetriever } from './retriever'; +import { KnowledgeEnrichmentMiddleware } from './middleware'; import type { RetrievalResult } from './types'; export interface PlanOutput { @@ -14,7 +14,7 @@ export class BuildAgentKnowledge { private retriever: LocalRetriever; constructor(customRetriever?: LocalRetriever) { - this.retriever = customRetriever || retriever; + this.retriever = customRetriever ?? defaultRetriever(); this.middleware = new KnowledgeEnrichmentMiddleware(this.retriever); } @@ -61,7 +61,7 @@ export class PlanAgentKnowledge { private retriever: LocalRetriever; constructor(customRetriever?: LocalRetriever) { - this.retriever = customRetriever || retriever; + this.retriever = customRetriever ?? defaultRetriever(); } /** diff --git a/packages/knowledge-engine/src/cli.ts b/packages/knowledge-engine/src/cli.ts index aa3c42035dc2..c565e5553147 100644 --- a/packages/knowledge-engine/src/cli.ts +++ b/packages/knowledge-engine/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import manager from './manager'; -import retriever from './retriever'; +import { KnowledgeEngineManager } from './manager'; +import { LocalRetriever } from './retriever'; const args = process.argv.slice(2); const command = args[0] || 'help'; @@ -8,6 +8,7 @@ const command = args[0] || 'help'; async function main() { switch (command) { case 'index': { + const manager = new KnowledgeEngineManager(); const targetPath = args[1]; const res = manager.buildIndex(targetPath); console.log('\n📊 إحصائيات الفهرس الحالية:'); @@ -22,6 +23,7 @@ async function main() { process.exit(1); } console.log(`\n🔍 البحث عن: "${query}"...\n`); + const retriever = new LocalRetriever(); const results = await retriever.retrieveRelevant(query, 5); if (results.length === 0) { console.log('لم يتم العثور على نتائج.'); @@ -39,12 +41,12 @@ async function main() { } case 'test': { - await manager.testRetrieval(); + await new KnowledgeEngineManager().testRetrieval(); break; } case 'stats': { - const stats = manager.getFullStats(); + const stats = new KnowledgeEngineManager().getFullStats(); console.log('\n📊 إحصائيات محرك المعرفة المحلي:'); console.log(JSON.stringify(stats, null, 2)); break; diff --git a/packages/knowledge-engine/src/index.ts b/packages/knowledge-engine/src/index.ts index ad260cb93f35..f0abcf691e9d 100644 --- a/packages/knowledge-engine/src/index.ts +++ b/packages/knowledge-engine/src/index.ts @@ -25,13 +25,3 @@ export { LocalRetriever, LocalRetriever as RetrievalEngine } from './retriever'; export { applyAbstentionGate, hasLexicalSupport, substantiveTokens } from './abstention'; export { AgentKnowledgeIntegration } from './agent-integration'; export { KnowledgeEngineManager } from './manager'; - -import manager from './manager'; -import retriever from './retriever'; -import integration from './agent-integration'; - -export default { - manager, - retriever, - integration, -}; diff --git a/packages/knowledge-engine/src/manager.ts b/packages/knowledge-engine/src/manager.ts index c889ac3ced49..940755ba8dea 100644 --- a/packages/knowledge-engine/src/manager.ts +++ b/packages/knowledge-engine/src/manager.ts @@ -81,5 +81,3 @@ export class KnowledgeEngineManager { return this.db.getStats(); } } - -export default new KnowledgeEngineManager(); diff --git a/packages/knowledge-engine/src/middleware.ts b/packages/knowledge-engine/src/middleware.ts index dc3807ab383a..9d2e0fc028c3 100644 --- a/packages/knowledge-engine/src/middleware.ts +++ b/packages/knowledge-engine/src/middleware.ts @@ -1,4 +1,4 @@ -import retriever, { LocalRetriever } from './retriever'; +import { defaultRetriever, LocalRetriever } from './retriever'; import type { RetrievalResult } from './types'; export interface EnrichmentMessage { @@ -18,7 +18,7 @@ export class KnowledgeEnrichmentMiddleware { private retriever: LocalRetriever; constructor(customRetriever?: LocalRetriever) { - this.retriever = customRetriever || retriever; + this.retriever = customRetriever ?? defaultRetriever(); } /** @@ -123,5 +123,3 @@ ${bestSolution.content} }; } } - -export default new KnowledgeEnrichmentMiddleware(); diff --git a/packages/knowledge-engine/src/paths.ts b/packages/knowledge-engine/src/paths.ts new file mode 100644 index 000000000000..b91fee10b313 --- /dev/null +++ b/packages/knowledge-engine/src/paths.ts @@ -0,0 +1,47 @@ +import { mkdirSync } from 'fs'; +import { homedir } from 'os'; +import { dirname, join } from 'path'; + +/** + * Packaged-runtime database locations. + * + * Inside a Bun-compiled single-file binary, `import.meta.dir` points into the + * read-only virtual filesystem (/$bunfs/root/...). Resolving a SQLite file + * there makes `new Database(path)` throw SQLITE_CANTOPEN and — because the + * engine instantiates default stores at import time — kills every CLI + * invocation, including `opencode --version`. + * + * Rule: explicit arg > $ENV override > packaged user-data default > + * source-tree default. Source runs keep the legacy next-to-package path, so + * dev behavior and existing tests are unchanged. Packaged runs fall back to + * a writable per-user data dir. Nothing is ever migrated or replaced + * automatically; point the env var at an existing file to reuse it. + */ + +export const BUNFS_MARKER = '$bunfs'; + +/** True when a module directory lives inside Bun's packaged virtual FS. Pure and unit-testable. */ +export function isPackagedDir(dir: string): boolean { + return dir.includes(BUNFS_MARKER); +} + +/** True when this module itself runs from inside a packaged binary. */ +export function isPackagedRuntime(moduleDir: string = import.meta.dir): boolean { + return isPackagedDir(moduleDir); +} + +/** Writable per-user data dir, following the repo-wide XDG convention. */ +export function userDataDir(): string { + const base = process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'); + return join(base, 'opencode'); +} + +/** Default filename location for a packaged run. */ +export function packagedDbPath(filename: string): string { + return join(userDataDir(), filename); +} + +/** Create the parent directory of a DB path. No-op when it exists. */ +export function ensureParentDir(dbPath: string): void { + mkdirSync(dirname(dbPath), { recursive: true }); +} diff --git a/packages/knowledge-engine/src/retriever.ts b/packages/knowledge-engine/src/retriever.ts index b9106c423ded..b045f35e78af 100644 --- a/packages/knowledge-engine/src/retriever.ts +++ b/packages/knowledge-engine/src/retriever.ts @@ -108,4 +108,13 @@ export class LocalRetriever { } } -export default new LocalRetriever(); +let sharedInstance: LocalRetriever | undefined; + +/** + * Process-shared retriever, created on first use — never at import time. + * Importing this module must not open any database. + */ +export function defaultRetriever(): LocalRetriever { + if (!sharedInstance) sharedInstance = new LocalRetriever(); + return sharedInstance; +} diff --git a/packages/knowledge-engine/src/staging.ts b/packages/knowledge-engine/src/staging.ts index 28afe4dea451..4070de8aae25 100644 --- a/packages/knowledge-engine/src/staging.ts +++ b/packages/knowledge-engine/src/staging.ts @@ -1,5 +1,6 @@ import { Database } from 'bun:sqlite'; import { join, dirname } from 'path'; +import { ensureParentDir, isPackagedRuntime, packagedDbPath } from './paths'; import { approveCandidate, createCandidate, @@ -26,13 +27,15 @@ export const CANDIDATES_DB_FILENAME = 'knowledge-candidates.db'; /** * Single explicit DB location: explicit arg wins, then - * $OPENCODE_KNOWLEDGE_CANDIDATES_DB, otherwise /knowledge-candidates.db. + * $OPENCODE_KNOWLEDGE_CANDIDATES_DB, then a packaged user-data default + * (writable, outside $bunfs), otherwise /knowledge-candidates.db. * Deliberately separate from knowledge.db — staging and production never share a file. */ export function resolveCandidatesDbPath(requested?: string): string { if (requested) return requested; const fromEnv = process.env.OPENCODE_KNOWLEDGE_CANDIDATES_DB; if (fromEnv) return fromEnv; + if (isPackagedRuntime()) return packagedDbPath(CANDIDATES_DB_FILENAME); return join(dirname(import.meta.dir), CANDIDATES_DB_FILENAME); } @@ -118,6 +121,7 @@ export class CandidateStore { constructor(dbPath?: string) { this.dbPath = resolveCandidatesDbPath(dbPath); + ensureParentDir(this.dbPath); this.db = new Database(this.dbPath); this.init(); } diff --git a/packages/knowledge-engine/src/vector-db.ts b/packages/knowledge-engine/src/vector-db.ts index 3ab1c7a3ca0d..7233051948df 100644 --- a/packages/knowledge-engine/src/vector-db.ts +++ b/packages/knowledge-engine/src/vector-db.ts @@ -4,18 +4,22 @@ import { join, dirname } from 'path'; import type { KnowledgeChunk, RetrievalResult, SearchOptions, IndexStats } from './types'; import { LocalEmbedder } from './embedder'; import { applyAbstentionGate } from './abstention'; +import { ensureParentDir, isPackagedRuntime, packagedDbPath } from './paths'; export const KNOWLEDGE_DB_FILENAME = 'knowledge.db'; /** - * Single explicit DB location: $OPENCODE_KNOWLEDGE_DB wins when set, - * otherwise /knowledge.db next to this package. + * Single explicit DB location: explicit arg wins, then $OPENCODE_KNOWLEDGE_DB, + * then a packaged user-data default (writable, outside $bunfs), otherwise + * /knowledge.db next to this package (source runs). * No silent candidate chain — a wrong path must fail loudly, never attach elsewhere. + * Nothing is migrated automatically: point the env var at an existing file to reuse it. */ export function resolveKnowledgeDbPath(requested?: string): string { if (requested) return requested; const fromEnv = process.env.OPENCODE_KNOWLEDGE_DB; if (fromEnv) return fromEnv; + if (isPackagedRuntime()) return packagedDbPath(KNOWLEDGE_DB_FILENAME); return join(dirname(import.meta.dir), KNOWLEDGE_DB_FILENAME); } @@ -25,6 +29,7 @@ export class LocalVectorDB { constructor(dbPath?: string) { this.dbPath = resolveKnowledgeDbPath(dbPath); + ensureParentDir(this.dbPath); this.db = new Database(this.dbPath); this.init(); } @@ -383,5 +388,3 @@ export class LocalVectorDB { this.db.close(); } } - -export default new LocalVectorDB(); diff --git a/packages/knowledge-engine/tests/lazy-init.test.ts b/packages/knowledge-engine/tests/lazy-init.test.ts new file mode 100644 index 000000000000..ec745e149006 --- /dev/null +++ b/packages/knowledge-engine/tests/lazy-init.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; + +/** + * Lazy knowledge initialization (KNOWLEDGE_DB_LAZY_INITIALIZATION). + * + * Importing the engine — the exact chain behind `opencode --version` and + * `opencode session list`, which statically reach the package barrel — + * must neither open nor create any database. Only explicit construction + * (e.g. the `search` command) may initialize storage on demand. + */ +describe('Knowledge lazy initialization', () => { + test('importing the barrel creates no database files', async () => { + const xdg = join('/tmp', `lazy-xdg_${Date.now()}_${Math.floor(Math.random() * 1e6)}`); + mkdirSync(xdg, { recursive: true }); + const prevXdg = process.env.XDG_DATA_HOME; + const prevK = process.env.OPENCODE_KNOWLEDGE_DB; + const prevC = process.env.OPENCODE_KNOWLEDGE_CANDIDATES_DB; + process.env.XDG_DATA_HOME = xdg; + delete process.env.OPENCODE_KNOWLEDGE_DB; + delete process.env.OPENCODE_KNOWLEDGE_CANDIDATES_DB; + try { + await import('../src/index.ts'); + expect(existsSync(join(xdg, 'opencode', 'knowledge.db'))).toBe(false); + expect(existsSync(join(xdg, 'opencode', 'knowledge-candidates.db'))).toBe(false); + expect(existsSync(join(xdg, 'opencode'))).toBe(false); + } finally { + if (prevXdg === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = prevXdg; + if (prevK !== undefined) process.env.OPENCODE_KNOWLEDGE_DB = prevK; + if (prevC !== undefined) process.env.OPENCODE_KNOWLEDGE_CANDIDATES_DB = prevC; + rmSync(xdg, { recursive: true, force: true }); + } + }); + + test('explicit construction still initializes storage on demand', async () => { + const xdg = join('/tmp', `lazy-demand_${Date.now()}_${Math.floor(Math.random() * 1e6)}`); + mkdirSync(xdg, { recursive: true }); + const prevXdg = process.env.XDG_DATA_HOME; + const prevK = process.env.OPENCODE_KNOWLEDGE_DB; + process.env.XDG_DATA_HOME = xdg; + delete process.env.OPENCODE_KNOWLEDGE_DB; + try { + const { LocalVectorDB } = await import('../src/vector-db.ts'); + // Source runs keep the next-to-package default: nothing may appear under XDG. + expect(existsSync(join(xdg, 'opencode'))).toBe(false); + const dbPath = join(xdg, 'opencode', 'knowledge.db'); + const db = new LocalVectorDB(dbPath); + expect(existsSync(dbPath)).toBe(true); + db.close(); + } finally { + if (prevXdg === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = prevXdg; + if (prevK !== undefined) process.env.OPENCODE_KNOWLEDGE_DB = prevK; + rmSync(xdg, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/knowledge-engine/tests/packaged-paths.test.ts b/packages/knowledge-engine/tests/packaged-paths.test.ts new file mode 100644 index 000000000000..7215ec052d5e --- /dev/null +++ b/packages/knowledge-engine/tests/packaged-paths.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, rmSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { isPackagedDir, packagedDbPath, userDataDir } from '../src/paths'; +import { KNOWLEDGE_DB_FILENAME, LocalVectorDB, resolveKnowledgeDbPath } from '../src/vector-db'; +import { CANDIDATES_DB_FILENAME, CandidateStore, resolveCandidatesDbPath } from '../src/staging'; + +function tmpDir(prefix: string): string { + const dir = join('/tmp', `${prefix}_${Date.now()}_${Math.floor(Math.random() * 1e6)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +describe('Packaged knowledge DB paths (KNOWLEDGE_DB_PACKAGED_PATH_FIX)', () => { + test('isPackagedDir detects only Bun virtual-FS paths', () => { + expect(isPackagedDir('/$bunfs/root/packages/knowledge-engine')).toBe(true); + expect(isPackagedDir('/mnt/k/opencode/packages/knowledge-engine')).toBe(false); + expect(isPackagedDir('/tmp/x.db')).toBe(false); + }); + + test('packaged default lives in the writable user-data dir, never under $bunfs', () => { + const p = packagedDbPath(KNOWLEDGE_DB_FILENAME); + expect(p.includes('$bunfs')).toBe(false); + expect(p).toBe(join(userDataDir(), KNOWLEDGE_DB_FILENAME)); + expect(userDataDir()).toBe(join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'opencode')); + }); + + test('source-run default still resolves next to the package (dev unchanged)', () => { + const prev = process.env.OPENCODE_KNOWLEDGE_DB; + delete process.env.OPENCODE_KNOWLEDGE_DB; + try { + const def = resolveKnowledgeDbPath(); + expect(def.includes('$bunfs')).toBe(false); + expect(def.endsWith('/packages/knowledge-engine/knowledge.db')).toBe(true); + } finally { + if (prev !== undefined) process.env.OPENCODE_KNOWLEDGE_DB = prev; + } + }); + + test('explicit requested and env paths keep priority over every default', () => { + expect(resolveKnowledgeDbPath('/x/y.db')).toBe('/x/y.db'); + expect(resolveCandidatesDbPath('/x/y.db')).toBe('/x/y.db'); + const prevK = process.env.OPENCODE_KNOWLEDGE_DB; + const prevC = process.env.OPENCODE_KNOWLEDGE_CANDIDATES_DB; + process.env.OPENCODE_KNOWLEDGE_DB = '/tmp/env-k.db'; + process.env.OPENCODE_KNOWLEDGE_CANDIDATES_DB = '/tmp/env-c.db'; + try { + expect(resolveKnowledgeDbPath()).toBe('/tmp/env-k.db'); + expect(resolveCandidatesDbPath()).toBe('/tmp/env-c.db'); + expect(resolveKnowledgeDbPath('/x/y.db')).toBe('/x/y.db'); + } finally { + if (prevK === undefined) delete process.env.OPENCODE_KNOWLEDGE_DB; + else process.env.OPENCODE_KNOWLEDGE_DB = prevK; + if (prevC === undefined) delete process.env.OPENCODE_KNOWLEDGE_CANDIDATES_DB; + else process.env.OPENCODE_KNOWLEDGE_CANDIDATES_DB = prevC; + } + }); + + test('constructors create a missing parent dir instead of SQLITE_CANTOPEN', () => { + const dir = tmpDir('pkg-paths'); + try { + const kPath = join(dir, 'nested', 'knowledge.db'); + const kdb = new LocalVectorDB(kPath); + expect(existsSync(kPath)).toBe(true); + kdb.close(); + + const cPath = join(dir, 'nested', 'candidates.db'); + const cdb = new CandidateStore(cPath); + expect(existsSync(cPath)).toBe(true); + expect(cdb.path()).toBe(cPath); + cdb.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('candidates default filename stays separate from knowledge.db', () => { + expect(CANDIDATES_DB_FILENAME).not.toBe(KNOWLEDGE_DB_FILENAME); + expect(packagedDbPath(CANDIDATES_DB_FILENAME)).not.toBe(packagedDbPath(KNOWLEDGE_DB_FILENAME)); + }); +}); From f69c53119240dde2c376d30a73681689cc1cb647 Mon Sep 17 00:00:00 2001 From: AH Date: Wed, 16 Sep 2026 00:09:31 -0700 Subject: [PATCH 8/8] docs(knowledge): record engineering memory behavior review --- BRANCH_BEHAVIOR_REVIEW.md | 93 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 BRANCH_BEHAVIOR_REVIEW.md diff --git a/BRANCH_BEHAVIOR_REVIEW.md b/BRANCH_BEHAVIOR_REVIEW.md new file mode 100644 index 000000000000..ea7c6bb6cf17 --- /dev/null +++ b/BRANCH_BEHAVIOR_REVIEW.md @@ -0,0 +1,93 @@ +# Engineering Memory Branch — Behavior Review + +**Branch:** `feat/engineering-memory-v1` @ `b6a571d982` +**Base:** `origin/dev` (`origin/main` does not exist) +**Scope:** `origin/dev..HEAD` — 7 commits, 64 files, +6959/-57 +**Method:** read + test only. No rebase, PR, merge, source edits, commits, or pushes. +**Date:** 2026-09-16 + +## Risk 1 — First-turn retrieval + +- **Claim:** Knowledge retrieval on the first turn cannot crash a session, injects nothing when disabled or empty, and adds no serialized latency. +- **Evidence:** + - Gated by `Flag.OPENCODE_EXPERIMENTAL_KNOWLEDGE` (`guidance.ts:72` returns `SystemContext.empty` before any I/O); flag is falsy unless `OPENCODE_EXPERIMENTAL_KNOWLEDGE`/`OPENCODE_EXPERIMENTAL` is set (`flag.ts:11-13,60-62`). + - Four nested fail-to-empty layers: `retrieval.ts` init→noop (incl. `catchDefect`), guidance `peekPending`, history load, and `retrieval.search` — all `catch`/`catchDefect` to empty. + - Budgets: `MIN_QUERY_CHARS=12`, `TOP_K=3`, `MAX_TOTAL_CHARS=2500`, `MAX_PER_DOC_CHARS=1000`; zero docs → `SystemContext.empty` (no injection). Engine-level abstention gate on top. + - Runs concurrently with 3 other context loads (`llm.ts` `Effect.all`, `concurrency: "unbounded"`). + - Flag-off test proves zero queries and system `["Initial context"]`; flag-on tests prove pending-steer/queue/history paths. +- **Commands / exits:** + - `bun test test/knowledge/` (packages/core) → 9 pass / 0 fail, exit 0. +- **Affected files:** `packages/core/src/knowledge/guidance.ts`, `packages/core/src/knowledge/retrieval.ts`, `packages/core/src/session/runner/llm.ts`, `packages/core/src/flag/flag.ts`. +- **Risk:** No test drives a *throwing* retriever through `guidance.load` (failure path is code-evident, not test-covered). `vectorSearch` is a full-table scan O(n) — fine at ~523 rows, unbounded growth unmeasured. No live first-turn latency measured. +- **Verdict:** PASS_WITH_LIMITATION + +## Risk 2 — learn.ts write paths + +- **Claim:** No automatic admission; staging and knowledge writes are separated; failures leave no partial knowledge behind. +- **Evidence:** + - `resolveMemoryPaths` refuses `staging === knowledge` (learn.ts:108-118). + - `propose` writes staging only, all-pending, no approval/admission/knowledge writes (learn.ts:124-147). + - Review commands are staging-only; `approve`/`reject`/`supersede` go through `decide()` (pending-only/B0 validity). No approve-and-admit path exists (separate yargs commands). + - `admit` refuses non-approved (`admission.ts` `toChunk` gate + `validateCandidate` problems check), verifies persistence (self-match ≥0.99) and retrievability, compensates by deleting its own chunk on post-write failure and rethrows. Staging is only read (`get`/`list`) during admit. + - All 7 DB-touching functions open handles explicitly and close in `finally`. +- **Commands / exits:** + - `bun test test/cli/learn-memory.test.ts` (packages/opencode) → 29 pass / 0 fail, exit 0. +- **Affected files:** `packages/opencode/src/cli/cmd/learn.ts`, `packages/knowledge-engine/src/admission.ts`, `packages/knowledge-engine/src/staging.ts`. +- **Risk:** `propose` stages rows one-by-one with no cross-row transaction — a mid-batch failure leaves partial staging rows (audit store, low severity, unreported count). No test asserts the compensation path against a failing verifier (code-evident only). +- **Verdict:** PASS_WITH_LIMITATION + +## Risk 3 — Empty-XDG search (packaged binary) + +- **Claim:** With no corpus and no env override, `search` starts, reports honestly, touches nothing else, and creates storage only on demand. +- **Evidence:** Fresh `XDG_DATA_HOME=/tmp/opencode/empty-xdg`, env unset, rebuilt binary: `search "useEffect cleanup"` → exit 0, honest Arabic no-results message, created only `/opencode/knowledge.db(+wal/shm)`. Corpus `packages/knowledge-engine/knowledge.db` untouched (Sep 13 mtime). Earlier same-setup runs: `--version` and `session list` exit 0 with zero knowledge files created. +- **Commands / exits:** binary `search` → exit 0; `find` shows only the on-demand DB; `ls -l` corpus unchanged. +- **Affected files:** `packages/knowledge-engine/src/paths.ts`, `src/vector-db.ts`, `src/staging.ts`, `src/cli.ts` (opencode `search` command path). +- **Risk:** The no-results message does not distinguish "empty database" from "no match" and does not point at `OPENCODE_KNOWLEDGE_DB` — usability gap, not a defect. No synthetic data was created for this proof. +- **Verdict:** PASS_WITH_LIMITATION + +## Risk 4 — Barrel default-instance removal + +- **Claim:** Removing eager default singletons breaks no declared API consumed in-repo; all public entry points import side-effect free. +- **Evidence:** + - Repo-wide search: zero default imports of the barrel or of `manager`/`retriever`/`middleware`/`vector-db` outside the engine itself; zero `@opencode-ai/knowledge-engine/` imports anywhere (only the pre-existing type-only barrel import in core). + - `package.json` subpath exports (`.`, `./retriever`, `./integration`, `./manager`) keep every named export; only the instantiating defaults were removed. Remaining defaults are side-effect free (classes, pure embedder/extractor). + - Source-mode import of all four entry points under a temp `XDG_DATA_HOME` → ok, exit 0, zero files created. Packaged barrel proof → `barrel-import-ok`, exit 0, zero files created. +- **Commands / exits:** entry-point import script → exit 0; `find` empty. +- **Affected files:** `packages/knowledge-engine/src/index.ts`, `src/retriever.ts`, `src/manager.ts`, `src/middleware.ts`, `src/agents.ts`, `src/agent-integration.ts`, `src/vector-db.ts`, `src/cli.ts`. +- **Risk:** Out-of-repo consumers (external plugins/SDKs) are undiscoverable from here; if any relied on the removed defaults, this is a breaking change for them. No deprecation alias was left. +- **Verdict:** PASS_WITH_LIMITATION + +## Commit scope classification (content-level) + +| Commit | Files | Verdict | +|---|---|---| +| `1d4420a045` feat(knowledge) — 30 files, all in `packages/knowledge-engine/` | ATOMIC_AND_COHERENT | +| `d6b3a8bf54` feat(core) — knowledge/{guidance,retrieval}, flag, session wiring + tests | ATOMIC_AND_COHERENT | +| `efda00b601` feat(cli) — learn/project/search + runtime wiring + learn-memory test (+mechanical bun.lock) | ATOMIC_AND_COHERENT | +| `3ccd908349` chore(git) — `.gitignore` only | ATOMIC_AND_COHERENT | +| `3ab225dc24` feat(opencode) — MCP codemode + skills + report (reviewed in-session) | ATOMIC_AND_COHERENT | +| `70726bd70d` fix(core) — 1-line mergeAll + contract test (reviewed in-session) | ATOMIC_AND_COHERENT | +| `b6a571d982` fix(knowledge) — 12 files, all in `packages/knowledge-engine/` (reviewed in-session) | ATOMIC_AND_COHERENT | + +No MIXED_SCOPE, no REQUIRES_SPLIT. Historical note: the packaged-startup defect was introduced in `1d4420a045` (eager defaults) and repaired in-branch by `b6a571d982` — the tip is coherent, but a reviewer reading commits in order should know the middle of the stack does not start packaged-clean on its own. + +## Updated checklist (evidence only) + +- [x] Working Tree clean; local == fork @ `b6a571d982` +- [x] Correct base is `origin/dev` (`origin/main` does not exist) +- [x] 7-commit log known; titles verified +- [x] No DB/dist/Playwright outputs tracked +- [x] Per-commit scope isolation proven by content (table above) +- [x] First-turn retrieval behavior reviewed → PASS_WITH_LIMITATION +- [x] learn.ts write paths reviewed → PASS_WITH_LIMITATION +- [x] Empty-XDG behavior reviewed → PASS_WITH_LIMITATION +- [x] Public export compatibility reviewed → PASS_WITH_LIMITATION +- [x] Targeted tests completed (9 core knowledge, 29 learn-memory, 80 engine, packaged proofs) +- [ ] Latest origin/dev fetched +- [ ] Rebase impact reviewed +- [ ] CI result available +- [ ] Final PR diff reviewed + +``` +ENGINEERING_MEMORY_BRANCH_BEHAVIOR_REVIEW_COMPLETE +```