From 0b72ded5f31503235d015da7aad7df11c34ab421 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Thu, 1 Oct 2026 22:50:49 +0530 Subject: [PATCH 01/24] feat(graph,repos): integrate enterprise code repo AST indexing and optimize knowledge graph --- ai/graph/EntityResolver.js | 44 +- ai/graph/GraphBuilder.js | 78 ++- ai/graph/GraphDB.js | 9 +- ai/graph/sources/CodeKnowledgeSource.js | 478 ------------- ai/graph/sources/code/CodeEvidenceBuilder.js | 88 +++ ai/graph/sources/code/CodeKnowledgeSource.js | 372 +++++++++++ ai/graph/sources/code/RepositoryScanner.js | 156 +++++ ai/graph/sources/code/TreeSitterCodeParser.js | 584 ++++++++++++++++ ai/graph/sources/code/index.js | 11 + ai/queue/GraphWorker.js | 3 + electron/ai/aiHandlers.cjs | 9 +- electron/ai/workerProcess.cjs | 51 +- electron/lib/ipc/coreIpc.cjs | 169 ++++- electron/tools/EnterpriseToolSuite.cjs | 334 ++++++++- package-lock.json | 21 +- package.json | 2 + src/components/AttachedReposPage.jsx | 632 ++++-------------- src/components/KnowledgeGraph.jsx | 205 +++--- src/components/repos/AttachRepoModal.jsx | 135 ++++ src/components/repos/RepoCard.jsx | 137 ++++ src/components/repos/RepoStatusBadge.jsx | 41 ++ src/components/repos/index.js | 3 + src/styles/AttachedReposPage.css | 460 +++++++------ tests/CodeEvidenceBuilder.test.js | 46 ++ tests/CodeKnowledgeSource.test.js | 74 +- tests/RepositoryScanner.test.js | 80 +++ tests/manage_repositories.test.js | 82 +++ 27 files changed, 2991 insertions(+), 1313 deletions(-) delete mode 100644 ai/graph/sources/CodeKnowledgeSource.js create mode 100644 ai/graph/sources/code/CodeEvidenceBuilder.js create mode 100644 ai/graph/sources/code/CodeKnowledgeSource.js create mode 100644 ai/graph/sources/code/RepositoryScanner.js create mode 100644 ai/graph/sources/code/TreeSitterCodeParser.js create mode 100644 ai/graph/sources/code/index.js create mode 100644 src/components/repos/AttachRepoModal.jsx create mode 100644 src/components/repos/RepoCard.jsx create mode 100644 src/components/repos/RepoStatusBadge.jsx create mode 100644 src/components/repos/index.js create mode 100644 tests/CodeEvidenceBuilder.test.js create mode 100644 tests/RepositoryScanner.test.js create mode 100644 tests/manage_repositories.test.js diff --git a/ai/graph/EntityResolver.js b/ai/graph/EntityResolver.js index e2245b7..e8f81ab 100644 --- a/ai/graph/EntityResolver.js +++ b/ai/graph/EntityResolver.js @@ -7,6 +7,48 @@ const { createLogger } = require('../core/logger'); const log = createLogger('EntityResolver'); +const CANONICAL_TYPE_MAP = { + person: 'Person', + organization: 'Organization', + company: 'Company', + location: 'Location', + event: 'Event', + concept: 'Concept', + project: 'Project', + task: 'Task', + decision: 'Decision', + idea: 'Idea', + tag: 'Tag', + image: 'Image', + document: 'Document', + folder: 'Folder', + workspace: 'Workspace', + note: 'Note', + section: 'Section', + externalurl: 'ExternalURL', + external_url: 'ExternalURL', + codeblock: 'CodeBlock', + diagram: 'Diagram', + keyterm: 'KeyTerm', + formula: 'Formula', + callout: 'Callout', + repo: 'Repo', + codemodule: 'CodeModule', + codeclass: 'CodeClass', + codeinterface: 'CodeInterface', + codefunction: 'CodeFunction', + apiendpoint: 'APIEndpoint', + dbmodel: 'DBModel' +}; + +function canonicalizeType(t) { + if (!t) return 'Concept'; + const clean = String(t).trim(); + const key = clean.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (CANONICAL_TYPE_MAP[key]) return CANONICAL_TYPE_MAP[key]; + return clean.charAt(0).toUpperCase() + clean.slice(1); +} + class EntityResolver { constructor(graphDb, embeddingService = null) { this.graphDb = graphDb; @@ -185,7 +227,7 @@ class EntityResolver { } } - return proposedType || 'Concept'; + return canonicalizeType(proposedType); } /** diff --git a/ai/graph/GraphBuilder.js b/ai/graph/GraphBuilder.js index 4237702..f8171b2 100644 --- a/ai/graph/GraphBuilder.js +++ b/ai/graph/GraphBuilder.js @@ -65,7 +65,7 @@ class GraphBuilder { const ExcalidrawKnowledgeSource = require('./sources/ExcalidrawKnowledgeSource'); const DrawioKnowledgeSource = require('./sources/DrawioKnowledgeSource'); const MermaidKnowledgeSource = require('./sources/MermaidKnowledgeSource'); - const CodeKnowledgeSource = require('./sources/CodeKnowledgeSource'); + const { CodeKnowledgeSource } = require('./sources/code'); const registry = new KnowledgeSourceRegistry(); @@ -119,6 +119,29 @@ class GraphBuilder { : `ent-${item.source.sourceType()}-${String(ent.name).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; this.graphDb.upsertEntity({ id, name: ent.name, canonical_name: ent.name, type: ent.type || 'Entity', properties: ent.properties || {} }); } + + const evidenceMap = new Map(); + if (Array.isArray(evidence)) { + for (const ev of evidence) { + const evId = evidenceStore.addEvidence({ + sourceId: ev.sourceId || item.path, + extractor: ev.extractor || item.source.sourceType(), + subjectText: ev.subjectText || ev.subject_text || item.path, + subjectSpanStart: ev.subjectSpanStart ?? null, + subjectSpanEnd: ev.subjectSpanEnd ?? null, + predicateText: ev.predicateText || ev.predicate_text || 'related_to', + objectText: ev.objectText || ev.object_text || '', + objectSpanStart: ev.objectSpanStart ?? null, + objectSpanEnd: ev.objectSpanEnd ?? null, + rawSentence: ev.rawSentence || ev.raw_sentence || ev.subjectText || item.path, + confidence: ev.confidence || item.source.baseConfidence() || 1.0 + }); + if (evId && ev.subjectText && ev.predicateText && ev.objectText) { + evidenceMap.set(`${ev.subjectText}::${ev.predicateText}::${ev.objectText}`, evId); + } + } + } + for (const rel of relationships) { const srcId = this.graphService?.entityResolver ? this.graphService.entityResolver.generateEntityId(rel.source_name, rel.source_type || 'Entity') @@ -129,19 +152,15 @@ class GraphBuilder { if (srcId !== tgtId) { this.graphDb.upsertEntity({ id: srcId, name: rel.source_name, canonical_name: rel.source_name, type: rel.source_type || 'Entity' }); this.graphDb.upsertEntity({ id: tgtId, name: rel.target_name, canonical_name: rel.target_name, type: rel.target_type || 'Entity' }); - this.graphDb.upsertRelationship({ source_id: srcId, target_id: tgtId, type: rel.type, weight: rel.weight, confidence: rel.confidence }); - } - } - if (Array.isArray(evidence)) { - for (const ev of evidence) { - evidenceStore.addEvidence({ - sourceId: item.path, + const evId = evidenceMap.get(`${rel.source_name}::${rel.type}::${rel.target_name}`) || null; + this.graphDb.upsertRelationship({ + source_id: srcId, + target_id: tgtId, + type: rel.type, + weight: rel.weight, + confidence: rel.confidence, extractor: item.source.sourceType(), - subjectText: ev.subjectText || ev.subject_text || item.path, - predicateText: ev.predicateText || ev.predicate_text || 'related_to', - objectText: ev.objectText || ev.object_text || '', - rawSentence: ev.rawSentence || ev.raw_sentence || ev.subjectText || item.path, - confidence: ev.confidence || item.source.baseConfidence() || 1.0 + evidence_id: evId }); } } @@ -204,6 +223,39 @@ class GraphBuilder { try { this.graphDb.db.exec('PRAGMA ANALYZE;'); } catch { /* ignore */ } } + // Update attached repos scan stats in metadata.json + if (attachedRepos.length > 0 && workspaceRoot) { + try { + const metaPath = path.join(workspaceRoot, '.notes-app', 'metadata.json'); + if (fs.existsSync(metaPath)) { + const metaObj = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + if (Array.isArray(metaObj.attachedRepos)) { + let changed = false; + for (const r of metaObj.attachedRepos) { + if (!r || !r.id) continue; + let symbolCount = 0; + try { + const countRow = this.graphDb.db.prepare( + "SELECT COUNT(*) as count FROM entities WHERE properties LIKE ?" + ).get(`%"repoId":"${r.id}"%`); + symbolCount = countRow?.count || 0; + } catch { /* ignore count query */ } + + r.lastScannedAt = new Date().toISOString(); + r.status = 'indexed'; + r.symbolCount = symbolCount; + changed = true; + } + if (changed) { + fs.writeFileSync(metaPath, JSON.stringify(metaObj, null, 2), 'utf8'); + } + } + } + } catch (metaErr) { + log.warn('Failed to update attached repo scan stats in metadata.json:', metaErr.message); + } + } + log.info(`Knowledge Graph rebuild complete. Processed: ${processedCount}, Failed: ${failedCount}`); this._logDb?.addLog('graph', `Knowledge Graph rebuild complete. Processed: ${processedCount}, Failed: ${failedCount}`, 'info', { processedCount, diff --git a/ai/graph/GraphDB.js b/ai/graph/GraphDB.js index 3e8d1eb..93731d1 100644 --- a/ai/graph/GraphDB.js +++ b/ai/graph/GraphDB.js @@ -376,8 +376,13 @@ class GraphDB { } catch { /* ignore junction insert error */ } } } catch (err) { - if (err.message?.includes('FOREIGN KEY') && evidence_id) { - stmt.run(source_id, target_id, type, weight, clampedConfidence, extractor, metadataJson, null); + if (err.message?.includes('FOREIGN KEY')) { + try { + const now = new Date().toISOString(); + this.db.prepare('INSERT OR IGNORE INTO entities (id, name, canonical_name, type, updated_at) VALUES (?, ?, ?, ?, ?)').run(source_id, source_id, source_id, 'Entity', now); + this.db.prepare('INSERT OR IGNORE INTO entities (id, name, canonical_name, type, updated_at) VALUES (?, ?, ?, ?, ?)').run(target_id, target_id, target_id, 'Entity', now); + stmt.run(source_id, target_id, type, weight, clampedConfidence, extractor, metadataJson, null); + } catch { /* ignore fallback error */ } } else { throw err; } diff --git a/ai/graph/sources/CodeKnowledgeSource.js b/ai/graph/sources/CodeKnowledgeSource.js deleted file mode 100644 index 0df003d..0000000 --- a/ai/graph/sources/CodeKnowledgeSource.js +++ /dev/null @@ -1,478 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const KnowledgeSource = require('./KnowledgeSource'); - -// Supported extensions -const CODE_EXTENSIONS = new Set([ - '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', - '.py', - '.go', - '.rs', - '.java', - '.c', '.cpp', '.h', '.hpp', '.cs', - '.rb', '.php', - '.vue', '.svelte' -]); - -const IGNORED_DIRECTORIES = new Set([ - 'node_modules', 'dist', 'build', '.git', '.svn', '.hg', - 'vendor', 'coverage', '.next', '.nuxt', '.output', - '__pycache__', '.pytest_cache', '.venv', 'venv', 'env', - 'bin', 'obj', 'target', '.idea', '.vscode' -]); - -class CodeKnowledgeSource extends KnowledgeSource { - constructor(attachedRepos = []) { - super(); - this.attachedRepos = Array.isArray(attachedRepos) ? attachedRepos : []; - } - - sourceType() { - return 'code'; - } - - baseConfidence() { - return 0.95; - } - - supports(filePath) { - if (!filePath || typeof filePath !== 'string') return false; - const ext = path.extname(filePath).toLowerCase(); - return CODE_EXTENSIONS.has(ext); - } - - /** - * Discovers code files from all attached repositories. - */ - discover(workspaceRoot) { - const discovered = []; - - // Also check if workspaceRoot itself has code or if attachedRepos are configured - let repos = [...this.attachedRepos]; - - // Attempt to load attached repos from workspace metadata if not pre-provided - if (repos.length === 0 && workspaceRoot) { - try { - const metaPath = path.join(workspaceRoot, '.notes-app', 'metadata.json'); - if (fs.existsSync(metaPath)) { - const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); - if (Array.isArray(meta.attachedRepos)) { - repos = meta.attachedRepos; - } - } - } catch { /* ignore */ } - } - - for (const repo of repos) { - if (!repo || !repo.path || !fs.existsSync(repo.path)) continue; - try { - this._walkRepo(repo.path, repo, discovered, 0, 1500); - } catch (err) { - console.warn(`[CodeKnowledgeSource] Failed walking repo ${repo.name || repo.path}:`, err.message); - } - } - - return discovered; - } - - _walkRepo(dir, repo, result, depth = 0, maxFiles = 1500) { - if (depth > 12 || result.length >= maxFiles) return; - let entries = []; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - if (result.length >= maxFiles) break; - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - if (!IGNORED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.')) { - this._walkRepo(fullPath, repo, result, depth + 1, maxFiles); - } - } else if (entry.isFile()) { - const ext = path.extname(entry.name).toLowerCase(); - if (CODE_EXTENSIONS.has(ext)) { - // Check file size (skip minified / huge files > 512KB) - try { - const stat = fs.statSync(fullPath); - if (stat.size < 512 * 1024) { - result.push(fullPath); - } - } catch { /* ignore */ } - } - } - } - } - - /** - * Find matching repo for a file path - */ - _getRepoForFile(filePath) { - const normalized = path.resolve(filePath); - for (const repo of this.attachedRepos) { - if (repo && repo.path && normalized.startsWith(path.resolve(repo.path))) { - return repo; - } - } - return { name: 'Repository', id: 'repo-unknown' }; - } - - /** - * Extract code entities (Classes, Functions, Routes, Models, Modules) - */ - async extractEntities(filePath, content = null) { - const entities = []; - let fileContent = content; - if (fileContent === null && fs.existsSync(filePath)) { - try { - fileContent = fs.readFileSync(filePath, 'utf8'); - } catch { - return entities; - } - } - if (!fileContent) return entities; - - const repo = this._getRepoForFile(filePath); - const repoPrefix = repo.name || 'code'; - const relPath = repo.path ? path.relative(repo.path, filePath).replace(/\\/g, '/') : path.basename(filePath); - const ext = path.extname(filePath).toLowerCase(); - - // 1. File / Module Entity - entities.push({ - name: `${repoPrefix}/${relPath}`, - canonical_name: `${repoPrefix}/${relPath}`, - type: 'CodeModule', - properties: { - repoId: repo.id, - repoName: repoPrefix, - filePath: relPath, - absolutePath: filePath, - language: ext.replace('.', ''), - lines: fileContent.split('\n').length - } - }); - - const lines = fileContent.split('\n'); - - // 2. JS / TS Parsing - if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext)) { - this._extractJsTsEntities(lines, repoPrefix, relPath, filePath, entities); - } - // 3. Python Parsing - else if (ext === '.py') { - this._extractPythonEntities(lines, repoPrefix, relPath, filePath, entities); - } - // 4. Generic (Go, Rust, etc.) - else { - this._extractGenericEntities(lines, repoPrefix, relPath, filePath, ext, entities); - } - - return entities; - } - - _extractJsTsEntities(lines, repoPrefix, relPath, filePath, entities) { - const classRegex = /(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z0-9_$]+)(?:\s+extends\s+([A-Za-z0-9_$]+))?/g; - const interfaceRegex = /(?:export\s+)?interface\s+([A-Za-z0-9_$]+)/g; - const funcRegex = /(?:export\s+)?(?:async\s+)?function\s+([A-Za-z0-9_$]+)\s*\(/g; - const constFuncRegex = /(?:export\s+)?const\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z0-9_$]+)\s*=>/g; - const routeRegex = /(?:app|router|server)\.(get|post|put|delete|patch|options|use)\s*\(\s*['"`]([^'"`]+)['"`]/gi; - const modelRegex = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:mongoose\.model|new\s+Schema|model)\s*\(/g; - - lines.forEach((lineText, index) => { - const lineNum = index + 1; - const trimmed = lineText.trim(); - if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return; - - // Classes - let match; - classRegex.lastIndex = 0; - while ((match = classRegex.exec(lineText)) !== null) { - const className = match[1]; - entities.push({ - name: className, - canonical_name: `${repoPrefix}::${className}`, - type: 'CodeClass', - properties: { - filePath: relPath, - absolutePath: filePath, - line: lineNum, - extends: match[2] || null, - kind: 'class' - } - }); - } - - // Interfaces - interfaceRegex.lastIndex = 0; - while ((match = interfaceRegex.exec(lineText)) !== null) { - const ifaceName = match[1]; - entities.push({ - name: ifaceName, - canonical_name: `${repoPrefix}::${ifaceName}`, - type: 'CodeInterface', - properties: { - filePath: relPath, - absolutePath: filePath, - line: lineNum, - kind: 'interface' - } - }); - } - - // Functions - funcRegex.lastIndex = 0; - while ((match = funcRegex.exec(lineText)) !== null) { - const funcName = match[1]; - entities.push({ - name: `${funcName}()`, - canonical_name: `${repoPrefix}::${relPath}::${funcName}`, - type: 'CodeFunction', - properties: { - filePath: relPath, - absolutePath: filePath, - line: lineNum, - symbol: funcName, - kind: 'function' - } - }); - } - - // Const Arrow Functions - constFuncRegex.lastIndex = 0; - while ((match = constFuncRegex.exec(lineText)) !== null) { - const funcName = match[1]; - entities.push({ - name: `${funcName}()`, - canonical_name: `${repoPrefix}::${relPath}::${funcName}`, - type: 'CodeFunction', - properties: { - filePath: relPath, - absolutePath: filePath, - line: lineNum, - symbol: funcName, - kind: 'arrow_function' - } - }); - } - - // API Endpoints - routeRegex.lastIndex = 0; - while ((match = routeRegex.exec(lineText)) !== null) { - const method = match[1].toUpperCase(); - const routePath = match[2]; - entities.push({ - name: `${method} ${routePath}`, - canonical_name: `${method} ${routePath}`, - type: 'APIEndpoint', - properties: { - method, - route: routePath, - filePath: relPath, - line: lineNum - } - }); - } - - // Models - modelRegex.lastIndex = 0; - while ((match = modelRegex.exec(lineText)) !== null) { - const modelName = match[1]; - entities.push({ - name: modelName, - canonical_name: `${repoPrefix}::model::${modelName}`, - type: 'DBModel', - properties: { - filePath: relPath, - line: lineNum, - kind: 'schema_model' - } - }); - } - }); - } - - _extractPythonEntities(lines, repoPrefix, relPath, filePath, entities) { - const classRegex = /^class\s+([A-Za-z0-9_$]+)(?:\(([^)]+)\))?:/g; - const defRegex = /^(?:\s+)?def\s+([A-Za-z0-9_$]+)\s*\(/g; - const routeRegex = /@(?:app|router|api)\.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/g; - - lines.forEach((lineText, index) => { - const lineNum = index + 1; - const trimmed = lineText.trim(); - if (trimmed.startsWith('#')) return; - - let match; - classRegex.lastIndex = 0; - while ((match = classRegex.exec(trimmed)) !== null) { - const className = match[1]; - entities.push({ - name: className, - canonical_name: `${repoPrefix}::${className}`, - type: 'CodeClass', - properties: { - filePath: relPath, - absolutePath: filePath, - line: lineNum, - baseClass: match[2] || null - } - }); - } - - defRegex.lastIndex = 0; - while ((match = defRegex.exec(trimmed)) !== null) { - const funcName = match[1]; - if (!funcName.startsWith('__')) { - entities.push({ - name: `${funcName}()`, - canonical_name: `${repoPrefix}::${relPath}::${funcName}`, - type: 'CodeFunction', - properties: { - filePath: relPath, - absolutePath: filePath, - line: lineNum, - symbol: funcName - } - }); - } - } - - routeRegex.lastIndex = 0; - while ((match = routeRegex.exec(trimmed)) !== null) { - const method = match[1].toUpperCase(); - const routePath = match[2]; - entities.push({ - name: `${method} ${routePath}`, - canonical_name: `${method} ${routePath}`, - type: 'APIEndpoint', - properties: { - method, - route: routePath, - filePath: relPath, - line: lineNum - } - }); - } - }); - } - - _extractGenericEntities(lines, repoPrefix, relPath, filePath, ext, entities) { - const goFuncRegex = /^func\s+(?:\([^)]+\)\s+)?([A-Za-z0-9_$]+)\s*\(/g; - const goStructRegex = /^type\s+([A-Za-z0-9_$]+)\s+struct/g; - const rustFnRegex = /^pub\s+fn\s+([A-Za-z0-9_$]+)\s*\(/g; - const rustStructRegex = /^pub\s+struct\s+([A-Za-z0-9_$]+)/g; - - lines.forEach((lineText, index) => { - const lineNum = index + 1; - const trimmed = lineText.trim(); - let match; - - if (ext === '.go') { - goFuncRegex.lastIndex = 0; - if ((match = goFuncRegex.exec(trimmed)) !== null) { - entities.push({ - name: `${match[1]}()`, - canonical_name: `${repoPrefix}::${relPath}::${match[1]}`, - type: 'CodeFunction', - properties: { filePath: relPath, line: lineNum, symbol: match[1] } - }); - } - goStructRegex.lastIndex = 0; - if ((match = goStructRegex.exec(trimmed)) !== null) { - entities.push({ - name: match[1], - canonical_name: `${repoPrefix}::${match[1]}`, - type: 'CodeClass', - properties: { filePath: relPath, line: lineNum } - }); - } - } else if (ext === '.rs') { - rustFnRegex.lastIndex = 0; - if ((match = rustFnRegex.exec(trimmed)) !== null) { - entities.push({ - name: `${match[1]}()`, - canonical_name: `${repoPrefix}::${relPath}::${match[1]}`, - type: 'CodeFunction', - properties: { filePath: relPath, line: lineNum, symbol: match[1] } - }); - } - rustStructRegex.lastIndex = 0; - if ((match = rustStructRegex.exec(trimmed)) !== null) { - entities.push({ - name: match[1], - canonical_name: `${repoPrefix}::${match[1]}`, - type: 'CodeClass', - properties: { filePath: relPath, line: lineNum } - }); - } - } - }); - } - - /** - * Extract relationships (CONTAINS, IMPORTS) - */ - async extractRelationships(filePath, content = null) { - const relationships = []; - let fileContent = content; - if (fileContent === null && fs.existsSync(filePath)) { - try { - fileContent = fs.readFileSync(filePath, 'utf8'); - } catch { - return relationships; - } - } - if (!fileContent) return relationships; - - const repo = this._getRepoForFile(filePath); - const repoPrefix = repo.name || 'code'; - const relPath = repo.path ? path.relative(repo.path, filePath).replace(/\\/g, '/') : path.basename(filePath); - const moduleName = `${repoPrefix}/${relPath}`; - - // 1. Repo -> Module CONTAINS - relationships.push({ - source_name: repoPrefix, - source_type: 'Repo', - target_name: moduleName, - target_type: 'CodeModule', - type: 'CONTAINS', - weight: 1.0, - confidence: 1.0 - }); - - // 2. Extract Import relationships - const importRegex = /(?:import\s+.*?from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g; - let match; - while ((match = importRegex.exec(fileContent)) !== null) { - const targetImport = match[1] || match[2]; - if (targetImport && (targetImport.startsWith('.') || targetImport.startsWith('/'))) { - relationships.push({ - source_name: moduleName, - source_type: 'CodeModule', - target_name: targetImport, - target_type: 'CodeModule', - type: 'IMPORTS', - weight: 0.8, - confidence: 0.9 - }); - } - } - - return relationships; - } - - async extractEvidence(filePath, _content = null) { - return []; - } - - extractMetadata(filePath, _content = null) { - return { - source: 'code', - path: filePath - }; - } -} - -module.exports = CodeKnowledgeSource; diff --git a/ai/graph/sources/code/CodeEvidenceBuilder.js b/ai/graph/sources/code/CodeEvidenceBuilder.js new file mode 100644 index 0000000..7291621 --- /dev/null +++ b/ai/graph/sources/code/CodeEvidenceBuilder.js @@ -0,0 +1,88 @@ +/** + * CodeEvidenceBuilder + * Generates deterministic evidence records for code-derived graph relationships. + * Every relationship from a repository must have traceable provenance. + */ + +const path = require('path'); + +class CodeEvidenceBuilder { + /** + * Build a list of evidence objects for a set of code relationships. + * + * @param {object} params + * @param {string} params.repoId - repository ID + * @param {string} params.repoName - repository display name + * @param {string} params.filePath - absolute path to the source file + * @param {string} params.relPath - workspace-relative path (repoName/src/file.ts) + * @param {Array} params.relationships - raw relationships from extractor + * @returns {Array<{relationship: object, evidence: object}>} + */ + buildForRelationships({ repoId, repoName, filePath, relPath, relationships }) { + const result = []; + + for (const rel of relationships) { + const evidence = this._evidenceForRelationship({ + repoId, + repoName, + filePath, + relPath, + rel + }); + result.push({ relationship: rel, evidence }); + } + + return result; + } + + /** + * Build evidence for a single relationship. + */ + _evidenceForRelationship({ repoId, repoName, filePath, relPath, rel }) { + // Construct a human-readable raw sentence describing where this relationship comes from + const rawSentence = this._describeRelationship(rel, relPath, repoName); + + return { + sourceId: filePath, // absolute file path — consistent with note evidence + extractor: 'code', // deterministic, not AI + subjectText: rel.source_name, + predicateText: rel.type, + objectText: rel.target_name, + rawSentence, + confidence: rel.confidence || 1.0, + // Additional code-specific provenance metadata stored in rawSentence + _meta: { + repoId, + repoName, + relPath, + relationshipType: rel.type, + sourceType: rel.source_type, + targetType: rel.target_type + } + }; + } + + _describeRelationship(rel, relPath, repoName) { + const src = rel.source_name; + const tgt = rel.target_name; + + switch (rel.type) { + case 'CONTAINS': + return `${src} contains ${tgt} in ${repoName}/${relPath}`; + case 'IMPORTS': + return `${src} imports ${tgt} in ${relPath}`; + case 'EXTENDS': + return `${src} extends ${tgt} in ${relPath}`; + case 'IMPLEMENTS': + return `${src} implements ${tgt} in ${relPath}`; + case 'CALLS': + return `${src} calls ${tgt} in ${relPath}`; + case 'DEFINES_ROUTE': + return `${src} defines route ${tgt} in ${relPath}`; + default: + return `${src} ${rel.type} ${tgt} in ${repoName}/${relPath}`; + } + } +} + +module.exports = { CodeEvidenceBuilder }; diff --git a/ai/graph/sources/code/CodeKnowledgeSource.js b/ai/graph/sources/code/CodeKnowledgeSource.js new file mode 100644 index 0000000..3b342cd --- /dev/null +++ b/ai/graph/sources/code/CodeKnowledgeSource.js @@ -0,0 +1,372 @@ +/** + * CodeKnowledgeSource + * KnowledgeSource implementation that indexes code repositories. + * + * Orchestrates: + * RepositoryScanner — file discovery + * TreeSitterCodeParser — AST parsing (with regex fallback) + * CodeEvidenceBuilder — provenance for every relationship + */ + +const fs = require('fs'); +const path = require('path'); +const KnowledgeSource = require('../KnowledgeSource'); +const { RepositoryScanner } = require('./RepositoryScanner'); +const { CodeEvidenceBuilder } = require('./CodeEvidenceBuilder'); +const treeSitterParser = require('./TreeSitterCodeParser'); + +class CodeKnowledgeSource extends KnowledgeSource { + /** + * @param {Array<{id: string, name: string, path: string}>} attachedRepos + */ + constructor(attachedRepos = []) { + super(); + this.attachedRepos = Array.isArray(attachedRepos) ? attachedRepos : []; + this._scanner = new RepositoryScanner(); + this._evidenceBuilder = new CodeEvidenceBuilder(); + this._astCache = new Map(); + } + + sourceType() { return 'code'; } + baseConfidence() { return 0.95; } + + supports(filePath) { + return this._scanner.supports(filePath); + } + + // ─── Discovery ───────────────────────────────────────────────────────────── + + /** + * Discover all code files from all attached repositories. + * Falls back to reading metadata.json when no repos were passed directly. + */ + discover(workspaceRoot) { + const repos = this._resolveRepos(workspaceRoot); + const discovered = []; + + for (const repo of repos) { + if (!repo || !repo.path || !fs.existsSync(repo.path)) continue; + const files = this._scanner.scan(repo.path); + discovered.push(...files); + } + + return discovered; + } + + _resolveRepos(workspaceRoot) { + if (this.attachedRepos.length > 0) return this.attachedRepos; + + // Fallback: load from workspace metadata.json + if (!workspaceRoot) return []; + try { + const metaPath = path.join(workspaceRoot, '.notes-app', 'metadata.json'); + if (fs.existsSync(metaPath)) { + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + if (Array.isArray(meta.attachedRepos)) { + // Also populate this.attachedRepos so _getRepoForFile works correctly + this.attachedRepos = meta.attachedRepos; + return this.attachedRepos; + } + } + } catch { /* ignore read errors */ } + return []; + } + + // ─── Repo resolution ─────────────────────────────────────────────────────── + + /** + * Find the repo entry that owns filePath. + * Returns null if the file doesn't belong to any attached repo (prevents phantom provenance). + */ + _getRepoForFile(filePath) { + const normalized = path.resolve(filePath); + for (const repo of this.attachedRepos) { + if (repo && repo.path && normalized.startsWith(path.resolve(repo.path))) { + return repo; + } + } + return null; // not null-coalesced to a fake repo — let callers handle this + } + + // ─── AST cache ───────────────────────────────────────────────────────────── + + async _getAstParsed(filePath, fileContent, repoPrefix, relPath) { + const cacheKey = `${filePath}:${fileContent.length}`; + if (this._astCache.has(cacheKey)) return this._astCache.get(cacheKey); + + const result = await treeSitterParser.parse(filePath, fileContent, repoPrefix, relPath); + if (result) this._astCache.set(cacheKey, result); + return result; + } + + // ─── Entities ────────────────────────────────────────────────────────────── + + async extractEntities(filePath, content = null) { + const fileContent = content ?? this._readFile(filePath); + if (!fileContent) return []; + + const repo = this._getRepoForFile(filePath); + const repoPrefix = repo?.name || 'code'; + const relPath = repo?.path + ? path.relative(repo.path, filePath).replace(/\\/g, '/') + : path.basename(filePath); + const ext = path.extname(filePath).toLowerCase(); + + const moduleEntity = { + name: `${repoPrefix}/${relPath}`, + canonical_name: `${repoPrefix}/${relPath}`, + type: 'CodeModule', + properties: { + repoId: repo?.id || 'unknown', + repoName: repoPrefix, + filePath: relPath, + absolutePath: filePath, + language: ext.replace('.', ''), + lines: fileContent.split('\n').length + } + }; + + // Prefer Tree-Sitter AST + const astResult = await this._getAstParsed(filePath, fileContent, repoPrefix, relPath); + if (astResult?.entities?.length > 0) { + return [moduleEntity, ...astResult.entities]; + } + + // Regex fallback + const entities = [moduleEntity]; + const lines = fileContent.split('\n'); + if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext)) { + this._extractJsTsEntities(lines, repoPrefix, relPath, filePath, entities); + } else if (ext === '.py') { + this._extractPythonEntities(lines, repoPrefix, relPath, filePath, entities); + } else { + this._extractGenericEntities(lines, repoPrefix, relPath, filePath, ext, entities); + } + + return entities; + } + + // ─── Relationships ───────────────────────────────────────────────────────── + + async extractRelationships(filePath, content = null) { + const fileContent = content ?? this._readFile(filePath); + if (!fileContent) return []; + + const repo = this._getRepoForFile(filePath); + const repoPrefix = repo?.name || 'code'; + const relPath = repo?.path + ? path.relative(repo.path, filePath).replace(/\\/g, '/') + : path.basename(filePath); + const moduleName = `${repoPrefix}/${relPath}`; + + const relationships = [ + { + source_name: repoPrefix, + source_type: 'Repo', + target_name: moduleName, + target_type: 'CodeModule', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + } + ]; + + // Prefer Tree-Sitter AST + const astResult = await this._getAstParsed(filePath, fileContent, repoPrefix, relPath); + if (astResult?.relationships?.length > 0) { + relationships.push(...astResult.relationships); + return relationships; + } + + // Regex fallback: import detection + const importRegex = /(?:import\s+.*?from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g; + let match; + while ((match = importRegex.exec(fileContent)) !== null) { + const target = match[1] || match[2]; + if (target && (target.startsWith('.') || target.startsWith('/'))) { + relationships.push({ + source_name: moduleName, + source_type: 'CodeModule', + target_name: target, + target_type: 'CodeModule', + type: 'IMPORTS', + weight: 0.8, + confidence: 0.9 + }); + } + } + + return relationships; + } + + // ─── Evidence ────────────────────────────────────────────────────────────── + + /** + * Produce evidence records for every relationship in this file. + * This is what gives code relationships real provenance in the graph. + */ + async extractEvidence(filePath, content = null) { + const fileContent = content ?? this._readFile(filePath); + if (!fileContent) return []; + + const repo = this._getRepoForFile(filePath); + if (!repo) return []; // no provenance if file doesn't match a known repo + + const relPath = path.relative(repo.path, filePath).replace(/\\/g, '/'); + const relationships = await this.extractRelationships(filePath, fileContent); + + const pairs = this._evidenceBuilder.buildForRelationships({ + repoId: repo.id, + repoName: repo.name, + filePath, + relPath, + relationships + }); + + return pairs.map(p => p.evidence); + } + + extractMetadata(filePath) { + const repo = this._getRepoForFile(filePath); + return { + source: 'code', + sourceType: 'repository', + repoId: repo?.id || null, + repoName: repo?.name || null, + path: filePath + }; + } + + // ─── Private helpers ─────────────────────────────────────────────────────── + + _readFile(filePath) { + if (!filePath || !fs.existsSync(filePath)) return null; + try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; } + } + + _extractJsTsEntities(lines, repoPrefix, relPath, filePath, entities) { + const classRegex = /(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z0-9_$]+)(?:\s+extends\s+([A-Za-z0-9_$]+))?/g; + const interfaceRegex = /(?:export\s+)?interface\s+([A-Za-z0-9_$]+)/g; + const funcRegex = /(?:export\s+)?(?:async\s+)?function\s+([A-Za-z0-9_$]+)\s*\(/g; + const constFuncRegex = /(?:export\s+)?const\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z0-9_$]+)\s*=>/g; + const routeRegex = /(?:app|router|server)\.(get|post|put|delete|patch|options|use)\s*\(\s*['"`]([^'"`]+)['"`]/gi; + const modelRegex = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:mongoose\.model|new\s+Schema|model)\s*\(/g; + + lines.forEach((lineText, index) => { + const lineNum = index + 1; + const trimmed = lineText.trim(); + if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return; + + let match; + classRegex.lastIndex = 0; + while ((match = classRegex.exec(lineText)) !== null) { + entities.push({ name: match[1], canonical_name: `${repoPrefix}::${match[1]}`, type: 'CodeClass', + properties: { filePath: relPath, absolutePath: filePath, line: lineNum, extends: match[2] || null, kind: 'class' } }); + } + + interfaceRegex.lastIndex = 0; + while ((match = interfaceRegex.exec(lineText)) !== null) { + entities.push({ name: match[1], canonical_name: `${repoPrefix}::${match[1]}`, type: 'CodeInterface', + properties: { filePath: relPath, absolutePath: filePath, line: lineNum, kind: 'interface' } }); + } + + funcRegex.lastIndex = 0; + while ((match = funcRegex.exec(lineText)) !== null) { + entities.push({ name: `${match[1]}()`, canonical_name: `${repoPrefix}::${relPath}::${match[1]}`, type: 'CodeFunction', + properties: { filePath: relPath, absolutePath: filePath, line: lineNum, symbol: match[1], kind: 'function' } }); + } + + constFuncRegex.lastIndex = 0; + while ((match = constFuncRegex.exec(lineText)) !== null) { + entities.push({ name: `${match[1]}()`, canonical_name: `${repoPrefix}::${relPath}::${match[1]}`, type: 'CodeFunction', + properties: { filePath: relPath, absolutePath: filePath, line: lineNum, symbol: match[1], kind: 'arrow_function' } }); + } + + routeRegex.lastIndex = 0; + while ((match = routeRegex.exec(lineText)) !== null) { + const method = match[1].toUpperCase(); + entities.push({ name: `${method} ${match[2]}`, canonical_name: `${method} ${match[2]}`, type: 'APIEndpoint', + properties: { method, route: match[2], filePath: relPath, line: lineNum } }); + } + + modelRegex.lastIndex = 0; + while ((match = modelRegex.exec(lineText)) !== null) { + entities.push({ name: match[1], canonical_name: `${repoPrefix}::model::${match[1]}`, type: 'DBModel', + properties: { filePath: relPath, line: lineNum, kind: 'schema_model' } }); + } + }); + } + + _extractPythonEntities(lines, repoPrefix, relPath, filePath, entities) { + const classRegex = /^class\s+([A-Za-z0-9_$]+)(?:\(([^)]+)\))?:/g; + const defRegex = /^(?:\s+)?def\s+([A-Za-z0-9_$]+)\s*\(/g; + const routeRegex = /@(?:app|router|api)\.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/g; + + lines.forEach((lineText, index) => { + const lineNum = index + 1; + const trimmed = lineText.trim(); + if (trimmed.startsWith('#')) return; + + let match; + classRegex.lastIndex = 0; + while ((match = classRegex.exec(trimmed)) !== null) { + entities.push({ name: match[1], canonical_name: `${repoPrefix}::${match[1]}`, type: 'CodeClass', + properties: { filePath: relPath, absolutePath: filePath, line: lineNum, baseClass: match[2] || null } }); + } + + defRegex.lastIndex = 0; + while ((match = defRegex.exec(trimmed)) !== null) { + if (!match[1].startsWith('__')) { + entities.push({ name: `${match[1]}()`, canonical_name: `${repoPrefix}::${relPath}::${match[1]}`, type: 'CodeFunction', + properties: { filePath: relPath, absolutePath: filePath, line: lineNum, symbol: match[1] } }); + } + } + + routeRegex.lastIndex = 0; + while ((match = routeRegex.exec(trimmed)) !== null) { + const method = match[1].toUpperCase(); + entities.push({ name: `${method} ${match[2]}`, canonical_name: `${method} ${match[2]}`, type: 'APIEndpoint', + properties: { method, route: match[2], filePath: relPath, line: lineNum } }); + } + }); + } + + _extractGenericEntities(lines, repoPrefix, relPath, filePath, ext, entities) { + const goFuncRegex = /^func\s+(?:\([^)]+\)\s+)?([A-Za-z0-9_$]+)\s*\(/g; + const goStructRegex = /^type\s+([A-Za-z0-9_$]+)\s+struct/g; + const rustFnRegex = /^pub\s+fn\s+([A-Za-z0-9_$]+)\s*\(/g; + const rustStructRegex = /^pub\s+struct\s+([A-Za-z0-9_$]+)/g; + + lines.forEach((lineText, index) => { + const lineNum = index + 1; + const trimmed = lineText.trim(); + let match; + + if (ext === '.go') { + goFuncRegex.lastIndex = 0; + if ((match = goFuncRegex.exec(trimmed)) !== null) { + entities.push({ name: `${match[1]}()`, canonical_name: `${repoPrefix}::${relPath}::${match[1]}`, type: 'CodeFunction', + properties: { filePath: relPath, line: lineNum, symbol: match[1] } }); + } + goStructRegex.lastIndex = 0; + if ((match = goStructRegex.exec(trimmed)) !== null) { + entities.push({ name: match[1], canonical_name: `${repoPrefix}::${match[1]}`, type: 'CodeClass', + properties: { filePath: relPath, line: lineNum } }); + } + } else if (ext === '.rs') { + rustFnRegex.lastIndex = 0; + if ((match = rustFnRegex.exec(trimmed)) !== null) { + entities.push({ name: `${match[1]}()`, canonical_name: `${repoPrefix}::${relPath}::${match[1]}`, type: 'CodeFunction', + properties: { filePath: relPath, line: lineNum, symbol: match[1] } }); + } + rustStructRegex.lastIndex = 0; + if ((match = rustStructRegex.exec(trimmed)) !== null) { + entities.push({ name: match[1], canonical_name: `${repoPrefix}::${match[1]}`, type: 'CodeClass', + properties: { filePath: relPath, line: lineNum } }); + } + } + }); + } +} + +module.exports = CodeKnowledgeSource; diff --git a/ai/graph/sources/code/RepositoryScanner.js b/ai/graph/sources/code/RepositoryScanner.js new file mode 100644 index 0000000..9d81a3e --- /dev/null +++ b/ai/graph/sources/code/RepositoryScanner.js @@ -0,0 +1,156 @@ +/** + * RepositoryScanner + * Handles recursive file discovery within a repository, applying ignore rules. + */ + +const fs = require('fs'); +const path = require('path'); + +const IGNORED_DIRECTORIES = new Set([ + 'node_modules', 'dist', '__dist', 'dist__', '__dist__', '_dist', + 'build', '__build__', '_build', 'out', '__out__', '_out', + 'assets', 'chunks', 'bundle', 'bundles', 'static', + 'test', 'tests', '__tests__', '__test__', 'spec', 'specs', + 'mocks', '__mocks__', 'fixtures', '__fixtures__', + 'cypress', 'playwright', 'e2e', + '.git', '.svn', '.hg', + 'vendor', 'bower_components', 'coverage', '.nyc_output', + '.next', '.nuxt', '.output', '.svelte-kit', '.vite', '.parcel-cache', '.turbo', '.webpack', '.rollup.cache', + '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.tox', + '.venv', 'venv', 'env', '.env', 'virtualenv', '.conda', + 'bin', 'obj', 'target', 'pkg', + '.idea', '.vscode', '.vs', '.settings', '.cache', '.gradle', '.cargo', + 'tmp', '.tmp', 'temp', '.temp', 'logs', '.logs' +]); + +const IGNORED_DIR_PATTERNS = [ + /^__.*__$/, + /.*[-_.]dist.*/i, + /.*[-_.]build.*/i, + /.*[-_.]out.*/i, + /.*[-_.]cache.*/i, + /.*[-_.]test(s)?$/i, + /.*[-_.]spec(s)?$/i, + /^\.egg-info$/, + /.*\.egg-info$/i +]; + +const IGNORED_FILE_SUFFIXES = [ + '.min.js', '.min.mjs', '.min.cjs', '.min.css', + '.bundle.js', '.chunk.js', '.chunk.mjs', + '.map', '.d.ts.map', '.d.ts', + // Test and spec file suffixes + '.test.js', '.test.jsx', '.test.ts', '.test.tsx', '.test.mjs', '.test.cjs', + '.spec.js', '.spec.jsx', '.spec.ts', '.spec.tsx', '.spec.mjs', '.spec.cjs', + '.cy.js', '.cy.ts', '.cy.jsx', '.cy.tsx', + '.stories.jsx', '.stories.tsx', '.stories.js', '.stories.ts', + '_test.go' +]; + +const IGNORED_FILE_PATTERNS = [ + /^test_.*\.py$/i, + /.*_test\.py$/i, + /\.[a-zA-Z0-9_-]{8,16}\.(js|mjs|cjs|css)$/i +]; + +const CODE_EXTENSIONS = new Set([ + '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', + '.py', + '.go', + '.rs', + '.java', + '.c', '.cpp', '.h', '.hpp', '.cc', '.cs', + '.rb', '.php', + '.vue', '.svelte', + '.lua', '.kt', '.swift', '.scala', '.sh', '.bash' +]); + +const MAX_FILE_SIZE_BYTES = 512 * 1024; // 512 KB + +class RepositoryScanner { + /** + * @param {object} options + * @param {Set} [options.ignoredDirs] - directory names to skip + * @param {Set} [options.codeExtensions] - file extensions to include + * @param {number} [options.maxFiles] - cap on discovered file count + * @param {number} [options.maxDepth] - max directory traversal depth + * @param {number} [options.maxFileSizeBytes] + */ + constructor(options = {}) { + this.ignoredDirs = options.ignoredDirs || IGNORED_DIRECTORIES; + this.codeExtensions = options.codeExtensions || CODE_EXTENSIONS; + this.maxFiles = options.maxFiles || 1500; + this.maxDepth = options.maxDepth || 12; + this.maxFileSizeBytes = options.maxFileSizeBytes || MAX_FILE_SIZE_BYTES; + } + + /** + * Returns true if this scanner supports the given file extension. + * @param {string} filePath + */ + supports(filePath) { + if (!filePath || typeof filePath !== 'string') return false; + const lower = path.basename(filePath).toLowerCase(); + const ext = path.extname(filePath).toLowerCase(); + if (!this.codeExtensions.has(ext)) return false; + if (IGNORED_FILE_SUFFIXES.some(s => lower.endsWith(s))) return false; + if (IGNORED_FILE_PATTERNS.some(p => p.test(lower))) return false; + return true; + } + + /** + * Walk a repository root and return absolute paths of all indexable code files. + * @param {string} repoPath - absolute path to repository root + * @returns {string[]} + */ + scan(repoPath) { + if (!repoPath || !fs.existsSync(repoPath)) return []; + const result = []; + try { + this._walk(repoPath, result, 0); + } catch (err) { + console.warn(`[RepositoryScanner] Failed scanning ${repoPath}:`, err.message); + } + return result; + } + + _walk(dir, result, depth) { + if (depth > this.maxDepth || result.length >= this.maxFiles) return; + + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (result.length >= this.maxFiles) break; + + const lowerName = entry.name.toLowerCase(); + + // Skip hidden dirs (except .github, etc.) and explicitly ignored dirs/patterns + if (entry.isDirectory()) { + if (this.ignoredDirs.has(entry.name) || this.ignoredDirs.has(lowerName) || entry.name.startsWith('.')) continue; + const matchesPattern = IGNORED_DIR_PATTERNS.some(p => p.test(lowerName)); + if (matchesPattern) continue; + + this._walk(path.join(dir, entry.name), result, depth + 1); + } else if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + if (!this.codeExtensions.has(ext)) continue; + if (IGNORED_FILE_SUFFIXES.some(s => lowerName.endsWith(s))) continue; + if (IGNORED_FILE_PATTERNS.some(p => p.test(lowerName))) continue; + + const fullPath = path.join(dir, entry.name); + try { + if (fs.statSync(fullPath).size <= this.maxFileSizeBytes) { + result.push(fullPath); + } + } catch { /* skip inaccessible */ } + } + } + } +} + +module.exports = { RepositoryScanner, IGNORED_DIRECTORIES, CODE_EXTENSIONS }; diff --git a/ai/graph/sources/code/TreeSitterCodeParser.js b/ai/graph/sources/code/TreeSitterCodeParser.js new file mode 100644 index 0000000..ac18512 --- /dev/null +++ b/ai/graph/sources/code/TreeSitterCodeParser.js @@ -0,0 +1,584 @@ +/** + * TreeSitterCodeParser + * Multi-language AST parser powered by web-tree-sitter and WebAssembly grammars. + * 100% self-contained in Node.js / Electron. + */ + +const path = require('path'); +let Parser = null; +try { + Parser = require('web-tree-sitter'); +} catch { + Parser = null; +} + +const EXT_TO_WASM = { + '.js': 'javascript', + '.jsx': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.ts': 'typescript', + '.tsx': 'tsx', + '.py': 'python', + '.go': 'go', + '.rs': 'rust', + '.java': 'java', + '.c': 'c', + '.h': 'c', + '.cpp': 'cpp', + '.hpp': 'cpp', + '.cc': 'cpp', + '.cs': 'c_sharp', + '.rb': 'ruby', + '.php': 'php', + '.vue': 'vue', + '.lua': 'lua', + '.kt': 'kotlin', + '.swift': 'swift', + '.scala': 'scala', + '.sh': 'bash', + '.bash': 'bash' +}; + +class TreeSitterCodeParser { + constructor() { + this._initialized = false; + this._initPromise = null; + this._languages = new Map(); + this._parser = null; + } + + async initialize() { + if (this._initialized) return true; + if (this._initPromise) return this._initPromise; + + this._initPromise = (async () => { + if (!Parser) return false; + try { + await Parser.init(); + this._parser = new Parser(); + this._initialized = true; + return true; + } catch (err) { + console.warn('[TreeSitterCodeParser] Initialization failed:', err.message); + return false; + } + })(); + + return this._initPromise; + } + + async getLanguage(ext) { + const wasmName = EXT_TO_WASM[ext.toLowerCase()]; + if (!wasmName) return null; + + if (this._languages.has(wasmName)) { + return this._languages.get(wasmName); + } + + try { + const wasmPath = require.resolve(`tree-sitter-wasms/out/tree-sitter-${wasmName}.wasm`); + const lang = await Parser.Language.load(wasmPath); + this._languages.set(wasmName, lang); + return lang; + } catch (err) { + console.warn(`[TreeSitterCodeParser] Failed loading grammar for ${wasmName}:`, err.message); + return null; + } + } + + /** + * Parse code content into structured AST entities & relationships. + */ + async parse(filePath, content, repoPrefix = 'code', relPath = '') { + const ext = path.extname(filePath).toLowerCase(); + const ok = await this.initialize(); + if (!ok) return null; + + const lang = await this.getLanguage(ext); + if (!lang) return null; + + try { + this._parser.setLanguage(lang); + const tree = this._parser.parse(content); + const moduleName = `${repoPrefix}/${relPath || path.basename(filePath)}`; + + const entities = []; + const relationships = []; + const calls = new Set(); + const declaredFunctions = new Set(); + + this._walk(tree.rootNode, { + ext, + filePath, + relPath, + repoPrefix, + moduleName, + entities, + relationships, + calls, + declaredFunctions, + currentClass: null, + currentFunction: null + }); + + // Match calls to declared functions within file + for (const callName of calls) { + if (declaredFunctions.has(callName)) { + relationships.push({ + source_name: moduleName, + source_type: 'CodeModule', + target_name: `${callName}()`, + target_type: 'CodeFunction', + type: 'CALLS', + weight: 0.7, + confidence: 0.85 + }); + } + } + + return { entities, relationships }; + } catch (err) { + console.warn(`[TreeSitterCodeParser] AST parsing error on ${filePath}:`, err.message); + return null; + } + } + + _walk(node, ctx) { + if (!node) return; + const type = node.type; + const startLine = node.startPosition.row + 1; + + // 1. JavaScript / TypeScript AST + if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ctx.ext)) { + if (type === 'class_declaration' || type === 'class') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const className = nameNode.text; + const heritage = node.children.find(c => c.type === 'class_heritage'); + const extendsName = heritage ? heritage.text.replace(/^extends\s+/, '').trim() : null; + + ctx.entities.push({ + name: className, + canonical_name: `${ctx.repoPrefix}::${className}`, + type: 'CodeClass', + properties: { + filePath: ctx.relPath, + absolutePath: ctx.filePath, + line: startLine, + extends: extendsName, + kind: 'class' + } + }); + + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: className, + target_type: 'CodeClass', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + + if (extendsName) { + ctx.relationships.push({ + source_name: className, + source_type: 'CodeClass', + target_name: extendsName, + target_type: 'CodeClass', + type: 'EXTENDS', + weight: 0.9, + confidence: 0.95 + }); + } + + const prevClass = ctx.currentClass; + ctx.currentClass = className; + for (let i = 0; i < node.namedChildCount; i++) { + this._walk(node.namedChild(i), ctx); + } + ctx.currentClass = prevClass; + return; + } + } + + if (type === 'interface_declaration') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const ifaceName = nameNode.text; + ctx.entities.push({ + name: ifaceName, + canonical_name: `${ctx.repoPrefix}::${ifaceName}`, + type: 'CodeInterface', + properties: { + filePath: ctx.relPath, + absolutePath: ctx.filePath, + line: startLine, + kind: 'interface' + } + }); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: ifaceName, + target_type: 'CodeInterface', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + } + } + + if (type === 'function_declaration' || type === 'method_definition') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const funcName = nameNode.text; + ctx.declaredFunctions.add(funcName); + ctx.entities.push({ + name: `${funcName}()`, + canonical_name: `${ctx.repoPrefix}::${ctx.relPath}::${funcName}`, + type: 'CodeFunction', + properties: { + filePath: ctx.relPath, + absolutePath: ctx.filePath, + line: startLine, + symbol: funcName, + memberOf: ctx.currentClass || null, + kind: type === 'method_definition' ? 'method' : 'function' + } + }); + + const parentSource = ctx.currentClass || ctx.moduleName; + const parentType = ctx.currentClass ? 'CodeClass' : 'CodeModule'; + ctx.relationships.push({ + source_name: parentSource, + source_type: parentType, + target_name: `${funcName}()`, + target_type: 'CodeFunction', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + + const prevFunc = ctx.currentFunction; + ctx.currentFunction = funcName; + for (let i = 0; i < node.namedChildCount; i++) { + this._walk(node.namedChild(i), ctx); + } + ctx.currentFunction = prevFunc; + return; + } + } + + if (type === 'lexical_declaration' || type === 'variable_declaration') { + for (const decl of node.namedChildren) { + if (decl.type === 'variable_declarator') { + const idNode = decl.childForFieldName('name'); + const initNode = decl.childForFieldName('value'); + if (idNode && initNode) { + if (initNode.type === 'arrow_function' || initNode.type === 'function') { + const funcName = idNode.text; + ctx.declaredFunctions.add(funcName); + ctx.entities.push({ + name: `${funcName}()`, + canonical_name: `${ctx.repoPrefix}::${ctx.relPath}::${funcName}`, + type: 'CodeFunction', + properties: { + filePath: ctx.relPath, + absolutePath: ctx.filePath, + line: decl.startPosition.row + 1, + symbol: funcName, + kind: 'arrow_function' + } + }); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: `${funcName}()`, + target_type: 'CodeFunction', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + } + } + } + } + } + + if (type === 'call_expression') { + const fnNode = node.childForFieldName('function'); + if (fnNode) { + const fnText = fnNode.text; + // Check route handlers: app.get('/...', ...), router.post('/...', ...) + if (fnText.includes('.')) { + const parts = fnText.split('.'); + const method = parts[parts.length - 1].toLowerCase(); + if (['get', 'post', 'put', 'delete', 'patch', 'use'].includes(method)) { + const argsNode = node.childForFieldName('arguments'); + if (argsNode && argsNode.namedChildCount > 0) { + const firstArg = argsNode.namedChild(0); + if (firstArg && (firstArg.type === 'string' || firstArg.type === 'template_string')) { + const routePath = firstArg.text.replace(/['"`]/g, ''); + const epName = `${method.toUpperCase()} ${routePath}`; + ctx.entities.push({ + name: epName, + canonical_name: epName, + type: 'APIEndpoint', + properties: { + method: method.toUpperCase(), + route: routePath, + filePath: ctx.relPath, + line: startLine + } + }); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: epName, + target_type: 'APIEndpoint', + type: 'DEFINES_ROUTE', + weight: 1.0, + confidence: 0.95 + }); + } + } + } + } else { + ctx.calls.add(fnText); + } + } + } + + if (type === 'import_statement') { + const sourceNode = node.childForFieldName('source'); + if (sourceNode) { + const importTarget = sourceNode.text.replace(/['"]/g, ''); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: importTarget, + target_type: 'CodeModule', + type: 'IMPORTS', + weight: 0.8, + confidence: 0.95 + }); + } + } + } + + // 2. Python AST + else if (ctx.ext === '.py') { + if (type === 'class_definition') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const className = nameNode.text; + const superclasses = node.childForFieldName('superclasses'); + const baseName = superclasses ? superclasses.text.replace(/[()]/g, '').trim() : null; + + ctx.entities.push({ + name: className, + canonical_name: `${ctx.repoPrefix}::${className}`, + type: 'CodeClass', + properties: { + filePath: ctx.relPath, + absolutePath: ctx.filePath, + line: startLine, + baseClass: baseName, + kind: 'class' + } + }); + + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: className, + target_type: 'CodeClass', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + + if (baseName) { + ctx.relationships.push({ + source_name: className, + source_type: 'CodeClass', + target_name: baseName, + target_type: 'CodeClass', + type: 'EXTENDS', + weight: 0.9, + confidence: 0.95 + }); + } + + const prevClass = ctx.currentClass; + ctx.currentClass = className; + for (let i = 0; i < node.namedChildCount; i++) { + this._walk(node.namedChild(i), ctx); + } + ctx.currentClass = prevClass; + return; + } + } + + if (type === 'function_definition') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const funcName = nameNode.text; + ctx.declaredFunctions.add(funcName); + ctx.entities.push({ + name: `${funcName}()`, + canonical_name: `${ctx.repoPrefix}::${ctx.relPath}::${funcName}`, + type: 'CodeFunction', + properties: { + filePath: ctx.relPath, + absolutePath: ctx.filePath, + line: startLine, + symbol: funcName, + memberOf: ctx.currentClass || null + } + }); + + const parentSource = ctx.currentClass || ctx.moduleName; + const parentType = ctx.currentClass ? 'CodeClass' : 'CodeModule'; + ctx.relationships.push({ + source_name: parentSource, + source_type: parentType, + target_name: `${funcName}()`, + target_type: 'CodeFunction', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + + const prevFunc = ctx.currentFunction; + ctx.currentFunction = funcName; + for (let i = 0; i < node.namedChildCount; i++) { + this._walk(node.namedChild(i), ctx); + } + ctx.currentFunction = prevFunc; + return; + } + } + + if (type === 'import_from_statement' || type === 'import_statement') { + const modNode = node.childForFieldName('module_name') || node.childForFieldName('name'); + if (modNode) { + const importTarget = modNode.text; + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: importTarget, + target_type: 'CodeModule', + type: 'IMPORTS', + weight: 0.8, + confidence: 0.95 + }); + } + } + } + + // 3. Go AST + else if (ctx.ext === '.go') { + if (type === 'function_declaration' || type === 'method_declaration') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const funcName = nameNode.text; + ctx.entities.push({ + name: `${funcName}()`, + canonical_name: `${ctx.repoPrefix}::${ctx.relPath}::${funcName}`, + type: 'CodeFunction', + properties: { filePath: ctx.relPath, line: startLine, symbol: funcName } + }); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: `${funcName}()`, + target_type: 'CodeFunction', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + } + } else if (type === 'type_declaration') { + for (const spec of node.namedChildren) { + if (spec.type === 'type_spec') { + const nameNode = spec.childForFieldName('name'); + if (nameNode) { + const typeName = nameNode.text; + ctx.entities.push({ + name: typeName, + canonical_name: `${ctx.repoPrefix}::${typeName}`, + type: 'CodeClass', + properties: { filePath: ctx.relPath, line: startLine } + }); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: typeName, + target_type: 'CodeClass', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + } + } + } + } + } + + // 4. Rust AST + else if (ctx.ext === '.rs') { + if (type === 'function_item') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const funcName = nameNode.text; + ctx.entities.push({ + name: `${funcName}()`, + canonical_name: `${ctx.repoPrefix}::${ctx.relPath}::${funcName}`, + type: 'CodeFunction', + properties: { filePath: ctx.relPath, line: startLine, symbol: funcName } + }); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: `${funcName}()`, + target_type: 'CodeFunction', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + } + } else if (type === 'struct_item' || type === 'enum_item' || type === 'trait_item') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const typeName = nameNode.text; + ctx.entities.push({ + name: typeName, + canonical_name: `${ctx.repoPrefix}::${typeName}`, + type: type === 'trait_item' ? 'CodeInterface' : 'CodeClass', + properties: { filePath: ctx.relPath, line: startLine } + }); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: typeName, + target_type: type === 'trait_item' ? 'CodeInterface' : 'CodeClass', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + } + } + } + + // Walk children + for (let i = 0; i < node.namedChildCount; i++) { + this._walk(node.namedChild(i), ctx); + } + } +} + +const parserInstance = new TreeSitterCodeParser(); +module.exports = parserInstance; diff --git a/ai/graph/sources/code/index.js b/ai/graph/sources/code/index.js new file mode 100644 index 0000000..f79ddc9 --- /dev/null +++ b/ai/graph/sources/code/index.js @@ -0,0 +1,11 @@ +const CodeKnowledgeSource = require('./CodeKnowledgeSource'); +const { RepositoryScanner } = require('./RepositoryScanner'); +const { CodeEvidenceBuilder } = require('./CodeEvidenceBuilder'); +const treeSitterParser = require('./TreeSitterCodeParser'); + +module.exports = { + CodeKnowledgeSource, + RepositoryScanner, + CodeEvidenceBuilder, + treeSitterParser +}; diff --git a/ai/queue/GraphWorker.js b/ai/queue/GraphWorker.js index a73607c..f9731eb 100644 --- a/ai/queue/GraphWorker.js +++ b/ai/queue/GraphWorker.js @@ -98,17 +98,20 @@ class GraphWorker { const ExcalidrawKnowledgeSource = require('../graph/sources/ExcalidrawKnowledgeSource'); const DrawioKnowledgeSource = require('../graph/sources/DrawioKnowledgeSource'); const MermaidKnowledgeSource = require('../graph/sources/MermaidKnowledgeSource'); + const { CodeKnowledgeSource } = require('../graph/sources/code'); const EvidenceStore = require('../graph/EvidenceStore'); const registry = new KnowledgeSourceRegistry(); const excSrc = new ExcalidrawKnowledgeSource(); const drwSrc = new DrawioKnowledgeSource(); const mrmSrc = new MermaidKnowledgeSource(); + const codeSrc = new CodeKnowledgeSource(); let source = null; if (excSrc.supports(job.note_path)) source = excSrc; else if (drwSrc.supports(job.note_path)) source = drwSrc; else if (mrmSrc.supports(job.note_path)) source = mrmSrc; + else if (codeSrc.supports(job.note_path)) source = codeSrc; if (source && this.graphDb) { const { entities, relationships, evidence } = await registry.extract(source, job.note_path, content); diff --git a/electron/ai/aiHandlers.cjs b/electron/ai/aiHandlers.cjs index 2326e55..54e2395 100644 --- a/electron/ai/aiHandlers.cjs +++ b/electron/ai/aiHandlers.cjs @@ -697,15 +697,22 @@ async function handleBuildGraph(_event, _payload) { if (workspaceFiles.length === 0 && workspaceRoot && fs.existsSync(workspaceRoot)) { function scanMarkdownFiles(dir) { let results = []; + const IGNORE_DIR_NAMES = new Set([ + 'node_modules', 'dist', 'build', 'out', 'assets', 'chunks', 'bundle', 'static', + 'test', 'tests', '__tests__', '__test__', 'spec', 'specs', 'fixtures', 'mocks', + 'cypress', 'playwright', 'e2e' + ]); try { const list = fs.readdirSync(dir); for (const file of list) { - if (file.startsWith('.') || file === 'node_modules' || file === 'dist' || file === 'build') continue; + const lower = file.toLowerCase(); + if (file.startsWith('.') || IGNORE_DIR_NAMES.has(lower) || /.*[-_.]dist.*/i.test(lower) || /.*[-_.]build.*/i.test(lower)) continue; const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat && stat.isDirectory()) { results = results.concat(scanMarkdownFiles(fullPath)); } else if (file.endsWith('.md')) { + if (/\.(test|spec)\.md$/i.test(file)) continue; results.push(fullPath); } } diff --git a/electron/ai/workerProcess.cjs b/electron/ai/workerProcess.cjs index 71bdb52..cf13e0f 100644 --- a/electron/ai/workerProcess.cjs +++ b/electron/ai/workerProcess.cjs @@ -7,15 +7,22 @@ const fs = require('fs'); function scanMarkdownFiles(dir) { let results = []; + const IGNORE_DIR_NAMES = new Set([ + 'node_modules', 'dist', 'build', 'out', 'assets', 'chunks', 'bundle', 'static', + 'test', 'tests', '__tests__', '__test__', 'spec', 'specs', 'fixtures', 'mocks', + 'cypress', 'playwright', 'e2e' + ]); try { const list = fs.readdirSync(dir); for (const file of list) { - if (file.startsWith('.') || file === 'node_modules') continue; + const lower = file.toLowerCase(); + if (file.startsWith('.') || IGNORE_DIR_NAMES.has(lower) || /.*[-_.]dist.*/i.test(lower) || /.*[-_.]build.*/i.test(lower)) continue; const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat && stat.isDirectory()) { results = results.concat(scanMarkdownFiles(fullPath)); } else if (file.endsWith('.md')) { + if (/\.(test|spec)\.md$/i.test(file)) continue; results.push(fullPath); } } @@ -28,6 +35,7 @@ let indexWorker = null; let queue = null; let localEmbedder = null; +let mockAgent = null; let graphDb = null; let graphQueue = null; let graphWorker = null; @@ -66,7 +74,7 @@ if (process.parentPort) { graphQueue = new GraphQueue(graphDb); const AIConfig = require('../../ai/core/AIConfig'); const aiConfig = new AIConfig(appDataDir); - const mockAgent = { appDataDir, workspaceRoot, config: aiConfig }; + mockAgent = { appDataDir, workspaceRoot, config: aiConfig }; graphService = new GraphService(mockAgent, graphDb); graphWorker = new GraphWorker(graphDb, graphQueue, graphService); @@ -216,9 +224,10 @@ if (process.parentPort) { } } else if (type === 'rebuildGraph') { let { workspaceFiles } = payload; + const workspaceRoot = graphDb?.workspaceRoot || mockAgent?.workspaceRoot; if (!Array.isArray(workspaceFiles) || workspaceFiles.length === 0) { - if (graphDb && graphDb.workspaceRoot) { - workspaceFiles = scanMarkdownFiles(graphDb.workspaceRoot); + if (workspaceRoot) { + workspaceFiles = scanMarkdownFiles(workspaceRoot); } else { workspaceFiles = []; } @@ -226,11 +235,45 @@ if (process.parentPort) { if (graphDb) { graphDb.clear(); } + + // Run comprehensive GraphBuilder to index attached repos, metadata, and diagrams + if (workspaceRoot && graphDb && graphService) { + try { + const GraphBuilder = require('../../ai/graph/GraphBuilder'); + const builder = new GraphBuilder(mockAgent, graphDb, graphService); + await builder.rebuild(); + } catch (bErr) { + console.error('[Worker Process] GraphBuilder rebuild error:', bErr); + } + } + if (graphQueue) { graphQueue.clear(); for (const file of workspaceFiles) { graphQueue.enqueue(file); } + + // Enqueue code files from attached repositories + if (workspaceRoot) { + try { + const metaPath = path.join(workspaceRoot, '.notes-app', 'metadata.json'); + if (fs.existsSync(metaPath)) { + const metaObj = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + if (Array.isArray(metaObj.attachedRepos)) { + const { RepositoryScanner } = require('../../ai/graph/sources/code'); + const scanner = new RepositoryScanner(); + for (const r of metaObj.attachedRepos) { + if (r.path && fs.existsSync(r.path)) { + const codeFiles = scanner.scan(r.path); + for (const cf of codeFiles) { + graphQueue.enqueue(cf); + } + } + } + } + } + } catch { /* ignore */ } + } } if (graphWorker) { graphWorker.resume(); diff --git a/electron/lib/ipc/coreIpc.cjs b/electron/lib/ipc/coreIpc.cjs index d44c3d6..1383a2f 100644 --- a/electron/lib/ipc/coreIpc.cjs +++ b/electron/lib/ipc/coreIpc.cjs @@ -752,7 +752,35 @@ function registerCoreIpcHandlers(ipcMain, deps) { registerTrustedHandler("workspace-metadata:get-attached-repos", () => { if (typeof getWorkspaceMetadataStore === "function") { const store = getWorkspaceMetadataStore(); - if (store) return store.getAttachedRepos(); + if (store) { + const repos = store.getAttachedRepos() || []; + const notesRoot = typeof getNotesRoot === "function" ? getNotesRoot() : null; + if (notesRoot) { + try { + const dbPath = path.join(notesRoot, ".notes-app", "ai-graph.db"); + if (fs.existsSync(dbPath)) { + const { DatabaseSync } = require("node:sqlite"); + const gdb = new DatabaseSync(dbPath); + try { + for (const r of repos) { + if (r.status === "unindexed" || !r.symbolCount) { + const row = gdb.prepare("SELECT COUNT(*) as count FROM entities WHERE properties LIKE ?").get(`%"repoId":"${r.id}"%`); + if (row && row.count > 0) { + r.status = "indexed"; + r.symbolCount = row.count; + r.lastScannedAt = r.lastScannedAt || new Date().toISOString(); + store.updateAttachedRepo(r.id, r); + } + } + } + } finally { + try { gdb.close(); } catch {} + } + } + } catch { /* ignore sync error */ } + } + return repos; + } } return []; }); @@ -774,12 +802,123 @@ function registerCoreIpcHandlers(ipcMain, deps) { } } catch { /* ignore git info failure */ } } + + // Initialize repo record and upsert repo entity immediately + repoData.status = 'indexing'; + repoData.symbolCount = 1; + repoData.lastScannedAt = new Date().toISOString(); + + const notesRoot = typeof getNotesRoot === "function" ? getNotesRoot() : null; + if (notesRoot) { + const dbPath = path.join(notesRoot, ".notes-app", "ai-graph.db"); + if (fs.existsSync(dbPath)) { + try { + const { DatabaseSync } = require("node:sqlite"); + const gdb = new DatabaseSync(dbPath); + const repoId = `ent-code-${String(repoData.name).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + gdb.prepare(` + INSERT INTO entities (id, name, canonical_name, type, properties, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + canonical_name = excluded.canonical_name, + type = excluded.type, + properties = excluded.properties, + updated_at = excluded.updated_at + `).run(repoId, repoData.name, repoData.name, 'Repo', JSON.stringify({ repoId: repoData.id, path: repoData.path }), new Date().toISOString()); + gdb.close(); + } catch { /* ignore initial repo node insert */ } + } + } + const added = store.addAttachedRepo(repoData); const allRepos = store.getAttachedRepos(); for (const win of BrowserWindow.getAllWindows()) { if (!win || win.isDestroyed()) continue; win.webContents.send("workspace-metadata:attached-repos-changed", allRepos); } + + // Run detailed AST code extraction in non-blocking background queue + if (repoData.path && fs.existsSync(repoData.path)) { + setImmediate(async () => { + try { + const { CodeKnowledgeSource, RepositoryScanner } = require("../../../ai/graph/sources/code"); + const codeSource = new CodeKnowledgeSource([repoData]); + const scanner = new RepositoryScanner(); + const files = scanner.scan(repoData.path); + + let symbolCount = 1; + if (notesRoot) { + const dbPath = path.join(notesRoot, ".notes-app", "ai-graph.db"); + if (fs.existsSync(dbPath)) { + const { DatabaseSync } = require("node:sqlite"); + const gdb = new DatabaseSync(dbPath); + try { + // Process files in non-blocking chunks of 8 + const CHUNK_SIZE = 8; + for (let i = 0; i < files.length && i < 200; i += CHUNK_SIZE) { + const chunk = files.slice(i, i + CHUNK_SIZE); + gdb.exec("BEGIN"); + for (const filePath of chunk) { + try { + const fileEntities = await codeSource.extractEntities(filePath); + const fileRelationships = await codeSource.extractRelationships(filePath); + + for (const ent of fileEntities) { + const entId = `ent-code-${String(ent.name).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + gdb.prepare(` + INSERT INTO entities (id, name, canonical_name, type, properties, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + canonical_name = excluded.canonical_name, + type = excluded.type, + properties = excluded.properties, + updated_at = excluded.updated_at + `).run(entId, ent.name, ent.canonical_name || ent.name, ent.type || 'CodeModule', JSON.stringify(ent.properties || {}), new Date().toISOString()); + symbolCount++; + } + + for (const rel of fileRelationships) { + const srcId = `ent-code-${String(rel.source_name).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + const tgtId = `ent-code-${String(rel.target_name).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + if (srcId !== tgtId) { + gdb.prepare(` + INSERT OR IGNORE INTO relationships (source_id, target_id, type, weight, confidence, metadata) + VALUES (?, ?, ?, ?, ?, ?) + `).run(srcId, tgtId, rel.type, rel.weight || 1.0, rel.confidence || 0.95, JSON.stringify(rel.metadata || {})); + } + } + } catch { /* ignore individual file error */ } + } + gdb.exec("COMMIT"); + // Yield to event loop between chunks + await new Promise(resolve => setImmediate(resolve)); + } + } catch { + try { gdb.exec("ROLLBACK"); } catch {} + } finally { + try { gdb.close(); } catch {} + } + } + } + + store.updateAttachedRepo(repoData.id, { + status: 'indexed', + symbolCount, + lastScannedAt: new Date().toISOString() + }); + const updatedRepos = store.getAttachedRepos(); + for (const win of BrowserWindow.getAllWindows()) { + if (!win || win.isDestroyed()) continue; + win.webContents.send("workspace-metadata:attached-repos-changed", updatedRepos); + } + } catch (bgErr) { + console.warn("[Background Repo Scanner] Error:", bgErr.message); + store.updateAttachedRepo(repoData.id, { status: 'indexed' }); + } + }); + } return { success: true, repo: added, all: allRepos }; } } @@ -791,7 +930,35 @@ function registerCoreIpcHandlers(ipcMain, deps) { if (typeof getWorkspaceMetadataStore === "function") { const store = getWorkspaceMetadataStore(); if (store) { + const repo = (store.getAttachedRepos() || []).find((r) => r.id === repoId || r.path === repoId); const removed = store.removeAttachedRepo(repoId); + + // Purge repo entities from ai-graph.db if workspace db exists + try { + const notesRoot = typeof getNotesRoot === "function" ? getNotesRoot() : null; + if (notesRoot) { + const dbPath = path.join(notesRoot, ".notes-app", "ai-graph.db"); + if (fs.existsSync(dbPath)) { + const { DatabaseSync } = require("node:sqlite"); + const gdb = new DatabaseSync(dbPath); + try { + gdb.exec("BEGIN"); + if (repo && repo.id) { + gdb.prepare("DELETE FROM entities WHERE properties LIKE ?").run(`%"repoId":"${repo.id}"%`); + } + if (repo && repo.path) { + gdb.prepare("DELETE FROM entities WHERE properties LIKE ?").run(`%"absolutePath":"${repo.path.replace(/\\/g, '\\\\')}%`); + } + gdb.exec("COMMIT"); + } catch { + try { gdb.exec("ROLLBACK"); } catch {} + } finally { + try { gdb.close(); } catch {} + } + } + } + } catch { /* ignore graph purge errors */ } + const allRepos = store.getAttachedRepos(); for (const win of BrowserWindow.getAllWindows()) { if (!win || win.isDestroyed()) continue; diff --git a/electron/tools/EnterpriseToolSuite.cjs b/electron/tools/EnterpriseToolSuite.cjs index eecc44e..ccec6c8 100644 --- a/electron/tools/EnterpriseToolSuite.cjs +++ b/electron/tools/EnterpriseToolSuite.cjs @@ -80,7 +80,89 @@ class EnterpriseToolSuite { } } - // 2. Local Notes Search + // 2. Code Symbols Search (when source is "code" or "all") + if (source === 'code' || source === 'all') { + try { + const dbPath = path.join(workspaceRoot, '.notes-app', 'ai-graph.db'); + if (fs.existsSync(dbPath)) { + const { DatabaseSync } = require('node:sqlite'); + const gdb = new DatabaseSync(dbPath); + try { + const codeTypes = ['CodeModule', 'CodeClass', 'CodeInterface', 'CodeFunction', 'APIEndpoint', 'DBModel', 'Repo']; + const placeholders = codeTypes.map(() => '?').join(','); + const rows = gdb.prepare(` + SELECT id, name, canonical_name, type, properties + FROM entities + WHERE type IN (${placeholders}) + `).all(...codeTypes); + + const queryTokens = query.toLowerCase().replace(/[^a-z0-9_\-\s]/g, ' ').split(/\s+/).filter(t => t.length >= 2); + const codeHits = []; + + for (const row of rows) { + const name = row.name || ''; + const canonical = row.canonical_name || ''; + let props = {}; + try { props = JSON.parse(row.properties || '{}'); } catch {} + + let score = 0; + const matchReasons = { titleMatch: false, textSnippets: [], tagMatches: [] }; + + if (query) { + const lowerQ = query.toLowerCase(); + if (name.toLowerCase() === lowerQ || canonical.toLowerCase() === lowerQ) { + score += 3.0; + matchReasons.titleMatch = true; + } else if (name.toLowerCase().includes(lowerQ) || canonical.toLowerCase().includes(lowerQ)) { + score += 2.0; + matchReasons.titleMatch = true; + } else if (props.filePath && props.filePath.toLowerCase().includes(lowerQ)) { + score += 1.5; + matchReasons.textSnippets.push(`File: ${props.filePath}`); + } else { + for (const tok of queryTokens) { + if (name.toLowerCase().includes(tok) || canonical.toLowerCase().includes(tok)) { + score += 0.8; + } + } + } + } else { + score = 1.0; + } + + if (score > 0) { + codeHits.push({ + path: props.filePath || row.name, + title: row.name, + canonicalName: row.canonical_name, + type: row.type, + repoName: props.repoName || props.repoId || 'code', + score: Number(score.toFixed(2)), + matchReasons, + properties: props + }); + } + } + + codeHits.sort((a, b) => b.score - a.score); + if (source === 'code') { + results.hits = codeHits.slice(0, limit); + results.totalHits = codeHits.length; + return results; + } + } finally { + try { gdb.close(); } catch {} + } + } + } catch (err) { + if (source === 'code') { + results.codeError = err.message; + return results; + } + } + } + + // 3. Local Notes Search const allFiles = collectMarkdownFiles(workspaceRoot); const scoredHits = []; @@ -499,6 +581,42 @@ class EnterpriseToolSuite { // Git history const gitHistory = includeGit ? getFileGitHistory(fullPath, workspaceRoot, 5) : []; + // Repo symbol resolution + let repoSymbols = undefined; + if (inc.repoSymbols !== false) { + try { + const dbPath = path.join(workspaceRoot, '.notes-app', 'ai-graph.db'); + if (fs.existsSync(dbPath)) { + const { DatabaseSync } = require('node:sqlite'); + const gdb = new DatabaseSync(dbPath); + try { + const wikiMatches = (rawContent.match(/\[\[([^|\]]+)(?:\|[^\]]+)?\]\]/g) || []) + .map(m => m.replace(/^\[\[|\]\]$/g, '').split('|')[0].trim()); + if (wikiMatches.length > 0) { + const placeholders = wikiMatches.map(() => '?').join(','); + const matchedEntities = gdb.prepare(` + SELECT id, name, canonical_name, type, properties + FROM entities + WHERE (name IN (${placeholders}) OR canonical_name IN (${placeholders})) + AND type IN ('CodeModule', 'CodeClass', 'CodeInterface', 'CodeFunction', 'APIEndpoint', 'DBModel', 'Repo') + `).all(...wikiMatches, ...wikiMatches); + + if (matchedEntities.length > 0) { + repoSymbols = matchedEntities.map(ent => ({ + name: ent.name, + canonicalName: ent.canonical_name, + type: ent.type, + properties: typeof ent.properties === 'string' ? JSON.parse(ent.properties || '{}') : ent.properties + })); + } + } + } finally { + try { gdb.close(); } catch {} + } + } + } catch { /* ignore graph read error */ } + } + // Stats const words = cleanMarkdown(rawContent).split(/\s+/).filter(Boolean).length; const readingTimeMin = Math.max(1, Math.ceil(words / 200)); @@ -535,6 +653,7 @@ class EnterpriseToolSuite { media: includeMedia ? media : undefined, links: includeLinks ? links : undefined, gitHistory: includeGit ? gitHistory : undefined, + repoSymbols, suggestedFollowUps: [ { action: 'edit_note', @@ -1161,7 +1280,7 @@ class EnterpriseToolSuite { const workspaceRoot = context.workspaceRoot || args.workspaceRoot; if (!workspaceRoot) throw new Error('Workspace root is required.'); - const operation = (args.operation || 'summary').toLowerCase(); + const operation = String(args.operation || (args.action ? 'repos' : 'summary')).toLowerCase(); // 1. SUMMARY if (operation === 'summary') { @@ -1206,12 +1325,46 @@ class EnterpriseToolSuite { } catch { /* skip */ } } + // Pull code entities and relationships from ai-graph.db if present + try { + const dbPath = path.join(workspaceRoot, '.notes-app', 'ai-graph.db'); + if (fs.existsSync(dbPath)) { + const { DatabaseSync } = require('node:sqlite'); + const gdb = new DatabaseSync(dbPath); + try { + const codeRows = gdb.prepare(` + SELECT id, name, canonical_name, type, properties + FROM entities + WHERE type IN ('CodeModule', 'CodeClass', 'CodeInterface', 'CodeFunction', 'APIEndpoint', 'DBModel', 'Repo') + LIMIT 150 + `).all(); + for (const cr of codeRows) { + if (!seenNodes.has(cr.id)) { + seenNodes.add(cr.id); + nodes.push({ id: cr.id, title: cr.name, type: cr.type, canonicalName: cr.canonical_name }); + } + } + + const relRows = gdb.prepare(` + SELECT source_id, target_id, type, weight, confidence + FROM relationships + LIMIT 300 + `).all(); + for (const rr of relRows) { + edges.push({ from: rr.source_id, to: rr.target_id, relation: rr.type, weight: rr.weight, confidence: rr.confidence }); + } + } finally { + try { gdb.close(); } catch {} + } + } + } catch { /* ignore graph read error */ } + return { operation: 'graph', totalNodes: nodes.length, totalEdges: edges.length, - nodes: nodes.slice(0, 100), - edges: edges.slice(0, 200) + nodes: nodes.slice(0, 200), + edges: edges.slice(0, 400) }; } @@ -1262,6 +1415,11 @@ class EnterpriseToolSuite { return this.workspaceService.getNotesIndex({ workspaceRoot, folder: args.folder }); } + // 7. REPOS + if (operation === 'repos') { + return this.manageRepositories(args, context); + } + throw new Error(`Unknown workspace_overview operation: "${operation}".`); } @@ -1383,6 +1541,139 @@ class EnterpriseToolSuite { } } + // ─── 8. MANAGE REPOSITORIES ──────────────────────────────────────────────── + + async manageRepositories(args = {}, context = {}) { + const workspaceRoot = context.workspaceRoot || args.workspaceRoot; + if (!workspaceRoot) throw new Error('Workspace root is required.'); + + const action = String(args.action || 'list').toLowerCase(); + const metaPath = path.join(workspaceRoot, '.notes-app', 'metadata.json'); + + const loadMeta = () => { + if (fs.existsSync(metaPath)) { + try { + return JSON.parse(fs.readFileSync(metaPath, 'utf8')); + } catch { return { attachedRepos: [] }; } + } + return { attachedRepos: [] }; + }; + + const saveMeta = (data) => { + const dir = path.dirname(metaPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(metaPath, JSON.stringify(data, null, 2), 'utf8'); + }; + + switch (action) { + case 'list': { + const meta = loadMeta(); + const repos = Array.isArray(meta.attachedRepos) ? meta.attachedRepos : []; + return { operation: 'repos', action: 'list', count: repos.length, repositories: repos }; + } + + case 'add': { + const repoPath = args.path || args.repoPath; + if (!repoPath) throw new Error('path is required for adding repository.'); + const normPath = path.resolve(repoPath); + if (!fs.existsSync(normPath)) throw new Error(`Repository path "${repoPath}" does not exist on disk.`); + + const meta = loadMeta(); + if (!Array.isArray(meta.attachedRepos)) meta.attachedRepos = []; + + const existing = meta.attachedRepos.find(r => r.path === normPath || r.id === args.id); + if (existing) { + return { operation: 'repos', action: 'add', success: true, message: 'Repository already attached', repo: existing, repositories: meta.attachedRepos }; + } + + const crypto = require('crypto'); + const repoId = args.id || `repo-${crypto.randomBytes(4).toString('hex')}`; + const repoName = args.name || path.basename(normPath); + const branch = args.branch || 'main'; + + const newRepo = { + id: repoId, + name: repoName, + path: normPath, + branch, + attachedAt: new Date().toISOString(), + status: 'unindexed', + symbolCount: 0 + }; + + meta.attachedRepos.push(newRepo); + saveMeta(meta); + + return { operation: 'repos', action: 'add', success: true, repo: newRepo, repositories: meta.attachedRepos }; + } + + case 'remove': { + const repoId = args.repoId || args.id || args.path; + if (!repoId) throw new Error('repoId or path is required for removing repository.'); + + const meta = loadMeta(); + if (!Array.isArray(meta.attachedRepos)) meta.attachedRepos = []; + + const targetRepo = meta.attachedRepos.find(r => r.id === repoId || r.path === repoId); + meta.attachedRepos = meta.attachedRepos.filter(r => r.id !== repoId && r.path !== repoId); + saveMeta(meta); + + // Purge matching entities in graph db + try { + const dbPath = path.join(workspaceRoot, '.notes-app', 'ai-graph.db'); + if (fs.existsSync(dbPath)) { + const { DatabaseSync } = require('node:sqlite'); + const gdb = new DatabaseSync(dbPath); + try { + gdb.exec('BEGIN'); + if (targetRepo?.id) { + gdb.prepare('DELETE FROM entities WHERE properties LIKE ?').run(`%"repoId":"${targetRepo.id}"%`); + } + if (targetRepo?.path) { + gdb.prepare('DELETE FROM entities WHERE properties LIKE ?').run(`%"absolutePath":"${targetRepo.path.replace(/\\/g, '\\\\')}%`); + } + gdb.exec('COMMIT'); + } catch { + try { gdb.exec('ROLLBACK'); } catch {} + } finally { + try { gdb.close(); } catch {} + } + } + } catch { /* ignore graph purge error */ } + + return { operation: 'repos', action: 'remove', success: true, removedRepoId: repoId, repositories: meta.attachedRepos }; + } + + case 'status': { + const repoId = args.repoId || args.id || args.path; + const meta = loadMeta(); + const repos = Array.isArray(meta.attachedRepos) ? meta.attachedRepos : []; + if (repoId) { + const repo = repos.find(r => r.id === repoId || r.path === repoId); + if (!repo) throw new Error(`Repository "${repoId}" not found.`); + return { operation: 'repos', action: 'status', repo }; + } + return { operation: 'repos', action: 'status', repositories: repos }; + } + + case 'refresh': + case 'rescan': { + const meta = loadMeta(); + const repos = Array.isArray(meta.attachedRepos) ? meta.attachedRepos : []; + return { + operation: 'repos', + action, + success: true, + message: 'Repository scan requested. Use aiBuildGraph to execute full indexing.', + repositories: repos + }; + } + + default: + throw new Error(`Unknown manage_repositories action: "${action}". Supported actions: [list, add, remove, status, refresh].`); + } + } + // ─── ENTERPRISE TOOL DEFINITIONS FOR MCP REGISTRATION ───────────────────── getToolDefinitions() { @@ -1406,9 +1697,9 @@ class EnterpriseToolSuite { }, source: { type: 'string', - enum: ['notes', 'web', 'all'], + enum: ['notes', 'code', 'web', 'all'], default: 'notes', - description: 'Where to search: "notes" (workspace notes), "web" (external web search), or "all" (both)' + description: 'Where to search: "notes" (workspace notes), "code" (code entities and AST symbols), "web" (external web search), or "all"' }, mode: { type: 'string', @@ -1466,6 +1757,7 @@ class EnterpriseToolSuite { diagrams: { type: 'boolean', default: true, description: 'Include Mermaid diagrams and linked drawing files' }, tasks: { type: 'boolean', default: true, description: 'Include checklist tasks with status and due dates' }, links: { type: 'boolean', default: true, description: 'Include outgoing wikilinks and backlinks from other notes' }, + repoSymbols: { type: 'boolean', default: true, description: 'Include linked code symbols referenced in this note' }, gitHistory: { type: 'boolean', default: true, description: 'Include recent git commits modifying this note' }, stats: { type: 'boolean', default: true, description: 'Include word count, line count, and reading time' } } @@ -1640,9 +1932,9 @@ class EnterpriseToolSuite { name: 'workspace_overview', version: 'v1', capability: 'graph:traverse', - aliases: ['explore_topic_graph', 'get_graph'], + aliases: ['explore_topic_graph', 'get_graph', 'manage_repositories', 'manage_repos', 'attached_repos'], informationNeeds: ['entity_relationships', 'knowledge_graph', 'workspace_metadata'], - description: 'Workspace intelligence, structure, health, and diagnostics. Returns hierarchical folder trees, knowledge graph relationships, disk storage stats, link integrity audits (broken wikilinks), and recent file activity.', + description: 'Workspace intelligence, structure, health, and diagnostics. Returns hierarchical folder trees, knowledge graph relationships, attached repository summaries, disk storage stats, link integrity audits (broken wikilinks), and recent file activity.', isWrite: false, annotations: { readOnly: true, idempotent: true }, execute: async (args, context) => this.workspaceOverview(args, context), @@ -1651,9 +1943,29 @@ class EnterpriseToolSuite { properties: { operation: { type: 'string', - enum: ['summary', 'tree', 'graph', 'lint', 'index', 'recent_activity'], - default: 'summary', - description: '"summary" (health & note count), "tree" (folder/file hierarchy), "graph" (wikilink nodes & edges), "lint" (audit broken wikilinks & empty notes), "index" (structured notes catalog), "recent_activity" (recent modified notes)' + enum: ['summary', 'tree', 'graph', 'repos', 'lint', 'index', 'recent_activity'], + description: '"summary" (health & note count), "tree" (folder/file hierarchy), "graph" (wikilink & code entity graph), "repos" (attached git repositories status), "lint" (audit broken wikilinks & empty notes), "index" (structured notes catalog), "recent_activity" (recent modified notes)' + }, + action: { + type: 'string', + enum: ['list', 'add', 'remove', 'status', 'refresh'], + description: 'Action for "repos" operation: "list" (all attached repos), "add" (attach new local repo), "remove" (detach repo and purge graph symbols), "status" (inspect scan stats), "refresh" (request rescan)' + }, + path: { + type: 'string', + description: 'Local absolute directory path of the repository to attach' + }, + name: { + type: 'string', + description: 'Display alias name for the repository' + }, + branch: { + type: 'string', + description: 'Default Git branch name (default: main)' + }, + repoId: { + type: 'string', + description: 'Repository ID for "remove" or "status" actions' }, folder: { type: 'string', diff --git a/package-lock.json b/package-lock.json index a313297..c218b39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "notely", - "version": "0.1.47", + "version": "0.1.50", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "notely", - "version": "0.1.47", + "version": "0.1.50", "hasInstallScript": true, "license": "CC-BY-NC-4.0", "dependencies": { @@ -25,7 +25,9 @@ "onnxruntime-node": "^1.27.0", "onnxruntime-web": "^1.27.0", "simple-git": "^3.36.0", + "tree-sitter-wasms": "^0.1.13", "uuid": "^14.0.1", + "web-tree-sitter": "^0.22.6", "xlsx": "^0.18.5", "yauzl": "^3.4.0", "yazl": "^3.3.1", @@ -17856,6 +17858,15 @@ "tree-kill": "cli.js" } }, + "node_modules/tree-sitter-wasms": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/tree-sitter-wasms/-/tree-sitter-wasms-0.1.13.tgz", + "integrity": "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ==", + "license": "Unlicense", + "dependencies": { + "tree-sitter-wasms": "^0.1.11" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -20358,6 +20369,12 @@ "loose-envify": "^1.0.0" } }, + "node_modules/web-tree-sitter": { + "version": "0.22.6", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.22.6.tgz", + "integrity": "sha512-hS87TH71Zd6mGAmYCvlgxeGDjqd9GTeqXNqTT+u0Gs51uIozNIaaq/kUAbV/Zf56jb2ZOyG8BxZs2GG9wbLi6Q==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index 85b6381..dcab7f6 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,9 @@ "onnxruntime-node": "^1.27.0", "onnxruntime-web": "^1.27.0", "simple-git": "^3.36.0", + "tree-sitter-wasms": "^0.1.13", "uuid": "^14.0.1", + "web-tree-sitter": "^0.22.6", "xlsx": "^0.18.5", "yauzl": "^3.4.0", "yazl": "^3.3.1", diff --git a/src/components/AttachedReposPage.jsx b/src/components/AttachedReposPage.jsx index 63cc9d1..c3ac08d 100644 --- a/src/components/AttachedReposPage.jsx +++ b/src/components/AttachedReposPage.jsx @@ -2,21 +2,10 @@ import { useState, useMemo, useEffect, useCallback } from "react"; import { FolderGit2, GitBranch, - GitCommit, Search, Plus, RefreshCw, - Trash2, - ExternalLink, - Share2, - FileText, - X, - Copy, - Check, - PanelLeftClose, - PanelLeftOpen, - FolderOpen, - Layers + X } from "lucide-react"; import { getAttachedRepos, @@ -25,13 +14,13 @@ import { onAttachedReposChanged, openFolder, openExternal, - aiBuildGraph, - onGraphProgress + aiBuildGraph } from "../services/electronService"; import AppButton from "./AppButton"; -import AppIconButton from "./AppIconButton"; -import AppInput from "./AppInput"; -import OverlayDialog from "./OverlayDialog"; +import { + RepoCard, + AttachRepoModal +} from "./repos"; import { showToast } from "../utils/notificationUtils"; import "../styles/AttachedReposPage.css"; @@ -40,25 +29,13 @@ export function AttachedReposPage({ documents = [], onBack, onClose, - onNotify, - onOpenKnowledgeGraph, - onOpenNote + onNotify }) { const handleBack = onBack || onClose; const [repos, setRepos] = useState([]); const [searchQuery, setSearchQuery] = useState(""); - const [selectedRepoId, setSelectedRepoId] = useState("all"); - const [selectedItem, setSelectedItem] = useState(null); - const [sidebarOpen, setSidebarOpen] = useState(true); const [isScanning, setIsScanning] = useState(false); - const [scanProgress, setScanProgress] = useState(null); - const [copiedLink, setCopiedLink] = useState(false); - - // Attach Modal State const [attachModalOpen, setAttachModalOpen] = useState(false); - const [newRepoPath, setNewRepoPath] = useState(""); - const [newRepoName, setNewRepoName] = useState(""); - const [newRepoBranch, setNewRepoBranch] = useState("main"); // Escape key handler useEffect(() => { @@ -66,8 +43,6 @@ export function AttachedReposPage({ if (e.key === "Escape") { if (attachModalOpen) { setAttachModalOpen(false); - } else if (selectedItem) { - setSelectedItem(null); } else if (typeof handleBack === "function") { handleBack(); } @@ -75,7 +50,7 @@ export function AttachedReposPage({ }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [attachModalOpen, selectedItem, handleBack]); + }, [attachModalOpen, handleBack]); const notifyUser = useCallback((msg, type = "info") => { if (typeof onNotify === "function") { @@ -105,57 +80,12 @@ export function AttachedReposPage({ }; }, [loadRepos]); - // Browse Directory for new repo - const handleBrowseRepoFolder = async () => { - try { - if (window.notesApi?.showOpenDialog) { - const result = await window.notesApi.showOpenDialog({ - title: "Select Git Repository Directory", - properties: ["openDirectory"] - }); - if (result && !result.canceled && result.filePaths?.length > 0) { - const selected = result.filePaths[0]; - setNewRepoPath(selected); - const baseName = selected.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "repository"; - if (!newRepoName) setNewRepoName(baseName); - } - } - } catch (err) { - notifyUser("Error opening folder picker: " + err.message, "error"); - } - }; - - // Submit Attach Repo - const handleAttachSubmit = async (e) => { - if (e) e.preventDefault(); - if (!newRepoPath.trim()) { - notifyUser("Repository path is required", "error"); - return; - } - try { - await addAttachedRepo({ - path: newRepoPath.trim(), - name: newRepoName.trim() || newRepoPath.split(/[\\/]/).pop(), - branch: newRepoBranch.trim() || "main" - }); - notifyUser("Repository attached successfully", "success"); - setAttachModalOpen(false); - setNewRepoPath(""); - setNewRepoName(""); - loadRepos(); - } catch (err) { - notifyUser("Failed attaching repository: " + err.message, "error"); - } - }; - // Detach Repo const handleDetachRepo = async (repoId, repoName) => { if (window.confirm(`Detach repository "${repoName}" from workspace?`)) { try { await removeAttachedRepo(repoId); notifyUser("Repository detached", "info"); - if (selectedRepoId === repoId) setSelectedRepoId("all"); - if (selectedItem?.id === repoId) setSelectedItem(null); loadRepos(); } catch (err) { notifyUser("Failed to detach repository: " + err.message, "error"); @@ -167,29 +97,38 @@ export function AttachedReposPage({ const handleRescanGraph = async () => { if (isScanning) return; setIsScanning(true); - setScanProgress("Starting AST scan..."); try { - const unsub = onGraphProgress((data) => { - if (data?.noteName) { - setScanProgress(`Processing: ${data.noteName} (${data.current}/${data.total})`); - } - }); await aiBuildGraph(); - if (typeof unsub === "function") unsub(); - notifyUser("Code & Notes Knowledge Graph updated!", "success"); + notifyUser("Knowledge Graph updated", "success"); loadRepos(); } catch (err) { notifyUser("Scan failed: " + err.message, "error"); } finally { setIsScanning(false); - setScanProgress(null); + } + }; + + // Submit Attach Repo + const handleAttachSubmit = async ({ path: repoPath, name: repoName, branch: repoBranch }) => { + try { + await addAttachedRepo({ + path: repoPath, + name: repoName, + branch: repoBranch + }); + notifyUser("Repository attached successfully", "success"); + setAttachModalOpen(false); + loadRepos(); + handleRescanGraph(); + } catch (err) { + notifyUser("Failed attaching repository: " + err.message, "error"); } }; // Filtered Repos const filteredRepos = useMemo(() => { return repos.filter((r) => { - const q = searchQuery.toLowerCase(); + const q = searchQuery.toLowerCase().trim(); if (!q) return true; return ( (r.name && r.name.toLowerCase().includes(q)) || @@ -199,32 +138,21 @@ export function AttachedReposPage({ }); }, [repos, searchQuery]); - // Find Notes referencing selected repo - const referencingNotes = useMemo(() => { - if (!selectedItem || !documents.length) return []; - const name = selectedItem.name || ""; - if (!name) return []; - - return documents.filter((doc) => { - if (doc.entryType !== "file") return false; - const content = doc.content || ""; - return ( - content.includes(`[[${name}]]`) || - content.includes(`@${name}`) || - content.includes(name) - ); - }); - }, [selectedItem, documents]); - - // Copy Markdown Link - const handleCopyLink = () => { - if (!selectedItem) return; - const linkText = `[[${selectedItem.name}]]`; - navigator.clipboard.writeText(linkText); - setCopiedLink(true); - notifyUser(`Copied ${linkText} to clipboard`, "info"); - setTimeout(() => setCopiedLink(false), 2000); - }; + // Map of repo name to reference counts in documents + const referenceCounts = useMemo(() => { + if (!documents.length) return {}; + const counts = {}; + for (const repo of repos) { + if (!repo.name) continue; + const count = documents.filter((doc) => { + if (doc.entryType !== "file") return false; + const c = doc.content || ""; + return c.includes(`[[${repo.name}]]`) || c.includes(`@${repo.name}`); + }).length; + counts[repo.id] = count; + } + return counts; + }, [repos, documents]); // Open in External IDE (VS Code) const handleOpenInIDE = (filePath) => { @@ -247,440 +175,120 @@ export function AttachedReposPage({ Attached Code Repositories - - -
- {/* Header Toolbar */} -
- setSidebarOpen((prev) => !prev)} - aria-label={sidebarOpen ? "Hide filters sidebar" : "Show filters sidebar"} - title={sidebarOpen ? "Hide filters sidebar" : "Show filters sidebar"} - > - {sidebarOpen ? : } - - -
- - setSearchQuery(e.target.value)} - /> -
-
- - - {repos.length} {repos.length === 1 ? "Repository" : "Repositories"} Attached - +
+
+ + {repos.length} {repos.length === 1 ? "Repo" : "Repos"}
-
- - - {isScanning ? "Scanning..." : "Rescan Graph"} - - - {onOpenKnowledgeGraph && ( - - - Knowledge Graph - - )} - - setAttachModalOpen(true)} - title="Attach an external Git repository" - > - - Attach Repo - -
+ + + {isScanning ? "Scanning..." : "Rescan"} + + + setAttachModalOpen(true)} + title="Attach an external Git repository" + > + + Attach Repo +
+
- {/* Scanning progress banner */} - {scanProgress && ( -
- - {scanProgress} -
- )} - - {/* Main Body */} -
- {/* Left Sidebar */} -
-
-
-
- - - Repositories - - {repos.length} -
+
+ {/* Toolbar: Search input */} + {repos.length > 0 && ( +
+
+ + setSearchQuery(e.target.value)} + /> + {searchQuery && ( - {repos.map((r) => ( - - ))} -
- -
-
- Knowledge Graph -
-
-
AST code symbols are extracted and merged into Notely's Knowledge Graph.
-
- Link from any note with [[SymbolName]] or @repo/path. -
-
-
+ )}
+ )} - {/* Canvas / Main Cards Grid */} + {/* Main Body */} +
- {filteredRepos.length === 0 ? ( + {repos.length === 0 ? (
-
+
No Repositories Attached Yet
-
- Attach your project's Git repositories (frontend, backend, microservices) to include code symbols, classes, functions, and routes in your Knowledge Graph. +
+ Attach your project's local Git repositories to index code symbols, classes, and functions into your workspace graph.
setAttachModalOpen(true)} - style={{ marginTop: "12px" }} > - - Attach First Repository + + Attach First Repository + +
+ ) : filteredRepos.length === 0 ? ( +
+ +
No Matching Repositories
+
+ No attached repositories matched "{searchQuery}". +
+ setSearchQuery("")} + > + Clear Filter
) : (
{filteredRepos.map((repo) => ( -
setSelectedItem(repo)} - > -
-
- - {repo.name} -
- - - {repo.branch || "main"} - -
- -
- {repo.path} -
- - {repo.headCommit && ( -
- - {repo.headCommit.slice(0, 7)} - {repo.remoteUrl && ( - - {repo.remoteUrl} - - )} -
- )} - -
- { - e.stopPropagation(); - openFolder(repo.path); - }} - title="Open folder in File Explorer" - > - - Folder - - - { - e.stopPropagation(); - handleOpenInIDE(repo.path); - }} - title="Open in VS Code" - > - - VS Code - - - { - e.stopPropagation(); - handleDetachRepo(repo.id, repo.name); - }} - title="Detach repository from workspace" - > - - Detach - -
-
+ repo={repo} + referencingCount={referenceCounts[repo.id] || 0} + onDetach={handleDetachRepo} + onOpenFolder={openFolder} + onOpenInIDE={handleOpenInIDE} + onCopyLink={(name) => notifyUser(`Copied [[${name}]] to clipboard`, "info")} + /> ))}
)}
- - {/* Right Inspector Drawer */} - {selectedItem && ( -
-
-
- - - {selectedItem.name} - -
- setSelectedItem(null)} - aria-label="Close drawer" - title="Close drawer" - > - - -
- -
-
- Local Path -
-
- {selectedItem.path} -
-
- - {selectedItem.branch && ( -
-
- Active Branch -
-
- - {selectedItem.branch} -
-
- )} - - {/* Referencing Notes Section */} -
-
- Referenced in Notes - {referencingNotes.length} -
- - {referencingNotes.length === 0 ? ( -
- No notes currently reference this repository. Mention it in any note with [[{selectedItem.name}]]. -
- ) : ( -
- {referencingNotes.map((doc) => ( -
{ - if (onOpenNote) onOpenNote(doc.filePath); - }} - > -
- - - {doc.title || doc.filePath} - -
- -
- ))} -
- )} -
- - {/* Action Buttons */} -
- - {copiedLink ? : } - {copiedLink ? "Copied Link!" : "Copy WikiLink [[Name]]"} - - - handleOpenInIDE(selectedItem.path)} - > - - Open in VS Code - - - {onOpenKnowledgeGraph && ( - - - View in Knowledge Graph - - )} -
-
- )}
{/* Attach Repo Dialog */} - {attachModalOpen && ( - setAttachModalOpen(false)} - title="Attach Git Repository" - size="md" - > -
-
- -
- setNewRepoPath(e.target.value)} - style={{ flex: 1 }} - required - /> - - - Browse - -
-
- -
- - setNewRepoName(e.target.value)} - /> -
- -
- - setNewRepoBranch(e.target.value)} - /> -
- -
- Notely will scan this repository for code files, classes, and exported functions and integrate them into your workspace's Knowledge Graph. -
- -
- setAttachModalOpen(false)} - > - Cancel - - - - Attach Repository - -
-
-
- )} + setAttachModalOpen(false)} + onAttach={handleAttachSubmit} + />
); } diff --git a/src/components/KnowledgeGraph.jsx b/src/components/KnowledgeGraph.jsx index a9e3ad3..54dc383 100644 --- a/src/components/KnowledgeGraph.jsx +++ b/src/components/KnowledgeGraph.jsx @@ -6,7 +6,8 @@ import { useNodesState, useEdgesState, Handle, - Position + Position, + MarkerType } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { @@ -47,8 +48,8 @@ import { useConfirm } from '../hooks/useConfirm'; import * as d3Force from 'd3-force'; import '../styles/KnowledgeGraph.css'; -// Custom Node component -const CustomNode = ({ data, selected }) => { +// Custom Node component with directional connection handles (memoized for high FPS) +const CustomNode = React.memo(({ data, selected }) => { const isHub = (data.degree || 0) >= 5; const typeColor = data.typeColor || { border: 'var(--accent-solid, #2f5d62)', background: 'rgba(47, 93, 98, 0.12)', text: 'var(--accent-solid, #2f5d62)' }; const name = data.raw?.name || data.raw?.canonical_name || 'Node'; @@ -70,22 +71,8 @@ const CustomNode = ({ data, selected }) => { }} title={`${name} (${data.raw?.type || 'Entity'}) — ${data.degree || 0} connections`} > - + +
{ )} - + +
); -}; +}); const nodeTypes = { customNode: CustomNode, @@ -245,6 +218,49 @@ const RELATIONSHIP_COLORS = { DEFAULT: '#06b6d4' // Vibrant Cyan fallback }; +const CANONICAL_TYPE_MAP = { + note: 'Note', + person: 'Person', + project: 'Project', + technology: 'Technology', + company: 'Company', + organization: 'Organization', + concept: 'Concept', + task: 'Task', + decision: 'Decision', + idea: 'Idea', + tag: 'Tag', + image: 'Image', + document: 'Document', + folder: 'Folder', + workspace: 'Workspace', + section: 'Section', + event: 'Event', + location: 'Location', + externalurl: 'ExternalURL', + external_url: 'ExternalURL', + codeblock: 'CodeBlock', + diagram: 'Diagram', + keyterm: 'KeyTerm', + formula: 'Formula', + callout: 'Callout', + repo: 'Repo', + codemodule: 'CodeModule', + codeclass: 'CodeClass', + codeinterface: 'CodeInterface', + codefunction: 'CodeFunction', + apiendpoint: 'APIEndpoint', + dbmodel: 'DBModel' +}; + +const normalizeType = (rawType) => { + if (!rawType) return 'Concept'; + const clean = String(rawType).trim(); + const key = clean.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (CANONICAL_TYPE_MAP[key]) return CANONICAL_TYPE_MAP[key]; + return clean.charAt(0).toUpperCase() + clean.slice(1); +}; + export default function KnowledgeGraph({ onBack }) { const { confirm } = useConfirm(); const [nodes, setNodes, onNodesChange] = useNodesState([]); @@ -256,11 +272,19 @@ export default function KnowledgeGraph({ onBack }) { Project: true, Technology: true, Company: true, + Organization: true, Concept: true, Task: true, Image: true, Document: true, - ExternalURL: true + ExternalURL: true, + Repo: true, + CodeModule: true, + CodeClass: true, + CodeInterface: true, + CodeFunction: true, + APIEndpoint: true, + DBModel: true }); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -280,6 +304,7 @@ export default function KnowledgeGraph({ onBack }) { const [showEdgeLabels, setShowEdgeLabels] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true); + const [rawEntities, setRawEntities] = useState([]); const [rawRelationships, setRawRelationships] = useState([]); const [chargeStrength] = useState(-280); const [linkDistance] = useState(150); @@ -328,7 +353,7 @@ export default function KnowledgeGraph({ onBack }) { } }, []); - // Load Graph Data + // Load Graph Data & Compute Force-Directed Layout const loadGraphData = useCallback(async () => { try { setLoading(true); @@ -347,8 +372,15 @@ export default function KnowledgeGraph({ onBack }) { } if (graphRes.success && graphRes.data) { - const { entities, relationships } = graphRes.data; - setRawRelationships(relationships || []); + const rawEntitiesData = Array.isArray(graphRes.data.entities) ? graphRes.data.entities : []; + const entities = rawEntitiesData.map(ent => ({ + ...ent, + type: normalizeType(ent.type) + })); + const relationships = Array.isArray(graphRes.data.relationships) ? graphRes.data.relationships : []; + + setRawEntities(entities); + setRawRelationships(relationships); const degrees = {}; entities.forEach(e => { degrees[e.id] = 0; }); @@ -361,8 +393,8 @@ export default function KnowledgeGraph({ onBack }) { const forceNodes = entities.map(entity => ({ id: entity.id, entity, - x: Math.random() * 500, - y: Math.random() * 500 + x: (Math.random() - 0.5) * 600 + 400, + y: (Math.random() - 0.5) * 600 + 350 })); const forceLinks = relationships @@ -384,7 +416,7 @@ export default function KnowledgeGraph({ onBack }) { .force('collision', d3Force.forceCollide().radius(dynamicCollision)) .stop(); - const ticks = Math.min(120, Math.max(40, Math.round(nodeCount * 1.5))); + const ticks = Math.min(80, Math.max(30, Math.round(nodeCount * 0.8))); for (let i = 0; i < ticks; i++) simulation.tick(); forceNodes.forEach(node => { @@ -406,8 +438,7 @@ export default function KnowledgeGraph({ onBack }) { raw: entity, degree, nodeSize, - typeColor: typeColors, - relationships: relationships.filter(r => r.source_id === entity.id || r.target_id === entity.id) + typeColor: typeColors }, position: { x: node.x, y: node.y }, style: { @@ -452,8 +483,13 @@ export default function KnowledgeGraph({ onBack }) { labelBgStyle: { fill: 'var(--surface-bg)', stroke: 'var(--border-default)', strokeWidth: 0.5, fillOpacity: 0.9 }, labelBgPadding: [1.5, 3], labelBgBorderRadius: 3, - markerEnd: { type: 'arrowclosed', color: isMentions ? 'rgba(140, 140, 140, 0.35)' : relColor, width: 8, height: 8 }, - animated: relTypeUpper === 'DEPENDS_ON' || relTypeUpper === 'USES' + markerEnd: { + type: MarkerType.ArrowClosed, + color: isMentions ? 'rgba(140, 140, 140, 0.45)' : relColor, + width: 14, + height: 14 + }, + animated: relTypeUpper === 'DEPENDS_ON' || relTypeUpper === 'USES' || relTypeUpper === 'IMPORTS' }; }); @@ -478,13 +514,6 @@ export default function KnowledgeGraph({ onBack }) { const unsubscribe = onGraphProgress((payload) => { if (payload) { setGraphStatus(prev => ({ ...prev, ...payload })); - - // Refresh logs in real-time on progress events - aiGetLogs('graph', 50).then((logsRes) => { - if (logsRes && logsRes.success && Array.isArray(logsRes.data)) { - setGraphLogs(logsRes.data); - } - }).catch(() => {}); if (!payload.isBuilding && isRebuilding) { setIsRebuilding(false); @@ -589,49 +618,44 @@ export default function KnowledgeGraph({ onBack }) { const activeNeighbors = new Set(); if (hoveredNodeId) { activeNeighbors.add(hoveredNodeId); - edges.forEach(edge => { + for (let i = 0; i < edges.length; i++) { + const edge = edges[i]; if (edge.source === hoveredNodeId) activeNeighbors.add(edge.target); if (edge.target === hoveredNodeId) activeNeighbors.add(edge.source); - }); + } } - const visibleNodes = nodes.map(node => { - const raw = node.data.raw; + const visibleNodes = []; + const visibleNodeIds = new Set(); + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const raw = node.data?.raw || {}; const typeMatch = selectedTypes[raw.type] !== false; - const searchMatch = !q || raw.name.toLowerCase().includes(q) || raw.type.toLowerCase().includes(q) || raw.id.toLowerCase().includes(q); - const isVisible = typeMatch && searchMatch; + const searchMatch = !q || (raw.name && raw.name.toLowerCase().includes(q)) || (raw.type && raw.type.toLowerCase().includes(q)); - let opacity = 1; - if (isVisible && hoveredNodeId) { - if (!activeNeighbors.has(node.id)) opacity = 0.2; + if (typeMatch && searchMatch) { + visibleNodeIds.add(node.id); + const opacity = hoveredNodeId ? (activeNeighbors.has(node.id) ? 1 : 0.2) : 1; + visibleNodes.push(opacity === 1 ? node : { ...node, style: { ...node.style, opacity } }); } + } - return { - ...node, - style: { ...node.style, display: isVisible ? 'flex' : 'none', opacity } - }; - }); - - const visibleNodeIds = new Set(visibleNodes.filter(n => n.style.display !== 'none').map(n => n.id)); - const visibleEdges = edges - .filter(edge => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target)) - .map(edge => { - let opacity = 1; - if (hoveredNodeId) { - const connectsHovered = edge.source === hoveredNodeId || edge.target === hoveredNodeId; - opacity = connectsHovered ? 1 : 0.15; - } - return { + const visibleEdges = []; + for (let i = 0; i < edges.length; i++) { + const edge = edges[i]; + if (visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target)) { + const opacity = hoveredNodeId ? ((edge.source === hoveredNodeId || edge.target === hoveredNodeId) ? 1 : 0.15) : 1; + visibleEdges.push({ ...edge, label: showEdgeLabels ? edge.rawLabel : undefined, - style: { ...edge.style, opacity }, - labelStyle: { ...edge.labelStyle, opacity }, - labelBgStyle: { ...edge.labelBgStyle, opacity } - }; - }); + style: opacity === 1 ? edge.style : { ...edge.style, opacity } + }); + } + } return { - filteredNodes: visibleNodes.filter(n => n.style.display !== 'none'), + filteredNodes: visibleNodes, filteredEdges: visibleEdges }; }, [nodes, edges, searchQuery, selectedTypes, hoveredNodeId, showEdgeLabels]); @@ -707,7 +731,7 @@ export default function KnowledgeGraph({ onBack }) {
- Nodes: {graphStatus.nodeCount} | Edges: {graphStatus.edgeCount} + Nodes: {graphStatus.nodeCount || nodes.length} | Edges: {graphStatus.edgeCount || edges.length}
@@ -914,9 +938,13 @@ export default function KnowledgeGraph({ onBack }) {
{[ { label: 'LINKS', color: RELATIONSHIP_COLORS.LINKS_TO }, + { label: 'IMPORTS', color: RELATIONSHIP_COLORS.IMPORTS }, + { label: 'CALLS', color: RELATIONSHIP_COLORS.CALLS }, + { label: 'CONTAINS', color: RELATIONSHIP_COLORS.CONTAINS }, + { label: 'EXPORTS', color: RELATIONSHIP_COLORS.EXPORTS }, + { label: 'DOCUMENTS', color: RELATIONSHIP_COLORS.DOCUMENTS }, { label: 'DEPENDS', color: RELATIONSHIP_COLORS.DEPENDS_ON }, { label: 'USES', color: RELATIONSHIP_COLORS.USES }, - { label: 'CONTAINS', color: RELATIONSHIP_COLORS.CONTAINS }, { label: 'MENTIONS', color: RELATIONSHIP_COLORS.MENTIONS_NOTE }, { label: 'TAGGED', color: RELATIONSHIP_COLORS.TAGGED }, { label: 'URL', color: RELATIONSHIP_COLORS.REFERENCES_URL }, @@ -1162,7 +1190,8 @@ export default function KnowledgeGraph({ onBack }) { onNodeMouseLeave={onNodeMouseLeave} fitView fitViewOptions={{ padding: 0.2, maxZoom: 1.2 }} - minZoom={0.001} + onlyRenderVisibleElements={true} + minZoom={0.01} maxZoom={2.5} defaultViewport={{ x: 0, y: 0, zoom: 1.0 }} style={{ width: '100%', height: '100%', background: 'var(--app-bg)' }} diff --git a/src/components/repos/AttachRepoModal.jsx b/src/components/repos/AttachRepoModal.jsx new file mode 100644 index 0000000..698820c --- /dev/null +++ b/src/components/repos/AttachRepoModal.jsx @@ -0,0 +1,135 @@ +import { useState, useEffect } from "react"; +import { FolderOpen, Plus } from "lucide-react"; +import OverlayDialog from "../OverlayDialog"; +import AppButton from "../AppButton"; +import AppInput from "../AppInput"; + +export function AttachRepoModal({ + open = false, + onClose, + onAttach +}) { + const [newRepoPath, setNewRepoPath] = useState(""); + const [newRepoName, setNewRepoName] = useState(""); + const [newRepoBranch, setNewRepoBranch] = useState("main"); + + useEffect(() => { + if (!open) { + setNewRepoPath(""); + setNewRepoName(""); + setNewRepoBranch("main"); + } + }, [open]); + + const handleBrowse = async () => { + try { + if (window.notesApi?.showOpenDialog) { + const result = await window.notesApi.showOpenDialog({ + title: "Select Git Repository Directory", + properties: ["openDirectory"] + }); + if (result && !result.canceled && result.filePaths?.length > 0) { + const selected = result.filePaths[0]; + setNewRepoPath(selected); + const baseName = selected.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "repository"; + if (!newRepoName) setNewRepoName(baseName); + } + } + } catch { + // dialog cancelled or not available + } + }; + + const handleSubmit = (e) => { + if (e) e.preventDefault(); + if (!newRepoPath.trim()) return; + if (typeof onAttach === "function") { + onAttach({ + path: newRepoPath.trim(), + name: newRepoName.trim() || newRepoPath.split(/[\\/]/).pop(), + branch: newRepoBranch.trim() || "main" + }); + } + }; + + if (!open) return null; + + return ( + +
+
+ +
+ setNewRepoPath(e.target.value)} + required + /> + + + Browse + +
+
+ +
+ + setNewRepoName(e.target.value)} + /> +
+ +
+ + setNewRepoBranch(e.target.value)} + /> +
+ +
+ Notely will scan this repository for code files, classes, and exported functions and integrate them into your workspace's Knowledge Graph. +
+ +
+ + Cancel + + + + Attach Repository + +
+
+
+ ); +} + +export default AttachRepoModal; diff --git a/src/components/repos/RepoCard.jsx b/src/components/repos/RepoCard.jsx new file mode 100644 index 0000000..732a26f --- /dev/null +++ b/src/components/repos/RepoCard.jsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { + FolderGit2, + GitBranch, + GitCommit, + FolderOpen, + Trash2, + Copy, + Check +} from "lucide-react"; +import AppIconButton from "../AppIconButton"; +import RepoStatusBadge from "./RepoStatusBadge"; + +function VscodeIcon({ size = 14, className = "" }) { + return ( + + ); +} + +export function RepoCard({ + repo, + referencingCount = 0, + onDetach, + onOpenFolder, + onOpenInIDE, + onCopyLink +}) { + const [copied, setCopied] = useState(false); + if (!repo) return null; + + const handleCopy = (e) => { + e.stopPropagation(); + const linkText = `[[${repo.name}]]`; + navigator.clipboard.writeText(linkText); + setCopied(true); + if (typeof onCopyLink === "function") onCopyLink(repo.name); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+
+ +
+
+
+ {repo.name} +
+ + {repo.branch && ( + + + {repo.branch} + + )} +
+
+
+ {repo.path} +
+ {(repo.headCommit || referencingCount > 0) && ( +
+ {repo.headCommit && ( +
+ + {repo.headCommit.slice(0, 7)} +
+ )} + {referencingCount > 0 && ( +
+ {referencingCount} {referencingCount === 1 ? "note ref" : "note refs"} +
+ )} +
+ )} +
+
+ +
+ + {copied ? : } + + + { + if (typeof onOpenFolder === "function") onOpenFolder(repo.path); + }} + title="Open folder in File Explorer" + aria-label="Open folder in File Explorer" + > + + + + { + if (typeof onOpenInIDE === "function") onOpenInIDE(repo.path); + }} + title="Open in VS Code" + aria-label="Open in VS Code" + > + + + + { + if (typeof onDetach === "function") onDetach(repo.id, repo.name); + }} + title="Detach repository from workspace" + aria-label="Detach repository from workspace" + > + + +
+
+ ); +} + +export default RepoCard; diff --git a/src/components/repos/RepoStatusBadge.jsx b/src/components/repos/RepoStatusBadge.jsx new file mode 100644 index 0000000..9d990fd --- /dev/null +++ b/src/components/repos/RepoStatusBadge.jsx @@ -0,0 +1,41 @@ +import { CheckCircle2, Clock, AlertCircle } from "lucide-react"; + +export function RepoStatusBadge({ status = "unindexed", symbolCount = 0 }) { + const normalizedStatus = String(status || "unindexed").toLowerCase(); + + if (normalizedStatus === "indexed") { + return ( + + + Indexed{symbolCount > 0 ? ` (${symbolCount})` : ""} + + ); + } + + if (normalizedStatus === "indexing") { + return ( + + + Indexing… + + ); + } + + if (normalizedStatus === "error") { + return ( + + + Error + + ); + } + + return ( + + + Unindexed + + ); +} + +export default RepoStatusBadge; diff --git a/src/components/repos/index.js b/src/components/repos/index.js new file mode 100644 index 0000000..678afe5 --- /dev/null +++ b/src/components/repos/index.js @@ -0,0 +1,3 @@ +export { RepoStatusBadge } from "./RepoStatusBadge"; +export { RepoCard } from "./RepoCard"; +export { AttachRepoModal } from "./AttachRepoModal"; \ No newline at end of file diff --git a/src/styles/AttachedReposPage.css b/src/styles/AttachedReposPage.css index 25751b1..f801b94 100644 --- a/src/styles/AttachedReposPage.css +++ b/src/styles/AttachedReposPage.css @@ -1,5 +1,5 @@ /* Attached Code Repositories Page Styling - 100% compliant with Notely Design System & Media Gallery standards */ + 100% compliant with Notely Design System & global CSS tokens */ .attached-repos-page { display: flex; @@ -12,173 +12,134 @@ font-family: inherit; } -.arp-container { - display: flex; - flex-direction: column; - flex: 1; - width: 100%; - overflow: hidden; - position: relative; -} - -/* Header toolbar */ -.arp-header-actions { +.attached-repos-page .detail-topbar { display: flex; align-items: center; - gap: var(--space-3, 12px); - padding: 10px 16px; - min-height: 52px; - border-bottom: 1px solid var(--border-default, rgba(255, 255, 255, 0.08)); - background: var(--surface-subtle, var(--surface-bg)); - box-sizing: border-box; - flex-wrap: nowrap; -} - -.arp-search-wrapper { - position: relative; - flex: 1; - max-width: 360px; -} - -.arp-search-icon { - position: absolute; - left: 10px; - top: 50%; - transform: translateY(-50%); - color: var(--text-muted, #888); - pointer-events: none; -} - -.arp-search-input { - width: 100%; - padding: 6px 12px 6px 32px; - background: var(--surface-bg, #1a1a1a); - border: 1px solid var(--border-default, #333); - border-radius: var(--radius-md, 6px); - color: var(--text-strong, #fff); - font-size: 13px; - outline: none; - transition: border-color 0.15s ease, box-shadow 0.15s ease; + justify-content: space-between; + padding: 0 16px; + height: 48px; + min-height: 48px; + border-bottom: 1px solid var(--border-default); + background: var(--surface-bg); box-sizing: border-box; - height: 32px; } -.arp-search-input:focus { - border-color: var(--accent-solid, #8b5cf6); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-solid, #8b5cf6) 20%, transparent); +.attached-repos-page .detail-topbar-actions { + display: flex; + align-items: center; + gap: 8px; } -.arp-stats-pill { +.topbar-stat-pill { display: flex; align-items: center; - gap: 8px; - padding: 0 12px; - height: 32px; + gap: 6px; + padding: 4px 10px; + height: 28px; background: var(--surface-muted, rgba(255, 255, 255, 0.04)); border: 1px solid var(--border-soft, rgba(255, 255, 255, 0.08)); - border-radius: var(--radius-md, 6px); + border-radius: var(--radius-sm, 4px); font-size: 12px; color: var(--text-secondary, #aaa); font-weight: 500; - white-space: nowrap; box-sizing: border-box; } -.arp-stats-pill strong { - color: var(--text-strong, #fff); -} - -.arp-header-buttons { - display: flex; - align-items: center; - gap: 8px; - margin-left: auto; +.topbar-stat-pill svg { + color: var(--accent-solid, #8b5cf6); } -/* Main Body Layout */ -.arp-body { +.arp-container { display: flex; + flex-direction: column; flex: 1; - min-height: 0; width: 100%; - position: relative; overflow: hidden; + position: relative; } -/* Left Sidebar */ -.arp-sidebar { - background: var(--surface-bg); +/* Sub-toolbar: Search */ +.arp-toolbar { display: flex; - flex-direction: column; - overflow-y: auto; + align-items: center; + gap: 12px; + padding: 10px 16px; + background: var(--surface-subtle, var(--surface-bg)); + border-bottom: 1px solid var(--border-soft, rgba(255, 255, 255, 0.06)); box-sizing: border-box; - flex-shrink: 0; } -.arp-sidebar-section-scroll { - padding: 14px 12px; +.arp-search-wrapper { + position: relative; + width: 100%; + max-width: 360px; display: flex; - flex-direction: column; - gap: 16px; + align-items: center; + background: var(--surface-bg, #1a1a1a); + border: 1px solid var(--border-soft, #333); + border-radius: var(--radius-sm, 4px); + box-sizing: border-box; + height: 32px; + padding: 0 8px; + outline: none; + transition: border-color var(--motion-fast, 0.15s ease); } -.arp-sidebar-section { - display: flex; - flex-direction: column; - gap: 6px; +.arp-search-wrapper:hover { + border-color: var(--border-default, #555); } -.arp-sidebar-section-title { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-muted, #71717a); - padding: 0 4px 4px; - display: flex; - align-items: center; - justify-content: space-between; +.arp-search-wrapper:focus-within { + border-color: var(--border-default, #555); + outline: none; + box-shadow: none; } -.arp-filter-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 6px 8px; - border-radius: var(--radius-md, 6px); - font-size: 13px; - color: var(--text-secondary); - cursor: pointer; - background: transparent; - border: 1px solid transparent; - transition: all 0.15s ease; - width: 100%; - text-align: left; +.arp-search-icon { + color: var(--text-muted, #888); + flex-shrink: 0; + margin-right: 6px; + pointer-events: none; } -.arp-filter-item:hover { - background: var(--surface-hover, rgba(255, 255, 255, 0.06)); - color: var(--text-primary); +.arp-search-input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + color: var(--text-strong, #fff); + font-size: 12.5px; + outline: none !important; + box-shadow: none !important; + padding: 0; + height: 100%; } -.arp-filter-item.active { - background: color-mix(in srgb, var(--accent-solid, #8b5cf6) 16%, transparent); - color: var(--accent-solid, #8b5cf6); - border-color: color-mix(in srgb, var(--accent-solid, #8b5cf6) 30%, transparent); - font-weight: 500; +.arp-search-clear { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 2px; + border-radius: var(--radius-sm, 4px); + flex-shrink: 0; } -.arp-filter-count { - font-size: 11px; - padding: 1px 6px; - border-radius: 999px; - background: var(--surface-muted, rgba(255, 255, 255, 0.08)); - color: var(--text-muted); +.arp-search-clear:hover { + color: var(--text-strong); } -.arp-filter-item.active .arp-filter-count { - background: color-mix(in srgb, var(--accent-solid, #8b5cf6) 25%, transparent); - color: var(--accent-solid, #8b5cf6); +/* Main Body Layout */ +.arp-body { + display: flex; + flex: 1; + min-height: 0; + width: 100%; + position: relative; + overflow: hidden; } /* Canvas / Grid Area */ @@ -196,47 +157,61 @@ .arp-grid { padding: 16px; display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; } +/* Repo Card */ .arp-card { - background: var(--surface-card, var(--surface-bg)); - border: 1px solid var(--border-default); - border-radius: 8px; + background: var(--surface-bg); + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg, 8px); padding: 16px; display: flex; flex-direction: column; gap: 12px; - transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; - position: relative; - cursor: pointer; + transition: border-color var(--motion-fast, 0.15s ease), box-shadow var(--motion-fast, 0.15s ease); box-sizing: border-box; } .arp-card:hover { - border-color: var(--accent-solid, #8b5cf6); - transform: translateY(-2px); - box-shadow: var(--shadow-md, 0 4px 16px rgba(0, 0, 0, 0.25)); + border-color: var(--border-default); + box-shadow: var(--shadow-sm, 0 1px 3px rgba(0, 0, 0, 0.1)); } -.arp-card.selected { - border-color: var(--accent-solid, #8b5cf6); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-solid, #8b5cf6) 30%, transparent); +.arp-card-main { + display: flex; + align-items: flex-start; + gap: 12px; } -.arp-card-header { +.arp-card-icon-box { + width: 36px; + height: 36px; + border-radius: var(--radius-md, 6px); + background: var(--surface-muted, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--border-soft, rgba(255, 255, 255, 0.08)); display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 8px; + align-items: center; + justify-content: center; + color: var(--accent-solid, #8b5cf6); + flex-shrink: 0; } -.arp-card-title-group { +.arp-card-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.arp-card-header { display: flex; align-items: center; + justify-content: space-between; gap: 8px; - min-width: 0; + flex-wrap: wrap; } .arp-card-title { @@ -248,92 +223,71 @@ white-space: nowrap; } +.arp-card-badges { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + .arp-branch-pill { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; - padding: 2px 7px; - background: color-mix(in srgb, var(--accent-solid, #8b5cf6) 15%, transparent); + padding: 2px 6px; + background: color-mix(in srgb, var(--accent-solid, #8b5cf6) 12%, transparent); color: var(--accent-solid, #8b5cf6); - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--accent-solid, #8b5cf6) 30%, transparent); + border-radius: var(--radius-sm, 4px); + border: 1px solid color-mix(in srgb, var(--accent-solid, #8b5cf6) 25%, transparent); flex-shrink: 0; } .arp-card-path { - font-size: 11px; - color: var(--text-muted); - font-family: monospace; + font-size: 12px; + color: var(--text-secondary); + font-family: var(--font-code, monospace); word-break: break-all; line-height: 1.4; } -.arp-card-commit { +.arp-card-meta-row { display: flex; align-items: center; - gap: 6px; + gap: 12px; font-size: 11px; color: var(--text-muted); } -.arp-card-actions { +.arp-card-commit { display: flex; align-items: center; - justify-content: flex-end; - gap: 6px; - margin-top: auto; - padding-top: 10px; - border-top: 1px solid var(--border-soft); + gap: 5px; } -/* Right Detail Drawer */ -.arp-drawer { - width: 350px; - min-width: 300px; - background: var(--surface-bg); - border-left: 1px solid var(--border-default); - display: flex; - flex-direction: column; - overflow-y: auto; - box-sizing: border-box; - padding: 16px; - gap: 16px; - flex-shrink: 0; +.arp-commit-hash { + font-family: var(--font-code, monospace); } -.arp-drawer-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding-bottom: 8px; - border-bottom: 1px solid var(--border-soft); -} - -.arp-notes-list { - display: flex; - flex-direction: column; - gap: 6px; +.arp-card-ref-count { + margin-left: auto; + color: var(--text-secondary); } -.arp-note-item { +.arp-card-actions { display: flex; align-items: center; - justify-content: space-between; - padding: 6px 10px; - background: var(--surface-muted, rgba(255, 255, 255, 0.04)); - border: 1px solid var(--border-soft); - border-radius: var(--radius-md, 6px); - font-size: 12px; - cursor: pointer; - transition: all 0.15s ease; + justify-content: flex-end; + gap: 6px; + margin-top: auto; + padding-top: 10px; + border-top: 1px solid var(--border-soft); } -.arp-note-item:hover { - border-color: var(--accent-solid, #8b5cf6); - background: var(--surface-hover, rgba(255, 255, 255, 0.08)); - color: var(--accent-solid, #8b5cf6); +.arp-card-actions .icon-button.danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--status-danger, #ef4444) 15%, transparent); + color: var(--status-danger, #ef4444); + border-color: color-mix(in srgb, var(--status-danger, #ef4444) 30%, transparent); } /* Empty State */ @@ -350,8 +304,116 @@ } .arp-empty-icon { - width: 48px; - height: 48px; + width: 44px; + height: 44px; color: var(--accent-solid, #8b5cf6); opacity: 0.6; } + +.arp-empty-title { + font-size: 15px; + font-weight: 600; + color: var(--text-strong); +} + +.arp-empty-desc { + font-size: 13px; + max-width: 420px; + line-height: 1.5; +} + +.arp-empty-btn { + margin-top: 8px; +} + +/* Status Badges */ +.arp-status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + padding: 2px 6px; + border-radius: var(--radius-sm, 4px); + font-weight: 500; +} + +.arp-status-badge--indexed { + background: color-mix(in srgb, var(--status-success, #22c55e) 15%, transparent); + color: var(--status-success, #22c55e); + border: 1px solid color-mix(in srgb, var(--status-success, #22c55e) 30%, transparent); +} + +.arp-status-badge--indexing { + background: color-mix(in srgb, var(--status-info, #3b82f6) 15%, transparent); + color: var(--status-info, #3b82f6); + border: 1px solid color-mix(in srgb, var(--status-info, #3b82f6) 30%, transparent); +} + +.arp-status-badge--error { + background: color-mix(in srgb, var(--status-danger, #ef4444) 15%, transparent); + color: var(--status-danger, #ef4444); + border: 1px solid color-mix(in srgb, var(--status-danger, #ef4444) 30%, transparent); +} + +.arp-status-badge--unindexed { + background: var(--surface-muted, rgba(255, 255, 255, 0.06)); + color: var(--text-muted); + border: 1px solid var(--border-soft); +} + +.arp-btn-icon-mr { + margin-right: 4px; +} + +/* Progress banner */ +.arp-progress-banner { + background: var(--surface-accent, rgba(139, 92, 246, 0.12)); + border-bottom: 1px solid var(--border-soft, rgba(255, 255, 255, 0.08)); + padding: 8px 16px; + font-size: 12px; + display: flex; + align-items: center; + gap: 8px; + color: var(--accent-solid, #8b5cf6); +} + +/* Form in Attach Repo Dialog */ +.arp-dialog-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.arp-form-label { + display: block; + font-size: 12px; + font-weight: 600; + margin-bottom: 6px; + color: var(--text-strong); +} + +.arp-browse-row { + display: flex; + gap: 8px; +} + +.arp-browse-row .app-input-wrapper, +.arp-browse-row .app-input { + flex: 1; +} + +.arp-form-hint { + background: var(--surface-muted, rgba(255, 255, 255, 0.04)); + padding: 10px; + border-radius: var(--radius-sm, 4px); + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; +} + +.arp-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; +} diff --git a/tests/CodeEvidenceBuilder.test.js b/tests/CodeEvidenceBuilder.test.js new file mode 100644 index 0000000..ddfebbd --- /dev/null +++ b/tests/CodeEvidenceBuilder.test.js @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { CodeEvidenceBuilder } from '../ai/graph/sources/code/CodeEvidenceBuilder'; + +describe('CodeEvidenceBuilder', () => { + it('builds evidence records for code relationships', () => { + const builder = new CodeEvidenceBuilder(); + const pairs = builder.buildForRelationships({ + repoId: 'repo-abc', + repoName: 'backend', + filePath: '/workspace/backend/src/auth.js', + relPath: 'src/auth.js', + relationships: [ + { + source_name: 'backend/src/auth.js', + source_type: 'CodeModule', + target_name: 'AuthService', + target_type: 'CodeClass', + type: 'CONTAINS', + confidence: 0.95 + }, + { + source_name: 'backend/src/auth.js', + source_type: 'CodeModule', + target_name: './utils', + target_type: 'CodeModule', + type: 'IMPORTS', + confidence: 0.9 + } + ] + }); + + expect(pairs.length).toBe(2); + expect(pairs[0].relationship.type).toBe('CONTAINS'); + expect(pairs[0].evidence.extractor).toBe('code'); + expect(pairs[0].evidence.subjectText).toBe('backend/src/auth.js'); + expect(pairs[0].evidence.predicateText).toBe('CONTAINS'); + expect(pairs[0].evidence.objectText).toBe('AuthService'); + expect(pairs[0].evidence.confidence).toBe(0.95); + + expect(pairs[1].relationship.type).toBe('IMPORTS'); + expect(pairs[1].evidence.extractor).toBe('code'); + expect(pairs[1].evidence.subjectText).toBe('backend/src/auth.js'); + expect(pairs[1].evidence.predicateText).toBe('IMPORTS'); + expect(pairs[1].evidence.objectText).toBe('./utils'); + }); +}); diff --git a/tests/CodeKnowledgeSource.test.js b/tests/CodeKnowledgeSource.test.js index 7a9e951..4342e7e 100644 --- a/tests/CodeKnowledgeSource.test.js +++ b/tests/CodeKnowledgeSource.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import CodeKnowledgeSource from '../ai/graph/sources/CodeKnowledgeSource'; +import CodeKnowledgeSource from '../ai/graph/sources/code/CodeKnowledgeSource'; describe('CodeKnowledgeSource', () => { it('should return correct sourceType and baseConfidence', () => { @@ -85,4 +85,76 @@ def predict_pipeline(tensor_input): expect(classifier.type).toBe('CodeClass'); expect(classifier.properties.baseClass).toBe('BaseModel'); }); + + it('should extract Go structs and functions via AST', async () => { + const source = new CodeKnowledgeSource([ + { id: 'repo-go', name: 'go-service', path: '/workspace/go-service' } + ]); + + const goSnippet = ` +package main + +type Config struct { + Port int +} + +func StartServer(cfg Config) { +} + `; + + const entities = await source.extractEntities('/workspace/go-service/main.go', goSnippet); + const names = entities.map((e) => e.name); + + expect(names).toContain('go-service/main.go'); + expect(names).toContain('Config'); + expect(names).toContain('StartServer()'); + }); + + it('should extract Rust structs and functions via AST', async () => { + const source = new CodeKnowledgeSource([ + { id: 'repo-rs', name: 'rust-engine', path: '/workspace/rust-engine' } + ]); + + const rsSnippet = ` +pub struct TaskManager { + pub id: u64, +} + +pub fn execute_job(task: TaskManager) { +} + `; + + const entities = await source.extractEntities('/workspace/rust-engine/lib.rs', rsSnippet); + const names = entities.map((e) => e.name); + + expect(names).toContain('rust-engine/lib.rs'); + expect(names).toContain('TaskManager'); + expect(names).toContain('execute_job()'); + }); + + it('should extract evidence records for code relationships', async () => { + const source = new CodeKnowledgeSource([ + { id: 'repo-js', name: 'web-app', path: '/workspace/web-app' } + ]); + + const jsSnippet = ` + import { Helper } from './helper'; + export class AppController {} + `; + + const evidence = await source.extractEvidence('/workspace/web-app/src/app.js', jsSnippet); + expect(evidence.length).toBeGreaterThan(0); + expect(evidence[0].extractor).toBe('code'); + expect(evidence[0].subjectText).toContain('web-app'); + }); + + it('should return empty evidence array for files not matching any repo', async () => { + const source = new CodeKnowledgeSource([ + { id: 'repo-js', name: 'web-app', path: '/workspace/web-app' } + ]); + + const evidence = await source.extractEvidence('/other-dir/unrelated.js', 'class X {}'); + expect(evidence).toEqual([]); + }); }); + diff --git a/tests/RepositoryScanner.test.js b/tests/RepositoryScanner.test.js new file mode 100644 index 0000000..3860e1a --- /dev/null +++ b/tests/RepositoryScanner.test.js @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { RepositoryScanner } from '../ai/graph/sources/code/RepositoryScanner'; + +describe('RepositoryScanner', () => { + const tempDir = path.join(__dirname, 'temp-repo-scanner-test'); + + beforeEach(() => { + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); + fs.mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('supports standard programming language file extensions', () => { + const scanner = new RepositoryScanner(); + expect(scanner.supports('app.js')).toBe(true); + expect(scanner.supports('index.ts')).toBe(true); + expect(scanner.supports('Component.jsx')).toBe(true); + expect(scanner.supports('main.py')).toBe(true); + expect(scanner.supports('server.go')).toBe(true); + expect(scanner.supports('lib.rs')).toBe(true); + expect(scanner.supports('readme.md')).toBe(false); + expect(scanner.supports('photo.png')).toBe(false); + }); + + it('scans code files and ignores node_modules and dot folders', () => { + const srcDir = path.join(tempDir, 'src'); + const nodeModulesDir = path.join(tempDir, 'node_modules', 'foo'); + const dotGitDir = path.join(tempDir, '.git'); + + fs.mkdirSync(srcDir, { recursive: true }); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.mkdirSync(dotGitDir, { recursive: true }); + + fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;'); + fs.writeFileSync(path.join(srcDir, 'utils.js'), 'module.exports = {};'); + fs.writeFileSync(path.join(nodeModulesDir, 'ignored.js'), 'console.log();'); + fs.writeFileSync(path.join(dotGitDir, 'config.js'), 'ignored'); + + const scanner = new RepositoryScanner(); + const files = scanner.scan(tempDir); + + expect(files.length).toBe(2); + expect(files.some(f => f.endsWith('index.ts'))).toBe(true); + expect(files.some(f => f.endsWith('utils.js'))).toBe(true); + expect(files.some(f => f.includes('node_modules'))).toBe(false); + expect(files.some(f => f.includes('.git'))).toBe(false); + }); + + it('ignores docs-site-dist, assets, chunks, and test files', () => { + const srcDir = path.join(tempDir, 'src'); + const docsDistDir = path.join(tempDir, 'docs-site-dist', 'assets', 'chunks'); + const testsDir = path.join(tempDir, 'tests', 'unit'); + + fs.mkdirSync(srcDir, { recursive: true }); + fs.mkdirSync(docsDistDir, { recursive: true }); + fs.mkdirSync(testsDir, { recursive: true }); + + fs.writeFileSync(path.join(srcDir, 'CoreService.ts'), 'export class CoreService {}'); + fs.writeFileSync(path.join(srcDir, 'CoreService.test.ts'), 'test("core", () => {});'); + fs.writeFileSync(path.join(srcDir, 'CoreService.spec.js'), 'test("core", () => {});'); + fs.writeFileSync(path.join(docsDistDir, 'katex.C5jXJg4s.js'), 'var katex = {};'); + fs.writeFileSync(path.join(testsDir, 'test_runner.js'), 'run();'); + + const scanner = new RepositoryScanner(); + const files = scanner.scan(tempDir); + + expect(files.length).toBe(1); + expect(files[0].endsWith('CoreService.ts')).toBe(true); + expect(files.some(f => f.includes('docs-site-dist'))).toBe(false); + expect(files.some(f => f.includes('katex'))).toBe(false); + expect(files.some(f => f.includes('.test.'))).toBe(false); + expect(files.some(f => f.includes('.spec.'))).toBe(false); + expect(files.some(f => f.endsWith('test_runner.js'))).toBe(false); + }); +}); diff --git a/tests/manage_repositories.test.js b/tests/manage_repositories.test.js new file mode 100644 index 0000000..9c8a076 --- /dev/null +++ b/tests/manage_repositories.test.js @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { applicationToolRegistry } from '../electron/tools/ApplicationToolRegistry.cjs'; + +describe('MCP Repository Tools & Extensions', () => { + const tempWorkspace = path.join(__dirname, 'temp-mcp-repo-test'); + const tempRepo = path.join(__dirname, 'temp-mcp-attached-repo'); + + beforeEach(() => { + if (fs.existsSync(tempWorkspace)) fs.rmSync(tempWorkspace, { recursive: true, force: true }); + if (fs.existsSync(tempRepo)) fs.rmSync(tempRepo, { recursive: true, force: true }); + + fs.mkdirSync(path.join(tempWorkspace, '.notes-app'), { recursive: true }); + fs.mkdirSync(tempRepo, { recursive: true }); + }); + + afterEach(() => { + if (fs.existsSync(tempWorkspace)) fs.rmSync(tempWorkspace, { recursive: true, force: true }); + if (fs.existsSync(tempRepo)) fs.rmSync(tempRepo, { recursive: true, force: true }); + }); + + it('manage_repositories: list returns empty array initially', async () => { + const res = await applicationToolRegistry.executeTool('manage_repositories', { action: 'list' }, { workspaceRoot: tempWorkspace }); + expect(res.success).toBe(true); + expect(res.data.count).toBe(0); + expect(res.data.repositories).toEqual([]); + }); + + it('manage_repositories: add and remove attached repository', async () => { + // Add repo + const addRes = await applicationToolRegistry.executeTool('manage_repositories', { + action: 'add', + path: tempRepo, + name: 'test-backend', + branch: 'main' + }, { workspaceRoot: tempWorkspace }); + + expect(addRes.success).toBe(true); + expect(addRes.data.repo.name).toBe('test-backend'); + expect(addRes.data.repositories.length).toBe(1); + + const repoId = addRes.data.repo.id; + + // Status check + const statusRes = await applicationToolRegistry.executeTool('manage_repositories', { + action: 'status', + repoId + }, { workspaceRoot: tempWorkspace }); + expect(statusRes.success).toBe(true); + expect(statusRes.data.repo.id).toBe(repoId); + + // Remove repo + const removeRes = await applicationToolRegistry.executeTool('manage_repositories', { + action: 'remove', + repoId + }, { workspaceRoot: tempWorkspace }); + + expect(removeRes.success).toBe(true); + expect(removeRes.data.repositories.length).toBe(0); + }); + + it('workspace_overview: repos operation returns attached repositories', async () => { + // Write metadata with repo + const metaPath = path.join(tempWorkspace, '.notes-app', 'metadata.json'); + fs.writeFileSync(metaPath, JSON.stringify({ + attachedRepos: [ + { id: 'repo-1', name: 'frontend', path: tempRepo, branch: 'main', status: 'indexed', symbolCount: 42 } + ] + }, null, 2)); + + const res = await applicationToolRegistry.executeTool('workspace_overview', { + operation: 'repos' + }, { workspaceRoot: tempWorkspace }); + + expect(res.success).toBe(true); + expect(res.data.operation).toBe('repos'); + expect(res.data.count).toBe(1); + expect(res.data.repositories[0].name).toBe('frontend'); + expect(res.data.repositories[0].symbolCount).toBe(42); + }); +}); From 045df2d8341d89659e8e40a6fe158218144acc6c Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Thu, 1 Oct 2026 23:30:19 +0530 Subject: [PATCH 02/24] feat(graph): add canvas renderer, layout pre-computation & import resolution --- ai/graph/GraphBuilder.js | 9 + ai/graph/GraphDB.js | 72 +- ai/graph/GraphSchema.js | 8 +- ai/graph/layout/GraphLayoutEngine.js | 113 +++ ai/graph/sources/code/CodeEvidenceBuilder.js | 2 - ai/graph/sources/code/CodeKnowledgeSource.js | 11 +- ai/graph/sources/code/TreeSitterCodeParser.js | 58 +- ai/queue/GraphWorker.js | 3 + src/components/KnowledgeGraph.jsx | 822 ++---------------- src/components/graph/EntityInspector.jsx | 122 +++ src/components/graph/GraphCanvasView.jsx | 585 +++++++++++++ src/components/graph/GraphSidebar.jsx | 225 +++++ src/components/graph/graphUtils.js | 130 +++ tests/CodeKnowledgeSource.test.js | 2 +- tests/graph_layout_performance.test.js | 85 ++ 15 files changed, 1467 insertions(+), 780 deletions(-) create mode 100644 ai/graph/layout/GraphLayoutEngine.js create mode 100644 src/components/graph/EntityInspector.jsx create mode 100644 src/components/graph/GraphCanvasView.jsx create mode 100644 src/components/graph/GraphSidebar.jsx create mode 100644 src/components/graph/graphUtils.js create mode 100644 tests/graph_layout_performance.test.js diff --git a/ai/graph/GraphBuilder.js b/ai/graph/GraphBuilder.js index f8171b2..5f0997a 100644 --- a/ai/graph/GraphBuilder.js +++ b/ai/graph/GraphBuilder.js @@ -218,6 +218,15 @@ class GraphBuilder { const validator = new GraphValidationEngine(this.graphDb, this._logDb); await validator.validate(); + // Pre-compute & cache 2D force layout coordinates in SQLite + if (this.graphDb && typeof this.graphDb.recomputeLayout === 'function') { + try { + this.graphDb.recomputeLayout(); + } catch (lErr) { + log.warn('GraphLayoutEngine pass skipped:', lErr.message); + } + } + // Optimize SQLite query planner if (this.graphDb?.db) { try { this.graphDb.db.exec('PRAGMA ANALYZE;'); } catch { /* ignore */ } diff --git a/ai/graph/GraphDB.js b/ai/graph/GraphDB.js index 93731d1..d9c4a24 100644 --- a/ai/graph/GraphDB.js +++ b/ai/graph/GraphDB.js @@ -85,7 +85,7 @@ class GraphDB { } // Versioned database schema migrations (Gap 3) - const TARGET_SCHEMA_VERSION = 4; + const TARGET_SCHEMA_VERSION = 5; let currentVersion = 0; try { const vRow = this.db.prepare('PRAGMA user_version').get(); @@ -158,6 +158,12 @@ class GraphDB { this.db.exec(CREATE_ENTITY_EMBEDDINGS_TABLE); } catch { /* ignore */ } } + if (fromVersion < 5) { + // Version 5: Add layout coordinates and node degree cache + try { this.db.exec('ALTER TABLE entities ADD COLUMN pos_x REAL;'); } catch { /* ignore */ } + try { this.db.exec('ALTER TABLE entities ADD COLUMN pos_y REAL;'); } catch { /* ignore */ } + try { this.db.exec('ALTER TABLE entities ADD COLUMN degree INTEGER DEFAULT 0;'); } catch { /* ignore */ } + } } upsertEntityVector(entityId, vectorArray) { @@ -448,7 +454,14 @@ class GraphDB { .map(e => { const props = typeof e.properties === 'string' ? JSON.parse(e.properties || '{}') : (e.properties || {}); const conf = typeof e.confidence === 'number' ? e.confidence : (typeof props.confidence === 'number' ? props.confidence : 1.0); - return { ...e, confidence: conf, properties: props }; + return { + ...e, + confidence: conf, + properties: props, + pos_x: typeof e.pos_x === 'number' && !isNaN(e.pos_x) ? e.pos_x : null, + pos_y: typeof e.pos_y === 'number' && !isNaN(e.pos_y) ? e.pos_y : null, + degree: typeof e.degree === 'number' ? e.degree : 0 + }; }) .filter(e => e.confidence >= minConfidence); @@ -472,6 +485,61 @@ class GraphDB { return { entities, relationships }; } + /** + * Batch update entity coordinates and connectivity degrees + * @param {Map|Array<{id: string, pos_x: number, pos_y: number, degree: number}>} positions + */ + updateNodePositions(positions) { + if (!this.db || !positions) return 0; + try { + const updateStmt = this.db.prepare('UPDATE entities SET pos_x = ?, pos_y = ?, degree = ? WHERE id = ?'); + let count = 0; + + if (positions instanceof Map) { + for (const [id, pos] of positions.entries()) { + try { + updateStmt.run(pos.x, pos.y, pos.degree || 0, id); + count++; + } catch { /* continue on single node error */ } + } + } else if (Array.isArray(positions)) { + for (const pos of positions) { + try { + updateStmt.run(pos.pos_x ?? pos.x, pos.pos_y ?? pos.y, pos.degree || 0, pos.id); + count++; + } catch { /* continue */ } + } + } + + return count; + } catch (err) { + log.warn('Failed to update node positions in SQLite:', err.message); + return 0; + } + } + + /** + * Recompute full graph layout and cache coordinates to SQLite + * @param {Object} options + */ + recomputeLayout(options = {}) { + if (!this.db) return null; + try { + const GraphLayoutEngine = require('./layout/GraphLayoutEngine'); + const { entities, relationships } = this.getAll(0.0); + if (!entities.length) return { nodes: [], positions: new Map() }; + + const layoutResult = GraphLayoutEngine.computeLayout(entities, relationships, options); + if (layoutResult?.positions) { + this.updateNodePositions(layoutResult.positions); + } + return layoutResult; + } catch (err) { + log.warn('Failed to recompute and cache graph layout:', err.message); + return null; + } + } + /** * Find entity by note path */ diff --git a/ai/graph/GraphSchema.js b/ai/graph/GraphSchema.js index 9a0ce2a..1386d76 100644 --- a/ai/graph/GraphSchema.js +++ b/ai/graph/GraphSchema.js @@ -14,6 +14,9 @@ CREATE TABLE IF NOT EXISTS entities ( extractor TEXT DEFAULT 'gliner', model_version TEXT DEFAULT '2.1', confidence REAL DEFAULT 1.0, + pos_x REAL, + pos_y REAL, + degree INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); @@ -101,7 +104,10 @@ const ALTER_ENTITIES_ADD_COLUMNS = [ `ALTER TABLE entities ADD COLUMN source_count INTEGER DEFAULT 1;`, `ALTER TABLE entities ADD COLUMN first_seen_at TEXT DEFAULT (datetime('now'));`, `ALTER TABLE entities ADD COLUMN is_retired INTEGER DEFAULT 0;`, - `ALTER TABLE entities ADD COLUMN merged_into TEXT;` + `ALTER TABLE entities ADD COLUMN merged_into TEXT;`, + `ALTER TABLE entities ADD COLUMN pos_x REAL;`, + `ALTER TABLE entities ADD COLUMN pos_y REAL;`, + `ALTER TABLE entities ADD COLUMN degree INTEGER DEFAULT 0;` ]; const CREATE_RELATIONSHIP_EVIDENCE_TABLE = ` diff --git a/ai/graph/layout/GraphLayoutEngine.js b/ai/graph/layout/GraphLayoutEngine.js new file mode 100644 index 0000000..8a3f1f5 --- /dev/null +++ b/ai/graph/layout/GraphLayoutEngine.js @@ -0,0 +1,113 @@ +/** + * GraphLayoutEngine + * Pure mathematical / physical force simulation layout calculator. + * Independent of SQLite / IPC layers for clean modularity and testing. + */ + +const d3Force = require('d3-force'); + +class GraphLayoutEngine { + /** + * Compute 2D coordinates and connectivity degree for graph nodes + * @param {Array} entities - List of entity objects with id + * @param {Array} relationships - List of relationship objects with source_id and target_id + * @param {Object} options - Force layout configuration + * @returns {{ nodes: Array, positions: Map }} + */ + static computeLayout(entities = [], relationships = [], options = {}) { + if (!Array.isArray(entities) || entities.length === 0) { + return { nodes: [], positions: new Map() }; + } + + const { + width = 1000, + height = 800, + ticks = Math.min(100, Math.max(30, Math.round(entities.length * 0.8))), + baseDistance = 140, + baseCharge = -280, + baseCollision = 60 + } = options; + + // 1. Calculate connectivity degree per entity + const degrees = new Map(); + for (const ent of entities) { + degrees.set(ent.id, 0); + } + for (const rel of relationships) { + if (degrees.has(rel.source_id)) { + degrees.set(rel.source_id, degrees.get(rel.source_id) + 1); + } + if (degrees.has(rel.target_id)) { + degrees.set(rel.target_id, degrees.get(rel.target_id) + 1); + } + } + + // 2. Prepare nodes for force simulation + const entityIds = new Set(entities.map(e => e.id)); + const forceNodes = entities.map((entity, idx) => { + // Preserve existing valid positions if available, otherwise distribute around center + const hasValidX = typeof entity.pos_x === 'number' && !isNaN(entity.pos_x); + const hasValidY = typeof entity.pos_y === 'number' && !isNaN(entity.pos_y); + + return { + id: entity.id, + entity, + degree: degrees.get(entity.id) || 0, + x: hasValidX ? entity.pos_x : (width / 2) + (Math.random() - 0.5) * (width * 0.7), + y: hasValidY ? entity.pos_y : (height / 2) + (Math.random() - 0.5) * (height * 0.7), + index: idx + }; + }); + + // 3. Prepare filtered links + const forceLinks = relationships + .filter(rel => entityIds.has(rel.source_id) && entityIds.has(rel.target_id)) + .map(rel => ({ + source: rel.source_id, + target: rel.target_id, + weight: rel.weight || 1.0 + })); + + // 4. Configure dynamic forces based on graph scale + const nodeCount = forceNodes.length; + const dynamicDistance = Math.max(80, Math.min(300, baseDistance + (nodeCount > 100 ? 50 : 0))); + const dynamicCharge = Math.min(-150, baseCharge - (nodeCount > 100 ? 150 : 0)); + const dynamicCollision = Math.max(40, baseCollision + (nodeCount > 100 ? 20 : 0)); + + const simulation = d3Force.forceSimulation(forceNodes) + .force('link', d3Force.forceLink(forceLinks).id(d => d.id).distance(dynamicDistance)) + .force('charge', d3Force.forceManyBody().strength(dynamicCharge)) + .force('center', d3Force.forceCenter(width / 2, height / 2)) + .force('collision', d3Force.forceCollide().radius(dynamicCollision)) + .stop(); + + // 5. Run simulation synchronously in worker + for (let i = 0; i < ticks; i++) { + simulation.tick(); + } + + // 6. Build positions map and normalized node results + const positions = new Map(); + const resultNodes = forceNodes.map(node => { + const x = isNaN(node.x) || typeof node.x !== 'number' ? width / 2 : Math.round(node.x * 10) / 10; + const y = isNaN(node.y) || typeof node.y !== 'number' ? height / 2 : Math.round(node.y * 10) / 10; + const deg = node.degree; + + positions.set(node.id, { x, y, degree: deg }); + return { + id: node.id, + pos_x: x, + pos_y: y, + degree: deg, + entity: node.entity + }; + }); + + return { + nodes: resultNodes, + positions + }; + } +} + +module.exports = GraphLayoutEngine; diff --git a/ai/graph/sources/code/CodeEvidenceBuilder.js b/ai/graph/sources/code/CodeEvidenceBuilder.js index 7291621..20337a1 100644 --- a/ai/graph/sources/code/CodeEvidenceBuilder.js +++ b/ai/graph/sources/code/CodeEvidenceBuilder.js @@ -4,8 +4,6 @@ * Every relationship from a repository must have traceable provenance. */ -const path = require('path'); - class CodeEvidenceBuilder { /** * Build a list of evidence objects for a set of code relationships. diff --git a/ai/graph/sources/code/CodeKnowledgeSource.js b/ai/graph/sources/code/CodeKnowledgeSource.js index 3b342cd..66c956b 100644 --- a/ai/graph/sources/code/CodeKnowledgeSource.js +++ b/ai/graph/sources/code/CodeKnowledgeSource.js @@ -157,6 +157,7 @@ class CodeKnowledgeSource extends KnowledgeSource { const relPath = repo?.path ? path.relative(repo.path, filePath).replace(/\\/g, '/') : path.basename(filePath); + const ext = path.extname(filePath).toLowerCase(); const moduleName = `${repoPrefix}/${relPath}`; const relationships = [ @@ -183,11 +184,17 @@ class CodeKnowledgeSource extends KnowledgeSource { let match; while ((match = importRegex.exec(fileContent)) !== null) { const target = match[1] || match[2]; - if (target && (target.startsWith('.') || target.startsWith('/'))) { + if (target && target.startsWith('.')) { + const dir = path.dirname(relPath).replace(/\\/g, '/'); + let resolvedRel = path.posix.normalize(path.posix.join(dir === '.' ? '' : dir, target)); + if (!path.extname(resolvedRel)) { + resolvedRel = `${resolvedRel}${ext}`; + } + const targetName = `${repoPrefix}/${resolvedRel}`; relationships.push({ source_name: moduleName, source_type: 'CodeModule', - target_name: target, + target_name: targetName, target_type: 'CodeModule', type: 'IMPORTS', weight: 0.8, diff --git a/ai/graph/sources/code/TreeSitterCodeParser.js b/ai/graph/sources/code/TreeSitterCodeParser.js index ac18512..047cc3d 100644 --- a/ai/graph/sources/code/TreeSitterCodeParser.js +++ b/ai/graph/sources/code/TreeSitterCodeParser.js @@ -354,16 +354,25 @@ class TreeSitterCodeParser { if (type === 'import_statement') { const sourceNode = node.childForFieldName('source'); if (sourceNode) { - const importTarget = sourceNode.text.replace(/['"]/g, ''); - ctx.relationships.push({ - source_name: ctx.moduleName, - source_type: 'CodeModule', - target_name: importTarget, - target_type: 'CodeModule', - type: 'IMPORTS', - weight: 0.8, - confidence: 0.95 - }); + const importRaw = sourceNode.text.replace(/['"]/g, '').trim(); + if (importRaw.startsWith('.')) { + const dir = path.dirname(ctx.relPath || '').replace(/\\/g, '/'); + let resolvedRel = path.posix.normalize(path.posix.join(dir === '.' ? '' : dir, importRaw)); + if (!path.extname(resolvedRel)) { + resolvedRel = `${resolvedRel}${ctx.ext}`; + } + const targetName = `${ctx.repoPrefix}/${resolvedRel}`; + + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: targetName, + target_type: 'CodeModule', + type: 'IMPORTS', + weight: 0.8, + confidence: 0.95 + }); + } } } } @@ -465,16 +474,25 @@ class TreeSitterCodeParser { if (type === 'import_from_statement' || type === 'import_statement') { const modNode = node.childForFieldName('module_name') || node.childForFieldName('name'); if (modNode) { - const importTarget = modNode.text; - ctx.relationships.push({ - source_name: ctx.moduleName, - source_type: 'CodeModule', - target_name: importTarget, - target_type: 'CodeModule', - type: 'IMPORTS', - weight: 0.8, - confidence: 0.95 - }); + const importRaw = modNode.text.trim(); + if (importRaw.startsWith('.')) { + const dir = path.dirname(ctx.relPath || '').replace(/\\/g, '/'); + let resolvedRel = path.posix.normalize(path.posix.join(dir === '.' ? '' : dir, importRaw.replace(/\./g, '/'))); + if (!path.extname(resolvedRel)) { + resolvedRel = `${resolvedRel}.py`; + } + const targetName = `${ctx.repoPrefix}/${resolvedRel}`; + + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: targetName, + target_type: 'CodeModule', + type: 'IMPORTS', + weight: 0.8, + confidence: 0.95 + }); + } } } } diff --git a/ai/queue/GraphWorker.js b/ai/queue/GraphWorker.js index f9731eb..4247ae8 100644 --- a/ai/queue/GraphWorker.js +++ b/ai/queue/GraphWorker.js @@ -64,6 +64,9 @@ class GraphWorker { // Run background maintenance when queue becomes empty try { await this.maintenance.runMaintenance(); + if (this.graphDb && typeof this.graphDb.recomputeLayout === 'function') { + this.graphDb.recomputeLayout(); + } } catch (mErr) { log.warn('Background GraphMaintenance pass skipped:', mErr.message); } diff --git a/src/components/KnowledgeGraph.jsx b/src/components/KnowledgeGraph.jsx index 54dc383..e75175a 100644 --- a/src/components/KnowledgeGraph.jsx +++ b/src/components/KnowledgeGraph.jsx @@ -1,27 +1,12 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; -import { - ReactFlow, - Controls, - Background, - useNodesState, - useEdgesState, - Handle, - Position, - MarkerType -} from '@xyflow/react'; -import '@xyflow/react/dist/style.css'; import { Search, - Layers, ShieldAlert, Database, Pause, Play, - CheckSquare, - Square, Trash2, RotateCw, - ExternalLink, FileText, Braces, PanelLeftClose, @@ -45,226 +30,17 @@ import { import { OverlayDialog } from './OverlayDialog'; import { useConfirm } from '../hooks/useConfirm'; -import * as d3Force from 'd3-force'; -import '../styles/KnowledgeGraph.css'; - -// Custom Node component with directional connection handles (memoized for high FPS) -const CustomNode = React.memo(({ data, selected }) => { - const isHub = (data.degree || 0) >= 5; - const typeColor = data.typeColor || { border: 'var(--accent-solid, #2f5d62)', background: 'rgba(47, 93, 98, 0.12)', text: 'var(--accent-solid, #2f5d62)' }; - const name = data.raw?.name || data.raw?.canonical_name || 'Node'; - - return ( -
- - - -
- - - {data.raw?.type || 'Entity'} - -
- - 70 ? '10px' : '8px', - color: 'var(--text-strong)', - textAlign: 'center', - lineHeight: 1.15, - overflow: 'hidden', - textOverflow: 'ellipsis', - display: '-webkit-box', - WebkitLineClamp: 2, - WebkitBoxOrient: 'vertical', - wordBreak: 'break-word', - maxWidth: '94%', - pointerEvents: 'none' - }} - > - {name} - - - {(data.degree || 0) > 0 && ( - - {data.degree} - - )} - - - -
- ); -}); - -const nodeTypes = { - customNode: CustomNode, - default: CustomNode -}; - -const TYPE_COLORS = { - Note: { background: 'var(--kg-note-bg)', border: 'var(--kg-note-border)', text: 'var(--kg-note-border)' }, - Person: { background: 'var(--kg-person-bg)', border: 'var(--kg-person-border)', text: 'var(--kg-person-border)' }, - Project: { background: 'var(--kg-project-bg)', border: 'var(--kg-project-border)', text: 'var(--kg-project-border)' }, - Technology: { background: 'var(--kg-tech-bg)', border: 'var(--kg-tech-border)', text: 'var(--kg-tech-border)' }, - Company: { background: 'var(--kg-company-bg)', border: 'var(--kg-company-border)', text: 'var(--kg-company-border)' }, - Organization: { background: 'var(--kg-company-bg)', border: 'var(--kg-company-border)', text: 'var(--kg-company-border)' }, - Concept: { background: 'var(--kg-concept-bg)', border: 'var(--kg-concept-border)', text: 'var(--kg-concept-border)' }, - Task: { background: 'var(--kg-task-bg)', border: 'var(--kg-task-border)', text: 'var(--kg-task-border)' }, - Image: { background: 'var(--kg-image-bg)', border: 'var(--kg-image-border)', text: 'var(--kg-image-border)' }, - Document: { background: 'var(--kg-doc-bg)', border: 'var(--kg-doc-border)', text: 'var(--kg-doc-border)' }, - ExternalURL: { background: 'var(--kg-url-bg)', border: 'var(--kg-url-border)', text: 'var(--kg-url-border)' }, - CodeBlock: { background: 'var(--kg-tech-bg)', border: 'var(--kg-tech-border)', text: 'var(--kg-tech-border)' }, - Diagram: { background: 'var(--kg-image-bg)', border: 'var(--kg-image-border)', text: 'var(--kg-image-border)' }, - Section: { background: 'var(--kg-concept-bg)', border: 'var(--kg-concept-border)', text: 'var(--kg-concept-border)' }, - Tag: { background: 'var(--kg-project-bg)', border: 'var(--kg-project-border)', text: 'var(--kg-project-border)' }, - KeyTerm: { background: 'var(--kg-person-bg)', border: 'var(--kg-person-border)', text: 'var(--kg-person-border)' }, - Formula: { background: 'var(--kg-company-bg)', border: 'var(--kg-company-border)', text: 'var(--kg-company-border)' }, - Callout: { background: 'var(--kg-task-bg)', border: 'var(--kg-task-border)', text: 'var(--kg-task-border)' }, - - // Code & AST Graph Entities - Repo: { background: 'rgba(139, 92, 246, 0.16)', border: '#8b5cf6', text: '#8b5cf6' }, - CodeModule: { background: 'rgba(99, 102, 241, 0.16)', border: '#6366f1', text: '#6366f1' }, - CodeClass: { background: 'rgba(168, 85, 247, 0.16)', border: '#a855f7', text: '#a855f7' }, - CodeInterface: { background: 'rgba(192, 132, 252, 0.16)', border: '#c084fc', text: '#c084fc' }, - CodeFunction: { background: 'rgba(56, 189, 248, 0.16)', border: '#38bdf8', text: '#38bdf8' }, - APIEndpoint: { background: 'rgba(245, 158, 11, 0.16)', border: '#f59e0b', text: '#f59e0b' }, - DBModel: { background: 'rgba(16, 185, 129, 0.16)', border: '#10b981', text: '#10b981' } -}; +import GraphSidebar from './graph/GraphSidebar'; +import GraphCanvasView from './graph/GraphCanvasView'; +import { normalizeType } from './graph/graphUtils'; -const DEFAULT_COLOR = { background: 'var(--kg-default-bg)', border: 'var(--kg-default-border)', text: 'var(--text-strong)' }; - -const RELATIONSHIP_COLORS = { - // Semantic / LLM Relationships - DEPENDS_ON: '#f59e0b', // Amber - USES: '#06b6d4', // Cyan - REFERENCES: '#6366f1', // Indigo - CONTAINS: '#10b981', // Emerald - HAS: '#10b981', // Emerald - MENTIONS: '#8b5cf6', // Purple - CREATED_BY: '#f43f5e', // Rose - OWNED_BY: '#f43f5e', // Rose - DOCUMENTS: '#a855f7', // Purple (Doc -> Code) - IMPORTS: '#6366f1', // Indigo (Code -> Code) - CALLS: '#f59e0b', // Amber (Func -> API) - EXPORTS: '#38bdf8', // Sky (Module -> Symbol) - - // Structural Note Graph Relationships - LINKS_TO: '#6366f1', // Indigo - TAGGED: '#ec4899', // Pink - CONTAINS_MEDIA: '#10b981', // Emerald - REFERENCES_URL: '#3b82f6', // Blue - ATTACHES_FILE: '#eab308', // Yellow - CONTAINS_CODE: '#06b6d4', // Cyan - CONTAINS_SECTION: '#14b8a6', // Teal - EMPHASIZES: '#f97316', // Orange - REFERENCES_CODE: '#0284c7', // Sky Blue - HAS_CALLOUT: '#a855f7', // Violet - CONTAINS_FORMULA: '#d946ef', // Fuchsia - HAS_OPEN_TASK: '#ef4444', // Red - HAS_COMPLETED_TASK: '#22c55e',// Green - MENTIONS_NOTE: '#8b5cf6', // Purple - RELATED_TO: '#8b5cf6', // Purple - DEFAULT: '#06b6d4' // Vibrant Cyan fallback -}; - -const CANONICAL_TYPE_MAP = { - note: 'Note', - person: 'Person', - project: 'Project', - technology: 'Technology', - company: 'Company', - organization: 'Organization', - concept: 'Concept', - task: 'Task', - decision: 'Decision', - idea: 'Idea', - tag: 'Tag', - image: 'Image', - document: 'Document', - folder: 'Folder', - workspace: 'Workspace', - section: 'Section', - event: 'Event', - location: 'Location', - externalurl: 'ExternalURL', - external_url: 'ExternalURL', - codeblock: 'CodeBlock', - diagram: 'Diagram', - keyterm: 'KeyTerm', - formula: 'Formula', - callout: 'Callout', - repo: 'Repo', - codemodule: 'CodeModule', - codeclass: 'CodeClass', - codeinterface: 'CodeInterface', - codefunction: 'CodeFunction', - apiendpoint: 'APIEndpoint', - dbmodel: 'DBModel' -}; - -const normalizeType = (rawType) => { - if (!rawType) return 'Concept'; - const clean = String(rawType).trim(); - const key = clean.toLowerCase().replace(/[^a-z0-9]/g, ''); - if (CANONICAL_TYPE_MAP[key]) return CANONICAL_TYPE_MAP[key]; - return clean.charAt(0).toUpperCase() + clean.slice(1); -}; +import '../styles/KnowledgeGraph.css'; export default function KnowledgeGraph({ onBack }) { const { confirm } = useConfirm(); - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [rawEntities, setRawEntities] = useState([]); + const [rawRelationships, setRawRelationships] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); const [selectedTypes, setSelectedTypes] = useState({ Note: true, @@ -288,7 +64,17 @@ export default function KnowledgeGraph({ onBack }) { }); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); - const [graphStatus, setGraphStatus] = useState({ nodeCount: 0, edgeCount: 0, sizeBytes: 0, isBuilding: false, isPaused: false, current: 0, total: 0, progress: 0, noteName: '' }); + const [graphStatus, setGraphStatus] = useState({ + nodeCount: 0, + edgeCount: 0, + sizeBytes: 0, + isBuilding: false, + isPaused: false, + current: 0, + total: 0, + progress: 0, + noteName: '' + }); const [selectedNode, setSelectedNode] = useState(null); const [graphLogs, setGraphLogs] = useState([]); const [preferences, setPreferences] = useState({ @@ -304,11 +90,6 @@ export default function KnowledgeGraph({ onBack }) { const [showEdgeLabels, setShowEdgeLabels] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true); - const [rawEntities, setRawEntities] = useState([]); - const [rawRelationships, setRawRelationships] = useState([]); - const [chargeStrength] = useState(-280); - const [linkDistance] = useState(150); - const [collideRadius] = useState(80); const handleCopyJSON = async () => { try { @@ -341,11 +122,11 @@ export default function KnowledgeGraph({ onBack }) { const loadModelAndPrefs = useCallback(async () => { try { const modelRes = await aiGetGraphModelStatus(); - if (modelRes.success && modelRes.data) { + if (modelRes?.success && modelRes.data) { setModelStatus(modelRes.data); } const prefsRes = await aiGetPreferences(); - if (prefsRes.success && prefsRes.data) { + if (prefsRes?.success && prefsRes.data) { setPreferences(prev => ({ ...prev, ...prefsRes.data })); } } catch (err) { @@ -353,7 +134,7 @@ export default function KnowledgeGraph({ onBack }) { } }, []); - // Load Graph Data & Compute Force-Directed Layout + // Load Graph Data using cached SQLite coordinates (Instant O(1) load) const loadGraphData = useCallback(async () => { try { setLoading(true); @@ -363,15 +144,15 @@ export default function KnowledgeGraph({ onBack }) { const statusRes = await aiGetGraphStatus(); const logsRes = await aiGetLogs('graph', 50); - if (logsRes && logsRes.success && Array.isArray(logsRes.data)) { + if (logsRes?.success && Array.isArray(logsRes.data)) { setGraphLogs(logsRes.data); } - if (statusRes.success && statusRes.data) { + if (statusRes?.success && statusRes.data) { setGraphStatus(prev => ({ ...prev, ...statusRes.data })); } - if (graphRes.success && graphRes.data) { + if (graphRes?.success && graphRes.data) { const rawEntitiesData = Array.isArray(graphRes.data.entities) ? graphRes.data.entities : []; const entities = rawEntitiesData.map(ent => ({ ...ent, @@ -381,129 +162,15 @@ export default function KnowledgeGraph({ onBack }) { setRawEntities(entities); setRawRelationships(relationships); - - const degrees = {}; - entities.forEach(e => { degrees[e.id] = 0; }); - relationships.forEach(rel => { - if (degrees[rel.source_id] !== undefined) degrees[rel.source_id]++; - if (degrees[rel.target_id] !== undefined) degrees[rel.target_id]++; - }); - - const entityIds = new Set(entities.map(e => e.id)); - const forceNodes = entities.map(entity => ({ - id: entity.id, - entity, - x: (Math.random() - 0.5) * 600 + 400, - y: (Math.random() - 0.5) * 600 + 350 - })); - - const forceLinks = relationships - .filter(rel => entityIds.has(rel.source_id) && entityIds.has(rel.target_id)) - .map(rel => ({ - source: rel.source_id, - target: rel.target_id - })); - - const nodeCount = forceNodes.length; - const dynamicDistance = Math.max(100, Math.min(300, linkDistance + (nodeCount > 50 ? 40 : 0))); - const dynamicCharge = Math.min(-150, chargeStrength - (nodeCount > 50 ? 120 : 0)); - const dynamicCollision = Math.max(50, collideRadius + (nodeCount > 50 ? 15 : 0)); - - const simulation = d3Force.forceSimulation(forceNodes) - .force('link', d3Force.forceLink(forceLinks).id(d => d.id).distance(dynamicDistance)) - .force('charge', d3Force.forceManyBody().strength(dynamicCharge)) - .force('center', d3Force.forceCenter(400, 350)) - .force('collision', d3Force.forceCollide().radius(dynamicCollision)) - .stop(); - - const ticks = Math.min(80, Math.max(30, Math.round(nodeCount * 0.8))); - for (let i = 0; i < ticks; i++) simulation.tick(); - - forceNodes.forEach(node => { - if (isNaN(node.x) || typeof node.x !== 'number') node.x = Math.random() * 500; - if (isNaN(node.y) || typeof node.y !== 'number') node.y = Math.random() * 500; - }); - - const formattedNodes = forceNodes.map(node => { - const entity = node.entity; - const degree = degrees[entity.id] || 0; - const nodeSize = Math.max(48, Math.min(96, 48 + degree * 5.5)); - const typeColors = TYPE_COLORS[entity.type] || DEFAULT_COLOR; - const isHub = degree >= 5; - - return { - id: entity.id, - type: 'default', - data: { - raw: entity, - degree, - nodeSize, - typeColor: typeColors - }, - position: { x: node.x, y: node.y }, - style: { - background: typeColors.background, - border: `1.5px solid ${typeColors.border}`, - borderRadius: '50%', - width: nodeSize, - height: nodeSize, - padding: 0, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - boxShadow: isHub - ? `0 0 16px ${typeColors.border}33, var(--shadow-sm)` - : `0 0 8px ${typeColors.border}1a, var(--shadow-xs)`, - cursor: 'pointer', - transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)' - } - }; - }); - - const formattedEdges = relationships.map((rel) => { - const relTypeUpper = String(rel.type || 'RELATION').toUpperCase(); - const relColor = RELATIONSHIP_COLORS[relTypeUpper] || RELATIONSHIP_COLORS.DEFAULT; - const isMentions = rel.type === 'mentions'; - const cleanLabel = (rel.type && !isMentions) ? String(rel.type).replace(/_/g, ' ').toLowerCase() : undefined; - - return { - id: `edge-${rel.id}-${rel.source_id}-${rel.target_id}`, - source: rel.source_id, - target: rel.target_id, - rawLabel: cleanLabel, - label: cleanLabel, - type: 'straight', - style: { - stroke: isMentions ? 'rgba(140, 140, 140, 0.35)' : relColor, - strokeWidth: isMentions ? 1.0 : 1.5, - strokeDasharray: isMentions ? '3 3' : undefined, - transition: 'opacity var(--motion-standard)' - }, - labelStyle: { fill: 'var(--text-muted)', fontSize: 6.5, fontWeight: 600, letterSpacing: '0.2px' }, - labelBgStyle: { fill: 'var(--surface-bg)', stroke: 'var(--border-default)', strokeWidth: 0.5, fillOpacity: 0.9 }, - labelBgPadding: [1.5, 3], - labelBgBorderRadius: 3, - markerEnd: { - type: MarkerType.ArrowClosed, - color: isMentions ? 'rgba(140, 140, 140, 0.45)' : relColor, - width: 14, - height: 14 - }, - animated: relTypeUpper === 'DEPENDS_ON' || relTypeUpper === 'USES' || relTypeUpper === 'IMPORTS' - }; - }); - - setNodes(formattedNodes); - setEdges(formattedEdges); } else { - setError(graphRes.error || 'Failed to load graph nodes.'); + setError(graphRes?.error || 'Failed to load graph nodes.'); } } catch (err) { setError(err.message || 'Error occurred fetching Knowledge Graph.'); } finally { setLoading(false); } - }, [setNodes, setEdges, chargeStrength, linkDistance, collideRadius]); + }, []); useEffect(() => { loadModelAndPrefs(); @@ -556,7 +223,7 @@ export default function KnowledgeGraph({ onBack }) { const handleRebuild = async () => { const confirmed = await confirm({ title: 'Rebuild Knowledge Graph?', - message: 'Are you sure you want to rebuild the Knowledge Graph from scratch? This will re-parse all notes and extract entities in the background.', + message: 'Are you sure you want to rebuild the Knowledge Graph from scratch? This will re-parse all notes and compute layout in the background.', confirmLabel: 'Rebuild', cancelLabel: 'Cancel', variant: 'primary' @@ -566,8 +233,8 @@ export default function KnowledgeGraph({ onBack }) { try { setError(''); setIsRebuilding(true); - setNodes([]); - setEdges([]); + setRawEntities([]); + setRawRelationships([]); setGraphStatus(prev => ({ ...prev, isBuilding: true, current: 0, noteName: 'Initializing GLiNER/GLiREL worker...' })); const rebuildRes = await aiBuildGraph(); if (!rebuildRes.success) { @@ -584,19 +251,18 @@ export default function KnowledgeGraph({ onBack }) { } }; - const onNodeClick = useCallback((event, node) => { - setSelectedNode(node.data.raw); + const handleNodeClick = useCallback((rawEntity) => { + setSelectedNode(rawEntity); }, []); - const onNodeDoubleClick = useCallback(async (event, node) => { - const raw = node?.data?.raw; - if (raw?.note_path) { + const handleNodeDoubleClick = useCallback(async (rawEntity) => { + if (rawEntity?.note_path) { try { const { appOpenNote } = await import('../services/electronService'); if (typeof appOpenNote === 'function') { - await appOpenNote(raw.note_path); + await appOpenNote(rawEntity.note_path); } else { - window.dispatchEvent(new CustomEvent('app:open-note', { detail: { path: raw.note_path } })); + window.dispatchEvent(new CustomEvent('app:open-note', { detail: { path: rawEntity.note_path } })); } if (onBack) onBack(); } catch (err) { @@ -605,60 +271,13 @@ export default function KnowledgeGraph({ onBack }) { } }, [onBack]); - const [hoveredNodeId, setHoveredNodeId] = useState(null); - const onNodeMouseEnter = useCallback((event, node) => setHoveredNodeId(node.id), []); - const onNodeMouseLeave = useCallback(() => setHoveredNodeId(null), []); - - const handleTypeToggle = (type) => { - setSelectedTypes(prev => ({ ...prev, [type]: !prev[type] })); - }; - - const { filteredNodes, filteredEdges } = useMemo(() => { - const q = searchQuery.toLowerCase().trim(); - const activeNeighbors = new Set(); - if (hoveredNodeId) { - activeNeighbors.add(hoveredNodeId); - for (let i = 0; i < edges.length; i++) { - const edge = edges[i]; - if (edge.source === hoveredNodeId) activeNeighbors.add(edge.target); - if (edge.target === hoveredNodeId) activeNeighbors.add(edge.source); - } - } - - const visibleNodes = []; - const visibleNodeIds = new Set(); - - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - const raw = node.data?.raw || {}; - const typeMatch = selectedTypes[raw.type] !== false; - const searchMatch = !q || (raw.name && raw.name.toLowerCase().includes(q)) || (raw.type && raw.type.toLowerCase().includes(q)); - - if (typeMatch && searchMatch) { - visibleNodeIds.add(node.id); - const opacity = hoveredNodeId ? (activeNeighbors.has(node.id) ? 1 : 0.2) : 1; - visibleNodes.push(opacity === 1 ? node : { ...node, style: { ...node.style, opacity } }); - } - } - - const visibleEdges = []; - for (let i = 0; i < edges.length; i++) { - const edge = edges[i]; - if (visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target)) { - const opacity = hoveredNodeId ? ((edge.source === hoveredNodeId || edge.target === hoveredNodeId) ? 1 : 0.15) : 1; - visibleEdges.push({ - ...edge, - label: showEdgeLabels ? edge.rawLabel : undefined, - style: opacity === 1 ? edge.style : { ...edge.style, opacity } - }); - } - } - - return { - filteredNodes: visibleNodes, - filteredEdges: visibleEdges - }; - }, [nodes, edges, searchQuery, selectedTypes, hoveredNodeId, showEdgeLabels]); + // Filtered dataset for Canvas View based on selected entity types + const filteredNodes = useMemo(() => { + return rawEntities.filter(ent => { + const typeMatch = selectedTypes[ent.type] !== false; + return typeMatch; + }); + }, [rawEntities, selectedTypes]); const sizeMB = (graphStatus.sizeBytes / (1024 * 1024)).toFixed(2); @@ -675,7 +294,7 @@ export default function KnowledgeGraph({ onBack }) {
- {/* Header Bar with Live Top Progress Banner when Building */} + {/* Header Bar */}
@@ -731,7 +350,7 @@ export default function KnowledgeGraph({ onBack }) {
- Nodes: {graphStatus.nodeCount || nodes.length} | Edges: {graphStatus.edgeCount || edges.length} + Nodes: {graphStatus.nodeCount || rawEntities.length} | Edges: {graphStatus.edgeCount || rawRelationships.length}
@@ -806,311 +425,22 @@ export default function KnowledgeGraph({ onBack }) { {/* Main Body */}
- {/* Sidebar */} -
-
- {/* Entity Types Checklist */} -
-
-

- - Entity Types -

-
- - -
-
-
- {(() => { - const getTypeColor = (type) => { - if (TYPE_COLORS[type]) return TYPE_COLORS[type]; - let hash = 0; - for (let i = 0; i < type.length; i++) { - hash = (type.charCodeAt(i) + ((hash << 5) - hash)) | 0; - } - const hue = Math.abs(hash) % 360; - return { - background: `hsl(${hue}, 75%, 95%)`, - border: `hsl(${hue}, 70%, 45%)`, - text: `hsl(${hue}, 70%, 45%)` - }; - }; - - const typesInGraph = new Set(nodes.map(n => n.data?.raw?.type || 'Entity')); - const allTypesSet = new Set([...Object.keys(TYPE_COLORS), ...typesInGraph]); - const activeTypes = Array.from(allTypesSet) - .filter(type => nodes.some(n => (n.data?.raw?.type || 'Entity') === type)) - .sort((a, b) => { - const countA = nodes.filter(n => (n.data?.raw?.type || 'Entity') === a).length; - const countB = nodes.filter(n => (n.data?.raw?.type || 'Entity') === b).length; - return countB - countA; - }); - - if (activeTypes.length === 0) { - return No entities extracted yet.; - } - - return activeTypes.map(type => { - const color = getTypeColor(type); - const count = nodes.filter(n => (n.data?.raw?.type || 'Entity') === type).length; - return ( - - ); - }); - })()} -
-
- - {/* Compact Relationship & Arrow Legend */} -
-
-

- Arrow & Colors -

-
- -
- Source - ──► - Target -
-
-
- -
- {[ - { label: 'LINKS', color: RELATIONSHIP_COLORS.LINKS_TO }, - { label: 'IMPORTS', color: RELATIONSHIP_COLORS.IMPORTS }, - { label: 'CALLS', color: RELATIONSHIP_COLORS.CALLS }, - { label: 'CONTAINS', color: RELATIONSHIP_COLORS.CONTAINS }, - { label: 'EXPORTS', color: RELATIONSHIP_COLORS.EXPORTS }, - { label: 'DOCUMENTS', color: RELATIONSHIP_COLORS.DOCUMENTS }, - { label: 'DEPENDS', color: RELATIONSHIP_COLORS.DEPENDS_ON }, - { label: 'USES', color: RELATIONSHIP_COLORS.USES }, - { label: 'MENTIONS', color: RELATIONSHIP_COLORS.MENTIONS_NOTE }, - { label: 'TAGGED', color: RELATIONSHIP_COLORS.TAGGED }, - { label: 'URL', color: RELATIONSHIP_COLORS.REFERENCES_URL }, - ].map(item => ( - - - {item.label} - - ))} -
-
- - {/* Extraction Logs Panel */} -
-
-

- Extraction Logs -

- -
-
- {graphLogs.length > 0 ? ( - graphLogs.slice(0, 10).map((logItem, i) => ( -
- [{new Date(logItem.timestamp).toLocaleTimeString()}] - {logItem.message} -
- )) - ) : ( - No logs yet. - )} -
-
-
- - {/* Selected Node Inspector */} - {selectedNode && ( -
-
-

- - Entity Inspector -

- -
-
-
- Name - {selectedNode.name || selectedNode.canonical_name} -
-
- Category - - {selectedNode.type} - -
- - {/* Connected Neighbors List */} - {(() => { - const connected = rawRelationships.filter(r => r.source_id === selectedNode.id || r.target_id === selectedNode.id); - if (connected.length === 0) return null; - - return ( -
- Connected Neighbors ({connected.length}) -
- {connected.map((rel, idx) => { - const isOutgoing = rel.source_id === selectedNode.id; - const otherId = isOutgoing ? rel.target_id : rel.source_id; - const otherNode = nodes.find(n => n.id === otherId)?.data?.raw; - const otherName = otherNode?.name || otherId.replace(/^ent-[^-]+-/, ''); - const relUpper = String(rel.type || 'RELATION').toUpperCase(); - const relColor = RELATIONSHIP_COLORS[relUpper] || RELATIONSHIP_COLORS.DEFAULT; - - return ( -
{ - if (otherNode) setSelectedNode(otherNode); - }} - className="kg-neighbor-item" - style={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - background: 'var(--surface-muted)', - border: '1px solid var(--border-soft)', - borderRadius: '5px', - padding: '3px 6px', - fontSize: '10px', - cursor: 'pointer', - transition: 'all 0.15s ease' - }} - title={`Inspect ${otherName}`} - > -
- {isOutgoing ? '→' : '←'} - {otherName} -
- - {String(rel.type).replace(/_/g, ' ')} - -
- ); - })} -
-
- ); - })()} - - {selectedNode.note_path && ( -
- -
- )} -
-
- )} -
- - {/* Full-Height Graph Canvas Viewport */} + {/* Modular Sidebar */} + handleNodeDoubleClick({ note_path: notePath })} + /> + + {/* Canvas Viewport */}
{/* Sidebar toggle button */}
)} @@ -1167,7 +497,7 @@ export default function KnowledgeGraph({ onBack }) {
)} - {!error && !loading && !graphStatus.isBuilding && nodes.length === 0 && ( + {!error && !loading && !graphStatus.isBuilding && rawEntities.length === 0 && (

No Knowledge Graph Data

@@ -1178,27 +508,15 @@ export default function KnowledgeGraph({ onBack }) {
)} - - - - + edges={rawRelationships} + onNodeClick={handleNodeClick} + onNodeDoubleClick={handleNodeDoubleClick} + showEdgeLabels={showEdgeLabels} + searchQuery={searchQuery} + selectedNode={selectedNode} + />
diff --git a/src/components/graph/EntityInspector.jsx b/src/components/graph/EntityInspector.jsx new file mode 100644 index 0000000..2a1fbe5 --- /dev/null +++ b/src/components/graph/EntityInspector.jsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { Sparkles } from 'lucide-react'; +import { TYPE_COLORS, DEFAULT_COLOR, RELATIONSHIP_COLORS } from './graphUtils'; + +export default function EntityInspector({ + selectedNode, + entities = [], + relationships = [], + onSelectNode, + onClose, + onOpenNote +}) { + if (!selectedNode) return null; + + const typeStyle = TYPE_COLORS[selectedNode.type] || DEFAULT_COLOR; + const connected = relationships.filter( + r => r.source_id === selectedNode.id || r.target_id === selectedNode.id + ); + + const entityMap = new Map(entities.map(e => [e.id, e])); + + return ( +
+
+

+ + Entity Inspector +

+ +
+ +
+
+ Name + {selectedNode.name || selectedNode.canonical_name} +
+ +
+ Category + + {selectedNode.type} + +
+ + {connected.length > 0 && ( +
+ Connected Neighbors ({connected.length}) +
+ {connected.map((rel, idx) => { + const isOutgoing = rel.source_id === selectedNode.id; + const otherId = isOutgoing ? rel.target_id : rel.source_id; + const otherNode = entityMap.get(otherId); + const otherName = otherNode?.name || otherNode?.canonical_name || otherId.replace(/^ent-([^-]+-)?/, '') || otherId; + const relUpper = String(rel.type || 'RELATION').toUpperCase(); + const relColor = RELATIONSHIP_COLORS[relUpper] || RELATIONSHIP_COLORS.DEFAULT; + + return ( +
{ + if (otherNode && typeof onSelectNode === 'function') { + onSelectNode(otherNode); + } + }} + className="kg-neighbor-item" + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + background: 'var(--surface-muted)', + border: '1px solid var(--border-soft)', + borderRadius: '5px', + padding: '3px 6px', + fontSize: '10px', + cursor: 'pointer', + transition: 'all 0.15s ease' + }} + title={`Inspect ${otherName}`} + > +
+ {isOutgoing ? '→' : '←'} + + {otherName} + +
+ + {String(rel.type).replace(/_/g, ' ')} + +
+ ); + })} +
+
+ )} + + {selectedNode.note_path && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/components/graph/GraphCanvasView.jsx b/src/components/graph/GraphCanvasView.jsx new file mode 100644 index 0000000..f2e5ff4 --- /dev/null +++ b/src/components/graph/GraphCanvasView.jsx @@ -0,0 +1,585 @@ +import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import { ZoomIn, ZoomOut, Maximize2, RotateCcw, SlidersHorizontal } from 'lucide-react'; +import { TYPE_COLORS, RELATIONSHIP_COLORS, getTypeColor } from './graphUtils'; + +export default function GraphCanvasView({ + nodes = [], + edges = [], + onNodeClick, + onNodeDoubleClick, + showEdgeLabels = true, + searchQuery = '', + selectedNode = null +}) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + + // Viewport transformation state: scale, translation + const [transform, setTransform] = useState({ scale: 1.0, x: 0, y: 0 }); + const [hoveredNode, setHoveredNode] = useState(null); + const [minDegree, setMinDegree] = useState(0); + const [showFilterPanel, setShowFilterPanel] = useState(false); + + const isDraggingRef = useRef(false); + const dragStartRef = useRef({ x: 0, y: 0 }); + const transformRef = useRef(transform); + transformRef.current = transform; + + // Build quick neighbor adjacency index for instantaneous hover highlighting + const { adjacencyMap, nodePositions, edgeList } = useMemo(() => { + const adj = new Map(); + const pos = []; + const validIds = new Set(); + + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + const deg = n.degree || 0; + if (deg < minDegree && (!searchQuery.trim() || !n.name?.toLowerCase().includes(searchQuery.toLowerCase()))) { + continue; + } + + validIds.add(n.id); + adj.set(n.id, new Set([n.id])); + const x = typeof n.pos_x === 'number' ? n.pos_x : (n.position?.x || 500); + const y = typeof n.pos_y === 'number' ? n.pos_y : (n.position?.y || 400); + const radius = Math.max(16, Math.min(36, 16 + deg * 2.4)); + + pos.push({ + id: n.id, + raw: n.data?.raw || n, + x, + y, + radius, + degree: deg, + type: n.data?.raw?.type || n.type || 'Entity', + name: n.data?.raw?.name || n.name || n.canonical_name || 'Node' + }); + } + + const filteredEdges = []; + for (let i = 0; i < edges.length; i++) { + const e = edges[i]; + const src = e.source_id || e.source; + const tgt = e.target_id || e.target; + if (validIds.has(src) && validIds.has(tgt)) { + adj.get(src)?.add(tgt); + adj.get(tgt)?.add(src); + filteredEdges.push({ + source: src, + target: tgt, + type: e.type || 'RELATION', + rawLabel: e.label || e.rawLabel || e.type + }); + } + } + + return { + adjacencyMap: adj, + nodePositions: pos, + edgeList: filteredEdges + }; + }, [nodes, edges, minDegree, searchQuery]); + + // Fit view helper + const fitView = useCallback(() => { + if (!containerRef.current || nodePositions.length === 0) return; + const { clientWidth: w, clientHeight: h } = containerRef.current; + if (w <= 0 || h <= 0) return; + + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const n of nodePositions) { + if (n.x < minX) minX = n.x; + if (n.x > maxX) maxX = n.x; + if (n.y < minY) minY = n.y; + if (n.y > maxY) maxY = n.y; + } + + const padding = 100; + const graphW = Math.max(100, maxX - minX + padding * 2); + const graphH = Math.max(100, maxY - minY + padding * 2); + + const scale = Math.min(1.6, Math.max(0.12, Math.min(w / graphW, h / graphH))); + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + setTransform({ + scale, + x: w / 2 - centerX * scale, + y: h / 2 - centerY * scale + }); + }, [nodePositions]); + + // Auto-fit view on initial load + useEffect(() => { + if (nodePositions.length > 0) { + fitView(); + } + }, [nodePositions.length, fitView]); + + // Main Canvas Rendering Loop + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !containerRef.current) return; + const ctx = canvas.getContext('2d', { alpha: true }); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const w = containerRef.current.clientWidth; + const h = containerRef.current.clientHeight; + + if (canvas.width !== w * dpr || canvas.height !== h * dpr) { + canvas.width = w * dpr; + canvas.height = h * dpr; + } + + ctx.save(); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + + const { scale, x: tx, y: ty } = transform; + + // 0. Draw subtle dynamic background grid dots + const gridSize = 32 * scale; + if (gridSize >= 12) { + const offsetX = ((tx % gridSize) + gridSize) % gridSize; + const offsetY = ((ty % gridSize) + gridSize) % gridSize; + + ctx.fillStyle = 'rgba(255, 255, 255, 0.04)'; + for (let x = offsetX; x < w; x += gridSize) { + for (let y = offsetY; y < h; y += gridSize) { + ctx.beginPath(); + ctx.arc(x, y, 1.0, 0, Math.PI * 2); + ctx.fill(); + } + } + } + + ctx.translate(tx, ty); + ctx.scale(scale, scale); + + const activeHoverSet = hoveredNode ? adjacencyMap.get(hoveredNode.id) : null; + const isSearching = !!searchQuery.trim(); + const searchLower = searchQuery.toLowerCase().trim(); + + // Map for fast coordinate lookup + const posMap = new Map(nodePositions.map(n => [n.id, n])); + + // 1. Draw Edges + for (let i = 0; i < edgeList.length; i++) { + const edge = edgeList[i]; + const srcNode = posMap.get(edge.source); + const tgtNode = posMap.get(edge.target); + if (!srcNode || !tgtNode) continue; + + const relTypeUpper = String(edge.type || 'RELATION').toUpperCase(); + const isMentions = relTypeUpper === 'MENTIONS' || relTypeUpper === 'MENTIONS_NOTE'; + const edgeColor = RELATIONSHIP_COLORS[relTypeUpper] || RELATIONSHIP_COLORS.DEFAULT; + + // Determine opacity based on hover/search + let opacity = 0.55; + const isConnected = hoveredNode && (edge.source === hoveredNode.id || edge.target === hoveredNode.id); + if (hoveredNode) { + opacity = isConnected ? 1.0 : 0.06; + } else if (isSearching) { + const srcMatch = srcNode.name.toLowerCase().includes(searchLower); + const tgtMatch = tgtNode.name.toLowerCase().includes(searchLower); + opacity = srcMatch || tgtMatch ? 0.95 : 0.1; + } + + ctx.save(); + ctx.globalAlpha = opacity; + ctx.strokeStyle = isMentions ? 'rgba(160, 160, 160, 0.4)' : edgeColor; + ctx.lineWidth = isMentions ? 1.0 : (isConnected ? 2.5 : 1.4); + + if (isMentions) { + ctx.setLineDash([4, 4]); + } + + // Draw connection line + ctx.beginPath(); + ctx.moveTo(srcNode.x, srcNode.y); + ctx.lineTo(tgtNode.x, tgtNode.y); + ctx.stroke(); + + // Draw directional arrow near target + const angle = Math.atan2(tgtNode.y - srcNode.y, tgtNode.x - srcNode.x); + const arrowDist = tgtNode.radius + 6; + const arrowX = tgtNode.x - arrowDist * Math.cos(angle); + const arrowY = tgtNode.y - arrowDist * Math.sin(angle); + const arrowSize = 6; + + ctx.setLineDash([]); + ctx.fillStyle = ctx.strokeStyle; + ctx.beginPath(); + ctx.moveTo(arrowX, arrowY); + ctx.lineTo(arrowX - arrowSize * Math.cos(angle - Math.PI / 6), arrowY - arrowSize * Math.sin(angle - Math.PI / 6)); + ctx.lineTo(arrowX - arrowSize * Math.cos(angle + Math.PI / 6), arrowY - arrowSize * Math.sin(angle + Math.PI / 6)); + ctx.closePath(); + ctx.fill(); + + // Draw edge label pill when zoomed in + if (showEdgeLabels && scale >= 0.75 && !isMentions && edge.rawLabel) { + const midX = (srcNode.x + tgtNode.x) / 2; + const midY = (srcNode.y + tgtNode.y) / 2; + const labelText = String(edge.rawLabel).replace(/_/g, ' ').toLowerCase(); + + ctx.font = '600 7px system-ui, -apple-system, sans-serif'; + const textWidth = ctx.measureText(labelText).width; + + // Label pill background + ctx.fillStyle = 'rgba(18, 22, 28, 0.9)'; + ctx.strokeStyle = `${edgeColor}66`; + ctx.lineWidth = 0.5; + const pillW = textWidth + 8; + const pillH = 12; + const pillR = 3; + + ctx.beginPath(); + ctx.roundRect(midX - pillW / 2, midY - pillH / 2, pillW, pillH, pillR); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = '#e2e8f0'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(labelText, midX, midY); + } + + ctx.restore(); + } + + // 2. Draw Nodes + for (let i = 0; i < nodePositions.length; i++) { + const node = nodePositions[i]; + const typeStyle = TYPE_COLORS[node.type] || getTypeColor(node.type); + const isSelected = selectedNode && selectedNode.id === node.id; + const isHovered = hoveredNode && hoveredNode.id === node.id; + const isHub = node.degree >= 5; + + let opacity = 1.0; + if (hoveredNode) { + opacity = activeHoverSet?.has(node.id) ? 1.0 : 0.12; + } else if (isSearching) { + const match = node.name.toLowerCase().includes(searchLower) || node.type.toLowerCase().includes(searchLower); + opacity = match ? 1.0 : 0.12; + } + + ctx.save(); + ctx.globalAlpha = opacity; + + // Outer glow on selected or hovered node + if (isSelected || isHovered) { + ctx.shadowColor = typeStyle.border || '#2f5d62'; + ctx.shadowBlur = isSelected ? 16 : 10; + } + + // Hub outer pulse ring + if (isHub || isSelected || isHovered) { + ctx.beginPath(); + ctx.arc(node.x, node.y, node.radius + (isSelected ? 5 : 3), 0, Math.PI * 2); + ctx.strokeStyle = typeStyle.border || '#2f5d62'; + ctx.lineWidth = isSelected ? 2.5 : 1.2; + ctx.stroke(); + } + + // Circle Fill & Border + ctx.beginPath(); + ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2); + ctx.fillStyle = typeStyle.background || 'rgba(47, 93, 98, 0.2)'; + ctx.fill(); + ctx.shadowBlur = 0; // reset shadow for crisp stroke + ctx.strokeStyle = typeStyle.border || '#2f5d62'; + ctx.lineWidth = isSelected ? 2.5 : 1.5; + ctx.stroke(); + + // Node Entity Type Indicator Dot + ctx.beginPath(); + ctx.arc(node.x, node.y - node.radius * 0.38, isHub ? 3 : 2, 0, Math.PI * 2); + ctx.fillStyle = typeStyle.border || '#2f5d62'; + ctx.fill(); + + // Label (LOD: Level of Detail) + const showLabel = scale >= 0.45 || isHub || isHovered || isSelected; + if (showLabel) { + const textColor = typeStyle.border || '#2f5d62'; + + // 1. Small uppercase category tag above node name + if (scale >= 0.65 || isHovered || isSelected) { + ctx.font = '800 5.5px system-ui, -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = textColor; + const typeLabel = String(node.type || 'Entity').toUpperCase(); + ctx.fillText(typeLabel.slice(0, 10), node.x, node.y - node.radius * 0.38); + } + + // 2. Main node name with high contrast color + const fontSize = Math.max(7.5, Math.min(10.5, Math.round(node.radius * 0.42))); + ctx.font = `700 ${fontSize}px system-ui, -apple-system, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = textColor; + + // Auto truncate label + let displayName = node.name; + if (displayName.length > 14) { + displayName = displayName.slice(0, 12) + '…'; + } + + const textY = (scale >= 0.65 || isHovered || isSelected) ? node.y + (node.radius > 16 ? 3.5 : 2) : node.y; + ctx.fillText(displayName, node.x, textY); + + // 3. Node degree badge on hubs + if (node.degree > 0 && scale >= 0.55) { + const badgeX = node.x + node.radius * 0.72; + const badgeY = node.y + node.radius * 0.72; + const badgeRadius = 6.5; + + ctx.beginPath(); + ctx.arc(badgeX, badgeY, badgeRadius, 0, Math.PI * 2); + ctx.fillStyle = '#1e293b'; + ctx.fill(); + ctx.strokeStyle = `${textColor}88`; + ctx.lineWidth = 1; + ctx.stroke(); + + ctx.font = '800 6.5px system-ui, -apple-system, sans-serif'; + ctx.fillStyle = textColor; + ctx.fillText(`${node.degree}`, badgeX, badgeY + 0.5); + } + } + + ctx.restore(); + } + + ctx.restore(); + }, [nodePositions, edgeList, transform, hoveredNode, adjacencyMap, showEdgeLabels, searchQuery, selectedNode]); + + // Mouse / Pointer Event Handlers + const handleWheel = useCallback((e) => { + e.preventDefault(); + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + const zoomFactor = e.deltaY < 0 ? 1.15 : 0.85; + setTransform(prev => { + const newScale = Math.max(0.06, Math.min(4.0, prev.scale * zoomFactor)); + const newX = mouseX - (mouseX - prev.x) * (newScale / prev.scale); + const newY = mouseY - (mouseY - prev.y) * (newScale / prev.scale); + return { scale: newScale, x: newX, y: newY }; + }); + }, []); + + const handleMouseDown = useCallback((e) => { + if (e.button !== 0) return; + isDraggingRef.current = true; + dragStartRef.current = { x: e.clientX, y: e.clientY }; + }, []); + + const findNodeAtScreenPos = useCallback((clientX, clientY) => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return null; + + const { scale, x: tx, y: ty } = transformRef.current; + const canvasX = (clientX - rect.left - tx) / scale; + const canvasY = (clientY - rect.top - ty) / scale; + + for (let i = nodePositions.length - 1; i >= 0; i--) { + const node = nodePositions[i]; + const dx = canvasX - node.x; + const dy = canvasY - node.y; + const hitRadius = Math.max(node.radius, 18); + if (dx * dx + dy * dy <= hitRadius * hitRadius) { + return node; + } + } + return null; + }, [nodePositions]); + + const handleMouseMove = useCallback((e) => { + if (isDraggingRef.current) { + const dx = e.clientX - dragStartRef.current.x; + const dy = e.clientY - dragStartRef.current.y; + dragStartRef.current = { x: e.clientX, y: e.clientY }; + setTransform(prev => ({ ...prev, x: prev.x + dx, y: prev.y + dy })); + } else { + const found = findNodeAtScreenPos(e.clientX, e.clientY); + setHoveredNode(found); + if (canvasRef.current) { + canvasRef.current.style.cursor = found ? 'pointer' : 'grab'; + } + } + }, [findNodeAtScreenPos]); + + const handleMouseUp = useCallback((e) => { + isDraggingRef.current = false; + const moved = Math.abs(e.clientX - dragStartRef.current.x) + Math.abs(e.clientY - dragStartRef.current.y); + if (moved < 5) { + const clicked = findNodeAtScreenPos(e.clientX, e.clientY); + if (clicked && typeof onNodeClick === 'function') { + onNodeClick(clicked.raw); + } + } + }, [findNodeAtScreenPos, onNodeClick]); + + const handleDoubleClick = useCallback((e) => { + const clicked = findNodeAtScreenPos(e.clientX, e.clientY); + if (clicked && typeof onNodeDoubleClick === 'function') { + onNodeDoubleClick(clicked.raw); + } + }, [findNodeAtScreenPos, onNodeDoubleClick]); + + // Setup non-passive wheel listener + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + canvas.addEventListener('wheel', handleWheel, { passive: false }); + return () => { + canvas.removeEventListener('wheel', handleWheel); + }; + }, [handleWheel]); + + const zoomPercent = Math.round(transform.scale * 100); + + return ( +
+ { + isDraggingRef.current = false; + setHoveredNode(null); + }} + style={{ + width: '100%', + height: '100%', + display: 'block', + cursor: 'grab' + }} + /> + + {/* Floating Canvas Controls */} +
+ + {zoomPercent}% + + + + + + + + + + + + +
+ + {/* Connectivity Density Filter Popover */} + {showFilterPanel && ( +
+
+ Min Connections + {minDegree}+ +
+ setMinDegree(Number(e.target.value))} + style={{ width: '100%', accentColor: 'var(--accent-solid)', cursor: 'pointer' }} + /> + + {minDegree === 0 ? 'Showing all nodes' : `Showing nodes with $\\ge ${minDegree}$ links`} + +
+ )} +
+ ); +} diff --git a/src/components/graph/GraphSidebar.jsx b/src/components/graph/GraphSidebar.jsx new file mode 100644 index 0000000..58fd5e6 --- /dev/null +++ b/src/components/graph/GraphSidebar.jsx @@ -0,0 +1,225 @@ +import React, { useMemo } from 'react'; +import { Layers, CheckSquare, Square, ExternalLink } from 'lucide-react'; +import { TYPE_COLORS, RELATIONSHIP_COLORS, getTypeColor } from './graphUtils'; +import EntityInspector from './EntityInspector'; + +export default function GraphSidebar({ + sidebarOpen, + entities = [], + relationships = [], + selectedTypes = {}, + setSelectedTypes, + showEdgeLabels, + setShowEdgeLabels, + selectedNode, + setSelectedNode, + graphLogs = [], + onOpenNote +}) { + // Pre-calculate type counts efficiently + const { activeTypes, typeCounts } = useMemo(() => { + const counts = new Map(); + for (const ent of entities) { + const t = ent.type || 'Entity'; + counts.set(t, (counts.get(t) || 0) + 1); + } + + const allTypesSet = new Set([...Object.keys(TYPE_COLORS), ...counts.keys()]); + const sorted = Array.from(allTypesSet) + .filter(t => (counts.get(t) || 0) > 0) + .sort((a, b) => (counts.get(b) || 0) - (counts.get(a) || 0)); + + return { activeTypes: sorted, typeCounts: counts }; + }, [entities]); + + const handleTypeToggle = (type) => { + setSelectedTypes(prev => ({ ...prev, [type]: !prev[type] })); + }; + + const handleSelectAll = () => { + const allKnown = new Set([...Object.keys(TYPE_COLORS), ...activeTypes]); + const next = {}; + allKnown.forEach(k => { next[k] = true; }); + setSelectedTypes(next); + }; + + const handleSelectNone = () => { + const allKnown = new Set([...Object.keys(TYPE_COLORS), ...activeTypes]); + const next = {}; + allKnown.forEach(k => { next[k] = false; }); + setSelectedTypes(next); + }; + + return ( +
+
+ {/* Entity Types Checklist */} +
+
+

+ + Entity Types +

+
+ + +
+
+ +
+ {activeTypes.length === 0 ? ( + No entities extracted yet. + ) : ( + activeTypes.map(type => { + const color = getTypeColor(type); + const count = typeCounts.get(type) || 0; + const isChecked = selectedTypes[type] !== false; + + return ( + + ); + }) + )} +
+
+ + {/* Compact Relationship & Arrow Legend */} +
+
+

+ Arrow & Colors +

+
+ +
+ Source + ──► + Target +
+
+
+ +
+ {[ + { label: 'LINKS', color: RELATIONSHIP_COLORS.LINKS_TO }, + { label: 'IMPORTS', color: RELATIONSHIP_COLORS.IMPORTS }, + { label: 'CALLS', color: RELATIONSHIP_COLORS.CALLS }, + { label: 'CONTAINS', color: RELATIONSHIP_COLORS.CONTAINS }, + { label: 'EXPORTS', color: RELATIONSHIP_COLORS.EXPORTS }, + { label: 'DOCUMENTS', color: RELATIONSHIP_COLORS.DOCUMENTS }, + { label: 'DEPENDS', color: RELATIONSHIP_COLORS.DEPENDS_ON }, + { label: 'USES', color: RELATIONSHIP_COLORS.USES }, + { label: 'MENTIONS', color: RELATIONSHIP_COLORS.MENTIONS_NOTE }, + { label: 'TAGGED', color: RELATIONSHIP_COLORS.TAGGED }, + { label: 'URL', color: RELATIONSHIP_COLORS.REFERENCES_URL } + ].map(item => ( + + + {item.label} + + ))} +
+
+ + {/* Extraction Logs Panel */} +
+
+

+ Extraction Logs +

+ +
+
+ {graphLogs.length > 0 ? ( + graphLogs.slice(0, 8).map((logItem, i) => ( +
+ [{new Date(logItem.timestamp).toLocaleTimeString()}] + {logItem.message} +
+ )) + ) : ( + No logs yet. + )} +
+
+
+ + {/* Entity Inspector */} + {selectedNode && ( + setSelectedNode(null)} + onOpenNote={onOpenNote} + /> + )} +
+ ); +} diff --git a/src/components/graph/graphUtils.js b/src/components/graph/graphUtils.js new file mode 100644 index 0000000..66ebb18 --- /dev/null +++ b/src/components/graph/graphUtils.js @@ -0,0 +1,130 @@ +/** + * Graph utility constants, color palettes, and data normalization functions + */ + +export const TYPE_COLORS = { + Note: { background: 'rgba(99, 102, 241, 0.18)', border: '#6366f1', text: '#6366f1' }, + Person: { background: 'rgba(236, 72, 153, 0.18)', border: '#ec4899', text: '#ec4899' }, + Project: { background: 'rgba(59, 130, 246, 0.18)', border: '#3b82f6', text: '#3b82f6' }, + Technology: { background: 'rgba(6, 182, 212, 0.18)', border: '#06b6d4', text: '#06b6d4' }, + Company: { background: 'rgba(245, 158, 11, 0.18)', border: '#f59e0b', text: '#f59e0b' }, + Organization: { background: 'rgba(249, 115, 22, 0.18)', border: '#f97316', text: '#f97316' }, + Concept: { background: 'rgba(139, 92, 246, 0.18)', border: '#8b5cf6', text: '#8b5cf6' }, + Task: { background: 'rgba(239, 68, 68, 0.18)', border: '#ef4444', text: '#ef4444' }, + Image: { background: 'rgba(16, 185, 129, 0.18)', border: '#10b981', text: '#10b981' }, + Document: { background: 'rgba(234, 179, 8, 0.18)', border: '#eab308', text: '#eab308' }, + ExternalURL: { background: 'rgba(14, 165, 233, 0.18)', border: '#0ea5e9', text: '#0ea5e9' }, + CodeBlock: { background: 'rgba(168, 85, 247, 0.18)', border: '#a855f7', text: '#a855f7' }, + Diagram: { background: 'rgba(20, 184, 166, 0.18)', border: '#14b8a6', text: '#14b8a6' }, + Section: { background: 'rgba(168, 85, 247, 0.18)', border: '#a855f7', text: '#a855f7' }, + Tag: { background: 'rgba(244, 63, 94, 0.18)', border: '#f43f5e', text: '#f43f5e' }, + KeyTerm: { background: 'rgba(16, 185, 129, 0.18)', border: '#10b981', text: '#10b981' }, + Formula: { background: 'rgba(217, 70, 239, 0.18)', border: '#d946ef', text: '#d946ef' }, + Callout: { background: 'rgba(245, 158, 11, 0.18)', border: '#f59e0b', text: '#f59e0b' }, + + // Code & AST Graph Entities + Repo: { background: 'rgba(139, 92, 246, 0.18)', border: '#8b5cf6', text: '#8b5cf6' }, + CodeModule: { background: 'rgba(99, 102, 241, 0.18)', border: '#6366f1', text: '#6366f1' }, + CodeClass: { background: 'rgba(168, 85, 247, 0.18)', border: '#a855f7', text: '#a855f7' }, + CodeInterface: { background: 'rgba(192, 132, 252, 0.18)', border: '#c084fc', text: '#c084fc' }, + CodeFunction: { background: 'rgba(56, 189, 248, 0.18)', border: '#38bdf8', text: '#38bdf8' }, + APIEndpoint: { background: 'rgba(245, 158, 11, 0.18)', border: '#f59e0b', text: '#f59e0b' }, + DBModel: { background: 'rgba(16, 185, 129, 0.18)', border: '#10b981', text: '#10b981' } +}; + +export const DEFAULT_COLOR = { + background: 'rgba(47, 93, 98, 0.18)', + border: '#2f5d62', + text: '#2f5d62' +}; + +export const RELATIONSHIP_COLORS = { + // Semantic / LLM Relationships + DEPENDS_ON: '#f59e0b', // Amber + USES: '#06b6d4', // Cyan + REFERENCES: '#6366f1', // Indigo + CONTAINS: '#10b981', // Emerald + HAS: '#10b981', // Emerald + MENTIONS: '#8b5cf6', // Purple + CREATED_BY: '#f43f5e', // Rose + OWNED_BY: '#f43f5e', // Rose + DOCUMENTS: '#a855f7', // Purple (Doc -> Code) + IMPORTS: '#6366f1', // Indigo (Code -> Code) + CALLS: '#f59e0b', // Amber (Func -> API) + EXPORTS: '#38bdf8', // Sky (Module -> Symbol) + + // Structural Note Graph Relationships + LINKS_TO: '#6366f1', // Indigo + TAGGED: '#ec4899', // Pink + CONTAINS_MEDIA: '#10b981', // Emerald + REFERENCES_URL: '#3b82f6', // Blue + ATTACHES_FILE: '#eab308', // Yellow + CONTAINS_CODE: '#06b6d4', // Cyan + CONTAINS_SECTION: '#14b8a6', // Teal + EMPHASIZES: '#f97316', // Orange + REFERENCES_CODE: '#0284c7', // Sky Blue + HAS_CALLOUT: '#a855f7', // Violet + CONTAINS_FORMULA: '#d946ef', // Fuchsia + HAS_OPEN_TASK: '#ef4444', // Red + HAS_COMPLETED_TASK: '#22c55e',// Green + MENTIONS_NOTE: '#8b5cf6', // Purple + RELATED_TO: '#8b5cf6', // Purple + DEFAULT: '#06b6d4' // Cyan fallback +}; + +export const CANONICAL_TYPE_MAP = { + note: 'Note', + person: 'Person', + project: 'Project', + technology: 'Technology', + company: 'Company', + organization: 'Organization', + concept: 'Concept', + task: 'Task', + decision: 'Decision', + idea: 'Idea', + tag: 'Tag', + image: 'Image', + document: 'Document', + folder: 'Folder', + workspace: 'Workspace', + section: 'Section', + event: 'Event', + location: 'Location', + externalurl: 'ExternalURL', + external_url: 'ExternalURL', + codeblock: 'CodeBlock', + diagram: 'Diagram', + keyterm: 'KeyTerm', + formula: 'Formula', + callout: 'Callout', + repo: 'Repo', + codemodule: 'CodeModule', + codeclass: 'CodeClass', + codeinterface: 'CodeInterface', + codefunction: 'CodeFunction', + apiendpoint: 'APIEndpoint', + dbmodel: 'DBModel' +}; + +export const normalizeType = (rawType) => { + if (!rawType) return 'Concept'; + const clean = String(rawType).trim(); + const key = clean.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (CANONICAL_TYPE_MAP[key]) return CANONICAL_TYPE_MAP[key]; + return clean.charAt(0).toUpperCase() + clean.slice(1); +}; + +export const getTypeColor = (type) => { + if (TYPE_COLORS[type]) return TYPE_COLORS[type]; + let hash = 0; + for (let i = 0; i < type.length; i++) { + hash = (type.charCodeAt(i) + ((hash << 5) - hash)) | 0; + } + const hue = Math.abs(hash) % 360; + return { + background: `hsl(${hue}, 75%, 95%)`, + border: `hsl(${hue}, 70%, 45%)`, + text: `hsl(${hue}, 70%, 45%)` + }; +}; diff --git a/tests/CodeKnowledgeSource.test.js b/tests/CodeKnowledgeSource.test.js index 4342e7e..5078cb0 100644 --- a/tests/CodeKnowledgeSource.test.js +++ b/tests/CodeKnowledgeSource.test.js @@ -57,7 +57,7 @@ describe('CodeKnowledgeSource', () => { const relationships = await source.extractRelationships('/workspace/my-app/src/auth.ts', codeSnippet); expect(relationships.some((r) => r.type === 'CONTAINS' && r.target_name === 'my-app/src/auth.ts')).toBe(true); - expect(relationships.some((r) => r.type === 'IMPORTS' && r.target_name === './base')).toBe(true); + expect(relationships.some((r) => r.type === 'IMPORTS' && r.target_name === 'my-app/src/base.ts')).toBe(true); }); it('should extract Python classes and functions', async () => { diff --git a/tests/graph_layout_performance.test.js b/tests/graph_layout_performance.test.js new file mode 100644 index 0000000..fe6639f --- /dev/null +++ b/tests/graph_layout_performance.test.js @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import GraphDB from '../ai/graph/GraphDB'; +import GraphLayoutEngine from '../ai/graph/layout/GraphLayoutEngine'; + +describe('GraphLayoutEngine & SQLite Layout Cache Test Suite', () => { + let tmpDir; + let graphDb; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notely-graph-layout-test-')); + graphDb = new GraphDB(tmpDir); + graphDb.initialize(); + }); + + afterEach(() => { + if (graphDb && graphDb.close) { + graphDb.close(); + } + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('1. GraphLayoutEngine accurately computes coordinates and node degrees', () => { + const entities = [ + { id: 'ent-1', name: 'Node 1', type: 'Concept' }, + { id: 'ent-2', name: 'Node 2', type: 'Technology' }, + { id: 'ent-3', name: 'Node 3', type: 'Note' } + ]; + + const relationships = [ + { source_id: 'ent-1', target_id: 'ent-2', type: 'uses' }, + { source_id: 'ent-1', target_id: 'ent-3', type: 'references' } + ]; + + const result = GraphLayoutEngine.computeLayout(entities, relationships, { + width: 1000, + height: 800, + ticks: 50 + }); + + expect(result.nodes).toHaveLength(3); + expect(result.positions.size).toBe(3); + + const pos1 = result.positions.get('ent-1'); + const pos2 = result.positions.get('ent-2'); + const pos3 = result.positions.get('ent-3'); + + expect(pos1.degree).toBe(2); + expect(pos2.degree).toBe(1); + expect(pos3.degree).toBe(1); + + expect(typeof pos1.x).toBe('number'); + expect(typeof pos1.y).toBe('number'); + expect(isNaN(pos1.x)).toBe(false); + expect(isNaN(pos1.y)).toBe(false); + }); + + it('2. GraphDB persists and retrieves cached layout coordinates and degrees', () => { + graphDb.upsertEntity({ id: 'ent-a', name: 'Alpha', type: 'Project' }); + graphDb.upsertEntity({ id: 'ent-b', name: 'Beta', type: 'Person' }); + graphDb.upsertRelationship({ source_id: 'ent-a', target_id: 'ent-b', type: 'managed_by' }); + + // Initial state before layout computation + const initial = graphDb.getAll(); + expect(initial.entities).toHaveLength(2); + + // Run layout computation and cache to SQLite + const layout = graphDb.recomputeLayout({ ticks: 40 }); + expect(layout.nodes).toHaveLength(2); + + // Fetch again and verify cached coordinates exist + const cached = graphDb.getAll(); + const entA = cached.entities.find(e => e.id === 'ent-a'); + const entB = cached.entities.find(e => e.id === 'ent-b'); + + expect(typeof entA.pos_x).toBe('number'); + expect(typeof entA.pos_y).toBe('number'); + expect(entA.degree).toBe(1); + expect(entB.degree).toBe(1); + }); +}); From 05ff7f3cec74a22a8563f964814302f1f1db9c43 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Thu, 1 Oct 2026 23:37:38 +0530 Subject: [PATCH 03/24] feat(graph): convert entity inspector to modal popup --- src/components/KnowledgeGraph.jsx | 17 ++- src/components/graph/EntityInspector.jsx | 185 ++++++++++++++--------- src/components/graph/GraphSidebar.jsx | 19 +-- src/styles/KnowledgeGraph.css | 11 +- 4 files changed, 132 insertions(+), 100 deletions(-) diff --git a/src/components/KnowledgeGraph.jsx b/src/components/KnowledgeGraph.jsx index e75175a..89be654 100644 --- a/src/components/KnowledgeGraph.jsx +++ b/src/components/KnowledgeGraph.jsx @@ -32,6 +32,7 @@ import { useConfirm } from '../hooks/useConfirm'; import GraphSidebar from './graph/GraphSidebar'; import GraphCanvasView from './graph/GraphCanvasView'; +import EntityInspector from './graph/EntityInspector'; import { normalizeType } from './graph/graphUtils'; import '../styles/KnowledgeGraph.css'; @@ -429,15 +430,11 @@ export default function KnowledgeGraph({ onBack }) { handleNodeDoubleClick({ note_path: notePath })} /> {/* Canvas Viewport */} @@ -562,6 +559,18 @@ export default function KnowledgeGraph({ onBack }) {
)} + + {/* Entity Inspector Popup Modal */} + {selectedNode && ( + setSelectedNode(null)} + onOpenNote={(notePath) => handleNodeDoubleClick({ note_path: notePath })} + /> + )} ); } diff --git a/src/components/graph/EntityInspector.jsx b/src/components/graph/EntityInspector.jsx index 2a1fbe5..2285736 100644 --- a/src/components/graph/EntityInspector.jsx +++ b/src/components/graph/EntityInspector.jsx @@ -1,5 +1,6 @@ import React from 'react'; -import { Sparkles } from 'lucide-react'; +import { FileText, X } from 'lucide-react'; +import { OverlayDialog } from '../OverlayDialog'; import { TYPE_COLORS, DEFAULT_COLOR, RELATIONSHIP_COLORS } from './graphUtils'; export default function EntityInspector({ @@ -20,23 +21,22 @@ export default function EntityInspector({ const entityMap = new Map(entities.map(e => [e.id, e])); return ( -
-
-

- - Entity Inspector -

- -
- -
-
- Name - {selectedNode.name || selectedNode.canonical_name} -
- -
- Category + +
+ {/* Header */} +
+
+ +

+ {selectedNode.name || selectedNode.canonical_name} +

+
{selectedNode.type}
- {connected.length > 0 && ( -
- Connected Neighbors ({connected.length}) -
- {connected.map((rel, idx) => { - const isOutgoing = rel.source_id === selectedNode.id; - const otherId = isOutgoing ? rel.target_id : rel.source_id; - const otherNode = entityMap.get(otherId); - const otherName = otherNode?.name || otherNode?.canonical_name || otherId.replace(/^ent-([^-]+-)?/, '') || otherId; - const relUpper = String(rel.type || 'RELATION').toUpperCase(); - const relColor = RELATIONSHIP_COLORS[relUpper] || RELATIONSHIP_COLORS.DEFAULT; + {/* Details Content */} +
+ {selectedNode.note_path && ( +
+ + Source Document + + + {selectedNode.note_path} + +
+ )} + + {/* Connected Neighbors List */} + {connected.length > 0 && ( +
+
+ + Connected Relationships + + + {connected.length} {connected.length === 1 ? 'link' : 'links'} + +
+
+ {connected.map((rel, idx) => { + const isOutgoing = rel.source_id === selectedNode.id; + const otherId = isOutgoing ? rel.target_id : rel.source_id; + const otherNode = entityMap.get(otherId); + const otherName = otherNode?.name || otherNode?.canonical_name || otherId.replace(/^ent-([^-]+-)?/, '') || otherId; + const relUpper = String(rel.type || 'RELATION').toUpperCase(); + const relColor = RELATIONSHIP_COLORS[relUpper] || RELATIONSHIP_COLORS.DEFAULT; - return ( -
{ - if (otherNode && typeof onSelectNode === 'function') { - onSelectNode(otherNode); - } - }} - className="kg-neighbor-item" - style={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - background: 'var(--surface-muted)', - border: '1px solid var(--border-soft)', - borderRadius: '5px', - padding: '3px 6px', - fontSize: '10px', - cursor: 'pointer', - transition: 'all 0.15s ease' - }} - title={`Inspect ${otherName}`} - > -
- {isOutgoing ? '→' : '←'} - - {otherName} + return ( +
{ + if (otherNode && typeof onSelectNode === 'function') { + onSelectNode(otherNode); + } + }} + className="kg-neighbor-item" + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + background: 'var(--surface-muted)', + border: '1px solid var(--border-soft)', + borderRadius: '6px', + padding: '6px 10px', + fontSize: '11px', + cursor: 'pointer', + transition: 'all 0.15s ease' + }} + title={`Inspect ${otherName}`} + > +
+ {isOutgoing ? '→' : '←'} + + {otherName} + +
+ + {String(rel.type).replace(/_/g, ' ')}
- - {String(rel.type).replace(/_/g, ' ')} - -
- ); - })} + ); + })} +
-
- )} + )} +
- {selectedNode.note_path && ( -
+ {/* Footer Actions */} +
+ + {selectedNode.note_path && ( -
- )} + )} +
-
+ ); } diff --git a/src/components/graph/GraphSidebar.jsx b/src/components/graph/GraphSidebar.jsx index 58fd5e6..3e36b36 100644 --- a/src/components/graph/GraphSidebar.jsx +++ b/src/components/graph/GraphSidebar.jsx @@ -1,20 +1,15 @@ import React, { useMemo } from 'react'; import { Layers, CheckSquare, Square, ExternalLink } from 'lucide-react'; import { TYPE_COLORS, RELATIONSHIP_COLORS, getTypeColor } from './graphUtils'; -import EntityInspector from './EntityInspector'; export default function GraphSidebar({ sidebarOpen, entities = [], - relationships = [], selectedTypes = {}, setSelectedTypes, showEdgeLabels, setShowEdgeLabels, - selectedNode, - setSelectedNode, - graphLogs = [], - onOpenNote + graphLogs = [] }) { // Pre-calculate type counts efficiently const { activeTypes, typeCounts } = useMemo(() => { @@ -208,18 +203,6 @@ export default function GraphSidebar({
- - {/* Entity Inspector */} - {selectedNode && ( - setSelectedNode(null)} - onOpenNote={onOpenNote} - /> - )}
); } diff --git a/src/styles/KnowledgeGraph.css b/src/styles/KnowledgeGraph.css index a7ac1ad..d4c95f1 100644 --- a/src/styles/KnowledgeGraph.css +++ b/src/styles/KnowledgeGraph.css @@ -604,9 +604,14 @@ /* Interactive Neighbor List Items in Inspector */ .kg-neighbor-item:hover { - background: var(--surface-bg) !important; - border-color: var(--accent-solid) !important; - transform: translateX(2px); + background: var(--surface-elevated) !important; + border-color: var(--border-default) !important; +} + +.kg-inspector-footer-close-btn:hover { + background: var(--surface-elevated) !important; + border-color: var(--border-default) !important; + color: var(--text-strong) !important; } /* Polished ReactFlow Canvas Controls */ From 8bd27653c6d7c9122479200b09bf01a2c56619dc Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Fri, 2 Oct 2026 00:14:14 +0530 Subject: [PATCH 04/24] feat(mcp): add dynamic prompts & resource subscriptions - add workspace prompt engine with YAML frontmatter persistence\n- register MCP prompts/list, prompts/get, resources/subscribe\n- add MCP prompt manager UI, editor, and live execution tester\n- normalize design tokens and icon steps across MCP pages --- electron/mcp/McpLifecycle.cjs | 27 + electron/mcp/McpPrompts.cjs | 246 ++++++++- electron/mcp/McpServer.cjs | 62 ++- electron/preload.cjs | 4 + electron/tools/EnterpriseToolSuite.cjs | 20 + src/components/MCPToolsPage.jsx | 482 +++++++++++++++++- src/components/graph/EntityInspector.jsx | 4 +- src/services/electron/mcpService.js | 36 ++ src/styles/MCPSettings.css | 143 ++++++ .../mcpCustomPromptsAndSubscriptions.test.js | 183 +++++++ 10 files changed, 1183 insertions(+), 24 deletions(-) create mode 100644 tests/mcp/mcpCustomPromptsAndSubscriptions.test.js diff --git a/electron/mcp/McpLifecycle.cjs b/electron/mcp/McpLifecycle.cjs index 0e74bb8..225e031 100644 --- a/electron/mcp/McpLifecycle.cjs +++ b/electron/mcp/McpLifecycle.cjs @@ -6,6 +6,7 @@ const { McpConfig } = require('./McpConfig.cjs'); const { McpSessionManager } = require('./McpSessionManager.cjs'); const { McpServer } = require('./McpServer.cjs'); +const { mcpPromptsRegistry } = require('./McpPrompts.cjs'); class McpLifecycle { constructor() { @@ -202,6 +203,12 @@ class McpLifecycle { }; } + broadcastResourceUpdated(uri) { + if (this.server && typeof this.server.broadcastResourceUpdated === 'function') { + this.server.broadcastResourceUpdated(uri); + } + } + registerIpcHandlers(ipcMain) { ipcMain.handle('mcp:get-status', async () => { return this.getStatus(); @@ -233,6 +240,26 @@ class McpLifecycle { stats: this.sessionManager.getStats() }; }); + + ipcMain.handle('mcp:list-prompts', async () => { + const root = typeof this.getWorkspaceRoot === 'function' ? this.getWorkspaceRoot() : null; + return mcpPromptsRegistry.listPrompts(root); + }); + + ipcMain.handle('mcp:get-prompt', async (_event, name, args) => { + const root = typeof this.getWorkspaceRoot === 'function' ? this.getWorkspaceRoot() : null; + return mcpPromptsRegistry.getPrompt(name, args || {}, root); + }); + + ipcMain.handle('mcp:save-prompt', async (_event, promptData) => { + const root = typeof this.getWorkspaceRoot === 'function' ? this.getWorkspaceRoot() : null; + return mcpPromptsRegistry.saveCustomPrompt(root, promptData); + }); + + ipcMain.handle('mcp:delete-prompt', async (_event, name) => { + const root = typeof this.getWorkspaceRoot === 'function' ? this.getWorkspaceRoot() : null; + return mcpPromptsRegistry.deleteCustomPrompt(root, name); + }); } async shutdown() { diff --git a/electron/mcp/McpPrompts.cjs b/electron/mcp/McpPrompts.cjs index 3dfaa56..6c0739d 100644 --- a/electron/mcp/McpPrompts.cjs +++ b/electron/mcp/McpPrompts.cjs @@ -164,34 +164,256 @@ Provide an executive daily briefing: } ]; +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); + class McpPromptsRegistry { constructor() { - this.prompts = new Map(); + this.builtinPrompts = new Map(); for (const p of ENTERPRISE_PROMPTS) { - this.prompts.set(p.name, p); + this.builtinPrompts.set(p.name, { ...p, source: 'builtin' }); + } + } + + /** + * Scan and load custom markdown prompts from /.notes-app/prompts/*.md + * @param {string} workspaceRoot + * @returns {Map} + */ + loadWorkspacePrompts(workspaceRoot) { + const promptsMap = new Map(); + if (!workspaceRoot || typeof workspaceRoot !== 'string') { + return promptsMap; + } + + const promptsDir = path.join(workspaceRoot, '.notes-app', 'prompts'); + if (!fs.existsSync(promptsDir)) { + return promptsMap; + } + + try { + const files = fs.readdirSync(promptsDir); + for (const file of files) { + if (!file.endsWith('.md')) continue; + const filePath = path.join(promptsDir, file); + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = this.parsePromptFile(raw, file); + if (parsed) { + parsed.filePath = filePath; + parsed.fileName = file; + parsed.source = 'workspace'; + promptsMap.set(parsed.name, parsed); + } + } catch (err) { + console.warn(`[McpPrompts] Failed to parse custom prompt file "${file}":`, err.message); + } + } + } catch (err) { + console.warn(`[McpPrompts] Error reading prompts directory "${promptsDir}":`, err.message); + } + + return promptsMap; + } + + /** + * Parse a Markdown prompt file with YAML frontmatter. + */ + parsePromptFile(rawContent, fallbackName = 'custom_prompt') { + const fmMatch = rawContent.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + let frontmatter = {}; + let templateBody = rawContent.trim(); + + if (fmMatch) { + try { + frontmatter = yaml.load(fmMatch[1]) || {}; + templateBody = (fmMatch[2] || '').trim(); + } catch (err) { + console.warn('[McpPrompts] YAML parse error:', err.message); + } } + + const name = String(frontmatter.name || path.basename(fallbackName, '.md')) + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '_'); + + const description = String(frontmatter.description || 'Workspace custom prompt workflow.').trim(); + const argsDef = Array.isArray(frontmatter.arguments) + ? frontmatter.arguments.map(arg => ({ + name: String(arg.name || '').trim(), + description: String(arg.description || '').trim(), + required: Boolean(arg.required) + })).filter(a => Boolean(a.name)) + : []; + + return { + name, + description, + arguments: argsDef, + template: templateBody, + generateMessages: (providedArgs = {}) => { + let text = templateBody; + // Interpolate {{argName}} or {{argName || "fallback"}} + text = text.replace(/\{\{\s*([a-zA-Z0-9_-]+)(?:\s*\|\|\s*["']([^"']*)["'])?\s*\}\}/g, (_match, varName, fallbackVal) => { + if (providedArgs[varName] !== undefined && providedArgs[varName] !== null && String(providedArgs[varName]).trim() !== '') { + return String(providedArgs[varName]); + } + return fallbackVal !== undefined ? fallbackVal : ''; + }); + + return [ + { + role: 'user', + content: { + type: 'text', + text + } + } + ]; + } + }; } - listPrompts() { - return Array.from(this.prompts.values()).map(p => ({ - name: p.name, - description: p.description, - arguments: p.arguments || [] - })); + /** + * List all prompts (Built-in + Workspace Custom). + * @param {string} [workspaceRoot] + */ + listPrompts(workspaceRoot = null) { + const results = []; + + // Built-ins + for (const p of this.builtinPrompts.values()) { + results.push({ + name: p.name, + description: p.description, + arguments: p.arguments || [], + source: 'builtin' + }); + } + + // Workspace custom + if (workspaceRoot) { + const wsPrompts = this.loadWorkspacePrompts(workspaceRoot); + for (const p of wsPrompts.values()) { + // If name collides with built-in, custom overrides or appears with source + const existingIdx = results.findIndex(r => r.name === p.name); + const item = { + name: p.name, + description: p.description, + arguments: p.arguments || [], + template: p.template || '', + source: 'workspace', + fileName: p.fileName + }; + if (existingIdx !== -1) { + results[existingIdx] = item; + } else { + results.push(item); + } + } + } + + return results; } - getPrompt(name, args = {}) { - const prompt = this.prompts.get(name); + /** + * Get and execute prompt message generator. + * @param {string} name + * @param {object} args + * @param {string} [workspaceRoot] + */ + getPrompt(name, args = {}, workspaceRoot = null) { + if (!name) throw new Error('Prompt name is required.'); + const cleanName = String(name).trim().toLowerCase(); + + // Check workspace prompts first + if (workspaceRoot) { + const wsPrompts = this.loadWorkspacePrompts(workspaceRoot); + if (wsPrompts.has(cleanName)) { + const prompt = wsPrompts.get(cleanName); + return { + description: prompt.description, + messages: prompt.generateMessages(args || {}) + }; + } + } + + // Fall back to built-ins + const prompt = this.builtinPrompts.get(cleanName); if (!prompt) { throw new Error(`Prompt "${name}" not found in MCP Prompts Registry.`); } - const messages = prompt.generateMessages(args || {}); return { description: prompt.description, - messages + messages: prompt.generateMessages(args || {}) + }; + } + + /** + * Save a workspace custom prompt to .notes-app/prompts/.md + * @param {string} workspaceRoot + * @param {object} promptData + */ + saveCustomPrompt(workspaceRoot, promptData = {}) { + if (!workspaceRoot) throw new Error('Workspace root is required to save custom prompt.'); + if (!promptData.name) throw new Error('Prompt name is required.'); + + const cleanName = String(promptData.name) + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '_'); + + const promptsDir = path.join(workspaceRoot, '.notes-app', 'prompts'); + if (!fs.existsSync(promptsDir)) { + fs.mkdirSync(promptsDir, { recursive: true }); + } + + const frontmatter = { + name: cleanName, + description: String(promptData.description || '').trim(), + arguments: Array.isArray(promptData.arguments) ? promptData.arguments : [] + }; + + const yml = yaml.dump(frontmatter).trim(); + const templateBody = String(promptData.template || '').trim(); + const fileContent = `---\n${yml}\n---\n\n${templateBody}\n`; + + const targetPath = path.join(promptsDir, `${cleanName}.md`); + const tmpPath = path.join(promptsDir, `.${cleanName}.${Date.now()}.tmp`); + + fs.writeFileSync(tmpPath, fileContent, 'utf8'); + fs.renameSync(tmpPath, targetPath); + + return { + success: true, + name: cleanName, + fileName: `${cleanName}.md`, + filePath: targetPath }; } + + /** + * Delete a workspace custom prompt from .notes-app/prompts/.md + * @param {string} workspaceRoot + * @param {string} name + */ + deleteCustomPrompt(workspaceRoot, name) { + if (!workspaceRoot) throw new Error('Workspace root is required.'); + if (!name) throw new Error('Prompt name is required.'); + + const cleanName = String(name).trim().toLowerCase().replace(/[^a-z0-9_-]/g, '_'); + const targetPath = path.join(workspaceRoot, '.notes-app', 'prompts', `${cleanName}.md`); + + if (fs.existsSync(targetPath)) { + fs.unlinkSync(targetPath); + return { success: true, name: cleanName, deleted: true }; + } + + return { success: false, name: cleanName, deleted: false, error: 'File not found' }; + } } const mcpPromptsRegistry = new McpPromptsRegistry(); diff --git a/electron/mcp/McpServer.cjs b/electron/mcp/McpServer.cjs index aad813d..ccaa4aa 100644 --- a/electron/mcp/McpServer.cjs +++ b/electron/mcp/McpServer.cjs @@ -17,7 +17,9 @@ const { GetPromptRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, - ReadResourceRequestSchema + ReadResourceRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema } = require('@modelcontextprotocol/sdk/types.js'); const { applicationToolRegistry } = require('../tools/ApplicationToolRegistry.cjs'); const { mcpPromptsRegistry } = require('./McpPrompts.cjs'); @@ -55,6 +57,7 @@ class McpServer { this.httpServer = null; this.transports = new Map(); // sessionId -> { transport, server } (legacy SSE) this.streamableTransports = new Map(); // sessionId -> { transport, server } (Streamable HTTP) + this.resourceSubscriptions = new Map(); // sessionId -> Set this.isRunning = false; this.lastError = null; this.errorCode = null; @@ -78,6 +81,30 @@ class McpServer { return authHeader.trim() === `Bearer ${this.bearerToken.trim()}`; } + broadcastResourceUpdated(uri) { + if (!uri) return; + const notifySession = (sessionId, session) => { + const subs = this.resourceSubscriptions.get(sessionId); + if (subs && (subs.has(uri) || subs.has('*') || (uri.startsWith('notely://notes/') && (subs.has('notely://notes/*') || subs.has('notely://notes/{path}'))))) { + try { + if (session?.server && typeof session.server.notification === 'function') { + session.server.notification({ + method: 'notifications/resources/updated', + params: { uri } + }); + } + } catch { /* session closed */ } + } + }; + + for (const [sessionId, session] of this.streamableTransports.entries()) { + notifySession(sessionId, session); + } + for (const [sessionId, session] of this.transports.entries()) { + notifySession(sessionId, session); + } + } + _createServerInstance(sessionIdOrFn) { const getActiveSessionId = () => { if (typeof sessionIdOrFn === 'function') { @@ -88,7 +115,7 @@ class McpServer { const server = new Server( { name: 'notely', version: appVersion }, - { capabilities: { tools: {}, prompts: {}, resources: {} } } + { capabilities: { tools: {}, prompts: {}, resources: { subscribe: true, listChanged: true } } } ); server.setRequestHandler(ListToolsRequestSchema, async () => { @@ -100,12 +127,33 @@ class McpServer { }); server.setRequestHandler(ListPromptsRequestSchema, async () => { - return { prompts: mcpPromptsRegistry.listPrompts() }; + const activeWorkspaceRoot = this.getWorkspaceRoot ? this.getWorkspaceRoot() : null; + return { prompts: mcpPromptsRegistry.listPrompts(activeWorkspaceRoot) }; }); server.setRequestHandler(GetPromptRequestSchema, async (request) => { const { name, arguments: args } = request.params; - return mcpPromptsRegistry.getPrompt(name, args || {}); + const activeWorkspaceRoot = this.getWorkspaceRoot ? this.getWorkspaceRoot() : null; + return mcpPromptsRegistry.getPrompt(name, args || {}, activeWorkspaceRoot); + }); + + server.setRequestHandler(SubscribeRequestSchema, async (request) => { + const { uri } = request.params; + const sessionId = getActiveSessionId(); + if (!this.resourceSubscriptions.has(sessionId)) { + this.resourceSubscriptions.set(sessionId, new Set()); + } + this.resourceSubscriptions.get(sessionId).add(uri); + return {}; + }); + + server.setRequestHandler(UnsubscribeRequestSchema, async (request) => { + const { uri } = request.params; + const sessionId = getActiveSessionId(); + if (this.resourceSubscriptions.has(sessionId)) { + this.resourceSubscriptions.get(sessionId).delete(uri); + } + return {}; }); server.setRequestHandler(ListResourcesRequestSchema, async () => { @@ -327,13 +375,14 @@ class McpServer { const advertisedSchemas = this.allowWriteTools ? allSchemas : allSchemas.filter(t => !t.isWrite); + const activeWorkspaceRoot = this.getWorkspaceRoot ? this.getWorkspaceRoot() : null; res.end(JSON.stringify({ status: 'ok', server: 'notely-mcp', version: '0.1.41', port: this.port, toolsCount: advertisedSchemas.length, - promptsCount: mcpPromptsRegistry.listPrompts().length, + promptsCount: mcpPromptsRegistry.listPrompts(activeWorkspaceRoot).length, resourcesCount: 2, activeSessions: (this.sessionManager ? this.sessionManager.getActiveSessions().length : 0) + this.streamableTransports.size })); @@ -363,8 +412,9 @@ class McpServer { res.end(JSON.stringify({ error: 'Unauthorized: invalid or missing Bearer token' })); return; } + const activeWorkspaceRoot = this.getWorkspaceRoot ? this.getWorkspaceRoot() : null; res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ prompts: mcpPromptsRegistry.listPrompts() })); + res.end(JSON.stringify({ prompts: mcpPromptsRegistry.listPrompts(activeWorkspaceRoot) })); return; } diff --git a/electron/preload.cjs b/electron/preload.cjs index 7522e52..dec2e4b 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -72,6 +72,10 @@ contextBridge.exposeInMainWorld("notesApi", { mcpStop: () => ipcRenderer.invoke("mcp:stop"), mcpRestart: () => ipcRenderer.invoke("mcp:restart"), mcpGetSessions: () => ipcRenderer.invoke("mcp:get-sessions"), + mcpListPrompts: () => ipcRenderer.invoke("mcp:list-prompts"), + mcpGetPrompt: (name, args) => ipcRenderer.invoke("mcp:get-prompt", name, args), + mcpSavePrompt: (promptData) => ipcRenderer.invoke("mcp:save-prompt", promptData), + mcpDeletePrompt: (name) => ipcRenderer.invoke("mcp:delete-prompt", name), onMcpStatusChanged: (callback) => { if (typeof callback !== "function") return () => {}; const listener = (_event, payload) => callback(payload); diff --git a/electron/tools/EnterpriseToolSuite.cjs b/electron/tools/EnterpriseToolSuite.cjs index ccec6c8..c36de25 100644 --- a/electron/tools/EnterpriseToolSuite.cjs +++ b/electron/tools/EnterpriseToolSuite.cjs @@ -40,6 +40,22 @@ class EnterpriseToolSuite { get workspaceService() { return this.registry.workspaceService; } get webService() { return this.registry.webService; } + _notifyResourceChange(workspaceRoot, noteRelPath = null) { + try { + const { mcpLifecycle } = require('../mcp/McpLifecycle.cjs'); + if (mcpLifecycle && typeof mcpLifecycle.broadcastResourceUpdated === 'function') { + if (noteRelPath) { + mcpLifecycle.broadcastResourceUpdated(`notely://notes/${encodeURIComponent(noteRelPath)}`); + mcpLifecycle.broadcastResourceUpdated(`notely://notes/${noteRelPath}`); + } + mcpLifecycle.broadcastResourceUpdated('notely://workspace/tree'); + mcpLifecycle.broadcastResourceUpdated('notely://workspace/stats'); + } + } catch { + // Non-blocking notification + } + } + // ─── 1. SEARCH ───────────────────────────────────────────────────────────── async search(args = {}, context = {}) { @@ -693,6 +709,7 @@ class EnterpriseToolSuite { } if (!dryRun) { await safeDeleteFile(targetAbsPath); + this._notifyResourceChange(workspaceRoot, toWorkspaceRelative(targetAbsPath, workspaceRoot)); } return { path: toWorkspaceRelative(targetAbsPath, workspaceRoot), @@ -716,6 +733,8 @@ class EnterpriseToolSuite { const destDir = path.dirname(newAbsPath); if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true }); fs.renameSync(targetAbsPath, newAbsPath); + this._notifyResourceChange(workspaceRoot, toWorkspaceRelative(newAbsPath, workspaceRoot)); + this._notifyResourceChange(workspaceRoot, toWorkspaceRelative(targetAbsPath, workspaceRoot)); } return { previousPath: toWorkspaceRelative(targetAbsPath, workspaceRoot), @@ -873,6 +892,7 @@ class EnterpriseToolSuite { if (!dryRun) { atomicWriteFile(targetAbsPath, updatedContent, options.backup); + this._notifyResourceChange(workspaceRoot, toWorkspaceRelative(targetAbsPath, workspaceRoot)); } const relPath = toWorkspaceRelative(targetAbsPath, workspaceRoot); diff --git a/src/components/MCPToolsPage.jsx b/src/components/MCPToolsPage.jsx index e89f1f4..a09e326 100644 --- a/src/components/MCPToolsPage.jsx +++ b/src/components/MCPToolsPage.jsx @@ -25,10 +25,17 @@ import { BookOpen, ShieldAlert, ShieldCheck, - Ban + Ban, + MessageSquare, + Plus, + Trash2, + Edit3, + ExternalLink, + X } from "lucide-react"; import { listTools, executeTool } from "../services/electron/aiService"; import { mcpGetStatus, mcpGetConfig, onMcpStatusChanged } from "../services/electronService"; +import { mcpListPrompts, mcpGetPrompt, mcpSavePrompt, mcpDeletePrompt } from "../services/electron/mcpService"; import "../styles/KnowledgeGraph.css"; import "../styles/AISettings.css"; import "../styles/MCPSettings.css"; @@ -126,13 +133,26 @@ export function MCPToolsPage({ onBack, onNotify, onOpenSettings }) { const [copiedOutput, setCopiedOutput] = useState(false); const [configTarget, setConfigTarget] = useState("antigravity"); + // Prompts Manager State + const [activeTab, setActiveTab] = useState("tools"); // "tools" | "prompts" + const [prompts, setPrompts] = useState([]); + const [promptFilterQuery, setPromptFilterQuery] = useState(""); + const [expandedPrompt, setExpandedPrompt] = useState(null); + const [promptEditorOpen, setPromptEditorOpen] = useState(false); + const [promptFormData, setPromptFormData] = useState({ name: "", description: "", arguments: [], template: "" }); + const [promptTestArgs, setPromptTestArgs] = useState({}); + const [promptTestResult, setPromptTestResult] = useState(null); + const [testingPromptName, setTestingPromptName] = useState(null); + const [savingPrompt, setSavingPrompt] = useState(false); + const loadData = useCallback(async () => { try { setLoading(true); - const [res, st, cfg] = await Promise.all([ + const [res, st, cfg, promptList] = await Promise.all([ listTools().catch(() => null), mcpGetStatus().catch(() => null), - mcpGetConfig().catch(() => null) + mcpGetConfig().catch(() => null), + mcpListPrompts().catch(() => []) ]); const toolArray = Array.isArray(res) ? res @@ -141,6 +161,7 @@ export function MCPToolsPage({ onBack, onNotify, onOpenSettings }) { : []; setTools(toolArray); setStatus(st); + setPrompts(Array.isArray(promptList) ? promptList : []); if (cfg) { setAllowWrite(cfg.allowWriteTools !== undefined ? Boolean(cfg.allowWriteTools) : true); } @@ -181,6 +202,18 @@ export function MCPToolsPage({ onBack, onNotify, onOpenSettings }) { }); }, [tools, filterQuery, selectedCategory]); + const filteredPrompts = useMemo(() => { + return prompts.filter((p) => { + const q = promptFilterQuery.toLowerCase().trim(); + if (!q) return true; + return ( + p.name.toLowerCase().includes(q) || + (p.description && p.description.toLowerCase().includes(q)) || + (p.source && p.source.toLowerCase().includes(q)) + ); + }); + }, [prompts, promptFilterQuery]); + const handleRunTool = async (tool) => { try { setRunningTest(true); @@ -242,6 +275,75 @@ export function MCPToolsPage({ onBack, onNotify, onOpenSettings }) { } }; + const handleOpenNewPrompt = () => { + setPromptFormData({ + name: "", + description: "", + arguments: [{ name: "topic", description: "Target topic or note name", required: true }], + template: "Please inspect all notes related to \"{{topic}}\" using the search and read_note tools.\nProvide a structured summary with key takeaways and open action items." + }); + setPromptEditorOpen(true); + }; + + const handleEditPrompt = (prompt) => { + setPromptFormData({ + name: prompt.name, + description: prompt.description || "", + arguments: Array.isArray(prompt.arguments) ? JSON.parse(JSON.stringify(prompt.arguments)) : [], + template: prompt.template || "" + }); + setPromptEditorOpen(true); + }; + + const handleSavePrompt = async () => { + if (!promptFormData.name.trim()) { + onNotify?.("Prompt name is required.", "error"); + return; + } + try { + setSavingPrompt(true); + const res = await mcpSavePrompt(promptFormData); + if (res?.success) { + onNotify?.(`Custom prompt "${res.name}" saved to .notes-app/prompts/`, "success"); + setPromptEditorOpen(false); + await loadData(); + } + } catch (err) { + onNotify?.(`Failed to save prompt: ${err.message}`, "error"); + } finally { + setSavingPrompt(false); + } + }; + + const handleDeletePrompt = async (prompt) => { + if (!window.confirm(`Are you sure you want to delete the prompt "${prompt.name}"?`)) { + return; + } + try { + const res = await mcpDeletePrompt(prompt.name); + if (res?.success) { + onNotify?.(`Prompt "${prompt.name}" deleted.`, "success"); + await loadData(); + } + } catch (err) { + onNotify?.(`Failed to delete prompt: ${err.message}`, "error"); + } + }; + + const handleTestPrompt = async (prompt) => { + try { + setTestingPromptName(prompt.name); + const args = promptTestArgs[prompt.name] || {}; + const res = await mcpGetPrompt(prompt.name, args); + setPromptTestResult({ promptName: prompt.name, result: res }); + onNotify?.(`Generated messages for prompt "${prompt.name}"`, "success"); + } catch (err) { + onNotify?.(`Prompt evaluation failed: ${err.message}`, "error"); + } finally { + setTestingPromptName(null); + } + }; + const copyToolName = (name) => { navigator.clipboard.writeText(name); setCopiedName(name); @@ -463,7 +565,26 @@ export function MCPToolsPage({ onBack, onNotify, onOpenSettings }) { - {/* Two Column Layout (Left Sidebar) */} + {/* Navigation Tabs */} +
+ + +
+ + {/* Tab 1: Tools Layout */} + {activeTab === "tools" && (
{/* Left Sidebar Area */}
@@ -945,6 +1066,359 @@ export function MCPToolsPage({ onBack, onNotify, onOpenSettings }) { )}
+ )} + + {/* Tab 2: Prompts Manager */} + {activeTab === "prompts" && ( +
+ {/* Prompts Top Controls */} +
+
+ + setPromptFilterQuery(e.target.value)} + style={{ paddingLeft: "32px", width: "100%", fontSize: "13px" }} + /> +
+ + +
+ + {/* Prompts Grid / List */} + {filteredPrompts.length === 0 ? ( +
+ +

No Prompts Found

+

+ {promptFilterQuery ? "No prompt templates match your search query." : "Author your first custom workflow in .notes-app/prompts/"} +

+
+ ) : ( +
+ {filteredPrompts.map((prompt) => { + const isExpanded = expandedPrompt === prompt.name; + const isBuiltin = prompt.source === "builtin"; + const argsList = Array.isArray(prompt.arguments) ? prompt.arguments : []; + + return ( +
+
+
+ + {prompt.name} + + + {isBuiltin ? "Built-in Enterprise" : "Workspace Custom"} + + {prompt.fileName && ( + + .notes-app/prompts/{prompt.fileName} + + )} +
+ +
+ + {!isBuiltin && ( + + )} + {!isBuiltin && ( + + )} +
+
+ +

+ {prompt.description} +

+ + {/* Arguments Pills */} +
+ + Arguments: + + {argsList.length === 0 ? ( + None required + ) : ( + argsList.map((arg) => ( + + {arg.name}{arg.required ? " *" : ""} + + )) + )} +
+ + {/* Interactive Prompt Tester Block */} + {isExpanded && ( +
+

+ Test Prompt Execution (Generate Messages) +

+ + {argsList.length > 0 && ( +
+ {argsList.map((arg) => ( +
+ + { + const current = promptTestArgs[prompt.name] || {}; + setPromptTestArgs({ + ...promptTestArgs, + [prompt.name]: { ...current, [arg.name]: e.target.value } + }); + }} + style={{ fontSize: "12px", padding: "4px 8px", height: "28px" }} + /> +
+ ))} +
+ )} + +
+ +
+ + {promptTestResult && promptTestResult.promptName === prompt.name && ( +
+
+ MCP Prompt Messages Output: + +
+
+                                {JSON.stringify(promptTestResult.result, null, 2)}
+                              
+
+ )} +
+ )} +
+ ); + })} +
+ )} +
+ )} + + {/* Prompt Editor Modal */} + {promptEditorOpen && ( +
+
+
+
+ +

+ {promptFormData.name ? `Edit Prompt: ${promptFormData.name}` : "Create Custom Workspace Prompt"} +

+
+ +
+ +
+
+ + setPromptFormData({ ...promptFormData, name: e.target.value })} + style={{ fontSize: "13px" }} + /> + + Stored on disk as .notes-app/prompts/{promptFormData.name || "name"}.md + +
+ +
+ + setPromptFormData({ ...promptFormData, description: e.target.value })} + style={{ fontSize: "13px" }} + /> +
+ + {/* Arguments List */} +
+
+ + +
+ + {(!promptFormData.arguments || promptFormData.arguments.length === 0) ? ( + No arguments defined. Prompt will run without parameters. + ) : ( +
+ {promptFormData.arguments.map((arg, idx) => ( +
+ { + const next = [...promptFormData.arguments]; + next[idx].name = e.target.value; + setPromptFormData({ ...promptFormData, arguments: next }); + }} + style={{ flex: 1, fontSize: "12px", height: "28px" }} + /> + { + const next = [...promptFormData.arguments]; + next[idx].description = e.target.value; + setPromptFormData({ ...promptFormData, arguments: next }); + }} + style={{ flex: 2, fontSize: "12px", height: "28px" }} + /> + + +
+ ))} +
+ )} +
+ + {/* Template Body */} +
+ + +
- +
` }, { @@ -95,23 +95,23 @@ export const formStencils = [ category: "forms", desc: "Card number, expiration and security code fields", icon: CreditCard, - content: `
+ content: `

Payment Method

- +
- +
- +
- +
` }, { @@ -120,7 +120,7 @@ export const formStencils = [ category: "forms", desc: "Switch toggle rows for feature settings", icon: Sliders, - content: `
+ content: `
Dark Theme
diff --git a/src/components/wireframe/stencils/frames.js b/src/components/wireframe/stencils/frames.js index 8ab019b..cffb4be 100644 --- a/src/components/wireframe/stencils/frames.js +++ b/src/components/wireframe/stencils/frames.js @@ -7,7 +7,7 @@ export const frameStencils = [ category: "frames", desc: "Clean desktop artboard frame with header boundary", icon: Monitor, - content: `
+ content: `
Desktop Artboard Boundary (1200px)
` }, @@ -17,8 +17,8 @@ export const frameStencils = [ category: "frames", desc: "375x720px mobile viewport with speaker notch", icon: Smartphone, - content: `
-
+ content: `
+
Mobile Viewport (375px)
@@ -30,15 +30,15 @@ export const frameStencils = [ category: "frames", desc: "Sidebar navigation + topbar + main canvas container", icon: Columns, - content: `
+ content: `
- Notely App + Notely App
-
Dashboard
-
Projects
-
Team
-
Settings
+
Dashboard
+
Projects
+
Team
+
Settings
@@ -46,7 +46,7 @@ export const frameStencils = [
-
Drop dashboard widgets and cards here
+
Drop dashboard widgets and cards here
` @@ -57,7 +57,7 @@ export const frameStencils = [ category: "frames", desc: "768px tablet layout with camera indicator", icon: Tablet, - content: `
+ content: `
Tablet Viewport (768px)
` @@ -68,7 +68,7 @@ export const frameStencils = [ category: "frames", desc: "Two-column hero brand showcase and signup form", icon: Columns, - content: `
+ content: `
Notely Platform

Next generation technical note-taking

@@ -77,9 +77,9 @@ export const frameStencils = [

Create your account

Start building notes and wireframes locally.

- - - + + +
` }, @@ -89,21 +89,21 @@ export const frameStencils = [ category: "frames", desc: "Sidebar category navigation + form content pane", icon: Sliders, - content: `
+ content: `
Settings
-
General
-
Team
-
Security
+
General
+
Team
+
Security

Profile Details

Manage your public workspace presence.

- +
- +
` } diff --git a/src/components/wireframe/stencils/headers.js b/src/components/wireframe/stencils/headers.js index 7f546b0..f59f8d1 100644 --- a/src/components/wireframe/stencils/headers.js +++ b/src/components/wireframe/stencils/headers.js @@ -7,10 +7,10 @@ export const headerStencils = [ category: "headers", desc: "Logo, pill links, search & CTA buttons", icon: Box, - content: `
+ content: `
- Brand + Brand
- - + +
` }, @@ -31,10 +31,10 @@ export const headerStencils = [ category: "headers", desc: "Pill segmented navigation bar", icon: Layout, - content: `
- - - + content: `
+ + +
` }, { @@ -54,8 +54,8 @@ export const headerStencils = [
- - + +
` }, @@ -65,7 +65,7 @@ export const headerStencils = [ category: "headers", desc: "Hierarchical trail with active note indicator", icon: Compass, - content: `