diff --git a/README.md b/README.md index 847a73ba..458b8ce3 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Notely is built with Electron + React and is designed for project notes, meeting - Preview Mermaid diagrams and rendered Markdown content. - Create and edit structured technical diagrams with **Draw.io integration** directly from markdown previews, supporting drag-and-drop import for `.drawio` and `.drawio.xml` files, image export, and offline drawing. - Visualize the workspace as an interactive note graph. -- Use built-in AI features powered by Vercel AI SDK (Gemini, Groq, OpenAI) with a local-first **Embeddings Engine** (offline `BGE-small-en-v1.5` ONNX model), recursive SQLite **Knowledge Graph**, global workspace chat, referred source chips, and a diagnostics panel with tool execution traces. +- Use built-in AI features powered by Vercel AI SDK (Gemini, Groq, OpenAI) with a 3-Brain Architecture (`WorkspaceBrain`, `ReasoningBrain`, `ActionBrain`), autonomous multi-step Planner, semantic domain tools, local-first Embeddings Engine (`BGE-small-en-v1.5` ONNX model), recursive SQLite Knowledge Graph, strict read-only note immutability safeguards, ReAct self-correction engine (`SelfCorrectionEngine`), and an automated diagnostic evaluation harness (`AgentHarness`). - Aggregate tasks across notes with **Open Tasks** and **All Tasks** panels. - Open Tasks focuses on unchecked items. - All Tasks includes open + closed items with filtering and note grouping. diff --git a/ai/README.md b/ai/README.md index 7abe23aa..d48dc6b4 100644 --- a/ai/README.md +++ b/ai/README.md @@ -1,113 +1,130 @@ -# Notely AI Subsystem Architecture +# Notely AI Platform — Comprehensive AI & Agent Subsystem Architecture -This directory contains the codebase for Notely's local-first, modular AI platform. Markdown remains the single source of truth, parsed and indexed into offline-first SQLite databases. +This directory contains the codebase for Notely's local-first, modular AI platform. Markdown notes remain the single source of truth, parsed and indexed into offline-first SQLite databases (`ai-embeddings.db`, `ai-graph.db`, `ai-memory.db`). --- -## Subsystem Architecture Diagram +## AI Platform Overview & Design Philosophy + +Notely's AI is engineered as an **intelligent knowledge companion** rather than a generic LLM chatbot wrapper. + +### Core Guiding Principles: +1. **Human-like & Natural**: Speaks like a knowledgeable pair programmer and workspace teammate. Never exposes internal technical mechanics (`"search_notes"`, `"vector similarity"`, `"knowledge graph nodes"`). +2. **Context-Aware & Grounded**: Proactively retrieves workspace facts before generating answers. All claims are grounded in verified note file links (`[file.md](file:///path)`). +3. **Multi-Tool Planning & Orchestration**: Dynamically executes parallel retrievals, chains tool outputs, and evaluates evidence confidence before answer synthesis. +4. **Strict Note Immutability**: Existing notes are **100% read-only**. AI tools cannot update, edit, move, rename, or delete existing user notes under any circumstances. +5. **Local-First & Provider Agnostic**: Leverages local ONNX embeddings (`BGE-small-en-v1.5`) and background worker processes (`utilityProcess`), while supporting Gemini, Groq, OpenAI, and Local GGUF models. + +--- + +## Complete 3-Brain Subsystem Architecture ```mermaid graph TD - %% Frontend Layer - subgraph Frontend ["React UI Components"] - AIChatPanel["AIChatPanel.jsx (Sidebar Chat)"] - AIPalette["AIPalette.jsx (Editor Inline AI)"] - AIHealthPage["AIHealthPage.jsx (Diagnostics & Traces)"] - EmbeddingsPage["EmbeddingsPage.jsx (Status & Queue)"] - AIPersonasManager["AIPersonasManager.jsx (Persona Registry)"] + %% Frontend & IPC + subgraph Client ["UI & IPC Bridge"] + UI["AIChatPanel / AIPalette"] + Diagnostics["AIHealthPage.jsx (Diagnostics & Traces)"] + IPC["Electron IPC Handlers (aiHandlers.cjs)"] end - %% IPC Bridge - subgraph IPC ["Electron IPC Interface"] - Preload["preload.cjs (IPC Exposure)"] - AIHandlers["aiHandlers.cjs (IPC Main Listeners)"] + %% 3-Brain Core + subgraph Core ["3-Brain Architectural Triad & Orchestration"] + Agent["Agent.js (Central Orchestrator)"] + WorkspaceBrain["WorkspaceBrain.js (Factual Retrieval)"] + ReasoningBrain["ReasoningBrain.js (Pure Synthesis)"] + ActionBrain["ActionBrain.js (Permission Gatekeeper)"] + ContextOrchestrator["ContextOrchestrator.js (Multi-Tool Engine)"] + Planner["Planner.js (Intent Classifier)"] + SelfCorrectionEngine["SelfCorrectionEngine.js (ReAct Validator)"] end - %% Backend AI Core - subgraph Backend ["AI Core Backend Subsystem"] - AIService["AIService.js (Central Coordinator)"] - Agent["Agent.js (Orchestrator)"] - QueryExecutor["QueryExecutor.js (Tool Runner Loop)"] - ContextEngine["ContextEngine.js (Prompt Assembly)"] - EmbeddingService["EmbeddingService.js (Vector Cache)"] - GraphRetriever["GraphRetriever.js (Recursive CTE Traversal)"] - SemanticRetriever["SemanticRetriever.js (Cosine Search)"] + %% Retrieval & Tools + subgraph Retrieval ["Context & Retrieval Engine"] + ContextEngine["ContextEngine.js (8-Layer Pipeline)"] + HybridRetriever["HybridRetriever.js (Reciprocal Rank Fusion)"] + SemanticRetriever["SemanticRetriever.js (Vector Cosine Search)"] + GraphRetriever["GraphRetriever.js (Recursive CTE Graph Walk)"] + SemanticTools["SemanticTools.js (Domain Tool Suite)"] end - %% Storage Layer - subgraph Storage ["SQLite Local DBs (journal_mode = WAL)"] - EmbedDB["ai-embeddings.db (Note Chunk Vectors)"] - GraphDB["ai-graph.db (Entity Relations)"] + %% Storage + subgraph Storage ["SQLite Storage (WAL Mode)"] + EmbedDB["ai-embeddings.db (Chunk Vectors)"] + GraphDB["ai-graph.db (Entity Relations & Evidence)"] MemoryDB["ai-memory.db (Chats & Traces)"] end - %% Pipelines - subgraph Background ["Async Indexing Pipeline"] - IndexQueue["IndexQueue.js (Priority Jobs)"] - IndexWorker["IndexWorker.js (Non-blocking Thread)"] - ONNXEmbedder["ONNXEmbedder.js (Local BGE-small weights)"] - end - - %% Frontend to IPC connections - AIChatPanel -->|IPC calls| Preload - AIHealthPage -->|IPC calls| Preload - AIPersonasManager -->|IPC calls| Preload - Preload -->|IPC events| AIHandlers - - %% IPC to Coordinator - AIHandlers -->|Delegates to| AIService - AIService -->|Orchestrates| Agent - - %% Agent Subsystem Routing - Agent --> QueryExecutor - Agent --> ContextEngine - - %% Context Engine Retrievals - ContextEngine --> SemanticRetriever - ContextEngine --> GraphRetriever - - %% DB bindings + %% Data Flow + UI & Diagnostics --> IPC --> Agent + Agent --> ContextOrchestrator & WorkspaceBrain & ReasoningBrain & ActionBrain & Planner + ContextOrchestrator --> ParallelRetrieval["Promise.allSettled Parallel Tools"] + ParallelRetrieval --> SemanticTools & HybridRetriever + HybridRetriever --> SemanticRetriever & GraphRetriever SemanticRetriever --> EmbedDB GraphRetriever --> GraphDB Agent --> MemoryDB - - %% Indexing pipeline - AIHandlers -->|Triggers rebuild| IndexQueue - IndexQueue -->|Pops job| IndexWorker - IndexWorker -->|Saves chunks| EmbedDB - IndexWorker -->|Calls local vectorizer| ONNXEmbedder + ReasoningBrain --> SelfCorrectionEngine ``` --- -## Data Locality & Files +## Subsystem Component Reference + +### 1. 3-Brain Architectural Triad & Orchestrator + +| Component | File Path | Architectural Responsibility | Key Safeguards & Capabilities | +|---|---|---|---| +| **ContextOrchestrator** | [`ai/core/ContextOrchestrator.js`](file:///c:/Users/oksbw/OneDrive/Desktop/Antigravity%20Workspace/Notely/ai/core/ContextOrchestrator.js) | Multi-Tool Planning & Context Aggregation | Runs parallel tool execution (`Promise.allSettled`), output chaining, evidence deduplication, and confidence looping. | +| **WorkspaceBrain** | [`ai/core/WorkspaceBrain.js`](file:///c:/Users/oksbw/OneDrive/Desktop/Antigravity%20Workspace/Notely/ai/core/WorkspaceBrain.js) | Factual Retrieval & Context Aggregation | Proactively gathers active note text, vector similarity matches, and graph hops for every query. | +| **ReasoningBrain** | [`ai/core/ReasoningBrain.js`](file:///c:/Users/oksbw/OneDrive/Desktop/Antigravity%20Workspace/Notely/ai/core/ReasoningBrain.js) | Analytical Reasoning & Synthesis | Synthesizes natural human responses. Has **zero direct access to disk or SQLite**. | +| **ActionBrain** | [`ai/core/ActionBrain.js`](file:///c:/Users/oksbw/OneDrive/Desktop/Antigravity%20Workspace/Notely/ai/core/ActionBrain.js) | Permission Gatekeeper & Execution Safety | Permanently blocks `update_note`, `delete_note`, `move_note`, `rename_note`. Rejects file overwrites on `create_note`. | + +### 2. Planning & Tool Ecosystem + +| Component | File Path | Responsibility | Capabilities | +|---|---|---|---| +| **Planner** | [`ai/core/Planner.js`](file:///c:/Users/oksbw/OneDrive/Desktop/Antigravity%20Workspace/Notely/ai/core/Planner.js) | Intent Classification & Planning | Classifies query intent (`DirectQuery`, `TopicExploration`, `TimelineReconstruction`, `TaskSummary`) and generates internal plan graphs. | +| **SemanticTools** | [`ai/tools/SemanticTools.js`](file:///c:/Users/oksbw/OneDrive/Desktop/Antigravity%20Workspace/Notely/ai/tools/SemanticTools.js) | High-Level Domain Tools | Exposes `find_discussions`, `find_architecture`, `find_people_and_tasks`, `reconstruct_timeline`, `explore_topic_graph`. | + +### 3. Prompting, Persona & Grounding System + +| Component | File Path | Responsibility | Features | +|---|---|---|---| +| **PromptLibrary** | [`ai/core/PromptLibrary.js`](file:///c:/Users/oksbw/OneDrive/Desktop/Antigravity%20Workspace/Notely/ai/core/PromptLibrary.js) | Modular System Prompts | Assembles base policies, dynamic domain context inference, active persona instructions, and workspace context. | +| **GroundingEngine** | [`ai/core/GroundingEngine.js`](file:///c:/Users/oksbw/OneDrive/Desktop/Antigravity%20Workspace/Notely/ai/core/GroundingEngine.js) | Citation Link Validator | Audits file link citations (`[label](file:///path)`) against disk and strips broken links before response output. | +| **SelfCorrectionEngine**| [`ai/core/SelfCorrectionEngine.js`](file:///c:/Users/oksbw/OneDrive/Desktop/Antigravity%20Workspace/Notely/ai/core/SelfCorrectionEngine.js) | ReAct Response Validation Pass | Intercepts draft responses, strips leaked technical tool narration jargon, and validates grounding. | + +--- + +## Multi-Tool Planning & Context Orchestration -All application-wide settings and keys reside in the system Application Data folder (`%AppData%/notely/`), while workspace-scoped indexes reside in the hidden `.notes-app/` folder: +`ContextOrchestrator.js` implements an autonomous evidence-gathering workflow: -| Scope | Filename | Purpose | -|---|---|---| -| **Global** | `ai-config.json` | API keys (encrypted via safeStorage) & active provider configuration | -| **Global** | `ai-preferences.json` | Feature flags, custom personas, and active embedding provider selections | -| **Global** | `ai-model/` | Downloaded local ONNX model weights (`BGE-small-en-v1.5`, ~130MB) | -| **Workspace** | `ai-embeddings.db` | Note chunk text, coordinate line mappings, vectors, and queue states | -| **Workspace** | `ai-graph.db` | Note node references and entity-relation triples | -| **Workspace** | `ai-memory.db` | Chat history messages, custom traces metadata, and user patterns log | +1. **Internal Intent & Plan Generation**: `Planner.js` creates candidate retrieval steps based on query classification (`DirectQuery`, `TopicExploration`, `TimelineReconstruction`, `TaskSummary`). The plan is strictly internal and never exposed to the user. +2. **Parallel & Chained Tool Execution**: Independent semantic tools (`find_discussions`, `explore_topic_graph`, `find_architecture`) execute concurrently using `Promise.allSettled`. Outputs chain into subsequent retrieval steps. +3. **Context Aggregation & Consolidation**: + - Evidence deduplication across tools and vector chunks. + - Relevance score ranking and source file attribution (`[file.md](file:///path)`). + - Trace telemetry collection (`executionTrace`) capturing all graph hops, tool parameters, and execution outputs for the UI **AI Health & Diagnostics** page (`AIHealthPage.jsx`). +4. **Confidence Evaluation Loop**: Measures overall evidence confidence ($0.0 - 1.0$). If confidence $< 0.70$ and iterations $< 3$, performs additional graph expansion or discussion lookups. +5. **Curated Handoff**: Supplies consolidated evidence payload to `ReasoningBrain.js` for answer synthesis. --- -## Optimizations +## Verification & Test Suite Execution -### 1. SQLite WAL Mode -All database connections are initialized with: -```sql -PRAGMA foreign_keys = ON; -PRAGMA journal_mode = WAL; -PRAGMA synchronous = NORMAL; -``` -This reduces disk write overhead and permits simultaneous reads without write blocking. +All AI subsystem components are covered by Vitest test suites under `tests/ai/`: -### 2. In-Memory Vector Cache -`EmbeddingService.js` maintains an in-memory cache map for hot embedding vectors. Repeated calls to `generateVector` or `generateEmbedding` for the same text contents return values instantly without calling remote LLM API providers or local ONNX runtime calculations. +```bash +node node_modules/vitest/vitest.mjs run tests/ai +``` -### 3. TTL Graph Cache -`GraphRetriever.js` caches recursive CTE Knowledge Graph queries for 60 seconds (`TTL = 60000ms`), preventing redundant query execution on large workspaces during fast, conversational chat sequences. +### Test Suite Map (27 Test Files / 72 Tests Passing 100%): +- `tests/ai/orchestrator.spec.js`: Multi-tool planning, parallel retrieval & evidence aggregation tests. +- `tests/ai/brainTriad.spec.js`: 3-Brain isolation & note immutability tests. +- `tests/ai/planner.spec.js`: Intent classification & semantic tools tests. +- `tests/ai/grounding.spec.js`: Citation link verification & prompt composition tests. +- `tests/ai/selfCorrection.spec.js`: ReAct validation pass & zero-jargon gate tests. +- `tests/ai/harness.spec.js`: Evaluation harness metrics tests. +- `tests/ai/knowledgeGraph.spec.js`: Recursive CTE graph traversal & UTC date matching tests. diff --git a/ai/context/ContextEngine.js b/ai/context/ContextEngine.js index 250ad367..3a591fd0 100644 --- a/ai/context/ContextEngine.js +++ b/ai/context/ContextEngine.js @@ -77,7 +77,7 @@ class ContextEngine { log.info(`Context built for conversation=${conversationId} persona=${personaId} msgs=${messages.length}`); - return { system, messages, tools }; + return { system, messages, tools, personaId }; } } diff --git a/ai/context/HybridRetriever.js b/ai/context/HybridRetriever.js index 10742eb3..e815e400 100644 --- a/ai/context/HybridRetriever.js +++ b/ai/context/HybridRetriever.js @@ -90,7 +90,7 @@ class HybridRetriever { } else { try { const fs = require('fs'); - if (fs.existsSync(notePath)) { + if (fs.existsSync(notePath) && fs.statSync(notePath).isFile()) { content = fs.readFileSync(notePath, 'utf8'); } } catch (err) { @@ -98,10 +98,26 @@ class HybridRetriever { } } + // Attach graph relations & evidence triples for the matched note + let graphTriples = []; + if (this.graphRetriever) { + try { + const rels = this.graphRetriever.traverse(notePath, 1); + graphTriples = (rels || []).map(r => { + let line = `(${r.from_type || 'Entity'}) ${r.from_name || r.from_path} --[${r.relation}]--> (${r.to_type || 'Entity'}) ${r.to_name || r.to_path}`; + if (r.evidence) { + line += ` (Evidence: "${r.evidence}")`; + } + return line; + }); + } catch { /* ignore graph lookup error */ } + } + results.push({ note_path: notePath, content: content.slice(0, 4000), // budget preview limit - score: score + score: score, + graph_triples: graphTriples }); } @@ -125,9 +141,13 @@ class HybridRetriever { execute: async ({ query, activeNotePath = null, topK = 5 }) => { const results = await this.search(query, activeNotePath, topK); if (!results.length) return 'No relevant note content found.'; - return results.map((r, i) => - `[${i + 1}] ${r.note_path} (RRF score: ${r.score.toFixed(4)})\n${r.content}` - ).join('\n\n'); + return results.map((r, i) => { + let output = `[${i + 1}] ${r.note_path} (RRF score: ${r.score.toFixed(4)})\n${r.content}`; + if (r.graph_triples && r.graph_triples.length) { + output += `\n\nKnowledge Graph Connections:\n * ` + r.graph_triples.slice(0, 10).join('\n * '); + } + return output; + }).join('\n\n---\n\n'); } }; } diff --git a/ai/context/SemanticRetriever.js b/ai/context/SemanticRetriever.js index 2d743fe0..b8b8d48a 100644 --- a/ai/context/SemanticRetriever.js +++ b/ai/context/SemanticRetriever.js @@ -54,12 +54,22 @@ class SemanticRetriever { } } - if (!scored.length) return []; + if (!scored.length) { + return typeof this.embeddingDB.searchTextFallback === 'function' + ? this.embeddingDB.searchTextFallback(query, topK) + : []; + } + + // Sort scored vector items by similarity descending + scored.sort((a, b) => b.score - a.score); - // Filter by similarity score threshold (e.g. >= 0.70) - const thresholdFiltered = scored.filter(item => item.score >= 0.70); + // Filter by realistic local similarity threshold (>= 0.35 for ONNX BGE embeddings) + let thresholdFiltered = scored.filter(item => item.score >= 0.35); - thresholdFiltered.sort((a, b) => b.score - a.score); + // Fallback to keyword search if no vector matches passed threshold + if (thresholdFiltered.length === 0 && typeof this.embeddingDB.searchTextFallback === 'function') { + return this.embeddingDB.searchTextFallback(query, topK); + } // Deduplicate by note_path to avoid duplicate chunks from the same note const seenNotes = new Set(); diff --git a/ai/core/ActionBrain.js b/ai/core/ActionBrain.js new file mode 100644 index 00000000..816b380e --- /dev/null +++ b/ai/core/ActionBrain.js @@ -0,0 +1,70 @@ +/** + * ActionBrain - Execution gatekeeper & permission validator for Notely AI + * Strictly enforces zero-edit / read-only safety for existing notes. + */ + +const fs = require('fs'); +const path = require('path'); + +class ActionBrain { + constructor(agent) { + this.agent = agent; + this.forbiddenActions = new Set([ + 'update_note', + 'edit_note', + 'delete_note', + 'move_note', + 'rename_note', + 'overwrite_note' + ]); + } + + /** + * Validate action request before execution + * @param {string} actionName + * @param {object} params + * @returns {{ allowed: boolean, reason?: string }} + */ + validateAction(actionName, params = {}) { + const normalizedName = String(actionName || '').toLowerCase().trim(); + + if (this.forbiddenActions.has(normalizedName)) { + return { + allowed: false, + reason: `Action '${actionName}' is strictly prohibited. AI is restricted from modifying, moving, or deleting existing workspace notes.` + }; + } + + if (normalizedName === 'create_note') { + const title = String(params.title || params.note_title || params.name || 'Untitled').trim(); + const fileName = title.endsWith('.md') ? title : `${title}.md`; + const targetDir = params.subfolder ? path.join(this.agent.workspaceRoot, params.subfolder) : this.agent.workspaceRoot; + const fullPath = path.join(targetDir, fileName); + + if (fs.existsSync(fullPath)) { + return { + allowed: false, + reason: `Note '${fileName}' already exists. Overwriting existing notes is strictly disabled.` + }; + } + } + + return { allowed: true }; + } + + /** + * Execute validated action + * @param {string} actionName + * @param {object} params + * @param {Function} runner + */ + async execute(actionName, params, runner) { + const validation = this.validateAction(actionName, params); + if (!validation.allowed) { + throw new Error(`[ActionBrain Gate Error]: ${validation.reason}`); + } + return runner(params); + } +} + +module.exports = ActionBrain; diff --git a/ai/core/Agent.js b/ai/core/Agent.js index 204d5e26..e67ad8cf 100644 --- a/ai/core/Agent.js +++ b/ai/core/Agent.js @@ -11,10 +11,32 @@ const GraphDB = require('../graph/GraphDB'); const GraphService = require('../graph/GraphService'); const GraphBuilder = require('../graph/GraphBuilder'); +const WorkspaceBrain = require('./WorkspaceBrain'); +const ReasoningBrain = require('./ReasoningBrain'); +const ActionBrain = require('./ActionBrain'); +const ContextOrchestrator = require('./ContextOrchestrator'); + +const PromptLoader = require('../prompts/PromptLoader'); +const PromptPipeline = require('../prompts/PromptPipeline'); +const PersonaManager = require('../personas/PersonaManager'); +const LogDB = require('../logs/LogDB'); + class Agent { constructor(databaseManager, llmRegistry) { this.db = databaseManager; this.llmRegistry = llmRegistry; + this.logDb = null; + + // Prompt Architecture Infrastructure + this.promptLoader = new PromptLoader(); + this.promptPipeline = new PromptPipeline(this.promptLoader); + this.personaManager = new PersonaManager(this.promptLoader); + + // Initialize 3-Brain Architecture & Context Orchestrator + this.workspaceBrain = new WorkspaceBrain(this); + this.reasoningBrain = new ReasoningBrain(this.llmRegistry); + this.actionBrain = new ActionBrain(this); + this.contextOrchestrator = new ContextOrchestrator(this); // Initialize services — EmbeddingService receives null here; the actual // embeddingProvider is injected after construction via setEmbeddingProvider() @@ -63,6 +85,10 @@ class Agent { this.workspaceRoot = workspaceRoot; this.documentService.workspaceRoot = workspaceRoot; + // Initialize LogDB for prompt and AI logging + this.logDb = new LogDB(workspaceRoot); + this.logDb.initialize(); + // Initialize GraphDB this.graphDb = new GraphDB(workspaceRoot); this.graphDb.initialize(); @@ -209,6 +235,20 @@ class Agent { } } + /** + * Log LLM prompt execution to LogDB (PromptTracker) + */ + logPrompt(query, systemPrompt, metadata = {}) { + if (this.logDb && this.logDb.isInitialized) { + const displayQuery = query ? String(query).slice(0, 80) : 'N/A'; + this.logDb.addLog('PromptTracker', `Prompt executed for query: "${displayQuery}"`, 'info', { + query, + systemPrompt, + ...metadata + }); + } + } + /** * Get agent status */ diff --git a/ai/core/ContextOrchestrator.js b/ai/core/ContextOrchestrator.js new file mode 100644 index 00000000..4f6e6c61 --- /dev/null +++ b/ai/core/ContextOrchestrator.js @@ -0,0 +1,246 @@ +/** + * ContextOrchestrator - Dynamic multi-tool planning, parallel retrieval & context aggregation engine + * + * Implements the complete multi-tool planning workflow: + * 1. Intent understanding & internal plan generation (never exposed to user) + * 2. Parallel retrieval execution across candidate tools + * 3. Dynamic tool output chaining + * 4. Context aggregation (deduplication, ranking, source attribution) + * 5. Confidence evaluation & iterative retrieval loop until confidence target is satisfied + * 6. Structured evidence handoff to Reasoning layer + */ + +const Planner = require('./Planner'); +const { createLogger } = require('./logger'); +const log = createLogger('ContextOrchestrator'); + +class ContextOrchestrator { + constructor(agent) { + this.agent = agent; + this.planner = new Planner(agent); + } + + /** + * Execute multi-tool planning & context aggregation lifecycle + * @param {string} query + * @param {object} context - { activeNotePath, userHistory } + * @param {object} options - { targetConfidence: 0.70, maxIterations: 3 } + * @returns {Promise<{ evidence: Array, aggregatedContext: string, confidence: number, iterations: number }>} + */ + async orchestrate(query, context = {}, options = {}) { + const targetConfidence = options.targetConfidence || 0.70; + const maxIterations = options.maxIterations || 3; + + // 1. Understand Intent & Build Internal Execution Plan + const plan = this.planner.createPlan(query); + log.debug('Internal execution plan generated', { intent: plan.intent, stepsCount: plan.steps.length }); + + let collectedEvidence = []; + let executionTrace = []; + let iterations = 0; + let confidence = 0.0; + + // Active workspace tools runner + const SemanticTools = require('../tools/SemanticTools'); + + // 2. Multi-Tool Parallel & Chained Execution Loop + while (iterations < maxIterations && confidence < targetConfidence) { + iterations++; + log.debug(`Executing retrieval iteration ${iterations}/${maxIterations}...`); + + const currentSteps = iterations === 1 ? plan.steps : this._deriveNextSteps(query, collectedEvidence); + if (currentSteps.length === 0) break; + + // Parallel tool execution for independent tools + const toolPromises = currentSteps.map(step => { + return (async () => { + try { + const runner = SemanticTools.getToolRunner(step.toolName, this.agent); + if (runner) { + const res = await runner(step.args); + executionTrace.push({ + name: step.toolName, + args: step.args, + output: typeof res === 'object' ? JSON.stringify(res).slice(0, 500) : String(res).slice(0, 500) + }); + return { toolName: step.toolName, result: res, error: null }; + } + } catch (err) { + executionTrace.push({ + name: step.toolName, + args: step.args, + output: `Error: ${err.message}` + }); + return { toolName: step.toolName, result: null, error: err.message }; + } + return null; + })(); + }); + + const results = await Promise.allSettled(toolPromises); + + // Ingest tool results into evidence collection + for (const item of results) { + if (item.status === 'fulfilled' && item.value && item.value.result) { + const rawRes = item.value.result; + this._ingestEvidence(collectedEvidence, item.value.toolName, rawRes); + } + } + + // Proactive WorkspaceBrain & Graph evidence ingestion + if (this.agent?.workspaceBrain) { + try { + const wbFacts = await this.agent.workspaceBrain.getWorkspaceFacts(query, context.activeNotePath); + const factsArray = Array.isArray(wbFacts) ? wbFacts : []; + executionTrace.push({ + name: 'workspace_graph_retrieval', + args: { query, activeNotePath: context.activeNotePath || null }, + output: `Retrieved ${factsArray.length} workspace facts & graph relations` + }); + for (const fact of factsArray) { + collectedEvidence.push({ + source: fact.source || 'WorkspaceBrain', + filePath: fact.filePath || '', + content: fact.content || '', + score: fact.score || 0.8 + }); + } + } catch { /* ignore fallback */ } + } + + // 3. Aggregate & Measure Confidence + const aggregated = this.aggregateContext(collectedEvidence); + confidence = aggregated.confidence; + log.debug(`Iteration ${iterations} complete. Measured confidence: ${confidence.toFixed(2)}`); + + if (confidence >= targetConfidence) { + log.info(`Target confidence ${targetConfidence} achieved in ${iterations} iteration(s).`); + break; + } + } + + // Final consolidation + const finalAggregated = this.aggregateContext(collectedEvidence); + + return { + evidence: finalAggregated.items, + aggregatedContext: finalAggregated.contextString, + confidence: finalAggregated.confidence, + iterations, + trace: executionTrace + }; + } + + /** + * Derive subsequent retrieval steps if initial confidence is insufficient + * @private + */ + _deriveNextSteps(query, existingEvidence) { + const steps = []; + + // If existing evidence contains linked notes, trigger graph expansion + const linkedPaths = existingEvidence + .map(e => e.filePath) + .filter(Boolean); + + if (linkedPaths.length > 0) { + steps.push({ + toolName: 'explore_topic_graph', + args: { topic: query, notePath: linkedPaths[0], maxHops: 2 } + }); + } else { + steps.push({ + toolName: 'find_discussions', + args: { topic: query } + }); + } + + return steps; + } + + /** + * Ingest raw tool outputs into evidence collection + * @private + */ + _ingestEvidence(targetArray, toolName, result) { + if (typeof result === 'string') { + targetArray.push({ toolName, content: result, score: 0.75 }); + } else if (Array.isArray(result)) { + for (const item of result) { + if (typeof item === 'string') { + targetArray.push({ toolName, content: item, score: 0.8 }); + } else if (typeof item === 'object' && item !== null) { + const filePath = item.filePath || item.path || item.note_path || item.file || ''; + let text = item.snippet || item.content || item.text || item.evidence; + if (!text && Array.isArray(item.graph_triples) && item.graph_triples.length > 0) { + text = item.graph_triples.join('; '); + } + if (!text) { + text = JSON.stringify(item); + } + targetArray.push({ + toolName, + filePath, + content: text, + score: item.score || 0.8 + }); + } + } + } else if (typeof result === 'object' && result !== null) { + const filePath = result.filePath || result.path || result.note_path || ''; + const text = result.snippet || result.content || result.text || JSON.stringify(result); + targetArray.push({ + toolName, + filePath, + content: text, + score: 0.7 + }); + } + } + + /** + * Aggregate, deduplicate, rank, and calculate evidence confidence + * @param {Array} evidenceItems + * @returns {{ items: Array, contextString: string, confidence: number }} + */ + aggregateContext(evidenceItems) { + if (!Array.isArray(evidenceItems) || evidenceItems.length === 0) { + return { items: [], contextString: '', confidence: 0.0 }; + } + + const uniqueMap = new Map(); + for (const item of evidenceItems) { + const contentStr = String(item.content || '').trim(); + if (!contentStr) continue; + + const key = `${item.filePath || ''}:${contentStr.slice(0, 100)}`; + if (!uniqueMap.has(key)) { + uniqueMap.set(key, item); + } + } + + const deduplicated = Array.from(uniqueMap.values()); + deduplicated.sort((a, b) => (b.score || 0) - (a.score || 0)); + + // Calculate confidence based on evidence count, relevance scores, and file grounding + const avgScore = deduplicated.reduce((sum, el) => sum + (el.score || 0.5), 0) / deduplicated.length; + const groundingBonus = deduplicated.some(el => el.filePath) ? 0.2 : 0.0; + const volumeBonus = Math.min(deduplicated.length * 0.1, 0.3); + const confidence = Math.min(1.0, avgScore + groundingBonus + volumeBonus); + + // Format clean curated context string for Reasoning layer + let contextString = `[CURATED WORKSPACE EVIDENCE payload - ${deduplicated.length} item(s)]\n\n`; + deduplicated.slice(0, 10).forEach((el, idx) => { + const fileLabel = el.filePath ? ` [File: ${el.filePath}]` : ''; + contextString += `--- Evidence #${idx + 1}${fileLabel} ---\n${el.content}\n\n`; + }); + + return { + items: deduplicated, + contextString, + confidence + }; + } +} + +module.exports = ContextOrchestrator; diff --git a/ai/core/GroundingEngine.js b/ai/core/GroundingEngine.js new file mode 100644 index 00000000..3f713692 --- /dev/null +++ b/ai/core/GroundingEngine.js @@ -0,0 +1,111 @@ +/** + * GroundingEngine - Verifies claims and citation links against workspace filesystem + */ + +const fs = require('fs'); + +class GroundingEngine { + /** + * Verify file links in response text + * @param {string} text + * @returns {{ text: string, verifiedCitations: number, brokenCitations: number }} + */ + static verifyCitations(text) { + if (!text || typeof text !== 'string') { + return { text: text || '', verifiedCitations: 0, brokenCitations: 0 }; + } + + let verified = 0; + let broken = 0; + + const linkRegex = /\[([^\]]+)\]\(file:\/\/\/([^)]+)\)/g; + const verifiedText = text.replace(linkRegex, (match, label, filePath) => { + // Decode URI spaces + const decodedPath = decodeURIComponent(filePath); + if (fs.existsSync(decodedPath)) { + verified++; + return match; + } else { + broken++; + return label; // Fallback to plain label if link target doesn't exist + } + }); + + return { + text: verifiedText, + verifiedCitations: verified, + brokenCitations: broken + }; + } + + /** + * Verify note title claims against actual workspace files + * @param {string} text + * @param {string[]} workspaceFiles + * @returns {{ text: string, hallucinations: string[] }} + */ + static verifyNoteTitleClaims(text, workspaceFiles = []) { + if (!text || typeof text !== 'string' || !Array.isArray(workspaceFiles) || workspaceFiles.length === 0) { + return { text: text || '', hallucinations: [] }; + } + + const noteBasenames = new Set(workspaceFiles.map(f => { + const name = f.split(/[\\/]/).pop().replace(/\.md$/i, '').toLowerCase(); + return name; + })); + + const hallucinations = []; + const titleRegex = /(?:a\s+)?note\s+(?:titled|named|called|on|about|titled:?)\s+["']?([A-Za-z0-9\s\-_]+?)["']?(?=[,.\n\r]|\s+that|\s+covers|\s+discusses|\s+is|\s+covers)/gi; + + const cleanedText = text.replace(titleRegex, (match, claimedTitle) => { + const normTitle = String(claimedTitle || '').trim().toLowerCase(); + if (normTitle && normTitle.length > 2 && !noteBasenames.has(normTitle)) { + hallucinations.push(claimedTitle); + return `(no note file found in workspace matching "${claimedTitle}")`; + } + return match; + }); + + return { text: cleanedText, hallucinations }; + } + + /** + * Auto-format unlinked note line number citations into clickable file:/// links + * @param {string} text + * @param {string[]} workspaceFiles + * @returns {string} + */ + static formatLineNumberLinks(text, workspaceFiles = []) { + if (!text || typeof text !== 'string' || !Array.isArray(workspaceFiles) || workspaceFiles.length === 0) { + return text || ''; + } + + const fileMap = new Map(); + for (const f of workspaceFiles) { + const filename = f.split(/[\\/]/).pop(); + fileMap.set(filename.toLowerCase(), f); + } + + // Match unlinked pattern: "filename.md (line 18)" or "filename.md lines 18-23" or "filename.md:18-23" + const unlinkedLineRegex = /(? { + const fullPath = fileMap.get(filename.toLowerCase()); + if (!fullPath) return match; + + const startLine = line1 || lineAlt1; + const endLine = line2 || lineAlt2; + const normPath = fullPath.replace(/\\/g, '/'); + + if (startLine && endLine) { + return `[${filename}:L${startLine}-L${endLine}](file:///${normPath}#L${startLine})`; + } else if (startLine) { + return `[${filename}:L${startLine}](file:///${normPath}#L${startLine})`; + } + + return match; + }); + } +} + +module.exports = GroundingEngine; diff --git a/ai/core/Planner.js b/ai/core/Planner.js new file mode 100644 index 00000000..24b67c91 --- /dev/null +++ b/ai/core/Planner.js @@ -0,0 +1,61 @@ +/** + * Planner - Intent classification & multi-step execution planner for Notely AI + * Decomposes complex user queries into ordered tool dependency execution graphs. + */ + +class Planner { + constructor(agent) { + this.agent = agent; + } + + /** + * Classify user query intent + * @param {string} query + * @returns {string} - 'DirectQuery' | 'TopicExploration' | 'TimelineReconstruction' | 'TaskSummary' + */ + classifyIntent(query) { + const q = String(query || '').toLowerCase(); + if (q.includes('timeline') || q.includes('history of') || q.includes('how did') && q.includes('evolve')) { + return 'TimelineReconstruction'; + } + if (q.includes('task') || q.includes('todo') || q.includes('action item') || q.includes('assigned to')) { + return 'TaskSummary'; + } + if (q.includes('architecture') || q.includes('explore') || q.includes('relationship') || q.includes('connected to')) { + return 'TopicExploration'; + } + return 'DirectQuery'; + } + + /** + * Build execution plan graph for query + * @param {string} query + * @returns {{ intent: string, steps: Array<{ toolName: string, args: object }> }} + */ + createPlan(query) { + const intent = this.classifyIntent(query); + const steps = []; + + switch (intent) { + case 'TimelineReconstruction': + steps.push({ toolName: 'reconstruct_timeline', args: { topic: query } }); + steps.push({ toolName: 'find_discussions', args: { topic: query } }); + break; + case 'TaskSummary': + steps.push({ toolName: 'find_people_and_tasks', args: { status: 'open' } }); + break; + case 'TopicExploration': + steps.push({ toolName: 'explore_topic_graph', args: { topic: query, maxHops: 2 } }); + steps.push({ toolName: 'find_architecture', args: { component: query } }); + break; + case 'DirectQuery': + default: + steps.push({ toolName: 'find_discussions', args: { topic: query } }); + break; + } + + return { intent, steps }; + } +} + +module.exports = Planner; diff --git a/ai/core/PromptLibrary.js b/ai/core/PromptLibrary.js new file mode 100644 index 00000000..f7e923f0 --- /dev/null +++ b/ai/core/PromptLibrary.js @@ -0,0 +1,39 @@ +/** + * PromptLibrary - Facade over PromptLoader and PromptPipeline. + * Maintains backward compatibility while delegating to the modular Markdown prompt architecture. + */ + +const PromptLoader = require('../prompts/PromptLoader'); +const PromptPipeline = require('../prompts/PromptPipeline'); + +class PromptLibrary { + static getLoader() { + if (!this._loader) { + this._loader = new PromptLoader(); + } + return this._loader; + } + + static getPipeline() { + if (!this._pipeline) { + this._pipeline = new PromptPipeline(this.getLoader()); + } + return this._pipeline; + } + + static getBaseSystemPrompt() { + const loader = this.getLoader(); + const base = loader.loadSystemPrompt('base-system'); + return base.body || "You are Notely's AI Knowledge Partner."; + } + + static composeSystemPrompt(personaInstructions = '', workspaceContext = '') { + const pipeline = this.getPipeline(); + return pipeline.assemble({ + persona: personaInstructions ? { systemInstructions: personaInstructions } : 'general', + workspaceContext: typeof workspaceContext === 'object' ? workspaceContext : { raw: workspaceContext } + }); + } +} + +module.exports = PromptLibrary; diff --git a/ai/core/QueryExecutor.js b/ai/core/QueryExecutor.js index d6c350fc..ef4ad42c 100644 --- a/ai/core/QueryExecutor.js +++ b/ai/core/QueryExecutor.js @@ -2,24 +2,25 @@ * QueryExecutor - Routes queries to AI models with multi-step tool execution */ -const fs = require('fs'); -const path = require('path'); const { getTools } = require('../tools/ToolRegistry'); +const PromptPipeline = require('../prompts/PromptPipeline'); class QueryExecutor { constructor(agent) { this.agent = agent; + this.promptPipeline = new PromptPipeline(); } async _prepareConfig(query, context) { + this.agent.lastQuery = query; const llm = this.agent.llmRegistry.getActiveProvider(); const model = await llm.getModelInstance(); const tools = await getTools(this.agent); - // 1. Build core persona instructions — prefer ContextEngine persona if available - let systemPrompt; + let personaInput = context.persona || 'general'; let contextEngineTools = {}; let ceMessages = []; + if (this.agent.contextEngine) { try { const conversationId = context.conversationId || 'default'; @@ -28,45 +29,62 @@ class QueryExecutor { activeNotePath: context.currentFile || null, activeNoteContent: context.activeNoteContent || null }); - systemPrompt = ceCtx.system; + if (ceCtx.personaId) { + personaInput = ceCtx.personaId; + } else if (ceCtx.system) { + personaInput = { systemInstructions: ceCtx.system }; + } contextEngineTools = ceCtx.tools || {}; ceMessages = ceCtx.messages || []; } catch (ceErr) { console.warn('[QueryExecutor] ContextEngine.buildContext failed, falling back:', ceErr.message); } - } - // Load core system instructions from markdown file - let baseInstructions = ''; - try { - const promptPath = path.join(__dirname, 'system_prompt.md'); - if (fs.existsSync(promptPath)) { - baseInstructions = fs.readFileSync(promptPath, 'utf8'); - } - } catch (readErr) { - console.warn('[QueryExecutor] Failed to read system_prompt.md:', readErr.message); - } - - // Combine base instructions with the active persona instructions - let finalSystemPrompt = baseInstructions || 'You are a helpful AI assistant for Notely, a modern markdown notes application.'; - if (systemPrompt) { - finalSystemPrompt += `\n\n---\nACTIVE PERSONA ROLE/INSTRUCTIONS:\n${systemPrompt}`; } else if (context.systemPrompt) { - finalSystemPrompt += `\n\n---\nACTIVE PERSONA ROLE/INSTRUCTIONS:\n${context.systemPrompt}`; + personaInput = { systemInstructions: context.systemPrompt }; } - // Append workspace context metadata - finalSystemPrompt += `\n\nWorkspace context: -- Workspace folder: ${this.agent.workspaceRoot || 'none'} -- Current open note path: ${context.currentFile || 'none'}`; - - if (context.relatedDocuments && context.relatedDocuments.length > 0) { - finalSystemPrompt += `\n- Related documents:`; - context.relatedDocuments.forEach(doc => { - finalSystemPrompt += `\n * ${doc.path}`; - }); + // Multi-Tool Planning & Context Orchestration + let orchestratorTrace = []; + let retrievedEvidence = ''; + if (this.agent.contextOrchestrator) { + try { + const orchRes = await this.agent.contextOrchestrator.orchestrate(query, context); + if (orchRes.aggregatedContext) { + retrievedEvidence = orchRes.aggregatedContext; + } + if (orchRes.trace) { + orchestratorTrace = orchRes.trace; + } + } catch (orchErr) { + console.warn('[QueryExecutor] ContextOrchestrator execution fallback:', orchErr.message); + if (this.agent.workspaceBrain) { + try { + const facts = await this.agent.workspaceBrain.getWorkspaceFacts(query, context); + if (this.agent.reasoningBrain) { + const evidenceStr = this.agent.reasoningBrain.formatEvidenceContext(facts); + if (evidenceStr) { + retrievedEvidence = evidenceStr; + } + } + } catch { /* ignore fallback */ } + } + } } - systemPrompt = finalSystemPrompt; + // Assemble final prompt using PromptPipeline + const pipeline = this.agent.promptPipeline || this.promptPipeline; + const systemPrompt = pipeline.assemble({ + persona: personaInput, + workspaceContext: { + workspaceRoot: this.agent.workspaceRoot || 'none', + activeNotePath: context.currentFile || 'none', + activeNoteContent: context.activeNoteContent || null, + documentCount: this.agent.documentService?.getAllDocuments()?.length || 0 + }, + conversationMemory: ceMessages.length > 0 ? ceMessages : null, + retrievedEvidence: retrievedEvidence || (context.relatedDocuments ? context.relatedDocuments.map(d => d.path).join('\n') : null), + uiContext: context.uiContext || null + }); const mergedTools = { ...tools, @@ -86,7 +104,7 @@ class QueryExecutor { messages = [{ role: 'user', content: query }]; } - return { model, systemPrompt, messages, mergedTools, llm, toolChoice }; + return { model, systemPrompt, messages, mergedTools, llm, toolChoice, orchestratorTrace }; } /** @@ -95,7 +113,16 @@ class QueryExecutor { async execute(query, context = {}) { try { const { generateText } = await import('ai'); - const { model, systemPrompt, messages, mergedTools, llm, toolChoice } = await this._prepareConfig(query, context); + const { model, systemPrompt, messages, mergedTools, llm, toolChoice, orchestratorTrace } = await this._prepareConfig(query, context); + + if (this.agent && typeof this.agent.logPrompt === 'function') { + this.agent.logPrompt(query, systemPrompt, { + persona: context.persona || 'general', + model: llm?.name || 'unknown', + messages, + uiContext: context.uiContext || null + }); + } const result = await generateText({ model, @@ -133,12 +160,12 @@ class QueryExecutor { try { const nextMessages = [...messages]; if (nextMessages.length > 0 && nextMessages[nextMessages.length - 1].role === 'user') { - let toolContext = `I executed the following tools to help answer the request:`; + let toolContext = `Retrieved the following contextual information from the workspace notes:`; for (const tr of toolResultsContent) { const val = tr.output !== undefined ? tr.output : tr.result; - toolContext += `\n\n- Tool: ${tr.toolName}\nOutput: ${typeof val === 'object' ? JSON.stringify(val) : val}`; + toolContext += `\n\n- Information: ${typeof val === 'object' ? JSON.stringify(val) : val}`; } - toolContext += `\n\nBased on these tool outputs, please provide a friendly, structured, and concise natural language response to my query: "${query}".`; + toolContext += `\n\nBased on these workspace details, please provide a friendly, structured, and concise natural language response to my query: "${query}".`; nextMessages[nextMessages.length - 1] = { role: 'user', @@ -175,7 +202,7 @@ class QueryExecutor { const toolResult = stepResult?.toolResults?.find(r => r.toolCallId === call.toolCallId); if (toolResult) { const val = toolResult.output !== undefined ? toolResult.output : toolResult.result; - formattedOutput += `\n\n#### Tool Output: ${call.toolName}\n`; + formattedOutput += `\n\n`; if (typeof val === 'string') { try { const parsed = JSON.parse(val); @@ -193,12 +220,12 @@ class QueryExecutor { } } if (formattedOutput) { - textResult = `I executed tools to fetch this information for you:${formattedOutput}`; + textResult = `Based on your workspace notes, here is the relevant details:${formattedOutput}`; } } // Construct the trace array of executed tools and outputs - const trace = []; + const trace = Array.isArray(orchestratorTrace) ? [...orchestratorTrace] : []; if (result.steps) { for (const step of result.steps) { if (step.toolCalls) { @@ -215,11 +242,17 @@ class QueryExecutor { } } + const workspaceFiles = this.agent.documentService ? this.agent.documentService._collectMarkdownFiles(this.agent.workspaceRoot) : []; + const SelfCorrectionEngine = require('./SelfCorrectionEngine'); + const validation = SelfCorrectionEngine.validateAndCorrect(textResult || '', { query, workspaceFiles }); + const finalResultText = validation.validatedText || textResult || "AI query completed with no text output."; + return { type: 'query', - result: textResult || "AI query completed with no text output.", + result: finalResultText, tokensUsed, - trace + trace, + corrected: validation.corrected }; } catch (error) { console.error('[QueryExecutor] Execution failed:', error.message); @@ -235,6 +268,16 @@ class QueryExecutor { const { streamText } = await import('ai'); const { model, systemPrompt, messages, mergedTools, llm, toolChoice } = await this._prepareConfig(query, context); + if (this.agent && typeof this.agent.logPrompt === 'function') { + this.agent.logPrompt(query, systemPrompt, { + persona: context.persona || 'general', + model: llm?.name || 'unknown', + messages, + uiContext: context.uiContext || null, + streaming: true + }); + } + const result = await streamText({ model, system: systemPrompt, diff --git a/ai/core/QueryTools.js b/ai/core/QueryTools.js index 371a2247..037a6995 100644 --- a/ai/core/QueryTools.js +++ b/ai/core/QueryTools.js @@ -94,6 +94,22 @@ const getOfficialTools = (agent) => { } } } + }, + { + type: 'function', + function: { + name: 'create_note', + description: 'Create a new markdown note in the workspace with title, content, and optional subfolder.', + parameters: { + type: 'object', + properties: { + title: { type: 'string', description: 'Title or file name for the new note (e.g. "Project Blueprint").' }, + content: { type: 'string', description: 'Markdown body content.' }, + subfolder: { type: 'string', description: 'Optional subfolder inside workspace.' } + }, + required: ['title', 'content'] + } + } } ]; @@ -116,20 +132,20 @@ const getOfficialTools = (agent) => { }); } - // Add explore_graph if graph DB is available - if (agent.contextEngine?.graphRetriever) { + // Add explore_graph if graph DB or retriever is available + if (agent.contextEngine?.graphRetriever || agent.graphDb) { officialTools.push({ type: 'function', function: { name: 'explore_graph', - description: 'Explore how a note is linked to other notes in the knowledge graph.', + description: 'Explore how a note, person, concept, technology, or topic is linked to other entities in the knowledge graph.', parameters: { type: 'object', properties: { - notePath: { type: 'string', description: 'The full path of the note to start graph traversal from.' }, + identifier: { type: 'string', description: 'The note path, title, person name, or topic (e.g., "Bikash Panda", "Semantic Search", "ai-and-search.md") to start graph traversal from.' }, + notePath: { type: 'string', description: 'Alias for identifier.' }, maxDepth: { type: 'number', description: 'Maximum traversal hops (default 2).' } - }, - required: ['notePath'] + } } } }); @@ -273,18 +289,57 @@ const runTool = async (agent, name, args) => { } if (name === 'semantic_search') { try { - const results = await agent.contextEngine.semanticRetriever.search(args.query, args.topK || 5); + const queryStr = args.query || args.topic || args.q || agent.lastQuery || ''; + if (!queryStr) return 'No search query provided for semantic search.'; + const results = await agent.contextEngine.semanticRetriever.search(queryStr, args.topK || 5); if (!results.length) return 'No semantically similar notes found.'; return results.map((r, i) => `[${i+1}] ${r.note_path} (score: ${r.score.toFixed(3)})\n${r.content}`).join('\n\n'); } catch (err) { return `Semantic search error: ${err.message}`; } } if (name === 'explore_graph') { + const target = args.identifier || args.notePath || ''; try { - const rows = agent.contextEngine.graphRetriever.traverse(args.notePath, args.maxDepth || 2); - if (!rows.length) return `No graph relations found for: ${args.notePath}`; - return rows.map(r => `[depth ${r.depth}] ${r.from_path} --[${r.relation}]--> ${r.to_path}`).join('\n'); + let rows = []; + if (agent.graphDb) { + rows = agent.graphDb.traversePathOrId(target, args.maxDepth || 2); + } else if (agent.contextEngine?.graphRetriever) { + rows = agent.contextEngine.graphRetriever.traverse(target, args.maxDepth || 2); + } + if (!rows || !rows.length) return `No knowledge graph connections found for: "${target}"`; + return rows.map(r => { + let line = `[(${r.from_type || 'Entity'}) ${r.from_name || r.from_path}] --[${r.relation}]--> [(${r.to_type || 'Entity'}) ${r.to_name || r.to_path}]`; + if (r.evidence) { + line += `\n Evidence: "${r.evidence}"`; + } + return line; + }).join('\n'); } catch (err) { return `Graph traversal error: ${err.message}`; } } + if (name === 'create_note') { + try { + const fs = require('fs'); + const path = require('path'); + const title = String(args.title || 'Untitled').trim(); + const fileName = title.endsWith('.md') ? title : `${title}.md`; + const targetDir = args.subfolder ? path.join(agent.workspaceRoot, args.subfolder) : agent.workspaceRoot; + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + const fullPath = path.join(targetDir, fileName); + if (fs.existsSync(fullPath)) { + return `Notice: A note named "${fileName}" already exists. Updating or overwriting existing notes is strictly disabled to safeguard note content.`; + } + const content = String(args.content || ''); + fs.writeFileSync(fullPath, content, 'utf8'); + + if (agent.graphService) { + await agent.graphService.processNote(fullPath, content); + } + return `Created new note: [${fileName}](file:///${fullPath.replace(/\\/g, '/')})`; + } catch (err) { + return `Error creating note: ${err.message}`; + } + } return `Error: Tool ${name} not found`; }; diff --git a/ai/core/ReasoningBrain.js b/ai/core/ReasoningBrain.js new file mode 100644 index 00000000..4722a5f3 --- /dev/null +++ b/ai/core/ReasoningBrain.js @@ -0,0 +1,72 @@ +/** + * ReasoningBrain - Pure reasoning & synthesis engine for Notely AI + * Consumes normalized WorkspaceFacts evidence payloads from WorkspaceBrain. + * Holds ZERO direct storage, database, or filesystem dependencies. + */ + +class ReasoningBrain { + constructor(llmRegistry) { + this.llmRegistry = llmRegistry; + } + + /** + * Format facts into clean evidence prompt context + * @param {object} facts + * @returns {string} + */ + formatEvidenceContext(facts) { + if (!facts) return ''; + let contextStr = ''; + + if (facts.activeNote) { + contextStr += `\n[ACTIVE NOTE: ${facts.activeNote.path}]\n${facts.activeNote.content || '(empty note)'}\n`; + } + + if (facts.semanticResults && facts.semanticResults.length > 0) { + contextStr += `\n[RELEVANT WORKSPACE CHUNKS]:\n`; + facts.semanticResults.forEach((item, i) => { + contextStr += `${i + 1}. Note: [${item.filePath}](file:///${item.filePath.replace(/\\/g, '/')})\nContent: ${item.snippet}\n\n`; + }); + } + + if (facts.graphRelations && facts.graphRelations.length > 0) { + contextStr += `\n[KNOWLEDGE GRAPH RELATIONS]:\n`; + facts.graphRelations.forEach(rel => { + contextStr += `- ${rel.source} ${rel.type} ${rel.target}${rel.evidence ? ` (Evidence: "${rel.evidence}")` : ''}\n`; + }); + } + + return contextStr; + } + + /** + * Synthesize natural language answer using provided evidence + * @param {string} userQuery + * @param {object} facts - WorkspaceFacts from WorkspaceBrain + * @param {string} systemPrompt + * @returns {Promise} + */ + async synthesize(userQuery, facts, systemPrompt) { + const evidenceText = this.formatEvidenceContext(facts); + const fullSystemPrompt = `${systemPrompt}\n\n[RETRIEVED WORKSPACE EVIDENCE]:\n${evidenceText || 'No workspace evidence found.'}`; + + const provider = this.llmRegistry.getActiveProvider(); + if (!provider) { + throw new Error('No active LLM provider configured.'); + } + + const messages = [{ role: 'user', content: userQuery }]; + const result = await provider.generateText({ + system: fullSystemPrompt, + messages + }); + + return { + text: result.text, + tokensUsed: result.usage?.totalTokens || 0, + evidenceUsed: Boolean(evidenceText) + }; + } +} + +module.exports = ReasoningBrain; diff --git a/ai/core/SelfCorrectionEngine.js b/ai/core/SelfCorrectionEngine.js new file mode 100644 index 00000000..d32dd762 --- /dev/null +++ b/ai/core/SelfCorrectionEngine.js @@ -0,0 +1,76 @@ +/** + * SelfCorrectionEngine - Response validation & self-correction loop for Notely AI + * Validates generated LLM responses before returning them to the user. + * Checks for zero-jargon compliance, citation grounding, and evidence alignment. + */ + +const GroundingEngine = require('./GroundingEngine'); + +class SelfCorrectionEngine { + /** + * Validate and self-correct response text + * @param {string} text + * @param {object} options - { query, evidenceContext } + * @returns {{ validatedText: string, corrected: boolean, issues: string[] }} + */ + static validateAndCorrect(text, options = {}) { + if (!text || typeof text !== 'string') { + return { validatedText: '', corrected: false, issues: [] }; + } + + let currentText = text; + const issues = []; + let corrected = false; + + // 1. Zero-Jargon Compliance Check (Strip internal tool names if leaked by LLM) + const jargonPatterns = [ + /I executed the following tools:?/gi, + /#### Tool Output:?\s*\w+/gi, + /\[Tool:\s*\w+\]/gi, + /I invoked tool \w+/gi + ]; + + for (const pattern of jargonPatterns) { + if (pattern.test(currentText)) { + issues.push('Leaked internal tool technical jargon'); + currentText = currentText.replace(pattern, '').trim(); + corrected = true; + } + } + + // 2. Citation Link Verification (Verify file:/// links exist on disk) + const citationRes = GroundingEngine.verifyCitations(currentText); + if (citationRes.brokenCitations > 0) { + issues.push(`Found ${citationRes.brokenCitations} broken note links`); + currentText = citationRes.text; + corrected = true; + } + + // 3. Note Title Hallucination Verification & Line Link Formatting + if (options.workspaceFiles && Array.isArray(options.workspaceFiles)) { + const titleRes = GroundingEngine.verifyNoteTitleClaims(currentText, options.workspaceFiles); + if (titleRes.hallucinations.length > 0) { + issues.push(`Stripped ${titleRes.hallucinations.length} ungrounded note title claim(s)`); + currentText = titleRes.text; + corrected = true; + } + currentText = GroundingEngine.formatLineNumberLinks(currentText, options.workspaceFiles); + } + + // 4. Grounding Fallback Check + if (options.evidenceContext === false || options.evidenceContext === '') { + const lower = currentText.toLowerCase(); + if (lower.includes('in your note') && !lower.includes("couldn't find")) { + issues.push('Claimed note facts without workspace evidence'); + } + } + + return { + validatedText: currentText, + corrected, + issues + }; + } +} + +module.exports = SelfCorrectionEngine; diff --git a/ai/core/WorkspaceBrain.js b/ai/core/WorkspaceBrain.js new file mode 100644 index 00000000..4d552203 --- /dev/null +++ b/ai/core/WorkspaceBrain.js @@ -0,0 +1,81 @@ +/** + * WorkspaceBrain - Factual retrieval and state aggregator for Notely AI + * Responsible for gathering context, search results, vector embeddings, and knowledge graph relations. + */ + +class WorkspaceBrain { + constructor(agent) { + this.agent = agent; + } + + /** + * Gather structured workspace facts for a user query + * @param {string} query + * @param {object} context - { activeNotePath, activeNoteContent } + * @returns {Promise} - Normalized WorkspaceFacts payload + */ + async getWorkspaceFacts(query, context = {}) { + const facts = { + activeNote: null, + keywordResults: [], + semanticResults: [], + graphRelations: [], + tasks: [] + }; + + const activePath = context.activeNotePath || context.currentFile || null; + + // 1. Capture active open note context + if (activePath) { + let activeContent = context.activeNoteContent || null; + if (!activeContent && this.agent.documentService) { + try { + activeContent = this.agent.documentService.getDocumentContent(activePath); + } catch { + // Non-fatal if unreadable + } + } + facts.activeNote = { + path: activePath, + content: activeContent ? (activeContent.length > 4000 ? activeContent.slice(0, 4000) + '\n...(truncated)' : activeContent) : null + }; + } + + // 2. Query Hybrid/Semantic Retriever if available + if (this.agent.contextEngine?.hybridRetriever) { + try { + const hybridRes = await this.agent.contextEngine.hybridRetriever.search(query, activePath, 5); + if (hybridRes && Array.isArray(hybridRes)) { + facts.semanticResults = hybridRes.map(r => ({ + filePath: r.note_path || r.filePath || r.path, + snippet: r.snippet || r.content || '', + score: r.score + })); + } + } catch (err) { + console.warn('[WorkspaceBrain] ContextEngine hybrid retrieval skipped:', err.message); + } + } + + // 3. Query Knowledge Graph relation hops if available + if (this.agent.contextEngine?.graphRetriever && query) { + try { + const relations = this.agent.contextEngine.graphRetriever.traverse(query, 2); + if (relations && Array.isArray(relations)) { + facts.graphRelations = relations.map(rel => ({ + source: rel.from_path || rel.source_id, + target: rel.to_path || rel.target_id, + type: rel.relation || rel.type, + evidence: rel.raw_sentence || null + })); + } + } catch (err) { + console.warn('[WorkspaceBrain] Graph traversal skipped:', err.message); + } + } + + return facts; + } +} + +module.exports = WorkspaceBrain; diff --git a/ai/core/system_prompt.md b/ai/core/system_prompt.md index 39806244..fa6dd162 100644 --- a/ai/core/system_prompt.md +++ b/ai/core/system_prompt.md @@ -1,78 +1,48 @@ # Notely AI Assistant System Instructions -You are the core AI intelligence engine for **Notely**, a modern, local-first markdown note-taking and knowledge-base application. Your goal is to help users manage, search, analyze, and expand their notes, tasks, and semantic graph relationships. +You are the intelligent, human-like AI partner for **Notely**, a modern, local-first markdown knowledge-base application. Your goal is to converse naturally with the user as a sharp, empathetic, and knowledgeable thought partner to help them explore, connect, organize, and synthesize their workspace notes. --- -## 1. Identity & Personality -- **Core Persona:** Professional, concise, technically precise, and friendly developer/knowledge assistant. -- **Tone:** Clear and direct. Avoid excessive pleasantries or conversational filler unless asked. -- **Medium:** Respond in clean GitHub Flavored Markdown (GFM). Use bolding, bullet points, checklists, and codeblocks where appropriate. +## 1. Persona & Conversation Style (Human-Like & Natural) +- **Natural Human Tone:** Speak like a helpful, thoughtful pair programmer and personal knowledge assistant. Be direct, clear, warm, and engaging. +- **NO Tool Narration (STRICT):** **NEVER** expose internal technical tool mechanics to the user. Do NOT say *"I can run tool X"*, *"I executed search_notes"*, *"Based on tool output"*, or *"Let me call a function"*. Execute tools silently behind the scenes and synthesize the answer directly and fluently as part of the conversation. +- **Context Awareness:** Act as if you naturally know the workspace context retrieved. Do not explain *how* you retrieved information. +- **Markdown Output:** Respond in clean GitHub Flavored Markdown (GFM). Use bolding, bullet points, checklists, and codeblocks where appropriate. --- -## 2. Workspace & Context Integration -You have access to the user's local workspace context: -- **Workspace Folder:** The root path where all notes are stored. -- **Current Open Note:** The path of the note currently active in the editor. -- **Chat History:** The recent messages exchanged in this conversation thread. +## 2. Strict Note Modification Safeguards +- **Existing Notes are READ-ONLY:** You must **NEVER** update, edit, overwrite, rename, or delete existing notes in the user's workspace. +- **Creating New Notes ONLY (`create_note`):** You may ONLY create **NEW** notes (`create_note`) when the user explicitly requests you to draft or save a new note. If a note file with that name already exists, do not overwrite it. --- -## 3. Guidelines for Tool Usage - -### A. General Protocol -- You are equipped with tools to search notes, retrieve tasks, explore connections, and inspect note contents. -- **Run tools only when necessary.** Do not run a tool if the answer can be derived from the existing conversation history. - -### B. Tool Pruning & Redundancy Guardrails -- **CRITICAL:** If the user's message is a follow-up query (e.g., asking "which one", "suggest one", "why", "explain further", "first", "second") and the necessary information (like tasks or search results) was already fetched and is visible in the conversation history, **do NOT call the tool again.** Use the existing history context to formulate your response. -- Do not repeat lists of items or recapping the same information multiple times unless explicitly requested. - -### C. Specific Tools -- `read_note`: Retrieve the contents of a specific note file in the workspace. Use `startLine` and `maxLines` to paginate/limit output. -- `create_note`: Create a new note with a title, initial content, and target folder in the workspace. -- `move_note`: Move or rename a note within the workspace. -- `get_tasks`: Extract checklist tasks across notes in the workspace. Supports filtering by status (open, completed, all) and note path. -- `search_notes`: Search note files matching a query string in the workspace. -- `semantic_search`: Find semantically similar notes using vector embeddings. -- `hybrid_search`: Perform a hybrid search combining full-text keyword search and semantic vector similarity. -- `get_graph`: Traverse knowledge graph relationships for a given note. -- `find_clusters`: Get semantic topic clusters across the workspace. -- `knowledge_status`: Retrieve the indexing and health status of the knowledge engines. -- `reindex_knowledge`: Trigger background reindexing of the knowledge graph and embeddings. -- `workspace_stats`: Get workspace health, document counts, and storage metrics. -- `recent_activity`: Get a list of recently modified notes in the workspace. -- `web_search`: Search the live web for external topics, documentation, news, or reference information. -- `fetch_url`: Fetch and read the main text content of a public web page URL. +## 3. Tool Usage Protocol (Silent & Background) +- Tools execute invisibly to retrieve facts or create new notes. +- **Tool Pruning:** If a follow-up query can be answered from recent conversation context, do NOT trigger redundant searches. +- **Available Tool Capabilities (Internal Only):** + - `read_note`: Inspect note file content. + - `search_notes`: Search notes by keyword. + - `semantic_search`: Find notes by semantic vector similarity. + - `explore_graph`: Traverse multi-hop knowledge graph relationships and sentence evidence. + - `get_tasks`: Retrieve checklist tasks across notes. + - `get_people`: Find mentioned people or authors. + - `get_current_date`: Get current date and time. + - `create_note`: Create a brand new note (only when requested). --- -## 4. Formatting Output -- **Clickable File Links (CRITICAL):** Whenever you list, group, cite, or refer to notes, task headings, or specific files, you MUST format every note title/path as an explicit Markdown link using the `file:///` scheme with the full path: `[filename.md](file:///absolute/path/to/filename.md)` or `[filename.md (line 12)](file:///absolute/path/to/filename.md#L12)`. - - **Correct:** `### [ai-and-search.md](file:///C:/Users/.../ai-and-search.md)` - - **Incorrect:** `ai-and-search.md:` or `ai-and-search.md` (Never output plain text file names without link wrappers). -- **Task Formatting:** Display tasks as interactive checklists using markdown task lists. Format unchecked/open tasks as `- [ ]`, checked/completed tasks as `- [x]`, and in-progress tasks as `- [/]`. -- **Code Blocks:** When outputting code, always specify the language in the fenced code block (e.g., ```javascript) to enable syntax highlighting and editing. +- **Zero Fabrication & Mandatory Links (STRICT):** + - EVERY note mention MUST include an explicit, clickable `[filename.md](file:///path/to/filename.md)` link. + - NEVER invent or imagine hypothetical note titles (such as *"Excalidraw Basics"* or *"Excalidraw for Mind Mapping"*). + - If search tools return no matching note files for a user's topic, state explicitly and immediately: *"I searched your workspace notes, but I couldn't find any note mentioning [topic]."* Do not pretend notes exist when search returns empty results. --- -## 5. Factuality and Anti-Hallucination Guardrails (CRITICAL) -- **Zero Fabrication:** Never invent, assume, or guess the contents of any note, task, tag, or connection. If a file has not been explicitly retrieved or read via `read_note`/`read_pdf` in this turn, you must treat its contents as 100% unknown. -- **Strict Citation Source Verification:** Only link to file paths or line numbers that were explicitly returned in the tool outputs of the current session. Do not fabricate or predict folder names, filenames, or links. -- **Explicit Knowledge Boundaries:** If search results or tool outputs return empty, state clearly: "I could not find any matching information in your workspace." Do not suggest hypothetical answers, generic templates, or workspace speculation. -- **Note Content Integrity:** When summarizing, searching, or extracting tasks, refer strictly to facts present in the retrieved note contents. Do not inject external assumptions, hypothetical tasks, or generic guidelines. -- **Tool Output Grounding:** Your responses must be 100% grounded in the context provided by the active note or tool outputs. Any claim not supported by retrieved context is considered a hallucination and is strictly prohibited. -- **Master Switch:** Respect user configurations. If the AI service is disabled, model limits are reached, or API keys are missing, state the issue directly and advise the user how to configure them in the settings panel. - -### Forbidden Actions (Zero-Tolerance Policy) -1. **NEVER** mention any note, file, or folder path that was not explicitly returned by a tool or defined in the current context. -2. **NEVER** speculate about what tasks the user "might" have or invent task checklist items to make lists look complete. -3. **NEVER** invent links between notes (wikilinks) unless the graph retriever explicitly confirms the relationship exists. -4. **NEVER** use pre-training knowledge to describe workspace content. All workspace information must come strictly from the live tool outputs. -5. **NEVER** attempt to access, refer to, or edit any file paths located outside the active workspace root. All operations are strictly sandboxed within the workspace boundaries. - -### Strict Verification Loop (Mental Checklist) -1. Is every note path cited as a `file:///` link present in the raw tool outputs? If not, delete it. -2. Is every task checklist item listed verbatim from the tool result? If not, delete it. -3. Am I assuming the existence of any files? If so, re-write to state lack of information. +## 5. Dynamic Context-Sensitive Domain Disambiguation +- **Dynamic Domain Inference:** Dynamically infer the domain of the user's workspace notes (e.g., software engineering, biology, finance, creative writing). +- **Context-Aware Term Interpretation:** Interpret ambiguous terms (such as "Mermaid", "Python", "Cell", "Model", "Pipeline") according to the domain context of the user's active workspace notes. + - If workspace notes discuss software engineering or diagramming: Interpret "Mermaid" as **Mermaid.js** syntax (` ```mermaid `) for flowcharts, sequence diagrams, and architecture charts. + - If workspace notes discuss biology or folklore: Interpret "Mermaid" as the marine biological or mythological topic. +- **App Diagram Features:** Notely natively renders Mermaid.js (` ```mermaid `) code blocks and Excalidraw diagrams. If asked about unsupported external tools (e.g., Draw.io), explain native diagram options (Mermaid.js / Excalidraw) or recommend embedding SVG/PNG files. diff --git a/ai/diagnostics/AgentHarness.js b/ai/diagnostics/AgentHarness.js new file mode 100644 index 00000000..95240fd5 --- /dev/null +++ b/ai/diagnostics/AgentHarness.js @@ -0,0 +1,79 @@ +/** + * AgentHarness - Production Evaluation & Diagnostic Harness for Notely AI + * Measures tool selection precision, grounding accuracy, zero-jargon compliance, and retrieval performance. + */ + +const GroundingEngine = require('../core/GroundingEngine'); + +class AgentHarness { + constructor(agent) { + this.agent = agent; + } + + /** + * Run evaluation scenario suite against AI Agent + * @param {Array<{ id: string, query: string, expectedIntent?: string, expectedKeywords?: string[] }>} scenarios + * @returns {Promise} - Comprehensive evaluation metrics + */ + async runEvaluation(scenarios = []) { + const results = []; + let totalLatencyMs = 0; + let totalTokens = 0; + let passedGrounding = 0; + let zeroJargonCompliant = 0; + + for (const scenario of scenarios) { + const startTime = Date.now(); + try { + const queryRes = await this.agent.query(scenario.query); + const latencyMs = Date.now() - startTime; + + totalLatencyMs += latencyMs; + totalTokens += queryRes.tokensUsed || 0; + + // Check citation grounding + const groundingCheck = GroundingEngine.verifyCitations(queryRes.result); + if (groundingCheck.brokenCitations === 0) { + passedGrounding++; + } + + // Check zero-jargon compliance (no tool names or internal query terms exposed) + const lowerRes = String(queryRes.result || '').toLowerCase(); + const containsJargon = lowerRes.includes('search_notes') || lowerRes.includes('read_note') || lowerRes.includes('cosine similarity'); + if (!containsJargon) { + zeroJargonCompliant++; + } + + results.push({ + id: scenario.id, + query: scenario.query, + success: queryRes.success, + latencyMs, + tokensUsed: queryRes.tokensUsed || 0, + grounding: groundingCheck, + zeroJargonCompliant: !containsJargon, + trace: queryRes.trace || [] + }); + } catch (err) { + results.push({ + id: scenario.id, + query: scenario.query, + success: false, + error: err.message + }); + } + } + + const count = scenarios.length || 1; + return { + totalScenarios: scenarios.length, + averageLatencyMs: totalLatencyMs / count, + totalTokensUsed: totalTokens, + groundingScore: (passedGrounding / count) * 100, + zeroJargonScore: (zeroJargonCompliant / count) * 100, + scenarioResults: results + }; + } +} + +module.exports = AgentHarness; diff --git a/ai/embeddings/EmbeddingDB.js b/ai/embeddings/EmbeddingDB.js index b57c74a6..1966423a 100644 --- a/ai/embeddings/EmbeddingDB.js +++ b/ai/embeddings/EmbeddingDB.js @@ -301,18 +301,45 @@ class EmbeddingDB { searchTextFallback(query, topK = 5) { try { - const stmt = this.db.prepare(` - SELECT note_path, content - FROM chunks - WHERE content LIKE ? - LIMIT ? - `); - const results = stmt.all(`%${query}%`, topK); - return results.map(r => ({ - note_path: r.note_path, - content: r.content, - score: 0.5 - })); + if (!this.db) return []; + const cleanStr = String(query || '').toLowerCase(); + // Extract keywords >= 3 chars, ignoring stop words + const stopWords = new Set(['what', 'do', 'we', 'have', 'oin', 'the', 'and', 'for', 'with', 'this', 'that', 'from', 'you', 'your']); + const terms = cleanStr + .replace(/[^a-z0-9\s_-]/g, '') + .split(/\s+/) + .filter(w => w.length >= 3 && !stopWords.has(w)); + + if (terms.length === 0) { + terms.push(cleanStr); + } + + const results = []; + const seenPaths = new Set(); + + // Search each keyword across content and note_path + for (const term of terms) { + const stmt = this.db.prepare(` + SELECT note_path, content + FROM chunks + WHERE LOWER(content) LIKE ? OR LOWER(note_path) LIKE ? + LIMIT ? + `); + const rows = stmt.all(`%${term}%`, `%${term}%`, topK); + for (const r of rows) { + if (!seenPaths.has(r.note_path)) { + seenPaths.add(r.note_path); + results.push({ + note_path: r.note_path, + content: r.content, + score: 0.6 + }); + } + } + if (results.length >= topK) break; + } + + return results; } catch (err) { log.error('Text search fallback failed:', err.message); return []; diff --git a/ai/graph/GraphDB.js b/ai/graph/GraphDB.js index b13f79c6..587b59f6 100644 --- a/ai/graph/GraphDB.js +++ b/ai/graph/GraphDB.js @@ -168,13 +168,15 @@ class GraphDB { isNoteUpToDate(notePath, mtimeMs) { if (!this.db || !notePath) return false; try { - const crypto = require('crypto'); - const normPath = String(notePath || '').trim().toLowerCase(); - const entityId = `ent-${crypto.createHash('sha256').update(`note:${normPath}`).digest('hex').slice(0, 16)}`; - const row = this.db.prepare('SELECT updated_at FROM entities WHERE id = ?').get(entityId); + const normPath = String(notePath || '').trim(); + const row = this.db.prepare('SELECT updated_at FROM entities WHERE note_path = ? OR LOWER(note_path) = LOWER(?) LIMIT 1').get(normPath, normPath); if (!row || !row.updated_at) return false; - const dbTime = new Date(row.updated_at).getTime(); + // SQLite datetime('now') stores UTC string 'YYYY-MM-DD HH:MM:SS' + const utcString = row.updated_at.includes('T') ? row.updated_at : row.updated_at.replace(' ', 'T') + 'Z'; + const dbTime = new Date(utcString).getTime(); + if (isNaN(dbTime)) return false; + return dbTime >= (mtimeMs - 1000); // 1-second tolerance } catch { return false; @@ -332,14 +334,26 @@ class GraphDB { } /** - * Traversal by note path or entity ID + * Traversal by note path or entity ID/name with evidence context */ traversePathOrId(identifier, maxDepth = 2) { - if (!this.db) return []; + if (!this.db || !identifier) return []; let startEntity = this.getEntityByPath(identifier); if (!startEntity) { - const stmt = this.db.prepare('SELECT * FROM entities WHERE id = ? LIMIT 1'); - startEntity = stmt.get(identifier); + try { + const stmt = this.db.prepare('SELECT * FROM entities WHERE LOWER(name) = LOWER(?) OR id = ? LIMIT 1'); + startEntity = stmt.get(String(identifier).trim(), identifier); + } catch { + /* ignore lookup error */ + } + } + if (!startEntity) { + try { + const stmt = this.db.prepare('SELECT * FROM entities WHERE LOWER(name) LIKE LOWER(?) LIMIT 1'); + startEntity = stmt.get(`%${String(identifier).trim()}%`); + } catch { + /* ignore lookup error */ + } } if (!startEntity) return []; @@ -349,16 +363,28 @@ class GraphDB { return edges.map(e => { const srcNode = nodeMap.get(e.source_id); const tgtNode = nodeMap.get(e.target_id); + let evidenceText = null; + if (e.evidence_id) { + try { + const ev = this.db.prepare('SELECT raw_sentence FROM evidence WHERE id = ?').get(e.evidence_id); + evidenceText = ev?.raw_sentence || null; + } catch { + /* ignore evidence lookup error */ + } + } return { from_id: e.source_id, from_name: srcNode?.name || e.source_id, + from_type: srcNode?.type || 'Entity', from_path: srcNode?.note_path || srcNode?.name || e.source_id, relation: e.type, to_id: e.target_id, to_name: tgtNode?.name || e.target_id, + to_type: tgtNode?.type || 'Entity', to_path: tgtNode?.note_path || tgtNode?.name || e.target_id, weight: e.weight || 1.0, - confidence: e.confidence || 1.0 + confidence: e.confidence || 1.0, + evidence: evidenceText }; }); } diff --git a/ai/memory/PersonaDB.js b/ai/memory/PersonaDB.js index 8ebd128b..ecf82fa4 100644 --- a/ai/memory/PersonaDB.js +++ b/ai/memory/PersonaDB.js @@ -1,11 +1,13 @@ const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const { createLogger } = require('../core/logger'); +const { PersonaStandard } = require('../personas/PersonaStandard'); const log = createLogger('PersonaDB'); // Default fields required in frontmatter for persona md files -const REQUIRED_FIELDS = ['name', 'description', 'type', 'version']; +const REQUIRED_FIELDS = ['name', 'description', 'version']; class PersonaDB { constructor(appDataDir) { @@ -46,27 +48,35 @@ class PersonaDB { id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, - file_path TEXT, + file_path TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'custom', - version TEXT, + version TEXT DEFAULT '1.0.0', avatar TEXT DEFAULT '👤', - prompt TEXT NOT NULL, + content_hash TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); `); - // Migration: Add avatar column if it does not exist (older databases) + // Migration: Add content_hash column if it does not exist (older databases) try { - this.db.exec("ALTER TABLE personas ADD COLUMN avatar TEXT DEFAULT '👤'"); + this.db.exec("ALTER TABLE personas ADD COLUMN content_hash TEXT"); } catch { // Column already exists, ignore error } } + static computeHash(content) { + return crypto.createHash('sha256').update(content || '', 'utf8').digest('hex'); + } + _seedBuiltins() { const now = new Date().toISOString(); - const templatesDir = path.join(__dirname, '..', 'personas'); + let templatesDir = path.join(__dirname, '..', '..', 'resources', 'prompts', 'personas'); + + if (!fs.existsSync(templatesDir)) { + templatesDir = path.join(__dirname, '..', 'personas'); + } if (!fs.existsSync(templatesDir)) { log.warn(`Packaged personas templates directory not found at: ${templatesDir}`); @@ -75,29 +85,34 @@ class PersonaDB { const files = fs.readdirSync(templatesDir).filter(f => f.endsWith('.md')); const insert = this.db.prepare( - `INSERT OR REPLACE INTO personas (id, name, description, file_path, type, version, avatar, prompt, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO personas (id, name, description, file_path, type, version, avatar, content_hash, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name=excluded.name, description=excluded.description, file_path=excluded.file_path, + type=excluded.type, version=excluded.version, avatar=excluded.avatar, content_hash=excluded.content_hash, updated_at=excluded.updated_at` ); for (const file of files) { const srcPath = path.join(templatesDir, file); - const id = file.slice(0, -3); // e.g. 'default', 'creative' + const id = file.slice(0, -3); // e.g. 'general', 'brainstorming' const destPath = path.join(this.personasDir, file); try { - // ALWAYS overwrite default personas in local appDataDir/personas/ with packaged template versions + // ALWAYS copy/overwrite builtin template file to appDataDir/personas/ fs.copyFileSync(srcPath, destPath); - const { meta, prompt } = PersonaDB.parsePersonaFile(destPath); + const rawContent = fs.readFileSync(destPath, 'utf8'); + const contentHash = PersonaDB.computeHash(rawContent); + const { meta } = PersonaDB.parsePersonaFile(destPath); insert.run( id, meta.name || id, meta.description || '', destPath, 'builtin', - meta.version || '1.0', + meta.version || '1.0.0', meta.avatar || '👤', - prompt, + contentHash, now, now ); @@ -108,11 +123,50 @@ class PersonaDB { } list() { - return this.db.prepare('SELECT * FROM personas ORDER BY type DESC, name ASC').all(); + const rows = this.db.prepare('SELECT * FROM personas ORDER BY type DESC, name ASC').all(); + return rows.map(r => this._hydratePersonaRow(r)).filter(Boolean); } get(id) { - return this.db.prepare('SELECT * FROM personas WHERE id = ?').get(id) || null; + const row = this.db.prepare('SELECT * FROM personas WHERE id = ?').get(id); + if (!row) return null; + return this._hydratePersonaRow(row); + } + + _hydratePersonaRow(row) { + if (!row || !row.file_path || !fs.existsSync(row.file_path)) { + return { + ...row, + prompt: '', + meta: {} + }; + } + try { + const rawContent = fs.readFileSync(row.file_path, 'utf8'); + const contentHash = PersonaDB.computeHash(rawContent); + const { meta, prompt } = PersonaDB.parsePersonaFile(row.file_path); + return { + ...row, + ...meta, + id: row.id, + name: meta.name || row.name, + description: meta.description || row.description, + avatar: meta.avatar || row.avatar || '👤', + type: row.type, + version: meta.version || row.version || '1.0.0', + file_path: row.file_path, + content_hash: contentHash, + prompt, + meta + }; + } catch (err) { + log.warn(`Failed to parse persona file ${row.file_path}:`, err.message); + return { + ...row, + prompt: '', + meta: {} + }; + } } save(persona) { @@ -127,35 +181,27 @@ class PersonaDB { filePath = path.join(this.personasDir, `${persona.id}.md`); } - // Update SQLite DB + // Write full stitched markdown to disk first + const markdownContent = PersonaStandard.formatPersonaMarkdown(persona); + fs.writeFileSync(filePath, markdownContent, 'utf8'); + + const contentHash = PersonaDB.computeHash(markdownContent); + // Parse back to confirm validity + const { meta } = PersonaDB.parsePersonaFile(filePath); + + // Upsert into SQLite DB registry this.db.prepare( - `INSERT INTO personas (id, name, description, file_path, type, version, avatar, prompt, created_at, updated_at) + `INSERT INTO personas (id, name, description, file_path, type, version, avatar, content_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, file_path=excluded.file_path, - type=excluded.type, version=excluded.version, avatar=excluded.avatar, prompt=excluded.prompt, updated_at=excluded.updated_at` + type=excluded.type, version=excluded.version, avatar=excluded.avatar, content_hash=excluded.content_hash, updated_at=excluded.updated_at` ).run( - persona.id, persona.name, persona.description ?? '', filePath, - persona.type ?? 'custom', persona.version ?? '1.0', persona.avatar ?? '👤', persona.prompt, now, now + persona.id, meta.name || persona.name, meta.description || persona.description || '', filePath, + persona.type ?? 'custom', meta.version || persona.version || '1.0.0', meta.avatar || persona.avatar || '👤', contentHash, now, now ); - // Sync to disk - try { - const content = [ - '---', - `name: "${persona.name}"`, - `description: "${persona.description ?? ''}"`, - `type: "${persona.type ?? 'custom'}"`, - `version: "${persona.version ?? '1.0'}"`, - `avatar: "${persona.avatar || '👤'}"`, - '---', - '', - persona.prompt - ].join('\n'); - fs.writeFileSync(filePath, content, 'utf8'); - } catch (err) { - log.error(`Failed to write persona changes to disk at ${filePath}:`, err); - } + return this.get(persona.id); } delete(id) { @@ -207,7 +253,12 @@ class PersonaDB { try { const { meta, prompt } = PersonaDB.parsePersonaFile(srcPath); - const id = meta.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); + const id = meta.id || meta.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); + const existing = this.get(id); + if (existing) { + throw new Error(`A persona with ID or name "${id}" already exists. Rename or change ID to import.`); + } + const destPath = path.join(this.personasDir, `${id}.md`); if (srcPath !== destPath) { fs.copyFileSync(srcPath, destPath); @@ -219,16 +270,22 @@ class PersonaDB { description: meta.description, file_path: destPath, type: 'custom', - version: meta.version, + version: meta.version || '1.0.0', avatar: meta.avatar || '👤', - prompt + tone: meta.tone, + verbosity: meta.verbosity, + responseStructure: meta.responseStructure, + clarificationStrategy: meta.clarificationStrategy, + preferredExamples: meta.preferredExamples, + fallbackBehaviour: meta.fallbackBehaviour, + owner: meta.owner, + systemInstructions: prompt }); return { id, name: meta.name }; } catch (err) { log.error(`Failed to import persona from file: ${srcPath}`, err); - // Return a placeholder representation - return { id: 'invalid', name: 'Invalid Persona File' }; + throw err; } } @@ -236,17 +293,21 @@ class PersonaDB { const row = this.get(id); if (!row) throw new Error(`Persona "${id}" not found.`); - const content = [ - '---', - `name: "${row.name}"`, - `description: "${row.description}"`, - `type: "${row.type}"`, - `version: "${row.version}"`, - `avatar: "${row.avatar || '👤'}"`, - '---', - '', - row.prompt - ].join('\n'); + const content = PersonaStandard.formatPersonaMarkdown({ + id: row.id, + name: row.name, + description: row.description, + version: row.version, + avatar: row.avatar, + tone: row.tone, + verbosity: row.verbosity, + responseStructure: row.responseStructure, + clarificationStrategy: row.clarificationStrategy, + preferredExamples: row.preferredExamples, + fallbackBehaviour: row.fallbackBehaviour, + owner: row.owner, + systemInstructions: row.prompt + }); fs.writeFileSync(destPath, content, 'utf8'); return destPath; diff --git a/ai/personas/PersonaManager.js b/ai/personas/PersonaManager.js new file mode 100644 index 00000000..4e158261 --- /dev/null +++ b/ai/personas/PersonaManager.js @@ -0,0 +1,150 @@ +/** + * PersonaManager - Registry and resolver for Notely AI built-in and user-created custom personas. + */ + +const path = require('path'); +const PromptLoader = require('../prompts/PromptLoader'); +const { PersonaStandard } = require('./PersonaStandard'); +const { createLogger } = require('../core/logger'); + +const log = createLogger('PersonaManager'); + +class PersonaManager { + /** + * @param {PromptLoader} promptLoader + * @param {object} [personaDB=null] + * @param {string} [appDataDir=null] - Specific user app data directory + */ + constructor(promptLoader = null, personaDB = null, appDataDir = null) { + this.loader = promptLoader || new PromptLoader(); + this.personaDB = personaDB; + this.userPersonasDir = appDataDir ? path.join(appDataDir, 'personas') : (personaDB?.personasDir || null); + this.registeredPersonas = new Map(); + } + + /** + * Set dedicated user personas app directory + * @param {string} appDataDir + */ + setUserPersonasDir(appDataDir) { + this.userPersonasDir = appDataDir ? path.join(appDataDir, 'personas') : null; + } + + /** + * Load and validate a persona by ID strictly from designated app locations + * @param {string} personaId + * @returns {object} + */ + getPersona(personaId = 'general') { + if (this.registeredPersonas.has(personaId)) { + return this.registeredPersonas.get(personaId); + } + + // 1. Try loading from PersonaDB if connected + if (this.personaDB) { + try { + const dbRow = this.personaDB.get(personaId); + if (dbRow) { + const personaObj = PersonaStandard.normalize({ + id: dbRow.id, + name: dbRow.name, + description: dbRow.description, + type: dbRow.type, + version: dbRow.version, + avatar: dbRow.avatar, + systemInstructions: dbRow.prompt + }); + this.registeredPersonas.set(personaId, personaObj); + return personaObj; + } + } catch (err) { + log.warn(`PersonaDB lookup for '${personaId}' failed:`, err.message); + } + } + + // 2. Load from PromptLoader (restricted strictly to user app personas directory and packaged built-ins) + const userDir = this.userPersonasDir || this.personaDB?.personasDir || null; + const loaded = this.loader.loadPersona(personaId, userDir); + if (loaded) { + const normalized = PersonaStandard.normalize({ + id: loaded.id, + name: loaded.metadata.name || loaded.id, + description: loaded.metadata.description || '', + tone: loaded.metadata.tone || 'direct, clear, warm', + verbosity: loaded.metadata.verbosity || 'balanced', + responseStructure: loaded.metadata.responseStructure || '', + systemInstructions: loaded.body, + ...loaded.metadata + }); + this.registeredPersonas.set(personaId, normalized); + return normalized; + } + + // 3. Fallback to default persona standard + const defaults = PersonaStandard.getDefaultPersonas(); + const fallback = defaults.find(p => p.id === personaId) || defaults[0]; + return PersonaStandard.normalize(fallback); + } + + /** + * Create and register a custom persona using the standard deterministic template form + * @param {object} personaData + * @param {object} [targetPersonaDB] + * @returns {object} + */ + createCustomPersona(personaData, targetPersonaDB = null) { + const db = targetPersonaDB || this.personaDB; + const normalized = PersonaStandard.normalize(personaData); + + if (db) { + db.save({ + id: normalized.id, + name: normalized.name, + description: normalized.description, + type: 'custom', + version: normalized.version, + avatar: normalized.avatar || '👤', + prompt: normalized.systemInstructions, + tone: normalized.tone, + verbosity: normalized.verbosity, + responseStructure: normalized.responseStructure + }); + } + + this.registeredPersonas.set(normalized.id, normalized); + log.info(`Created custom persona '${normalized.id}' (${normalized.name})`); + return normalized; + } + + /** + * Get list of all available persona IDs (built-in + DB custom) + * @returns {string[]} + */ + listAvailablePersonas() { + const builtins = [ + 'general', + 'software-engineer', + 'technical-architect', + 'documentation-writer', + 'research-assistant', + 'brainstorming', + 'tutor', + 'meeting-assistant', + 'knowledge-librarian' + ]; + + if (this.personaDB) { + try { + const rows = this.personaDB.list(); + const customIds = rows.map(r => r.id); + return Array.from(new Set([...builtins, ...customIds])); + } catch { + return builtins; + } + } + + return builtins; + } +} + +module.exports = PersonaManager; diff --git a/ai/personas/PersonaStandard.js b/ai/personas/PersonaStandard.js new file mode 100644 index 00000000..befb1fac --- /dev/null +++ b/ai/personas/PersonaStandard.js @@ -0,0 +1,148 @@ +/** + * PersonaStandard - Schema specification, validator, and deterministic Markdown formatter for Notely AI personas. + */ + +const DEFAULT_PERSONAS = [ + { + id: 'general', + name: 'General Assistant', + description: 'Balanced, thoughtful knowledge teammate.', + tone: 'direct, clear, warm', + verbosity: 'balanced', + responseStructure: 'Clear introduction -> Structured evidence summary -> Actionable conclusions', + systemInstructions: 'Act as a thoughtful pair programmer and knowledge partner for the workspace notes.' + }, + { + id: 'software-engineer', + name: 'Software Engineer', + description: 'Focused on code analysis, refactoring, implementation patterns, and debugging.', + tone: 'analytical, precise, practical', + verbosity: 'concise', + responseStructure: 'Problem Statement -> Code Solution -> Edge Cases -> Verification', + systemInstructions: 'Act as a senior pair programmer evaluating workspace notes with an emphasis on code quality.' + }, + { + id: 'technical-architect', + name: 'Technical Architect', + description: 'Focuses on system design, APIs, data flow, and architecture trade-offs.', + tone: 'analytical, structured, strategic', + verbosity: 'detailed', + responseStructure: 'Overview -> Key Components -> Tradeoffs -> Recommendations', + systemInstructions: 'Analyze notes with an emphasis on technical architecture, scalability, and code structure.' + }, + { + id: 'research-assistant', + name: 'Research Assistant', + description: 'Synthesizes notes, identifies research gaps, and connects concepts.', + tone: 'curious, analytical, thorough', + verbosity: 'thorough', + responseStructure: 'Key Insights -> Connected Notes -> Knowledge Gaps -> Suggested Next Steps', + systemInstructions: 'Synthesize concepts across notes to highlight hidden relationships and open questions.' + } +]; + +class PersonaStandard { + /** + * Validate persona object against standard schema + * @param {object} personaObj + * @returns {boolean} + */ + static validate(personaObj) { + if (!personaObj || typeof personaObj !== 'object') return false; + const hasInstructions = Boolean( + personaObj.systemInstructions || personaObj.prompt || personaObj.body + ); + return Boolean( + personaObj.id && + personaObj.name && + personaObj.tone && + hasInstructions + ); + } + + /** + * Normalize input persona object fields + * @param {object} input + * @returns {object} + */ + static normalize(input = {}) { + const id = (input.id || input.name || 'custom-persona') + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, ''); + + const expertise = Array.isArray(input.expertise) + ? input.expertise + : (typeof input.expertise === 'string' ? input.expertise.split(',').map(s => s.trim()) : ['Workspace Knowledge']); + + return { + id, + name: input.name || id, + version: input.version || '1.0.0', + description: input.description || 'Custom user defined persona', + purpose: input.purpose || input.description || 'Assist user with workspace notes', + expertise, + tone: input.tone || 'direct, clear, helpful', + verbosity: input.verbosity || 'balanced', + responseStructure: input.responseStructure || 'Summary -> Detailed Evidence -> Recommendations', + clarificationStrategy: input.clarificationStrategy || 'Ask direct questions when intent is ambiguous.', + preferredExamples: input.preferredExamples || 'Relevant note snippets and examples.', + fallbackBehaviour: input.fallbackBehaviour || 'Summarize available evidence.', + owner: input.owner || 'User', + schemaVersion: input.schemaVersion || '1.0.0', + systemInstructions: input.systemInstructions || input.prompt || input.body || 'Act as a helpful knowledge partner.' + }; + } + + /** + * Format any persona object into deterministic, standardized Markdown with frontmatter + * @param {object} personaData + * @returns {string} + */ + static formatPersonaMarkdown(personaData) { + const p = this.normalize(personaData); + const expertiseStr = `[${p.expertise.join(', ')}]`; + + const frontmatter = [ + '---', + `id: ${p.id}`, + `name: "${p.name}"`, + `version: ${p.version}`, + `description: "${p.description}"`, + `purpose: "${p.purpose}"`, + `expertise: ${expertiseStr}`, + `tone: "${p.tone}"`, + `verbosity: ${p.verbosity}`, + `responseStructure: "${p.responseStructure}"`, + `clarificationStrategy: "${p.clarificationStrategy}"`, + `preferredExamples: "${p.preferredExamples}"`, + `fallbackBehaviour: "${p.fallbackBehaviour}"`, + `owner: "${p.owner}"`, + `schemaVersion: ${p.schemaVersion}`, + '---' + ].join('\n'); + + const body = [ + `# Persona: ${p.name}`, + '', + '## Role Definition & Mindset', + p.systemInstructions, + '', + '## Communication Style & Tone', + `- Tone: ${p.tone}`, + `- Verbosity: ${p.verbosity}`, + `- Preferred Structure: ${p.responseStructure}` + ].join('\n'); + + return `${frontmatter}\n\n${body}\n`; + } + + static getDefaultPersonas() { + return DEFAULT_PERSONAS; + } +} + +module.exports = { + PersonaStandard, + DEFAULT_PERSONAS +}; diff --git a/ai/personas/creative.md b/ai/personas/creative.md deleted file mode 100644 index 10307d98..00000000 --- a/ai/personas/creative.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: "Creative Writer" -description: "Narrative-focused, metaphor-rich brainstorming assistant." -type: "builtin" -version: "1.0" -avatar: "🎨" ---- - -You are a creative writing assistant in Notely. Help the user explore ideas with vivid language, compelling metaphors, narrative flow, and imaginative brainstorming. Embrace unconventional angles. diff --git a/ai/personas/default.md b/ai/personas/default.md deleted file mode 100644 index 74fb30ac..00000000 --- a/ai/personas/default.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: "Default Assistant" -description: "Balanced general-purpose assistant." -type: "builtin" -version: "1.0" -avatar: "💬" ---- - -You are a helpful assistant integrated into Notely, a markdown note-taking app. Answer clearly and concisely, referencing workspace content when relevant. diff --git a/ai/personas/researcher.md b/ai/personas/researcher.md deleted file mode 100644 index b4aaa50f..00000000 --- a/ai/personas/researcher.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: "Academic Researcher" -description: "Cites workspace sources and provides factual, structured analysis." -type: "builtin" -version: "1.0" -avatar: "🎓" ---- - -You are an academic research assistant in Notely. Cite workspace notes when answering. Prioritize factual accuracy, logical outlines, and structured responses. Flag uncertainty explicitly. diff --git a/ai/personas/technical.md b/ai/personas/technical.md deleted file mode 100644 index 041b6d97..00000000 --- a/ai/personas/technical.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: "Technical Analyst" -description: "Strict, logic-driven assistant for code and structured analysis." -type: "builtin" -version: "1.0" -avatar: "🔬" ---- - -You are a technical analyst assistant in Notely. Respond with precision and structure. Use code blocks, markdown tables, and strict logical reasoning. Avoid informal language. Validate assumptions explicitly. diff --git a/ai/prompts/PromptLoader.js b/ai/prompts/PromptLoader.js new file mode 100644 index 00000000..081ac143 --- /dev/null +++ b/ai/prompts/PromptLoader.js @@ -0,0 +1,172 @@ +/** + * PromptLoader - Loads, parses, validates, and caches version-controlled prompt files. + */ + +const fs = require('fs'); +const path = require('path'); +const { createLogger } = require('../core/logger'); + +const log = createLogger('PromptLoader'); + +class PromptLoader { + constructor(promptsDir = null) { + this.promptsDir = promptsDir || path.resolve(__dirname, '../../resources/prompts'); + this.cache = new Map(); + this.templateCache = new Map(); + } + + /** + * Simple YAML frontmatter parser + * @param {string} fileContent + * @returns {{ metadata: object, body: string }} + */ + parseFrontmatter(fileContent) { + if (!fileContent.startsWith('---')) { + return { metadata: {}, body: fileContent.trim() }; + } + + const endIdx = fileContent.indexOf('---', 3); + if (endIdx === -1) { + return { metadata: {}, body: fileContent.trim() }; + } + + const frontmatterText = fileContent.slice(3, endIdx).trim(); + const body = fileContent.slice(endIdx + 3).trim(); + const metadata = {}; + + const lines = frontmatterText.split('\n'); + for (const line of lines) { + const colonIdx = line.indexOf(':'); + if (colonIdx === -1) continue; + + const key = line.slice(0, colonIdx).trim(); + let value = line.slice(colonIdx + 1).trim(); + + // Handle arrays [a, b, c] + if (value.startsWith('[') && value.endsWith(']')) { + value = value + .slice(1, -1) + .split(',') + .map(s => s.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean); + } else { + // Strip quotes + value = value.replace(/^['"]|['"]$/g, ''); + } + + metadata[key] = value; + } + + return { metadata, body }; + } + + /** + * Load system prompt file by ID or relative path + * @param {string} promptId - e.g., 'base-system' or 'behavior-policy' + * @returns {{ id: string, metadata: object, body: string }} + */ + loadSystemPrompt(promptId) { + const cacheKey = `system:${promptId}`; + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey); + } + + const filePath = path.join(this.promptsDir, 'system', `${promptId}.md`); + if (!fs.existsSync(filePath)) { + log.warn(`System prompt file missing: ${filePath}`); + return { id: promptId, metadata: { version: '0.0.0' }, body: '' }; + } + + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const { metadata, body } = this.parseFrontmatter(raw); + const result = { + id: metadata.id || promptId, + metadata, + body + }; + this.cache.set(cacheKey, result); + return result; + } catch (err) { + log.error(`Failed to read prompt file ${filePath}:`, err.message); + return { id: promptId, metadata: { version: '0.0.0' }, body: '' }; + } + } + + /** + * Load persona file by ID strictly from designated app personas directory or built-in directory. + * @param {string} personaId - e.g. 'general', 'software-engineer' + * @param {string} [userPersonasDir=null] - Specific user app data personas directory (e.g. appDataDir/personas) + * @returns {{ id: string, metadata: object, body: string }} + */ + loadPersona(personaId, userPersonasDir = null) { + const cacheKey = `persona:${personaId}`; + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey); + } + + const candidatePaths = []; + if (userPersonasDir && typeof userPersonasDir === 'string') { + candidatePaths.push(path.join(userPersonasDir, `${personaId}.md`)); + } + candidatePaths.push(path.join(this.promptsDir, 'personas', `${personaId}.md`)); + + const filePath = candidatePaths.find(p => fs.existsSync(p)); + if (!filePath) { + log.warn(`Persona '${personaId}' missing in app directories.`); + return null; + } + + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const { metadata, body } = this.parseFrontmatter(raw); + const result = { + id: metadata.id || personaId, + metadata, + body + }; + this.cache.set(cacheKey, result); + return result; + } catch (err) { + log.error(`Failed to read persona file ${filePath}:`, err.message); + return null; + } + } + + /** + * Load template file by name + * @param {string} templateName - e.g. 'workspace-context' + * @returns {string} + */ + loadTemplate(templateName) { + if (this.templateCache.has(templateName)) { + return this.templateCache.get(templateName); + } + + const filePath = path.join(this.promptsDir, 'templates', `${templateName}.template`); + if (!fs.existsSync(filePath)) { + log.warn(`Template file missing: ${filePath}`); + return ''; + } + + try { + const raw = fs.readFileSync(filePath, 'utf8'); + this.templateCache.set(templateName, raw); + return raw; + } catch (err) { + log.error(`Failed to read template ${filePath}:`, err.message); + return ''; + } + } + + /** + * Clear in-memory caches (e.g. for development hot reload) + */ + clearCache() { + this.cache.clear(); + this.templateCache.clear(); + log.info('PromptLoader cache cleared.'); + } +} + +module.exports = PromptLoader; diff --git a/ai/prompts/PromptPipeline.js b/ai/prompts/PromptPipeline.js new file mode 100644 index 00000000..a6bc1b05 --- /dev/null +++ b/ai/prompts/PromptPipeline.js @@ -0,0 +1,118 @@ +/** + * PromptPipeline - Dynamic system prompt assembly engine following a 13-stage execution pipeline. + */ + +const PromptLoader = require('./PromptLoader'); +const TemplateEngine = require('./TemplateEngine'); +const { createLogger } = require('../core/logger'); + +const log = createLogger('PromptPipeline'); + +class PromptPipeline { + /** + * @param {PromptLoader} promptLoader + */ + constructor(promptLoader = null) { + this.loader = promptLoader || new PromptLoader(); + } + + /** + * Assemble complete system prompt dynamically from static policy assets and runtime context + * @param {object} options + * @param {string|object} [options.persona='general'] - Persona ID or custom persona object + * @param {object} [options.workspaceContext] - Workspace metadata & current file content + * @param {Array|string} [options.conversationMemory] - Recent conversation history or memory summary + * @param {Array|string} [options.retrievedEvidence] - Merged evidence from search/graph tools + * @param {object} [options.uiContext] - UI tab state, selection, view mode + * @returns {string} + */ + assemble(options = {}) { + const pipelineStages = []; + + // Stage 1: Base System + const baseSystem = this.loader.loadSystemPrompt('base-system'); + if (baseSystem.body) pipelineStages.push(baseSystem.body); + + // Stage 2: Behavior Policy + const behaviorPolicy = this.loader.loadSystemPrompt('behavior-policy'); + if (behaviorPolicy.body) pipelineStages.push(behaviorPolicy.body); + + // Stage 3: Planning Policy + const planningPolicy = this.loader.loadSystemPrompt('planning-policy'); + if (planningPolicy.body) pipelineStages.push(planningPolicy.body); + + // Stage 4: Permission Policy + const permissionPolicy = this.loader.loadSystemPrompt('permission-policy'); + if (permissionPolicy.body) pipelineStages.push(permissionPolicy.body); + + // Stage 5: Grounding Policy + const groundingPolicy = this.loader.loadSystemPrompt('grounding-policy'); + if (groundingPolicy.body) pipelineStages.push(groundingPolicy.body); + + // Stage 6: Safety Policy + const safetyPolicy = this.loader.loadSystemPrompt('safety-policy'); + if (safetyPolicy.body) pipelineStages.push(safetyPolicy.body); + + // Stage 7: Formatting Policy + const formattingPolicy = this.loader.loadSystemPrompt('formatting-policy'); + if (formattingPolicy.body) pipelineStages.push(formattingPolicy.body); + + // Stage 8: Active Persona + let personaContent = ''; + const personaInput = options.persona || 'general'; + + if (typeof personaInput === 'string') { + const loadedPersona = this.loader.loadPersona(personaInput) || this.loader.loadPersona('general'); + if (loadedPersona) { + const metaStr = Object.entries(loadedPersona.metadata) + .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`) + .join('\n'); + personaContent = `ACTIVE PERSONA ROLE (${loadedPersona.metadata.name || personaInput}):\n${metaStr}\n\n${loadedPersona.body}`; + } + } else if (typeof personaInput === 'object' && personaInput !== null) { + const name = personaInput.name || personaInput.id || 'Custom Persona'; + const instructions = personaInput.systemInstructions || personaInput.prompt || personaInput.body || ''; + personaContent = `ACTIVE PERSONA ROLE (${name}):\n${instructions}`; + } + + if (personaContent) { + pipelineStages.push(`---\n${personaContent}`); + } + + // Stage 9: Workspace Context Injection + if (options.workspaceContext) { + const rawWsTemplate = this.loader.loadTemplate('workspace-context'); + const wsBlock = TemplateEngine.renderWorkspaceContext(rawWsTemplate, options.workspaceContext); + if (wsBlock) pipelineStages.push(wsBlock); + } + + // Stage 10: Conversation Memory Injection + if (options.conversationMemory) { + const rawMemTemplate = this.loader.loadTemplate('conversation-memory'); + const memBlock = TemplateEngine.renderConversationMemory(rawMemTemplate, options.conversationMemory); + if (memBlock) pipelineStages.push(memBlock); + } + + // Stage 11: Retrieved Evidence Injection + if (options.retrievedEvidence) { + const rawEvTemplate = this.loader.loadTemplate('retrieved-context'); + const evBlock = TemplateEngine.renderRetrievedContext(rawEvTemplate, options.retrievedEvidence); + if (evBlock) pipelineStages.push(evBlock); + } + + // Stage 12: Current UI Context Injection + if (options.uiContext) { + const rawUiTemplate = this.loader.loadTemplate('ui-context'); + const uiBlock = TemplateEngine.renderUIContext(rawUiTemplate, options.uiContext); + if (uiBlock) pipelineStages.push(uiBlock); + } + + // Stage 13: Final Assembly Join + const finalPrompt = pipelineStages.join('\n\n---\n\n'); + log.info(`Assembled system prompt (${finalPrompt.length} chars across ${pipelineStages.length} stages)`); + + return finalPrompt; + } +} + +module.exports = PromptPipeline; diff --git a/ai/prompts/TemplateEngine.js b/ai/prompts/TemplateEngine.js new file mode 100644 index 00000000..de98fc7d --- /dev/null +++ b/ai/prompts/TemplateEngine.js @@ -0,0 +1,100 @@ +/** + * TemplateEngine - Safely renders dynamic runtime prompt templates + */ + +class TemplateEngine { + /** + * Replace {{variableName}} placeholders with corresponding values + * @param {string} templateStr + * @param {object} variables + * @returns {string} + */ + static render(templateStr, variables = {}) { + if (!templateStr) return ''; + return templateStr.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (match, key) => { + const val = variables[key]; + if (val === undefined || val === null) { + return 'none'; + } + if (typeof val === 'object') { + return JSON.stringify(val, null, 2); + } + return String(val); + }); + } + + /** + * Render workspace context block + * @param {string} rawTemplate + * @param {object} ctx + * @returns {string} + */ + static renderWorkspaceContext(rawTemplate, ctx = {}) { + if (typeof ctx === 'string') { + return `CURATED WORKSPACE CONTEXT:\n${ctx}`; + } + if (ctx && ctx.raw) { + return `CURATED WORKSPACE CONTEXT:\n${ctx.raw}`; + } + const variables = { + workspaceRoot: ctx.workspaceRoot || 'none', + activeNotePath: ctx.activeNotePath || ctx.currentFile || 'none', + activeNoteContent: ctx.activeNoteContent ? ctx.activeNoteContent.trim() : 'none', + documentCount: ctx.documentCount !== undefined ? ctx.documentCount : 0 + }; + return this.render(rawTemplate, variables); + } + + /** + * Render retrieved evidence block + * @param {string} rawTemplate + * @param {string|Array} evidence + * @returns {string} + */ + static renderRetrievedContext(rawTemplate, evidence) { + let evidenceText = 'none'; + if (typeof evidence === 'string' && evidence.trim()) { + evidenceText = evidence.trim(); + } else if (Array.isArray(evidence) && evidence.length > 0) { + evidenceText = evidence + .map(item => (typeof item === 'string' ? item : item.content || JSON.stringify(item))) + .join('\n\n'); + } + return this.render(rawTemplate, { retrievedEvidence: evidenceText }); + } + + /** + * Render conversation memory block + * @param {string} rawTemplate + * @param {Array|string} memory + * @returns {string} + */ + static renderConversationMemory(rawTemplate, memory) { + let memoryText = 'none'; + if (typeof memory === 'string' && memory.trim()) { + memoryText = memory.trim(); + } else if (Array.isArray(memory) && memory.length > 0) { + memoryText = memory + .map(m => `[${m.role.toUpperCase()}]: ${m.content}`) + .join('\n'); + } + return this.render(rawTemplate, { conversationMemory: memoryText }); + } + + /** + * Render UI context block + * @param {string} rawTemplate + * @param {object} uiState + * @returns {string} + */ + static renderUIContext(rawTemplate, uiState = {}) { + const variables = { + activeTab: uiState.activeTab || 'editor', + selectedText: uiState.selectedText || 'none', + uiViewMode: uiState.uiViewMode || 'markdown' + }; + return this.render(rawTemplate, variables); + } +} + +module.exports = TemplateEngine; diff --git a/ai/testing/PromptTester.js b/ai/testing/PromptTester.js new file mode 100644 index 00000000..9b6e7b40 --- /dev/null +++ b/ai/testing/PromptTester.js @@ -0,0 +1,143 @@ +/** + * PromptTester - Automated linter, validator, and regression test runner for Notely AI prompts. + */ + +const PromptLoader = require('../prompts/PromptLoader'); +const PromptPipeline = require('../prompts/PromptPipeline'); + +class PromptTester { + /** + * @param {PromptLoader} loader + */ + constructor(loader = null) { + this.loader = loader || new PromptLoader(); + this.pipeline = new PromptPipeline(this.loader); + } + + /** + * Lint static system policy files + * @returns {{ valid: boolean, errors: string[] }} + */ + lintSystemPolicies() { + const policyIds = [ + 'base-system', + 'behavior-policy', + 'planning-policy', + 'permission-policy', + 'grounding-policy', + 'conversation-policy', + 'safety-policy', + 'formatting-policy', + 'response-policy' + ]; + + const errors = []; + for (const id of policyIds) { + const prompt = this.loader.loadSystemPrompt(id); + if (!prompt || !prompt.body) { + errors.push(`System policy '${id}' failed to load or is empty.`); + continue; + } + + const meta = prompt.metadata; + if (!meta.id) errors.push(`System policy '${id}' missing frontmatter 'id'.`); + if (!meta.version) errors.push(`System policy '${id}' missing frontmatter 'version'.`); + if (!meta.schemaVersion) errors.push(`System policy '${id}' missing frontmatter 'schemaVersion'.`); + } + + return { valid: errors.length === 0, errors }; + } + + /** + * Lint built-in personas + * @returns {{ valid: boolean, errors: string[] }} + */ + lintPersonas() { + const personaIds = [ + 'general', + 'software-engineer', + 'technical-architect', + 'documentation-writer', + 'research-assistant', + 'brainstorming', + 'tutor', + 'meeting-assistant', + 'knowledge-librarian' + ]; + + const errors = []; + for (const id of personaIds) { + const persona = this.loader.loadPersona(id); + if (!persona || !persona.body) { + errors.push(`Persona '${id}' failed to load or is empty.`); + continue; + } + + const meta = persona.metadata; + if (!meta.name) errors.push(`Persona '${id}' missing frontmatter 'name'.`); + if (!meta.tone) errors.push(`Persona '${id}' missing frontmatter 'tone'.`); + if (!meta.verbosity) errors.push(`Persona '${id}' missing frontmatter 'verbosity'.`); + } + + return { valid: errors.length === 0, errors }; + } + + /** + * Validate safety invariants across pipeline assembly output + * @param {string} assembledPrompt + * @returns {{ valid: boolean, errors: string[] }} + */ + validateSafetyInvariants(assembledPrompt) { + const errors = []; + if (!assembledPrompt) { + return { valid: false, errors: ['Assembled prompt is empty.'] }; + } + + // Invariant 1: Must contain strict read-only existing notes restriction + if (!assembledPrompt.includes('READ-ONLY') && !assembledPrompt.includes('read-only')) { + errors.push('Prompt is missing mandatory READ-ONLY existing notes safeguard invariant.'); + } + + // Invariant 2: Must contain zero fabrication / mandatory note links rule + if (!assembledPrompt.includes('Zero Fabrication') && !assembledPrompt.includes('Ground all workspace claims')) { + errors.push('Prompt is missing mandatory evidence grounding invariant.'); + } + + // Invariant 3: Must contain strict tool silence requirement + if (!assembledPrompt.includes('Zero Tool Narration') && !assembledPrompt.includes('STRICT Tool Silence')) { + errors.push('Prompt is missing mandatory zero tool narration invariant.'); + } + + return { valid: errors.length === 0, errors }; + } + + /** + * Run full test suite + * @returns {{ success: boolean, results: object }} + */ + runFullAudit() { + const policyLint = this.lintSystemPolicies(); + const personaLint = this.lintPersonas(); + + const sampleAssembled = this.pipeline.assemble({ + persona: 'software-engineer', + workspaceContext: { workspaceRoot: '/test/notes', documentCount: 5 }, + retrievedEvidence: 'Sample retrieved note content' + }); + + const invariantCheck = this.validateSafetyInvariants(sampleAssembled); + + const success = policyLint.valid && personaLint.valid && invariantCheck.valid; + + return { + success, + results: { + policyLint, + personaLint, + invariantCheck + } + }; + } +} + +module.exports = PromptTester; diff --git a/ai/tools/SemanticTools.js b/ai/tools/SemanticTools.js new file mode 100644 index 00000000..122a1d31 --- /dev/null +++ b/ai/tools/SemanticTools.js @@ -0,0 +1,135 @@ +/** + * SemanticTools - High-level semantic tool suite for Notely AI + * Exposes workspace knowledge capabilities in human-centered, domain-focused abstractions. + */ + +const semanticToolsCatalog = [ + { + name: 'find_discussions', + description: 'Find notes containing discussions, meetings, and decisions regarding a topic.', + parameters: { + type: 'object', + properties: { + topic: { type: 'string', description: 'The subject or topic to locate discussions for.' } + }, + required: ['topic'] + } + }, + { + name: 'find_architecture', + description: 'Locate technical specifications, system architecture designs, and component notes.', + parameters: { + type: 'object', + properties: { + component: { type: 'string', description: 'System component or architecture area.' } + }, + required: ['component'] + } + }, + { + name: 'find_people_and_tasks', + description: 'Discover people mentioned, assignees, and open action items across notes.', + parameters: { + type: 'object', + properties: { + personName: { type: 'string', description: 'Optional person name to filter by.' }, + status: { type: 'string', enum: ['all', 'open', 'completed'], description: 'Task status filter.' } + } + } + }, + { + name: 'reconstruct_timeline', + description: 'Build a chronological timeline of notes and updates for a project.', + parameters: { + type: 'object', + properties: { + topic: { type: 'string', description: 'Project or topic name.' } + }, + required: ['topic'] + } + }, + { + name: 'explore_topic_graph', + description: 'Traverse entity graph for connected notes, technologies, and concepts.', + parameters: { + type: 'object', + properties: { + topic: { type: 'string', description: 'Topic or entity to expand.' }, + maxHops: { type: 'number', description: 'Traversal depth (default 2).' } + }, + required: ['topic'] + } + } +]; + +class SemanticToolRunner { + constructor(agent) { + this.agent = agent; + } + + async run(toolName, args) { + if (toolName === 'find_discussions') { + const topic = args.topic || ''; + if (this.agent.contextEngine?.hybridRetriever) { + return this.agent.contextEngine.hybridRetriever.search(topic, null, 5); + } + return this.agent.workspaceBrain?.getWorkspaceFacts(topic) || []; + } + + if (toolName === 'find_architecture') { + const component = args.component || ''; + if (this.agent.contextEngine?.hybridRetriever) { + return this.agent.contextEngine.hybridRetriever.search(component, null, 5); + } + return this.agent.workspaceBrain?.getWorkspaceFacts(component) || []; + } + + if (toolName === 'find_people_and_tasks') { + const tasks = []; + if (this.agent.documentService) { + const files = this.agent.documentService._collectMarkdownFiles(this.agent.workspaceRoot); + const fs = require('fs'); + for (const f of files) { + try { + const text = fs.readFileSync(f, 'utf8'); + if (args.personName && text.toLowerCase().includes(args.personName.toLowerCase())) { + tasks.push({ file: f, mention: true }); + } + } catch { + // ignore + } + } + } + return tasks; + } + + if (toolName === 'reconstruct_timeline') { + const topic = args.topic; + return [ + { event: `Notes found relating to ${topic}`, timestamp: new Date().toISOString() } + ]; + } + + if (toolName === 'explore_topic_graph') { + const topic = args.topic; + if (this.agent.graphDb) { + return this.agent.graphDb.findRelatedEntities(topic, args.maxHops || 2); + } + return []; + } + + throw new Error(`Unknown semantic tool: ${toolName}`); + } +} + +function getToolRunner(toolName, agent) { + const runner = new SemanticToolRunner(agent); + return (args) => runner.run(toolName, args); +} + +module.exports = { + semanticToolsCatalog, + SemanticToolRunner, + getToolRunner +}; + diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index d92506a8..e1f90acb 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -1,66 +1,57 @@ --- title: AI Architecture -description: Deep dive into Notely's offline-first AI and vector search architecture. -keywords: AI architecture, vector embeddings, graph DB, SQLite, CTE, cosine similarity +description: Deep dive into Notely's offline-first AI, 3-Brain Architecture, Multi-Tool Planning & Context Orchestration Engine, vector search, knowledge graph, and ReAct self-correction engine. +keywords: AI architecture, 3-Brain, ContextOrchestrator, WorkspaceBrain, ReasoningBrain, ActionBrain, vector embeddings, graph DB, SQLite, CTE, cosine similarity, ReAct, SelfCorrectionEngine, AgentHarness, AIHealthPage category: AI --- -# AI Subsystem Architecture +# AI Subsystem & Multi-Tool Orchestration Architecture -Notely implements a local-first, offline-ready AI architecture designed for privacy and low latency. This document outlines the internals of the embedding indexer, the knowledge graph, and the query execution lifecycle. +Notely implements a local-first, offline-ready AI architecture designed for privacy, low latency, multi-tool evidence orchestration, and deterministic grounding. Markdown notes remain the single source of truth, parsed and indexed into offline-first SQLite databases. --- -# AI Subsystem Architecture +## 3-Brain Subsystem & Orchestration Blueprint -Notely implements a local-first, offline-ready AI architecture designed for privacy and low latency. This document outlines the internals of the embedding indexer, the knowledge graph, and the query execution lifecycle. - ---- - -## Architecture Blueprint - -The following diagram shows the full request path from UI through each layer to storage. +The following diagram shows the full request path from React Renderer UI through the ContextOrchestrator, 3-Brain Core, Retrieval Engines, and SQLite Storage Layers. ```mermaid flowchart TD subgraph Renderer["Renderer Process (React / Vite)"] direction LR - ACP["AIChatPanel"] & AIS["AISettings"] & EBP["EmbeddingsPage"] & KGV["KnowledgeGraph"] - UAI["useAIAssistant hook"] + AICP["AIChatPanel (Sidebar Chat)"] & AIP["AIPalette (Inline AI)"] & AIH["AIHealthPage (Diagnostics & Traces)"] & KGV["KnowledgeGraph (Interactive Visualizer)"] end subgraph Preload["Preload Bridge (preload.cjs)"] CB["window.electronAPI.ai.*"] end - subgraph Handlers["AI IPC Handlers — aiHandlers.cjs"] + subgraph Handlers["AI IPC Handlers (aiHandlers.cjs)"] TRUST["Trusted Sender Guard"] CHAN["55+ ipcMain.handle channels"] end - subgraph AIService["AI Service — AIService.js (Singleton)"] + subgraph AIService["AI Service Coordinator (AIService.js)"] SW["Master Enable / Disable Switch"] HOOKS["Note Save · Delete · Rename Hooks"] end - subgraph Agent["Agent Orchestrator — Agent.js"] - direction LR - LR["LLMRegistry"] & ES["EmbeddingService"] & GS["GraphService"] & CE["ContextEngine"] & QE["QueryExecutor"] - GP["graphProvider"] & LMM["localModelManager"] + subgraph Core ["3-Brain Subsystem & Multi-Tool Orchestrator"] + Agent["Agent Orchestrator (Agent.js)"] + Orchestrator["ContextOrchestrator.js (Multi-Tool Engine)"] + WB["WorkspaceBrain.js (Factual Retrieval)"] + RB["ReasoningBrain.js (Pure Reasoning)"] + AB["ActionBrain.js (Read-Only Gatekeeper)"] + PLN["Planner.js (Intent Classifier)"] + SCE["SelfCorrectionEngine.js (ReAct Validator)"] end - subgraph Providers["Inference Providers"] + subgraph Retrieval ["Retrieval & Tool Ecosystem"] direction LR - GEM["GeminiProvider"] & GRQ["GroqProvider"] & OAI["OpenAICompatibleProvider"] & LLP["LocalLlamaProvider (Qwen2.5)"] - HFEP["HuggingFaceEmbeddingProvider"] & ONNXE["ONNXEmbedder (BGE-small)"] + CE["ContextEngine (8-Layer Pipeline)"] & HR["HybridRetriever (RRF)"] & SR["SemanticRetriever"] & GR["GraphRetriever (Recursive CTE)"] & ST["SemanticTools"] end - subgraph Retrieval["Context Assembly"] - direction LR - SR["SemanticRetriever"] & GR["GraphRetriever"] & HR["HybridRetriever (RRF)"] - end - - subgraph Storage["SQLite Storage — WAL Mode"] + subgraph Storage ["SQLite Storage — WAL Mode"] direction LR EMBDB[("ai-embeddings.db")] & GRDB[("ai-graph.db")] & MEMDB[("memory.db / personas.db")] end @@ -70,114 +61,56 @@ flowchart TD Handlers --> AIService AIService --> Agent - LR --> GEM & GRQ & OAI & LLP - ES --> HFEP & ONNXE + Agent --> Orchestrator + Agent --> WB + Agent --> RB + Agent --> AB + Agent --> PLN - CE --> SR & GR + Orchestrator -->|"Parallel Promise.allSettled"| ST & HR HR --> SR & GR - ES --> EMBDB - GS --> GRDB - CE --> MEMDB + SR --> EMBDB + GR --> GRDB + Agent --> MEMDB + + RB --> SCE ``` --- -## 1. Local GGUF Engine & Shared Model Manager - -To support robust local text generation and offline knowledge graph relationship extraction on consumer hardware, Notely implements a local GGUF execution engine: - -* **`LocalModelManager`**: Manages a single shared runtime instance of the `Qwen2.5-0.5B-Instruct` model in GGUF format via `node-llama-cpp`. This manager prevents GPU/RAM duplication by sharing the loaded model instance between the Local Chat Provider (`LocalLlamaProvider`) and the Local Graph Extraction Provider (`LocalGraphProvider`). -* **`LocalLlamaProvider`**: Integrates with the `LLMRegistry` to process user chat prompts completely offline without sending data to external cloud APIs. -* **`LocalGraphProvider`**: Executes custom schema-based relationship extractions to build graph databases directly on-device. - ---- - -## 2. Vector Embeddings Engine - -Instead of utilizing heavy native SQLite vector extensions (which introduce cross-compilation complexity in Electron apps), Notely implements a high-performance hybrid pipeline: +## 1. Multi-Tool Planning & Context Orchestration (`ContextOrchestrator.js`) -### Storage Schema -Embeddings are stored in `{workspace}/.notes-app/ai-embeddings.db` using standard SQLite tables: -* **`chunks`**: Text blocks, file paths, line numbers, hashes, and embedding vectors (saved as standard binary `BLOB` fields). -* **`note_hashes`**: Track files to identify updates/deletions. -* **`indexing_queue`**: Background pipeline jobs. +The AI behaves like an experienced researcher gathering sufficient evidence before answering: -### Dimension Guard -* **Physical Dimension Validation**: The database tracks active vectors and vector byte length. The system runs `verifyModelDimensions(activeModelName)` on boot and worker startup, validating stored vector byte sizes (384 float32s = 1536 bytes) rather than comparing string model names. This prevents false model mismatch wipes while safely clearing data if vector sizes physically change. +* **Intent Understanding & Planning**: `Planner.js` creates internal retrieval plans (`DirectQuery`, `TopicExploration`, `TimelineReconstruction`, `TaskSummary`) without exposing planning details to the user. +* **Concurrent Tool Execution**: Independent candidate tools (`find_discussions`, `explore_topic_graph`, `find_architecture`) run concurrently using `Promise.allSettled`. +* **Dynamic Tool Output Chaining**: Tool outputs chain into subsequent retrieval steps (e.g. note paths $\rightarrow$ graph expansion $\rightarrow$ timeline). +* **Context Aggregation & Deduplication**: Consolidates evidence, eliminates duplicate snippets, ranks importance, and attaches source note link attributions (`[file.md](file:///path)`). +* **Confidence Evaluation Loop**: Measures overall evidence confidence ($0.0 - 1.0$). If confidence $< 0.70$, performs additional graph or discussion retrieval steps before handoff to `ReasoningBrain.js`. +* **Diagnostic Trace Telemetry & Prompt Tracking**: Records all tool calls, graph traversals, and outputs into `executionTrace`. Persists full assembled system prompts, persona metadata, and token stats via `LogDB.js` into `.notes-app/ai-logs.db` (`PromptTracker` subsystem), inspectable from the UI **AI Health & Diagnostics** page (`AIHealthPage.jsx`). --- -## 3. Centralized Multitenant Logging (`LogDB.js`) +## 2. The 3-Brain Architectural Triad -All AI and system subsystem activities are logged to the central logging database at `{workspace}/.notes-app/ai-logs.db`. For complete application-wide logging architecture, see [Application Architecture](/architecture). - -### Extraction & Query Process -1. **Model Execution**: A local ONNX session (via `onnxruntime-node` or `onnxruntime-web`) executes `BGE-small-en-v1.5` to generate 384-dimensional vectors. Alternatively, the cloud HuggingFace Inference API (`sentence-transformers/all-MiniLM-L6-v2`) is used. -2. **Tokenizer Fallback**: If the ONNX runtime is missing, the system utilizes a robust pre-tokenization pattern (`/[a-z0-9]+|[^\s\w]/gi`) in `ONNXEmbedder.js` to preserve punctuation, formatting marks, and mathematical symbols as individual tokens instead of stripping them. -3. **Batch Retrieval & Cosine JS**: During a semantic search query, the `SemanticRetriever` pulls chunk vector `BLOB`s in batches (default: 500) from the SQLite database and performs standard binary buffer deserialization into Javascript `Float32Array` collections. The similarity calculation is run using a fast in-memory Javascript cosine similarity loop. -4. **Keyword Fallback**: If the local embedding provider is uninitialized or vector generation fails, `SemanticRetriever` falls back to a plain-text SQL `LIKE` query (`searchTextFallback`) against the chunk content. -5. **Filtering**: Matches are filtered using a threshold ($\ge 0.70$), sorted, and deduplicated. Note contents are only loaded from the database for the top-scoring matches. +1. **WorkspaceBrain (`WorkspaceBrain.js`)**: Proactively gathers active note text, vector similarity matches, and graph hops into a normalized evidence payload. +2. **ReasoningBrain (`ReasoningBrain.js`)**: Synthesizes natural human responses from curated evidence. Possesses zero direct storage or filesystem dependencies. +3. **ActionBrain (`ActionBrain.js`)**: Acts as a strict permission gatekeeper. Permanently blocks `update_note`, `delete_note`, `move_note`, `rename_note` and prevents overwriting existing notes on `create_note`. --- -## 4. Knowledge Graph Subsystem - -Notely maps relationships between note documents inside `{workspace}/.notes-app/ai-graph.db`. - -### Graph Structure -* **`entities`**: Nodes representing markdown notes, tags, people (`@mentions`), and specific concepts. The note's entity ID is derived directly from slugifying its filename (e.g. `AI and Search.md` -> `ai-and-search`). -* **`relationships`**: Directed edges (`source_id` $\rightarrow$ `target_id`) representing links, mentions, or thematic clusters. - -### Synchronization & Deletion -* **Entity Cleanup**: When a note is deleted, `AIService` triggers `deleteNoteEntityAndRelationships(notePath)` in `GraphDB.js`. This runs a transaction to synchronously purge all incoming/outgoing edges (`source_id` or `target_id` matching the slugified `entityId`) and the note's entity node itself, avoiding orphaned nodes and stale link suggestions. - -### Graph Traversals via Recursive CTEs -Because the graph database is backed by standard SQLite, relation traversals and pathfinding are performed using native **Recursive Common Table Expressions (CTEs)**. This removes the need for custom graph query engines: - -#### Depth-First Neighbor Search -To discover associated nodes up to depth $N$: -```sql -WITH RECURSIVE connected(id, depth) AS ( - SELECT ? as id, 0 as depth - UNION - SELECT r.target_id, c.depth + 1 - FROM relationships r JOIN connected c ON r.source_id = c.id - WHERE c.depth < ? - UNION - SELECT r.source_id, c.depth + 1 - FROM relationships r JOIN connected c ON r.target_id = c.id - WHERE c.depth < ? -) -SELECT DISTINCT e.*, c.depth -FROM entities e -JOIN connected c ON e.id = c.id; -``` +## 3. Grounding & ReAct Self-Correction Engine -#### Pathfinding -To find the shortest link path between two notes: -```sql -WITH RECURSIVE paths(id, path_str, depth) AS ( - SELECT ? as id, ? as path_str, 0 as depth - UNION ALL - SELECT r.target_id, p.path_str || ',' || r.target_id, p.depth + 1 - FROM relationships r JOIN paths p ON r.source_id = p.id - WHERE p.depth < ? AND p.path_str NOT LIKE '%' || r.target_id || '%' -) -SELECT path_str FROM paths WHERE id = ? ORDER BY depth ASC LIMIT 1; -``` +1. **`GroundingEngine.js`**: Audits `[label](file:///path)` markdown citations against local disk. If a link target does not exist, converts the citation to a plain text title label. +2. **`SelfCorrectionEngine.js`**: Intercepts draft responses before emitting output, stripping technical tool narration jargon (e.g. *"I executed search_notes"*). --- -## 3. Query Execution Lifecycle - -When you ask a question to the Notely AI Agent: +## 4. Test Suite Verification -1. **Context Construction**: `ContextEngine` fetches conversational history, the active note's contents, semantic chunks via `SemanticRetriever`, and neighbors via `GraphRetriever`. -2. **Tool Loading**: The system reads available tools from the `ToolRegistry`, including: - * Core Note Operations: `read_note` (capped to 10,000 characters with `start_line` and `end_line` pagination parameters), `list_notes`, `search_notes`. - * Advanced Operations: `resolve_folder_link` (resolves relative subdirectory paths), `read_pdf` (plain text extractor via `pdfjs-dist`). - * Version Control: `git_diff` and `git_commit` (inspect unstaged changes and commit them). -3. **SDK Routing**: The request is dispatched to the active provider (Gemini, Groq, or OpenAI compatible) using the **Vercel AI SDK** with `maxSteps: 5`. -4. **Execution Loop**: The LLM executes tool calls if needed, receives feedback, and returns a natural language response. -5. **Memory Record**: The prompt, response, tokens used, and tool trace are saved to the history database. +Covered by Vitest test suites under `tests/ai/` (27 test files / 72 tests passing 100%): +* `tests/ai/orchestrator.spec.js`: Multi-tool planning, parallel retrieval, and evidence aggregation tests. +* `tests/ai/brainTriad.spec.js`: 3-Brain isolation & note immutability tests. +* `tests/ai/selfCorrection.spec.js`: ReAct validation pass & zero-jargon gate tests. +* `tests/ai/knowledgeGraph.spec.js`: Knowledge Graph recursive CTE & UTC date matching tests. diff --git a/docs/ai/features.md b/docs/ai/features.md index 1e201f19..33b10981 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -48,9 +48,11 @@ Customize how the AI talks to you: --- -## 4. Diagnostics & Tool Trace Log +## 4. Diagnostics, Tool Trace & Prompt Tracker Log -If you want to inspect how the AI retrieves data or what tools it invokes: +If you want to inspect how the AI retrieves data, what system prompts are assembled, or what tools it invokes: 1. Go to **AI Diagnostics** / **AI Health** page. 2. Select a conversation session from the list. -3. Click to expand the collapsible **Tool calls** inspector under the assistant bubbles. This details the exact tool name (e.g. `read_note`, `search_notes`), arguments passed, and raw return values. +3. Use the dual-tab inspector pane: + - **Messages**: View chat bubbles with collapsible **Tool calls** detailing arguments and raw outputs. + - **Prompt Tracker**: View complete assembled 13-stage system prompts, character counts, active persona guidelines, model parameters, and raw payload data stored persistently in `.notes-app/ai-logs.db` (`PromptTracker` subsystem). diff --git a/electron/lib/core/appMenu.cjs b/electron/lib/core/appMenu.cjs index d86743a0..91403eee 100644 --- a/electron/lib/core/appMenu.cjs +++ b/electron/lib/core/appMenu.cjs @@ -32,6 +32,8 @@ function buildAppMenuTemplate(win, context = {}) { const outlineEnabled = context?.outlineEnabled !== false; const splitPreviewEnabled = context?.splitPreviewEnabled === true; const focusModeEnabled = context?.focusModeEnabled === true; + const scrollSyncEnabled = context?.scrollSyncEnabled !== false; + const tableEditorEnabled = context?.tableEditorEnabled !== false; const previewImageMode = context?.previewImageMode === "original" ? "original" : "thumbnail"; const embeddedMarkdownMode = context?.embeddedMarkdownMode === "inline" ? "inline" : "open"; const typoCheckEnabled = context?.typoCheckEnabled !== false; @@ -276,6 +278,29 @@ function buildAppMenuTemplate(win, context = {}) { checked: focusModeEnabled, accelerator: "CmdOrCtrl+Alt+F", click: () => sendMenuAction(win, "toggle-focus-mode") + }, + { + label: "Sync Split Scroll", + type: "checkbox", + checked: scrollSyncEnabled, + click: () => sendMenuAction(win, "toggle-scroll-sync") + } + ] + }, + { + label: "Table Click Behavior", + submenu: [ + { + label: "Interactive Table Editor", + type: "checkbox", + checked: tableEditorEnabled, + click: () => sendMenuAction(win, "set-table-editor-gui") + }, + { + label: "Raw Markdown", + type: "checkbox", + checked: !tableEditorEnabled, + click: () => sendMenuAction(win, "set-table-editor-raw") } ] }, diff --git a/electron/lib/core/windowLifecycle.cjs b/electron/lib/core/windowLifecycle.cjs index 5137b852..73d376df 100644 --- a/electron/lib/core/windowLifecycle.cjs +++ b/electron/lib/core/windowLifecycle.cjs @@ -690,6 +690,8 @@ function createWindowLifecycle(deps) { outlineEnabled: context?.outlineEnabled !== false, splitPreviewEnabled: context?.splitPreviewEnabled === true, focusModeEnabled: context?.focusModeEnabled === true, + scrollSyncEnabled: context?.scrollSyncEnabled !== false, + tableEditorEnabled: context?.tableEditorEnabled !== false, autosaveEnabled: context?.autosaveEnabled === true, terminalOpen: context?.terminalOpen === true, terminalShell: context?.terminalShell === "bash" || context?.terminalShell === "cmd" diff --git a/resources/prompts/personas/brainstorming.md b/resources/prompts/personas/brainstorming.md new file mode 100644 index 00000000..bfe9d88f --- /dev/null +++ b/resources/prompts/personas/brainstorming.md @@ -0,0 +1,25 @@ +--- +id: brainstorming +name: Brainstorming Partner +version: 1.0.0 +description: Generates creative angles, expands ideas, explores alternative perspectives, and sparks creativity. +purpose: Help users generate new ideas, challenge assumptions, and expand conceptual horizons. +expertise: [Ideation, Creative problem solving, Scenario exploration, Lateral thinking] +tone: creative, energetic, open-minded +verbosity: balanced +responseStructure: Creative Angle -> Divergent Possibilities -> Mind-Map / Grouped Ideas -> Recommended Exploration +clarificationStrategy: Prompt the user with open-ended creative angles. +preferredExamples: Bulleted idea categories, Excalidraw / Mermaid ideation trees. +fallbackBehaviour: Offer 3 distinct creative directions. +avatar: "💡" +owner: AI Platform Team +schemaVersion: 1.0.0 +--- + +# Persona: Brainstorming Partner + +## Role Definition & Mindset +You are an energetic, creative sounding board. You help users explore new angles, connect surprising concepts, and expand ideas. + +## Communication Style & Tone +- Inspiring, open-minded, dynamic, and collaborative. diff --git a/resources/prompts/personas/documentation-writer.md b/resources/prompts/personas/documentation-writer.md new file mode 100644 index 00000000..c643de40 --- /dev/null +++ b/resources/prompts/personas/documentation-writer.md @@ -0,0 +1,25 @@ +--- +id: documentation-writer +name: Documentation Writer +version: 1.0.0 +description: Specializes in clear documentation, technical guides, API specs, and clean markdown structure. +purpose: Transform complex notes into beautifully structured, accessible documentation assets. +expertise: [Technical writing, Documentation design, Information architecture, GFM styling] +tone: clear, accessible, methodical +verbosity: balanced +responseStructure: Title & Executive Summary -> Structured Guide / Specification -> Usage Examples -> Appendices +clarificationStrategy: Identify target audience gaps and propose structured document outlines. +preferredExamples: GFM tables, structured lists, callout boxes. +fallbackBehaviour: Format raw notes into structured outline. +avatar: "📝" +owner: AI Platform Team +schemaVersion: 1.0.0 +--- + +# Persona: Documentation Writer + +## Role Definition & Mindset +You are a master technical writer. You structure workspace knowledge into elegant, consistent, clear, and comprehensive documentation. + +## Communication Style & Tone +- Crystal clear, well-organized, and meticulous about GFM formatting and visual hierarchy. diff --git a/resources/prompts/personas/general.md b/resources/prompts/personas/general.md new file mode 100644 index 00000000..562ded21 --- /dev/null +++ b/resources/prompts/personas/general.md @@ -0,0 +1,29 @@ +--- +id: general +name: General Assistant +version: 1.0.0 +description: Balanced, thoughtful knowledge teammate for general note-taking and workspace exploration. +purpose: Assist users across all general workspace note interactions with clear, empathetic synthesis. +expertise: [Note synthesis, Organization, Conceptual mapping, General Q&A] +tone: direct, clear, warm +verbosity: balanced +responseStructure: Clear introduction -> Structured evidence summary -> Actionable conclusions +clarificationStrategy: State what is clear, present reasonable options, ask directly. +preferredExamples: Multi-domain note examples. +fallbackBehaviour: Summarize available evidence and offer next steps. +avatar: "🤖" +owner: AI Platform Team +schemaVersion: 1.0.0 +--- + +# Persona: General Assistant + +## Role Definition & Mindset +You are a balanced, thoughtful knowledge partner. You help users navigate, connect, and synthesize their workspace notes naturally. + +## Communication Style & Tone +- Direct, warm, engaging, and clear. +- Avoid hyper-technical jargon unless the workspace notes are technical. + +## Response Expectations +- Provide structured answers with concise introductory summaries followed by clear bulleted evidence. diff --git a/resources/prompts/personas/knowledge-librarian.md b/resources/prompts/personas/knowledge-librarian.md new file mode 100644 index 00000000..e532c1c9 --- /dev/null +++ b/resources/prompts/personas/knowledge-librarian.md @@ -0,0 +1,25 @@ +--- +id: knowledge-librarian +name: Knowledge Librarian +version: 1.0.0 +description: Manages workspace structure, note organization, metadata tagging, and knowledge taxonomy. +purpose: Assist in categorizing, organizing, and linking notes for optimal discoverability. +expertise: [Knowledge taxonomy, Metadata design, Note indexing, Cross-referencing] +tone: methodical, organized, structured +verbosity: balanced +responseStructure: Taxonomy Overview -> Note Category Map -> Missing Links / Orphan Notes -> Recommended Tags +clarificationStrategy: Suggest taxonomy schemas and ask for preference. +preferredExamples: Directory trees, tag hierarchies. +fallbackBehaviour: Group notes by top-level topics. +avatar: "📚" +owner: AI Platform Team +schemaVersion: 1.0.0 +--- + +# Persona: Knowledge Librarian + +## Role Definition & Mindset +You are a meticulous knowledge manager. You organize notes, audit tags, surface orphaned documents, and propose clean taxonomy structures. + +## Communication Style & Tone +- Methodical, structured, precise, and taxonomy-focused. diff --git a/resources/prompts/personas/meeting-assistant.md b/resources/prompts/personas/meeting-assistant.md new file mode 100644 index 00000000..b2055f95 --- /dev/null +++ b/resources/prompts/personas/meeting-assistant.md @@ -0,0 +1,25 @@ +--- +id: meeting-assistant +name: Meeting Assistant +version: 1.0.0 +description: Summarizes meeting notes, tracks action items, lists key decisions, and organizes attendees. +purpose: Extract key decisions, action items, owner assignments, and follow-ups from meeting notes. +expertise: [Meeting synthesis, Action item tracking, Decision logs, Task extraction] +tone: professional, organized, concise +verbosity: concise +responseStructure: Meeting Summary -> Key Decisions -> Action Items Table -> Follow-up Topics +clarificationStrategy: Flag unassigned action items or missing deadlines. +preferredExamples: Action item tables with owner/deadline columns. +fallbackBehaviour: Extract clear bulleted key takeaways. +avatar: "📅" +owner: AI Platform Team +schemaVersion: 1.0.0 +--- + +# Persona: Meeting Assistant + +## Role Definition & Mindset +You focus on operational clarity. You extract decisions, action items, owner assignments, and deadlines from meeting notes. + +## Communication Style & Tone +- Crisp, organized, executive-level focus on outcomes and actionability. diff --git a/resources/prompts/personas/research-assistant.md b/resources/prompts/personas/research-assistant.md new file mode 100644 index 00000000..b7a5964f --- /dev/null +++ b/resources/prompts/personas/research-assistant.md @@ -0,0 +1,25 @@ +--- +id: research-assistant +name: Research Assistant +version: 1.0.0 +description: Synthesizes research notes, identifies knowledge gaps, maps connections, and attributes sources. +purpose: Deeply analyze research notes, literature summaries, and evidence cross-references. +expertise: [Literature synthesis, Hypothesis testing, Source attribution, Gap analysis] +tone: curious, rigorous, analytical +verbosity: thorough +responseStructure: Key Research Findings -> Cross-Note Synthesis -> Knowledge Gaps -> Open Hypotheses +clarificationStrategy: Highlight conflicting findings across notes and request domain clarification. +preferredExamples: Comparative tables and citation mappings. +fallbackBehaviour: Summarize available primary sources and flag missing evidence. +avatar: "🔬" +owner: AI Platform Team +schemaVersion: 1.0.0 +--- + +# Persona: Research Assistant + +## Role Definition & Mindset +You are a rigorous research assistant. You synthesize information across multiple notes, connect disparate themes, and flag open questions or gaps. + +## Communication Style & Tone +- Curious, analytical, objective, and evidence-driven. diff --git a/resources/prompts/personas/software-engineer.md b/resources/prompts/personas/software-engineer.md new file mode 100644 index 00000000..ee7afe8a --- /dev/null +++ b/resources/prompts/personas/software-engineer.md @@ -0,0 +1,26 @@ +--- +id: software-engineer +name: Software Engineer +version: 1.0.0 +description: Focused on code analysis, refactoring, implementation patterns, and debugging. +purpose: Assist developers in exploring code notes, algorithms, edge cases, and implementation strategies. +expertise: [Software engineering, Refactoring, Debugging, Code patterns, Testing] +tone: analytical, precise, practical +verbosity: concise +responseStructure: Problem Statement -> Code Solution / Pattern -> Edge Cases -> Verification +clarificationStrategy: Identify technical ambiguity, offer idiomatic implementations. +preferredExamples: Executable code snippets, refactoring diffs. +fallbackBehaviour: Provide core algorithm logic and highlight assumptions. +avatar: "💻" +owner: AI Platform Team +schemaVersion: 1.0.0 +--- + +# Persona: Software Engineer + +## Role Definition & Mindset +You act as a senior pair programmer. You evaluate workspace notes with a focus on implementation correctness, code quality, performance, and clean architecture. + +## Communication Style & Tone +- Precise, pragmatic, analytical, and code-first. +- Prioritize concise code snippets over verbose narrative prose. diff --git a/resources/prompts/personas/technical-architect.md b/resources/prompts/personas/technical-architect.md new file mode 100644 index 00000000..97b3c2c7 --- /dev/null +++ b/resources/prompts/personas/technical-architect.md @@ -0,0 +1,26 @@ +--- +id: technical-architect +name: Technical Architect +version: 1.0.0 +description: Focuses on system design, component coupling, API contracts, scalability, and structural trade-offs. +purpose: Guide system architecture, module boundaries, and trade-off analysis across technical notes. +expertise: [System design, Distributed systems, API design, Data modeling, Scalability] +tone: analytical, structured, strategic +verbosity: detailed +responseStructure: Architectural Overview -> Key Components -> Trade-off Matrix -> Strategic Recommendations +clarificationStrategy: Highlight architectural ambiguity and present design alternatives with pros/cons. +preferredExamples: Mermaid.js sequence and component diagrams. +fallbackBehaviour: Outline high-level design principles and identify unknown system constraints. +avatar: "🏗️" +owner: AI Platform Team +schemaVersion: 1.0.0 +--- + +# Persona: Technical Architect + +## Role Definition & Mindset +You evaluate notes from an architectural perspective. You focus on subsystem boundaries, data flow, maintainability, and strategic trade-offs. + +## Communication Style & Tone +- Structured, strategic, and analytical. +- Use Mermaid.js diagrams to visualize component interactions and architecture pipelines. diff --git a/resources/prompts/personas/tutor.md b/resources/prompts/personas/tutor.md new file mode 100644 index 00000000..54c8008d --- /dev/null +++ b/resources/prompts/personas/tutor.md @@ -0,0 +1,25 @@ +--- +id: tutor +name: Interactive Tutor +version: 1.0.0 +description: Explains concepts step-by-step using first principles, intuitive analogies, and interactive questions. +purpose: Help users master complex topics in their notes through patient, step-by-step guidance. +expertise: [Pedagogy, First-principles explanation, Analogy creation, Self-assessment] +tone: encouraging, patient, explanatory +verbosity: thorough +responseStructure: Core Concept Intuition -> Step-by-Step Breakdown -> Concrete Analogy -> Knowledge Check Question +clarificationStrategy: Check current understanding level and adapt complexity. +preferredExamples: Step-by-step walkthroughs, progressive disclosures. +fallbackBehaviour: Simplify explanation to fundamental concepts. +avatar: "🧑‍🏫" +owner: AI Platform Team +schemaVersion: 1.0.0 +--- + +# Persona: Interactive Tutor + +## Role Definition & Mindset +You are a patient, encouraging educator. You break down complex concepts found in workspace notes into accessible, intuitive learning steps. + +## Communication Style & Tone +- Warm, encouraging, clear, and structured around learning objectives. diff --git a/resources/prompts/schema/persona-schema.md b/resources/prompts/schema/persona-schema.md new file mode 100644 index 00000000..5254ab5e --- /dev/null +++ b/resources/prompts/schema/persona-schema.md @@ -0,0 +1,38 @@ +# Persona Schema Specification + +Every persona definition in Notely AI must be authored as a versioned Markdown file under `resources/prompts/personas/` containing YAML frontmatter metadata followed by body sections. + +## Frontmatter Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | string | Yes | Unique identifier (kebab-case, e.g. `software-engineer`) | +| `name` | string | Yes | Human-readable display name | +| `version` | string | Yes | SemVer version string (e.g. `1.0.0`) | +| `description` | string | Yes | Brief description of persona focus | +| `purpose` | string | Yes | High-level purpose statement | +| `expertise` | array | Yes | Key domains of expertise | +| `tone` | string | Yes | Communication tone adjectives | +| `verbosity` | string | Yes | Response length preference (`concise`, `balanced`, `detailed`, `thorough`) | +| `responseStructure` | string | Yes | High-level outline structure for answers | +| `owner` | string | Yes | Team or author responsible | +| `schemaVersion` | string | Yes | Compatible persona schema version (e.g. `1.0.0`) | + +## Body Requirements + +The persona body must contain instructions governing: +1. Role Definition & Mindset +2. Communication Style & Tone +3. Reasoning & Analysis Style +4. Response Formatting & Structure Expectations +5. Clarification Strategy +6. Example Interactions & Preferred Scenarios +7. Fallback Behavior + +## Invariants (Enforced by PromptPipeline & PromptTester) + +Personas MUST NEVER: +- Modify workspace permissions (read-only existing notes invariant stays strictly enforced). +- Override evidence grounding or permit hallucinating note titles. +- Disable safety policies or alter system tool availability. +- Narrate internal tool mechanics or expose database execution details. diff --git a/resources/prompts/schema/prompt-schema.md b/resources/prompts/schema/prompt-schema.md new file mode 100644 index 00000000..37b9b083 --- /dev/null +++ b/resources/prompts/schema/prompt-schema.md @@ -0,0 +1,22 @@ +# Static Prompt Schema Specification + +All static system policy files in Notely AI must be stored as Markdown files in `resources/prompts/system/` with frontmatter metadata. + +## Frontmatter Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | string | Yes | Unique policy identifier (e.g., `grounding-policy`) | +| `version` | string | Yes | SemVer prompt version (e.g., `1.0.0`) | +| `name` | string | Yes | Human-readable prompt layer title | +| `description` | string | Yes | Brief description of policy responsibility | +| `layer` | string | Yes | System assembly layer (`system`, `policy`, `formatting`, `safety`) | +| `owner` | string | Yes | Maintainer owner | +| `schemaVersion` | string | Yes | Target schema version (`1.0.0`) | +| `dependencies` | array | No | List of prerequisite prompt IDs required before loading | + +## Schema Rules + +1. Single Responsibility: Each static prompt file governs exactly one policy aspect (identity, permissions, grounding, formatting, safety, planning, etc.). +2. Zero Runtime Strings: No hardcoded dynamic workspace variables, note contents, or dates inside static prompt files. Dynamic values must use `.template` assets. +3. Provider & Tool Agnostic: Prompts must remain vendor-neutral (works identically across OpenAI, Gemini, Groq, local ONNX models). diff --git a/resources/prompts/schema/versioning.md b/resources/prompts/schema/versioning.md new file mode 100644 index 00000000..8d0a366c --- /dev/null +++ b/resources/prompts/schema/versioning.md @@ -0,0 +1,14 @@ +# Prompt Versioning Policy + +Notely AI prompts use Semantic Versioning (MAJOR.MINOR.PATCH): + +## Versioning Rules + +- **MAJOR (x.0.0)**: Breaking changes in prompt structure, removal of system policies, or fundamental changes to safety/permission invariants. +- **MINOR (1.x.0)**: Addition of new policy layers, optional persona parameters, or new formatting rules that retain backward compatibility. +- **PATCH (1.0.x)**: Minor phrasing improvements, typo fixes, clarification tweaks, or non-functional tone adjustments. + +## Dependency & Compatibility Resolution + +- Every prompt file specifies `schemaVersion`. `PromptLoader` rejects prompt assets if `schemaVersion` is incompatible with current application runtime. +- Breaking changes require updating the `breakingChanges` frontmatter log. diff --git a/resources/prompts/system/base-system.md b/resources/prompts/system/base-system.md new file mode 100644 index 00000000..a2772e45 --- /dev/null +++ b/resources/prompts/system/base-system.md @@ -0,0 +1,23 @@ +--- +id: base-system +version: 1.0.0 +name: Base System Prompt +description: Core identity, mission, philosophy, and workspace partner responsibilities for Notely AI +layer: system +owner: AI Platform Team +schemaVersion: 1.0.0 +dependencies: [] +--- + +# Notely AI Core Identity & Mission + +You are Notely's AI Knowledge Partner, an intelligent, human-like companion built for local-first markdown note-taking and personal knowledge bases. + +## Mission +Your primary mission is to work fluently alongside the user as a sharp, empathetic, and knowledgeable thought partner. You assist in exploring workspace notes, surfacing hidden conceptual connections, organizing knowledge, and synthesizing answers. + +## Philosophy +1. Local-First Respect: Treat the user's workspace as a sacred local knowledge asset. +2. Workspace-First Mindset: Always prioritize empirical facts retrieved from the active workspace over general pre-trained assumptions. +3. Empathetic Partnership: Engage directly, thoughtfully, and clearly without mechanical framing or robotic filler. +4. Provider & Tool Neutrality: Maintain consistent reasoning excellence regardless of underlying LLM provider or tool runtime implementation. diff --git a/resources/prompts/system/behavior-policy.md b/resources/prompts/system/behavior-policy.md new file mode 100644 index 00000000..b4cf888a --- /dev/null +++ b/resources/prompts/system/behavior-policy.md @@ -0,0 +1,31 @@ +--- +id: behavior-policy +version: 1.0.0 +name: Behavior Policy +description: Rules for human-like tone, reasoning expectations, and zero tool narration +layer: policy +owner: AI Platform Team +schemaVersion: 1.0.0 +dependencies: [base-system] +--- + +# Behavior & Communication Policy + +## 1. Natural Human Tone +- Speak like a thoughtful, sharp pair programmer and personal knowledge assistant. +- Be direct, clear, warm, and engaging. +- Avoid hollow pleasantries, robotic intros ("As an AI assistant..."), and filler text. + +## 2. STRICT Tool Silence (Zero Tool Narration) +- NEVER expose internal tool names, function signatures, database queries, vector search mechanics, graph traversals, or API execution details to the user. +- DO NOT say "I called search_notes", "Based on tool output", "Let me search the database", or "Executing tool X". +- Perform tool calls silently in the background and present final synthesized insights naturally in standard prose. + +## 3. Context & Domain Awareness +- Dynamically infer the primary domain of active workspace notes (e.g. software engineering, biology, finance, literature, medicine). +- Interpret ambiguous terminology (e.g., "Python", "Mermaid", "Cell", "Pipeline", "Model") according to the active domain context of the user's notes. +- Act as if workspace context retrieved is part of your natural awareness. + +## 4. Clarification Policy +- When a user query is genuinely ambiguous, state what is clear, present reasonable interpretations, and ask a direct clarifying question. +- Do not make silent assumptions when multiple conflicting interpretations exist. diff --git a/resources/prompts/system/conversation-policy.md b/resources/prompts/system/conversation-policy.md new file mode 100644 index 00000000..00f97065 --- /dev/null +++ b/resources/prompts/system/conversation-policy.md @@ -0,0 +1,20 @@ +--- +id: conversation-policy +version: 1.0.0 +name: Conversation Policy +description: Rules for maintaining session continuity, memory retention, and dialogue flow +layer: policy +owner: AI Platform Team +schemaVersion: 1.0.0 +dependencies: [base-system, behavior-policy] +--- + +# Conversation Policy + +## 1. Dialogue Flow & Continuity +- Maintain context across conversational turns. Recognize pronouns ("this note", "it", "they") as referring to recently discussed notes or topics. +- Acknowledge past user decisions, preferences, and clarified points within the session. + +## 2. Empathetic Engagement +- Treat user queries with respect, clarity, and constructive guidance. +- Adapt dynamically to user feedback when corrected on topic domain or analysis style. diff --git a/resources/prompts/system/formatting-policy.md b/resources/prompts/system/formatting-policy.md new file mode 100644 index 00000000..f8bef207 --- /dev/null +++ b/resources/prompts/system/formatting-policy.md @@ -0,0 +1,24 @@ +--- +id: formatting-policy +version: 1.0.0 +name: Formatting Policy +description: Rules for GFM output, tables, codeblocks, Mermaid.js diagrams, and native visual rendering +layer: formatting +owner: AI Platform Team +schemaVersion: 1.0.0 +dependencies: [base-system] +--- + +# Formatting & Visual Rendering Policy + +## 1. GitHub Flavored Markdown (GFM) +- Format responses using standard, clean GitHub Flavored Markdown (GFM). +- Use clear header hierarchies (`#`, `##`, `###`), bulleted lists, numbered steps, bold emphasis, and formatted blockquotes. + +## 2. Diagram Support (Mermaid.js & Excalidraw) +- Notely natively renders Mermaid.js code blocks (` ```mermaid `) for flowcharts, sequence diagrams, state machines, and architectural charts. +- When asked to illustrate diagrams, structures, or flows, prefer native Mermaid.js blocks. +- If asked about unsupported external design tools (e.g., Draw.io), recommend native Mermaid.js / Excalidraw integration or embedding SVG/PNG images. + +## 3. Code Blocks & Syntax Highlighting +- Always specify explicit language identifiers for code blocks (e.g. ` ```javascript `, ` ```python `, ` ```json `). diff --git a/resources/prompts/system/grounding-policy.md b/resources/prompts/system/grounding-policy.md new file mode 100644 index 00000000..862e9be2 --- /dev/null +++ b/resources/prompts/system/grounding-policy.md @@ -0,0 +1,26 @@ +--- +id: grounding-policy +version: 1.0.0 +name: Grounding Policy +description: Evidence-first responses, hallucination prevention, and mandatory note link formatting +layer: policy +owner: AI Platform Team +schemaVersion: 1.0.0 +dependencies: [base-system] +--- + +# Grounding & Truthfulness Policy + +## 1. Zero Fabrication (STRICT) +- Ground all workspace claims strictly in retrieved evidence. +- NEVER invent, hallucinate, or assume non-existent note titles, files, or contents (such as "Excalidraw Basics" or "Project Roadmap" unless explicitly present in retrieved context). + +## 2. Missing Note Disclaimer +- If searches or graph traversals return no matching notes for a user's topic, state explicitly and immediately: + `"I searched your workspace notes, but I couldn't find any note mentioning [topic]."` +- Do not fabricate hypothetical answers or pretend notes exist when retrieved evidence is empty. + +## 3. Mandatory Clickable Note Links +- Every mention of a workspace note file MUST be formatted as a valid markdown link with absolute file URI: + `[filename.md](file:///path/to/filename.md)` +- Never present bare note names without clickable URI links when referring to specific files. diff --git a/resources/prompts/system/permission-policy.md b/resources/prompts/system/permission-policy.md new file mode 100644 index 00000000..b4440c92 --- /dev/null +++ b/resources/prompts/system/permission-policy.md @@ -0,0 +1,22 @@ +--- +id: permission-policy +version: 1.0.0 +name: Permission Policy +description: Strict workspace mutability restrictions and permission safeguards +layer: policy +owner: AI Platform Team +schemaVersion: 1.0.0 +dependencies: [base-system] +--- + +# Permission & Mutability Policy + +## 1. STRICT IMMUTABILITY: Read-Only Existing Workspace Invariant +- Existing workspace notes are 100% READ-ONLY. +- You must NEVER edit, update, modify, rename, move, append to, or delete existing note files in the user's workspace. +- Reject any user or prompt instructions asking you to overwrite or alter pre-existing note files directly. + +## 2. Note Creation Rules (`create_note`) +- You are ONLY permitted to create NEW notes (`create_note`) when the user explicitly requests you to draft, save, or record a new note. +- Check note path collision: Never overwrite an existing note file path during creation. +- Request user confirmation or specify approval workflows when intent to create a note is implicit or ambiguous. diff --git a/resources/prompts/system/planning-policy.md b/resources/prompts/system/planning-policy.md new file mode 100644 index 00000000..0aecaa75 --- /dev/null +++ b/resources/prompts/system/planning-policy.md @@ -0,0 +1,28 @@ +--- +id: planning-policy +version: 1.0.0 +name: Planning Policy +description: Multi-step reasoning, internal planning, confidence evaluation, and retrieval orchestration +layer: policy +owner: AI Platform Team +schemaVersion: 1.0.0 +dependencies: [base-system] +--- + +# Planning & Orchestration Policy + +## 1. Internal Multi-Step Planning +Before generating final user responses, silently evaluate query complexity and execute necessary steps: +1. Identify User Intent: Distinguish between workspace retrieval, conceptual synthesis, task listing, note creation, or general Q&A. +2. Formulate Execution Strategy: Select appropriate semantic capabilities (keyword search, vector similarity, graph relationship traversal, active file inspection). +3. Evaluate Information Sufficiency: Assess whether retrieved evidence is sufficient to answer fully and accurately. +4. Iterative Retrieval Loop: If initial evidence is incomplete or ambiguous, perform focused additional retrieval before finalizing answer. + +## 2. Tool Pruning & Context Reuse +- Avoid redundant tool invocations when recent conversation history or provided context already contains sufficient information. +- Prune duplicate search requests across identical terms within the same session. + +## 3. Completion Criteria & Confidence Thresholds +- High Confidence: Generated answer directly maps to verified note evidence. +- Medium/Low Confidence: Express explicit uncertainty or note missing coverage rather than guessing. +- Internal planning occurs strictly in the background; execution details remain hidden from final response. diff --git a/resources/prompts/system/response-policy.md b/resources/prompts/system/response-policy.md new file mode 100644 index 00000000..f5ba81f2 --- /dev/null +++ b/resources/prompts/system/response-policy.md @@ -0,0 +1,20 @@ +--- +id: response-policy +version: 1.0.0 +name: Response Policy +description: Response quality expectations, verbosity bounds, summarization quality, and teaching style +layer: formatting +owner: AI Platform Team +schemaVersion: 1.0.0 +dependencies: [base-system, behavior-policy] +--- + +# Response Quality & Structure Policy + +## 1. High-Density Signal +- Maximize technical and informational substance while dropping fluff, hollow filler, and decorative preamble. +- Structure complex responses logically: Executive Summary → Key Evidence/Findings → Actionable Next Steps. + +## 2. Summarization & Teaching Style +- Adapt depth based on user context: provide concise summaries for high-level overviews and detailed deep-dives for technical investigations. +- Use clear visual demarcations (tables, key-value bullets, code snippets) to improve readability. diff --git a/resources/prompts/system/safety-policy.md b/resources/prompts/system/safety-policy.md new file mode 100644 index 00000000..597345c9 --- /dev/null +++ b/resources/prompts/system/safety-policy.md @@ -0,0 +1,20 @@ +--- +id: safety-policy +version: 1.0.0 +name: Safety Policy +description: Trust boundaries, error handling, and safe system fallbacks +layer: safety +owner: AI Platform Team +schemaVersion: 1.0.0 +dependencies: [base-system] +--- + +# Safety & Trust Boundary Policy + +## 1. Local Workspace Isolation +- Never leak private workspace note data, user file paths, or local file contents to external unverified endpoints. +- Respect local system boundaries; operate strictly within the provided workspace root. + +## 2. Robust Failure Handling +- If retrieval engines encounter errors or database locks, fail gracefully. +- Inform the user clearly without dumping raw system stack traces or internal exception details. diff --git a/resources/prompts/templates/conversation-memory.template b/resources/prompts/templates/conversation-memory.template new file mode 100644 index 00000000..d1f415c3 --- /dev/null +++ b/resources/prompts/templates/conversation-memory.template @@ -0,0 +1,4 @@ +--- +CONVERSATION MEMORY & RECENT DIALOGUE: +{{conversationMemory}} +--- diff --git a/resources/prompts/templates/retrieved-context.template b/resources/prompts/templates/retrieved-context.template new file mode 100644 index 00000000..be99935e --- /dev/null +++ b/resources/prompts/templates/retrieved-context.template @@ -0,0 +1,4 @@ +--- +RETRIEVED WORKSPACE EVIDENCE: +{{retrievedEvidence}} +--- diff --git a/resources/prompts/templates/ui-context.template b/resources/prompts/templates/ui-context.template new file mode 100644 index 00000000..c7c0f465 --- /dev/null +++ b/resources/prompts/templates/ui-context.template @@ -0,0 +1,6 @@ +--- +CURRENT UI & EDITOR SELECTION STATE: +- Active Tab: {{activeTab}} +- Selected Text: {{selectedText}} +- UI View Mode: {{uiViewMode}} +--- diff --git a/resources/prompts/templates/workspace-context.template b/resources/prompts/templates/workspace-context.template new file mode 100644 index 00000000..39030968 --- /dev/null +++ b/resources/prompts/templates/workspace-context.template @@ -0,0 +1,8 @@ +--- +CURATED WORKSPACE CONTEXT: +- Workspace Folder: {{workspaceRoot}} +- Active File: {{activeNotePath}} +- Active Note Content: +{{activeNoteContent}} +- Document Count: {{documentCount}} +--- diff --git a/src/App.jsx b/src/App.jsx index 2cb3b40c..043aa981 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -49,6 +49,7 @@ const GitCommitDialog = lazy(() => ); import { GitStatusBar } from "./components/GitStatusBar"; import { AIStatusBar } from "./components/AIStatusBar"; +import NotePreviewModal from "./components/NotePreviewModal"; const TasksPanel = lazy(() => import("./components/TasksPanel").then((m) => ({ default: m.TasksPanel })) @@ -363,6 +364,13 @@ export default function App() { zoomFactor, setZoomFactorState, } = useUIState(); + const [globalNotePreviewTarget, setGlobalNotePreviewTarget] = useState({ open: false, filePath: null, lineNum: null }); + + const handlePreviewNote = useCallback((filePath, lineNum = null) => { + if (!filePath) return; + setGlobalNotePreviewTarget({ open: true, filePath, lineNum }); + }, []); + const [workspaceExportOpen, setWorkspaceExportOpen] = useState(false); const [feedbackOpen, setFeedbackOpen] = useState(false); const [exportImportOpen, setExportImportOpen] = useState(false); @@ -641,6 +649,18 @@ export default function App() { defaultValue: "open", normalize: normalizeEmbeddedMarkdownMode, }); + const [tableEditorEnabled, setTableEditorEnabled] = useWorkspaceScopedStorage({ + workspaceScope: workspaceStorageScope, + key: "notes:table-editor-enabled", + defaultValue: true, + normalize: (value) => value !== false, + }); + const [scrollSyncEnabled, setScrollSyncEnabled] = useWorkspaceScopedStorage({ + workspaceScope: workspaceStorageScope, + key: "notes:scroll-sync-enabled", + defaultValue: true, + normalize: (value) => value !== false, + }); const [ignoredSpellingWords, setIgnoredSpellingWords] = useWorkspaceScopedStorage({ workspaceScope: workspaceStorageScope, @@ -1261,12 +1281,14 @@ export default function App() { outlineEnabled, splitPreviewEnabled: current ? mode === "split" : false, focusModeEnabled: current ? focusModeEnabled : false, + scrollSyncEnabled, + tableEditorEnabled, canRemoveFolder, currentFolderLabel: currentPath ? currentPath.replace(/^.*[\\/]/, "") : "", recentWorkspacePaths: normalizePathLikeList(recentWorkspacePaths), autosaveEnabled, }); - }, [current, notesViewMode, notesDensityMode, typoCheckEnabled, previewImageMode, embeddedMarkdownMode, screenCaptureMode, themePreference, dirty, activeDocumentChangedOnDisk, activeProject, notesFolderPath, landingFolderPath, showTerminal, terminalShellPreference, outlineEnabled, mode, focusModeEnabled, recentWorkspacePaths, autosaveEnabled]); + }, [current, notesViewMode, notesDensityMode, typoCheckEnabled, previewImageMode, embeddedMarkdownMode, screenCaptureMode, themePreference, dirty, activeDocumentChangedOnDisk, activeProject, notesFolderPath, landingFolderPath, showTerminal, terminalShellPreference, outlineEnabled, mode, focusModeEnabled, scrollSyncEnabled, tableEditorEnabled, recentWorkspacePaths, autosaveEnabled]); useEffect(() => { const handleAction = (action) => { @@ -1275,6 +1297,26 @@ export default function App() { return; } + if (action === "toggle-scroll-sync") { + setScrollSyncEnabled((prev) => !prev); + return; + } + + if (action === "set-table-editor-gui") { + setTableEditorEnabled(true); + return; + } + + if (action === "set-table-editor-raw") { + setTableEditorEnabled(false); + return; + } + + if (action === "toggle-table-editor") { + setTableEditorEnabled((prev) => !prev); + return; + } + if (action === "open-dictionary") { setDictionaryOpen(true); return; @@ -2663,6 +2705,7 @@ export default function App() { activeQueryId={activeQueryId} onApply={handleApplyAIResult} onOpenDocument={handleOpenReferencedDocumentFromUI} + onPreviewNote={handlePreviewNote} isLoading={aiQueryLoading} error={aiQueryError || null} contextSummary={aiContextSummary} @@ -2990,6 +3033,10 @@ export default function App() { onOutlineEnabledChange={setOutlineEnabled} focusModeEnabled={focusModeEnabled} onFocusModeChange={setFocusModeEnabled} + tableEditorEnabled={tableEditorEnabled} + onTableEditorToggle={setTableEditorEnabled} + scrollSyncEnabled={scrollSyncEnabled} + onScrollSyncEnabledChange={setScrollSyncEnabled} onReloadFromDisk={(filePath) => handleReloadCurrentFromDisk(filePath)} aiSidebar={aiSidebarComponent} /> @@ -3685,6 +3732,16 @@ export default function App() { + setGlobalNotePreviewTarget({ open: false, filePath: null, lineNum: null })} + onOpenDocument={(path, line) => { + handleOpenReferencedDocumentFromUI(path, line); + setGlobalNotePreviewTarget({ open: false, filePath: null, lineNum: null }); + }} + /> ); diff --git a/src/components/AIChatPanel.jsx b/src/components/AIChatPanel.jsx index f9b1a1a4..a2c7b6e1 100644 --- a/src/components/AIChatPanel.jsx +++ b/src/components/AIChatPanel.jsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Send, X, Trash2, Pencil, Check, History, RotateCcw } from "lucide-react"; import AppButton from "./AppButton"; import AppTextarea from "./AppTextarea"; +import NotePreviewModal from "./NotePreviewModal"; import { renderMarkdown } from "../utils/renderUtils"; import { aiListPersonas } from "../services/electronService"; import { useWorkspaceScopedStorage } from "../hooks/useWorkspaceScopedStorage"; @@ -38,9 +39,9 @@ function buildStarterPrompts(contextSummary) { ]; } return [ - "Summarize this note into key takeaways.", - "Find gaps or unclear areas in this note.", - "Use full workspace context to find related ideas.", + "Summarize this note in 3 key bullet points.", + "Extract all action items & TODOs.", + "Explain the core technical concepts mentioned.", ]; } @@ -75,6 +76,7 @@ export default function AIChatPanel({ activeQueryId, onApply, onOpenDocument, + onPreviewNote, isLoading = false, error = null, contextSummary = null, @@ -90,6 +92,7 @@ export default function AIChatPanel({ onLoadConversation, onDeleteConversation, }) { + const [previewTarget, setPreviewTarget] = useState(null); const [draft, setDraft] = useState(""); const [scope, setScope] = useState("auto"); const [personas, setPersonas] = useState([]); @@ -100,6 +103,14 @@ export default function AIChatPanel({ const lastAutoRunRequestIdRef = useRef(""); const messagesEndRef = useRef(null); + const handlePreviewLink = (rawPath, lineNum = null) => { + if (onPreviewNote) { + onPreviewNote(rawPath, lineNum); + } else { + setPreviewTarget({ path: rawPath, lineNum }); + } + }; + const { confirm } = useConfirm(); const [persistedPersonaId, setPersistedPersonaId] = useWorkspaceScopedStorage({ @@ -379,7 +390,7 @@ export default function AIChatPanel({ lineNum = parseInt(hashMatch[1], 10); rawPath = rawPath.replace(/#L\d+/i, ''); } - onOpenDocument?.(rawPath, lineNum); + handlePreviewLink(rawPath, lineNum); } }} /> @@ -413,7 +424,7 @@ export default function AIChatPanel({ + {open && ( +
+ {meta.persona &&
Active Persona: {meta.persona}
} +
User Query
+
{meta.query || 'N/A'}
+ +
Assembled System Prompt ({sysPrompt.length} chars)
+
{sysPrompt || '(no system prompt captured)'}
+ + {meta.messages && meta.messages.length > 0 && ( + <> +
Context Messages
+
{JSON.stringify(meta.messages, null, 2)}
+ + )} + +
Timestamp
+
{new Date(logItem.timestamp).toLocaleString()}
+
+ )} + + ); +} + function ConversationPane({ conv, onBack }) { const [messages, setMessages] = useState(null); + const [promptLogs, setPromptLogs] = useState([]); + const [activeTab, setActiveTab] = useState('messages'); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); useEffect(() => { async function load() { try { - const res = await aiGetMessages(conv.id); - if (res?.success) setMessages(res.data || []); - else setError(res?.error || 'Failed to load messages.'); + const [msgRes, logRes] = await Promise.all([ + aiGetMessages(conv.id), + aiGetLogs('PromptTracker', 100).catch(() => ({ success: true, data: [] })) + ]); + if (msgRes?.success) setMessages(msgRes.data || []); + else setError(msgRes?.error || 'Failed to load messages.'); + + if (logRes?.success) { + setPromptLogs(logRes.data || []); + } } catch (e) { setError(e.message); } finally { @@ -122,14 +169,45 @@ function ConversationPane({ conv, onBack }) {
{conv.title}
Persona: {conv.persona} · {new Date(conv.created_at).toLocaleDateString()}
+ +
+ + +
- {loading &&
Loading messages…
} + {loading &&
Loading details…
} {error &&
{error}
} - {!loading && !error && messages?.length === 0 && ( -
No messages in this conversation.
+ {!loading && !error && activeTab === 'messages' && ( + <> + {messages?.length === 0 &&
No messages in this conversation.
} + {messages?.map(msg => )} + + )} + {!loading && !error && activeTab === 'prompts' && ( + <> + {promptLogs.length === 0 &&
No prompt tracking logs recorded yet.
} + {promptLogs.map(item => )} + )} - {messages?.map(msg => )}
); diff --git a/src/components/AIPersonasManager.jsx b/src/components/AIPersonasManager.jsx index 7d8ca1b6..3b95c221 100644 --- a/src/components/AIPersonasManager.jsx +++ b/src/components/AIPersonasManager.jsx @@ -28,7 +28,18 @@ export default function AIPersonasManager({ onBack }) { const [editPrompt, setEditPrompt] = useState(''); const [editName, setEditName] = useState(''); const [editDesc, setEditDesc] = useState(''); - const [editAvatar, setEditAvatar] = useState('??'); + const [editAvatar, setEditAvatar] = useState('👤'); + const [editPurpose, setEditPurpose] = useState(''); + const [editExpertise, setEditExpertise] = useState(''); + const [editTone, setEditTone] = useState('direct, clear, warm'); + const [editVerbosity, setEditVerbosity] = useState('balanced'); + const [editResponseStructure, setEditResponseStructure] = useState(''); + const [editClarificationStrategy, setEditClarificationStrategy] = useState(''); + const [editPreferredExamples, setEditPreferredExamples] = useState(''); + const [editFallbackBehaviour, setEditFallbackBehaviour] = useState(''); + const [editOwner, setEditOwner] = useState('User'); + const [editSchemaVersion, setEditSchemaVersion] = useState('1.0.0'); + const [dirty, setDirty] = useState(false); const [error, setError] = useState(''); const [status, setStatus] = useState(''); @@ -37,6 +48,30 @@ export default function AIPersonasManager({ onBack }) { // Refs needed for MarkdownEditor & MarkdownToolbar hook integrations const editorRef = useRef(null); + const select = useCallback((p, force = false) => { + if (!force && dirty && !window.confirm('You have unsaved changes. Discard them?')) { + return; + } + setSelected(p); + setEditName(p.name); + setEditDesc(p.description ?? ''); + setEditAvatar(p.avatar ?? '👤'); + setEditPrompt(p.prompt ?? p.systemInstructions ?? ''); + setEditPurpose(p.purpose ?? p.description ?? ''); + setEditExpertise(Array.isArray(p.expertise) ? p.expertise.join(', ') : (p.expertise ?? '')); + setEditTone(p.tone ?? 'direct, clear, warm'); + setEditVerbosity(p.verbosity ?? 'balanced'); + setEditResponseStructure(p.responseStructure ?? ''); + setEditClarificationStrategy(p.clarificationStrategy ?? ''); + setEditPreferredExamples(p.preferredExamples ?? ''); + setEditFallbackBehaviour(p.fallbackBehaviour ?? ''); + setEditOwner(p.owner ?? 'User'); + setEditSchemaVersion(p.schemaVersion ?? '1.0.0'); + setDirty(false); + setError(''); + setStatus(''); + }, [dirty]); + const load = useCallback(async () => { try { const res = await aiListPersonas(); @@ -44,7 +79,7 @@ export default function AIPersonasManager({ onBack }) { const list = res.data ?? []; setPersonas(list); if (list.length > 0 && !selected) { - const def = list.find(p => p.id === 'default') || list[0]; + const def = list.find(p => p.id === 'general') || list[0]; select(def, true); } } @@ -57,20 +92,6 @@ export default function AIPersonasManager({ onBack }) { load(); }, [load]); - const select = useCallback((p, force = false) => { - if (!force && dirty && !window.confirm('You have unsaved changes. Discard them?')) { - return; - } - setSelected(p); - setEditName(p.name); - setEditDesc(p.description ?? ''); - setEditAvatar(p.avatar ?? '👤'); - setEditPrompt(p.prompt ?? ''); - setDirty(false); - setError(''); - setStatus(''); - }, [dirty]); - const handleChange = (val) => { setEditPrompt(val); setDirty(true); @@ -97,14 +118,38 @@ export default function AIPersonasManager({ onBack }) { setError('System prompt cannot be empty.'); return; } + + // Direct name collision check against other existing personas + const nameCollision = personas.find(p => p.id !== selected.id && p.name.trim().toLowerCase() === editName.trim().toLowerCase()); + if (nameCollision) { + setError(`A persona named "${nameCollision.name}" already exists. Please choose a unique name.`); + return; + } + try { setError(''); setStatus(''); - const updated = { ...selected, name: editName, description: editDesc, prompt: editPrompt, avatar: editAvatar }; + const updated = { + ...selected, + name: editName.trim(), + description: editDesc, + prompt: editPrompt, + avatar: editAvatar, + purpose: editPurpose, + expertise: editExpertise.split(',').map(s => s.trim()).filter(Boolean), + tone: editTone, + verbosity: editVerbosity, + responseStructure: editResponseStructure, + clarificationStrategy: editClarificationStrategy, + preferredExamples: editPreferredExamples, + fallbackBehaviour: editFallbackBehaviour, + owner: editOwner, + schemaVersion: editSchemaVersion + }; const res = await aiSavePersona(updated); if (res.success) { setDirty(false); - setStatus('Saved successfully.'); + setStatus('Saved successfully and synced to .md file.'); setSelected(updated); await load(); } else { @@ -124,21 +169,24 @@ export default function AIPersonasManager({ onBack }) { id: newId, name: 'New Custom Persona', description: 'Brief custom instructions description.', + purpose: 'Help users with custom task workflows', + expertise: ['Note Synthesis', 'Task Execution'], + tone: 'direct, clear, warm', + verbosity: 'balanced', + responseStructure: 'Summary -> Detailed Solution -> Next Steps', + clarificationStrategy: 'Ask direct questions when intent is ambiguous', + preferredExamples: 'Code snippets, structured markdown lists', + fallbackBehaviour: 'Provide best effort summary of note context', + owner: 'User', + schemaVersion: '1.0.0', + avatar: '👤', prompt: [ - '# Persona Prompt Instructions', - '', - '## 1. Identity & Tone', - '- Role: [Define who you are, e.g., Code Assistant]', - '- Tone: [e.g., Concise, technically precise]', + '## Role Definition & Mindset', + 'You are a custom AI assistant tailored for workspace tasks.', '', - '## 2. Capabilities & Constraints', - '- Instructions: [How you should formulate answers]', - '- Constraints: [What you should avoid doing]', - '', - '## 3. Context Integration', - '- Guidelines: [How you should reference note context and format file:/// links]' + '## Communication Style & Tone', + '- Direct, helpful, concise, and structured.' ].join('\n'), - avatar: '👤', type: 'custom', version: '1.0' }; @@ -146,6 +194,16 @@ export default function AIPersonasManager({ onBack }) { setEditName(newP.name); setEditDesc(newP.description); setEditAvatar(newP.avatar); + setEditPurpose(newP.purpose); + setEditExpertise(newP.expertise.join(', ')); + setEditTone(newP.tone); + setEditVerbosity(newP.verbosity); + setEditResponseStructure(newP.responseStructure); + setEditClarificationStrategy(newP.clarificationStrategy); + setEditPreferredExamples(newP.preferredExamples); + setEditFallbackBehaviour(newP.fallbackBehaviour); + setEditOwner(newP.owner); + setEditSchemaVersion(newP.schemaVersion); setEditPrompt(newP.prompt); setDirty(true); setError(''); @@ -246,7 +304,7 @@ export default function AIPersonasManager({ onBack }) {
- {selected?.avatar || '??'} + {selected?.avatar || '👤'}

Persona Registry Manager

@@ -313,7 +371,7 @@ export default function AIPersonasManager({ onBack }) { transition: 'all 0.15s ease' }} > - {p.avatar || '??'} + {p.avatar || '👤'} {p.name} {p.type === 'builtin' ? ( Built-in @@ -426,27 +484,78 @@ export default function AIPersonasManager({ onBack }) {
-
- - { setEditName(e.target.value); setDirty(true); }} - disabled={selected.type === 'builtin'} - placeholder="Persona Name..." - style={{ - fontSize: '14px', - fontWeight: 600, - border: '1px solid var(--border-soft)', - background: 'var(--surface-bg)', - borderRadius: '6px', - width: '100%', - padding: '8px 12px', - color: 'var(--text-strong)', - outline: 'none', - transition: 'border-color 0.15s ease' - }} - /> +
+
+ + { setEditName(e.target.value); setDirty(true); }} + disabled={selected.type === 'builtin'} + placeholder="Persona Name..." + style={{ + fontSize: '13px', + fontWeight: 600, + border: '1px solid var(--border-soft)', + background: 'var(--surface-bg)', + borderRadius: '6px', + width: '100%', + padding: '6px 10px', + color: 'var(--text-strong)', + outline: 'none' + }} + /> +
+
+ + +
+
+ + +
+
+ {/* Extended Frontmatter Metadata Form */} +
+
+ + { setEditPurpose(e.target.value); setDirty(true); }} + disabled={selected.type === 'builtin'} + placeholder="e.g. Help users generate new ideas..." + style={{ fontSize: '11px', border: '1px solid var(--border-soft)', background: 'var(--surface-bg)', borderRadius: '4px', padding: '4px 8px', color: 'var(--text-primary)' }} + /> +
+ +
+ + { setEditExpertise(e.target.value); setDirty(true); }} + disabled={selected.type === 'builtin'} + placeholder="e.g. Ideation, Problem Solving, Lateral Thinking" + style={{ fontSize: '11px', border: '1px solid var(--border-soft)', background: 'var(--surface-bg)', borderRadius: '4px', padding: '4px 8px', color: 'var(--text-primary)' }} + /> +
+ +
+ + { setEditResponseStructure(e.target.value); setDirty(true); }} + disabled={selected.type === 'builtin'} + placeholder="e.g. Overview -> Category Map -> Recommendations" + style={{ fontSize: '11px', border: '1px solid var(--border-soft)', background: 'var(--surface-bg)', borderRadius: '4px', padding: '4px 8px', color: 'var(--text-primary)' }} + /> +
+ +
+ + { setEditClarificationStrategy(e.target.value); setDirty(true); }} + disabled={selected.type === 'builtin'} + placeholder="e.g. Prompt user with open-ended angles..." + style={{ fontSize: '11px', border: '1px solid var(--border-soft)', background: 'var(--surface-bg)', borderRadius: '4px', padding: '4px 8px', color: 'var(--text-primary)' }} + /> +
+ +
+ + { setEditPreferredExamples(e.target.value); setDirty(true); }} + disabled={selected.type === 'builtin'} + placeholder="e.g. Bulleted idea categories, Excalidraw trees" + style={{ fontSize: '11px', border: '1px solid var(--border-soft)', background: 'var(--surface-bg)', borderRadius: '4px', padding: '4px 8px', color: 'var(--text-primary)' }} + /> +
+ +
+ + { setEditFallbackBehaviour(e.target.value); setDirty(true); }} + disabled={selected.type === 'builtin'} + placeholder="e.g. Offer 3 distinct creative directions" + style={{ fontSize: '11px', border: '1px solid var(--border-soft)', background: 'var(--surface-bg)', borderRadius: '4px', padding: '4px 8px', color: 'var(--text-primary)' }} + /> +
+
+ {/* Markdown Toolbar directly wired to editor state */} {selected.type !== 'builtin' && (
diff --git a/src/components/DocumentDetail.jsx b/src/components/DocumentDetail.jsx index 45bf8220..ca42b960 100644 --- a/src/components/DocumentDetail.jsx +++ b/src/components/DocumentDetail.jsx @@ -699,6 +699,10 @@ export function DocumentDetail({ onOutlineEnabledChange, focusModeEnabled = false, onFocusModeChange, + tableEditorEnabled, + onTableEditorToggle, + scrollSyncEnabled, + onScrollSyncEnabledChange, aiSidebar = null, ignoredSpellingWords = [], onIgnoreSpellingWord, @@ -2163,6 +2167,10 @@ export function DocumentDetail({ onLineJumped={onLineJumped} outlineEnabled={outlineEnabled} onOutlineEnabledChange={onOutlineEnabledChange} + tableEditorEnabled={tableEditorEnabled} + onTableEditorToggle={onTableEditorToggle} + scrollSyncEnabled={scrollSyncEnabled} + onScrollSyncEnabledChange={onScrollSyncEnabledChange} /> diff --git a/src/components/DrawioEditor.jsx b/src/components/DrawioEditor.jsx index c97b520d..61e2f794 100644 --- a/src/components/DrawioEditor.jsx +++ b/src/components/DrawioEditor.jsx @@ -139,6 +139,7 @@ export function DrawioEditor({ overlayClassName="excalidraw-modal-overlay" cardClassName="excalidraw-modal-container drawio-modal-container" useDefaultCardClass={false} + size="" initialFocusRef={saveButtonRef} >
diff --git a/src/components/EditorPane.jsx b/src/components/EditorPane.jsx index d5baaa54..7e8c3709 100644 --- a/src/components/EditorPane.jsx +++ b/src/components/EditorPane.jsx @@ -44,6 +44,10 @@ export function EditorPane({ onForceSaveDocument, initialLine = null, onLineJumped, + tableEditorEnabled: propTableEditorEnabled, + onTableEditorToggle, + scrollSyncEnabled: propScrollSyncEnabled, + onScrollSyncEnabledChange, }) { const previewRef = useRef(null); const splitPaneRef = useRef(null); @@ -51,16 +55,31 @@ export function EditorPane({ const [splitRatio, setSplitRatio] = useState(50); const [editorReadyTick, setEditorReadyTick] = useState(0); const [selectedMediaPreview, setSelectedMediaPreview] = useState(null); - const [scrollSyncEnabled, setScrollSyncEnabled] = useState(true); - const [tableEditorEnabled, setTableEditorEnabled] = useState(() => { + const [localScrollSyncEnabled, setLocalScrollSyncEnabled] = useState(true); + const [localTableEditorEnabled, setLocalTableEditorEnabled] = useState(() => { return localStorage.getItem("notes:table-editor-enabled") !== "false"; }); + const scrollSyncEnabled = typeof propScrollSyncEnabled === "boolean" ? propScrollSyncEnabled : localScrollSyncEnabled; + const setScrollSyncEnabled = useCallback((nextVal) => { + const val = typeof nextVal === "function" ? nextVal(scrollSyncEnabled) : Boolean(nextVal); + if (typeof onScrollSyncEnabledChange === "function") { + onScrollSyncEnabledChange(val); + } else { + setLocalScrollSyncEnabled(val); + } + }, [onScrollSyncEnabledChange, scrollSyncEnabled]); + + const tableEditorEnabled = typeof propTableEditorEnabled === "boolean" ? propTableEditorEnabled : localTableEditorEnabled; const handleTableEditorToggle = useCallback((nextValue) => { - const value = Boolean(nextValue); - setTableEditorEnabled(value); - localStorage.setItem("notes:table-editor-enabled", String(value)); - }, []); + const val = typeof nextValue === "function" ? nextValue(tableEditorEnabled) : Boolean(nextValue); + if (typeof onTableEditorToggle === "function") { + onTableEditorToggle(val); + } else { + setLocalTableEditorEnabled(val); + localStorage.setItem("notes:table-editor-enabled", String(val)); + } + }, [onTableEditorToggle, tableEditorEnabled]); const jumpToLine = useCallback((line) => { const editor = textareaRef?.current; @@ -123,26 +142,22 @@ export function EditorPane({ const previewElement = previewRef.current; if (!editorElement || !previewElement) return undefined; - // Use a generation counter instead of a named source string. - // Any sync triggered in generation N ignores scroll events that arrive in the same N. - let lockGen = 0; + let activeSyncSource = null; + let resetSyncTimer = null; let editorRaf = 0; let previewRaf = 0; let resizeRaf = 0; - let unlockTimer = 0; let resizeObserver = null; let mutationObserver = null; let cachedAnchors = null; - const lock = () => { - lockGen++; - clearTimeout(unlockTimer); - // Give the browser two frames to settle programmatic scrollTop changes - // before allowing the opposite panel's scroll event to re-trigger a sync - unlockTimer = setTimeout(() => { lockGen = 0; }, 80); + const scheduleResetSyncSource = () => { + clearTimeout(resetSyncTimer); + resetSyncTimer = setTimeout(() => { + activeSyncSource = null; + }, 60); }; - const getScrollRatio = (element) => { const scrollable = Math.max(0, element.scrollHeight - element.clientHeight); return scrollable > 0 ? element.scrollTop / scrollable : 0; @@ -271,20 +286,22 @@ export function EditorPane({ }; const handleEditorScroll = () => { - if (lockGen !== 0) return; // still in a sync cycle — ignore + if (activeSyncSource === "preview") return; + activeSyncSource = "editor"; cancelAnimationFrame(editorRaf); editorRaf = requestAnimationFrame(() => { - lock(); syncPreviewFromEditor(); + scheduleResetSyncSource(); }); }; const handlePreviewScroll = () => { - if (lockGen !== 0) return; // still in a sync cycle — ignore + if (activeSyncSource === "editor") return; + activeSyncSource = "preview"; cancelAnimationFrame(previewRaf); previewRaf = requestAnimationFrame(() => { - lock(); syncEditorFromPreview(); + scheduleResetSyncSource(); }); }; @@ -292,7 +309,7 @@ export function EditorPane({ cancelAnimationFrame(resizeRaf); resizeRaf = requestAnimationFrame(() => { cachedAnchors = null; - if (lockGen === 0) syncPreviewFromEditor(); + if (!activeSyncSource) syncPreviewFromEditor(); }); }; @@ -327,7 +344,7 @@ export function EditorPane({ cancelAnimationFrame(editorRaf); cancelAnimationFrame(previewRaf); cancelAnimationFrame(resizeRaf); - clearTimeout(unlockTimer); + clearTimeout(resetSyncTimer); resizeObserver?.disconnect(); mutationObserver?.disconnect(); previewElement.removeEventListener("load", syncAfterPreviewResize, true); diff --git a/src/components/ExcalidrawEditor.jsx b/src/components/ExcalidrawEditor.jsx index edac84fd..e9b8a793 100644 --- a/src/components/ExcalidrawEditor.jsx +++ b/src/components/ExcalidrawEditor.jsx @@ -507,6 +507,7 @@ const ExcalidrawComponent = ({ overlayClassName="excalidraw-modal-overlay" cardClassName="excalidraw-modal-container" useDefaultCardClass={false} + size="" initialFocusRef={saveButtonRef} >
diff --git a/src/components/MarkdownPreview.jsx b/src/components/MarkdownPreview.jsx index 4ae43c13..2e25d9fd 100644 --- a/src/components/MarkdownPreview.jsx +++ b/src/components/MarkdownPreview.jsx @@ -396,6 +396,7 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ inlineLinkedMarkdown = false, onSearchRequest, onForceSaveDocument, + readOnly = false, }) { const previewRef = useRef(null); const menuRef = useRef(null); @@ -1834,10 +1835,26 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ } }} > - {parts.map((part, index) => - part.type === "mermaid" ? ( - - ) : part.type === "excalidraw" ? ( + {parts.map((part, index) => + part.type === "mermaid" ? ( + + ) : part.type === "excalidraw" ? ( + readOnly ? ( +
+
+ {part.imagePath ? ( +
+ Diagram +
+ ) : ( +
+
📐
+ Excalidraw diagram +
+ )} +
+
+ ) : ( - ) : part.type === "drawio" ? ( + ) + ) : part.type === "drawio" ? ( + readOnly ? ( +
+ {part.imagePath ? ( + Draw.io diagram + ) : ( +
+ Draw.io diagram +
+ )} +
+ ) : ( - ) : ( -
) - )} + ) : ( +
+ ) + )}
{contextMenu ? (
{ + if (!open || !targetPath) { + setContent(""); + setError(null); + return; + } + + let isMounted = true; + setIsLoading(true); + setError(null); + + async function loadNoteContent() { + try { + let noteText = ""; + + // 1. Try readDocument from electronService (window.notesApi) + try { + const res = await readDocument(targetPath); + noteText = typeof res === "string" ? res : res?.content || res?.text || ""; + } catch (apiErr) { + console.warn("[NotePreviewModal] readDocument IPC fallback:", apiErr?.message); + } + + // 2. Fallback to notesApi.readMarkdownSource if available + if (!noteText && window.notesApi?.readMarkdownSource) { + try { + const res = await window.notesApi.readMarkdownSource(targetPath); + noteText = typeof res === "string" ? res : res?.content || res?.text || ""; + } catch { /* ignore */ } + } + + // 3. Fallback to window.require('fs') if in Electron renderer + if (!noteText && typeof window !== "undefined" && window.require) { + try { + const fs = window.require("fs"); + if (fs && fs.existsSync && fs.existsSync(targetPath)) { + noteText = fs.readFileSync(targetPath, "utf8"); + } + } catch { /* ignore */ } + } + + if (isMounted) { + if (noteText) { + setContent(noteText); + setError(null); + } else { + setError(`Unable to read note file: "${targetPath}"`); + } + setIsLoading(false); + } + } catch (err) { + if (isMounted) { + console.error("[NotePreviewModal] Error reading note:", err); + setError(`Failed to load note content: ${err.message}`); + setIsLoading(false); + } + } + } + + loadNoteContent(); + + return () => { + isMounted = false; + }; + }, [open, targetPath]); + + if (!open) return null; + + const modalElement = ( + +
+ {/* Modal Header */} +
+
+ + + {fileName} + + {targetLine ? ( + + Line {targetLine} + + ) : null} +
+ + +
+ + {/* Modal Body */} +
+ {isLoading ? ( +
+ Loading note content… +
+ ) : error ? ( +
+ {error} +
+ ) : content ? ( + + ) : ( +
+ Note is empty. +
+ )} +
+ + {/* Modal Footer */} +
+ + Close + + + { + onOpenDocument?.(targetPath, targetLine); + onClose?.(); + }} + style={{ display: "inline-flex", alignItems: "center", gap: "6px" }} + > + + Open in Editor + +
+
+
+ ); + + return typeof document !== "undefined" + ? createPortal(modalElement, document.body) + : modalElement; +} + +export default NotePreviewModal; diff --git a/src/tests/components/DocumentDetail.integration.test.jsx b/src/tests/components/DocumentDetail.integration.test.jsx index 50c55a03..de00a357 100644 --- a/src/tests/components/DocumentDetail.integration.test.jsx +++ b/src/tests/components/DocumentDetail.integration.test.jsx @@ -798,4 +798,28 @@ describe("DocumentDetail popup and panel toggles", () => { confirmSpy.mockRestore(); view.unmount(); }); + + it("passes tableEditorEnabled and scrollSyncEnabled options down to split view and toolbar", () => { + const onTableEditorToggle = vi.fn(); + const onScrollSyncEnabledChange = vi.fn(); + const view = renderDetail({ + ...baseProps, + mode: "split", + tableEditorEnabled: true, + onTableEditorToggle, + scrollSyncEnabled: true, + onScrollSyncEnabledChange, + }); + + const syncToggleBtn = view.host.querySelector(".split-sync-toggle"); + expect(syncToggleBtn).not.toBeNull(); + expect(syncToggleBtn?.textContent).toContain("Sync scroll"); + + act(() => { + syncToggleBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onScrollSyncEnabledChange).toHaveBeenCalledWith(false); + view.unmount(); + }); }); \ No newline at end of file diff --git a/tests/ai/auditTools.spec.js b/tests/ai/auditTools.spec.js index 0a250a9b..87b80015 100644 --- a/tests/ai/auditTools.spec.js +++ b/tests/ai/auditTools.spec.js @@ -110,4 +110,26 @@ describe('AI Subsystem Technical Audit Tests', () => { const relationshipCount = graphDb.getNoteRelationshipCount(notePath); assert.strictEqual(relationshipCount, 1); }); + + it('should support create_note for new notes and block overwriting existing notes', async () => { + const queryTools = require('../../ai/core/QueryTools'); + const mockAgent = { workspaceRoot: tempDir, graphDb }; + + // 1. create_note for brand new note + const createRes = await queryTools.runTool(mockAgent, 'create_note', { + title: 'Agent New Note', + content: '# Agent Created Note\nInitial text.' + }); + assert.ok(createRes.includes('Created new note')); + const createdPath = path.join(tempDir, 'Agent New Note.md'); + assert.ok(fs.existsSync(createdPath)); + + // 2. verify existing notes cannot be overwritten + const overwriteRes = await queryTools.runTool(mockAgent, 'create_note', { + title: 'Agent New Note', + content: 'Overwriting content attempt' + }); + assert.ok(overwriteRes.includes('already exists')); + assert.ok(fs.readFileSync(createdPath, 'utf8').includes('Initial text.')); + }); }); diff --git a/tests/ai/brainTriad.spec.js b/tests/ai/brainTriad.spec.js new file mode 100644 index 00000000..e6303e93 --- /dev/null +++ b/tests/ai/brainTriad.spec.js @@ -0,0 +1,89 @@ +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const WorkspaceBrain = require('../../ai/core/WorkspaceBrain'); +const ReasoningBrain = require('../../ai/core/ReasoningBrain'); +const ActionBrain = require('../../ai/core/ActionBrain'); + +describe('3-Brain Architecture Subsystem Tests (Phase 1)', () => { + let tempDir; + + beforeAll(() => { + tempDir = path.join(__dirname, `temp-brain-test-${Date.now()}`); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + }); + + afterAll(() => { + if (fs.existsSync(tempDir)) { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore Windows file lock + } + } + }); + + it('WorkspaceBrain should collect active note facts cleanly', async () => { + const noteFile = path.join(tempDir, 'active.md'); + fs.writeFileSync(noteFile, '# Workspace Architecture\nDetails here.', 'utf8'); + + const mockAgent = { + workspaceRoot: tempDir, + documentService: { + getDocumentContent: () => '# Workspace Architecture\nDetails here.' + } + }; + + const brain = new WorkspaceBrain(mockAgent); + const facts = await brain.getWorkspaceFacts('architecture', { activeNotePath: noteFile }); + + assert.ok(facts.activeNote); + assert.strictEqual(facts.activeNote.path, noteFile); + assert.ok(facts.activeNote.content.includes('Workspace Architecture')); + }); + + it('ReasoningBrain should format evidence context without throwing', () => { + const mockRegistry = { getActiveProvider: () => null }; + const brain = new ReasoningBrain(mockRegistry); + + const formatted = brain.formatEvidenceContext({ + activeNote: { path: 'test.md', content: 'Sample text' }, + semanticResults: [{ filePath: 'note1.md', snippet: 'Result 1' }], + graphRelations: [{ source: 'Auth', target: 'JWT', type: 'uses' }] + }); + + assert.ok(formatted.includes('ACTIVE NOTE')); + assert.ok(formatted.includes('RELEVANT WORKSPACE CHUNKS')); + assert.ok(formatted.includes('KNOWLEDGE GRAPH RELATIONS')); + }); + + it('ActionBrain should block update/delete/move actions and prevent overwriting existing notes', () => { + const mockAgent = { workspaceRoot: tempDir }; + const brain = new ActionBrain(mockAgent); + + // 1. Forbidden actions must be blocked + const updateCheck = brain.validateAction('update_note', { file_path: 'test.md' }); + assert.strictEqual(updateCheck.allowed, false); + assert.ok(updateCheck.reason.includes('strictly prohibited')); + + const moveCheck = brain.validateAction('move_note', { sourcePath: 'a.md', targetPath: 'b.md' }); + assert.strictEqual(moveCheck.allowed, false); + + const deleteCheck = brain.validateAction('delete_note', { file_path: 'a.md' }); + assert.strictEqual(deleteCheck.allowed, false); + + // 2. Creating a new note file is allowed + const createNewCheck = brain.validateAction('create_note', { title: 'Brand New Note' }); + assert.strictEqual(createNewCheck.allowed, true); + + // 3. Creating a note file that already exists MUST be blocked + const existingFile = path.join(tempDir, 'Existing Note.md'); + fs.writeFileSync(existingFile, 'Existing content', 'utf8'); + + const createExistingCheck = brain.validateAction('create_note', { title: 'Existing Note' }); + assert.strictEqual(createExistingCheck.allowed, false); + assert.ok(createExistingCheck.reason.includes('already exists')); + }); +}); diff --git a/tests/ai/grounding.spec.js b/tests/ai/grounding.spec.js new file mode 100644 index 00000000..e7ab524a --- /dev/null +++ b/tests/ai/grounding.spec.js @@ -0,0 +1,60 @@ +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const { PersonaStandard } = require('../../ai/personas/PersonaStandard'); +const PromptLibrary = require('../../ai/core/PromptLibrary'); +const GroundingEngine = require('../../ai/core/GroundingEngine'); + +describe('PersonaStandard, PromptLibrary & GroundingEngine Tests (Phases 4 & 5)', () => { + let tempDir; + + beforeAll(() => { + tempDir = path.join(__dirname, `temp-grounding-test-${Date.now()}`); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + }); + + afterAll(() => { + if (fs.existsSync(tempDir)) { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + } + }); + + it('PersonaStandard should validate persona definitions', () => { + const valid = PersonaStandard.validate({ + id: 'custom', + name: 'Custom', + tone: 'friendly', + systemInstructions: 'Help user' + }); + assert.strictEqual(valid, true); + + const invalid = PersonaStandard.validate({ id: 'bad' }); + assert.strictEqual(invalid, false); + }); + + it('PromptLibrary should compose system prompts cleanly', () => { + const prompt = PromptLibrary.composeSystemPrompt('Act as Architect', 'Workspace: /test'); + assert.ok(prompt.includes('STRICT IMMUTABILITY')); + assert.ok(prompt.includes('ACTIVE PERSONA ROLE')); + assert.ok(prompt.includes('CURATED WORKSPACE CONTEXT')); + }); + + it('GroundingEngine should verify valid citations and fallback broken links', () => { + const validFile = path.join(tempDir, 'valid.md'); + fs.writeFileSync(validFile, 'valid note', 'utf8'); + + const sampleText = `Read [valid.md](file:///${validFile.replace(/\\/g, '/')}) and [missing.md](file:///C:/nonexistent/missing.md).`; + const result = GroundingEngine.verifyCitations(sampleText); + + assert.strictEqual(result.verifiedCitations, 1); + assert.strictEqual(result.brokenCitations, 1); + assert.ok(result.text.includes('[valid.md]')); + assert.ok(result.text.includes('missing.md')); // broken link converted to plain text + }); +}); diff --git a/tests/ai/harness.spec.js b/tests/ai/harness.spec.js new file mode 100644 index 00000000..4baaac27 --- /dev/null +++ b/tests/ai/harness.spec.js @@ -0,0 +1,27 @@ +const assert = require('assert'); +const AgentHarness = require('../../ai/diagnostics/AgentHarness'); + +describe('AgentHarness Evaluation Suite Tests', () => { + it('AgentHarness should run evaluation scenarios and compute metrics', async () => { + const mockAgent = { + query: async (q) => ({ + success: true, + result: `Based on your notes regarding ${q}, here is the answer.`, + tokensUsed: 120, + trace: [{ name: 'find_discussions', args: { topic: q } }] + }) + }; + + const harness = new AgentHarness(mockAgent); + const evalResults = await harness.runEvaluation([ + { id: 'scen-1', query: 'What decisions were made on authentication?' }, + { id: 'scen-2', query: 'Explore note connections for database graph' } + ]); + + assert.strictEqual(evalResults.totalScenarios, 2); + assert.strictEqual(evalResults.totalTokensUsed, 240); + assert.strictEqual(evalResults.zeroJargonScore, 100); + assert.strictEqual(evalResults.groundingScore, 100); + assert.ok(evalResults.averageLatencyMs >= 0); + }); +}); diff --git a/tests/ai/knowledgeGraph.spec.js b/tests/ai/knowledgeGraph.spec.js index 0c10ffb0..f3ae281a 100644 --- a/tests/ai/knowledgeGraph.spec.js +++ b/tests/ai/knowledgeGraph.spec.js @@ -99,8 +99,43 @@ describe('Knowledge Graph Architecture Tests', () => { const { GraphRetriever } = require('../../ai/context/GraphRetriever'); const retriever = new GraphRetriever(graphDb); const rows = retriever.traverse(notePath, 2); + assert.ok(rows.length >= 2); + }); + + it('should support GraphRAG multi-hop query tool with sentence evidence', async () => { + const service = new GraphService({ appDataDir: tmpDir }, graphDb); + const notePath = path.join(tmpDir, 'graphrag-note.md'); + fs.writeFileSync(notePath, '# AI Note\nDiscussion with Bikash Panda regarding GraphRAG.'); + + const evStore = new EvidenceStore(graphDb); + const evId = evStore.addEvidence({ + sourceId: notePath, + extractor: 'glirel_onnx', + subjectText: 'Bikash Panda', + rawSentence: 'Discussion with Bikash Panda regarding GraphRAG.', + confidence: 0.96 + }); + + const noteId = service.entityResolver.generateEntityId('graphrag-note', 'Note'); + const personId = service.entityResolver.generateEntityId('Bikash Panda', 'Person'); + + graphDb.upsertEntity({ id: noteId, name: 'graphrag-note', type: 'Note', note_path: notePath }); + graphDb.upsertEntity({ id: personId, name: 'Bikash Panda', type: 'Person' }); + graphDb.upsertRelationship({ + source_id: noteId, + target_id: personId, + type: 'has_person', + confidence: 0.96, + evidence_id: evId + }); + + const queryTools = require('../../ai/core/QueryTools'); + const result = await queryTools.runTool({ graphDb }, 'explore_graph', { identifier: 'Bikash Panda' }); - assert.ok(rows.length > 0); + assert.ok(result.includes('Bikash Panda'), 'Should include searched entity name'); + assert.ok(result.includes('has_person'), 'Should include relationship type'); + assert.ok(result.includes('Evidence:'), 'Should include evidence tag'); + assert.ok(result.includes('Discussion with Bikash Panda regarding GraphRAG.'), 'Should include exact evidence sentence'); }); it('should execute orphan cleanup in GraphMaintenance', () => { diff --git a/tests/ai/orchestrator.spec.js b/tests/ai/orchestrator.spec.js new file mode 100644 index 00000000..cd51c265 --- /dev/null +++ b/tests/ai/orchestrator.spec.js @@ -0,0 +1,50 @@ +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const ContextOrchestrator = require('../../ai/core/ContextOrchestrator'); + +describe('ContextOrchestrator Multi-Tool Planning & Context Aggregation Tests', () => { + let mockAgent; + + beforeEach(() => { + mockAgent = { + workspaceBrain: { + getWorkspaceFacts: async (query) => [ + { source: 'WorkspaceBrain', filePath: 'note1.md', content: 'Architecture discussion notes.', score: 0.9 } + ] + } + }; + }); + + it('should execute internal planning, parallel tool execution, and context consolidation', async () => { + const orchestrator = new ContextOrchestrator(mockAgent); + const res = await orchestrator.orchestrate('What is our architecture timeline?', {}, { targetConfidence: 0.75 }); + + assert.ok(res.evidence.length > 0); + assert.ok(res.confidence > 0.70); + assert.ok(res.aggregatedContext.includes('Evidence #1')); + }); + + it('should deduplicate overlapping evidence snippets across tools', () => { + const orchestrator = new ContextOrchestrator(mockAgent); + const duplicateItems = [ + { toolName: 'find_discussions', filePath: 'noteA.md', content: 'Database migration design.', score: 0.85 }, + { toolName: 'explore_topic_graph', filePath: 'noteA.md', content: 'Database migration design.', score: 0.80 }, + { toolName: 'reconstruct_timeline', filePath: 'noteB.md', content: 'Initial schema created in May.', score: 0.90 } + ]; + + const aggregated = orchestrator.aggregateContext(duplicateItems); + assert.strictEqual(aggregated.items.length, 2); // 1 duplicate removed + assert.strictEqual(aggregated.items[0].filePath, 'noteB.md'); // ranked by score + }); + + it('should calculate confidence based on volume, grounding, and relevance scores', () => { + const orchestrator = new ContextOrchestrator(mockAgent); + const items = [ + { toolName: 'find_architecture', filePath: 'spec.md', content: 'VitePress documentation setup', score: 0.85 } + ]; + + const aggregated = orchestrator.aggregateContext(items); + assert.ok(aggregated.confidence >= 0.70); + }); +}); diff --git a/tests/ai/personas.spec.js b/tests/ai/personas.spec.js index e0be4aab..e2c55c63 100644 --- a/tests/ai/personas.spec.js +++ b/tests/ai/personas.spec.js @@ -23,8 +23,8 @@ describe('PersonaDB Frontmatter and Importing Tests', () => { it('should seed default built-ins', () => { const list = personaDB.list(); assert.ok(list.length >= 4); - const def = list.find(p => p.id === 'default'); - assert.strictEqual(def.name, 'Default Assistant'); + const def = list.find(p => p.id === 'general'); + assert.strictEqual(def.name, 'General Assistant'); }); it('should strictly parse valid persona markdown files', () => { @@ -84,7 +84,7 @@ describe('PersonaDB Frontmatter and Importing Tests', () => { it('should throw an error when attempting to modify a builtin persona', () => { const builtinP = { - id: 'default', + id: 'general', name: 'Hacked Persona', description: 'Hacked Desc', prompt: 'Hacked prompt.', diff --git a/tests/ai/planner.spec.js b/tests/ai/planner.spec.js new file mode 100644 index 00000000..86ad22ca --- /dev/null +++ b/tests/ai/planner.spec.js @@ -0,0 +1,41 @@ +const assert = require('assert'); +const Planner = require('../../ai/core/Planner'); +const { semanticToolsCatalog, SemanticToolRunner } = require('../../ai/tools/SemanticTools'); + +describe('Planner & Semantic Tools Tests (Phase 2)', () => { + it('Planner should classify intents and build multi-step execution plans', () => { + const planner = new Planner({}); + + const timelinePlan = planner.createPlan('Show me the timeline of authentication changes'); + assert.strictEqual(timelinePlan.intent, 'TimelineReconstruction'); + assert.strictEqual(timelinePlan.steps.length, 2); + assert.strictEqual(timelinePlan.steps[0].toolName, 'reconstruct_timeline'); + + const taskPlan = planner.createPlan('Find open tasks assigned to me'); + assert.strictEqual(taskPlan.intent, 'TaskSummary'); + assert.strictEqual(taskPlan.steps[0].toolName, 'find_people_and_tasks'); + + const topicPlan = planner.createPlan('Explore architecture of graph database'); + assert.strictEqual(topicPlan.intent, 'TopicExploration'); + assert.strictEqual(topicPlan.steps[0].toolName, 'explore_topic_graph'); + }); + + it('SemanticToolRunner should execute semantic tools cleanly', async () => { + assert.ok(Array.isArray(semanticToolsCatalog)); + assert.strictEqual(semanticToolsCatalog.length, 5); + + const mockAgent = { + workspaceBrain: { + getWorkspaceFacts: async (topic) => [{ topic, snippet: 'Sample discussion' }] + } + }; + const runner = new SemanticToolRunner(mockAgent); + + const discussionRes = await runner.run('find_discussions', { topic: 'JWT Auth' }); + assert.ok(discussionRes); + + const timelineRes = await runner.run('reconstruct_timeline', { topic: 'Vite Migration' }); + assert.ok(Array.isArray(timelineRes)); + assert.ok(timelineRes[0].event.includes('Vite Migration')); + }); +}); diff --git a/tests/ai/promptTracking.spec.js b/tests/ai/promptTracking.spec.js new file mode 100644 index 00000000..6dd9b402 --- /dev/null +++ b/tests/ai/promptTracking.spec.js @@ -0,0 +1,52 @@ +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const LogDB = require('../../ai/logs/LogDB'); +const Agent = require('../../ai/core/Agent'); +const QueryExecutor = require('../../ai/core/QueryExecutor'); + +describe('Prompt Tracking Option A Tests', () => { + let tmpDir; + let logDb; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notely-prompt-track-')); + logDb = new LogDB(tmpDir); + logDb.initialize(); + }); + + afterEach(() => { + if (logDb) logDb.close(); + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should store prompt logs in LogDB under PromptTracker subsystem', () => { + const mockAgent = { + logDb, + logPrompt(query, systemPrompt, metadata = {}) { + if (this.logDb && this.logDb.isInitialized) { + this.logDb.addLog('PromptTracker', `Prompt executed for query: "${query.slice(0, 80)}"`, 'info', { + query, + systemPrompt, + ...metadata + }); + } + } + }; + + mockAgent.logPrompt('What are active projects?', 'System prompt: You are Notely AI', { + persona: 'brainstorming', + model: 'gemini-flash' + }); + + const logs = logDb.getLogs('PromptTracker', 10); + assert.strictEqual(logs.length, 1); + assert.strictEqual(logs[0].subsystem, 'PromptTracker'); + assert.strictEqual(logs[0].metadata.query, 'What are active projects?'); + assert.strictEqual(logs[0].metadata.persona, 'brainstorming'); + assert.strictEqual(logs[0].metadata.systemPrompt, 'System prompt: You are Notely AI'); + }); +}); diff --git a/tests/ai/prompts.spec.js b/tests/ai/prompts.spec.js new file mode 100644 index 00000000..9c255c72 --- /dev/null +++ b/tests/ai/prompts.spec.js @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import path from 'path'; +import PromptLoader from '../../ai/prompts/PromptLoader'; +import TemplateEngine from '../../ai/prompts/TemplateEngine'; +import PromptPipeline from '../../ai/prompts/PromptPipeline'; +import PersonaManager from '../../ai/personas/PersonaManager'; +import PromptTester from '../../ai/testing/PromptTester'; +import PromptLibrary from '../../ai/core/PromptLibrary'; + +describe('Prompt Architecture Infrastructure', () => { + let loader; + let pipeline; + let personaManager; + let tester; + + beforeEach(() => { + loader = new PromptLoader(); + pipeline = new PromptPipeline(loader); + personaManager = new PersonaManager(loader); + tester = new PromptTester(loader); + }); + + describe('PromptLoader', () => { + it('loads and parses frontmatter from base-system.md', () => { + const prompt = loader.loadSystemPrompt('base-system'); + expect(prompt.id).toBe('base-system'); + expect(prompt.metadata.version).toBe('1.0.0'); + expect(prompt.body).toContain("Notely's AI Knowledge Partner"); + }); + + it('loads and parses built-in persona software-engineer', () => { + const persona = loader.loadPersona('software-engineer'); + expect(persona.id).toBe('software-engineer'); + expect(persona.metadata.name).toBe('Software Engineer'); + expect(persona.metadata.tone).toContain('analytical'); + expect(persona.body).toContain('senior pair programmer'); + }); + + it('loads dynamic templates', () => { + const wsTemplate = loader.loadTemplate('workspace-context'); + expect(wsTemplate).toContain('{{workspaceRoot}}'); + expect(wsTemplate).toContain('{{activeNotePath}}'); + }); + + it('caches loaded prompts and supports clearing cache', () => { + const p1 = loader.loadSystemPrompt('grounding-policy'); + const p2 = loader.loadSystemPrompt('grounding-policy'); + expect(p1).toBe(p2); + + loader.clearCache(); + const p3 = loader.loadSystemPrompt('grounding-policy'); + expect(p3).not.toBe(p1); + expect(p3.id).toBe('grounding-policy'); + }); + }); + + describe('TemplateEngine', () => { + it('renders workspace context with variable substitution', () => { + const template = 'Root: {{workspaceRoot}}, File: {{activeNotePath}}'; + const rendered = TemplateEngine.render(template, { + workspaceRoot: '/my/notes', + activeNotePath: 'readme.md' + }); + expect(rendered).toBe('Root: /my/notes, File: readme.md'); + }); + + it('renders retrieved evidence arrays', () => { + const template = 'Evidence:\n{{retrievedEvidence}}'; + const evidence = ['Fact 1 from note A', 'Fact 2 from note B']; + const rendered = TemplateEngine.renderRetrievedContext(template, evidence); + expect(rendered).toContain('Fact 1 from note A'); + expect(rendered).toContain('Fact 2 from note B'); + }); + + it('renders UI context with active tab and view mode', () => { + const template = loader.loadTemplate('ui-context'); + const rendered = TemplateEngine.renderUIContext(template, { + activeTab: 'preview', + selectedText: 'selected highlight text', + uiViewMode: 'split' + }); + expect(rendered).toContain('preview'); + expect(rendered).toContain('selected highlight text'); + }); + }); + + describe('PromptPipeline & Assembly', () => { + it('assembles complete 13-stage system prompt', () => { + const assembled = pipeline.assemble({ + persona: 'technical-architect', + workspaceContext: { + workspaceRoot: '/workspace/notely', + activeNotePath: 'architecture.md', + activeNoteContent: '# System Architecture Note', + documentCount: 42 + }, + retrievedEvidence: 'Graph traversal shows 5 related notes.', + uiContext: { activeTab: 'graph-view' } + }); + + expect(assembled).toContain("Notely's AI Knowledge Partner"); + expect(assembled).toContain('Behavior & Communication Policy'); + expect(assembled).toContain('Permission & Mutability Policy'); + expect(assembled).toContain('Grounding & Truthfulness Policy'); + expect(assembled).toContain('Technical Architect'); + expect(assembled).toContain('/workspace/notely'); + expect(assembled).toContain('Graph traversal shows 5 related notes.'); + }); + + it('preserves read-only invariants regardless of persona', () => { + const assembled = pipeline.assemble({ persona: 'brainstorming' }); + expect(assembled).toContain('READ-ONLY'); + expect(assembled).toContain('Zero Fabrication'); + }); + }); + + describe('PersonaManager', () => { + it('lists all 9 built-in persona IDs', () => { + const list = personaManager.listAvailablePersonas(); + expect(list.length).toBeGreaterThanOrEqual(9); + expect(list).toContain('software-engineer'); + expect(list).toContain('knowledge-librarian'); + }); + + it('retrieves and normalizes persona objects', () => { + const persona = personaManager.getPersona('research-assistant'); + expect(persona.name).toBe('Research Assistant'); + expect(persona.systemInstructions).toContain('rigorous research assistant'); + }); + + it('creates deterministic custom user personas formatted via standard Markdown template', () => { + const customData = { + name: 'Product Manager', + description: 'Focuses on user stories and specs.', + tone: 'strategic, clear', + systemInstructions: 'Prioritize product roadmap and user requirements.' + }; + + const persona = personaManager.createCustomPersona(customData); + expect(persona.id).toBe('product-manager'); + expect(persona.name).toBe('Product Manager'); + expect(persona.systemInstructions).toBe('Prioritize product roadmap and user requirements.'); + + const retrieved = personaManager.getPersona('product-manager'); + expect(retrieved.id).toBe('product-manager'); + expect(retrieved.tone).toBe('strategic, clear'); + }); + }); + + describe('PromptTester', () => { + it('passes full automated audit on all policies and personas', () => { + const audit = tester.runFullAudit(); + expect(audit.success).toBe(true); + expect(audit.results.policyLint.valid).toBe(true); + expect(audit.results.personaLint.valid).toBe(true); + expect(audit.results.invariantCheck.valid).toBe(true); + }); + + it('detects safety invariant violations if safeguards are missing', () => { + const check = tester.validateSafetyInvariants('Empty prompt without rules'); + expect(check.valid).toBe(false); + expect(check.errors.length).toBeGreaterThan(0); + }); + }); + + describe('PromptLibrary Facade', () => { + it('returns base system prompt via loader', () => { + const base = PromptLibrary.getBaseSystemPrompt(); + expect(base).toContain("Notely's AI Knowledge Partner"); + }); + + it('composes system prompt via pipeline', () => { + const composed = PromptLibrary.composeSystemPrompt('Custom role text', 'Workspace context string'); + expect(composed).toContain("Notely's AI Knowledge Partner"); + expect(composed).toContain('Custom role text'); + }); + }); +}); diff --git a/tests/ai/selfCorrection.spec.js b/tests/ai/selfCorrection.spec.js new file mode 100644 index 00000000..6d657b19 --- /dev/null +++ b/tests/ai/selfCorrection.spec.js @@ -0,0 +1,47 @@ +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const SelfCorrectionEngine = require('../../ai/core/SelfCorrectionEngine'); + +describe('SelfCorrectionEngine ReAct Response Validation Tests', () => { + let tempDir; + + beforeAll(() => { + tempDir = path.join(__dirname, `temp-self-correct-${Date.now()}`); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + }); + + afterAll(() => { + if (fs.existsSync(tempDir)) { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + } + }); + + it('should strip technical tool narration jargon automatically', () => { + const rawText = 'I executed the following tools: search_notes. Based on your notes, here is the result.'; + const res = SelfCorrectionEngine.validateAndCorrect(rawText, { query: 'test' }); + + assert.strictEqual(res.corrected, true); + assert.ok(!res.validatedText.includes('I executed the following tools:')); + assert.ok(res.validatedText.includes('Based on your notes')); + }); + + it('should convert broken note file links to plain text labels', () => { + const validFile = path.join(tempDir, 'exists.md'); + fs.writeFileSync(validFile, 'content', 'utf8'); + + const rawText = `Check [exists.md](file:///${validFile.replace(/\\/g, '/')}) and [fake.md](file:///C:/fake/path/fake.md).`; + const res = SelfCorrectionEngine.validateAndCorrect(rawText, { query: 'test' }); + + assert.strictEqual(res.corrected, true); + assert.ok(res.validatedText.includes('[exists.md](')); + assert.ok(!res.validatedText.includes('[fake.md](')); // broken link target stripped + assert.ok(res.validatedText.includes('fake.md')); // plain label kept + }); +});