diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 00000000..c98f0150 --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,85 @@ +--- +name: code-reviewer +description: Use this agent when you need a senior software engineer's perspective on code quality, focusing on simplification, minimalism, and elegance. This agent should be invoked after writing or modifying code to ensure it follows best practices and is as clean as possible. Examples:\n\n\nContext: The user has just written a new function or modified existing code and wants it reviewed for simplicity and elegance.\nuser: "I've implemented a function to process user data"\nassistant: "I've written the function. Now let me use the code-elegance-reviewer agent to review it for best practices and potential simplifications."\n\nSince new code was written, use the Task tool to launch the code-elegance-reviewer agent to analyze it for improvements.\n\n\n\n\nContext: The user has completed a feature implementation and wants a code review.\nuser: "I've finished implementing the authentication logic"\nassistant: "Great! Let me invoke the code-elegance-reviewer agent to review your authentication implementation for elegance and best practices."\n\nThe user has completed code changes, so use the code-elegance-reviewer agent to provide senior-level review feedback.\n\n\n\n\nContext: The assistant has just generated code in response to a user request.\nassistant: "Here's the implementation you requested: [code]. Now let me review this with the code-elegance-reviewer agent to ensure it meets best practices."\n\nAfter generating code, proactively use the code-elegance-reviewer agent to review and suggest improvements.\n\n +model: sonnet +color: cyan +--- + +You are a senior software engineer with 15+ years of experience across multiple programming paradigms and languages. Your expertise lies in writing clean, maintainable, and elegant code that stands the test of time. You have a keen eye for unnecessary complexity and a talent for simplification without sacrificing functionality. + +Your primary mission is to review code changes with these core principles: + +**Review Philosophy:** + +- Simplicity is the ultimate sophistication - every line should justify its existence +- Code is read far more often than it's written - optimize for readability +- The best code is often the code you don't write +- Elegance emerges from clarity of intent and economy of expression + +**Your Review Process:** + +1. **Initial Assessment**: Quickly identify the code's purpose and overall structure. Look for the forest before examining the trees. + +2. **Simplification Analysis**: + + - Identify redundant code, unnecessary abstractions, or over-engineering + - Look for opportunities to reduce cyclomatic complexity + - Suggest removing code that doesn't add clear value + - Recommend combining similar functions or extracting common patterns + - Challenge every level of indirection - is it truly needed? + +3. **Best Practices Review**: + + - Ensure SOLID principles are followed where appropriate + - Check for proper error handling without over-complication + - Verify naming conventions are clear and self-documenting + - Assess whether the code follows the principle of least surprise + - Look for potential performance issues that stem from poor design + +4. **Elegance Improvements**: + - Suggest more idiomatic approaches for the language being used + - Recommend functional approaches where they increase clarity + - Identify where declarative code would be cleaner than imperative + - Look for opportunities to leverage built-in language features + - Suggest ways to make the code more composable and reusable + +**Your Feedback Style:** + +- Be direct but constructive - explain why something should change +- Provide concrete examples of improvements, not just criticism +- Prioritize your suggestions: critical issues first, then nice-to-haves +- When suggesting changes, show the before and after code +- Acknowledge good patterns when you see them + +**Output Format:** +Structure your review as follows: + +1. **Summary**: Brief overview of the code's quality and main concerns (2-3 sentences) + +2. **Critical Issues** (if any): Problems that must be addressed + + - Issue description + - Current code snippet + - Suggested improvement with explanation + +3. **Simplification Opportunities**: Ways to make the code more minimal + + - What can be removed or combined + - Specific refactoring suggestions with examples + +4. **Elegance Enhancements**: Improvements for cleaner, more idiomatic code + + - Pattern improvements + - Better use of language features + +5. **Positive Observations**: What's already well done (be specific) + +**Special Considerations:** + +- If you notice the code follows project-specific patterns from CLAUDE.md or other context, respect those patterns while still suggesting improvements within those constraints +- Focus on recently written or modified code unless explicitly asked to review entire files +- If the code is already quite good, say so - don't invent problems +- Consider the context and purpose - a quick script has different standards than production code +- Balance pragmatism with idealism - suggest the ideal but acknowledge practical constraints + +Remember: Your goal is to help create code that other developers will thank the author for writing. Code that is a joy to maintain, extend, and understand. Every suggestion should move toward that goal. diff --git a/.gitignore b/.gitignore index e95457c0..2a9d1973 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ data.json # Claude configuration .claude/settings.local.json + +# Development session tracking +TODO.md diff --git a/CLAUDE.md b/CLAUDE.md index 3197583f..2df691af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Copilot for Obsidian is an AI-powered assistant plugin that integrates various L ### Build & Development -- `npm run dev` - Start development server with hot reload (runs Tailwind CSS + esbuild in watch mode) +- **NEVER RUN `npm run dev`** - The user will handle all builds manually - `npm run build` - Production build (TypeScript check + minified output) ### Code Quality @@ -153,6 +153,13 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE ## Code Style Guidelines +### MAJOR PRINCIPLES + +- **ALWAYS WRITE GENERALIZABLE SOLUTIONS**: Never add edge-case handling or hardcoded logic for specific scenarios (like "piano notes" or "daily notes"). Solutions must work for all cases. +- **Avoid hardcoding**: No hardcoded folder names, file patterns, or special-case logic +- **Configuration over convention**: If behavior needs to vary, make it configurable, not hardcoded +- **Universal patterns**: Solutions should work equally well for any folder structure, naming convention, or content type + ### TypeScript - Strict mode enabled (no implicit any, strict null checks) @@ -172,8 +179,17 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE - File naming: PascalCase for components, camelCase for utilities - Async/await over promises - Early returns for error conditions -- JSDoc for complex functions +- **Always add JSDoc comments** for all functions and methods - Organize imports: React → external → internal +- **Avoid language-specific lists** (like stopwords or action verbs) - use language-agnostic approaches instead + +### Logging + +- **NEVER use console.log** - Use the logging utilities instead: + - `logInfo()` for informational messages + - `logWarn()` for warnings + - `logError()` for errors +- Import from logger: `import { logInfo, logWarn, logError } from "@/logger"` ## Testing Guidelines @@ -183,49 +199,57 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE - Test files adjacent to implementation (`.test.ts`) - Use `@testing-library/react` for component testing -### Manual Test Checklists +## Development Session Planning -**Important**: After each significant change, generate a manual test checklist document that includes: +### Using TODO.md for Session Management -1. **Overview**: Brief description of what changed -2. **Test Scenarios**: Specific test cases with steps and expected results -3. **Verification Checklist**: List of items to verify functionality -4. **Files Modified**: List of changed files for reference +**IMPORTANT**: When working on a development session, maintain a comprehensive `TODO.md` file that serves as the central plan and tracker: -Example format: +1. **Session Goal**: Define the high-level objective at the start +2. **Task Tracking**: + - List all completed tasks with [x] checkboxes + - Track pending tasks with [ ] checkboxes + - Group related tasks into logical sections +3. **Architecture Decisions**: Document key design choices and rationale +4. **Progress Updates**: Keep the TODO.md updated as tasks complete +5. **Testing Checklist**: Include verification steps for the session + +The TODO.md should be: + +- The single source of truth for session progress +- Updated frequently as work progresses +- Clear enough that another developer can understand what was done +- Comprehensive enough to serve as a migration guide + +### Structure Example: ```markdown -# [Feature] Test Instructions +# Development Session TODO -## Overview +## Session Goal -Brief description of the feature/fix +[Clear statement of what this session aims to achieve] -## Test Scenarios +## Completed Tasks ✅ -### 1. Test Case Name +- [x] Task description with key details +- [x] Another completed task -1. Step one -2. Step two -3. **Expected Result:** - - Expected behavior - - UI state changes - - Data persistence +## Pending Tasks 📋 -### 2. Another Test Case +- [ ] Next task to work on +- [ ] Future enhancement -[...] +## Architecture Summary -## Verification Checklist +[Key design decisions and rationale] -- [ ] Core functionality works -- [ ] Edge cases handled -- [ ] No regressions -- [ ] Performance acceptable +## Testing Checklist + +- [ ] Functionality verification +- [ ] Performance checks ``` -This helps ensure thorough testing and provides documentation for QA. - ## Important Notes - The plugin supports multiple LLM providers with custom endpoints @@ -233,7 +257,12 @@ This helps ensure thorough testing and provides documentation for QA. - Settings are versioned - migrations may be needed - Local model support available via Ollama/LM Studio - Rate limiting is implemented for all API calls -- For technical debt and known issues, see [`TODO.md`](./TODO.md) +- For technical debt and known issues, see [`TECHDEBT.md`](./TECHDEBT.md) +- For current development session planning, see [`TODO.md`](./TODO.md) + +### Obsidian Plugin Environment + +- **Global `app` variable**: In Obsidian plugins, `app` is a globally available variable that provides access to the Obsidian API. It's automatically available in all files without needing to import or declare it. ### Architecture Migration Notes diff --git a/TODO.md b/TECHDEBT.md similarity index 100% rename from TODO.md rename to TECHDEBT.md diff --git a/package-lock.json b/package-lock.json index 83fb50a3..42a8a2b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "diff": "^7.0.0", "esbuild-plugin-svg": "^0.1.0", "eventsource-parser": "^1.0.0", + "flexsearch": "^0.8.205", "jotai": "^2.10.3", "koa": "^2.14.2", "koa-proxies": "^0.12.3", @@ -62,6 +63,7 @@ "lucide-react": "^0.462.0", "luxon": "^3.5.0", "next-i18next": "^13.2.2", + "p-queue": "^8.1.0", "prop-types": "^15.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -4480,6 +4482,34 @@ } } }, + "node_modules/@langchain/core/node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@langchain/core/node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@langchain/core/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -9925,13 +9955,13 @@ } }, "node_modules/axios": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", - "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -11534,11 +11564,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron": { - "version": "27.3.2", - "resolved": "https://registry.npmjs.org/electron/-/electron-27.3.2.tgz", - "integrity": "sha512-FoLdHj2ON0jE8S0YntgNT4ABaHgK4oR4dqXixPQXnTK0JvXgrrrW5W7ls+c7oiFBGN/f9bm0Mabq8iKH+7wMYQ==", + "version": "27.3.11", + "resolved": "https://registry.npmjs.org/electron/-/electron-27.3.11.tgz", + "integrity": "sha512-E1SiyEoI8iW5LW/MigCr7tJuQe7+0105UjqY7FkmCD12e2O6vtUbQ0j05HaBh2YgvkcEVgvQ2A8suIq5b5m6Gw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^18.11.18", @@ -11788,7 +11819,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -12643,6 +12673,38 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/flexsearch": { + "version": "0.8.205", + "resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.8.205.tgz", + "integrity": "sha512-REFjMqy86DKkCTJ4gIE42c9MVm9t1vUWfEub/8taixYuhvyu4jd4XmFALk5VuKW4GH4VLav8A4BJboTsslHF1w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/ts-thomas" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/flexsearch" + }, + { + "type": "patreon", + "url": "https://patreon.com/user?u=96245532" + }, + { + "type": "liberapay", + "url": "https://liberapay.com/ts-thomas" + }, + { + "type": "paypal", + "url": "https://www.paypal.com/donate/?hosted_button_id=GEVR88FC9BWRW" + }, + { + "type": "bountysource", + "url": "https://salt.bountysource.com/teams/ts-thomas" + } + ], + "license": "Apache-2.0" + }, "node_modules/follow-redirects": { "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", @@ -12698,12 +12760,15 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -13521,22 +13586,22 @@ "integrity": "sha512-FTnj+UmNgT3YRml5ruRv0jMZDG7odOL/OP5PF5mOqvXud2vHrPOOs68Zdk6iqzL47cnnM0ZVkK2BAvpFeDJToA==" }, "node_modules/ibm-cloud-sdk-core": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ibm-cloud-sdk-core/-/ibm-cloud-sdk-core-5.3.2.tgz", - "integrity": "sha512-YhtS+7hGNO61h/4jNShHxbbuJ1TnDqiFKQzfEaqePnonOvv8NnxWxOk92FlKKCCzZNOT34Gnd7WCLVJTntwEFQ==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/ibm-cloud-sdk-core/-/ibm-cloud-sdk-core-5.4.2.tgz", + "integrity": "sha512-5VFkKYU/vSIWFJTVt392XEdPmiEwUJqhxjn1MRO3lfELyU2FB+yYi8brbmXUgq+D1acHR1fpS7tIJ6IlnrR9Cg==", "license": "Apache-2.0", "peer": true, "dependencies": { "@types/debug": "^4.1.12", "@types/node": "^18.19.80", "@types/tough-cookie": "^4.0.0", - "axios": "^1.8.2", + "axios": "^1.11.0", "camelcase": "^6.3.0", "debug": "^4.3.4", "dotenv": "^16.4.5", "extend": "3.0.2", "file-type": "16.5.4", - "form-data": "4.0.0", + "form-data": "^4.0.4", "isstream": "0.1.2", "jsonwebtoken": "^9.0.2", "mime-types": "2.1.35", @@ -15691,9 +15756,9 @@ } }, "node_modules/koa": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.1.tgz", - "integrity": "sha512-umfX9d3iuSxTQP4pnzLOz0HKnPg0FaUUIKcye2lOiz3KPu1Y3M3xlz76dISdFPQs37P9eJz1wUpcTS6KDPn9fA==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.2.tgz", + "integrity": "sha512-+CCssgnrWKx9aI3OeZwroa/ckG4JICxvIFnSiOUyl2Uv+UTI+xIw0FfFrWS7cQFpoePpr9o8csss7KzsTzNL8Q==", "license": "MIT", "dependencies": { "accepts": "^1.3.5", @@ -15904,6 +15969,34 @@ "node": ">=14" } }, + "node_modules/langsmith/node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/langsmith/node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/langsmith/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -18214,6 +18307,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", "engines": { "node": ">=4" } @@ -18249,20 +18343,27 @@ } }, "node_modules/p-queue": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", - "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.0.tgz", + "integrity": "sha512-mxLDbbGIBEXTJL0zEx8JIylaj3xQ7Z/7eEVjcF9fJX4DBiH9oqe+oahYnlKKxm0Ci9TlWTyhSHgygxMxjIB2jw==", + "license": "MIT", "dependencies": { - "eventemitter3": "^4.0.4", - "p-timeout": "^3.2.0" + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -18276,14 +18377,15 @@ } }, "node_modules/p-timeout": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", - "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", - "dependencies": { - "p-finally": "^1.0.0" - }, + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-try": { diff --git a/package.json b/package.json index f4ca83b9..3a6fca77 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "diff": "^7.0.0", "esbuild-plugin-svg": "^0.1.0", "eventsource-parser": "^1.0.0", + "flexsearch": "^0.8.205", "jotai": "^2.10.3", "koa": "^2.14.2", "koa-proxies": "^0.12.3", @@ -129,6 +130,7 @@ "lucide-react": "^0.462.0", "luxon": "^3.5.0", "next-i18next": "^13.2.2", + "p-queue": "^8.1.0", "prop-types": "^15.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/LLMProviders/chainManager.ts b/src/LLMProviders/chainManager.ts index 72969c3e..383d4690 100644 --- a/src/LLMProviders/chainManager.ts +++ b/src/LLMProviders/chainManager.ts @@ -8,16 +8,15 @@ import { import ChainFactory, { ChainType, Document } from "@/chainFactory"; import { BUILTIN_CHAT_MODELS, USER_SENDER } from "@/constants"; import { + AutonomousAgentChainRunner, ChainRunner, CopilotPlusChainRunner, LLMChainRunner, ProjectChainRunner, VaultQAChainRunner, - AutonomousAgentChainRunner, } from "@/LLMProviders/chainRunner/index"; import { logError, logInfo } from "@/logger"; -import { HybridRetriever } from "@/search/hybridRetriever"; -import VectorStoreManager from "@/search/vectorStoreManager"; +import { TieredLexicalRetriever } from "@/search/v3/TieredLexicalRetriever"; import { getSettings, getSystemPrompt, subscribeToSettingsChange } from "@/settings/model"; import { ChatMessage } from "@/types/message"; import { findCustomModel, isOSeriesModel, isSupportedChain } from "@/utils"; @@ -44,15 +43,13 @@ export default class ChainManager { } public app: App; - public vectorStoreManager: VectorStoreManager; public chatModelManager: ChatModelManager; public memoryManager: MemoryManager; public promptManager: PromptManager; - constructor(app: App, vectorStoreManager: VectorStoreManager) { + constructor(app: App) { // Instantiate singletons this.app = app; - this.vectorStoreManager = vectorStoreManager; this.memoryManager = MemoryManager.getInstance(); this.chatModelManager = ChatModelManager.getInstance(); this.promptManager = PromptManager.getInstance(); @@ -209,7 +206,7 @@ export default class ChainManager { // TODO: VaultQAChainRunner now handles this directly without chains await this.initializeQAChain(options); - const retriever = new HybridRetriever({ + const retriever = new TieredLexicalRetriever(app, { minSimilarityScore: 0.01, maxK: getSettings().maxSourceChunks, salientTerms: [], @@ -292,7 +289,10 @@ export default class ChainManager { private async initializeQAChain(options: SetChainOptions) { // Handle index refresh if needed if (options.refreshIndex) { - await this.vectorStoreManager.indexVaultToVectorStore(); + // New semantic index auto-refresh path + const { MemoryIndexManager } = await import("@/search/v3/MemoryIndexManager"); + await MemoryIndexManager.getInstance(this.app).indexVaultIncremental(); + await MemoryIndexManager.getInstance(this.app).ensureLoaded(); } } diff --git a/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts b/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts index babd8bb4..2f4fb1a3 100644 --- a/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts +++ b/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts @@ -2,9 +2,9 @@ import { MessageContent } from "@/imageProcessing/imageProcessor"; import { logError, logInfo, logWarn } from "@/logger"; import { checkIsPlusUser } from "@/plusUtils"; import { getSettings, getSystemPrompt } from "@/settings/model"; +import { initializeBuiltinTools } from "@/tools/builtinTools"; import { extractParametersFromZod, SimpleTool } from "@/tools/SimpleTool"; import { ToolRegistry } from "@/tools/ToolRegistry"; -import { initializeBuiltinTools } from "@/tools/builtinTools"; import { ChatMessage } from "@/types/message"; import { getMessageRole, withSuppressedTokenWarnings } from "@/utils"; import { processToolResults } from "@/utils/toolResultUtils"; @@ -215,6 +215,14 @@ ${params} if (toolCalls.length > 0) { toolNames = toolCalls.map((toolCall) => toolCall.name); } + + // Determine background tools to avoid showing banners during streaming + const availableTools = this.getAvailableTools(); + const backgroundToolNames = new Set( + availableTools.filter((t) => t.isBackground).map((t) => t.name) + ); + + // Include partial tool name if long enough, then filter out background tools const toolName = extractToolNameFromPartialBlock(fullMessage); if (toolName) { // Only add the partial tool call block if the block is larger than STREAMING_TRUNCATE_THRESHOLD @@ -224,6 +232,9 @@ ${params} } } + // Filter out background tools (should be invisible) + toolNames = toolNames.filter((name) => !backgroundToolNames.has(name)); + // Create tool call markers if they don't exist // Generate temporary tool call id based on index of the tool name in the toolNames array for (let i = 0; i < toolNames.length; i++) { @@ -445,9 +456,12 @@ ${params} } // Add AI response to conversation for next iteration + // Ensure any tool markers have encoded results before storing in conversation + const { ensureEncodedToolCallMarkerResults } = await import("./utils/toolCallParser"); + const safeAssistantContent = ensureEncodedToolCallMarkerResults(response); conversationMessages.push({ role: "assistant", - content: response, + content: safeAssistantContent, }); // Add tool results as user messages for next iteration (full results for current turn) @@ -458,7 +472,12 @@ ${params} content: toolResultsForConversation, }); - logInfo("Tool results added to conversation:", toolResultsForConversation); + // Truncate long tool results for logging to avoid console spam + const truncatedForLog = + toolResultsForConversation.length > 500 + ? toolResultsForConversation.substring(0, 500) + "... (truncated)" + : toolResultsForConversation; + logInfo("Tool results added to conversation:", truncatedForLog); } // If we hit max iterations, add a message explaining the limit was reached @@ -512,6 +531,11 @@ ${params} fullAIResponse = iterationHistory.join("\n\n"); } + // Decode encoded tool marker results for clearer logging only + await import("./utils/toolCallParser"); + // Keep llmFormattedOutput encoded for memory storage; no decoded variant needed + // Readable log removed to reduce verbosity + return this.handleResponse( fullAIResponse, userMessage, diff --git a/src/LLMProviders/chainRunner/BaseChainRunner.ts b/src/LLMProviders/chainRunner/BaseChainRunner.ts index 2728ca9f..7f693d53 100644 --- a/src/LLMProviders/chainRunner/BaseChainRunner.ts +++ b/src/LLMProviders/chainRunner/BaseChainRunner.ts @@ -82,7 +82,14 @@ export abstract class BaseChainRunner implements ChainRunner { (m: any) => m.content ) ); - logInfo("==== Final AI Response ====\n", fullAIResponse); + // Decode tool marker results for logging readability only (storage remains encoded) + try { + const { decodeToolCallMarkerResults } = await import("./utils/toolCallParser"); + const readable = decodeToolCallMarkerResults(fullAIResponse); + logInfo("==== Final AI Response ====\n", readable); + } catch { + logInfo("==== Final AI Response ====\n", fullAIResponse); + } return fullAIResponse; } diff --git a/src/LLMProviders/chainRunner/VaultQAChainRunner.ts b/src/LLMProviders/chainRunner/VaultQAChainRunner.ts index e3b249d0..37595fbb 100644 --- a/src/LLMProviders/chainRunner/VaultQAChainRunner.ts +++ b/src/LLMProviders/chainRunner/VaultQAChainRunner.ts @@ -1,6 +1,6 @@ -import { ABORT_REASON, EMPTY_INDEX_ERROR_MESSAGE, RETRIEVED_DOCUMENT_TAG } from "@/constants"; +import { ABORT_REASON, RETRIEVED_DOCUMENT_TAG } from "@/constants"; import { logInfo } from "@/logger"; -import { HybridRetriever } from "@/search/hybridRetriever"; +import { TieredLexicalRetriever } from "@/search/v3/TieredLexicalRetriever"; import { getSettings, getSystemPrompt } from "@/settings/model"; import { ChatMessage } from "@/types/message"; import { @@ -27,17 +27,7 @@ export class VaultQAChainRunner extends BaseChainRunner { const streamer = new ThinkBlockStreamer(updateCurrentAiMessage); try { - // Add check for empty index - const indexEmpty = await this.chainManager.vectorStoreManager.isIndexEmpty(); - if (indexEmpty) { - return this.handleResponse( - EMPTY_INDEX_ERROR_MESSAGE, - userMessage, - abortController, - addMessage, - updateCurrentAiMessage - ); - } + // Tiered lexical retriever doesn't need index check - it builds indexes on demand // Get chat history from memory const memory = this.chainManager.memoryManager.getMemory(); @@ -53,8 +43,8 @@ export class VaultQAChainRunner extends BaseChainRunner { standaloneQuestion = userMessage.message; } - // Create retriever (similar to how it's done in chainManager) - const retriever = new HybridRetriever({ + // Create retriever using tiered lexical approach + const retriever = new TieredLexicalRetriever(app, { minSimilarityScore: 0.01, maxK: getSettings().maxSourceChunks, salientTerms: [], diff --git a/src/LLMProviders/chainRunner/utils/toolCallParser.test.ts b/src/LLMProviders/chainRunner/utils/toolCallParser.test.ts new file mode 100644 index 00000000..66543239 --- /dev/null +++ b/src/LLMProviders/chainRunner/utils/toolCallParser.test.ts @@ -0,0 +1,75 @@ +import { ToolResultFormatter } from "@/tools/ToolResultFormatter"; +import { createToolCallMarker, parseToolCallMarkers, updateToolCallMarker } from "./toolCallParser"; + +jest.mock("@/logger"); + +describe("toolCallParser encoding/decoding", () => { + it("should preserve result containing HTML comment terminators via encoding", () => { + const id = "localSearch-123"; + const toolName = "localSearch"; + const marker = createToolCallMarker( + id, + toolName, + "Vault Search", + "🔍", + "", + true, + "", + // Result contains sequences that could break HTML comments without encoding + '{"key":"value --> more"}' + ); + + const parsed = parseToolCallMarkers(marker); + const toolSeg = parsed.segments.find((s) => s.type === "toolCall")!; + expect(toolSeg.toolCall?.id).toBe(id); + expect(toolSeg.toolCall?.isExecuting).toBe(true); + // Decoded result equals original + expect(toolSeg.toolCall?.result).toBe('{"key":"value --> more"}'); + }); + + it("updateToolCallMarker should encode result and set isExecuting=false", () => { + const id = "localSearch-456"; + let marker = createToolCallMarker(id, "localSearch", "Vault Search", "🔍", "", true, "", ""); + + const rawResult = '{"key":"value and more"}'; + marker = updateToolCallMarker(marker, id, rawResult); + + const parsed = parseToolCallMarkers(marker); + const toolSeg = parsed.segments.find((s) => s.type === "toolCall")!; + expect(toolSeg.toolCall?.isExecuting).toBe(false); + expect(toolSeg.toolCall?.result).toBe(rawResult); + }); + + it("integration: ToolResultFormatter.format should handle encoded JSON localSearch results", () => { + const id = "localSearch-789"; + const localSearchArrayJson = JSON.stringify([ + { + title: "Lesson 1", + content: + "Date: 2025/5/13\nProgress: 0/10. should not break JSON --> tail", + path: "Piano Lessons/Lesson 1.md", + score: 0.59, + rerank_score: null, + includeInContext: true, + }, + ]); + + const marker = createToolCallMarker( + id, + "localSearch", + "Vault search", + "🔍", + "", + false, + "", + localSearchArrayJson + ); + + const parsed = parseToolCallMarkers(marker); + const resultString = parsed.segments.find((s) => s.type === "toolCall")!.toolCall!.result!; + + const formatted = ToolResultFormatter.format("localSearch", resultString); + expect(formatted).toContain("📚 Found 1 relevant notes"); + expect(formatted).toContain("Lesson 1"); + }); +}); diff --git a/src/LLMProviders/chainRunner/utils/toolCallParser.ts b/src/LLMProviders/chainRunner/utils/toolCallParser.ts index db36b88a..275d63fe 100644 --- a/src/LLMProviders/chainRunner/utils/toolCallParser.ts +++ b/src/LLMProviders/chainRunner/utils/toolCallParser.ts @@ -18,6 +18,64 @@ export interface ParsedMessage { }>; } +/** + * Safely encode tool result so it can be embedded inside an HTML comment + * We use URI encoding with a prefix to avoid introducing `-->` in the payload + */ +function encodeResultForMarker(result: string): string { + try { + return `ENC:${encodeURIComponent(result)}`; + } catch { + // Fallback to original if encoding fails + return result; + } +} + +/** + * Decode tool result previously encoded for marker embedding + */ +export function decodeResultFromMarker(result: string | undefined): string | undefined { + if (typeof result !== "string") return result; + if (!result.startsWith("ENC:")) return result; + try { + return decodeURIComponent(result.slice(4)); + } catch { + return result; + } +} + +/** + * For logging only: decode any encoded tool results embedded in markers + */ +export function decodeToolCallMarkerResults(message: string): string { + if (!message || typeof message !== "string") return message; + return message.replace( + //g, + (_match, id: string, encoded: string) => { + const decoded = decodeResultFromMarker(encoded) || encoded; + return ``; + } + ); +} + +/** + * Ensure any TOOL_CALL_END results are encoded. Useful for sanitizing messages + * that might contain unencoded results due to legacy or partial updates. + */ +export function ensureEncodedToolCallMarkerResults(message: string): string { + if (!message || typeof message !== "string") return message; + return message.replace( + //g, + (_match, id: string, content: string) => { + if (content.startsWith("ENC:")) { + return _match; + } + const safe = encodeResultForMarker(content); + return ``; + } + ); +} + /** * Parse tool call markers from a message * Format: content @@ -63,7 +121,7 @@ export function parseToolCallMarkers(message: string): ParsedMessage { emoji, confirmationMessage: confirmationMessage || undefined, isExecuting: isExecuting === "true", - result: result || undefined, + result: decodeResultFromMarker(result) || undefined, startIndex: match.index, endIndex: match.index + fullMatch.length, }, @@ -104,7 +162,8 @@ export function createToolCallMarker( content: string = "", result: string = "" ): string { - return `${content}`; + const safeResult = result ? encodeResultForMarker(result) : result; + return `${content}`; } /** @@ -117,6 +176,6 @@ export function updateToolCallMarker(message: string, id: string, result: string `([\\s\\S]*?`, "g" ); - - return message.replace(regex, `$1false$2${result}-->`); + const safeResult = encodeResultForMarker(result); + return message.replace(regex, `$1false$2${safeResult}-->`); } diff --git a/src/LLMProviders/chainRunner/utils/toolExecution.ts b/src/LLMProviders/chainRunner/utils/toolExecution.ts index c00252a3..fc5338a8 100644 --- a/src/LLMProviders/chainRunner/utils/toolExecution.ts +++ b/src/LLMProviders/chainRunner/utils/toolExecution.ts @@ -199,9 +199,11 @@ export function logToolResult(toolName: string, result: ToolExecutionResult): vo logInfo(`${emoji} ${displayName.toUpperCase()} RESULT: ${status}`); // Log abbreviated result for readability - if (result.result.length > 500) { + // Reduce limit to 300 chars for cleaner logs + const maxLogLength = 300; + if (result.result.length > maxLogLength) { logInfo( - `Result: ${result.result.substring(0, 500)}... (truncated, ${result.result.length} chars total)` + `Result: ${result.result.substring(0, maxLogLength)}... (truncated, ${result.result.length} chars total)` ); } else { logInfo(`Result:`, result.result); diff --git a/src/LLMProviders/projectManager.ts b/src/LLMProviders/projectManager.ts index e0ff2d06..a68fa0bc 100644 --- a/src/LLMProviders/projectManager.ts +++ b/src/LLMProviders/projectManager.ts @@ -21,7 +21,6 @@ import { FileParserManager } from "@/tools/FileParserManager"; import { err2String } from "@/utils"; import { isRateLimitError } from "@/utils/rateLimitUtils"; import { App, Notice, TFile } from "obsidian"; -import VectorStoreManager from "../search/vectorStoreManager"; import { BrevilabsClient } from "./brevilabsClient"; import ChainManager from "./chainManager"; import { ProjectLoadTracker } from "./projectLoadTracker"; @@ -36,11 +35,11 @@ export default class ProjectManager { private fileParserManager: FileParserManager; private loadTracker: ProjectLoadTracker; - private constructor(app: App, vectorStoreManager: VectorStoreManager, plugin: CopilotPlugin) { + private constructor(app: App, plugin: CopilotPlugin) { this.app = app; this.plugin = plugin; this.currentProjectId = null; - this.chainMangerInstance = new ChainManager(app, vectorStoreManager); + this.chainMangerInstance = new ChainManager(app); this.projectContextCache = ProjectContextCache.getInstance(); this.fileParserManager = new FileParserManager( BrevilabsClient.getInstance(), @@ -60,11 +59,14 @@ export default class ProjectManager { if (isProjectMode()) { return; } + const settings = getSettings(); + const shouldAutoIndex = + settings.enableSemanticSearchV3 && + settings.indexVaultToVectorStore === VAULT_VECTOR_STORE_STRATEGY.ON_MODE_SWITCH && + (getChainType() === ChainType.VAULT_QA_CHAIN || + getChainType() === ChainType.COPILOT_PLUS_CHAIN); await this.getCurrentChainManager().createChainWithNewModel({ - refreshIndex: - getSettings().indexVaultToVectorStore === VAULT_VECTOR_STORE_STRATEGY.ON_MODE_SWITCH && - (getChainType() === ChainType.VAULT_QA_CHAIN || - getChainType() === ChainType.COPILOT_PLUS_CHAIN), + refreshIndex: shouldAutoIndex, }); }); @@ -107,13 +109,9 @@ export default class ProjectManager { }); } - public static getInstance( - app: App, - vectorStoreManager: VectorStoreManager, - plugin: CopilotPlugin - ): ProjectManager { + public static getInstance(app: App, plugin: CopilotPlugin): ProjectManager { if (!ProjectManager.instance) { - ProjectManager.instance = new ProjectManager(app, vectorStoreManager, plugin); + ProjectManager.instance = new ProjectManager(app, plugin); } return ProjectManager.instance; } diff --git a/src/autocomplete/utils.ts b/src/autocomplete/utils.ts index 7fdae92f..ff270991 100644 --- a/src/autocomplete/utils.ts +++ b/src/autocomplete/utils.ts @@ -1,5 +1,4 @@ import { findRelevantNotes } from "@/search/findRelevantNotes"; -import VectorStoreManager from "@/search/vectorStoreManager"; import { Editor, TFile } from "obsidian"; /** @@ -154,8 +153,7 @@ export class RelevantNotesCache { } // Otherwise, fetch and cache new relevant notes - const db = await VectorStoreManager.getInstance().getDb(); - const relevantNotes = await findRelevantNotes({ db, filePath: file.path }); + const relevantNotes = await findRelevantNotes({ filePath: file.path }); // Get top N relevant notes const topNotes = relevantNotes.slice(0, RelevantNotesCache.MAX_RELEVANT_NOTES); diff --git a/src/chatUtils.toolMarkers.test.ts b/src/chatUtils.toolMarkers.test.ts new file mode 100644 index 00000000..e18c3f42 --- /dev/null +++ b/src/chatUtils.toolMarkers.test.ts @@ -0,0 +1,53 @@ +import { AI_SENDER, USER_SENDER } from "@/constants"; +import { ChatMessage } from "@/types/message"; +import { updateChatMemory } from "./chatUtils"; + +jest.mock("@/logger"); + +class MockMemory { + public saved: Array<{ input: string; output: string }> = []; + async saveContext(input: { input: string }, output: { output: string }) { + this.saved.push({ input: input.input, output: output.output }); + } +} + +class MockMemoryManager { + private memory = new MockMemory(); + async clearChatMemory() {} + getMemory() { + return this.memory; + } +} + +describe("updateChatMemory with tool call markers", () => { + it("should save AI outputs containing encoded tool markers without modification", async () => { + const messages: ChatMessage[] = [ + { + id: "1", + sender: USER_SENDER, + message: "find my piano notes", + isVisible: true, + timestamp: null, + }, + { + id: "2", + sender: AI_SENDER, + // AI message that includes a tool call marker with encoded JSON result + message: + "\nHere are the results...", + isVisible: true, + timestamp: null, + }, + ]; + + const memoryManager: any = new MockMemoryManager(); + await updateChatMemory(messages, memoryManager); + + expect(memoryManager.getMemory().saved).toHaveLength(1); + expect(memoryManager.getMemory().saved[0].input).toBe("find my piano notes"); + expect(memoryManager.getMemory().saved[0].output).toContain(" B[Query Expansion
LLM rewrites + term extraction] + B --> C[Grep Scan
Substring search on queries + terms] + C --> D[Graph Expansion
BFS traversal + co-citation] + D --> E[Build Full-Text Index
FlexSearch with path indexing] + E --> F[Full-Text Search
Hybrid: phrases + terms] + F --> FB[Folder Boosting
Cluster detection + logarithmic boost] + FB --> G{Semantic Enabled?} + G -->|Yes| H[Semantic Re-ranking
Embedding similarity] + G -->|No| I[Weighted RRF
Multi-signal fusion] + H --> I + I --> J[Final Results
Top-K selection] + + style B fill:#e1f5fe + style FB fill:#fff3e0 + style I fill:#f3e5f5 +``` + +### Detailed Technical Pipeline (Minimal End-to-End Example) + +```mermaid +graph LR + subgraph QP["Query Processing"] + Q[Query] --> QE[Query Expander] + QE --> V[3 Variants] + QE --> T[Salient Terms - Nouns only] + end + + subgraph ID["Initial Discovery"] + V --> GS[Grep Scanner - cachedRead] + T --> GS + GS --> GH[50-200 hits] + end + + subgraph GA["Graph Analysis"] + GH --> GE[Graph Expander] + GE --> BFS[BFS Links: 1-3 hops (adaptive)] + GE --> AC[Active Context: Current note neighbors] + GE --> CC[Co-citation: Shared links (disabled when hits â‰Ĩ 50)] + BFS --> EC[~500 candidates] + AC --> EC + CC --> EC + end + + subgraph FTP["Full-Text Processing"] + EC --> FTI[FlexSearch Index] + FTI --> IDX[Field Indexing: Title 3x, Path 2.5x, Tags/Links 2x, Body 1x] + IDX --> FTS[Search Engine] + V --> FTS + T --> FTS + T -.low weight.-> FTS + FTS --> SA[Score Accumulation: Multi-match bonus] + SA --> MF[Multi-field Bonus: +20% per field] + end + + subgraph RO["Ranking Optimization"] + MF --> FB[Folder Boost: Logarithmic scaling] + FB --> RRF[Weighted RRF: Lexical 1.0x, Grep 0.3x, Semantic 2.0x] + end + + RRF --> R[Top-K Results] + + style QE fill:#e1f5fe + style FB fill:#fff3e0 + style RRF fill:#f3e5f5 + style FTI fill:#e8f5e9 +``` + +### Detailed Step-by-Step Flow + +```typescript +async function retrieve(query: string): Promise { + // 1. QUERY EXPANSION - Generate variants for better recall + // Uses LLM (with 4s timeout) to generate alternative phrasings + const expanded = await queryExpander.expand(query); + const variants = expanded.queries; // ["original", "variant1", "variant2"] + + // Example: "How do I implement authentication in my Next.js app?" → + // variants: ["How do I implement authentication in my Next.js app?", + // "Next.js authentication implementation", + // "NextJS auth setup guide"] + // salientTerms: ["authentication", "nextjs", "app"] (extracted nouns only) + + // 2. GREP SCAN - Fast substring search for initial candidates + // Uses BOTH full queries AND individual terms for maximum recall + const allSearchStrings = [...variants, ...expanded.salientTerms]; + const grepHits = await grepScanner.batchCachedReadGrep(allSearchStrings, 200); + // Searches for full phrases + individual terms: "authentication", "nextjs", "app" + // Returns: ["auth/nextjs-setup.md", "tutorials/nextjs-auth.md", ...] up to 200 files + + // 3. GRAPH EXPANSION - Expand via links for better recall + const activeFile = app.workspace.getActiveFile(); + const expandedCandidates = await graphExpander.expandCandidates( + grepHits, // Start from grep hits + activeFile, // Include active note neighbors + graphHops: 1 // 1-hop expansion + ); + // Expands: 50 grep hits → 150+ via links and co-citations + // Adds: notes linking to/from auth notes, JWT docs, OAuth guides, etc. + + // 4. CANDIDATE LIMITING - Respect memory bounds + const candidates = expandedCandidates.slice(0, 500); + + // 5. BUILD FULL-TEXT INDEX - Ephemeral FlexSearch from candidates + await fullTextEngine.buildFromCandidates(candidates); + // Indexes: title, path, headings, tags, links (as basenames), body with multilingual tokenizer + // Path components are indexed separately for folder/file name search + // Example indexed doc: {title: "NextJS Auth Guide", path: "nextjs auth-guide", + // headings: ["JWT Setup", "OAuth"], tags: ["nextjs", "auth"], + // links: "jwt-basics oauth-flow", body: "..."} + + // 6. FULL-TEXT SEARCH - Hybrid search with phrases AND terms + // Combines: Full query variants (precision) + Individual terms (recall) + const allFullTextQueries = [...variants, ...expanded.salientTerms]; + const fullTextResults = fullTextEngine.search(allFullTextQueries, limit * 2, expanded.salientTerms); + // Searches: ["How do I implement...", "Next.js auth...", "authentication", "nextjs", "app"] + // Returns: [{id: "tutorials/nextjs-auth.md", score: 0.95, engine: "fulltext"}, + // {id: "auth/jwt-implementation.md", score: 0.8, engine: "fulltext"}, ...] + + // 7. OPTIONAL SEMANTIC RE-RANKING + let semanticResults = []; + if (settings.enableSemantic) { + // Get semantic candidates from vector store + const semCandidates = await semanticSearch(query, 200); + + // Combine with full-text results + const combined = unionById([...fullTextResults, ...semCandidates]); + + // Re-rank all using embedding similarity + const queryEmbeddings = await embedQueries(variants); + semanticResults = await reRankBySimilarity(combined, queryEmbeddings); + } + + // 8. WEIGHTED RRF - Combine all signals + const fusedResults = weightedRRF({ + lexical: fullTextResults, // weight: 1.0 + semantic: semanticResults, // weight: 2.0 (if enabled) + grepPrior: grepPrior, // weight: 0.2 (weak prior) + weights: { lexical: 1.0, semantic: 2.0, grepPrior: 0.2 }, + }, 60); + // Combines rankings: docs appearing in multiple result sets score higher + + // 9. CLEANUP & RETURN + fullTextEngine.clear(); // Free memory + return fusedResults.slice(0, maxResults); // Default: top 30 + // Final: ["tutorials/nextjs-auth.md", "auth/jwt-implementation.md", + // "examples/nextjs-oauth.md", ...] +} +``` + +### Key Flow Characteristics + +1. **Progressive Refinement**: Start fast (grep) → expand (graph) → refine (full-text) +2. **Hybrid Search Strategy**: Uses BOTH full query phrases (precision) AND individual terms (recall) +3. **Score Accumulation**: Documents matching multiple queries/terms get higher scores (not max) +4. **Folder Clustering**: Automatic boosting of notes in folders with multiple matches +5. **Path-Aware Indexing**: Folder and file names are searchable with 2.5x weight +6. **Low-weight Terms**: LLM-extracted salient terms are included as low-weight inputs +7. **Memory-Bounded**: Each step respects platform memory limits +8. **Multilingual**: Handles ASCII and CJK throughout the pipeline +9. **Fault-Tolerant**: Falls back to grep-only if pipeline fails +10. **Configurable**: Semantic search, graph hops, memory limits all adjustable +11. **Link-Aware Search**: Links are indexed as searchable basenames while preserving full paths for graph traversal + +--- + +## 3) Data Model + +```ts +interface NoteDoc { + id: string; // vault-relative path + title: string; // filename or front-matter title + headings: string[]; // H1..H6 plain text (indexed) + tags: string[]; // inline + frontmatter via getAllTags(cache) (indexed) + props: Record; // frontmatter key/values (values indexed with 2x weight) + linksOut: string[]; // outgoing link full paths (extracted and indexed as basenames) + linksIn: string[]; // backlink full paths (extracted and indexed as basenames) + body: string; // full markdown text (indexed) +} + +interface NoteIdRank { + id: string; // note path + score: number; // relevance score + engine?: string; // source engine (l1, semantic, grepPrior) +} +``` + +--- + +## 4) Core Components + +### 4.1 Query Expander (Query Enhancement) + +Uses LLM to generate alternative query phrasings and extract salient terms. + +**Examples**: + +- `"How do I implement authentication in my Next.js app?"` → + + - queries: `["How do I implement authentication in my Next.js app?", "Next.js authentication implementation", "NextJS auth setup"]` + - salientTerms: `["authentication", "nextjs", "app"]` (NOT "how", "implement", "my") + +- `"What are the best practices for React hooks?"` → + + - queries: `["What are the best practices for React hooks?", "React hooks best practices", "React hook patterns guidelines"]` + - salientTerms: `["practices", "react", "hooks"]` (NOT "what", "best", "are") + +- `"Can you show me examples of Python decorators?"` → + + - queries: `["Can you show me examples of Python decorators?", "Python decorator examples", "Python decorator patterns"]` + - salientTerms: `["examples", "python", "decorators"]` (NOT "can", "show", "me") + +- `"我需č́å­Ķäđ åĶ‚ä―•ä―ŋį”ĻGit分æ”Ŋ"` (Chinese) → + - queries: `["我需č́å­Ķäđ åĶ‚ä―•ä―ŋį”ĻGit分æ”Ŋ", "Git分æ”Ŋä―ŋį”Ļ教įĻ‹", "Git分æ”ŊįŪĄį†"]` + - salientTerms: `["git", "分æ”Ŋ"]` (NOT "需č́", "å­Ķäđ ", "åĶ‚ä―•", "ä―ŋį”Ļ") + +**Key Features**: + +- **Language-agnostic**: Works with any language (English, Chinese, Japanese, etc.) +- **Smart filtering**: Excludes action verbs (find, search, get, æŸĨæ‰ū, buscar, etc.) +- **Timeout protection**: 4s timeout prevents slow LLM responses +- **Caching**: Results cached to avoid redundant LLM calls +- **Fallback**: Uses original query if LLM unavailable + +### 4.2 Grep Scanner (L0 - Initial Seeding) + +Fast substring search using Obsidian's `cachedRead`. Searches both full queries and individual terms with batch processing optimized for platform (10 files on mobile, 50 on desktop). + +### 4.3 Graph Expander + +Discovers related notes through link analysis, expanding initial grep hits from ~50 to 150+ candidates. + +**Three Strategies:** + +1. **BFS Link Traversal** - Follows outgoing/backlinks from grep hits +2. **Active Context** - Includes neighbors of currently open note +3. **Co-citation** - Finds notes linking to same targets (topic similarity) + +Enables discovery of conceptually related notes without exact term matches by leveraging the knowledge graph structure. + +**Guardrails:** + +- H = configured graph traversal depth (Graph hops setting) +- Small set (1–4 grep hits): effectiveHops = min(H + 1, 3) +- Large set (â‰Ĩ50 grep hits): effectiveHops = 1 and co-citation is disabled + +### 4.4 Full-Text Engine (L1 - Ephemeral Body Index) + +FlexSearch index built per-query with security and performance optimizations: + +**Security Features:** + +- Path validation via `VaultPathValidator` to prevent path traversal attacks +- Content size limit of 10MB per file to prevent memory exhaustion +- Circular reference handling with depth-based limiting (maxDepth=2) + +#### FlexSearch Ranking Algorithm + +FlexSearch uses a **Contextual Index** algorithm (NOT BM25/TF-IDF). Key characteristics: + +1. **Position-based scoring**: Results are ranked by position (1st result = score 1.0, 2nd = 0.5, 3rd = 0.33, etc.) +2. **Field weights**: Title (3x) > Path (2.5x) > Headings/Tags/Props/Links (2x) > Body (1x) +3. **Input format**: Can be either sentences or terms. Our tokenizer splits into: + - ASCII words: `"hello world"` → `["hello", "world"]` + - CJK bigrams: `"äļ­æ–‡įž–įĻ‹"` → `["äļ­æ–‡", "æ–‡įž–", "įž–įĻ‹"]` + +**Hybrid Search Approach:** + +- Searches both full query phrases (for precision) and individual terms (for recall) +- Accumulates scores from multiple queries (documents matching multiple terms rank higher) +- Multi-field bonus: 20% boost for each additional field matched +- Results in better recall without sacrificing precision + +**Folder Boosting Algorithm:** + +The system applies intelligent folder-based boosting to improve clustering of related notes: + +1. **Folder Prevalence Detection**: Counts how many results are in each folder +2. **Logarithmic Boost**: Notes in folders with multiple matches get boosted + - Formula: `boostFactor = 1 + log2(count + 1)` + - Example: 3 docs in folder → ~2x boost, 7 docs → ~2.3x boost +3. **Applied After Scoring**: Boost applied after initial scoring but before final ranking +4. **Promotes Topic Clusters**: Helps surface groups of related notes from the same project/topic + +**Path Indexing:** + +- Path components are indexed separately with 2.5x weight +- Example: `"Piano Lessons/Lesson 2.md"` indexes as `"Piano Lessons Lesson 2"` +- Enables folder name and file name searching + +**Frontmatter Property Indexing:** + +- Property VALUES are indexed (keys are ignored) with 2x weight +- Supports strings, numbers, booleans, dates, and arrays of primitives +- Skips null/undefined values and empty strings +- Example frontmatter: + ```yaml + --- + author: John Doe + date: 2024-01-01 + published: true + tags: [tutorial, advanced] + status: draft + priority: 1 + --- + ``` + Indexes as: `"John Doe 2024-01-01T00:00:00.000Z true tutorial advanced draft 1"` +- Enables searching for content by author, status, boolean flags, or any custom metadata +- Date objects are indexed as ISO strings (searchable by year, month, day) +- Note: Property keys are NOT searchable (can't search "author:" or "status:") + +**Implementation Details:** + +- Builds ephemeral FlexSearch index per-query +- Memory-bounded with platform-aware limits (20MB mobile, 100MB desktop) +- Custom tokenizer handles ASCII words and CJK bigrams +- Links indexed as searchable basenames while preserving full paths + +### 4.5 Semantic Layer (Optional) + +- Storage: JSONL snapshots (`copilot-index-v3-000.jsonl`, â€Ķ) persisted under `.obsidian/` or `.copilot/` +- Loading: `MemoryIndexManager.loadIfExists()` at startup; non-disruptive if missing +- Building: `indexVault()` full rebuild; `indexVaultIncremental()` reindexes only changed files +- Vector store: LangChain `MemoryVectorStore` in-memory; addVectors + similaritySearchVectorWithScore +- Retrieval: Aggregates per-note by averaging top-3 chunk similarities; per-query min–max scaling +- Fusion: Weighted RRF (semantic default weight 1.5) + tiny rank epsilon for score differentiation + +### 4.6 Weighted RRF + +Combines multiple rankings with configurable weights using Reciprocal Rank Fusion (RRF). Documents appearing in multiple result sets receive higher scores. Default weights: lexical (1.0x), semantic (2.0x), grep prior (0.3x). + +--- + +## 5) Runtime Logging + +When a search is executed, you'll see detailed logging showing each pipeline step: + +``` +=== SearchCore: Starting search for "How do I implement authentication in my Next.js app?" === +Query expansion: 3 variants + 3 terms + Variants: ["How do I implement authentication in my Next.js app?", "Next.js authentication implementation", "NextJS auth setup guide"] + Terms: ["authentication", "nextjs", "app"] +Grep scan: Found 52 initial matches +Graph expansion: 52 grep → 176 expanded → 176 final candidates +Full-text index: Built with 176 documents +FullText: Indexed 176/200 docs (18% memory, 1543210 bytes) +FullText: Boosting 3 folders with multiple matches + nextjs: 5 docs (2.32x boost) + auth: 3 docs (2.00x boost) + tutorials: 2 docs (1.58x boost) +Full-text search: Found 35 results (using 6 search inputs) +Final results: 30 documents (after RRF) + +┌─────────┮──────────────────────┮──────────────────────────┮────────┮──────────┐ +│ (index) │ title │ path │ score │ engine │ +├─────────┾──────────────────────┾──────────────────────────┾────────┾──────────â”Ī +│ 0 │ 'nextjs-auth.md' │ 'nextjs/auth.md' │ '10.00'│ 'rrf' │ +│ 1 │ 'config.md' │ 'nextjs/config.md' │ '3.60' │ 'rrf' │ +│ 2 │ 'jwt.md' │ 'nextjs/jwt.md' │ '3.00' │ 'rrf' │ +└─────────â”ī──────────────────────â”ī──────────────────────────â”ī────────â”ī──────────┘ +``` + +This logging helps debug search performance and understand the retrieval flow. + +## 6) Performance & Configuration + +### Performance Characteristics + +- **No persistent index**: Everything built per-query +- **Grep scan**: < 50ms for 1k files (cached) +- **Graph expansion**: < 30ms for 200 nodes +- **Full-text build**: < 100ms for 500 candidates +- **Total latency**: < 200ms P95 +- **Memory peak**: < 20MB mobile, < 100MB desktop + +### Settings + +- Enable Semantic Search (v3): master toggle for memory index and auto-index strategy +- Auto-Index Strategy: NEVER, ON STARTUP, ON MODE SWITCH (only when semantic toggle is on) +- Requests per Minute: embedding rate control during indexing +- Embedding Batch Size: indexing throughput control +- Exclusions/Inclusions: respected by `MemoryIndexManager` during builds +- Disable index loading on mobile: skips load/build on mobile to save resources + +--- + +## Implementation Status + +### ✅ Completed Features: + +**Core Pipeline:** + +- Query expansion with LLM integration and 4s timeout via AbortController +- Grep scanner with platform-optimized batching (10 mobile, 50 desktop) +- Graph expander with true BFS traversal and visited tracking +- Full-text engine with ephemeral FlexSearch and multilingual tokenizer (ASCII + CJK) +- Weighted RRF with simple linear scaling; grep prior ranked and reduced weight (0.2) +- TieredLexicalRetriever orchestrator integrated into search tools + +**Security & Performance:** + +- Path traversal protection via VaultPathValidator +- Content size limits (10MB per file) to prevent memory exhaustion +- Circular reference handling with depth-based limiting +- Score normalization using Math.tanh for natural 0-1 range +- Array operations optimized with in-place mutations +- Single-file reindexing for opportunistic updates + +**Semantic Search (Optional):** + +- JSONL-backed MemoryIndexManager with partitioned storage +- LangChain MemoryVectorStore for in-memory vector operations +- Incremental indexing for new/modified files only +- Auto-index strategies: NEVER, ON STARTUP, ON MODE SWITCH +- Settings toggle to enable/disable semantic search + +**UX Improvements:** + +- Live indexing progress with pause/resume/stop controls +- Inclusions/exclusions displayed during indexing +- Commands: "Refresh Vault Index" (incremental), "Force Reindex Vault" (full) +- Clear Index command removes all index files +- Unified scoring with rerank_score attached to all documents + +### ✅ Final Status: + +**Completed in Final Session (2025-08-11):** + +- Graph hops setting (1-3 range) added to QA settings with slider UI +- Rate limiting properly integrated with RateLimiter class +- Batching fixed to prepare all chunks first, then process in batches (matching old implementation) +- Indexing notices show file counts instead of chunk counts +- Relevant Notes UI fixed to always show, even without index +- Refresh button fixed to only reindex current file when index exists +- "List Indexed Files" command restored and updated for v3 +- All console.error replaced with logError for consistent logging +- Settings validation for all SearchOptions parameters +- Documentation updated to reflect final implementation + +**Key Fixes Applied:** + +- MemoryIndexManager returns file counts, not chunk counts +- Rate limiting applied once per batch, not per file +- Batch size setting properly respected across all chunks +- Removed duplicate "Semantic memory index updated" notices +- Fixed imports and TypeScript errors in commands + +**Deferred (Not Critical):** +The following features were considered but deemed unnecessary based on current performance: + +- Incremental indexing hooks (active note switching handles updates) +- Result caching with LRU (performance is already good) +- Debounce/batch indexing (current implementation handles well) +- Additional settings UI beyond graph hops (not needed) +- Metrics panel (existing logging is adequate) + +### Migration Notes: + +- Orama-based modules deprecated: OramaSearchModal marked as obsolete +- VectorStoreManager replaced by MemoryIndexManager for v3 +- All v3 tests passing (126 tests), clean TypeScript build +- Backwards compatible with existing search tools + +**Key Insights**: + +- No persistent full-text index needed - grep provides fast initial seeding +- Graph expansion dramatically improves recall (3x candidates) +- Ephemeral indexing eliminates maintenance overhead +- Security hardening prevents common attack vectors +- Platform-aware memory management ensures stability diff --git a/src/search/v3/SearchCore.test.ts b/src/search/v3/SearchCore.test.ts new file mode 100644 index 00000000..e76bf65b --- /dev/null +++ b/src/search/v3/SearchCore.test.ts @@ -0,0 +1,173 @@ +import { SearchCore } from "./SearchCore"; +import { MemoryIndexManager } from "./MemoryIndexManager"; +import { BaseChatModel } from "@langchain/core/language_models/chat_models"; + +// Mock MemoryIndexManager +jest.mock("./MemoryIndexManager", () => ({ + MemoryIndexManager: { + getInstance: jest.fn().mockReturnValue({ + search: jest.fn(), + ensureLoaded: jest.fn(), + isAvailable: jest.fn().mockReturnValue(true), + }), + }, +})); + +// Mock logger +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logError: jest.fn(), + logWarn: jest.fn(), +})); + +// Minimal mock app for SearchCore +const createMockApp = () => ({ + vault: { + getAbstractFileByPath: jest.fn((path: string) => ({ path, stat: { mtime: Date.now() } })), + cachedRead: jest.fn(async () => "content"), + getMarkdownFiles: jest.fn(() => []), + }, + metadataCache: { + resolvedLinks: {}, + getBacklinksForFile: jest.fn(() => ({ data: {} })), + getFileCache: jest.fn(() => ({ headings: [], frontmatter: {} })), + }, + workspace: { + getActiveFile: jest.fn(() => null), + }, +}); + +describe("SearchCore - grep prior normalization", () => { + it("should normalize ranked grep scores and not overflow", async () => { + const app: any = createMockApp(); + const core = new SearchCore(app); + + // Spy on internal rankGrepHits scoring by providing many queries and hits + const queries = [ + "a b", + "c d", + "e f", // phrases + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", // terms + ]; + const hits = ["note1.md", "note2.md", "note3.md"]; // few hits + + // Mock file reads + app.vault.cachedRead = jest.fn(async (file: any) => `${file.path} content a b c d e f g h i j`); + + // Access private via any + const ranked = await (core as any).rankGrepHits(queries, hits); + expect(Array.isArray(ranked)).toBe(true); + // Should preserve all ids + expect(ranked.sort()).toEqual(hits.sort()); + }); +}); + +describe("SearchCore - HyDE Integration", () => { + let app: any; + let mockChatModel: jest.Mocked; + let getChatModel: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + app = createMockApp(); + + // Mock chat model + mockChatModel = { + invoke: jest.fn(), + } as unknown as jest.Mocked; + + getChatModel = jest.fn().mockResolvedValue(mockChatModel); + }); + + it("should generate HyDE document when semantic search is enabled", async () => { + // Mock chat model response + mockChatModel.invoke.mockResolvedValue({ + content: "OAuth authentication involves configuring identity providers and JWT tokens.", + lc_kwargs: {}, + } as any); + + // Mock MemoryIndexManager + const mockIndex = MemoryIndexManager.getInstance(app); + (mockIndex.search as jest.Mock).mockResolvedValue([{ id: "auth/oauth.md", score: 0.95 }]); + + // Create SearchCore with chat model + const core = new SearchCore(app, getChatModel); + + // Mock internal methods to avoid full pipeline + const generateHyDESpy = jest.spyOn(core as any, "generateHyDE"); + + // Execute retrieve with semantic enabled + await core.retrieve("How do I implement authentication?", { + maxResults: 10, + enableSemantic: true, + }); + + // Verify HyDE was called + expect(generateHyDESpy).toHaveBeenCalledWith("How do I implement authentication?"); + expect(mockChatModel.invoke).toHaveBeenCalledWith( + expect.stringContaining("Write a brief, informative passage"), + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + }); + + it("should handle HyDE timeout gracefully", async () => { + // Mock timeout error after 4 seconds + mockChatModel.invoke.mockRejectedValue( + Object.assign(new Error("Aborted"), { name: "AbortError" }) + ); + + const mockIndex = MemoryIndexManager.getInstance(app); + (mockIndex.search as jest.Mock).mockResolvedValue([]); + + const core = new SearchCore(app, getChatModel); + + // Should not throw + await expect( + core.retrieve("test query", { + maxResults: 10, + enableSemantic: true, + }) + ).resolves.toBeDefined(); + }); + + it("should not generate HyDE when semantic is disabled", async () => { + const core = new SearchCore(app, getChatModel); + + await core.retrieve("test query", { + maxResults: 10, + enableSemantic: false, + }); + + // getChatModel will be called for query expansion, but not for HyDE + // Check that invoke was called only once (for query expansion, not HyDE) + const invocations = mockChatModel.invoke.mock.calls; + if (invocations.length > 0) { + // Should only have query expansion call, not HyDE call + expect(invocations[0][0]).not.toContain("Write a brief, informative passage"); + } + }); + + it("should work without chat model", async () => { + const core = new SearchCore(app, undefined); + + const mockIndex = MemoryIndexManager.getInstance(app); + (mockIndex.search as jest.Mock).mockResolvedValue([{ id: "test.md", score: 0.8 }]); + + // Should not throw even without chat model + const results = await core.retrieve("test query", { + maxResults: 10, + enableSemantic: true, + }); + + expect(results).toBeDefined(); + }); +}); diff --git a/src/search/v3/SearchCore.ts b/src/search/v3/SearchCore.ts new file mode 100644 index 00000000..e7833440 --- /dev/null +++ b/src/search/v3/SearchCore.ts @@ -0,0 +1,340 @@ +import { logError, logInfo } from "@/logger"; +import { BaseChatModel } from "@langchain/core/language_models/chat_models"; +import { App } from "obsidian"; +import { FullTextEngine } from "./engines/FullTextEngine"; +import { GraphExpander } from "./expanders/GraphExpander"; +import { NoteIdRank, SearchOptions } from "./interfaces"; +import { MemoryIndexManager } from "./MemoryIndexManager"; +import { QueryExpander } from "./QueryExpander"; +import { GrepScanner } from "./scanners/GrepScanner"; +import { weightedRRF } from "./utils/RRF"; + +// LLM timeout for query expansion and HyDE generation +const LLM_GENERATION_TIMEOUT_MS = 4000; + +/** + * Core search engine that orchestrates the multi-stage retrieval pipeline + */ +export class SearchCore { + private grepScanner: GrepScanner; + private graphExpander: GraphExpander; + private fullTextEngine: FullTextEngine; + private queryExpander: QueryExpander; + + constructor( + private app: App, + private getChatModel?: () => Promise + ) { + this.grepScanner = new GrepScanner(app); + this.graphExpander = new GraphExpander(app); + this.fullTextEngine = new FullTextEngine(app); + this.queryExpander = new QueryExpander({ + getChatModel: this.getChatModel, + maxVariants: 3, + timeout: LLM_GENERATION_TIMEOUT_MS, + }); + } + + /** + * Main retrieval pipeline + * @param query - User's search query + * @param options - Search options + * @returns Ranked list of note IDs + */ + async retrieve(query: string, options: SearchOptions = {}): Promise { + // Validate and sanitize options + const maxResults = Math.min(Math.max(1, options.maxResults || 30), 100); + const enableSemantic = options.enableSemantic || false; + const semanticWeight = Math.min(Math.max(0, options.semanticWeight || 1.5), 10); + const candidateLimit = Math.min(Math.max(10, options.candidateLimit || 500), 1000); + const graphHops = Math.min(Math.max(1, options.graphHops || 1), 3); + const rrfK = Math.min(Math.max(1, options.rrfK || 60), 100); + + try { + logInfo(`\n=== SearchCore: Starting search for "${query}" ===`); + + // 1. Expand query into variants and terms + const expanded = await this.queryExpander.expand(query); + const queries = expanded.queries; + // Combine expanded salient terms with any provided salient terms + const salientTerms = options.salientTerms + ? [...new Set([...expanded.salientTerms, ...options.salientTerms])] + : expanded.salientTerms; + + logInfo(`Query expansion: ${queries.length} variants + ${salientTerms.length} terms`); + logInfo(` Variants: [${queries.map((q) => `"${q}"`).join(", ")}]`); + logInfo(` Terms: [${salientTerms.map((t) => `"${t}"`).join(", ")}]`); + + // 2. GREP for initial candidates (use both queries and terms) + const allSearchStrings = [...queries, ...salientTerms]; + const grepHits = await this.grepScanner.batchCachedReadGrep(allSearchStrings, 200); + + logInfo(`Grep scan: Found ${grepHits.length} initial matches`); + + // 3. Graph expansion from grep results + const activeFile = this.app.workspace.getActiveFile(); + const expandedCandidates = await this.graphExpander.expandCandidates( + grepHits, + activeFile, + graphHops + ); + + // 4. Limit candidates + const candidates = expandedCandidates.slice(0, candidateLimit); + + logInfo( + `Graph expansion: ${grepHits.length} grep → ${expandedCandidates.length} expanded → ${candidates.length} final candidates` + ); + + // 5. Build ephemeral full-text index + const indexed = await this.fullTextEngine.buildFromCandidates(candidates); + + logInfo(`Full-text index: Built with ${indexed} documents`); + + // 6. Search full-text index with both query variants AND salient terms + // This hybrid approach maximizes both precision (from phrases) and recall (from terms) + const allFullTextQueries = [...queries, ...salientTerms]; + // Pass salient terms as low-weight terms + const fullTextResults = this.fullTextEngine.search( + allFullTextQueries, + maxResults * 2, + salientTerms + ); + + logInfo( + `Full-text search: Found ${fullTextResults.length} results (using ${allFullTextQueries.length} search inputs)` + ); + + // Only log in debug mode + // if (fullTextResults.length > 0) { + // logInfo("Full-text top 10 results before RRF:"); + // fullTextResults.slice(0, 10).forEach((r, i) => { + // logInfo(` ${i+1}. ${r.id} (score: ${r.score.toFixed(4)})`); + // }); + // } + + // 7. Optional semantic retrieval (vector-only, in-memory JSONL-backed index) + let semanticResults: NoteIdRank[] = []; + if (enableSemantic) { + try { + const index = MemoryIndexManager.getInstance(this.app); + const topK = Math.min(candidateLimit, 200); + + // Generate HyDE document for semantic diversity + const hydeDoc = await this.generateHyDE(query); + + // Build search queries: original variants + HyDE if available + const semanticQueries = [...allFullTextQueries]; + if (hydeDoc) { + // Add HyDE document as the first query for higher weight + semanticQueries.unshift(hydeDoc); + } + + const hits = await index.search(semanticQueries, topK, candidates); + semanticResults = hits.map((h) => ({ id: h.id, score: h.score, engine: "semantic" })); + } catch (error) { + logInfo("SearchCore: Semantic retrieval failed", error as any); + } + } + + // 8. Rank grep hits by evidence quality before fusion (path/content × phrase/term) + const rankedGrep = await this.rankGrepHits(allSearchStrings, grepHits.slice(0, 100)); + const grepPrior: NoteIdRank[] = rankedGrep.slice(0, 50).map((id, idx) => ({ + id, + score: 1 / (idx + 1), + engine: "grep", + })); + + // 9. Weighted RRF + const fusedResults = weightedRRF({ + lexical: fullTextResults, + semantic: semanticResults, + grepPrior: grepPrior, + weights: { + lexical: 1.0, + semantic: semanticWeight, + grepPrior: 0.2, + }, + k: rrfK, + }); + + // 10. Clean up full-text index to free memory + this.fullTextEngine.clear(); + + // 11. Return top K results + const finalResults = fusedResults.slice(0, maxResults); + + // Log results in an inspectable format + if (finalResults.length > 0) { + const resultsForLogging = finalResults.map((result) => { + const file = this.app.vault.getAbstractFileByPath(result.id); + return { + title: file?.name || result.id, + path: result.id, + score: parseFloat(result.score.toFixed(4)), + engine: result.engine, + }; + }); + logInfo(`Final results: ${finalResults.length} documents (after RRF)`); + // Log as an array object for better inspection in console + logInfo("Search results:", resultsForLogging); + } else { + logInfo("No results found"); + } + + return finalResults; + } catch (error) { + logError("SearchCore: Retrieval failed", error); + + // Fallback to simple grep results + const fallbackResults = await this.fallbackSearch(query, maxResults); + return fallbackResults; + } + } + + /** + * Fallback search using only grep + * @param query - Search query + * @param limit - Maximum results + * @returns Basic grep results as NoteIdRank + */ + private async fallbackSearch(query: string, limit: number): Promise { + try { + const grepHits = await this.grepScanner.grep(query, limit); + return grepHits.map((id, idx) => ({ + id, + score: 1 / (idx + 1), + engine: "grep", + })); + } catch (error) { + logError("SearchCore: Fallback search failed", error); + return []; + } + } + + /** + * Get statistics about the last retrieval + */ + getStats(): { + fullTextStats: { documentsIndexed: number; memoryUsed: number; memoryPercent: number }; + } { + return { + fullTextStats: this.fullTextEngine.getStats(), + }; + } + + /** + * Clear all caches and reset state + */ + clear(): void { + this.fullTextEngine.clear(); + this.queryExpander.clearCache(); + logInfo("SearchCore: Cleared all caches"); + } + + /** + * Generate a hypothetical document using HyDE (Hypothetical Document Embeddings) + * Creates a synthetic answer to help find semantically similar documents + */ + private async generateHyDE(query: string): Promise { + try { + // Get chat model if available + if (!this.getChatModel) { + return null; + } + + const chatModel = await this.getChatModel(); + if (!chatModel) { + return null; + } + + // Simple prompt for pure hypothetical generation + const prompt = `Write a brief, informative passage (2-3 sentences) that directly answers this question. Use specific details and terminology that would appear in a comprehensive answer. + +Question: ${query} + +Answer:`; + + // Generate with timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), LLM_GENERATION_TIMEOUT_MS); + + const response = await chatModel.invoke(prompt, { signal: controller.signal }); + clearTimeout(timeoutId); + + const hydeDoc = response.content?.toString() || null; + if (hydeDoc) { + logInfo(`HyDE generated: ${hydeDoc.slice(0, 100)}...`); + } + return hydeDoc; + } catch (error: any) { + if (error?.name === "AbortError") { + logInfo(`HyDE generation timed out (${LLM_GENERATION_TIMEOUT_MS / 1000}s limit)`); + } else { + logInfo(`HyDE generation skipped: ${error?.message || "Unknown error"}`); + } + return null; + } + } + + /** + * Rank grep hits by evidence strength using path/content and phrase/term categories + */ + private async rankGrepHits(queries: string[], hits: string[]): Promise { + const phraseQueries = queries.filter((q) => q.trim().includes(" ")); + const termQueries = queries.filter((q) => !q.trim().includes(" ")); + + const scored: Array<{ id: string; score: number }> = []; + + for (const id of hits) { + let score = 0; + try { + const file = this.app.vault.getAbstractFileByPath(id); + const pathLower = id.toLowerCase(); + const contentLower = file + ? (await this.app.vault.cachedRead(file as any)).toLowerCase() + : ""; + + // Prefer phrase matches + let pathPhrase = 0; + let contentPhrase = 0; + for (const pq of phraseQueries) { + const p = pq.toLowerCase(); + if (pathLower.includes(p)) pathPhrase++; + if (contentLower.includes(p)) contentPhrase++; + } + + // Term matches + let pathTerm = 0; + let contentTerm = 0; + const distinctMatched = new Set(); + for (const tq of termQueries) { + const t = tq.toLowerCase(); + if (pathLower.includes(t)) { + pathTerm++; + distinctMatched.add(t); + } else if (contentLower.includes(t)) { + contentTerm++; + distinctMatched.add(t); + } + } + + // Compute raw evidence score + const raw = + 4 * pathPhrase + + 3 * contentPhrase + + 2 * pathTerm + + 1 * contentTerm + + 0.5 * distinctMatched.size; + // Use tanh for natural 0-1 normalization with soft saturation + score = Math.tanh(raw / 4); + } catch { + score = 0; + } + scored.push({ id, score }); + } + + scored.sort((a, b) => b.score - a.score); + return scored.map((s) => s.id); + } +} diff --git a/src/search/v3/TieredLexicalRetriever.test.ts b/src/search/v3/TieredLexicalRetriever.test.ts new file mode 100644 index 00000000..0a02e149 --- /dev/null +++ b/src/search/v3/TieredLexicalRetriever.test.ts @@ -0,0 +1,215 @@ +import { Document } from "@langchain/core/documents"; +import { TFile } from "obsidian"; +import * as SearchCoreModule from "./SearchCore"; +import { TieredLexicalRetriever } from "./TieredLexicalRetriever"; + +// Mock modules +jest.mock("obsidian"); +jest.mock("@/logger"); +jest.mock("./SearchCore", () => { + return { + SearchCore: jest.fn().mockImplementation(() => ({ + retrieve: jest.fn().mockResolvedValue([ + { id: "note1.md", score: 0.8, engine: "fulltext" }, + { id: "note2.md", score: 0.6, engine: "grep" }, + ]), + })), + }; +}); +jest.mock("@/LLMProviders/chatModelManager"); + +describe("TieredLexicalRetriever", () => { + let retriever: TieredLexicalRetriever; + let mockApp: any; + // legacy var no longer used after refactor + + beforeEach(() => { + // Mock app + mockApp = { + vault: { + getAbstractFileByPath: jest.fn(), + cachedRead: jest.fn(), + }, + metadataCache: { + getFileCache: jest.fn(), + }, + }; + + // Ensure SearchCore is mocked before constructing retriever + jest.spyOn(SearchCoreModule, "SearchCore").mockImplementation((() => ({ + retrieve: jest.fn().mockResolvedValue([ + { id: "note1.md", score: 0.8, engine: "fulltext" }, + { id: "note2.md", score: 0.6, engine: "grep" }, + ]), + })) as any); + + // Create retriever instance + retriever = new TieredLexicalRetriever(mockApp, { + minSimilarityScore: 0.1, + maxK: 30, + salientTerms: [], // Required field + }); + }); + + // Folder boost behavior is now implemented in FullTextEngine and covered by its tests. + + describe("combineResults", () => { + it("should prioritize mentioned notes", () => { + const searchDocs = [ + new Document({ + pageContent: "Search result 1", + metadata: { path: "note1.md", score: 0.9 }, + }), + new Document({ + pageContent: "Search result 2", + metadata: { path: "note2.md", score: 0.8 }, + }), + ]; + + const mentionedNotes = [ + new Document({ + pageContent: "Mentioned note", + metadata: { path: "mentioned.md", score: 0.5 }, + }), + new Document({ + pageContent: "Duplicate note", + metadata: { path: "note1.md", score: 0.3 }, // Lower score but mentioned + }), + ]; + + const combined = (retriever as any).combineResults(searchDocs, mentionedNotes); + + // Should have 3 unique documents + expect(combined.length).toBe(3); + + // Mentioned note should be included even with lower score + const mentionedDoc = combined.find((d: Document) => d.metadata.path === "mentioned.md"); + expect(mentionedDoc).toBeDefined(); + + // note1.md from mentioned notes should override search result + const note1 = combined.find((d: Document) => d.metadata.path === "note1.md"); + expect(note1?.metadata.score).toBe(0.3); // Score from mentioned notes + }); + + it("should sort by score after folder boosting", () => { + const searchDocs = [ + new Document({ + pageContent: "Note A", + metadata: { path: "folder/noteA.md", score: 0.6 }, + }), + new Document({ + pageContent: "Note B", + metadata: { path: "folder/noteB.md", score: 0.5 }, + }), + new Document({ + pageContent: "Note C", + metadata: { path: "other/noteC.md", score: 0.7 }, + }), + ]; + + const combined = (retriever as any).combineResults(searchDocs, []); + + // After folder boost, folder notes might rank higher + // Results should be sorted by score descending + for (let i = 1; i < combined.length; i++) { + expect(combined[i].metadata.score).toBeLessThanOrEqual(combined[i - 1].metadata.score); + } + }); + }); + + describe("getRelevantDocuments", () => { + beforeEach(() => { + // nothing needed here now; SearchCore is mocked above + + // Mock file system + mockApp.vault.getAbstractFileByPath.mockImplementation((path: string) => { + if (path === "note1.md" || path === "note2.md") { + const file = new (TFile as any)(path); + Object.setPrototypeOf(file, (TFile as any).prototype); + (file as any).stat = { mtime: 1000, ctime: 1000 }; + return file; + } + return null; + }); + + mockApp.vault.cachedRead.mockResolvedValue("File content"); + mockApp.metadataCache.getFileCache.mockReturnValue({ + tags: [{ tag: "#test" }], + }); + }); + + it("should integrate all components correctly", async () => { + // Ensure SearchCore mock returns two results for this test + jest.spyOn(SearchCoreModule, "SearchCore").mockImplementation((() => ({ + retrieve: jest.fn().mockResolvedValue([ + { id: "note1.md", score: 0.8, engine: "fulltext" }, + { id: "note2.md", score: 0.6, engine: "grep" }, + ]), + })) as any); + + const query = "test query"; + const results = await retriever.getRelevantDocuments(query); + + // Should return documents + expect(results.length).toBe(2); + expect(results[0].metadata.path).toBe("note1.md"); + expect(results[0].metadata.score).toBeGreaterThanOrEqual(0.8); + }); + + it("should handle empty search results", async () => { + jest + .spyOn(SearchCoreModule, "SearchCore") + .mockImplementation((() => ({ retrieve: jest.fn().mockResolvedValue([]) })) as any); + // Recreate retriever to use the new mock implementation + retriever = new TieredLexicalRetriever(mockApp, { + minSimilarityScore: 0.1, + maxK: 30, + salientTerms: [], + }); + const results = await retriever.getRelevantDocuments("no matches"); + expect(results).toEqual([]); + }); + + it("should extract mentioned notes from query", async () => { + mockApp.vault.getAbstractFileByPath.mockImplementation((path: string) => { + if (path === "mentioned.md") { + const file = new (TFile as any)(path); + Object.setPrototypeOf(file, (TFile as any).prototype); + (file as any).stat = { mtime: 1000, ctime: 1000 }; + return file; + } + if (path === "mentioned") { + const file = new (TFile as any)("mentioned.md"); + Object.setPrototypeOf(file, (TFile as any).prototype); + (file as any).stat = { mtime: 1000, ctime: 1000 }; + return file; + } + if (path === "note1.md" || path === "note2.md" || path === "other.md") { + const file = new (TFile as any)(path); + Object.setPrototypeOf(file, (TFile as any).prototype); + (file as any).stat = { mtime: 1000, ctime: 1000 }; + return file; + } + return null; + }); + + jest.spyOn(SearchCoreModule, "SearchCore").mockImplementation((() => ({ + retrieve: jest.fn().mockResolvedValue([{ id: "other.md", score: 0.4, engine: "fulltext" }]), + })) as any); + + // Recreate retriever to use the new mock implementation + retriever = new TieredLexicalRetriever(mockApp, { + minSimilarityScore: 0.1, + maxK: 30, + salientTerms: [], + }); + + const query = "search [[mentioned]] for something"; + const results = await retriever.getRelevantDocuments(query); + + // Should include the mentioned note + const mentioned = results.find((d) => d.metadata.path === "mentioned.md"); + expect(mentioned).toBeDefined(); + }); + }); +}); diff --git a/src/search/v3/TieredLexicalRetriever.ts b/src/search/v3/TieredLexicalRetriever.ts new file mode 100644 index 00000000..817e2d2d --- /dev/null +++ b/src/search/v3/TieredLexicalRetriever.ts @@ -0,0 +1,398 @@ +import { logInfo, logWarn } from "@/logger"; +import { getSettings } from "@/settings/model"; +import { extractNoteFiles } from "@/utils"; +import { BaseCallbackConfig } from "@langchain/core/callbacks/manager"; +import { Document } from "@langchain/core/documents"; +import { BaseRetriever } from "@langchain/core/retrievers"; +import { App, TFile } from "obsidian"; +import { SearchCore } from "./SearchCore"; +// Defer requiring ChatModelManager until runtime to avoid test-time import issues +let getChatModelManagerSingleton: (() => any) | null = null; +async function safeGetChatModel() { + try { + if (!getChatModelManagerSingleton) { + // dynamic import to prevent module load side effects during tests + const mod = await import("@/LLMProviders/chatModelManager"); + getChatModelManagerSingleton = () => mod.default.getInstance(); + } + const chatModelManager = getChatModelManagerSingleton(); + return chatModelManager.getChatModel(); + } catch { + return null; + } +} + +/** + * Tiered Lexical Retriever that implements multi-stage note retrieval: + * 1. Grep scan for initial candidates + * 2. Graph expansion to find related notes + * 3. Full-text search with FlexSearch + * 4. Optional semantic reranking (future) + * + * This retriever builds ephemeral indexes on-demand for each search, + * ensuring always-fresh results without manual index management. + */ +export class TieredLexicalRetriever extends BaseRetriever { + public lc_namespace = ["tiered_lexical_retriever"]; + private searchCore: SearchCore; + + constructor( + private app: App, + private options: { + minSimilarityScore?: number; + maxK: number; + salientTerms: string[]; + timeRange?: { startTime: number; endTime: number }; + textWeight?: number; + returnAll?: boolean; + useRerankerThreshold?: number; // Not used in v3, kept for compatibility + } + ) { + super(); + // Provide safe getter for chat model (returns null in tests if unavailable) + this.searchCore = new SearchCore(app, safeGetChatModel); + } + + /** + * Main entry point for document retrieval, compatible with LangChain interface. + * @param query - The search query + * @param config - Optional callback configuration + * @returns Array of Document objects with content and metadata + */ + public async getRelevantDocuments( + query: string, + config?: BaseCallbackConfig + ): Promise { + try { + // If time range is specified, ONLY return time-relevant documents + if (this.options.timeRange) { + return this.getTimeRangeDocuments(query); + } + + // Normal search flow when no time range + // Extract note TFiles wrapped in [[]] from the query + const noteFiles = extractNoteFiles(query, this.app.vault); + const noteTitles = noteFiles.map((file) => file.basename); + + // Combine salient terms with note titles + const enhancedSalientTerms = [...new Set([...this.options.salientTerms, ...noteTitles])]; + + if (getSettings().debug) { + logInfo("TieredLexicalRetriever: Starting search", { + query, + salientTerms: enhancedSalientTerms, + maxK: this.options.maxK, + }); + } + + // Perform the tiered search + const searchResults = await this.searchCore.retrieve(query, { + maxResults: this.options.maxK, + salientTerms: enhancedSalientTerms, + enableSemantic: !!getSettings().enableSemanticSearchV3, + graphHops: getSettings().graphHops || 1, + }); + + // Get title-matched notes that should always be included + const titleMatches = await this.getTitleMatches(noteFiles); + + // Convert search results to Document format + const searchDocuments = await this.convertToDocuments(searchResults); + + // Combine and deduplicate results + const combinedDocuments = this.combineResults(searchDocuments, titleMatches); + + if (getSettings().debug) { + logInfo("TieredLexicalRetriever: Search complete", { + totalResults: combinedDocuments.length, + titleMatches: titleMatches.length, + searchResults: searchResults.length, + }); + } + + return combinedDocuments; + } catch (error) { + logWarn("TieredLexicalRetriever: Error during search", error); + // Fallback to empty results on error + return []; + } + } + + /** + * Get documents for time-based queries. + * ONLY returns daily notes and documents within the time range. + */ + private async getTimeRangeDocuments(_query: string): Promise { + if (!this.options.timeRange) { + return []; + } + + const { startTime, endTime } = this.options.timeRange; + + // Generate daily note titles for the date range + const dailyNoteTitles = this.generateDailyNoteDateRange(startTime, endTime); + + if (getSettings().debug) { + logInfo("TieredLexicalRetriever: Generated daily note titles", { + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + titlesCount: dailyNoteTitles.length, + firstTitle: dailyNoteTitles[0], + lastTitle: dailyNoteTitles[dailyNoteTitles.length - 1], + }); + } + + // Extract daily note files by exact title match + const dailyNoteQuery = dailyNoteTitles.join(", "); + const dailyNoteFiles = extractNoteFiles(dailyNoteQuery, this.app.vault); + + // Get documents for daily notes + const dailyNoteDocuments = await this.getTitleMatches(dailyNoteFiles); + + // Mark all daily notes for inclusion in context + const dailyNotesWithContext = dailyNoteDocuments.map((doc) => { + doc.metadata.includeInContext = true; + return doc; + }); + + // For time-based queries, we DON'T run regular search + // Instead, find all documents modified within the time range + const allFiles = this.app.vault.getMarkdownFiles(); + const timeFilteredDocuments: Document[] = []; + + // Limit the number of time-filtered documents to avoid overwhelming results + const maxTimeFilteredDocs = Math.min(this.options.maxK, 100); + + for (const file of allFiles) { + // Only include files modified within the time range + if (file.stat.mtime >= startTime && file.stat.mtime <= endTime) { + // Skip if already included as daily note + if (dailyNoteFiles.some((f) => f.path === file.path)) { + continue; + } + + // Stop if we have enough documents + if (timeFilteredDocuments.length >= maxTimeFilteredDocs) { + break; + } + + try { + const content = await this.app.vault.cachedRead(file); + const cache = this.app.metadataCache.getFileCache(file); + + // Calculate score based on recency (more recent = higher score) + const daysSinceModified = (Date.now() - file.stat.mtime) / (1000 * 60 * 60 * 24); + const recencyScore = Math.max(0.3, Math.min(1.0, 1.0 - daysSinceModified / 30)); + + timeFilteredDocuments.push( + new Document({ + pageContent: content, + metadata: { + path: file.path, + title: file.basename, + mtime: file.stat.mtime, + ctime: file.stat.ctime, + tags: cache?.tags?.map((t) => t.tag) || [], + includeInContext: true, + score: recencyScore, + rerank_score: recencyScore, + source: "time-filtered", + }, + }) + ); + } catch (error) { + logWarn(`TieredLexicalRetriever: Failed to read file ${file.path}`, error); + } + } + } + + // Combine and deduplicate + const documentMap = new Map(); + + // Add daily notes first (they have priority) + for (const doc of dailyNotesWithContext) { + documentMap.set(doc.metadata.path, doc); + } + + // Add time-filtered documents + for (const doc of timeFilteredDocuments) { + if (!documentMap.has(doc.metadata.path)) { + documentMap.set(doc.metadata.path, { + ...doc, + metadata: { + ...doc.metadata, + includeInContext: true, + }, + }); + } + } + + // Sort by score (daily notes get score 1.0, time-filtered by recency) + const results = Array.from(documentMap.values()).sort((a, b) => { + const scoreA = a.metadata.score || 0; + const scoreB = b.metadata.score || 0; + return scoreB - scoreA; + }); + + if (getSettings().debug) { + logInfo("TieredLexicalRetriever: Time range search complete", { + timeRange: this.options.timeRange, + dailyNotesFound: dailyNoteFiles.length, + timeFilteredDocs: timeFilteredDocuments.length, + totalResults: results.length, + }); + } + + return results; + } + + /** + * Generate daily note titles for a date range. + * Returns titles in [[YYYY-MM-DD]] format. + */ + private generateDailyNoteDateRange(startTime: number, endTime: number): string[] { + const dailyNotes: string[] = []; + const start = new Date(startTime); + const end = new Date(endTime); + + // Limit to 365 days for performance + const maxDays = 365; + const daysDiff = Math.ceil((endTime - startTime) / (1000 * 60 * 60 * 24)); + + if (daysDiff > maxDays) { + logWarn( + `TieredLexicalRetriever: Date range exceeds ${maxDays} days, limiting to recent ${maxDays} days` + ); + start.setTime(end.getTime() - maxDays * 24 * 60 * 60 * 1000); + } + + const current = new Date(start); + while (current <= end) { + // Use en-CA locale for YYYY-MM-DD format + dailyNotes.push(`[[${current.toLocaleDateString("en-CA")}]]`); + current.setDate(current.getDate() + 1); + } + + return dailyNotes; + } + + /** + * Get documents for notes matching by title (explicit [[]] mentions or time-based queries). + * These are always included in results regardless of search score. + */ + private async getTitleMatches(noteFiles: TFile[]): Promise { + const chunks: Document[] = []; + + for (const file of noteFiles) { + try { + const content = await this.app.vault.cachedRead(file); + const cache = this.app.metadataCache.getFileCache(file); + + // Create a document for the entire note + chunks.push( + new Document({ + pageContent: content, + metadata: { + path: file.path, + title: file.basename, + mtime: file.stat.mtime, + ctime: file.stat.ctime, + tags: cache?.tags?.map((t) => t.tag) || [], + includeInContext: true, // Always include title matches + score: 1.0, // Max score for title matches + rerank_score: 1.0, + source: "title-match", + }, + }) + ); + } catch (error) { + logWarn(`TieredLexicalRetriever: Failed to read title-matched file ${file.path}`, error); + } + } + + return chunks; + } + + /** + * Convert v3 search results to LangChain Document format. + */ + private async convertToDocuments( + searchResults: Array<{ id: string; score: number; engine?: string }> + ): Promise { + const documents: Document[] = []; + + for (const result of searchResults) { + try { + const file = this.app.vault.getAbstractFileByPath(result.id); + if (!file || !(file instanceof TFile)) continue; + + const content = await this.app.vault.cachedRead(file); + if (!content) continue; + + const cache = this.app.metadataCache.getFileCache(file); + + documents.push( + new Document({ + pageContent: content, + metadata: { + path: result.id, + title: file.basename, + mtime: file.stat.mtime, + ctime: file.stat.ctime, + tags: cache?.tags?.map((t) => t.tag) || [], + score: result.score, + rerank_score: result.score, + engine: result.engine || "v3", + includeInContext: result.score > (this.options.minSimilarityScore || 0.1), + }, + }) + ); + } catch (error) { + logWarn(`TieredLexicalRetriever: Failed to convert result ${result.id}`, error); + } + } + + return documents; + } + + /** + * Combine search results with mentioned notes, deduplicating by path. + */ + private combineResults(searchDocuments: Document[], titleMatches: Document[]): Document[] { + const documentMap = new Map(); + + // Add title matches first (they have priority for inclusion) + for (const doc of titleMatches) { + documentMap.set(doc.metadata.path, doc); + } + + // Add search results; if title-match already exists, attach fused score as rerank_score (keep original score for tests/UI semantics) + for (const doc of searchDocuments) { + const key = doc.metadata.path; + if (!documentMap.has(key)) { + documentMap.set(key, doc); + } else { + const existing = documentMap.get(key)!; + const fused = (doc.metadata as any).rerank_score ?? doc.metadata.score ?? 0; + const merged: Document = { + ...existing, + metadata: { + ...existing.metadata, + // Preserve original score from title match; add fused score as rerank_score for consistency across displays + rerank_score: fused, + engine: (doc.metadata as any).engine || existing.metadata.engine, + includeInContext: true, + }, + } as Document; + documentMap.set(key, merged); + } + } + + // Sort by score descending + return Array.from(documentMap.values()).sort((a, b) => { + const scoreA = a.metadata.score || 0; + const scoreB = b.metadata.score || 0; + return scoreB - scoreA; + }); + } +} diff --git a/src/search/v3/engines/FullTextEngine.test.ts b/src/search/v3/engines/FullTextEngine.test.ts new file mode 100644 index 00000000..c9a28c10 --- /dev/null +++ b/src/search/v3/engines/FullTextEngine.test.ts @@ -0,0 +1,578 @@ +// Mock Obsidian modules first (before imports) +jest.mock("obsidian", () => { + // Define MockTFile inside the mock factory + class MockTFile { + path: string; + basename: string; + stat: { mtime: number }; + + constructor(path: string) { + this.path = path; + this.basename = path.replace(".md", ""); + this.stat = { mtime: Date.now() }; + } + } + + return { + TFile: MockTFile, + Platform: { + isMobile: false, + }, + getAllTags: jest.fn((cache) => { + if (cache?.frontmatter?.tags) { + return cache.frontmatter.tags; + } + return []; + }), + }; +}); + +import { TFile } from "obsidian"; +import { FullTextEngine } from "./FullTextEngine"; + +describe("FullTextEngine", () => { + let engine: FullTextEngine; + let mockApp: any; + + beforeEach(() => { + // Mock metadata cache + const mockCache: Record = { + "note1.md": { + headings: [{ heading: "Introduction" }, { heading: "Setup Guide" }], + frontmatter: { title: "TypeScript Guide", tags: ["programming", "typescript"] }, + }, + "note2.md": { + headings: [{ heading: "Machine Learning Basics" }], + frontmatter: { title: "ML Tutorial" }, + }, + "note3.md": { + headings: [], + frontmatter: {}, + }, + }; + + // Mock app + mockApp = { + vault: { + getAbstractFileByPath: jest.fn((path) => { + if (!path || path === "missing.md") return null; + const file = new (TFile as any)(path); + // Make it pass instanceof TFile check + Object.setPrototypeOf(file, TFile.prototype); + return file; + }), + cachedRead: jest.fn((file) => { + const contents: Record = { + "note1.md": "TypeScript is a typed superset of JavaScript", + "note2.md": "Machine learning with Python and TensorFlow", + "note3.md": "React and Vue are JavaScript frameworks", + }; + return Promise.resolve(contents[file.path] || ""); + }), + }, + metadataCache: { + getFileCache: jest.fn((file: any) => mockCache[file.path]), + resolvedLinks: { + "note1.md": { "note2.md": 1, "note3.md": 2 }, + "note2.md": { "note1.md": 1 }, + "note3.md": {}, + }, + getBacklinksForFile: jest.fn((file) => ({ + data: file.path === "note1.md" ? { "note2.md": 1 } : {}, + })), + }, + }; + + engine = new FullTextEngine(mockApp); + }); + + describe("tokenizeMixed", () => { + it("should tokenize ASCII words", () => { + const tokens = (engine as any).tokenizeMixed("Hello World TypeScript"); + + expect(tokens).toContain("hello"); + expect(tokens).toContain("world"); + expect(tokens).toContain("typescript"); + }); + + it("should tokenize alphanumeric and underscores", () => { + const tokens = (engine as any).tokenizeMixed("test_123 var_name"); + + expect(tokens).toContain("test_123"); + expect(tokens).toContain("var_name"); + }); + + it("should generate CJK bigrams", () => { + const tokens = (engine as any).tokenizeMixed("äļ­æ–‡įž–įĻ‹"); + + expect(tokens).toContain("äļ­æ–‡"); + expect(tokens).toContain("æ–‡įž–"); + expect(tokens).toContain("įž–įĻ‹"); + }); + + it("should handle mixed content", () => { + const tokens = (engine as any).tokenizeMixed("TypeScript 和 JavaScript įž–įĻ‹"); + + expect(tokens).toContain("typescript"); + expect(tokens).toContain("javascript"); + expect(tokens).toContain("įž–įĻ‹"); + }); + + it("should handle single CJK characters", () => { + const tokens = (engine as any).tokenizeMixed("äļ­ æ–‡"); + + expect(tokens).toContain("äļ­"); + expect(tokens).toContain("文"); + }); + + it("should return empty array for empty input", () => { + const tokens = (engine as any).tokenizeMixed(""); + expect(tokens).toEqual([]); + }); + }); + + describe("buildFromCandidates", () => { + it("should index candidate files", async () => { + const candidates = ["note1.md", "note2.md"]; + const indexed = await engine.buildFromCandidates(candidates); + + expect(indexed).toBe(2); + + const stats = engine.getStats(); + expect(stats.documentsIndexed).toBe(2); + }); + + it("should respect candidate limit", async () => { + const candidates = Array.from({ length: 1000 }, (_, i) => `note${i}.md`); + + // Mock vault to return files for all candidates + mockApp.vault.getAbstractFileByPath = jest.fn((path) => ({ + path, + basename: path.replace(".md", ""), + stat: { mtime: Date.now() }, + })); + + await engine.buildFromCandidates(candidates); + + const stats = engine.getStats(); + expect(stats.documentsIndexed).toBeLessThanOrEqual(500); // Desktop limit + }); + + it("should handle missing files gracefully", async () => { + mockApp.vault.getAbstractFileByPath = jest.fn((path) => { + if (path === "missing.md") return null; + const file = new (TFile as any)(path); + Object.setPrototypeOf(file, TFile.prototype); + return file; + }); + + const candidates = ["note1.md", "missing.md", "note2.md"]; + const indexed = await engine.buildFromCandidates(candidates); + + expect(indexed).toBe(2); // Should skip missing file + }); + + it("should clear previous index before building", async () => { + await engine.buildFromCandidates(["note1.md"]); + let stats = engine.getStats(); + expect(stats.documentsIndexed).toBe(1); + + await engine.buildFromCandidates(["note2.md", "note3.md"]); + stats = engine.getStats(); + expect(stats.documentsIndexed).toBe(2); // Should have cleared note1 + }); + + it("should skip unsafe vault paths", async () => { + // Mock vault to return files for any safe path + mockApp.vault.getAbstractFileByPath = jest.fn((path) => { + if (path === "note1.md") { + const file = new (TFile as any)(path); + Object.setPrototypeOf(file, TFile.prototype); + return file; + } + return null; + }); + + const candidates = ["../evil.md", "/abs.md", "note1.md"]; + const indexed = await engine.buildFromCandidates(candidates); + expect(indexed).toBe(1); + }); + }); + + describe("search", () => { + beforeEach(async () => { + await engine.buildFromCandidates(["note1.md", "note2.md", "note3.md"]); + }); + + it("should search indexed documents", () => { + const results = engine.search(["typescript"], 10); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].engine).toBe("fulltext"); + }); + + it("should handle multiple query variants", () => { + const results = engine.search(["typescript", "javascript"], 10); + + expect(results.length).toBeGreaterThan(0); + }); + + it("should return empty array for no matches", () => { + const results = engine.search(["nonexistentterm"], 10); + + expect(results).toEqual([]); + }); + + it("should respect limit parameter", () => { + const results = engine.search(["typescript"], 1); + + expect(results.length).toBeLessThanOrEqual(1); + }); + + it("should handle empty query array", () => { + const results = engine.search([], 10); + + expect(results).toEqual([]); + }); + }); + + describe("search scoring", () => { + beforeEach(async () => { + // Create more specific test data for scoring tests + const scoringMockCache: Record = { + "Piano Lessons/Lesson 1.md": { + headings: [{ heading: "Piano Basics" }], + frontmatter: { title: "Piano Lesson 1" }, + }, + "Piano Lessons/Lesson 2.md": { + headings: [{ heading: "Piano Scales" }], + frontmatter: { title: "Piano Lesson 2" }, + }, + "daily/2024-01-01.md": { + headings: [], + frontmatter: { title: "Daily Note" }, + }, + "projects/music.md": { + headings: [{ heading: "Music Theory" }], + frontmatter: { title: "Music Project", tags: ["piano", "music"] }, + }, + }; + + mockApp.metadataCache.getFileCache = jest.fn((file: any) => scoringMockCache[file.path]); + mockApp.vault.cachedRead = jest.fn((file) => { + const contents: Record = { + "Piano Lessons/Lesson 1.md": "Learning piano fundamentals and basic notes", + "Piano Lessons/Lesson 2.md": "Advanced piano techniques and chord progressions", + "daily/2024-01-01.md": "Today I practiced piano for 30 minutes", + "projects/music.md": "Piano music theory and composition notes", + }; + return Promise.resolve(contents[file.path] || ""); + }); + + await engine.buildFromCandidates([ + "Piano Lessons/Lesson 1.md", + "Piano Lessons/Lesson 2.md", + "daily/2024-01-01.md", + "projects/music.md", + ]); + }); + + it("should apply field weighting correctly", () => { + const results = engine.search(["piano"], 10); + + // Title matches should score higher than body matches + const titleMatch = results.find((r) => r.id.includes("Lesson")); + const bodyMatch = results.find((r) => r.id.includes("daily")); + + if (titleMatch && bodyMatch) { + expect(titleMatch.score).toBeGreaterThan(bodyMatch.score); + } + }); + + it("should boost multi-field matches", () => { + const results = engine.search(["piano"], 10); + + // The music.md file has "piano" in tags and body, should get multi-field bonus + const multiFieldMatch = results.find((r) => r.id === "projects/music.md"); + expect(multiFieldMatch).toBeDefined(); + + // Should be ranked relatively high due to multi-field bonus + const index = results.findIndex((r) => r.id === "projects/music.md"); + expect(index).toBeLessThan(3); // Should be in top 3 + }); + + it("should score path matches with proper weight", () => { + const results = engine.search(["piano lessons"], 10); + + // Files in "Piano Lessons" folder should match on path field + const lessonFiles = results.filter((r) => r.id.includes("Piano Lessons")); + expect(lessonFiles.length).toBe(2); + + // Both lesson files should be ranked high + const lesson1Index = results.findIndex((r) => r.id.includes("Lesson 1")); + const lesson2Index = results.findIndex((r) => r.id.includes("Lesson 2")); + + expect(lesson1Index).toBeLessThan(3); + expect(lesson2Index).toBeLessThan(3); + }); + + it("should handle position-based scoring", () => { + const results = engine.search(["piano"], 10); + + // All results should have decreasing scores + for (let i = 1; i < results.length; i++) { + expect(results[i].score).toBeLessThanOrEqual(results[i - 1].score); + } + }); + }); + + describe("getFieldWeight", () => { + it("should return correct weights for known fields", () => { + const getFieldWeight = (engine as any).getFieldWeight.bind(engine); + + expect(getFieldWeight("title")).toBe(3); + expect(getFieldWeight("path")).toBe(2.5); + expect(getFieldWeight("headings")).toBe(2); + expect(getFieldWeight("tags")).toBe(2); + expect(getFieldWeight("props")).toBe(2); + expect(getFieldWeight("links")).toBe(2); + expect(getFieldWeight("body")).toBe(1); + }); + + it("should return default weight for unknown fields", () => { + const getFieldWeight = (engine as any).getFieldWeight.bind(engine); + expect(getFieldWeight("unknown")).toBe(1); + }); + }); + + describe("clear", () => { + it("should reset index and memory", async () => { + await engine.buildFromCandidates(["note1.md", "note2.md"]); + + let stats = engine.getStats(); + expect(stats.documentsIndexed).toBe(2); + expect(stats.memoryUsed).toBeGreaterThan(0); + + engine.clear(); + + stats = engine.getStats(); + expect(stats.documentsIndexed).toBe(0); + expect(stats.memoryUsed).toBe(0); + }); + }); + + describe("getStats", () => { + it("should return correct statistics", async () => { + const stats1 = engine.getStats(); + expect(stats1.documentsIndexed).toBe(0); + expect(stats1.memoryUsed).toBe(0); + expect(stats1.memoryPercent).toBe(0); + + await engine.buildFromCandidates(["note1.md"]); + + const stats2 = engine.getStats(); + expect(stats2.documentsIndexed).toBe(1); + expect(stats2.memoryUsed).toBeGreaterThan(0); + expect(stats2.memoryPercent).toBeGreaterThanOrEqual(0); + expect(stats2.memoryPercent).toBeLessThanOrEqual(100); + }); + }); + + describe("frontmatter property indexing", () => { + beforeEach(() => { + // Add test notes with various frontmatter properties + const propsCache: Record = { + "author-test.md": { + headings: [], + frontmatter: { + author: "John Doe", + date: "2024-01-01", + status: "draft", + }, + }, + "project-test.md": { + headings: [], + frontmatter: { + project: "Machine Learning", + priority: 1, + tags: ["ai", "research"], + nested: { ignore: "this" }, // Should be ignored + }, + }, + "array-test.md": { + headings: [], + frontmatter: { + keywords: ["typescript", "react", "testing"], + authors: ["Alice", "Bob"], + numbers: [100, 200, 300], + }, + }, + "edge-cases.md": { + headings: [], + frontmatter: { + published: true, + draft: false, + date: new Date("2024-01-15"), + nullValue: null, + emptyString: " ", + nestedArray: [["should", "skip"], "but this works"], + }, + }, + }; + + // Update mock cache + mockApp.metadataCache.getFileCache = jest.fn((file: TFile) => { + return propsCache[file.path] || { headings: [], frontmatter: {} }; + }); + + // Update vault content + mockApp.vault.cachedRead = jest.fn((file: TFile) => { + const contents: Record = { + "author-test.md": "This is a draft document", + "project-test.md": "Machine learning research content", + "array-test.md": "Testing arrays in frontmatter", + "edge-cases.md": "Testing edge cases", + }; + return Promise.resolve(contents[file.path] || ""); + }); + }); + + it("should index string property values", async () => { + await engine.buildFromCandidates(["author-test.md"]); + + // Should find by author name + const results = engine.search(["John Doe"], 10); + expect(results.length).toBeGreaterThan(0); + expect(results[0].id).toBe("author-test.md"); + }); + + it("should index number property values", async () => { + await engine.buildFromCandidates(["project-test.md"]); + + // Should find by priority number (converted to string) + const results = engine.search(["1"], 10); + expect(results.some((r) => r.id === "project-test.md")).toBe(true); + }); + + it("should index array property values", async () => { + await engine.buildFromCandidates(["array-test.md"]); + + // Should find by array elements + const results1 = engine.search(["typescript"], 10); + expect(results1.some((r) => r.id === "array-test.md")).toBe(true); + + const results2 = engine.search(["Alice"], 10); + expect(results2.some((r) => r.id === "array-test.md")).toBe(true); + + // Should find by number in array (converted to string) + const results3 = engine.search(["300"], 10); + expect(results3.some((r) => r.id === "array-test.md")).toBe(true); + }); + + it("should NOT index nested objects", async () => { + await engine.buildFromCandidates(["project-test.md"]); + + // Should NOT find by nested object value + const results = engine.search(["ignore"], 10); + expect(results.some((r) => r.id === "project-test.md")).toBe(false); + }); + + it("should NOT index property keys", async () => { + await engine.buildFromCandidates(["author-test.md"]); + + // Should NOT find by property key alone + // Use a unique property key that doesn't appear in values or body + const results = engine.search(["status"], 10); + + // "status" key should not be indexed, but "draft" value should be + // So searching for "status" should not find the document + // (unless "status" appears in the body, which it doesn't in our test) + expect(results.some((r) => r.id === "author-test.md")).toBe(false); + + // But searching for the value "draft" should find it + const valueResults = engine.search(["draft"], 10); + expect(valueResults.some((r) => r.id === "author-test.md")).toBe(true); + }); + + it("should index boolean values", async () => { + await engine.buildFromCandidates(["edge-cases.md"]); + + // Should find by boolean values converted to strings + const trueResults = engine.search(["true"], 10); + expect(trueResults.some((r) => r.id === "edge-cases.md")).toBe(true); + + const falseResults = engine.search(["false"], 10); + expect(falseResults.some((r) => r.id === "edge-cases.md")).toBe(true); + }); + + it("should downweight boolean/numeric tokens in props field", async () => { + await engine.buildFromCandidates(["edge-cases.md", "project-test.md"]); + + const boolResults = engine.search(["true"], 10); + const numResults = engine.search(["1"], 10); + + expect(boolResults.length).toBeGreaterThan(0); + expect(numResults.length).toBeGreaterThan(0); + + const boolTop = boolResults[0]; + const numTop = numResults[0]; + expect(boolTop.score).toBeLessThanOrEqual(0.5); + expect(numTop.score).toBeLessThanOrEqual(0.5); + }); + + it("should index Date objects as ISO strings", async () => { + // Note: Our mock frontmatter has a Date object + // In real Obsidian, dates in frontmatter are usually strings + // But we support Date objects if they're present + await engine.buildFromCandidates(["edge-cases.md"]); + + // The Date object should be converted to ISO string + // Should find by searching for the ISO format + const results = engine.search(["2024-01-15T00:00:00"], 10); + expect(results.some((r) => r.id === "edge-cases.md")).toBe(true); + }); + + it("should skip null values and empty strings", async () => { + await engine.buildFromCandidates(["edge-cases.md"]); + + // Should NOT find by "null" string + const nullResults = engine.search(["null"], 10); + expect(nullResults.some((r) => r.id === "edge-cases.md")).toBe(false); + + // Empty strings should also be skipped (no way to test directly) + }); + + it("should handle nested arrays properly", async () => { + await engine.buildFromCandidates(["edge-cases.md"]); + + // Should find the string in the nested array + const results = engine.search(["but this works"], 10); + expect(results.some((r) => r.id === "edge-cases.md")).toBe(true); + + // Should NOT find the nested array elements + const nestedResults = engine.search(["should"], 10); + expect(nestedResults.some((r) => r.id === "edge-cases.md")).toBe(false); + }); + + it("should handle circular frontmatter safely", async () => { + const a: any = { name: "A" }; + const b: any = { name: "B" }; + a.ref = b; + b.ref = a; // circular + + const propsCache: Record = { + "circular.md": { + headings: [], + frontmatter: a, + }, + }; + + mockApp.metadataCache.getFileCache = jest.fn((file: TFile) => { + return propsCache[file.path] || { headings: [], frontmatter: {} }; + }); + mockApp.vault.cachedRead = jest.fn((file: TFile) => Promise.resolve("body")); + + await expect(engine.buildFromCandidates(["circular.md"])).resolves.toBe(1); + }); + }); +}); diff --git a/src/search/v3/engines/FullTextEngine.ts b/src/search/v3/engines/FullTextEngine.ts new file mode 100644 index 00000000..11241c0b --- /dev/null +++ b/src/search/v3/engines/FullTextEngine.ts @@ -0,0 +1,496 @@ +import { logInfo } from "@/logger"; +import FlexSearch from "flexsearch"; +import { App, TFile, getAllTags } from "obsidian"; +import { NoteDoc, NoteIdRank } from "../interfaces"; +import { MemoryManager } from "../utils/MemoryManager"; +import { VaultPathValidator } from "../utils/VaultPathValidator"; + +/** + * Full-text search engine using ephemeral FlexSearch index built per-query + */ +export class FullTextEngine { + private index: any; // FlexSearch.Document + private memoryManager: MemoryManager; + private indexedDocs = new Set(); + + // Security: Maximum content size to prevent memory exhaustion (10MB) + private static readonly MAX_CONTENT_SIZE = 10 * 1024 * 1024; + + constructor(private app: App) { + this.memoryManager = new MemoryManager(); + this.index = this.createIndex(); + } + + /** + * Create a new FlexSearch index with multilingual tokenization + */ + private createIndex(): any { + const Document = (FlexSearch as any).Document; + const tokenizer = this.tokenizeMixed.bind(this); + return new Document({ + encode: false, + tokenize: tokenizer, + cache: false, + document: { + id: "id", + index: [ + { field: "title", tokenize: tokenizer, weight: 3 }, + { field: "path", tokenize: tokenizer, weight: 2.5 }, // Path components are highly relevant + { field: "headings", tokenize: tokenizer, weight: 2 }, + { field: "tags", tokenize: tokenizer, weight: 2 }, + { field: "props", tokenize: tokenizer, weight: 2 }, // Frontmatter property values + { field: "links", tokenize: tokenizer, weight: 2 }, + { field: "body", tokenize: tokenizer, weight: 1 }, + ], + store: false, // Don't store docs to save memory + }, + }); + } + + /** + * Hybrid tokenizer for ASCII words + CJK bigrams + * @param str - String to tokenize + * @returns Array of tokens + */ + private tokenizeMixed(str: string): string[] { + if (!str) return []; + + const tokens: string[] = []; + + // ASCII words (including alphanumeric and underscores) + const asciiWords = str.toLowerCase().match(/[a-z0-9_]+/g) || []; + tokens.push(...asciiWords); + + // CJK pattern for Chinese, Japanese, Korean characters + const cjkPattern = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]+/g; + const cjkMatches = str.match(cjkPattern) || []; + + // Generate bigrams for CJK text + for (const match of cjkMatches) { + // Add single character for length 1 + if (match.length === 1) { + tokens.push(match); + } + // Generate bigrams + for (let i = 0; i < match.length - 1; i++) { + tokens.push(match.slice(i, i + 2)); + } + } + + return tokens; + } + + /** + * Build ephemeral index from candidate note paths + * @param candidatePaths - Array of note paths to index + * @returns Number of documents indexed + */ + async buildFromCandidates(candidatePaths: string[]): Promise { + this.clear(); + + let indexed = 0; + const limitedCandidates = candidatePaths.slice(0, this.memoryManager.getCandidateLimit()); + + for (const path of limitedCandidates) { + // Guard against path traversal or invalid inputs + if (!VaultPathValidator.isValid(path)) { + logInfo(`FullText: Skipping unsafe path ${path}`); + continue; + } + if (!this.memoryManager.canAddContent(1000)) { + // Rough estimate + logInfo( + `FullText: Memory limit reached (${indexed} docs, ${this.memoryManager.getUsagePercent()}% used)` + ); + break; + } + + const file = this.app.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + const doc = await this.createNoteDoc(file); + if (doc) { + const bodySize = MemoryManager.getByteSize(doc.body); + + if (this.memoryManager.canAddContent(bodySize)) { + // Extract basenames from links for searchability (optimized) + const linkBasenames = [...doc.linksOut, ...doc.linksIn] + .map((path) => { + const lastSlash = path.lastIndexOf("/"); + const basename = lastSlash >= 0 ? path.slice(lastSlash + 1) : path; + return basename.endsWith(".md") ? basename.slice(0, -3) : basename; + }) + .join(" "); + + // Extract path components for searchability + // "Piano Lessons/Lesson 2.md" → "Piano Lessons Lesson 2" + const pathComponents = doc.id.replace(/\.md$/, "").split("/").join(" "); + + // Extract frontmatter property values for searchability + const propValues = this.extractPropertyValues(doc.props); + + this.index.add({ + id: doc.id, + title: doc.title, + path: pathComponents, // Index folder and file names + headings: doc.headings.join(" "), + tags: doc.tags.join(" "), + props: propValues.join(" "), // Index frontmatter property values + links: linkBasenames, // Index link basenames for search + body: doc.body, + }); + + this.memoryManager.addBytes(bodySize); + this.indexedDocs.add(doc.id); + indexed++; + } + } + } + } + + logInfo( + `FullText: Indexed ${indexed}/${candidatePaths.length} docs (${this.memoryManager.getUsagePercent()}% memory, ${this.memoryManager.getBytesUsed()} bytes)` + ); + return indexed; + } + + /** + * Create NoteDoc from TFile using metadata cache + * @param file - Obsidian TFile + * @returns NoteDoc or null if file can't be processed + */ + private async createNoteDoc(file: TFile): Promise { + try { + const cache = this.app.metadataCache.getFileCache(file); + let content = await this.app.vault.cachedRead(file); + + // Security: Limit content size to prevent memory exhaustion + if (content.length > FullTextEngine.MAX_CONTENT_SIZE) { + logInfo( + `FullText: File ${file.path} exceeds size limit (${content.length} bytes), truncating` + ); + content = content.substring(0, FullTextEngine.MAX_CONTENT_SIZE); + } + + // Extract metadata + const allTags = cache ? (getAllTags(cache) ?? []) : []; + const headings = cache?.headings?.map((h) => h.heading) ?? []; + const props = cache?.frontmatter ?? {}; + + // Get links (using full paths for accuracy) + const outgoing = this.app.metadataCache.resolvedLinks[file.path] ?? {}; + const backlinks = this.app.metadataCache.getBacklinksForFile(file)?.data ?? {}; + + // Store full paths for link information + const linksOut = Object.keys(outgoing); + const linksIn = Object.keys(backlinks); + + // Get title from frontmatter or filename + const frontmatter = props as Record; + const title = frontmatter?.title || frontmatter?.name || file.basename; + + return { + id: file.path, + title, + headings, + tags: allTags, + props, + linksOut, + linksIn, + body: content, + }; + } catch (error) { + logInfo(`FullText: Skipped ${file.path}: ${error}`); + return null; + } + } + + /** + * Extract frontmatter property values for search indexing. + * Only indexes primitive values (strings, numbers, booleans) and arrays of primitives. + * Skips objects and null/undefined values. + * + * @param props - Frontmatter properties object + * @returns Array of string values for indexing + */ + private extractPropertyValues(props: Record | undefined): string[] { + const propValues: string[] = []; + if (props && typeof props === "object") { + for (const value of Object.values(props)) { + this.extractPrimitiveValues(value, propValues, 2); + } + } + return propValues; + } + + /** + * Extract primitive values with depth limit to prevent infinite recursion. + * Simpler approach using depth limit instead of circular reference tracking. + * + * @param value - The value to extract from + * @param output - Array to collect extracted string values + * @param maxDepth - Maximum recursion depth + */ + private extractPrimitiveValues(value: unknown, output: string[], maxDepth: number): void { + if (maxDepth <= 0 || value == null) return; + + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed) output.push(trimmed); + } else if (typeof value === "number" || typeof value === "boolean") { + output.push(String(value)); + } else if (value instanceof Date) { + output.push(value.toISOString()); + } else if (Array.isArray(value)) { + // Limit array processing to first 10 items for performance + value.slice(0, 10).forEach((item) => { + if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") { + const str = typeof item === "string" ? item.trim() : String(item); + if (str) output.push(str); + } + }); + } + // Skip objects entirely - simpler and safer + } + + /** + * Search the ephemeral index with multiple query variants + * + * Scoring happens in three stages: + * 1. Score Accumulation: Documents matching multiple queries get additive scores + * 2. Multi-field Bonus: Documents matching in multiple fields (title, tags, etc.) get boosted + * 3. Folder Boost: Documents in folders with multiple matches get boosted + * + * @param queries - Array of query strings + * @param limit - Maximum results per query + * @returns Array of NoteIdRank results + */ + search(queries: string[], limit: number = 30, lowWeightTerms: string[] = []): NoteIdRank[] { + const scoreMap = new Map< + string, + { score: number; fieldMatches: Set; queriesMatched: Set } + >(); + + // Only log if we have many queries or debug mode + if (queries.length > 5) { + logInfo(`FullText: Searching with ${queries.length} queries`); + } + + // Build a lookup for low-weight terms + const lowWeightLookup = new Set(lowWeightTerms.map((t) => t.toLowerCase())); + + // Process each query + for (const query of queries) { + try { + const results = this.index.search(query, { limit: limit * 2, enrich: true }); + + // Process results with improved scoring + if (Array.isArray(results)) { + let queryMatchCount = 0; + for (const fieldResult of results) { + if (!fieldResult?.result || !fieldResult?.field) continue; + + const fieldName = fieldResult.field; + const fieldWeight = this.getFieldWeight(fieldName); + const isPhrase = query.trim().includes(" "); + const baseQueryWeight = isPhrase ? 1.2 : 0.85; + // Downweight LLM-provided salient terms relative to original queries + const isLowWeightTerm = lowWeightLookup.has(query.toLowerCase()); + const lowWeightFactor = isLowWeightTerm ? 0.6 : 1.0; + + // Noise reduction for property values: heavy downweight for boolean/numeric tokens + const isBooleanLiteral = /^(true|false|yes|no|on|off)$/i.test(query.trim()); + const isNumericLiteral = /^\d+(?:[.,]\d+)?$/.test(query.trim()); + const propNoiseFactor = + fieldName === "props" && (isBooleanLiteral || isNumericLiteral) ? 0.1 : 1.0; + + const queryWeight = baseQueryWeight * lowWeightFactor * propNoiseFactor; + + for (let idx = 0; idx < fieldResult.result.length; idx++) { + const item = fieldResult.result[idx]; + const id = typeof item === "string" ? item : item?.id; + if (id) { + queryMatchCount++; + // Calculate position-based score with field weighting + const positionScore = 1 / (idx + 1); + const fieldScore = positionScore * fieldWeight * queryWeight; + + const existing = scoreMap.get(id) || { + score: 0, + fieldMatches: new Set(), + queriesMatched: new Set(), + }; + // Accumulate scores from different queries (don't use Math.max) + // This way, documents matching multiple query terms get higher scores + const updated: { + score: number; + fieldMatches: Set; + queriesMatched: Set; + } = { + score: existing.score + fieldScore, + fieldMatches: new Set(existing.fieldMatches).add(fieldName), + queriesMatched: new Set(existing.queriesMatched).add(query), + }; + scoreMap.set(id, updated); + } + } + } + // Only log significant match counts + if (queryMatchCount > 10) { + logInfo(` Query "${query}": ${queryMatchCount} matches found`); + } + } + } catch (error) { + logInfo(`FullText: Search failed for "${query}": ${error}`); + } + } + + // Apply bonus for multi-field matches + // Example: Query "OAuth NextJS" + // - Doc A matches "OAuth" in title only → multiFieldBonus = 1.0 (no bonus) + // - Doc B matches "OAuth" in title AND "NextJS" in tags → multiFieldBonus = 1.2 (20% boost) + // - Doc C matches in title, tags, AND body → multiFieldBonus = 1.4 (40% boost) + const finalResults: NoteIdRank[] = []; + for (const [id, data] of scoreMap.entries()) { + // Boost score if matched in multiple fields + // Each additional field beyond the first adds 20% to the score + const multiFieldBonus = 1 + (data.fieldMatches.size - 1) * 0.2; + const coverageBonus = 1 + Math.max(0, data.queriesMatched.size - 1) * 0.1; + let finalScore = data.score * multiFieldBonus * coverageBonus; + + // Cheap phrase-in-path/title bonus + const pathIndexString = id.replace(/\.md$/, "").split("/").join(" ").toLowerCase(); + for (const q of data.queriesMatched) { + if (q.includes(" ")) { + const ql = q.toLowerCase(); + if (pathIndexString.includes(ql)) { + finalScore *= 1.5; + break; + } + } + } + + finalResults.push({ id, score: finalScore, engine: "fulltext" }); + } + + // Apply folder-based boosting before sorting + this.applyFolderBoost(finalResults); + + // Sort again after boosting and return top results + finalResults.sort((a, b) => b.score - a.score); + return finalResults.slice(0, limit); + } + + /** + * Apply folder-based boosting to improve ranking of related notes. + * + * IMPORTANT: Only boosts documents that are ALREADY in the search results. + * Does NOT add new documents from the same folder. + * + * How it works: + * 1. Counts how many results are in each folder + * 2. Boosts ALL results in folders with 2+ matches + * 3. Boost formula: score * (1 + log2(count + 1)) + * + * Example scenarios: + * + * Case A: dirA/dirB/docA.md and dirA/dirB/docB.md (same folder) + * - Both docs are in "dirA/dirB" folder + * - Folder count = 2 + * - Both get boost: score * 1.58 (~58% boost) + * + * Case B: dirA/dirB/docA.md and dirA/docC.md (different folders) + * - docA is in "dirA/dirB" folder (count = 1, no boost) + * - docC is in "dirA" folder (count = 1, no boost) + * - Neither gets boosted because each folder only has 1 match + * + * Real example with query "OAuth NextJS": + * - Results: nextjs/auth.md, nextjs/config.md, nextjs/jwt.md, tutorials/oauth.md + * - "nextjs" folder has 3 docs → each gets 2x boost + * - "tutorials" folder has 1 doc → no boost + * + * @param results - Array of search results to apply boosting to (modified in place) + */ + private applyFolderBoost(results: NoteIdRank[]): void { + // Count notes per folder + const folderCounts = new Map(); + + for (const result of results) { + const lastSlash = result.id.lastIndexOf("/"); + if (lastSlash > 0) { + const folder = result.id.substring(0, lastSlash); + folderCounts.set(folder, (folderCounts.get(folder) || 0) + 1); + } + } + + // Log folder boost summary + const foldersWithMultiple = Array.from(folderCounts.entries()).filter(([, count]) => count > 1); + if (foldersWithMultiple.length > 0) { + logInfo(`FullText: Boosting ${foldersWithMultiple.length} folders with multiple matches`); + // Log top folders + foldersWithMultiple.slice(0, 3).forEach(([folder, count]) => { + const boostFactor = 1 + Math.log2(count + 1); + logInfo(` ${folder}: ${count} docs (${boostFactor.toFixed(2)}x boost)`); + }); + } + + // Apply boost to notes in folders with multiple matches + for (const result of results) { + const lastSlash = result.id.lastIndexOf("/"); + if (lastSlash > 0) { + const folder = result.id.substring(0, lastSlash); + const count = folderCounts.get(folder) || 1; + + // Boost score based on folder prevalence (more notes in folder = higher boost) + if (count > 1) { + const minScoreThreshold = 0.2; + if (result.score >= minScoreThreshold) { + const rawBoost = 1 + Math.log2(count + 1); + const boostFactor = Math.min(rawBoost, 1.8); + result.score = result.score * boostFactor; + (result as any).folderBoost = boostFactor; + } + } + } + } + } + + /** + * Get field weight for scoring + */ + private getFieldWeight(fieldName: string): number { + const weights: Record = { + title: 3, + path: 2.5, + headings: 2, + tags: 2, + props: 2, + links: 2, + body: 1, + }; + return weights[fieldName] || 1; + } + + /** + * Clear the ephemeral index and reset memory tracking + */ + clear(): void { + this.index = this.createIndex(); + this.indexedDocs.clear(); + this.memoryManager.reset(); + } + + /** + * Get statistics about the current index + */ + getStats(): { + documentsIndexed: number; + memoryUsed: number; + memoryPercent: number; + } { + return { + documentsIndexed: this.indexedDocs.size, + memoryUsed: this.memoryManager.getBytesUsed(), + memoryPercent: this.memoryManager.getUsagePercent(), + }; + } +} diff --git a/src/search/v3/expanders/GraphExpander.test.ts b/src/search/v3/expanders/GraphExpander.test.ts new file mode 100644 index 00000000..08d7cf23 --- /dev/null +++ b/src/search/v3/expanders/GraphExpander.test.ts @@ -0,0 +1,346 @@ +import { App, TFile } from "obsidian"; +import { GraphExpander } from "./GraphExpander"; + +describe("GraphExpander", () => { + let expander: GraphExpander; + let mockApp: App; + + beforeEach(() => { + // Create a mock graph structure for testing + // Graph visualization: + // A <-> B <-> C + // | | | + // v v v + // D <-> E <-> F + // | | + // v v + // G H + + const mockResolvedLinks: Record> = { + "A.md": { "B.md": 1, "D.md": 1 }, + "B.md": { "A.md": 1, "C.md": 1, "E.md": 1 }, + "C.md": { "B.md": 1, "F.md": 1 }, + "D.md": { "A.md": 1, "E.md": 1, "G.md": 1 }, + "E.md": { "B.md": 1, "D.md": 1, "F.md": 1 }, + "F.md": { "C.md": 1, "E.md": 1, "H.md": 1 }, + "G.md": { "D.md": 1 }, + "H.md": { "F.md": 1 }, + // Isolated node + "I.md": {}, + // Self-referencing node + "J.md": { "J.md": 1 }, + }; + + // Create backlinks data structure (inverse of resolved links) + const mockBacklinks: Record> = {}; + for (const [source, targets] of Object.entries(mockResolvedLinks)) { + for (const target of Object.keys(targets)) { + if (!mockBacklinks[target]) { + mockBacklinks[target] = new Set(); + } + mockBacklinks[target].add(source); + } + } + + mockApp = { + metadataCache: { + resolvedLinks: mockResolvedLinks, + getBacklinksForFile: jest.fn((file: TFile) => { + const backlinks = mockBacklinks[file.path]; + if (!backlinks) { + return null; + } + const data: Record = {}; + backlinks.forEach((link) => { + data[link] = { link: 1 }; + }); + return { data }; + }), + }, + vault: { + getAbstractFileByPath: jest.fn((path: string) => { + // Create a mock that passes instanceof TFile check + const file = Object.create(TFile.prototype); + file.path = path; + file.basename = path.replace(".md", ""); + return file; + }), + }, + workspace: { + getActiveFile: jest.fn(() => null), + }, + } as any; + + expander = new GraphExpander(mockApp); + }); + + describe("expandFromNotes - BFS behavior", () => { + it("should return starting notes with 0 hops", async () => { + const result = await expander.expandFromNotes(["A.md"], 0); + expect(result).toEqual(["A.md"]); + }); + + it("should expand 1 hop correctly (direct neighbors only)", async () => { + const result = await expander.expandFromNotes(["A.md"], 1); + const resultSet = new Set(result); + + // A.md links to B.md and D.md + // B.md and D.md link back to A.md (already included) + expect(resultSet.has("A.md")).toBe(true); + expect(resultSet.has("B.md")).toBe(true); + expect(resultSet.has("D.md")).toBe(true); + expect(resultSet.size).toBe(3); + }); + + it("should expand 2 hops correctly (BFS level by level)", async () => { + const result = await expander.expandFromNotes(["A.md"], 2); + const resultSet = new Set(result); + + // Level 0: A + // Level 1: B, D (from A) + // Level 2: C, E, G (from B and D, excluding already visited A) + expect(resultSet.has("A.md")).toBe(true); + expect(resultSet.has("B.md")).toBe(true); + expect(resultSet.has("D.md")).toBe(true); + expect(resultSet.has("C.md")).toBe(true); + expect(resultSet.has("E.md")).toBe(true); + expect(resultSet.has("G.md")).toBe(true); + expect(resultSet.size).toBe(6); + + // Should NOT include F or H (they're 3 hops away) + expect(resultSet.has("F.md")).toBe(false); + expect(resultSet.has("H.md")).toBe(false); + }); + + it("should expand 3 hops to reach entire connected component", async () => { + const result = await expander.expandFromNotes(["A.md"], 3); + const resultSet = new Set(result); + + // Debug: log what we found + console.log("3-hop expansion from A found:", Array.from(resultSet).sort()); + + // H is 4 hops from A: A -> B/D -> C/E -> F -> H + // So with 3 hops we should get A,B,C,D,E,F,G (7 nodes) + expect(resultSet.size).toBe(7); // A through G (H is 4 hops away) + expect(resultSet.has("H.md")).toBe(false); // H is 4 hops from A + expect(resultSet.has("I.md")).toBe(false); // Isolated + }); + + it("should handle multiple starting nodes", async () => { + const result = await expander.expandFromNotes(["A.md", "F.md"], 1); + const resultSet = new Set(result); + + // From A: B, D + // From F: C, E, H + // Total: A, F (starting) + B, D, C, E, H (neighbors) + expect(resultSet.size).toBe(7); + }); + + it("should not revisit nodes (proper visited tracking)", async () => { + // Track which paths are accessed for their links + const accessedPaths: string[] = []; + const originalResolvedLinks = mockApp.metadataCache.resolvedLinks; + + // Create a proxy to track accesses + mockApp.metadataCache.resolvedLinks = new Proxy(originalResolvedLinks, { + get(target, prop) { + if (typeof prop === "string" && prop.endsWith(".md")) { + accessedPaths.push(prop); + } + return target[prop as keyof typeof target]; + }, + }); + + await expander.expandFromNotes(["A.md"], 2); + + // Count how many times each path was accessed + const accessCounts = accessedPaths.reduce( + (acc, path) => { + acc[path] = (acc[path] || 0) + 1; + return acc; + }, + {} as Record + ); + + // Each node should be accessed at most once (BFS property) + Object.values(accessCounts).forEach((count) => { + expect(count).toBe(1); + }); + + // Specifically, A should only be accessed once (in level 0) + expect(accessCounts["A.md"]).toBe(1); + }); + + it("should handle isolated nodes", async () => { + const result = await expander.expandFromNotes(["I.md"], 5); + expect(result).toEqual(["I.md"]); // No expansion possible + }); + + it("should handle self-referencing nodes", async () => { + const result = await expander.expandFromNotes(["J.md"], 1); + expect(result).toEqual(["J.md"]); // Self-links shouldn't cause issues + }); + + it("should terminate early when no new nodes found", async () => { + const result = await expander.expandFromNotes(["G.md"], 10); + const resultSet = new Set(result); + + // G -> D -> A,E -> B -> C -> F -> H (all reachable in 5 hops) + // Should stop early even though we asked for 10 hops + expect(resultSet.size).toBe(8); // All connected nodes + }); + }); + + describe("getCoCitations", () => { + it("should find co-cited notes", async () => { + // Debug: Let's trace exactly what getCoCitations does + // It looks at B and D's outgoing links + console.log("B links to:", Object.keys(mockApp.metadataCache.resolvedLinks["B.md"])); + console.log("D links to:", Object.keys(mockApp.metadataCache.resolvedLinks["D.md"])); + + // For each target, it finds who else links there (via backlinks) + const result = await expander.getCoCitations(["B.md", "D.md"]); + console.log("Co-citations result:", result); + + // Based on our mock: + // B links to: A, C, E + // D links to: A, E, G + // Common targets that both link to: A, E + + // For target E: who has backlinks from E? + // E's backlinks should include B, D, and F + // So F should be found as a co-citation + + // If result is empty, there might be an issue with how backlinks are set up + if (result.length === 0) { + // Let's check if the backlinks are working + const eFile = mockApp.vault.getAbstractFileByPath("E.md"); + if (eFile) { + const eBacklinks = mockApp.metadataCache.getBacklinksForFile(eFile as TFile); + console.log("E's backlinks:", eBacklinks?.data ? Object.keys(eBacklinks.data) : "none"); + } + } + + expect(result).toContain("F.md"); + }); + + it("should exclude input notes from co-citations", async () => { + const result = await expander.getCoCitations(["B.md"]); + + // Should not include B itself + expect(result).not.toContain("B.md"); + }); + + it("should return empty for notes without outgoing links", async () => { + // G links to D, H links to F + // So this test needs different inputs - use I (isolated) instead + const result = await expander.getCoCitations(["I.md"]); + expect(result).toEqual([]); + }); + }); + + describe("getActiveNoteNeighbors", () => { + it("should return empty when no active file", async () => { + const result = await expander.getActiveNoteNeighbors(); + expect(result).toEqual([]); + }); + + it("should expand from active file", async () => { + mockApp.workspace.getActiveFile = jest.fn( + () => + ({ + path: "C.md", + basename: "C", + }) as TFile + ); + + const result = await expander.getActiveNoteNeighbors(1); + const resultSet = new Set(result); + + // C links to B and F + expect(resultSet.has("C.md")).toBe(true); + expect(resultSet.has("B.md")).toBe(true); + expect(resultSet.has("F.md")).toBe(true); + expect(resultSet.size).toBe(3); + }); + }); + + describe("expandCandidates - integration", () => { + it("should combine all three strategies", async () => { + mockApp.workspace.getActiveFile = jest.fn( + () => + ({ + path: "H.md", + basename: "H", + }) as TFile + ); + + const grepHits = ["A.md", "B.md"]; + const result = await expander.expandCandidates( + grepHits, + mockApp.workspace.getActiveFile(), + 1 + ); + const resultSet = new Set(result); + + // From grep expansion: A, B, C, D, E + // From active (H): F, H + // From co-citations: F (A and B both connect to nodes that F connects to) + + expect(resultSet.has("A.md")).toBe(true); + expect(resultSet.has("B.md")).toBe(true); + expect(resultSet.has("C.md")).toBe(true); + expect(resultSet.has("D.md")).toBe(true); + expect(resultSet.has("E.md")).toBe(true); + expect(resultSet.has("F.md")).toBe(true); + expect(resultSet.has("H.md")).toBe(true); + }); + + it("should skip co-citations for very large result sets (>=50)", async () => { + const grepHits = Array.from({ length: 60 }, (_, i) => `note${i}.md`); + + const getCoCitationsSpy = jest.spyOn(expander, "getCoCitations"); + + await expander.expandCandidates(grepHits, null, 1); + + expect(getCoCitationsSpy).not.toHaveBeenCalled(); + }); + + it("should allow +1 hop when grep hits are small (<5)", async () => { + const grepHits = ["A.md", "B.md", "C.md", "D.md"]; // 4 hits + const expandSpy = jest.spyOn(expander, "expandFromNotes"); + await expander.expandCandidates(grepHits, null, 1); + expect(expandSpy).toHaveBeenCalledWith(grepHits, 2); + }); + }); + + describe("Performance characteristics", () => { + it("should handle large graphs efficiently", async () => { + // Create a larger graph for performance testing + const largeGraph: Record> = {}; + const nodeCount = 100; + + // Create a chain of 100 nodes + for (let i = 0; i < nodeCount; i++) { + const current = `node${i}.md`; + largeGraph[current] = {}; + + // Link to previous and next + if (i > 0) largeGraph[current][`node${i - 1}.md`] = 1; + if (i < nodeCount - 1) largeGraph[current][`node${i + 1}.md`] = 1; + } + + mockApp.metadataCache.resolvedLinks = largeGraph; + + const startTime = Date.now(); + const result = await expander.expandFromNotes(["node50.md"], 10); + const duration = Date.now() - startTime; + + // Should complete quickly even with many nodes + expect(duration).toBeLessThan(100); // 100ms max + + // Should find 21 nodes (10 in each direction + starting node) + expect(result.length).toBe(21); + }); + }); +}); diff --git a/src/search/v3/expanders/GraphExpander.ts b/src/search/v3/expanders/GraphExpander.ts new file mode 100644 index 00000000..25c7375a --- /dev/null +++ b/src/search/v3/expanders/GraphExpander.ts @@ -0,0 +1,203 @@ +import { logInfo } from "@/logger"; +import { App, TFile } from "obsidian"; + +/** + * GraphExpander increases search recall by finding related notes through link analysis. + * + * It uses three strategies to expand the initial search results: + * 1. **Link Traversal**: Follows outgoing links and backlinks from found notes + * 2. **Active Context**: Includes neighbors of the currently open note + * 3. **Co-citation**: Finds notes that link to the same targets (similar topics) + * + * This helps discover relevant notes that don't contain the search terms directly + * but are conceptually related through the knowledge graph structure. + */ +export class GraphExpander { + constructor(private app: App) {} + + /** + * Performs breadth-first search (BFS) traversal of the link graph. + * + * Algorithm: + * - Level 0: Starting notes + * - Level 1: Direct links from level 0 (not yet visited) + * - Level 2: Direct links from level 1 (not yet visited) + * - Etc... + * + * Each note is visited exactly once. Early termination if no new notes found. + * + * @param notePaths - Starting note paths to expand from + * @param hops - Maximum BFS depth (1 = direct links only, 2 = links of links, etc.) + * @returns All discovered note paths including the starting notes + * + * @example + * // Starting from ["A.md"] with 2 hops: + * // Level 0: ["A.md"] + * // Level 1: ["B.md", "C.md"] (A's neighbors) + * // Level 2: ["D.md", "E.md"] (B and C's neighbors, excluding already visited) + * // Returns: ["A.md", "B.md", "C.md", "D.md", "E.md"] + */ + async expandFromNotes(notePaths: string[], hops: number = 1): Promise { + const visited = new Set(notePaths); + let currentLevel = new Set(notePaths); + + for (let hop = 0; hop < hops; hop++) { + const nextLevel = new Set(); + + // Only expand nodes from the current level (true BFS) + for (const path of currentLevel) { + // Get outgoing links + const outgoing = this.app.metadataCache.resolvedLinks[path] || {}; + for (const link of Object.keys(outgoing)) { + if (!visited.has(link)) { + visited.add(link); + nextLevel.add(link); + } + } + + // Get backlinks + const file = this.app.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + const backlinks = this.app.metadataCache.getBacklinksForFile(file); + if (backlinks?.data) { + for (const link of Object.keys(backlinks.data)) { + if (!visited.has(link)) { + visited.add(link); + nextLevel.add(link); + } + } + } + } + } + + // Only log for first hop or if debugging + // Log details are captured in expandCandidates summary + + // Move to next level for next iteration + currentLevel = nextLevel; + + // Stop early if no new nodes were discovered + if (nextLevel.size === 0) { + break; + } + } + + return Array.from(visited); + } + + /** + * Gets all notes connected to the currently active/open note. + * Useful for adding context about what the user is currently working on. + * + * @param hops - Link distance from active note (default: 1) + * @returns Connected note paths, or empty if no note is active + */ + async getActiveNoteNeighbors(hops: number = 1): Promise { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) { + return []; + } + + return this.expandFromNotes([activeFile.path], hops); + } + + /** + * Finds co-cited notes - notes that share common outgoing links. + * + * If Note A links to X,Y,Z and Note B also links to X,Y,Z, + * they're likely about similar topics even if they don't link to each other. + * + * @param notePaths - Notes to find co-citations for + * @returns Notes that link to the same targets (excluding input notes) + */ + async getCoCitations(notePaths: string[]): Promise { + const coCited = new Set(); + + for (const path of notePaths) { + const outgoing = this.app.metadataCache.resolvedLinks[path] || {}; + + // For each outgoing link, find other notes that also link to it + for (const target of Object.keys(outgoing)) { + const targetFile = this.app.vault.getAbstractFileByPath(target); + if (targetFile instanceof TFile) { + const backlinks = this.app.metadataCache.getBacklinksForFile(targetFile); + if (backlinks?.data) { + Object.keys(backlinks.data).forEach((link) => { + if (!notePaths.includes(link)) { + coCited.add(link); + } + }); + } + } + } + } + + return Array.from(coCited); + } + + /** + * Main expansion method combining all three strategies. + * + * 1. Expands from grep hits via links (primary expansion) + * 2. Adds active note context (user's current focus) + * 3. Adds co-citations for small result sets (topic similarity) + * + * @param grepHits - Initial notes found by text search + * @param activeFile - Currently open note (for context) + * @param hops - Link traversal depth (default: 1) + * @returns Union of all discovered notes + */ + async expandCandidates( + grepHits: string[], + activeFile: TFile | null, + hops: number = 1 + ): Promise { + // Minimal guardrails: + // - If grep hits are small (<5), allow +1 hop (cap at 3) + // - If grep hits are large (>=50), force 1 hop and disable co-citations + const smallSet = grepHits.length > 0 && grepHits.length < 5; + const veryLargeSet = grepHits.length >= 50; + const effectiveHops = smallSet ? Math.min(hops + 1, 3) : veryLargeSet ? 1 : hops; + + const allCandidates = new Set(grepHits); + let expandedFromGrep: string[] = []; + let activeNeighbors: string[] = []; + let coCitations: string[] = []; + + // Expand from grep hits + if (grepHits.length > 0) { + expandedFromGrep = await this.expandFromNotes(grepHits, effectiveHops); + expandedFromGrep.forEach((path) => allCandidates.add(path)); + } + + // Add active note neighbors + if (activeFile) { + activeNeighbors = await this.expandFromNotes([activeFile.path], effectiveHops); + activeNeighbors.forEach((path) => allCandidates.add(path)); + } + + // Add co-citations for better recall (only for small result sets to avoid explosion) + // Co-citation finds notes about similar topics based on shared outgoing links + if (!veryLargeSet && grepHits.length > 0 && grepHits.length < 50) { + coCitations = await this.getCoCitations(grepHits); + coCitations.forEach((path) => allCandidates.add(path)); + } + + const expansionSummary = []; + if (expandedFromGrep.length > grepHits.length) { + expansionSummary.push(`grep: ${grepHits.length}→${expandedFromGrep.length}`); + } + if (activeFile && activeNeighbors.length > 1) { + expansionSummary.push(`active: ${activeNeighbors.length}`); + } + if (coCitations.length > 0) { + expansionSummary.push(`co-cited: ${coCitations.length}`); + } + + if (expansionSummary.length > 0) { + logInfo(` Graph expansion: ${expansionSummary.join(", ")} → ${allCandidates.size} total`); + } + + return Array.from(allCandidates); + } +} diff --git a/src/search/v3/index.ts b/src/search/v3/index.ts new file mode 100644 index 00000000..0c40491f --- /dev/null +++ b/src/search/v3/index.ts @@ -0,0 +1,16 @@ +// Main exports for v3 search implementation +export { SearchCore } from "./SearchCore"; +export { QueryExpander, type ExpandedQuery } from "./QueryExpander"; + +// Core interfaces +export type { NoteDoc, NoteIdRank, SearchOptions } from "./interfaces"; + +// Individual components (for testing/advanced use) +export { GrepScanner } from "./scanners/GrepScanner"; +export { GraphExpander } from "./expanders/GraphExpander"; +export { FullTextEngine } from "./engines/FullTextEngine"; +export { SemanticReranker } from "./rerankers/SemanticReranker"; + +// Utilities +export { weightedRRF, simpleRRF, type RRFConfig } from "./utils/RRF"; +export { MemoryManager } from "./utils/MemoryManager"; diff --git a/src/search/v3/interfaces.ts b/src/search/v3/interfaces.ts new file mode 100644 index 00000000..cb562570 --- /dev/null +++ b/src/search/v3/interfaces.ts @@ -0,0 +1,33 @@ +/** + * Core document structure for search indexing + */ +export interface NoteDoc { + id: string; // vault-relative path + title: string; // filename or front-matter title + headings: string[]; // H1..H6 plain text (indexed) + tags: string[]; // inline + frontmatter via getAllTags(cache) (indexed) + props: Record; // frontmatter key/values (values indexed, keys ignored) + linksOut: string[]; // outgoing link full paths (extracted and indexed as basenames) + linksIn: string[]; // backlink full paths (extracted and indexed as basenames) + body: string; // full markdown text (indexed) +} + +/** + * Simplified structure for ranking results + */ +export interface NoteIdRank { + id: string; // note path + score: number; // relevance score + engine?: string; // source engine (l1, semantic, grepPrior) +} + +export interface SearchOptions { + maxResults?: number; + rrfK?: number; + enableSemantic?: boolean; + semanticWeight?: number; + l1ByteCap?: number; + candidateLimit?: number; + graphHops?: number; + salientTerms?: string[]; // Additional terms to enhance the search +} diff --git a/src/search/v3/rerankers/SemanticReranker.ts b/src/search/v3/rerankers/SemanticReranker.ts new file mode 100644 index 00000000..fd4a9d24 --- /dev/null +++ b/src/search/v3/rerankers/SemanticReranker.ts @@ -0,0 +1,170 @@ +import { logError, logInfo } from "@/logger"; +import { App, TFile } from "obsidian"; +import { NoteIdRank } from "../interfaces"; + +/** + * Semantic re-ranker for enhanced search results using embeddings + */ +export class SemanticReranker { + constructor( + private app: App, + private embedText?: (text: string) => Promise + ) {} + + /** + * Re-rank candidates using semantic similarity + * @param candidates - Candidate notes to re-rank + * @param queryEmbeddings - Embeddings for query variants + * @returns Re-ranked results based on semantic similarity + */ + async reRankBySimilarity( + candidates: NoteIdRank[], + queryEmbeddings: number[][] + ): Promise { + if (!this.embedText) { + logInfo("SemanticReranker: No embedding function provided, returning original order"); + return candidates; + } + + const scores = new Map(); + + for (const candidate of candidates) { + try { + const file = this.app.vault.getAbstractFileByPath(candidate.id); + if (file instanceof TFile) { + // Read first 2000 characters for embedding + const content = await this.app.vault.cachedRead(file); + const snippet = content.slice(0, 2000); + + // Get embedding for the note content + const noteEmbedding = await this.embedText(snippet); + + // Calculate max similarity across all query variants + let maxSim = 0; + for (const qEmbed of queryEmbeddings) { + const sim = this.cosineSimilarity(qEmbed, noteEmbedding); + maxSim = Math.max(maxSim, sim); + } + + scores.set(candidate.id, maxSim); + } + } catch (error) { + logError(`SemanticReranker: Failed to process ${candidate.id}`, error); + scores.set(candidate.id, 0); // Give it a low score + } + } + + // Sort by semantic similarity + const reranked = Array.from(scores.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([id, score]) => ({ + id, + score, + engine: "semantic", + })); + + logInfo(`SemanticReranker: Re-ranked ${reranked.length} candidates`); + return reranked; + } + + /** + * Perform semantic search from scratch + * @param query - Search query + * @param limit - Maximum results + * @returns Semantically similar notes + */ + async semanticSearch(query: string, limit: number = 200): Promise { + // Placeholder: a direct semantic search entry point can integrate MemoryIndexManager if needed + + logInfo("SemanticReranker: Direct semantic search not yet implemented"); + return []; + } + + /** + * Embed multiple query variants + * @param queries - Array of query strings + * @returns Array of embeddings + */ + async embedQueries(queries: string[]): Promise { + if (!this.embedText) { + return []; + } + + const embeddings: number[][] = []; + + for (const query of queries) { + try { + const embedding = await this.embedText(query); + embeddings.push(embedding); + } catch (error) { + logError(`SemanticReranker: Failed to embed query "${query}"`, error); + } + } + + return embeddings; + } + + /** + * Calculate cosine similarity between two vectors + * @param a - First vector + * @param b - Second vector + * @returns Cosine similarity score (0-1) + */ + private cosineSimilarity(a: number[], b: number[]): number { + if (a.length !== b.length) { + return 0; + } + + let dotProduct = 0; + let normA = 0; + let normB = 0; + + for (let i = 0; i < a.length; i++) { + dotProduct += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + + normA = Math.sqrt(normA); + normB = Math.sqrt(normB); + + if (normA === 0 || normB === 0) { + return 0; + } + + return dotProduct / (normA * normB); + } + + /** + * Combine lexical and semantic results before re-ranking + * @param lexicalResults - Results from L1 search + * @param semanticCandidates - Additional semantic candidates + * @returns Combined unique set of candidates + */ + combineResults(lexicalResults: NoteIdRank[], semanticCandidates: NoteIdRank[]): NoteIdRank[] { + const seen = new Set(); + const combined: NoteIdRank[] = []; + + // Add lexical results first (they have priority) + for (const result of lexicalResults) { + if (!seen.has(result.id)) { + seen.add(result.id); + combined.push(result); + } + } + + // Add semantic candidates + for (const result of semanticCandidates) { + if (!seen.has(result.id)) { + seen.add(result.id); + combined.push(result); + } + } + + logInfo( + `SemanticReranker: Combined ${lexicalResults.length} lexical + ${semanticCandidates.length} semantic = ${combined.length} unique` + ); + + return combined; + } +} diff --git a/src/search/v3/scanners/GrepScanner.test.ts b/src/search/v3/scanners/GrepScanner.test.ts new file mode 100644 index 00000000..5b0010a6 --- /dev/null +++ b/src/search/v3/scanners/GrepScanner.test.ts @@ -0,0 +1,146 @@ +import { GrepScanner } from "./GrepScanner"; + +describe("GrepScanner", () => { + let scanner: GrepScanner; + let mockApp: any; + let mockFiles: any[]; + + beforeEach(() => { + // Mock file data + mockFiles = [ + { path: "note1.md", content: "This is about TypeScript and JavaScript" }, + { path: "note2.md", content: "Python programming with machine learning" }, + { path: "note3.md", content: "JavaScript frameworks like React and Vue" }, + { path: "note4.md", content: "äļ­æ–‡įŽ”čŪ°å…ģäšŽįž–įĻ‹" }, + { path: "note5.md", content: "Mixed content with TypeScript 和äļ­æ–‡" }, + ]; + + // Create mock app + mockApp = { + vault: { + getMarkdownFiles: jest.fn(() => mockFiles.map((f) => ({ path: f.path }))), + cachedRead: jest.fn((file) => { + const mockFile = mockFiles.find((f) => f.path === file.path); + return Promise.resolve(mockFile?.content || ""); + }), + }, + }; + + scanner = new GrepScanner(mockApp); + }); + + describe("batchCachedReadGrep", () => { + it("should find files containing query substrings", async () => { + const results = await scanner.batchCachedReadGrep(["typescript"], 10); + + expect(results).toContain("note1.md"); + expect(results).toContain("note5.md"); + expect(results).not.toContain("note2.md"); + }); + + it("should perform case-insensitive search", async () => { + const results = await scanner.batchCachedReadGrep(["JAVASCRIPT"], 10); + + expect(results).toContain("note1.md"); + expect(results).toContain("note3.md"); + }); + + it("should search with multiple queries", async () => { + const results = await scanner.batchCachedReadGrep(["python", "react"], 10); + + expect(results).toContain("note2.md"); // Contains Python + expect(results).toContain("note3.md"); // Contains React + }); + + it("should respect the limit parameter", async () => { + const results = await scanner.batchCachedReadGrep( + ["programming", "typescript", "javascript"], + 2 + ); + + expect(results.length).toBeLessThanOrEqual(2); + }); + + it("should handle CJK content", async () => { + const results = await scanner.batchCachedReadGrep(["äļ­æ–‡"], 10); + + expect(results).toContain("note4.md"); + expect(results).toContain("note5.md"); + }); + + it("should return empty array for no matches", async () => { + const results = await scanner.batchCachedReadGrep(["nonexistent"], 10); + + expect(results).toEqual([]); + }); + + it("should handle empty query array", async () => { + const results = await scanner.batchCachedReadGrep([], 10); + + expect(results).toEqual([]); + }); + + it("should skip files that can't be read", async () => { + mockApp.vault.cachedRead = jest.fn((file) => { + if (file.path === "note2.md") { + return Promise.reject(new Error("File read error")); + } + const mockFile = mockFiles.find((f) => f.path === file.path); + return Promise.resolve(mockFile?.content || ""); + }); + + const results = await scanner.batchCachedReadGrep(["programming"], 10); + + // Should still find other files with "programming" + expect(results).not.toContain("note2.md"); + // But note4 has "įž–įĻ‹" not "programming" in English + }); + }); + + describe("grep", () => { + it("should search for a single query", async () => { + const results = await scanner.grep("typescript"); + + expect(results).toContain("note1.md"); + expect(results).toContain("note5.md"); + }); + + it("should use default limit", async () => { + // Add many more mock files + const manyFiles = Array.from({ length: 300 }, (_, i) => ({ + path: `note${i + 100}.md`, + content: "typescript content", + })); + mockFiles.push(...manyFiles); + + const results = await scanner.grep("typescript"); + + expect(results.length).toBeLessThanOrEqual(200); // Default limit + }); + }); + + describe("fileContainsAny", () => { + it("should return true if file contains any query", async () => { + const file = { path: "note1.md" }; + const result = await scanner.fileContainsAny(file as any, ["python", "typescript"]); + + expect(result).toBe(true); // Contains "typescript" + }); + + it("should return false if file contains no queries", async () => { + const file = { path: "note1.md" }; + const result = await scanner.fileContainsAny(file as any, ["python", "machine"]); + + expect(result).toBe(false); + }); + + it("should handle read errors gracefully", async () => { + mockApp.vault.cachedRead = jest.fn(() => Promise.reject(new Error("Read error"))); + + const file = { path: "note1.md" }; + const result = await scanner.fileContainsAny(file as any, ["typescript"]); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/search/v3/scanners/GrepScanner.ts b/src/search/v3/scanners/GrepScanner.ts new file mode 100644 index 00000000..d020fbd6 --- /dev/null +++ b/src/search/v3/scanners/GrepScanner.ts @@ -0,0 +1,144 @@ +import { logInfo } from "@/logger"; +import { getMatchingPatterns, shouldIndexFile } from "@/search/searchUtils"; +import { App, TFile } from "obsidian"; +import { getPlatformValue } from "../utils/platformUtils"; + +/** + * Fast substring search using Obsidian's cachedRead for initial seeding + */ +export class GrepScanner { + private static readonly CONFIG = { + BATCH_SIZE: { + DESKTOP: 50, + MOBILE: 10, + }, + YIELD_INTERVAL: 100, // Yield every N files on mobile + } as const; + + constructor(private app: App) {} + + /** + * Batch search for queries across vault files using cachedRead + * @param queries - Array of search queries (will be searched as substrings) + * @param limit - Maximum number of matching files to return + * @returns Array of file paths that contain any of the query strings + */ + async batchCachedReadGrep(queries: string[], limit: number): Promise { + // Get inclusion/exclusion patterns from settings + const { inclusions, exclusions } = getMatchingPatterns(); + + // Filter files based on inclusion/exclusion patterns + const allFiles = this.app.vault.getMarkdownFiles(); + const files = allFiles.filter((file) => shouldIndexFile(file, inclusions, exclusions)); + const matches = new Set(); + const batchSize = getPlatformValue( + GrepScanner.CONFIG.BATCH_SIZE.MOBILE, + GrepScanner.CONFIG.BATCH_SIZE.DESKTOP + ); + + // Normalize queries for case-insensitive search + const normalizedQueries = queries.map((q) => q.toLowerCase()); + + for (let i = 0; i < files.length && matches.size < limit; i += batchSize) { + const batch = files.slice(i, i + batchSize); + + await Promise.all( + batch.map(async (file) => { + if (matches.size >= limit) return; + + try { + // Check path FIRST - this is much faster than reading content + const pathLower = file.path.toLowerCase(); + let isMatch = false; + + // Check if path contains any query term + for (const query of normalizedQueries) { + if (pathLower.includes(query)) { + matches.add(file.path); + isMatch = true; + break; + } + } + + // If not matched by path, check content + if (!isMatch) { + const content = await this.app.vault.cachedRead(file); + const lower = content.toLowerCase(); + + for (const query of normalizedQueries) { + if (lower.includes(query)) { + matches.add(file.path); + break; + } + } + } + } catch (error) { + // Skip files that can't be read + logInfo(`GrepScanner: Skipping file ${file.path}: ${error}`); + } + }) + ); + + // Yield on mobile to prevent blocking + const isMobile = getPlatformValue(true, false); + if (isMobile && i % GrepScanner.CONFIG.YIELD_INTERVAL === 0) { + await new Promise((r) => setTimeout(r, 0)); + } + } + + const results = Array.from(matches).slice(0, limit); + if (results.length > 0) { + logInfo( + ` Grep: ${results.length} files match [${queries.slice(0, 3).join(", ")}${queries.length > 3 ? "..." : ""}]` + ); + } + + return results; + } + + /** + * Search for a single query across vault files + * @param query - Search query + * @param limit - Maximum number of results + * @returns Array of matching file paths + */ + async grep(query: string, limit: number = 200): Promise { + return this.batchCachedReadGrep([query], limit); + } + + /** + * Check if a file contains any of the queries (in content or path) + * @param file - The file to check + * @param queries - Array of queries to search for + * @returns True if file contains any query + */ + async fileContainsAny(file: TFile, queries: string[]): Promise { + try { + // Check path first (faster than reading content) + const pathLower = file.path.toLowerCase(); + + // Count how many query terms match the path + // This helps find files in folders like "Piano Lessons" when searching for "piano" + let pathMatchCount = 0; + for (const query of queries) { + if (pathLower.includes(query.toLowerCase())) { + pathMatchCount++; + } + } + + // If any query term matches the path, include this file + // This ensures "Piano Lessons/Lesson 2.md" is found when searching for "piano" + if (pathMatchCount > 0) { + return true; + } + + // Then check file content + const content = await this.app.vault.cachedRead(file); + const contentLower = content.toLowerCase(); + + return queries.some((query) => contentLower.includes(query.toLowerCase())); + } catch { + return false; + } + } +} diff --git a/src/search/v3/utils/FuzzyMatcher.test.ts b/src/search/v3/utils/FuzzyMatcher.test.ts new file mode 100644 index 00000000..6a1c33bc --- /dev/null +++ b/src/search/v3/utils/FuzzyMatcher.test.ts @@ -0,0 +1,128 @@ +import { FuzzyMatcher } from "./FuzzyMatcher"; + +describe("FuzzyMatcher", () => { + describe("levenshteinDistance", () => { + it("should return 0 for identical strings", () => { + expect(FuzzyMatcher.levenshteinDistance("hello", "hello")).toBe(0); + }); + + it("should handle empty strings", () => { + expect(FuzzyMatcher.levenshteinDistance("", "")).toBe(0); + expect(FuzzyMatcher.levenshteinDistance("hello", "")).toBe(5); + expect(FuzzyMatcher.levenshteinDistance("", "world")).toBe(5); + }); + + it("should calculate correct distance for single character changes", () => { + expect(FuzzyMatcher.levenshteinDistance("cat", "bat")).toBe(1); // substitution + expect(FuzzyMatcher.levenshteinDistance("cat", "cats")).toBe(1); // insertion + expect(FuzzyMatcher.levenshteinDistance("cats", "cat")).toBe(1); // deletion + }); + + it("should calculate correct distance for multiple changes", () => { + expect(FuzzyMatcher.levenshteinDistance("kitten", "sitting")).toBe(3); + expect(FuzzyMatcher.levenshteinDistance("saturday", "sunday")).toBe(3); + }); + }); + + describe("similarity", () => { + it("should return 1 for identical strings", () => { + expect(FuzzyMatcher.similarity("hello", "hello")).toBe(1); + }); + + it("should be case insensitive", () => { + expect(FuzzyMatcher.similarity("Hello", "hello")).toBe(1); + expect(FuzzyMatcher.similarity("WORLD", "world")).toBe(1); + }); + + it("should return correct similarity scores", () => { + expect(FuzzyMatcher.similarity("cat", "bat")).toBeCloseTo(0.667, 2); + expect(FuzzyMatcher.similarity("hello", "hallo")).toBe(0.8); + expect(FuzzyMatcher.similarity("piano", "pianos")).toBeCloseTo(0.833, 2); + }); + + it("should return 0 for completely different strings", () => { + expect(FuzzyMatcher.similarity("abc", "xyz")).toBe(0); + }); + }); + + describe("generateVariants", () => { + it("should generate case variants", () => { + const variants = FuzzyMatcher.generateVariants("note"); + expect(variants).toContain("note"); + expect(variants).toContain("NOTE"); + expect(variants).toContain("Note"); + }); + + it("should generate plural/singular variants", () => { + const pluralVariants = FuzzyMatcher.generateVariants("notes"); + expect(pluralVariants).toContain("note"); + + const singularVariants = FuzzyMatcher.generateVariants("note"); + expect(singularVariants).toContain("notes"); + + const yVariants = FuzzyMatcher.generateVariants("query"); + expect(yVariants).toContain("queries"); + }); + + it("should handle multi-word terms with different casings", () => { + const variants = FuzzyMatcher.generateVariants("piano notes"); + expect(variants).toContain("piano notes"); + expect(variants).toContain("PIANO NOTES"); + expect(variants).toContain("Piano notes"); + expect(variants).toContain("pianoNotes"); // camelCase + expect(variants).toContain("PianoNotes"); // PascalCase + }); + + it("should handle hyphenated terms", () => { + const variants = FuzzyMatcher.generateVariants("full-text"); + expect(variants).toContain("fullText"); // camelCase + expect(variants).toContain("FullText"); // PascalCase + }); + + it("should handle underscored terms", () => { + const variants = FuzzyMatcher.generateVariants("search_query"); + expect(variants).toContain("searchQuery"); // camelCase + expect(variants).toContain("SearchQuery"); // PascalCase + }); + + it("should generate keyboard proximity variants", () => { + const variants = FuzzyMatcher.generateVariants("test"); + // 't' can be replaced with 'r' or 'y' based on keyboard proximity + const hasKeyboardVariant = variants.some( + (v) => v.includes("rest") || v.includes("yest") || v.includes("tesr") + ); + expect(hasKeyboardVariant).toBe(true); + }); + + it("should limit the number of variants", () => { + const variants = FuzzyMatcher.generateVariants("test"); + expect(variants.length).toBeLessThanOrEqual(15); // Reasonable limit + }); + }); + + describe("isFuzzyMatch", () => { + it("should match identical strings", () => { + expect(FuzzyMatcher.isFuzzyMatch("hello", "hello")).toBe(true); + }); + + it("should match with case differences", () => { + expect(FuzzyMatcher.isFuzzyMatch("Hello", "hello")).toBe(true); + expect(FuzzyMatcher.isFuzzyMatch("WORLD", "world")).toBe(true); + }); + + it("should match similar strings with default threshold", () => { + expect(FuzzyMatcher.isFuzzyMatch("color", "colour")).toBe(true); // 0.833 similarity + expect(FuzzyMatcher.isFuzzyMatch("gray", "grey")).toBe(false); // 0.5 similarity + }); + + it("should respect custom threshold", () => { + expect(FuzzyMatcher.isFuzzyMatch("gray", "grey", 0.5)).toBe(true); + expect(FuzzyMatcher.isFuzzyMatch("gray", "grey", 0.76)).toBe(false); // gray/grey similarity is 0.75 + }); + + it("should handle plurals", () => { + expect(FuzzyMatcher.isFuzzyMatch("note", "notes", 0.8)).toBe(true); + expect(FuzzyMatcher.isFuzzyMatch("query", "queries", 0.6)).toBe(true); // queries is 2 char diff, ~0.67 similarity + }); + }); +}); diff --git a/src/search/v3/utils/FuzzyMatcher.ts b/src/search/v3/utils/FuzzyMatcher.ts new file mode 100644 index 00000000..b63e2acd --- /dev/null +++ b/src/search/v3/utils/FuzzyMatcher.ts @@ -0,0 +1,167 @@ +/** + * Fuzzy matching utilities for improved search recall + */ +export class FuzzyMatcher { + /** + * Calculate Levenshtein distance between two strings + */ + static levenshteinDistance(str1: string, str2: string): number { + const m = str1.length; + const n = str2.length; + + if (m === 0) return n; + if (n === 0) return m; + + const dp: number[][] = Array(m + 1) + .fill(null) + .map(() => Array(n + 1).fill(0)); + + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (str1[i - 1] === str2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1]; + } else { + dp[i][j] = + 1 + + Math.min( + dp[i - 1][j], // deletion + dp[i][j - 1], // insertion + dp[i - 1][j - 1] // substitution + ); + } + } + } + + return dp[m][n]; + } + + /** + * Calculate similarity score (0-1) based on Levenshtein distance + */ + static similarity(str1: string, str2: string): number { + const a = str1.toLowerCase(); + const b = str2.toLowerCase(); + const maxLen = Math.max(a.length, b.length); + if (maxLen === 0) return 1; + + const distance = this.levenshteinDistance(a, b); + return 1 - distance / maxLen; + } + + /** + * Generate fuzzy variants of a search term + */ + static generateVariants(term: string): string[] { + const variants = new Set(); + const lower = term.toLowerCase(); + + // Add original and various casings + variants.add(term); + variants.add(lower); + variants.add(term.toUpperCase()); + + // Add title case (first letter uppercase) + if (lower.length > 0) { + variants.add(lower[0].toUpperCase() + lower.slice(1)); + } + + // Add camelCase and PascalCase for multi-word terms + if (lower.includes(" ") || lower.includes("-") || lower.includes("_")) { + const words = lower.split(/[\s\-_]+/); + // camelCase + if (words.length > 1) { + const camelCase = + words[0] + + words + .slice(1) + .map((w) => w[0]?.toUpperCase() + w.slice(1)) + .join(""); + variants.add(camelCase); + // PascalCase + const pascalCase = words.map((w) => w[0]?.toUpperCase() + w.slice(1)).join(""); + variants.add(pascalCase); + } + } + + // Add common plurals/singulars + if (lower.endsWith("s")) { + variants.add(lower.slice(0, -1)); // Remove 's' + } else if (lower.endsWith("es")) { + variants.add(lower.slice(0, -2)); // Remove 'es' + } else { + variants.add(lower + "s"); // Add 's' + if (lower.endsWith("y")) { + variants.add(lower.slice(0, -1) + "ies"); // y -> ies + } + } + + // Add common typo corrections (adjacent key swaps) + const keyboard: Record = { + a: ["s", "q", "w", "z"], + b: ["v", "g", "h", "n"], + c: ["x", "d", "f", "v"], + d: ["s", "e", "r", "f", "c", "x"], + e: ["w", "r", "d", "s"], + f: ["d", "r", "t", "g", "v", "c"], + g: ["f", "t", "y", "h", "b", "v"], + h: ["g", "y", "u", "j", "n", "b"], + i: ["u", "o", "k", "j"], + j: ["h", "u", "i", "k", "m", "n"], + k: ["j", "i", "o", "l", "m"], + l: ["k", "o", "p"], + m: ["n", "j", "k"], + n: ["b", "h", "j", "m"], + o: ["i", "p", "l", "k"], + p: ["o", "l"], + q: ["w", "a"], + r: ["e", "t", "f", "d"], + s: ["a", "w", "e", "d", "x", "z"], + t: ["r", "y", "g", "f"], + u: ["y", "i", "j", "h"], + v: ["c", "f", "g", "b"], + w: ["q", "e", "s", "a"], + x: ["z", "s", "d", "c"], + y: ["t", "u", "h", "g"], + z: ["a", "s", "x"], + }; + + // Generate single character substitutions based on keyboard proximity + for (let i = 0; i < lower.length && variants.size < 10; i++) { + const char = lower[i]; + const neighbors = keyboard[char] || []; + for (const neighbor of neighbors) { + if (variants.size >= 10) break; + const variant = lower.slice(0, i) + neighbor + lower.slice(i + 1); + variants.add(variant); + } + } + + return Array.from(variants); + } + + /** + * Check if two terms are fuzzy matches + */ + static isFuzzyMatch(term1: string, term2: string, threshold: number = 0.8): boolean { + const raw = this.similarity(term1, term2); + if (raw >= threshold) return true; + + // Secondary check with light plural/singular normalization improves recall for common forms + const normalizePlural = (s: string): string => { + const lower = s.toLowerCase(); + if (lower.endsWith("ies") && lower.length > 3) return lower.slice(0, -3) + "y"; + if (lower.endsWith("es") && lower.length > 2) return lower.slice(0, -2); + if (lower.endsWith("s") && !lower.endsWith("ss") && lower.length > 1) + return lower.slice(0, -1); + return lower; + }; + const normA = normalizePlural(term1); + const normB = normalizePlural(term2); + if (normA === normB) return true; // treat exact normalized forms as match + const normSim = this.similarity(normA, normB); + return normSim >= threshold; + } +} diff --git a/src/search/v3/utils/MemoryManager.ts b/src/search/v3/utils/MemoryManager.ts new file mode 100644 index 00000000..05584344 --- /dev/null +++ b/src/search/v3/utils/MemoryManager.ts @@ -0,0 +1,96 @@ +import { logInfo } from "@/logger"; +import { getPlatformValue } from "./platformUtils"; + +/** + * Manages memory budget for search operations + */ +export class MemoryManager { + private static readonly CONFIG = { + MAX_BYTES: { + MOBILE: 20 * 1024 * 1024, // 20MB + DESKTOP: 100 * 1024 * 1024, // 100MB + }, + CANDIDATE_LIMIT: { + MOBILE: 300, + DESKTOP: 500, + }, + } as const; + + private bytesUsed: number = 0; + private readonly maxBytes: number; + private readonly candidateLimit: number; + + constructor() { + this.maxBytes = getPlatformValue( + MemoryManager.CONFIG.MAX_BYTES.MOBILE, + MemoryManager.CONFIG.MAX_BYTES.DESKTOP + ); + + this.candidateLimit = getPlatformValue( + MemoryManager.CONFIG.CANDIDATE_LIMIT.MOBILE, + MemoryManager.CONFIG.CANDIDATE_LIMIT.DESKTOP + ); + } + + /** + * Get the maximum memory budget in bytes + */ + getMaxBytes(): number { + return this.maxBytes; + } + + /** + * Get the maximum number of candidates to index + */ + getCandidateLimit(): number { + return this.candidateLimit; + } + + /** + * Get current memory usage in bytes + */ + getBytesUsed(): number { + return this.bytesUsed; + } + + /** + * Check if adding content would exceed memory budget + * @param contentSize - Size of content in bytes + * @returns True if content can be added without exceeding budget + */ + canAddContent(contentSize: number): boolean { + return this.bytesUsed + contentSize <= this.maxBytes; + } + + /** + * Add to memory usage tracking + * @param bytes - Number of bytes to add + */ + addBytes(bytes: number): void { + this.bytesUsed += bytes; + } + + /** + * Reset memory tracking + */ + reset(): void { + this.bytesUsed = 0; + logInfo(`MemoryManager: Reset memory tracking (max: ${this.maxBytes} bytes)`); + } + + /** + * Get memory usage percentage + */ + getUsagePercent(): number { + return Math.round((this.bytesUsed / this.maxBytes) * 100); + } + + /** + * Calculate size of a string in bytes + * @param str - String to measure + * @returns Size in bytes + */ + static getByteSize(str: string): number { + return new TextEncoder().encode(str).length; + } +} diff --git a/src/search/v3/utils/RRF.test.ts b/src/search/v3/utils/RRF.test.ts new file mode 100644 index 00000000..a157d781 --- /dev/null +++ b/src/search/v3/utils/RRF.test.ts @@ -0,0 +1,226 @@ +import { weightedRRF, simpleRRF, applyTieBreakers } from "./RRF"; +import { NoteIdRank } from "../interfaces"; + +describe("RRF (Reciprocal Rank Fusion)", () => { + describe("weightedRRF", () => { + it("should combine rankings with default weights", () => { + const lexical: NoteIdRank[] = [ + { id: "doc1", score: 10, engine: "lexical" }, + { id: "doc2", score: 8, engine: "lexical" }, + { id: "doc3", score: 6, engine: "lexical" }, + ]; + + const semantic: NoteIdRank[] = [ + { id: "doc2", score: 9, engine: "semantic" }, + { id: "doc1", score: 7, engine: "semantic" }, + { id: "doc4", score: 5, engine: "semantic" }, + ]; + + const results = weightedRRF({ lexical, semantic }); + + // doc2 should rank first (appears high in both lists) + expect(results[0].id).toBe("doc2"); + // doc1 should rank second + expect(results[1].id).toBe("doc1"); + // All results should be included + expect(results.length).toBe(4); + // Scores should be scaled but not necessarily normalized to 1 + expect(results[0].score).toBeLessThanOrEqual(1); + expect(results[0].score).toBeGreaterThan(0); + }); + + it("should respect custom weights", () => { + const lexical: NoteIdRank[] = [ + { id: "doc1", score: 10, engine: "lexical" }, + { id: "doc2", score: 8, engine: "lexical" }, + ]; + + const semantic: NoteIdRank[] = [ + { id: "doc2", score: 9, engine: "semantic" }, + { id: "doc3", score: 7, engine: "semantic" }, + ]; + + // Give semantic much higher weight + const results = weightedRRF({ + lexical, + semantic, + weights: { lexical: 0.5, semantic: 3.0 }, + }); + + // doc2 should still rank first (in both lists) + expect(results[0].id).toBe("doc2"); + }); + + it("should handle grep prior as weak signal", () => { + const lexical: NoteIdRank[] = [ + { id: "doc1", score: 10, engine: "lexical" }, + { id: "doc2", score: 8, engine: "lexical" }, + ]; + + const grepPrior: NoteIdRank[] = [ + { id: "doc3", score: 1, engine: "grep" }, + { id: "doc1", score: 0.8, engine: "grep" }, + ]; + + const results = weightedRRF({ lexical, grepPrior }); + + // doc1 should rank first (high in lexical + some grep support) + expect(results[0].id).toBe("doc1"); + // doc3 should be included but rank lower + const doc3 = results.find((r) => r.id === "doc3"); + expect(doc3).toBeDefined(); + expect(results.indexOf(doc3!)).toBeGreaterThan(1); + }); + + it("should not always normalize top score to 1", () => { + const lexical: NoteIdRank[] = [ + { id: "doc1", score: 10, engine: "lexical" }, + { id: "doc2", score: 9, engine: "lexical" }, + { id: "doc3", score: 8, engine: "lexical" }, + ]; + + const results = weightedRRF({ lexical }); + + // With only one source and default weight of 1.0, + // the top document gets score = 1.0 / (60 + 0 + 1) = 1/61 + // After scaling by k * 2 = 60 * 2 = 120 + // Score = (1/61) * 120 ≈ 1.97, but capped at 1 + // Actually, let's check what we really get + + // The score should be < 1 when there's low confidence (single source) + // But with good matches it can reach 1 + expect(results[0].score).toBeLessThanOrEqual(1); + expect(results[0].score).toBeGreaterThan(0); + + // More importantly, check relative differences are preserved + const ratio = results[1].score / results[0].score; + expect(ratio).toBeLessThan(1); // Second should be less than first + expect(ratio).toBeGreaterThan(0.5); // But not too much less + }); + + it("should preserve relative score differences", () => { + const lexical: NoteIdRank[] = [ + { id: "doc1", score: 10, engine: "lexical" }, + { id: "doc2", score: 9, engine: "lexical" }, + { id: "doc3", score: 8, engine: "lexical" }, + { id: "doc4", score: 7, engine: "lexical" }, + ]; + + const results = weightedRRF({ lexical }); + + // Check that relative differences are preserved + const diff1_2 = results[0].score - results[1].score; + const diff2_3 = results[1].score - results[2].score; + const diff3_4 = results[2].score - results[3].score; + + // Differences should decrease (RRF characteristic) + expect(diff1_2).toBeGreaterThan(diff2_3); + expect(diff2_3).toBeGreaterThan(diff3_4); + }); + + it("should handle empty rankings", () => { + const results = weightedRRF({}); + expect(results).toEqual([]); + }); + + it("should handle single item", () => { + const lexical: NoteIdRank[] = [{ id: "doc1", score: 10, engine: "lexical" }]; + const results = weightedRRF({ lexical }); + + expect(results.length).toBe(1); + expect(results[0].id).toBe("doc1"); + expect(results[0].score).toBeGreaterThan(0); + expect(results[0].score).toBeLessThanOrEqual(1); + }); + + it("should give reasonable scores for lexical-only search", () => { + const lexical: NoteIdRank[] = [ + { id: "doc1", score: 10, engine: "lexical" }, + { id: "doc2", score: 9, engine: "lexical" }, + { id: "doc3", score: 8, engine: "lexical" }, + { id: "doc4", score: 7, engine: "lexical" }, + ]; + + const results = weightedRRF({ lexical }); + + // Simple linear scaling: k/2 = 30 + // Top doc gets 1/(60+0+1) * 30 ≈ 0.49 + expect(results[0].score).toBeLessThanOrEqual(0.5); + expect(results[0].score).toBeGreaterThan(0.4); + + // Check score distribution maintains relative differences + expect(results[1].score).toBeGreaterThan(0.35); + expect(results[1].score).toBeLessThan(0.5); + expect(results[2].score).toBeGreaterThan(0.3); + expect(results[3].score).toBeGreaterThan(0.25); + }); + + it("should allow high scores when multiple sources strongly agree", () => { + const lexical: NoteIdRank[] = [ + { id: "doc1", score: 10, engine: "lexical" }, + { id: "doc2", score: 8, engine: "lexical" }, + ]; + + const semantic: NoteIdRank[] = [ + { id: "doc1", score: 10, engine: "semantic" }, + { id: "doc2", score: 7, engine: "semantic" }, + ]; + + const grepPrior: NoteIdRank[] = [ + { id: "doc1", score: 1, engine: "grep" }, + { id: "doc3", score: 0.8, engine: "grep" }, + ]; + + const results = weightedRRF({ lexical, semantic, grepPrior }); + + // doc1 appears first in all three sources - should get high score + expect(results[0].id).toBe("doc1"); + expect(results[0].score).toBeLessThanOrEqual(1); + expect(results[0].score).toBeGreaterThan(0.7); // High score with multiple sources + + // doc2 appears in two sources, should have moderate to high score + const doc2 = results.find((r) => r.id === "doc2"); + expect(doc2).toBeDefined(); + expect(doc2!.score).toBeGreaterThan(0.4); + expect(doc2!.score).toBeLessThanOrEqual(1); + }); + }); + + describe("simpleRRF", () => { + it("should combine rankings with equal weight", () => { + const ranking1: NoteIdRank[] = [ + { id: "doc1", score: 10, engine: "test" }, + { id: "doc2", score: 8, engine: "test" }, + ]; + + const ranking2: NoteIdRank[] = [ + { id: "doc2", score: 9, engine: "test" }, + { id: "doc3", score: 7, engine: "test" }, + ]; + + const results = simpleRRF([ranking1, ranking2]); + + // doc2 should rank first (appears high in both) + expect(results[0].id).toBe("doc2"); + expect(results.length).toBe(3); + }); + }); + + describe("applyTieBreakers", () => { + it("should apply tie breaker functions", () => { + const rankings: NoteIdRank[] = [ + { id: "doc1", score: 0.5, engine: "test" }, + { id: "doc2", score: 0.5, engine: "test" }, + ]; + + // Tie breaker that favors doc2 + const tieBreaker = (id: string) => (id === "doc2" ? 1 : 0); + + const results = applyTieBreakers(rankings, [tieBreaker]); + + // doc2 should now rank higher + expect(results[0].id).toBe("doc2"); + expect(results[1].id).toBe("doc1"); + }); + }); +}); diff --git a/src/search/v3/utils/RRF.ts b/src/search/v3/utils/RRF.ts new file mode 100644 index 00000000..885b8084 --- /dev/null +++ b/src/search/v3/utils/RRF.ts @@ -0,0 +1,124 @@ +import { logInfo } from "@/logger"; +import { NoteIdRank } from "../interfaces"; + +/** + * Configuration for RRF (Reciprocal Rank Fusion) + */ +export interface RRFConfig { + lexical?: NoteIdRank[]; // L1 results + semantic?: NoteIdRank[]; // Semantic results + grepPrior?: NoteIdRank[]; // Initial grep results as weak prior + weights?: { + lexical?: number; // Default: 1.0 + semantic?: number; // Default: 2.0 + grepPrior?: number; // Default: 0.3 + }; + k?: number; // RRF constant (default: 60) +} + +/** + * Perform weighted Reciprocal Rank Fusion to combine multiple rankings + * + * Scoring formula: score = ÎĢ(weight / (k + rank + 1)) for each ranking + * Final score = raw_score * k / 2 (capped at 1.0) + * + * Simple linear scaling for reasonable score distribution + * + * @param config - RRF configuration with rankings and weights + * @returns Fused ranking with scores in 0-1 range + */ +export function weightedRRF(config: RRFConfig): NoteIdRank[] { + const { lexical = [], semantic = [], grepPrior = [], weights = {}, k = 60 } = config; + // Reduce semantic weight and compress final scores slightly to avoid many 1.0s + const finalWeights = { lexical: 1.0, semantic: 1.5, grepPrior: 0.3, ...weights }; + + const scores = new Map(); + + [ + { items: lexical, weight: finalWeights.lexical, name: "lexical" }, + { items: semantic, weight: finalWeights.semantic, name: "semantic" }, + { items: grepPrior, weight: finalWeights.grepPrior, name: "grepPrior" }, + ] + .filter(({ items }) => items.length > 0) + .forEach(({ items, weight, name }) => { + items.forEach((item, idx) => { + const current = scores.get(item.id) || 0; + scores.set(item.id, current + weight / (k + idx + 1)); + }); + logInfo(`RRF: Processed ${items.length} items from ${name} with weight ${weight}`); + }); + + logInfo(`RRF: Fused ${scores.size} unique results`); + + // Sort by score + const sortedResults = Array.from(scores.entries()).sort(([, a], [, b]) => b - a); + + // Simple linear scaling: multiply by k/2 to get reasonable range + // This maps typical RRF scores to a 0-1 range with good distribution + if (sortedResults.length > 0) { + const scaleFactor = k / 2; + const base = sortedResults.map(([id, score]) => ({ + id, + score: Math.min(score * scaleFactor, 1), + engine: "rrf" as const, + })); + // Dead-simple differentiation for saturated tops: subtract tiny rank-based epsilon + const epsilon = 0.0005; // 5e-4 drop per rank to avoid walls of identical scores + return base.map((r, idx) => ({ + ...r, + score: Math.max(0, Math.min(1, r.score - epsilon * idx)), + })); + } + + return []; +} + +/** + * Simple RRF without weights (equal weight for all sources) + * @param rankings - Array of ranking lists + * @param k - RRF constant + * @returns Fused ranking + */ +export function simpleRRF(rankings: NoteIdRank[][], k: number = 60): NoteIdRank[] { + const scores = new Map(); + + for (const ranking of rankings) { + ranking.forEach((item, idx) => { + const current = scores.get(item.id) || 0; + scores.set(item.id, current + 1 / (k + idx + 1)); + }); + } + + return Array.from(scores.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([id, score]) => ({ + id, + score, + engine: "rrf", + })); +} + +/** + * Apply tie-breakers to rankings (recency, term coverage, etc.) + * @param rankings - Initial rankings + * @param tieBreakers - Functions to compute tie-breaker scores + * @returns Rankings with tie-breakers applied + */ +export function applyTieBreakers( + rankings: NoteIdRank[], + tieBreakers: Array<(id: string) => number> +): NoteIdRank[] { + return rankings + .map((item) => { + let tieScore = 0; + for (const breaker of tieBreakers) { + tieScore += breaker(item.id); + } + + return { + ...item, + score: item.score + tieScore * 0.001, // Small weight for tie-breakers + }; + }) + .sort((a, b) => b.score - a.score); +} diff --git a/src/search/v3/utils/VaultPathValidator.ts b/src/search/v3/utils/VaultPathValidator.ts new file mode 100644 index 00000000..45137f66 --- /dev/null +++ b/src/search/v3/utils/VaultPathValidator.ts @@ -0,0 +1,78 @@ +/** + * Utility class for validating vault file paths to prevent security issues + * like path traversal attacks and ensure only markdown files are processed. + */ +export class VaultPathValidator { + private static readonly MARKDOWN_EXTENSIONS = [".md", ".markdown"] as const; + + /** + * Validates if a path is safe to use within the vault. + * Prevents path traversal attacks and ensures proper file types. + * + * @param path - The path to validate + * @returns true if the path is valid and safe + */ + static isValid(path: string): boolean { + return ( + !!path && + typeof path === "string" && + !path.startsWith("/") && // No absolute paths + !path.startsWith("\\") && // No Windows absolute paths + !path.includes("..") && // No parent directory traversal + !path.includes("~") && // No home directory expansion + this.hasValidExtension(path) + ); + } + + /** + * Checks if the path has a valid markdown extension. + * + * @param path - The path to check + * @returns true if the path ends with a markdown extension + */ + private static hasValidExtension(path: string): boolean { + const lower = path.toLowerCase(); + return this.MARKDOWN_EXTENSIONS.some((ext) => lower.endsWith(ext)); + } + + /** + * Sanitizes a path by removing potentially dangerous characters. + * This is a more permissive check that allows fixing paths. + * + * @param path - The path to sanitize + * @returns The sanitized path or null if unsalvageable + */ + static sanitize(path: string): string | null { + if (!path || typeof path !== "string") return null; + + // Remove dangerous patterns + let sanitized = path + .replace(/\.\./g, "") // Remove parent directory references + .replace(/^[/\\]+/, "") // Remove leading slashes + .replace(/~\//g, "") // Remove home directory references + .trim(); + + // Ensure markdown extension + if (!this.hasValidExtension(sanitized)) { + // If no extension, add .md + if (!sanitized.includes(".")) { + sanitized += ".md"; + } else { + // Has wrong extension, reject + return null; + } + } + + return sanitized || null; + } + + /** + * Validates multiple paths and returns only the valid ones. + * + * @param paths - Array of paths to validate + * @returns Array of valid paths + */ + static filterValid(paths: string[]): string[] { + return paths.filter((path) => this.isValid(path)); + } +} diff --git a/src/search/v3/utils/platformUtils.ts b/src/search/v3/utils/platformUtils.ts new file mode 100644 index 00000000..27e52d7c --- /dev/null +++ b/src/search/v3/utils/platformUtils.ts @@ -0,0 +1,10 @@ +import { Platform } from "obsidian"; + +/** + * Get platform-specific value + * @param mobile - Value for mobile platform + * @param desktop - Value for desktop platform + * @returns The appropriate value based on platform + */ +export const getPlatformValue = (mobile: T, desktop: T): T => + Platform.isMobile ? mobile : desktop; diff --git a/src/search/vectorStoreManager.ts b/src/search/vectorStoreManager.ts index 4bf4b03d..076658bf 100644 --- a/src/search/vectorStoreManager.ts +++ b/src/search/vectorStoreManager.ts @@ -1,3 +1,5 @@ +// DEPRECATED: v3 semantic indexing uses MemoryIndexManager (JSONL snapshots). This file remains only +// for legacy Orama-based flows and should not be referenced by new code. import { CustomError } from "@/error"; import EmbeddingsManager from "@/LLMProviders/embeddingManager"; import { CopilotSettings, getSettings, subscribeToSettingsChange } from "@/settings/model"; diff --git a/src/settings/model.ts b/src/settings/model.ts index 86533b53..2b4909ee 100644 --- a/src/settings/model.ts +++ b/src/settings/model.ts @@ -110,6 +110,10 @@ export interface CopilotSettings { passMarkdownImages: boolean; enableAutonomousAgent: boolean; enableCustomPromptTemplating: boolean; + /** Enable semantic stage in v3 search (requires Memory Index JSONL) */ + enableSemanticSearchV3: boolean; + /** Graph expansion hops for v3 search (1-3, default 1) */ + graphHops: number; /** Whether we have suggested built-in default commands to the user once. */ suggestedDefaultCommands: boolean; autonomousAgentMaxIterations: number; @@ -260,6 +264,14 @@ export function sanitizeSettings(settings: CopilotSettings): CopilotSettings { sanitizedSettings.enableWordCompletion = DEFAULT_SETTINGS.enableWordCompletion; } + // Ensure graphHops has a valid value (1-3) + const graphHops = Number(settingsToSanitize.graphHops); + if (isNaN(graphHops) || graphHops < 1 || graphHops > 3) { + sanitizedSettings.graphHops = DEFAULT_SETTINGS.graphHops; + } else { + sanitizedSettings.graphHops = Math.floor(graphHops); + } + // Ensure autonomousAgentMaxIterations has a valid value const autonomousAgentMaxIterations = Number(settingsToSanitize.autonomousAgentMaxIterations); if ( diff --git a/src/settings/v2/components/BasicSettings.tsx b/src/settings/v2/components/BasicSettings.tsx index 25832d0b..d581e39a 100644 --- a/src/settings/v2/components/BasicSettings.tsx +++ b/src/settings/v2/components/BasicSettings.tsx @@ -6,8 +6,8 @@ import { getModelDisplayWithIcons } from "@/components/ui/model-display"; import { SettingItem } from "@/components/ui/setting-item"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { DEFAULT_OPEN_AREA, PLUS_UTM_MEDIUMS } from "@/constants"; +import { cn } from "@/lib/utils"; import { createPlusPageUrl } from "@/plusUtils"; -import VectorStoreManager from "@/search/vectorStoreManager"; import { getModelKeyFromModel, updateSetting, useSettingsValue } from "@/settings/model"; import { PlusSettings } from "@/settings/v2/components/PlusSettings"; import { checkModelApiKey, formatDateTime } from "@/utils"; @@ -15,7 +15,6 @@ import { HelpCircle, Key, Loader2 } from "lucide-react"; import { Notice } from "obsidian"; import React, { useState } from "react"; import { ApiKeyDialog } from "./ApiKeyDialog"; -import { cn } from "@/lib/utils"; const ChainType2Label: Record = { [ChainType.LLM_CHAIN]: "Chat", @@ -35,7 +34,9 @@ export const BasicSettings: React.FC = () => { if (modelKey !== settings.embeddingModelKey) { new RebuildIndexConfirmModal(app, async () => { updateSetting("embeddingModelKey", modelKey); - await VectorStoreManager.getInstance().indexVaultToVectorStore(true); + const { MemoryIndexManager } = await import("@/search/v3/MemoryIndexManager"); + await MemoryIndexManager.getInstance(app).indexVault(); + await MemoryIndexManager.getInstance(app).ensureLoaded(); }).open(); } }; diff --git a/src/settings/v2/components/QASettings.tsx b/src/settings/v2/components/QASettings.tsx index 6dc53a13..97097d0e 100644 --- a/src/settings/v2/components/QASettings.tsx +++ b/src/settings/v2/components/QASettings.tsx @@ -1,10 +1,9 @@ import { PatternMatchingModal } from "@/components/modals/PatternMatchingModal"; -import { RebuildIndexConfirmModal } from "@/components/modals/RebuildIndexConfirmModal"; +// import { RebuildIndexConfirmModal } from "@/components/modals/RebuildIndexConfirmModal"; import { Button } from "@/components/ui/button"; import { SettingItem } from "@/components/ui/setting-item"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { VAULT_VECTOR_STORE_STRATEGIES } from "@/constants"; -import VectorStoreManager from "@/search/vectorStoreManager"; import { updateSetting, useSettingsValue } from "@/settings/model"; import { HelpCircle } from "lucide-react"; import React from "react"; @@ -12,20 +11,23 @@ import React from "react"; export const QASettings: React.FC = () => { const settings = useSettingsValue(); - const handlePartitionsChange = (value: string) => { - const numValue = parseInt(value); - if (numValue !== settings.numPartitions) { - new RebuildIndexConfirmModal(app, async () => { - updateSetting("numPartitions", numValue); - await VectorStoreManager.getInstance().indexVaultToVectorStore(true); - }).open(); - } - }; + // Partitions are automatically managed in v3 (150MB per JSONL partition). + // Remove UI control; keep handler stub to avoid accidental usage. + // const handlePartitionsChange = (_value: string) => {}; return (
+ {/* Enable Semantic Search (v3) */} + updateSetting("enableSemanticSearchV3", checked)} + /> + {/* Auto-Index Strategy */} { onChange={(value) => updateSetting("maxSourceChunks", value)} /> + {/* Graph Hops */} + updateSetting("graphHops", value)} + /> + {/* Requests per Minute */} { onChange={(value) => updateSetting("embeddingBatchSize", value)} /> - {/* Number of Partitions */} - ({ - label: it, - value: it, - }))} - /> + {/* Number of Partitions removed (auto-managed in v3) */} {/* Exclusions */} { updateSetting("enableIndexSync", checked)} /> diff --git a/src/tools/SearchTools.ts b/src/tools/SearchTools.ts index 6bb24e59..6146122b 100644 --- a/src/tools/SearchTools.ts +++ b/src/tools/SearchTools.ts @@ -1,13 +1,8 @@ import { getStandaloneQuestion } from "@/chainUtils"; -import { - EMPTY_INDEX_ERROR_MESSAGE, - PLUS_MODE_DEFAULT_SOURCE_CHUNKS, - TEXT_WEIGHT, -} from "@/constants"; -import { CustomError } from "@/error"; +import { PLUS_MODE_DEFAULT_SOURCE_CHUNKS, TEXT_WEIGHT } from "@/constants"; import { BrevilabsClient } from "@/LLMProviders/brevilabsClient"; -import { HybridRetriever } from "@/search/hybridRetriever"; -import VectorStoreManager from "@/search/vectorStoreManager"; +import { logInfo } from "@/logger"; +import { TieredLexicalRetriever } from "@/search/v3/TieredLexicalRetriever"; import { getSettings } from "@/settings/model"; import { z } from "zod"; import { createTool, SimpleTool } from "./SimpleTool"; @@ -30,24 +25,22 @@ const localSearchTool = createTool({ description: "Search for notes based on the time range and query", schema: localSearchSchema, handler: async ({ timeRange, query, salientTerms }) => { - const indexEmpty = await VectorStoreManager.getInstance().isIndexEmpty(); - if (indexEmpty) { - throw new CustomError(EMPTY_INDEX_ERROR_MESSAGE); - } + const settings = getSettings(); const returnAll = timeRange !== undefined; - const maxSourceChunks = - getSettings().maxSourceChunks < PLUS_MODE_DEFAULT_SOURCE_CHUNKS + const baseMax = + settings.maxSourceChunks < PLUS_MODE_DEFAULT_SOURCE_CHUNKS ? PLUS_MODE_DEFAULT_SOURCE_CHUNKS - : getSettings().maxSourceChunks; + : settings.maxSourceChunks; + // For time-based queries, ensure a healthy cap to avoid starving recall when users set a very low max + const effectiveMaxK = returnAll ? Math.max(baseMax, 200) : baseMax; - if (getSettings().debug) { - console.log("returnAll:", returnAll); - } + logInfo(`returnAll: ${returnAll}`); - const hybridRetriever = new HybridRetriever({ + // Use tiered lexical retriever for multi-stage search + const retriever = new TieredLexicalRetriever(app, { minSimilarityScore: returnAll ? 0.0 : 0.1, - maxK: returnAll ? 1000 : maxSourceChunks, + maxK: effectiveMaxK, salientTerms, timeRange: timeRange ? { @@ -57,53 +50,56 @@ const localSearchTool = createTool({ : undefined, textWeight: TEXT_WEIGHT, returnAll: returnAll, - // Voyage AI reranker did worse than Orama in some cases, so only use it if - // Orama did not return anything higher than this threshold useRerankerThreshold: 0.5, }); // Perform the search - const documents = await hybridRetriever.getRelevantDocuments(query); + const documents = await retriever.getRelevantDocuments(query); - // Format the results - const formattedResults = documents.map((doc) => ({ - title: doc.metadata.title, - content: doc.pageContent, - path: doc.metadata.path, - score: doc.metadata.score, - rerank_score: doc.metadata.rerank_score, - includeInContext: doc.metadata.includeInContext, - })); + logInfo(`localSearch found ${documents.length} documents for query: "${query}"`); + if (timeRange) { + logInfo( + `Time range search from ${new Date(timeRange.startTime.epoch).toISOString()} to ${new Date(timeRange.endTime.epoch).toISOString()}` + ); + } + + // Format the results - only include snippet, not full content + const formattedResults = documents.map((doc) => { + const scored = doc.metadata.rerank_score ?? doc.metadata.score ?? 0; + return { + title: doc.metadata.title || "Untitled", + // Only include a snippet for display (first 200 chars) + content: doc.pageContent.substring(0, 200), + path: doc.metadata.path || "", + // Ensure both fields reflect the same final fused score when present + score: scored, + rerank_score: scored, + includeInContext: doc.metadata.includeInContext ?? true, + source: doc.metadata.source, // Pass through source for proper labeling + // Show actual modified time for time-based queries + mtime: doc.metadata.mtime ?? null, + }; + }); return JSON.stringify(formattedResults); }, }); +// Note: indexTool is kept for backward compatibility but is no longer used with v3 search const indexTool = createTool({ name: "indexVault", description: "Index the vault to the Copilot index", schema: z.void(), // No parameters handler: async () => { - try { - const indexedCount = await VectorStoreManager.getInstance().indexVaultToVectorStore(); - const indexResultPrompt = `Please report whether the indexing was successful.\nIf success is true, just say it is successful. If 0 files is indexed, say there are no new files to index.`; - return ( - indexResultPrompt + - JSON.stringify({ - success: true, - message: - indexedCount === 0 - ? "No new files to index." - : `Indexed ${indexedCount} files in the vault.`, - }) - ); - } catch (error) { - console.error("Error indexing vault:", error); - return JSON.stringify({ - success: false, - message: "An error occurred while indexing the vault.", - }); - } + // Tiered lexical retriever doesn't require manual indexing - it builds ephemeral indexes on demand + const indexResultPrompt = `The tiered lexical retriever builds indexes on demand and doesn't require manual indexing.\n`; + return ( + indexResultPrompt + + JSON.stringify({ + success: true, + message: "Tiered lexical retriever uses on-demand indexing. No manual indexing required.", + }) + ); }, isBackground: true, }); diff --git a/src/tools/ToolResultFormatter.ts b/src/tools/ToolResultFormatter.ts index 76d92a73..0d67dc34 100644 --- a/src/tools/ToolResultFormatter.ts +++ b/src/tools/ToolResultFormatter.ts @@ -1,3 +1,5 @@ +import { logWarn } from "@/logger"; + /** * Format tool results for display in the UI * Each formatter should return a user-friendly representation of the tool result @@ -18,13 +20,24 @@ export class ToolResultFormatter { } static format(toolName: string, result: string): string { try { - // Try to parse the result as JSON first + // Decode tool marker encoding if present (ENC:...) + let normalized = result; + if (typeof normalized === "string" && normalized.startsWith("ENC:")) { + try { + normalized = decodeURIComponent(normalized.slice(4)); + } catch { + // fall back to original + } + } + + // Try to parse the (potentially decoded) result as JSON first let parsedResult: any; try { - parsedResult = JSON.parse(result); - } catch { + parsedResult = JSON.parse(normalized); + } catch (e) { // If not JSON, use the raw string - parsedResult = result; + logWarn(`ToolResultFormatter: Failed to parse JSON for ${toolName}:`, e); + parsedResult = normalized; } // Route to specific formatter based on tool name @@ -51,146 +64,89 @@ export class ToolResultFormatter { } private static formatLocalSearch(result: any): string { - // If already formatted or not a string, return as is - if (typeof result !== "string") { - return typeof result === "object" ? JSON.stringify(result, null, 2) : String(result); + const searchResults = this.parseSearchResults(result); + + if (!Array.isArray(searchResults)) { + return typeof result === "string" ? result : JSON.stringify(result, null, 2); } - // Check if it looks like JSON (array or object) - const trimmedResult = result.trim(); - if (!trimmedResult.startsWith("[") && !trimmedResult.startsWith("{")) { - return result; + const count = searchResults.length; + if (count === 0) { + return "📚 Found 0 relevant notes\n\nNo matching notes found."; } - // Try standard JSON parsing first - let searchResults = this.tryParseJson(result); + const topResults = searchResults.slice(0, 10); + const formattedItems = topResults + .map((item, index) => this.formatSearchItem(item, index)) + .join("\n\n"); - // If parsing failed, try regex extraction for malformed JSON - try { - if (searchResults.length === 0) { - // Match individual JSON objects (works for arrays or malformed JSON) - const objectRegex = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g; - const matches = result.match(objectRegex); + const footer = count > 10 ? `\n\n... and ${count - 10} more results` : ""; - if (matches) { - const regexResults: any[] = []; - for (const match of matches) { - try { - // Parse each object individually - const obj = JSON.parse(match); - regexResults.push(obj); - } catch { - // Skip malformed objects - } - } - searchResults = regexResults; + return `📚 Found ${count} relevant notes\n\nTop results:\n\n${formattedItems}${footer}`; + } + + private static parseSearchResults(result: any): any[] { + if (Array.isArray(result)) return result; + if (typeof result === "object" && result !== null) return [result]; + if (typeof result === "string") { + const trimmed = result.trim(); + if (!trimmed.startsWith("[") && !trimmed.startsWith("{")) { + return []; + } + return this.tryParseJson(result); + } + return []; + } + + private static formatSearchItem(item: any, index: number): string { + const filename = item.path?.split("/").pop()?.replace(/\.md$/, "") || item.title || "Untitled"; + const score = item.rerank_score || item.score || 0; + const scoreDisplay = typeof score === "number" ? score.toFixed(4) : score; + + // For time-filtered results, show as "Recency" instead of "Relevance" + const scoreLabel = item.source === "time-filtered" ? "Recency" : "Relevance"; + + const lines = [`${index + 1}. ${filename}`]; + + // For time-filtered queries, show actual modified time instead of a recency score + if (item.source === "time-filtered") { + if (item.mtime) { + try { + const d = new Date(item.mtime); + const iso = isNaN(d.getTime()) ? String(item.mtime) : d.toISOString(); + lines.push(` 🕒 Modified: ${iso}${item.includeInContext ? " ✓" : ""}`); + } catch { + lines.push(` 🕒 Modified: ${String(item.mtime)}${item.includeInContext ? " ✓" : ""}`); } } - - if (searchResults.length === 0) { - // Fallback: try to extract key information using regex - const titleRegex = /"title"\s*:\s*"([^"]+)"/g; - const pathRegex = /"path"\s*:\s*"([^"]+)"/g; - const scoreRegex = /"score"\s*:\s*([\d.]+)/g; - - let titleMatch; - const results = []; - while ((titleMatch = titleRegex.exec(result))) { - const title = titleMatch[1]; - pathRegex.lastIndex = titleMatch.index; - const pathMatch = pathRegex.exec(result); - scoreRegex.lastIndex = titleMatch.index; - const scoreMatch = scoreRegex.exec(result); - - results.push({ - title: title, - path: pathMatch ? pathMatch[1] : "", - score: scoreMatch ? parseFloat(scoreMatch[1]) : 0, - content: "", // Content is too complex to extract reliably - }); - } - - if (results.length > 0) { - searchResults = results; - } - } - } catch { - // If extraction fails, return original - return result; + } else if (item.source === "title-match") { + // For title matches, avoid misleading numeric scores; mark as a title match + lines.push(` 🔖 Title match${item.includeInContext ? " ✓" : ""}`); + } else { + // Default: show relevance-like score line + lines.push(` 📊 ${scoreLabel}: ${scoreDisplay}${item.includeInContext ? " ✓" : ""}`); } - // Check if it's an array of search results - if (Array.isArray(searchResults)) { - const output: string[] = [`📚 Found ${searchResults.length} relevant notes`]; - - if (searchResults.length === 0) { - output.push("\nNo matching notes found."); - return output.join(""); - } - - output.push(""); - output.push("Top results:"); - output.push(""); - - // Show top 10 results - searchResults.slice(0, 10).forEach((item, index) => { - const filename = - item.path?.split("/").pop()?.replace(/\.md$/, "") || item.title || "Untitled"; - const score = item.rerank_score || item.score || 0; - const scoreDisplay = typeof score === "number" ? score.toFixed(3) : score; - - output.push(`${index + 1}. ${filename}`); - output.push(` 📊 Relevance: ${scoreDisplay}${item.includeInContext ? " ✓" : ""}`); - - // Show a snippet of content if available - if (item.content) { - // Extract actual content from the formatted string - let cleanContent = item.content; - - // Remove NOTE TITLE section - cleanContent = cleanContent.replace( - /NOTE TITLE:[\s\S]*?(?=METADATA:|NOTE BLOCK CONTENT:)/, - "" - ); - - // Remove METADATA section - cleanContent = cleanContent.replace(/METADATA:\{[\s\S]*?\}/, ""); - - // Extract content after NOTE BLOCK CONTENT: - const contentMatch = cleanContent.match(/NOTE BLOCK CONTENT:\s*([\s\S]*)/); - if (contentMatch) { - cleanContent = contentMatch[1]; - } - - // Clean up the content - const snippet = cleanContent - .substring(0, 150) - .replace(/\n+/g, " ") - .replace(/\s+/g, " ") - .trim(); - - if (snippet) { - output.push(` 💎 "${snippet}${cleanContent.length > 150 ? "..." : ""}"`); - } - } - - // Show path if different from filename - if (item.path && !item.path.endsWith(`/${filename}.md`)) { - output.push(` 📁 ${item.path}`); - } - - output.push(""); - }); - - if (searchResults.length > 10) { - output.push(`... and ${searchResults.length - 10} more results`); - } - - return output.join("\n"); + const snippet = this.extractContentSnippet(item.content); + if (snippet) { + lines.push(` 💎 "${snippet}${item.content?.length > 150 ? "..." : ""}"`); } - // If not an array, return original - return typeof result === "string" ? result : JSON.stringify(result, null, 2); + if (item.path && !item.path.endsWith(`/${filename}.md`)) { + lines.push(` 📁 ${item.path}`); + } + + return lines.join("\n"); + } + + private static extractContentSnippet(content: string, maxLength = 150): string { + if (!content) return ""; + + // Try to extract content after NOTE BLOCK CONTENT: pattern + const contentMatch = content.match(/NOTE BLOCK CONTENT:\s*([\s\S]*)/); + const cleanContent = contentMatch?.[1] || content; + + return cleanContent.substring(0, maxLength).replace(/\s+/g, " ").trim(); } private static formatWebSearch(result: any): string {